From e1071f59e9b7c8e5e1d2ece693425a8fefb7c0f7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:02:53 -0700 Subject: [PATCH 01/43] Say why an update install failed instead of stalling for three minutes (#12224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the real install-failure cause instead of letting a failed elevation stall silently, and keeps the reconnect wait inside its total budget by recomputing the remaining time after each awaited RPC. Relates to #11906 — this fixes the observability half. The functional half (a .deb/.rpm host cannot elevate and can never self-update) is unchanged, so the issue stays open. --- .../updater.install-failure-cause.test.ts | 333 ++++++++++++++++++ src/main/updater.test.ts | 20 +- src/main/updater.ts | 58 ++- .../components/UpdateCard.error-card.test.tsx | 25 ++ .../settings/RemoteServerUpdateDialog.tsx | 4 +- ...emote-server-install-failure-probe.test.ts | 104 ++++++ .../remote-server-install-failure-probe.ts | 36 ++ .../remote-server-restart-wait.test.ts | 178 ++++++++++ .../src/runtime/remote-server-restart-wait.ts | 81 +++++ .../remote-server-update-coordinator.test.ts | 89 +++++ .../remote-server-update-coordinator.ts | 56 ++- .../src/store/slices/remote-server-updates.ts | 9 +- src/shared/app-version.ts | 10 + 13 files changed, 949 insertions(+), 54 deletions(-) create mode 100644 src/main/updater.install-failure-cause.test.ts create mode 100644 src/renderer/src/runtime/remote-server-install-failure-probe.test.ts create mode 100644 src/renderer/src/runtime/remote-server-install-failure-probe.ts create mode 100644 src/renderer/src/runtime/remote-server-restart-wait.test.ts create mode 100644 src/renderer/src/runtime/remote-server-restart-wait.ts diff --git a/src/main/updater.install-failure-cause.test.ts b/src/main/updater.install-failure-cause.test.ts new file mode 100644 index 00000000000..5e61ac19554 --- /dev/null +++ b/src/main/updater.install-failure-cause.test.ts @@ -0,0 +1,333 @@ +import os from 'node:os' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as TracerModule from './observability/tracer' +import type * as UpdaterModule from './updater' + +const { + appMock, + browserWindowMock, + nativeUpdaterMock, + autoUpdaterMock, + isMock, + killAllPtyMock, + recordUpdaterLifecycleMock +} = vi.hoisted(() => { + const appEventHandlers = new Map void)[]>() + const eventHandlers = new Map void)[]>() + + const appOn = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + const handlers = appEventHandlers.get(event) ?? [] + handlers.push(handler) + appEventHandlers.set(event, handlers) + return appMock + }) + + const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + const handlers = eventHandlers.get(event) ?? [] + handlers.push(handler) + eventHandlers.set(event, handlers) + return autoUpdaterMock + }) + + const emit = (event: string, ...args: unknown[]) => { + for (const handler of eventHandlers.get(event) ?? []) { + handler(...args) + } + } + + const reset = () => { + appEventHandlers.clear() + appOn.mockClear() + eventHandlers.clear() + on.mockClear() + autoUpdaterMock.checkForUpdates.mockReset() + autoUpdaterMock.downloadUpdate.mockReset() + autoUpdaterMock.quitAndInstall.mockReset() + autoUpdaterMock.setFeedURL.mockClear() + } + + const autoUpdaterMock = { + autoDownload: false, + autoInstallOnAppQuit: false, + on, + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + quitAndInstall: vi.fn(), + setFeedURL: vi.fn(), + emit, + reset + } + + return { + appMock: { + isPackaged: true, + getVersion: vi.fn(() => '1.4.162'), + on: appOn, + quit: vi.fn(), + exit: vi.fn() + }, + browserWindowMock: { getAllWindows: vi.fn(() => []) }, + nativeUpdaterMock: { on: vi.fn() }, + autoUpdaterMock, + isMock: { dev: false }, + killAllPtyMock: vi.fn(), + recordUpdaterLifecycleMock: vi.fn() + } +}) + +vi.mock('electron', () => ({ + app: appMock, + BrowserWindow: browserWindowMock, + autoUpdater: nativeUpdaterMock, + powerMonitor: { on: vi.fn() }, + shell: { openExternal: vi.fn() }, + net: { fetch: vi.fn() } +})) + +vi.mock('electron-updater', () => ({ autoUpdater: autoUpdaterMock })) +vi.mock('./electron-updater-loader', () => ({ + loadElectronAutoUpdater: () => autoUpdaterMock +})) +vi.mock('@electron-toolkit/utils', () => ({ is: isMock })) +vi.mock('./ipc/pty', () => ({ killAllPty: killAllPtyMock })) +vi.mock('./updater-changelog', () => ({ + fetchChangelog: vi.fn().mockResolvedValue(null) +})) +vi.mock('./updater-nudge', () => ({ + fetchNudge: vi.fn().mockResolvedValue(null), + shouldApplyNudge: vi.fn().mockReturnValue(false) +})) +vi.mock('./updater-lifecycle-diagnostics', () => ({ + recordUpdaterLifecycle: recordUpdaterLifecycleMock +})) + +// The real electron-updater DebUpdater failure text when elevation is impossible. +const DEB_ELEVATION_ERROR = + 'Error: Command failed: /usr/bin/pkexec --disable-internal-agent "/bin/bash" "-c" "dpkg -i \'/home/u/.cache/orca-updater/pending/orca-ide_1.4.163_amd64.deb\'"\npkexec must be setuid root' + +// electron-updater's ERR_UPDATER_INVALID_SIGNATURE text, which drives its own card in UpdateCard. +const WINDOWS_SIGNATURE_MISMATCH_ERROR = + 'New version 1.4.163 is not signed by the application owner: publisherNames: Orca, Inc.' + +type CapturedSpan = { + readonly name: string + readonly exit: { readonly _tag: string; readonly cause?: string } +} + +const originalPlatform = process.platform + +let spans: CapturedSpan[] +let tracer: typeof TracerModule | null = null + +function capturingSink(): TracerModule.TracerSink { + return { + push(record) { + spans.push(record as CapturedSpan) + }, + flush() { + /* no-op */ + }, + close() { + /* no-op */ + } + } +} + +function installSpan(): CapturedSpan | undefined { + return spans.find((span) => span.name === 'updater.install') +} + +/** Drives the updater to `downloaded`, the only state `quitAndInstall` acts on. */ +async function reachDownloaded(): Promise { + const mainWindow = { webContents: { send: vi.fn() } } + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + // Why: resetModules gives each test a fresh module graph, so the sink must be installed on the + // same tracer instance updater.ts will import. + tracer = await import('./observability/tracer') + tracer.setActiveSink(capturingSink()) + const updater = await import('./updater') + + updater.setupAutoUpdater(mainWindow as never) + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '1.4.163' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + autoUpdaterMock.emit('update-downloaded', { version: '1.4.163' }) + expect(updater.getUpdateStatus().state).toBe('downloaded') + return updater +} + +/** + * On a `.deb` Linux host electron-updater's `install()` catches the failed elevation and + * re-dispatches it through the 'error' event *synchronously* inside `quitAndInstall()`. Orca + * recovers the app state, so the payload has to survive on the status and the span has to exit + * Failure — otherwise the only record of why the install never ran is destroyed (#11906). + */ +describe('quitAndInstall failure carries the updater cause', () => { + beforeEach(() => { + vi.resetModules() + autoUpdaterMock.reset() + nativeUpdaterMock.on.mockReset() + browserWindowMock.getAllWindows.mockReset() + browserWindowMock.getAllWindows.mockReturnValue([]) + appMock.getVersion.mockReset() + appMock.getVersion.mockReturnValue('1.4.162') + appMock.quit.mockReset() + appMock.isPackaged = true + isMock.dev = false + killAllPtyMock.mockReset() + recordUpdaterLifecycleMock.mockReset() + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true + }) + vi.useRealTimers() + spans = [] + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true + }) + tracer?._resetTracerForTests() + tracer = null + }) + + it('reports the underlying elevation failure rather than a generic restart message', async () => { + const { quitAndInstall, getUpdateStatus } = await reachDownloaded() + + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.emit('error', new Error(DEB_ELEVATION_ERROR)) + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + const status = getUpdateStatus() + expect(status.state).toBe('error') + // The only place the real cause exists is this event payload; dropping it leaves nothing + // in the logs and nothing actionable in the client's remote-server update dialog. + const message = status.state === 'error' ? status.message : '' + expect(message).toContain('pkexec') + expect(message).toContain('Could not start the update installer.') + }) + + it('keeps the durable breadcrumb to classification only', async () => { + const { quitAndInstall } = await reachDownloaded() + + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.emit('error', new Error(DEB_ELEVATION_ERROR)) + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( + 'quit_and_install_failed_via_event', + { errorType: 'Error' }, + expect.anything() + ) + }) + + it('exits the install span Failure with the cause', async () => { + const { quitAndInstall } = await reachDownloaded() + + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.emit('error', new Error(DEB_ELEVATION_ERROR)) + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(installSpan()).toBeDefined() + }) + + expect(installSpan()?.exit._tag).toBe('Failure') + expect(installSpan()?.exit.cause).toContain('pkexec') + }) + + it('exits the install span Success when the installer takes over', async () => { + const { quitAndInstall } = await reachDownloaded() + + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + // The installer runs and the old process is left to exit; no 'error' comes back. + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(installSpan()).toBeDefined() + }) + + expect(installSpan()?.exit._tag).toBe('Success') + }) + + it('keeps a Windows signature verdict unprefixed so its own card still renders', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true + }) + const { quitAndInstall, getUpdateStatus } = await reachDownloaded() + + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.emit('error', new Error(WINDOWS_SIGNATURE_MISMATCH_ERROR)) + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + const status = getUpdateStatus() + // Prefixing this would put two contradictory instructions on one card. + expect(status.state === 'error' ? status.message : '').toBe(WINDOWS_SIGNATURE_MISMATCH_ERROR) + }) + + it('redacts the cause the same way the retained-package card does', async () => { + const { quitAndInstall, getUpdateStatus } = await reachDownloaded() + const home = os.homedir() + const escape = String.fromCharCode(27) + + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.emit( + 'error', + new Error(`${escape}[31mdpkg -i '${home}/.cache/orca-updater/pending/orca.deb'${escape}[0m`) + ) + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + const status = getUpdateStatus() + const message = status.state === 'error' ? status.message : '' + expect(message).not.toContain(home) + expect(message).not.toContain(escape) + expect(message).toContain("/.cache/orca-updater/pending/orca.deb'") + }) + + it('carries the cause when quitAndInstall throws instead of dispatching an error event', async () => { + const { quitAndInstall, getUpdateStatus } = await reachDownloaded() + + // Squirrel/NSIS can reject the request by throwing out of the native call; the event path never runs. + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + throw new Error('Squirrel.framework is missing from the app bundle') + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(getUpdateStatus().state).toBe('error') + }) + + const status = getUpdateStatus() + const message = status.state === 'error' ? status.message : '' + expect(message).toContain('Squirrel.framework is missing from the app bundle') + expect(message).toContain('Could not start the update installer.') + }) +}) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index a0ab479343d..dce8b39a7b6 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -159,6 +159,12 @@ const { getLinuxRootPackageTypeMock, recordUpdaterLifecycleMock } = vi.hoisted(( recordUpdaterLifecycleMock: vi.fn() })) +// Why: macOS keeps the restart advice because quitting does re-stage a Squirrel update. +const PRE_COMMIT_INSTALL_FAILURE = + process.platform === 'darwin' + ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' + : 'Could not start the update installer. Orca remains open.' + // Why: only the marker resolver is faked so the real artifact capture/redaction path stays under test. vi.mock('./linux-update-package-type', () => ({ getLinuxRootPackageType: getLinuxRootPackageTypeMock @@ -1829,10 +1835,8 @@ describe('updater', () => { state: 'error', // Why: a pre-commit install failure is not fixed by restarting, so the copy must not // suggest it — except on macOS, where quitting does re-stage a Squirrel update. - message: - process.platform === 'darwin' - ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' - : 'Could not start the update installer. Orca remains open.' + // The updater's own text is appended because it is the only record of why the install never ran. + message: `${PRE_COMMIT_INSTALL_FAILURE} (No update filepath provided, can't quit and install)` }) ) }) @@ -3911,11 +3915,7 @@ describe('updater', () => { const lastStatus = (send: ReturnType): UpdateStatus | undefined => send.mock.calls.findLast(([channel]) => channel === 'updater:status')?.[1] - // Why: macOS keeps the restart advice because quitting does re-stage a Squirrel update. - const PRE_COMMIT_FAILURE_MESSAGE = - process.platform === 'darwin' - ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' - : 'Could not start the update installer. Orca remains open.' + const PRE_COMMIT_FAILURE_MESSAGE = PRE_COMMIT_INSTALL_FAILURE const AGENT_STDERR = 'pkexec: Error executing command as another user: No authentication agent found.' @@ -4081,7 +4081,7 @@ describe('updater', () => { expect(send).toHaveBeenCalledWith('updater:status', { state: 'error', - message: PRE_COMMIT_FAILURE_MESSAGE + message: `${PRE_COMMIT_FAILURE_MESSAGE} (${EXIT_127})` }) expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith( 'linux_package_install_failed', diff --git a/src/main/updater.ts b/src/main/updater.ts index 302591286f3..96bf163e54e 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -13,7 +13,10 @@ import type { RemoteServerUpdaterSnapshot, RemoteServerUpdateSupport } from '../shared/remote-server-update' -import { isWindowsSignatureCheckUnavailableFailure } from '../shared/updater-windows-signature-check' +import { + isWindowsSignatureCheckUnavailableFailure, + isWindowsSignatureMismatchFailure +} from '../shared/updater-windows-signature-check' import { killAllPty } from './ipc/pty' import { withUpdaterSpan } from './observability/instrumentation' import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' @@ -763,6 +766,8 @@ async function performQuitAndInstall(): Promise { true ) resetQuitForUpdateState() + // Why: a bare return would exit this span Success and hide the aborted install from tracing. + span.fail('Could not persist the supervised serve update handoff') return } @@ -790,6 +795,12 @@ async function performQuitAndInstall(): Promise { // Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran. if (!quitAndInstallInProgress) { + // Why: recovery already wrote the reason to currentStatus; a bare return would exit this span Success. + span.fail( + currentStatus.state === 'error' + ? currentStatus.message + : 'quitAndInstall returned without invoking the installer' + ) return } @@ -853,8 +864,9 @@ async function performQuitAndInstall(): Promise { recoveryStatus ?? { state: 'error', // Why: past the native invoke this is the same pre-commit failure the event path reports, so it gets the same copy; only a pre-native exception can be helped by a restart. + // A synchronous throw out of quitAndInstall carries the same installer text the 'error' event would have. message: quitAndInstallNativeInvokedBeforeReset - ? getPreCommitInstallFailureMessage() + ? withInstallFailureCause(getPreCommitInstallFailureMessage(), error) : 'Could not restart to install the update. Quit and reopen Orca, then try again.' } ) @@ -890,6 +902,33 @@ function sendInstallFailureStatus(status: UpdateStatus): void { sendStatus(status, { force: true }) } +const INSTALL_FAILURE_CAUSE_MAX_LENGTH = 200 + +/** + * Appends the updater's own text to the generic install-failure copy. Without it the only record of + * why the install never started is destroyed — on Linux that text carries the exact `dpkg -i ` + * command the user has to run by hand, and remote clients get nothing but "it didn't come back". + */ +function withInstallFailureCause(baseMessage: string, error: unknown): string { + const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : '' + // Why: the retained-package card runs its text through this same sanitizer, so a home directory, + // user name, or terminal escape must not reach the card merely because no artifact was tracked. + const redacted = + redactLinuxPackageInstallText(raw, getTrackedLinuxPackageArtifact()?.path ?? null) ?? '' + const cause = redacted.slice(0, INSTALL_FAILURE_CAUSE_MAX_LENGTH) + if (!cause || cause === 'Unknown error') { + return baseMessage + } + // Why: UpdateCard picks the whole card off this string, so a signature verdict must not be prefixed by contradictory restart advice. + if ( + isWindowsSignatureCheckUnavailableFailure(cause) || + isWindowsSignatureMismatchFailure(cause) + ) { + return cause + } + return `${baseMessage} (${cause})` +} + /** * The recovery status for a failed `.deb`/`.rpm` install, or null when no retained package can * recover it. Must run before `resetQuitForUpdateState()` clears the attempt diagnostic. @@ -945,14 +984,19 @@ function handleQuitAndInstallFailure(error?: unknown): boolean { const recoveryStatus = buildLinuxPackageInstallFailureStatus(error) failServeUpdateHandoff('The native updater rejected the install request.') resetQuitForUpdateState() - recordUpdaterLifecycle('quit_and_install_failed_via_event', undefined, { - level: 'warn', - message: 'Update install could not start; recovered app state' - }) + // Durable data carries classification only — the cause text stays on the status the user can read. + recordUpdaterLifecycle( + 'quit_and_install_failed_via_event', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Update install could not start; recovered app state' + } + ) sendInstallFailureStatus( recoveryStatus ?? { state: 'error', - message: getPreCommitInstallFailureMessage() + message: withInstallFailureCause(getPreCommitInstallFailureMessage(), error) } ) return true diff --git a/src/renderer/src/components/UpdateCard.error-card.test.tsx b/src/renderer/src/components/UpdateCard.error-card.test.tsx index 0e41c46f07b..4e4aec4d2b5 100644 --- a/src/renderer/src/components/UpdateCard.error-card.test.tsx +++ b/src/renderer/src/components/UpdateCard.error-card.test.tsx @@ -127,6 +127,19 @@ describe('UpdateCard Windows signature failures', () => { 'true' ) }) + + // An install failure now carries the updater's own text, so it can reach these branches too. + it('routes a signature verdict raised during install to the security-stop card', () => { + const message = + 'New version 1.4.200 is not signed by the application owner: publisherNames: Orca' + renderAfterAvailableStatus() + + act(() => useAppStore.getState().setUpdateStatus({ state: 'error', message })) + + expect(screen.getByText("Update Wasn't Installed")).toBeTruthy() + // The generic restart advice must not be prefixed onto a security stop. + expect(screen.queryByText(/Quit and reopen Orca/)).toBeNull() + }) }) describe('UpdateCard hourly builds', () => { @@ -260,6 +273,18 @@ describe('UpdateCard Linux package-install recovery', () => { expect(download).toHaveBeenCalledTimes(1) }) + it('shows the appended install cause behind the generic card details', () => { + const message = + 'Could not start the update installer. Orca remains open. (Command failed: pkexec must be setuid root)' + renderAfterAvailableStatus() + + act(() => useAppStore.getState().setUpdateStatus({ state: 'error', message })) + + expect(screen.getByText('Update Error')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Show details' })) + expect(screen.getByText(message)).toBeTruthy() + }) + it('leaves the HTTP/1.1 compatibility branch untouched', () => { renderAfterAvailableStatus() diff --git a/src/renderer/src/components/settings/RemoteServerUpdateDialog.tsx b/src/renderer/src/components/settings/RemoteServerUpdateDialog.tsx index 918eec3344a..473caf85f00 100644 --- a/src/renderer/src/components/settings/RemoteServerUpdateDialog.tsx +++ b/src/renderer/src/components/settings/RemoteServerUpdateDialog.tsx @@ -93,7 +93,9 @@ function ServerUpdateRow({ {help ? (

{help} diff --git a/src/renderer/src/runtime/remote-server-install-failure-probe.test.ts b/src/renderer/src/runtime/remote-server-install-failure-probe.test.ts new file mode 100644 index 00000000000..1c840aecbac --- /dev/null +++ b/src/renderer/src/runtime/remote-server-install-failure-probe.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot +} from '../../../shared/remote-server-update' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { readRemoteServerInstallFailure } from './remote-server-install-failure-probe' + +const support = { + installMode: 'supervised-headless-serve', + automatic: true, + reason: 'available' +} as const + +const install: RemoteServerUpdateInstallResult = { + accepted: true, + fromVersion: '1.4.0', + targetVersion: '1.5.0', + runtimeId: 'runtime-old' +} + +function runtime(runtimeId = 'runtime-old'): RuntimeStatus { + return { + runtimeId, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 2, + liveLeafCount: 1, + capabilities: ['updater.remote-control.v1'], + appVersion: '1.4.0', + remoteUpdateSupport: support + } +} + +function snapshot( + status: RemoteServerUpdaterSnapshot['status'], + runtimeId = 'runtime-old' +): RemoteServerUpdaterSnapshot { + return { appVersion: '1.4.0', runtimeId, support, status } +} + +describe('readRemoteServerInstallFailure', () => { + it('returns the message the original process is publishing', async () => { + const getUpdaterStatus = vi.fn(async () => + snapshot({ state: 'error', message: 'pkexec must be setuid root' }) + ) + + await expect( + readRemoteServerInstallFailure('server-1', { getUpdaterStatus }, install, runtime()) + ).resolves.toBe('pkexec must be setuid root') + }) + + it('bounds the status call by the deadline the caller passes', async () => { + const getUpdaterStatus = vi.fn(async () => + snapshot({ state: 'error', message: 'pkexec must be setuid root' }) + ) + + await readRemoteServerInstallFailure( + 'server-1', + { getUpdaterStatus }, + install, + runtime(), + 4_000 + ) + + // Without this the call inherits the RPC client's 15s default and can outlast the reconnect budget. + expect(getUpdaterStatus).toHaveBeenCalledWith('server-1', 4_000) + }) + + it('ignores an error published by a different process', async () => { + const getUpdaterStatus = vi.fn(async () => + snapshot({ state: 'error', message: 'pkexec must be setuid root' }, 'runtime-new') + ) + + await expect( + readRemoteServerInstallFailure('server-1', { getUpdaterStatus }, install, runtime()) + ).resolves.toBeNull() + }) + + it('ignores a snapshot that is not an error', async () => { + const getUpdaterStatus = vi.fn(async () => snapshot({ state: 'downloaded', version: '1.5.0' })) + + await expect( + readRemoteServerInstallFailure('server-1', { getUpdaterStatus }, install, runtime()) + ).resolves.toBeNull() + }) + + it('costs no round trip once the replacement runtime answers', async () => { + const getUpdaterStatus = vi.fn(async () => + snapshot({ state: 'error', message: 'pkexec must be setuid root' }) + ) + + await expect( + readRemoteServerInstallFailure( + 'server-1', + { getUpdaterStatus }, + install, + runtime('runtime-new') + ) + ).resolves.toBeNull() + expect(getUpdaterStatus).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/remote-server-install-failure-probe.ts b/src/renderer/src/runtime/remote-server-install-failure-probe.ts new file mode 100644 index 00000000000..7f37fd38453 --- /dev/null +++ b/src/renderer/src/runtime/remote-server-install-failure-probe.ts @@ -0,0 +1,36 @@ +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot +} from '../../../shared/remote-server-update' +import type { RuntimeStatus } from '../../../shared/runtime-types' + +type InstallFailureProbeTransport = { + getUpdaterStatus: ( + environmentId: string, + timeoutMs?: number + ) => Promise +} + +/** + * The install RPC is accepted before the installer runs, so a server that failed to restart keeps + * publishing the reason through `updater.getStatus`. Reading it turns a three-minute reconnect + * timeout into the actual cause. Both runtime-id guards matter: a snapshot from a process that is + * not the one we asked to install describes some other attempt. + */ +export async function readRemoteServerInstallFailure( + environmentId: string, + transport: InstallFailureProbeTransport, + install: RemoteServerUpdateInstallResult, + runtime: RuntimeStatus, + timeoutMs?: number +): Promise { + if (runtime.runtimeId !== install.runtimeId) { + return null + } + // Why: without an explicit deadline this inherits the RPC client's 15s default and can outlast the caller's own wait. + const snapshot = await transport.getUpdaterStatus(environmentId, timeoutMs) + if (snapshot.runtimeId !== install.runtimeId || snapshot.status.state !== 'error') { + return null + } + return snapshot.status.message +} diff --git a/src/renderer/src/runtime/remote-server-restart-wait.test.ts b/src/renderer/src/runtime/remote-server-restart-wait.test.ts new file mode 100644 index 00000000000..76567556d92 --- /dev/null +++ b/src/renderer/src/runtime/remote-server-restart-wait.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot +} from '../../../shared/remote-server-update' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { waitForReplacementRuntime } from './remote-server-restart-wait' + +const support = { + installMode: 'supervised-headless-serve', + automatic: true, + reason: 'available' +} as const + +const install: RemoteServerUpdateInstallResult = { + accepted: true, + fromVersion: '1.4.0', + targetVersion: '1.5.0', + runtimeId: 'runtime-old' +} + +const idleSnapshot: RemoteServerUpdaterSnapshot = { + appVersion: '1.4.0', + runtimeId: 'runtime-old', + support, + status: { state: 'downloaded', version: '1.5.0' } +} + +function runtime(version: string, runtimeId: string): RuntimeStatus { + return { + runtimeId, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 2, + liveLeafCount: 1, + capabilities: ['updater.remote-control.v1'], + appVersion: version, + remoteUpdateSupport: support + } +} + +describe('waitForReplacementRuntime', () => { + it('returns the replacement process once it answers on the target version', async () => { + let clock = 0 + let ticks = 0 + + await expect( + waitForReplacementRuntime( + 'server-1', + { + now: () => clock, + wait: async (milliseconds) => { + clock += milliseconds + }, + getUpdaterStatus: async () => idleSnapshot, + getRuntimeStatus: async () => { + ticks += 1 + return ticks > 1 ? runtime('1.5.0', 'runtime-new') : runtime('1.4.0', 'runtime-old') + } + }, + install, + { reconnectTimeoutMs: 1_000, pollIntervalMs: 10 } + ) + ).resolves.toMatchObject({ runtimeId: 'runtime-new', appVersion: '1.5.0' }) + }) + + it('never lets a poll RPC outlive the reconnect deadline', async () => { + let clock = 0 + const reconnectTimeoutMs = 25_000 + const pollIntervalMs = 500 + + await expect( + waitForReplacementRuntime( + 'server-1', + { + now: () => clock, + wait: async (milliseconds) => { + clock += milliseconds + }, + getUpdaterStatus: async () => idleSnapshot, + // A wedged SSH link burns the whole allowance it is given before rejecting. + getRuntimeStatus: async (_environmentId, timeoutMs) => { + clock += timeoutMs ?? 0 + throw new Error('runtime status timed out') + } + }, + install, + { reconnectTimeoutMs, pollIntervalMs } + ) + ).rejects.toThrow('remote_update_reconnect_timeout') + + // A tick allowed its full 10s past the deadline would push this well beyond the budget. + expect(clock).toBeLessThanOrEqual(reconnectTimeoutMs) + }) + + it('keeps the total wait inside the budget when the status RPC eats it before the probe', async () => { + let clock = 0 + const reconnectTimeoutMs = 20_000 + + await expect( + waitForReplacementRuntime( + 'server-1', + { + now: () => clock, + wait: async (milliseconds) => { + clock += milliseconds + }, + // Both RPCs burn everything they are handed, so a stale budget doubles the tick. + getUpdaterStatus: async (_environmentId, timeoutMs) => { + clock += timeoutMs ?? 0 + return idleSnapshot + }, + getRuntimeStatus: async (_environmentId, timeoutMs) => { + clock += timeoutMs ?? 0 + return runtime('1.4.0', 'runtime-old') + } + }, + install, + { reconnectTimeoutMs, pollIntervalMs: 500 } + ) + ).rejects.toThrow('remote_update_reconnect_timeout') + + expect(clock).toBeLessThanOrEqual(reconnectTimeoutMs) + }) + + it('charges the failure probe only the budget left after the status RPC', async () => { + let clock = 0 + const getUpdaterStatus = vi.fn(async () => idleSnapshot) + + await expect( + waitForReplacementRuntime( + 'server-1', + { + now: () => clock, + wait: async (milliseconds) => { + clock += milliseconds + }, + getUpdaterStatus, + getRuntimeStatus: async () => { + clock += 2_000 + return runtime('1.4.0', 'runtime-old') + } + }, + install, + { reconnectTimeoutMs: 9_000, pollIntervalMs: 1_000 } + ) + ).rejects.toThrow('remote_update_reconnect_timeout') + + // First probe runs on the second tick: it starts at 3s, the status RPC spends 2s more, + // so 4s of the 9s budget is left — not the 6s the tick began with. + expect(getUpdaterStatus).toHaveBeenNthCalledWith(1, 'server-1', 4_000) + }) + + it('bounds the failure probe by the same remaining budget', async () => { + let clock = 0 + const getUpdaterStatus = vi.fn(async () => idleSnapshot) + + await expect( + waitForReplacementRuntime( + 'server-1', + { + now: () => clock, + wait: async (milliseconds) => { + clock += milliseconds + }, + getUpdaterStatus, + getRuntimeStatus: async () => runtime('1.4.0', 'runtime-old') + }, + install, + { reconnectTimeoutMs: 4_000, pollIntervalMs: 1_000 } + ) + ).rejects.toThrow('remote_update_reconnect_timeout') + + // First tick is the probe's warm-up; the second runs with 3s of the 4s budget left. + expect(getUpdaterStatus).toHaveBeenCalledWith('server-1', 3_000) + }) +}) diff --git a/src/renderer/src/runtime/remote-server-restart-wait.ts b/src/renderer/src/runtime/remote-server-restart-wait.ts new file mode 100644 index 00000000000..d78b2170f1d --- /dev/null +++ b/src/renderer/src/runtime/remote-server-restart-wait.ts @@ -0,0 +1,81 @@ +import { hasReachedAppVersion } from '../../../shared/app-version' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot +} from '../../../shared/remote-server-update' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { readRemoteServerInstallFailure } from './remote-server-install-failure-probe' + +type RestartWaitTransport = { + getRuntimeStatus: (environmentId: string, timeoutMs?: number) => Promise + getUpdaterStatus: ( + environmentId: string, + timeoutMs?: number + ) => Promise + wait: (milliseconds: number) => Promise + now?: () => number +} + +type RestartWaitTiming = { + reconnectTimeoutMs: number + pollIntervalMs: number +} + +/** Per-call ceiling for the two poll RPCs; each tick also clamps to the time left. */ +const RESTART_WAIT_RPC_TIMEOUT_MS = 10_000 + +/** + * Waits for the replacement process to answer on the target version and returns its runtime status. + * Throws the server's own install-failure text as soon as the *original* process publishes one — + * without that, a server that could not restart is indistinguishable from a slow one until the + * reconnect budget runs out, and the user is told the wrong thing. + */ +export async function waitForReplacementRuntime( + environmentId: string, + transport: RestartWaitTransport, + install: RemoteServerUpdateInstallResult, + timing: RestartWaitTiming +): Promise { + const now = transport.now ?? Date.now + const deadline = now() + timing.reconnectTimeoutMs + // Why: the install RPC returns before the installer fires, so an error on the first tick belongs to an earlier attempt. + let probed = false + while (now() < deadline) { + // Why: an RPC allowed to outlive the deadline drags the whole wait past the budget the caller set. + const rpcTimeoutMs = Math.min(RESTART_WAIT_RPC_TIMEOUT_MS, deadline - now()) + let installFailure: string | null = null + try { + const status = await transport.getRuntimeStatus(environmentId, rpcTimeoutMs) + const version = status.appVersion?.trim() ?? '' + const reachedTarget = hasReachedAppVersion(version, install.targetVersion) + if (status.runtimeId !== install.runtimeId && reachedTarget) { + return status + } + // Why: the status RPC already spent part of rpcTimeoutMs, so the probe re-reads what is left + // rather than starting a second full budget of its own. + const probeTimeoutMs = Math.min(RESTART_WAIT_RPC_TIMEOUT_MS, deadline - now()) + installFailure = + probed && probeTimeoutMs > 0 + ? await readRemoteServerInstallFailure( + environmentId, + transport, + install, + status, + probeTimeoutMs + ) + : null + probed = true + } catch { + // A refused connection is expected while the server process is being replaced. + } + // Why: thrown outside the try so the loop's own catch cannot swallow the reason we came for. + if (installFailure !== null) { + throw new Error(installFailure) + } + const waitMs = Math.min(timing.pollIntervalMs, deadline - now()) + if (waitMs > 0) { + await transport.wait(waitMs) + } + } + throw new Error('remote_update_reconnect_timeout') +} diff --git a/src/renderer/src/runtime/remote-server-update-coordinator.test.ts b/src/renderer/src/runtime/remote-server-update-coordinator.test.ts index 824fdf6fbdd..66cb646f711 100644 --- a/src/renderer/src/runtime/remote-server-update-coordinator.test.ts +++ b/src/renderer/src/runtime/remote-server-update-coordinator.test.ts @@ -44,6 +44,10 @@ function status(version: string, runtimeId = 'runtime-old', automatic = true): R } } +// The main process's own copy once quitAndInstall fails; it must survive to the client verbatim. +const INSTALL_FAILURE = + "Could not start the update installer. Orca remains open. (Command failed: pkexec dpkg -i '/home/u/.cache/orca-updater/pending/orca-ide_1.5.0_amd64.deb' pkexec must be setuid root)" + const availableSnapshot: RemoteServerUpdaterSnapshot = { appVersion: '1.4.0', runtimeId: 'runtime-old', @@ -275,6 +279,91 @@ describe('remote server update execution', () => { }) }) + it('repeats the install failure a still-running server is publishing', async () => { + const snapshots = [ + availableSnapshot, + { ...availableSnapshot, status: { state: 'downloaded', version: '1.5.0' } } + ] satisfies RemoteServerUpdaterSnapshot[] + let installed = false + const result = await runRemoteServerUpdate( + availableEntry(), + transport({ + getUpdaterStatus: async () => + installed + ? { ...availableSnapshot, status: { state: 'error', message: INSTALL_FAILURE } } + : (snapshots.shift() ?? availableSnapshot), + install: async (): Promise => { + installed = true + return { + accepted: true, + fromVersion: '1.4.0', + targetVersion: '1.5.0', + runtimeId: 'runtime-old' + } + }, + // The server never restarts: same runtimeId, same version, still perfectly reachable. + getRuntimeStatus: async () => status('1.4.0', 'runtime-old') + }), + () => undefined, + { timing: { operationTimeoutMs: 10, reconnectTimeoutMs: 60, pollIntervalMs: 1 } } + ) + expect(result).toMatchObject({ phase: 'failed', error: INSTALL_FAILURE }) + }) + + it('ignores an updater error that predates the install request', async () => { + const snapshots = [ + availableSnapshot, + { ...availableSnapshot, status: { state: 'downloaded', version: '1.5.0' } } + ] satisfies RemoteServerUpdaterSnapshot[] + let reconnectTicks = 0 + const result = await runRemoteServerUpdate( + availableEntry(), + transport({ + // A background check that errored inside the 100ms before the installer fires. + getUpdaterStatus: async () => + snapshots.shift() ?? { + ...availableSnapshot, + status: { state: 'error', message: 'a background check failed' } + }, + getRuntimeStatus: async () => { + reconnectTicks += 1 + return reconnectTicks > 1 + ? status('1.5.0', 'runtime-new') + : status('1.4.0', 'runtime-old') + } + }), + () => undefined, + { timing: { operationTimeoutMs: 10, reconnectTimeoutMs: 60, pollIntervalMs: 1 } } + ) + expect(result).toMatchObject({ phase: 'updated', currentVersion: '1.5.0' }) + }) + + it('keeps waiting when the failure probe itself cannot be reached', async () => { + const snapshots = [ + availableSnapshot, + { ...availableSnapshot, status: { state: 'downloaded', version: '1.5.0' } } + ] satisfies RemoteServerUpdaterSnapshot[] + const result = await runRemoteServerUpdate( + availableEntry(), + transport({ + getUpdaterStatus: async () => { + const next = snapshots.shift() + if (!next) { + throw new Error('connection refused') + } + return next + }, + getRuntimeStatus: async () => status('1.4.0', 'runtime-old') + }), + () => undefined, + { timing: { operationTimeoutMs: 10, reconnectTimeoutMs: 6, pollIntervalMs: 1 } } + ) + expect(result).toMatchObject({ + phase: 'failed', + error: 'The server did not reconnect on the updated version.' + }) + }) + it('turns a capability race into manual update guidance', async () => { const result = await runRemoteServerUpdate( availableEntry(), diff --git a/src/renderer/src/runtime/remote-server-update-coordinator.ts b/src/renderer/src/runtime/remote-server-update-coordinator.ts index 77b1b6f2cbd..196699b6eed 100644 --- a/src/renderer/src/runtime/remote-server-update-coordinator.ts +++ b/src/renderer/src/runtime/remote-server-update-coordinator.ts @@ -1,5 +1,6 @@ import { compareAppVersions, + hasReachedAppVersion, isPerfPrereleaseAppVersion, isPrereleaseAppVersion, isValidAppVersion @@ -13,6 +14,7 @@ import type { import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' import type { RuntimeStatus } from '../../../shared/runtime-types' import type { UpdateCheckOptions } from '../../../shared/types' +import { waitForReplacementRuntime } from './remote-server-restart-wait' import { remoteServerUpdateErrorMessage } from './remote-server-update-errors' import { pollRemoteServerUpdater } from './remote-server-updater-polling' @@ -45,7 +47,10 @@ export type RemoteServerUpdateEntry = { export type RemoteServerUpdateTransport = { getRuntimeStatus: (environmentId: string, timeoutMs?: number) => Promise - getUpdaterStatus: (environmentId: string) => Promise + getUpdaterStatus: ( + environmentId: string, + timeoutMs?: number + ) => Promise check: ( environmentId: string, options: UpdateCheckOptions @@ -213,12 +218,7 @@ export async function runRemoteServerUpdate( if (available.status.state === 'not-available') { const status = await transport.getRuntimeStatus(entry.environmentId, 10_000) const currentVersion = status.appVersion?.trim() ?? '' - const reachedTarget = - entry.targetVersion !== null && - isValidAppVersion(currentVersion) && - isValidAppVersion(entry.targetVersion) && - compareAppVersions(currentVersion, entry.targetVersion) >= 0 - if (!reachedTarget) { + if (!hasReachedAppVersion(currentVersion, entry.targetVersion)) { throw new Error('remote_update_requested_version_unavailable') } next = { @@ -267,34 +267,22 @@ export async function runRemoteServerUpdate( } onProgress(next) - const now = transport.now ?? Date.now - const reconnectDeadline = now() + timing.reconnectTimeoutMs - while (now() < reconnectDeadline) { - try { - const status = await transport.getRuntimeStatus(entry.environmentId, 10_000) - const version = status.appVersion?.trim() ?? '' - const reachedTarget = - isValidAppVersion(version) && - isValidAppVersion(install.targetVersion) && - compareAppVersions(version, install.targetVersion) >= 0 - if (status.runtimeId !== install.runtimeId && reachedTarget) { - next = { - ...next, - phase: 'updated', - currentVersion: version, - runtimeId: status.runtimeId, - liveTabCount: status.liveTabCount, - liveLeafCount: status.liveLeafCount - } - onProgress(next) - return next - } - } catch { - // A refused connection is expected while the server process is being replaced. - } - await transport.wait(timing.pollIntervalMs) + const replacement = await waitForReplacementRuntime( + entry.environmentId, + transport, + install, + timing + ) + next = { + ...next, + phase: 'updated', + currentVersion: replacement.appVersion?.trim() ?? '', + runtimeId: replacement.runtimeId, + liveTabCount: replacement.liveTabCount, + liveLeafCount: replacement.liveLeafCount } - throw new Error('remote_update_reconnect_timeout') + onProgress(next) + return next } catch (error) { next = { ...next, diff --git a/src/renderer/src/store/slices/remote-server-updates.ts b/src/renderer/src/store/slices/remote-server-updates.ts index 909c05e3190..80b8a7370c1 100644 --- a/src/renderer/src/store/slices/remote-server-updates.ts +++ b/src/renderer/src/store/slices/remote-server-updates.ts @@ -33,8 +33,13 @@ const transport: RemoteServerUpdateTransport = { window.api.runtimeEnvironments .getStatus({ selector: environmentId, timeoutMs }) .then((response) => unwrapRuntimeRpcResult(response)), - getUpdaterStatus: (environmentId) => - callRemoteUpdater(environmentId, 'updater.getStatus'), + getUpdaterStatus: (environmentId, timeoutMs) => + callRemoteUpdater( + environmentId, + 'updater.getStatus', + undefined, + timeoutMs + ), check: (environmentId, options) => callRemoteUpdater(environmentId, 'updater.check', options), download: (environmentId) => diff --git a/src/shared/app-version.ts b/src/shared/app-version.ts index a904b4990c3..e3c537f4534 100644 --- a/src/shared/app-version.ts +++ b/src/shared/app-version.ts @@ -94,3 +94,13 @@ export function compareAppVersions(left: string, right: string): number { return 0 } + +/** True when `current` is at or past `target`. Unparseable or absent versions never qualify. */ +export function hasReachedAppVersion(current: string, target: string | null): boolean { + return ( + target !== null && + isValidAppVersion(current) && + isValidAppVersion(target) && + compareAppVersions(current, target) >= 0 + ) +} From 2548b816c05a2fe85c12e7f1cd7958512096fab9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:03:40 -0700 Subject: [PATCH 02/43] Keep the app responsive when security software slows process creation (#12217) * fix(ports): keep the app responsive when security software slows process creation Orca ran the workspace port scan's probe commands (lsof/ps on macOS, netstat + powershell.exe on Windows) directly in the Electron main process. libuv performs process creation inline on the calling event loop, which in the main process is the browser UI thread, so an endpoint-security module hooking CreateProcessW froze the whole window for the length of the spawn. The same stall also produced a false diagnosis: the 4s command watchdog was armed before execFile (local-workspace-port-scanner.ts:389 -> :410), so its deadline had already passed by the time the command started. Every scan on a hooked host reported a command timeout, tripping the 60s -> 5min backoff and the "Port scanning is temporarily paused after a command timeout" banner even though the commands themselves were healthy. Probe commands now run on a lazily created, unref'd worker thread with FIFO one-at-a-time dispatch, and the watchdog is armed after execFile returns so it measures the command rather than the spawn. Node's own execFile timeout kill (killed: true) is classified as a command timeout, keeping the backoff working for genuine hangs. A scan that observes a stalled spawn skips its optional metadata commands for that cycle, capping a hooked-host scan at roughly one stall instead of three. Closes #11161 * fix(ports): keep advertised URLs when a stalled spawn skips port metadata Review follow-up on #11161. The stalled-spawn early return handed scanWorkspacePorts raw ports with no cwd/commandLine, so every port failed attribution and reconcileAdvertisedUrls told the watcher each worktree's listeners had vanished. shouldEvictAfterScan then deleted every cached advertised URL and broadcast a removal event; those URLs are only ever captured from live PTY output, so the dev-server link was gone until the server restarted. The scanners now report metadataAvailable, and reconciliation is skipped for a scan that never gathered attribution evidence. The skip is also no longer self-perpetuating: on an EDR-hooked host every spawn stalls, so gating purely on the current scan's spawnMs made every port permanently external (Stop refused with 'Only workspace-owned local processes can be stopped here.'). Metadata is now re-probed on the scan after a skip, matching what the comment and test name already claimed. Co-authored-by: Orca * test(windows): stop a temp-dir lock from failing the CLI launcher smoke test The native launcher assertions passed on windows-latest, but teardown's rmSync raced Windows' release of the image handle on the exe the test had just executed and threw EPERM, failing the job. Cleanup now retries and, on Windows only, tolerates a residual lock code instead of reporting it as a launcher regression. Co-authored-by: Orca * fix(ports): scope the metadata skip away from attribution-dependent scans The metadata skip was a process-wide parity flag, so Stop and the localhost-label allowlist could land on a degraded cycle and reject a port the panel had just shown as workspace-owned. Give those callers an explicit requireMetadata option, and carry the previous cycle's listener metadata forward so a skipped background scan no longer republishes workspace ports as external. Also pin the watchdog ordering: the stall in the execution test was shorter than the watchdog budget, so a watchdog armed before execFile still passed. * build: guard worker-thread entries against electron imports (#11161) Electron's module is not registered on worker threads, so require("electron") throws "Cannot find module 'electron'" inside a main-process worker and kills it at startup (verified on Electron 43.1.0). plain-node-entry-guard covered only forked plain-Node entries, so the five worker entries relied on hand-written "must stay electron-free" comments. The port-scan probe worker is one import away from port-scan-command-client.ts, which deliberately contains require('electron'). A violation there fails closed at runtime while every unit test still passes, because the client's require is try/caught on the main thread. Covers stt-worker, warp-theme-parser-worker, session-scanner-opencode-sqlite-worker-entry, main-thread-hang-watchdog-entry and port-scan-command-worker-entry. The scan is transitive over the emitted chunk graph, so a shared chunk that reaches electron is caught too. Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> * test(windows): retry teardown for main's duplicate-PATH launcher fixture Main's new csc-compiled harness runs an exe from the temp tree, which is exactly the image-handle/AV lock the merged-in removeFixtureTree retry exists for; its bare rmSync would report a teardown lock as a launcher failure. Co-authored-by: Orca * test(ports): pin the packaged-asar worker entry path resolveWorkerEntryPath's packaged branch never runs in dev or e2e, so the path construction had no coverage. Split the electron read out of it and unit-test both layouts. Co-authored-by: Orca --------- Co-authored-by: Orca Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- build-plugins/plain-node-entry-guard.ts | 31 +- config/knip.json | 1 + .../build-windows-cli-launcher.test.mjs | 21 +- config/scripts/plain-node-entry-guard.test.ts | 88 +++++ electron.vite.config.ts | 5 + src/main/ipc/localhost-worktree-labels.ts | 6 +- src/main/ipc/workspace-ports.test.ts | 52 +-- .../local-workspace-port-scanner.test.ts | 298 +++++++++----- .../ports/local-workspace-port-scanner.ts | 235 +++++++---- .../ports/port-scan-command-client.test.ts | 300 +++++++++++++++ src/main/ports/port-scan-command-client.ts | 364 ++++++++++++++++++ .../ports/port-scan-command-execution.test.ts | 97 +++++ src/main/ports/port-scan-command-execution.ts | 115 ++++++ .../port-scan-command-import-boundary.test.ts | 18 + src/main/ports/port-scan-command-protocol.ts | 36 ++ .../ports/port-scan-command-worker-entry.ts | 53 +++ .../ports/workspace-port-ownership.test.ts | 57 +++ src/main/ports/workspace-port-ownership.ts | 11 +- 18 files changed, 1590 insertions(+), 198 deletions(-) create mode 100644 src/main/ports/port-scan-command-client.test.ts create mode 100644 src/main/ports/port-scan-command-client.ts create mode 100644 src/main/ports/port-scan-command-execution.test.ts create mode 100644 src/main/ports/port-scan-command-execution.ts create mode 100644 src/main/ports/port-scan-command-import-boundary.test.ts create mode 100644 src/main/ports/port-scan-command-protocol.ts create mode 100644 src/main/ports/port-scan-command-worker-entry.ts create mode 100644 src/main/ports/workspace-port-ownership.test.ts diff --git a/build-plugins/plain-node-entry-guard.ts b/build-plugins/plain-node-entry-guard.ts index a7ad171a402..13499118b65 100644 --- a/build-plugins/plain-node-entry-guard.ts +++ b/build-plugins/plain-node-entry-guard.ts @@ -26,6 +26,23 @@ const PLAIN_NODE_ENTRY_NAMES = [ 'codex/codex-app-server-grant-entry' ] as const +// Entries executed as worker threads of the main process. Electron's module is +// not registered on worker threads, so require("electron") throws +// "Cannot find module 'electron'" there too (verified on Electron 43) and kills +// the worker at startup. These carry hand-written "must stay electron-free" +// comments, which is convention, not enforcement — and the port-scan worker in +// particular sits one import away from a client module that deliberately does +// require electron. +const WORKER_THREAD_ENTRY_NAMES = [ + 'stt-worker', + 'warp-theme-parser-worker', + 'session-scanner-opencode-sqlite-worker-entry', + 'main-thread-hang-watchdog-entry', + 'port-scan-command-worker-entry' +] as const + +type EntryRuntime = 'plain-Node process' | 'worker thread' + const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/ function collectReachableChunks( @@ -56,13 +73,14 @@ function collectReachableChunks( function assertNoElectronRequire( entryName: string, entry: OutputChunk, - byFileName: Map + byFileName: Map, + runtime: EntryRuntime = 'plain-Node process' ): void { for (const chunk of collectReachableChunks(entry, byFileName)) { if (ELECTRON_REQUIRE_RE.test(chunk.code)) { throw new Error( `[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` + - `requires electron. "${entryName}" runs as a plain-Node process, where ` + + `requires electron. "${entryName}" runs as a ${runtime}, where ` + `require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` + `v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.` ) @@ -125,7 +143,14 @@ export function createPlainNodeEntryGuardPlugin(): Plugin { for (const entryName of PLAIN_NODE_ENTRY_NAMES) { const entry = entryByName.get(entryName) if (entry) { - assertNoElectronRequire(entryName, entry, byFileName) + assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process') + } + } + + for (const entryName of WORKER_THREAD_ENTRY_NAMES) { + const entry = entryByName.get(entryName) + if (entry) { + assertNoElectronRequire(entryName, entry, byFileName, 'worker thread') } } diff --git a/config/knip.json b/config/knip.json index a48793b7902..cb929266ff3 100644 --- a/config/knip.json +++ b/config/knip.json @@ -10,6 +10,7 @@ "src/main/speech/stt-worker.ts", "src/main/warp-themes/warp-theme-parser-worker.ts", "src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts", + "src/main/ports/port-scan-command-worker-entry.ts", "src/main/ipc/parcel-watcher-process-entry.ts", "src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts", "src/main/codex/codex-app-server-grant-entry.ts", diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 6e4803ed7ba..4bb219a5687 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -14,6 +14,21 @@ import { describe, expect, it } from 'vitest' const itCrossHost = process.platform === 'win32' ? it.skip : it const projectRoot = resolve(import.meta.dirname, '../..') +const WINDOWS_LOCK_CODES = ['EBUSY', 'ENOTEMPTY', 'EPERM'] + +// Why: Windows releases the image handle on a just-executed exe (and finishes the +// AV scan of the freshly compiled one) after the process exits, so tearing down the +// fixture races those locks. Retry, then leave the temp tree rather than reporting a +// teardown lock as a launcher failure. +function removeFixtureTree(path) { + try { + rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) + } catch (error) { + if (process.platform !== 'win32' || !WINDOWS_LOCK_CODES.includes(error?.code)) { + throw error + } + } +} // Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows; // keep the larger allowance scoped to the real compiler integration test. function itWindows(name, test) { @@ -35,7 +50,7 @@ describe('Windows CLI launcher', () => { expect(result.stderr).toContain('Windows CLI launcher') expect(result.stderr).toContain('Windows host') } finally { - rmSync(outputRoot, { recursive: true, force: true }) + removeFixtureTree(outputRoot) } }) @@ -108,7 +123,7 @@ describe('Windows CLI launcher', () => { orcaNodeOptions: '--no-warnings' }) } finally { - rmSync(appRoot, { recursive: true, force: true }) + removeFixtureTree(appRoot) } }) @@ -161,7 +176,7 @@ describe('Windows CLI launcher', () => { pathKeys: ['PATH', 'Path'] }) } finally { - rmSync(appRoot, { recursive: true, force: true }) + removeFixtureTree(appRoot) } }) }) diff --git a/config/scripts/plain-node-entry-guard.test.ts b/config/scripts/plain-node-entry-guard.test.ts index a7583e1d470..b9601a67d41 100644 --- a/config/scripts/plain-node-entry-guard.test.ts +++ b/config/scripts/plain-node-entry-guard.test.ts @@ -85,3 +85,91 @@ describe('plain Node entry guard', () => { ) }) }) + +// Why (#11161): Electron's module is not registered on worker threads either — +// require("electron") throws "Cannot find module 'electron'" inside a +// main-process worker and kills it at startup. The worker entries carried only +// hand-written "must stay electron-free" comments, and the port-scan worker sits +// one import away from a client that deliberately does require electron. +describe('worker thread entry guard', () => { + function runWorkerWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void { + const hook = plugin.writeBundle + if (typeof hook !== 'function') { + throw new Error('Expected writeBundle hook') + } + hook.call( + { meta: { watchMode: false } } as never, + { dir: createOutputDir() } as Rollup.NormalizedOutputOptions, + bundle + ) + } + + function workerChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk { + return { + type: 'chunk', + code, + dynamicImports: [], + fileName: `${name}.js`, + imports, + isEntry: true, + name + } as Rollup.OutputChunk + } + + it('rejects an Electron require reachable from a worker entry', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const bundle = { + 'port-scan-command-worker-entry.js': workerChunk( + 'port-scan-command-worker-entry', + 'require("electron")' + ) + } as Rollup.OutputBundle + + expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('requires electron') + }) + + it('names the worker-thread runtime so the failure is actionable', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const bundle = { + 'stt-worker.js': workerChunk('stt-worker', 'require("electron")') + } as Rollup.OutputBundle + + expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('runs as a worker thread') + }) + + // The real risk is transitive: a worker entry importing a shared chunk that + // reaches the electron-requiring client, not a direct import anyone would spot. + it('follows shared chunks out of a worker entry', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const bundle = { + 'session-scanner-opencode-sqlite-worker-entry.js': workerChunk( + 'session-scanner-opencode-sqlite-worker-entry', + 'require("./chunks/shared.js")', + ['chunks/shared.js'] + ), + 'chunks/shared.js': { + type: 'chunk', + code: 'require("electron")', + dynamicImports: [], + fileName: 'chunks/shared.js', + imports: [], + isEntry: false, + name: 'shared' + } as Rollup.OutputChunk + } as Rollup.OutputBundle + + expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('chunks/shared.js') + }) + + it('passes a clean worker entry', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const bundle = { + 'warp-theme-parser-worker.js': workerChunk( + 'warp-theme-parser-worker', + 'require("node:worker_threads")' + ) + } as Rollup.OutputBundle + + expect(() => runWorkerWriteBundle(plugin, bundle)).not.toThrow() + }) +}) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 39f923cb215..a6e656ac9c6 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -216,6 +216,11 @@ export const electronViteConfig: UserConfig = { 'session-scanner-opencode-sqlite-worker-entry': resolve( 'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts' ), + // Why: libuv spawns processes inline on the calling loop, so the port + // scan's probe commands run on a worker thread instead of the UI one. + 'port-scan-command-worker-entry': resolve( + 'src/main/ports/port-scan-command-worker-entry.ts' + ), // Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults // can't take down the main process (issue #7547). 'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'), diff --git a/src/main/ipc/localhost-worktree-labels.ts b/src/main/ipc/localhost-worktree-labels.ts index 382072f6c05..77c3c3d6503 100644 --- a/src/main/ipc/localhost-worktree-labels.ts +++ b/src/main/ipc/localhost-worktree-labels.ts @@ -42,7 +42,11 @@ async function assertAllowedTarget(store: Store, targetUrl: string): Promise { if (String(port.port) !== targetPort) { return false diff --git a/src/main/ipc/workspace-ports.test.ts b/src/main/ipc/workspace-ports.test.ts index 841c046092f..f5afe03628b 100644 --- a/src/main/ipc/workspace-ports.test.ts +++ b/src/main/ipc/workspace-ports.test.ts @@ -102,14 +102,18 @@ describe('registerWorkspacePortHandlers', () => { worktrees: [{ id: 'attacker', path: '/tmp/not-authorized', repoId: 'local-repo' }] }) - expect(scanWorkspacePortsMock).toHaveBeenCalledWith([ - { - id: 'local-repo::/workspace/repo', - repoId: 'local-repo', - displayName: 'Primary', - path: '/workspace/repo' - } - ]) + expect(scanWorkspacePortsMock).toHaveBeenCalledWith( + [ + { + id: 'local-repo::/workspace/repo', + repoId: 'local-repo', + displayName: 'Primary', + path: '/workspace/repo' + } + ], + undefined, + undefined + ) }) it('deduplicates concurrent scans for the same store-derived probe set', async () => { @@ -139,20 +143,24 @@ describe('registerWorkspacePortHandlers', () => { await handlers.get('workspacePorts:scan')?.(null, undefined) - expect(scanWorkspacePortsMock).toHaveBeenCalledWith([ - { - id: 'local-repo::/workspace/repo', - repoId: 'local-repo', - displayName: 'Primary', - path: '/workspace/repo' - }, - { - id: 'other-repo::/workspace/other', - repoId: 'other-repo', - displayName: 'Other', - path: '/workspace/other' - } - ]) + expect(scanWorkspacePortsMock).toHaveBeenCalledWith( + [ + { + id: 'local-repo::/workspace/repo', + repoId: 'local-repo', + displayName: 'Primary', + path: '/workspace/repo' + }, + { + id: 'other-repo::/workspace/other', + repoId: 'other-repo', + displayName: 'Other', + path: '/workspace/other' + } + ], + undefined, + undefined + ) }) it('stops a process only after the current scan proves the pid owns a workspace port', async () => { diff --git a/src/main/ports/local-workspace-port-scanner.test.ts b/src/main/ports/local-workspace-port-scanner.test.ts index a6eeb21e5ee..132eba1950e 100644 --- a/src/main/ports/local-workspace-port-scanner.test.ts +++ b/src/main/ports/local-workspace-port-scanner.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import path from 'node:path' import { attributePortToWorkspace, @@ -9,13 +9,21 @@ import { resetWorkspacePortScanTimeoutBackoffForTests, scanWorkspacePorts } from './local-workspace-port-scanner' +import { PortScanCommandTimeoutError } from './port-scan-command-protocol' -const execFileMock = vi.hoisted(() => vi.fn()) +const runPortScanCommandMock = vi.hoisted(() => vi.fn()) -vi.mock('child_process', () => ({ - execFile: execFileMock +vi.mock('./port-scan-command-client', () => ({ + runPortScanCommand: runPortScanCommandMock, + isPortScanWorkerUnavailableError: () => false })) +const LSOF_LISTEN_OUTPUT = ['p123', 'cnode', 'n127.0.0.1:5173'].join('\n') + +function urlWatcherStub(): { lookup: () => undefined; reconcileScan: Mock } { + return { lookup: () => undefined, reconcileScan: vi.fn() } +} + const worktrees = [ { id: 'repo::/repo', @@ -177,52 +185,42 @@ describe('scanWorkspacePorts attribution work', () => { afterEach(() => { resetWorkspacePortScanTimeoutBackoffForTests() vi.restoreAllMocks() - execFileMock.mockReset() + runPortScanCommandMock.mockReset() }) it('normalizes worktree paths once per scan instead of once per port phase', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') const win32ResolveSpy = vi.spyOn(path.win32, 'resolve') const posixResolveSpy = vi.spyOn(path.posix, 'resolve') - const invokeCallback = (callback: unknown, stdout: string): void => { - if (typeof callback !== 'function') { - throw new Error('missing execFile callback') - } - const execCallback = callback as (error: Error | null, stdout: string) => void - execCallback(null, stdout) - } - execFileMock.mockImplementation( - (command: string, args: string[], _options: unknown, callback: unknown) => { - if (command === 'lsof' && args.includes('-iTCP')) { - invokeCallback( - callback, - ['p123', 'cnode', 'n127.0.0.1:3000', 'p124', 'cnode', 'n127.0.0.1:3001'].join('\n') - ) - } else if (command === 'lsof') { - invokeCallback( - callback, - ['p123', 'n/repo/service', 'p124', 'n/repo/worktrees/feature/app'].join('\n') - ) - } else if (command === 'ps') { - invokeCallback( - callback, - [ - '123 node /repo/service/server.js', - '124 node /repo/worktrees/feature/app/server.js' - ].join('\n') - ) - } else { - invokeCallback(callback, '') + runPortScanCommandMock.mockImplementation(async (command: string, args: string[]) => { + if (command === 'lsof' && args.includes('-iTCP')) { + return { + stdout: ['p123', 'cnode', 'n127.0.0.1:3000', 'p124', 'cnode', 'n127.0.0.1:3001'].join( + '\n' + ), + spawnMs: 5 } - return { kill: vi.fn() } } - ) - - const scan = await scanWorkspacePorts(worktrees, { - lookup: () => undefined, - reconcileScan: vi.fn() + if (command === 'lsof') { + return { + stdout: ['p123', 'n/repo/service', 'p124', 'n/repo/worktrees/feature/app'].join('\n'), + spawnMs: 5 + } + } + if (command === 'ps') { + return { + stdout: [ + '123 node /repo/service/server.js', + '124 node /repo/worktrees/feature/app/server.js' + ].join('\n'), + spawnMs: 5 + } + } + return { stdout: '', spawnMs: 5 } }) + const scan = await scanWorkspacePorts(worktrees, urlWatcherStub()) + expect(scan.ports.filter((port) => port.kind === 'workspace')).toHaveLength(2) const win32WorktreePathResolveCalls = win32ResolveSpy.mock.calls.filter( ([input]) => input === '/repo' || input === '/repo/worktrees/feature' @@ -240,62 +238,39 @@ describe('scanWorkspacePorts command timeout', () => { vi.useRealTimers() resetWorkspacePortScanTimeoutBackoffForTests() vi.restoreAllMocks() - execFileMock.mockReset() + runPortScanCommandMock.mockReset() }) it('returns an unavailable scan when lsof never reports completion', async () => { - vi.useFakeTimers() vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') - const killMock = vi.fn() - execFileMock.mockImplementation(() => ({ kill: killMock })) + runPortScanCommandMock.mockRejectedValue( + new PortScanCommandTimeoutError('lsof timed out after 4000ms') + ) - let settled = false - const scanPromise = scanWorkspacePorts([], { - lookup: () => undefined, - reconcileScan: vi.fn() - }).then((scan) => { - settled = true - return scan - }) - - await vi.advanceTimersByTimeAsync(4_000) - - expect(settled).toBe(true) - await expect(scanPromise).resolves.toMatchObject({ + await expect(scanWorkspacePorts([], urlWatcherStub())).resolves.toMatchObject({ platform: 'darwin', ports: [], unavailableReason: 'Port scanning is unavailable on darwin.' }) - expect(killMock).toHaveBeenCalled() }) it('backs off after a command timeout instead of launching lsof on every scan tick', async () => { vi.useFakeTimers() vi.setSystemTime(1_000) vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') - const killMock = vi.fn() - execFileMock.mockImplementation(() => ({ kill: killMock })) + runPortScanCommandMock.mockRejectedValue( + new PortScanCommandTimeoutError('lsof timed out after 4000ms') + ) - const firstScanPromise = scanWorkspacePorts([], { - lookup: () => undefined, - reconcileScan: vi.fn() - }) - - await vi.advanceTimersByTimeAsync(4_000) - await expect(firstScanPromise).resolves.toMatchObject({ + await expect(scanWorkspacePorts([], urlWatcherStub())).resolves.toMatchObject({ platform: 'darwin', ports: [], unavailableReason: 'Port scanning is unavailable on darwin.' }) - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runPortScanCommandMock).toHaveBeenCalledTimes(1) const cooldownScans = await Promise.all( - Array.from({ length: 10 }, () => - scanWorkspacePorts([], { - lookup: () => undefined, - reconcileScan: vi.fn() - }) - ) + Array.from({ length: 10 }, () => scanWorkspacePorts([], urlWatcherStub())) ) expect(cooldownScans).toHaveLength(10) @@ -306,25 +281,172 @@ describe('scanWorkspacePorts command timeout', () => { expect( cooldownScans.every((scan) => scan.unavailableReason?.includes('temporarily paused')) ).toBe(true) - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runPortScanCommandMock).toHaveBeenCalledTimes(1) vi.setSystemTime(65_001) await vi.advanceTimersByTimeAsync(0) - execFileMock.mockImplementation( - (_command: string, args: string[], _options: unknown, callback: unknown) => { - const execCallback = callback as (error: Error | null, stdout: string) => void - const output = args.includes('-iTCP') ? 'p123\ncnode\nn127.0.0.1:3000' : '' - execCallback(null, output) - return { kill: vi.fn() } - } - ) + runPortScanCommandMock.mockImplementation(async (_command: string, args: string[]) => ({ + stdout: args.includes('-iTCP') ? 'p123\ncnode\nn127.0.0.1:3000' : '', + spawnMs: 5 + })) - const recoveredScan = await scanWorkspacePorts([], { - lookup: () => undefined, - reconcileScan: vi.fn() - }) + const recoveredScan = await scanWorkspacePorts([], urlWatcherStub()) expect(recoveredScan.unavailableReason).toBeUndefined() - expect(execFileMock).toHaveBeenCalledTimes(4) + expect(runPortScanCommandMock).toHaveBeenCalledTimes(4) }) }) + +describe('scanWorkspacePorts with delayed process creation', () => { + afterEach(() => { + resetWorkspacePortScanTimeoutBackoffForTests() + vi.restoreAllMocks() + runPortScanCommandMock.mockReset() + }) + + // Regression for #11161: an endpoint-security hook makes CreateProcessW take + // seconds, so the command's own budget must not be charged for the spawn. + it('does not report a command timeout when only process creation was delayed', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + runPortScanCommandMock.mockResolvedValue({ stdout: LSOF_LISTEN_OUTPUT, spawnMs: 4_200 }) + + const first = await scanWorkspacePorts([], urlWatcherStub()) + const second = await scanWorkspacePorts([], urlWatcherStub()) + + expect(first.unavailableReason).toBeUndefined() + expect(second.unavailableReason).toBeUndefined() + expect(first.ports).toHaveLength(1) + }) + + it('skips the optional metadata commands for one cycle after a stalled spawn', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + runPortScanCommandMock.mockResolvedValue({ stdout: LSOF_LISTEN_OUTPUT, spawnMs: 4_200 }) + + const scan = await scanWorkspacePorts([], urlWatcherStub()) + + expect(runPortScanCommandMock).toHaveBeenCalledTimes(1) + expect(scan.ports).toHaveLength(1) + }) + + // Regression for #11161 review: a metadata-less scan must not be reconciled as + // "the listener vanished" — that evicts advertised URLs only a PTY can restore. + it('does not reconcile advertised URLs for a scan that skipped metadata', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + runPortScanCommandMock.mockResolvedValue({ stdout: LSOF_LISTEN_OUTPUT, spawnMs: 4_200 }) + const watcher = urlWatcherStub() + + await scanWorkspacePorts(worktrees, watcher) + + expect(watcher.reconcileScan).not.toHaveBeenCalled() + }) + + it('re-probes metadata on the scan after a skip instead of degrading forever', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + runPortScanCommandMock.mockImplementation(async (command: string, args: string[]) => { + if (command === 'lsof' && args.includes('-iTCP')) { + return { stdout: LSOF_LISTEN_OUTPUT, spawnMs: 4_200 } + } + if (command === 'lsof') { + return { stdout: ['p123', 'n/repo'].join('\n'), spawnMs: 4_200 } + } + return { stdout: '123 node /repo/server.js', spawnMs: 4_200 } + }) + const watcher = urlWatcherStub() + + const skipped = await scanWorkspacePorts(worktrees, watcher) + const recovered = await scanWorkspacePorts(worktrees, watcher) + + expect(skipped.ports[0]?.kind).toBe('external') + expect(recovered.ports[0]).toMatchObject({ kind: 'workspace' }) + expect(runPortScanCommandMock).toHaveBeenCalledTimes(4) + expect(watcher.reconcileScan).toHaveBeenCalledTimes(worktrees.length) + }) + + it('still collects process metadata when process creation was fast', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + runPortScanCommandMock.mockImplementation(async (_command: string, args: string[]) => ({ + stdout: args.includes('-iTCP') ? LSOF_LISTEN_OUTPUT : '', + spawnMs: 5 + })) + + await scanWorkspacePorts([], urlWatcherStub()) + + expect(runPortScanCommandMock).toHaveBeenCalledTimes(3) + }) + + // Regression for #11161 review: the skip parity is driven by the 30s poller, + // so a one-shot user action would otherwise land on a random parity. + it('keeps probing metadata for callers that require attribution', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + mockStalledDarwinScan() + + const scan = await scanWorkspacePorts(worktrees, urlWatcherStub(), { requireMetadata: true }) + + expect(scan.ports[0]).toMatchObject({ kind: 'workspace' }) + expect(runPortScanCommandMock).toHaveBeenCalledTimes(3) + }) + + it('does not let a required-metadata scan reset the background skip parity', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + mockStalledDarwinScan() + + await scanWorkspacePorts(worktrees, urlWatcherStub()) + await scanWorkspacePorts(worktrees, urlWatcherStub(), { requireMetadata: true }) + await scanWorkspacePorts(worktrees, urlWatcherStub()) + + // 1 skipped + 3 required + 3 recovered; a reset parity would skip twice. + expect(runPortScanCommandMock).toHaveBeenCalledTimes(7) + }) + + // Regression for #11161 review: without carry-forward the panel moves every + // workspace port into External on each skipped cycle. + it('carries the previous cycle attribution through a skipped scan', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + let listenSpawnMs = 5 + runPortScanCommandMock.mockImplementation(async (command: string, args: string[]) => { + if (command === 'lsof' && args.includes('-iTCP')) { + return { stdout: LSOF_LISTEN_OUTPUT, spawnMs: listenSpawnMs } + } + return { stdout: command === 'lsof' ? ['p123', 'n/repo'].join('\n') : '', spawnMs: 5 } + }) + + const full = await scanWorkspacePorts(worktrees, urlWatcherStub()) + listenSpawnMs = 4_200 + const skipped = await scanWorkspacePorts(worktrees, urlWatcherStub()) + + expect(full.ports[0]).toMatchObject({ kind: 'workspace' }) + expect(skipped.ports[0]).toMatchObject({ kind: 'workspace', processName: 'node' }) + }) + + it('does not hand carried-forward metadata to a different listener', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + let listenOutput = LSOF_LISTEN_OUTPUT + let listenSpawnMs = 5 + runPortScanCommandMock.mockImplementation(async (command: string, args: string[]) => { + if (command === 'lsof' && args.includes('-iTCP')) { + return { stdout: listenOutput, spawnMs: listenSpawnMs } + } + return { stdout: command === 'lsof' ? ['p123', 'n/repo'].join('\n') : '', spawnMs: 5 } + }) + + await scanWorkspacePorts(worktrees, urlWatcherStub()) + listenOutput = ['p123', 'cnode', 'n127.0.0.1:9999'].join('\n') + listenSpawnMs = 4_200 + const skipped = await scanWorkspacePorts(worktrees, urlWatcherStub()) + + expect(skipped.ports[0]?.kind).toBe('external') + }) +}) + +/** Darwin scan where every spawn stalls past the metadata-skip threshold. */ +function mockStalledDarwinScan(): void { + runPortScanCommandMock.mockImplementation(async (command: string, args: string[]) => { + if (command === 'lsof' && args.includes('-iTCP')) { + return { stdout: LSOF_LISTEN_OUTPUT, spawnMs: 4_200 } + } + if (command === 'lsof') { + return { stdout: ['p123', 'n/repo'].join('\n'), spawnMs: 4_200 } + } + return { stdout: '123 node /repo/server.js', spawnMs: 4_200 } + }) +} diff --git a/src/main/ports/local-workspace-port-scanner.ts b/src/main/ports/local-workspace-port-scanner.ts index 1d8f4dbf891..ce2b7b7683c 100644 --- a/src/main/ports/local-workspace-port-scanner.ts +++ b/src/main/ports/local-workspace-port-scanner.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines -- Why: the platform-specific scan paths share parsing, attribution, and normalization rules that must stay in lockstep. */ -import { execFile } from 'node:child_process' import { readFile, readdir, readlink } from 'node:fs/promises' import path from 'node:path' import type { @@ -11,14 +10,36 @@ import type { } from '../../shared/workspace-ports' import { getProcessOutputFields } from '../../shared/process-output-field-scanner' import { advertisedUrlWatcher, type AdvertisedUrlWatcher } from './advertised-url-watcher' +import { isPortScanWorkerUnavailableError, runPortScanCommand } from './port-scan-command-client' +import { PortScanCommandTimeoutError } from './port-scan-command-protocol' import { WorkspacePortScanTimeoutBackoff } from './workspace-port-scan-timeout-backoff' -const COMMAND_TIMEOUT_MS = 4_000 +// Why (#11161): on an EDR-hooked host process creation alone can take seconds. +// Past this, skip the scan's optional metadata commands for one cycle so a scan +// costs roughly one stall instead of three. +const SLOW_SPAWN_SKIP_METADATA_MS = 2_000 const MAX_PORTS = 200 const HTTP_PORTS = new Set([80, 3000, 3001, 4200, 5000, 5173, 5174, 8000, 8080, 8888]) const HTTPS_PORTS = new Set([443, 8443]) const commandTimeoutBackoff = new WorkspacePortScanTimeoutBackoff() +let loggedWorkerUnavailable = false +// Why (#11161): a hooked host stalls every spawn, so gating only on the current +// scan would drop metadata forever. Never skip twice running, so attribution — +// and the Stop action and advertised-URL matching that ride on it — recovers on +// the next tick. +let skippedMetadataOnLastScan = false +// Why (#11161): a skipped cycle would otherwise report every listener as +// external, so the panel flip-flops and the Stop action loses its owner. Carry +// the previous cycle's metadata forward, keyed tightly enough that a recycled +// pid cannot inherit it. +let lastListenerMetadata = new Map() + +export type WorkspacePortScanOptions = { + /** Set by attribution-dependent callers (Stop, the localhost-label allowlist) + * that must never trade owner metadata for scan latency. */ + requireMetadata?: boolean +} type RawListeningPort = { host: string @@ -40,9 +61,16 @@ type NormalizedWorkspacePortProbe = { normalizedPath: string } +type PlatformListeningPortScan = { + ports: RawListeningPort[] + /** False when a stalled spawn made the scan skip its cwd/command-line probes. */ + metadataAvailable: boolean +} + export async function scanWorkspacePorts( worktrees: WorkspacePortProbe[], - urlWatcher: Pick = advertisedUrlWatcher + urlWatcher: Pick = advertisedUrlWatcher, + options: WorkspacePortScanOptions = {} ): Promise { const cooldown = commandTimeoutBackoff.snapshot() if (cooldown.isCoolingDown) { @@ -54,10 +82,15 @@ export async function scanWorkspacePorts( } try { - const rawPorts = await scanPlatformListeningPorts() + const { ports: rawPorts, metadataAvailable } = await scanPlatformListeningPorts(options) commandTimeoutBackoff.recordSuccess() const normalizedWorktrees = normalizeWorkspacePortProbes(worktrees) - reconcileAdvertisedUrls(rawPorts, normalizedWorktrees, urlWatcher) + // Why (#11161): without cwd/command-line every port looks unattributed, and + // reconciling that would read as "the listener vanished" and evict cached + // advertised URLs that only live PTY output can ever restore. + if (metadataAvailable) { + reconcileAdvertisedUrls(rawPorts, normalizedWorktrees, urlWatcher) + } const ports = rawPorts .map((port) => enrichPort(port, normalizedWorktrees, urlWatcher)) .sort(compareWorkspacePorts) @@ -67,13 +100,66 @@ export async function scanWorkspacePorts( if (isCommandTimeoutError(error)) { commandTimeoutBackoff.recordTimeout() } - console.warn('[workspace-ports] scan failed', error) + warnScanFailure(error) return makeUnavailableScan(`Port scanning is unavailable on ${process.platform}.`) } } export function resetWorkspacePortScanTimeoutBackoffForTests(): void { commandTimeoutBackoff.reset() + loggedWorkerUnavailable = false + skippedMetadataOnLastScan = false + lastListenerMetadata = new Map() +} + +function shouldSkipMetadataCommands(spawnMs: number, opts: WorkspacePortScanOptions): boolean { + if (opts.requireMetadata) { + // Leave the skip parity alone: it belongs to the background scan cadence, + // and a one-shot user action must not shift which tick degrades. + return false + } + const skip = spawnMs > SLOW_SPAWN_SKIP_METADATA_MS && !skippedMetadataOnLastScan + skippedMetadataOnLastScan = skip + return skip +} + +/** Identity tight enough that a recycled pid cannot inherit stale metadata. */ +function listenerMetadataKey(port: RawListeningPort): string { + return `${port.pid ?? 'unknown'}:${port.host}:${port.port}` +} + +function rememberListenerMetadata(ports: readonly RawListeningPort[]): void { + lastListenerMetadata = new Map( + ports.map((port) => [ + listenerMetadataKey(port), + { processName: port.processName, commandLine: port.commandLine, cwd: port.cwd } + ]) + ) +} + +function recallListenerMetadata(port: RawListeningPort): RawListeningPort { + const remembered = lastListenerMetadata.get(listenerMetadataKey(port)) + if (!remembered) { + return port + } + return { + ...port, + processName: port.processName ?? remembered.processName, + commandLine: port.commandLine ?? remembered.commandLine, + cwd: port.cwd ?? remembered.cwd + } +} + +// Why: a mispackaged probe worker fails identically forever, so logging it on +// every 30s scan tick is pure noise. +function warnScanFailure(error: unknown): void { + if (isPortScanWorkerUnavailableError(error)) { + if (loggedWorkerUnavailable) { + return + } + loggedWorkerUnavailable = true + } + console.warn('[workspace-ports] scan failed', error) } function makeUnavailableScan(reason: string): WorkspacePortScanResult { @@ -194,38 +280,73 @@ export function parseProcNetTcp(content: string): { host: string; port: number; return results } -async function scanPlatformListeningPorts(): Promise { +async function scanPlatformListeningPorts( + options: WorkspacePortScanOptions +): Promise { + const scan = await dispatchPlatformListeningPortScan(options) + if (scan.metadataAvailable) { + rememberListenerMetadata(scan.ports) + return scan + } + return { ...scan, ports: scan.ports.map(recallListenerMetadata) } +} + +async function dispatchPlatformListeningPortScan( + options: WorkspacePortScanOptions +): Promise { if (process.platform === 'linux') { return scanLinuxProcPorts() } if (process.platform === 'darwin') { - return scanDarwinLsofPorts() + return scanDarwinLsofPorts(options) } if (process.platform === 'win32') { - return scanWindowsNetstatPorts() + return scanWindowsNetstatPorts(options) } throw new Error(`Port scanning is not supported on ${process.platform}`) } -async function scanDarwinLsofPorts(): Promise { - const { stdout } = await runCommand('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn']) +async function scanDarwinLsofPorts( + options: WorkspacePortScanOptions +): Promise { + const { stdout, spawnMs } = await runPortScanCommand('lsof', [ + '-nP', + '-iTCP', + '-sTCP:LISTEN', + '-F', + 'pcn' + ]) const ports = parseLsofListeningOutput(stdout) + if (shouldSkipMetadataCommands(spawnMs, options)) { + return { ports, metadataAvailable: false } + } const metadata = await loadDarwinProcessMetadata( new Set(ports.flatMap((p) => (p.pid ? [p.pid] : []))) ) - return ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })) + return { + ports: ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })), + metadataAvailable: true + } } -async function scanWindowsNetstatPorts(): Promise { - const { stdout } = await runCommand('netstat', ['-ano', '-p', 'tcp']) +async function scanWindowsNetstatPorts( + options: WorkspacePortScanOptions +): Promise { + const { stdout, spawnMs } = await runPortScanCommand('netstat', ['-ano', '-p', 'tcp']) const ports = parseNetstatListeningOutput(stdout) + if (shouldSkipMetadataCommands(spawnMs, options)) { + return { ports, metadataAvailable: false } + } const metadata = await loadWindowsProcessMetadata( new Set(ports.flatMap((p) => (p.pid ? [p.pid] : []))) ) - return ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })) + return { + ports: ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })), + metadataAvailable: true + } } -async function scanLinuxProcPorts(): Promise { +async function scanLinuxProcPorts(): Promise { const [tcp4, tcp6] = await Promise.all([ readProcNet('/proc/net/tcp'), readProcNet('/proc/net/tcp6') @@ -248,7 +369,7 @@ async function scanLinuxProcPorts(): Promise { }) } - return dedupeRawPorts(rawPorts) + return { ports: dedupeRawPorts(rawPorts), metadataAvailable: true } } async function readProcNet( @@ -321,10 +442,24 @@ async function loadDarwinProcessMetadata(pids: Set): Promise null), - runCommand('ps', ['-p', pidList, '-o', 'pid=', '-o', 'command=']).catch(() => null) - ]) + // Why (#11161): sequential, not Promise.all — the probe worker dispatches one + // command at a time, so issuing both at once would only queue the second. + const cwdOutput = await runPortScanCommand('lsof', [ + '-a', + '-p', + pidList, + '-d', + 'cwd', + '-Fn' + ]).catch(() => null) + const commandOutput = await runPortScanCommand('ps', [ + '-p', + pidList, + '-o', + 'pid=', + '-o', + 'command=' + ]).catch(() => null) let currentPid: number | null = null for (const line of cwdOutput?.stdout.split('\n') ?? []) { @@ -360,7 +495,7 @@ async function loadWindowsProcessMetadata( .filter(Number.isFinite) .map((pid) => `ProcessId=${pid}`) .join(' OR ') - const { stdout } = await runCommand('powershell.exe', [ + const { stdout } = await runPortScanCommand('powershell.exe', [ '-NoProfile', '-Command', `Get-CimInstance Win32_Process -Filter "${pidFilter}" | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress` @@ -382,62 +517,8 @@ async function loadWindowsProcessMetadata( return result } -async function runCommand(command: string, args: string[]): Promise<{ stdout: string }> { - return await new Promise((resolve, reject) => { - let settled = false - let child: ReturnType | undefined - const timer = setTimeout(() => { - if (settled) { - return - } - settled = true - child?.kill() - reject(new CommandTimeoutError(command, COMMAND_TIMEOUT_MS)) - }, COMMAND_TIMEOUT_MS) - - const settle = (callback: () => void): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - callback() - } - - // Why: Node's execFile timeout only signals the child; if the callback - // never arrives, the workspace port scan would otherwise hang forever. - try { - child = execFile( - command, - args, - { - timeout: COMMAND_TIMEOUT_MS, - maxBuffer: 2 * 1024 * 1024, - windowsHide: true - }, - (error, stdout) => { - if (error) { - settle(() => reject(error)) - return - } - settle(() => resolve({ stdout: String(stdout) })) - } - ) - } catch (error) { - settle(() => reject(error)) - } - }) -} - -class CommandTimeoutError extends Error { - constructor(command: string, timeoutMs: number) { - super(`${command} timed out after ${timeoutMs}ms`) - this.name = 'CommandTimeoutError' - } -} - function isCommandTimeoutError(error: unknown): boolean { - return error instanceof CommandTimeoutError + return error instanceof PortScanCommandTimeoutError } async function readTextIfAvailable(filePath: string): Promise { diff --git a/src/main/ports/port-scan-command-client.test.ts b/src/main/ports/port-scan-command-client.test.ts new file mode 100644 index 00000000000..d1a15d29774 --- /dev/null +++ b/src/main/ports/port-scan-command-client.test.ts @@ -0,0 +1,300 @@ +import { readFileSync } from 'node:fs' +import { join, sep } from 'node:path' +import { Worker } from 'node:worker_threads' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CALL_DEADLINE_MS, + MAX_QUEUED_CALLS, + PortScanCommandClient, + isPortScanWorkerUnavailableError, + resolveWorkerEntryPath +} from './port-scan-command-client' +import { + PortScanCommandTimeoutError, + type PortScanCommandRequest +} from './port-scan-command-protocol' + +type PortScanCommandResponseBody = + | { ok: true; stdout: string; spawnMs: number } + | { ok: false; timedOut: boolean; error: string } + +const execFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('child_process', () => ({ + execFile: execFileMock +})) + +// A worker_threads stand-in the tests drive directly: it records posted requests +// and lets a test emit message/error/exit without a built worker bundle. +class FakeWorker { + postedRequests: PortScanCommandRequest[] = [] + terminated = false + private listeners = new Map void>>() + + on(event: string, listener: (arg?: unknown) => void): this { + const set = this.listeners.get(event) ?? new Set() + set.add(listener) + this.listeners.set(event, set) + return this + } + + off(event: string, listener: (arg?: unknown) => void): this { + this.listeners.get(event)?.delete(listener) + return this + } + + removeAllListeners(): void { + this.listeners.clear() + } + + unref(): void {} + + async terminate(): Promise { + this.terminated = true + return 1 + } + + postMessage(request: PortScanCommandRequest): void { + this.postedRequests.push(request) + } + + emit(event: string, arg?: unknown): void { + // Copy first: the client removes its listeners synchronously during a fault. + for (const listener of Array.from(this.listeners.get(event) ?? [])) { + listener(arg) + } + } + + respond(body: PortScanCommandResponseBody): void { + const last = this.postedRequests.at(-1) + if (!last) { + throw new Error('no request posted to fake worker') + } + this.emit('message', { id: last.id, ...body }) + } +} + +function makeFactory(workers: FakeWorker[]): () => Worker { + return () => { + const worker = new FakeWorker() + workers.push(worker) + return worker as unknown as Worker + } +} + +function makeClient(workers: FakeWorker[]): PortScanCommandClient { + return new PortScanCommandClient({ workerFactory: makeFactory(workers), log() {} }) +} + +describe('PortScanCommandClient', () => { + afterEach(() => { + vi.useRealTimers() + execFileMock.mockReset() + }) + + it('dispatches one command at a time so a stalled spawn cannot fan out', async () => { + const workers: FakeWorker[] = [] + const client = makeClient(workers) + + const first = client.run('lsof', ['-nP']) + const second = client.run('ps', ['-p', '1']) + await Promise.resolve() + + // Why (#11161): a second in-flight request would have its deadline armed + // while the worker's loop is still blocked inside the first uv_spawn. + expect(workers[0].postedRequests.map((request) => request.command)).toEqual(['lsof']) + + workers[0].respond({ ok: true, stdout: 'lsof-out', spawnMs: 12 }) + await expect(first).resolves.toEqual({ stdout: 'lsof-out', spawnMs: 12 }) + expect(workers[0].postedRequests.map((request) => request.command)).toEqual(['lsof', 'ps']) + + workers[0].respond({ ok: true, stdout: 'ps-out', spawnMs: 9 }) + await expect(second).resolves.toEqual({ stdout: 'ps-out', spawnMs: 9 }) + }) + + it('ignores responses that do not correlate with the active request', async () => { + const workers: FakeWorker[] = [] + const client = makeClient(workers) + + const pending = client.run('lsof', []) + await Promise.resolve() + workers[0].emit('message', { id: 999, ok: true, stdout: 'stale', spawnMs: 1 }) + workers[0].respond({ ok: true, stdout: 'fresh', spawnMs: 3 }) + + await expect(pending).resolves.toMatchObject({ stdout: 'fresh' }) + }) + + it('rehydrates a worker-side command timeout so the scan can back off', async () => { + const workers: FakeWorker[] = [] + const client = makeClient(workers) + + const pending = client.run('lsof', []) + await Promise.resolve() + workers[0].respond({ ok: false, timedOut: true, error: 'lsof timed out after 4000ms' }) + + await expect(pending).rejects.toBeInstanceOf(PortScanCommandTimeoutError) + }) + + it('reports a worker crash as a non-timeout error and respawns for the next call', async () => { + const workers: FakeWorker[] = [] + const client = makeClient(workers) + + const pending = client.run('lsof', []) + await Promise.resolve() + workers[0].emit('error', new Error('worker blew up')) + + const error = await pending.catch((err: unknown) => err) + expect(error).toBeInstanceOf(Error) + expect(error).not.toBeInstanceOf(PortScanCommandTimeoutError) + expect(workers[0].terminated).toBe(true) + + const next = client.run('ps', []) + await Promise.resolve() + expect(workers).toHaveLength(2) + workers[1].respond({ ok: true, stdout: 'ps-out', spawnMs: 2 }) + await expect(next).resolves.toMatchObject({ stdout: 'ps-out' }) + }) + + it('terminates a silent worker at the call deadline without arming the backoff', async () => { + vi.useFakeTimers() + const workers: FakeWorker[] = [] + const client = makeClient(workers) + + const pending = client.run('netstat', ['-ano']) + const settled = pending.catch((err: unknown) => err) + await vi.advanceTimersByTimeAsync(CALL_DEADLINE_MS) + + const error = await settled + expect(error).toBeInstanceOf(Error) + expect(error).not.toBeInstanceOf(PortScanCommandTimeoutError) + expect(workers[0].terminated).toBe(true) + }) + + it('rejects overflow instead of growing the queue without bound', async () => { + const workers: FakeWorker[] = [] + const client = makeClient(workers) + + const accepted = Array.from({ length: MAX_QUEUED_CALLS + 1 }, () => client.run('lsof', [])) + const overflow = client.run('lsof', []) + + const error = await overflow.catch((err: unknown) => err) + expect(error).toBeInstanceOf(Error) + expect(error).not.toBeInstanceOf(PortScanCommandTimeoutError) + + for (let i = 0; i < accepted.length; i++) { + workers[0].respond({ ok: true, stdout: 'drained', spawnMs: 1 }) + } + await expect(Promise.all(accepted)).resolves.toHaveLength(MAX_QUEUED_CALLS + 1) + }) + + it('fails closed instead of spawning the command on this thread', async () => { + const client = new PortScanCommandClient({ + workerFactory: () => { + throw new Error('worker entry not found') + }, + log() {} + }) + + const error = await client.run('lsof', []).catch((err: unknown) => err) + + expect(isPortScanWorkerUnavailableError(error)).toBe(true) + expect(execFileMock).not.toHaveBeenCalled() + }) +}) + +// Why: the packaged branch never runs in dev or e2e (both take the __dirname +// path), so it is pinned here at the path-construction level. Whether Electron's +// asar shim can load a Worker entry from inside app.asar is not testable here — +// that still needs a packaged smoke test. +describe('resolveWorkerEntryPath', () => { + const WORKER_ENTRY_FILENAME = 'port-scan-command-worker-entry.js' + + it('resolves a packaged build under resourcesPath/app.asar/out/main', () => { + const resourcesPath = join(sep, 'Applications', 'Orca.app', 'Contents', 'Resources') + + const resolved = resolveWorkerEntryPath({ + isPackaged: true, + resourcesPath, + moduleDir: join(sep, 'unpackaged', 'out', 'main') + }) + + expect(resolved.startsWith(`${resourcesPath}${sep}`)).toBe(true) + expect(resolved.slice(resourcesPath.length + 1).split(sep)).toEqual([ + 'app.asar', + 'out', + 'main', + WORKER_ENTRY_FILENAME + ]) + }) + + it('ignores resourcesPath when the app is not packaged', () => { + const moduleDir = join(sep, 'repo', 'out', 'main') + + const resolved = resolveWorkerEntryPath({ + isPackaged: false, + resourcesPath: join(sep, 'Applications', 'Orca.app', 'Contents', 'Resources'), + moduleDir + }) + + expect(resolved).toBe(join(moduleDir, WORKER_ENTRY_FILENAME)) + expect(resolved).not.toContain('app.asar') + }) + + // A rename in the build config would leave both branches pointing at a file + // that is never emitted, and only the packaged one fails silently. + it('names the entry the main build actually emits', () => { + const config = readFileSync( + join(import.meta.dirname, '..', '..', '..', 'electron.vite.config.ts'), + 'utf8' + ) + + expect(config).toContain("'port-scan-command-worker-entry': resolve(") + }) +}) + +const REAL_WORKER_BLOCK_MS = 800 + +// Mirrors the worker entry's protocol but blocks its own thread first, standing +// in for the endpoint-security hook that makes CreateProcessW take seconds. +const BLOCKING_WORKER_SCRIPT = ` +const { parentPort } = require('node:worker_threads') +const { execFile } = require('node:child_process') +parentPort.on('message', (request) => { + const startedAt = Date.now() + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${REAL_WORKER_BLOCK_MS}) + execFile(process.execPath, ['-e', ''], () => { + parentPort.postMessage({ + id: request.id, + ok: true, + stdout: 'ok', + spawnMs: Date.now() - startedAt + }) + }) +}) +` + +describe('PortScanCommandClient on a real worker thread', () => { + it('keeps the calling event loop responsive while a spawn stalls', async () => { + const client = new PortScanCommandClient({ + workerFactory: () => new Worker(BLOCKING_WORKER_SCRIPT, { eval: true }), + log() {} + }) + + let last = Date.now() + let maxStallMs = 0 + const probe = setInterval(() => { + const now = Date.now() + maxStallMs = Math.max(maxStallMs, now - last - 10) + last = now + }, 10) + try { + const results = await Promise.all([client.run('lsof', []), client.run('ps', [])]) + + expect(results.map((result) => result.stdout)).toEqual(['ok', 'ok']) + expect(results[0].spawnMs).toBeGreaterThanOrEqual(REAL_WORKER_BLOCK_MS - 100) + expect(maxStallMs).toBeLessThan(400) + } finally { + clearInterval(probe) + } + }, 30_000) +}) diff --git a/src/main/ports/port-scan-command-client.ts b/src/main/ports/port-scan-command-client.ts new file mode 100644 index 00000000000..9748a8cfc2b --- /dev/null +++ b/src/main/ports/port-scan-command-client.ts @@ -0,0 +1,364 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { + PORT_SCAN_COMMAND_TIMEOUT_MS, + PortScanCommandTimeoutError, + type PortScanCommandRequest, + type PortScanCommandResponse +} from './port-scan-command-protocol' + +// Why (#11161): a lazily-spawned, unref'd worker runs the port scan's probe +// spawns off the Electron main-process event loop, because libuv performs +// process creation inline on the calling thread. Lifecycle (FIFO one-at-a-time +// dispatch, per-call deadlines, respawn-on-fault, idle teardown, fail-closed) +// mirrors src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts; +// the duplicated ~150 lines are cheaper than a premature shared abstraction, so +// a third adopter should extract one. +// +// This module contains the literal text require('electron'), so it must never +// become reachable from a plain-Node fork entry (build-plugins/ +// plain-node-entry-guard.ts fails the build on that text, try/catch or not). + +// Why: the worker's own loop absorbs the spawn stall, so the client only needs +// a backstop for a wedged thread. Kept at 30s because a scan sits on the +// user-blocking localhost-label allowlist path (src/main/ipc/ +// localhost-worktree-labels.ts). +export const WORKER_STALL_GRACE_MS = 26_000 +export const CALL_DEADLINE_MS = PORT_SCAN_COMMAND_TIMEOUT_MS + WORKER_STALL_GRACE_MS +// Deliberately far longer than the 30s scan cadence so a visible window does not +// re-create the worker every tick; the renderer stops the interval when hidden, +// so this is effectively the hidden-window teardown. +export const IDLE_TEARDOWN_MS = 5 * 60_000 +export const MAX_CONSECUTIVE_DEATHS = 3 +// One scan issues at most three commands; anything beyond this is pile-up. +export const MAX_QUEUED_CALLS = 8 + +export type PortScanCommandResult = { stdout: string; spawnMs: number } +export type PortScanWorkerFactory = () => Worker + +// Distinguishes "no worker at all" from a timeout or crash so the scanner can +// log it once and callers never mistake it for a command timeout. +class PortScanWorkerUnavailableError extends Error {} + +/** True when a scan failed because the probe worker could not be started. */ +export function isPortScanWorkerUnavailableError(error: unknown): boolean { + return error instanceof PortScanWorkerUnavailableError +} + +type PendingCall = { + request: PortScanCommandRequest + resolve: (value: PortScanCommandResult) => void + reject: (error: Error) => void + timer: NodeJS.Timeout | null +} + +/** + * Main-thread bridge that runs port-scan probe commands on a persistent worker + * thread. Dispatches one command at a time (FIFO), times each call out from + * dispatch, respawns after faults (capped by `MAX_CONSECUTIVE_DEATHS`), tears + * the worker down after `IDLE_TEARDOWN_MS`, and fails closed when no worker can + * be spawned rather than moving process creation back onto the main thread. + */ +export class PortScanCommandClient { + private worker: Worker | null = null + private active: PendingCall | null = null + private queue: PendingCall[] = [] + private idleTimer: NodeJS.Timeout | null = null + private consecutiveDeaths = 0 + private nextId = 1 + private loggedWorkerUnavailable = false + private cleanupWorkerListeners: (() => void) | null = null + private readonly workerFactory: PortScanWorkerFactory + private readonly log: (message: string) => void + + constructor(options: { workerFactory: PortScanWorkerFactory; log?: (message: string) => void }) { + this.workerFactory = options.workerFactory + this.log = options.log ?? ((message) => console.warn(message)) + } + + /** + * Run one probe command on the worker. + * @param command - Executable name (lsof, ps, netstat, powershell.exe). + * @param args - Argument vector passed verbatim to execFile. + * @returns The command's stdout plus its measured process-creation latency. + */ + run(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + if (this.queue.length >= MAX_QUEUED_CALLS) { + reject(new Error(`Port scan command queue is full; dropped ${command}.`)) + return + } + // A fresh burst from full idle starts a new scan: clear any death count + // carried from a prior scan so the respawn cap can't drain this scan early. + if (!this.active && this.queue.length === 0) { + this.consecutiveDeaths = 0 + } + this.queue.push({ + request: { id: this.nextId++, command, args }, + resolve, + reject, + timer: null + }) + this.pump() + }) + } + + private pump(): void { + if (this.active || this.queue.length === 0) { + return + } + const worker = this.ensureWorker() + if (!worker) { + this.failQueuedAsUnavailable() + return + } + const call = this.queue.shift() + if (!call) { + return + } + this.active = call + this.clearIdleTimer() + // Why (#11161): one at a time. uv_spawn blocks the worker's own loop, so a + // second concurrent request would have its deadline armed while the first + // spawn is still stalling the thread, producing a false timeout. + call.timer = setTimeout(() => this.onDeadline(call), CALL_DEADLINE_MS) + call.timer.unref?.() + worker.postMessage(call.request) + } + + private ensureWorker(): Worker | null { + if (this.worker) { + return this.worker + } + try { + const worker = this.workerFactory() + const onMessage = (response: PortScanCommandResponse): void => this.onMessage(response) + const onError = (error: Error): void => this.onWorkerFault(error) + const onExit = (code: number): void => this.onWorkerExit(code) + worker.on('message', onMessage) + worker.on('error', onError) + worker.on('exit', onExit) + this.cleanupWorkerListeners = () => { + worker.off('message', onMessage) + worker.off('error', onError) + worker.off('exit', onExit) + } + // Never keep the app alive for a port scan. + worker.unref?.() + this.worker = worker + return worker + } catch (err) { + // Why (#11161): never fall back to in-process execFile here; a missing + // bundle must report port scanning as unavailable rather than reintroduce + // the main-thread freeze this worker boundary exists to prevent. + if (!this.loggedWorkerUnavailable) { + this.loggedWorkerUnavailable = true + this.log(`[workspace-ports] probe worker unavailable. ${errorMessage(err)}`) + } + return null + } + } + + private onMessage(response: PortScanCommandResponse): void { + const call = this.active + if (!call || call.request.id !== response.id) { + return + } + this.consecutiveDeaths = 0 + if (response.ok) { + this.settle(call, () => call.resolve({ stdout: response.stdout, spawnMs: response.spawnMs })) + } else { + const error = response.timedOut + ? new PortScanCommandTimeoutError(response.error) + : new Error(response.error) + this.settle(call, () => call.reject(error)) + } + this.afterSettle() + } + + private onDeadline(call: PendingCall): void { + if (this.active !== call) { + return + } + // Plain Error on purpose: a wedged worker is not a command timeout and must + // never feed the scanner's timeout backoff. + this.onWorkerFault(new Error(`Port scan probe worker stalled after ${CALL_DEADLINE_MS}ms`)) + } + + private onWorkerExit(code: number): void { + // A clean self-exit is not a death, but the stale handle must be dropped or + // the next dispatch would post into a dead worker and stall to its deadline. + if (code === 0 && !this.active && this.queue.length === 0) { + this.destroyWorker() + return + } + this.onWorkerFault(new Error(`Port scan probe worker exited with code ${code}`)) + } + + private onWorkerFault(error: Error): void { + const failed = this.active + this.destroyWorker() + this.consecutiveDeaths++ + if (failed) { + this.settle(failed, () => failed.reject(error)) + } + if (this.consecutiveDeaths >= MAX_CONSECUTIVE_DEATHS) { + this.drainQueueAfterCrashLoop(error) + return + } + if (this.queue.length > 0) { + this.pump() + } + } + + private drainQueueAfterCrashLoop(error: Error): void { + const pending = this.queue + this.queue = [] + this.consecutiveDeaths = 0 + const drainError = new Error(`Port scan probe worker crashed repeatedly (${error.message})`) + for (const call of pending) { + this.settle(call, () => call.reject(drainError)) + } + } + + private failQueuedAsUnavailable(): void { + const pending = this.queue + this.queue = [] + for (const call of pending) { + this.settle(call, () => + call.reject(new PortScanWorkerUnavailableError('port scan probe worker spawn failed')) + ) + } + } + + private settle(call: PendingCall, run: () => void): void { + if (call.timer) { + clearTimeout(call.timer) + call.timer = null + } + if (this.active === call) { + this.active = null + } + run() + } + + private afterSettle(): void { + if (this.queue.length > 0) { + this.pump() + } else { + this.scheduleIdleTeardown() + } + } + + private scheduleIdleTeardown(): void { + this.clearIdleTimer() + if (!this.worker) { + return + } + this.idleTimer = setTimeout(() => this.teardownIfIdle(), IDLE_TEARDOWN_MS) + this.idleTimer.unref?.() + } + + private teardownIfIdle(): void { + this.idleTimer = null + // Only tear down with nothing active AND nothing queued: a request arriving + // as the timer fires must never be lost to a self-exiting worker. + if (this.active || this.queue.length > 0) { + return + } + this.destroyWorker() + } + + private clearIdleTimer(): void { + if (this.idleTimer) { + clearTimeout(this.idleTimer) + this.idleTimer = null + } + } + + private destroyWorker(): void { + this.clearIdleTimer() + const worker = this.worker + this.worker = null + if (!worker) { + return + } + this.cleanupWorkerListeners?.() + this.cleanupWorkerListeners = null + worker.removeAllListeners() + // Terminating can orphan a probe child mid-spawn; the worker reaps what it + // can on exit, and every probe here is short-lived. + void worker.terminate().catch(() => undefined) + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +const WORKER_ENTRY_FILENAME = 'port-scan-command-worker-entry.js' + +/** Where the built worker entry can live: packaged resources or the build dir. */ +export type WorkerEntryLayout = { + isPackaged: boolean + resourcesPath: string + moduleDir: string +} + +/** + * Resolve the built worker entry for one runtime layout. + * @param layout - Packaged flag plus both candidate roots. + * @returns Path passed to `new Worker()`. + */ +export function resolveWorkerEntryPath(layout: WorkerEntryLayout): string { + // Packaged builds leave this entry inside app.asar — only forked child + // processes are asarUnpack'd — so it resolves off resourcesPath rather than + // the bundler's __dirname, matching the shipped stt/warp/opencode workers. + // Split out from the electron read so the packaged branch is testable without + // a packaged build. + if (layout.isPackaged) { + return join(layout.resourcesPath, 'app.asar', 'out', 'main', WORKER_ENTRY_FILENAME) + } + return join(layout.moduleDir, WORKER_ENTRY_FILENAME) +} + +function currentWorkerEntryLayout(): WorkerEntryLayout { + let app: { isPackaged: boolean } | null = null + try { + app = require('electron').app ?? null + } catch { + app = null + } + return { + isPackaged: app?.isPackaged === true, + resourcesPath: process.resourcesPath, + moduleDir: __dirname + } +} + +function defaultWorkerFactory(): Worker { + const workerPath = resolveWorkerEntryPath(currentWorkerEntryLayout()) + // Why: a missing built entry must throw synchronously so the client can fail + // closed before it waits on a worker that can never post a result. + if (!existsSync(workerPath)) { + throw new Error(`Port scan command worker entry not found: ${workerPath}`) + } + return new Worker(workerPath) +} + +let sharedClient: PortScanCommandClient | null = null + +/** + * Run a port-scan probe command through the process-wide worker client. + * @param command - Executable name (lsof, ps, netstat, powershell.exe). + * @param args - Argument vector passed verbatim to execFile. + * @returns The command's stdout plus its measured process-creation latency. + */ +export function runPortScanCommand( + command: string, + args: string[] +): Promise { + sharedClient ??= new PortScanCommandClient({ workerFactory: defaultWorkerFactory }) + return sharedClient.run(command, args) +} diff --git a/src/main/ports/port-scan-command-execution.test.ts b/src/main/ports/port-scan-command-execution.test.ts new file mode 100644 index 00000000000..15e97d4a3a9 --- /dev/null +++ b/src/main/ports/port-scan-command-execution.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runPortScanCommandInProcess } from './port-scan-command-execution' +import { + PORT_SCAN_COMMAND_TIMEOUT_MS, + PortScanCommandTimeoutError, + WATCHDOG_GRACE_MS +} from './port-scan-command-protocol' + +const execFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('child_process', () => ({ + execFile: execFileMock +})) + +// Why (#11161): must outlast the whole watchdog budget, otherwise a watchdog +// armed before execFile still survives the stall and the ordering goes unpinned. +const SPAWN_STALL_MS = PORT_SCAN_COMMAND_TIMEOUT_MS + WATCHDOG_GRACE_MS + 200 +const LSOF_OUTPUT = ['p123', 'cnode', 'n127.0.0.1:5173'].join('\n') + +/** Emulates a hooked CreateProcessW: blocks the calling thread inside uv_spawn. */ +function blockCallingThread(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) +} + +describe('runPortScanCommandInProcess', () => { + afterEach(() => { + vi.useRealTimers() + execFileMock.mockReset() + }) + + it('arms the watchdog only after process creation returns', async () => { + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: unknown) => { + blockCallingThread(SPAWN_STALL_MS) + // The command itself is healthy once it finally starts. + setTimeout(() => (callback as (e: null, out: string) => void)(null, LSOF_OUTPUT), 5) + return { kill: vi.fn() } + } + ) + + const result = await runPortScanCommandInProcess('lsof', ['-nP', '-iTCP']) + + expect(result.stdout).toBe(LSOF_OUTPUT) + expect(result.spawnMs).toBeGreaterThanOrEqual(SPAWN_STALL_MS - 100) + }) + + it('kills the child and times out when the callback never arrives', async () => { + vi.useFakeTimers() + const killMock = vi.fn() + execFileMock.mockImplementation(() => ({ kill: killMock })) + + let settled = false + const promise = runPortScanCommandInProcess('lsof', []).catch((error: unknown) => { + settled = true + return error + }) + + await vi.advanceTimersByTimeAsync(PORT_SCAN_COMMAND_TIMEOUT_MS) + expect(settled).toBe(false) + + await vi.advanceTimersByTimeAsync(WATCHDOG_GRACE_MS) + + expect(await promise).toBeInstanceOf(PortScanCommandTimeoutError) + expect(killMock).toHaveBeenCalled() + }) + + it("classifies Node's own execFile timeout kill as a command timeout", async () => { + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: unknown) => { + const killed = Object.assign(new Error('Command failed: lsof'), { + killed: true, + signal: 'SIGTERM' + }) + setTimeout(() => (callback as (e: Error) => void)(killed), 0) + return { kill: vi.fn() } + } + ) + + await expect(runPortScanCommandInProcess('lsof', [])).rejects.toBeInstanceOf( + PortScanCommandTimeoutError + ) + }) + + it('leaves a genuine command failure unclassified so the scan does not back off', async () => { + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: unknown) => { + setTimeout(() => (callback as (e: Error) => void)(new Error('spawn ENOENT')), 0) + return { kill: vi.fn() } + } + ) + + const error = await runPortScanCommandInProcess('lsof', []).catch((err: unknown) => err) + + expect(error).toBeInstanceOf(Error) + expect(error).not.toBeInstanceOf(PortScanCommandTimeoutError) + }) +}) diff --git a/src/main/ports/port-scan-command-execution.ts b/src/main/ports/port-scan-command-execution.ts new file mode 100644 index 00000000000..9c63fc9aaa3 --- /dev/null +++ b/src/main/ports/port-scan-command-execution.ts @@ -0,0 +1,115 @@ +import { execFile, type ChildProcess } from 'node:child_process' +import { + PORT_SCAN_COMMAND_TIMEOUT_MS, + PortScanCommandTimeoutError, + WATCHDOG_GRACE_MS, + portScanCommandTimeoutMessage +} from './port-scan-command-protocol' + +// Why (#11161): libuv runs uv_spawn inline on whichever event loop calls it, so +// an endpoint-security hook on CreateProcessW stalls that thread for the whole +// spawn. This module is only ever entered from the worker thread +// (port-scan-command-worker-entry.ts); nothing on the main thread may import it. + +const WATCHDOG_TIMEOUT_MS = PORT_SCAN_COMMAND_TIMEOUT_MS + WATCHDOG_GRACE_MS + +const activeChildren = new Set() + +/** + * Run a port-scan probe command and report how long process creation took. + * @param command - Executable name resolved against the worker's PATH. + * @param args - Argument vector passed verbatim, never shell-interpolated. + * @returns The command's stdout plus `spawnMs`, the measured process-creation + * latency callers use to skip optional follow-up commands on a stalled host. + * @throws PortScanCommandTimeoutError when the command itself outran its budget. + */ +export async function runPortScanCommandInProcess( + command: string, + args: string[] +): Promise<{ stdout: string; spawnMs: number }> { + return await new Promise((resolve, reject) => { + let settled = false + let timer: NodeJS.Timeout | null = null + let spawnMs = 0 + let child: ChildProcess | undefined + + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } + if (child) { + activeChildren.delete(child) + } + callback() + } + + const startedAt = Date.now() + try { + child = execFile( + command, + args, + { + timeout: PORT_SCAN_COMMAND_TIMEOUT_MS, + maxBuffer: 2 * 1024 * 1024, + windowsHide: true + }, + (error, stdout) => { + if (error) { + // Node kills its own execFile timeout with a signal, which surfaces + // as killed:true — the only way to tell a timeout from a real error. + const timedOut = (error as { killed?: boolean }).killed === true + settle(() => + reject( + timedOut + ? new PortScanCommandTimeoutError( + portScanCommandTimeoutMessage(command, PORT_SCAN_COMMAND_TIMEOUT_MS) + ) + : error + ) + ) + return + } + settle(() => resolve({ stdout: String(stdout), spawnMs })) + } + ) + } catch (error) { + settle(() => reject(error)) + return + } + + // Why (#11161): measured after execFile returns, because that call blocks + // for the whole of process creation. Arming the watchdog earlier would + // charge the spawn stall against the command's budget and fire immediately. + spawnMs = Date.now() - startedAt + if (settled || !child) { + return + } + activeChildren.add(child) + timer = setTimeout(() => { + settle(() => { + child?.kill() + reject( + new PortScanCommandTimeoutError( + portScanCommandTimeoutMessage(command, WATCHDOG_TIMEOUT_MS) + ) + ) + }) + }, WATCHDOG_TIMEOUT_MS) + }) +} + +/** Best-effort reap so a terminated worker does not orphan an in-flight probe. */ +export function killActivePortScanCommands(): void { + for (const child of activeChildren) { + try { + child.kill() + } catch { + // Already exited; nothing to reap. + } + } + activeChildren.clear() +} diff --git a/src/main/ports/port-scan-command-import-boundary.test.ts b/src/main/ports/port-scan-command-import-boundary.test.ts new file mode 100644 index 00000000000..bd9b9d05fb8 --- /dev/null +++ b/src/main/ports/port-scan-command-import-boundary.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Why (#11161): libuv runs process creation inline on the calling event loop, +// so the port scan only stays off CrBrowserMain while these main-thread modules +// spawn nothing themselves. Spawning belongs to port-scan-command-execution.ts, +// which only the worker entry imports. +const MAIN_THREAD_MODULES = ['local-workspace-port-scanner.ts', 'port-scan-command-client.ts'] + +describe('port scan main-thread spawn boundary', () => { + it.each(MAIN_THREAD_MODULES)('keeps %s free of child_process', (fileName) => { + const source = readFileSync(join(import.meta.dirname, fileName), 'utf8') + + expect(source).not.toMatch(/from\s+['"](node:)?child_process['"]/) + expect(source).not.toMatch(/require\(\s*['"](node:)?child_process['"]\s*\)/) + }) +}) diff --git a/src/main/ports/port-scan-command-protocol.ts b/src/main/ports/port-scan-command-protocol.ts new file mode 100644 index 00000000000..02beeb982a8 --- /dev/null +++ b/src/main/ports/port-scan-command-protocol.ts @@ -0,0 +1,36 @@ +// Why (#11161): request/response shapes plus the timeout error shared by the +// port-scan worker entry and its main-thread client. Kept free of Electron, +// node:worker_threads and node:child_process so importing it from either side +// can never drag the other side's dependencies across the boundary. + +export const PORT_SCAN_COMMAND_TIMEOUT_MS = 4_000 +// Node's own execFile timeout is the primary killer; the manual watchdog only +// covers "the callback never arrived", so it must fire strictly later. +export const WATCHDOG_GRACE_MS = 1_000 + +export type PortScanCommandRequest = { + id: number + command: string + args: string[] +} + +export type PortScanCommandResponse = + | { id: number; ok: true; stdout: string; spawnMs: number } + | { id: number; ok: false; timedOut: boolean; error: string } + +/** + * Raised when a port-scan command was killed after exceeding its budget. Errors + * do not survive structured clone as subclasses, so the worker reports a + * `timedOut` flag and the client reconstructs this type from it. + */ +export class PortScanCommandTimeoutError extends Error { + constructor(message: string) { + super(message) + this.name = 'PortScanCommandTimeoutError' + } +} + +/** Shared wording so worker-side and client-side timeouts read identically. */ +export function portScanCommandTimeoutMessage(command: string, timeoutMs: number): string { + return `${command} timed out after ${timeoutMs}ms` +} diff --git a/src/main/ports/port-scan-command-worker-entry.ts b/src/main/ports/port-scan-command-worker-entry.ts new file mode 100644 index 00000000000..b5cb5ad5a3a --- /dev/null +++ b/src/main/ports/port-scan-command-worker-entry.ts @@ -0,0 +1,53 @@ +import { parentPort } from 'node:worker_threads' +import { + killActivePortScanCommands, + runPortScanCommandInProcess +} from './port-scan-command-execution' +import { + PortScanCommandTimeoutError, + type PortScanCommandRequest, + type PortScanCommandResponse +} from './port-scan-command-protocol' + +// Why (#11161): process creation blocks the event loop that issues it. Running +// the port scan's probe commands on this worker thread keeps an EDR-hooked +// CreateProcessW off CrBrowserMain. The client dispatches one request at a +// time, so this loop stays serial; imports must remain electron-free. + +if (!parentPort) { + throw new Error('Port scan command worker must run with a parent port.') +} +const port = parentPort + +process.once('exit', killActivePortScanCommands) + +async function handleRequest(request: PortScanCommandRequest): Promise { + try { + const { stdout, spawnMs } = await runPortScanCommandInProcess(request.command, request.args) + return { id: request.id, ok: true, stdout, spawnMs } + } catch (err) { + return { + id: request.id, + ok: false, + timedOut: err instanceof PortScanCommandTimeoutError, + error: err instanceof Error ? err.message : String(err) + } + } +} + +port.on('message', (request: PortScanCommandRequest) => { + void handleRequest(request).then((response) => { + try { + port.postMessage(response) + } catch { + // A non-cloneable result would otherwise post nothing and leave the client + // waiting out its deadline; fail that request fast instead. + port.postMessage({ + id: request.id, + ok: false, + timedOut: false, + error: 'Port scan command result could not be serialized.' + }) + } + }) +}) diff --git a/src/main/ports/workspace-port-ownership.test.ts b/src/main/ports/workspace-port-ownership.test.ts new file mode 100644 index 00000000000..b367fcbf29c --- /dev/null +++ b/src/main/ports/workspace-port-ownership.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { killWorkspacePort } from './workspace-port-ownership' + +const scanWorkspacePortsMock = vi.hoisted(() => vi.fn()) + +vi.mock('./local-workspace-port-scanner', () => ({ + scanWorkspacePorts: scanWorkspacePortsMock +})) + +const worktrees = [{ id: 'repo::/repo', repoId: 'repo', displayName: 'main', path: '/repo' }] + +describe('killWorkspacePort', () => { + afterEach(() => { + scanWorkspacePortsMock.mockReset() + vi.restoreAllMocks() + }) + + // Regression for #11161 review: on an EDR-hooked host the background poller + // alternates the metadata skip, so an unscoped skip would fail Stop with + // "Only workspace-owned local processes can be stopped here" every other try. + it('requires owner metadata from the authorizing re-scan', async () => { + scanWorkspacePortsMock.mockResolvedValue({ platform: 'darwin', scannedAt: 0, ports: [] }) + + await killWorkspacePort(worktrees, { pid: 123, port: 5173 }) + + expect(scanWorkspacePortsMock).toHaveBeenCalledWith(worktrees, undefined, { + requireMetadata: true + }) + }) + + it('refuses a pid the re-scan does not attribute to a workspace', async () => { + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + scanWorkspacePortsMock.mockResolvedValue({ + platform: 'darwin', + scannedAt: 0, + ports: [ + { + id: '127.0.0.1:5173:123', + bindHost: '127.0.0.1', + connectHost: '127.0.0.1', + port: 5173, + pid: 123, + protocol: 'http', + kind: 'external' + } + ] + }) + + const result = await killWorkspacePort(worktrees, { pid: 123, port: 5173 }) + + expect(result).toEqual({ + ok: false, + reason: 'Only workspace-owned local processes can be stopped here.' + }) + expect(killSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ports/workspace-port-ownership.ts b/src/main/ports/workspace-port-ownership.ts index 9ae5bf6582a..9f7df2465cb 100644 --- a/src/main/ports/workspace-port-ownership.ts +++ b/src/main/ports/workspace-port-ownership.ts @@ -8,7 +8,7 @@ import type { WorkspacePortProbe, WorkspacePortScanResult } from '../../shared/workspace-ports' -import { scanWorkspacePorts } from './local-workspace-port-scanner' +import { scanWorkspacePorts, type WorkspacePortScanOptions } from './local-workspace-port-scanner' export type WorkspacePortProbeInput = WorkspacePortProbe & { connectionId?: string | null @@ -82,7 +82,9 @@ export async function killWorkspacePort( return { ok: false, reason: 'Invalid process or port.' } } - const scan = await scanWorkspacePorts([...worktrees]) + // Why (#11161): this re-scan is the authorization check for SIGTERM, so it + // must never land on a cycle that skipped the owner-attribution metadata. + const scan = await scanWorkspacePorts([...worktrees], undefined, { requireMetadata: true }) const port = scan.ports.find( (candidate) => candidate.pid === args.pid && candidate.port === args.port ) @@ -113,7 +115,8 @@ export async function killWorkspacePort( } export async function scanWorkspacePortProbes( - worktrees: readonly WorkspacePortProbe[] + worktrees: readonly WorkspacePortProbe[], + options?: WorkspacePortScanOptions ): Promise { - return scanWorkspacePorts([...worktrees]) + return scanWorkspacePorts([...worktrees], undefined, options) } From 9058059154548aae974e434c4311c4f2a57f271d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:51:50 +0000 Subject: [PATCH 03/43] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index df51a8732ac..98410899a3d 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 13m + + downloads: 14m @@ -15,7 +15,7 @@ downloads downloads - 13m - 13m + 14m + 14m From 7c4c021aa60f397b95220f3fbf595bc3f61bf9ec Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:12:23 -0700 Subject: [PATCH 04/43] docs: update Android APK link to v0.0.37 (#12515) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b161a52b71a..670b54e2835 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk) +- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) --- From 02afd3557c615cfe444901954faa986be0fac018 Mon Sep 17 00:00:00 2001 From: 5Hyeons Date: Tue, 4 Aug 2026 01:43:11 -0700 Subject: [PATCH 05/43] fix(sidebar): keep the toolbar labels in the selected UI language (#11889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SidebarToolbar is a React.memo boundary whose props are shallow-equal on a language switch, so without its own useTranslation() subscription it kept the English copy it rendered at boot — the persisted locale is applied asynchronously, after the lazy catalog loads. Co-authored-by: 5Hyeons --- .../sidebar/SidebarToolbar.test.tsx | 27 ++++++++++++++++++- .../src/components/sidebar/SidebarToolbar.tsx | 7 +++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx index f3dde9621ea..1ef570524cb 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx @@ -4,6 +4,8 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store' +import { i18n } from '@/i18n/i18n' +import ko from '@/i18n/locales/ko.json' import SidebarToolbar from './SidebarToolbar' const mocks = vi.hoisted(() => ({ @@ -82,12 +84,15 @@ describe('SidebarToolbar moved workspace board hint', () => { } }) - afterEach(() => { + afterEach(async () => { roots.splice(0).forEach((root) => { act(() => root.unmount()) }) document.body.replaceChildren() vi.clearAllMocks() + // Why: the language-change case mutates the shared i18n instance; restore + // here so a failing assertion cannot leave later tests running in Korean. + await i18n.changeLanguage('en') }) it('does not show the moved hint to brand-new users after their first board click', async () => { @@ -131,6 +136,26 @@ describe('SidebarToolbar moved workspace board hint', () => { expect(window.localStorage.getItem('orca.workspaceBoardMovedHintSeen.v1')).toBe('true') }) + // Why: the toolbar is a React.memo boundary whose props never change on a + // language switch, so without its own useTranslation() subscription it keeps + // the English copy it rendered at boot (the persisted locale is applied + // asynchronously, after the lazy catalog loads). + it('relabels itself when the UI language changes after mount', async () => { + const { container } = await renderToolbar() + expect(container.querySelector('button[aria-label="Workspace board"]')).not.toBeNull() + + await act(async () => { + await i18n.changeLanguage('ko') + }) + + // Why: read the expected copy from the catalog instead of hardcoding it, so + // editing the Korean wording cannot fail this test as a bogus stale-render + // report. + const localized = ko.auto.components.sidebar.SidebarToolbar['49f62c5665'] + expect(localized).not.toBe('Workspace board') + expect(container.querySelector(`button[aria-label="${localized}"]`)).not.toBeNull() + }) + it('renders the profile switcher before settings in the footer controls', async () => { const { container } = await renderToolbar() const html = container.innerHTML diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.tsx index ff2e030c7a5..dcf57248839 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.tsx @@ -1,5 +1,6 @@ import React from 'react' import { Kanban } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { ScrollToCurrentWorkspaceToolbarButton } from './ScrollToCurrentWorkspaceToolbarButton' @@ -23,6 +24,12 @@ const SidebarToolbar = React.memo(function SidebarToolbar({ workspaceBoardDragPreviewOpen = false, onWorkspaceBoardToggle }: SidebarToolbarProps) { + // Why: this memo boundary needs its own language subscription, while + // translate() preserves Orca's pseudo-localization behavior. Without it the + // toolbar (and the ScrollToCurrentWorkspaceToolbarButton it renders) keeps + // whatever language was active at boot — English, since the persisted locale + // is applied asynchronously after the lazy catalog loads. + useTranslation() const [workspaceBoardMovedHintOpen, setWorkspaceBoardMovedHintOpen] = React.useState(false) const movedHintEligibleRef = React.useRef(null) const persistedUIReady = useAppStore((state) => state.persistedUIReady) From 17d88e098d883734325246840bef196c5e6820fd Mon Sep 17 00:00:00 2001 From: Evgenii Date: Tue, 4 Aug 2026 01:43:26 -0700 Subject: [PATCH 06/43] fix(i18n): format the stats date with the selected UI language (#12105) formatTrackingSince() used the OS locale rather than the language chosen in Settings. getIntlLocale() resolves the active i18n language to a BCP-47 tag, mapping the synthetic plugin resource language that Intl rejects back to the pack's real locale. Co-authored-by: Evgenii --- .../src/components/stats/StatsPane.tsx | 4 +- src/renderer/src/i18n/i18n.ts | 18 +++++ src/renderer/src/i18n/intl-locale.test.ts | 79 +++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/i18n/intl-locale.test.ts diff --git a/src/renderer/src/components/stats/StatsPane.tsx b/src/renderer/src/components/stats/StatsPane.tsx index 002a2dca4ae..4d62ad89bb5 100644 --- a/src/renderer/src/components/stats/StatsPane.tsx +++ b/src/renderer/src/components/stats/StatsPane.tsx @@ -15,7 +15,7 @@ import { DropdownMenuTrigger } from '../ui/dropdown-menu' import { AgentIcon } from '@/lib/agent-catalog' -import { translate } from '@/i18n/i18n' +import { getIntlLocale, translate } from '@/i18n/i18n' export { getStatsPaneSearchEntries } from './stats-search' function formatDuration(ms: number): string { @@ -44,7 +44,7 @@ function formatTrackingSince(timestamp: number | null): string { } const date = new Date(timestamp) return translate('auto.components.stats.StatsPane.trackingSince', 'Tracking since {{value0}}', { - value0: date.toLocaleDateString(undefined, { + value0: date.toLocaleDateString(getIntlLocale(), { month: 'short', day: 'numeric', year: 'numeric' diff --git a/src/renderer/src/i18n/i18n.ts b/src/renderer/src/i18n/i18n.ts index 3f35e2ec224..0d5cbad54cf 100644 --- a/src/renderer/src/i18n/i18n.ts +++ b/src/renderer/src/i18n/i18n.ts @@ -95,6 +95,24 @@ export async function setRendererUiLanguage(language: UiLanguage): Promise const registeredPluginLanguages = new Set() let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = [] +/** + * BCP-47 tag for `Intl` formatting. Plugin catalogs register under a synthetic + * `plugin` resource language that `Intl` rejects, so fall back to the tag + * the pack declares (for example `ru-RU`) and finally to the default locale. + */ +export function getIntlLocale(): string { + const active = i18n.language + const pack = pluginLanguagePacks.find((entry) => entry.resourceLanguage === active) + const candidate = pack?.locale ?? active + try { + // Why: an empty result means Intl has no data for the tag, so fall through + // to the default locale instead of letting Intl pick the runtime one. + return Intl.DateTimeFormat.supportedLocalesOf(candidate)[0] ?? DEFAULT_LOCALE + } catch { + return DEFAULT_LOCALE + } +} + export function resolveRendererResourceLanguage(language: string): string { return pluginLanguagePacks.find((pack) => pack.id === language)?.resourceLanguage ?? language } diff --git a/src/renderer/src/i18n/intl-locale.test.ts b/src/renderer/src/i18n/intl-locale.test.ts new file mode 100644 index 00000000000..69f0de08455 --- /dev/null +++ b/src/renderer/src/i18n/intl-locale.test.ts @@ -0,0 +1,79 @@ +/** + * `getIntlLocale()` exists because plugin catalogs register under a synthetic + * `plugin` resource language. Passing that straight to `Intl` throws, and + * passing `undefined` silently falls back to the OS locale rather than the + * language the user selected. + * + * The branch assertions stub `supportedLocalesOf` so they describe the helper's + * logic rather than whichever locales the runtime's ICU build happens to carry. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getIntlLocale, i18n, setRendererPluginLanguagePacks } from './i18n' +import { pluginLanguageResourceId } from '../../../shared/plugins/plugin-language-pack-artifact' +import { DEFAULT_LOCALE } from './supported-languages' + +const PACK_ID = 'plugin:smwbev.russian/ru-RU' as const +const PACK_RESOURCE = pluginLanguageResourceId(PACK_ID) +const PACK = { + id: PACK_ID, + resourceLanguage: PACK_RESOURCE, + pluginKey: 'smwbev.russian', + locale: 'ru-RU', + catalog: {} +} + +type Packs = Parameters[0] + +async function activate(language: string, packs: Packs = []): Promise { + setRendererPluginLanguagePacks(packs) + await i18n.changeLanguage(language) +} + +/** Stubs ICU lookup so a locale counts as supported only when listed. */ +function withSupportedLocales(supported: readonly string[]): void { + vi.spyOn(Intl.DateTimeFormat, 'supportedLocalesOf').mockImplementation((requested) => { + const tags = Array.isArray(requested) ? requested : [requested as string] + return tags.filter((tag) => supported.includes(tag)) as string[] + }) +} + +afterEach(async () => { + vi.restoreAllMocks() + setRendererPluginLanguagePacks([]) + await i18n.changeLanguage(DEFAULT_LOCALE) +}) + +describe('getIntlLocale', () => { + it('passes a supported built-in locale straight through', async () => { + withSupportedLocales(['es']) + await activate('es') + expect(getIntlLocale()).toBe('es') + }) + + it('resolves a plugin resource language to the locale the pack declares', async () => { + withSupportedLocales(['ru-RU']) + await activate(PACK_RESOURCE, [PACK]) + // Without the pack lookup this would reach Intl as `plugin`. + expect(getIntlLocale()).toBe('ru-RU') + }) + + it('falls back to the default locale when ICU has no data for the tag', async () => { + withSupportedLocales([]) + await activate('es') + // Returning the tag here would let Intl silently format with the runtime locale. + expect(getIntlLocale()).toBe(DEFAULT_LOCALE) + }) + + it('falls back to the default locale when Intl rejects the tag', async () => { + vi.spyOn(Intl.DateTimeFormat, 'supportedLocalesOf').mockImplementation(() => { + throw new RangeError('invalid language tag') + }) + await activate(PACK_RESOURCE) + expect(getIntlLocale()).toBe(DEFAULT_LOCALE) + }) + + it('keeps the synthetic resource language unusable for Intl directly', () => { + // Unstubbed on purpose: this is a property of the tag, not of ICU data. + expect(() => Intl.DateTimeFormat.supportedLocalesOf(PACK_RESOURCE)).toThrow(RangeError) + }) +}) From 439a8c46cf1728be4d3c621c649242f4db78753d Mon Sep 17 00:00:00 2001 From: Evgenii Date: Tue, 4 Aug 2026 01:44:17 -0700 Subject: [PATCH 07/43] fix(plugins): let language packs translate plugin chrome (#12455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protectedTranslation refused every language-pack key under auto.components.settings.plugin*, which caught 104 keys that carry no trust meaning — section titles, empty states, Refresh, Add path. A 35-path exact allowlist opens those while consent, provenance, and every *Failed string stay protected; anything new stays protected until it is added deliberately. PluginsSettingsSection.experimental is held back from the contributed allowlist: the "Experimental" chip is a trust badge, which the module's own boundary comment places out of scope. Co-authored-by: Evgenii --- .../src/i18n/plugin-chrome-allowlist.test.ts | 56 +++++++++++ .../plugin-language-pack-artifact.test.ts | 60 ++++++++++++ .../plugins/plugin-language-pack-artifact.ts | 17 +++- .../plugins/plugin-translatable-chrome.ts | 92 +++++++++++++++++++ 4 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/i18n/plugin-chrome-allowlist.test.ts create mode 100644 src/shared/plugins/plugin-translatable-chrome.ts diff --git a/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts b/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts new file mode 100644 index 00000000000..19859369a78 --- /dev/null +++ b/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts @@ -0,0 +1,56 @@ +/** + * The plugin-chrome exemption is a list of exact catalog paths, so it can rot in + * two directions: a renamed key leaves a dead entry behind, and a key that moves + * into a component the security tests name would widen the exemption silently. + * These assertions catch both. + */ +import { describe, expect, it } from 'vitest' +import { translatablePluginChromePaths } from '../../../shared/plugins/plugin-translatable-chrome' +import en from './locales/en.json' + +// Kept in sync with the components plugin-language-pack-artifact.test.ts pins as +// security copy: nothing from them may appear in the chrome exemption. +const SECURITY_COPY_COMPONENTS = [ + 'PluginConsentDialog', + 'PluginConsentProvenance', + 'pluginError', + 'PluginKeybindingConsentPreview', + 'PluginMarketplaceListingRow', + 'PluginMarketplacePreviewDialog', + 'PluginMarketplaceSourceDialog', + 'PluginVmRecipeConsentPreview' +] + +function lookup(key: string): string | undefined { + const value = key + .split('.') + .reduce( + (node, part) => + node && typeof node === 'object' ? (node as Record)[part] : undefined, + en as unknown + ) + return typeof value === 'string' ? value : undefined +} + +describe('translatable plugin chrome', () => { + it.each(translatablePluginChromePaths())('%s still exists in the English catalog', (path) => { + expect(lookup(path)).toBeTypeOf('string') + }) + + it('exempts nothing from a component the security tests pin', () => { + const leaked = translatablePluginChromePaths().filter((path) => + SECURITY_COPY_COMPONENTS.some((component) => + path.startsWith(`auto.components.settings.${component}.`) + ) + ) + + expect(leaked).toEqual([]) + }) + + // Why: `*Failed` copy reports what happened to a trust decision (a reviewed + // source that changed, a rollback target that is gone), so the boundary is the + // whole family rather than a per-message judgement. + it('exempts no failure copy', () => { + expect(translatablePluginChromePaths().filter((path) => path.endsWith('Failed'))).toEqual([]) + }) +}) diff --git a/src/shared/plugins/plugin-language-pack-artifact.test.ts b/src/shared/plugins/plugin-language-pack-artifact.test.ts index c3f9980e7e6..e684bb50095 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.test.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.test.ts @@ -57,6 +57,66 @@ describe('plugin language-pack artifacts', () => { ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) }) + it.each([ + ['PluginsSettingsSection', 'title', 'Плагины'], + ['PluginMarketplaceBrowser', 'refresh', 'Обновить'], + ['PluginDevelopmentSection', 'title', 'Разработка'] + ])('lets a language pack translate %s.%s, which asserts nothing', (component, key, value) => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { components: { settings: { [component]: { [key]: value } } } } + }) + ).ok + ).toBe(true) + }) + + // Why: the chrome exemption must not leak into the copy that carries a claim + // about what plugins may do — that copy is the whole reason the prefix is broad. + it.each([ + ['description', 'Plugins are sandboxed and cannot read your files.'], + ['systemDescription', 'Every plugin here has been reviewed by Orca.'], + ['featureOff', 'Installed plugins keep running while the system is off.'] + ])('still refuses PluginsSettingsSection.%s', (key, value) => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { components: { settings: { PluginsSettingsSection: { [key]: value } } } } + }) + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) + }) + + it('still refuses the development-plugin permission promise', () => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { + components: { + settings: { + PluginDevelopmentSection: { help: 'Dev plugins skip permission review.' } + } + } + } + }) + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) + }) + + it('still refuses install failure copy that reports a trust event', () => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { + components: { + settings: { PluginMarketplaceBrowser: { installFailed: 'Installed successfully.' } } + } + } + }) + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) + }) + it('forges no trust badge: the community→Official swap is refused', () => { expect( parsePluginLanguagePackArtifact( diff --git a/src/shared/plugins/plugin-language-pack-artifact.ts b/src/shared/plugins/plugin-language-pack-artifact.ts index 0e4dd218d95..917adeef69e 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.ts @@ -1,3 +1,8 @@ +import { + translatablePluginChrome, + translatablePluginChromeContainer +} from './plugin-translatable-chrome' + export const PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES = 20_000 export const PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH = 16 @@ -5,6 +10,8 @@ const DANGEROUS_CATALOG_KEYS = new Set(['__proto__', 'prototype', 'constructor'] // Why: every plugin-facing security surface lives under this namespace, so // protecting the whole subtree keeps a new dialog from silently becoming // plugin-writable the way PluginConsentProvenance did when it was extracted. +// The subtree also holds copy with no security meaning; those exact paths are +// listed in plugin-translatable-chrome.ts and stay translatable. const PROTECTED_TRANSLATION_ROOT = 'auto.components.settings.' const PROTECTED_TRANSLATION_MODULE = /^plugin/i @@ -45,6 +52,9 @@ function protectedTranslation(path: string): boolean { if (!path.startsWith(PROTECTED_TRANSLATION_ROOT)) { return false } + if (translatablePluginChrome(path)) { + return false + } return PROTECTED_TRANSLATION_MODULE.test(path.slice(PROTECTED_TRANSLATION_ROOT.length)) } @@ -88,7 +98,12 @@ export function parsePluginLanguagePackArtifact(raw: string): PluginLanguagePack return { ok: false, error: `catalog key ${key || '(empty)'} is not safe` } } const path = frame.path ? `${frame.path}.${key}` : key - if (protectedTranslation(path)) { + // A protected container stays walkable only when exempt chrome sits below + // it; every leaf inside is still matched against its own full path. + if ( + protectedTranslation(path) && + !(isCatalogObject(value) && translatablePluginChromeContainer(path)) + ) { return { ok: false, error: `catalog cannot replace protected security copy at ${path}` } } if (typeof value === 'string') { diff --git a/src/shared/plugins/plugin-translatable-chrome.ts b/src/shared/plugins/plugin-translatable-chrome.ts new file mode 100644 index 00000000000..7a2e4c0fa2a --- /dev/null +++ b/src/shared/plugins/plugin-translatable-chrome.ts @@ -0,0 +1,92 @@ +// Why: the protected-translation prefix is deliberately broad so a consent +// surface added tomorrow is covered the day it lands — the lesson from +// PluginConsentProvenance. That breadth also catches copy that carries no +// security meaning at all: section titles, empty states, search affordances, +// and the local development form. A language pack that cannot translate +// "Refresh" leaves the plugins pane half-translated in every locale. +// +// This list is the narrow exception, and it is a list of exact paths rather +// than a pattern on purpose: anything new stays protected until someone +// deliberately adds it here, so the fail-safe survives. +// +// A path belongs here only if rewriting it cannot mislead the person deciding +// whether to trust a plugin. That rules out, and this list therefore omits: +// +// - trust badges and safety status — `PluginMarketplaceListingRow.official`, +// `.blocked`, `PluginSettingsRow.blocked`; +// - promises about what plugins may do — `PluginsSettingsSection.description` +// ("Plugins run on this computer"), `.systemDescription` ("Nothing runs +// until you review and enable it"), `.featureOff` ("stays disabled"), and +// `PluginDevelopmentSection.help` ("Dev plugins still require permission +// review"). Swapping any of these for reassuring text is the attack; +// - failure copy that reports a trust event — `installFailed` ("The reviewed +// source may have changed"). Sibling `*Failed` strings stay protected too, +// so the boundary is a rule rather than a judgement call per message; +// - destructive confirmations — `PluginRemoveDialog`, `PluginRollbackDialog`; +// - every dialog the existing tests already name as security copy. +const TRANSLATABLE_PLUGIN_CHROME = new Set([ + // Plugins pane frame: headings and list states, no claims about behavior. + 'auto.components.settings.PluginsSettingsSection.title', + 'auto.components.settings.PluginsSettingsSection.systemLabel', + 'auto.components.settings.PluginsSettingsSection.install', + 'auto.components.settings.PluginsSettingsSection.loading', + 'auto.components.settings.PluginsSettingsSection.empty', + 'auto.components.settings.PluginsSettingsSection.emptyTitle', + 'auto.components.settings.PluginsSettingsSection.noInstalledResults', + 'auto.components.settings.PluginsSettingsSection.noInstalledResultsTitle', + // Marketplace browser chrome: refresh, search, and empty states. + 'auto.components.settings.PluginMarketplaceBrowser.manageSources', + 'auto.components.settings.PluginMarketplaceBrowser.addSource', + 'auto.components.settings.PluginMarketplaceBrowser.refresh', + 'auto.components.settings.PluginMarketplaceBrowser.refreshing', + 'auto.components.settings.PluginMarketplaceBrowser.loading', + 'auto.components.settings.PluginMarketplaceBrowser.tryAgain', + 'auto.components.settings.PluginMarketplaceBrowser.clearSearch', + 'auto.components.settings.PluginMarketplaceBrowser.empty', + 'auto.components.settings.PluginMarketplaceBrowser.emptyTitle', + 'auto.components.settings.PluginMarketplaceBrowser.noInstalled', + 'auto.components.settings.PluginMarketplaceBrowser.noInstalledTitle', + 'auto.components.settings.PluginMarketplaceBrowser.noResults', + 'auto.components.settings.PluginMarketplaceBrowser.noResultsTitle', + 'auto.components.settings.PluginMarketplaceBrowser.noSourcesTitle', + // Local development form: field labels and validation, desktop-only paths. + 'auto.components.settings.PluginDevelopmentSection.title', + 'auto.components.settings.PluginDevelopmentSection.add', + 'auto.components.settings.PluginDevelopmentSection.remove', + 'auto.components.settings.PluginDevelopmentSection.pathLabel', + 'auto.components.settings.PluginDevelopmentSection.pathRequired', + 'auto.components.settings.PluginDevelopmentSection.placeholder', + // Settings-search index entries: they route to a pane, they do not assert. + 'auto.components.settings.plugins.search.title', + 'auto.components.settings.plugins.search.description', + 'auto.components.settings.plugins.search.install', + 'auto.components.settings.plugins.search.permissions', + 'auto.components.settings.plugins.search.logs', + 'auto.components.settings.plugins.search.development' +]) + +/** True when a protected-prefix path is plugin chrome a language pack may translate. */ +export function translatablePluginChrome(path: string): boolean { + return TRANSLATABLE_PLUGIN_CHROME.has(path) +} + +/** + * True when a protected-prefix container holds exempt chrome somewhere below it. + * The walk rejects a protected path as soon as it sees it, so a container has to + * stay walkable for its exempt leaves to be reachable — every leaf inside is + * still checked against its own full path. + */ +export function translatablePluginChromeContainer(path: string): boolean { + const prefix = `${path}.` + for (const exempt of TRANSLATABLE_PLUGIN_CHROME) { + if (exempt.startsWith(prefix)) { + return true + } + } + return false +} + +/** The exempt paths, for tests that check the list against the English catalog. */ +export function translatablePluginChromePaths(): string[] { + return [...TRANSLATABLE_PLUGIN_CHROME] +} From 4e8f13605207b83dcbf4da1bcf739ae3fdc29fcd Mon Sep 17 00:00:00 2001 From: Evgenii Date: Tue, 4 Aug 2026 01:44:30 -0700 Subject: [PATCH 08/43] fix(i18n): localize the integration card status pills (#12208) Every card under Settings -> Integrations built its status pill from bare literals inside the JSX call, so the pill stayed English beside a translated description. audit-localization-coverage.mjs inspects JSX attributes and object properties, not conditional expressions handed to a prop, so the gate stayed green while the strings shipped untranslated. The three token-configured cards share tokenProviderStatusLabel(); Gitea passes optional: true because it works read-only without a token, which is why its unconfigured state reads Optional setup rather than Not configured. Co-authored-by: Evgenii --- .../settings/jira-integration-card.tsx | 9 +++- .../task-tracker-integration-cards.tsx | 12 ++++- ...token-source-control-integration-cards.tsx | 44 ++++++------------- .../settings/token-source-control-status.ts | 39 ++++++++++++++++ ...tegration-card-status-localization.test.ts | 44 +++++++++++++++++++ src/renderer/src/i18n/locales/en.json | 16 +++++-- 6 files changed, 128 insertions(+), 36 deletions(-) create mode 100644 src/renderer/src/components/settings/token-source-control-status.ts create mode 100644 src/renderer/src/i18n/integration-card-status-localization.test.ts diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index e1889c2f073..e9f510628c6 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -99,7 +99,14 @@ export function JiraIntegrationCard(): React.JSX.Element { } checking={checking} statusTone={connected ? 'connected' : 'attention'} - statusLabel={connected ? 'Connected' : 'Not connected'} + statusLabel={ + connected + ? translate('auto.components.settings.jira.integration.card.statusConnected', 'Connected') + : translate( + 'auto.components.settings.jira.integration.card.statusNotConnected', + 'Not connected' + ) + } actions={ !checking ? ( {deleteTarget?.name}{' '} + {translate( + 'auto.components.automations.AutomationsPage.b264564427', + 'and its run history. Workspaces created by previous runs are not deleted.' + )} + + + {deleteTarget ? ( +

+
{deleteTarget.name}
+
+ {deleteTarget.workspaceMode === 'new_per_run' + ? translate( + 'auto.components.automations.AutomationsPage.cd8397cc32', + 'New workspace each run' + ) + : translate( + 'auto.components.automations.AutomationsPage.36f71740a7', + 'Selected workspace' + )} +
+
+ ) : null} + + + + + + + + ) +} + +export function ExternalAutomationDeleteDialog({ + externalDeleteTarget, + confirmButtonRef, + onOpenChange, + onCancel, + onConfirm +}: { + externalDeleteTarget: { + manager: ExternalAutomationManager + job: ExternalAutomationJob + } | null + confirmButtonRef: React.RefObject + onOpenChange: (open: boolean) => void + onCancel: () => void + onConfirm: () => void +}): React.JSX.Element { + return ( + + { + event.preventDefault() + confirmButtonRef.current?.focus() + }} + > + + + {translate( + 'auto.components.automations.AutomationsPage.9adfab2596', + 'Delete External Automation' + )} + + + {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '} + + {externalDeleteTarget?.job.name} + {' '} + {translate('auto.components.automations.AutomationsPage.02a33e3204', 'from')}{' '} + {externalDeleteTarget + ? getExternalProviderLabel(externalDeleteTarget.manager) + : translate( + 'auto.components.automations.AutomationsPage.8500baacb4', + 'external source' + )}{' '} + {translate('auto.components.automations.AutomationsPage.1b586f0e2b', 'on')}{' '} + {externalDeleteTarget?.manager.targetLabel}. + + + {externalDeleteTarget ? ( +
+
+ {externalDeleteTarget.job.name} +
+
+ { + getExternalAutomationScheduleDisplay( + externalDeleteTarget.manager, + externalDeleteTarget.job + ).label + } +
+
+ ) : null} + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx new file mode 100644 index 00000000000..b5269b65593 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx @@ -0,0 +1,223 @@ +import React from 'react' +import { Clock, Pause, Pencil, Play, Trash2 } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { cn } from '@/lib/utils' +import type { + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import { + formatExternalDate, + getExternalProviderLabel, + getExternalTargetKindLabel +} from './external-automation-display' +import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' +import { + getExternalAutomationActionDisabledMessage, + getExternalAutomationSourceAvailability +} from './external-automation-source-availability' +import { translate } from '@/i18n/i18n' + +export function AutomationListExternalRows({ + entries, + selectedExternalKey, + relativeNow, + sshConnectionStates, + externalActionKey, + onSelect, + onRequestAction, + onEdit +}: { + entries: readonly ExternalAutomationListEntry[] + selectedExternalKey: string | null | undefined + relativeNow: number + sshConnectionStates: ReadonlyMap> + externalActionKey: string | null + onSelect: (entryKey: string) => void + onRequestAction: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction + ) => void + onEdit: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void +}): React.JSX.Element { + return ( + <> + {entries.map((entry) => { + const providerLabel = getExternalProviderLabel(entry.manager) + const targetKindLabel = getExternalTargetKindLabel(entry.manager) + if (entry.kind === 'source') { + const sshStatus = + entry.manager.target.type === 'ssh' + ? sshConnectionStates.get(entry.manager.target.connectionId)?.status + : undefined + const sourceAvailability = getExternalAutomationSourceAvailability({ + manager: entry.manager, + providerLabel, + targetKindLabel, + sshStatus + }) + return ( + + ) + } + const nextRunLabel = entry.job.enabled + ? formatExternalDate(entry.job.nextRunAt, relativeNow) + : translate('auto.components.automations.AutomationsPage.paused', 'Paused') + const entrySshStatus = + entry.manager.target.type === 'ssh' + ? sshConnectionStates.get(entry.manager.target.connectionId)?.status + : undefined + const disabledMessage = getExternalAutomationActionDisabledMessage({ + manager: entry.manager, + providerLabel, + targetKindLabel, + sshStatus: entrySshStatus, + actionInProgress: externalActionKey !== null + }) + const actionDisabled = disabledMessage !== null + const scheduleDisplay = getExternalAutomationScheduleDisplay(entry.manager, entry.job) + return ( + + + + + + onRequestAction(entry.manager, entry.job, 'run')} + > + + + {disabledMessage ?? + translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')} + + + {entry.manager.provider === 'hermes' ? ( + onEdit(entry.manager, entry.job)} + > + + {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + + ) : null} + + onRequestAction(entry.manager, entry.job, entry.job.enabled ? 'pause' : 'resume') + } + > + {entry.job.enabled ? : } + {entry.job.enabled + ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') + : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')} + + + onRequestAction(entry.manager, entry.job, 'delete')} + > + + {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} + + + + ) + })} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx new file mode 100644 index 00000000000..1a5a4994aac --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx @@ -0,0 +1,245 @@ +import React from 'react' +import { Clock, Pause, Pencil, Play, Trash2 } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { cn } from '@/lib/utils' +import type { Automation, AutomationRun } from '../../../../shared/automations-types' +import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import { formatAutomationSchedule } from '../../../../shared/automation-schedules' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' +import type { AutomationHostTarget } from './automation-host-client' +import { formatAutomationDateTimeWithRelative } from './automation-page-parts' +import { + formatAutomationCost, + formatAutomationTokens, + summarizeAutomationRunUsage +} from './automation-usage-model' +import { getAutomationTargetAvailability } from './automation-target-availability' +import { getAgentLabel } from './automation-draft-model' +import { translate } from '@/i18n/i18n' + +export function AutomationListLocalRows({ + automations, + selectedId, + isSelectedLocal, + runs, + relativeNow, + repoMap, + worktreeMap, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + automationHostTarget, + automationSourceHostAvailabilityById, + onSelect, + onRunNow, + onEdit, + onToggle, + onDelete +}: { + automations: readonly Automation[] + selectedId: string | null | undefined + isSelectedLocal: boolean + runs: readonly AutomationRun[] + relativeNow: number + repoMap: ReadonlyMap + worktreeMap: ReadonlyMap + projectHostSetups: readonly ProjectHostSetup[] + sshConnectionStates: ReadonlyMap> + runtimeStatusByEnvironmentId: ReadonlyMap< + string, + { status: RuntimeStatus | null; checkedAt: number } + > + automationHostTarget: AutomationHostTarget | null + automationSourceHostAvailabilityById: ReadonlyMap + onSelect: (automationId: string) => void + onRunNow: (automation: Automation) => void + onEdit: (automation: Automation) => void + onToggle: (automation: Automation) => void + onDelete: (automation: Automation) => void +}): React.JSX.Element { + // Why: one pass over runs instead of a full scan per rendered automation — + // this list re-renders on the relativeNow timer. + const runsByAutomationId = React.useMemo(() => { + const grouped = new Map() + for (const run of runs) { + const existing = grouped.get(run.automationId) + if (existing) { + existing.push(run) + } else { + grouped.set(run.automationId, [run]) + } + } + return grouped + }, [runs]) + return ( + <> + {automations.map((automation) => { + const automationRepo = repoMap.get(getAutomationRunRepoId(automation)) + const automationWorktree = automation.workspaceId + ? worktreeMap.get(automation.workspaceId) + : null + const automationRunAvailability = getAutomationTargetAvailability({ + automation, + repo: automationRepo, + workspace: automationWorktree, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + automationHostTarget, + sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) + }) + const baseRefLabel = + automation.baseBranch ?? + automationRepo?.worktreeBaseRef ?? + translate( + 'auto.components.automations.AutomationsPage.projectDefaultBaseRef', + 'project default' + ) + const workspaceLabel = + automation.workspaceMode === 'new_per_run' + ? translate( + 'auto.components.automations.AutomationsPage.createFromBaseRef', + 'Create from {{baseRef}}', + { baseRef: baseRefLabel } + ) + : (automationWorktree?.displayName ?? + translate( + 'auto.components.automations.AutomationsPage.missingWorkspace', + 'Missing workspace' + )) + const usageSummary = summarizeAutomationRunUsage( + runsByAutomationId.get(automation.id) ?? [] + ) + const usageText = + usageSummary.knownRuns > 0 + ? translate( + 'auto.components.automations.AutomationsPage.runUsageSummary', + '{{cost}} est. · {{tokens}} tokens', + { + cost: formatAutomationCost(usageSummary.estimatedCostUsd), + tokens: formatAutomationTokens(usageSummary.totalTokens) + } + ) + : usageSummary.unavailableRuns > 0 + ? translate( + 'auto.components.automations.AutomationsPage.usageUnavailable', + 'Usage unavailable' + ) + : translate( + 'auto.components.automations.AutomationsPage.noRunUsageYet', + 'No run usage yet' + ) + const nextRunLabel = automation.enabled + ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow) + : translate('auto.components.automations.AutomationsPage.paused', 'Paused') + const scheduleLabel = formatAutomationSchedule(automation.rrule) + return ( + + + + + + { + if (!automationRunAvailability.canRunNow) { + event.preventDefault() + return + } + onRunNow(automation) + }} + > + + + {automationRunAvailability.canRunNow + ? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now') + : automationRunAvailability.message} + + + onEdit(automation)}> + + {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + + onToggle(automation)}> + {automation.enabled ? ( + + ) : ( + + )} + {automation.enabled + ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') + : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')} + + + onDelete(automation)}> + + {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} + + + + ) + })} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationListSearchField.tsx b/src/renderer/src/components/automations/AutomationListSearchField.tsx new file mode 100644 index 00000000000..3a2bceb7c48 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListSearchField.tsx @@ -0,0 +1,106 @@ +import React, { useRef } from 'react' +import { Search, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' + +type AutomationListSearchFieldProps = { + query: string + isTooLarge: boolean + onQueryChange: (query: string) => void + onClear: () => void + className?: string +} + +export function AutomationListSearchField({ + query, + isTooLarge, + onQueryChange, + onClear, + className +}: AutomationListSearchFieldProps): React.JSX.Element { + const inputRef = useRef(null) + const hasText = query !== '' + const tooLargeMessage = isTooLarge + ? translate( + 'auto.components.automations.AutomationListSearchField.tooLong', + 'Search text is too long — list is unfiltered' + ) + : null + + return ( +
+ + onQueryChange(event.target.value)} + onKeyDown={(event) => { + if (event.key !== 'Escape' || event.nativeEvent.isComposing) { + return + } + if (!hasText) { + return + } + event.preventDefault() + onClear() + }} + /> + {hasText ? ( +
+ {isTooLarge ? ( + + {translate( + 'auto.components.automations.AutomationListSearchField.tooLongShort', + 'Too long' + )} + + ) : null} + +
+ ) : null} + {isTooLarge ? ( +
+ {tooLargeMessage} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationRunHistory.tsx b/src/renderer/src/components/automations/AutomationRunHistory.tsx index be8a76dcd5e..777eaff3fa2 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.tsx @@ -19,7 +19,7 @@ import { translate } from '@/i18n/i18n' type AutomationRunHistoryProps = { runs: AutomationRun[] automationId: string - worktreeMap: Map + worktreeMap: ReadonlyMap onOpenRun: (run: AutomationRun) => void } diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx new file mode 100644 index 00000000000..2d290f26ac6 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -0,0 +1,339 @@ +import React from 'react' +import { Eye, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { cn } from '@/lib/utils' +import type { + Automation, + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager, + ExternalAutomationRun, + AutomationRun +} from '../../../../shared/automations-types' +import type { Worktree } from '../../../../shared/types' +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import { AutomationDetail } from './AutomationDetail' +import { HermesCronOutputView } from './HermesCronOutputView' +import { AutomationRunPageFrame } from './AutomationRunPageFrame' +import { AutomationRunHistory } from './AutomationRunHistory' +import { ExternalAutomationManagers } from './ExternalAutomationManagers' +import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import { + formatExternalDate, + getExternalProviderLabel, + getExternalRunContent, + getExternalRunStatusLabel, + getExternalRunStatusVariant +} from './external-automation-display' +import { + formatAutomationDateTimeWithRelative, + getAutomationRunStatusLabel, + getAutomationRunStatusVariant +} from './automation-page-parts' +import { getAutomationRunContent } from './automation-run-content' +import type { AutomationTargetAvailability } from './automation-target-availability' +import type { AutomationRunViewState } from './automation-run-view-state' +import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display' +import type { ExternalAutomationSourceAvailability } from './external-automation-source-availability' +import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state' +import { translate } from '@/i18n/i18n' + +type AutomationsDetailPaneProps = { + selected: Automation | null + selectedExternal: ExternalAutomationListEntry | null + selectedExternalRunPage: SelectedExternalRunPage | null + selectedAutomationRunPage: AutomationRun | null + selectedRuns: AutomationRun[] + activePaneTab: AutomationPaneTab + relativeNow: number + externalActionKey: string | null + selectedRepoDisplayName: string + selectedRepoDefaultBaseRef: string | null + selectedWorkspaceName: string + hostLabelById: ReadonlyMap + selectedRunNowAvailability: AutomationTargetAvailability | null + selectedExternalSourceAvailability: ExternalAutomationSourceAvailability | null + selectedExternalSshSource: { + manager: ExternalAutomationManager + } | null + selectedExternalSshConnected: boolean + selectedAutomationRunPageWorkspaceDisplay: AutomationRunWorkspaceDisplay | null + selectedAutomationRunPageViewState: AutomationRunViewState | null + canRerunSelectedAutomationRunPage: boolean + isSelectedAutomationRunPageRerunPending: boolean + worktreeMap: ReadonlyMap + fetchExternalAutomationRuns: FetchExternalAutomationRuns + onActivePaneTabChange: (tab: AutomationPaneTab) => void + onClearExternalRunPage: () => void + onClearAutomationRunPage: () => void + requestExternalAction: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction + ) => void + openExternalRunPage: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + run: ExternalAutomationRun + ) => void + openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void + connectExternalAutomationSource: (manager: ExternalAutomationManager) => void + runNow: (automation: Automation) => void + openEditDialog: (automation: Automation) => void + toggleAutomation: (automation: Automation) => void + requestDeleteAutomation: (automation: Automation) => void + rerunAutomationRun: (automation: Automation, run: AutomationRun) => void + openRunWorkspace: (run: AutomationRun) => void + openAutomationRunPage: (run: AutomationRun) => void +} + +export function AutomationsDetailPane({ + selected, + selectedExternal, + selectedExternalRunPage, + selectedAutomationRunPage, + selectedRuns, + activePaneTab, + relativeNow, + externalActionKey, + selectedRepoDisplayName, + selectedRepoDefaultBaseRef, + selectedWorkspaceName, + hostLabelById, + selectedRunNowAvailability, + selectedExternalSourceAvailability, + selectedExternalSshSource, + selectedExternalSshConnected, + selectedAutomationRunPageWorkspaceDisplay, + selectedAutomationRunPageViewState, + canRerunSelectedAutomationRunPage, + isSelectedAutomationRunPageRerunPending, + worktreeMap, + fetchExternalAutomationRuns, + onActivePaneTabChange, + onClearExternalRunPage, + onClearAutomationRunPage, + requestExternalAction, + openExternalRunPage, + openEditExternalDialog, + connectExternalAutomationSource, + runNow, + openEditDialog, + toggleAutomation, + requestDeleteAutomation, + rerunAutomationRun, + openRunWorkspace, + openAutomationRunPage +}: AutomationsDetailPaneProps): React.JSX.Element { + return ( +
+ {selectedExternal ? ( +
+ {selectedExternalRunPage ? ( + + + + ) : selectedExternal.kind === 'job' ? ( + + ) : ( +
+
+
+
+ {selectedExternal.manager.targetLabel} +
+
+ {selectedExternalSourceAvailability?.summary} +
+
+ {selectedExternalSshSource ? ( + + ) : null} +
+
+ {selectedExternalSourceAvailability?.detail} +
+
+ )} +
+ ) : ( + onActivePaneTabChange(value as AutomationPaneTab)} + className="min-h-0 flex-1 gap-0" + > +
+ + + {translate('auto.components.automations.AutomationsPage.bb1b2cd31e', 'Overview')} + + + {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '} + {selectedRuns.length} + + +
+ + + void runNow(automation)} + onEdit={(automation) => void openEditDialog(automation)} + onToggle={(automation) => void toggleAutomation(automation)} + onDelete={requestDeleteAutomation} + /> + + + + {selectedAutomationRunPage ? ( + + {canRerunSelectedAutomationRunPage && selected ? ( + + ) : null} + {selectedAutomationRunPageViewState ? ( + + ) : null} + + } + onBack={onClearAutomationRunPage} + > + + + ) : selected ? ( + + ) : ( +
+ {translate( + 'auto.components.automations.AutomationsPage.c3a28c9793', + 'Select an automation to view runs.' + )} +
+ )} +
+
+ )} +
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx new file mode 100644 index 00000000000..31d9cc9d6d9 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -0,0 +1,206 @@ +import React from 'react' +import { Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import type { + Automation, + AutomationRun, + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' +import type { AutomationHostTarget } from './automation-host-client' +import { clampAutomationListSearchQueryInput } from './automation-list-search' +import type { AutomationPaneTab } from './automation-page-state' +import { AutomationListSearchField } from './AutomationListSearchField' +import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import { AutomationListLocalRows } from './AutomationListLocalRows' +import { AutomationListExternalRows } from './AutomationListExternalRows' +import { translate } from '@/i18n/i18n' + +type AutomationsListPanelProps = { + hasListItems: boolean + hasFilteredListItems: boolean + isListSearchActive: boolean + listSearchQuery: string + isListSearchQueryTooLarge: boolean + onListSearchQueryChange: (query: string) => void + filteredAutomations: readonly Automation[] + filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] + selected: Automation | null + selectedExternal: ExternalAutomationListEntry | null + runs: readonly AutomationRun[] + relativeNow: number + repoMap: ReadonlyMap + worktreeMap: ReadonlyMap + projectHostSetups: readonly ProjectHostSetup[] + sshConnectionStates: ReadonlyMap> + runtimeStatusByEnvironmentId: ReadonlyMap< + string, + { status: RuntimeStatus | null; checkedAt: number } + > + automationHostTarget: AutomationHostTarget | null + automationSourceHostAvailabilityById: ReadonlyMap + externalActionKey: string | null + selectAutomationId: (automationId: string | null) => void + selectExternalKey: (externalKey: string | null) => void + setActivePaneTab: (tab: AutomationPaneTab) => void + runNow: (automation: Automation) => void + openEditDialog: (automation: Automation) => void + toggleAutomation: (automation: Automation) => void + requestDeleteAutomation: (automation: Automation) => void + requestExternalAction: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction + ) => void + openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void + openCreateDialog: (template?: AutomationTemplate) => void +} + +export function AutomationsListPanel({ + hasListItems, + hasFilteredListItems, + isListSearchActive, + listSearchQuery, + isListSearchQueryTooLarge, + onListSearchQueryChange, + filteredAutomations, + filteredExternalAutomationEntries, + selected, + selectedExternal, + runs, + relativeNow, + repoMap, + worktreeMap, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + automationHostTarget, + automationSourceHostAvailabilityById, + externalActionKey, + selectAutomationId, + selectExternalKey, + setActivePaneTab, + runNow, + openEditDialog, + toggleAutomation, + requestDeleteAutomation, + requestExternalAction, + openEditExternalDialog, + openCreateDialog +}: AutomationsListPanelProps): React.JSX.Element { + return ( +
+ {hasListItems ? ( +
+ + onListSearchQueryChange(clampAutomationListSearchQueryInput(query)) + } + onClear={() => onListSearchQueryChange('')} + /> +
+ ) : null} +
+ {hasFilteredListItems ? ( +
+ + {translate('auto.components.automations.AutomationsPage.761a35834d', 'Automation')} + + + {translate('auto.components.automations.AutomationsPage.587a4b205c', 'Next')} + +
+ ) : null} + { + selectExternalKey(null) + selectAutomationId(automationId) + }} + onRunNow={runNow} + onEdit={openEditDialog} + onToggle={toggleAutomation} + onDelete={requestDeleteAutomation} + /> + { + selectExternalKey(entryKey) + setActivePaneTab('overview') + }} + onRequestAction={requestExternalAction} + onEdit={openEditExternalDialog} + /> + {hasListItems && isListSearchActive && !hasFilteredListItems ? ( +
+ {translate( + 'auto.components.automations.AutomationsPage.noSearchMatches', + 'No automations match your search.' + )} +
+ ) : null} + {!hasListItems ? ( +
+
+ {translate( + 'auto.components.automations.AutomationsPage.d207ab4c25', + 'Start from a template' + )} +
+ {getAutomationTemplates().map((template) => ( + + ))} + +
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index d55ee012dae..97f15d844a6 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -1,46 +1,16 @@ /* eslint-disable max-lines -- Why: this page owns the automations list/detail - * orchestration while the form and detail presentation live in sibling files. */ + * orchestration while the form, list, and detail presentation live in sibling files. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - CalendarClock, - Check, - Clock, - Eye, - Pause, - Pencil, - Play, - Plus, - RefreshCw, - Trash2, - X -} from 'lucide-react' +import { CalendarClock, Plus, RefreshCw, X } from 'lucide-react' import { toast } from 'sonner' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' -import type { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger -} from '@/components/ui/context-menu' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { cn } from '@/lib/utils' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { getAgentCatalog } from '@/lib/agent-catalog' import { useRepoMap, useWorktreeMap } from '@/store/selectors' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -50,7 +20,6 @@ import type { ExternalAutomationJob, ExternalAutomationManager, ExternalAutomationRun, - AutomationPrecheck, AutomationRun, AutomationUpdateInput } from '../../../../shared/automations-types' @@ -61,34 +30,20 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' -import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { PreflightStatus } from '../../../../preload/api-types' -import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import type { OrcaHooks, Repo, Worktree } from '../../../../shared/types' +import type { OrcaHooks, Repo } from '../../../../shared/types' import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id' import { - buildAutomationCronSchedule, buildAutomationRrule, - formatAutomationSchedule, isValidAutomationCronSchedule, isValidAutomationSchedule, tryParseAutomationRrule } from '../../../../shared/automation-schedules' -import { - formatAutomationDateTimeWithRelative, - getAutomationRunStatusLabel, - getAutomationRunStatusVariant -} from './automation-page-parts' -import { - formatAutomationCost, - formatAutomationTokens, - summarizeAutomationRunUsage -} from './automation-usage-model' import { canRerunAutomationRun, - getAutomationRerunPendingRemainingMs, - getAutomationRunViewState + getAutomationRunViewState, + waitForAutomationRerunPendingVisibility } from './automation-run-view-state' import { automationRunMatchesPaneKey, @@ -98,22 +53,17 @@ import { resolveAutomationRunOpenTarget } from './automation-run-open-target' import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { AutomationDetail } from './AutomationDetail' -import { HermesCronOutputView } from './HermesCronOutputView' import { AutomationEditorDialog, type AutomationCreateTarget, type AutomationDraft } from './AutomationEditorDialog' -import { AutomationRunPageFrame } from './AutomationRunPageFrame' -import { AutomationRunHistory } from './AutomationRunHistory' import { getAutomationSetupDecisionDraftValue, getVisibleAutomationSetupDecision, resolveAutomationSetupDecisionForSave } from './automation-setup-decision' -import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' +import type { AutomationTemplate } from './automation-templates' import { getAutomationTargetAvailability } from './automation-target-availability' import { buildAutomationRunContextForRepo } from './automation-run-context' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' @@ -125,14 +75,14 @@ import { } from '../task-source-provider-availability' import type { TaskSourceHostAvailability } from '../task-source-context-summary' import { - getExternalAutomationActionDisabledMessage, getExternalAutomationSourceAvailability, isSshConnectionBusy } from './external-automation-source-availability' import { createAutomationForTarget, deleteAutomationForTarget, - type AutomationHostTarget, + getAutomationHostTargetFromKey, + getAutomationHostTargetKey, getAutomationListTarget, getAutomationOwnerTarget, getAutomationTargetFromHostId, @@ -141,223 +91,38 @@ import { runAutomationNowForTarget, updateAutomationForTarget } from './automation-host-client' -import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' -import { ExternalAutomationManagers } from './ExternalAutomationManagers' import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable' +import { + AUTOMATION_DEFAULT_TIME, + buildDraftPrecheck, + buildHermesCronSchedule, + formatTimeInput, + getDefaultWorktree, + parseDraftTime +} from './automation-draft-model' +import { + getRepoBackedAutomationSourceContext, + getRuntimeSourceHostAvailability, + type RepoBackedAutomationSourceContext +} from './automation-source-context' +import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state' +import { + getExternalAutomationKey, + getExternalAutomationSourceKey, + getExternalProviderLabel, + getExternalTargetKindLabel, + isMissingExternalRunsApiError +} from './external-automation-display' +import { buildExternalAutomationListEntries } from './external-automation-list-entries' +import { useAutomationListSearch } from './use-automation-list-search' +import { AutomationDeleteDialog, ExternalAutomationDeleteDialog } from './AutomationDeleteDialogs' +import { AutomationsListPanel } from './AutomationsListPanel' +import { AutomationsDetailPane } from './AutomationsDetailPane' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { translate } from '@/i18n/i18n' const AGENTS = getAgentCatalog().map((agent) => agent.id) -const DEFAULT_TIME = '09:00' const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' -type AutomationPaneTab = 'overview' | 'runs' -type RepoBackedAutomationSourceContext = TaskSourceContext & { provider: 'github' | 'gitlab' } - -type ExternalAutomationListEntry = - | { - kind: 'job' - key: string - manager: ExternalAutomationManager - job: ExternalAutomationJob - } - | { - kind: 'source' - key: string - manager: ExternalAutomationManager - } - -type SelectedExternalRunPage = { - manager: ExternalAutomationManager - job: ExternalAutomationJob - run: ExternalAutomationRun -} - -function getAutomationHostTargetKey(target: AutomationHostTarget): string { - return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local' -} - -function getAutomationHostTargetFromKey(key: string | null): AutomationHostTarget | null { - if (!key) { - return null - } - if (key.startsWith('environment:')) { - return { kind: 'environment', environmentId: key.slice('environment:'.length) } - } - return { kind: 'local' } -} - -function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null { - return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null -} - -function getRepoBackedAutomationSourceContext( - automation: Automation -): RepoBackedAutomationSourceContext | null { - const context = automation.sourceContext - return context?.provider === 'github' || context?.provider === 'gitlab' - ? (context as RepoBackedAutomationSourceContext) - : null -} - -function getRuntimeSourceHostAvailability( - context: TaskSourceContext, - runtimeStatusByEnvironmentId: ReadonlyMap< - string, - { status: RuntimeStatus | null; checkedAt: number } - > -): TaskSourceHostAvailability | null { - const parsed = parseExecutionHostId(context.hostId) - if (parsed?.kind !== 'runtime') { - return null - } - const entry = runtimeStatusByEnvironmentId.get(parsed.environmentId) - if (!entry) { - return { hostId: context.hostId, reason: 'checking-task-source-capability' } - } - if (!entry.status) { - return { hostId: context.hostId, health: 'disconnected' } - } - if (entry.status.graphStatus !== 'ready') { - return { hostId: context.hostId, health: 'connecting' } - } - const capabilities = entry.status.capabilities - if (!capabilities) { - return { hostId: context.hostId, reason: 'checking-task-source-capability' } - } - if (!capabilities.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY)) { - return { hostId: context.hostId, reason: 'missing-task-source-capability' } - } - return null -} - -function formatTimeInput(hour: number, minute: number): string { - return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` -} - -function parseDraftTime(time: string): { hour: number; minute: number } { - const [rawHour, rawMinute] = time.split(':').map((part) => Number(part)) - return { - hour: Number.isFinite(rawHour) ? rawHour : 9, - minute: Number.isFinite(rawMinute) ? rawMinute : 0 - } -} - -function buildDraftPrecheck(draft: AutomationDraft): AutomationPrecheck | null { - const command = draft.precheckCommand.trim() - if (!command) { - return null - } - const rawTimeout = Number(draft.precheckTimeoutSeconds) - return { - command, - timeoutSeconds: Number.isFinite(rawTimeout) ? rawTimeout : 60 - } -} - -function buildHermesCronSchedule(draft: AutomationDraft): string { - if (draft.preset === 'custom') { - return draft.customSchedule.trim() - } - const { hour, minute } = parseDraftTime(draft.time) - return buildAutomationCronSchedule({ - preset: draft.preset, - hour, - minute, - dayOfWeek: Number(draft.dayOfWeek) - }) -} - -function getAgentLabel(agentId: string): string { - return getAgentCatalog().find((agent) => agent.id === agentId)?.label ?? agentId -} - -function getExternalAutomationKey( - manager: ExternalAutomationManager, - job: ExternalAutomationJob -): string { - return `${manager.id}:${job.id}` -} - -function getExternalAutomationSourceKey(manager: ExternalAutomationManager): string { - return `${manager.id}:source` -} - -function formatExternalDate(value: string | null, now: number): string { - if (!value) { - return 'Never' - } - const parsed = Date.parse(value) - if (!Number.isFinite(parsed)) { - return value - } - return formatAutomationDateTimeWithRelative(parsed, now) -} - -function getExternalProviderLabel(manager: ExternalAutomationManager): string { - return manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw' -} - -function getExternalTargetKindLabel(manager: ExternalAutomationManager): string { - return manager.target.type === 'ssh' ? 'SSH host' : 'Local' -} - -function getExternalRunStatusLabel(run: ExternalAutomationRun): string { - switch (run.status) { - case 'completed': - return 'Completed' - case 'failed': - return 'Failed' - case 'unknown': - return 'Unknown' - } -} - -function getExternalRunStatusVariant( - run: ExternalAutomationRun -): React.ComponentProps['variant'] { - switch (run.status) { - case 'completed': - return 'secondary' - case 'failed': - return 'destructive' - case 'unknown': - return 'outline' - } -} - -function getExternalRunContent(run: ExternalAutomationRun): string { - return run.outputContent ?? run.error ?? run.outputPreview ?? 'No output content available.' -} - -function getAutomationRunContent(run: AutomationRun): string { - const savedOutput = run.outputSnapshot?.content.trim() - if (savedOutput) { - return run.outputSnapshot?.content ?? savedOutput - } - if (run.precheckResult) { - const output = [run.precheckResult.stderr.trim(), run.precheckResult.stdout.trim()] - .filter(Boolean) - .join('\n\n') - if (output) { - return output - } - } - return run.error ?? run.usage?.unavailableMessage ?? 'No output content available.' -} - -function isMissingExternalRunsApiError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return /listExternalRuns|automations:listExternalRuns|No handler registered/i.test(message) -} - -async function waitForAutomationRerunPendingVisibility(pendingStartedAt: number): Promise { - const remainingMs = getAutomationRerunPendingRemainingMs({ pendingStartedAt }) - if (remainingMs <= 0) { - return - } - await new Promise((resolve) => window.setTimeout(resolve, remainingMs)) -} - export default function AutomationsPage(): React.JSX.Element { const repos = useAppStore((s) => s.repos) const projectHostSetups = useAppStore((s) => s.projectHostSetups) @@ -415,6 +180,7 @@ export default function AutomationsPage(): React.JSX.Element { ) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) + const [listSearchQuery, setListSearchQuery] = useState('') const [createOpen, setCreateOpen] = useState(false) const [createTarget, setCreateTarget] = useState('orca') const [editingAutomationId, setEditingAutomationId] = useState(null) @@ -463,6 +229,9 @@ export default function AutomationsPage(): React.JSX.Element { const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false) const editRequestRef = useRef(0) const deleteConfirmButtonRef = useRef(null) + // Why: both dialogs stay mounted, so a shared ref would let one dialog's + // unmount clear the focus target the other still needs. + const externalDeleteConfirmButtonRef = useRef(null) const completionInFlightRef = useRef>(new Set()) const rerunRunIdsInFlightRef = useRef>(new Set()) const workspaceNameCacheRef = useRef>(new Map()) @@ -488,40 +257,35 @@ export default function AutomationsPage(): React.JSX.Element { precheckCommand: '', precheckTimeoutSeconds: '60', preset: 'weekdays', - time: DEFAULT_TIME, + time: AUTOMATION_DEFAULT_TIME, dayOfWeek: '1', customSchedule: '', missedRunGraceMinutes: '720', scheduleWarning: null }) - const externalAutomationEntries = useMemo( - () => - externalManagers.flatMap((manager): ExternalAutomationListEntry[] => { - if (manager.jobs.length === 0) { - if ( - manager.provider === 'hermes' && - (manager.status === 'unavailable' || manager.error) - ) { - return [ - { - kind: 'source' as const, - key: getExternalAutomationSourceKey(manager), - manager - } - ] - } - return [] - } - return manager.jobs.map((job) => ({ - kind: 'job' as const, - key: getExternalAutomationKey(manager, job), - manager, - job - })) - }), + const externalAutomationEntries = useMemo( + () => buildExternalAutomationListEntries(externalManagers), [externalManagers] ) + const { + isListSearchQueryTooLarge, + isListSearchActive, + filteredAutomations, + filteredExternalAutomationEntries, + hasListItems, + hasFilteredListItems + } = useAutomationListSearch({ + listSearchQuery, + automations, + externalAutomationEntries, + repoMap, + selectedId, + selectedExternalKey, + selectAutomationId, + selectExternalKey + }) + const selectedExternal = externalAutomationEntries.find((entry) => entry.key === selectedExternalKey) ?? (automations.length === 0 ? (externalAutomationEntries[0] ?? null) : null) @@ -769,7 +533,10 @@ export default function AutomationsPage(): React.JSX.Element { : null const canRerunSelectedAutomationRunPage = selectedAutomationRunPage !== null && - canRerunAutomationRun({ automation: selected, run: selectedAutomationRunPage }) + canRerunAutomationRun({ + automation: selected, + run: selectedAutomationRunPage + }) const isSelectedAutomationRunPageRerunPending = selectedAutomationRunPage !== null && rerunRunIdsInFlight.has(selectedAutomationRunPage.id) const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey @@ -1298,7 +1065,7 @@ export default function AutomationsPage(): React.JSX.Element { precheckCommand: '', precheckTimeoutSeconds: '60', preset: 'weekdays', - time: DEFAULT_TIME, + time: AUTOMATION_DEFAULT_TIME, dayOfWeek: '1', customSchedule: '', missedRunGraceMinutes: '720', @@ -1356,7 +1123,7 @@ export default function AutomationsPage(): React.JSX.Element { precheckCommand: latest.precheck?.command ?? '', precheckTimeoutSeconds: String(latest.precheck?.timeoutSeconds ?? 60), preset: schedule?.preset ?? (hasCustomSchedule ? 'custom' : 'weekdays'), - time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : DEFAULT_TIME, + time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : AUTOMATION_DEFAULT_TIME, dayOfWeek: String(schedule?.dayOfWeek ?? 1), customSchedule: hasCustomSchedule ? latest.rrule : '', missedRunGraceMinutes: String(latest.missedRunGraceMinutes), @@ -1404,7 +1171,7 @@ export default function AutomationsPage(): React.JSX.Element { precheckCommand: '', precheckTimeoutSeconds: '60', preset: hasCustomSchedule ? 'custom' : 'weekdays', - time: DEFAULT_TIME, + time: AUTOMATION_DEFAULT_TIME, dayOfWeek: '1', customSchedule: hasCustomSchedule ? rawSchedule : '', missedRunGraceMinutes: '720', @@ -2009,7 +1776,9 @@ export default function AutomationsPage(): React.JSX.Element { ) return } - const state = await window.api.ssh.connect({ targetId: manager.target.connectionId }) + const state = await window.api.ssh.connect({ + targetId: manager.target.connectionId + }) if (!state || state.status !== 'connected') { toast.error( state?.error ?? @@ -2107,6 +1876,12 @@ export default function AutomationsPage(): React.JSX.Element { return } + // Why: fields that clear their own value on Escape consume this press; + // blurring here would drop focus and let the next Escape close the page. + if (target.dataset.escapeClearsValue === 'true') { + return + } + // Why: match Tasks page behavior: Esc first exits field focus, then exits // the page once focus is back on page chrome. if ( @@ -2232,8 +2007,10 @@ export default function AutomationsPage(): React.JSX.Element { onSave={() => void saveAutomation()} /> - { if (open) { return @@ -2241,766 +2018,116 @@ export default function AutomationsPage(): React.JSX.Element { setDeleteTarget(null) setDontAskDeleteAgain(false) }} - > - { - event.preventDefault() - deleteConfirmButtonRef.current?.focus() - }} - > - - - {translate( - 'auto.components.automations.AutomationsPage.080dcb5fbb', - 'Delete Automation' - )} - - - {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '} - {deleteTarget?.name}{' '} - {translate( - 'auto.components.automations.AutomationsPage.b264564427', - 'and its run history. Workspaces created by previous runs are not deleted.' - )} - - - {deleteTarget ? ( -
-
{deleteTarget.name}
-
- {deleteTarget.workspaceMode === 'new_per_run' - ? translate( - 'auto.components.automations.AutomationsPage.cd8397cc32', - 'New workspace each run' - ) - : translate( - 'auto.components.automations.AutomationsPage.36f71740a7', - 'Selected workspace' - )} -
-
- ) : null} - - - - - -
-
+ onDontAskAgainToggle={() => setDontAskDeleteAgain((prev) => !prev)} + onCancel={() => { + setDeleteTarget(null) + setDontAskDeleteAgain(false) + }} + onConfirm={() => void confirmDeleteAutomation()} + /> - { if (!open) { setExternalDeleteTarget(null) } }} - > - { - event.preventDefault() - deleteConfirmButtonRef.current?.focus() - }} - > - - - {translate( - 'auto.components.automations.AutomationsPage.9adfab2596', - 'Delete External Automation' - )} - - - {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '} - - {externalDeleteTarget?.job.name} - {' '} - {translate('auto.components.automations.AutomationsPage.02a33e3204', 'from')}{' '} - {externalDeleteTarget - ? getExternalProviderLabel(externalDeleteTarget.manager) - : translate( - 'auto.components.automations.AutomationsPage.8500baacb4', - 'external source' - )}{' '} - {translate('auto.components.automations.AutomationsPage.1b586f0e2b', 'on')} - {externalDeleteTarget?.manager.targetLabel}. - - - {externalDeleteTarget ? ( -
-
- {externalDeleteTarget.job.name} -
-
- { - getExternalAutomationScheduleDisplay( - externalDeleteTarget.manager, - externalDeleteTarget.job - ).label - } -
-
- ) : null} - - - - -
-
+ onCancel={() => setExternalDeleteTarget(null)} + onConfirm={() => void confirmDeleteExternalAutomation()} + />
-
-
- {automations.length + externalAutomationEntries.length > 0 ? ( -
- - {translate( - 'auto.components.automations.AutomationsPage.761a35834d', - 'Automation' - )} - - - {translate('auto.components.automations.AutomationsPage.587a4b205c', 'Next')} - -
- ) : null} - {automations.map((automation) => { - const automationRepo = repoMap.get(getAutomationRunRepoId(automation)) - const automationWorktree = automation.workspaceId - ? worktreeMap.get(automation.workspaceId) - : null - const automationRunAvailability = getAutomationTargetAvailability({ - automation, - repo: automationRepo, - workspace: automationWorktree, - projectHostSetups, - sshConnectionStates, - runtimeStatusByEnvironmentId, - automationHostTarget, - sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) - }) - const workspaceLabel = - automation.workspaceMode === 'new_per_run' - ? `Create from ${automation.baseBranch ?? automationRepo?.worktreeBaseRef ?? 'project default'}` - : (automationWorktree?.displayName ?? 'Missing workspace') - const usageSummary = summarizeAutomationRunUsage( - runs.filter((run) => run.automationId === automation.id) - ) - const usageText = - usageSummary.knownRuns > 0 - ? `${formatAutomationCost( - usageSummary.estimatedCostUsd - )} est. · ${formatAutomationTokens(usageSummary.totalTokens)} tokens` - : usageSummary.unavailableRuns > 0 - ? 'Usage unavailable' - : 'No run usage yet' - const nextRunLabel = automation.enabled - ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow) - : 'Paused' - const scheduleLabel = formatAutomationSchedule(automation.rrule) - return ( - - - - - - { - if (!automationRunAvailability.canRunNow) { - event.preventDefault() - return - } - void runNow(automation) - }} - > - - - {automationRunAvailability.canRunNow - ? translate( - 'auto.components.automations.AutomationsPage.2faecab10b', - 'Run Now' - ) - : automationRunAvailability.message} - - - void openEditDialog(automation)}> - - {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} - - void toggleAutomation(automation)}> - {automation.enabled ? ( - - ) : ( - - )} - {automation.enabled - ? translate( - 'auto.components.automations.AutomationsPage.b457436d6a', - 'Pause' - ) - : translate( - 'auto.components.automations.AutomationsPage.376631ef2b', - 'Resume' - )} - - - requestDeleteAutomation(automation)} - > - - {translate( - 'auto.components.automations.AutomationsPage.15e0bfb13b', - 'Delete' - )} - - - - ) - })} - {externalAutomationEntries.map((entry) => { - const providerLabel = getExternalProviderLabel(entry.manager) - const targetKindLabel = getExternalTargetKindLabel(entry.manager) - if (entry.kind === 'source') { - const sshStatus = - entry.manager.target.type === 'ssh' - ? sshConnectionStates.get(entry.manager.target.connectionId)?.status - : undefined - const sourceAvailability = getExternalAutomationSourceAvailability({ - manager: entry.manager, - providerLabel, - targetKindLabel, - sshStatus - }) - return ( - + void runNow(automation)} + openEditDialog={(automation) => void openEditDialog(automation)} + toggleAutomation={(automation) => void toggleAutomation(automation)} + requestDeleteAutomation={requestDeleteAutomation} + requestExternalAction={requestExternalAction} + openEditExternalDialog={openEditExternalDialog} + openCreateDialog={openCreateDialog} + /> + + - - - - - requestExternalAction(entry.manager, entry.job, 'run')} - > - - - {disabledMessage ?? - translate( - 'auto.components.automations.AutomationsPage.2faecab10b', - 'Run Now' - )} - - - {entry.manager.provider === 'hermes' ? ( - openEditExternalDialog(entry.manager, entry.job)} - > - - {translate( - 'auto.components.automations.AutomationsPage.f4612e3f78', - 'Edit' - )} - - ) : null} - - requestExternalAction( - entry.manager, - entry.job, - entry.job.enabled ? 'pause' : 'resume' - ) - } - > - {entry.job.enabled ? ( - - ) : ( - - )} - {entry.job.enabled - ? translate( - 'auto.components.automations.AutomationsPage.b457436d6a', - 'Pause' - ) - : translate( - 'auto.components.automations.AutomationsPage.376631ef2b', - 'Resume' - )} - - - requestExternalAction(entry.manager, entry.job, 'delete')} - > - - {translate( - 'auto.components.automations.AutomationsPage.15e0bfb13b', - 'Delete' - )} - - - - ) - })} - {automations.length === 0 && externalAutomationEntries.length === 0 ? ( -
-
- {translate( - 'auto.components.automations.AutomationsPage.d207ab4c25', - 'Start from a template' - )} -
- {getAutomationTemplates().map((template) => ( - - ))} - -
- ) : null} -
-
- -
- {selectedExternal ? ( -
- {selectedExternalRunPage ? ( - setSelectedExternalRunPage(null)} - > - - - ) : selectedExternal.kind === 'job' ? ( - - ) : ( -
-
-
-
- {selectedExternal.manager.targetLabel} -
-
- {selectedExternalSourceAvailability?.summary} -
-
- {selectedExternalSshSource ? ( - - ) : null} -
-
- {selectedExternalSourceAvailability?.detail} -
-
- )} -
- ) : ( - setActivePaneTab(value as AutomationPaneTab)} - className="min-h-0 flex-1 gap-0" - > -
- - - {translate( - 'auto.components.automations.AutomationsPage.bb1b2cd31e', - 'Overview' - )} - - - {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '} - {selectedRuns.length} - - -
- - - void runNow(automation)} - onEdit={(automation) => void openEditDialog(automation)} - onToggle={(automation) => void toggleAutomation(automation)} - onDelete={requestDeleteAutomation} - /> - - - - {selectedAutomationRunPage ? ( - - {canRerunSelectedAutomationRunPage && selected ? ( - - ) : null} - {selectedAutomationRunPageViewState ? ( - - ) : null} - - } - onBack={() => setSelectedAutomationRunPageId(null)} - > - - - ) : selected ? ( - - ) : ( -
- {translate( - 'auto.components.automations.AutomationsPage.c3a28c9793', - 'Select an automation to view runs.' - )} -
- )} -
-
- )} -
+ : (selectedWorktree?.displayName ?? + translate( + 'auto.components.automations.AutomationsPage.missingWorkspace', + 'Missing workspace' + )) + } + hostLabelById={hostLabelById} + selectedRunNowAvailability={selectedRunNowAvailability} + selectedExternalSourceAvailability={selectedExternalSourceAvailability} + selectedExternalSshSource={ + selectedExternalSshSource ? { manager: selectedExternalSshSource.manager } : null + } + selectedExternalSshConnected={selectedExternalSshConnected} + selectedAutomationRunPageWorkspaceDisplay={selectedAutomationRunPageWorkspaceDisplay} + selectedAutomationRunPageViewState={selectedAutomationRunPageViewState} + canRerunSelectedAutomationRunPage={canRerunSelectedAutomationRunPage} + isSelectedAutomationRunPageRerunPending={isSelectedAutomationRunPageRerunPending} + worktreeMap={worktreeMap} + fetchExternalAutomationRuns={fetchExternalAutomationRuns} + onActivePaneTabChange={setActivePaneTab} + onClearExternalRunPage={() => setSelectedExternalRunPage(null)} + onClearAutomationRunPage={() => setSelectedAutomationRunPageId(null)} + requestExternalAction={requestExternalAction} + openExternalRunPage={openExternalRunPage} + openEditExternalDialog={openEditExternalDialog} + connectExternalAutomationSource={(manager) => + void connectExternalAutomationSource(manager) + } + runNow={(automation) => void runNow(automation)} + openEditDialog={(automation) => void openEditDialog(automation)} + toggleAutomation={(automation) => void toggleAutomation(automation)} + requestDeleteAutomation={requestDeleteAutomation} + rerunAutomationRun={(automation, run) => void rerunAutomationRun(automation, run)} + openRunWorkspace={openRunWorkspace} + openAutomationRunPage={openAutomationRunPage} + />
) diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx index bd6e1b67549..f64684f8c29 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx @@ -10,7 +10,11 @@ import type { ExternalAutomationManager, ExternalAutomationRun } from '../../../../shared/automations-types' -import { formatAutomationDateTimeWithRelative } from './automation-page-parts' +import { + formatExternalDate, + getExternalProviderLabel, + getExternalTargetKindLabel +} from './external-automation-display' import { ExternalAutomationRunTable, type FetchExternalAutomationRuns @@ -37,17 +41,6 @@ type ExternalAutomationManagersProps = { onEdit?: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void } -function formatExternalDate(value: string | null, now: number): string { - if (!value) { - return 'Never' - } - const parsed = Date.parse(value) - if (!Number.isFinite(parsed)) { - return value - } - return formatAutomationDateTimeWithRelative(parsed, now) -} - function actionKey( manager: ExternalAutomationManager, job: ExternalAutomationJob, @@ -56,14 +49,6 @@ function actionKey( return `${manager.id}:${job.id}:${action}` } -function getProviderLabel(manager: ExternalAutomationManager): string { - return manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw' -} - -function getTargetKindLabel(manager: ExternalAutomationManager): string { - return manager.target.type === 'ssh' ? 'SSH host' : 'Local' -} - function ExternalActionButton({ label, disabled, @@ -140,7 +125,7 @@ export function ExternalAutomationManagers({
{manager.targetLabel}
- {getProviderLabel(manager)} / {getTargetKindLabel(manager)} ·{' '} + {getExternalProviderLabel(manager)} / {getExternalTargetKindLabel(manager)} ·{' '} {manager.status === 'available' ? manager.canManage ? translate( @@ -236,8 +221,8 @@ export function ExternalAutomationManagers({ 'auto.components.automations.ExternalAutomationManagers.20fd7a3a15', 'next' )}{' '} - {formatExternalDate(job.nextRunAt, now)} · {getProviderLabel(manager)} /{' '} - {manager.targetLabel} + {formatExternalDate(job.nextRunAt, now)} ·{' '} + {getExternalProviderLabel(manager)} / {manager.targetLabel}
{manager.provider === 'hermes' ? (
diff --git a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx index f6e5f693371..2c322737cc8 100644 --- a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx @@ -10,7 +10,11 @@ import type { ExternalAutomationManager, ExternalAutomationRun } from '../../../../shared/automations-types' -import { formatAutomationDateTimeWithRelative } from './automation-page-parts' +import { + formatExternalDate, + getExternalRunStatusLabel, + getExternalRunStatusVariant +} from './external-automation-display' import { createExternalAutomationRunTableState, resolveExternalAutomationFetchedRuns, @@ -41,41 +45,6 @@ type ExternalAutomationRunTableProps = { onOpenRun?: (run: ExternalAutomationRun) => void } -function formatExternalDate(value: string | null, now: number): string { - if (!value) { - return 'Never' - } - const parsed = Date.parse(value) - if (!Number.isFinite(parsed)) { - return value - } - return formatAutomationDateTimeWithRelative(parsed, now) -} - -function getRunStatusLabel(run: ExternalAutomationRun): string { - switch (run.status) { - case 'completed': - return 'Completed' - case 'failed': - return 'Failed' - case 'unknown': - return 'Unknown' - } -} - -function getRunStatusVariant( - run: ExternalAutomationRun -): React.ComponentProps['variant'] { - switch (run.status) { - case 'completed': - return 'secondary' - case 'failed': - return 'destructive' - case 'unknown': - return 'outline' - } -} - function getRunSummary(run: ExternalAutomationRun): string { return run.error ?? run.outputPreview ?? 'No output preview' } @@ -263,7 +232,9 @@ export function ExternalAutomationRunTable({ {getRunSummary(run)} - {getRunStatusLabel(run)} + + {getExternalRunStatusLabel(run)} + ))}
diff --git a/src/renderer/src/components/automations/automation-draft-model.ts b/src/renderer/src/components/automations/automation-draft-model.ts new file mode 100644 index 00000000000..747bc615782 --- /dev/null +++ b/src/renderer/src/components/automations/automation-draft-model.ts @@ -0,0 +1,52 @@ +import { getAgentCatalog } from '@/lib/agent-catalog' +import type { AutomationPrecheck } from '../../../../shared/automations-types' +import { buildAutomationCronSchedule } from '../../../../shared/automation-schedules' +import type { Worktree } from '../../../../shared/types' +import type { AutomationDraft } from './AutomationEditorDialog' + +export const AUTOMATION_DEFAULT_TIME = '09:00' + +export function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null { + return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null +} + +export function formatTimeInput(hour: number, minute: number): string { + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` +} + +export function parseDraftTime(time: string): { hour: number; minute: number } { + const [rawHour, rawMinute] = time.split(':').map((part) => Number(part)) + return { + hour: Number.isFinite(rawHour) ? rawHour : 9, + minute: Number.isFinite(rawMinute) ? rawMinute : 0 + } +} + +export function buildDraftPrecheck(draft: AutomationDraft): AutomationPrecheck | null { + const command = draft.precheckCommand.trim() + if (!command) { + return null + } + const rawTimeout = Number(draft.precheckTimeoutSeconds) + return { + command, + timeoutSeconds: Number.isFinite(rawTimeout) ? rawTimeout : 60 + } +} + +export function buildHermesCronSchedule(draft: AutomationDraft): string { + if (draft.preset === 'custom') { + return draft.customSchedule.trim() + } + const { hour, minute } = parseDraftTime(draft.time) + return buildAutomationCronSchedule({ + preset: draft.preset, + hour, + minute, + dayOfWeek: Number(draft.dayOfWeek) + }) +} + +export function getAgentLabel(agentId: string): string { + return getAgentCatalog().find((agent) => agent.id === agentId)?.label ?? agentId +} diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index b3569b4b7b2..36e1ff0560a 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -26,6 +26,20 @@ export type AutomationHostTarget = | { kind: 'local' } | { kind: 'environment'; environmentId: string } +export function getAutomationHostTargetKey(target: AutomationHostTarget): string { + return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local' +} + +export function getAutomationHostTargetFromKey(key: string | null): AutomationHostTarget | null { + if (!key) { + return null + } + if (key.startsWith('environment:')) { + return { kind: 'environment', environmentId: key.slice('environment:'.length) } + } + return { kind: 'local' } +} + export function getAutomationTargetFromHostId( hostId: string | null | undefined ): AutomationHostTarget { diff --git a/src/renderer/src/components/automations/automation-list-search.test.ts b/src/renderer/src/components/automations/automation-list-search.test.ts new file mode 100644 index 00000000000..47f59faaba6 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-search.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import { + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS, + AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES, + AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT, + automationListSearchFieldsMatch, + automationListSearchIndexMatches, + buildAutomationListSearchFingerprint, + buildAutomationListSearchIndex, + buildAutomationProjectSearchText, + clampAutomationListSearchQueryInput, + filterByActiveAutomationListSearchQuery, + filterByAutomationListSearch, + filterByAutomationListSearchIndex, + getActiveAutomationListSearchQuery, + isAutomationListSearchQueryTooLarge, + normalizeAutomationListSearchField, + resolveAutomationListSearchQuery, + truncateAutomationListSearchField +} from './automation-list-search' + +describe('automation-list-search', () => { + it('normalizes query casing and whitespace', () => { + expect(getActiveAutomationListSearchQuery(' Auto PR ')).toBe('auto pr') + expect(resolveAutomationListSearchQuery(' Auto PR ')).toEqual({ + status: 'active', + query: 'auto pr' + }) + }) + + it('rejects oversized queries without searching', () => { + const oversized = 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1) + expect(isAutomationListSearchQueryTooLarge(oversized)).toBe(true) + expect(getActiveAutomationListSearchQuery(oversized)).toBeNull() + expect(resolveAutomationListSearchQuery(oversized)).toEqual({ status: 'too_large' }) + expect( + automationListSearchFieldsMatch( + { name: 'Auto PR', project: 'orca', prompt: 'nudge' }, + oversized + ) + ).toBe(false) + + const items = [ + { id: '1', name: 'Auto PR', project: 'orca', prompt: 'nudge' }, + { id: '2', name: 'Nightly', project: 'mobile', prompt: 'ship' } + ] + // Why: oversized paste must leave the list unfiltered, not blank it. + expect(filterByAutomationListSearch(items, oversized, (item) => item)).toBe(items) + }) + + it('rejects queries over the byte limit but under the code-unit limit', () => { + // 3 UTF-8 bytes per character, so half the cap in code units is over it in bytes. + const multiByte = '한'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES / 2) + expect(multiByte.length).toBeLessThanOrEqual(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES) + expect(isAutomationListSearchQueryTooLarge(multiByte)).toBe(true) + expect(resolveAutomationListSearchQuery(multiByte)).toEqual({ status: 'too_large' }) + }) + + it('treats whitespace-only queries as inactive (no search work)', () => { + expect(getActiveAutomationListSearchQuery(' \t ')).toBeNull() + expect(resolveAutomationListSearchQuery(' ')).toEqual({ status: 'inactive' }) + const items = [{ name: 'A', project: 'p1', prompt: 'one' }] + expect(filterByAutomationListSearch(items, ' ', (item) => item)).toBe(items) + }) + + it('clamps stored query input so multi-MB pastes are discarded', () => { + const hugePaste = 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES * 100) + const clamped = clampAutomationListSearchQueryInput(hugePaste) + expect(clamped.length).toBe(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1) + expect(isAutomationListSearchQueryTooLarge(clamped)).toBe(true) + expect(clampAutomationListSearchQueryInput('auto pr')).toBe('auto pr') + }) + + it('caps indexed field length so huge prompts stay bounded', () => { + const prompt = `${'x'.repeat(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS)}unique-tail-token` + const index = buildAutomationListSearchIndex({ + name: 'Nightly', + project: 'mobile', + prompt + }) + expect(index.prompt.length).toBe(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS) + expect(automationListSearchIndexMatches(index, 'unique-tail-token')).toBe(false) + expect(automationListSearchIndexMatches(index, 'xxxx')).toBe(true) + }) + + it('null-safely normalizes missing fields', () => { + expect(normalizeAutomationListSearchField(null, 10)).toBe('') + expect(normalizeAutomationListSearchField(undefined, 10)).toBe('') + expect( + buildAutomationListSearchIndex({ + name: 'Job', + project: 'host', + prompt: null as unknown as string + }).prompt + ).toBe('') + }) + + it('does not split surrogate pairs when truncating', () => { + const emoji = '😀' + const value = `${'a'.repeat(7)}${emoji}` + expect(truncateAutomationListSearchField(value, 8)).toBe('a'.repeat(7)) + expect(truncateAutomationListSearchField(value, 9)).toBe(value) + }) + + it('indexes unknown project fallback for missing repos', () => { + expect(buildAutomationProjectSearchText({})).toBe(AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT) + expect(buildAutomationProjectSearchText({ displayName: ' ', path: null })).toBe( + AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT + ) + expect(buildAutomationProjectSearchText({ displayName: 'orca', path: '/tmp/orca' })).toBe( + 'orca /tmp/orca' + ) + const index = buildAutomationListSearchIndex({ + name: 'Orphan', + project: buildAutomationProjectSearchText({}), + prompt: 'hi' + }) + expect(automationListSearchIndexMatches(index, 'unknown')).toBe(true) + }) + + it('matches name, project, or prompt', () => { + const fields = { + name: 'Auto PR assignment', + project: 'orca / main', + prompt: 'Assign reviewers for open PRs' + } + expect(automationListSearchFieldsMatch(fields, 'assignment')).toBe(true) + expect(automationListSearchFieldsMatch(fields, 'ORCA')).toBe(true) + expect(automationListSearchFieldsMatch(fields, 'reviewers')).toBe(true) + expect(automationListSearchFieldsMatch(fields, 'missing')).toBe(false) + }) + + it('filters by active query without re-resolving bounds', () => { + const items = [ + { id: '1', name: 'Auto Issue assignment', project: 'orca', prompt: 'triage issues' }, + { id: '2', name: 'Nightly deploy', project: 'mobile', prompt: 'ship apk' }, + { id: '3', name: 'PR nudge', project: 'orca', prompt: 'remind reviewers' } + ] + const indexes = items.map((item) => + buildAutomationListSearchIndex({ + name: item.name, + project: item.project, + prompt: item.prompt + }) + ) + expect( + filterByActiveAutomationListSearchQuery(items, indexes, 'apk').map((item) => item.id) + ).toEqual(['2']) + expect( + filterByAutomationListSearchIndex(items, indexes, 'orca').map((item) => item.id) + ).toEqual(['1', '3']) + expect(filterByAutomationListSearchIndex(items, indexes, ' ')).toBe(items) + expect( + filterByAutomationListSearchIndex( + items, + indexes, + 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1) + ) + ).toBe(items) + // Why: a desynchronized index must leave the list unfiltered, not blank it. + expect( + filterByActiveAutomationListSearchQuery(items, indexes.slice(0, 1), 'apk').map( + (item) => item.id + ) + ).toEqual(['1', '2', '3']) + }) + + it('builds a stable fingerprint from search sources only', () => { + const sources = [ + { name: 'A', project: 'p1', prompt: 'one' }, + { name: 'B', project: 'p2', prompt: 'two' } + ] + expect(buildAutomationListSearchFingerprint(sources)).toBe( + buildAutomationListSearchFingerprint([ + { name: 'A', project: 'p1', prompt: 'one' }, + { name: 'B', project: 'p2', prompt: 'two' } + ]) + ) + expect(buildAutomationListSearchFingerprint(sources)).not.toBe( + buildAutomationListSearchFingerprint([ + { name: 'A', project: 'p1', prompt: 'changed' }, + { name: 'B', project: 'p2', prompt: 'two' } + ]) + ) + }) +}) diff --git a/src/renderer/src/components/automations/automation-list-search.ts b/src/renderer/src/components/automations/automation-list-search.ts new file mode 100644 index 00000000000..b27da8fe221 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-search.ts @@ -0,0 +1,244 @@ +import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' + +/** Pasted queries above this are rejected so filtering never runs on unbounded input. */ +export const AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES = 2 * 1024 + +// Why: prompts can be multi-MB agent instructions. Index only a fixed prefix so +// lowercasing/includes stay O(bound) per automation rather than O(prompt size). +export const AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS = 512 +export const AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS = 1_024 +export const AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS = 8 * 1024 + +/** Indexed when a local automation has no resolved project so "unknown" still matches. */ +export const AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT = 'unknown project' + +export type AutomationListSearchFields = { + name: string + project: string + prompt: string +} + +/** Lowercased, length-capped fields ready for substring match. */ +export type AutomationListSearchIndex = { + name: string + project: string + prompt: string +} + +export type AutomationListSearchQueryResolution = + | { status: 'inactive' } + | { status: 'too_large' } + | { status: 'active'; query: string } + +export function isAutomationListSearchQueryTooLarge( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): boolean { + return isClipboardTextByteLengthOverLimit(rawQuery, maxBytes) +} + +/** + * Caps the controlled input value so a multi-MB paste cannot pin renderer + * memory. Keeping maxBytes+1 code units is enough for the over-limit check + * (`length > maxBytes`) while discarding the rest of the paste. + */ +export function clampAutomationListSearchQueryInput( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): string { + const maxStoredCodeUnits = maxBytes + 1 + if (rawQuery.length <= maxStoredCodeUnits) { + return rawQuery + } + return rawQuery.slice(0, maxStoredCodeUnits) +} + +export function resolveAutomationListSearchQuery( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): AutomationListSearchQueryResolution { + // Why: length pre-check short-circuits multi-MB pastes before UTF-8 scan. + if (isClipboardTextByteLengthOverLimit(rawQuery, maxBytes)) { + return { status: 'too_large' } + } + const query = rawQuery.trim().toLowerCase() + if (!query) { + return { status: 'inactive' } + } + return { status: 'active', query } +} + +/** Active lowercase query, or null when search must not run. */ +export function getActiveAutomationListSearchQuery( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): string | null { + const resolved = resolveAutomationListSearchQuery(rawQuery, maxBytes) + return resolved.status === 'active' ? resolved.query : null +} + +/** Avoid splitting a surrogate pair at the cap boundary. */ +export function truncateAutomationListSearchField(value: string, maxCodeUnits: number): string { + if (value.length <= maxCodeUnits) { + return value + } + let end = maxCodeUnits + const last = value.charCodeAt(end - 1) + // High surrogate at the cut would leave an orphan low surrogate. + if (last >= 0xd800 && last <= 0xdbff) { + end -= 1 + } + return value.slice(0, end) +} + +export function normalizeAutomationListSearchField( + value: string | null | undefined, + maxCodeUnits: number +): string { + if (value == null || value === '') { + return '' + } + return truncateAutomationListSearchField(value, maxCodeUnits).toLowerCase() +} + +export function buildAutomationListSearchIndex( + fields: AutomationListSearchFields +): AutomationListSearchIndex { + return { + name: normalizeAutomationListSearchField( + fields.name, + AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS + ), + project: normalizeAutomationListSearchField( + fields.project, + AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS + ), + prompt: normalizeAutomationListSearchField( + fields.prompt, + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS + ) + } +} + +export function buildAutomationProjectSearchText(parts: { + displayName?: string | null + path?: string | null +}): string { + const joined = [parts.displayName, parts.path] + .map((part) => part?.trim() ?? '') + .filter(Boolean) + .join(' ') + return joined || AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT +} + +export function automationListSearchIndexMatches( + index: AutomationListSearchIndex, + activeQuery: string +): boolean { + // Why: check short fields first so huge-prompt includes rarely run. + return ( + index.name.includes(activeQuery) || + index.project.includes(activeQuery) || + index.prompt.includes(activeQuery) + ) +} + +export function automationListSearchFieldsMatch( + fields: AutomationListSearchFields, + rawQuery: string +): boolean { + const resolved = resolveAutomationListSearchQuery(rawQuery) + if (resolved.status === 'too_large') { + return false + } + if (resolved.status === 'inactive') { + return true + } + return automationListSearchIndexMatches(buildAutomationListSearchIndex(fields), resolved.query) +} + +/** + * Filters with an already-resolved active query. Callers must pass null/skip + * when search is inactive or too large so this never runs "for free". + */ +export function filterByActiveAutomationListSearchQuery( + items: readonly T[], + indexes: readonly AutomationListSearchIndex[], + activeQuery: string +): T[] { + if (indexes.length !== items.length) { + return [...items] + } + const matches: T[] = [] + for (let i = 0; i < items.length; i += 1) { + const item = items[i] + const index = indexes[i] + if (item !== undefined && index && automationListSearchIndexMatches(index, activeQuery)) { + matches.push(item) + } + } + return matches +} + +/** + * Filters items by a prebuilt index. Empty and oversized queries leave the + * original array reference untouched so the list stays unfiltered and search + * work is skipped entirely. + */ +export function filterByAutomationListSearchIndex( + items: readonly T[], + indexes: readonly AutomationListSearchIndex[], + rawQuery: string +): readonly T[] { + const activeQuery = getActiveAutomationListSearchQuery(rawQuery) + if (activeQuery === null) { + return items + } + return filterByActiveAutomationListSearchQuery(items, indexes, activeQuery) +} + +/** Builds indexes then filters. Prefer prebuilt indexes when filtering often. */ +export function filterByAutomationListSearch( + items: readonly T[], + rawQuery: string, + getFields: (item: T) => AutomationListSearchFields +): readonly T[] { + const activeQuery = getActiveAutomationListSearchQuery(rawQuery) + if (activeQuery === null) { + return items + } + const matches: T[] = [] + for (const item of items) { + if ( + automationListSearchIndexMatches(buildAutomationListSearchIndex(getFields(item)), activeQuery) + ) { + matches.push(item) + } + } + return matches +} + +/** + * Content fingerprint for search-relevant fields only. Used so list refresh + * ticks that replace arrays with equivalent search content do not rebuild + * indexes or re-run filtering. + */ +export function buildAutomationListSearchFingerprint( + sources: readonly AutomationListSearchFields[] +): string { + if (sources.length === 0) { + return '' + } + let fingerprint = '' + for (let i = 0; i < sources.length; i += 1) { + if (i > 0) { + fingerprint += '\u0000' + } + const source = sources[i] + if (!source) { + continue + } + fingerprint += `${source.name}\u0001${source.project}\u0001${source.prompt}` + } + return fingerprint +} diff --git a/src/renderer/src/components/automations/automation-page-state.ts b/src/renderer/src/components/automations/automation-page-state.ts new file mode 100644 index 00000000000..b4eb2a31cc0 --- /dev/null +++ b/src/renderer/src/components/automations/automation-page-state.ts @@ -0,0 +1,15 @@ +import type { + ExternalAutomationJob, + ExternalAutomationManager, + ExternalAutomationRun +} from '../../../../shared/automations-types' + +/** Detail-pane tab shared by the page, its list panel, and the detail pane. */ +export type AutomationPaneTab = 'overview' | 'runs' + +/** External run opened as a full page inside the detail pane. */ +export type SelectedExternalRunPage = { + manager: ExternalAutomationManager + job: ExternalAutomationJob + run: ExternalAutomationRun +} diff --git a/src/renderer/src/components/automations/automation-run-content.ts b/src/renderer/src/components/automations/automation-run-content.ts new file mode 100644 index 00000000000..5ea2eddabb4 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-content.ts @@ -0,0 +1,17 @@ +import type { AutomationRun } from '../../../../shared/automations-types' + +export function getAutomationRunContent(run: AutomationRun): string { + const savedOutput = run.outputSnapshot?.content.trim() + if (savedOutput) { + return run.outputSnapshot?.content ?? savedOutput + } + if (run.precheckResult) { + const output = [run.precheckResult.stderr.trim(), run.precheckResult.stdout.trim()] + .filter(Boolean) + .join('\n\n') + if (output) { + return output + } + } + return run.error ?? run.usage?.unavailableMessage ?? 'No output content available.' +} diff --git a/src/renderer/src/components/automations/automation-run-view-state.ts b/src/renderer/src/components/automations/automation-run-view-state.ts index dd300871ac7..56f55dec088 100644 --- a/src/renderer/src/components/automations/automation-run-view-state.ts +++ b/src/renderer/src/components/automations/automation-run-view-state.ts @@ -21,6 +21,16 @@ export function getAutomationRerunPendingRemainingMs({ return Math.max(0, pendingStartedAt + AUTOMATION_RERUN_PENDING_MIN_VISIBLE_MS - now) } +export async function waitForAutomationRerunPendingVisibility( + pendingStartedAt: number +): Promise { + const remainingMs = getAutomationRerunPendingRemainingMs({ pendingStartedAt }) + if (remainingMs <= 0) { + return + } + await new Promise((resolve) => window.setTimeout(resolve, remainingMs)) +} + export function canRerunAutomationRun({ automation, run diff --git a/src/renderer/src/components/automations/automation-source-context.ts b/src/renderer/src/components/automations/automation-source-context.ts new file mode 100644 index 00000000000..e8a05ebabc5 --- /dev/null +++ b/src/renderer/src/components/automations/automation-source-context.ts @@ -0,0 +1,56 @@ +import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { Automation } from '../../../../shared/automations-types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' + +export type RepoBackedAutomationSourceContext = TaskSourceContext & { + provider: 'github' | 'gitlab' +} + +export function getRepoBackedAutomationSourceContext( + automation: Automation +): RepoBackedAutomationSourceContext | null { + const context = automation.sourceContext + return context?.provider === 'github' || context?.provider === 'gitlab' + ? (context as RepoBackedAutomationSourceContext) + : null +} + +export function getRuntimeSourceHostAvailability( + context: TaskSourceContext, + runtimeStatusByEnvironmentId: ReadonlyMap< + string, + { status: RuntimeStatus | null; checkedAt: number } + > +): TaskSourceHostAvailability | null { + const parsed = parseExecutionHostId(context.hostId) + if (parsed?.kind !== 'runtime') { + return null + } + const entry = runtimeStatusByEnvironmentId.get(parsed.environmentId) + if (!entry) { + return { + hostId: context.hostId, + reason: 'checking-task-source-capability' + } + } + if (!entry.status) { + return { hostId: context.hostId, health: 'disconnected' } + } + if (entry.status.graphStatus !== 'ready') { + return { hostId: context.hostId, health: 'connecting' } + } + const capabilities = entry.status.capabilities + if (!capabilities) { + return { + hostId: context.hostId, + reason: 'checking-task-source-capability' + } + } + if (!capabilities.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY)) { + return { hostId: context.hostId, reason: 'missing-task-source-capability' } + } + return null +} diff --git a/src/renderer/src/components/automations/external-automation-display.ts b/src/renderer/src/components/automations/external-automation-display.ts new file mode 100644 index 00000000000..ee8e9d19d68 --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-display.ts @@ -0,0 +1,71 @@ +import React from 'react' +import type { Badge } from '@/components/ui/badge' +import type { + ExternalAutomationJob, + ExternalAutomationManager, + ExternalAutomationRun +} from '../../../../shared/automations-types' +import { formatAutomationDateTimeWithRelative } from './automation-page-parts' + +export function getExternalAutomationKey( + manager: ExternalAutomationManager, + job: ExternalAutomationJob +): string { + return `${manager.id}:${job.id}` +} + +export function getExternalAutomationSourceKey(manager: ExternalAutomationManager): string { + return `${manager.id}:source` +} + +export function formatExternalDate(value: string | null, now: number): string { + if (!value) { + return 'Never' + } + const parsed = Date.parse(value) + if (!Number.isFinite(parsed)) { + return value + } + return formatAutomationDateTimeWithRelative(parsed, now) +} + +export function getExternalProviderLabel(manager: ExternalAutomationManager): string { + return manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw' +} + +export function getExternalTargetKindLabel(manager: ExternalAutomationManager): string { + return manager.target.type === 'ssh' ? 'SSH host' : 'Local' +} + +export function getExternalRunStatusLabel(run: ExternalAutomationRun): string { + switch (run.status) { + case 'completed': + return 'Completed' + case 'failed': + return 'Failed' + case 'unknown': + return 'Unknown' + } +} + +export function getExternalRunStatusVariant( + run: ExternalAutomationRun +): React.ComponentProps['variant'] { + switch (run.status) { + case 'completed': + return 'secondary' + case 'failed': + return 'destructive' + case 'unknown': + return 'outline' + } +} + +export function getExternalRunContent(run: ExternalAutomationRun): string { + return run.outputContent ?? run.error ?? run.outputPreview ?? 'No output content available.' +} + +export function isMissingExternalRunsApiError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /listExternalRuns|automations:listExternalRuns|No handler registered/i.test(message) +} diff --git a/src/renderer/src/components/automations/external-automation-list-entries.ts b/src/renderer/src/components/automations/external-automation-list-entries.ts new file mode 100644 index 00000000000..01b3a84720a --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-list-entries.ts @@ -0,0 +1,46 @@ +import type { + ExternalAutomationJob, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import { + getExternalAutomationKey, + getExternalAutomationSourceKey +} from './external-automation-display' + +export type ExternalAutomationListEntry = + | { + kind: 'job' + key: string + manager: ExternalAutomationManager + job: ExternalAutomationJob + } + | { + kind: 'source' + key: string + manager: ExternalAutomationManager + } + +export function buildExternalAutomationListEntries( + managers: readonly ExternalAutomationManager[] +): ExternalAutomationListEntry[] { + return managers.flatMap((manager): ExternalAutomationListEntry[] => { + if (manager.jobs.length === 0) { + if (manager.provider === 'hermes' && (manager.status === 'unavailable' || manager.error)) { + return [ + { + kind: 'source' as const, + key: getExternalAutomationSourceKey(manager), + manager + } + ] + } + return [] + } + return manager.jobs.map((job) => ({ + kind: 'job' as const, + key: getExternalAutomationKey(manager, job), + manager, + job + })) + }) +} diff --git a/src/renderer/src/components/automations/use-automation-list-search.ts b/src/renderer/src/components/automations/use-automation-list-search.ts new file mode 100644 index 00000000000..ce16d98c06a --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-list-search.ts @@ -0,0 +1,265 @@ +import { useDeferredValue, useEffect, useMemo } from 'react' +import type { Automation } from '../../../../shared/automations-types' +import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import type { Repo } from '../../../../shared/types' +import { + automationListSearchIndexMatches, + buildAutomationListSearchIndex, + buildAutomationProjectSearchText, + resolveAutomationListSearchQuery, + truncateAutomationListSearchField, + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS, + type AutomationListSearchFields, + type AutomationListSearchIndex +} from './automation-list-search' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import { getExternalProviderLabel } from './external-automation-display' + +export function useAutomationListSearch({ + listSearchQuery, + automations, + externalAutomationEntries, + repoMap, + selectedId, + selectedExternalKey, + selectAutomationId, + selectExternalKey +}: { + listSearchQuery: string + automations: readonly Automation[] + externalAutomationEntries: readonly ExternalAutomationListEntry[] + repoMap: ReadonlyMap + selectedId: string | null + selectedExternalKey: string | null + selectAutomationId: (automationId: string | null) => void + selectExternalKey: (externalKey: string | null) => void +}): { + isListSearchQueryTooLarge: boolean + isListSearchActive: boolean + filteredAutomations: readonly Automation[] + filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] + hasListItems: boolean + hasFilteredListItems: boolean +} { + // Why: keep the input snappy; matching is deferred so caret never waits on + // index scans. Only the normalized active query can fire a search. + const deferredListSearchQuery = useDeferredValue(listSearchQuery) + const liveListSearchResolution = useMemo( + () => resolveAutomationListSearchQuery(listSearchQuery), + [listSearchQuery] + ) + const deferredListSearchResolution = useMemo( + () => resolveAutomationListSearchQuery(deferredListSearchQuery), + [deferredListSearchQuery] + ) + // Why: field feedback tracks the live value so a huge paste is labeled + // immediately; list filtering stays on the deferred resolution. + const isListSearchQueryTooLarge = liveListSearchResolution.status === 'too_large' + // Why: null means search must not run (empty, whitespace, or too large). + const activeListSearchQuery = + deferredListSearchResolution.status === 'active' ? deferredListSearchResolution.query : null + const isListSearchActive = activeListSearchQuery !== null + + // Why: fingerprint includes id + search fields so refresh ticks that only + // change nextRunAt / usage do not rebuild indexes or re-run matching. Prompts + // are truncated to the indexed prefix so each tick stays O(bound) per row. + const automationSearchFingerprint = useMemo( + () => + automations + .map((automation) => { + const repo = repoMap.get(getAutomationRunRepoId(automation)) + const project = buildAutomationProjectSearchText({ + displayName: repo?.displayName, + path: repo?.path + }) + const prompt = truncateAutomationListSearchField( + automation.prompt, + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS + ) + return `${automation.id}\u0001${automation.name}\u0001${project}\u0001${prompt}` + }) + .join('\u0000'), + [automations, repoMap] + ) + const automationSearchRows = useMemo((): { + id: string + index: AutomationListSearchIndex + }[] => { + return automations.map((automation) => { + const repo = repoMap.get(getAutomationRunRepoId(automation)) + return { + id: automation.id, + index: buildAutomationListSearchIndex({ + name: automation.name, + project: buildAutomationProjectSearchText({ + displayName: repo?.displayName, + path: repo?.path + }), + prompt: automation.prompt + }) + } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- fingerprint is the rebuild gate + }, [automationSearchFingerprint]) + + const externalAutomationSearchFingerprint = useMemo( + () => + externalAutomationEntries + .map((entry) => { + if (entry.kind === 'source') { + return `${entry.key}\u0001${entry.manager.targetLabel}\u0001${getExternalProviderLabel(entry.manager)}\u0001` + } + const prompt = truncateAutomationListSearchField( + entry.job.prompt ?? entry.job.promptPreview ?? '', + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS + ) + return `${entry.key}\u0001${entry.job.name}\u0001${getExternalProviderLabel(entry.manager)}\u0001${entry.manager.targetLabel}\u0001${entry.job.workdir ?? ''}\u0001${prompt}` + }) + .join('\u0000'), + [externalAutomationEntries] + ) + const externalAutomationSearchRows = useMemo((): { + key: string + index: AutomationListSearchIndex + }[] => { + return externalAutomationEntries.map((entry) => { + const fields: AutomationListSearchFields = + entry.kind === 'source' + ? { + name: entry.manager.targetLabel, + project: `${getExternalProviderLabel(entry.manager)} ${entry.manager.targetLabel}`, + prompt: '' + } + : { + name: entry.job.name, + project: [ + getExternalProviderLabel(entry.manager), + entry.manager.targetLabel, + entry.job.workdir + ] + .filter(Boolean) + .join(' '), + prompt: entry.job.prompt ?? entry.job.promptPreview ?? '' + } + return { key: entry.key, index: buildAutomationListSearchIndex(fields) } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- fingerprint is the rebuild gate + }, [externalAutomationSearchFingerprint]) + + // Why: matching runs only when the normalized query or search content changes — + // never on relativeNow / nextRunAt / usage refresh alone. + const filteredAutomationIds = useMemo((): readonly string[] | null => { + if (activeListSearchQuery === null) { + return null + } + const ids: string[] = [] + for (const row of automationSearchRows) { + if (automationListSearchIndexMatches(row.index, activeListSearchQuery)) { + ids.push(row.id) + } + } + return ids + }, [activeListSearchQuery, automationSearchRows]) + + const filteredExternalAutomationKeys = useMemo((): readonly string[] | null => { + if (activeListSearchQuery === null) { + return null + } + const keys: string[] = [] + for (const row of externalAutomationSearchRows) { + if (automationListSearchIndexMatches(row.index, activeListSearchQuery)) { + keys.push(row.key) + } + } + return keys + }, [activeListSearchQuery, externalAutomationSearchRows]) + + const filteredAutomations = useMemo((): readonly Automation[] => { + if (filteredAutomationIds === null) { + return automations + } + if (filteredAutomationIds.length === 0) { + return [] + } + const byId = new Map(automations.map((automation) => [automation.id, automation])) + const next: Automation[] = [] + for (const id of filteredAutomationIds) { + const automation = byId.get(id) + if (automation) { + next.push(automation) + } + } + return next + }, [automations, filteredAutomationIds]) + + const filteredExternalAutomationEntries = useMemo((): readonly ExternalAutomationListEntry[] => { + if (filteredExternalAutomationKeys === null) { + return externalAutomationEntries + } + if (filteredExternalAutomationKeys.length === 0) { + return [] + } + const byKey = new Map(externalAutomationEntries.map((entry) => [entry.key, entry])) + const next: ExternalAutomationListEntry[] = [] + for (const key of filteredExternalAutomationKeys) { + const entry = byKey.get(key) + if (entry) { + next.push(entry) + } + } + return next + }, [externalAutomationEntries, filteredExternalAutomationKeys]) + + const hasListItems = automations.length + externalAutomationEntries.length > 0 + const hasFilteredListItems = + filteredAutomations.length + filteredExternalAutomationEntries.length > 0 + + // Why: when search hides the current row, move selection to the first visible + // match so list highlight and detail stay aligned. No matches → keep detail. + useEffect(() => { + if (activeListSearchQuery === null) { + return + } + const localVisible = + selectedExternalKey === null && + selectedId != null && + filteredAutomations.some((automation) => automation.id === selectedId) + const externalVisible = + selectedExternalKey != null && + filteredExternalAutomationEntries.some((entry) => entry.key === selectedExternalKey) + if (localVisible || externalVisible) { + return + } + const firstLocal = filteredAutomations[0] + if (firstLocal) { + if (selectedExternalKey !== null) { + selectExternalKey(null) + } + if (selectedId !== firstLocal.id) { + selectAutomationId(firstLocal.id) + } + return + } + const firstExternal = filteredExternalAutomationEntries[0] + if (firstExternal && selectedExternalKey !== firstExternal.key) { + selectExternalKey(firstExternal.key) + } + }, [ + activeListSearchQuery, + filteredAutomations, + filteredExternalAutomationEntries, + selectAutomationId, + selectExternalKey, + selectedExternalKey, + selectedId + ]) + + return { + isListSearchQueryTooLarge, + isListSearchActive, + filteredAutomations, + filteredExternalAutomationEntries, + hasListItems, + hasFilteredListItems + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b3ce98a4edd..b7f0deb0871 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13943,7 +13943,20 @@ "a21f6c33ad": "Automation source refreshed.", "53f06f0ad5": "Retry source", "pendingAutomationMissing": "Automation no longer available.", - "pendingAutomationRunMissing": "Run history no longer available." + "pendingAutomationRunMissing": "Run history no longer available.", + "noSearchMatches": "No automations match your search.", + "paused": "Paused", + "runCount": "{{count}} runs", + "runCount_one": "{{count}} run", + "runCount_other": "{{count}} runs", + "projectDefaultBaseRef": "project default", + "createFromBaseRef": "Create from {{baseRef}}", + "missingWorkspace": "Missing workspace", + "runUsageSummary": "{{cost}} est. · {{tokens}} tokens", + "usageUnavailable": "Usage unavailable", + "noRunUsageYet": "No run usage yet", + "noWorkspace": "No workspace", + "latestSavedOutput": "Latest saved output" }, "CreateFromPicker": { "f061f49e3f": "Search repo branches...", @@ -14056,6 +14069,13 @@ "5a7863909c": "Run setup for each new workspace", "18f000ad4e": "Advanced", "874b72195b": "When this automation creates a workspace, prepare it the same way creating a worktree by hand does — run the project's setup and open its terminal tabs." + }, + "AutomationListSearchField": { + "tooLong": "Search text is too long — list is unfiltered", + "label": "Search automations", + "placeholder": "Search by name, project, or prompt", + "tooLongShort": "Too long", + "clear": "Clear search" } }, "agent": { From e4aadccefffd99189e08683eec082f7d11b931f3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:36:07 -0700 Subject: [PATCH 25/43] fix(mobile): keep repeated-prefix native chat replies streaming (STA-3333) (#12501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): keep repeated-prefix chat replies streaming Text alone can't tell "the transcript caught up with this stream" from "a new reply repeats the previous turn's prefix", so the old suppress-on- prefix rule swallowed genuine repeated replies. A stateful gate remembers which transcript tail predates the current stream segment and hides the bubble only when that tail moved during the segment, scoped to the active host/workspace/tab/session so a swapped chat can't inherit a baseline. Refs STA-3333. * fix(mobile): keep the streaming gate alive across chat/terminal toggles The gate lived in MobileNativeChatView, but MobileNativeChatOverlay returns null whenever the user peeks at the terminal — that unmounts the view and throws the baseline away, so the repeated-prefix reply was swallowed again on the way back. Move the gate (and the fold memo it reads) up to the overlay, which stays mounted across those toggles. While hidden the transcript is empty and the throttled stream reports no text, which the gate would have read as "idle" and re-anchored on. Pass the agent's working state so a textless tick inside a live segment holds the baseline instead. The scope key is now keyed off the tab rather than the view-gated chat resolution, so it survives the toggle too; streamIdentity keeps its exact previous value because the delayed-send guards compare against it. Also drops a dead disjunct in the caught-up test: a null baseline is already unequal to every real tail id. * test(mobile): model the real re-show ordering in the streaming-gate tests The overlay regression test replayed the transcript before the stream text on the way back from the terminal view. That ordering is backwards: the session withholds `messages` until a fresh read settles (an RPC round trip) while the throttled stream text returns in ~50ms — and with the transcript already back, a gate that got discarded on the toggle still passes. Replay the real order, which pins the gate's lifetime as intended. Swaps the hidden-gap duplicate case for the in-view one (a tool frame clears the assistant text mid-turn), which is where the hold actually earns its keep; the hidden-gap direction stays covered at the gate level. * fix(mobile): stop the streaming gate adopting a reply as its own history A textless status tick was re-anchoring the gate's pre-stream baseline, so two paths still rendered wrong: - The reply's transcript push beats its throttled status text whenever the pane stays `working` past the turn (a live subagent or background task). The tick in between adopted the just-landed reply as history, and the status text that followed rendered it a second time — a duplicate bubble, and a regression against main's suppress-on-prefix rule. - Peeking at the terminal between turns empties the transcript. That empty tail was adopted as the baseline, so the next repeated-prefix reply was swallowed again — the bug this PR exists to fix. Only a tick that carries a real tail and sits outside a live turn anchors now, with an exception for a gate that has never anchored: mounted mid-turn, the first real tail it sees is the best history it will ever get. Also drop `buildMobileNativeChatData`, a test-only builder this PR had wired the new gate into; its green test asserted the exact suppression this PR removes. Its fold/pending/image coverage moves to the builder the view calls. * test(mobile): pin the textless anchor's text reset Mutation testing found the `prevText` reset on an anchoring textless tick unpinned: keeping the previous turn's text there reads the next turn's opener as a new segment, re-anchors onto the reply that just landed, and renders it a second time — the same duplicate-bubble class already fixed twice on this branch. --- .../session/MobileNativeChatOverlay.test.ts | 193 ++++++++++++++ .../src/session/MobileNativeChatOverlay.tsx | 19 +- .../src/session/MobileNativeChatView.test.ts | 60 ++++- mobile/src/session/MobileNativeChatView.tsx | 18 +- .../mobile-native-chat-render-data.test.ts | 94 +++---- .../session/mobile-native-chat-render-data.ts | 51 +--- .../mobile-native-chat-streaming-gate.test.ts | 238 ++++++++++++++++++ .../mobile-native-chat-streaming-gate.ts | 93 +++++++ .../use-mobile-native-chat-controller.test.ts | 102 +++++++- .../use-mobile-native-chat-controller.ts | 16 +- ...use-mobile-native-chat-streaming-bubble.ts | 31 +++ 11 files changed, 787 insertions(+), 128 deletions(-) create mode 100644 mobile/src/session/MobileNativeChatOverlay.test.ts create mode 100644 mobile/src/session/mobile-native-chat-streaming-gate.test.ts create mode 100644 mobile/src/session/mobile-native-chat-streaming-gate.ts create mode 100644 mobile/src/session/use-mobile-native-chat-streaming-bubble.ts diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts new file mode 100644 index 00000000000..2680064c94b --- /dev/null +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -0,0 +1,193 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileNativeChatOverlay } from './MobileNativeChatOverlay' +import type { MobileNativeChatController } from './use-mobile-native-chat-controller' + +vi.mock('react-native', () => ({ + StyleSheet: { create: (styles: unknown) => styles, absoluteFillObject: {} }, + View: 'View' +})) + +vi.mock('./MobileNativeChatView', () => ({ MobileNativeChatView: 'ChatView' })) + +function assistantTurn(id: string, text: string): NativeChatMessage { + return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } +} + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +/** One render of the route: chat visible or not, the transcript it currently + * holds, and the agent-status stream behind it. */ +type Tick = { + show?: boolean + messages?: NativeChatMessage[] + streamingText?: string + streamLive?: boolean + identity?: string +} + +function overlayElement(tick: Tick): ReturnType { + const controller = { + showNativeChat: tick.show ?? true, + nativeChatSession: { messages: tick.messages ?? [], status: 'ready' }, + nativeChatAgent: 'claude', + nativeChatAgentWorking: tick.streamLive ?? false, + nativeChatStreamingText: tick.streamingText, + nativeChatStreamLive: tick.streamLive ?? false, + nativeChatStreamScopeKey: tick.identity ?? 'tab-a', + chatPending: [], + chatComposerText: '', + setChatComposerText: vi.fn() + } as unknown as MobileNativeChatController + return createElement(MobileNativeChatOverlay, { + controller, + images: {} as never, + onMicPress: vi.fn(), + micActive: false, + dictationMode: 'toggle', + onMicPressIn: vi.fn(), + onMicPressOut: vi.fn(), + inputLockReason: null, + sendErrorMessage: null, + onClearSendError: vi.fn(), + keyboardInset: 0 + }) +} + +describe('MobileNativeChatOverlay streaming gate', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function render(tick: Tick): Promise { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(overlayElement(tick)) + }) + } finally { + restore() + } + } + + async function update(tick: Tick): Promise { + await act(async () => { + renderer?.update(overlayElement(tick)) + }) + } + + /** The bubble text handed to the chat list, or `'hidden'` when chat is off. */ + function streaming(): string | null | 'hidden' { + const views = renderer!.root.findAll((node) => node.type === 'ChatView') + return views.length === 0 ? 'hidden' : (views[0].props.streaming as string | null) + } + + it('keeps streaming a reply that repeats the previous turn as a prefix', async () => { + const prior = [assistantTurn('a1', 'The tests pass.')] + await render({ messages: prior }) + expect(streaming()).toBeNull() + + await update({ messages: prior, streamingText: 'The tests', streamLive: true }) + + expect(streaming()).toBe('The tests') + }) + + it('drops the streaming bubble once the reply lands as its own turn', async () => { + const prior = [assistantTurn('a1', 'Done.')] + await render({ messages: prior }) + await update({ messages: prior, streamingText: 'Done.', streamLive: true }) + expect(streaming()).toBe('Done.') + + await update({ + messages: [...prior, assistantTurn('a2', 'Done.')], + streamingText: 'Done.', + streamLive: true + }) + + expect(streaming()).toBeNull() + }) + + it('keeps the bubble across a peek at the terminal view', async () => { + // Toggling to the terminal unmounts the chat list and unsubscribes its + // transcript. The gate lives above that boundary, so the baseline survives + // and the repeated-prefix reply keeps streaming on the way back. + const prior = [assistantTurn('a1', 'Done.')] + await render({ messages: prior }) + await update({ messages: prior, streamingText: 'Done.', streamLive: true }) + expect(streaming()).toBe('Done.') + + await update({ show: false, messages: [], streamLive: true }) + expect(streaming()).toBe('hidden') + // Back on chat the session withholds its transcript until a fresh read + // settles, so the throttled stream text returns a round trip ahead of it. + await update({ messages: [], streamLive: true }) + await update({ messages: [], streamingText: 'Done.', streamLive: true }) + await update({ messages: prior, streamingText: 'Done.', streamLive: true }) + + expect(streaming()).toBe('Done.') + }) + + it('keeps the bubble across a peek at the terminal taken between turns', async () => { + // Same toggle, but taken while idle: the transcript empties before the next + // turn starts, so the gate has to reject that empty tail as a baseline. + const prior = [assistantTurn('a1', 'Done.')] + await render({ messages: prior }) + + await update({ show: false, messages: [] }) + await update({ show: false, messages: [], streamLive: true }) + await update({ messages: [], streamLive: true }) + await update({ messages: [], streamingText: 'Done.', streamLive: true }) + await update({ messages: prior, streamingText: 'Done.', streamLive: true }) + + expect(streaming()).toBe('Done.') + }) + + it('hides a repeated part whose own turn landed during a mid-turn gap', async () => { + // Between parts the status frame carries no assistant text (a tool call), so + // the stream goes textless while the turn is still live and the part that + // just finished lands in the transcript. Re-anchoring on that tick would + // adopt it as history and render it a second time. + const prior = [assistantTurn('a1', 'Done.')] + await render({ messages: prior }) + await update({ messages: prior, streamingText: 'Done.', streamLive: true }) + expect(streaming()).toBe('Done.') + + const landed = [...prior, assistantTurn('a2', 'Done.')] + await update({ messages: landed, streamLive: true }) + await update({ messages: landed, streamingText: 'Done.', streamLive: true }) + + expect(streaming()).toBeNull() + }) + + it("does not carry one chat's baseline into another stream identity", async () => { + const prior = [assistantTurn('a1', 'Shared answer text')] + await render({ messages: prior, identity: 'tab-a' }) + + await update({ + messages: prior, + streamingText: 'Shared answer', + streamLive: true, + identity: 'tab-b' + }) + + expect(streaming()).toBeNull() + }) +}) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index b8f9cd9277b..77c5ab72fed 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -1,7 +1,10 @@ +import { useMemo } from 'react' import { StyleSheet, View } from 'react-native' import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView' +import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' import type { MobileNativeChatController } from './use-mobile-native-chat-controller' +import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble' type Props = { controller: MobileNativeChatController @@ -22,7 +25,9 @@ type Props = { } /** Keeps the terminal mounted underneath chat so its PTY subscription survives - * view toggles while the native surface owns the visible composer. */ + * view toggles while the native surface owns the visible composer. Also owns + * the streaming gate: this component stays mounted across those toggles, while + * the chat list below it does not. */ export function MobileNativeChatOverlay({ controller, images, @@ -36,19 +41,27 @@ export function MobileNativeChatOverlay({ onClearSendError, keyboardInset }: Props): React.JSX.Element | null { + const session = controller.nativeChatSession + const folded = useMemo(() => foldMobileNativeChatMessages(session.messages), [session.messages]) + const streaming = useMobileNativeChatStreamingBubble( + folded, + controller.nativeChatStreamingText, + controller.nativeChatStreamScopeKey, + controller.nativeChatStreamLive + ) if (!controller.showNativeChat) { return null } - const session = controller.nativeChatSession return ( ({ @@ -58,6 +59,9 @@ vi.mock('./MobileNativeChatComposer', async () => { }) type Overrides = { + messages?: Parameters[0]['messages'] + folded?: Parameters[0]['folded'] + streaming?: string | null sendErrorMessage?: string | null onClearSendError?: () => void inputLockReason?: 'disconnected' | 'waiting' | null @@ -75,7 +79,25 @@ function suppressRendererWarning(): () => void { return () => spy.mockRestore() } -describe('MobileNativeChatView send-error banner', () => { +function assistantTurn(id: string, text: string): NativeChatMessage { + return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } +} + +function chatViewElement(overrides: Overrides): ReturnType { + return createElement(MobileNativeChatView, { + messages: [], + folded: [], + status: 'ready', + streaming: null, + onSend: vi.fn().mockResolvedValue(true), + pending: [], + composerText: '', + onComposerTextChange: vi.fn(), + ...overrides + }) +} + +describe('MobileNativeChatView', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { @@ -91,23 +113,25 @@ describe('MobileNativeChatView send-error banner', () => { const restore = suppressRendererWarning() try { await act(async () => { - renderer = create( - createElement(MobileNativeChatView, { - messages: [], - status: 'ready', - onSend: overrides.onSend ?? vi.fn().mockResolvedValue(true), - pending: [], - composerText: '', - onComposerTextChange: vi.fn(), - ...overrides - }) - ) + renderer = create(chatViewElement(overrides)) }) } finally { restore() } } + async function update(overrides: Overrides = {}): Promise { + await act(async () => { + renderer?.update(chatViewElement(overrides)) + }) + } + + /** Ids of the rows the list is currently rendering. */ + function listIds(): string[] { + const list = renderer!.root.find((node) => node.type === 'FlatList') + return (list.props.data as { id: string }[]).map((row) => row.id) + } + function banners(): ReactTestInstance[] { return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') } @@ -161,4 +185,16 @@ describe('MobileNativeChatView send-error banner', () => { expect(onClearSendError).toHaveBeenCalledOnce() }) + + // The gate that decides `streaming` lives in MobileNativeChatOverlay, which + // outlives this view; see MobileNativeChatOverlay.test.ts. + it('appends the gated streaming bubble after the folded transcript', async () => { + const folded = [assistantTurn('a1', 'The tests pass.')] + await render({ folded }) + expect(listIds()).toEqual(['a1']) + + await update({ folded, streaming: 'The tests' }) + + expect(listIds()).toEqual(['a1', 'streaming']) + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index f14efc1f25f..1a2ae71ca66 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -16,7 +16,6 @@ import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' import { buildMobileNativeChatTransientData, - foldMobileNativeChatMessages, mobileNativeChatEmptyState, type MobileNativeChatPendingItem } from './mobile-native-chat-render-data' @@ -39,7 +38,10 @@ import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' type Props = { + /** Raw transcript, only for telling "still loading" from "loaded and empty". */ messages: NativeChatMessage[] + /** `messages` with noise stripped and tool turns folded in, from the overlay. */ + folded: NativeChatMessage[] status: MobileNativeChatStatus error?: string /** Resolved agent for this chat; names the empty-state copy (desktop parity). */ @@ -47,9 +49,9 @@ type Props = { agentWorking?: boolean /** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */ onStop?: () => void - /** Live partial assistant text while a turn is still streaming (from the agent - * status hook). Shown as an in-progress bubble until the transcript catches up. */ - streamingText?: string + /** Live partial assistant text to show as an in-progress bubble, already gated + * by the overlay against the transcript catching up. */ + streaming: string | null hasMore?: boolean loadingEarlier?: boolean onLoadEarlier?: () => void @@ -102,12 +104,13 @@ type Props = { export function MobileNativeChatView({ messages, + folded, status, error, agent, agentWorking, onStop, - streamingText, + streaming, hasMore, loadingEarlier, onLoadEarlier, @@ -165,10 +168,9 @@ export function MobileNativeChatView({ // `data` is the list source: folded transcript + synthetic streaming bubble + // route-owned optimistic queued messages. Memoize on the same deps so the // downstream autoscroll effects/`renderItem` keep referential stability. - const foldedMessages = useMemo(() => foldMobileNativeChatMessages(messages), [messages]) const { data } = useMemo( - () => buildMobileNativeChatTransientData({ folded: foldedMessages, streamingText, pending }), - [foldedMessages, streamingText, pending] + () => buildMobileNativeChatTransientData({ folded, streaming, pending }), + [folded, streaming, pending] ) // Follow the tail as the conversation grows and keep the newest message above diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 4dabff8f600..b4a273d31ca 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { - buildMobileNativeChatData, + buildMobileNativeChatTransientData, + foldMobileNativeChatMessages, mobileNativeChatEmptyState } from './mobile-native-chat-render-data' @@ -50,13 +51,22 @@ describe('mobileNativeChatEmptyState', () => { }) }) -describe('buildMobileNativeChatData', () => { +/** Mirrors the view: fold the raw transcript, then assemble the list. */ +function build( + messages: NativeChatMessage[], + streaming: string | null, + pending: Parameters[0]['pending'] +): NativeChatMessage[] { + return buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages(messages), + streaming, + pending + }).data +} + +describe('buildMobileNativeChatTransientData', () => { it('appends pending optimistic messages at the tail as user turns', () => { - const { data } = buildMobileNativeChatData({ - messages: [assistant('a1', 'hello')], - streamingText: undefined, - pending: [{ id: 'p1', text: 'queued' }] - }) + const data = build([assistant('a1', 'hello')], null, [{ id: 'p1', text: 'queued' }]) const last = data[data.length - 1] expect(last.id).toBe('p1') expect(last.role).toBe('user') @@ -64,10 +74,9 @@ describe('buildMobileNativeChatData', () => { }) it('renders a pending send with images as text followed by image-ref thumbnails', () => { - const { data } = buildMobileNativeChatData({ - messages: [], - pending: [{ id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }] - }) + const data = build([], null, [ + { id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] } + ]) const last = data[data.length - 1] expect(last.role).toBe('user') expect(last.blocks).toEqual([ @@ -78,10 +87,7 @@ describe('buildMobileNativeChatData', () => { }) it('renders an image-only pending send (no text) as just the thumbnail', () => { - const { data } = buildMobileNativeChatData({ - messages: [], - pending: [{ id: 'p1', text: '', images: ['file:///a.jpg'] }] - }) + const data = build([], null, [{ id: 'p1', text: '', images: ['file:///a.jpg'] }]) expect(data[data.length - 1].blocks).toEqual([{ type: 'image-ref', url: 'file:///a.jpg' }]) }) @@ -89,14 +95,15 @@ describe('buildMobileNativeChatData', () => { // Claude records an attached image as `[Image: source: /path]` + an // `[Image #1] `-prefixed caption turn; the fold must merge them into one // user turn with an image-ref block instead of showing raw marker text. - const { data } = buildMobileNativeChatData({ - messages: [ + const data = build( + [ user('u1', '[Image: source: /tmp/a.png]'), user('u2', '[Image #1] look at this'), assistant('a1', 'nice photo') ], - pending: [] - }) + null, + [] + ) const merged = data.find((message) => message.role === 'user') expect(merged?.blocks).toEqual([ { type: 'image-ref', path: '/tmp/a.png' }, @@ -105,49 +112,20 @@ describe('buildMobileNativeChatData', () => { }) it('renders a lone image marker turn (no caption) as an image-ref block', () => { - const { data } = buildMobileNativeChatData({ - messages: [user('u1', '[Image: source: /tmp/a.png]')], - pending: [] - }) + const data = build([user('u1', '[Image: source: /tmp/a.png]')], null, []) expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }]) }) - it('adds a synthetic streaming bubble while the partial text leads the transcript', () => { - const { streaming, data } = buildMobileNativeChatData({ - messages: [user('u1', 'hi')], - streamingText: 'thinking out loud', - pending: [] - }) - expect(streaming).toBe('thinking out loud') - expect(data.some((m) => m.id === 'streaming')).toBe(true) + it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => { + // Whether text streams at all is the gate's call + // (`mobile-native-chat-streaming-gate.test.ts`); this only places it. + const data = build([user('u1', 'hi')], 'thinking out loud', [{ id: 'p1', text: 'queued' }]) + expect(data.map((message) => message.id)).toEqual(['u1', 'streaming', 'p1']) + expect(data[1].blocks).toEqual([{ type: 'text', text: 'thinking out loud' }]) }) - it('shows a short new streaming reply even after a longer previous turn', () => { - // The last folded turn is a long completed reply; a short new stream must not - // be suppressed just for being shorter than the prior turn. - const { streaming, data } = buildMobileNativeChatData({ - messages: [assistant('a1', 'This is a long completed previous answer that ran on a while')], - streamingText: 'Ok', - pending: [] - }) - expect(streaming).toBe('Ok') - expect(data.some((m) => m.id === 'streaming')).toBe(true) - }) - - it('drops the streaming bubble once the real assistant turn already contains it', () => { - const { streaming, data } = buildMobileNativeChatData({ - messages: [assistant('a1', 'done answer')], - streamingText: 'done', - pending: [] - }) - expect(streaming).toBeNull() - expect(data.some((m) => m.id === 'streaming')).toBe(false) - }) - - it('returns no streaming bubble for empty/whitespace streaming text', () => { - expect( - buildMobileNativeChatData({ messages: [], streamingText: ' ', pending: [] }).streaming - ).toBeNull() - expect(buildMobileNativeChatData({ messages: [], pending: [] }).streaming).toBeNull() + it('omits the bubble when the gate withheld the streaming text', () => { + const data = build([assistant('a1', 'done answer')], null, []) + expect(data.some((message) => message.id === 'streaming')).toBe(false) }) }) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 068050fc43d..e11b9ac56f4 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -43,41 +43,25 @@ export type MobileNativeChatPendingItem = { images?: string[] } -/** Derive the list data from the raw transcript: fold tool turns into the - * assistant turn, optionally append a synthetic streaming bubble, then the - * route-owned optimistic "queued" messages at the tail. Returns the - * intermediate `folded`/`streaming` so the caller can memoize on them. */ -export function buildMobileNativeChatData({ - messages, - streamingText, - pending -}: { - messages: NativeChatMessage[] - streamingText?: string - pending: MobileNativeChatPendingItem[] -}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { - const folded = foldMobileNativeChatMessages(messages) - return buildMobileNativeChatTransientData({ folded, streamingText, pending }) -} - export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { // Normalize first (desktop assembler parity): image marker turns fold into // image-ref blocks instead of rendering as raw `[Image: …]` text. return foldToolMessages(stripNoiseMessages(normalizeImageTranscriptMessages(messages))) } +/** Assemble the list data the chat renders: the folded transcript, then a + * synthetic bubble for the streaming text the gate let through, then the + * route-owned optimistic "queued" messages at the tail. */ export function buildMobileNativeChatTransientData({ folded, - streamingText, + streaming, pending }: { folded: NativeChatMessage[] - streamingText?: string + /** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */ + streaming: string | null pending: MobileNativeChatPendingItem[] }): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { - // Only show the streaming bubble while its text leads the transcript — once the - // real assistant turn lands with the same text, drop the synthetic one. - const streaming = deriveStreaming(folded, streamingText) const data: NativeChatMessage[] = [ ...folded, ...(streaming @@ -106,26 +90,3 @@ export function buildMobileNativeChatTransientData({ ] return { folded, streaming, data } } - -function deriveStreaming(folded: NativeChatMessage[], streamingText?: string): string | null { - const text = streamingText?.trim() - if (!text) { - return null - } - const last = folded[folded.length - 1] - const lastText = - last?.role === 'assistant' - ? last.blocks - .filter((b) => b.type === 'text') - .map((b) => (b.type === 'text' ? b.text : '')) - .join('') - .trim() - : '' - // Hide the synthetic bubble only once the real turn has landed leading with the - // streamed text. A bare length compare would suppress a short new reply behind a - // longer previous turn; a completed prior turn won't start with the new prefix. - if (lastText.startsWith(text)) { - return null - } - return text -} diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.test.ts b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts new file mode 100644 index 00000000000..312be591ca9 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + createMobileNativeChatStreamingGate, + deriveMobileNativeChatStreaming, + type MobileNativeChatStreamingGate +} from './mobile-native-chat-streaming-gate' + +function assistant(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + } +} + +/** Run a sequence of (folded, streamingText) ticks through one gate. */ +function run(ticks: { folded: NativeChatMessage[]; text?: string; live?: boolean }[]): { + gate: MobileNativeChatStreamingGate + results: (string | null)[] +} { + let gate = createMobileNativeChatStreamingGate() + const results: (string | null)[] = [] + for (const tick of ticks) { + const step = deriveMobileNativeChatStreaming(gate, tick.folded, tick.text, { + streamLive: tick.live + }) + gate = step.gate + results.push(step.streaming) + } + return { gate, results } +} + +describe('deriveMobileNativeChatStreaming', () => { + it('shows a genuine reply that repeats the previous turn as a prefix', () => { + const prior = [assistant('a1', 'The tests pass.')] + const { results } = run([ + { folded: prior }, // idle tick anchors the pre-stream tail + { folded: prior, text: 'The' }, + { folded: prior, text: 'The tests' }, + { folded: prior, text: 'The tests pass.' } + ]) + expect(results).toEqual([null, 'The', 'The tests', 'The tests pass.']) + }) + + it('hides the bubble once the real turn lands leading with the streamed text', () => { + const prior = [assistant('a1', 'earlier turn')] + const landed = [...prior, assistant('a2', 'fresh answer with a tail')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'fresh answer' }, + { folded: landed, text: 'fresh answer' } + ]) + expect(results).toEqual([null, 'fresh answer', null]) + }) + + it('suppresses an identical repeated reply once its own turn lands', () => { + const prior = [assistant('a1', 'Done.')] + const landed = [...prior, assistant('a2', 'Done.')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'Done.' }, // repeated-prefix reply stays visible + { folded: landed, text: 'Done.' } // its own turn landed — hide + ]) + expect(results).toEqual([null, 'Done.', null]) + }) + + it('keeps hiding for the rest of a segment after the turn lands', () => { + const prior = [assistant('a1', 'earlier')] + const landed = [...prior, assistant('a2', 'answer body')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'answer' }, + { folded: landed, text: 'answer' }, + { folded: landed, text: 'answer bo' } + ]) + expect(results).toEqual([null, 'answer', null, null]) + }) + + it('keeps the segment baseline through textless ticks while the turn is live', () => { + // Chat is hidden mid-stream: the transcript unsubscribes and the status + // stops reaching the gate, but the turn has not ended. Coming back, the + // stream text returns before the re-read transcript does. + const prior = [assistant('a1', 'Done.')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'Done.', live: true }, + { folded: [], live: true }, + { folded: [], text: 'Done.', live: true }, + { folded: prior, text: 'Done.', live: true } + ]) + expect(results).toEqual([null, 'Done.', null, 'Done.', 'Done.']) + }) + + it('still hides after a hidden gap once the reply landed as its own turn', () => { + const prior = [assistant('a1', 'Done.')] + const landed = [...prior, assistant('a2', 'Done.')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'Done.', live: true }, + { folded: [], live: true }, + { folded: landed, text: 'Done.', live: true } + ]) + expect(results).toEqual([null, 'Done.', null, null]) + }) + + it('hides a reply whose own turn landed before its status text arrived', () => { + // The pane stays `working` past the reply (a subagent or a background task + // is still live), and the transcript push beats the throttled status text. + // Anchoring on that textless tick would adopt the reply as pre-stream + // history and render it a second time as a bubble. + const prior = [assistant('a1', 'Done.')] + const landed = [...prior, assistant('a2', 'Done.')] + const { results } = run([ + { folded: prior, live: true }, + { folded: landed, live: true }, + { folded: landed, text: 'Done.', live: true }, + { folded: landed, text: 'Done.', live: true } + ]) + expect(results).toEqual([null, null, null, null]) + }) + + it('keeps the pre-stream baseline across a hidden gap taken between turns', () => { + // Peeking at the terminal while idle tears the transcript down to empty. An + // empty tail is not history: adopting it strands the baseline and swallows + // the repeated-prefix reply that arrives next. + const prior = [assistant('a1', 'Done.')] + const { results } = run([ + { folded: prior }, + { folded: [] }, + { folded: [], live: true }, + { folded: prior, text: 'Done.', live: true } + ]) + expect(results).toEqual([null, null, null, 'Done.']) + }) + + it('anchors on the first tail it sees when mounted mid-turn', () => { + // Opening a workspace whose agent is already working: that first textless + // tick is the only pre-stream history the gate will ever get. + const prior = [assistant('a1', 'Done.')] + const { results } = run([ + { folded: prior, live: true }, + { folded: prior, text: 'Done.', live: true } + ]) + expect(results).toEqual([null, 'Done.']) + }) + + it('anchors on a textless tick once the turn ends', () => { + const prior = [assistant('a1', 'first answer')] + const landed = [...prior, assistant('a2', 'second answer')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'second answer', live: true }, + { folded: landed }, + { folded: landed, text: 'second answer', live: true } + ]) + expect(results).toEqual([null, 'second answer', null, 'second answer']) + }) + + it('does not treat the previous turn as a segment start after re-anchoring', () => { + // The textless anchor clears the remembered text too. Keeping it would read + // the next turn's opener as a new segment, re-anchor onto the reply that + // just landed, and render it a second time as a bubble. + const prior = [assistant('a1', 'context')] + const firstLanded = [...prior, assistant('a2', 'Alpha done')] + const secondLanded = [...firstLanded, assistant('a3', 'Beta reply')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'Alpha', live: true }, + { folded: firstLanded }, // turn ended — re-anchor onto a2 + { folded: secondLanded, text: 'Beta', live: true } // a3 already landed + ]) + expect(results).toEqual([null, 'Alpha', null, null]) + }) + + it('re-anchors when a new reply part replaces the stream mid-turn', () => { + const prior = [assistant('a1', 'context')] + const partOneLanded = [...prior, assistant('a2', 'part one full text')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'part one' }, + { folded: partOneLanded, text: 'part one' }, // caught up — hide + // Part two is not an extension of part one: new segment, new baseline. + { folded: partOneLanded, text: 'part' } + ]) + expect(results).toEqual([null, 'part one', null, 'part']) + }) + + it('falls back to suppress-on-prefix when text arrives on the first tick', () => { + // No tail ever observed before the text: a duplicate bubble is worse than + // briefly hiding a mount-coincident repeated reply. + const landed = [assistant('a1', 'flushed part still streaming in status')] + const { results } = run([{ folded: landed, text: 'flushed part' }]) + expect(results).toEqual([null]) + }) + + it('is idempotent for a repeated tick', () => { + const prior = [assistant('a1', 'The tests pass.')] + const first = run([{ folded: prior }, { folded: prior, text: 'The tests' }]) + const again = deriveMobileNativeChatStreaming(first.gate, prior, 'The tests') + expect(again.streaming).toBe('The tests') + expect(again.gate).toBe(first.gate) + }) + + it('drops a prior chat baseline when the stream identity changes', () => { + // The other chat's tail must not license showing a bubble here — a swapped + // scope resets to the mid-stream fallback rather than reusing its baseline. + const repeatedId = [assistant('a1', 'new answer landed')] + let gate = createMobileNativeChatStreamingGate('tab-a') + gate = deriveMobileNativeChatStreaming(gate, repeatedId, undefined, { scopeKey: 'tab-a' }).gate + + const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', { + scopeKey: 'tab-b' + }) + + expect(switched.streaming).toBeNull() + expect(switched.gate.scopeKey).toBe('tab-b') + expect(switched.gate.baselineTailId).toBeNull() + }) + + it('returns null for empty or whitespace streaming text', () => { + const prior = [assistant('a1', 'x')] + expect(run([{ folded: prior, text: ' ' }]).results).toEqual([null]) + expect(run([{ folded: prior }]).results).toEqual([null]) + }) + + it('shows the first reply of an empty chat and hides it once the turn lands', () => { + const landed = [assistant('a1', 'Hello there')] + const { results } = run([ + { folded: [] }, + { folded: [], text: 'Hello' }, + { folded: landed, text: 'Hello' } + ]) + expect(results).toEqual([null, 'Hello', null]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.ts b/mobile/src/session/mobile-native-chat-streaming-gate.ts new file mode 100644 index 00000000000..a857803214c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-streaming-gate.ts @@ -0,0 +1,93 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +/** Decides whether the live streaming preview should render as a synthetic + * bubble. Text alone can't tell "the transcript caught up with this stream" + * from "a new reply happens to repeat the previous turn's prefix" — the old + * prefix test swallowed genuine repeated-prefix replies. The gate keeps the + * transcript tail observed when the current stream segment began: the bubble + * hides only when the tail MOVED during the segment and leads with the + * streamed text (the real turn landed), never for an older identical turn. */ +export type MobileNativeChatStreamingGate = { + /** Chat/session identity this baseline belongs to. */ + scopeKey: string | null + /** Streamed text seen on the previous tick ('' while idle). */ + prevText: string + /** Folded tail message id when the current segment began; null while the + * gate has never observed a transcript tail (text arrived on its very first + * tick), where the legacy suppress-on-prefix rule applies. */ + baselineTailId: string | null +} + +export function createMobileNativeChatStreamingGate( + scopeKey: string | null = null +): MobileNativeChatStreamingGate { + return { scopeKey, prevText: '', baselineTailId: null } +} + +function assistantTailText(tail: NativeChatMessage | undefined): string { + if (!tail || tail.role !== 'assistant') { + return '' + } + return tail.blocks + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join('') + .trim() +} + +// Reuses the incoming gate object when nothing moved, so a caller can detect +// "no change" by reference (and a render-time state adjustment can settle). +function advanceGate( + gate: MobileNativeChatStreamingGate, + prevText: string, + baselineTailId: string | null +): MobileNativeChatStreamingGate { + return gate.prevText === prevText && gate.baselineTailId === baselineTailId + ? gate + : { ...gate, prevText, baselineTailId } +} + +/** Advance the gate one tick and derive the visible streaming text (null hides + * the bubble). Pure and idempotent for a repeated (text, tail) pair, so a + * re-render without new data cannot flip the decision. */ +export function deriveMobileNativeChatStreaming( + gate: MobileNativeChatStreamingGate, + folded: readonly NativeChatMessage[], + streamingText: string | undefined, + options: { + scopeKey?: string | null + /** Whether the agent is still mid-turn. A textless tick then means "no + * observation this render", not "the stream ended". */ + streamLive?: boolean + } = {} +): { gate: MobileNativeChatStreamingGate; streaming: string | null } { + const scopeKey = options.scopeKey === undefined ? gate.scopeKey : options.scopeKey + const scopedGate = + gate.scopeKey === scopeKey ? gate : createMobileNativeChatStreamingGate(scopeKey) + const text = streamingText?.trim() ?? '' + const tail = folded.at(-1) + const tailId = tail?.id ?? null + if (!text) { + // Only a textless tick that carries a real tail and is outside a live turn + // is trustworthy pre-stream history. Mid-turn gaps (a tool call, a throttle + // lull, the transcript landing the reply before its status text) would + // otherwise adopt that reply as history and render it a second time as a + // bubble; a torn-down transcript carries no tail at all. The exception is a + // gate that has never anchored — mounted mid-turn, the first real tail it + // sees is the best pre-stream history it will ever get. + const canAnchor = tailId !== null && (!options.streamLive || scopedGate.baselineTailId === null) + return { gate: canAnchor ? advanceGate(scopedGate, '', tailId) : scopedGate, streaming: null } + } + // A stream that is not an extension of the previous tick is a new segment + // (next reply part); re-anchor to the tail that predates it. + const segmentStart = scopedGate.prevText !== '' && !text.startsWith(scopedGate.prevText) + const baselineTailId = segmentStart ? tailId : scopedGate.baselineTailId + const tailLeadsWithStream = assistantTailText(tail).startsWith(text) + // A null baseline (text on the very first tick, no tail ever seen) is unequal + // to every real tail id, so this degrades to the legacy suppress-on-prefix rule. + const caughtUp = tailLeadsWithStream && tailId !== baselineTailId + return { + gate: advanceGate(scopedGate, text, baselineTailId), + streaming: caughtUp ? null : text + } +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 2b4fb0e9d0f..35318d7e7f2 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -47,8 +47,12 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({ vi.mock('./use-mobile-native-chat-prompts', () => ({ useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null }) })) +const answerSendArgs: { streamIdentity?: string }[] = [] vi.mock('./use-mobile-native-chat-answer-send', () => ({ - useMobileNativeChatAnswerSend: () => ({ answerAsk: vi.fn(), cancelPending: vi.fn() }) + useMobileNativeChatAnswerSend: (args: { streamIdentity?: string }) => { + answerSendArgs.push(args) + return { answerAsk: vi.fn(), cancelPending: vi.fn() } + } })) vi.mock('./mobile-native-chat-permission-send', () => ({ useMobileNativeChatPermissionSend: () => vi.fn() @@ -421,3 +425,99 @@ describe('useMobileNativeChatController launch-draft wiring', () => { expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true }) }) }) + +describe('useMobileNativeChatController streaming scope', () => { + let renderer: ReactTestRenderer | null = null + let controller: MobileNativeChatController | null = null + const clientStub = { sendRequest: vi.fn() } + + const workingTab = { + type: 'terminal', + id: 'tab-1', + terminal: 'term-1', + launchAgent: 'claude', + agentStatus: { + state: 'working', + agentType: 'claude', + providerSession: { id: 'session-1' } + }, + isActive: true + } + + function Harness(): null { + controller = useMobileNativeChatController({ + client: clientStub as unknown as RpcClient, + connState: 'connected', + hostId: 'h', + worktreeId: 'w', + activeSessionTab: workingTab as never, + activeSessionTabId: 'tab-1', + activeHandleRef: { current: 'term-1' }, + deviceTokenRef: { current: null }, + nativeChatTranscriptIsLocalReadable: true, + nativeChatInputLeaseReady: true, + onSendError: vi.fn(), + onSendResolved: vi.fn() + }) + return null + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + viewMode.isTabChatView = () => true + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { + if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { + return + } + original(...a) + }) + try { + act(() => { + renderer = create(createElement(Harness)) + }) + } finally { + spy.mockRestore() + } + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + controller = null + viewMode.isTabChatView = () => true + }) + + it('holds the stream scope and liveness while the user peeks at the terminal', () => { + // Both feed the streaming gate, which lives above the chat view's mount and + // must not be reset by a view toggle. + expect(controller?.showNativeChat).toBe(true) + const scopeKey = controller?.nativeChatStreamScopeKey + expect(scopeKey).toContain('session-1') + expect(controller?.nativeChatStreamLive).toBe(true) + + viewMode.isTabChatView = () => false + act(() => renderer?.update(createElement(Harness))) + + expect(controller?.showNativeChat).toBe(false) + expect(controller?.nativeChatAgentWorking).toBe(false) + expect(controller?.nativeChatStreamScopeKey).toBe(scopeKey) + expect(controller?.nativeChatStreamLive).toBe(true) + }) + + it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => { + // `streamIdentity` fences a delayed answer/stop to the route it was armed + // on, so it must still drop its session when chat closes — the scope key is + // the one that has to survive the toggle. Same string while chat is open. + const before = answerSendArgs.at(-1)?.streamIdentity + expect(before).toBe(controller?.nativeChatStreamScopeKey) + + viewMode.isTabChatView = () => false + act(() => renderer?.update(createElement(Harness))) + + const after = answerSendArgs.at(-1)?.streamIdentity + expect(after).not.toBe(before) + expect(after).not.toContain('session-1') + expect(controller?.nativeChatStreamScopeKey).toBe(before) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index fd0a564b19c..37f7b5399c2 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -49,6 +49,10 @@ export type MobileNativeChatController = { nativeChatSession: ReturnType nativeChatAgentWorking: boolean nativeChatStreamingText?: string + /** Agent mid-turn, regardless of whether chat is the visible view. */ + nativeChatStreamLive: boolean + /** Host/workspace/tab/session scope for stateful streaming suppression. */ + nativeChatStreamScopeKey: string nativeChatPermission: ReturnType nativeChatQuestion: ReturnType nativeChatAsk: ReturnType @@ -124,7 +128,12 @@ export function useMobileNativeChatController(args: { activeChatAgentRef.current = activeChatResolution?.agent ?? null const activeChatSessionId = activeChatResolution?.sessionId ?? null - const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` + const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}` + const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` + // Same chat, but keyed off the tab rather than the view-gated resolution: + // `streamIdentity` goes session-less the moment the user peeks at the terminal, + // and a scope that flips on a view toggle throws the gate's baseline away. + const streamScopeKey = `${routeKey}\0${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeHandleRef.current ?? ''}` const nativeChatSession = useMobileNativeChatSession({ client, @@ -160,6 +169,9 @@ export function useMobileNativeChatController(args: { const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null const nativeChatAgentWorking = nativeChatStatus?.state === 'working' + // Deliberately not gated on the chat view being visible: the streaming gate + // has to tell "hidden mid-turn" from "the turn ended". + const nativeChatStreamLive = activeSessionTab?.agentStatus?.state === 'working' // Throttle the streaming bubble: OpenCode emits a status frame per streamed // part, and each one re-renders and re-parses the whole accumulated markdown. const nativeChatStreamingText = useThrottledLatestValue( @@ -273,6 +285,8 @@ export function useMobileNativeChatController(args: { nativeChatSession, nativeChatAgentWorking, nativeChatStreamingText, + nativeChatStreamLive, + nativeChatStreamScopeKey: streamScopeKey, nativeChatPermission, nativeChatQuestion, nativeChatAsk, diff --git a/mobile/src/session/use-mobile-native-chat-streaming-bubble.ts b/mobile/src/session/use-mobile-native-chat-streaming-bubble.ts new file mode 100644 index 00000000000..632fa0b2282 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-streaming-bubble.ts @@ -0,0 +1,31 @@ +import { useState } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + createMobileNativeChatStreamingGate, + deriveMobileNativeChatStreaming +} from './mobile-native-chat-streaming-gate' + +/** Live streaming-bubble text for the chat list. The gate remembers which + * transcript tail predates the current stream segment, so a reply that repeats + * the previous turn's prefix still shows while streaming. Call this from a + * component that outlives the chat list itself: the baseline has to survive the + * view toggles that unmount it, or the next segment reverts to prefix-matching. + * `streamLive` keeps those textless gaps from reading as an idle stream. */ +export function useMobileNativeChatStreamingBubble( + folded: readonly NativeChatMessage[], + streamingText: string | undefined, + scopeKey: string, + streamLive: boolean +): string | null { + const [gate, setGate] = useState(() => createMobileNativeChatStreamingGate(scopeKey)) + const step = deriveMobileNativeChatStreaming(gate, folded, streamingText, { + scopeKey, + streamLive + }) + if (step.gate !== gate) { + // Render-time state adjustment (derived-state pattern): the advance is + // idempotent for a repeated (text, tail) pair, so this settles in one pass. + setGate(step.gate) + } + return step.streaming +} From e138d28fa65916e7086449357e577a70e808bef7 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:37:59 -0700 Subject: [PATCH 26/43] Fix Linear filter chips showing UUIDs after dropdown closes (#12564) Fetch metadata when filters are selected, not only while the popover is open, so chip labels remain readable after closing the dropdown. --- ...-issue-attribute-filter-dropdowns.test.tsx | 109 +++++++++++++++++- ...inear-issue-attribute-filter-dropdowns.tsx | 13 ++- tests/e2e/linear-filter-chip-labels.spec.ts | 105 +++++++++++++++++ 3 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/linear-filter-chip-labels.spec.ts diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx index 753e24872dc..845ed09b0d3 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx +++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx @@ -1,10 +1,48 @@ -import { describe, expect, it } from 'vitest' +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { LinearTeam } from '../../../shared/types' import { clearLinearIssueAttributeFacet, countLinearIssueAttributeFilters, linearIssueAttributeFilterPillLabels } from './linear-issue-attribute-filter-sections' import type { LinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter' +import LinearIssueAttributeFilterDropdowns from './linear-issue-attribute-filter-dropdowns' + +const metadataMocks = vi.hoisted(() => ({ + useTeamsStates: vi.fn((teamIds: readonly string[]) => ({ + data: teamIds.length > 0 ? [{ id: 'state-1', name: 'Todo' }] : [], + loading: false, + error: null + })), + useTeamsLabels: vi.fn((teamIds: readonly string[]) => ({ + data: teamIds.length > 0 ? [{ id: 'label-1', name: 'Bug' }] : [], + loading: false, + error: null + })), + useTeamsMembers: vi.fn((teamIds: readonly string[]) => ({ + data: teamIds.length > 0 ? [{ id: 'member-1', displayName: 'Ada Lovelace' }] : [], + loading: false, + error: null + })) +})) + +vi.mock('@/hooks/useIssueMetadata', () => metadataMocks) + +const roots: Root[] = [] + +afterEach(() => { + roots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + metadataMocks.useTeamsStates.mockClear() + metadataMocks.useTeamsLabels.mockClear() + metadataMocks.useTeamsMembers.mockClear() +}) const sample: LinearIssueAttributeFilter = { stateIds: ['s1', 's2'], @@ -38,3 +76,72 @@ describe('linear-issue-attribute-filter helpers', () => { expect(pills[3]?.value).toBe('Bug') }) }) + +describe('LinearIssueAttributeFilterDropdowns', () => { + it('keeps metadata lazy while closed with only static filter labels', () => { + const team: LinearTeam = { id: 'team-1', name: 'Engineering', key: 'ENG' } + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + + act(() => { + root.render( + undefined} + workspaceId="workspace-1" + isAllWorkspaces={false} + primaryTeam={team} + selectedTeamIds={[]} + availableTeams={[team]} + /> + ) + }) + + expect(metadataMocks.useTeamsStates).toHaveBeenCalledWith([], undefined, null) + expect(metadataMocks.useTeamsLabels).toHaveBeenCalledWith([], undefined, null) + expect(metadataMocks.useTeamsMembers).toHaveBeenCalledWith([], undefined, null) + }) + + it('keeps readable metadata names available after the popover closes', () => { + const value: LinearIssueAttributeFilter = { + stateIds: ['state-1'], + priorities: [], + assignee: { kind: 'user', id: 'member-1' }, + labelIds: ['label-1'] + } + const team: LinearTeam = { id: 'team-1', name: 'Engineering', key: 'ENG' } + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + + act(() => { + root.render( + undefined} + workspaceId="workspace-1" + isAllWorkspaces={false} + primaryTeam={team} + selectedTeamIds={[]} + availableTeams={[team]} + /> + ) + }) + + expect(container.textContent).toContain('Todo') + expect(container.textContent).toContain('Ada Lovelace') + expect(container.textContent).toContain('Bug') + expect(container.textContent).not.toContain('state-1') + expect(metadataMocks.useTeamsStates).toHaveBeenCalledWith(['team-1'], undefined, 'workspace-1') + expect(metadataMocks.useTeamsLabels).toHaveBeenCalledWith(['team-1'], undefined, 'workspace-1') + expect(metadataMocks.useTeamsMembers).toHaveBeenCalledWith(['team-1'], undefined, 'workspace-1') + }) +}) diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx index fa47d6def6b..9cf550e2fb3 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx +++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx @@ -76,9 +76,15 @@ export default function LinearIssueAttributeFilterDropdowns({ }: Props): React.JSX.Element { const [popoverOpen, setPopoverOpen] = useState(false) const [openSection, setOpenSection] = useState(null) + const activeCount = countLinearIssueAttributeFilters(value) + const metadataNeeded = + popoverOpen || + value.stateIds.length > 0 || + value.labelIds.length > 0 || + value.assignee?.kind === 'user' const activeTeamIds = useMemo(() => { - if (!popoverOpen || isAllWorkspaces) { + if (!metadataNeeded || isAllWorkspaces) { return [] as string[] } return resolveLinearIssueAttributeFilterTeamIds({ @@ -86,10 +92,10 @@ export default function LinearIssueAttributeFilterDropdowns({ availableTeams, primaryTeamId: primaryTeam?.id ?? null }) - }, [popoverOpen, isAllWorkspaces, selectedTeamIds, availableTeams, primaryTeam?.id]) + }, [metadataNeeded, isAllWorkspaces, selectedTeamIds, availableTeams, primaryTeam?.id]) const concreteWorkspaceId = - popoverOpen && !isAllWorkspaces && workspaceId && workspaceId !== 'all' ? workspaceId : null + metadataNeeded && !isAllWorkspaces && workspaceId && workspaceId !== 'all' ? workspaceId : null // Why: multi-team / All teams must union filter options across every selected team (#8739). const states = useTeamsStates(activeTeamIds, settings, concreteWorkspaceId) @@ -179,7 +185,6 @@ export default function LinearIssueAttributeFilterDropdowns({ [members.data] ) - const activeCount = countLinearIssueAttributeFilters(value) const pills = linearIssueAttributeFilterPillLabels({ value, stateNamesById, diff --git a/tests/e2e/linear-filter-chip-labels.spec.ts b/tests/e2e/linear-filter-chip-labels.spec.ts new file mode 100644 index 00000000000..1ccb3d06921 --- /dev/null +++ b/tests/e2e/linear-filter-chip-labels.spec.ts @@ -0,0 +1,105 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { getStoreState, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const FIXTURE = { + workspace: { + id: 'linear-workspace-3393', + displayName: 'Linear E2E User', + email: 'linear-e2e@example.test', + organizationId: 'linear-org-3393', + organizationName: 'Linear E2E Workspace' + }, + team: { + id: 'linear-team-3393', + name: 'Engineering', + key: 'ENG' + }, + state: { + id: 'linear-state-uuid-3393', + name: 'In Review', + type: 'started', + color: '#888888', + position: 1 + }, + issue: { + id: 'linear-issue-3393', + workspaceId: 'linear-workspace-3393', + identifier: 'ENG-3393', + title: 'Keep filter chip labels readable', + url: 'https://linear.example.test/ENG-3393', + state: { name: 'In Review', type: 'started', color: '#888888' }, + team: { id: 'linear-team-3393', name: 'Engineering', key: 'ENG' }, + labels: [], + labelIds: [], + priority: 0, + updatedAt: '2026-08-04T18:00:00.000Z' + } +} as const + +async function installLinearFilterBackend(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(({ ipcMain }, fixture) => { + ipcMain.removeHandler('linear:status') + ipcMain.handle('linear:status', async () => ({ + connected: true, + viewer: fixture.workspace, + workspaces: [fixture.workspace], + activeWorkspaceId: fixture.workspace.id, + selectedWorkspaceId: fixture.workspace.id + })) + + ipcMain.removeHandler('linear:listTeams') + ipcMain.handle('linear:listTeams', async () => [fixture.team]) + + ipcMain.removeHandler('linear:listIssues') + ipcMain.handle('linear:listIssues', async () => ({ items: [fixture.issue], hasMore: false })) + + ipcMain.removeHandler('linear:teamStates') + ipcMain.handle('linear:teamStates', async () => [fixture.state]) + + ipcMain.removeHandler('linear:teamLabels') + ipcMain.handle('linear:teamLabels', async () => []) + + ipcMain.removeHandler('linear:teamMembers') + ipcMain.handle('linear:teamMembers', async () => []) + }, FIXTURE) +} + +async function openLinearTasks(page: Page): Promise { + await page.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + await store.getState().checkLinearConnection(true) + store.getState().openTaskPage({ taskSource: 'linear' }) + }) +} + +test('Linear filter chips keep readable names after the dropdown closes', async ({ + electronApp, + orcaPage +}) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await installLinearFilterBackend(electronApp) + await openLinearTasks(orcaPage) + + await expect + .poll(() => getStoreState(orcaPage, 'activeView'), { timeout: 5_000 }) + .toBe('tasks') + const filtersButton = orcaPage.getByRole('button', { name: 'Filters', exact: true }) + await expect(filtersButton).toBeVisible() + await expect(orcaPage.getByText(FIXTURE.issue.title, { exact: true })).toBeVisible() + + await filtersButton.click() + const popover = orcaPage.locator('[data-slot="popover-content"]') + await popover.getByRole('button', { name: 'Status', exact: true }).click() + await popover.getByText(FIXTURE.state.name, { exact: true }).click() + await filtersButton.click() + await expect(popover).toHaveCount(0) + + const statusChip = orcaPage.getByRole('button', { name: 'Remove Status filter' }).locator('..') + await expect(statusChip).toContainText(FIXTURE.state.name) + await expect(statusChip).not.toContainText(FIXTURE.state.id) +}) From 9759bd2e76470aa642d17414a075b1d998506e41 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:40:36 -0700 Subject: [PATCH 27/43] fix(mobile): center native chat checkmarks (#12565) --- mobile/src/session/MobileNativeChatAsk.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx index b485818970c..86fcb0dc4a2 100644 --- a/mobile/src/session/MobileNativeChatAsk.tsx +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -282,6 +282,7 @@ const styles = StyleSheet.create({ }, option: { flexDirection: 'row', + alignItems: 'center', gap: spacing.sm, padding: spacing.sm, borderRadius: radii.card, @@ -299,8 +300,7 @@ const styles = StyleSheet.create({ borderWidth: 1.5, borderColor: colors.textMuted, alignItems: 'center', - justifyContent: 'center', - marginTop: 1 + justifyContent: 'center' }, checkCircle: { borderRadius: 9 From d27d69fbffc2baf0cd29f609d4b340ac4762b219 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:01:39 -0700 Subject: [PATCH 28/43] fix(mobile): serialize native chat PTY writes and fence superseded ask keystrokes (STA-3333) (#12502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): serialize native chat PTY writes Two composed native-chat write sequences into one PTY interleaved their bytes: the per-terminal send-in-flight guard lived inside the image attachments hook, so ask answers, permission choices, and question answers wrote straight past it. Move the guard to a shared module-scope write lock (the terminal outlives any one screen) and take it on all four paths. Ask answers additionally queue behind the prior chain's RPC rather than racing it, and a superseding answer is fenced once a key has actually landed: an accepted or ambiguously-delivered keystroke already moved the remote selector, so a replacement's from-scratch key plan would answer the wrong question. A superseding answer inherits the cancelled chain's hold (refcounted, last chain out releases) so changing your mind mid-answer still works, and Stop/cancel stay unguarded so an interrupt can never deadlock against the send it cancels. Refs STA-3333. * fix(mobile): report a fenced native-chat answer instead of dropping it The fence added for superseding Ask answers returned false with no onSendError, and the card re-enables on a false result — so a queued answer vanished with no banner and no toast, looking exactly like a dead button. Every other bail in answerAsk reports. Keep the generation- mismatch branch silent: a newer answer owns the error surface. Also covers the hold-count release, which had no test at all: replacing it with an unconditional release left all 58 tests green while silently reopening the terminal mid-sequence — the exact interleave this PR fixes. * fix(mobile): stop a superseded answer from clearing the fence banner A chain that finished its key plan reported `true` even after a newer answer superseded it. The route sends through useNativeChatAcceptedAction, whose accepted callback retires the send-error banner — and that callback runs after the successor's fence report, because finishTurn() fires in `finally`, before the chain's own promise settles. So the successful predecessor deterministically wiped the fence message the successor had just raised: every healthy write took that branch, which made the previous commit's report vacuous exactly where it mattered. A superseded chain now reports no success, matching every other supersession checkpoint in this hook. * fix(mobile): fence on the turn slot, not the generation counter The supersession guards added in c36f2ce14e read `generationRef`, but `cancelPending()` bumps that counter from three callers with no successor chain: Stop, ask-cancel, and the lease effect that fires on every disconnect. An answer whose key had already landed then reported false — `fail()` never runs on an accepted write, so the card stayed up silent with Submit re-enabled, and the retry wrote the same key into a selector that key had already advanced. Test the turn slot instead: a successor takes it synchronously before its first await and cannot resolve ahead of this chain, so it means "a successor took over" exactly, without catching bare cancellations. * fix(mobile): report a fenced answer when a dropped lease bumps the generation The fence-report guard tested generationRef, which answers "was I cancelled", not "did a successor replace me". Stop and ask-cancel both write an Escape that clears the ask card, so their silence is harmless. A dropped input lease bumps the same counter and writes nothing: the card stays up with Submit re-enabled while the predecessor's option key has already advanced the live selector, so the retry double-steps it. Gate on the turn slot, matching the two success returns. * test(mobile): cover the turn-slot release after a landed answer The slot delete in the finally was the one line in this hook no test killed: without it a landed answer's resolved-false turn stays parked, so every later answer on that handle reads a fenced predecessor and the ask card dies after its first use. * test(mobile): keep PTY write locks terminal-scoped --- ...mobile-native-chat-permission-send.test.ts | 44 ++ .../mobile-native-chat-permission-send.ts | 27 +- ...le-native-chat-terminal-write-lock.test.ts | 20 + .../mobile-native-chat-terminal-write-lock.ts | 25 + ...use-mobile-native-chat-answer-send.test.ts | 527 ++++++++++++++++++ .../use-mobile-native-chat-answer-send.ts | 284 ++++++---- ...bile-native-chat-image-attachments.test.ts | 6 +- ...se-mobile-native-chat-image-attachments.ts | 16 +- ...se-mobile-native-chat-message-send.test.ts | 33 +- .../use-mobile-native-chat-message-send.ts | 27 +- 10 files changed, 885 insertions(+), 124 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-terminal-write-lock.test.ts create mode 100644 mobile/src/session/mobile-native-chat-terminal-write-lock.ts diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts index 1b595683112..253f8c8b85a 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.test.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts @@ -13,6 +13,11 @@ import { markMobileNativeChatInputStale, resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite, + resetMobileNativeChatTerminalWritesForTests +} from './mobile-native-chat-terminal-write-lock' describe('sendMobileNativeChatPermissionResponse', () => { it('writes an approval as raw bytes without appending Return', async () => { @@ -64,6 +69,7 @@ describe('useMobileNativeChatPermissionSend', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true resetMobileNativeChatStaleInputForTests() + resetMobileNativeChatTerminalWritesForTests() }) afterEach(() => { @@ -102,4 +108,42 @@ describe('useMobileNativeChatPermissionSend', () => { expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false }) expect(isMobileNativeChatInputStale('terminal')).toBe(true) }) + + it('rejects a choice while another composed write holds the terminal, then recovers', async () => { + const onSendError = vi.fn() + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } } + }) + function Harness(): null { + respond = useMobileNativeChatPermissionSend({ + client: { sendRequest } as unknown as RpcClient, + enabled: true, + handleRef: { current: 'terminal' }, + deviceTokenRef: { current: null }, + onSendError + }) + return null + } + act(() => { + renderer = create(createElement(Harness)) + }) + + // An image paste sequence is mid-flight into the same PTY: the choice + // keystroke must not interleave into it. + expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true) + await act(async () => { + await expect(respond?.('1')).resolves.toBe(false) + }) + expect(sendRequest).not.toHaveBeenCalled() + expect(onSendError).toHaveBeenCalledWith('Response not sent') + + releaseMobileNativeChatTerminalWrite('terminal') + await act(async () => { + await expect(respond?.('1')).resolves.toBe(true) + }) + // The choice released its own hold on the way out. + expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true) + releaseMobileNativeChatTerminalWrite('terminal') + }) }) diff --git a/mobile/src/session/mobile-native-chat-permission-send.ts b/mobile/src/session/mobile-native-chat-permission-send.ts index b06ff88fe48..7f58b0ab44e 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.ts @@ -4,6 +4,10 @@ import { sendMobileNativeChatMessageWithOutcome, type MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite +} from './mobile-native-chat-terminal-write-lock' export function sendMobileNativeChatPermissionResponse(args: { client: RpcClient @@ -36,15 +40,26 @@ export function useMobileNativeChatPermissionSend(args: { args.onSendError('Response not sent (disconnected)') return false } + // A choice keystroke must not interleave into a mid-flight composed write + // (image paste, paced answer) on the same PTY. + if (!acquireMobileNativeChatTerminalWrite(terminal)) { + args.onSendError('Response not sent') + return false + } // No stale-input heal here (unlike the text/ask sends): a choice is an // `enter: false` key for an active overlay that swallows the clear, so it // would consume the marker still protecting the next real message. - const outcome = await sendMobileNativeChatPermissionResponse({ - client: args.client, - terminal, - deviceToken: args.deviceTokenRef.current, - text - }) + let outcome: MobileNativeChatSendOutcome + try { + outcome = await sendMobileNativeChatPermissionResponse({ + client: args.client, + terminal, + deviceToken: args.deviceTokenRef.current, + text + }) + } finally { + releaseMobileNativeChatTerminalWrite(terminal) + } if (outcome === 'unknown') { // Why: the response may have been delivered (ack lost / path cutover) — // a definite "not sent" would invite a double answer. diff --git a/mobile/src/session/mobile-native-chat-terminal-write-lock.test.ts b/mobile/src/session/mobile-native-chat-terminal-write-lock.test.ts new file mode 100644 index 00000000000..c15ce3c826e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-terminal-write-lock.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite, + resetMobileNativeChatTerminalWritesForTests +} from './mobile-native-chat-terminal-write-lock' + +describe('mobile-native-chat-terminal-write-lock', () => { + afterEach(resetMobileNativeChatTerminalWritesForTests) + + it('allows composed writes on different terminals to proceed concurrently', () => { + expect(acquireMobileNativeChatTerminalWrite('terminal-a')).toBe(true) + + expect(acquireMobileNativeChatTerminalWrite('terminal-b')).toBe(true) + expect(acquireMobileNativeChatTerminalWrite('terminal-a')).toBe(false) + + releaseMobileNativeChatTerminalWrite('terminal-a') + releaseMobileNativeChatTerminalWrite('terminal-b') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-terminal-write-lock.ts b/mobile/src/session/mobile-native-chat-terminal-write-lock.ts new file mode 100644 index 00000000000..5624a384ed3 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-terminal-write-lock.ts @@ -0,0 +1,25 @@ +// Serializes composed native-chat write sequences (clear/paste/settle/submit, +// paced answer keystrokes) per HOST terminal. Two concurrent sequences into one +// PTY interleave their bytes; a second sender must be rejected up front, not +// woven in. Module scope for the same reason as the stale-input marker: the +// terminal outlives any one screen, and independent hooks share the same PTY. +const writeInFlightTerminals = new Set() + +/** Claim the terminal for one composed write sequence. False = another + * sequence is mid-flight; the caller must reject its send. */ +export function acquireMobileNativeChatTerminalWrite(terminal: string): boolean { + if (writeInFlightTerminals.has(terminal)) { + return false + } + writeInFlightTerminals.add(terminal) + return true +} + +export function releaseMobileNativeChatTerminalWrite(terminal: string): void { + writeInFlightTerminals.delete(terminal) +} + +/** Test-only: module scope outlives a single test's hooks. */ +export function resetMobileNativeChatTerminalWritesForTests(): void { + writeInFlightTerminals.clear() +} diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts index 8f22884145e..b296ea4851b 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -11,7 +11,13 @@ import { markMobileNativeChatInputStale, resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite, + resetMobileNativeChatTerminalWritesForTests +} from './mobile-native-chat-terminal-write-lock' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' +import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' type AnswerSend = ReturnType @@ -40,17 +46,24 @@ describe('useMobileNativeChatAnswerSend', () => { let mountedClient: RpcClient | null = null let mountedOnSendError: ((message: string) => void) | null = null let mountedAgent: AgentType = 'claude' + // The route sends through useNativeChatAcceptedAction, whose accepted callback + // retires the shared send-error banner (use-mobile-native-chat-controller.ts). + let acceptedAnswerAsk: AnswerSend['answerAsk'] | null = null + let onAccepted = vi.fn() beforeEach(() => { + onAccepted = vi.fn() vi.useFakeTimers() globalThis.IS_REACT_ACT_ENVIRONMENT = true resetMobileNativeChatStaleInputForTests() + resetMobileNativeChatTerminalWritesForTests() }) afterEach(() => { act(() => renderer?.unmount()) renderer = null answerSend = null + acceptedAnswerAsk = null mountedClient = null mountedOnSendError = null mountedAgent = 'claude' @@ -68,6 +81,7 @@ describe('useMobileNativeChatAnswerSend', () => { streamIdentity: 'host\0worktree\0tab\0session', onSendError: mountedOnSendError! }) + acceptedAnswerAsk = useNativeChatAcceptedAction(answerSend.answerAsk, onAccepted) return null } @@ -396,4 +410,517 @@ describe('useMobileNativeChatAnswerSend', () => { await expect(result).resolves.toBe(false) expect(sendRequest).toHaveBeenCalledTimes(1) }) + + it('rejects an answer while another composed write holds the terminal', async () => { + const onSendError = vi.fn() + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + // An image paste sequence is mid-flight into the same PTY. + expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true) + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false) + expect(sendRequest).not.toHaveBeenCalled() + expect(onSendError).toHaveBeenCalledWith('Answer not sent') + + // Once that sequence releases, answers flow again. + releaseMobileNativeChatTerminalWrite('terminal') + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + }) + + it('answers again on the same handle after an earlier answer already landed', async () => { + const onSendError = vi.fn() + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])).resolves.toBe(true) + // A landed answer resolves its turn FALSE — correct for a queued successor, + // fatal if the turn outlives the chain. Leaving it parked in the slot fences + // every later answer on this handle for the life of the hook, not just an + // overlapping one, so the ask card dies after its first use. + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(onSendError).not.toHaveBeenCalled() + }) + + it('fences a superseding answer after the cancelled chain moved the selector', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(prompt, [{ indices: [0] }, { indices: [0] }]) + }) + // The first digit already advanced Claude to q2. Replaying a from-q1 key + // plan now would answer the wrong question. + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + // Both chains unwound: the terminal is free for the next composed write. + expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true) + releaseMobileNativeChatTerminalWrite('terminal') + }) + + it('does not write a successor after the prior in-flight key is accepted', async () => { + let resolveFirst: (response: unknown) => void = () => undefined + const sendRequest = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }) + ) + .mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(prompt, [{ indices: [0] }, { indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + + // The successor is queued behind the in-flight key, not racing it. + expect(sendRequest).toHaveBeenCalledTimes(1) + await act(async () => { + resolveFirst(acceptedResponse()) + await Promise.resolve() + }) + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('lets a queued successor continue after the prior key is definitely rejected', async () => { + let resolveFirst: (response: unknown) => void = () => undefined + const sendRequest = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }) + ) + .mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + await act(async () => { + resolveFirst({ + ...acceptedResponse(), + result: { send: { accepted: false } } + }) + await Promise.resolve() + }) + + // Nothing landed, so the selector never moved — the successor is safe. + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('does not write a successor after the prior delivery becomes ambiguous', async () => { + let rejectFirst: (error: Error) => void = () => undefined + const sendRequest = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFirst = reject + }) + ) + .mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + await act(async () => { + rejectFirst(markRpcDeliveryUnknown(new Error('Connection closed'))) + await Promise.resolve() + }) + + // The key may have landed; a blind successor could double-step the selector. + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('tells the user when a queued answer is fenced instead of dropping it silently', async () => { + const onSendError = vi.fn() + let rejectFirst: (error: Error) => void = () => undefined + const sendRequest = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFirst = reject + }) + ) + .mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + await act(async () => { + rejectFirst(markRpcDeliveryUnknown(new Error('Connection closed'))) + await Promise.resolve() + }) + + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + // The card re-enables on a false result, so an unreported fence looks exactly + // like a dead button. The superseded chain stays quiet — the newest answer + // owns the error surface, and it is the one the user is waiting on. + expect(onSendError).toHaveBeenCalledTimes(1) + expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying') + }) + + it('tells the user when a dropped lease fences a queued answer whose predecessor landed', async () => { + const onSendError = vi.fn() + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + // A transport blip, not a user cancel: unlike Stop and ask-cancel, the lost + // input lease writes no Escape, so the card stays up with Submit re-enabled. + await setEnabled(false) + await act(async () => { + settle[0]!(acceptedResponse()) + await Promise.resolve() + }) + + expect(sendRequest).toHaveBeenCalledTimes(1) + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + // The first option key LANDED, so the live selector already moved. Reporting + // nothing invites a retry that double-steps it. + expect(onSendError).toHaveBeenCalledTimes(1) + expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying') + }) + + it('keeps the terminal locked while a queued successor writes', async () => { + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + + // Nothing landed, so the successor is cleared to write. + await act(async () => { + settle[0]!({ ...acceptedResponse(), result: { send: { accepted: false } } }) + await Promise.resolve() + }) + expect(sendRequest).toHaveBeenCalledTimes(2) + // The successor's key is on the wire. The superseded chain unwinding behind it + // must NOT free the terminal, or an image paste interleaves into this sequence. + expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(false) + + await act(async () => { + settle[1]!(acceptedResponse()) + await Promise.resolve() + }) + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(true) + expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true) + releaseMobileNativeChatTerminalWrite('terminal') + }) + + it('does not retire the fence banner when the superseded answer lands', async () => { + const onSendError = vi.fn() + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + // The healthy path: the first answer LANDS, which is also the case that fences + // hardest — its key moved the live selector. + await act(async () => { + settle[0]!(acceptedResponse()) + await Promise.resolve() + }) + + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying') + // A superseded chain reporting success would clear the banner it just raised — + // the accepted hook runs after the fence, so the user would see nothing at all. + expect(onAccepted).not.toHaveBeenCalled() + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('does not retire the fence banner when a superseded pasted answer lands', async () => { + const onSendError = vi.fn() + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok') + + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + await act(async () => { + settle[0]!(acceptedResponse()) + await Promise.resolve() + }) + + // The pasted shape commits with Enter, so a superseded chain is doubly unsafe + // to report as accepted — the answer it committed is not the one on screen. + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + expect(onAccepted).not.toHaveBeenCalled() + expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying') + }) + + it('reports a landed answer that Stop cancelled with no successor waiting', async () => { + const onSendError = vi.fn() + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + let first: Promise | undefined + await act(async () => { + first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + // Stop, an ask cancel, and a dropped input lease all bump the generation with + // NO successor chain behind them. + await act(async () => { + answerSend?.cancelPending() + settle[0]!(acceptedResponse()) + await Promise.resolve() + }) + + // The key is on the PTY and nobody else owns the surface. Calling that a + // non-send leaves the card up and silent (fail() never runs on an accepted + // write), and the retry double-steps the selector this key already moved. + await expect(first).resolves.toBe(true) + expect(onAccepted).toHaveBeenCalledTimes(1) + expect(onSendError).not.toHaveBeenCalled() + }) + + it('reports a landed pasted answer that Stop cancelled with no successor', async () => { + const onSendError = vi.fn() + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok') + + let first: Promise | undefined + await act(async () => { + first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + await act(async () => { + answerSend?.cancelPending() + settle[0]!(acceptedResponse()) + await Promise.resolve() + }) + + // The pasted shape already committed with Enter, so suppressing the success + // strands an answered card the user is invited to submit a second time. + await expect(first).resolves.toBe(true) + expect(onAccepted).toHaveBeenCalledTimes(1) + expect(onSendError).not.toHaveBeenCalled() + }) + + it('fences a third answer behind an already-fenced successor, reporting once', async () => { + const onSendError = vi.fn() + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + let first: Promise | undefined + let second: Promise | undefined + let third: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + await act(async () => { + third = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await act(async () => { + settle[0]!(acceptedResponse()) + await Promise.resolve() + }) + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + await expect(third).resolves.toBe(false) + // The middle chain sent nothing, so only the verdict it INHERITED can stop the + // third from replaying a from-scratch plan onto the advanced selector. + expect(sendRequest).toHaveBeenCalledTimes(1) + // Only the newest chain owns the error surface. + expect(onSendError).toHaveBeenCalledTimes(1) + expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying') + }) + + it('queues a late third answer behind the successor already on the wire', async () => { + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + let first: Promise | undefined + let second: Promise | undefined + let third: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + // Nothing landed, so the successor is cleared and puts its own key on the wire. + await act(async () => { + settle[0]!({ ...acceptedResponse(), result: { send: { accepted: false } } }) + await Promise.resolve() + }) + expect(sendRequest).toHaveBeenCalledTimes(2) + + await act(async () => { + third = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + await Promise.resolve() + }) + // The first chain unwound while the second was mid-write: it must not have + // dropped the second's turn, or this one writes into the same PTY concurrently. + expect(sendRequest).toHaveBeenCalledTimes(2) + + await act(async () => { + settle[1]!(acceptedResponse()) + await Promise.resolve() + }) + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + await expect(third).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(2) + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts index 11a3a030788..b893f7ae071 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -14,6 +14,10 @@ import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite +} from './mobile-native-chat-terminal-write-lock' import { resolveNativeChatTranscriptAgent, shouldStepNativeChatAskAnswer @@ -72,6 +76,12 @@ export function useMobileNativeChatAnswerSend(args: { const generationRef = useRef(0) const activeRouteRef = useRef({ client, enabled, sessionId, streamIdentity }) activeRouteRef.current = { client, enabled, sessionId, streamIdentity } + // Per-terminal count of this hook's chains sharing one write-lock hold: a + // superseding answer inherits the cancelled chain's hold (it re-enters before + // the old chain unwinds), and only the last chain out releases the lock. + const writeHoldsRef = useRef(new Map()) + // Successors wait for the prior RPC and inherit any delivery ambiguity. + const writeTurnsRef = useRef(new Map>()) const delaysRef = useRef< Set<{ timer: ReturnType; resolve: (completed: boolean) => void }> >(new Set()) @@ -103,125 +113,191 @@ export function useMobileNativeChatAnswerSend(args: { if (!hasAskAnswer(prompt, selections)) { return false } + // One composed write sequence per terminal: an answer landing mid-flight + // in an image paste (or vice versa) would interleave bytes into the PTY. + // A superseding answer shares the cancelled chain's hold on this terminal + // (that chain has not unwound to its release yet). + const holds = writeHoldsRef.current + const heldCount = holds.get(handle) ?? 0 + if (heldCount === 0 && !acquireMobileNativeChatTerminalWrite(handle)) { + onSendError('Answer not sent') + return false + } + holds.set(handle, heldCount + 1) + const previousTurn = writeTurnsRef.current.get(handle) ?? Promise.resolve(true) + let finishTurn: (safeToContinue: boolean) => void = () => undefined + const turn = new Promise((resolve) => { + finishTurn = resolve + }) + writeTurnsRef.current.set(handle, turn) // A new answer supersedes any still-pending keystroke writes. cancelPending() const generation = generationRef.current let sawUnknownOutcome = false let sawAcceptedGroup = false - // One budget for the whole answer instead of a fresh timeout per keystroke - // group, which let an N-group selector hold the card for N × the send timeout. - // It bounds transport time only: each deliberate pacing wait is credited back - // below, so a long multi-question answer still gets a full budget to write in. - let deadline = openMobileNativeChatSendBudget() - const sendTerminal = async (body: string, enter: boolean): Promise => { - const activeRoute = activeRouteRef.current - if ( - !activeRoute.enabled || - activeRoute.client !== client || - activeRoute.sessionId !== sessionId || - activeRoute.streamIdentity !== streamIdentity || - handleRef.current !== handle - ) { - return false - } - const outcome = await sendMobileNativeChatMessageWithOutcome({ - client, - terminal: handle, - text: body, - enter, - deadline, - ...(deviceTokenRef.current - ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } - : {}) - }) - if (outcome === 'unknown') { - sawUnknownOutcome = true - } - if (outcome === 'accepted') { - sawAcceptedGroup = true - } - return outcome === 'accepted' - } - const wait = (ms: number): Promise => - new Promise((resolve) => { - const delay = { - timer: setTimeout(() => { - delaysRef.current.delete(delay) - resolve(generationRef.current === generation) - }, ms), - resolve - } - delaysRef.current.add(delay) - }) - const fail = (): false => { - if (generationRef.current === generation) { - // Why: keystrokes that may have landed (ack lost / path cutover) must - // not read as a definite failure — a blind resend could double-step - // the selector. An earlier group that WAS accepted is the same hazard - // in definite form: a multi-question answer whose shared budget ran out - // mid-sequence left the remote selector half-stepped, and telling the - // user nothing was sent invites a retry on top of the advanced state. - onSendError( - sawAcceptedGroup - ? 'Answer partly sent — check chat before retrying' - : sawUnknownOutcome - ? 'Answer unconfirmed — check chat before retrying' - : 'Answer not sent' - ) - } - return false - } - // Grok commits pasted labels; Claude and Codex need their selector-specific - // keystrokes paced so each step renders before the next lands. - if (!shouldStepNativeChatAskAnswer(agentRef.current)) { - // This shape pastes the label into the composer and commits it, so an - // orphaned image paste would be submitted along with the answer (#10228). - // The selector shapes below deliberately skip the heal: their keys are - // `enter: false` for an active overlay, and a single-select answer is a - // bare option digit that cannot submit the line at all, so clearing there - // would consume the marker still protecting the next real message. - // Desktop splits it identically — use-native-chat-interactive-send.ts - // routes only the pasted-label shape through the clearing sender. - if ( - !(await healMobileNativeChatStaleInput({ - client, - terminal: handle, - deviceToken: deviceTokenRef.current, - deadline - })) - ) { - if (generationRef.current === generation) { - onSendError('Answer not sent') + let predecessorSafe = true + try { + predecessorSafe = await previousTurn + if (!predecessorSafe) { + // Fenced. Report it: the card re-enables on a false result, so silence + // here is indistinguishable from a dead button. "Check chat" rather than + // a bare "not sent" because the PREVIOUS answer's keys may have landed. + // Gate on the turn slot, not the generation: a dropped input lease bumps + // the generation without writing the Escape that Stop and ask-cancel do, + // so the card is still up and silence there strands an advanced selector. + if (writeTurnsRef.current.get(handle) === turn) { + onSendError('Answer not sent — check chat before retrying') } return false } + // Superseded by a newer answer, which owns the error surface from here. if (generationRef.current !== generation) { return false } - return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail() - } - const groups = - resolveNativeChatTranscriptAgent(agentRef.current) === 'codex' - ? buildCodexAskAnswerKeys(prompt, selections) - : buildAskAnswerKeys(prompt, selections) - for (let index = 0; index < groups.length; index += 1) { - if (generationRef.current !== generation) { - return false - } - const group = groups[index]! - const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text) - if (!(await sendTerminal(body, false))) { - return fail() - } - if (index < groups.length - 1) { - if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) { + // One budget for the whole answer instead of a fresh timeout per keystroke + // group, which let an N-group selector hold the card for N × the send timeout. + // It bounds transport time only: each deliberate pacing wait is credited back + // below, so a long multi-question answer still gets a full budget to write in. + let deadline = openMobileNativeChatSendBudget() + const sendTerminal = async (body: string, enter: boolean): Promise => { + const activeRoute = activeRouteRef.current + if ( + !activeRoute.enabled || + activeRoute.client !== client || + activeRoute.sessionId !== sessionId || + activeRoute.streamIdentity !== streamIdentity || + handleRef.current !== handle + ) { return false } - // Pacing is deliberate, not transport latency — don't charge it to the budget. - deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS + const outcome = await sendMobileNativeChatMessageWithOutcome({ + client, + terminal: handle, + text: body, + enter, + deadline, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (outcome === 'unknown') { + sawUnknownOutcome = true + } + if (outcome === 'accepted') { + sawAcceptedGroup = true + } + return outcome === 'accepted' + } + const wait = (ms: number): Promise => { + // Already superseded: don't hold the successor for a full pacing step + // waiting on a timer whose only job is to report the cancellation. + if (generationRef.current !== generation) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + const delay = { + timer: setTimeout(() => { + delaysRef.current.delete(delay) + resolve(generationRef.current === generation) + }, ms), + resolve + } + delaysRef.current.add(delay) + }) + } + const fail = (): false => { + if (generationRef.current === generation) { + // Why: keystrokes that may have landed (ack lost / path cutover) must + // not read as a definite failure — a blind resend could double-step + // the selector. An earlier group that WAS accepted is the same hazard + // in definite form: a multi-question answer whose shared budget ran out + // mid-sequence left the remote selector half-stepped, and telling the + // user nothing was sent invites a retry on top of the advanced state. + onSendError( + sawAcceptedGroup + ? 'Answer partly sent — check chat before retrying' + : sawUnknownOutcome + ? 'Answer unconfirmed — check chat before retrying' + : 'Answer not sent' + ) + } + return false + } + // Grok commits pasted labels; Claude and Codex need their selector-specific + // keystrokes paced so each step renders before the next lands. + if (!shouldStepNativeChatAskAnswer(agentRef.current)) { + // This shape pastes the label into the composer and commits it, so an + // orphaned image paste would be submitted along with the answer (#10228). + // The selector shapes below deliberately skip the heal: their keys are + // `enter: false` for an active overlay, and a single-select answer is a + // bare option digit that cannot submit the line at all, so clearing there + // would consume the marker still protecting the next real message. + // Desktop splits it identically — use-native-chat-interactive-send.ts + // routes only the pasted-label shape through the clearing sender. + if ( + !(await healMobileNativeChatStaleInput({ + client, + terminal: handle, + deviceToken: deviceTokenRef.current, + deadline + })) + ) { + if (generationRef.current === generation) { + onSendError('Answer not sent') + } + return false + } + if (generationRef.current !== generation) { + return false + } + // A chain a successor took over from must not report success either: an + // accepted answer retires the shared send-error banner, wiping the + // successor's fence. Test the turn slot, not the generation counter — + // Stop, ask-cancel and a dropped lease all bump the generation with no + // successor, and there a landed answer IS a success. + const sent = (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail() + return sent && writeTurnsRef.current.get(handle) === turn + } + const groups = + resolveNativeChatTranscriptAgent(agentRef.current) === 'codex' + ? buildCodexAskAnswerKeys(prompt, selections) + : buildAskAnswerKeys(prompt, selections) + for (let index = 0; index < groups.length; index += 1) { + if (generationRef.current !== generation) { + return false + } + const group = groups[index]! + const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text) + if (!(await sendTerminal(body, false))) { + return fail() + } + if (index < groups.length - 1) { + if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) { + return false + } + // Pacing is deliberate, not transport latency — don't charge it to the budget. + deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS + } + } + // Taken over on the last key: same as above, the successor owns the surface. + return groups.length > 0 && writeTurnsRef.current.get(handle) === turn + } finally { + // Any accepted key changed the live selector, so a queued replacement + // cannot safely apply its from-scratch key plan to that new position. + finishTurn(predecessorSafe && !sawUnknownOutcome && !sawAcceptedGroup) + if (writeTurnsRef.current.get(handle) === turn) { + writeTurnsRef.current.delete(handle) + } + // Last chain out releases; a superseded chain unwinding late must not + // free the lock out from under the successor sharing its hold. + const remaining = (holds.get(handle) ?? 1) - 1 + if (remaining <= 0) { + holds.delete(handle) + releaseMobileNativeChatTerminalWrite(handle) + } else { + holds.set(handle, remaining) } } - return groups.length > 0 }, [ agentRef, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index be2f9a3730e..13cb5d96c48 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input' +import { resetMobileNativeChatTerminalWritesForTests } from './mobile-native-chat-terminal-write-lock' import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' // Fully stub the picker so the real expo/react-native chain never loads under @@ -88,9 +89,10 @@ describe('useMobileNativeChatImageAttachments', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true pick.mockReset() - // Stale markers live at module scope now (they outlive the screen), so they - // also outlive a test. + // Stale markers and write locks live at module scope (they outlive the + // screen), so they also outlive a test. resetMobileNativeChatStaleInputForTests() + resetMobileNativeChatTerminalWritesForTests() }) afterEach(() => { act(() => renderer?.unmount()) diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index a692b11acf1..38f9e462e46 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -26,6 +26,10 @@ import { isMobileNativeChatInputStale, markMobileNativeChatInputStale } from './mobile-native-chat-stale-input' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite +} from './mobile-native-chat-terminal-write-lock' type CurrentRef = { readonly current: T } type ShowToast = (message: string, durationMs?: number) => void @@ -127,8 +131,6 @@ export function useMobileNativeChatImageAttachments({ // checked 'connected' at entry, so only a ref can see a mid-upload disconnect. const connStateRef = useRef(connState) connStateRef.current = connState - // Serialize clear/paste/submit ownership per terminal while allowing other tabs to send. - const sendInFlightTerminalsRef = useRef(new Set()) const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS @@ -219,15 +221,15 @@ export function useMobileNativeChatImageAttachments({ const sendNativeChat = useCallback( async (text: string): Promise => { + // Serialize clear/paste/submit ownership per terminal while allowing other + // tabs to send. Shared with the prompt-card writes (answer/permission), so + // a card tap can't interleave into a mid-flight paste sequence either. const operationTerminal = activeHandleRef.current - if (operationTerminal && sendInFlightTerminalsRef.current.has(operationTerminal)) { + if (operationTerminal && !acquireMobileNativeChatTerminalWrite(operationTerminal)) { onError?.() onSendError('Message not sent') return false } - if (operationTerminal) { - sendInFlightTerminalsRef.current.add(operationTerminal) - } // One budget for the whole user action. The paste loop, the settle, and the // text body that follows are a single send from the composer's point of view; // opening a budget per leg let `sending` run to twice the stated ceiling. @@ -347,7 +349,7 @@ export function useMobileNativeChatImageAttachments({ } } finally { if (operationTerminal) { - sendInFlightTerminalsRef.current.delete(operationTerminal) + releaseMobileNativeChatTerminalWrite(operationTerminal) } } }, diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index da36cdb925b..7c7c8e5814f 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -19,6 +19,11 @@ vi.mock('./mobile-native-chat-stale-input', () => ({ })) import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite, + resetMobileNativeChatTerminalWritesForTests +} from './mobile-native-chat-terminal-write-lock' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' type Send = ReturnType @@ -28,6 +33,7 @@ const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123' describe('useMobileNativeChatMessageSend', () => { let renderer: ReactTestRenderer | null = null let api: Send | null = null + let onSendError = vi.fn() const mount = ( readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null @@ -44,7 +50,7 @@ describe('useMobileNativeChatMessageSend', () => { restoreRejectedDraft: () => {}, acceptSend: () => {}, holdUnconfirmedSend: () => {}, - onSendError: () => {} + onSendError }) return null } @@ -70,6 +76,8 @@ describe('useMobileNativeChatMessageSend', () => { sendWithOutcome.mockResolvedValue('accepted') clearInputWrite.mockReset() clearInputWrite.mockResolvedValue(true) + onSendError = vi.fn() + resetMobileNativeChatTerminalWritesForTests() }) afterEach(() => { act(() => { @@ -173,4 +181,27 @@ describe('useMobileNativeChatMessageSend', () => { }) expect(sentArgs().resolvedLaunchDraft).toBeUndefined() }) + + it('rejects a question answer while another composed write holds the terminal', async () => { + mount(() => null) + // An image paste sequence is mid-flight into the same PTY. + expect(acquireMobileNativeChatTerminalWrite('term')).toBe(true) + + let result: boolean | undefined + await act(async () => { + result = await api!.answerQuestion('1') + }) + expect(result).toBe(false) + expect(sendWithOutcome).not.toHaveBeenCalled() + expect(onSendError).toHaveBeenCalledWith('Answer not sent') + + releaseMobileNativeChatTerminalWrite('term') + await act(async () => { + result = await api!.answerQuestion('1') + }) + expect(result).toBe(true) + // The answer released its own hold on the way out. + expect(acquireMobileNativeChatTerminalWrite('term')).toBe(true) + releaseMobileNativeChatTerminalWrite('term') + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 4143af338c7..7847f83c8e0 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -7,6 +7,10 @@ import { type MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite +} from './mobile-native-chat-terminal-write-lock' import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' @@ -210,11 +214,26 @@ export function useMobileNativeChatMessageSend(args: { [sendWithOutcome] ) - // A question answer is not composer text, so it never syncs the draft. + // A question answer is not composer text, so it never syncs the draft. It + // reaches this send directly (not through the image hook's locked path), so + // it takes the per-terminal write lock itself: an answer landing mid-flight + // in an image paste sequence would interleave bytes into the PTY. const answerQuestion = useCallback( - async (text: string): Promise => - (await sendMessage(text, undefined, false)) !== 'rejected', - [sendMessage] + async (text: string): Promise => { + const terminal = handleRef.current + if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) { + onSendError('Answer not sent') + return false + } + try { + return (await sendMessage(text, undefined, false)) !== 'rejected' + } finally { + if (terminal) { + releaseMobileNativeChatTerminalWrite(terminal) + } + } + }, + [handleRef, onSendError, sendMessage] ) return { send, sendWithOutcome, answerQuestion } From 1816d3eee29588f612643ec66635c8852bd1b1d3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:10:07 -0700 Subject: [PATCH 29/43] test(terminal): pin IME chord survival across mid-composition re-renders (STA-3291) (#12357) Busy panes render mid-composition (title updates drive renders), and the modified-Enter chord owner must survive those renders or held-modifier CJK input leaks newlines. Wires the hook TerminalPane-shaped (dep objects rebuilt per render) and interleaves re-renders through hold, composition, and auto-repeat; fails under pre-fix churn wiring (verified: redispatch not absorbed), passes with memoized actions. --- .../keyboard-handlers-ime.test.tsx | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx index 6d25e856ef5..536d3e9c9cb 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx @@ -3,6 +3,7 @@ import { cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyTransport } from './pty-transport' +import { useExpandCollapseActions } from './expand-collapse' import { useTerminalKeyboardShortcuts } from './keyboard-handlers' import { installTerminalImeCompositionRoute, @@ -365,4 +366,74 @@ describe('Windows IME keyboard ownership', () => { hook.unmount() harness.dispose() }) + + // Why: STA-3291 — busy panes render mid-composition (title updates), and the + // chord owner must survive those renders or held-modifier CJK input leaks + // newlines. Wiring mirrors TerminalPane: dep objects rebuilt every render. + it('absorbs the bare Enter redispatch when re-renders land mid-composition', () => { + const harness = createHarness() + const factoryFields = { + expandedPaneIdRef: { current: null }, + expandedStyleSnapshotRef: { current: new Map() }, + containerRef: { current: null }, + managerRef: { current: null }, + setExpandedPaneId: vi.fn(), + setTabPaneExpanded: vi.fn(), + pendingPaneSizeRefreshFrameIdsRef: { current: [] }, + persistLayoutSnapshot: vi.fn() + } + const hook = renderHook(() => { + const actions = useExpandCollapseActions({ ...factoryFields, tabId: 'tab-1' }) + useTerminalKeyboardShortcuts({ + ...harness.deps, + setExpandedPane: actions.setExpandedPane, + restoreExpandedLayout: actions.restoreExpandedLayout, + refreshPaneSizes: actions.refreshPaneSizes, + toggleExpandPane: actions.toggleExpandPane + }) + }) + + harness.terminalInput.dispatchEvent( + keyboardEvent('keydown', { + key: 'Shift', + code: 'ShiftLeft', + keyCode: 16, + timeStamp: 1, + shiftKey: true + }) + ) + hook.rerender() + harness.startComposition() + hook.rerender() + for (let repeat = 0; repeat < 5; repeat++) { + harness.terminalInput.dispatchEvent( + keyboardEvent('keydown', { + key: 'Process', + code: 'Enter', + keyCode: 229, + timeStamp: 10 + repeat, + isComposing: true, + shiftKey: true, + repeat: repeat > 0 + }) + ) + hook.rerender() + } + + const redispatch = keyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + timeStamp: 40 + }) + harness.terminalInput.dispatchEvent(redispatch) + + expect(redispatch.defaultPrevented).toBe(true) + const newlineSends = harness.sendInput.mock.calls.filter( + ([data]) => typeof data === 'string' && data.includes('\r') + ) + expect(newlineSends).toHaveLength(0) + hook.unmount() + harness.dispose() + }) }) From 88549619d204b29d93396217b76bbd43b5a90d08 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:37:04 -0700 Subject: [PATCH 30/43] feat(sidebar): inline SSH reconnect control on workspace cards (#12396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sidebar): inline SSH reconnect control on workspace cards Replaces the blocking SshDisconnectedDialog with an inline pill in the workspace card title row, and unifies the SSH connect vocabulary across the sidebar card, terminal overlay, host-header menu, and status-bar row. - new src/renderer/src/ssh/ modules: typechecker-total status predicates (recoverability), a shared in-flight connect registry, the promoted UI connect timeout, and the shared connect verb table - WorktreeCardSshHostControl: one 16px pill shape for every state, icon-only in compact/new card modes, passive glyph for connected/null/removed hosts - migrates the four duplicated status predicates to the shared module - deletes SshDisconnectedDialog (and its window-capture Enter handler) * fix(ssh): address review round 1 on the inline reconnect control - reconnect surfaces get a 180s UI connect fence instead of the composer's 20s: main allows 120s for an interactive passphrase before the 30s connect even starts, so the short cap toasted "timed out" and ran the stale-metadata resync against a host that was about to connect fine - carries the existing es/ja/ko/zh translations onto the shared connect verbs (they were en-only, regressing four locales) and drops the dead SshDisconnectedDialog key namespace - migrates the three remaining copies of the connecting predicate (SshStatusSegment, SshTargetRow, external-automation-source-availability) - SshTargetStatusRow and SshTargetRow now join the shared in-flight registry, so a connect started on one surface disables the others immediately - drops the per-card aria-live region that duplicated the button's own label * fix(ssh): address review round 2 on the inline reconnect control - guard sshTargetRemoved on isRuntimeOwnedSshTargetId: runtime-owned targets are filtered out of ssh:listTargets, so absence is not evidence of removal (every ephemeral-VM card otherwise read "SSH host removed") - drop the card-root SSH dim: an ancestor opacity composited the control's destructive tint and spinner down to an illegible alpha - aria-disabled instead of disabled while connecting, so the pill stays pointer-reachable for its tooltip and focus - stop Enter/Space propagation so WorktreeList's container key handler does not steal activation - prefer a live connection over a stale removal tombstone - reclaim title width in labeled mode (no leading icon, no min-width floor) - register composer connects in the shared in-flight registry * temp checkin of files * fix(ssh): hold connect lock for backend request, not UI timeout Replace manual begin/end pairs with trackSshConnect wrapper that holds the lock for the full backend request duration. Previously, the lock was released after the UI timeout fired, even though the backend was still dialing — a second click on any surface for this host would trigger a second connect and a duplicate credential prompt on passphrase-gated targets. The wrapper survives unmount, unlike a finally block in a component handler. * fix(ssh): scope connect locks to their acquisition, not target A tracked request settling after its lock was cleared (via reset or explicit end) must not unlock a newer connect on the same target. Lock IDs ensure only the owning acquisition releases, preventing stale settlements from clearing active locks. --- .../components/NewWorkspaceComposerCard.tsx | 45 +-- ...external-automation-source-availability.ts | 3 +- .../settings/ssh-target-action-state.ts | 9 +- .../sidebar/AutoRenameFailedDialog.tsx | 4 +- .../sidebar/HostSectionHeaderMenu.tsx | 11 +- .../sidebar/SshDisconnectedDialog.tsx | 205 ---------- .../src/components/sidebar/SshTargetRow.tsx | 35 +- .../WorktreeCard.affiliate-list-mode.test.tsx | 4 - .../WorktreeCard.compact-hover.test.tsx | 4 - ....compact-ports-hover-independence.test.tsx | 4 - ...orktreeCard.hosted-review-refresh.test.tsx | 4 - .../sidebar/WorktreeCard.lineage.test.tsx | 4 - .../WorktreeCard.merged-pr-display.test.tsx | 4 - .../WorktreeCard.pinned-repo-icon.test.tsx | 4 - .../sidebar/WorktreeCard.pr-display.test.tsx | 4 - .../WorktreeCard.quick-actions.test.tsx | 4 - ...WorktreeCard.ssh-reconnect-prompt.test.tsx | 107 +++-- .../src/components/sidebar/WorktreeCard.tsx | 73 ++-- .../WorktreeCardSshHostControl.test.tsx | 377 ++++++++++++++++++ .../sidebar/WorktreeCardSshHostControl.tsx | 283 +++++++++++++ ....lineage-agent-expansion-coupling.test.tsx | 4 - .../WorktreeList.lineage-child-card.test.ts | 20 - ...ktreeList.lineage-child-real-card.test.tsx | 4 - ...treeList.status-lane-lineage-drop.test.tsx | 4 - .../status-bar/SshStatusSegment.tsx | 7 +- .../status-bar/SshTargetStatusRow.tsx | 26 +- .../TerminalSshReconnectOverlay.test.tsx | 46 ++- .../TerminalSshReconnectOverlay.tsx | 62 ++- .../terminal-pane/pty-connection.ts | 2 +- src/renderer/src/hooks/useIpcEvents.ts | 3 +- src/renderer/src/i18n/locales/en.json | 34 +- src/renderer/src/i18n/locales/es.json | 21 +- src/renderer/src/i18n/locales/ja.json | 21 +- src/renderer/src/i18n/locales/ko.json | 21 +- src/renderer/src/i18n/locales/zh.json | 21 +- .../src/lib/new-workspace-ssh-gate.ts | 3 +- .../src/ssh/ssh-connect-in-flight.test.ts | 150 +++++++ src/renderer/src/ssh/ssh-connect-in-flight.ts | 90 +++++ .../src/ssh/ssh-connect-ui-timeout.test.ts | 64 +++ .../src/ssh/ssh-connect-ui-timeout.ts | 40 ++ src/renderer/src/ssh/ssh-connect-verb.test.ts | 35 ++ src/renderer/src/ssh/ssh-connect-verb.ts | 27 ++ .../ssh/ssh-connection-recoverability.test.ts | 72 ++++ .../src/ssh/ssh-connection-recoverability.ts | 36 ++ 44 files changed, 1475 insertions(+), 526 deletions(-) delete mode 100644 src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeCardSshHostControl.test.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx create mode 100644 src/renderer/src/ssh/ssh-connect-in-flight.test.ts create mode 100644 src/renderer/src/ssh/ssh-connect-in-flight.ts create mode 100644 src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts create mode 100644 src/renderer/src/ssh/ssh-connect-ui-timeout.ts create mode 100644 src/renderer/src/ssh/ssh-connect-verb.test.ts create mode 100644 src/renderer/src/ssh/ssh-connect-verb.ts create mode 100644 src/renderer/src/ssh/ssh-connection-recoverability.test.ts create mode 100644 src/renderer/src/ssh/ssh-connection-recoverability.ts diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 72ad520f2de..6bea00d9906 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -66,6 +66,8 @@ import type { TaskSourceContext } from '../../../shared/task-source-context' import type { RuntimeStatus } from '../../../shared/runtime-types' import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' import { translate } from '@/i18n/i18n' +import { withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout' +import { isSshConnectInFlight, trackSshConnect } from '@/ssh/ssh-connect-in-flight' type RepoOption = React.ComponentProps['repos'][number] type EphemeralVmRecipeOption = NonNullable[number] @@ -210,33 +212,6 @@ function getSshStatusLabel(status: SshConnectionStatus): string { return SSH_STATUS_LABELS[status] ?? status } -// Why: bound how long the run-target picker waits on a host connect so a stalled backend -// connect can't leave the row's disabled/spinner state stuck forever. The backend keeps going. -const RUN_TARGET_CONNECT_UI_TIMEOUT_MS = 20_000 - -async function withUiConnectTimeout(promise: Promise): Promise { - let timer: ReturnType | undefined - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => { - reject( - new Error( - translate( - 'auto.components.NewWorkspaceComposerCard.connectTimedOut', - 'Connection timed out. It may still be connecting in the background.' - ) - ) - ) - }, RUN_TARGET_CONNECT_UI_TIMEOUT_MS) - }) - try { - return await Promise.race([promise, timeout]) - } finally { - if (timer) { - clearTimeout(timer) - } - } -} - function SetupCommandPreview({ setupConfig }: { setupConfig: SetupConfig }): React.JSX.Element { // Why: just the script in a quiet monochrome card — the source label (orca.yaml / local) and // the run-setup toggle live in the section header above, so the card carries no chrome of its @@ -534,10 +509,18 @@ export default function NewWorkspaceComposerCard({ } try { if (action.kind === 'ssh') { - // Why: ssh.connect has no built-in timeout; a stalled connect would otherwise leave the - // row's spinner/disabled state stuck forever. Bound the UI wait — the backend keeps - // connecting and the picker updates from store SSH state if it later succeeds. - await withUiConnectTimeout(window.api.ssh.connect({ targetId: action.targetId })) + if (isSshConnectInFlight(action.targetId)) { + return + } + // Why: ssh.connect has no built-in timeout; a stalled connect would otherwise leave + // the row's spinner/disabled state stuck forever. Bound the UI wait — the backend + // keeps connecting and the picker updates from store SSH state if it later succeeds. + // The shared registry tracks that backend request (not this bounded wait), so the + // sidebar card control and terminal overlay for this host stay locked until it + // settles — a second dial on a passphrase-gated target means a second prompt. + await withUiConnectTimeout( + trackSshConnect(action.targetId, window.api.ssh.connect({ targetId: action.targetId })) + ) return } diff --git a/src/renderer/src/components/automations/external-automation-source-availability.ts b/src/renderer/src/components/automations/external-automation-source-availability.ts index 92f0749f892..acb51056e73 100644 --- a/src/renderer/src/components/automations/external-automation-source-availability.ts +++ b/src/renderer/src/components/automations/external-automation-source-availability.ts @@ -3,6 +3,7 @@ import type { ExternalAutomationProvider } from '../../../../shared/automations-types' import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability' export type ExternalAutomationSourceAvailability = { statusLabel: string @@ -75,7 +76,7 @@ export function getExternalAutomationSourceAvailability({ } export function isSshConnectionBusy(status: SshConnectionStatus | undefined): boolean { - return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting' + return isConnectingSshStatus(status) } export function getExternalAutomationActionDisabledMessage(args: { diff --git a/src/renderer/src/components/settings/ssh-target-action-state.ts b/src/renderer/src/components/settings/ssh-target-action-state.ts index 38db0cdbf28..c0f076cc282 100644 --- a/src/renderer/src/components/settings/ssh-target-action-state.ts +++ b/src/renderer/src/components/settings/ssh-target-action-state.ts @@ -1,15 +1,10 @@ +import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability' import type { SshConnectionStatus } from '../../../../shared/ssh-types' export type SshTargetBusyAction = 'terminate' | 'reset' | 'remove' -const SSH_TARGET_CONNECTING_STATUSES: ReadonlySet = new Set([ - 'connecting', - 'deploying-relay', - 'reconnecting' -]) - export function isSshTargetConnecting(status: SshConnectionStatus): boolean { - return SSH_TARGET_CONNECTING_STATUSES.has(status) + return isConnectingSshStatus(status) } export function shouldClearPendingSshReset({ diff --git a/src/renderer/src/components/sidebar/AutoRenameFailedDialog.tsx b/src/renderer/src/components/sidebar/AutoRenameFailedDialog.tsx index caf4a5ab5eb..6ea256e5aa3 100644 --- a/src/renderer/src/components/sidebar/AutoRenameFailedDialog.tsx +++ b/src/renderer/src/components/sidebar/AutoRenameFailedDialog.tsx @@ -26,7 +26,7 @@ type AutoRenameFailedDialogProps = { * CLI output when main still holds it (in-memory, lost on restart), falling * back to the persisted excerpt — either can run many lines, so it gets a * dedicated scrollable surface rather than a tooltip — see the sibling - * SshDisconnectedDialog pattern. + * AddRemoteHostDialog pattern. */ export function AutoRenameFailedDialog({ open, @@ -150,7 +150,7 @@ export function AutoRenameFailedDialog({
{/* Why: Close backs the user out, so it stays quiet (outline, not a - solid CTA) — matching the sibling SshDisconnectedDialog. */} + solid CTA) — matching the other sidebar dialogs. */} diff --git a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx index 5c944fa3a1e..3aead74fd81 100644 --- a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx +++ b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx @@ -24,6 +24,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' +import { sshConnectVerb } from '@/ssh/ssh-connect-verb' import { parseExecutionHostId } from '../../../../shared/execution-host' import { describeRuntimeCompatBlock } from '../../../../shared/protocol-compat' import { @@ -74,18 +75,18 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS const [renameOpen, setRenameOpen] = useState(false) const [removeOpen, setRemoveOpen] = useState(false) const mountedRef = useMountedRef() - const sshConnected = useAppStore((s) => { + const sshStatus = useAppStore((s) => { const parsed = parseExecutionHostId(row.hostId) if (parsed?.kind !== 'ssh') { - return false + return null } - return s.sshConnectionStates.get(parsed.targetId)?.status === 'connected' + return s.sshConnectionStates.get(parsed.targetId)?.status ?? null }) const model = buildHostHeaderMenuModel({ kind: row.kind, health: row.health, - sshConnected, + sshConnected: sshStatus === 'connected', compatibility: row.compatibility }) const removalTarget = resolveHostRemoval(row.hostId) @@ -242,7 +243,7 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS {model.actions.includes('ssh-reconnect') && ( void runSshAction('connect')}> - {translate('auto.components.sidebar.HostSectionHeaderMenu.63f36455cc', 'Reconnect')} + {sshConnectVerb(sshStatus)} )} {model.actions.includes('ssh-disconnect') && ( diff --git a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx deleted file mode 100644 index fd4a460e29a..00000000000 --- a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { toast } from 'sonner' -import { Loader2, Server, ServerOff } from 'lucide-react' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { useMountedRef } from '@/hooks/useMountedRef' -import { statusColor } from '@/components/settings/SshTargetCard' -import type { SshConnectionStatus } from '../../../../shared/ssh-types' -import { translate } from '@/i18n/i18n' - -type SshDisconnectedDialogProps = { - open: boolean - onOpenChange: (open: boolean) => void - targetId: string - targetLabel: string - status: SshConnectionStatus -} - -const STATUS_MESSAGES: Partial> = { - get disconnected() { - return translate( - 'auto.components.sidebar.SshDisconnectedDialog.disconnected', - 'This SSH host is not connected.' - ) - }, - get reconnecting() { - return translate( - 'auto.components.sidebar.SshDisconnectedDialog.reconnecting', - 'Reconnecting to the remote host...' - ) - }, - get 'reconnection-failed'() { - return translate( - 'auto.components.sidebar.SshDisconnectedDialog.reconnectionFailed', - 'Reconnection to the remote host failed.' - ) - }, - get error() { - return translate( - 'auto.components.sidebar.SshDisconnectedDialog.376bed88e5', - 'The connection to the remote host encountered an error.' - ) - }, - get 'auth-failed'() { - return translate( - 'auto.components.sidebar.SshDisconnectedDialog.authFailed', - 'Authentication to the remote host failed.' - ) - } -} - -function isReconnectable(status: SshConnectionStatus): boolean { - return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status) -} - -export function SshDisconnectedDialog({ - open, - onOpenChange, - targetId, - targetLabel, - status -}: SshDisconnectedDialogProps): React.JSX.Element { - const [connecting, setConnecting] = useState(false) - const mountedRef = useMountedRef() - - const handleReconnect = useCallback(async () => { - setConnecting(true) - try { - await window.api.ssh.connect({ targetId }) - if (mountedRef.current) { - onOpenChange(false) - } - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate( - 'auto.components.sidebar.SshDisconnectedDialog.656368f3a2', - 'Reconnection failed' - ) - ) - } finally { - if (mountedRef.current) { - setConnecting(false) - } - } - }, [mountedRef, targetId, onOpenChange]) - - const isConnecting = - connecting || - status === 'connecting' || - status === 'deploying-relay' || - status === 'reconnecting' - const reconnectingMessage = - STATUS_MESSAGES.reconnecting ?? - translate( - 'auto.components.sidebar.SshDisconnectedDialog.reconnecting', - 'Reconnecting to the remote host...' - ) - const disconnectedMessage = - STATUS_MESSAGES.disconnected ?? - translate( - 'auto.components.sidebar.SshDisconnectedDialog.disconnected', - 'This SSH host is not connected.' - ) - const message = isConnecting - ? reconnectingMessage - : (STATUS_MESSAGES[status] ?? disconnectedMessage) - const showReconnect = isReconnectable(status) - - useEffect(() => { - // Window-level Enter handler. The dialog typically appears while focus - // is inside an embedded terminal (xterm) or editor (monaco) that - // aggressively reclaims focus, so dialog-scoped key handlers never - // fire. Listening on window (capture phase) catches Enter regardless - // of where focus actually lives while the dialog is open. - if (!open || !showReconnect || isConnecting) { - return undefined - } - const onKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Enter' || event.defaultPrevented) { - return - } - if (event.isComposing) { - return - } - event.preventDefault() - event.stopPropagation() - void handleReconnect() - } - window.addEventListener('keydown', onKeyDown, true) - return () => window.removeEventListener('keydown', onKeyDown, true) - }, [open, showReconnect, isConnecting, handleReconnect]) - - return ( - - - - - {isConnecting ? ( - - ) : ( - - )} - {isConnecting - ? translate( - 'auto.components.sidebar.SshDisconnectedDialog.cb5938ae79', - 'Reconnecting...' - ) - : translate( - 'auto.components.sidebar.SshDisconnectedDialog.11552bf786', - 'SSH Disconnected' - )} - - {message} - - -
- -
- {targetLabel} -
- {isConnecting ? ( - - ) : ( - - )} -
- - - - {showReconnect && ( - - )} - -
-
- ) -} diff --git a/src/renderer/src/components/sidebar/SshTargetRow.tsx b/src/renderer/src/components/sidebar/SshTargetRow.tsx index 3ce6db39e76..98323550c97 100644 --- a/src/renderer/src/components/sidebar/SshTargetRow.tsx +++ b/src/renderer/src/components/sidebar/SshTargetRow.tsx @@ -4,10 +4,17 @@ * Why extracted: keeps AddRepoSteps.tsx under the 400-line oxlint limit * while isolating the inline-connect interaction logic. */ -import React, { useCallback, useRef, useState } from 'react' +import React from 'react' import { Loader2 } from 'lucide-react' import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types' import { translate } from '@/i18n/i18n' +import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability' +import { + beginSshConnect, + endSshConnect, + isSshConnectInFlight, + useSshConnectInFlight +} from '@/ssh/ssh-connect-in-flight' type Props = { target: SshTarget & { state?: SshConnectionState } @@ -22,15 +29,12 @@ export function SshTargetRow({ onSelect, onConnect }: Props): React.JSX.Element { - const [connecting, setConnecting] = useState(false) - const mountedRef = useRef(true) + // Why: the shared registry replaces local state — every SSH surface dials one connection + // per target, and it survives this row unmounting mid-connect. + const connecting = useSshConnectInFlight(target.id) const status = target.state?.status ?? 'disconnected' const isConnected = status === 'connected' - const isBusy = - connecting || - status === 'connecting' || - status === 'deploying-relay' || - status === 'reconnecting' + const isBusy = connecting || isConnectingSshStatus(status) const dotColor = isConnected ? 'bg-green-500' : isBusy @@ -47,26 +51,17 @@ export function SshTargetRow({ // Why: prevent the row's onClick from also firing and treating the click // as a selection when the target is disconnected. e.stopPropagation() - if (isBusy) { + if (isBusy || isSshConnectInFlight(target.id)) { return } - setConnecting(true) + beginSshConnect(target.id) void onConnect(target.id).finally(() => { - if (mountedRef.current) { - setConnecting(false) - } + endSshConnect(target.id) }) } - const handleRowRootRef = useCallback((node: HTMLDivElement | null): void => { - // Why: SSH connects can resolve after this row is removed; the row ref - // gives the async completion the same guard without a mount-only Effect. - mountedRef.current = node !== null - }, []) - return (
({ default: () =>
})) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => (
{children}
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index 9aa2f31b577..707ce16ec61 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -111,10 +111,6 @@ vi.mock('./WorktreeCardAgents', () => ({ ) })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx index 3887c52c79d..5aee22c8b16 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx @@ -124,10 +124,6 @@ vi.mock('./WorktreeCardAgents', () => ({ default: () =>
})) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx index 3cc0f9dd1a0..98c2a1b3712 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx @@ -63,10 +63,6 @@ vi.mock('./WorktreeCardAgents', () => ({ default: () => null })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx index 71fa30bed26..cbbf5a35b86 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx @@ -53,10 +53,6 @@ vi.mock('./WorktreeCardAgents', () => ({ default: () => null })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx index 9df826e66d0..02927202e29 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx @@ -68,10 +68,6 @@ vi.mock('./WorktreeCardAgents', () => ({ default: () => null })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx index 88d5d65d818..9da7873b388 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx @@ -59,10 +59,6 @@ vi.mock('./WorktreeCardAgents', () => ({ default: () => null })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index 7df0cd25c94..fef27ea71cc 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -72,10 +72,6 @@ vi.mock('./WorktreeCardAgents', () => ({ default: () => null })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index d21085ab3cb..7456cce2faa 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -72,10 +72,6 @@ vi.mock('./WorktreeCardAgents', () => ({ default: () => null })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx index 1b2b6c9f664..19ca3a4e441 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx @@ -13,6 +13,7 @@ const updateWorktreeMeta = vi.fn() let WorktreeCard: typeof WorktreeCardComponent let sshConnectionStates = new Map() let sshTargetLabels = new Map() +let removedSshTargetLabels = new Map() let runtimeStatusByEnvironmentId = new Map() let runtimeEnvironments: { id: string; name: string }[] = [] let sshStateByEnvironment = new Map() @@ -35,7 +36,7 @@ vi.mock('@/store', () => ({ remoteBranchConflictByWorktreeId: {}, runtimeEnvironments, runtimeStatusByEnvironmentId, - removedSshTargetLabels: new Map(), + removedSshTargetLabels, settings: null, sshConnectionStates, sshStateByEnvironment, @@ -70,24 +71,6 @@ vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'idle' })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: ({ - open, - status, - targetLabel - }: { - open: boolean - status: string - targetLabel: string - }) => ( -
- ) -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', @@ -137,6 +120,7 @@ describe('WorktreeCard SSH reconnect prompt', () => { vi.clearAllMocks() sshConnectionStates = new Map() sshTargetLabels = new Map() + removedSshTargetLabels = new Map() runtimeStatusByEnvironmentId = new Map() runtimeEnvironments = [] sshStateByEnvironment = new Map() @@ -144,7 +128,9 @@ describe('WorktreeCard SSH reconnect prompt', () => { worktreeCardProperties = ['status'] }) - it('does not auto-open the blocking reconnect dialog for a restored active disconnected SSH worktree', () => { + // Supersedes the old "does not auto-open the blocking reconnect dialog" assertion: + // the dialog is gone, so no card state can open one. + it('offers an inline reconnect control and never a blocking dialog for a disconnected SSH worktree', () => { sshConnectionStates.set('ssh-target-1', { status: 'disconnected' }) sshTargetLabels.set('ssh-target-1', 'Remote target') @@ -152,11 +138,82 @@ describe('WorktreeCard SSH reconnect prompt', () => { ) - // The dialog is blocking, so being the active/restored card must not steal - // focus app-wide; it only opens on deliberate click (see handleClick). - expect(markup).toContain('data-ssh-disconnected-dialog="closed"') - // The disconnected state is still discoverable via the non-blocking card chip. - expect(markup).toContain('SSH disconnected') + expect(markup).toContain('Connect to SSH host Remote target') + expect(markup).toContain('Connect') + expect(markup).not.toContain('ssh-disconnected-dialog') + // Why: a card-root opacity composites the whole subtree, so it would dim the control's + // destructive tint and spinner in the exact state the control exists for. + expect(markup).not.toContain('opacity-60') + }) + + it('names the failure state in the control verb rather than a generic Connect', () => { + sshConnectionStates.set('ssh-target-1', { status: 'auth-failed' }) + sshTargetLabels.set('ssh-target-1', 'Remote target') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Reconnect SSH host Remote target') + expect(markup).toContain('authentication failed') + }) + + it('renders the passive host glyph, not a control, when the SSH host is connected', () => { + sshConnectionStates.set('ssh-target-1', { status: 'connected' }) + sshTargetLabels.set('ssh-target-1', 'Remote target') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Project on SSH host Remote target') + expect(markup).not.toContain('Connect to SSH host') + }) + + // Why: the control keys off repo.connectionId, which is orthogonal to workspace kind — + // a folder workspace on a disconnected SSH host must get the same affordance. + it('offers the control for a folder workspace on a disconnected SSH host', () => { + sshConnectionStates.set('ssh-target-1', { status: 'error' }) + sshTargetLabels.set('ssh-target-1', 'Remote target') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Retry SSH connection to Remote target') + }) + + // Why: ssh:listTargets filters runtime-owned targets out, so "absent from the target list" + // is not evidence of removal — deriving targetRemoved from it would put every ephemeral-VM + // workspace card into the dead "host removed" state. + it('never reports a runtime-owned SSH target as removed', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('was removed') + expect(markup).toContain('Project on SSH host') + }) + + // Why: a removed host can never connect, so the card must not offer a Connect that only fails. + it('reports a removed SSH host once the target list no longer carries it', () => { + sshConnectionStates.set('ssh-target-1', { status: 'error' }) + removedSshTargetLabels.set('ssh-target-1', 'Remote target (removed)') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('was removed') + expect(markup).not.toContain('Retry SSH connection') }) it('marks a runtime-host worktree disconnected when its environment has no status', () => { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 9cc437fc513..2d3e2852cea 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -21,13 +21,13 @@ import { } from 'lucide-react' import CacheTimer, { usePromptCacheCountdownStartedAt } from './CacheTimer' import WorktreeContextMenu from './WorktreeContextMenu' -import { SshDisconnectedDialog } from './SshDisconnectedDialog' import { AutoRenameFailedDialog } from './AutoRenameFailedDialog' import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt' import WorktreeCardAgents from './WorktreeCardAgents' import { useWorktreeAgentRows } from './useWorktreeAgentRows' import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' import { cn } from '@/lib/utils' +import { WorktreeCardSshHostControl } from './WorktreeCardSshHostControl' import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' import { isFolderRepo } from '../../../../shared/repo-kind' import type { HostedReviewInfo } from '../../../../shared/hosted-review' @@ -94,7 +94,8 @@ import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constant import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { selectRuntimeAwareSshStatus, - selectRuntimeAwareSshTargetLabel + selectRuntimeAwareSshTargetLabel, + selectRuntimeAwareSshTargetRemoved } from '@/store/slices/runtime-environment-ssh' import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state' @@ -362,9 +363,13 @@ const WorktreeCard = React.memo(function WorktreeCard({ } }, [sshOwnerEnvironmentId]) const isSshDisconnected = sshStatus != null && sshStatus !== 'connected' - // Why: terminal views have their own reconnect overlay; reserve the blocking dialog for non-terminal views (default to terminal when ambiguous). - const activeViewIsTerminal = useAppStore( - (s) => (s.activeTabTypeByWorktree?.[worktree.id] ?? 'terminal') === 'terminal' + // Why: only reported on positive evidence, so a removed host never offers a Connect that can + // only fail. Runtime-owned targets are excluded for the same reason sshStatus excludes them — + // ssh:listTargets filters them out, so "absent from the target list" is not evidence of removal. + const sshTargetRemoved = useAppStore((s) => + repo?.connectionId && !isRuntimeOwnedSshTargetId(repo.connectionId) + ? selectRuntimeAwareSshTargetRemoved(s, sshOwnerEnvironmentId, repo.connectionId) + : false ) const parsedRepoHost = parseExecutionHostId(repo?.executionHostId) @@ -390,8 +395,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ } return !s.runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId)?.status }) - // Why: the reconnect dialog blocks, so it never auto-shows for the active card (would steal app-wide focus); opens only on deliberate focus (handleClick). - const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false) const [titleRenaming, setTitleRenaming] = useState(false) const [showRenameErrorDialog, setShowRenameErrorDialog] = useState(false) // Why: read the target label from its owning host's store instead of exposing HUB-private SSH metadata as client-local state. @@ -877,10 +880,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ worktree.id, worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : undefined) ) - // Why: a deliberate card click warrants the blocking reconnect prompt; skip it when a terminal already shows the overlay. - if (isSshDisconnected && !activeViewIsTerminal) { - setShowDisconnectedDialog(true) - } onActivate?.() }, [ @@ -893,7 +892,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ isDeleting, activationRowKey, isSshDisconnected, - activeViewIsTerminal, onActivate, onImmediateActivate, onSelectionGesture @@ -1428,28 +1426,15 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} {repo?.connectionId && ( - - - - {isSshDisconnected ? ( - - ) : ( - - )} - - - - {isSshDisconnected - ? translate( - 'auto.components.sidebar.WorktreeCard.021538e1d1', - 'SSH disconnected' - ) - : translate( - 'auto.components.sidebar.WorktreeCard.ca74db7550', - 'Project on SSH host' - )} - - + )} {!repo?.connectionId && parsedRepoHost?.kind === 'runtime' && ( @@ -1457,7 +1442,10 @@ const WorktreeCard = React.memo(function WorktreeCard({ {isRuntimeDisconnected ? ( - + // Passive by design: runtime ("Orca server") hosts have no + // renderer-reachable connect API, unlike the SSH glyph above which is + // now a control. Don't "fix" the inconsistency by wiring one up. + ) : ( )} @@ -1889,7 +1877,10 @@ const WorktreeCard = React.memo(function WorktreeCard({ ], titleRenaming && '!border-transparent !bg-transparent !shadow-none !ring-0', isDeleting && 'opacity-50 grayscale cursor-not-allowed', - (isSshDisconnected || isRuntimeDisconnected) && !isDeleting && 'opacity-60' + // Why: no SSH dim — the inline host control now states the disconnected state + // explicitly, and a subtree opacity would composite its destructive tint and spinner + // down to an illegible alpha (a descendant cannot escape an ancestor's opacity). + isRuntimeDisconnected && !isDeleting && 'opacity-60' )} data-worktree-card-surface="true" data-worktree-card-active={isActiveSurface ? activeSurfaceVariant : undefined} @@ -1940,16 +1931,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} - {repo?.connectionId && ( - - )} - {typeof worktree.firstAgentMessageRenameError === 'string' && worktree.firstAgentMessageRenameError.length > 0 && ( ({ error: vi.fn() })) + +const environmentSshMocks = vi.hoisted(() => ({ + connectRuntimeEnvironmentSshTarget: vi.fn(), + resyncRuntimeEnvironmentSshTargets: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: toastMocks.error } })) + +vi.mock('@/runtime/runtime-environment-ssh-state', () => environmentSshMocks) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace('{{value0}}', values?.value0 ?? '') +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +function installSshApi( + connect: ReturnType, + overrides: Record> = {} +): void { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ssh: { + connect, + listTargets: vi.fn().mockResolvedValue([]), + listRemovedTargetLabels: vi.fn().mockResolvedValue({}), + ...overrides + } + } + }) +} + +function renderControl( + props: Partial> = {} +) { + return render( + {}} + {...props} + /> + ) +} + +describe('WorktreeCardSshHostControl', () => { + beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + resetSshConnectInFlightForTests() + toastMocks.error.mockReset() + environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockReset() + environmentSshMocks.resyncRuntimeEnvironmentSshTargets.mockReset() + installSshApi(vi.fn().mockResolvedValue(undefined)) + }) + + afterEach(() => { + cleanup() + }) + + it('offers a Connect control naming the host for a disconnected target', () => { + renderControl() + + expect(screen.getByRole('button', { name: 'Connect to SSH host devbox' })).toBeEnabled() + }) + + // Why: the verb must match the terminal overlay, host-header menu, and status-bar row. + it.each([ + ['auth-failed', 'Reconnect'], + ['error', 'Retry'], + ['reconnection-failed', 'Retry'], + ['disconnected', 'Connect'] + ] as const)('labels the %s state %s', (status, verb) => { + renderControl({ status }) + + expect(screen.getByRole('button')).toHaveTextContent(verb) + }) + + it('distinguishes an auth failure from a generic connection failure in the tooltip', () => { + const { container } = renderControl({ status: 'auth-failed' }) + expect(container.querySelector('[data-tooltip]')).toHaveTextContent( + 'devbox · authentication failed' + ) + + cleanup() + const retry = renderControl({ status: 'error' }) + expect(retry.container.querySelector('[data-tooltip]')).toHaveTextContent( + 'devbox · connection failed' + ) + }) + + it.each(['error', 'reconnection-failed', 'auth-failed'] as const)( + 'tints the %s state with the destructive token, not the quiet one', + (status) => { + renderControl({ status }) + + expect(screen.getByRole('button')).toHaveClass('text-destructive') + } + ) + + it.each(['connecting', 'deploying-relay', 'reconnecting'] as const)( + 'shows a disabled busy control while the host is %s', + (status) => { + renderControl({ status }) + + const button = screen.getByRole('button', { name: 'Connecting to SSH host devbox' }) + // Why: aria-disabled, not disabled — a real `disabled` would let a second click fall + // through to the card and would kill the tooltip for the whole in-flight window. + expect(button).toHaveAttribute('aria-disabled', 'true') + expect(button).toHaveAttribute('aria-busy', 'true') + expect(button).not.toBeDisabled() + } + ) + + it('renders the passive host glyph, not a control, when connected', () => { + renderControl({ status: 'connected' }) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText('Project on SSH host devbox')).toBeInTheDocument() + }) + + // Why: runtime-owned targets deliberately have no renderer-visible status; the card has + // always shown the plain host glyph there rather than a false disconnected state. + it('renders the passive host glyph for a null status', () => { + renderControl({ status: null }) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText('Project on SSH host devbox')).toBeInTheDocument() + }) + + // Why: this is the exact bug targetRemoved exists to prevent — a Connect that can only fail. + it('never offers Connect for a removed host, even in a failed state', () => { + renderControl({ status: 'error', targetRemoved: true }) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText('SSH host devbox was removed')).toBeInTheDocument() + }) + + it('keeps the label available to assistive tech but not on screen in icon-only mode', () => { + renderControl({ iconOnly: true }) + + // Why: aria-label is the accessible name, so a second sr-only copy would be dead markup. + const button = screen.getByRole('button', { name: 'Connect to SSH host devbox' }) + expect(button).not.toHaveTextContent('Connect') + expect(button).toHaveClass('w-4') + }) + + it('connects and mirrors the returned state so deferred PTY reattach can resume', async () => { + const connectedState: SshConnectionState = { + targetId: 'ssh-target-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + remotePlatform: 'linux' + } + const connect = vi.fn().mockResolvedValue(connectedState) + installSshApi(connect) + const user = userEvent.setup() + renderControl() + + await user.click(screen.getByRole('button')) + + expect(connect).toHaveBeenCalledWith({ targetId: 'ssh-target-1' }) + await waitFor(() => + expect(useAppStore.getState().sshConnectionStates.get('ssh-target-1')).toEqual(connectedState) + ) + }) + + // Why: reconnecting a host and navigating to its workspace are separate intents; the + // control sits inside the card's own click target. + it('does not activate the surrounding card when clicked', async () => { + const onCardClick = vi.fn() + const user = userEvent.setup() + render( +
+ {}} + /> +
+ ) + + await user.click(screen.getByRole('button')) + + expect(onCardClick).not.toHaveBeenCalled() + }) + + it('reports connect failures and resyncs target metadata so a ghost host converges', async () => { + const connect = vi.fn().mockRejectedValue(new Error('SSH target "ssh-target-1" not found')) + const listTargets = vi + .fn() + .mockResolvedValue([ + { id: 'ssh-live', label: 'devbox', host: 'devbox', port: 22, username: 'me' } + ]) + const listRemovedTargetLabels = vi + .fn() + .mockResolvedValue({ 'ssh-target-1': 'devbox (removed)' }) + installSshApi(connect, { listTargets, listRemovedTargetLabels }) + const user = userEvent.setup() + renderControl() + + await user.click(screen.getByRole('button')) + + await waitFor(() => + expect(toastMocks.error).toHaveBeenCalledWith('SSH target "ssh-target-1" not found') + ) + await waitFor(() => { + expect(useAppStore.getState().sshTargetLabels.get('ssh-live')).toBe('devbox') + expect(useAppStore.getState().removedSshTargetLabels.get('ssh-target-1')).toBe( + 'devbox (removed)' + ) + }) + }) + + it('routes connect to the owning Orca server for a remote-owned target', async () => { + const connect = vi.fn().mockResolvedValue(undefined) + installSshApi(connect) + environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockResolvedValue(null) + const user = userEvent.setup() + renderControl({ sshOwnerEnvironmentId: 'env-1' }) + + await user.click(screen.getByRole('button')) + + await waitFor(() => + expect(environmentSshMocks.connectRuntimeEnvironmentSshTarget).toHaveBeenCalledWith( + 'env-1', + 'ssh-target-1' + ) + ) + // The local ssh API must never see a remote host's target. + expect(connect).not.toHaveBeenCalled() + }) + + // Why: N cards can share one host, and a passphrase-gated target would prompt N times. + it('suppresses a sibling card dialing a host that is already connecting', async () => { + const connect = vi.fn().mockReturnValue(new Promise(() => {})) + installSshApi(connect) + const user = userEvent.setup() + render( + <> + {}} + /> + {}} + /> + + ) + + const [first, second] = screen.getAllByRole('button') + await user.click(first) + + await waitFor(() => expect(second).toHaveAttribute('aria-disabled', 'true')) + expect(connect).toHaveBeenCalledTimes(1) + }) + + // Why: aria-disabled leaves the button clickable, so the handler owns the guard. + it('ignores a second click while its own connect is in flight', async () => { + const connect = vi.fn().mockReturnValue(new Promise(() => {})) + installSshApi(connect) + const user = userEvent.setup() + renderControl() + + const button = screen.getByRole('button') + await user.click(button) + await user.click(button) + + expect(connect).toHaveBeenCalledTimes(1) + }) + + // Why: the sidebar scroll root treats a bubbled Enter as "focus the terminal", which would + // cancel the button's activation and move focus off the card. + it('does not let Enter or Space bubble to the sidebar key handler', async () => { + const onContainerKeyDown = vi.fn() + const user = userEvent.setup() + render( +
+ {}} + /> +
+ ) + + screen.getByRole('button').focus() + await user.keyboard('{Enter}') + await user.keyboard(' ') + + expect(onContainerKeyDown).not.toHaveBeenCalled() + }) + + it('shows the connected glyph even if a stale removal tombstone is present', () => { + renderControl({ status: 'connected', targetRemoved: true }) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText('Project on SSH host devbox')).toBeInTheDocument() + }) + + // Why: the title row hosts sibling h-4 pills; a taller state would shift every card on the host. + it('carries one height class across every actionable state', () => { + const heights = new Set() + for (const status of [ + 'disconnected', + 'error', + 'auth-failed', + 'reconnection-failed', + 'connecting', + 'deploying-relay', + 'reconnecting' + ] as const) { + cleanup() + renderControl({ status }) + const classes = screen.getByRole('button').className + heights.add(classes.split(' ').find((token) => token.startsWith('h-')) ?? 'none') + } + + expect([...heights]).toEqual(['h-4']) + }) + + // Why: the passive branch swaps the 16px button for a bare glyph span; a larger icon there + // would make the row taller in exactly the states with nothing to act on. + it.each([ + ['connected', false], + [null, false], + ['error', true] + ] as const)('sizes the passive glyph for %s at size-3', (status, targetRemoved) => { + const { container } = renderControl({ status, targetRemoved }) + + expect(container.querySelector('svg')).toHaveClass('size-3') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx b/src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx new file mode 100644 index 00000000000..890baba32e7 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx @@ -0,0 +1,283 @@ +import { useCallback } from 'react' +import { Loader2, Server, ServerOff } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + connectRuntimeEnvironmentSshTarget, + resyncRuntimeEnvironmentSshTargets +} from '@/runtime/runtime-environment-ssh-state' +import { canConnectSshStatus, isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability' +import { sshConnectingLabel, sshConnectVerb } from '@/ssh/ssh-connect-verb' +import { SSH_RECONNECT_UI_TIMEOUT_MS, withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout' +import { + isSshConnectInFlight, + trackSshConnect, + useSshConnectInFlight +} from '@/ssh/ssh-connect-in-flight' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' + +type WorktreeCardSshHostControlProps = { + targetId: string + /** Card passes `sshTargetLabel || repo.displayName` — the selector can return a bare target id. */ + targetLabel: string + /** Null for runtime-owned targets: renders the passive connected glyph, as before. */ + status: SshConnectionStatus | null + targetRemoved: boolean + /** Non-null when the SSH target belongs to a remote Orca server; routes connect to that runtime. */ + sshOwnerEnvironmentId: string | null + /** True when the row cannot afford a visible label: icon-only with an sr-only label. */ + iconOnly: boolean + onPointerDown: React.PointerEventHandler +} + +// One shape for every state, from the sibling rename-failed control in the same title row +// (WorktreeCard.tsx). States differ only by color token, so the pill never changes height. +const PILL_BASE = + 'h-4 shrink-0 gap-0.5 rounded !px-0.5 text-[10px] font-medium leading-none has-[>svg]:!px-0.5' +const PILL_QUIET = + 'text-muted-foreground border border-worktree-sidebar-border bg-worktree-sidebar shadow-none hover:bg-worktree-sidebar-accent hover:text-foreground focus-visible:border-worktree-sidebar-border focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring' +const PILL_FAILED = + 'text-destructive border border-destructive/40 bg-destructive/10 hover:bg-destructive/15 hover:text-destructive focus-visible:border-destructive/40 focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring' + +function PassiveGlyph({ + icon, + tooltip, + accessibleName, + targetLabel +}: { + icon: React.ReactNode + tooltip: string + accessibleName: string + targetLabel: string +}): React.JSX.Element { + return ( + + + + {icon} + {accessibleName} + + + + {tooltip} + + + ) +} + +export function WorktreeCardSshHostControl({ + targetId, + targetLabel, + status, + targetRemoved, + sshOwnerEnvironmentId, + iconOnly, + onPointerDown +}: WorktreeCardSshHostControlProps): React.JSX.Element | null { + const setSshConnectionState = useAppStore((store) => store.setSshConnectionState) + // Why: shared registry, not local state — the terminal overlay and every other card on + // this host dial the same connection, and the store status lags a click by one IPC hop. + const inFlight = useSshConnectInFlight(targetId) + + const handleConnect = useCallback(async () => { + if (isSshConnectInFlight(targetId) || isConnectingSshStatus(status)) { + return + } + try { + if (sshOwnerEnvironmentId) { + // Bucket state is written inside the helper, mirroring the local path. + await trackSshConnect( + targetId, + connectRuntimeEnvironmentSshTarget(sshOwnerEnvironmentId, targetId) + ) + } else { + // Why: track the connect request, not this bounded wait — the backend is still + // dialing after the UI timeout fires, so releasing here would let the next click + // raise a second credential prompt. + const connectState = await withUiConnectTimeout( + trackSshConnect(targetId, window.api.ssh.connect({ targetId })), + SSH_RECONNECT_UI_TIMEOUT_MS + ) + if (connectState) { + // Why: ssh.connect can resolve before the global state-change IPC lands; + // the waiting deferred PTY reattach path keys off this renderer store. + setSshConnectionState(targetId, connectState) + } + } + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.connectFailed', + 'SSH connection failed' + ) + ) + // Why: a failed connect usually means the renderer's target metadata is stale + // (target removed, or re-added under a new id). Resync so the control converges to + // the removed state instead of offering the same failing Connect forever (STA-1468). + // Apply the target list first — a removed-labels failure must not discard it. + if (sshOwnerEnvironmentId) { + void resyncRuntimeEnvironmentSshTargets(sshOwnerEnvironmentId).catch(() => {}) + } else { + void (async () => { + const targets = await window.api.ssh.listTargets() + useAppStore.getState().setSshTargetsMetadata(targets) + const removedLabels = await window.api.ssh.listRemovedTargetLabels() + useAppStore.getState().setRemovedSshTargetLabels(removedLabels) + })().catch(() => {}) + } + } + }, [setSshConnectionState, sshOwnerEnvironmentId, status, targetId]) + + // A live connection outranks a stale removal tombstone. A null status is a runtime-owned + // target: no renderer-reachable connect, and the card has always shown the plain host glyph + // there rather than a false disconnected state. + if (status === null || status === 'connected') { + return ( + } + tooltip={translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.connectedTooltip', + 'Project on SSH host' + )} + accessibleName={translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.connectedName', + 'Project on SSH host {{value0}}', + { value0: targetLabel } + )} + /> + ) + } + + // Why: a removed host can never connect, so it is checked before any failure status — + // offering Connect there is the exact bug targetRemoved exists to prevent. It also drops the + // destructive tint: a removed host is a settled fact, not an error to act on. + if (targetRemoved) { + return ( + } + tooltip={translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.removedTooltip', + 'SSH host removed — reconnect unavailable' + )} + accessibleName={translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.removedName', + 'SSH host {{value0}} was removed', + { value0: targetLabel } + )} + /> + ) + } + + const connecting = inFlight || isConnectingSshStatus(status) + const canConnect = canConnectSshStatus(status) + if (!connecting && !canConnect) { + // Defensive: every remaining member is either connecting or recoverable, but never + // render a dead button if the union grows. + return null + } + + const failed = status === 'error' || status === 'reconnection-failed' || status === 'auth-failed' + const label = connecting ? sshConnectingLabel() : sshConnectVerb(status) + const accessibleName = connecting + ? translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.connectingName', + 'Connecting to SSH host {{value0}}', + { value0: targetLabel } + ) + : status === 'auth-failed' + ? translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.authFailedName', + 'Reconnect SSH host {{value0}} — authentication failed', + { value0: targetLabel } + ) + : failed + ? translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.retryName', + 'Retry SSH connection to {{value0}}', + { value0: targetLabel } + ) + : translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.connectName', + 'Connect to SSH host {{value0}}', + { value0: targetLabel } + ) + const tooltip = connecting + ? accessibleName + : status === 'auth-failed' + ? translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.authFailedTooltip', + '{{value0}} · authentication failed', + { value0: targetLabel } + ) + : failed + ? translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.failedTooltip', + '{{value0}} · connection failed', + { value0: targetLabel } + ) + : accessibleName + + return ( + + + + + + {tooltip} + + + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx index 82630b66d8b..5a644f0d265 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx @@ -144,10 +144,6 @@ vi.mock('./CacheTimer', () => ({ // NOTE: intentionally NOT mocking ./WorktreeCardAgents — we render the real one. -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index 53d16e0d7b4..9d97e502065 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -212,26 +212,6 @@ vi.mock('./WorktreeContextMenu', () => ({ WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope' })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: ({ - open, - status, - targetId, - targetLabel - }: { - open: boolean - status: string - targetId: string - targetLabel: string - }) => - React.createElement('aside', { - 'data-lineage-ssh-dialog': open ? 'open' : 'closed', - 'data-ssh-status': status, - 'data-ssh-target-id': targetId, - 'data-ssh-target-label': targetLabel - }) -})) - vi.mock('@/components/ui/tooltip', () => ({ Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index 7a5dfbb13f9..52f3a591473 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -142,10 +142,6 @@ vi.mock('./WorktreeCardAgents', () => ({ SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment' })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx index 0311879c5b7..00c9e47a0c8 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx @@ -143,10 +143,6 @@ vi.mock('./WorktreeCardAgents', () => ({ SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment' })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index 42049cb5a25..70e500ad09a 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -21,10 +21,7 @@ import { RuntimeHostStatusRow, type RuntimeHostConnectionState } from './Runtime import { SshTargetStatusRow } from './SshTargetStatusRow' import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../../../shared/remote-runtime-shared-control-types' import { connectRuntimeEnvironmentAndRecordStatus } from './runtime-environment-explicit-connect' - -function isConnecting(status: SshConnectionStatus): boolean { - return ['connecting', 'deploying-relay', 'reconnecting'].includes(status) -} +import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability' type HostStatus = 'connected' | 'disconnected' | 'connecting' @@ -70,7 +67,7 @@ function sshStatusForOverall(status: SshConnectionStatus): HostStatus { if (status === 'connected') { return 'connected' } - return isConnecting(status) ? 'connecting' : 'disconnected' + return isConnectingSshStatus(status) ? 'connecting' : 'disconnected' } function runtimeHostConnectionState({ diff --git a/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx b/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx index a19b17b40e9..c8fa9100b90 100644 --- a/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx +++ b/src/renderer/src/components/status-bar/SshTargetStatusRow.tsx @@ -5,13 +5,17 @@ import { translate } from '@/i18n/i18n' import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '../../store' import { STATUS_LABELS, statusColor } from '../settings/SshTargetCard' +import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability' +import { sshConnectVerb } from '@/ssh/ssh-connect-verb' +import { + beginSshConnect, + endSshConnect, + isSshConnectInFlight, + useSshConnectInFlight +} from '@/ssh/ssh-connect-in-flight' import type { SshConnectionStatus } from '../../../../shared/ssh-types' import type { RemoteWorkspaceSyncStatus } from '../../store/slices/ssh' -function isReconnectable(status: SshConnectionStatus): boolean { - return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status) -} - function syncStatusLabel(status: RemoteWorkspaceSyncStatus | undefined): string | null { switch (status?.phase) { case 'pulling': @@ -62,9 +66,16 @@ export function SshTargetStatusRow({ const [busy, setBusy] = useState(false) const mountedRef = useMountedRef() const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) + // Why: shared with the sidebar card control and terminal overlay — a connect started here + // must disable those too, in the window before main broadcasts 'connecting'. + const connectInFlight = useSshConnectInFlight(targetId) const visibleSyncStatusLabel = syncStatusLabel(syncStatus) const handleConnect = useCallback(async () => { + if (isSshConnectInFlight(targetId)) { + return + } + beginSshConnect(targetId) setBusy(true) try { await window.api.ssh.connect({ targetId }) @@ -76,6 +87,7 @@ export function SshTargetStatusRow({ : translate('auto.components.status.bar.SshStatusSegment.2c29e2de68', 'Connection failed') ) } finally { + endSshConnect(targetId) if (mountedRef.current) { setBusy(false) } @@ -130,15 +142,15 @@ export function SshTargetStatusRow({ ) : null}
- {busy ? ( + {busy || connectInFlight ? ( - ) : isReconnectable(status) ? ( + ) : canConnectSshStatus(status) ? ( ) : status === 'connected' ? ( )} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index fdace14f0d6..b1eb0900865 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -8246,7 +8246,7 @@ export function connectPanePty( const alreadyConnected = useAppStore.getState().sshConnectionStates.get(connectionId)?.status === 'connected' if (!alreadyConnected) { - // Wait for the user-driven connect (SshDisconnectedDialog → passphrase → ssh.connect) to complete. + // Wait for the user-driven connect (sidebar card control or terminal reconnect overlay → passphrase → ssh.connect) to complete. // Why: resolve on terminal-failure statuses too ('auth-failed'/'error'/'reconnection-failed') so it can't hang forever if the user cancels or the connect fails. const outcome = await new Promise((resolve) => { // Why: 'disconnected' counts as terminal only after a non-disconnected status was seen (a real connect attempt that returned to 'disconnected'). diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 35c48344e63..bed3e4e86c8 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -24,6 +24,7 @@ import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constan import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom' +import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, @@ -2806,7 +2807,7 @@ export function useIpcEvents(): void { const previous = store.sshConnectionStates?.get(targetId) store.setSshConnectionState(targetId, state) - if (['disconnected', 'auth-failed', 'reconnection-failed', 'error'].includes(state.status)) { + if (canConnectSshStatus(state.status)) { reconnectAuthorityByTarget.delete(targetId) reconnectCoordinator.invalidate(targetId) // Why: remote agent list is tied to a live relay; clear on disconnect so reconnect re-detects against the new relay. diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b7f0deb0871..78658fcebce 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4447,19 +4447,6 @@ "folderPathIdentity": "Branch / folder path", "cli": "Orca CLI" }, - "SshDisconnectedDialog": { - "ca4a7892af": "Connecting...", - "89385db176": "Dismiss", - "656368f3a2": "Reconnection failed", - "376bed88e5": "The connection to the remote host encountered an error.", - "4afcca1d24": "Reconnect", - "11552bf786": "SSH Disconnected", - "cb5938ae79": "Reconnecting...", - "disconnected": "This SSH host is not connected.", - "reconnecting": "Reconnecting to the remote host...", - "reconnectionFailed": "Reconnection to the remote host failed.", - "authFailed": "Authentication to the remote host failed." - }, "SshTargetRow": { "4677394048": "Connecting…", "75ad429b5d": "Connect" @@ -5155,6 +5142,19 @@ "3b7ea51793": "Clear search", "7f1c2e94a5": "Search text is too long — the board is unfiltered", "9a4d0f6b21": "Too long" + }, + "WorktreeCardSshHostControl": { + "connectFailed": "SSH connection failed", + "removedTooltip": "SSH host removed — reconnect unavailable", + "removedName": "SSH host {{value0}} was removed", + "connectedTooltip": "Project on SSH host", + "connectedName": "Project on SSH host {{value0}}", + "connectingName": "Connecting to SSH host {{value0}}", + "authFailedName": "Reconnect SSH host {{value0}} — authentication failed", + "retryName": "Retry SSH connection to {{value0}}", + "connectName": "Connect to SSH host {{value0}}", + "authFailedTooltip": "{{value0}} · authentication failed", + "failedTooltip": "{{value0}} · connection failed" } }, "shared": { @@ -14619,6 +14619,14 @@ "emptyTrace": "No log is available for this GitLab job.", "timedOut": "Timed out loading the GitLab job log." } + }, + "ssh": { + "sshConnectVerb": { + "reconnect": "Reconnect", + "retry": "Retry", + "connect": "Connect", + "connecting": "Connecting…" + } } }, "components": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a970938ac40..dbe94a85ab4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4356,19 +4356,6 @@ "folderPathIdentity": "Rama / ruta de carpeta", "cli": "Orca CLI" }, - "SshDisconnectedDialog": { - "ca4a7892af": "Conectando...", - "89385db176": "Descartar", - "656368f3a2": "Falló la reconexión", - "376bed88e5": "La conexión al host remoto encontró un error.", - "4afcca1d24": "Reconectar", - "11552bf786": "SSH desconectado", - "cb5938ae79": "Reconectando...", - "disconnected": "Este host SSH no está conectado.", - "reconnecting": "Reconectando con el host remoto...", - "reconnectionFailed": "Falló la reconexión con el host remoto.", - "authFailed": "Falló la autenticación con el host remoto." - }, "SshTargetRow": { "4677394048": "Conectando…", "75ad429b5d": "Conectar" @@ -14365,6 +14352,14 @@ "emptyTrace": "No hay ningún registro disponible para este job de GitLab.", "timedOut": "Se agotó el tiempo de espera al cargar el registro del job de GitLab." } + }, + "ssh": { + "sshConnectVerb": { + "connect": "Conectar", + "connecting": "Conectando…", + "reconnect": "Reconectar", + "retry": "Reintentar" + } } }, "components": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 769a06c354f..830e17809cd 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4337,19 +4337,6 @@ "folderPathIdentity": "ブランチ / フォルダパス", "cli": "Orca CLI" }, - "SshDisconnectedDialog": { - "ca4a7892af": "接続中...", - "89385db176": "閉じる", - "656368f3a2": "再接続に失敗しました", - "376bed88e5": "リモート ホストへの接続でエラーが発生しました。", - "4afcca1d24": "再接続", - "11552bf786": "SSH が切断されました", - "cb5938ae79": "再接続中...", - "disconnected": "This remote repository is not connected.", - "reconnecting": "リモートホストに再接続中...", - "reconnectionFailed": "リモートホストへの再接続に失敗しました。", - "authFailed": "リモートホストへの認証に失敗しました。" - }, "SshTargetRow": { "4677394048": "接続中…", "75ad429b5d": "接続" @@ -14365,6 +14352,14 @@ "emptyTrace": "この GitLab ジョブのログはありません。", "timedOut": "GitLab ジョブのログの読み込みがタイムアウトしました。" } + }, + "ssh": { + "sshConnectVerb": { + "connect": "接続", + "connecting": "接続中…", + "reconnect": "再接続", + "retry": "再試行" + } } }, "components": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a1309686c99..4d1ed1e286e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4337,19 +4337,6 @@ "folderPathIdentity": "브랜치 / 폴더 경로", "cli": "Orca CLI" }, - "SshDisconnectedDialog": { - "ca4a7892af": "연결 중...", - "89385db176": "닫기", - "656368f3a2": "재연결 실패", - "376bed88e5": "원격 호스트 연결에 오류가 발생했습니다.", - "4afcca1d24": "다시 연결", - "11552bf786": "SSH 연결 끊김", - "cb5938ae79": "다시 연결하는 중...", - "disconnected": "이 SSH 호스트는 연결되어 있지 않습니다.", - "reconnecting": "원격 호스트에 다시 연결하는 중...", - "reconnectionFailed": "원격 호스트에 다시 연결하지 못했습니다.", - "authFailed": "원격 호스트 인증에 실패했습니다." - }, "SshTargetRow": { "4677394048": "연결 중…", "75ad429b5d": "연결" @@ -14376,6 +14363,14 @@ "emptyTrace": "이 GitLab 작업의 로그를 사용할 수 없습니다.", "timedOut": "GitLab 작업 로그를 불러오는 중 시간이 초과되었습니다." } + }, + "ssh": { + "sshConnectVerb": { + "connect": "연결", + "connecting": "연결 중…", + "reconnect": "다시 연결", + "retry": "재시도" + } } }, "components": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2d7abce6580..a76a2b01b06 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4349,19 +4349,6 @@ "folderPathIdentity": "分支 / 文件夹路径", "cli": "Orca CLI" }, - "SshDisconnectedDialog": { - "ca4a7892af": "正在连接...", - "89385db176": "关闭", - "656368f3a2": "重连失败", - "376bed88e5": "与远程主机的连接遇到错误。", - "4afcca1d24": "重新连接", - "11552bf786": "SSH 已断开", - "cb5938ae79": "正在重新连接...", - "disconnected": "此 SSH 主机未连接。", - "reconnecting": "正在重新连接远程主机...", - "reconnectionFailed": "重新连接到远程主机失败。", - "authFailed": "远程主机身份验证失败。" - }, "SshTargetRow": { "4677394048": "正在连接…", "75ad429b5d": "连接" @@ -14385,6 +14372,14 @@ "emptyTrace": "此 GitLab 作业没有可用的日志。", "timedOut": "加载 GitLab 作业日志超时。" } + }, + "ssh": { + "sshConnectVerb": { + "connect": "连接", + "connecting": "正在连接…", + "reconnect": "重新连接", + "retry": "重试" + } } }, "components": { diff --git a/src/renderer/src/lib/new-workspace-ssh-gate.ts b/src/renderer/src/lib/new-workspace-ssh-gate.ts index f597e18e245..ac1ae7afe55 100644 --- a/src/renderer/src/lib/new-workspace-ssh-gate.ts +++ b/src/renderer/src/lib/new-workspace-ssh-gate.ts @@ -1,3 +1,4 @@ +import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability' import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' import type { SshConnectionStatus } from '../../../shared/ssh-types' @@ -9,7 +10,7 @@ export type SelectedRepoSshGate = { } export function isSshConnectInProgress(status: SshConnectionStatus | null): boolean { - return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting' + return isConnectingSshStatus(status) } export function getSelectedRepoSshGate(input: { diff --git a/src/renderer/src/ssh/ssh-connect-in-flight.test.ts b/src/renderer/src/ssh/ssh-connect-in-flight.test.ts new file mode 100644 index 00000000000..4225c0d9f06 --- /dev/null +++ b/src/renderer/src/ssh/ssh-connect-in-flight.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + beginSshConnect, + endSshConnect, + isSshConnectInFlight, + resetSshConnectInFlightForTests, + subscribeSshConnectInFlight, + trackSshConnect +} from './ssh-connect-in-flight' +import { SSH_RECONNECT_UI_TIMEOUT_MS, withUiConnectTimeout } from './ssh-connect-ui-timeout' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +describe('ssh connect in-flight registry', () => { + beforeEach(() => { + resetSshConnectInFlightForTests() + }) + + it('tracks connects per target, so one host dialing does not disable another', () => { + beginSshConnect('ssh-a') + + expect(isSshConnectInFlight('ssh-a')).toBe(true) + expect(isSshConnectInFlight('ssh-b')).toBe(false) + }) + + it('clears the target when the connect settles', () => { + beginSshConnect('ssh-a') + endSshConnect('ssh-a') + + expect(isSshConnectInFlight('ssh-a')).toBe(false) + }) + + it('notifies subscribers on both edges', () => { + const listener = vi.fn() + subscribeSshConnectInFlight(listener) + + beginSshConnect('ssh-a') + endSshConnect('ssh-a') + + expect(listener).toHaveBeenCalledTimes(2) + }) + + // Why: every surface renders from one registry entry, so a duplicate begin must not + // emit again — and the paired end must not clear the flag while a connect is still live. + it('ignores a duplicate begin without re-notifying', () => { + const listener = vi.fn() + subscribeSshConnectInFlight(listener) + + beginSshConnect('ssh-a') + beginSshConnect('ssh-a') + + expect(listener).toHaveBeenCalledTimes(1) + expect(isSshConnectInFlight('ssh-a')).toBe(true) + }) + + // Why: a connect handler's finally can run for a target it never began (early return + // paths), and a spurious notify would re-render every subscribed card. + it('ignores an end for a target that was never in flight', () => { + const listener = vi.fn() + subscribeSshConnectInFlight(listener) + + endSshConnect('ssh-a') + + expect(listener).not.toHaveBeenCalled() + expect(isSshConnectInFlight('ssh-a')).toBe(false) + }) + + describe('trackSshConnect', () => { + it('holds the lock while the request is pending and clears it on resolve', async () => { + let settle: (value: string) => void = () => {} + const request = trackSshConnect( + 'ssh-a', + new Promise((resolve) => { + settle = resolve + }) + ) + + expect(isSshConnectInFlight('ssh-a')).toBe(true) + + settle('connected') + await request + + expect(isSshConnectInFlight('ssh-a')).toBe(false) + }) + + it('clears the lock when the request rejects', async () => { + const request = trackSshConnect('ssh-a', Promise.reject(new Error('Passphrase rejected'))) + + await expect(request).rejects.toThrow('Passphrase rejected') + await Promise.resolve() + + expect(isSshConnectInFlight('ssh-a')).toBe(false) + }) + + // The regression this guards: the UI wait is a race that rejects at the timeout while the + // backend keeps dialing. Releasing the lock there would let the next click on any surface + // for this host fire a second connect — a second credential prompt on a gated target. + it('keeps the lock after a UI timeout abandons the wait, so a second dial is suppressed', async () => { + vi.useFakeTimers() + try { + const request = trackSshConnect('ssh-a', new Promise(() => {})) + const uiWait = withUiConnectTimeout(request, SSH_RECONNECT_UI_TIMEOUT_MS) + const settled = uiWait.catch((error: Error) => error.message) + + await vi.advanceTimersByTimeAsync(SSH_RECONNECT_UI_TIMEOUT_MS) + + expect(await settled).toContain('timed out') + expect(isSshConnectInFlight('ssh-a')).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + // A tracked request outlives whatever dropped its lock — a reset between specs, or an + // explicit end. Its release must not unlock a later connect on the same target. + it('ignores a settle whose lock was already cleared, so a later connect keeps its lock', async () => { + let settleAbandoned: (value: string) => void = () => {} + const abandoned = trackSshConnect( + 'ssh-a', + new Promise((resolve) => { + settleAbandoned = resolve + }) + ) + resetSshConnectInFlightForTests() + + const listener = vi.fn() + subscribeSshConnectInFlight(listener) + beginSshConnect('ssh-a') + + settleAbandoned('connected') + await abandoned + await Promise.resolve() + + expect(isSshConnectInFlight('ssh-a')).toBe(true) + expect(listener).toHaveBeenCalledTimes(1) + }) + }) + + it('stops notifying after unsubscribe, so unmounted sidebar rows do not leak', () => { + const listener = vi.fn() + const unsubscribe = subscribeSshConnectInFlight(listener) + + unsubscribe() + beginSshConnect('ssh-a') + + expect(listener).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/ssh/ssh-connect-in-flight.ts b/src/renderer/src/ssh/ssh-connect-in-flight.ts new file mode 100644 index 00000000000..4a06f078c1f --- /dev/null +++ b/src/renderer/src/ssh/ssh-connect-in-flight.ts @@ -0,0 +1,90 @@ +import { useCallback, useSyncExternalStore } from 'react' + +// Why: the store status lags a user click by one IPC hop (main broadcasts 'connecting' +// after ssh.connect starts), and every workspace card on a host shares one connection. +// Component-local state would let two surfaces — or N cards on the same host — each fire +// a connect, which on a passphrase-gated target means N credential prompts. +// Keyed by target, valued by the id of the acquisition holding the lock: a release can then +// clear only its own acquisition, so a settle that lands after the lock was dropped (a test +// reset, an explicit end) cannot take down a newer connect's lock. +const inFlightLockIds = new Map() +let lastLockId = 0 +const listeners = new Set<() => void>() + +function emit(): void { + for (const listener of listeners) { + listener() + } +} + +export function subscribeSshConnectInFlight(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +function acquire(targetId: string): number { + const held = inFlightLockIds.get(targetId) + if (held !== undefined) { + return held + } + lastLockId += 1 + inFlightLockIds.set(targetId, lastLockId) + emit() + return lastLockId +} + +function releaseOwned(targetId: string, lockId: number): void { + if (inFlightLockIds.get(targetId) !== lockId) { + return + } + inFlightLockIds.delete(targetId) + emit() +} + +export function beginSshConnect(targetId: string): void { + acquire(targetId) +} + +export function endSshConnect(targetId: string): void { + if (!inFlightLockIds.delete(targetId)) { + return + } + emit() +} + +export function isSshConnectInFlight(targetId: string): boolean { + return inFlightLockIds.has(targetId) +} + +/** + * Holds the lock for `request`'s whole life and returns it unchanged. + * Why: UI callers race the request against a display timeout, but the backend keeps dialing + * past it — releasing when the caller stops waiting would let the next click raise a second + * credential prompt. Also survives unmount, unlike a `finally` in a component handler. + */ +export function trackSshConnect(targetId: string, request: Promise): Promise { + const lockId = acquire(targetId) + const release = (): void => { + releaseOwned(targetId, lockId) + } + // Two-arg then, not finally: a derived rejected promise here would go unhandled. + void request.then(release, release) + return request +} + +export function useSshConnectInFlight(targetId: string): boolean { + const getSnapshot = useCallback(() => inFlightLockIds.has(targetId), [targetId]) + return useSyncExternalStore(subscribeSshConnectInFlight, getSnapshot, getSnapshot) +} + +/** + * Test-only: the registry is module state, so specs must reset it between cases. + * A request tracked before the reset stays pending; its release is scoped to the cleared + * lock id, so it settles into a no-op rather than unlocking the next case's target. + */ +export function resetSshConnectInFlightForTests(): void { + inFlightLockIds.clear() + emit() +} diff --git a/src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts b/src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts new file mode 100644 index 00000000000..0d984e94d6c --- /dev/null +++ b/src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SSH_CONNECT_UI_TIMEOUT_MS, + SSH_RECONNECT_UI_TIMEOUT_MS, + withUiConnectTimeout +} from './ssh-connect-ui-timeout' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +describe('withUiConnectTimeout', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('passes a resolved connect state straight through', async () => { + await expect(withUiConnectTimeout(Promise.resolve('connected'))).resolves.toBe('connected') + }) + + it('propagates the connect rejection rather than the timeout message', async () => { + const failing = withUiConnectTimeout(Promise.reject(new Error('Passphrase rejected'))) + + await expect(failing).rejects.toThrow('Passphrase rejected') + }) + + // Why: ssh.connect has no timeout of its own, so a stalled backend would otherwise leave + // the control disabled with a spinner forever. + it('rejects a stalled connect once the UI budget elapses', async () => { + const stalled = withUiConnectTimeout(new Promise(() => {})) + const assertion = expect(stalled).rejects.toThrow(/Connection timed out/) + + await vi.advanceTimersByTimeAsync(SSH_CONNECT_UI_TIMEOUT_MS) + + await assertion + }) + + // Why: an interactive passphrase alone allows 120s in main, so the reconnect surfaces pass + // a longer budget; the default composer budget must not fence them. + it('honours a caller-supplied budget instead of the composer default', async () => { + const stalled = withUiConnectTimeout(new Promise(() => {}), SSH_RECONNECT_UI_TIMEOUT_MS) + const assertion = expect(stalled).rejects.toThrow(/Connection timed out/) + + await vi.advanceTimersByTimeAsync(SSH_CONNECT_UI_TIMEOUT_MS) + expect(vi.getTimerCount()).toBe(1) + + await vi.advanceTimersByTimeAsync(SSH_RECONNECT_UI_TIMEOUT_MS - SSH_CONNECT_UI_TIMEOUT_MS) + await assertion + }) + + it('gives a reconnect more time than main allows for an interactive passphrase', () => { + expect(SSH_RECONNECT_UI_TIMEOUT_MS).toBeGreaterThan(120_000) + }) + + it('clears the timer when the connect settles first, leaving no pending work', async () => { + await withUiConnectTimeout(Promise.resolve('connected')) + + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/ssh/ssh-connect-ui-timeout.ts b/src/renderer/src/ssh/ssh-connect-ui-timeout.ts new file mode 100644 index 00000000000..ab917ff35aa --- /dev/null +++ b/src/renderer/src/ssh/ssh-connect-ui-timeout.ts @@ -0,0 +1,40 @@ +import { translate } from '@/i18n/i18n' + +// Why: ssh.connect has no built-in timeout, so bound how long a UI control waits on it — +// a stalled backend connect must not leave a disabled spinner stuck forever. The backend +// keeps going regardless. +export const SSH_CONNECT_UI_TIMEOUT_MS = 20_000 + +// Why: a reconnect can legitimately outlast the composer budget — an interactive passphrase +// prompt alone allows 120s (main's CREDENTIAL_TIMEOUT_MS) before the 30s connect even starts, +// and a first relay deploy uploads a binary. A shorter fence would toast "timed out" and run +// the stale-metadata resync against a host that is about to connect fine. +export const SSH_RECONNECT_UI_TIMEOUT_MS = 180_000 + +export async function withUiConnectTimeout( + promise: Promise, + timeoutMs: number = SSH_CONNECT_UI_TIMEOUT_MS +): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new Error( + // Key kept from the original NewWorkspaceComposerCard home so existing + // translations survive the move. + translate( + 'auto.components.NewWorkspaceComposerCard.connectTimedOut', + 'Connection timed out. It may still be connecting in the background.' + ) + ) + ) + }, timeoutMs) + }) + try { + return await Promise.race([promise, timeout]) + } finally { + if (timer) { + clearTimeout(timer) + } + } +} diff --git a/src/renderer/src/ssh/ssh-connect-verb.test.ts b/src/renderer/src/ssh/ssh-connect-verb.test.ts new file mode 100644 index 00000000000..b3fcdcdcdc3 --- /dev/null +++ b/src/renderer/src/ssh/ssh-connect-verb.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import { sshConnectingLabel, sshConnectVerb } from './ssh-connect-verb' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +// Why: the sidebar card control, terminal overlay, host-header menu, and status-bar row all +// read this table. It is the only thing keeping them from naming one click three ways. +describe('sshConnectVerb', () => { + it('names an authentication failure a reconnect', () => { + expect(sshConnectVerb('auth-failed')).toBe('Reconnect') + }) + + it.each(['error', 'reconnection-failed'] as const)('names %s a retry', (status) => { + expect(sshConnectVerb(status)).toBe('Retry') + }) + + it.each(['disconnected', 'connecting', 'deploying-relay', 'reconnecting', 'connected'] as const)( + 'falls back to Connect for %s', + (status) => { + expect(sshConnectVerb(status)).toBe('Connect') + } + ) + + it.each([null, undefined])('falls back to Connect for %s', (status) => { + expect(sshConnectVerb(status)).toBe('Connect') + }) +}) + +describe('sshConnectingLabel', () => { + it('uses the single-character ellipsis, matching the rest of the catalog', () => { + expect(sshConnectingLabel()).toBe('Connecting…') + }) +}) diff --git a/src/renderer/src/ssh/ssh-connect-verb.ts b/src/renderer/src/ssh/ssh-connect-verb.ts new file mode 100644 index 00000000000..d5dd98a06e8 --- /dev/null +++ b/src/renderer/src/ssh/ssh-connect-verb.ts @@ -0,0 +1,27 @@ +import { translate } from '@/i18n/i18n' +import type { SshConnectionStatus } from '../../../shared/ssh-types' + +// Why: the sidebar card, the terminal overlay, the host-header menu, and the status-bar row +// can all be on screen at once. One vocabulary here stops them describing the same click +// three different ways. +export function sshConnectVerb(status: SshConnectionStatus | null | undefined): string { + switch (status) { + case 'auth-failed': + return translate('auto.ssh.sshConnectVerb.reconnect', 'Reconnect') + case 'error': + case 'reconnection-failed': + return translate('auto.ssh.sshConnectVerb.retry', 'Retry') + case null: + case undefined: + case 'connected': + case 'connecting': + case 'deploying-relay': + case 'disconnected': + case 'reconnecting': + return translate('auto.ssh.sshConnectVerb.connect', 'Connect') + } +} + +export function sshConnectingLabel(): string { + return translate('auto.ssh.sshConnectVerb.connecting', 'Connecting…') +} diff --git a/src/renderer/src/ssh/ssh-connection-recoverability.test.ts b/src/renderer/src/ssh/ssh-connection-recoverability.test.ts new file mode 100644 index 00000000000..05aa06eb26d --- /dev/null +++ b/src/renderer/src/ssh/ssh-connection-recoverability.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import type { SshConnectionStatus } from '../../../shared/ssh-types' +import { canConnectSshStatus, isConnectingSshStatus } from './ssh-connection-recoverability' + +// Union growth is caught by the typechecker (the modules use total Records), not here. +// These cases pin the classification itself, which four call sites now depend on. +const ALL_STATUSES: SshConnectionStatus[] = [ + 'disconnected', + 'connecting', + 'auth-failed', + 'deploying-relay', + 'connected', + 'reconnecting', + 'reconnection-failed', + 'error' +] + +describe('isConnectingSshStatus', () => { + it.each(['connecting', 'deploying-relay', 'reconnecting'] as const)( + 'treats %s as an attempt already under way', + (status) => { + expect(isConnectingSshStatus(status)).toBe(true) + } + ) + + it.each(['disconnected', 'auth-failed', 'connected', 'reconnection-failed', 'error'] as const)( + 'does not treat %s as connecting', + (status) => { + expect(isConnectingSshStatus(status)).toBe(false) + } + ) +}) + +describe('canConnectSshStatus', () => { + it.each(['disconnected', 'auth-failed', 'reconnection-failed', 'error'] as const)( + 'offers a user-driven connect for %s', + (status) => { + expect(canConnectSshStatus(status)).toBe(true) + } + ) + + it.each(['connecting', 'deploying-relay', 'reconnecting', 'connected'] as const)( + 'withholds connect for %s', + (status) => { + expect(canConnectSshStatus(status)).toBe(false) + } + ) +}) + +// Why: runtime-owned targets deliberately yield a null status. Both predicates must read +// that as "nothing to offer" so the card falls through to the passive host glyph. +describe('absent status', () => { + it.each([null, undefined])('classifies %s as neither connecting nor connectable', (status) => { + expect(isConnectingSshStatus(status)).toBe(false) + expect(canConnectSshStatus(status)).toBe(false) + }) +}) + +describe('the two predicates together', () => { + it('never claims a status is both connecting and connectable', () => { + for (const status of ALL_STATUSES) { + expect(isConnectingSshStatus(status) && canConnectSshStatus(status)).toBe(false) + } + }) + + it('leaves only connected outside both sets, so no state renders a dead control', () => { + const unclassified = ALL_STATUSES.filter( + (status) => !isConnectingSshStatus(status) && !canConnectSshStatus(status) + ) + expect(unclassified).toEqual(['connected']) + }) +}) diff --git a/src/renderer/src/ssh/ssh-connection-recoverability.ts b/src/renderer/src/ssh/ssh-connection-recoverability.ts new file mode 100644 index 00000000000..83e87b75216 --- /dev/null +++ b/src/renderer/src/ssh/ssh-connection-recoverability.ts @@ -0,0 +1,36 @@ +import type { SshConnectionStatus } from '../../../shared/ssh-types' + +// Why: a total Record makes a new SshConnectionStatus member a typecheck failure. An +// array + .includes() would silently classify it as "not recoverable", leaving its cards +// with a dead glyph and no way to reconnect. +const CONNECTING_BY_STATUS: Record = { + disconnected: false, + connecting: true, + 'auth-failed': false, + 'deploying-relay': true, + connected: false, + reconnecting: true, + 'reconnection-failed': false, + error: false +} + +const CAN_CONNECT_BY_STATUS: Record = { + disconnected: true, + connecting: false, + 'auth-failed': true, + 'deploying-relay': false, + connected: false, + reconnecting: false, + 'reconnection-failed': true, + error: true +} + +/** Relay deployment and reconnect are host-driven transients: no user action helps yet. */ +export function isConnectingSshStatus(status: SshConnectionStatus | null | undefined): boolean { + return status ? CONNECTING_BY_STATUS[status] : false +} + +/** Failure states a user-initiated connect can recover from. */ +export function canConnectSshStatus(status: SshConnectionStatus | null | undefined): boolean { + return status ? CAN_CONNECT_BY_STATUS[status] : false +} From 999e3a3a6d069e6179b762b68d153f07121d5601 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:42:25 -0700 Subject: [PATCH 31/43] feat(sidebar): link Linear issues from Edit Worktree Details (#12380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sidebar): link Linear issues from Edit Worktree Details The Issue field only accepted GitHub numbers, so a workspace tracking a Linear issue had no way to say so from the dialog — the link could only be set at creation time or through `orca worktree set --linear-issue`. Replaces the field with one provider-aware row: a chip suffix inside the input selects GitHub or Linear, and pasting a URL flips the chip to match. A bare key never steers the provider — Linear and Jira issue keys are byte-identical in shape, so shape alone cannot decide one. One issue per workspace. A changed field displaces the other provider's slot and the row names what Save is about to unlink. GitLab and Jira links are left alone: the row cannot display them, and nothing else in the UI could restore one it dropped. - Folder workspaces read-only (their link is creation-time only) - Remote runtimes assert the capability before writing or clearing, since `worktree.set` parses in strip mode and would silently drop the keys - `updateWorktreeMeta` now reports failure so the dialog can stay open instead of closing over a save that refetch reverted - Parses are length-bounded — `matchGitHubItemPath` strips trailing slashes with an unanchored regex that is quadratic on a large paste * fix(sidebar): respect one-issue-per-workspace rule conditionally Only clear displaced issue links when they actually existed, preventing unnecessary Linear keys in GitHub-only workspaces. Skip comment updates when unchanged to avoid workspace reordering. Add accessibility to displacement messages and improve folder workspace error handling. * fix(sidebar): resolve workspace ambiguity and improve Linear issue linki The same workspace ID can exist under multiple hosts — the owner index reports this as ambiguous rather than guessing. Dialog callers now pass their repoId so lookups are unambiguous. Linear identifiers without an org key are resolved across all workspaces (not just the active organization). Added race-condition protection for async issue lookups and better change detection to avoid clearing work-item titles when re-saving an identifier in different spelling. --- .../handlers/worktree-linear-issue-link.ts | 36 +- src/cli/index.test.ts | 3 + .../components/right-sidebar/ChecksPanel.tsx | 3 + .../src/components/sidebar/WorktreeCard.tsx | 14 +- .../sidebar/WorktreeContextMenu.tsx | 4 + .../sidebar/WorktreeIssueLinkField.tsx | 260 ++++++++ .../sidebar/WorktreeMetaDialog.test.tsx | 574 ++++++++++++++++++ .../components/sidebar/WorktreeMetaDialog.tsx | 236 ++++--- .../sidebar/use-worktree-issue-link.ts | 223 ++++++- .../sidebar/use-worktree-meta-workspace.ts | 102 ++++ .../sidebar/worktree-issue-displacement.ts | 48 ++ .../sidebar/worktree-meta-updates.test.ts | 391 ++++++++++-- .../sidebar/worktree-meta-updates.ts | 265 ++++++-- src/renderer/src/i18n/locales/en.json | 27 +- ...wake-sleeping-agents-in-background.test.ts | 4 +- .../src/store/slices/worktree-helpers.ts | 4 +- .../src/store/slices/worktrees.test.ts | 31 + src/renderer/src/store/slices/worktrees.ts | 66 +- src/shared/issue-link-input.test.ts | 118 ++++ src/shared/issue-link-input.ts | 75 +++ src/shared/linear-links.test.ts | 81 +++ src/shared/linear-links.ts | 52 ++ 22 files changed, 2381 insertions(+), 236 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx create mode 100644 src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts create mode 100644 src/renderer/src/components/sidebar/worktree-issue-displacement.ts create mode 100644 src/shared/issue-link-input.test.ts create mode 100644 src/shared/issue-link-input.ts diff --git a/src/cli/handlers/worktree-linear-issue-link.ts b/src/cli/handlers/worktree-linear-issue-link.ts index 2285da4f01a..c8e1020d847 100644 --- a/src/cli/handlers/worktree-linear-issue-link.ts +++ b/src/cli/handlers/worktree-linear-issue-link.ts @@ -1,17 +1,15 @@ -import { parseLinearIssueInput } from '../../shared/linear-links' +import { + buildLinearIssueLinkUpdates, + LINEAR_ISSUE_LINK_CLEARED, + type LinearIssueLinkUpdates +} from '../../shared/linear-links' import { RuntimeClientError } from '../runtime-client' -type LinearIssueLinkParams = { - linkedLinearIssue: string | null - linkedLinearIssueWorkspaceId: string | null - linkedLinearIssueOrganizationUrlKey: string | null -} - export function getOptionalLinearIssueLinkFlag( flags: Map, name: string, options: { allowNull?: boolean } = {} -): LinearIssueLinkParams | undefined { +): LinearIssueLinkUpdates | undefined { const value = getPresentStringFlag(flags, name) if (value === undefined) { return undefined @@ -24,28 +22,22 @@ export function getOptionalLinearIssueLinkFlag( 'Omit --linear-issue on create, or pass a Linear issue identifier or URL.' ) } - return { - linkedLinearIssue: null, - linkedLinearIssueWorkspaceId: null, - linkedLinearIssueOrganizationUrlKey: null - } + return { ...LINEAR_ISSUE_LINK_CLEARED } } - const parsed = parseLinearIssueInput(value) - if (!parsed) { + // Why: the shared builder treats empty input as "clear", which is right for a + // text field the user can blank but wrong for a flag — `--linear-issue " "` + // is a mistyped argument, not a request to unlink. Only the literal `null` + // clears, and that is handled above. + const updates = value.trim() === '' ? null : buildLinearIssueLinkUpdates(value) + if (!updates) { throw new RuntimeClientError( 'invalid_argument', 'Pass a Linear issue identifier like STA-335, a Linear issue URL, or null to clear.' ) } - return { - linkedLinearIssue: parsed.identifier, - // Why: changing a link must not keep a workspace id from a previous issue. - // The org key from URLs is enough for current-resolution to safely rehydrate it. - linkedLinearIssueWorkspaceId: null, - linkedLinearIssueOrganizationUrlKey: parsed.organizationUrlKey ?? null - } + return updates } function getPresentStringFlag( diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index bca1910cb10..2dad82ffdd1 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1245,6 +1245,9 @@ describe('orca cli worktree awareness', () => { linkedIssue: undefined, linkedLinearIssue: 'STA-335', linkedLinearIssueWorkspaceId: null, + // Why: a bare identifier carries no org, and the two scoping fields + // describe the issue being replaced. Inheriting a stale org key would + // pin the lookup to a workspace this issue does not live in. linkedLinearIssueOrganizationUrlKey: null, comment: undefined, runHooks: false, diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 2a1412cde94..028abac821c 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -3700,6 +3700,9 @@ export default function ChecksPanel(): React.JSX.Element { } openModal('edit-meta', { worktreeId: activeWorktreeId, + // Why: the same workspace ID can exist under two hosts. Naming the owner + // keeps the dialog on this workspace instead of the ambiguous lookup. + repoId: activeWorktree.repoId, currentDisplayName: activeWorktree.displayName, currentIssue: activeWorktree.linkedIssue, currentPR: activeWorktree.linkedPR ?? activeReview.number, diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 2d3e2852cea..b53a03ec833 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -265,6 +265,9 @@ const WorktreeCard = React.memo(function WorktreeCard({ e.stopPropagation() openModal('edit-meta', { worktreeId: worktree.id, + // Why: the same workspace ID can exist under two hosts. Naming the owner + // keeps the dialog on the clicked row instead of the ambiguous lookup. + repoId: worktree.repoId, currentDisplayName: worktree.displayName, currentIssue: worktree.linkedIssue, currentPR: worktree.linkedPR, @@ -280,6 +283,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ e.stopPropagation() openModal('edit-meta', { worktreeId: worktree.id, + repoId: worktree.repoId, currentDisplayName: worktree.displayName, currentIssue: worktree.linkedIssue, currentPR: worktree.linkedPR, @@ -899,7 +903,11 @@ const WorktreeCard = React.memo(function WorktreeCard({ ) const handleRenameTitle = useCallback( - (displayName: string) => updateWorktreeMeta(worktree.id, { displayName }), + // Inline rename has no surface for the failure; the store already logs and + // refetches, which reverts the optimistic title in place. + async (displayName: string): Promise => { + await updateWorktreeMeta(worktree.id, { displayName }) + }, [updateWorktreeMeta, worktree.id] ) @@ -913,6 +921,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ } openModal('edit-meta', { worktreeId: worktree.id, + repoId: worktree.repoId, currentDisplayName: worktree.displayName, currentIssue: worktree.linkedIssue, currentPR: worktree.linkedPR, @@ -926,7 +935,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ worktree.displayName, worktree.id, worktree.linkedIssue, - worktree.linkedPR + worktree.linkedPR, + worktree.repoId ] ) diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index ea77889e6c7..ac01350de30 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -598,6 +598,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const handleRename = useCallback(() => { openModal('edit-meta', { worktreeId: worktree.id, + // Why: the same workspace ID can exist under two hosts. Naming the owner + // keeps the dialog on this row instead of the ambiguous lookup. + repoId: worktree.repoId, currentDisplayName: worktree.displayName, currentIssue: worktree.linkedIssue, currentPR: worktree.linkedPR, @@ -606,6 +609,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ }) }, [ worktree.id, + worktree.repoId, worktree.displayName, worktree.linkedIssue, worktree.linkedPR, diff --git a/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx b/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx new file mode 100644 index 00000000000..6a7306d0100 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx @@ -0,0 +1,260 @@ +import React, { useCallback, useId, useMemo } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { LinearIcon } from '@/components/icons/LinearIcon' +import { ChevronDown, ExternalLink, Github, LoaderCircle } from 'lucide-react' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + ISSUE_LINK_PROVIDERS, + isIssueLinkProvider, + type IssueLinkProvider +} from '../../../../shared/issue-link-input' + +// Why: the value must clear the chip and the open-issue button that float over +// the input's right edge. Provider labels are localized, so the text share is +// measured in `ch` rather than baked into a Tailwind `pr-*` step that a longer +// translation would overlap. +const ADORNMENT_RESERVE_PX = 70 +const MAX_RESERVE = '55%' + +/** Exported for test: happy-dom drops `min()`, so this cannot be read back off a style. */ +export function issueAdornmentReserve(providerLabel: string): string { + return `min(calc(${ADORNMENT_RESERVE_PX}px + ${providerLabel.length}ch), ${MAX_RESERVE})` +} + +function providerLabel(provider: IssueLinkProvider): string { + return provider === 'linear' + ? translate('auto.components.sidebar.WorktreeIssueLinkField.25852bfc59', 'Linear') + : translate('auto.components.sidebar.WorktreeIssueLinkField.5b440069e6', 'GitHub') +} + +function ProviderIcon({ + provider, + className +}: { + provider: IssueLinkProvider + className?: string +}): React.JSX.Element { + return provider === 'linear' ? ( + + ) : ( + + ) +} + +export type WorktreeIssueLinkFieldProps = { + inputRef: React.RefObject + value: string + provider: IssueLinkProvider + /** Set when the current value cannot be saved, so the row can explain why. */ + isInvalid: boolean + /** Names the links this save would drop; both slots can be filled at once. */ + displacedLinkLabels: string[] | null + /** Folder workspaces have no persisted link slots — see the design doc. */ + isReadOnly: boolean + canOpenIssue: boolean + openingIssue: boolean + /** The last open attempt resolved to nothing — see useWorktreeIssueLink. */ + openIssueFailed: boolean + onValueChange: (value: string) => void + onProviderChange: (provider: IssueLinkProvider) => void + onOpenIssue: () => void + onKeyDown: (event: React.KeyboardEvent) => void +} + +export function WorktreeIssueLinkField(props: WorktreeIssueLinkFieldProps): React.JSX.Element { + const { + inputRef, + value, + provider, + isInvalid, + displacedLinkLabels, + isReadOnly, + canOpenIssue, + openingIssue, + openIssueFailed, + onValueChange, + onProviderChange, + onOpenIssue, + onKeyDown + } = props + + // Why: the helper `

` is the only surface for the invalid and displacement + // states, so it has to be announced with the field rather than just seen. + const helperId = useId() + const inputId = useId() + const label = providerLabel(provider) + const openIssueLabel = translate( + 'auto.components.sidebar.WorktreeIssueLinkField.161b2d053a', + 'Open linked issue' + ) + + const handleProviderChange = useCallback( + (next: string) => { + if (isIssueLinkProvider(next)) { + onProviderChange(next) + } + }, + [onProviderChange] + ) + + const helperText = useMemo(() => { + if (isReadOnly) { + return translate( + 'auto.components.sidebar.WorktreeIssueLinkField.d4785f9954', + "Issue links are set when a folder workspace is created and can't be changed here yet." + ) + } + if (isInvalid) { + return provider === 'linear' + ? translate( + 'auto.components.sidebar.WorktreeIssueLinkField.964d9bc00a', + 'Not a Linear issue key or linear.app issue URL.' + ) + : translate( + 'auto.components.sidebar.WorktreeIssueLinkField.0a7a2c6efd', + 'Not a GitHub issue number or issue URL.' + ) + } + // Why: ranked above displacement because it answers the click the user just + // made, and it clears as soon as they edit the value that caused it. + if (openIssueFailed) { + return provider === 'linear' + ? translate( + 'auto.components.sidebar.WorktreeIssueLinkField.d8c8a30d1f', + "Couldn't open that issue. Check the identifier and your Linear connection." + ) + : translate( + 'auto.components.sidebar.WorktreeIssueLinkField.269198eeda', + "Couldn't open that issue. Check the number and your GitHub connection." + ) + } + // Whole sentences per arity rather than a joined list: a translated " and " + // fragment would not survive languages that order or punctuate lists differently. + if (displacedLinkLabels && displacedLinkLabels.length > 1) { + return translate( + 'auto.components.sidebar.WorktreeIssueLinkField.72486800ff', + 'Saving unlinks {{first}} and {{second}} — a workspace tracks one issue.', + { first: displacedLinkLabels[0], second: displacedLinkLabels[1] } + ) + } + if (displacedLinkLabels?.length) { + return translate( + 'auto.components.sidebar.WorktreeIssueLinkField.2c245ac134', + 'Saving unlinks {{link}} — a workspace tracks one issue.', + { link: displacedLinkLabels[0] } + ) + } + return translate( + 'auto.components.sidebar.WorktreeIssueLinkField.f047887705', + 'Paste a GitHub or Linear URL, or enter a number. Leave blank to remove the link.' + ) + }, [displacedLinkLabels, isInvalid, isReadOnly, openIssueFailed, provider]) + + return ( +

+ +
+ onValueChange(e.target.value)} + onKeyDown={onKeyDown} + disabled={isReadOnly} + aria-invalid={isInvalid || undefined} + placeholder={translate( + 'auto.components.sidebar.WorktreeIssueLinkField.662ae142f8', + 'Issue #, or a GitHub or Linear URL' + )} + className="h-8 text-xs" + style={{ paddingRight: issueAdornmentReserve(label) }} + /> +
+ {/* Why: `modal` would stack a second scroll-lock layer over the + Dialog's, whose nested teardown strands pointer-events on body. */} + + + + + + + {ISSUE_LINK_PROVIDERS.map((item) => ( + + + {providerLabel(item)} + + ))} + + + + + + + + + {openIssueLabel} + + +
+
+ {/* Why: two lines are reserved up front. The dialog is centered by + transform, so a wrap-count change would shift the field being typed in. */} + {/* Why: the failure and displacement messages appear without focus moving + here, so only a live region announces them. */} +

+ {helperText} +

+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx new file mode 100644 index 00000000000..b45585a4628 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx @@ -0,0 +1,574 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { + FolderWorkspace, + LinearIssue, + Repo, + Worktree, + WorktreeMeta +} from '../../../../shared/types' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' + +// Why: Radix tooltips need a provider the dialog does not own, and the menu's +// portal needs real layout. Stand-ins keep these tests on provider selection. +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children?: ReactNode }) => <>{children}, + TooltipContent: () => null, + TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/dropdown-menu', async () => { + const React = await import('react') + const SelectContext = React.createContext<(value: string) => void>(() => {}) + const Passthrough = ({ children }: { children?: ReactNode }) => <>{children} + return { + DropdownMenu: Passthrough, + DropdownMenuTrigger: Passthrough, + DropdownMenuContent: Passthrough, + DropdownMenuRadioGroup: ({ + value, + onValueChange, + children + }: { + value: string + onValueChange: (value: string) => void + children?: ReactNode + }) => ( + +
{children}
+
+ ), + DropdownMenuRadioItem: ({ value, children }: { value: string; children?: ReactNode }) => { + const onSelect = React.useContext(SelectContext) + return ( + + ) + } + } +}) + +import WorktreeMetaDialog from './WorktreeMetaDialog' + +const REPO_ID = 'repo-1' +const WORKTREE_ID = 'repo-1::/repo/worktrees/feature' + +const initialState = useAppStore.getInitialState() +const updateWorktreeMeta = + vi.fn< + ( + id: string, + updates: Partial + ) => Promise<{ ok: true } | { ok: false; error: string }> + >() +const fetchLinearIssue = vi.fn<(...args: never[]) => Promise>() +const openUrl = vi.fn<(url: string) => void>() + +/** Only `url` is read by the open-issue path. */ +function makeLinearIssue(url: string): LinearIssue { + return { url } as LinearIssue +} + +function makeRepo(id: string = REPO_ID, path: string = '/repo'): Repo { + return { id, path, displayName: 'orca', badgeColor: '#999999', addedAt: 1 } +} + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: WORKTREE_ID, + repoId: REPO_ID, + path: '/repo/worktrees/feature', + displayName: 'Feature work', + branch: 'feature', + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: 'existing note', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + ...overrides + } +} + +function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { + return { + id: 'fw-1', + projectGroupId: 'pg-1', + name: 'Docs folder', + folderPath: '/repo/docs', + linkedTask: { + provider: 'linear', + type: 'issue', + number: 901, + title: 'Fix auth', + url: 'https://linear.app/acme/issue/STA-901', + linearIdentifier: 'STA-901' + }, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function openDialog( + options: { + worktree?: Partial + worktreeId?: string + folderWorkspace?: Partial + /** Extra owners of the same workspace ID, which the index reads as ambiguous. */ + otherRepos?: { repoId: string; worktree?: Partial }[] + modalRepoId?: string + linearViewerOrganizationUrlKey?: string + } = {} +): void { + const worktree = makeWorktree(options.worktree) + const otherRepos = options.otherRepos ?? [] + useAppStore.setState({ + repos: [makeRepo(), ...otherRepos.map((other) => makeRepo(other.repoId, `/${other.repoId}`))], + worktreesByRepo: { + [REPO_ID]: [worktree], + ...Object.fromEntries( + otherRepos.map((other) => [ + other.repoId, + [makeWorktree({ repoId: other.repoId, ...other.worktree })] + ]) + ) + }, + ...(options.folderWorkspace + ? { folderWorkspaces: [makeFolderWorkspace(options.folderWorkspace)] } + : {}), + ...(options.linearViewerOrganizationUrlKey + ? { + linearStatus: { + connected: true, + viewer: { + displayName: 'Viewer', + email: null, + organizationName: 'Active', + organizationUrlKey: options.linearViewerOrganizationUrlKey + } + } + } + : {}), + activeModal: 'edit-meta', + modalData: { + worktreeId: options.worktreeId ?? worktree.id, + ...(options.modalRepoId ? { repoId: options.modalRepoId } : {}), + currentDisplayName: worktree.displayName, + currentComment: worktree.comment, + focus: 'comment' + }, + updateWorktreeMeta, + fetchLinearIssue: fetchLinearIssue as unknown as ReturnType< + typeof useAppStore.getState + >['fetchLinearIssue'] + }) + render() +} + +function issueInput(): HTMLInputElement { + return screen.getByPlaceholderText('Issue #, or a GitHub or Linear URL') +} + +function providerChip(): HTMLButtonElement { + return screen.getByRole('button', { name: 'Issue provider' }) +} + +function saveButton(): HTMLButtonElement { + return screen.getByRole('button', { name: 'Save' }) +} + +function openIssueButton(): HTMLButtonElement { + return screen.getByRole('button', { name: 'Open linked issue' }) +} + +describe('WorktreeMetaDialog issue link row', () => { + beforeEach(() => { + useAppStore.setState(initialState, true) + updateWorktreeMeta.mockReset() + updateWorktreeMeta.mockResolvedValue({ ok: true }) + fetchLinearIssue.mockReset() + fetchLinearIssue.mockResolvedValue(null) + openUrl.mockReset() + Object.defineProperty(window, 'api', { + configurable: true, + value: { shell: { openUrl } } + }) + }) + + afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) + }) + + it('seeds the chip and value from a GitHub link', () => { + openDialog({ worktree: { linkedIssue: 42 } }) + + expect(providerChip().textContent).toContain('GitHub') + expect(issueInput().value).toBe('42') + }) + + it('seeds the chip and value from a Linear link', () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + expect(providerChip().textContent).toContain('Linear') + expect(issueInput().value).toBe('STA-335') + }) + + it('flips to Linear when a linear.app issue URL is pasted', () => { + openDialog({ worktree: { linkedIssue: 42 } }) + + fireEvent.change(issueInput(), { + target: { value: 'https://linear.app/acme/issue/STA-335/fix-the-thing' } + }) + + expect(providerChip().textContent).toContain('Linear') + }) + + // Why: Linear and Jira issue keys are the same shape, so only a URL may steer + // the provider — a bare key must never override the user's explicit choice. + it('keeps the chip on GitHub when a bare issue key is typed', () => { + openDialog({ worktree: { linkedIssue: 42 } }) + + fireEvent.change(issueInput(), { target: { value: 'GH-1234' } }) + + expect(providerChip().textContent).toContain('GitHub') + expect(providerChip().textContent).not.toContain('Linear') + }) + + it('flips to GitHub when a GitHub issue URL is pasted over a Linear link', () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + fireEvent.change(issueInput(), { + target: { value: 'https://github.com/acme/orca/issues/77' } + }) + + expect(providerChip().textContent).toContain('GitHub') + }) + + it('names the Linear issue that switching to GitHub would unlink', () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + fireEvent.click(screen.getByRole('menuitemradio', { name: 'GitHub' })) + fireEvent.change(issueInput(), { target: { value: '99' } }) + + expect( + screen.getByText('Saving unlinks Linear STA-335 — a workspace tracks one issue.') + ).toBeTruthy() + }) + + // Both slots can hold a link at once — naming only one understates the save. + it('names both links when clearing the field would drop both', () => { + openDialog({ worktree: { linkedIssue: 42, linkedLinearIssue: 'STA-335' } }) + + fireEvent.change(issueInput(), { target: { value: '' } }) + + expect( + screen.getByText( + 'Saving unlinks Linear STA-335 and GitHub #42 — a workspace tracks one issue.' + ) + ).toBeTruthy() + }) + + // The warning above only promises the displacement — this asserts the payload + // that carries it out, which is where the one-issue-per-workspace rule lives. + it('clears the displaced GitHub link when a Linear value is saved', async () => { + openDialog({ worktree: { linkedIssue: 42 } }) + + fireEvent.click(screen.getByRole('menuitemradio', { name: 'Linear' })) + fireEvent.change(issueInput(), { target: { value: 'STA-335' } }) + await act(async () => { + fireEvent.click(saveButton()) + }) + + await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)) + const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {} + expect(updates.linkedLinearIssue).toBe('STA-335') + expect(updates.linkedIssue).toBeNull() + }) + + // A GitHub-only save must carry no Linear keys: persistence gates the remote + // Linear capability on key presence, so a synthetic clear fails the save. + it('sends no Linear keys when the workspace has no Linear link', async () => { + openDialog({ worktree: { linkedIssue: 42 } }) + + fireEvent.change(issueInput(), { target: { value: '99' } }) + await act(async () => { + fireEvent.click(saveButton()) + }) + + await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)) + const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {} + expect(updates.linkedIssue).toBe(99) + expect(updates).not.toHaveProperty('linkedLinearIssue') + }) + + // updateWorktreeMeta stamps lastActivityAt on any comment write, which would + // reorder the workspace under the time-decay sidebar sort. + it('sends no comment when only the issue link changed', async () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + fireEvent.change(issueInput(), { target: { value: 'STA-999' } }) + await act(async () => { + fireEvent.click(saveButton()) + }) + + await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)) + expect(updateWorktreeMeta.mock.calls[0]?.[1] ?? {}).not.toHaveProperty('comment') + }) + + // A failed save refetches and reverts the optimistic write, so closing here + // would report success for an edit that silently undid itself. + it('keeps the dialog open and reports why when the save fails', async () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + updateWorktreeMeta.mockResolvedValue({ ok: false, error: 'Runtime is offline' }) + + fireEvent.change(issueInput(), { target: { value: 'STA-999' } }) + await act(async () => { + fireEvent.click(saveButton()) + }) + + expect(screen.getByRole('alert').textContent).toBe('Runtime is offline') + expect(useAppStore.getState().activeModal).toBe('edit-meta') + }) + + it('leaves the Linear link alone when only the comment is edited', async () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + fireEvent.change(screen.getByPlaceholderText('Notes about this worktree...'), { + target: { value: 'updated note' } + }) + + expect(screen.queryByText(/Saving unlinks/)).toBeNull() + + await act(async () => { + fireEvent.click(saveButton()) + }) + + await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)) + const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {} + expect(Object.keys(updates)).not.toContain('linkedLinearIssue') + expect(Object.keys(updates)).not.toContain('linkedIssue') + expect(updates.comment).toBe('updated note') + }) + + it('blocks saving an unparseable Linear value', () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + fireEvent.change(issueInput(), { target: { value: 'not an issue' } }) + + expect(screen.getByText('Not a Linear issue key or linear.app issue URL.')).toBeTruthy() + expect(saveButton().disabled).toBe(true) + }) + + it('is read-only for a folder workspace', () => { + openDialog({ worktreeId: folderWorkspaceKey('fw-1') }) + + expect(issueInput().disabled).toBe(true) + expect(providerChip().disabled).toBe(true) + expect( + screen.getByText( + "Issue links are set when a folder workspace is created and can't be changed here yet." + ) + ).toBeTruthy() + }) + + // Folder workspaces live outside worktreesByRepo, so the indexed lookup alone + // leaves the row blank and the link it does hold looks lost. + it('shows a folder workspace its own linked issue', () => { + openDialog({ worktreeId: folderWorkspaceKey('fw-1'), folderWorkspace: {} }) + + expect(issueInput().value).toBe('STA-901') + expect(providerChip().textContent).toContain('Linear') + }) + + // A background `orca worktree set` must not move the baseline mid-edit: the + // field would read as dirty and a comment-only save would write the stale seed. + it('keeps the baseline frozen when the store changes while open', async () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + act(() => { + useAppStore.setState({ + worktreesByRepo: { + [REPO_ID]: [makeWorktree({ linkedLinearIssue: 'STA-999' })] + } + }) + }) + fireEvent.change(screen.getByPlaceholderText('Notes about this worktree...'), { + target: { value: 'still working' } + }) + await act(async () => { + fireEvent.click(saveButton()) + }) + + const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {} + expect(updates.comment).toBe('still working') + expect(updates).not.toHaveProperty('linkedLinearIssue') + expect(updates).not.toHaveProperty('linkedIssue') + }) + + // A bare key names no organization. Building one from the connected viewer + // opens a not-found page — or a colliding issue — for every other workspace + // the user belongs to, and skips the lookup that would have said so. + it('resolves a bare Linear key across workspaces rather than the active organization', async () => { + fetchLinearIssue.mockResolvedValue(makeLinearIssue('https://linear.app/other/issue/STA-999')) + openDialog({ + worktree: { linkedLinearIssue: 'STA-335' }, + linearViewerOrganizationUrlKey: 'active-org' + }) + + fireEvent.change(issueInput(), { target: { value: 'STA-999' } }) + await act(async () => { + fireEvent.click(openIssueButton()) + }) + + expect(fetchLinearIssue.mock.calls[0]?.slice(0, 2)).toEqual(['STA-999', 'all']) + expect(openUrl).toHaveBeenCalledWith('https://linear.app/other/issue/STA-999') + }) + + // The stored key belongs to the persisted identifier, so it stays authoritative + // for it — no lookup, no round trip. + it('opens a stored Linear link directly from its organization key', async () => { + openDialog({ + worktree: { + linkedLinearIssue: 'STA-335', + linkedLinearIssueOrganizationUrlKey: 'acme' + }, + linearViewerOrganizationUrlKey: 'active-org' + }) + + await act(async () => { + fireEvent.click(openIssueButton()) + }) + + expect(fetchLinearIssue).not.toHaveBeenCalled() + expect(openUrl).toHaveBeenCalledWith('https://linear.app/acme/issue/STA-335') + }) + + // Promise.race cannot cancel the lookup, and the field stays editable while it + // runs — a late result must not open the issue the user just replaced. + it('does not open a lookup result after the field moved on', async () => { + let resolveLookup: ((issue: LinearIssue | null) => void) | undefined + fetchLinearIssue.mockReturnValue( + new Promise((resolve) => { + resolveLookup = resolve + }) + ) + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + fireEvent.change(issueInput(), { target: { value: 'STA-999' } }) + fireEvent.click(openIssueButton()) + fireEvent.change(issueInput(), { target: { value: 'STA-777' } }) + await act(async () => { + resolveLookup?.(makeLinearIssue('https://linear.app/acme/issue/STA-999')) + }) + + expect(openUrl).not.toHaveBeenCalled() + expect( + screen.queryByText( + "Couldn't open that issue. Check the identifier and your Linear connection." + ) + ).toBeNull() + }) + + // Displacement is decided at save time, not at open: a link added by the CLI + // while the dialog sat open must not outlive the save that warned about it. + it('clears a Linear link added while the dialog was open', async () => { + openDialog({ worktree: { linkedIssue: 42 } }) + + act(() => { + useAppStore.setState({ + worktreesByRepo: { + [REPO_ID]: [makeWorktree({ linkedIssue: 42, linkedLinearIssue: 'STA-999' })] + } + }) + }) + fireEvent.change(issueInput(), { target: { value: '99' } }) + + expect( + screen.getByText('Saving unlinks Linear STA-999 — a workspace tracks one issue.') + ).toBeTruthy() + + await act(async () => { + fireEvent.click(saveButton()) + }) + + await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)) + const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {} + expect(updates.linkedIssue).toBe(99) + expect(updates.linkedLinearIssue).toBeNull() + }) + + // Same issue, different spelling: the link is unchanged, so its title and + // SSH/runtime source context must survive the save. + it('keeps the linked work item when the value is only respelled', async () => { + openDialog({ + worktree: { + linkedLinearIssue: 'STA-335', + linkedWorkItem: { + provider: 'linear', + type: 'issue', + number: 335, + title: 'Fix auth', + url: 'https://linear.app/acme/issue/STA-335' + } + } + }) + + fireEvent.change(issueInput(), { target: { value: 'sta-335' } }) + + expect(screen.queryByText(/Saving unlinks/)).toBeNull() + + await act(async () => { + fireEvent.click(saveButton()) + }) + + await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)) + const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {} + expect(updates).not.toHaveProperty('linkedWorkItem') + expect(updates).not.toHaveProperty('linkedTaskSourceContext') + expect(updates).not.toHaveProperty('linkedLinearIssue') + }) + + // The owner index reports a duplicated workspace ID as ambiguous rather than + // guessing, so the opening row has to name its own bucket. + it('shows the clicked row when the same workspace ID exists under two hosts', () => { + openDialog({ + worktree: { linkedIssue: 42 }, + otherRepos: [{ repoId: 'repo-2', worktree: { linkedIssue: 77 } }], + modalRepoId: 'repo-2' + }) + + expect(issueInput().value).toBe('77') + expect(providerChip().textContent).toContain('GitHub') + }) + + it('dispatches nothing when the dialog is cancelled', async () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + fireEvent.change(issueInput(), { target: { value: '99' } }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + }) + + expect(updateWorktreeMeta).not.toHaveBeenCalled() + expect(useAppStore.getState().activeModal).toBe('none') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 5163a839ebf..4445520fd72 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -10,23 +10,40 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { getDisplacedLinkLabels } from './worktree-issue-displacement' import { buildWorktreeMetaUpdates, parseGitHubWorkItemNumberForMetaField, - type WorktreeMetaSavedPayload + type WorktreeMetaDraft, + type WorktreeMetaSavedPayload, + type WorktreeMetaSnapshot } from './worktree-meta-updates' import { useWorktreeIssueLink } from './use-worktree-issue-link' +import { useWorktreeMetaWorkspace } from './use-worktree-meta-workspace' +import { WorktreeIssueLinkField } from './WorktreeIssueLinkField' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import { ExternalLink, LoaderCircle } from 'lucide-react' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' +import { isWorkItemLinkQueryTooLarge } from '../../../../shared/new-workspace/work-item-link-query-bounds' +import { + getIssueLinkProviderFromUrl, + parseIssueLinkInput, + type IssueLinkProvider +} from '../../../../shared/issue-link-input' function resizeCommentTextarea(textarea: HTMLTextAreaElement): void { textarea.style.height = 'auto' textarea.style.height = `${textarea.scrollHeight}px` } +/** Only read before the first open, when nothing can be saved yet. */ +const EMPTY_SNAPSHOT: WorktreeMetaSnapshot = { + displayName: '', + comment: '', + issueInput: '', + issueProvider: 'github' +} + const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const activeModal = useAppStore((s) => s.activeModal) const modalData = useAppStore((s) => s.modalData) @@ -40,9 +57,6 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : '' const currentDisplayName = typeof modalData.currentDisplayName === 'string' ? modalData.currentDisplayName : '' - const currentIssue = - typeof modalData.currentIssue === 'number' ? String(modalData.currentIssue) : '' - const currentPR = typeof modalData.currentPR === 'number' ? String(modalData.currentPR) : '' const currentComment = typeof modalData.currentComment === 'string' ? modalData.currentComment : '' const focusField = typeof modalData.focus === 'string' ? modalData.focus : 'comment' @@ -51,15 +65,44 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { ? (modalData.afterSave as (payload: WorktreeMetaSavedPayload) => void | Promise) : null + // Why: the opening row names its repo bucket for workspace IDs the owner index + // reads as ambiguous across hosts. + const ownerRepoId = typeof modalData.repoId === 'string' ? modalData.repoId : null + const { + worktree, + linkedIssue, + linkedLinearIssue, + currentIssue, + currentProvider, + isFolderWorkspace, + liveLinks + } = useWorktreeMetaWorkspace({ worktreeId, ownerRepoId }) + // Why: ChecksPanel seeds the PR it is looking at, which may not be linked yet. + const currentPR = + typeof modalData.currentPR === 'number' + ? String(modalData.currentPR) + : worktree?.linkedPR != null + ? String(worktree.linkedPR) + : '' + const [displayNameInput, setDisplayNameInput] = useState('') const [issueInput, setIssueInput] = useState('') + const [issueProvider, setIssueProvider] = useState('github') const [prInput, setPrInput] = useState('') const [commentInput, setCommentInput] = useState('') const [saving, setSaving] = useState(false) - const { canOpenIssue, openingIssue, handleOpenIssue, resetOpeningIssue } = useWorktreeIssueLink({ - worktreeId, - issueInput - }) + const [saveError, setSaveError] = useState(null) + const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT) + const { canOpenIssue, openingIssue, openIssueFailed, handleOpenIssue, resetOpeningIssue } = + useWorktreeIssueLink({ + worktreeId, + ownerRepoId, + issueInput, + issueProvider, + linearOrganizationUrlKey: worktree?.linkedLinearIssueOrganizationUrlKey ?? null, + linkedLinearIssue: worktree?.linkedLinearIssue ?? null, + linearSourceContext: worktree?.linkedTaskSourceContext ?? null + }) const issueInputRef = useRef(null) const prInputRef = useRef(null) @@ -70,12 +113,42 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { if (isOpen && !prevIsOpenRef.current) { setDisplayNameInput(currentDisplayName) setIssueInput(currentIssue) + setIssueProvider(currentProvider) setPrInput(currentPR) setCommentInput(currentComment) + // Why: the baseline is frozen with the seed instead of tracking the store. + // A background `orca worktree set --linear-issue` while the dialog is open + // would otherwise move it, making the untouched field read as dirty — and + // the next comment-only save would write the stale seed back over the new link. + setSnapshot({ + displayName: currentDisplayName, + comment: currentComment, + issueInput: currentIssue, + issueProvider: currentProvider, + linkedLinearIssueOrganizationUrlKey: worktree?.linkedLinearIssueOrganizationUrlKey ?? null + }) + setSaveError(null) resetOpeningIssue() } prevIsOpenRef.current = isOpen + const draft = useMemo( + () => ({ displayNameInput, issueInput, issueProvider, prInput, commentInput }), + [displayNameInput, issueInput, issueProvider, prInput, commentInput] + ) + + // Why: a URL names its provider unambiguously. A bare key does not — Linear + // and Jira identifiers are the same shape — so typing never steers the chip. + const handleIssueInputChange = useCallback((next: string) => { + setIssueInput(next) + // Why: the same bound the save gate applies. Detection parses on every + // keystroke, and the field accepts pasted URLs of any length. + const detected = isWorkItemLinkQueryTooLarge(next) ? null : getIssueLinkProviderFromUrl(next) + if (detected) { + setIssueProvider(detected) + } + }, []) + const setCommentTextareaRef = useCallback( (textarea: HTMLTextAreaElement | null) => { textareaRef.current = textarea @@ -92,18 +165,42 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { resizeCommentTextarea(event.currentTarget) }, []) + // Why: bound the parse before it runs on every keystroke — the field accepts + // pasted URLs and has no length cap of its own. + const issueInvalid = useMemo(() => { + const trimmed = issueInput.trim() + if (trimmed === '' || isFolderWorkspace) { + return false + } + return ( + isWorkItemLinkQueryTooLarge(trimmed) || parseIssueLinkInput(trimmed, issueProvider) === null + ) + }, [isFolderWorkspace, issueInput, issueProvider]) + const canSave = useMemo(() => { if (!worktreeId) { return false } - const trimmedIssue = issueInput.trim() const trimmedPR = prInput.trim() - const issueValid = - trimmedIssue === '' || parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue') !== null + // Same quadratic-parse bound as the issue field — this runs on every keystroke. const prValid = - trimmedPR === '' || parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null - return issueValid && prValid - }, [worktreeId, issueInput, prInput]) + trimmedPR === '' || + (!isWorkItemLinkQueryTooLarge(trimmedPR) && + parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null) + return !issueInvalid && prValid + }, [worktreeId, issueInvalid, prInput]) + + const displacedLinkLabels = useMemo( + () => + getDisplacedLinkLabels({ + draft, + snapshot, + isFolderWorkspace, + linkedIssue, + linkedLinearIssue + }), + [draft, snapshot, isFolderWorkspace, linkedIssue, linkedLinearIssue] + ) const handleOpenChange = useCallback( (open: boolean) => { @@ -119,16 +216,22 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { return } setSaving(true) + // Why: a stale failure from the previous attempt must not sit under the + // spinner for the whole in-flight save. + setSaveError(null) try { - const updates = buildWorktreeMetaUpdates({ - displayNameInput, - currentDisplayName, - issueInput, - prInput, - commentInput - }) + const updates = buildWorktreeMetaUpdates(draft, snapshot, liveLinks) - await updateWorktreeMeta(worktreeId, updates) + const result = await updateWorktreeMeta(worktreeId, updates) + // Why: a failed save refetches and reverts the optimistic write. Closing + // here would report success for an edit that silently undid itself, and + // would discard the name, comment and PR changes in the same payload. + if (!result.ok) { + if (mountedRef.current) { + setSaveError(result.error) + } + return + } closeModal() // Why: follow-up refreshes should not turn a successful metadata save // into a failed dialog. @@ -145,11 +248,9 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { }, [ worktreeId, canSave, - displayNameInput, - currentDisplayName, - issueInput, - prInput, - commentInput, + draft, + snapshot, + liveLinks, updateWorktreeMeta, closeModal, afterSave, @@ -204,8 +305,8 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { {translate( - 'auto.components.sidebar.WorktreeMetaDialog.65770ad0f0', - 'Edit GitHub links and notes for this workspace.' + 'auto.components.sidebar.WorktreeMetaDialog.a0d191b7a7', + 'Edit issue links, pull request links, and notes for this workspace.' )} @@ -234,58 +335,21 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {

-
- -
- setIssueInput(e.target.value)} - onKeyDown={handleIssueKeyDown} - placeholder={translate( - 'auto.components.sidebar.WorktreeMetaDialog.741279e7b7', - 'Issue # or GitHub URL' - )} - className="h-8 pr-9 text-xs" - /> - - - - - - {translate( - 'auto.components.sidebar.WorktreeMetaDialog.029ea5ec57', - 'Open GitHub issue' - )} - - -
-

- {translate( - 'auto.components.sidebar.WorktreeMetaDialog.7c454be4c5', - 'Paste an issue URL, or enter a number. Leave blank to remove the link.' - )} -

-
+
+ {saveError ? ( +

+ {saveError} +

+ ) : null} +