From 11936f08ab1bb736a8f53e215cea5da3b2793793 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:25:52 -0700 Subject: [PATCH 01/23] perf(renderer): lazy load Linear setup dialog (#11687) --- src/renderer/src/app-startup-routing.test.ts | 12 ++ .../sidebar/LinearAgentSkillSetupDialog.tsx | 2 + ...ntSkillSetupPrompt.reminder-toast.test.tsx | 1 + .../LinearAgentSkillSetupPrompt.test.tsx | 15 +- .../sidebar/LinearAgentSkillSetupPrompt.tsx | 132 +++++++++--------- ...ntSkillSetupPrompt.update-command.test.tsx | 1 + 6 files changed, 93 insertions(+), 70 deletions(-) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index f638ae82083..8547e17a135 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -328,6 +328,18 @@ describe('renderer startup runtime routing', () => { ) }) + it('loads Linear agent setup implementation only after the prompt opens it', () => { + const source = readFileSync( + join(process.cwd(), 'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx'), + 'utf8' + ) + + expect(source).toContain("() => import('./LinearAgentSkillSetupDialog')") + expect(source).not.toContain("from './LinearAgentSkillSetupDialog'") + expect(source).toContain('const setupDialog = setupDialogOpen ? (') + expect(source).toContain('') + }) + it('does not eagerly import optional status-bar segments on startup', () => { const source = readFileSync( join(process.cwd(), 'src/renderer/src/components/status-bar/StatusBar.tsx'), diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx index e67b946cff9..1de868ec200 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx @@ -191,3 +191,5 @@ export function LinearAgentSkillSetupDialog({ ) } + +export default LinearAgentSkillSetupDialog diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx index d05496a19fe..72fcede0b7b 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.reminder-toast.test.tsx @@ -119,6 +119,7 @@ async function renderPrompt( await act(async () => { root?.render() }) + await import('./LinearAgentSkillSetupDialog') await act(async () => {}) } diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx index 0d784cb1033..c26f7024a9a 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.test.tsx @@ -147,6 +147,9 @@ async function renderPrompt( await act(async () => { root?.render() }) + if (props.surface === 'modal') { + await import('./LinearAgentSkillSetupDialog') + } await act(async () => {}) return container } @@ -178,6 +181,7 @@ function findBodyButton(label: string): HTMLButtonElement | undefined { } async function settleRender(): Promise { + await import('./LinearAgentSkillSetupDialog') await act(async () => {}) await act(async () => {}) } @@ -253,12 +257,7 @@ describe('LinearAgentSkillSetupPrompt', () => { const unlinked = await renderPrompt({ linked: false, remote: false }) expect(unlinked.textContent).not.toContain('Set up Linear agent skill') - await act(async () => { - root?.unmount() - }) - root = null - unlinked.remove() - container = null + await unmountPrompt() const ready = await renderPrompt({ linked: true, remote: false }) expect(ready.textContent).not.toContain('Set up Linear agent skill') @@ -343,6 +342,7 @@ describe('LinearAgentSkillSetupPrompt', () => { await act(async () => { setupButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) + await settleRender() expect(document.body.textContent).toContain("wsl.exe -d 'Fedora' -- bash -lc 'npx skills add") expect(mocks.panelProps.at(-1)).toEqual( @@ -425,7 +425,7 @@ describe('LinearAgentSkillSetupPrompt', () => { expect(mocks.getWslCliStatus).not.toHaveBeenCalled() }) - it('opens the terminal setup panel in a dialog only after the user asks to set up', async () => { + it('keeps the prompt usable and loads the lazy setup dialog only when requested', async () => { const rendered = await renderPrompt({ linked: true, remote: false }) expect(document.body.querySelector('[data-testid="linear-skill-inline-panel"]')).toBeNull() @@ -436,6 +436,7 @@ describe('LinearAgentSkillSetupPrompt', () => { await act(async () => { setupButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) + await settleRender() expect(document.body.querySelector('[data-testid="linear-skill-inline-panel"]')).not.toBeNull() expect(document.body.textContent).toContain('orca-linear') diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx index d840807a7e6..31c648da74b 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { RefreshCw, TicketCheck, X } from 'lucide-react' import type { CliInstallStatus } from '../../../../shared/cli-install-types' import type { ProjectExecutionRuntimeResolution } from '../../../../shared/project-execution-runtime' @@ -16,6 +16,7 @@ import { ensureOrcaCliAvailableForAgentSkillTerminal, isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite' +import { lazyWithRetry } from '@/lib/lazy-with-retry' import { cn } from '@/lib/utils' import { buildSkillCommandForRuntime, @@ -45,9 +46,12 @@ import { readLocalDismissed, type LinearAgentSkillPromptSettings } from './linear-agent-skill-runtime' -import { LinearAgentSkillSetupDialog } from './LinearAgentSkillSetupDialog' import { translate } from '@/i18n/i18n' +const LinearAgentSkillSetupDialog = lazyWithRetry(() => import('./LinearAgentSkillSetupDialog'), { + reloadKey: 'linear-agent-skill-setup-dialog' +}) + export const _linearAgentSkillSetupPromptInternalsForTests = { resetSessionReminders(): void { resetLinearAgentSkillSetupReminderToastState() @@ -278,69 +282,71 @@ export function LinearAgentSkillSetupPrompt({ return null } - const setupDialog = ( - window.api.cli.getWslInstallStatus(getWslCliDistroRequest(agentRuntime)) - : undefined - } - onBeforeOpenTerminal={async () => { - const requestIdentity = setupCheckIdentity - const writeIfCurrent = (write: () => void): void => { - writeCliStatusForIdentity(requestIdentity, write) - } - const nextStatus = + const setupDialog = setupDialogOpen ? ( + + { - writeIfCurrent(() => setCliStatus(nextCliStatus)) - } - }) - if (agentRuntime.runtime === 'wsl') { - writeIfCurrent(() => setCliStatus(nextStatus)) + ? () => window.api.cli.getWslInstallStatus(getWslCliDistroRequest(agentRuntime)) + : undefined } - }} - onRecheck={async () => { - if (surface === 'modal') { - setActiveSetupCheckIdentity(setupCheckIdentity) - setSetupCheckResult('checking') - await Promise.all([refreshCliStatus(), skill.refresh()]) - return - } - await refreshCliStatus() - await skill.refresh() - }} - onOpenChange={(open) => { - if (open) { - setSetupDialogOpen(true) - return - } - if (showSuccessModal) { - closeSuccessModal() - return - } - if (surface === 'modal') { - snoozeForSession() - return - } - setSetupDialogOpen(false) - }} - onDismissPermanently={dismissPermanently} - onDone={closeSuccessModal} - /> - ) + onBeforeOpenTerminal={async () => { + const requestIdentity = setupCheckIdentity + const writeIfCurrent = (write: () => void): void => { + writeCliStatusForIdentity(requestIdentity, write) + } + const nextStatus = + agentRuntime.runtime === 'wsl' + ? await ensureWslCliAvailableForAgentSkillTerminal(agentRuntime) + : await ensureOrcaCliAvailableForAgentSkillTerminal({ + onStatusChange: (nextCliStatus) => { + writeIfCurrent(() => setCliStatus(nextCliStatus)) + } + }) + if (agentRuntime.runtime === 'wsl') { + writeIfCurrent(() => setCliStatus(nextStatus)) + } + }} + onRecheck={async () => { + if (surface === 'modal') { + setActiveSetupCheckIdentity(setupCheckIdentity) + setSetupCheckResult('checking') + await Promise.all([refreshCliStatus(), skill.refresh()]) + return + } + await refreshCliStatus() + await skill.refresh() + }} + onOpenChange={(open) => { + if (open) { + setSetupDialogOpen(true) + return + } + if (showSuccessModal) { + closeSuccessModal() + return + } + if (surface === 'modal') { + snoozeForSession() + return + } + setSetupDialogOpen(false) + }} + onDismissPermanently={dismissPermanently} + onDone={closeSuccessModal} + /> + + ) : null if (surface === 'modal') { return setupDialog diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx index 7ff473b49da..af2c3f85572 100644 --- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx +++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.update-command.test.tsx @@ -107,6 +107,7 @@ async function renderPrompt( ) }) + await import('./LinearAgentSkillSetupDialog') await act(async () => {}) } From 037f7a07d30ca25a68764e074ec2f448049ca8f3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:29:01 -0700 Subject: [PATCH 02/23] fix(mobile): open host editor reliably (#11635) --- mobile/app/index.tsx | 3 +- .../transport/host-edit-navigation.test.ts | 29 +++++++++++++++++++ mobile/src/transport/host-edit-navigation.ts | 19 ++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 mobile/src/transport/host-edit-navigation.test.ts create mode 100644 mobile/src/transport/host-edit-navigation.ts diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index f486fe99c78..4000906c933 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -26,6 +26,7 @@ import { } from '../src/components/AccountUsage' import AsyncStorage from '@react-native-async-storage/async-storage' import { loadHosts } from '../src/transport/host-store' +import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' import { pickResumeWorktree } from '../src/worktree/resume-worktree' import type { RpcClient } from '../src/transport/rpc-client' @@ -1010,7 +1011,7 @@ export default function HomeScreen() { closeBeforePress: true, onPress: () => { setActionTarget(null) - router.push(`/h/${host.id}/edit`) + navigateToMobileHostEdit(router, host.id) } }) items.push({ diff --git a/mobile/src/transport/host-edit-navigation.test.ts b/mobile/src/transport/host-edit-navigation.test.ts new file mode 100644 index 00000000000..66ca63c5efa --- /dev/null +++ b/mobile/src/transport/host-edit-navigation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' +import { mobileHostEditRoute, navigateToMobileHostEdit } from './host-edit-navigation' + +describe('mobileHostEditRoute', () => { + it('keeps the dynamic host segment explicit for a cold host navigator', () => { + expect(mobileHostEditRoute('host-1')).toEqual({ + pathname: '/h/[hostId]/edit', + params: { hostId: 'host-1' } + }) + }) + + it('mounts a cold host navigator before replacing its index with edit', () => { + let nextFrame: FrameRequestCallback | null = null + const push = vi.fn() + const replace = vi.fn() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + nextFrame = callback + return 1 + }) + + navigateToMobileHostEdit({ push, replace }, 'host-1') + expect(push).toHaveBeenCalledWith('/h/host-1') + expect(replace).not.toHaveBeenCalled() + + nextFrame?.(0) + expect(replace).toHaveBeenCalledWith(mobileHostEditRoute('host-1')) + vi.unstubAllGlobals() + }) +}) diff --git a/mobile/src/transport/host-edit-navigation.ts b/mobile/src/transport/host-edit-navigation.ts new file mode 100644 index 00000000000..19dbf703dec --- /dev/null +++ b/mobile/src/transport/host-edit-navigation.ts @@ -0,0 +1,19 @@ +type HostEditRouter = { + push: (href: `/h/${string}`) => void + replace: (href: ReturnType) => void +} + +export function mobileHostEditRoute(hostId: string) { + return { + pathname: '/h/[hostId]/edit' as const, + params: { hostId } + } +} + +export function navigateToMobileHostEdit(router: HostEditRouter, hostId: string): void { + // Why: a cold nested host navigator resolves a deep push to its index route. + router.push(`/h/${hostId}`) + requestAnimationFrame(() => { + router.replace(mobileHostEditRoute(hostId)) + }) +} From fe979a402a12a05115b83ff7c2ec60fe74e2bac1 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 31 Jul 2026 00:36:09 -0700 Subject: [PATCH 03/23] Fix SSH port-forward rows disappearing after hydration (#11713) --- config/reliability-gates.jsonc | 135 ++++++- src/renderer/src/hooks/useIpcEvents.test.ts | 300 ++++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 62 ++- .../ssh-port-forward-lifecycle-evidence.ts | 225 +++++++++++ .../ssh-port-forward-snapshot-barrier.ts | 158 ++++++++ ...port-forward-snapshot-barrier.unit.test.ts | 69 ++++ .../ssh-port-forward-transport-evidence.ts | 145 +++++++ tests/e2e/ssh-port-forward-lifecycle.spec.ts | 370 ++++++++++++++++++ 8 files changed, 1445 insertions(+), 19 deletions(-) create mode 100644 tests/e2e/helpers/ssh-port-forward-lifecycle-evidence.ts create mode 100644 tests/e2e/helpers/ssh-port-forward-snapshot-barrier.ts create mode 100644 tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts create mode 100644 tests/e2e/helpers/ssh-port-forward-transport-evidence.ts create mode 100644 tests/e2e/ssh-port-forward-lifecycle.spec.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index a243d500d0b..fb86c790432 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-29", + "updatedAt": "2026-07-30", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -7879,6 +7879,139 @@ "Relay-hosted plugin provisioning, consent persistence, workers, and audit services remain out of scope and the relay registration therefore denies every provisioned identity by default." ], "demotionRule": "Keep experimental or demote to protection none if the suite flakes, permits a mismatched terminal send, performs more than one inventory list per call, accepts client-supplied grants, or relay and desktop error codes diverge." + }, + { + "id": "ssh-port-forward.renderer-snapshot-continuity", + "title": "SSH forwarded-port state survives stale renderer hydration", + "maturity": "experimental", + "protection": "partial", + "owner": "desktop-ssh", + "layer": "renderer-ssh-snapshot-reconciliation", + "surfaces": [ + "SSH Ports panel", + "forwarded-port renderer state", + "persisted forward restoration", + "ssh2 port forwarding", + "system-SSH port forwarding" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["ssh2", "system-ssh"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["ssh2", "system-ssh"], + "coverageNotes": "A deterministic renderer ordering test covers stale initial snapshots independently by target and stream, including hydration after partial SSH authority reconciliation. Headed macOS Electron tests force the same startup hydration race and use an ephemeral Docker sshd, a real remote Git worktree, real remote Node listeners, real HTTP forwards, in-place relay and full transport reconnect restoration, collision rejection, scan refresh, explicit removal, and an unrelated surviving forward through both transports. The forced-system run requires a recorded OpenSSH -L invocation.", + "motivatingLinks": ["user-reported SSH Ports panel disappearance"], + "invariant": "For one connected SSH authority, a renderer snapshot may update Forwarded or Detected state only if no newer push for that target and stream arrived after the snapshot began. Active tunnels and persisted intent remain authoritative through scan refresh and reconnect until explicit removal or a real connection-lifecycle transition.", + "oracle": "Hold empty initial Forwarded and Detected snapshot promises, publish live events, release the snapshots, and require each target and stream to preserve only its own newer push while applying unaffected snapshots. Reject one target's Detected snapshot and require its Forwarded snapshot plus later targets to hydrate independently. Begin another target with partial connected authority and require its snapshots to hydrate after same-watermark authority reconciliation. In headed Electron, hold an authoritative empty Forwarded snapshot across renderer reload, add a forward through the Ports panel, release and confirm the wrapped main handler resumed, then complete a later renderer-to-main listPortForwards round trip as the ordered hydration-continuation barrier. Before checking the Forwarded row, require main inventory, persisted intent, HTTP, remote process identity, and close warnings to prove the tunnel remained authoritative. Then forward two real Docker listeners, refresh detection, force an in-place relay-channel reconnect, perform a full transport reconnect, reject a bound local-port collision, remove one row, and require all signals to agree.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", + "pnpm exec electron-vite build --mode e2e", + "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" + ], + "testFiles": [ + "src/renderer/src/hooks/useIpcEvents.test.ts", + "src/main/ssh/ssh-port-forward.test.ts", + "src/main/ssh/system-ssh-forward-process.test.ts", + "src/main/ipc/ssh.test.ts", + "src/main/ssh/ssh-relay-session.test.ts", + "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts", + "tests/e2e/ssh-port-forward-lifecycle.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/hooks/useIpcEvents.test.ts", + "assertions": ["does not let initial SSH port snapshots overwrite newer push events"] + }, + { + "file": "src/main/ssh/ssh-port-forward.test.ts", + "assertions": ["lists forwards filtered by connectionId", "removes a forward by id"] + }, + { + "file": "src/main/ssh/system-ssh-forward-process.test.ts", + "assertions": [ + "does not spawn ssh when the requested local forward port is already in use", + "sends SIGTERM then SIGKILL when the process does not exit", + "does not resolve stop until the process exits" + ] + }, + { + "file": "src/main/ipc/ssh.test.ts", + "assertions": [ + "preserves active port forwards and live connections across handler re-registration", + "persists desired forwards and broadcasts when an active forward closes unexpectedly" + ] + }, + { + "file": "src/main/ssh/ssh-relay-session.test.ts", + "assertions": ["cleans up port forwards on reconnect"] + }, + { + "file": "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts", + "assertions": ["holds only the first matching request while its snapshot is unresolved"] + }, + { + "file": "tests/e2e/ssh-port-forward-lifecycle.spec.ts", + "assertions": ["keeps a user-forwarded listener live across scan refresh @headful"] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 6.77, + "summary": "Six renderer, main-process, and barrier lifecycle files passed with 215 tests, including deterministic stale-snapshot, rejected-stream, partial-authority, and single-capture isolation plus existing collision, delayed-exit, reconnect, persistence, re-registration, and unrelated-forward contracts." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 53.7, + "summary": "Headed Electron passed against a real Docker sshd over ssh2 after forcing the stale startup-snapshot race and exact renderer-continuation barrier, with two remote listeners, scan refresh, in-place relay and full transport reconnect restoration, collision rejection, explicit removal, independent state inventories, HTTP responses, process identity, and no live forward-close warning." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 66, + "summary": "The identical stale-snapshot and headed lifecycle passed through forced system OpenSSH, with a wrapper marker proving the -L forward process executed; in-place relay and full transport reconnects restored both intents, no unexpected forward close was captured before assertions, and intentional removal completed." + } + ], + "runtimeBudget": { + "p95Seconds": 180, + "scope": "focused renderer/main contracts plus two headed Docker SSH transport runs" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic contract and both real transport runs passed locally; CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The identical renderer unit and headed Electron oracles fail on current main because the stale empty hydration reply becomes a second and final Forwarded write, pass with bounded per-target per-stream pending-hydration state, fail again when only that fix is removed, and pass again after restoration. In the tightened headed revert, both transports first proved main inventory, persisted intent, HTTP, remote process identity, no live close warning, and renderer continuation, then failed only at the missing Forwarded-row assertion. Both passed after rebuilding the restored candidate." + }, + "performanceBudget": { + "required": true, + "evidence": "The fix adds one effect-scoped map containing only unresolved target hydrations, one O(1) boolean write per relevant existing push event, and one O(1) authority/stream check per initial snapshot. Forwarded and Detected hydrate independently so a stalled stream does not block its peer or later targets; entries are removed after both settle. It adds no polling, timers, IPC calls, scans, subprocesses, or renderer subscriptions." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Run the headed topology on Linux and Windows with their native system-SSH clients.", + "Re-run the headed snapshot barrier after Electron major-version upgrades.", + "Keep the independent Forwarded and Detected freshness assertions and both transport runs green." + ], + "knownGaps": [ + "Headed live evidence is macOS-only; Linux and Windows system-SSH clients were not exercised.", + "The headed race uses Electron's private invoke-handler registry to delay the real listPortForwards handler because Electron exposes no public handler-wrapping API.", + "The live run reconnects an existing app session but does not restart the packaged application from disk.", + "The live topology uses an SSH Git worktree; folder-workspace behavior is covered by target-scoped renderer reconciliation rather than a second headed topology." + ], + "demotionRule": "Keep experimental or demote if same-authority snapshots can overwrite newer pushes, reconnect silently loses persisted intent, one forward operation disturbs unrelated forwards, live HTTP diverges from renderer/main inventory, or either transport topology flakes without an identified product or harness fault." } ] } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 1f9b1763851..cfc4bd5d8fc 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -5539,6 +5539,306 @@ describe('useIpcEvents agent status snapshot integration', () => { } ) + it('does not let initial SSH port snapshots overwrite newer push events', async () => { + const targetId = 'target-ports' + const secondTargetId = 'target-ports-second' + const rejectingTargetId = 'target-ports-rejecting' + const partialTargetId = 'target-ports-partial' + const connectedState = { + targetId, + status: 'connected' as const, + error: null, + reconnectAttempt: 0, + providerEpoch: 'epoch-ports', + connectionGeneration: 3 + } + const secondConnectedState = { + ...connectedState, + targetId: secondTargetId, + providerEpoch: 'epoch-ports-second' + } + const rejectingConnectedState = { + ...connectedState, + targetId: rejectingTargetId, + providerEpoch: 'epoch-ports-rejecting' + } + const partialConnectedState = { + ...connectedState, + targetId: partialTargetId, + providerEpoch: null, + connectionGeneration: undefined + } + const reconciledPartialState = { + ...connectedState, + targetId: partialTargetId, + providerEpoch: 'epoch-ports-partial', + connectionGeneration: 4 + } + const liveForward = { + id: 'forward-live', + targetId, + localPort: 17860, + remoteHost: '127.0.0.1', + remotePort: 7860, + status: 'active' as const + } + const secondForward = { + ...liveForward, + id: 'forward-second', + targetId: secondTargetId, + localPort: 17861, + remotePort: 7861 + } + const rejectingTargetForward = { + ...liveForward, + id: 'forward-rejecting-target', + targetId: rejectingTargetId, + localPort: 17862, + remotePort: 7862 + } + const partialTargetForward = { + ...liveForward, + id: 'forward-partial-target', + targetId: partialTargetId, + localPort: 17863, + remotePort: 7863 + } + const detectedPort = { + port: 7860, + pid: 42, + processName: 'python', + command: 'python -m http.server 7860' + } + const secondDetectedPort = { + ...detectedPort, + port: 7861, + pid: 43, + command: 'python -m http.server 7861' + } + let resolveForwards: (value: []) => void = () => {} + let resolveDetected: (value: (typeof detectedPort)[]) => void = () => {} + let resolveSecondForwards: (value: (typeof secondForward)[]) => void = () => {} + let resolveSecondDetected: (value: []) => void = () => {} + const forwardsSnapshot = new Promise<[]>((resolve) => { + resolveForwards = resolve + }) + const detectedSnapshot = new Promise<(typeof detectedPort)[]>((resolve) => { + resolveDetected = resolve + }) + const secondForwardsSnapshot = new Promise<(typeof secondForward)[]>((resolve) => { + resolveSecondForwards = resolve + }) + const secondDetectedSnapshot = new Promise<[]>((resolve) => { + resolveSecondDetected = resolve + }) + const listPortForwards = vi.fn(({ targetId: requestedTargetId }: { targetId: string }) => { + if (requestedTargetId === rejectingTargetId) { + return Promise.resolve([rejectingTargetForward]) + } + if (requestedTargetId === partialTargetId) { + return Promise.resolve([partialTargetForward]) + } + return requestedTargetId === targetId ? forwardsSnapshot : secondForwardsSnapshot + }) + const listDetectedPorts = vi.fn(({ targetId: requestedTargetId }: { targetId: string }) => { + if (requestedTargetId === rejectingTargetId) { + return Promise.reject(new Error('detected snapshot unavailable')) + } + if (requestedTargetId === partialTargetId) { + return Promise.resolve([]) + } + return requestedTargetId === targetId ? detectedSnapshot : secondDetectedSnapshot + }) + let forwardListener: + | ((data: { targetId: string; forwards: (typeof liveForward)[] }) => void) + | undefined + let detectedListener: + | ((data: { targetId: string; ports: (typeof detectedPort)[] }) => void) + | undefined + const setPortForwards = vi.fn() + const setDetectedPorts = vi.fn() + const sshConnectionStates = new Map< + string, + | typeof connectedState + | typeof secondConnectedState + | typeof rejectingConnectedState + | typeof partialConnectedState + | typeof reconciledPartialState + >() + const storeState = buildStoreState({ + sshTargetLabels: new Map([ + [targetId, 'Ports Target'], + [secondTargetId, 'Second Ports Target'], + [rejectingTargetId, 'Rejecting Ports Target'], + [partialTargetId, 'Partial Ports Target'] + ]), + sshConnectionStates, + setSshConnectionState: ( + nextTargetId: string, + state: + | typeof connectedState + | typeof secondConnectedState + | typeof rejectingConnectedState + | typeof partialConnectedState + | typeof reconciledPartialState + ) => { + sshConnectionStates.set(nextTargetId, state) + }, + setPortForwards, + setDetectedPorts, + setSshTargetsMetadata: vi.fn(), + setRemovedSshTargetLabels: vi.fn(), + setRemoteWorkspaceSyncStatus: vi.fn(), + fetchRuntimeEnvironmentRepos: vi.fn(async () => []), + fetchWorktreeLineage: vi.fn(async () => undefined), + clearRemoteDetectedAgents: vi.fn(), + clearDirectSshTargetPtyBindings: vi.fn(), + clearRemovedSshTargetState: vi.fn(), + invalidateStaleDirectSshTargetPtyBindings: vi.fn(() => 0), + retryDirectSshTargetPanes: vi.fn(() => 0) + }) + const coordinator = { + requestReconnect: vi.fn(async () => ({ status: 'complete' })), + replaceAuthority: vi.fn(), + prepareOnly: vi.fn(async () => ({ token: null })), + correctUnboundTerminals: vi.fn(() => 0), + finalizeHydratedTerminals: vi.fn(() => 0), + invalidate: vi.fn(), + stop: vi.fn() + } + let partialTargetStateCalls = 0 + + stubReactSyncEffect() + stubAuxiliaryModules() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./direct-ssh-reconnect-rollout', () => ({ + isDirectSshReconnectCoordinatorRoutingEnabled: () => true + })) + vi.doMock('./direct-ssh-worktree-refresh-scheduler', () => ({ + createDirectSshWorktreeRefreshScheduler: () => ({ + stop: vi.fn(), + disposeProvider: vi.fn() + }) + })) + vi.doMock('./direct-ssh-host-hydration', () => ({ + createDirectSshHostHydration: () => ({ + capturePreparationInput: vi.fn(), + readHostScopedLineage: vi.fn(), + isPreparationTokenCurrent: vi.fn(() => true), + stop: vi.fn() + }) + })) + vi.doMock('./direct-ssh-reconnect-coordinator', () => ({ + createDirectSshReconnectCoordinator: () => coordinator + })) + vi.doMock('@/lib/direct-ssh-reconnect-product-telemetry', () => ({ + createDirectSshReconnectProductTelemetryAdapter: vi.fn() + })) + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: () => () => {}, + ssh: { + listTargets: () => + Promise.resolve([ + { id: rejectingTargetId, label: 'Rejecting Ports Target' }, + { id: partialTargetId, label: 'Partial Ports Target' }, + { id: targetId, label: 'Ports Target' }, + { id: secondTargetId, label: 'Second Ports Target' } + ]), + listRemovedTargetLabels: () => Promise.resolve({}), + getState: ({ targetId: requestedTargetId }: { targetId: string }) => { + if (requestedTargetId === partialTargetId) { + partialTargetStateCalls += 1 + return Promise.resolve( + partialTargetStateCalls === 1 ? partialConnectedState : reconciledPartialState + ) + } + return Promise.resolve( + requestedTargetId === rejectingTargetId + ? rejectingConnectedState + : requestedTargetId === targetId + ? connectedState + : secondConnectedState + ) + }, + listPortForwards, + listDetectedPorts, + onPortForwardsChanged: ( + listener: (data: { targetId: string; forwards: (typeof liveForward)[] }) => void + ) => { + forwardListener = listener + return () => {} + }, + onDetectedPortsChanged: ( + listener: (data: { targetId: string; ports: (typeof detectedPort)[] }) => void + ) => { + detectedListener = listener + return () => {} + } + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + useIpcEvents() + + await vi.waitFor(() => { + expect(forwardListener).toBeTypeOf('function') + expect(detectedListener).toBeTypeOf('function') + expect(partialTargetStateCalls).toBe(2) + expect(sshConnectionStates.get(partialTargetId)).toEqual(reconciledPartialState) + expect(listPortForwards).toHaveBeenCalledTimes(4) + expect(listDetectedPorts).toHaveBeenCalledTimes(4) + expect( + setPortForwards.mock.calls.filter( + ([requestedTargetId]) => requestedTargetId === rejectingTargetId + ) + ).toEqual([[rejectingTargetId, [rejectingTargetForward]]]) + expect( + setPortForwards.mock.calls.filter( + ([requestedTargetId]) => requestedTargetId === partialTargetId + ) + ).toEqual([[partialTargetId, [partialTargetForward]]]) + }) + forwardListener?.({ targetId, forwards: [liveForward] }) + resolveForwards([]) + resolveDetected([detectedPort]) + + await vi.waitFor(() => { + expect( + setPortForwards.mock.calls.filter(([requestedTargetId]) => requestedTargetId === targetId) + ).toEqual([[targetId, [liveForward]]]) + expect( + setDetectedPorts.mock.calls.filter(([requestedTargetId]) => requestedTargetId === targetId) + ).toEqual([[targetId, [detectedPort]]]) + expect(listPortForwards).toHaveBeenCalledTimes(4) + expect(listDetectedPorts).toHaveBeenCalledTimes(4) + }) + forwardListener?.({ targetId, forwards: [liveForward] }) + detectedListener?.({ targetId: secondTargetId, ports: [secondDetectedPort] }) + resolveSecondForwards([secondForward]) + resolveSecondDetected([]) + + await vi.waitFor(() => { + expect( + setPortForwards.mock.calls.filter( + ([requestedTargetId]) => requestedTargetId === secondTargetId + ) + ).toEqual([[secondTargetId, [secondForward]]]) + expect( + setDetectedPorts.mock.calls.filter( + ([requestedTargetId]) => requestedTargetId === secondTargetId + ) + ).toEqual([[secondTargetId, [secondDetectedPort]]]) + }) + }) + it('caps pending mobile state events while startup hydration is unresolved', async () => { const setFitOverride = vi.fn() const hydrateOverrides = vi.fn() diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 915df3dc05a..ba500554ae2 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2631,6 +2631,37 @@ export function useIpcEvents(): void { } const sshStateWatermarkByTargetId = new Map() + const pendingPortHydrationByTargetId = new Map< + string, + { receivedForwardPush: boolean; receivedDetectedPush: boolean } + >() + const hydrateSshPorts = (targetId: string, authority: DirectSshAuthority): void => { + const pendingPortHydration = { + receivedForwardPush: false, + receivedDetectedPush: false + } + pendingPortHydrationByTargetId.set(targetId, pendingPortHydration) + const isHydrationAuthorityCurrent = (): boolean => + !directSshEffectStopped && + directSshAuthoritiesEqual(currentDirectSshAuthority(targetId), authority) + const forwardHydration = window.api.ssh.listPortForwards({ targetId }).then((forwards) => { + // Why: if the session disconnected while awaiting the snapshot, applying it would resurrect a dead session's ports. + if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedForwardPush) { + useAppStore.getState().setPortForwards(targetId, forwards) + } + }) + const detectedHydration = window.api.ssh.listDetectedPorts({ targetId }).then((detected) => { + if (isHydrationAuthorityCurrent() && !pendingPortHydration.receivedDetectedPush) { + useAppStore.getState().setDetectedPorts(targetId, detected) + } + }) + // Why: one failed or stalled port stream must not block the other stream or later targets. + void Promise.allSettled([forwardHydration, detectedHydration]).then(() => { + if (pendingPortHydrationByTargetId.get(targetId) === pendingPortHydration) { + pendingPortHydrationByTargetId.delete(targetId) + } + }) + } let applySshConnectionStateChange!: ( targetId: string, state: SshConnectionState, @@ -2668,23 +2699,6 @@ export function useIpcEvents(): void { state as SshConnectionState, 'initial-hydration' ) - // Why: ports arrive only via push events; on reattach to a live session fetch snapshots or the Ports panel shows empty. - if ((state as SshConnectionState).status === 'connected') { - const authority = currentDirectSshAuthority(target.id) - const [forwards, detected] = await Promise.all([ - window.api.ssh.listPortForwards({ targetId: target.id }), - window.api.ssh.listDetectedPorts({ targetId: target.id }) - ]) - // Why: if the session disconnected while awaiting the snapshot, applying it would resurrect a dead session's ports. - if ( - !directSshEffectStopped && - authority && - directSshAuthoritiesEqual(currentDirectSshAuthority(target.id), authority) - ) { - useAppStore.getState().setPortForwards(target.id, forwards) - useAppStore.getState().setDetectedPorts(target.id, detected) - } - } } } } catch { @@ -2706,12 +2720,20 @@ export function useIpcEvents(): void { unsubs.push( window.api.ssh.onPortForwardsChanged(({ targetId, forwards }) => { + const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId) + if (pendingPortHydration) { + pendingPortHydration.receivedForwardPush = true + } useAppStore.getState().setPortForwards(targetId, forwards) }) ) unsubs.push( window.api.ssh.onDetectedPortsChanged(({ targetId, ports }) => { + const pendingPortHydration = pendingPortHydrationByTargetId.get(targetId) + if (pendingPortHydration) { + pendingPortHydration.receivedDetectedPush = true + } useAppStore.getState().setDetectedPorts(targetId, ports) }) ) @@ -2736,7 +2758,7 @@ export function useIpcEvents(): void { latest?.targetId !== targetId || !latest?.providerEpoch || latest.connectionGeneration === undefined || - sshStateWatermarkByTargetId.get(targetId) !== watermark + (sshStateWatermarkByTargetId.get(targetId) ?? 0) !== watermark ) { return } @@ -2840,6 +2862,10 @@ export function useIpcEvents(): void { }, { authority, previousAuthority, origin } ) + // Why: initial connected state can be partial; hydrate only after reconciliation yields a complete authority. + if (origin === 'initial-hydration') { + hydrateSshPorts(targetId, authority) + } } let sshTargetStateEventId = 0 diff --git a/tests/e2e/helpers/ssh-port-forward-lifecycle-evidence.ts b/tests/e2e/helpers/ssh-port-forward-lifecycle-evidence.ts new file mode 100644 index 00000000000..9d4c35b924b --- /dev/null +++ b/tests/e2e/helpers/ssh-port-forward-lifecycle-evidence.ts @@ -0,0 +1,225 @@ +import { request } from 'node:http' + +import { expect, type ElectronApplication, type Page } from '@stablyai/playwright-test' +import { + execDockerSshRelayTargetCommand, + shellQuote, + type DockerSshRelayTarget +} from './docker-ssh-relay-target' + +export type PortForwardEvidence = { + events: { targetId: string; forwards: { localPort: number; remotePort: number }[] }[] + rendererForwards: { localPort: number; remotePort: number }[] + managerForwards: { localPort: number; remotePort: number }[] + persistedForwards: { localPort: number; remotePort: number }[] +} + +export function requestForward(localPort: number): Promise { + return new Promise((resolve, reject) => { + const req = request( + { host: '127.0.0.1', port: localPort, path: '/', method: 'GET', timeout: 2_000 }, + (response) => { + let body = '' + response.setEncoding('utf8') + response.on('data', (chunk) => { + body += chunk + }) + response.on('end', () => resolve(body)) + } + ) + req.once('error', reject) + req.once('timeout', () => req.destroy(new Error('Forwarded HTTP request timed out'))) + req.end() + }) +} + +export function startRemoteHttpListener( + target: DockerSshRelayTarget, + port: number, + marker: string +): number { + const script = [ + "const http = require('node:http')", + `const marker = ${JSON.stringify(marker)}`, + "const server = http.createServer((_request, response) => response.end(marker + '\\n'))", + `server.listen(${port}, '127.0.0.1')` + ].join(';') + execDockerSshRelayTargetCommand( + target, + [ + `nohup node -e ${shellQuote(script)} >/tmp/orca-http-${port}.log 2>&1 < /dev/null &`, + `echo $! >/tmp/orca-http-${port}.pid` + ].join(' ') + ) + return Number(execDockerSshRelayTargetCommand(target, `cat /tmp/orca-http-${port}.pid`)) +} + +export function readRemoteListenerIdentity( + target: DockerSshRelayTarget, + port: number +): { pid: number; executable: string; command: string } { + const pid = Number(execDockerSshRelayTargetCommand(target, `cat /tmp/orca-http-${port}.pid`)) + return { + pid, + executable: execDockerSshRelayTargetCommand(target, `readlink /proc/${pid}/exe`), + command: execDockerSshRelayTargetCommand(target, `tr '\\000' ' ' { + await electronApp.evaluate(() => { + const scope = globalThis as typeof globalThis & { + __sshPortForwardWarnings?: string[] + __sshPortForwardOriginalWarn?: typeof console.warn + } + scope.__sshPortForwardWarnings = [] + scope.__sshPortForwardOriginalWarn = console.warn + console.warn = (...args: unknown[]) => { + const message = args.map(String).join(' ') + if (message.includes('[ssh')) { + scope.__sshPortForwardWarnings?.push(message) + } + scope.__sshPortForwardOriginalWarn?.(...args) + } + }) +} + +export async function readLifecycleWarnings(electronApp: ElectronApplication): Promise { + return electronApp.evaluate(() => { + const scope = globalThis as typeof globalThis & { + __sshPortForwardWarnings?: string[] + } + return scope.__sshPortForwardWarnings ?? [] + }) +} + +export async function restoreLifecycleWarningCapture( + electronApp: ElectronApplication +): Promise { + await electronApp.evaluate(() => { + const scope = globalThis as typeof globalThis & { + __sshPortForwardWarnings?: string[] + __sshPortForwardOriginalWarn?: typeof console.warn + } + if (scope.__sshPortForwardOriginalWarn) { + console.warn = scope.__sshPortForwardOriginalWarn + } + delete scope.__sshPortForwardWarnings + delete scope.__sshPortForwardOriginalWarn + }) +} + +export async function installRendererForwardCapture(page: Page): Promise { + await page.evaluate(() => { + const scope = window as typeof window & { + __sshPortForwardEvents?: unknown[] + __sshPortForwardUnsubscribe?: () => void + } + scope.__sshPortForwardEvents = [] + scope.__sshPortForwardUnsubscribe?.() + scope.__sshPortForwardUnsubscribe = window.api.ssh.onPortForwardsChanged((event) => { + scope.__sshPortForwardEvents?.push(event) + }) + }) +} + +export async function readPortForwardEvidence( + page: Page, + targetId: string +): Promise { + return page.evaluate( + async ({ targetId }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const target = (await window.api.ssh.listTargets()).find((entry) => entry.id === targetId) + const scope = window as typeof window & { + __sshPortForwardEvents?: PortForwardEvidence['events'] + } + return { + events: scope.__sshPortForwardEvents ?? [], + rendererForwards: store.getState().portForwardsByConnection[targetId] ?? [], + managerForwards: await window.api.ssh.listPortForwards({ targetId }), + persistedForwards: target?.portForwards ?? [] + } + }, + { targetId } + ) +} + +export async function openPortsPanel(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + state?.setRightSidebarTab('ports') + state?.setRightSidebarOpen(true) + }) + await expect(page.getByText('Ports', { exact: true }).last()).toBeVisible() +} + +export async function forwardPortFromPanel( + page: Page, + localPort: number, + remotePort: number +): Promise { + await page.getByRole('button', { name: 'Add', exact: true }).last().click() + const dialog = page.getByRole('dialog', { name: 'Forward a Port' }) + await expect(dialog).toBeVisible() + await dialog.getByLabel('Remote Port').fill(String(remotePort)) + await dialog.getByLabel('Local Port').fill(String(localPort)) + await dialog.getByRole('button', { name: 'Forward', exact: true }).click() + await expect(dialog).not.toBeVisible() +} + +export async function addPortForward( + page: Page, + args: { + targetId: string + localPort: number + remotePort: number + label: string + } +): Promise<{ id: string }> { + return page.evaluate( + ({ targetId, localPort, remotePort, label }) => + window.api.ssh.addPortForward({ + targetId, + localPort, + remoteHost: '127.0.0.1', + remotePort, + label + }), + args + ) +} + +export async function expectForwardEvidence( + page: Page, + targetId: string, + expected: { localPort: number; remotePort: number }[] +): Promise { + await expect + .poll( + async () => { + const evidence = await readPortForwardEvidence(page, targetId) + return { + renderer: evidence.rendererForwards.map(({ localPort, remotePort }) => ({ + localPort, + remotePort + })), + manager: evidence.managerForwards.map(({ localPort, remotePort }) => ({ + localPort, + remotePort + })), + persisted: evidence.persistedForwards.map(({ localPort, remotePort }) => ({ + localPort, + remotePort + })) + } + }, + { timeout: 30_000, message: 'renderer, manager, and persisted forward state did not agree' } + ) + .toEqual({ renderer: expected, manager: expected, persisted: expected }) +} diff --git a/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.ts b/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.ts new file mode 100644 index 00000000000..7127a754163 --- /dev/null +++ b/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.ts @@ -0,0 +1,158 @@ +import { createServer } from 'node:net' + +import type { ElectronApplication } from '@stablyai/playwright-test' + +type InvokeHandler = (event: unknown, args?: { targetId?: string }) => unknown + +type SnapshotBarrierState = { + targetId: string + captureClaimed: boolean + captured: boolean + released: boolean + release: () => void + originalHandler: InvokeHandler + handlerReturned: Promise + markHandlerReturned: () => void +} + +export type ReservedLocalPort = { + port: number + release: () => Promise +} + +export async function reserveLocalPort(): Promise { + const server = createServer() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (!address || typeof address === 'string') { + server.close() + throw new Error('Unable to reserve a local port') + } + let released = false + return { + port: address.port, + release: async () => { + if (released) { + return + } + released = true + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + } + } +} + +export async function installSshPortForwardSnapshotBarrier( + app: ElectronApplication, + targetId: string +): Promise { + await app.evaluate(({ ipcMain }, targetId) => { + const scope = globalThis as typeof globalThis & { + __sshPortForwardSnapshotBarrier?: SnapshotBarrierState + } + const handlers = ( + ipcMain as unknown as { + _invokeHandlers?: Map + } + )._invokeHandlers + const originalHandler = handlers?.get('ssh:listPortForwards') + if (!handlers || !originalHandler) { + throw new Error('ssh:listPortForwards handler is unavailable') + } + if (scope.__sshPortForwardSnapshotBarrier) { + throw new Error('SSH port-forward snapshot barrier is already installed') + } + let release!: () => void + const barrier = new Promise((resolve) => { + release = resolve + }) + let markHandlerReturned!: () => void + const handlerReturned = new Promise((resolve) => { + markHandlerReturned = resolve + }) + const state: SnapshotBarrierState = { + targetId, + captureClaimed: false, + captured: false, + released: false, + release, + originalHandler, + handlerReturned, + markHandlerReturned + } + scope.__sshPortForwardSnapshotBarrier = state + handlers.set('ssh:listPortForwards', async (event, args) => { + if (state.captureClaimed || args?.targetId !== state.targetId) { + return state.originalHandler(event, args) + } + state.captureClaimed = true + const snapshot = await state.originalHandler(event, args) + state.captured = true + await barrier + state.markHandlerReturned() + return snapshot + }) + }, targetId) +} + +export async function readSshPortForwardSnapshotBarrier( + app: ElectronApplication +): Promise<{ captured: boolean; released: boolean }> { + return app.evaluate(() => { + const state = ( + globalThis as typeof globalThis & { + __sshPortForwardSnapshotBarrier?: SnapshotBarrierState + } + ).__sshPortForwardSnapshotBarrier + return { + captured: state?.captured ?? false, + released: state?.released ?? false + } + }) +} + +export async function releaseSshPortForwardSnapshotBarrier( + app: ElectronApplication +): Promise { + await app.evaluate(async () => { + const state = ( + globalThis as typeof globalThis & { + __sshPortForwardSnapshotBarrier?: SnapshotBarrierState + } + ).__sshPortForwardSnapshotBarrier + if (state && !state.released) { + state.released = true + state.release() + } + await state?.handlerReturned + }) +} + +export async function restoreSshPortForwardSnapshotHandler( + app: ElectronApplication +): Promise { + await app.evaluate(({ ipcMain }) => { + const scope = globalThis as typeof globalThis & { + __sshPortForwardSnapshotBarrier?: SnapshotBarrierState + } + const state = scope.__sshPortForwardSnapshotBarrier + if (!state) { + return + } + if (!state.released) { + state.released = true + state.release() + } + const handlers = ( + ipcMain as unknown as { + _invokeHandlers?: Map + } + )._invokeHandlers + handlers?.set('ssh:listPortForwards', state.originalHandler) + delete scope.__sshPortForwardSnapshotBarrier + }) +} diff --git a/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts b/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts new file mode 100644 index 00000000000..e85564d185e --- /dev/null +++ b/tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts @@ -0,0 +1,69 @@ +import type { ElectronApplication } from '@stablyai/playwright-test' +import { describe, expect, it, vi } from 'vitest' +import { + installSshPortForwardSnapshotBarrier, + readSshPortForwardSnapshotBarrier, + releaseSshPortForwardSnapshotBarrier, + restoreSshPortForwardSnapshotHandler +} from './ssh-port-forward-snapshot-barrier' + +type InvokeHandler = (event: unknown, args?: { targetId?: string }) => unknown + +describe('SSH port-forward snapshot barrier', () => { + it('holds only the first matching request while its snapshot is unresolved', async () => { + const handlers = new Map() + let resolveFirstSnapshot: (value: string[]) => void = () => {} + const firstSnapshot = new Promise((resolve) => { + resolveFirstSnapshot = resolve + }) + let callCount = 0 + const originalHandler = vi.fn(() => { + callCount += 1 + return callCount === 1 ? firstSnapshot : Promise.resolve(['later-snapshot']) + }) + handlers.set('ssh:listPortForwards', originalHandler) + const app = { + evaluate: ( + callback: (electron: unknown, arg?: unknown) => unknown, + arg?: unknown + ): Promise => + Promise.resolve(callback({ ipcMain: { _invokeHandlers: handlers } }, arg)) + } as unknown as ElectronApplication + + let heldRequestStarted = false + await installSshPortForwardSnapshotBarrier(app, 'target-1') + try { + const wrappedHandler = handlers.get('ssh:listPortForwards') + expect(wrappedHandler).toBeTypeOf('function') + if (!wrappedHandler) { + throw new Error('Wrapped handler unavailable') + } + + heldRequestStarted = true + const firstRequest = Promise.resolve(wrappedHandler({}, { targetId: 'target-1' })) + await vi.waitFor(() => expect(originalHandler).toHaveBeenCalledOnce()) + const laterRequest = Promise.resolve(wrappedHandler({}, { targetId: 'target-1' })) + await expect(laterRequest).resolves.toEqual(['later-snapshot']) + expect(await readSshPortForwardSnapshotBarrier(app)).toEqual({ + captured: false, + released: false + }) + + resolveFirstSnapshot(['held-snapshot']) + await vi.waitFor(async () => { + expect(await readSshPortForwardSnapshotBarrier(app)).toEqual({ + captured: true, + released: false + }) + }) + await releaseSshPortForwardSnapshotBarrier(app) + await expect(firstRequest).resolves.toEqual(['held-snapshot']) + } finally { + resolveFirstSnapshot(['cleanup-snapshot']) + if (heldRequestStarted) { + await releaseSshPortForwardSnapshotBarrier(app) + } + await restoreSshPortForwardSnapshotHandler(app) + } + }) +}) diff --git a/tests/e2e/helpers/ssh-port-forward-transport-evidence.ts b/tests/e2e/helpers/ssh-port-forward-transport-evidence.ts new file mode 100644 index 00000000000..30fcc0e98d3 --- /dev/null +++ b/tests/e2e/helpers/ssh-port-forward-transport-evidence.ts @@ -0,0 +1,145 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { expect, type ElectronApplication, type Page } from '@stablyai/playwright-test' +import { + execDockerSshRelayTargetCommand, + shellQuote, + type DockerSshRelayTarget +} from './docker-ssh-relay-target' + +type CapturedSshState = { + status: string + providerEpoch?: string + connectionGeneration?: number +} + +export async function trustDockerSshHost( + electronApp: ElectronApplication, + target: DockerSshRelayTarget +): Promise { + const sshDir = join(target.tempDir, '.ssh') + mkdirSync(sshDir, { recursive: true }) + const knownHostsPath = join(sshDir, 'known_hosts') + const hostKeys = execFileSync('ssh-keyscan', ['-p', String(target.port), '127.0.0.1'], { + encoding: 'utf8' + }) + writeFileSync(knownHostsPath, hostKeys) + const invocationLogPath = join(target.tempDir, 'system-ssh-invocations') + const wrapperPath = join(target.tempDir, 'verified-system-ssh') + writeFileSync( + wrapperPath, + [ + '#!/bin/sh', + 'kind=transport', + 'for arg in "$@"; do', + ' if [ "$arg" = "-L" ]; then kind=forward; fi', + 'done', + `printf '%s\\n' "$kind" >> ${shellQuote(invocationLogPath)}`, + `exec /usr/bin/ssh -o UserKnownHostsFile=${shellQuote(knownHostsPath)} -o StrictHostKeyChecking=yes "$@"` + ].join('\n') + ) + chmodSync(wrapperPath, 0o755) + await electronApp.evaluate((_electron, path) => { + process.env.ORCA_SYSTEM_SSH_PATH = path + }, wrapperPath) + return invocationLogPath +} + +export function readSystemSshInvocationKinds(invocationLogPath: string): string[] { + if (!existsSync(invocationLogPath)) { + return [] + } + return readFileSync(invocationLogPath, 'utf8').split(/\r?\n/).filter(Boolean) +} + +export function terminateDockerSshRelayConnectChannel(target: DockerSshRelayTarget): number { + const output = execDockerSshRelayTargetCommand( + target, + ` +count=0 +for proc in /proc/[0-9]*; do + [ -r "$proc/cmdline" ] || continue + argv=() + mapfile -d '' -t argv < "$proc/cmdline" 2>/dev/null || continue + [ "\${argv[1]##*/}" = relay.js ] || continue + mode= + for arg in "\${argv[@]:2}"; do + if [ "$arg" = --connect ]; then mode=connect; fi + if [ "$arg" = --detached ]; then mode=detached; fi + done + if [ "$mode" = connect ]; then + kill -TERM "\${proc##*/}" + count=$((count + 1)) + fi +done +printf '%s' "$count" +` + ) + return Number(output) +} + +export async function installSshStateCapture(page: Page, targetId: string): Promise { + await page.evaluate((targetId) => { + const scope = window as typeof window & { + __sshLifecycleStates?: CapturedSshState[] + __sshLifecycleStateUnsubscribe?: () => void + } + scope.__sshLifecycleStateUnsubscribe?.() + scope.__sshLifecycleStates = [] + scope.__sshLifecycleStateUnsubscribe = window.api.ssh.onStateChanged((event) => { + if (event.targetId === targetId) { + scope.__sshLifecycleStates?.push(event.state) + } + }) + }, targetId) +} + +export async function readSshStateCapture(page: Page): Promise { + return page.evaluate( + () => + ( + window as typeof window & { + __sshLifecycleStates?: CapturedSshState[] + } + ).__sshLifecycleStates ?? [] + ) +} + +export async function forceDockerSshRelayChannelReconnect( + page: Page, + target: DockerSshRelayTarget, + targetId: string +): Promise { + await installSshStateCapture(page, targetId) + const authority = await page.evaluate( + (targetId) => window.__store?.getState().sshConnectionStates.get(targetId), + targetId + ) + expect(authority).toMatchObject({ + status: 'connected', + providerEpoch: expect.any(String), + connectionGeneration: expect.any(Number) + }) + expect(terminateDockerSshRelayConnectChannel(target)).toBeGreaterThan(0) + await expect + .poll( + async () => { + const states = await readSshStateCapture(page) + const current = await page.evaluate( + (targetId) => window.__store?.getState().sshConnectionStates.get(targetId), + targetId + ) + return ( + states.some((state) => state.status === 'reconnecting') && + states.some((state) => state.status === 'connected') && + current?.status === 'connected' && + (current.providerEpoch !== authority?.providerEpoch || + current.connectionGeneration !== authority?.connectionGeneration) + ) + }, + { timeout: 30_000, message: 'in-place relay channel did not reconnect' } + ) + .toBe(true) +} diff --git a/tests/e2e/ssh-port-forward-lifecycle.spec.ts b/tests/e2e/ssh-port-forward-lifecycle.spec.ts new file mode 100644 index 00000000000..fff647120b8 --- /dev/null +++ b/tests/e2e/ssh-port-forward-lifecycle.spec.ts @@ -0,0 +1,370 @@ +import { createServer } from 'node:net' + +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + cleanupDockerSshRelayTarget, + startDockerSshRelayTarget, + type DockerSshRelayTarget +} from './helpers/docker-ssh-relay-target' +import { + connectDockerSshRelayTarget, + reconnectDockerSshRelayTarget +} from './helpers/docker-ssh-relay-connection' +import { + installSshPortForwardSnapshotBarrier, + readSshPortForwardSnapshotBarrier, + releaseSshPortForwardSnapshotBarrier, + reserveLocalPort, + restoreSshPortForwardSnapshotHandler +} from './helpers/ssh-port-forward-snapshot-barrier' +import { + addPortForward, + expectForwardEvidence, + forwardPortFromPanel, + installLifecycleWarningCapture, + installRendererForwardCapture, + openPortsPanel, + readLifecycleWarnings, + readPortForwardEvidence, + readRemoteListenerIdentity, + requestForward, + restoreLifecycleWarningCapture, + startRemoteHttpListener +} from './helpers/ssh-port-forward-lifecycle-evidence' +import { + forceDockerSshRelayChannelReconnect, + readSshStateCapture, + readSystemSshInvocationKinds, + trustDockerSshHost +} from './helpers/ssh-port-forward-transport-evidence' + +const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' +const FORCE_SYSTEM_SSH = process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT === '1' +const REMOTE_PORT = 7860 +const REFRESH_BARRIER_PORT = 7861 +const SCAN_REFRESH_PORT = 7862 + +test.describe('Docker SSH port-forward lifecycle', () => { + test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH tests.') + test.skip(process.platform === 'win32', 'Docker SSH lifecycle uses POSIX process inspection.') + + test('keeps a user-forwarded listener live across scan refresh @headful', async ({ + electronApp, + orcaPage + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + const localPortReservation = await reserveLocalPort() + const unrelatedLocalPortReservation = await reserveLocalPort() + const localPort = localPortReservation.port + const unrelatedLocalPort = unrelatedLocalPortReservation.port + const marker = `ORCA_FORWARD_${Date.now()}` + const unrelatedMarker = `${marker}_UNRELATED` + try { + target = startDockerSshRelayTarget(testInfo) + const systemSshInvocationLogPath = await trustDockerSshHost(electronApp, target) + await installLifecycleWarningCapture(electronApp) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + const remote = await connectDockerSshRelayTarget(orcaPage, target) + const remotePid = startRemoteHttpListener(target, REMOTE_PORT, marker) + const unrelatedRemotePid = startRemoteHttpListener( + target, + REFRESH_BARRIER_PORT, + unrelatedMarker + ) + await openPortsPanel(orcaPage) + + await expect + .poll( + () => + orcaPage.evaluate( + ({ targetId, port }) => + window.api.ssh + .listDetectedPorts({ targetId }) + .then((ports) => ports.find((entry) => entry.port === port)?.pid ?? null), + { targetId: remote.targetId, port: REMOTE_PORT } + ), + { timeout: 45_000, message: 'remote HTTP listener was not detected' } + ) + .toBe(remotePid) + await expect(orcaPage.getByText(`:${REMOTE_PORT}`, { exact: true })).toBeVisible() + await expect + .poll( + () => + orcaPage.evaluate( + ({ targetId, port }) => + window.api.ssh + .listDetectedPorts({ targetId }) + .then((ports) => ports.some((entry) => entry.port === port)), + { targetId: remote.targetId, port: REFRESH_BARRIER_PORT } + ), + { timeout: 45_000, message: 'scan-refresh barrier listener was not detected' } + ) + .toBe(true) + await expect(orcaPage.getByText(`:${REFRESH_BARRIER_PORT}`, { exact: true })).toBeVisible() + + await installSshPortForwardSnapshotBarrier(electronApp, remote.targetId) + await orcaPage.evaluate(() => window.dispatchEvent(new Event('beforeunload'))) + await orcaPage.reload() + await waitForSessionReady(orcaPage, 60_000) + await expect + .poll(() => waitForActiveWorktree(orcaPage), { timeout: 60_000 }) + .toBe(remote.worktreeId) + await expect + .poll( + () => + orcaPage.evaluate( + (targetId) => window.__store?.getState().sshConnectionStates.get(targetId)?.status, + remote.targetId + ), + { timeout: 60_000, message: 'renderer did not restore the connected SSH target' } + ) + .toBe('connected') + await expect + .poll(() => readSshPortForwardSnapshotBarrier(electronApp), { + timeout: 30_000, + message: 'renderer hydration did not capture an empty Forwarded snapshot' + }) + .toEqual({ captured: true, released: false }) + + await installRendererForwardCapture(orcaPage) + await openPortsPanel(orcaPage) + await localPortReservation.release() + await forwardPortFromPanel(orcaPage, localPort, REMOTE_PORT) + await expect(orcaPage.getByText('Forwarded', { exact: true })).toBeVisible() + await expect( + orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true }) + ).toBeVisible() + await expect.poll(() => requestForward(localPort)).toContain(marker) + await expectForwardEvidence(orcaPage, remote.targetId, [ + { localPort, remotePort: REMOTE_PORT } + ]) + if (FORCE_SYSTEM_SSH) { + await expect + .poll(() => readSystemSshInvocationKinds(systemSshInvocationLogPath)) + .toContain('forward') + } else { + expect(readSystemSshInvocationKinds(systemSshInvocationLogPath)).not.toContain('forward') + } + + await releaseSshPortForwardSnapshotBarrier(electronApp) + const postHydrationRoundTripForwards = await orcaPage.evaluate( + (targetId) => window.api.ssh.listPortForwards({ targetId }), + remote.targetId + ) + expect(postHydrationRoundTripForwards).toContainEqual( + expect.objectContaining({ localPort, remotePort: REMOTE_PORT }) + ) + await expect(orcaPage.getByText(`:${REFRESH_BARRIER_PORT}`, { exact: true })).toBeVisible() + const staleSnapshotEvidence = await readPortForwardEvidence(orcaPage, remote.targetId) + const staleSnapshotIdentity = readRemoteListenerIdentity(target, REMOTE_PORT) + const staleSnapshotWarnings = await readLifecycleWarnings(electronApp) + expect(staleSnapshotEvidence.managerForwards).toContainEqual( + expect.objectContaining({ localPort, remotePort: REMOTE_PORT }) + ) + expect(staleSnapshotEvidence.persistedForwards).toContainEqual( + expect.objectContaining({ localPort, remotePort: REMOTE_PORT }) + ) + await expect(requestForward(localPort)).resolves.toContain(marker) + expect(staleSnapshotIdentity).toMatchObject({ + pid: remotePid, + executable: expect.stringContaining('/node'), + command: expect.stringContaining(String(REMOTE_PORT)) + }) + expect(staleSnapshotWarnings.filter((message) => message.includes('Port forward'))).toEqual( + [] + ) + await expect( + orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true }) + ).toBeVisible() + await expectForwardEvidence(orcaPage, remote.targetId, [ + { localPort, remotePort: REMOTE_PORT } + ]) + await restoreSshPortForwardSnapshotHandler(electronApp) + + startRemoteHttpListener(target, SCAN_REFRESH_PORT, `${marker}_SCAN_REFRESH`) + await expect + .poll( + () => + orcaPage.evaluate( + ({ targetId, port }) => + window.api.ssh + .listDetectedPorts({ targetId }) + .then((ports) => ports.some((entry) => entry.port === port)), + { targetId: remote.targetId, port: SCAN_REFRESH_PORT } + ), + { timeout: 45_000, message: 'scan-refresh listener was not detected in main' } + ) + .toBe(true) + await expect(orcaPage.getByText(`:${SCAN_REFRESH_PORT}`, { exact: true })).toBeVisible() + + await unrelatedLocalPortReservation.release() + const unrelatedForward = await addPortForward(orcaPage, { + targetId: remote.targetId, + localPort: unrelatedLocalPort, + remotePort: REFRESH_BARRIER_PORT, + label: 'unrelated-listener' + }) + await expectForwardEvidence(orcaPage, remote.targetId, [ + { localPort, remotePort: REMOTE_PORT }, + { localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT } + ]) + await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker) + + await forceDockerSshRelayChannelReconnect(orcaPage, target, remote.targetId) + await expectForwardEvidence(orcaPage, remote.targetId, [ + { localPort, remotePort: REMOTE_PORT }, + { localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT } + ]) + await expect.poll(() => requestForward(localPort)).toContain(marker) + await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker) + + const authorityBeforeTransportReconnect = await orcaPage.evaluate( + (targetId) => window.__store?.getState().sshConnectionStates.get(targetId), + remote.targetId + ) + await reconnectDockerSshRelayTarget(orcaPage, remote.targetId) + await expect + .poll( + async () => { + const state = await orcaPage.evaluate( + (targetId) => window.__store?.getState().sshConnectionStates.get(targetId), + remote.targetId + ) + return ( + state?.status === 'connected' && + (state.providerEpoch !== authorityBeforeTransportReconnect?.providerEpoch || + state.connectionGeneration !== + authorityBeforeTransportReconnect?.connectionGeneration) + ) + }, + { timeout: 30_000, message: 'renderer did not observe the reconnected SSH authority' } + ) + .toBe(true) + await expectForwardEvidence(orcaPage, remote.targetId, [ + { localPort, remotePort: REMOTE_PORT }, + { localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT } + ]) + await expect.poll(() => requestForward(localPort)).toContain(marker) + await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker) + await expect( + orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true }) + ).toBeVisible() + await expect( + orcaPage.getByText(`:${unrelatedLocalPort} → :${REFRESH_BARRIER_PORT}`, { exact: true }) + ).toBeVisible() + + const collisionServer = createServer() + await new Promise((resolve, reject) => { + collisionServer.once('error', reject) + collisionServer.listen(0, '127.0.0.1', resolve) + }) + const collisionAddress = collisionServer.address() + if (!collisionAddress || typeof collisionAddress === 'string') { + throw new Error('Unable to reserve a collision port') + } + try { + const collisionResult = await orcaPage.evaluate( + async ({ targetId, localPort, remotePort }) => { + try { + await window.api.ssh.addPortForward({ + targetId, + localPort, + remoteHost: '127.0.0.1', + remotePort, + label: 'collision' + }) + return { ok: true, message: '' } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } + }, + { + targetId: remote.targetId, + localPort: collisionAddress.port, + remotePort: REMOTE_PORT + } + ) + expect(collisionResult).toMatchObject({ ok: false }) + expect(collisionResult.message).toMatch(/in use|EADDRINUSE/i) + } finally { + await new Promise((resolve, reject) => + collisionServer.close((error) => (error ? reject(error) : resolve())) + ) + } + await expectForwardEvidence(orcaPage, remote.targetId, [ + { localPort, remotePort: REMOTE_PORT }, + { localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT } + ]) + + const primaryRow = orcaPage + .getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true }) + .locator('../../..') + await primaryRow.getByTitle('Remove').click() + await expect( + orcaPage.getByText(`:${localPort} → :${REMOTE_PORT}`, { exact: true }) + ).not.toBeVisible() + await expectForwardEvidence(orcaPage, remote.targetId, [ + { localPort: unrelatedLocalPort, remotePort: REFRESH_BARRIER_PORT } + ]) + await expect(requestForward(localPort)).rejects.toThrow() + await expect.poll(() => requestForward(unrelatedLocalPort)).toContain(unrelatedMarker) + + const evidence = await readPortForwardEvidence(orcaPage, remote.targetId) + const identity = readRemoteListenerIdentity(target, REMOTE_PORT) + const unrelatedIdentity = readRemoteListenerIdentity(target, REFRESH_BARRIER_PORT) + const warnings = await readLifecycleWarnings(electronApp) + const relayReconnectStates = await readSshStateCapture(orcaPage) + testInfo.annotations.push({ + type: 'ssh-port-forward-evidence', + description: JSON.stringify({ + evidence, + identity, + unrelatedIdentity, + removedForwardLocalPort: localPort, + unrelatedForward, + relayReconnectStates, + staleSnapshotEvidence, + systemSshInvocations: readSystemSshInvocationKinds(systemSshInvocationLogPath), + warnings + }) + }) + + expect(identity).toMatchObject({ + pid: remotePid, + executable: expect.stringContaining('/node'), + command: expect.stringContaining(String(REMOTE_PORT)) + }) + expect(unrelatedIdentity).toMatchObject({ + pid: unrelatedRemotePid, + executable: expect.stringContaining('/node'), + command: expect.stringContaining(String(REFRESH_BARRIER_PORT)) + }) + expect(evidence.rendererForwards).not.toContainEqual( + expect.objectContaining({ localPort, remotePort: REMOTE_PORT }) + ) + expect(evidence.managerForwards).not.toContainEqual( + expect.objectContaining({ localPort, remotePort: REMOTE_PORT }) + ) + expect(evidence.persistedForwards).not.toContainEqual( + expect.objectContaining({ localPort, remotePort: REMOTE_PORT }) + ) + expect(evidence.events.at(-1)?.forwards).toContainEqual( + expect.objectContaining({ + localPort: unrelatedLocalPort, + remotePort: REFRESH_BARRIER_PORT + }) + ) + await expect(requestForward(unrelatedLocalPort)).resolves.toContain(unrelatedMarker) + expect(warnings.filter((message) => message.includes('Port forward'))).toEqual([]) + } finally { + await restoreSshPortForwardSnapshotHandler(electronApp).catch(() => undefined) + await restoreLifecycleWarningCapture(electronApp).catch(() => undefined) + await localPortReservation.release().catch(() => undefined) + await unrelatedLocalPortReservation.release().catch(() => undefined) + cleanupDockerSshRelayTarget(target) + } + }) +}) From 336cef3185cf725cc12ab77da9158407ddc7a3de Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:39:34 -0700 Subject: [PATCH 04/23] [P1] fix(sidebar): drop onto the indicated board position in virtualized lanes (#11688) The sidebar->board drop translation still read the mounted DOM cards, while getCardDropTarget now indexes the lane's virtual layout, so a searched and scrolled lane resolved the drop to the wrong full-lane position. Co-authored-by: Orca --- .../workspace-kanban-sidebar-drop.test.ts | 153 ++++++++++++++++++ .../sidebar/workspace-kanban-sidebar-drop.ts | 71 ++++---- .../workspace-kanban-virtual-lane-layout.ts | 10 ++ 3 files changed, 204 insertions(+), 30 deletions(-) diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts index 08f627cc0b4..5a4a3d62b4c 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.test.ts @@ -210,6 +210,91 @@ function appendBoard(): { board: HTMLElement; lane: HTMLElement; firstCard: HTML return { board, lane, firstCard } } +const VIRTUAL_CARD_PITCH = 44 +const VIRTUAL_CARD_HEIGHT = 36 +// Scrolled past the head of the lane, so the mounted window starts mid-list. +const VIRTUAL_SPACER_TOP = -350 + +// A virtualized lane: only `windowRange` of the view is mounted, and the layout +// registration carries the whole view — exactly what the virtualizer publishes. +function appendVirtualBoard(args: { + fullLaneIds: readonly string[] + viewIds: readonly string[] + windowRange: readonly [number, number] + publishFullIds: boolean +}): { lane: HTMLElement; unregister: () => void } { + const board = document.createElement('div') + board.setAttribute('data-workspace-board-selection-surface', '') + setRect(board, { left: 0, top: 0, right: 320, bottom: 240, width: 320, height: 240 }) + + const lane = document.createElement('section') + lane.setAttribute('data-workspace-status-drop-target', '') + lane.dataset.workspaceStatus = 'doing' + setRect(lane, { left: 0, top: 0, right: 200, bottom: 220, width: 200, height: 220 }) + if (args.publishFullIds) { + lane.dataset.workspaceLaneFullIds = serializeWorkspaceLaneFullIds([...args.fullLaneIds]) ?? '' + } + + const laneScroll = document.createElement('div') + laneScroll.setAttribute('data-workspace-board-lane-scroll', '') + setRect(laneScroll, { left: 0, top: 0, right: 200, bottom: 220, width: 200, height: 220 }) + + const totalSize = args.viewIds.length * VIRTUAL_CARD_PITCH + const spacer = document.createElement('div') + setRect(spacer, { + left: 8, + top: VIRTUAL_SPACER_TOP, + right: 192, + bottom: VIRTUAL_SPACER_TOP + totalSize, + width: 184, + height: totalSize + }) + + for (let index = args.windowRange[0]; index <= args.windowRange[1]; index++) { + const worktreeId = args.viewIds[index]! + const card = document.createElement('div') + card.setAttribute('data-workspace-board-card-id', worktreeId) + card.dataset.workspaceBoardCardId = worktreeId + card.dataset.workspaceBoardCardIndex = String(index) + const top = VIRTUAL_SPACER_TOP + index * VIRTUAL_CARD_PITCH + setRect(card, { + left: 8, + top, + right: 192, + bottom: top + VIRTUAL_CARD_HEIGHT, + width: 184, + height: VIRTUAL_CARD_HEIGHT + }) + setVisible(card) + spacer.append(card) + } + + laneScroll.append(spacer) + lane.append(laneScroll) + board.append(lane) + document.body.append(board) + setElementFromPoint(lane) + + const unregister = registerWorkspaceKanbanVirtualLaneLayout({ + scrollElement: laneScroll, + spacerElement: spacer, + getItemIds: () => args.viewIds, + getMeasurements: () => + args.viewIds.map((_, index) => ({ + index, + start: index * VIRTUAL_CARD_PITCH, + end: index * VIRTUAL_CARD_PITCH + VIRTUAL_CARD_HEIGHT + })) + }) + return { lane, unregister } +} + +// 40-member lane under a search that matches every other card. +function searchedVirtualLaneIds(): { fullLaneIds: string[]; viewIds: string[] } { + const fullLaneIds = Array.from({ length: 40 }, (_, index) => `doing-${index}`) + return { fullLaneIds, viewIds: fullLaneIds.filter((_, index) => index % 2 === 0) } +} + function worktree(args: { id: string workspaceStatus: string @@ -356,6 +441,74 @@ describe('workspace kanban sidebar drop DOM bridge', () => { expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 2)).toBe(4) }) + it('lands a virtualized searched lane drop where the indicator pointed', () => { + const { fullLaneIds, viewIds } = searchedVirtualLaneIds() + const { unregister } = appendVirtualBoard({ + fullLaneIds, + viewIds, + windowRange: [8, 17], + publishFullIds: true + }) + + const target = getWorkspaceKanbanSidebarDropTarget(24, 210) + // The indicator sits between the mounted cards for view items 12 and 13, + // whose full-lane neighbours are doing-24 and doing-26. + expect(target).toMatchObject({ status: 'doing', dropIndex: 13, dropIndicatorY: 218 }) + expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', target.dropIndex)).toBe(26) + + unregister() + }) + + it('translates virtualized searched lane drops above and below the mounted window', () => { + const { fullLaneIds, viewIds } = searchedVirtualLaneIds() + const { unregister } = appendVirtualBoard({ + fullLaneIds, + viewIds, + windowRange: [8, 17], + publishFullIds: true + }) + + expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 0)).toBe(0) + expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 3)).toBe(6) + expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 19)).toBe(38) + expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 20)).toBe(39) + + unregister() + }) + + it('passes a virtualized unfiltered lane drop index through untranslated', () => { + const { fullLaneIds } = searchedVirtualLaneIds() + const { unregister } = appendVirtualBoard({ + fullLaneIds, + viewIds: fullLaneIds, + windowRange: [8, 17], + publishFullIds: false + }) + + expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([ + { key: 'doing', worktreeIds: fullLaneIds } + ]) + expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 13)).toBe(13) + + unregister() + }) + + it('still counts unlaid-out cards as lane members without a virtual layout', () => { + const { lane } = appendBoard() + const secondCard = lane.querySelectorAll('[data-workspace-board-card-id]')[1]! + secondCard.remove() + const hiddenCard = document.createElement('div') + hiddenCard.setAttribute('data-workspace-board-card-id', 'doing-hidden') + hiddenCard.dataset.workspaceBoardCardId = 'doing-hidden' + lane.append(hiddenCard, secondCard) + setElementFromPoint(lane) + + expect(getWorkspaceKanbanSidebarDropGroups()).toEqual([ + { key: 'doing', worktreeIds: ['doing-a', 'doing-hidden', 'doing-b'] } + ]) + expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 1)).toBe(2) + }) + it('passes the drop index through for a lane it cannot find', () => { appendBoard() diff --git a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts index 363038bd529..8475d91cdd8 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-sidebar-drop.ts @@ -23,9 +23,11 @@ import { updateCardDropIndicator, type WorkspaceKanbanCardDropTarget } from './workspace-kanban-card-pointer-drag-dom' +import { getWorkspaceKanbanVirtualLaneItemIds } from './workspace-kanban-virtual-lane-layout' const BOARD_SELECTOR = '[data-workspace-board-selection-surface]' const BOARD_SHEET_SELECTOR = '[data-workspace-board-sheet]' +const LANE_SCROLL_SELECTOR = '[data-workspace-board-lane-scroll]' const EXTERNAL_DRAG_TARGET_ATTR = 'data-workspace-board-external-drag-target' let externalDragTargetElement: HTMLElement | null = null @@ -53,24 +55,33 @@ function getLaneCardIds(lane: HTMLElement): HTMLElement[] { return Array.from(lane.querySelectorAll(CARD_SELECTOR)) } -// Mirrors getCardDropTarget's card scan so both sides share one index space. -// The offsetParent read forces layout, so callers pass the card list they -// already collected rather than re-querying. -function toRenderedCardIds(cards: readonly HTMLElement[]): string[] { - return cards - .filter((card) => card.offsetParent !== null) - .flatMap((card) => card.dataset.workspaceBoardCardId ?? []) -} - -// Why: board search hides non-matching cards, so the rendered card scan is a -// filtered lane. Lanes publish their full membership for exactly this reader. -// The fallback is the unfiltered card list — a lane member the browser is not -// laying out is still a member for manual-order purposes. -function toLaneFullWorktreeIds(lane: HTMLElement, cards: readonly HTMLElement[]): string[] { - return ( - parseWorkspaceLaneFullIds(lane.dataset.workspaceLaneFullIds) ?? - cards.flatMap((card) => card.dataset.workspaceBoardCardId ?? []) - ) +/** + * `viewIds` is the lane in the index space `getCardDropTarget` reports, and + * `fullLaneIds` is the lane's whole membership. Board search hides non-matching + * cards, so lanes publish their full membership for exactly this reader. + * + * Why the virtual layout first: lanes virtualize, so the mounted cards are a + * window rather than the lane view, and `getCardDropTarget` indexes the same + * virtual layout. The DOM scan is only the fallback for a lane the virtualizer + * never claimed — there the unfiltered card list is the full lane, since a card + * the browser is not laying out is still a member for manual-order purposes. + */ +function toLaneDropIds(lane: HTMLElement): { fullLaneIds: string[]; viewIds: string[] } { + const publishedFullLaneIds = parseWorkspaceLaneFullIds(lane.dataset.workspaceLaneFullIds) + const laneScroll = lane.querySelector(LANE_SCROLL_SELECTOR) + const virtualItemIds = laneScroll ? getWorkspaceKanbanVirtualLaneItemIds(laneScroll) : null + if (virtualItemIds) { + const viewIds = [...virtualItemIds] + return { fullLaneIds: publishedFullLaneIds ?? viewIds, viewIds } + } + const cards = getLaneCardIds(lane) + return { + fullLaneIds: + publishedFullLaneIds ?? cards.flatMap((card) => card.dataset.workspaceBoardCardId ?? []), + viewIds: cards + .filter((card) => card.offsetParent !== null) + .flatMap((card) => card.dataset.workspaceBoardCardId ?? []) + } } function getStatusDropTargetElement( @@ -128,7 +139,7 @@ export function getWorkspaceKanbanSidebarDropGroups(): WorktreeDragGroup[] { if (!status) { return [] } - return [{ key: status, worktreeIds: toLaneFullWorktreeIds(lane, getLaneCardIds(lane)) }] + return [{ key: status, worktreeIds: toLaneDropIds(lane).fullLaneIds }] }) } @@ -144,26 +155,26 @@ export function getWorkspaceKanbanSidebarDropTarget( } /** - * Translates a tracked drop index — which counts *rendered* cards, matching the - * drop indicator — onto the full lane that `getWorkspaceKanbanSidebarDropGroups` - * reports. Call this once, at the commit boundary: a tracked target can be - * committed after the pointer has left the lane, so translating earlier would - * miss that path. + * Translates a tracked drop index — which counts the lane's *view* items, + * matching the drop indicator — onto the full lane that + * `getWorkspaceKanbanSidebarDropGroups` reports. Call this once, at the commit + * boundary: a tracked target can be committed after the pointer has left the + * lane, so translating earlier would miss that path. */ export function resolveWorkspaceKanbanSidebarFullLaneDropIndex( status: WorkspaceStatus, - renderedDropIndex: number + viewDropIndex: number ): number { const board = getWorkspaceKanbanBoardElement() const lane = board ? getStatusDropTargetElement(board, status) : null if (!lane) { - return renderedDropIndex + return viewDropIndex } - const cards = getLaneCardIds(lane) + const { fullLaneIds, viewIds } = toLaneDropIds(lane) return resolveFullLaneDropIndex({ - fullLaneIds: toLaneFullWorktreeIds(lane, cards), - renderedIds: toRenderedCardIds(cards), - filteredDropIndex: renderedDropIndex + fullLaneIds, + renderedIds: viewIds, + filteredDropIndex: viewDropIndex }) } diff --git a/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts index e142b3617d6..4d7b2897fcd 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-virtual-lane-layout.ts @@ -44,6 +44,16 @@ export function registerWorkspaceKanbanVirtualLaneLayout(args: { } } +/** + * The lane's item ids in the index space `resolveWorkspaceKanbanVirtualLaneDropIndex` + * reports — every item in the lane view, not just the mounted virtual window. + */ +export function getWorkspaceKanbanVirtualLaneItemIds( + scrollElement: HTMLElement +): readonly string[] | null { + return virtualLaneLayouts.get(scrollElement)?.getItemIds() ?? null +} + export function getWorkspaceKanbanVirtualLaneItemRects( scrollElement: HTMLElement ): WorkspaceKanbanVirtualLaneItemRect[] | null { From 451baa1bc47f2ae4fd3cd2654e4199398612a151 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:39:44 -0700 Subject: [PATCH 05/23] fix(mobile): clear state after closing final tab (#11637) * fix(mobile): clear state after closing final tab * fix(mobile): clear terminal on empty snapshots * fix(mobile): preserve terminal during transient empty snapshot --- .../app/h/[hostId]/session/[worktreeId].tsx | 8 +++-- .../mobile-session-last-tab-close.test.ts | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 mobile/src/session/mobile-session-last-tab-close.test.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 6a7bfc3a54f..4ec0e564a79 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1894,6 +1894,7 @@ export default function SessionScreen() { setActiveHandle(active.terminal) subscribeToTerminal(active.terminal) } else if (active) { + // Why: an empty snapshot can transiently omit a live terminal; explicit close clears it on RPC success. const previous = activeHandleRef.current if (previous) { unsubscribeTerminal(previous) @@ -4133,6 +4134,7 @@ export default function SessionScreen() { reason: 'user' }) if (response.ok) { + const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id) if (tab.type === 'browser' && tab.browserPageId === pendingBrowserFocusPageIdRef.current) { pendingBrowserFocusPageIdRef.current = null } @@ -4143,14 +4145,16 @@ export default function SessionScreen() { initializedHandlesRef.current.delete(terminalHandle) clearTerminalLiveInputDefault(terminalHandle) } - setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id)) + sessionTabsRef.current = remainingTabs + setSessionTabs(remainingTabs) // Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab. closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000) // Why: bulk close re-activates the anchor before awaiting each close; // the render-synced ref sees that switch while this closure would not, // so comparing against the ref keeps the anchor from being nulled out. - if (activeSessionTabIdRef.current === tab.id) { + if (activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0) { activeSessionTabTypeRef.current = null + activeSessionTabIdRef.current = null setActiveSessionTabId(null) activeHandleRef.current = null setActiveHandle(null) diff --git a/mobile/src/session/mobile-session-last-tab-close.test.ts b/mobile/src/session/mobile-session-last-tab-close.test.ts new file mode 100644 index 00000000000..7bd6ad179fc --- /dev/null +++ b/mobile/src/session/mobile-session-last-tab-close.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const sessionRouteSource = readFileSync( + new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), + 'utf8' +) + +describe('mobile session last-tab close', () => { + it('preserves terminal identity while an empty snapshot may be transient', () => { + const start = sessionRouteSource.indexOf('const applySessionTabs = useCallback') + const end = sessionRouteSource.indexOf('const readMarkdownTab', start) + const block = sessionRouteSource.slice(start, end) + + expect(block).toContain('} else if (active) {') + }) + + it('clears stale active identity when closing leaves no tabs', () => { + const start = sessionRouteSource.indexOf('async function handleCloseSessionTab') + const end = sessionRouteSource.indexOf('const bulkCloseActions', start) + const block = sessionRouteSource.slice(start, end) + + expect(block).toContain( + 'activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0' + ) + expect(block).toContain('activeSessionTabIdRef.current = null') + expect(block).toContain('activeHandleRef.current = null') + }) +}) From 4f00b21186daabb219a05cfe16c9e0b9ba8a96e1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:40:11 -0700 Subject: [PATCH 06/23] fix(mobile): restore Codex chat session identity (#11636) * fix(mobile): restore Codex chat session identity * fix(mobile): reconcile native chat session ownership --- src/main/runtime/orca-runtime.test.ts | 122 ++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 42 ++++++++- 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 6c137a4dcf0..153f7840760 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -22157,6 +22157,128 @@ describe('OrcaRuntimeService', () => { ]) }) + it.each([ + { + behavior: 'fills a missing renderer session', + hookAgentType: 'codex', + hookOffset: 0, + rendererSessionId: null, + expectedSessionId: 'hook-session' + }, + { + behavior: 'replaces a stale renderer session at the same event timestamp', + hookAgentType: 'codex', + hookOffset: 0, + rendererSessionId: 'stale-renderer-session', + expectedSessionId: 'hook-session' + }, + { + behavior: 'preserves a renderer session newer than the hook row', + hookAgentType: 'codex', + hookOffset: -1, + rendererSessionId: 'newer-renderer-session', + expectedSessionId: 'newer-renderer-session' + }, + { + behavior: 'rejects a hook session owned by another agent', + hookAgentType: 'claude', + hookOffset: 1, + rendererSessionId: null, + expectedSessionId: null + } + ] as const)( + '$behavior', + async ({ hookAgentType, hookOffset, rendererSessionId, expectedSessionId }) => { + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = `codex-tab:${leafId}` + const providerSession = { + key: 'session_id' as const, + id: 'hook-session' + } + const now = Date.now() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'done', + prompt: '', + agentType: hookAgentType, + connectionId: null, + receivedAt: now + hookOffset, + stateStartedAt: now + hookOffset, + tabId: 'codex-tab', + worktreeId: TEST_WORKTREE_ID, + providerSession + } + ] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `codex-tab::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `codex-tab::${leafId}`, + parentTabId: 'codex-tab', + leafId, + title: 'Codex', + launchAgent: 'codex', + agentStatus: { + state: 'working', + prompt: 'Reply with MOBILE QA OK and nothing else.', + updatedAt: now, + stateStartedAt: now, + agentType: 'codex', + paneKey, + stateHistory: [], + ...(rendererSessionId + ? { + providerSession: { + key: 'session_id' as const, + id: rendererSessionId + } + } + : {}) + }, + isActive: true + } + ] + } + ] + }) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.objectContaining({ + state: 'working', + prompt: 'Reply with MOBILE QA OK and nothing else.', + agentType: 'codex' + }) + }) + ) + if (expectedSessionId) { + expect(result.tabs[0]).toHaveProperty('agentStatus.providerSession', { + key: 'session_id', + id: expectedSessionId + }) + } else { + expect(result.tabs[0]).not.toHaveProperty('agentStatus.providerSession') + } + } + ) + it('preserves authoritative OMP identity for Pi-compatible remote terminal snapshots', async () => { const runtime = new OrcaRuntimeService(store) const leafId = '11111111-1111-4111-8111-111111111111' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d6bc500632f..abc82437657 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -69,7 +69,8 @@ import { import { hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, - normalizeCompatibleAgentTitleForOwner + normalizeCompatibleAgentTitleForOwner, + resolveCompatibleAgentTypeForOwner } from '../../shared/agent-title-owner' import { resolvePaneAgentOwner } from '../../shared/pane-agent-owner' import { @@ -28674,6 +28675,9 @@ export class OrcaRuntimeService { const retainedAgentStatus = tab.agentStatus ? null : this.getFreshRetainedAgentStatusForMobileTab(paneKey, liveLeafPty ?? mobileStatusPty, tab) + const hookAgentStatus = tab.agentStatus + ? this.getHookAgentRowForPane(getHookRowsForPane(paneKey)) + : null const leafTitle = leaf ? getLatestAgentCandidateTitle( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, @@ -28691,7 +28695,11 @@ export class OrcaRuntimeService { const ownerAgent = resolvePaneAgentOwner({ launchAgent, - hookAgent: tab.agentStatus?.agentType ?? retainedAgentStatus?.payload.agentType ?? null + hookAgent: + tab.agentStatus?.agentType ?? + hookAgentStatus?.agentType ?? + retainedAgentStatus?.payload.agentType ?? + null }) ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? @@ -28702,8 +28710,32 @@ export class OrcaRuntimeService { ) const liveTitleEvidence = leafTitle ?? ptyTitle const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence) + // Why: renderer status can precede hook session identity, leaving native chat with no transcript address. + const rendererStatusAgent = + resolveCompatibleAgentTypeForOwner(tab.agentStatus?.agentType, ownerAgent) ?? + ownerAgent ?? + undefined + const hookSessionAgent = resolveCompatibleAgentTypeForOwner( + hookAgentStatus?.providerSessionAgentType, + ownerAgent + ) + const hookSessionMatchesRenderer = + !rendererStatusAgent || !hookSessionAgent || rendererStatusAgent === hookSessionAgent + const hookProviderSession = + hookAgentStatus?.providerSession && + hookSessionMatchesRenderer && + (!tab.agentStatus?.providerSession || + (hookAgentStatus.providerSessionReceivedAt ?? -1) >= tab.agentStatus.updatedAt) + ? hookAgentStatus.providerSession + : tab.agentStatus?.providerSession const normalizedTabAgentStatus = tab.agentStatus - ? normalizeCompatibleAgentStatusEntryForOwner(tab.agentStatus, ownerAgent) + ? normalizeCompatibleAgentStatusEntryForOwner( + { + ...tab.agentStatus, + ...(hookProviderSession ? { providerSession: hookProviderSession } : {}) + }, + ownerAgent + ) : null // Why: keep rich hook status on a live prompt/tool (authoritative even under a non-agent title), else interactivePrompt is lost. const hasLiveAgentSignal = @@ -28942,6 +28974,8 @@ export class OrcaRuntimeService { * read would keep offering native chat for what is now a plain shell. */ private getHookAgentRowForPane(rows: readonly AgentStatusIpcPayload[]): { providerSession: AgentProviderSessionMetadata | null + providerSessionAgentType: string | null + providerSessionReceivedAt: number | null agentType: string | null } { let session: AgentStatusIpcPayload | null = null @@ -28967,6 +29001,8 @@ export class OrcaRuntimeService { } return { providerSession: session?.providerSession ?? null, + providerSessionAgentType: session?.agentType ?? null, + providerSessionReceivedAt: session?.receivedAt ?? null, agentType: agent?.agentType ?? null } } From 48e52540d1a8ac720611ec04fd566bb03e515e09 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:51:39 -0700 Subject: [PATCH 07/23] fix(macos): verify Full Disk Access and re-arm notices (#11716) * fix(macos): verify full disk access status * perf(macos): keep full disk probe off main thread --- src/main/ipc/developer-permissions.test.ts | 23 +++++++ src/main/ipc/developer-permissions.ts | 21 +------ .../macos-full-disk-access-status.test.ts | 63 +++++++++++++++++++ src/main/macos-full-disk-access-status.ts | 44 +++++++++++++ src/main/macos-tcc-prompt-notice.test.ts | 38 +++++++++-- src/main/macos-tcc-prompt-notice.ts | 26 ++++++-- 6 files changed, 188 insertions(+), 27 deletions(-) create mode 100644 src/main/macos-full-disk-access-status.test.ts create mode 100644 src/main/macos-full-disk-access-status.ts diff --git a/src/main/ipc/developer-permissions.test.ts b/src/main/ipc/developer-permissions.test.ts index ccb119d0e5c..78c8d6e0be0 100644 --- a/src/main/ipc/developer-permissions.test.ts +++ b/src/main/ipc/developer-permissions.test.ts @@ -6,6 +6,7 @@ const { askForMediaAccessMock, getMediaAccessStatusMock, isTrustedAccessibilityClientMock, + getMacosFullDiskAccessStatusMock, execFileMock, createSocketMock, socketMock, @@ -28,6 +29,7 @@ const { askForMediaAccessMock: vi.fn(), getMediaAccessStatusMock: vi.fn(), isTrustedAccessibilityClientMock: vi.fn(), + getMacosFullDiskAccessStatusMock: vi.fn(), execFileMock: vi.fn(), createSocketMock: vi.fn(() => socketMock), socketMock, @@ -59,6 +61,10 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) +vi.mock('../macos-full-disk-access-status', () => ({ + getMacosFullDiskAccessStatus: getMacosFullDiskAccessStatusMock +})) + import { registerDeveloperPermissionHandlers } from './developer-permissions' describe('registerDeveloperPermissionHandlers', () => { @@ -72,6 +78,8 @@ describe('registerDeveloperPermissionHandlers', () => { askForMediaAccessMock.mockReset() getMediaAccessStatusMock.mockReset() isTrustedAccessibilityClientMock.mockReset() + getMacosFullDiskAccessStatusMock.mockReset() + getMacosFullDiskAccessStatusMock.mockResolvedValue('denied') execFileMock.mockReset() execFileMock.mockImplementation((...args: unknown[]) => { const callback = args.at(-1) @@ -119,6 +127,21 @@ describe('registerDeveloperPermissionHandlers', () => { return call[1] as (_event: unknown, args: { id: string }) => Promise } + it('returns the Full Disk Access read-probe status', async () => { + getMacosFullDiskAccessStatusMock.mockResolvedValue('granted') + registerDeveloperPermissionHandlers() + + const call = handleMock.mock.calls.find( + (registration: unknown[]) => registration[0] === 'developerPermissions:getStatus' + ) + const handler = call?.[1] as (() => Promise) | undefined + + await expect(handler?.()).resolves.toContainEqual({ + id: 'full-disk-access', + status: 'granted' + }) + }) + it('clears the local-network prompt fallback timer when UDP send settles first', async () => { registerDeveloperPermissionHandlers() diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts index d0d079bd093..dc1171b8c7d 100644 --- a/src/main/ipc/developer-permissions.ts +++ b/src/main/ipc/developer-permissions.ts @@ -1,9 +1,7 @@ import { execFile } from 'node:child_process' import dgram from 'node:dgram' -import { access } from 'node:fs/promises' -import { homedir } from 'node:os' -import path from 'node:path' import { ipcMain, shell, systemPreferences } from 'electron' +import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status' import type { DeveloperPermissionId, DeveloperPermissionRequestResult, @@ -50,21 +48,6 @@ function getMediaStatus(mediaType: 'microphone' | 'camera' | 'screen'): Develope } } -async function getFullDiskAccessStatus(): Promise { - const unsupported = unsupportedOffMac() - if (unsupported) { - return unsupported - } - try { - // Why: Safari bookmarks are TCC-protected, so read access is a practical - // Full Disk Access signal without touching user project contents. - await access(path.join(homedir(), 'Library', 'Safari', 'Bookmarks.plist')) - return 'granted' - } catch { - return 'unknown' - } -} - function getAccessibilityStatus(): DeveloperPermissionStatus { const unsupported = unsupportedOffMac() if (unsupported) { @@ -165,7 +148,7 @@ async function getPermissionState(id: DeveloperPermissionId): Promise { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) +}) + +describe('probeMacosFullDiskAccess', () => { + it('reports granted only when the TCC database opens for reading', async () => { + const readProbe = vi.fn().mockResolvedValue(undefined) + + await expect(probeMacosFullDiskAccess({ homeDirectory, readProbe })).resolves.toBe('granted') + expect(readProbe).toHaveBeenCalledWith(databasePath) + }) + + it.each(['EACCES', 'EPERM'])('reports denied for %s', async (code) => { + await expect( + probeMacosFullDiskAccess({ + homeDirectory, + readProbe: async () => { + throw fileSystemError(code) + } + }) + ).resolves.toBe('denied') + }) + + it.each(['ENOENT', 'ENOTDIR', 'EBUSY'])('keeps %s failures unknown', async (code) => { + await expect( + probeMacosFullDiskAccess({ + homeDirectory, + readProbe: async () => { + throw fileSystemError(code) + } + }) + ).resolves.toBe('unknown') + }) +}) + +describe('getMacosFullDiskAccessStatus', () => { + it('is unsupported off macOS', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + + await expect(getMacosFullDiskAccessStatus()).resolves.toBe('unsupported') + }) +}) diff --git a/src/main/macos-full-disk-access-status.ts b/src/main/macos-full-disk-access-status.ts new file mode 100644 index 00000000000..aea18b95677 --- /dev/null +++ b/src/main/macos-full-disk-access-status.ts @@ -0,0 +1,44 @@ +import { open } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { DeveloperPermissionStatus } from '../shared/developer-permissions-types' + +type ReadProbe = (filePath: string) => Promise + +async function openForRead(filePath: string): Promise { + const handle = await open(filePath, 'r') + await handle.close() +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined +} + +export async function probeMacosFullDiskAccess({ + homeDirectory = homedir(), + readProbe = openForRead +}: { + homeDirectory?: string + readProbe?: ReadProbe +} = {}): Promise { + const databasePath = join( + homeDirectory, + 'Library', + 'Application Support', + 'com.apple.TCC', + 'TCC.db' + ) + try { + await readProbe(databasePath) + return 'granted' + } catch (error) { + const code = errorCode(error) + return code === 'EACCES' || code === 'EPERM' ? 'denied' : 'unknown' + } +} + +export async function getMacosFullDiskAccessStatus(): Promise { + return process.platform === 'darwin' ? probeMacosFullDiskAccess() : 'unsupported' +} diff --git a/src/main/macos-tcc-prompt-notice.test.ts b/src/main/macos-tcc-prompt-notice.test.ts index 1c268a7e240..e8745b67c61 100644 --- a/src/main/macos-tcc-prompt-notice.test.ts +++ b/src/main/macos-tcc-prompt-notice.test.ts @@ -30,6 +30,7 @@ vi.mock('node:fs', async (importOriginal) => ({ const { TCC_PROMPT_NOTICE_THRESHOLD, + TCC_PROMPT_NOTICE_VERSION, TCC_PROMPT_WATCH_START_FALLBACK_MS, acknowledgePendingTccPromptNotice, consumePendingTccPromptNotice, @@ -67,7 +68,11 @@ describe('tcc prompt notice threshold', () => { handleTccPromptForTests() expect(writeFileAtomically).toHaveBeenCalledTimes(1) const [, contents] = writeFileAtomically.mock.calls[0] as [string, string] - expect(JSON.parse(contents)).toMatchObject({ promptCount: 1, notified: false }) + expect(JSON.parse(contents)).toMatchObject({ + noticeVersion: TCC_PROMPT_NOTICE_VERSION, + promptCount: 1, + notified: false + }) }) it('never fires again once dismissed, even past the threshold', () => { @@ -303,9 +308,9 @@ describe('tcc prompt notice threshold', () => { }) it.each([ - { promptCount: 2, notified: false }, - { promptCount: 1, notified: true } - ])('requires a fresh detection for a legacy tally: $promptCount/$notified', (persisted) => { + { promptCount: 2, notified: false, acknowledgedAfterClose: false }, + { promptCount: 1, notified: true, acknowledgedAfterClose: true } + ])('re-arms a legacy tally after a fresh detection: $promptCount/$notified', (persisted) => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) readTallyFile.mockReturnValue(JSON.stringify({ ...persisted, dismissed: false })) @@ -329,11 +334,35 @@ describe('tcc prompt notice threshold', () => { } }) + it('preserves a legacy permanent opt-out', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readTallyFile.mockReturnValue( + JSON.stringify({ + promptCount: 1, + notified: true, + dismissed: true, + acknowledgedAfterClose: true + }) + ) + try { + const mainWindow = createWindowStub() + initTccPromptNotice(mainWindow as never) + + expect(watchStart).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + expect(consumePendingTccPromptNotice(1)).toBeNull() + } finally { + Object.defineProperty(process, 'platform', platform!) + } + }) + it('replays an unclosed notice from the new delivery contract', () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) readTallyFile.mockReturnValue( JSON.stringify({ + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: 1, notified: false, dismissed: false, @@ -359,6 +388,7 @@ describe('tcc prompt notice threshold', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) readTallyFile.mockReturnValue( JSON.stringify({ + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: 1, notified: true, dismissed: false, diff --git a/src/main/macos-tcc-prompt-notice.ts b/src/main/macos-tcc-prompt-notice.ts index 9d7dea359d0..c51367d8c6a 100644 --- a/src/main/macos-tcc-prompt-notice.ts +++ b/src/main/macos-tcc-prompt-notice.ts @@ -14,6 +14,9 @@ import { MacosTccPromptWatch } from './macos-tcc-prompt-watch' /** Why: the first detected dialog identifies an affected user; the notice remains one-time. */ export const TCC_PROMPT_NOTICE_THRESHOLD = 1 +/** Why: re-arm users who may have trusted the former false-positive Full Disk Access badge. */ +export const TCC_PROMPT_NOTICE_VERSION = 2 + /** Why: preserve detection when Electron never emits `ready-to-show` without competing with startup. */ export const TCC_PROMPT_WATCH_START_FALLBACK_MS = 10_000 @@ -28,6 +31,7 @@ export type TccPromptNoticeClaim = TccPromptNoticePayload & { } type TccPromptTally = { + noticeVersion: number promptCount: number notified: boolean dismissed: boolean @@ -35,6 +39,7 @@ type TccPromptTally = { } const EMPTY_TALLY: TccPromptTally = { + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: 0, notified: false, dismissed: false, @@ -57,15 +62,28 @@ function loadTally(): TccPromptTally { try { const parsed = JSON.parse(readFileSync(tallyPath(), 'utf-8')) as Partial const dismissed = parsed.dismissed === true - if (!dismissed && typeof parsed.acknowledgedAfterClose !== 'boolean') { - // Why: a legacy tally only proves a past prompt, not that Full Disk Access is still missing. + if (dismissed) { + return { + noticeVersion: TCC_PROMPT_NOTICE_VERSION, + promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0, + notified: true, + dismissed: true, + acknowledgedAfterClose: true + } + } + if (parsed.noticeVersion !== TCC_PROMPT_NOTICE_VERSION) { + return { ...EMPTY_TALLY } + } + if (typeof parsed.acknowledgedAfterClose !== 'boolean') { + // Why: an incomplete tally proves only a past prompt, not that access is still missing. return { ...EMPTY_TALLY } } const acknowledgedAfterClose = parsed.acknowledgedAfterClose === true return { + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0, - notified: parsed.notified === true && (dismissed || acknowledgedAfterClose), - dismissed, + notified: parsed.notified === true && acknowledgedAfterClose, + dismissed: false, acknowledgedAfterClose } } catch { From f23b3308a5eb23855b4d7f4e676e1405568b4a99 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:54:40 -0700 Subject: [PATCH 08/23] fix(mobile): route external mouse click and drag to the terminal (#11473) * fix(mobile): route external mouse click and drag to the terminal The terminal WebView suppresses mousedown/click at capture so xterm's own mouse handling stays inert (its onData bytes are dropped by the mobile bridge). That left hardware mouse clicks and drags with no path at all: touch taps reached mouse-aware TUIs and drove selection, while a Bluetooth mouse or trackpad click did nothing (#8818; wheel half landed in #11247). Add a pointer-event router on the terminal surface (pointerType 'mouse', left button only) that mirrors touch semantics: - plain click: same pipeline as a touch tap (links/file paths first, then tracking-mode press+release reports, else keyboard focus), and a click on an active selection dismisses it like touch does - drag with mouse tracking: press at the anchor, per-cell motion reports (drag/any modes), release on pointerup or pointercancel - drag without tracking: character-anchored selection reusing the touch handle-drag plumbing (edge scroll, handles, copy pill) Widen the RN gesture-input grammar to pass left-drag motion reports (SGR button 32, default-encoding byte 64) through the existing validation and rate limiting. Mock server: echo the subscribe viewport and serialize scrollback so the session screen leaves the resubscribe loop, serve the session-tabs subscribe stream, and add a MOCK_TUI=1 mouse-tracking scenario plus a [SEND] byte log - the rig used to reproduce and verify this fix on an Android emulator. Fixes #8818 * fix(mobile): capture the mouse pointer and clear stale gestures on pointerdown A drag leaving the terminal surface dropped pointermove/pointerup without pointer capture, stranding the gesture; a pointerup lost outside the WebView could leave a tracked press latched until the next gesture. * fix(mobile): end mouse gestures whose pointerup never reached the surface Capture the mouse pointer on pointerdown so a drag that leaves the surface keeps delivering pointermove/pointerup; when capture is unavailable and the release is lost anyway, synthesize the release from the next buttons==0 pointermove or the next pointerdown, so a tracking TUI is never left with the left button latched down. * fix(mock-server): clear the terminal stream interval on resubscribe and unsubscribe * fix(mobile): synthesize the lost-pointerup release at the pointer's current cell * test(mobile): split terminal mouse click and drag coverage * test(mobile): satisfy changed-line quality checks * fix(mobile): cancel stale mock terminal callbacks * refactor(mobile): extract mouse report cell mapping --- mobile/scripts/mock-server-rpc-handlers.ts | 60 +---- .../mock-server-session-tabs-fixture.ts | 24 +- mobile/scripts/mock-server-terminal-stream.ts | 132 ++++++++++ ...erver-terminal-stream-cancellation.test.ts | 74 ++++++ .../terminal/terminal-gesture-input.test.ts | 16 ++ mobile/src/terminal/terminal-gesture-input.ts | 6 +- mobile/src/terminal/terminal-webview-html.ts | 33 +-- ...minal-webview-mouse-click-drag-injected.ts | 199 ++++++++++++++ .../terminal-webview-mouse-click.test.ts | 90 +++++++ .../terminal-webview-mouse-drag.test.ts | 87 ++++++ ...inal-webview-mouse-report-cell-injected.ts | 29 ++ .../terminal-webview-mouse-test-harness.ts | 248 ++++++++++++++++++ 12 files changed, 919 insertions(+), 79 deletions(-) create mode 100644 mobile/scripts/mock-server-terminal-stream.ts create mode 100644 mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-click.test.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-drag.test.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-test-harness.ts diff --git a/mobile/scripts/mock-server-rpc-handlers.ts b/mobile/scripts/mock-server-rpc-handlers.ts index c5aebf054b4..5971b9dee94 100644 --- a/mobile/scripts/mock-server-rpc-handlers.ts +++ b/mobile/scripts/mock-server-rpc-handlers.ts @@ -13,11 +13,7 @@ import { handleMockGitRequest } from './mock-server-git-state' import { handleMockAccountRequest } from './mock-server-account-rpc' import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario' import { handleMockSessionTabsRequest } from './mock-server-session-tabs-fixture' -import { - createMockTerminals, - FAKE_SCROLLBACK, - STREAMING_CHUNKS -} from './mock-server-terminal-fixtures' +import { handleMockTerminalRequest } from './mock-server-terminal-stream' import { createMockRepos, createMockWorktrees, readScenarioNumber } from './mobile-lag-scenario' const MOCK_REPO_COUNT = readScenarioNumber('MOCK_REPO_COUNT', 2) @@ -64,6 +60,8 @@ export type RpcResponse = { _meta: { runtimeId: string } } +export type RpcRespond = (response: RpcResponse, shouldSend?: () => boolean) => void + export const mockScenarioSummary = { repoCount: FAKE_REPOS.length, worktreeCount: fakeWorktrees.length, @@ -111,13 +109,18 @@ export function handleRequest( send: (response: RpcResponse) => void, ws: WebSocket ): void { - const respond = (response: RpcResponse) => { + const respond: RpcRespond = (response, shouldSend) => { + const deliver = () => { + if (shouldSend?.() !== false) { + send(response) + } + } const delay = responseDelayFor(request.method) if (delay > 0) { - setTimeout(() => send(response), delay) + setTimeout(deliver, delay) return } - send(response) + deliver() } // Each returns false for methods it does not own; first owner wins. @@ -126,7 +129,8 @@ export function handleRequest( handleMockFilePreviewRequest(request, respond, success, error) || handleMockAccountRequest(request, respond, success, error) || handleMockNativeChatRequest(request, respond, success, error, ws) || - handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId) + handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId) || + handleMockTerminalRequest(request, respond, success, ws, terminalListWorktreeId) ) { return } @@ -279,44 +283,6 @@ export function handleRequest( break } - case 'terminal.list': { - const terminals = createMockTerminals(terminalListWorktreeId(request.params?.worktree)) - respond( - success(request.id, { - terminals, - totalCount: terminals.length, - truncated: false - }) - ) - break - } - - case 'terminal.subscribe': { - respond(success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false })) - - let chunkIndex = 0 - const interval = setInterval(() => { - if (chunkIndex >= STREAMING_CHUNKS.length || ws.readyState !== ws.OPEN) { - clearInterval(interval) - if (ws.readyState === ws.OPEN) { - respond(success(request.id, { type: 'end' })) - } - return - } - respond(success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true)) - chunkIndex++ - }, 500) - break - } - - case 'terminal.send': - respond(success(request.id, { send: { handle: 'term-1', ok: true } })) - break - - case 'terminal.unsubscribe': - respond(success(request.id, { unsubscribed: true })) - break - case 'files.open': case 'files.openDiff': respond( diff --git a/mobile/scripts/mock-server-session-tabs-fixture.ts b/mobile/scripts/mock-server-session-tabs-fixture.ts index fbc94c2829e..f4a5bd1da1c 100644 --- a/mobile/scripts/mock-server-session-tabs-fixture.ts +++ b/mobile/scripts/mock-server-session-tabs-fixture.ts @@ -60,10 +60,24 @@ export function handleMockSessionTabsRequest( // absent or `id:`-prefixed selector means. resolveWorktreeId: (selector: unknown) => string | undefined ): boolean { - if (request.method !== 'session.tabs.list') { - return false + if (request.method === 'session.tabs.list') { + const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock' + respond(success(request.id, createMockSessionTabs(worktreeId))) + return true } - const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock' - respond(success(request.id, createMockSessionTabs(worktreeId))) - return true + if (request.method === 'session.tabs.subscribe') { + // Without a live stream the client's health loop keeps invalidating list + // fetches mid-flight (barrier bump on the failed probe), so the session + // screen never leaves 'Loading tabs'. snapshot then updated => 'live'. + const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock' + const snapshot = createMockSessionTabs(worktreeId) + respond(success(request.id, { type: 'snapshot', ...snapshot }, true)) + respond(success(request.id, { type: 'updated', ...snapshot }, true)) + return true + } + if (request.method === 'session.tabs.unsubscribe') { + respond(success(request.id, { unsubscribed: true })) + return true + } + return false } diff --git a/mobile/scripts/mock-server-terminal-stream.ts b/mobile/scripts/mock-server-terminal-stream.ts new file mode 100644 index 00000000000..f747ec59b21 --- /dev/null +++ b/mobile/scripts/mock-server-terminal-stream.ts @@ -0,0 +1,132 @@ +import type { WebSocket } from 'ws' +import type { RpcRequest, RpcRespond, RpcResponse } from './mock-server-rpc-handlers' +import { + createMockTerminals, + FAKE_SCROLLBACK, + STREAMING_CHUNKS +} from './mock-server-terminal-fixtures' + +// Why: the client resubscribes on every viewport change; without cancellation +// each resubscribe would stack another interval streaming under a dead request. +type TerminalStream = { interval: ReturnType | null } +const terminalStreams = new WeakMap>() + +function clearTerminalStream(ws: WebSocket, terminal: string): void { + const perTerminal = terminalStreams.get(ws) + const stream = perTerminal?.get(terminal) + if (stream) { + stopTerminalStreamInterval(stream) + perTerminal?.delete(terminal) + } +} + +function beginTerminalStream(ws: WebSocket, terminal: string): TerminalStream { + clearTerminalStream(ws, terminal) + let perTerminal = terminalStreams.get(ws) + if (!perTerminal) { + perTerminal = new Map() + terminalStreams.set(ws, perTerminal) + } + const stream = { interval: null } + perTerminal.set(terminal, stream) + return stream +} + +function isCurrentTerminalStream(ws: WebSocket, terminal: string, stream: TerminalStream): boolean { + return terminalStreams.get(ws)?.get(terminal) === stream && ws.readyState === ws.OPEN +} + +function stopTerminalStreamInterval(stream: TerminalStream): void { + if (stream.interval !== null) { + clearInterval(stream.interval) + stream.interval = null + } +} + +/** Terminal list/stream/input backend for the mock server. Returns false for + * methods it does not own. */ +export function handleMockTerminalRequest( + request: RpcRequest, + respond: RpcRespond, + success: (id: string, result: unknown, streaming?: boolean) => RpcResponse, + ws: WebSocket, + // Shared with `session.tabs.list` so both surfaces agree on which worktree an + // absent or `id:`-prefixed selector means. + resolveWorktreeId: (selector: unknown) => string | undefined +): boolean { + switch (request.method) { + case 'terminal.list': { + const terminals = createMockTerminals(resolveWorktreeId(request.params?.worktree)) + respond( + success(request.id, { + terminals, + totalCount: terminals.length, + truncated: false + }) + ) + return true + } + + case 'terminal.subscribe': { + const terminal = String(request.params?.terminal ?? 'term-1') + const stream = beginTerminalStream(ws, terminal) + const isCurrent = () => isCurrentTerminalStream(ws, terminal, stream) + // Why: the client resubscribes until scrollback echoes its viewport dims; + // the legacy `lines` shape left the session screen in that loop forever. + const viewport = request.params?.viewport as { cols?: number; rows?: number } | undefined + // MOCK_TUI=1 arms SGR drag mouse tracking (1002/1006) inside the scrollback + // itself so every xterm re-init re-enters the mode - used by mouse/touch + // input repros (#8818). + const tuiPreamble = + process.env.MOCK_TUI === '1' + ? '\x1b[?1002h\x1b[?1006h[mock] mouse tracking ON (1002/1006)\r\n' + : '' + respond( + success(request.id, { + type: 'scrollback', + cols: viewport?.cols ?? 80, + rows: viewport?.rows ?? 24, + serialized: FAKE_SCROLLBACK.replace(/\n/g, '\r\n') + tuiPreamble, + truncated: false + }), + isCurrent + ) + + let chunkIndex = 0 + stream.interval = setInterval(() => { + if (!isCurrent()) { + stopTerminalStreamInterval(stream) + return + } + if (chunkIndex >= STREAMING_CHUNKS.length) { + // Why: no `end` event - a live terminal stream stays open, and `end` + // makes the client tear the subscription down and blank the pane. + stopTerminalStreamInterval(stream) + return + } + respond( + success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true), + isCurrent + ) + chunkIndex++ + }, 500) + return true + } + + case 'terminal.send': + // Input-routing repros (#8818) assert on the exact bytes reaching the host. + console.log( + `[SEND] terminal=${String(request.params?.terminal)} text=${JSON.stringify(request.params?.text)}` + ) + respond(success(request.id, { send: { handle: 'term-1', ok: true } })) + return true + + case 'terminal.unsubscribe': + clearTerminalStream(ws, String(request.params?.terminal ?? 'term-1')) + respond(success(request.id, { unsubscribed: true })) + return true + + default: + return false + } +} diff --git a/mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts b/mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts new file mode 100644 index 00000000000..66e4900fe7c --- /dev/null +++ b/mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WebSocket } from 'ws' +import { handleMockTerminalRequest } from '../../scripts/mock-server-terminal-stream' +import { + handleRequest, + type RpcRequest, + type RpcRespond, + type RpcResponse +} from '../../scripts/mock-server-rpc-handlers' + +type DeferredResponse = { + response: RpcResponse + shouldSend?: () => boolean +} + +function success(id: string, result: unknown, streaming?: boolean): RpcResponse { + return { + id, + ok: true, + result, + ...(streaming ? { streaming: true as const } : {}), + _meta: { runtimeId: 'test' } + } +} + +function request(id: string, method: string): RpcRequest { + return { id, method, params: { terminal: 'term-1', viewport: { cols: 80, rows: 24 } } } +} + +describe('mock terminal stream cancellation', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + }) + + it('invalidates delayed frames after resubscribe and unsubscribe', () => { + vi.useFakeTimers() + const ws = { OPEN: 1, readyState: 1 } as unknown as WebSocket + const deferred: DeferredResponse[] = [] + const respond: RpcRespond = (response, shouldSend) => { + deferred.push({ response, shouldSend }) + } + const handle = (rpcRequest: RpcRequest) => + handleMockTerminalRequest(rpcRequest, respond, success, ws, () => 'wt') + + expect(handle(request('old', 'terminal.subscribe'))).toBe(true) + vi.advanceTimersByTime(500) + expect(deferred.filter(({ response }) => response.id === 'old')).toHaveLength(2) + + expect(handle(request('new', 'terminal.subscribe'))).toBe(true) + const oldResponses = deferred.filter(({ response }) => response.id === 'old') + const newResponses = deferred.filter(({ response }) => response.id === 'new') + expect(oldResponses.every(({ shouldSend }) => shouldSend?.() === false)).toBe(true) + expect(newResponses.every(({ shouldSend }) => shouldSend?.() === true)).toBe(true) + + expect(handle(request('stop', 'terminal.unsubscribe'))).toBe(true) + expect(newResponses.every(({ shouldSend }) => shouldSend?.() === false)).toBe(true) + }) + + it('drops queued delayed frames after stream cancellation', () => { + vi.useFakeTimers() + vi.stubEnv('MOCK_RPC_DELAY_TERMINAL_SUBSCRIBE_MS', '1000') + const ws = { OPEN: 1, readyState: 1 } as unknown as WebSocket + const send = vi.fn<(response: RpcResponse) => void>() + + handleRequest(request('old', 'terminal.subscribe'), send, ws) + vi.advanceTimersByTime(500) + handleRequest(request('new', 'terminal.subscribe'), send, ws) + handleRequest(request('stop', 'terminal.unsubscribe'), send, ws) + + vi.advanceTimersByTime(1000) + expect(send.mock.calls.map(([response]) => response.id)).toEqual(['stop']) + }) +}) diff --git a/mobile/src/terminal/terminal-gesture-input.test.ts b/mobile/src/terminal/terminal-gesture-input.test.ts index a120d569db9..a920f37a7a3 100644 --- a/mobile/src/terminal/terminal-gesture-input.test.ts +++ b/mobile/src/terminal/terminal-gesture-input.test.ts @@ -27,6 +27,22 @@ describe('isTerminalGestureInput', () => { expect(isTerminalGestureInput(`${ESC}[<0;9999;9999M${ESC}[<0;9999;9999m`)).toBe(true) }) + it('accepts SGR left-drag motion sequences but rejects a motion release', () => { + expect(isTerminalGestureInput(`${ESC}[<32;10;5M${ESC}[<32;11;5M`)).toBe(true) + expect(countTerminalGestureInputSequences(`${ESC}[<32;10;5M${ESC}[<32;11;5M`)).toBe(2) + expect(isTerminalGestureInput(`${ESC}[<0;10;5M${ESC}[<32;11;5M${ESC}[<0;11;5m`)).toBe(true) + expect(isTerminalGestureInput(`${ESC}[<32;10;5m`)).toBe(false) + }) + + it('accepts default-encoding left-drag motion sequences', () => { + expect(isTerminalGestureInput(`${ESC}[M${String.fromCharCode(64, 43, 38)}`)).toBe(true) + expect( + isTerminalGestureInput( + `${ESC}[M${String.fromCharCode(32, 33, 33)}${ESC}[M${String.fromCharCode(64, 34, 33)}${ESC}[M${String.fromCharCode(35, 34, 33)}` + ) + ).toBe(true) + }) + it('accepts repeated default mouse wheel sequences', () => { expect( isTerminalGestureInput( diff --git a/mobile/src/terminal/terminal-gesture-input.ts b/mobile/src/terminal/terminal-gesture-input.ts index 6b84c4824fe..6af8a2367f9 100644 --- a/mobile/src/terminal/terminal-gesture-input.ts +++ b/mobile/src/terminal/terminal-gesture-input.ts @@ -1,8 +1,9 @@ const ESC = '\x1b' const MAX_TERMINAL_GESTURE_INPUT_LENGTH = 2048 const MAX_TERMINAL_GESTURE_INPUT_SEQUENCES = 32 +// Buttons: 0 left press/release, 32 left-drag motion, 64/65 wheel. const SGR_MOUSE_GESTURE_SEQUENCE_RE = new RegExp( - `^${ESC}\\[<(0|64|65);([0-9]{1,4});([0-9]{1,4})([Mm])$` + `^${ESC}\\[<(0|32|64|65);([0-9]{1,4});([0-9]{1,4})([Mm])$` ) function isDefaultMouseGestureSequence(bytes: string, offset: number): number | null { @@ -12,8 +13,9 @@ function isDefaultMouseGestureSequence(bytes: string, offset: number): number | const button = bytes.charCodeAt(offset + 3) const col = bytes.charCodeAt(offset + 4) const row = bytes.charCodeAt(offset + 5) + // Buttons: 32 left press, 35 release, 64 left-drag motion, 96/97 wheel. if ( - (button === 32 || button === 35 || button === 96 || button === 97) && + (button === 32 || button === 35 || button === 64 || button === 96 || button === 97) && col >= 33 && col <= 126 && row >= 33 && diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index e8409cb3bee..a7715e4f564 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -11,6 +11,8 @@ import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' +import { TERMINAL_MOUSE_CLICK_DRAG_JS } from './terminal-webview-mouse-click-drag-injected' +import { TERMINAL_MOUSE_REPORT_CELL_JS } from './terminal-webview-mouse-report-cell-injected' import { TERMINAL_WHEEL_SCROLL_JS } from './terminal-webview-wheel-scroll-injected' const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = { @@ -1153,31 +1155,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} return { col: col, row: viewportRow + viewportY }; } - function viewportToMouseReportCell(clientX, clientY) { - if (!term) return null; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - if (cellW <= 0 || cellH <= 0) return null; - if (typeof clientX !== 'number') clientX = window.innerWidth / 2; - if (typeof clientY !== 'number') clientY = window.innerHeight / 2; - var total = getTotalScale(); - if (total <= 0) total = 1; - var sx = (clientX - panX) / total; - var sy = (clientY - panY) / total; - var maxX = Math.max(0, term.cols * cellW - 1); - var maxY = Math.max(0, term.rows * cellH - 1); - if (sx < 0) sx = 0; - if (sx > maxX) sx = maxX; - if (sy < 0) sy = 0; - if (sy > maxY) sy = maxY; - var col = Math.floor(sx / cellW); - var row = Math.floor(sy / cellH); - if (col < 0) col = 0; - if (col > term.cols - 1) col = term.cols - 1; - if (row < 0) row = 0; - if (row > term.rows - 1) row = term.rows - 1; - return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }; - } + ${TERMINAL_MOUSE_REPORT_CELL_JS} function isAlternateBufferActive() { try { @@ -1648,6 +1626,10 @@ ${TERMINAL_WEBGL_RECOVERY_JS} // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines). ${TERMINAL_WHEEL_SCROLL_JS} + // External mouse click/drag: see + // terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines). + ${TERMINAL_MOUSE_CLICK_DRAG_JS} + btnCopy.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); @@ -1705,6 +1687,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true); attachSurfaceWheelHandler(targetSurface); + attachSurfaceMouseClickDragHandler(targetSurface); targetSurface.addEventListener('touchstart', function(e) { if (dispatcherShouldBlockSurface()) return; diff --git a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts new file mode 100644 index 00000000000..bc9c12aa016 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts @@ -0,0 +1,199 @@ +// Indirect-pointer (external mouse / trackpad) click and drag for the terminal +// surface, injected into XTERM_HTML. Extracted from terminal-webview-html.ts to +// keep that file within its max-lines budget. Companion to +// terminal-webview-wheel-scroll-injected.ts, which owns the wheel half (#11247); +// this owns the click/drag half of #8818. Closes over host-IIFE state/functions: +// term, ESC, sel, selMode, selectionOverlay, TAP_SLOP, getMouseTrackingMode, +// viewportToCell, viewportToMouseReportCell, isSafeSgrMouseCoordinate, +// sgrMouseMode, sgrMousePixelsMode, notify, notifyTerminalSurfaceTap, +// cancelSelect, applyXtermSelection, repositionOverlay, handleDragMove, +// stopEdgeScroll, and dispatcherShouldBlockSurface. +// +// Why pointer events: a hardware mouse on Android/iPadOS raises pointer events +// with pointerType 'mouse' and NO touch events, while a finger raises +// pointerType 'touch' plus the touch events the document dispatcher owns. The +// capture-phase mousedown/click suppression in attachSurfaceEventHandlers stays: +// it is what keeps xterm's own mouse handling inert (its onData output is +// dropped by the mobile bridge), and pointer events are unaffected by it. +export const TERMINAL_MOUSE_CLICK_DRAG_JS = ` + var mouseGesture = null; + + // One report per transition, built with the same encoding ladder as + // buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns '' + // when the mode does not report this transition (x10 has no release, only + // drag/any report motion) or the cell is not encodable. + function buildMouseButtonReport(kind, clientX, clientY) { + var mouseTrackingMode = getMouseTrackingMode(); + if (mouseTrackingMode === 'none') return ''; + if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') return ''; + if (kind === 'release' && mouseTrackingMode === 'x10') return ''; + var cell = viewportToMouseReportCell(clientX, clientY); + if (!cell) return ''; + var sgrButton = kind === 'motion' ? 32 : 0; + var sgrFinal = kind === 'release' ? 'm' : 'M'; + if (sgrMousePixelsMode) { + if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; + return ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal; + } + if (sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + return ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal; + } + var button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32; + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + // Why: non-SGR mouse bytes above ASCII are not preserved reliably through + // the mobile JSON/RPC string path; drop instead of corrupting input. + if (col > 126 || row > 126) return ''; + return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function mouseReportCellKey(clientX, clientY) { + var cell = viewportToMouseReportCell(clientX, clientY); + return cell ? cell.col + ',' + cell.row : null; + } + + function abandonMouseGesture() { + var gesture = mouseGesture; + mouseGesture = null; + if (!gesture) return; + if (gesture.mode === 'tracking') { + // Why: the press report already went to the TUI; a lost pointer must not + // leave the button latched down on the far side. + var release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY); + if (release) notify({ type: 'terminal-input', bytes: release }); + } else if (gesture.mode === 'selecting') { + if (sel) sel.activeHandle = null; + stopEdgeScroll(); + } + } + + function beginMouseDrag(gesture) { + gesture.moved = true; + if (getMouseTrackingMode() !== 'none') { + gesture.mode = 'tracking'; + gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY); + var press = buildMouseButtonReport('press', gesture.startX, gesture.startY); + if (press) notify({ type: 'terminal-input', bytes: press }); + return; + } + var anchor = viewportToCell(gesture.startX, gesture.startY); + if (!anchor) { + gesture.mode = 'cancelled'; + return; + } + // Why: mouse drags select character-anchored ranges like desktop terminals, + // not the word-seeded long-press selection; reuse the touch handle-drag + // plumbing (edge scroll included) by acting as a live 'end' handle. + gesture.mode = 'selecting'; + selMode = 'select'; + sel = { anchor: anchor, focus: anchor, activeHandle: 'end' }; + selectionOverlay.classList.add('active'); + notify({ type: 'set-select-mode', enabled: true }); + applyXtermSelection(); + repositionOverlay(); + } + + function attachSurfaceMouseClickDragHandler(targetSurface) { + targetSurface.addEventListener('pointerdown', function(e) { + if (e.pointerType !== 'mouse' || e.button !== 0) return; + if (dispatcherShouldBlockSurface() || !term) return; + // Why: a pointerup lost outside the WebView must not leave the previous + // gesture latched (tracking press with no release) when the next one lands. + if (mouseGesture) abandonMouseGesture(); + // Why: mouse pointers have no implicit capture; without it a drag that + // leaves the surface drops pointermove/pointerup and strands the gesture. + try { + if (targetSurface.setPointerCapture) targetSurface.setPointerCapture(e.pointerId); + } catch (err) {} + mouseGesture = { + startX: e.clientX, startY: e.clientY, + lastX: e.clientX, lastY: e.clientY, + lastCellKey: null, + moved: false, + mode: 'pending', + dismissedSelection: false + }; + if (selMode === 'select') { + // Why: touch parity — pressing outside the pill dismisses the current + // selection; the same press may still start a new drag selection. + cancelSelect(); + mouseGesture.dismissedSelection = true; + } + }, true); + + targetSurface.addEventListener('pointermove', function(e) { + var gesture = mouseGesture; + if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return; + if (!term) return; + gesture.lastX = e.clientX; + gesture.lastY = e.clientY; + if ((e.buttons & 1) === 0) { + // Why: a pointerup lost outside the WebView (capture unavailable) must + // end the gesture here, or a tracked press stays latched at the TUI. + // Coordinates first, so the synthesized release lands where the + // pointer re-entered rather than at the previous cell. + abandonMouseGesture(); + return; + } + if (!gesture.moved) { + var dx = Math.abs(e.clientX - gesture.startX); + var dy = Math.abs(e.clientY - gesture.startY); + if (dx + dy <= TAP_SLOP) return; + beginMouseDrag(gesture); + } + if (gesture.mode === 'tracking') { + // Why: one motion report per cell keeps drags bounded by grid size, not + // by pointermove cadence, so the RN rate limiter is never the bottleneck. + var cellKey = mouseReportCellKey(e.clientX, e.clientY); + if (cellKey && cellKey !== gesture.lastCellKey) { + gesture.lastCellKey = cellKey; + var motion = buildMouseButtonReport('motion', e.clientX, e.clientY); + if (motion) notify({ type: 'terminal-input', bytes: motion }); + } + } else if (gesture.mode === 'selecting') { + handleDragMove('end', e.clientX, e.clientY); + } + }, true); + + targetSurface.addEventListener('pointerup', function(e) { + var gesture = mouseGesture; + if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) return; + mouseGesture = null; + if (gesture.mode === 'cancelled' || !term) return; + if (gesture.mode === 'tracking') { + var release = buildMouseButtonReport('release', e.clientX, e.clientY); + if (release) notify({ type: 'terminal-input', bytes: release }); + return; + } + if (gesture.mode === 'selecting') { + if (sel) sel.activeHandle = null; + stopEdgeScroll(); + repositionOverlay(); + return; + } + if (dispatcherShouldBlockSurface()) return; + // Why: a dismissing tap only clears the selection (touch parity); it must + // not also open a link or focus the keyboard underneath. + if (gesture.dismissedSelection) return; + // Plain click == touch tap: links and file paths win, then tracking-mode + // click reports, then keyboard focus. + notifyTerminalSurfaceTap(e.clientX, e.clientY); + }, true); + + targetSurface.addEventListener('pointercancel', function(e) { + if (e.pointerType !== 'mouse') return; + abandonMouseGesture(); + }, true); + + // Why: Android input injection can pair a mouse-flavored pointerdown with + // real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives, + // the document touch dispatcher owns the gesture. + targetSurface.addEventListener('touchstart', function() { + if (mouseGesture) abandonMouseGesture(); + }, true); + } +` diff --git a/mobile/src/terminal/terminal-webview-mouse-click.test.ts b/mobile/src/terminal/terminal-webview-mouse-click.test.ts new file mode 100644 index 00000000000..bb2494b62e7 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-click.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { ESC, useTerminalMouseWebViewHarness } from './terminal-webview-mouse-test-harness' + +describe('terminal WebView external mouse click', () => { + const mouse = useTerminalMouseWebViewHarness() + + it('reports a mouse click to a click-tracking TUI the way a touch tap does (#8818)', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' + + mouse.mouseClick(40, 60) + + // Default (non-SGR) encoding: press (32=' ') then release (35='#'), each + // ESC [ M btn col row. Cell bytes depend on the fit scale, not on routing. + const bytes = mouse.terminalInputBytes() + expect(bytes).toHaveLength(12) + expect(bytes.slice(0, 4)).toBe(`${ESC}[M `) + expect(bytes.slice(6, 10)).toBe(`${ESC}[M#`) + expect(bytes.slice(4, 6)).toBe(bytes.slice(10, 12)) + }) + + it('dismisses an existing selection with a click without focusing the keyboard', () => { + mouse.boot() + mouse.mouseDrag(40, 60, 160, 90) + mouse.clearPostedMessages() + + mouse.mouseClick(240, 200) + + const messages = mouse.postedMessages() + expect(messages.filter((message) => message.type === 'set-select-mode')).toEqual([ + { type: 'set-select-mode', enabled: false } + ]) + expect(messages.filter((message) => message.type === 'terminal-tap')).toEqual([]) + expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(false) + }) + + it('routes a plain mouse click to the tap pipeline for keyboard focus', () => { + mouse.boot() + + mouse.mouseClick(40, 60) + + const messages = mouse.postedMessages() + expect(messages.filter((message) => message.type === 'terminal-tap')).toHaveLength(1) + expect(mouse.terminalInputBytes()).toBe('') + }) + + it('ignores non-mouse pointers and non-left buttons', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' + + mouse.dispatchPointer('pointerdown', { + pointerType: 'touch', + x: 40, + y: 60, + button: 0, + buttons: 1 + }) + mouse.dispatchPointer('pointerup', { + pointerType: 'touch', + x: 40, + y: 60, + button: 0, + buttons: 0 + }) + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 2, buttons: 2 }) + mouse.dispatchPointer('pointerup', { x: 40, y: 60, button: 2, buttons: 0 }) + + expect(mouse.terminalInputBytes()).toBe('') + expect(mouse.postedMessages().filter((message) => message.type === 'terminal-tap')).toEqual([]) + }) + + it('lets the touch dispatcher own a gesture when touch events follow pointerdown', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' + + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) + const touchStart = new Event('touchstart', { bubbles: true }) + // Why: the document tap dispatcher reads touches[0]; happy-dom's plain Event lacks it. + Object.defineProperty(touchStart, 'touches', { + value: [{ identifier: 7, clientX: 40, clientY: 60 }] + }) + mouse.terminalSurface().dispatchEvent(touchStart) + mouse.dispatchPointer('pointerup', { x: 40, y: 60, button: 0, buttons: 0 }) + + // Why: Android SOURCE_MOUSE injections can pair a mouse pointerdown with + // real touch events; double-handling would double the click report. + expect(mouse.terminalInputBytes()).toBe('') + }) +}) diff --git a/mobile/src/terminal/terminal-webview-mouse-drag.test.ts b/mobile/src/terminal/terminal-webview-mouse-drag.test.ts new file mode 100644 index 00000000000..e4fac512873 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-drag.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { + DEFAULT_MOUSE_REPORT_RE, + ESC, + useTerminalMouseWebViewHarness +} from './terminal-webview-mouse-test-harness' + +describe('terminal WebView external mouse drag', () => { + const mouse = useTerminalMouseWebViewHarness() + + it('sends press, per-cell motion, and release for a drag-tracking mouse drag', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'drag' + + mouse.mouseDrag(40, 60, 160, 60) + + const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? [] + expect(reports.length).toBeGreaterThanOrEqual(3) + expect(reports[0]?.charCodeAt(3)).toBe(32) + for (const motion of reports.slice(1, -1)) { + expect(motion.charCodeAt(3)).toBe(64) + } + expect(reports.at(-1)?.charCodeAt(3)).toBe(35) + // Motion reports are deduped per cell, so a horizontal drag advances columns. + const motionCols = reports.slice(1, -1).map((report) => report.charCodeAt(4)) + expect(new Set(motionCols).size).toBe(motionCols.length) + }) + + it('does not report motion or release for an x10 click-only TUI drag', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'x10' + + mouse.mouseDrag(40, 60, 160, 60) + + // x10 reports presses only; the press goes out at drag start, no motion or release follows. + const bytes = mouse.terminalInputBytes() + expect(bytes.slice(0, 4)).toBe(`${ESC}[M `) + expect(bytes).toHaveLength(6) + }) + + it('selects character-anchored text on a mouse drag outside tracking mode', () => { + mouse.boot() + // Why: init itself emits a set-select-mode reset; only the drag matters here. + mouse.clearPostedMessages() + + mouse.mouseDrag(40, 60, 160, 90) + + expect(mouse.terminalInputBytes()).toBe('') + expect(mouse.selectionSpy()).toHaveBeenCalled() + expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(true) + const modes = mouse.postedMessages().filter((message) => message.type === 'set-select-mode') + expect(modes).toEqual([{ type: 'set-select-mode', enabled: true }]) + }) + + it('releases a tracked drag when the button state shows the pointerup was lost', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'drag' + + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) + mouse.dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 }) + // Pointer re-enters with the button already up — the pointerup never arrived. + mouse.dispatchPointer('pointermove', { x: 200, y: 60, button: 0, buttons: 0 }) + + const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? [] + expect(reports.at(-1)?.charCodeAt(3)).toBe(35) + + mouse.clearPostedMessages() + mouse.dispatchPointer('pointermove', { x: 240, y: 60, button: 0, buttons: 1 }) + + // The lost pointerup ended the gesture; a later unrelated move must not emit motion. + expect(mouse.terminalInputBytes()).toBe('') + }) + + it('releases a tracked drag when the pointer is cancelled mid-gesture', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'drag' + + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) + mouse.dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 }) + mouse.dispatchPointer('pointercancel', { x: 160, y: 60 }) + + const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? [] + // Press went to the TUI, so the cancel must not leave the button latched. + expect(reports.at(-1)?.charCodeAt(3)).toBe(35) + }) +}) diff --git a/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts b/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts new file mode 100644 index 00000000000..14baeeaf325 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts @@ -0,0 +1,29 @@ +// Mouse-report coordinate mapping injected into XTERM_HTML. Closes over term, +// panX/panY, getCellWidth/Height, and getTotalScale. +export const TERMINAL_MOUSE_REPORT_CELL_JS = ` + function viewportToMouseReportCell(clientX, clientY) { + if (!term) return null; + var cellW = getCellWidth(); + var cellH = getCellHeight(); + if (cellW <= 0 || cellH <= 0) return null; + if (typeof clientX !== 'number') clientX = window.innerWidth / 2; + if (typeof clientY !== 'number') clientY = window.innerHeight / 2; + var total = getTotalScale(); + if (total <= 0) total = 1; + var sx = (clientX - panX) / total; + var sy = (clientY - panY) / total; + var maxX = Math.max(0, term.cols * cellW - 1); + var maxY = Math.max(0, term.rows * cellH - 1); + if (sx < 0) sx = 0; + if (sx > maxX) sx = maxX; + if (sy < 0) sy = 0; + if (sy > maxY) sy = maxY; + var col = Math.floor(sx / cellW); + var row = Math.floor(sy / cellH); + if (col < 0) col = 0; + if (col > term.cols - 1) col = term.cols - 1; + if (row < 0) row = 0; + if (row > term.rows - 1) row = term.rows - 1; + return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }; + } +` diff --git a/mobile/src/terminal/terminal-webview-mouse-test-harness.ts b/mobile/src/terminal/terminal-webview-mouse-test-harness.ts new file mode 100644 index 00000000000..29500d3c710 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-test-harness.ts @@ -0,0 +1,248 @@ +import { runInThisContext } from 'node:vm' +import { afterEach, beforeEach, vi, type Mock } from 'vitest' +import { XTERM_HTML } from './terminal-webview-html' + +function iifeSource(): string { + const start = XTERM_HTML.indexOf('(function() {') + const end = XTERM_HTML.lastIndexOf('})();') + return XTERM_HTML.slice(start, end + '})();'.length) +} + +function bodyMarkup(): string { + const start = XTERM_HTML.indexOf('') + ''.length + const end = XTERM_HTML.indexOf('