diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 0148b3cbafd..61f38c081c4 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -2,7 +2,10 @@ const { chmodSync, existsSync, readdirSync } = require('node:fs') const { execFileSync } = require('node:child_process') const { join, resolve } = require('node:path') const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs') -const { verifyPackagedDaemonEntryBoots } = require('./scripts/verify-packaged-daemon-entry.cjs') +const { + assertPackagedDaemonEntryExists, + verifyPackagedDaemonEntryBoots +} = require('./scripts/verify-packaged-daemon-entry.cjs') const { createPackagedRuntimeNodeModuleResources, prunePackagedRuntimeNodeModules, @@ -141,8 +144,12 @@ module.exports = { if (context.arch === hostArchEnum || context.arch === 4) { verifyPackagedDaemonEntryBoots(resourcesDir) } else { + // Why: a cross-arch slice can't be booted by the host Node, but the + // unpacked entry must still exist — its absence is a layout regression + // regardless of arch, so only the boot is skipped, not the check. + assertPackagedDaemonEntryExists(resourcesDir) console.log( - `[verify-packaged-daemon-entry] skipped cross-arch slice (target ${context.arch}, host ${process.arch})` + `[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})` ) } chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 850b8810f9b..cd46c8003fa 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -353,6 +353,15 @@ describe('electron-builder config', () => { const launcherPath = join(resourcesDir, 'bin', 'orca-ide') await mkdir(join(resourcesDir, 'bin'), { recursive: true }) await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true }) + // Why: afterPack now fails hard when the unpacked daemon entry is + // missing, so the fixture must carry one like a real package layout. + const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') + await mkdir(unpackedMainDir, { recursive: true }) + await writeFile( + join(unpackedMainDir, 'daemon-entry.js'), + 'console.error("Usage: daemon-entry "); process.exit(1)\n', + 'utf8' + ) await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 }) await electronBuilderConfig.afterPack({ diff --git a/config/scripts/verify-packaged-daemon-entry.cjs b/config/scripts/verify-packaged-daemon-entry.cjs index 6ad91285ca3..8f5e802825c 100644 --- a/config/scripts/verify-packaged-daemon-entry.cjs +++ b/config/scripts/verify-packaged-daemon-entry.cjs @@ -2,6 +2,24 @@ const { existsSync } = require('node:fs') const { spawnSync } = require('node:child_process') const { join } = require('node:path') +// Why: `asarUnpack` in config/electron-builder.config.cjs lists +// out/main/daemon-entry.js on every platform, and the packaged daemon fork +// (src/main/daemon/daemon-init.ts) resolves exactly this unpacked path. A +// missing entry means the package layout regressed, so the check throws +// instead of skipping — a silent skip false-passed exactly the layout bug +// this gate exists to catch. +function assertPackagedDaemonEntryExists(resourcesDir) { + const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') + if (!existsSync(entryPath)) { + throw new Error( + `[verify-packaged-daemon-entry] missing unpacked daemon entry at ${entryPath} — ` + + `asarUnpack expects out/main/daemon-entry.js on every platform, so the packaged ` + + `daemon cannot be forked from this layout` + ) + } + return entryPath +} + // Why: v1.4.129-rc.1 shipped a terminal daemon that could not load (an electron // `require` leaked into its bundle) while every build check passed. This boots // the PACKAGED daemon-entry under plain Node against the asar-unpacked layout, @@ -14,13 +32,7 @@ const { join } = require('node:path') // /resources elsewhere). execPath defaults to the packaging Node. function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) { const execPath = options.execPath || process.execPath - const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js') - if (!existsSync(entryPath)) { - // Why: some targets/layouts do not unpack here; skip rather than fail so - // the hook stays safe across platforms it has not verified. - console.log(`[verify-packaged-daemon-entry] skipped — no unpacked entry at ${entryPath}`) - return - } + const entryPath = assertPackagedDaemonEntryExists(resourcesDir) const result = spawnSync(execPath, [entryPath], { encoding: 'utf8', timeout: 10_000 }) if (result.error) { @@ -43,4 +55,4 @@ function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) { console.log('[verify-packaged-daemon-entry] OK — packaged daemon-entry loads under plain Node') } -module.exports = { verifyPackagedDaemonEntryBoots } +module.exports = { assertPackagedDaemonEntryExists, verifyPackagedDaemonEntryBoots } diff --git a/config/scripts/verify-packaged-daemon-entry.test.mjs b/config/scripts/verify-packaged-daemon-entry.test.mjs new file mode 100644 index 00000000000..8705953ba61 --- /dev/null +++ b/config/scripts/verify-packaged-daemon-entry.test.mjs @@ -0,0 +1,57 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + assertPackagedDaemonEntryExists, + verifyPackagedDaemonEntryBoots +} = require('./verify-packaged-daemon-entry.cjs') + +describe('verify-packaged-daemon-entry', () => { + let resourcesDir + + beforeEach(() => { + resourcesDir = mkdtempSync(join(tmpdir(), 'orca-daemon-entry-verify-')) + }) + + afterEach(() => { + rmSync(resourcesDir, { recursive: true, force: true }) + }) + + function writePackagedEntry(source) { + const entryDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main') + mkdirSync(entryDir, { recursive: true }) + writeFileSync(join(entryDir, 'daemon-entry.js'), source) + } + + // Why: a silent skip on a missing entry false-passed exactly the packaged + // layout regression this gate exists to catch (rc.1 daemon-load incident). + it('throws when the unpacked daemon entry is missing', () => { + expect(() => assertPackagedDaemonEntryExists(resourcesDir)).toThrow( + /missing unpacked daemon entry/ + ) + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow( + /missing unpacked daemon entry/ + ) + }) + + it('passes when the packaged entry loads and reaches argv parsing', () => { + writePackagedEntry('console.error("Usage: daemon-entry "); process.exit(1)\n') + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).not.toThrow() + }) + + it('fails when the packaged entry cannot resolve its module graph', () => { + writePackagedEntry('require("orca-module-that-does-not-exist")\n') + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow( + /failed to load under plain Node/ + ) + }) + + it('fails when the packaged entry never reaches argv parsing', () => { + writePackagedEntry('process.exit(0)\n') + expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow(/did not reach argv parsing/) + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 21cae9319cc..8d92fa208ca 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -14,6 +14,8 @@ "../src/main/codex/codex-config-path-reference-rewrite.ts", "../src/main/codex/codex-home-paths.ts", "../src/main/codex/codex-hook-identity.ts", + "../src/main/codex/codex-wsl-hook-install-plan.ts", + "../src/main/codex/config-settings-promotion.ts", "../src/main/codex/config-toml-line-scan.ts", "../src/main/codex/config-toml-trust.ts", "../src/main/codex/hook-service.ts", diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index b712f768dce..1333939252a 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 4.1m + + downloads: 4.2m @@ -15,7 +15,7 @@ downloads downloads - 4.1m - 4.1m + 4.2m + 4.2m diff --git a/docs/issue-7649-vscode-wsl-launch.md b/docs/issue-7649-vscode-wsl-launch.md new file mode 100644 index 00000000000..4643672e41e --- /dev/null +++ b/docs/issue-7649-vscode-wsl-launch.md @@ -0,0 +1,95 @@ +# VS Code WSL Workspace Launch + +## Problem + +On Windows, choosing **Open in VS Code** for a workspace stored under a WSL UNC path opens the folder in a Windows VS Code environment instead of a Remote - WSL window ([issue #7649](https://github.com/stablyai/orca/issues/7649)). The renderer passes the workspace path and configured editor command unchanged (`src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx:103-121`), and the main process delegates launch argument construction to `resolveExternalEditorLaunchSpec` (`src/main/ipc/shell.ts:101-110`). The builder currently gives every non-Cursor executable only the original path (`src/main/external-editor-launch.ts:109-116`). + +The deterministic reproduction for `\\wsl.localhost\Ubuntu\home\aliuq\project` produces `code ` with no remote authority. VS Code's supported Windows CLI form is `code --remote wsl+ `. + +## Root cause + +`resolveExternalEditorLaunchSpec` does not distinguish a VS Code launch targeting a WSL UNC workspace. Orca already has a shared parser for both modern `\\wsl.localhost\...` and legacy `\\wsl$\...` paths (`src/shared/wsl-paths.ts:1-20`), but the editor launcher never uses it. VS Code therefore receives the Windows-visible UNC folder and correctly opens it as a local Windows workspace. + +## Non-goals + +- Change the Open in menu, settings UI, IPC payload, file-manager behavior, or SSH/remote-runtime behavior. +- Infer WSL identity for ordinary drive-letter paths from project runtime settings. +- Rewrite user-defined compound shell commands or add remote flags to Cursor, VSCodium, or arbitrary editors. +- Install or configure the VS Code WSL extension. + +## Design + +1. In the external-editor launch-spec builder, parse the target path with the shared WSL UNC parser when the host platform is Windows. +2. For direct/executable VS Code Stable or Insiders launchers only, reuse the existing normalized launcher-basename check and translate a recognized WSL target into `['--remote', 'wsl+', '']`. The exact allowlist recognizes Stable's `code`, Insiders' `code-insiders`, and the direct `Code - Insiders.exe` basename without matching unrelated `code-*` editors. Matching is case-insensitive and strips every Windows launcher suffix already supported by Orca (`.cmd`, `.exe`, and `.bat`). +3. Keep local paths, non-Windows hosts, non-VS-Code applications, and compound commands on their existing argument paths. The existing main-process spawn and Windows shim handling remain unchanged. +4. Replace the temporary reproduction harness with focused regression cases in `src/main/external-editor-launch.test.ts` covering modern and legacy WSL UNC forms plus unaffected local/custom-editor behavior. + +## Data flow + +- Worktree menu selects VS Code and sends `(workspacePath, 'code')` over existing IPC. +- Main process validates the absolute existing host path. +- Launch-spec builder resolves the VS Code executable. +- On Windows + WSL UNC + VS Code, the builder emits the Remote - WSL authority and Linux-native folder path. +- Existing Windows spawn wrapping launches VS Code with those arguments. + +## Edge cases + +- Modern `\\wsl.localhost\\...` and legacy `\\wsl$\\...` paths both preserve the distro spelling and convert separators to a POSIX path. +- Distro names and Linux folder paths containing spaces remain single arguments because launch-spec construction and Windows shim wrapping preserve argument-array boundaries. +- A WSL distro root maps to `/`. +- Windows drive-letter and ordinary UNC paths stay local. +- macOS/Linux behavior stays unchanged even for strings that resemble WSL UNC paths. +- Cursor retains `--new-window`; other custom editors retain their existing single path argument. +- Compound commands remain user-owned and are not rewritten because inserting flags safely would require parsing arbitrary shell syntax. +- SSH and remote-runtime workspaces remain blocked from local path opening by the existing renderer guard; this change does not alter that boundary. +- If the VS Code WSL extension is unavailable, launch behavior is left to VS Code and Orca retains its existing spawn-success contract. + +## Test plan + +- Unit: demonstrate the pre-fix launch spec lacks `--remote` for a modern WSL UNC path. +- Unit: assert the fixed modern UNC launch is `--remote`, `wsl+Ubuntu`, `/home/...`. +- Unit: assert legacy `\\wsl$` and distro-root paths produce the same Remote - WSL form. +- Unit: assert direct and resolved Windows VS Code Stable and Insiders launchers are matched case-insensitively across `.exe`, `.cmd`, and `.bat` suffixes. +- Unit: assert distro names and Linux folder paths containing spaces remain intact arguments through launch-spec construction and Windows shim forwarding. +- Unit: assert a Windows local path remains unchanged and explicit `darwin` and `linux` hosts do not acquire WSL remote arguments. +- Unit: assert Cursor and another custom editor are not given VS Code remote arguments. +- Integration/Electron: create a throwaway repo in the installed Ubuntu WSL distro, add/open it in Orca, choose Open in VS Code, and verify the VS Code remote indicator and an integrated-terminal Linux probe. +- Adjacent smoke: open a local Windows workspace in VS Code and verify it remains a local Windows window. +- Repository gates: focused Vitest files, `pnpm typecheck`, `pnpm lint`, and `pnpm check:max-lines-ratchet`. + +## UI quality bar + +Not UI-visible in Orca. The existing menu, labels, loading behavior, and errors do not change. The user-visible acceptance criterion is external: the launched VS Code window must identify the selected WSL distro and its terminal must run Linux. + +## Review screenshots + +1. Golden path: VS Code opened from a throwaway WSL workspace, showing the Remote - WSL indicator and a terminal Linux probe. +2. Adjacent local path: VS Code opened from a local Windows workspace, showing a local Windows terminal/environment. + +## Rollout + +1. Add launch-spec regression tests and observe the WSL case fail against the desired arguments. +2. Add the scoped VS Code + Windows + WSL argument translation. +3. Run focused and repository-wide static/test gates. +4. Validate WSL and local launches end to end, retaining local screenshots outside the PR. + +## Lightweight Eng Review + +- Scope: Kept to the launch-spec seam; no renderer, IPC, persistence, runtime-routing, or settings changes are needed because WSL filesystem identity is encoded in the UNC path. +- Architecture/data flow: Reuse `parseWslUncPath` in the main-process pure argument builder, then leave executable resolution, Windows shim wrapping, spawn lifecycle, and renderer guards unchanged. +- Failure modes covered: + - Modern and legacy WSL UNC aliases route to the correct distro and Linux path. + - Local/ordinary UNC paths and non-Windows platforms do not acquire remote flags. + - Case variants and supported Windows VS Code shim suffixes are recognized without classifying custom editors or compound shell commands as VS Code. + - Distro and folder names containing spaces survive the Windows shim as distinct arguments. + - Missing VS Code WSL support still follows the existing launch contract rather than adding a new partial-failure protocol. +- Test coverage required: + - Pure launch-spec regression cases in `src/main/external-editor-launch.test.ts` for WSL aliases/root, launcher case/suffix variants, and unaffected branches. + - `src/main/ipc/shell.test.ts` plus the existing Windows shim contract to prove remote arguments, including spaces, are forwarded as distinct values. + - Live Windows + Ubuntu WSL + VS Code smoke for the actual environment boundary. +- Performance/blast radius: One regex parse per external-editor click only; no startup, polling, watcher, terminal, or renderer cost. Blast radius is limited to direct VS Code launches of WSL UNC paths on Windows. +- UI quality bar: Not UI-visible in Orca; VS Code must visibly attach to the requested WSL distro and run a Linux terminal. +- Required review screenshots: + 1. WSL VS Code window with distro indicator and Linux terminal probe. + 2. Local Windows VS Code window demonstrating unchanged local launch behavior. +- Residual risks: VS Code without the WSL extension may reject or prompt on the valid remote launch; this is external dependency behavior and should not cause Orca to fall back silently to the wrong Windows environment. diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index f47ec591057..d1c5ebff14a 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -177,6 +177,7 @@ export default function RootLayout() { + diff --git a/mobile/app/connection-log.tsx b/mobile/app/connection-log.tsx new file mode 100644 index 00000000000..cc19497e080 --- /dev/null +++ b/mobile/app/connection-log.tsx @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' +import { View, Text, StyleSheet, Pressable, Platform } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import * as Clipboard from 'expo-clipboard' +import Constants from 'expo-constants' +import { ChevronLeft, Copy, Check } from 'lucide-react-native' +import { colors, spacing, typography } from '../src/theme/mobile-theme' +import { ConnectionLog } from '../src/components/ConnectionLog' +import { loadHosts } from '../src/transport/host-store' +import { connectionLogStore } from '../src/transport/connection-log-buffer' +import { + useHostClient, + useLastConnectedAt, + useReconnectAttempt +} from '../src/transport/client-context' +import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report' +import type { ConnectionLogEntry, HostProfile } from '../src/transport/types' + +// Why: getSnapshot must be referentially stable when there's no data — +// a fresh [] per call would make useSyncExternalStore re-render forever. +const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = [] + +// Why: reading the log is most needed while a host is failing, so this +// screen also *acquires* the host client — opening it kicks a dial and the +// log fills live instead of showing a stale tail. +export default function ConnectionLogScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + const [hosts, setHosts] = useState([]) + const [selectedId, setSelectedId] = useState(null) + const [copied, setCopied] = useState(false) + + useEffect(() => { + let stale = false + void loadHosts().then((loaded) => { + if (stale) { + return + } + setHosts(loaded) + setSelectedId((prev) => prev ?? loaded[0]?.id ?? null) + }) + return () => { + stale = true + } + }, []) + + const selected = hosts.find((h) => h.id === selectedId) ?? null + const { state } = useHostClient(selected?.id) + const reconnectAttempts = useReconnectAttempt(selected?.id) + const lastConnectedAt = useLastConnectedAt(selected?.id) + + const subscribe = useCallback( + (listener: () => void) => + selectedId ? connectionLogStore.subscribe(selectedId, listener) : () => {}, + [selectedId] + ) + const getSnapshot = useCallback( + () => (selectedId ? connectionLogStore.get(selectedId) : EMPTY_ENTRIES), + [selectedId] + ) + const entries = useSyncExternalStore(subscribe, getSnapshot) + + const copyDiagnostics = useCallback(async () => { + if (!selected) { + return + } + const report = buildConnectionDiagnosticsReport({ + hostName: selected.name, + endpoint: selected.endpoint, + state, + reconnectAttempts, + lastConnectedAt, + platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(), + appVersion: Constants.expoConfig?.version ?? 'unknown', + entries + }) + await Clipboard.setStringAsync(report) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }, [selected, state, reconnectAttempts, lastConnectedAt, entries]) + + return ( + + + router.back()}> + + + Connection log + + + {hosts.length > 1 && ( + + {hosts.map((host) => ( + setSelectedId(host.id)} + > + + {host.name} + + + ))} + + )} + + {selected ? ( + <> + + + {state} + {reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''} + + void copyDiagnostics()}> + {copied ? ( + + ) : ( + + )} + {copied ? 'Copied' : 'Copy diagnostics'} + + + {entries.length > 0 ? ( + + ) : ( + + No connection events yet this session. Events appear as the app dials this host. + + )} + + ) : ( + No paired hosts. + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + hostPicker: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + marginBottom: spacing.md + }, + hostChip: { + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + borderRadius: 16, + backgroundColor: colors.bgRaised + }, + hostChipActive: { + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + hostChipText: { + fontSize: typography.metaSize, + color: colors.textSecondary, + maxWidth: 160 + }, + hostChipTextActive: { + color: colors.textPrimary, + fontWeight: '600' + }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm + }, + statusText: { + fontSize: typography.metaSize, + color: colors.textSecondary + }, + copyButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + 2, + paddingVertical: spacing.xs + 2, + paddingHorizontal: spacing.md, + borderRadius: 8, + backgroundColor: colors.bgRaised + }, + copyButtonText: { + fontSize: typography.metaSize, + fontWeight: '600', + color: colors.textPrimary + }, + emptyText: { + fontSize: typography.metaSize, + color: colors.textMuted, + lineHeight: 18 + } +}) diff --git a/mobile/app/h/[hostId]/history/[worktreeId].tsx b/mobile/app/h/[hostId]/history/[worktreeId].tsx index ce7309eff0f..19198e4e5f1 100644 --- a/mobile/app/h/[hostId]/history/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/history/[worktreeId].tsx @@ -1,242 +1,28 @@ -import { useCallback, useEffect, useState } from 'react' -import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useLocalSearchParams, useRouter } from 'expo-router' -import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react-native' -import { useForceReconnect, useHostClient } from '../../../../src/transport/client-context' -import type { RpcSuccess } from '../../../../src/transport/types' -import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme' -import { - fetchMobileGitHistory, - mapMobileCommitRows, - type MobileCommitRow -} from '../../../../src/source-control/mobile-git-history' -import { resolveMobileHistoryScreenView } from '../../../../src/source-control/mobile-history-screen-state' -import type { GitBranchChangeEntry } from '../../../../../src/shared/types' +import { Redirect, useLocalSearchParams } from 'expo-router' +import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' -function firstParam(value: string | string[] | undefined): string { - return Array.isArray(value) ? (value[0] ?? '') : (value ?? '') -} - -export default function HistoryScreen() { +// History is now a segment of the Source Control hub. This route stays as a thin +// redirect so existing deep links (and any cached navigation) land on the hub with +// the History segment selected. +export default function HistoryRedirect() { const params = useLocalSearchParams<{ hostId?: string | string[] worktreeId?: string | string[] + name?: string | string[] + origin?: string | string[] }>() - const hostId = firstParam(params.hostId) - const worktreeId = firstParam(params.worktreeId) - const router = useRouter() - const insets = useSafeAreaInsets() - const { client, state: connState } = useHostClient(hostId) - const forceReconnect = useForceReconnect() - - const [rows, setRows] = useState(null) - const [error, setError] = useState(null) - const [reloadNonce, setReloadNonce] = useState(0) - const [expanded, setExpanded] = useState(null) - const [filesById, setFilesById] = useState>({}) - - useEffect(() => { - let active = true - if (!client || connState !== 'connected' || !worktreeId) { - return - } - // Reset prior error/rows so a successful retry doesn't stay stuck behind a - // stale error (error wins render precedence). - setError(null) - setRows(null) - void (async () => { - try { - const result = await fetchMobileGitHistory(client, worktreeId) - if (active) { - setRows(mapMobileCommitRows(result, Date.now())) - } - } catch (err) { - if (active) { - setError(err instanceof Error ? err.message : 'Failed to load history') - } - } - })() - return () => { - active = false - } - }, [client, connState, reloadNonce, worktreeId]) - - const retry = useCallback(() => { - setError(null) - // Why: retrying the fetch is useless while the transport's reconnect loop - // is parked at its backoff cap — revive the connection instead (mirrors - // MobileSourceControlPanel / issue #5049). The load effect re-runs via - // connState once the fresh client connects. - if (connState !== 'connected' && hostId) { - void forceReconnect(hostId) - return - } - setReloadNonce((n) => n + 1) - }, [connState, forceReconnect, hostId]) - - const toggleCommit = useCallback( - (row: MobileCommitRow) => { - const next = expanded === row.id ? null : row.id - setExpanded(next) - if (next && client && !filesById[row.id]) { - setFilesById((prev) => ({ ...prev, [row.id]: 'loading' })) - void client - .sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id }) - .then((response) => { - const entries = response.ok - ? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries - : [] - setFilesById((prev) => ({ ...prev, [row.id]: entries })) - }) - .catch(() => setFilesById((prev) => ({ ...prev, [row.id]: [] }))) - } - }, - [client, expanded, filesById, worktreeId] - ) - - const renderCommit = useCallback( - ({ item }: { item: MobileCommitRow }) => { - const files = filesById[item.id] - const isOpen = expanded === item.id - return ( - - [styles.commitHeader, pressed && styles.commitHeaderPressed]} - onPress={() => toggleCommit(item)} - > - {isOpen ? ( - - ) : ( - - )} - - - {item.subject} - - - {item.shortId} · {item.author} · {item.relativeTime} - - - - {isOpen ? ( - - {files === 'loading' || files === undefined ? ( - - ) : files.length === 0 ? ( - No file changes - ) : ( - files.map((file) => ( - - - {file.path} - - - {file.added ? +{file.added} : null} - {file.removed ? -{file.removed} : null} - - - )) - )} - - ) : null} - - ) - }, - [expanded, filesById, toggleCommit] - ) - - const view = resolveMobileHistoryScreenView({ - connected: client !== null && connState === 'connected', - rows, - error - }) - return ( - - - router.back()} accessibilityLabel="Back"> - - - Commit History - - {view.kind === 'error' || view.kind === 'waiting' ? ( - - - {view.kind === 'waiting' ? 'Waiting for desktop...' : view.message} - - - Retry - - - ) : view.kind === 'loading' ? ( - - - - ) : view.kind === 'empty' ? ( - - No commits. - - ) : ( - row.id} - contentContainerStyle={{ paddingBottom: spacing.lg + insets.bottom }} - /> - )} - + ) } - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: colors.bgBase }, - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - gap: spacing.sm - }, - back: { padding: spacing.xs }, - title: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, - state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg }, - stateText: { color: colors.textMuted, fontSize: typography.bodySize }, - retryButton: { - marginTop: spacing.md, - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - borderRadius: radii.button, - backgroundColor: colors.bgRaised - }, - retryText: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, - commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle }, - commitHeader: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm + 2 - }, - commitHeaderPressed: { backgroundColor: colors.bgRaised }, - commitMain: { flex: 1, minWidth: 0 }, - commitSubject: { color: colors.textPrimary, fontSize: typography.bodySize }, - commitMeta: { - color: colors.textMuted, - fontSize: typography.metaSize, - fontFamily: typography.monoFamily, - marginTop: 2 - }, - files: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: 4 }, - fileRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, - filePath: { - flex: 1, - color: colors.textSecondary, - fontSize: typography.metaSize, - fontFamily: typography.monoFamily - }, - fileStat: { fontSize: typography.metaSize, fontFamily: typography.monoFamily }, - add: { color: colors.gitDecorationAdded }, - del: { color: colors.gitDecorationDeleted }, - empty: { color: colors.textMuted, fontSize: typography.metaSize } -}) diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 16baabe9177..71bda7b64dc 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -552,6 +552,17 @@ export function HostScreen({ } }, [connState, client]) + useFocusEffect( + useCallback(() => { + // Why: opening the host is a strong user signal — reset a backed-off or + // trickling reconnect loop (and probe a possibly half-open socket) + // immediately instead of waiting out its timer. Deps stay empty so this + // fires per focus transition, not per connection-state change; nudging + // on every reconnecting↔connecting flip would defeat the backoff. + clientRef.current?.notifyForeground() + }, []) + ) + useFocusEffect( useCallback(() => { // The embedded sidebar drives its own polling below; focus never fires diff --git a/mobile/app/h/[hostId]/pr/[worktreeId].tsx b/mobile/app/h/[hostId]/pr/[worktreeId].tsx index 2e0530b0f4b..682e4d3481c 100644 --- a/mobile/app/h/[hostId]/pr/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/pr/[worktreeId].tsx @@ -1,30 +1,29 @@ -import { useLocalSearchParams } from 'expo-router' -import { useHostClient } from '../../../../src/transport/client-context' -import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context' -import { MobilePrViewPanel } from '../../../../src/components/pr-sidebar/MobilePrViewPanel' - -// Narrow-layout full-screen PR route. The standalone panel can't ride on the review -// screen's diff state, so branch/head SHA are resolved here from git.status + branchCompare. -export default function MobilePrViewScreen() { - const { hostId, worktreeId } = useLocalSearchParams<{ hostId: string; worktreeId: string }>() - const { client, state: connState } = useHostClient(hostId) - const { branch, headSha, status, isGithubRepo, repoLoaded, loaded } = useMobilePrBranchContext({ - client, - connState, - worktreeId - }) +import { Redirect, useLocalSearchParams } from 'expo-router' +import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' +// The Pull Request view is now a segment of the Source Control hub. This route +// stays as a thin redirect so existing deep links land on the hub with the Pull +// Request segment selected. The wide-layout dock opens the same hub with +// initialTab="pr" (SessionDockColumn), not this route. +export default function PrRedirect() { + const params = useLocalSearchParams<{ + hostId?: string | string[] + worktreeId?: string | string[] + name?: string | string[] + origin?: string | string[] + }>() return ( - ) } diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index c4701a302db..da2aae83fce 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -62,7 +62,10 @@ import { useReconnectAttempt, useLastConnectedAt } from '../../../../src/transport/client-context' -import { classifyConnection } from '../../../../src/transport/connection-health' +import { + classifyConnection, + verdictDisplayLabel +} from '../../../../src/transport/connection-health' import { useResponsiveLayout } from '../../../../src/layout/responsive-layout' import { type ActivePanel, @@ -853,20 +856,16 @@ export default function SessionScreen() { setActivePanel(null) } }, [canDockPanel, activePanel]) - // Session-level PR context feeds the docked PR panel and gates the GitHub-only - // PR entry so GitLab/other providers do not open a GitHub RPC surface. - const { - branch: prBranch, - headSha: prHeadSha, - status: prStatus, - isGithubRepo: prIsGithubRepo, - repoLoaded: prRepoContextLoaded, - loaded: prContextLoaded - } = useMobilePrBranchContext({ - client, - connState, - worktreeId - }) + // Session-level GitHub remote probe gates the PR dock icon so non-GitHub + // providers do not open the hosted-review surface. Branch/head/status for the + // hub are loaded inside MobileSourceControlPanel — skip the unused identity RPCs. + const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } = + useMobilePrBranchContext({ + client, + connState, + worktreeId, + includeBranchIdentity: false + }) useEffect(() => { if (prRepoContextLoaded && !prIsGithubRepo && activePanel === 'pr') { setActivePanel(null) @@ -1000,6 +999,9 @@ export default function SessionScreen() { const terminalCwdRef = useRef>(new Map()) const initialModesSeenRef = useRef>(new Set()) const deviceTokenRef = useRef(null) + // Why: state (not a ref) — the connection verdict needs a re-render once + // the endpoint loads so the Tailscale hint can appear. + const [hostEndpoint, setHostEndpoint] = useState(null) const clientRef = useRef(null) const connStateRef = useRef(connState) // Why: measured once from TerminalWebView on mount, then passed with every @@ -2453,6 +2455,7 @@ export default function SessionScreen() { const host = hosts.find((h) => h.id === hostId) if (host) { deviceTokenRef.current = host.deviceToken + setHostEndpoint(host.endpoint) } }) return () => { @@ -4265,13 +4268,14 @@ export default function SessionScreen() { void handleCreateTerminal() }, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId]) - // Why: the reconnect loop parks at its give-up cap; without an in-session - // affordance the only recovery is leaving the screen or restarting the - // app (issue #5049). Surface tap-to-retry once the verdict escalates. + // Why: the reconnect loop slows to a 90s trickle at its give-up cap; + // surface tap-to-retry once the verdict escalates so recovery doesn't + // wait out the trickle timer (issue #5049). const connectionVerdict = classifyConnection({ state: connState, reconnectAttempts, - lastConnectedAt + lastConnectedAt, + endpoint: hostEndpoint }) const showConnectionRetry = connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable' @@ -4284,7 +4288,7 @@ export default function SessionScreen() { ? '1 tab' : `${visibleTabs.length} tabs` : showConnectionRetry - ? `${connectionVerdict.label} — tap to retry` + ? `${verdictDisplayLabel(connectionVerdict)} — tap to retry` : MOBILE_SESSION_STATUS_LABELS[connState] // Why: keep safe-area padding in layout at all times, then visually translate @@ -4423,14 +4427,18 @@ export default function SessionScreen() { setActivePanel(action.next) return } + const descriptor = panelRouteDescriptor(action.panel) router.push({ - pathname: panelRouteDescriptor(action.panel).pathname, + pathname: descriptor.pathname, params: { hostId, worktreeId, name: worktreeName || '', - // Source control's post-diff-open dismissal keys off origin: 'session' (U2). - ...(action.panel === 'sourceControl' ? { origin: 'session' } : {}) + // SC + PR both land on the source-control hub; post-diff-open dismissal + // keys off origin: 'session' (U2). Files keeps its own route without origin. + ...(action.panel === 'sourceControl' || action.panel === 'pr' ? { origin: 'session' } : {}), + // The PR panel routes into the hub's Pull Request segment via descriptor params. + ...descriptor.params } }) } @@ -5116,13 +5124,6 @@ export default function SessionScreen() { hostId={hostId} worktreeId={worktreeId} name={worktreeName || ''} - client={client} - connState={connState} - branch={prBranch} - headSha={prHeadSha} - gitStatus={prStatus} - isGithubRepo={prIsGithubRepo} - branchContextLoaded={prContextLoaded && prRepoContextLoaded} availableWidth={sessionContentRowWidth} onRequestClose={() => setActivePanel(null)} onFileOpenStart={handleFileOpenStart} diff --git a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx index b9346eecaa5..1de11662dd8 100644 --- a/mobile/app/h/[hostId]/source-control/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/source-control/[worktreeId].tsx @@ -1,6 +1,7 @@ import { useLocalSearchParams } from 'expo-router' import { MobileSourceControlPanel } from '../../../../src/source-control/MobileSourceControlPanel' import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' +import { parseSourceControlHubTab } from '../../../../src/source-control/mobile-source-control-hub-tab' export default function MobileSourceControlScreen() { const params = useLocalSearchParams<{ @@ -8,6 +9,7 @@ export default function MobileSourceControlScreen() { worktreeId?: string | string[] name?: string | string[] origin?: string | string[] + tab?: string | string[] }>() return ( ) diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index acaf97b7646..eaee0e5f935 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -34,7 +34,7 @@ import { useForceReconnect, usePrimeHosts } from '../src/transport/client-context' -import { classifyConnection } from '../src/transport/connection-health' +import { classifyConnection, verdictDisplayLabel } from '../src/transport/connection-health' import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' import type { ConnectionState, HostProfile } from '../src/transport/types' import { triggerMediumImpact } from '../src/platform/haptics' @@ -827,7 +827,8 @@ export default function HomeScreen() { const verdict = classifyConnection({ state, reconnectAttempts: attempts, - lastConnectedAt + lastConnectedAt, + endpoint: item.endpoint }) const isError = verdict.kind === 'warning' || @@ -859,7 +860,7 @@ export default function HomeScreen() { - {verdict.label} + {verdictDisplayLabel(verdict)} {connected && info ? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}` : ''} diff --git a/mobile/app/troubleshoot.tsx b/mobile/app/troubleshoot.tsx index 0a1a63b7c27..5ae53e0084c 100644 --- a/mobile/app/troubleshoot.tsx +++ b/mobile/app/troubleshoot.tsx @@ -14,13 +14,9 @@ import { ChevronLeft, ChevronDown, ChevronUp, - WifiOff, - Shield, - Monitor, - Clock, - Globe, Activity, CheckCircle2, + ScrollText, XCircle, AlertTriangle } from 'lucide-react-native' @@ -30,7 +26,12 @@ import { startDiagnosticFetchTimeout, type DiagnosticFetchTimeout } from '../src/diagnostics/diagnostic-fetch-timeout' -import { formatEndpoint, testHostReachability } from '../src/diagnostics/host-reachability' +import { + formatEndpoint, + testHostReachability, + unreachableHostDetail +} from '../src/diagnostics/host-reachability' +import { troubleshootCommonIssues } from '../src/diagnostics/troubleshoot-common-issues' type DiagnosticStatus = 'idle' | 'running' | 'done' @@ -40,66 +41,6 @@ type CheckResult = { detail: string } -type TroubleshootSection = { - id: string - icon: React.ReactNode - title: string - steps: string[] -} - -const sections: TroubleshootSection[] = [ - { - id: 'wifi', - icon: , - title: 'Different WiFi Networks', - steps: [ - 'Both devices must be on the same local network.', - 'Ethernet and WiFi must share the same subnet.', - 'Try reconnecting WiFi on both devices.' - ] - }, - { - id: 'firewall', - icon: , - title: 'Firewall Blocking Port 6768', - steps: [ - 'macOS: System Settings → Network → Firewall — allow Orca.', - 'Windows: Defender Firewall → Allow app — enable Orca for Private networks.', - 'Linux: sudo ufw allow 6768', - 'Corporate/school networks may block P2P — try a personal hotspot.' - ] - }, - { - id: 'desktop', - icon: , - title: 'Desktop App Not Running', - steps: [ - 'Orca must be open on your desktop to accept connections.', - 'Try restarting Orca — the companion server starts on launch.', - 'After an update, you may need to re-pair via QR code.' - ] - }, - { - id: 'timeout', - icon: , - title: 'Connection Timeout', - steps: [ - 'Check WiFi signal strength on your phone.', - 'Go back to the host list and tap your host to retry.', - 'Restart both apps if timeouts persist.' - ] - }, - { - id: 'vpn', - icon: , - title: 'VPN Interference', - steps: [ - 'VPNs can route local traffic through a remote server.', - 'Disable the VPN or enable split tunneling / "Allow LAN".' - ] - } -] - function StatusIcon({ status }: { status: CheckResult['status'] }) { switch (status) { case 'pass': @@ -211,7 +152,7 @@ export default function TroubleshootScreen() { status: reachable ? 'pass' : 'fail', detail: reachable ? `Reachable at ${formatEndpoint(host.endpoint)}` - : `Cannot reach ${formatEndpoint(host.endpoint)}` + : unreachableHostDetail(host.endpoint) }) setChecks([...results]) } @@ -273,6 +214,17 @@ export default function TroubleshootScreen() { + [ + styles.diagnosticButton, + pressed && styles.diagnosticButtonPressed + ]} + onPress={() => router.push('/connection-log')} + > + + View connection log + + {checks.length > 0 && ( {checks.map((check, i) => ( @@ -295,7 +247,7 @@ export default function TroubleshootScreen() { Common issues - {sections.map((section, i) => ( + {troubleshootCommonIssues.map((section, i) => ( {i > 0 && } + + + + + Orca Mobile – Source Control Hub + + + +
+ +
+ 9:41 + + + + + + + + + + + + + +
+ + +
+ +
+

Source Control

+
make-center-split-more-visible
+
+ +
+ + +
+ + + +
+ + +
+
+ + + + + + + make-center-split-more-visible +
+
+ + ↑2 + ↓0 + + 3 changed · 1 staged +
+
+
+ + + + + + #7701 + Open + + ✓ 12/12 + 💬 3 + + + + + + +
+
+ + +
+ +
+
+ +
+ Staged Changes + 1 + + +
+
+
+ ↩ + Unstage +
+
+ 🗑 + Discard +
+
+ + + + + + M + + + + + + + + EditorFileTab.tsx + + ‎src/renderer/src/components/tab-bar/ + +12−4 + + + + + + + +
+
+ + +
+ Changes + 3 + + +
+
+
+Stage
+
🗑Discard
+
+ + + M + + + + + CenterSplitDivider.tsx + + ‎src/renderer/src/components/split-view/ + +34−8 + + + + +
+
+
+
+Stage
+
🗑Discard
+
+ + + M + + + + + split-view-geometry.ts + + ‎src/renderer/src/lib/ + +6−2 + + + + +
+
+
+
+Stage
+
🗑Discard
+
+ + + A + + + + + SplitDividerHandle.test.tsx + + ‎src/renderer/src/components/split-view/__tests__/ + +48−0 + + + + +
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ + +
+ 0 selected + + + +
+
+ + +
+
+
+ +
+
+ Open + #7701 + octocat wants to merge into main +
+
Make center split divider more visible
+
+ make-center-split-more-visible → main · updated 20m ago +
+
+ + +
+ 1 check failing + + +
+
+ + + + + e2e / playwright (ubuntu-latest) + Failing · 1 test · 2m 14s + + +
+ + +
+
+ Merge method + Squash and merge + + + + +
+
+
+
Auto-merge when checks pass
+
Merges automatically once required checks are green
+
+
+ +
+
+ +
1 failing check must pass before merging
+
+ + +
+ Unresolved comments + · 3 + + +
+
+ R + + rauchg · 18m ago + Can we clamp the divider width so it doesn't overlap the gutter on narrow + panes? + CenterSplitDivider.tsx:42 + +
+
+ M + + maren · 25m ago + Add a test for the min-width geometry branch. + split-view-geometry.ts:16 + +
+ + +
+ Reviewers +
+
+ O + octocat + Approved +
+
+ D + dstndev + Review requested +
+ + +
+ All checks + · 11 passed · 1 failing + + +
+
+ + + build / typecheckPassed · 1m 02s +
+
+ + + lint / oxlintPassed · 38s +
+
+
+
+ + +
+
+
+
+
+
+
Make center split divider more visible
+
+ a1b2c3d · Jinjing · 18m ago +
+
+
+
+
+
+
Add split-view geometry helper
+
+ 9f8e7d6 · Jinjing · 42m ago +
+
+
+ +
+ 2 commits ahead of origin/main + +
+ +
+
+
+
Merge pull request #7699 from orca/main
+
+ 0f7658d · web-flow · 3h ago +
+
+
+
+
+
+
Update README downloads badge
+
2dad1f0 · Jinjing · 5h ago
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+
+ + + + diff --git a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx index 2b9a9c78bca..5eb17e243d9 100644 --- a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx +++ b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx @@ -1,15 +1,11 @@ -import { useEffect } from 'react' -import { Pressable, StyleSheet, Text, View } from 'react-native' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useRouter } from 'expo-router' -import { ChevronLeft, ExternalLink, X } from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../../theme/mobile-theme' +import { StyleSheet, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { colors } from '../../theme/mobile-theme' import type { ConnectionState } from '../../transport/types' import type { RpcClient } from '../../transport/rpc-client' import type { MobileGitStatusResult } from '../../source-control/mobile-git-status' -import { useMobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller' +import type { MobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller' import { MobilePRSidebar } from '../MobilePRSidebar' -import { openMobilePrUrl } from '../MobilePrComposeSheet' type Props = { client: RpcClient | null @@ -20,13 +16,13 @@ type Props = { gitStatus: MobileGitStatusResult | null isGithubRepo?: boolean branchContextLoaded?: boolean - // Embedded (docked) drops the full-screen SafeAreaView chrome and shows a close - // affordance; the dock column owns the safe-area insets. Full-screen otherwise. - embedded?: boolean - onRequestClose?: () => void + controller: MobilePrSidebarController } -export function MobilePrViewPanel({ +// Chromeless PR sidebar body for the source-control hub's Pull Request segment. +// The hub owns the header, segmented control, load triggers, and the shared +// controller (one fetch feeds both the branch-card chip and this body). +export function MobilePrViewPanelBody({ client, connState, worktreeId, @@ -35,32 +31,10 @@ export function MobilePrViewPanel({ gitStatus, isGithubRepo = true, branchContextLoaded = true, - embedded = false, - onRequestClose + controller }: Props) { - const router = useRouter() const insets = useSafeAreaInsets() - const controller = useMobilePrSidebarController({ - client, - connState, - worktreeId, - branch, - headSha - }) - // A docked/full-screen PR panel is always visible — there is no drawer to open, - // so trigger the load directly once context is ready rather than gating on the - // showPRSidebar overlay flag (KTD4). - const prSidebarKind = controller.prSidebarState.kind - const refetch = controller.refetchPRSidebar - useEffect(() => { - if (branch && isGithubRepo && prSidebarKind === 'hidden') { - refetch() - } - }, [branch, isGithubRepo, prSidebarKind, refetch]) - - // Embedded: the dock column applies the bottom inset; full-screen relies on its own - // SafeAreaView (edges top only), so content must clear the home indicator itself. const sidebarState = !branchContextLoaded ? ({ kind: 'loading' } as const) : !isGithubRepo @@ -74,86 +48,22 @@ export function MobilePrViewPanel({ message: 'Current branch unavailable.' } as const) : controller.prSidebarState - // Why: open-on-host lives in the chrome so the PR URL is always flush-right of - // the screen title, even when the body is scrolled past the PR header. - const prUrl = - sidebarState.kind === 'ready' && sidebarState.data.pr.url ? sidebarState.data.pr.url : null - const prNumber = sidebarState.kind === 'ready' ? sidebarState.data.pr.number : null - const openPr = prUrl ? () => openMobilePrUrl(prUrl) : undefined - const openPrControl = openPr ? ( - [styles.iconButton, pressed && styles.iconButtonPressed]} - onPress={openPr} - hitSlop={8} - accessibilityRole="link" - accessibilityLabel={ - prNumber != null - ? `Open pull request #${prNumber} on the web` - : 'Open pull request on the web' - } - > - - - ) : null - const sidebar = ( - - ) - - if (embedded) { - return ( - - - - [styles.iconButton, pressed && styles.iconButtonPressed]} - onPress={onRequestClose} - hitSlop={8} - accessibilityLabel="Close pull request panel" - > - - - - Pull Request - - {openPrControl} - - - {sidebar} - - ) - } return ( - - - - [styles.iconButton, pressed && styles.iconButtonPressed]} - onPress={() => router.back()} - hitSlop={8} - accessibilityLabel="Back to session" - > - - - - Pull Request - - {openPrControl} - - - {sidebar} - + + + ) } @@ -161,34 +71,5 @@ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bgBase - }, - header: { - backgroundColor: colors.bgPanel, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle - }, - topBar: { - minHeight: 58, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - paddingHorizontal: spacing.md - }, - iconButton: { - width: 36, - height: 36, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button - }, - iconButtonPressed: { - backgroundColor: colors.bgRaised - }, - title: { - flex: 1, - minWidth: 0, - color: colors.textPrimary, - fontSize: typography.titleSize, - fontWeight: '600' } }) diff --git a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx index d974089c326..bf083303132 100644 --- a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx +++ b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' -import { ArrowRight, Pencil } from 'lucide-react-native' +import { ArrowRight, ExternalLink, Pencil } from 'lucide-react-native' import { colors } from '../../theme/mobile-theme' import type { GitHubWorkItemDetails, PRInfo } from '../../../../src/shared/types' import type { MobilePrTitleAction } from '../../session/use-mobile-pr-title-action' @@ -36,19 +36,32 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) { return ( - [ - styles.badge, - { borderColor: badgeColor }, - pressed && { opacity: 0.6 } - ]} - > - {badge.label} - + + [ + styles.badge, + { borderColor: badgeColor }, + pressed && { opacity: 0.6 } + ]} + > + {badge.label} + + {openPr ? ( + [styles.iconButton, pressed && { opacity: 0.6 }]} + > + + + ) : null} + { + it('summarizes a failing Tailscale host with its log', () => { + const report = buildConnectionDiagnosticsReport({ + hostName: 'Host 1', + endpoint: 'ws://100.65.9.106:6768', + state: 'reconnecting', + reconnectAttempts: 12, + lastConnectedAt: NOW - 5 * 60_000, + platform: 'ios 26.5.1', + appVersion: '0.0.29', + entries: [ + { + id: 'log-1', + ts: NOW - 60_000, + level: 'error', + message: 'WebSocket connect timeout', + detail: 'No TCP/WS handshake within 12s — endpoint unreachable?' + } + ], + nowMs: NOW + }) + + expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1') + expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)') + expect(report).toContain('State: reconnecting (reconnect attempts: 12)') + expect(report).toContain('(5m 0s ago)') + expect(report).toContain( + '[error] WebSocket connect timeout — No TCP/WS handshake within 12s — endpoint unreachable?' + ) + }) + + it('marks never-connected sessions and empty logs', () => { + const report = buildConnectionDiagnosticsReport({ + hostName: 'Host 2', + endpoint: 'ws://192.168.1.50:6768', + state: 'connecting', + reconnectAttempts: 0, + lastConnectedAt: null, + platform: 'android 15', + appVersion: '0.0.29', + entries: [], + nowMs: NOW + }) + + expect(report).toContain('Endpoint: 192.168.1.50:6768') + expect(report).not.toContain('(Tailscale)') + expect(report).toContain('Last connected: never this session') + expect(report).toContain('No connection events recorded this session.') + }) +}) diff --git a/mobile/src/diagnostics/connection-diagnostics-report.ts b/mobile/src/diagnostics/connection-diagnostics-report.ts new file mode 100644 index 00000000000..c762c69799e --- /dev/null +++ b/mobile/src/diagnostics/connection-diagnostics-report.ts @@ -0,0 +1,58 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' +import type { ConnectionLogEntry, ConnectionState } from '../transport/types' +import { formatEndpoint } from './host-reachability' + +// Why: one shareable text blob answering everything we historically had to +// ask reporters one message at a time (endpoint type, state, attempt count, +// last-connected, versions, and the reconnect lifecycle log). +export function buildConnectionDiagnosticsReport(args: { + hostName: string + endpoint: string + state: ConnectionState + reconnectAttempts: number + lastConnectedAt: number | null + platform: string + appVersion: string + entries: readonly ConnectionLogEntry[] + nowMs?: number +}): string { + const now = args.nowMs ?? Date.now() + const lines: string[] = [] + lines.push('Orca Mobile connection diagnostics') + lines.push(`Generated: ${new Date(now).toISOString()}`) + lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`) + lines.push(`Host: ${args.hostName}`) + lines.push( + `Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}` + ) + lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`) + lines.push( + args.lastConnectedAt == null + ? 'Last connected: never this session' + : `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)` + ) + lines.push('') + if (args.entries.length === 0) { + lines.push('No connection events recorded this session.') + } else { + lines.push(`Connection log (${args.entries.length} events, oldest first):`) + for (const entry of args.entries) { + const detail = entry.detail ? ` — ${entry.detail}` : '' + lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`) + } + } + return lines.join('\n') +} + +function formatAgo(ms: number): string { + const seconds = Math.max(0, Math.round(ms / 1000)) + if (seconds < 60) { + return `${seconds}s` + } + const minutes = Math.floor(seconds / 60) + if (minutes < 60) { + return `${minutes}m ${seconds % 60}s` + } + const hours = Math.floor(minutes / 60) + return `${hours}h ${minutes % 60}m` +} diff --git a/mobile/src/diagnostics/host-reachability.test.ts b/mobile/src/diagnostics/host-reachability.test.ts index 9b2933a4e0f..d20bc8c28cf 100644 --- a/mobile/src/diagnostics/host-reachability.test.ts +++ b/mobile/src/diagnostics/host-reachability.test.ts @@ -1,5 +1,27 @@ import { describe, expect, it, vi } from 'vitest' -import { testHostReachability } from './host-reachability' +import { testHostReachability, unreachableHostDetail } from './host-reachability' + +describe('unreachableHostDetail', () => { + it('points at Tailscale for tailnet CGNAT endpoints', () => { + expect(unreachableHostDetail('ws://100.65.9.106:6768')).toBe( + 'Cannot reach 100.65.9.106:6768 — check Tailscale' + ) + }) + + it('points at Tailscale for MagicDNS endpoints', () => { + expect(unreachableHostDetail('ws://my-desktop.tailnet-1234.ts.net:6768')).toBe( + 'Cannot reach my-desktop.tailnet-1234.ts.net:6768 — check Tailscale' + ) + }) + + it('stays generic for LAN endpoints', () => { + expect(unreachableHostDetail('ws://192.168.1.50:6768')).toBe('Cannot reach 192.168.1.50:6768') + }) + + it('does not treat non-CGNAT 100.x addresses as Tailscale', () => { + expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768') + }) +}) describe('testHostReachability', () => { it('returns false without leaving timers when WebSocket rejects a malformed endpoint', async () => { diff --git a/mobile/src/diagnostics/host-reachability.ts b/mobile/src/diagnostics/host-reachability.ts index 7ae292126e8..495d65a9764 100644 --- a/mobile/src/diagnostics/host-reachability.ts +++ b/mobile/src/diagnostics/host-reachability.ts @@ -1,3 +1,5 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' + const HOST_REACHABILITY_TIMEOUT_MS = 4000 // Why: troubleshooting needs a cheap endpoint probe without completing the @@ -56,3 +58,13 @@ export function formatEndpoint(endpoint: string): string { return endpoint } } + +// Why: an unreachable 100.x/*.ts.net host almost always means the phone's +// Tailscale tunnel is down or wedged (known iOS failure mode, fixed by +// toggling the VPN) — point at that instead of a bare "Cannot reach". +export function unreachableHostDetail(endpoint: string): string { + if (isTailscaleEndpoint(endpoint)) { + return `Cannot reach ${formatEndpoint(endpoint)} — check Tailscale` + } + return `Cannot reach ${formatEndpoint(endpoint)}` +} diff --git a/mobile/src/diagnostics/troubleshoot-common-issues.tsx b/mobile/src/diagnostics/troubleshoot-common-issues.tsx new file mode 100644 index 00000000000..5dc91eb65fc --- /dev/null +++ b/mobile/src/diagnostics/troubleshoot-common-issues.tsx @@ -0,0 +1,73 @@ +import { WifiOff, Shield, Monitor, Clock, Globe } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' + +export type TroubleshootSection = { + id: string + icon: React.ReactNode + title: string + steps: string[] +} + +export const troubleshootCommonIssues: TroubleshootSection[] = [ + { + id: 'wifi', + icon: , + title: 'Different WiFi Networks', + steps: [ + 'Both devices must be on the same local network (unless connected through Tailscale).', + 'Ethernet and WiFi must share the same subnet.', + 'Try reconnecting WiFi on both devices.' + ] + }, + { + id: 'firewall', + icon: , + title: 'Firewall Blocking Port 6768', + steps: [ + 'macOS: System Settings → Network → Firewall — allow Orca.', + 'Windows: Defender Firewall → Allow app — enable Orca for Private networks.', + 'Linux: sudo ufw allow 6768', + 'Corporate/school networks may block P2P — try a personal hotspot.' + ] + }, + { + id: 'desktop', + icon: , + title: 'Desktop App Not Running', + steps: [ + 'Orca must be open on your desktop to accept connections.', + 'Try restarting Orca — the companion server starts on launch.', + 'After an update, you may need to re-pair via QR code.' + ] + }, + { + id: 'timeout', + icon: , + title: 'Connection Timeout', + steps: [ + 'Check WiFi signal strength on your phone.', + 'Go back to the host list and tap your host to retry.', + 'Restart both apps if timeouts persist.' + ] + }, + { + id: 'tailscale', + icon: , + title: 'Tailscale Host Unreachable', + steps: [ + 'Host addresses like 100.x.x.x or *.ts.net connect through Tailscale — keep it ON.', + 'iOS/Android can silently wedge the tunnel: toggle Tailscale off and back on in the Tailscale app.', + 'Check the desktop is awake and shows as connected in your tailnet.', + 'Update the Tailscale app — recent releases fix reconnect bugs.' + ] + }, + { + id: 'vpn', + icon: , + title: 'Other VPN Interference', + steps: [ + 'Non-Tailscale VPNs can route local traffic through a remote server.', + 'Disable that VPN or enable split tunneling / "Allow LAN".' + ] + } +] diff --git a/mobile/src/session/SessionDockColumn.tsx b/mobile/src/session/SessionDockColumn.tsx index f02e89f7329..2f7ae6b5ca3 100644 --- a/mobile/src/session/SessionDockColumn.tsx +++ b/mobile/src/session/SessionDockColumn.tsx @@ -1,27 +1,16 @@ import { memo } from 'react' import { View, StyleSheet } from 'react-native' -import type { ConnectionState } from '../transport/types' -import type { RpcClient } from '../transport/rpc-client' import { MobileSourceControlPanel } from '../source-control/MobileSourceControlPanel' import { MobileFileExplorerPanel } from '../files/MobileFileExplorerPanel' -import { MobilePrViewPanel } from '../components/pr-sidebar/MobilePrViewPanel' import { mobilePrSidebarStyles } from '../components/pr-sidebar/mobile-pr-sidebar-styles' import { useMobileDockResize } from './use-mobile-dock-resize' import type { ActivePanel } from './session-panel-host' -import type { MobileGitStatusResult } from '../source-control/mobile-git-status' type Props = { activePanel: Exclude hostId: string worktreeId: string name: string - client: RpcClient | null - connState: ConnectionState - branch: string | null - headSha: string | null - gitStatus: MobileGitStatusResult | null - isGithubRepo: boolean - branchContextLoaded: boolean availableWidth: number onRequestClose: () => void onFileOpenStart?: () => void @@ -40,13 +29,6 @@ export function SessionDockColumn({ hostId, worktreeId, name, - client, - connState, - branch, - headSha, - gitStatus, - isGithubRepo, - branchContextLoaded, availableWidth, onRequestClose, onFileOpenStart, @@ -63,13 +45,6 @@ export function SessionDockColumn({ hostId={hostId} worktreeId={worktreeId} name={name} - client={client} - connState={connState} - branch={branch} - headSha={headSha} - gitStatus={gitStatus} - isGithubRepo={isGithubRepo} - branchContextLoaded={branchContextLoaded} onRequestClose={onRequestClose} onFileOpenStart={onFileOpenStart} onOpenedFileDiff={onOpenedFileDiff} @@ -85,18 +60,14 @@ const DockPanelContent = memo(function DockPanelContent({ hostId, worktreeId, name, - client, - connState, - branch, - headSha, - gitStatus, - isGithubRepo, - branchContextLoaded, onRequestClose, onFileOpenStart, onOpenedFileDiff }: DockPanelContentProps) { - if (activePanel === 'sourceControl') { + // Source Control and Pull Request share one hub instance so swapping dock icons + // keeps commit draft / visited-tab / scroll state and lands on the right segment + // (design: PR dock maps to hub + tab=pr). + if (activePanel === 'sourceControl' || activePanel === 'pr') { return ( ) } - if (activePanel === 'files') { - return ( - - ) - } return ( - diff --git a/mobile/src/session/mobile-pr-sidebar-state.ts b/mobile/src/session/mobile-pr-sidebar-state.ts index 5f5b679b210..4ce32a7cec7 100644 --- a/mobile/src/session/mobile-pr-sidebar-state.ts +++ b/mobile/src/session/mobile-pr-sidebar-state.ts @@ -140,6 +140,47 @@ export async function loadPrSidebarDetails( } } +// UI treats details===null as "still loading". After phase 2 finishes (success or +// non-fatal failure), never leave null — use prior comments if any, else empty body. +export function resolvePrSidebarDetailsAfterPhase2(args: { + fetched: GitHubWorkItemDetails | null + prior: GitHubWorkItemDetails | null + pr: PRInfo +}): GitHubWorkItemDetails { + if (args.fetched != null) { + return args.fetched + } + if (args.prior != null) { + return args.prior + } + return emptyPrSidebarDetails(args.pr) +} + +// Placeholder details so Description/Comments leave the spinner after a failed phase 2. +export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails { + return { + item: { + id: `pr-${pr.number}`, + type: 'pr', + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.url, + labels: [], + updatedAt: pr.updatedAt, + author: null + }, + body: '', + comments: [] + } +} + +// Soft head refresh may restart an in-flight phase-1 load; only skip when there is +// no visible/in-flight sidebar work (hidden or terminal error states). +export function shouldSoftRefreshPrSidebarOnHeadChange(kind: PrSidebarState['kind']): boolean { + return kind === 'ready' || kind === 'none' || kind === 'loading' +} + // Stale-response guard (KTD6): a load tagged with an older sequence must not // overwrite a newer one. The hook bumps a monotonic counter per load. export function shouldApplyResult(resultSeq: number, latestSeq: number): boolean { diff --git a/mobile/src/session/session-panel-host.test.ts b/mobile/src/session/session-panel-host.test.ts index 1028e489821..fb2845143d1 100644 --- a/mobile/src/session/session-panel-host.test.ts +++ b/mobile/src/session/session-panel-host.test.ts @@ -86,7 +86,8 @@ describe('panelRouteDescriptor', () => { pathname: '/h/[hostId]/files/[worktreeId]' }) expect(panelRouteDescriptor('pr')).toEqual({ - pathname: '/h/[hostId]/pr/[worktreeId]' + pathname: '/h/[hostId]/source-control/[worktreeId]', + params: { tab: 'pr' } }) }) }) diff --git a/mobile/src/session/session-panel-host.ts b/mobile/src/session/session-panel-host.ts index 280c368c543..fdf08602752 100644 --- a/mobile/src/session/session-panel-host.ts +++ b/mobile/src/session/session-panel-host.ts @@ -47,13 +47,18 @@ export function resolvePanelAction(args: { // Single source of truth for each panel's expo-router pathname pattern so narrow-push // and any deep-linking agree; the caller supplies the [hostId]/[worktreeId] params. -export function panelRouteDescriptor(panel: Exclude): { pathname: string } { +// The Pull Request panel is a segment of the source-control hub, so its narrow-push +// targets that route with `tab: 'pr'` rather than the standalone (redirecting) route. +export function panelRouteDescriptor(panel: Exclude): { + pathname: string + params?: Record +} { switch (panel) { case 'sourceControl': return { pathname: '/h/[hostId]/source-control/[worktreeId]' } case 'files': return { pathname: '/h/[hostId]/files/[worktreeId]' } case 'pr': - return { pathname: '/h/[hostId]/pr/[worktreeId]' } + return { pathname: '/h/[hostId]/source-control/[worktreeId]', params: { tab: 'pr' } } } } diff --git a/mobile/src/session/use-mobile-pr-branch-context.ts b/mobile/src/session/use-mobile-pr-branch-context.ts index ef2cf1a48d5..d0816b02f71 100644 --- a/mobile/src/session/use-mobile-pr-branch-context.ts +++ b/mobile/src/session/use-mobile-pr-branch-context.ts @@ -34,12 +34,16 @@ export function deriveMobilePrBranchContext( // Loads repo eligibility independently from branch/SHA. The header PR icon only // needs the cheap GitHub probe; the panel can keep loading branch context after // the entry point is already stable in the top bar. +// +// `includeBranchIdentity: false` skips git.status + branchCompare — use when the +// caller only gates a PR entry on hosted-repo eligibility (session dock icon). export function useMobilePrBranchContext(input: { client: RpcClient | null connState: ConnectionState worktreeId: string + includeBranchIdentity?: boolean }): MobilePrBranchContext { - const { client, connState, worktreeId } = input + const { client, connState, worktreeId, includeBranchIdentity = true } = input const [context, setContext] = useState({ branch: null, headSha: null, @@ -60,7 +64,7 @@ export function useMobilePrBranchContext(input: { status: null, isGithubRepo: false, repoLoaded: false, - loaded: false + loaded: !includeBranchIdentity }) return } @@ -70,7 +74,8 @@ export function useMobilePrBranchContext(input: { status: null, isGithubRepo: false, repoLoaded: false, - loaded: false + // Repo-only mode is "loaded" for branch fields immediately (they stay null). + loaded: !includeBranchIdentity }) void loadMobilePrRepoContext(client, worktreeId) @@ -95,6 +100,12 @@ export function useMobilePrBranchContext(input: { } }) + if (!includeBranchIdentity) { + return () => { + cancelled = true + } + } + void loadMobilePrBranchIdentity(client, worktreeId) .then((next) => { if (!cancelled) { @@ -121,7 +132,7 @@ export function useMobilePrBranchContext(input: { return () => { cancelled = true } - }, [ready, client, worktreeId]) + }, [ready, client, worktreeId, includeBranchIdentity]) return context } diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller-probe.test.ts b/mobile/src/session/use-mobile-pr-sidebar-controller-probe.test.ts new file mode 100644 index 00000000000..0645b6659c4 --- /dev/null +++ b/mobile/src/session/use-mobile-pr-sidebar-controller-probe.test.ts @@ -0,0 +1,100 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' + +const fetchGithubRepoSlugMock = vi.fn() + +// Only the repo probe matters here; the load path never runs without a branch. +vi.mock('./github-pr-rpc', () => ({ + fetchGithubRepoSlug: (...args: unknown[]) => fetchGithubRepoSlugMock(...args), + fetchHostedReviewForBranch: vi.fn(), + fetchPRChecks: vi.fn(), + fetchPRForBranch: vi.fn(), + fetchWorkItemDetails: vi.fn() +})) +vi.mock('../source-control/mobile-pr-link', () => ({ + fetchWorktreeLinkedPR: vi.fn(async () => null) +})) + +import { + useMobilePrSidebarController, + type MobilePrSidebarController +} from './use-mobile-pr-sidebar-controller' + +let captured: MobilePrSidebarController | null = null + +function Harness(props: Parameters[0]) { + captured = useMobilePrSidebarController(props) + return null +} + +async function flush(): Promise { + // Two ticks: the probe's resolved promise (.then) plus its setState commit. + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +describe('useMobilePrSidebarController repo probe', () => { + const client = { sendRequest: vi.fn() } as unknown as RpcClient + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + captured = null + fetchGithubRepoSlugMock.mockReset() + }) + + afterEach(() => { + act(() => { + renderer?.unmount() + }) + renderer = null + }) + + // Regression: repo eligibility must not be gated on a branch. A detached HEAD / + // mid-rebase worktree (branch === null) still has to resolve the probe, or the + // Pull Request segment gets stranded on a forever spinner instead of the + // "Current branch unavailable" state. + it('resolves the probe when connected without a branch (detached HEAD)', async () => { + fetchGithubRepoSlugMock.mockResolvedValue({ ok: true, result: { owner: 'o', repo: 'r' } }) + await act(async () => { + renderer = create( + createElement(Harness, { + client, + connState: 'connected', + worktreeId: 'w', + branch: null, + headSha: 'sha' + }) + ) + }) + await flush() + + expect(fetchGithubRepoSlugMock).toHaveBeenCalledWith(client, 'w') + expect(captured?.prSidebarRepoProbeLoaded).toBe(true) + expect(captured?.prSidebarIsGithubRepo).toBe(true) + // No branch means no PR load ran — state stays hidden, never a spinner. + expect(captured?.prSidebarState.kind).toBe('hidden') + }) + + it('does not probe until the client is connected', async () => { + fetchGithubRepoSlugMock.mockResolvedValue({ ok: true, result: { owner: 'o', repo: 'r' } }) + await act(async () => { + renderer = create( + createElement(Harness, { + client, + connState: 'connecting', + worktreeId: 'w', + branch: null, + headSha: 'sha' + }) + ) + }) + await flush() + + expect(fetchGithubRepoSlugMock).not.toHaveBeenCalled() + expect(captured?.prSidebarRepoProbeLoaded).toBe(false) + }) +}) diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts index 005c4dc0e5b..de77433fcb6 100644 --- a/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts +++ b/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts @@ -4,11 +4,15 @@ import type { HostedReviewInfo } from '../../../src/shared/hosted-review' import type { GitHubPrReadOutcome } from './github-pr-rpc' import { classifyPrSidebarFailure, + emptyPrSidebarDetails, loadPrSidebarData, loadPrSidebarDetails, + resolvePrSidebarDetailsAfterPhase2, shouldApplyResult, + shouldSoftRefreshPrSidebarOnHeadChange, type PrSidebarLoadDeps } from './mobile-pr-sidebar-state' +import { buildMobilePrSidebarIdentity } from './use-mobile-pr-sidebar-controller' function ok(result: T): GitHubPrReadOutcome { return { ok: true, result } @@ -200,3 +204,61 @@ describe('shouldApplyResult', () => { expect(shouldApplyResult(2, 3)).toBe(false) }) }) + +describe('shouldSoftRefreshPrSidebarOnHeadChange', () => { + it('restarts ready/none/loading so mid-flight head advances are not stuck', () => { + expect(shouldSoftRefreshPrSidebarOnHeadChange('ready')).toBe(true) + expect(shouldSoftRefreshPrSidebarOnHeadChange('none')).toBe(true) + expect(shouldSoftRefreshPrSidebarOnHeadChange('loading')).toBe(true) + }) + + it('skips hidden and terminal failures (chip bootstrap / retry own those)', () => { + expect(shouldSoftRefreshPrSidebarOnHeadChange('hidden')).toBe(false) + expect(shouldSoftRefreshPrSidebarOnHeadChange('error')).toBe(false) + expect(shouldSoftRefreshPrSidebarOnHeadChange('blocked')).toBe(false) + }) +}) + +describe('resolvePrSidebarDetailsAfterPhase2', () => { + it('prefers a successful fetch over prior details', () => { + const prior = emptyPrSidebarDetails(PR) + expect(resolvePrSidebarDetailsAfterPhase2({ fetched: DETAILS, prior, pr: PR })).toBe(DETAILS) + }) + + it('keeps prior details when the fetch is non-fatal null', () => { + const prior = emptyPrSidebarDetails(PR) + expect(resolvePrSidebarDetailsAfterPhase2({ fetched: null, prior, pr: PR })).toBe(prior) + }) + + it('uses empty details when phase 2 fails with nothing prior (no forever spinner)', () => { + const empty = resolvePrSidebarDetailsAfterPhase2({ + fetched: null, + prior: null, + pr: PR + }) + expect(empty.body).toBe('') + expect(empty.comments).toEqual([]) + expect(empty.item.number).toBe(7) + expect(empty.item.type).toBe('pr') + }) +}) + +describe('buildMobilePrSidebarIdentity', () => { + it('keys identity on worktree + branch, not head SHA', () => { + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe('w1\u0000feat') + // Head advances on commit must not form a new identity — soft refresh keeps ready UI. + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe( + buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' }) + ) + }) + + it('is null without a branch and changes when branch or worktree changes', () => { + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: null })).toBeNull() + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe( + buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'b' }) + ) + expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe( + buildMobilePrSidebarIdentity({ worktreeId: 'w2', branch: 'a' }) + ) + }) +}) diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.ts index b14f0dec783..415a7581b7d 100644 --- a/mobile/src/session/use-mobile-pr-sidebar-controller.ts +++ b/mobile/src/session/use-mobile-pr-sidebar-controller.ts @@ -11,7 +11,9 @@ import { import { loadPrSidebarData, loadPrSidebarDetails, + resolvePrSidebarDetailsAfterPhase2, shouldApplyResult, + shouldSoftRefreshPrSidebarOnHeadChange, type PrSidebarLoadDeps, type PrSidebarState } from './mobile-pr-sidebar-state' @@ -27,12 +29,20 @@ type PrSidebarControllerInput = { headSha: string | null } -function buildPrSidebarIdentity(args: { +// Load options for the shared PR controller. The Source Control hub chip only needs +// phase 1 (PR + checks); phase 2 (comments/body) is heavy and should wait until the +// Pull Request segment is actually open. +export type PrSidebarLoadOptions = { + includeDetails?: boolean +} + +// Identity is worktree + branch only. Head SHA advances on every commit and must not +// wipe a ready chip/sidebar to "loading" — soft-refresh uses the new head instead. +export function buildMobilePrSidebarIdentity(args: { worktreeId: string branch: string | null - headSha: string | null }): string | null { - return args.branch ? `${args.worktreeId}\u0000${args.branch}\u0000${args.headSha ?? ''}` : null + return args.branch ? `${args.worktreeId}\u0000${args.branch}` : null } export function useMobilePrSidebarController(input: PrSidebarControllerInput) { @@ -41,13 +51,31 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { // independent of whether the branch has an open PR (a no-PR branch shows an // empty state rather than hiding the icon). const [isGithubRepo, setIsGithubRepo] = useState(false) + // False until the probe resolves for this worktree. Consumers gate "unavailable + // for this provider" copy on it — isGithubRepo=false is meaningless mid-probe. + const [repoProbeLoaded, setRepoProbeLoaded] = useState(false) const [state, setState] = useState({ kind: 'hidden' }) const [showPRSidebar, setShowPRSidebar] = useState(false) const loadSeqRef = useRef(0) + // Phase-2-only fetches use a separate sequence so they cannot cancel a concurrent + // phase-1 soft refresh (and vice versa) when chip bootstrap left details null. + const detailsSeqRef = useRef(0) + // The (seq, prNumber) of the phase-2 fetch currently in flight. The hub's + // fill-in effect fires as soon as phase 1 renders ready with null details — + // exactly when load()'s own phase 2 just started. Without this claim, every + // cold PR-segment open fetched the heavy details payload twice. + const detailsInFlightRef = useRef<{ seq: number; prNumber: number } | null>(null) const stateIdentityRef = useRef(null) + const stateRef = useRef(state) + stateRef.current = state + const headShaRef = useRef(headSha) - const ready = client !== null && connState === 'connected' && !!branch - const identity = buildPrSidebarIdentity({ worktreeId, branch, headSha }) + // Repo eligibility (a GitHub remote) is independent of the branch, so the probe + // must not require one: a detached HEAD / mid-rebase worktree (branch === null) + // would otherwise never set repoProbeLoaded, stranding the PR segment on a + // forever spinner instead of the "Current branch unavailable" state. + const probeReady = client !== null && connState === 'connected' + const identity = buildMobilePrSidebarIdentity({ worktreeId, branch }) const buildDeps = useCallback((): PrSidebarLoadDeps | null => { if (!client) { @@ -63,66 +91,209 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { }, [client]) // Probe whether this is a GitHub repo to decide icon availability (GitHub-only). + // Worktree change must reset eligibility; a brief disconnect must not — otherwise + // the hub shows "unavailable for this provider" and hides the chip mid-session. + useEffect(() => { + setIsGithubRepo(false) + setRepoProbeLoaded(false) + }, [worktreeId]) + useEffect(() => { let cancelled = false - if (!ready || !client) { - setIsGithubRepo(false) + if (!probeReady || !client) { return } void fetchGithubRepoSlug(client, worktreeId).then((outcome) => { if (!cancelled) { setIsGithubRepo(outcome.ok && outcome.result !== null) + setRepoProbeLoaded(true) } }) return () => { cancelled = true } - }, [ready, client, worktreeId]) + }, [probeReady, client, worktreeId]) useEffect(() => { if (!identity) { loadSeqRef.current += 1 + detailsSeqRef.current += 1 stateIdentityRef.current = null setState({ kind: 'hidden' }) return } if (stateIdentityRef.current !== null && stateIdentityRef.current !== identity) { - // Why: ready/loading data is scoped to branch+head. A branch switch must - // not let the open panel keep rendering the previous PR as "fresh." + // Why: ready/loading data is scoped to branch. A branch switch must not let + // the open panel keep rendering the previous PR as "fresh." loadSeqRef.current += 1 + detailsSeqRef.current += 1 stateIdentityRef.current = null setState({ kind: 'hidden' }) } }, [identity]) - const load = useCallback(async () => { + const load = useCallback( + async (options?: PrSidebarLoadOptions) => { + const includeDetails = options?.includeDetails ?? true + const deps = buildDeps() + const loadIdentity = identity + if (!deps || !branch || !loadIdentity) { + return + } + const seq = loadSeqRef.current + 1 + loadSeqRef.current = seq + // In-flight phase-2 work is NOT invalidated here: this load only takes phase-2 + // ownership when its own phase 2 actually starts (the bump below), so a load + // superseded at the phase-1 guard can never orphan a detailsSeq and silently + // discard the only details fetch. A stale ensure applying mid-phase-1 is safe — + // its identity/number/kind guards only let matching details through. + const previousIdentity = stateIdentityRef.current + stateIdentityRef.current = loadIdentity + // Soft refresh: same branch already showing ready/none stays visible while + // checks re-fetch (head advanced after commit). Hard loading only on first load + // or after a real identity wipe. + const keepVisible = + previousIdentity === loadIdentity && + (stateRef.current.kind === 'ready' || + stateRef.current.kind === 'none' || + stateRef.current.kind === 'loading') + if (!keepVisible) { + setState({ kind: 'loading' }) + } + // Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with + // forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves. + const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha }) + if ( + !shouldApplyResult(seq, loadSeqRef.current) || + stateIdentityRef.current !== loadIdentity + ) { + return + } + stateIdentityRef.current = loadIdentity + + // Keep prior comments/body visible across phase 1 when the same PR is still open. + // loadPrSidebarData always returns details:null; without this, soft refresh and + // PR-tab refresh blank the comment tree until phase 2 finishes. + const priorDetails = + next.kind === 'ready' && + stateRef.current.kind === 'ready' && + stateRef.current.data.details != null && + stateRef.current.data.pr.number === next.data.pr.number + ? stateRef.current.data.details + : null + + if (next.kind === 'ready' && priorDetails != null) { + setState({ kind: 'ready', data: { ...next.data, details: priorDetails } }) + if (!includeDetails) { + return + } + } else { + setState(next) + if (next.kind !== 'ready' || !includeDetails) { + return + } + } + + // Phase 2: refresh (or first-load) the heavy comments/body payload. + const detailsSeq = detailsSeqRef.current + 1 + detailsSeqRef.current = detailsSeq + detailsInFlightRef.current = { seq: detailsSeq, prNumber: next.data.pr.number } + const fetchedDetails = await loadPrSidebarDetails(deps, worktreeId, next.data.pr.number) + // Release the claim unless a newer phase-2 superseded it (never clear theirs). + if (detailsInFlightRef.current?.seq === detailsSeq) { + detailsInFlightRef.current = null + } + // Phase-2 ownership is encoded by detailsSeq + identity + PR number alone — + // deliberately NOT by loadSeq: a chip-only soft refresh bumps loadSeq without + // bumping detailsSeq, and must not discard the in-flight details it preserved + // (ensure dedupes against this claim, so nothing would re-fetch them). + if ( + detailsSeq !== detailsSeqRef.current || + stateIdentityRef.current !== loadIdentity || + stateRef.current.kind !== 'ready' || + stateRef.current.data.pr.number !== next.data.pr.number + ) { + return + } + // Non-fatal null must not leave details===null (UI treats that as forever-loading). + const details = resolvePrSidebarDetailsAfterPhase2({ + fetched: fetchedDetails, + prior: stateRef.current.data.details, + pr: stateRef.current.data.pr + }) + setState({ kind: 'ready', data: { ...stateRef.current.data, details } }) + }, + [buildDeps, branch, headSha, identity, worktreeId] + ) + + // Phase-2 only — used when the hub opens the PR segment after a chip-only load. + // Uses detailsSeqRef (not loadSeqRef) so it cannot cancel a concurrent soft phase-1. + const ensurePrSidebarDetails = useCallback(async () => { + const current = stateRef.current + if (current.kind !== 'ready' || current.data.details != null) { + return + } const deps = buildDeps() const loadIdentity = identity - if (!deps || !branch || !loadIdentity) { + if (!deps || !loadIdentity || stateIdentityRef.current !== loadIdentity) { return } - const seq = loadSeqRef.current + 1 - loadSeqRef.current = seq - stateIdentityRef.current = loadIdentity - setState({ kind: 'loading' }) - // Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with - // forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves. - const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha }) - if (!shouldApplyResult(seq, loadSeqRef.current) || stateIdentityRef.current !== loadIdentity) { + const prNumber = current.data.pr.number + // A live phase-2 fetch for this PR is already in flight (its claim still owns + // the latest details seq) — do not start a duplicate. + const inFlight = detailsInFlightRef.current + if (inFlight && inFlight.prNumber === prNumber && inFlight.seq === detailsSeqRef.current) { return } - stateIdentityRef.current = loadIdentity - setState(next) - if (next.kind !== 'ready') { + const detailsSeq = detailsSeqRef.current + 1 + detailsSeqRef.current = detailsSeq + detailsInFlightRef.current = { seq: detailsSeq, prNumber } + const fetchedDetails = await loadPrSidebarDetails(deps, worktreeId, prNumber) + if (detailsInFlightRef.current?.seq === detailsSeq) { + detailsInFlightRef.current = null + } + if ( + detailsSeq !== detailsSeqRef.current || + stateIdentityRef.current !== loadIdentity || + stateRef.current.kind !== 'ready' || + stateRef.current.data.pr.number !== prNumber + ) { return } - // Phase 2: lazy-load the heavy comments/body payload and merge it in, so it never - // blocks the actionable PR UI. Re-check the seq so a newer load isn't clobbered. - const details = await loadPrSidebarDetails(deps, worktreeId, next.data.pr.number) - if (shouldApplyResult(seq, loadSeqRef.current) && stateIdentityRef.current === loadIdentity) { - setState({ kind: 'ready', data: { ...next.data, details } }) + const details = resolvePrSidebarDetailsAfterPhase2({ + fetched: fetchedDetails, + prior: stateRef.current.data.details, + pr: stateRef.current.data.pr + }) + setState({ + kind: 'ready', + data: { ...stateRef.current.data, details } + }) + }, [buildDeps, identity, worktreeId]) + + // Soft-refresh checks when HEAD advances on the same branch (post-commit). + // Also restarts an in-flight phase-1 load so a mid-flight head advance is not + // applied with a stale SHA (headShaRef would otherwise advance with no reload). + useEffect(() => { + if (headShaRef.current === headSha) { + return } - }, [buildDeps, branch, headSha, identity, worktreeId]) + headShaRef.current = headSha + // Identity was just wiped (branch/worktree switch): stateRef still holds the + // pre-wipe state in this effect flush, which would start a load flavored for + // the OLD surface (e.g. heavy details on the Changes tab). Let the owning + // surface's hidden-state effects drive the first load for the new identity. + if (stateIdentityRef.current === null) { + return + } + const current = stateRef.current + if (!shouldSoftRefreshPrSidebarOnHeadChange(current.kind)) { + return + } + void load({ + includeDetails: current.kind === 'ready' && current.data.details != null + }) + }, [headSha, load]) const openPRSidebar = useCallback(() => { setShowPRSidebar(true) @@ -131,17 +302,27 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) { stateIdentityRef.current !== identity || (state.kind !== 'ready' && state.kind !== 'loading') ) { - void load() + void load({ includeDetails: true }) + } else if (state.kind === 'ready' && state.data.details == null) { + void ensurePrSidebarDetails() } - }, [identity, state.kind, load]) + }, [identity, state, load, ensurePrSidebarDetails]) + + const retry = useCallback(() => { + void load({ includeDetails: true }) + }, [load]) return { prSidebarState: state, prSidebarIsGithubRepo: isGithubRepo, + prSidebarRepoProbeLoaded: repoProbeLoaded, showPRSidebar, setShowPRSidebar, openPRSidebar, - retryPRSidebar: load, - refetchPRSidebar: load + retryPRSidebar: retry, + refetchPRSidebar: load, + ensurePrSidebarDetails } } + +export type MobilePrSidebarController = ReturnType diff --git a/mobile/src/source-control/MobileGitHistoryList.tsx b/mobile/src/source-control/MobileGitHistoryList.tsx new file mode 100644 index 00000000000..d88fae540d8 --- /dev/null +++ b/mobile/src/source-control/MobileGitHistoryList.tsx @@ -0,0 +1,271 @@ +import { memo, useCallback, useEffect, useState } from 'react' +import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native' +import { ChevronDown, ChevronRight } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import type { ConnectionState, RpcSuccess } from '../transport/types' +import type { RpcClient } from '../transport/rpc-client' +import { useForceReconnect } from '../transport/client-context' +import { + fetchMobileGitHistory, + mapMobileCommitRows, + type MobileCommitRow +} from './mobile-git-history' +import { resolveMobileHistoryScreenView } from './mobile-history-screen-state' +import type { GitBranchChangeEntry } from '../../../src/shared/types' + +type Props = { + client: RpcClient | null + connState: ConnectionState + worktreeId: string + // Needed so Retry can revive a parked reconnect loop (STA-1511 / #5049). + hostId: string + bottomInset: number + // Bumped by the hub header refresh so History reloads without remounting. + refreshNonce?: number +} + +// Headerless commit-history list. Extracted from the /history route so the hub's +// History segment and the standalone route render the same body over one code path. +// Memoized: it stays mounted (hidden) while the Changes segment is active, and must +// not re-reconcile its FlatList on every commit-message keystroke re-render. +export const MobileGitHistoryList = memo(function MobileGitHistoryList({ + client, + connState, + worktreeId, + hostId, + bottomInset, + refreshNonce = 0 +}: Props) { + const forceReconnect = useForceReconnect() + const [rows, setRows] = useState(null) + const [error, setError] = useState(null) + const [reloadNonce, setReloadNonce] = useState(0) + const [expanded, setExpanded] = useState(null) + const [filesById, setFilesById] = useState>({}) + + // Worktree identity change must wipe history immediately — even while + // disconnected — so a kept-mounted hub segment never shows another tree's commits. + useEffect(() => { + setRows(null) + setError(null) + setExpanded(null) + setFilesById({}) + }, [worktreeId]) + + useEffect(() => { + let active = true + if (!client || connState !== 'connected' || !worktreeId) { + // Why: leave already-loaded rows (and expand state) alone across a drop — + // resolveMobileHistoryScreenView keeps them visible (STA-1511). + return + } + // Reset prior error/rows so a successful retry doesn't stay stuck behind a + // stale error (error wins render precedence). + setError(null) + setRows(null) + setExpanded(null) + setFilesById({}) + void (async () => { + try { + const result = await fetchMobileGitHistory(client, worktreeId) + if (active) { + setRows(mapMobileCommitRows(result, Date.now())) + } + } catch (err) { + if (active) { + setError(err instanceof Error ? err.message : 'Failed to load history') + } + } + })() + return () => { + active = false + } + }, [client, connState, reloadNonce, refreshNonce, worktreeId]) + + const retry = useCallback(() => { + setError(null) + // Why: retrying the fetch is useless while the transport's reconnect loop + // is parked at its backoff cap — revive the connection instead (mirrors + // MobileSourceControlPanel / issue #5049). The load effect re-runs via + // connState once the fresh client connects. + if (connState !== 'connected' && hostId) { + void forceReconnect(hostId) + return + } + setReloadNonce((n) => n + 1) + }, [connState, forceReconnect, hostId]) + + const toggleCommit = useCallback( + (row: MobileCommitRow) => { + const next = expanded === row.id ? null : row.id + setExpanded(next) + if (next && !filesById[row.id]) { + // No client (disconnected while cached rows stay visible): resolve to an + // empty file list so the row shows "No file changes" instead of a spinner + // that never completes — no request can be made. + if (!client) { + setFilesById((prev) => ({ ...prev, [row.id]: [] })) + return + } + setFilesById((prev) => ({ ...prev, [row.id]: 'loading' })) + void client + .sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id }) + .then((response) => { + const entries = response.ok + ? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries + : [] + setFilesById((prev) => { + // Drop stale responses if the row is no longer loading (collapsed + re-opened). + if (prev[row.id] !== 'loading') { + return prev + } + return { ...prev, [row.id]: entries } + }) + }) + .catch(() => + setFilesById((prev) => { + if (prev[row.id] !== 'loading') { + return prev + } + return { ...prev, [row.id]: [] } + }) + ) + } + }, + [client, expanded, filesById, worktreeId] + ) + + const renderCommit = useCallback( + ({ item }: { item: MobileCommitRow }) => { + const files = filesById[item.id] + const isOpen = expanded === item.id + return ( + + [styles.commitHeader, pressed && styles.commitHeaderPressed]} + onPress={() => toggleCommit(item)} + > + {isOpen ? ( + + ) : ( + + )} + + + {item.subject} + + + {item.shortId} · {item.author} · {item.relativeTime} + + + + {isOpen ? ( + + {files === 'loading' || files === undefined ? ( + + ) : files.length === 0 ? ( + No file changes + ) : ( + files.map((file) => ( + + + {file.path} + + + {file.added ? +{file.added} : null} + {file.removed ? -{file.removed} : null} + + + )) + )} + + ) : null} + + ) + }, + [expanded, filesById, toggleCommit] + ) + + const view = resolveMobileHistoryScreenView({ + connected: client !== null && connState === 'connected', + rows, + error + }) + + if (view.kind === 'error' || view.kind === 'waiting') { + return ( + + + {view.kind === 'waiting' ? 'Waiting for desktop...' : view.message} + + + Retry + + + ) + } + if (view.kind === 'loading') { + return ( + + + + ) + } + if (view.kind === 'empty') { + return ( + + No commits. + + ) + } + return ( + row.id} + contentContainerStyle={{ paddingBottom: spacing.lg + bottomInset }} + /> + ) +}) + +const styles = StyleSheet.create({ + state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg }, + stateText: { color: colors.textMuted, fontSize: typography.bodySize }, + retryButton: { + marginTop: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button, + backgroundColor: colors.bgRaised + }, + retryText: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, + commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle }, + commitHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2 + }, + commitHeaderPressed: { backgroundColor: colors.bgRaised }, + commitMain: { flex: 1, minWidth: 0 }, + commitSubject: { color: colors.textPrimary, fontSize: typography.bodySize }, + commitMeta: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontFamily: typography.monoFamily, + marginTop: 2 + }, + files: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: 4 }, + fileRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + filePath: { + flex: 1, + color: colors.textSecondary, + fontSize: typography.metaSize, + fontFamily: typography.monoFamily + }, + fileStat: { fontSize: typography.metaSize, fontFamily: typography.monoFamily }, + add: { color: colors.gitDecorationAdded }, + del: { color: colors.gitDecorationDeleted }, + empty: { color: colors.textMuted, fontSize: typography.metaSize } +}) diff --git a/mobile/src/source-control/MobileSourceControlBranchCard.tsx b/mobile/src/source-control/MobileSourceControlBranchCard.tsx new file mode 100644 index 00000000000..04aae01b0bc --- /dev/null +++ b/mobile/src/source-control/MobileSourceControlBranchCard.tsx @@ -0,0 +1,78 @@ +import { Pressable, Text, View } from 'react-native' +import { GitBranch } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-source-control-styles' +import { MobileSourceControlPrChip } from './MobileSourceControlPrChip' +import type { MobilePrChipSummary } from './mobile-pr-chip-summary' +import { mobileConflictAbortLabel } from './mobile-source-control-conflict-abort' + +type Props = { + branchLabel: string + syncLabel: string | null + unstagedCount: number + stagedCount: number + branchCount: number + conflictOperation: string | null + // True while any serial git IO is in flight — disables Abort so ops don't race. + conflictBusy: boolean + // True only while abort-merge / abort-rebase itself is running (label accuracy). + conflictAborting: boolean + onAbortConflict: (operation: string) => void + // The PR chip is shown only on repos with a hosted-review remote; null hides it. + prChip: MobilePrChipSummary | null + onOpenPr: () => void +} + +// Persistent card at the top of every hub segment: branch identity, sync/counts, +// conflict state, and the PR chip. Shared so PR/History see the same status the +// Changes lens does without re-deriving it. +export function MobileSourceControlBranchCard({ + branchLabel, + syncLabel, + unstagedCount, + stagedCount, + branchCount, + conflictOperation, + conflictBusy, + conflictAborting, + onAbortConflict, + prChip, + onOpenPr +}: Props) { + const showConflict = conflictOperation !== null && conflictOperation !== 'unknown' + return ( + + + + + + {branchLabel} + + + {syncLabel ? {syncLabel} : null} + + + {unstagedCount} changed + {stagedCount} staged + {branchCount > 0 ? {branchCount} on branch : null} + {showConflict ? ( + + {conflictOperation} + {conflictOperation === 'merge' || conflictOperation === 'rebase' ? ( + [styles.abortButton, pressed && styles.abortPressed]} + disabled={conflictBusy} + onPress={() => onAbortConflict(conflictOperation)} + > + + {mobileConflictAbortLabel(conflictOperation, conflictAborting)} + + + ) : null} + + ) : null} + + {prChip ? : null} + + ) +} diff --git a/mobile/src/source-control/MobileSourceControlContent.tsx b/mobile/src/source-control/MobileSourceControlContent.tsx index 3f1309fad97..a28504a5c53 100644 --- a/mobile/src/source-control/MobileSourceControlContent.tsx +++ b/mobile/src/source-control/MobileSourceControlContent.tsx @@ -1,5 +1,5 @@ import { ActivityIndicator, Pressable, SectionList, Text, TextInput, View } from 'react-native' -import { GitBranch, Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native' +import { Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native' import { colors, spacing } from '../theme/mobile-theme' import { MobileSourceControlCreatePrEntry } from './MobileSourceControlCreatePrEntry' import { MobileCommitFailurePanel } from './MobileCommitFailurePanel' @@ -7,12 +7,13 @@ import { KEYBOARD_COMMIT_BAR_CLEARANCE } from './mobile-source-control-screen-st import { makeRenderFileRow, BranchCompareFooter } from './MobileSourceControlFileRows' import type { MobileSourceControlState } from './use-mobile-source-control-state' import { styles } from './mobile-source-control-styles' +import { hubStyles } from './mobile-source-control-hub-styles' type Props = { state: MobileSourceControlState } -// The ready-state body: summary card, changed-files list, and commit bar. +// The ready-state Changes segment body: quick actions, changed-files list, and commit bar. export function MobileSourceControlContent({ state }: Props) { const { insets, @@ -29,23 +30,17 @@ export function MobileSourceControlContent({ state }: Props) { keyboardLift, openingPath, openingBranchPath, - status, sections, - branchEntries, hasVisibleChanges, stageablePaths, unstageablePaths, stagedCount, - unstagedCount, - branchLabel, - syncLabel, primaryAction, createPrAction, stageAll, unstageAll, generateCommitMessage, cancelGenerateCommitMessage, - abortConflictOperation, openFile, openBranchDiff, runGitAction @@ -66,41 +61,7 @@ export function MobileSourceControlContent({ state }: Props) { Reconnecting to desktop... ) : null} - - - - - - {branchLabel} - - - {syncLabel ? {syncLabel} : null} - - - {unstagedCount} changed - {stagedCount} staged - {branchEntries.length > 0 ? ( - {branchEntries.length} on branch - ) : null} - {status && status.conflictOperation !== 'unknown' ? ( - - {status.conflictOperation} - {(status.conflictOperation === 'merge' || status.conflictOperation === 'rebase') && ( - [styles.abortButton, pressed && styles.abortPressed]} - disabled={busyAction !== null} - onPress={() => void abortConflictOperation(status.conflictOperation)} - > - - {busyAction === `abort-${status.conflictOperation}` - ? 'Aborting…' - : `Abort ${status.conflictOperation}`} - - - )} - - ) : null} - + {commitFailureRecovery ? ( ) : ( void onRefresh: () => void + // When set (PR segment ready with a host URL), show open-on-web flush-right of + // the title so the control stays visible while the PR body scrolls. + onOpenPrWeb?: () => void + prNumber?: number | null } export function MobileSourceControlHeader({ @@ -16,7 +20,9 @@ export function MobileSourceControlHeader({ worktreeLabel, ioBusy, onBack, - onRefresh + onRefresh, + onOpenPrWeb, + prNumber = null }: Props) { return ( @@ -40,6 +46,21 @@ export function MobileSourceControlHeader({ {worktreeLabel} + {onOpenPrWeb ? ( + [styles.refreshButton, pressed && styles.refreshButtonPressed]} + onPress={onOpenPrWeb} + hitSlop={8} + accessibilityRole="link" + accessibilityLabel={ + prNumber != null + ? `Open pull request #${prNumber} on the web` + : 'Open pull request on the web' + } + > + + + ) : null} [ styles.refreshButton, diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx index 256cb9c0ab6..332f62312f1 100644 --- a/mobile/src/source-control/MobileSourceControlPanel.tsx +++ b/mobile/src/source-control/MobileSourceControlPanel.tsx @@ -1,3 +1,4 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, Pressable, Text, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { colors } from '../theme/mobile-theme' @@ -6,7 +7,17 @@ import { useMobileSourceControlActionSheet } from './use-mobile-source-control-a import { MobileSourceControlHeader } from './MobileSourceControlHeader' import { MobileSourceControlContent } from './MobileSourceControlContent' import { MobileSourceControlModals } from './MobileSourceControlModals' +import { MobileSourceControlSegments } from './MobileSourceControlSegments' +import { MobileSourceControlBranchCard } from './MobileSourceControlBranchCard' +import { MobileGitHistoryList } from './MobileGitHistoryList' import { styles } from './mobile-source-control-styles' +import { hubStyles } from './mobile-source-control-hub-styles' +import type { SourceControlHubTab } from './mobile-source-control-hub-tab' +import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary' +import { isMobileConflictAborting } from './mobile-source-control-conflict-abort' +import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller' +import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel' +import { openMobilePrUrl } from '../components/MobilePrComposeSheet' export type MobileSourceControlPanelProps = { hostId: string @@ -15,6 +26,8 @@ export type MobileSourceControlPanelProps = { /** Where the panel was launched from; drives the file-open dismissal path. */ origin?: string embedded?: boolean + /** Initial hub segment (from the route's `tab` deep-link param). */ + initialTab?: SourceControlHubTab onRequestClose?: () => void onFileOpenStart?: () => void onOpenedFileDiff?: (relativePath: string) => void @@ -26,10 +39,48 @@ export function MobileSourceControlPanel({ name = '', origin = '', embedded = false, + initialTab = 'changes', onRequestClose, onFileOpenStart, onOpenedFileDiff }: MobileSourceControlPanelProps) { + const [activeTab, setActiveTab] = useState(initialTab) + // Track first visit so Changes/History keep scroll state across segment switches + // without paying the mount cost until the user actually opens them. PR unmounts + // when inactive (WebViews / comment tree) but its controller state stays for the chip. + const [visitedTabs, setVisitedTabs] = useState>( + () => new Set([initialTab]) + ) + const [historyRefreshNonce, setHistoryRefreshNonce] = useState(0) + + // Deep-link / push with a different `tab` param should adopt the new segment + // (expo-router can reuse the screen instance when only query params change). + useEffect(() => { + setActiveTab(initialTab) + setVisitedTabs((prev) => { + if (prev.has(initialTab)) { + return prev + } + const next = new Set(prev) + next.add(initialTab) + return next + }) + }, [initialTab]) + + const selectTab = useCallback((tab: SourceControlHubTab) => { + setActiveTab(tab) + setVisitedTabs((prev) => { + if (prev.has(tab)) { + return prev + } + const next = new Set(prev) + next.add(tab) + return next + }) + }, []) + const openHistoryTab = useCallback(() => selectTab('history'), [selectTab]) + const openPrTab = useCallback(() => selectTab('pr'), [selectTab]) + const state = useMobileSourceControlState({ hostId, worktreeId, @@ -38,12 +89,15 @@ export function MobileSourceControlPanel({ embedded, onRequestClose, onFileOpenStart, - onOpenedFileDiff + onOpenedFileDiff, + onOpenHistory: openHistoryTab }) const actionSheetActions = useMobileSourceControlActionSheet(state) const { + client, connState, forceReconnect, + insets, router, setRootRef, worktreeLabel, @@ -51,25 +105,202 @@ export function MobileSourceControlPanel({ busyAction, openingPath, openingBranchPath, - loadStatus + loadStatus, + status, + branchCompareResult, + branchLabel, + syncLabel, + unstagedCount, + stagedCount, + branchEntries, + abortConflictOperation } = state const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null + const ready = screenState.kind === 'ready' + + // One PR controller feeds both the branch-card chip and the Pull Request + // segment, so the chip's rollup can never disagree with the checks list it + // links to. Branch + head come from the already-loaded git.status — no second + // status read. The chip loads independently, so it never blocks the file list. + // Keep last-known identity across a transient status unload (disconnect / + // failed refresh) so the controller does not wipe ready → hidden → cold start. + // Head SHA matches the review path: status.head ?? branchCompare headOid. + const lastPrBranchRef = useRef(null) + const lastPrHeadRef = useRef(null) + useEffect(() => { + lastPrBranchRef.current = null + lastPrHeadRef.current = null + }, [worktreeId]) + const statusBranch = status?.branch ?? null + const statusHead = status?.head ?? branchCompareResult?.summary.headOid ?? null + // Write last-known identity in an effect, not the render body: a discarded + // concurrent render must not leave the fallback holding a never-committed value. + useEffect(() => { + if (statusBranch) { + lastPrBranchRef.current = statusBranch + } + if (statusHead) { + lastPrHeadRef.current = statusHead + } + }, [statusBranch, statusHead]) + const prBranch = statusBranch ?? lastPrBranchRef.current + const prHeadSha = statusHead ?? lastPrHeadRef.current + const prController = useMobilePrSidebarController({ + client, + connState, + worktreeId, + branch: prBranch, + headSha: prHeadSha + }) + const isHostedRepo = prController.prSidebarIsGithubRepo + const prSidebarKind = prController.prSidebarState.kind + const refetchPr = prController.refetchPRSidebar + const ensurePrDetails = prController.ensurePrSidebarDetails + // Refs so tab effects do not re-fire when headSha recreates load() (soft refresh). + const refetchPrRef = useRef(refetchPr) + refetchPrRef.current = refetchPr + const ensurePrDetailsRef = useRef(ensurePrDetails) + ensurePrDetailsRef.current = ensurePrDetails + + // Chip bootstrap: phase-1 only (PR + checks). Full comment payload waits until + // the Pull Request segment is open — opening SC to stage must not pull details. + useEffect(() => { + if (activeTab === 'pr') { + return + } + if (prBranch && isHostedRepo && prSidebarKind === 'hidden') { + void refetchPrRef.current({ includeDetails: false }) + } + }, [activeTab, prBranch, isHostedRepo, prSidebarKind]) + + // Number of the ready PR whose phase-2 details are still missing (null when + // none). Keyed by PR number — not a boolean — so a same-branch PR swap during a + // chip-only soft refresh re-arms phase 2 for the new PR instead of leaving its + // comments on a forever-spinner (a stale ensure bails on the number mismatch). + const prDetailsMissingFor = + prController.prSidebarState.kind === 'ready' && prController.prSidebarState.data.details == null + ? prController.prSidebarState.data.pr.number + : null + + // PR segment: full load or phase-2 fill-in. Body only mounts while active. + // Guarded (and keyed) on prBranch so a branch that arrives while the segment is + // already open — e.g. mounted on a detached HEAD, then checkout — still loads; + // kind stays 'hidden' through that transition, so kind alone can't re-fire this. + useEffect(() => { + if (activeTab !== 'pr' || !isHostedRepo || !prBranch) { + return + } + if (prSidebarKind === 'hidden') { + void refetchPrRef.current({ includeDetails: true }) + return + } + if (prDetailsMissingFor != null) { + void ensurePrDetailsRef.current() + } + }, [activeTab, isHostedRepo, prBranch, prSidebarKind, prDetailsMissingFor]) + + const prChip = useMemo(() => { + // No branch (detached HEAD / mid-rebase) never runs a PR load, so the shared + // state stays 'hidden' — which the chip would render as a forever spinner. + // Hide the chip instead; the Pull Request segment shows "branch unavailable". + if (!isHostedRepo || !prBranch) { + return null + } + const commentCount = + prController.prSidebarState.kind === 'ready' + ? countUnresolvedReviewThreads(prController.prSidebarState.data.details?.comments) + : null + return buildMobilePrChipSummary(prController.prSidebarState, commentCount) + }, [isHostedRepo, prBranch, prController.prSidebarState]) + + // Design: refresh the active segment's body work, plus git.status for the shared + // branch card (counts/sync stay honest even while on History). Preserve ready + // status on a failed refresh so PR chip identity is not wiped to hidden. + const onRefresh = useCallback(() => { + void loadStatus({ preserveReadyOnFailure: true }) + if (activeTab === 'history') { + setHistoryRefreshNonce((n) => n + 1) + return + } + if (!isHostedRepo) { + return + } + if (activeTab === 'pr') { + void refetchPr({ includeDetails: true }) + return + } + // Changes: light chip refresh so the branch card stays current without comments. + void refetchPr({ includeDetails: false }) + }, [activeTab, isHostedRepo, loadStatus, refetchPr]) // Embedded mode docks beside the terminal: close the dock instead of popping // a route, and skip the full-screen safe-area chrome (the dock column owns it). - // Fall back to router.back() when embedded without a close handler so the button - // never silently no-ops. const onBack = embedded ? (onRequestClose ?? (() => router.back())) : () => router.back() + // Chromeless PR body has no panel header — surface open-on-web on the hub chrome + // while the Pull Request segment is active (same affordance as the old /pr route). + const prWebUrl = + activeTab === 'pr' && + prController.prSidebarState.kind === 'ready' && + prController.prSidebarState.data.pr.url + ? prController.prSidebarState.data.pr.url + : null + const prWebNumber = + prController.prSidebarState.kind === 'ready' ? prController.prSidebarState.data.pr.number : null const header = ( void loadStatus()} + onRefresh={onRefresh} + onOpenPrWeb={prWebUrl ? () => openMobilePrUrl(prWebUrl) : undefined} + prNumber={prWebNumber} /> ) + const statusGate = + screenState.kind === 'loading' ? ( + + + + ) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? ( + + + {screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'} + + {screenState.message} + {screenState.kind === 'error' ? ( + { + // Why: retrying the request is useless while the transport's + // reconnect loop is parked at its give-up cap — revive the + // connection instead (issue #5049). loadStatus re-runs via + // its connState effect once the new client connects. + if (connState !== 'connected' && hostId) { + void forceReconnect(hostId) + return + } + void loadStatus() + }} + > + Retry + + ) : null} + + ) : null + + // History only needs the RPC client — do not block it behind git.status. + // Changes/PR need status (branch, file list, head SHA), so they stay gated. + const showChanges = ready && (activeTab === 'changes' || visitedTabs.has('changes')) + const showHistory = activeTab === 'history' || visitedTabs.has('history') + // Why: only mount the PR body while its segment is active. Keep-mounting retained + // Mermaid WebViews and re-rendered the comment tree on every commit keystroke. + // Controller + chip state still live for instant re-open without a full cold start. + const showPrBody = ready && activeTab === 'pr' + const conflictOperation = status?.conflictOperation ?? null + const conflictAborting = isMobileConflictAborting(busyAction, conflictOperation) + return ( {embedded ? ( @@ -80,38 +311,64 @@ export function MobileSourceControlPanel({ )} - {screenState.kind === 'loading' ? ( - - + + + {ready ? ( + void abortConflictOperation(operation)} + prChip={prChip} + onOpenPr={openPrTab} + /> + ) : null} + + {showChanges ? ( + + - ) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? ( - - - {screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'} - - {screenState.message} - {screenState.kind === 'error' ? ( - { - // Why: retrying the request is useless while the transport's - // reconnect loop is parked at its give-up cap — revive the - // connection instead (issue #5049). loadStatus re-runs via - // its connState effect once the new client connects. - if (connState !== 'connected' && hostId) { - void forceReconnect(hostId) - return - } - void loadStatus() - }} - > - Retry - - ) : null} + ) : activeTab === 'changes' ? ( + statusGate + ) : null} + + {showPrBody ? ( + + - ) : ( - - )} + ) : activeTab === 'pr' ? ( + statusGate + ) : null} + + {showHistory ? ( + + + + ) : null} diff --git a/mobile/src/source-control/MobileSourceControlPrChip.tsx b/mobile/src/source-control/MobileSourceControlPrChip.tsx new file mode 100644 index 00000000000..1165aae8b9b --- /dev/null +++ b/mobile/src/source-control/MobileSourceControlPrChip.tsx @@ -0,0 +1,121 @@ +import { ActivityIndicator, Pressable, Text, View } from 'react-native' +import { + AlertTriangle, + Check, + ChevronRight, + CircleDot, + GitPullRequest, + MessageSquare, + X +} from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' +import { statusColor } from '../components/pr-sidebar/pr-sidebar-status-color' +import { hubStyles } from './mobile-source-control-hub-styles' +import type { MobilePrChipRollup, MobilePrChipSummary } from './mobile-pr-chip-summary' + +type Props = { + summary: MobilePrChipSummary + onPress: () => void +} + +// The glanceable PR status line on the branch card. Tapping it switches to the +// Pull Request segment. Rendered only when the repo supports hosted review — the +// parent gates on that, so this component always has something meaningful to show. +export function MobileSourceControlPrChip({ summary, onPress }: Props) { + return ( + [hubStyles.chip, pressed && hubStyles.chipPressed]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={chipAccessibilityLabel(summary)} + > + + + + {summary.kind === 'loading' ? ( + <> + + + Loading pull request… + + + ) : summary.kind === 'none' ? ( + <> + Create pull request + + + + ) : summary.kind === 'unavailable' ? ( + <> + + {summary.message} + + + + ) : ( + <> + #{summary.number} + + + {summary.stateLabel} + + + + {summary.commentCount != null && summary.commentCount > 0 ? ( + + + {summary.commentCount} + + ) : null} + + + + )} + + ) +} + +function ChipRollup({ rollup }: { rollup: MobilePrChipRollup }) { + const color = statusColor(rollup.token) + return ( + + + {rollup.text} + + ) +} + +function RollupIcon({ kind, color }: { kind: MobilePrChipRollup['kind']; color: string }) { + const size = 13 + const strokeWidth = 2.3 + switch (kind) { + case 'conflict': + return + case 'failing': + return + case 'running': + return + case 'passed': + return + case 'none': + return null + } +} + +function chipAccessibilityLabel(summary: MobilePrChipSummary): string { + switch (summary.kind) { + case 'loading': + return 'Loading pull request' + case 'none': + return 'Create pull request' + case 'unavailable': + return `Pull request unavailable: ${summary.message}` + case 'ready': { + const comments = + summary.commentCount != null && summary.commentCount > 0 + ? `, ${summary.commentCount} unresolved comments` + : '' + return `Pull request #${summary.number}, ${summary.stateLabel}, ${summary.rollup.text}${comments}. Open pull request.` + } + } +} diff --git a/mobile/src/source-control/MobileSourceControlSegments.tsx b/mobile/src/source-control/MobileSourceControlSegments.tsx new file mode 100644 index 00000000000..89564aca417 --- /dev/null +++ b/mobile/src/source-control/MobileSourceControlSegments.tsx @@ -0,0 +1,45 @@ +import { Pressable, Text, View } from 'react-native' +import { + SOURCE_CONTROL_HUB_TABS, + SOURCE_CONTROL_HUB_TAB_LABELS, + type SourceControlHubTab +} from './mobile-source-control-hub-tab' +import { hubStyles } from './mobile-source-control-hub-styles' + +type Props = { + active: SourceControlHubTab + onSelect: (tab: SourceControlHubTab) => void +} + +// The hub's top-level lens switcher. Switching is local state (no route push) so +// scroll position and the shared branch card persist across Changes/PR/History. +export function MobileSourceControlSegments({ active, onSelect }: Props) { + return ( + + {SOURCE_CONTROL_HUB_TABS.map((tab) => { + const isActive = tab === active + return ( + [ + hubStyles.segment, + isActive && hubStyles.segmentActive, + pressed && !isActive && hubStyles.segmentPressed + ]} + onPress={() => onSelect(tab)} + accessibilityRole="tab" + accessibilityState={{ selected: isActive }} + accessibilityLabel={SOURCE_CONTROL_HUB_TAB_LABELS[tab]} + > + + {SOURCE_CONTROL_HUB_TAB_LABELS[tab]} + + + ) + })} + + ) +} diff --git a/mobile/src/source-control/mobile-pr-chip-summary.test.ts b/mobile/src/source-control/mobile-pr-chip-summary.test.ts new file mode 100644 index 00000000000..88186a06ea0 --- /dev/null +++ b/mobile/src/source-control/mobile-pr-chip-summary.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest' +import type { PRCheckDetail, PRComment, PRInfo } from '../../../src/shared/types' +import type { PrSidebarState } from '../session/mobile-pr-sidebar-state' +import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary' + +function pr(overrides: Partial = {}): PRInfo { + return { + number: 7701, + title: 'Make center split more visible', + state: 'open', + url: 'https://example.test/pr/7701', + checksStatus: 'success', + updatedAt: '2026-07-07T00:00:00Z', + mergeable: 'MERGEABLE', + ...overrides + } +} + +function check( + conclusion: PRCheckDetail['conclusion'], + status: PRCheckDetail['status'] = 'completed' +): PRCheckDetail { + return { name: `check-${conclusion}-${status}`, status, conclusion, url: null } +} + +function ready(prInfo: PRInfo, checks: PRCheckDetail[]): PrSidebarState { + return { kind: 'ready', data: { pr: prInfo, checks, details: null } } +} + +describe('buildMobilePrChipSummary', () => { + it('maps non-ready states', () => { + expect(buildMobilePrChipSummary({ kind: 'hidden' })).toEqual({ kind: 'loading' }) + expect(buildMobilePrChipSummary({ kind: 'loading' })).toEqual({ kind: 'loading' }) + expect(buildMobilePrChipSummary({ kind: 'none' })).toEqual({ kind: 'none' }) + expect(buildMobilePrChipSummary({ kind: 'error', message: 'net' })).toEqual({ + kind: 'unavailable', + message: 'net' + }) + expect(buildMobilePrChipSummary({ kind: 'blocked', message: 'auth' })).toEqual({ + kind: 'unavailable', + message: 'auth' + }) + }) + + it('surfaces the PR number and state badge', () => { + const summary = buildMobilePrChipSummary(ready(pr({ state: 'draft' }), [check('success')])) + expect(summary.kind).toBe('ready') + if (summary.kind !== 'ready') { + return + } + expect(summary.number).toBe(7701) + expect(summary.stateLabel).toBe('Draft') + }) + + it('rolls up passed checks as passed/total', () => { + const summary = buildMobilePrChipSummary( + ready(pr(), [check('success'), check('success'), check('skipped')]) + ) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.rollup).toEqual({ kind: 'passed', text: '2/3', token: 'statusGreen' }) + }) + + it('prefers failing over running and passing', () => { + const summary = buildMobilePrChipSummary( + ready(pr(), [check('success'), check('failure'), check(null, 'in_progress')]) + ) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.rollup).toEqual({ kind: 'failing', text: '1 failing', token: 'statusRed' }) + }) + + it('shows running when nothing has failed yet', () => { + const summary = buildMobilePrChipSummary(ready(pr(), [check('success'), check(null, 'queued')])) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.rollup).toEqual({ kind: 'running', text: '1 running', token: 'statusAmber' }) + }) + + it('lets a merge conflict win over green checks', () => { + const summary = buildMobilePrChipSummary( + ready(pr({ mergeable: 'CONFLICTING' }), [check('success')]) + ) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.rollup.kind).toBe('conflict') + }) + + it('reports no checks when the list is empty', () => { + const summary = buildMobilePrChipSummary(ready(pr(), [])) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.rollup).toEqual({ kind: 'none', text: 'No checks', token: 'textSecondary' }) + }) + + it('passes through the unresolved comment count', () => { + const summary = buildMobilePrChipSummary(ready(pr(), [check('success')]), 3) + if (summary.kind !== 'ready') { + throw new Error('expected ready') + } + expect(summary.commentCount).toBe(3) + }) +}) + +describe('countUnresolvedReviewThreads', () => { + function comment(overrides: Partial): PRComment { + return { + id: 1, + author: 'octocat', + authorAvatarUrl: '', + body: 'x', + createdAt: '2026-07-07T00:00:00Z', + url: '', + ...overrides + } + } + + it('returns null when details have not loaded', () => { + expect(countUnresolvedReviewThreads(null)).toBeNull() + expect(countUnresolvedReviewThreads(undefined)).toBeNull() + }) + + it('counts each unresolved thread once and ignores resolved threads', () => { + const comments = [ + comment({ id: 1, threadId: 't1', isResolved: false }), + comment({ id: 2, threadId: 't1', isResolved: false }), + comment({ id: 3, threadId: 't2', isResolved: true }), + comment({ id: 4, threadId: 't3' }), + comment({ id: 5 }) + ] + expect(countUnresolvedReviewThreads(comments)).toBe(2) + }) +}) diff --git a/mobile/src/source-control/mobile-pr-chip-summary.ts b/mobile/src/source-control/mobile-pr-chip-summary.ts new file mode 100644 index 00000000000..87f54d43472 --- /dev/null +++ b/mobile/src/source-control/mobile-pr-chip-summary.ts @@ -0,0 +1,104 @@ +import type { PRComment } from '../../../src/shared/types' +import type { PrSidebarState } from '../session/mobile-pr-sidebar-state' +import { + prStateBadge, + summarizePRChecks, + type MobileStatusToken +} from '../components/pr-sidebar/pr-checks-presentation' + +// Pure derivation of the branch-card PR chip from the shared PR sidebar state. +// No React/native imports so the rollup precedence is unit-testable (KTD5). The +// chip and the Pull Request segment read the SAME PrSidebarState, so the chip's +// check rollup can never disagree with the checks list it links to. + +// The single check-rollup token the chip shows. Exactly one wins, by precedence: +// merge conflict > failing > running > passed > no checks. Worst-actionable-first +// so a red/amber signal is never hidden behind a green count. +export type MobilePrChipRollup = + | { kind: 'conflict'; text: string; token: MobileStatusToken } + | { kind: 'failing'; text: string; token: MobileStatusToken } + | { kind: 'running'; text: string; token: MobileStatusToken } + | { kind: 'passed'; text: string; token: MobileStatusToken } + | { kind: 'none'; text: string; token: MobileStatusToken } + +export type MobilePrChipSummary = + | { kind: 'loading' } + // GitHub repo, but this branch has no open/linked PR — the chip becomes a + // "create pull request" affordance that routes to the PR segment's composer. + | { kind: 'none' } + // A permanent (auth/permission) or transient failure loading PR data. The chip + // degrades to a muted, tappable line rather than vanishing. + | { kind: 'unavailable'; message: string } + | { + kind: 'ready' + number: number + stateLabel: string + stateToken: MobileStatusToken + rollup: MobilePrChipRollup + // Unresolved review-comment count, when the deferred details payload has + // loaded; null while it is still loading or unavailable. + commentCount: number | null + } + +// Unresolved review threads (the "💬 n" count). Counts each inline thread once by +// threadId when it is not resolved; top-level conversation comments (no threadId) +// are not review threads and don't count. Returns null when details haven't loaded. +export function countUnresolvedReviewThreads( + comments: PRComment[] | null | undefined +): number | null { + if (!comments) { + return null + } + const unresolved = new Set() + for (const comment of comments) { + if (comment.threadId && comment.isResolved !== true) { + unresolved.add(comment.threadId) + } + } + return unresolved.size +} + +export function buildMobilePrChipSummary( + state: PrSidebarState, + commentCount: number | null = null +): MobilePrChipSummary { + switch (state.kind) { + case 'hidden': + case 'loading': + return { kind: 'loading' } + case 'none': + return { kind: 'none' } + case 'error': + case 'blocked': + return { kind: 'unavailable', message: state.message } + case 'ready': { + const badge = prStateBadge(state.data.pr.state) + return { + kind: 'ready', + number: state.data.pr.number, + stateLabel: badge.label, + stateToken: badge.token, + rollup: buildChipRollup(state), + commentCount + } + } + } +} + +function buildChipRollup(state: Extract): MobilePrChipRollup { + // Conflicts win: the checks may be green, but the PR still can't merge. + if (state.data.pr.mergeable === 'CONFLICTING') { + return { kind: 'conflict', text: 'Conflicts', token: 'statusAmber' } + } + const checks = summarizePRChecks(state.data.checks) + if (checks.failed > 0) { + return { kind: 'failing', text: `${checks.failed} failing`, token: 'statusRed' } + } + if (checks.pending > 0) { + return { kind: 'running', text: `${checks.pending} running`, token: 'statusAmber' } + } + if (checks.passed > 0) { + return { kind: 'passed', text: `${checks.passed}/${checks.total}`, token: 'statusGreen' } + } + return { kind: 'none', text: 'No checks', token: 'textSecondary' } +} diff --git a/mobile/src/source-control/mobile-source-control-conflict-abort.test.ts b/mobile/src/source-control/mobile-source-control-conflict-abort.test.ts new file mode 100644 index 00000000000..fa9e40a4746 --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-conflict-abort.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + isMobileConflictAborting, + mobileConflictAbortLabel +} from './mobile-source-control-conflict-abort' + +describe('isMobileConflictAborting', () => { + it('is true only for the matching abort action', () => { + expect(isMobileConflictAborting('abort-merge', 'merge')).toBe(true) + expect(isMobileConflictAborting('abort-rebase', 'rebase')).toBe(true) + }) + + it('is false for other busy actions (stage/commit must not look like abort)', () => { + expect(isMobileConflictAborting('stage-all', 'merge')).toBe(false) + expect(isMobileConflictAborting('commit', 'rebase')).toBe(false) + expect(isMobileConflictAborting(null, 'merge')).toBe(false) + expect(isMobileConflictAborting('abort-merge', 'rebase')).toBe(false) + expect(isMobileConflictAborting('abort-merge', 'unknown')).toBe(false) + }) +}) + +describe('mobileConflictAbortLabel', () => { + it('shows Aborting only while abort is in flight', () => { + expect(mobileConflictAbortLabel('merge', true)).toBe('Aborting…') + expect(mobileConflictAbortLabel('merge', false)).toBe('Abort merge') + expect(mobileConflictAbortLabel('rebase', false)).toBe('Abort rebase') + }) +}) diff --git a/mobile/src/source-control/mobile-source-control-conflict-abort.ts b/mobile/src/source-control/mobile-source-control-conflict-abort.ts new file mode 100644 index 00000000000..ac71bcbf9ed --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-conflict-abort.ts @@ -0,0 +1,18 @@ +// Pure helpers for the branch-card conflict Abort control. Kept free of React so +// the busy-label rule (abort-in-flight only) is unit-testable. + +/** True while git.abortMerge / git.abortRebase is the active serial action. */ +export function isMobileConflictAborting( + busyAction: string | null, + conflictOperation: string | null +): boolean { + if (conflictOperation !== 'merge' && conflictOperation !== 'rebase') { + return false + } + return busyAction === `abort-${conflictOperation}` +} + +/** Label for the Abort control — never says "Aborting…" for unrelated busy work. */ +export function mobileConflictAbortLabel(conflictOperation: string, aborting: boolean): string { + return aborting ? 'Aborting…' : `Abort ${conflictOperation}` +} diff --git a/mobile/src/source-control/mobile-source-control-hub-styles.ts b/mobile/src/source-control/mobile-source-control-hub-styles.ts new file mode 100644 index 00000000000..683fa3a1dcc --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-hub-styles.ts @@ -0,0 +1,126 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +// Styles for the hub's segmented control and the branch-card PR chip. Split from +// mobile-source-control-styles.ts so neither file crosses the line limit. +export const hubStyles = StyleSheet.create({ + segments: { + flexDirection: 'row', + marginHorizontal: spacing.lg, + marginTop: spacing.sm, + padding: 3, + borderRadius: radii.button + 2, + backgroundColor: colors.bgRaised, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle, + gap: 2 + }, + segment: { + flex: 1, + minHeight: 34, + borderRadius: radii.button, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.xs + }, + segmentActive: { + backgroundColor: colors.bgPanel, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderSubtle + }, + segmentPressed: { + opacity: 0.7 + }, + segmentText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + segmentTextActive: { + color: colors.textPrimary + }, + // The PR chip sits below the count row inside the branch card, separated by a + // hairline so it reads as a distinct, tappable status line. + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + marginTop: spacing.md, + paddingTop: spacing.md, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + chipPressed: { + opacity: 0.7 + }, + chipIcon: { + width: 18, + alignItems: 'center' + }, + chipNumber: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '700' + }, + statePill: { + paddingHorizontal: spacing.sm, + paddingVertical: 1, + borderRadius: radii.button, + borderWidth: StyleSheet.hairlineWidth + }, + statePillText: { + fontSize: typography.metaSize, + fontWeight: '700' + }, + rollup: { + flexDirection: 'row', + alignItems: 'center', + gap: 4 + }, + rollupText: { + fontSize: typography.metaSize, + fontWeight: '600' + }, + comment: { + flexDirection: 'row', + alignItems: 'center', + gap: 4 + }, + commentText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontWeight: '600' + }, + // Pushes the chevron to the trailing edge without a fixed-width spacer. + chipSpacer: { + flex: 1, + minWidth: spacing.sm + }, + chipCreateText: { + color: colors.accentBlue, + fontSize: typography.bodySize, + fontWeight: '600' + }, + chipMutedText: { + flex: 1, + color: colors.textMuted, + fontSize: typography.metaSize + }, + // Wraps the Changes-only controls (commit-failure/error notice, create-PR entry, + // bulk Stage/Unstage row) that used to live inside the summary card, now that the + // card is shared across segments and holds only branch status. + changesControls: { + paddingHorizontal: spacing.lg, + marginTop: spacing.xs + }, + // Fills the remaining space below the header/segments/card so each segment's + // scroll view (SectionList / PR sidebar / history list) expands and scrolls. + tabBody: { + flex: 1 + }, + // Keep a previously-visited segment mounted (scroll + fetch state) without + // participating in layout while another segment is active. + tabBodyHidden: { + display: 'none' + } +}) diff --git a/mobile/src/source-control/mobile-source-control-hub-tab.test.ts b/mobile/src/source-control/mobile-source-control-hub-tab.test.ts new file mode 100644 index 00000000000..4caf135cf13 --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-hub-tab.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { parseSourceControlHubTab, SOURCE_CONTROL_HUB_TABS } from './mobile-source-control-hub-tab' + +describe('parseSourceControlHubTab', () => { + it('accepts each known tab', () => { + for (const tab of SOURCE_CONTROL_HUB_TABS) { + expect(parseSourceControlHubTab(tab)).toBe(tab) + } + }) + + it('reads the first element of an array param', () => { + expect(parseSourceControlHubTab(['pr', 'history'])).toBe('pr') + }) + + it('falls back to changes for unknown, empty, or missing values', () => { + expect(parseSourceControlHubTab('nope')).toBe('changes') + expect(parseSourceControlHubTab('')).toBe('changes') + expect(parseSourceControlHubTab(undefined)).toBe('changes') + expect(parseSourceControlHubTab(null)).toBe('changes') + expect(parseSourceControlHubTab([])).toBe('changes') + }) +}) diff --git a/mobile/src/source-control/mobile-source-control-hub-tab.ts b/mobile/src/source-control/mobile-source-control-hub-tab.ts new file mode 100644 index 00000000000..e243032a58c --- /dev/null +++ b/mobile/src/source-control/mobile-source-control-hub-tab.ts @@ -0,0 +1,29 @@ +// The three lenses of the mobile Source Control hub. Kept as a pure module (no +// React/native imports) so tab parsing is unit-testable and the deep-link `tab` +// query param and the segmented control share one source of truth. + +export type SourceControlHubTab = 'changes' | 'pr' | 'history' + +export const SOURCE_CONTROL_HUB_TABS: readonly SourceControlHubTab[] = [ + 'changes', + 'pr', + 'history' +] as const + +export const SOURCE_CONTROL_HUB_TAB_LABELS: Record = { + changes: 'Changes', + pr: 'Pull Request', + history: 'History' +} + +// Normalize a route param (possibly an array from expo-router, possibly unknown) +// to a valid tab, defaulting to 'changes'. Deep links that name a stale/invalid +// tab fall back rather than render a blank body. +export function parseSourceControlHubTab( + value: string | string[] | undefined | null +): SourceControlHubTab { + const first = Array.isArray(value) ? value[0] : value + return SOURCE_CONTROL_HUB_TABS.includes(first as SourceControlHubTab) + ? (first as SourceControlHubTab) + : 'changes' +} diff --git a/mobile/src/source-control/use-mobile-source-control-runners.ts b/mobile/src/source-control/use-mobile-source-control-runners.ts index 29892344779..026a706a5f9 100644 --- a/mobile/src/source-control/use-mobile-source-control-runners.ts +++ b/mobile/src/source-control/use-mobile-source-control-runners.ts @@ -45,6 +45,8 @@ type Params = { setCreatedPrUrl: (next: string | null) => void setCreatedPrWarning: (next: string | null) => void recordCommitFailure: RecordMobileCommitFailure + // Hub override: switch to the History segment instead of pushing the route. + onOpenHistory?: () => void } // All git workflow + action-sheet runners for the source-control panel. Split @@ -78,7 +80,8 @@ export function useMobileSourceControlRunners(params: Params) { setShowBranchPicker, setCreatedPrUrl, setCreatedPrWarning, - recordCommitFailure + recordCommitFailure, + onOpenHistory } = params const runGitWorkflow = useCallback( @@ -248,14 +251,24 @@ export function useMobileSourceControlRunners(params: Params) { const openHistory = useCallback(() => { setShowActionSheet(false) - if (hostId && worktreeId) { - router.push( - `/h/${hostId}/history/${encodeURIComponent(worktreeId)}` as Parameters< - typeof router.push - >[0] - ) + // Inside the hub, History is a segment — switch to it rather than pushing a + // route. Fallback pushes the hub with `tab=history` (not the redirecting + // /history route) so deep links land in one hop. + if (onOpenHistory) { + onOpenHistory() + return } - }, [hostId, router, setShowActionSheet, worktreeId]) + if (hostId && worktreeId) { + router.push({ + pathname: '/h/[hostId]/source-control/[worktreeId]', + params: { + hostId, + worktreeId, + tab: 'history' + } + } as Parameters[0]) + } + }, [hostId, onOpenHistory, router, setShowActionSheet, worktreeId]) // Switch to a local branch, then reload status. const checkoutBranch = useCallback( diff --git a/mobile/src/source-control/use-mobile-source-control-state.ts b/mobile/src/source-control/use-mobile-source-control-state.ts index cc69b89059c..c9a8ca406b0 100644 --- a/mobile/src/source-control/use-mobile-source-control-state.ts +++ b/mobile/src/source-control/use-mobile-source-control-state.ts @@ -42,6 +42,9 @@ export type MobileSourceControlStateParams = { onRequestClose?: () => void onFileOpenStart?: () => void onOpenedFileDiff?: (relativePath: string) => void + // When the panel runs inside the hub, "History" switches the segment instead of + // pushing the standalone route. Absent for the standalone/dock usage. + onOpenHistory?: () => void } export function useMobileSourceControlState(params: MobileSourceControlStateParams) { @@ -53,7 +56,8 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara embedded, onRequestClose, onFileOpenStart, - onOpenedFileDiff + onOpenedFileDiff, + onOpenHistory } = params const insets = useSafeAreaInsets() const { client, state: connState } = useHostClient(hostId) @@ -197,7 +201,8 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara setShowBranchPicker, setCreatedPrUrl, setCreatedPrWarning, - recordCommitFailure + recordCommitFailure, + onOpenHistory }) const createPrAction = useMobileSourceControlCreatePrAction({ client, diff --git a/mobile/src/terminal/terminal-text-input-normalization.test.ts b/mobile/src/terminal/terminal-text-input-normalization.test.ts index 29e15ea321b..c5213013e31 100644 --- a/mobile/src/terminal/terminal-text-input-normalization.test.ts +++ b/mobile/src/terminal/terminal-text-input-normalization.test.ts @@ -11,10 +11,4 @@ describe('normalizeTerminalTextInput', () => { it('keeps ASCII hyphens unchanged', () => { expect(normalizeTerminalTextInput('git checkout -- file')).toBe('git checkout -- file') }) - - it('preserves longer trailing hyphen runs when iOS re-collapses the controlled value', () => { - expect(normalizeTerminalTextInput('—', '--')).toBe('---') - expect(normalizeTerminalTextInput('—', '---')).toBe('----') - expect(normalizeTerminalTextInput('git checkout —', 'git checkout --')).toBe('git checkout ---') - }) }) diff --git a/mobile/src/terminal/terminal-text-input-normalization.ts b/mobile/src/terminal/terminal-text-input-normalization.ts index 70500421b3b..6316ce1bbaa 100644 --- a/mobile/src/terminal/terminal-text-input-normalization.ts +++ b/mobile/src/terminal/terminal-text-input-normalization.ts @@ -1,18 +1,9 @@ // Why: iOS smart punctuation can rewrite two ASCII hyphens into a single -// Unicode dash before React Native delivers terminal text input. +// Unicode dash before React Native delivers terminal text input. Each dash maps +// to exactly "--": recovering longer runs (#5222) needed the previous controlled +// value written back into the field, and that write-back kills iOS dictation (#7925). const IOS_SMART_DASH_REPLACEMENT_PATTERN = /[\u2013\u2014]/g -const IOS_SMART_DASH_REPLACEMENT_TEST = /[\u2013\u2014]/ -export function normalizeTerminalTextInput(text: string, previousText = ''): string { - const normalizedText = text.replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--') - const previousTrailingHyphens = /-+$/.exec(previousText)?.[0] ?? '' - const previousPrefix = previousText.slice(0, previousText.length - previousTrailingHyphens.length) - const collapsedPreviousHyphenRun = - previousTrailingHyphens.length >= 2 && - IOS_SMART_DASH_REPLACEMENT_TEST.test(text) && - (text === `${previousPrefix}\u2013` || text === `${previousPrefix}\u2014`) - if (collapsedPreviousHyphenRun) { - return `${previousText}-` - } - return normalizedText +export function normalizeTerminalTextInput(text: string): string { + return text.replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--') } diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 2b8461c582f..41f1b5092e8 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -20,6 +20,7 @@ import { type ReactNode } from 'react' import { connect, type RpcClient } from './rpc-client' +import { connectionLogStore } from './connection-log-buffer' import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers' import { loadHosts } from './host-store' import type { ConnectionState, HostProfile } from './types' @@ -150,7 +151,12 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { let client: RpcClient try { - client = connect(host.endpoint, host.deviceToken, host.publicKeyB64) + client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, { + // Why: retain reconnect lifecycle events for the Connection Log + // screen — without this the reasons a host is stuck live only in + // console.log, which users can't see or share. + onLog: (entry) => connectionLogStore.append(hostId, entry) + }) } catch { // Why: connect() can throw synchronously if the public key is // malformed or the endpoint URL is invalid. Notify so the UI diff --git a/mobile/src/transport/connection-health.test.ts b/mobile/src/transport/connection-health.test.ts new file mode 100644 index 00000000000..22e752a6335 --- /dev/null +++ b/mobile/src/transport/connection-health.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { classifyConnection, verdictDisplayLabel } from './connection-health' + +describe('classifyConnection Tailscale hint', () => { + const base = { + state: 'reconnecting' as const, + lastConnectedAt: null, + nowMs: 1_000_000 + } + + it('adds the hint to the warning verdict for a tailnet CGNAT endpoint', () => { + const verdict = classifyConnection({ + ...base, + reconnectAttempts: 3, + endpoint: 'ws://100.65.9.106:6768' + }) + expect(verdict).toMatchObject({ kind: 'warning', hint: 'check Tailscale' }) + }) + + it('adds the hint to the unreachable verdict for a MagicDNS endpoint', () => { + const verdict = classifyConnection({ + ...base, + reconnectAttempts: 12, + endpoint: 'ws://my-desktop.tailnet-1234.ts.net:6768' + }) + expect(verdict).toMatchObject({ + kind: 'unreachable', + reason: 'never-connected', + hint: 'check Tailscale' + }) + }) + + it('keeps plain labels for LAN endpoints', () => { + const warning = classifyConnection({ + ...base, + reconnectAttempts: 3, + endpoint: 'ws://192.168.1.50:6768' + }) + expect(warning.kind).toBe('warning') + expect('hint' in warning && warning.hint).toBeFalsy() + }) + + it('keeps plain labels when no endpoint is provided', () => { + const verdict = classifyConnection({ ...base, reconnectAttempts: 3 }) + expect(verdict.kind).toBe('warning') + expect('hint' in verdict && verdict.hint).toBeFalsy() + }) + + it('never hints on healthy states', () => { + const verdict = classifyConnection({ + state: 'connected', + reconnectAttempts: 0, + lastConnectedAt: 999_000, + endpoint: 'ws://100.65.9.106:6768', + nowMs: 1_000_000 + }) + expect(verdict).toEqual({ kind: 'normal', label: 'Connected' }) + }) +}) + +describe('verdictDisplayLabel', () => { + it('appends the hint to warning and unreachable labels', () => { + expect( + verdictDisplayLabel({ kind: 'warning', label: "Can't connect", hint: 'check Tailscale' }) + ).toBe("Can't connect — check Tailscale") + expect( + verdictDisplayLabel({ + kind: 'unreachable', + label: "Can't reach desktop", + reason: 'stale', + hint: 'check Tailscale' + }) + ).toBe("Can't reach desktop — check Tailscale") + }) + + it('returns the bare label without a hint', () => { + expect(verdictDisplayLabel({ kind: 'warning', label: "Can't connect" })).toBe("Can't connect") + expect(verdictDisplayLabel({ kind: 'normal', label: 'Connected' })).toBe('Connected') + }) +}) diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts index 4ce81beffd2..d3b8251fc1b 100644 --- a/mobile/src/transport/connection-health.ts +++ b/mobile/src/transport/connection-health.ts @@ -1,3 +1,4 @@ +import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint' import type { ConnectionState } from './types' // Why: thresholds for escalating connection UX from neutral @@ -11,7 +12,8 @@ import type { ConnectionState } from './types' // reuse the 60s cap). Combined with the never-connected / // stale-since-last-connect heuristic below, this is the trigger to // surface a "re-pair?" affordance. MUST stay aligned with -// rpc-client.ts GIVE_UP_AFTER_ATTEMPTS. +// rpc-client.ts GIVE_UP_AFTER_ATTEMPTS (past which the loop slows +// to a 90s trickle instead of parking). // - STALE_SINCE_LAST_CONNECT_MS: 60s → if we WERE connected this // session but haven't been for ≥ 1 minute despite the retry loop // spinning, treat the same as never-connected. Catches the case @@ -20,10 +22,21 @@ const WARNING_ATTEMPTS = 3 const UNREACHABLE_ATTEMPTS = 12 const STALE_SINCE_LAST_CONNECT_MS = 60_000 +// Why: a repeatedly-unreachable 100.x/*.ts.net endpoint almost always means +// the phone's Tailscale tunnel is down or wedged (a known iOS failure mode +// that only a manual toggle fixes) — not that the desktop moved. Say so +// instead of leaving the user staring at a generic "Can't connect". +const TAILSCALE_HINT = 'check Tailscale' + export type ConnectionVerdict = | { kind: 'normal'; label: string } - | { kind: 'warning'; label: string } // "Can't connect" - | { kind: 'unreachable'; label: string; reason: 'never-connected' | 'stale' } + | { kind: 'warning'; label: string; hint?: string } // "Can't connect" + | { + kind: 'unreachable' + label: string + reason: 'never-connected' | 'stale' + hint?: string + } | { kind: 'auth-failed'; label: string } // Why: the rpc-client's lastConnectedAt is a one-shot timestamp; we have @@ -33,10 +46,14 @@ export function classifyConnection(args: { state: ConnectionState reconnectAttempts: number lastConnectedAt: number | null + // Optional pinned host endpoint — enables the Tailscale hint on + // warning/unreachable verdicts. Callers without it get plain labels. + endpoint?: string | null nowMs?: number }): ConnectionVerdict { const { state, reconnectAttempts, lastConnectedAt } = args const now = args.nowMs ?? Date.now() + const hint = isTailscaleEndpoint(args.endpoint) ? TAILSCALE_HINT : undefined if (state === 'auth-failed') { return { kind: 'auth-failed', label: 'Auth failed' } @@ -60,21 +77,32 @@ export function classifyConnection(args: { return { kind: 'unreachable', label: "Can't reach desktop", - reason: 'never-connected' + reason: 'never-connected', + hint } } if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) { return { kind: 'unreachable', label: "Can't reach desktop", - reason: 'stale' + reason: 'stale', + hint } } } if (reconnectAttempts >= WARNING_ATTEMPTS) { - return { kind: 'warning', label: "Can't connect" } + return { kind: 'warning', label: "Can't connect", hint } } return { kind: 'normal', label: 'Reconnecting…' } } + +// Why: single place that turns a verdict into display text so every screen +// renders the Tailscale hint the same way. +export function verdictDisplayLabel(verdict: ConnectionVerdict): string { + if ((verdict.kind === 'warning' || verdict.kind === 'unreachable') && verdict.hint) { + return `${verdict.label} — ${verdict.hint}` + } + return verdict.label +} diff --git a/mobile/src/transport/connection-log-buffer.test.ts b/mobile/src/transport/connection-log-buffer.test.ts new file mode 100644 index 00000000000..e7ce662534b --- /dev/null +++ b/mobile/src/transport/connection-log-buffer.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest' +import { createConnectionLogStore } from './connection-log-buffer' +import type { ConnectionLogEntry } from './types' + +function entry(id: number): ConnectionLogEntry { + return { id: `log-${id}`, ts: 1_000 + id, level: 'info', message: `event ${id}` } +} + +describe('connection log buffer', () => { + it('keeps entries per host without cross-talk', () => { + const store = createConnectionLogStore() + store.append('host-a', entry(1)) + store.append('host-b', entry(2)) + + expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1']) + expect(store.get('host-b').map((e) => e.id)).toEqual(['log-2']) + }) + + it('drops the oldest entries past the cap', () => { + const store = createConnectionLogStore(3) + for (let i = 1; i <= 5; i++) { + store.append('host-a', entry(i)) + } + + expect(store.get('host-a').map((e) => e.id)).toEqual(['log-3', 'log-4', 'log-5']) + }) + + it('returns a stable snapshot reference until the next append', () => { + const store = createConnectionLogStore() + store.append('host-a', entry(1)) + + const first = store.get('host-a') + expect(store.get('host-a')).toBe(first) + + store.append('host-a', entry(2)) + expect(store.get('host-a')).not.toBe(first) + // Empty hosts must also be referentially stable (useSyncExternalStore). + expect(store.get('host-b')).toBe(store.get('host-b')) + }) + + it('notifies only the host being appended to and stops after unsubscribe', () => { + const store = createConnectionLogStore() + const onA = vi.fn() + const onB = vi.fn() + const unsubA = store.subscribe('host-a', onA) + store.subscribe('host-b', onB) + + store.append('host-a', entry(1)) + expect(onA).toHaveBeenCalledTimes(1) + expect(onB).not.toHaveBeenCalled() + + unsubA() + store.append('host-a', entry(2)) + expect(onA).toHaveBeenCalledTimes(1) + }) +}) diff --git a/mobile/src/transport/connection-log-buffer.ts b/mobile/src/transport/connection-log-buffer.ts new file mode 100644 index 00000000000..1b223ad8e1f --- /dev/null +++ b/mobile/src/transport/connection-log-buffer.ts @@ -0,0 +1,84 @@ +import type { ConnectionLogEntry } from './types' + +// Why: the rpc-client's onLog entries were only wired during pairing; for +// long-lived host connections everything went to console.log, invisible to +// users. This buffer retains the recent lifecycle events per host so a +// "Connection log" screen (and copy-diagnostics) can show why a connection +// is stuck without a debug build. Module-level so the log survives client +// swaps (forceReconnect) and provider remounts (hot reload); bounded so an +// all-night reconnect loop can't grow memory unbounded. +const MAX_ENTRIES_PER_HOST = 200 + +export type ConnectionLogStore = { + append: (hostId: string, entry: ConnectionLogEntry) => void + get: (hostId: string) => readonly ConnectionLogEntry[] + subscribe: (hostId: string, listener: () => void) => () => void +} + +export function createConnectionLogStore( + maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST +): ConnectionLogStore { + const entriesByHost = new Map() + const listenersByHost = new Map void>>() + // Why: useSyncExternalStore compares snapshots by reference — getSnapshot + // must return the SAME array until the data actually changes, or React + // loops re-rendering. Cache per host; invalidate on append. + const snapshotByHost = new Map() + const EMPTY: readonly ConnectionLogEntry[] = [] + + return { + append(hostId, entry) { + let entries = entriesByHost.get(hostId) + if (!entries) { + entries = [] + entriesByHost.set(hostId, entries) + } + entries.push(entry) + if (entries.length > maxEntriesPerHost) { + entries.splice(0, entries.length - maxEntriesPerHost) + } + snapshotByHost.delete(hostId) + const listeners = listenersByHost.get(hostId) + if (listeners) { + for (const listener of listeners) { + listener() + } + } + }, + + get(hostId) { + const cached = snapshotByHost.get(hostId) + if (cached) { + return cached + } + const entries = entriesByHost.get(hostId) + if (!entries || entries.length === 0) { + return EMPTY + } + const snapshot = Object.freeze([...entries]) + snapshotByHost.set(hostId, snapshot) + return snapshot + }, + + subscribe(hostId, listener) { + let listeners = listenersByHost.get(hostId) + if (!listeners) { + listeners = new Set() + listenersByHost.set(hostId, listeners) + } + listeners.add(listener) + return () => { + const set = listenersByHost.get(hostId) + if (!set) { + return + } + set.delete(listener) + if (set.size === 0) { + listenersByHost.delete(hostId) + } + } + } + } +} + +export const connectionLogStore = createConnectionLogStore() diff --git a/mobile/src/transport/rpc-client.test.ts b/mobile/src/transport/rpc-client.test.ts index e096ee659c9..b2adb18c4ce 100644 --- a/mobile/src/transport/rpc-client.test.ts +++ b/mobile/src/transport/rpc-client.test.ts @@ -618,28 +618,42 @@ describe('mobile rpc-client connection timeout', () => { return { client, socket } } - it('repro: a parked reconnect loop never retries on its own', async () => { + // Why 520_000ms: the 12 fast attempts cost Σ(RECONNECT_DELAYS) 360.5s of + // backoff plus 13 × 12s connect timeouts ≈ 516.5s, so 520s lands just + // past the give-up cap with the first trickle timer armed. + const PAST_GIVE_UP_CAP_MS = 520_000 + + it('keeps trickle-retrying after the give-up cap instead of parking', async () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') openAndAuthenticate(mockSockets[0]!) mockSockets[0]!.close() - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(PAST_GIVE_UP_CAP_MS) expect(client.getState()).toBe('reconnecting') - expect(client.getReconnectAttempt()).toBe(12) + expect(client.getReconnectAttempt()).toBeGreaterThanOrEqual(12) - // Stuck: arbitrary additional time produces no further attempts. + // A wedged VPN produces no revival nudge (issue #7824) — the loop must + // keep dialing on its own at the 90s trickle cadence. const socketsBefore = mockSockets.length - await vi.advanceTimersByTimeAsync(600_000) - expect(mockSockets.length).toBe(socketsBefore) + await vi.advanceTimersByTimeAsync(102_000) + expect(mockSockets.length).toBeGreaterThan(socketsBefore) + + // Once the tunnel heals, a trickle dial restores the session without + // any user action. 75s lands inside the next dial's 12s connect window + // (the prior dial failed mid-advance above, re-arming the 90s timer). + await vi.advanceTimersByTimeAsync(75_000) + openAndAuthenticate(mockSockets[mockSockets.length - 1]!) + expect(client.getState()).toBe('connected') + expect(client.getReconnectAttempt()).toBe(0) client.close() }) - it('restarts a parked reconnect loop on foreground', async () => { + it('restarts a backed-off reconnect loop on foreground without waiting out the trickle', async () => { const client = connect('ws://desktop.invalid', 'token', 'server-key') openAndAuthenticate(mockSockets[0]!) mockSockets[0]!.close() - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(PAST_GIVE_UP_CAP_MS) expect(client.getReconnectAttempt()).toBe(12) const socketsBefore = mockSockets.length @@ -921,7 +935,7 @@ describe('mobile rpc-client connection timeout', () => { () => null, (error: Error) => error ) - await vi.runAllTimersAsync() + await vi.advanceTimersByTimeAsync(520_000) expect(client.getState()).toBe('reconnecting') expect(client.getReconnectAttempt()).toBe(12) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 2cd484de4b8..b9d0c990261 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -103,16 +103,22 @@ export type RpcClient = { // time across all 12 attempts is ≈ 6 minutes before the give-up cap // fires (0.5+1+2+4+8+15+30+60+60+60+60+60 ≈ 360s). const RECONNECT_DELAYS = [500, 1000, 2000, 4000, 8000, 15_000, 30_000, 60_000] -// Why: cap auto-retry once we're clearly unreachable for a long time. +// Why: cap fast auto-retry once we're clearly unreachable for a long time. // With the tiered backoff above this is ≈ 6 minutes of continuous -// failure before we stop and surface the re-pair banner. The longer +// failure before the UI surfaces the re-pair banner. The longer // runway tolerates flaky AP-isolation routers and laptop sleep cycles // that briefly drop the LAN path. MUST stay aligned with // connection-health.ts UNREACHABLE_ATTEMPTS so the "unreachable" -// verdict matches the moment the loop actually pauses — if these -// drift the user sees "Reconnecting…" while the loop is silently -// parked. +// verdict matches the moment the loop slows to the trickle cadence. const GIVE_UP_AFTER_ATTEMPTS = 12 +// Why: past the cap the loop must never park permanently. A wedged +// Tailscale/VPN tunnel produces no AppState or network-type transition +// (still Wi-Fi, still "online"), so no revival nudge ever fires — users +// had to toggle Tailscale off/on just to force one. A slow trickle dial +// self-heals once the tunnel recovers while staying cheap: one TCP +// attempt per 90s, foreground-only (iOS/Android suspend JS timers in +// the background). +const TRICKLE_RECONNECT_DELAY_MS = 90_000 // Why: a single `unauthorized`/`e2ee_error` is not proof the pairing is dead. // Issue #5200: a tablet showed "Auth failed" and forced a needless re-pair // while the desktop still listed it as paired with a valid token — a transient @@ -280,9 +286,11 @@ export function connect( if (intentionallyClosed) { return Promise.reject(new Error('Client closed')) } - if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS && !reconnectTimer) { - // Why: after the retry cap there is no future state transition to - // release callers waiting before their per-request timeout starts. + if (state === 'reconnecting' && reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) { + // Why: past the retry cap the loop only trickles every 90s — callers + // must fail fast rather than hang on a host that's been unreachable + // for minutes. A trickle dial that succeeds flips state to 'connected' + // and later requests go through normally. return Promise.reject(new Error('Connection retry limit reached')) } return new Promise((resolve, reject) => { @@ -773,27 +781,35 @@ export function connect( } function scheduleReconnect() { - // Why: spinning reconnect forever drains battery and floods logs + // Why: spinning fast reconnects forever drains battery and floods logs // when the host is genuinely unreachable (wrong IP, port closed, - // host moved). Cap at GIVE_UP_AFTER_ATTEMPTS — the UI surfaces a - // "Can't reach desktop, re-pair?" banner at this point and the - // user can tap Retry (forceReconnect creates a fresh client, - // resetting the counter) or Re-pair. Without an explicit cap the - // worst-case is a phone left on the home screen burning a socket - // open every 4s indefinitely. - if (reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS) { - console.log('[net] reconnect-paused', { - attempt: reconnectAttempt, - reason: 'give-up-cap', - endpoint: redactedEndpoint(endpoint) - }) + // host moved). Past GIVE_UP_AFTER_ATTEMPTS the UI surfaces a + // "Can't reach desktop, re-pair?" banner and the loop drops to the + // 90s trickle cadence instead of parking — a permanently parked loop + // could only be revived by an AppState/network transition, which a + // wedged VPN tunnel never produces. + const pastGiveUpCap = reconnectAttempt >= GIVE_UP_AFTER_ATTEMPTS + let delay: number + if (pastGiveUpCap) { + // Why: the counter holds at the cap — connection-health thresholds and + // the "Can't reach desktop" verdict key off attempts >= 12, and a + // successful open resets it to 0 anyway. + delay = TRICKLE_RECONNECT_DELAY_MS rejectConnectWaiters('Connection retry limit reached') - return + } else { + delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! + reconnectAttempt++ } - const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]! - reconnectAttempt++ - console.log('[net] scheduleReconnect', { delayMs: delay, attempt: reconnectAttempt }) - emitLog('info', `Reconnect scheduled in ${delay}ms`, `Attempt ${reconnectAttempt}`) + console.log('[net] scheduleReconnect', { + delayMs: delay, + attempt: reconnectAttempt, + trickle: pastGiveUpCap + }) + emitLog( + 'info', + `Reconnect scheduled in ${delay}ms`, + pastGiveUpCap ? `Attempt ${reconnectAttempt} (slow retry)` : `Attempt ${reconnectAttempt}` + ) reconnectTimer = setTimeout(() => { reconnectTimer = null openConnection() @@ -1176,10 +1192,10 @@ export function connect( return } if (state === 'reconnecting') { - // Why: while backgrounded the retry loop may have parked at the - // give-up cap or be sitting on a 60s backoff timer. Returning to - // the foreground is a strong user signal — restart with a fresh - // attempt budget immediately instead of requiring an app restart. + // Why: while backgrounded the retry loop may be sitting on a 60s + // backoff or 90s trickle timer. Returning to the foreground is a + // strong user signal — restart with a fresh attempt budget + // immediately instead of waiting out the timer. console.log('[net] foreground — restarting reconnect loop', { attempt: reconnectAttempt, hadTimer: !!reconnectTimer diff --git a/package.json b/package.json index a0ae48e60ee..a24b1d1d669 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.131-rc.0", + "version": "1.4.131-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -69,8 +69,8 @@ "build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win", "build:icons": "bash resources/icon-source/generate.sh", - "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", - "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", + "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", + "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headless", "test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index 5ed9578685a..4ae4c962d59 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -92,32 +92,32 @@ describe('agent hooks CLI handler', () => { rmSync(userDataPath, { recursive: true, force: true }) }) - it('keeps the fresh-profile new card style default when creating offline settings', async () => { + it('keeps new card style off when creating offline settings for a fresh profile', async () => { await runAgentHooksOff(userDataPath) const persisted = readDataFile(userDataPath) - expect(persisted.settings.experimentalNewWorktreeCardStyle).toBe(true) + expect(persisted.settings.experimentalNewWorktreeCardStyle).toBe(false) expect(persisted.settings.agentStatusHooksEnabled).toBe(false) }) - it('defaults missing new card style on while offline-updated onboarding is open', async () => { + it('keeps missing new card style off when updating offline settings', async () => { const existing = getDefaultPersistedState(userDataPath) delete existing.settings.experimentalNewWorktreeCardStyle writeDataFile(userDataPath, existing) await runAgentHooksOff(userDataPath) - expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true) + expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(false) }) - it('preserves an existing explicit new card style opt-out when updating offline settings', async () => { + it('preserves an existing explicit new card style opt-in when updating offline settings', async () => { const existing = getDefaultPersistedState(userDataPath) - existing.settings.experimentalNewWorktreeCardStyle = false + existing.settings.experimentalNewWorktreeCardStyle = true writeDataFile(userDataPath, existing) await runAgentHooksOff(userDataPath) - expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(false) + expect(readDataFile(userDataPath).settings.experimentalNewWorktreeCardStyle).toBe(true) }) }) diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index 2419be2733f..2b83f5c15ea 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -31,16 +31,7 @@ function isRecord(value: unknown): value is Record { function readPersistedState(dataPath: string): PersistedState { if (!existsSync(dataPath)) { - const defaults = getDefaultPersistedState(homedir()) - return { - ...defaults, - settings: { - ...defaults.settings, - // Why: offline CLI can create the first profile before desktop load; - // match the Store fresh-install default instead of pinning old cards. - experimentalNewWorktreeCardStyle: true - } - } + return getDefaultPersistedState(homedir()) } try { const parsed = JSON.parse(readFileSync(dataPath, 'utf-8')) @@ -83,14 +74,9 @@ function readEnabledFromDisk(): boolean { function updateEnabledOnDisk(enabled: boolean): string { const dataPath = getDataPath() const state = readPersistedState(dataPath) - const experimentalNewWorktreeCardStyle = - state.settings?.experimentalNewWorktreeCardStyle ?? state.onboarding?.closedAt === null state.settings = { ...getDefaultPersistedState(homedir()).settings, ...state.settings, - // Why: offline CLI can run before Store.load(); mirror its open-onboarding - // default without overriding a saved user opt-out. - experimentalNewWorktreeCardStyle, agentStatusHooksEnabled: enabled } writePersistedState(dataPath, state) diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 69fed98ea8d..abcafe1b438 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PassThrough } from 'node:stream' import type { GlobalSettings } from '../../shared/types' +import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' const testState = { userDataDir: '', @@ -766,20 +767,20 @@ describe('CodexAccountService config sync', () => { if (script.includes('readlink -f')) { return `${wslLinuxHomePath}\n` } + if (script.includes('command -v codex')) { + throw new Error('bash -ic does not inherit the distro login-shell PATH') + } + if (args.slice(2, 5).join(' ') === '-- sh -c') { + expect(args).toEqual(buildWslCodexAvailabilityArgs('Debian')) + return '' + } mkdirSync(wslManagedHomePath, { recursive: true }) writeFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'account-id-for-test\n') return '' }) const spawnMock = vi.fn((command: string, args: string[]) => { expect(command).toBe('wsl.exe') - expect(args).toEqual([ - '-d', - 'Debian', - '--exec', - 'bash', - '-ic', - `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` - ]) + expect(args).toEqual(buildWslCodexLoginArgs('Debian', wslLinuxHomePath)) // Why: codex login runs inside WSL, so the rewritten path must be the // Linux-side ~/.codex, not a Windows UNC path. expect(readFileSync(join(wslManagedHomePath, 'config.toml'), 'utf-8')).toBe( @@ -876,8 +877,8 @@ describe('CodexAccountService config sync', () => { if (script.includes('readlink -f')) { return `${wslLinuxHomePath}\n` } - if (script.includes('command -v codex')) { - expect(args.slice(0, 5)).toEqual(['-d', 'Debian', '--exec', 'bash', '-ic']) + if (args.slice(2, 5).join(' ') === '-- sh -c') { + expect(args).toEqual(buildWslCodexAvailabilityArgs('Debian')) throw new Error('codex missing') } mkdirSync(wslManagedHomePath, { recursive: true }) @@ -960,14 +961,7 @@ describe('CodexAccountService config sync', () => { }) const spawnMock = vi.fn((command: string, args: string[]) => { expect(command).toBe('wsl.exe') - expect(args).toEqual([ - '-d', - 'Ubuntu', - '--exec', - 'bash', - '-ic', - `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` - ]) + expect(args).toEqual(buildWslCodexLoginArgs('Ubuntu', wslLinuxHomePath)) const child = new EventEmitter() as EventEmitter & { stdout: PassThrough stderr: PassThrough @@ -1075,14 +1069,7 @@ describe('CodexAccountService config sync', () => { }) const spawnMock = vi.fn((command: string, args: string[]) => { expect(command).toBe('wsl.exe') - expect(args).toEqual([ - '-d', - 'Ubuntu', - '--exec', - 'bash', - '-ic', - `export CODEX_HOME='${wslLinuxHomePath}'; exec codex login` - ]) + expect(args).toEqual(buildWslCodexLoginArgs('Ubuntu', wslLinuxHomePath)) expect(readFileSync(join(wslManagedHomePath, '.orca-managed-home'), 'utf-8')).toBe( 'account-1\n' ) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index c979725633b..2a0fb54bdc4 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -22,6 +22,11 @@ import type { RateLimitService } from '../rate-limits/service' import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' import { buildEncodedWslBashCommand } from '../wsl-bash-command' +import { + buildWslCodexAvailabilityArgs, + buildWslCodexLoginArgs, + WSL_CODEX_AVAILABILITY_TIMEOUT_MS +} from './wsl-codex-command' import { getCodexSelectionTargetForAccount, getSelectedCodexAccountIdForTarget, @@ -805,15 +810,7 @@ export class CodexAccountService { const spawnConfig = wslInfo ? { command: 'wsl.exe', - // Why: nvm and similar WSL installs often initialize PATH from interactive shell config. - args: [ - '-d', - wslInfo.distro, - '--exec', - 'bash', - '-ic', - `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}; exec codex login` - ], + args: buildWslCodexLoginArgs(wslInfo.distro, wslInfo.linuxPath), env: process.env, codexCommand: 'codex' } @@ -923,18 +920,10 @@ export class CodexAccountService { private assertWslCodexCliAvailable(wslInfo: { distro: string; linuxPath: string }): void { try { - execFileSync( - 'wsl.exe', - [ - '-d', - wslInfo.distro, - '--exec', - 'bash', - '-ic', - buildEncodedWslBashCommand('command -v codex >/dev/null 2>&1') - ], - { encoding: 'utf-8', timeout: 5000 } - ) + execFileSync('wsl.exe', buildWslCodexAvailabilityArgs(wslInfo.distro), { + encoding: 'utf-8', + timeout: WSL_CODEX_AVAILABILITY_TIMEOUT_MS + }) } catch (error) { throw new Error( `Codex CLI is not available in WSL ${wslInfo.distro}. Install Codex in that distro or switch Account location to Windows.`, diff --git a/src/main/codex-accounts/wsl-codex-command.test.ts b/src/main/codex-accounts/wsl-codex-command.test.ts new file mode 100644 index 00000000000..f696ddcde4f --- /dev/null +++ b/src/main/codex-accounts/wsl-codex-command.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { buildWslCodexAvailabilityArgs, buildWslCodexLoginArgs } from './wsl-codex-command' + +describe('WSL Codex commands', () => { + it('checks the alias-neutral PATH from the distro login shell', () => { + const args = buildWslCodexAvailabilityArgs('Ubuntu24-Dev') + + expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu24-Dev', '--', 'sh', '-c']) + expect(args.at(-1)).toContain('getent passwd') + expect(args.at(-1)).toContain('_orca_lookup_command=') + expect(args.at(-1)).toContain('codex') + expect(args.at(-1)).not.toContain('bash -ic') + }) + + it('launches the resolved Codex executable with its quoted managed home', () => { + const args = buildWslCodexLoginArgs('Ubuntu', '/home/alice/managed-home') + const command = args.at(-1) + + expect(command).toContain('export CODEX_HOME=') + expect(command).toContain('/home/alice/managed-home') + expect(command).toContain('exec "\\$resolved" login') + }) +}) diff --git a/src/main/codex-accounts/wsl-codex-command.ts b/src/main/codex-accounts/wsl-codex-command.ts new file mode 100644 index 00000000000..02527c8ed23 --- /dev/null +++ b/src/main/codex-accounts/wsl-codex-command.ts @@ -0,0 +1,43 @@ +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' +import { + buildWslLoginShellCommand, + escapeWslShCommandForWindows, + quotePosixShell +} from '../../shared/wsl-login-shell-command' + +export const WSL_CODEX_AVAILABILITY_TIMEOUT_MS = 5_000 + +export function buildWslCodexAvailabilityArgs(distro: string): string[] { + const command = [buildCodexPathLookup(), '[ -n "$resolved" ]'].join('\n') + return buildWslCodexShellArgs(distro, command) +} + +export function buildWslCodexLoginArgs(distro: string, linuxHomePath: string): string[] { + const command = [ + buildCodexPathLookup(), + 'if [ -z "$resolved" ]; then', + " printf '%s\\n' 'Codex CLI not found in the WSL login-shell PATH.' >&2", + ' exit 127', + 'fi', + `export CODEX_HOME=${quotePosixShell(linuxHomePath)}`, + 'exec "$resolved" login' + ].join('\n') + return buildWslCodexShellArgs(distro, command) +} + +function buildCodexPathLookup(): string { + return buildPosixCommandPathLookupScript({ kind: 'literal', value: 'codex' }) +} + +function buildWslCodexShellArgs(distro: string, command: string): string[] { + // Why: Codex must use the distro user's configured login shell, whose PATH + // can differ from a hard-coded non-login bash invocation. + return [ + '-d', + distro, + '--', + 'sh', + '-c', + escapeWslShCommandForWindows(buildWslLoginShellCommand(command)) + ] +} diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index ce9208e9b05..605f2cd5f24 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -3,6 +3,10 @@ import { dirname, join } from 'node:path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite' +import { + promoteCodexRuntimeSettingsToSystem, + snapshotCodexRuntimeSettingsBaseline +} from './config-settings-promotion' import { createTomlLineScanState, getTomlTableHeader, @@ -19,11 +23,19 @@ function getSystemCodexConfigTomlPath(): string { } export function syncSystemConfigIntoManagedCodexHome(): void { + // Why: the mirror overwrites runtime settings from ~/.codex, so changes the + // user made inside Orca-launched Codex (/model, /approvals) must be written + // back to ~/.codex first or this very pass silently reverts them. + promoteCodexRuntimeSettingsToSystem() try { syncSystemConfigIntoManagedCodexHomeUnsafe() } catch (error) { console.warn('[codex-config] Failed to mirror system Codex config:', error) + return } + // Why: the baseline advances only after a successful mirror; recording an + // unpromoted runtime change as Orca-written would strand it forever. + snapshotCodexRuntimeSettingsBaseline() } function syncSystemConfigIntoManagedCodexHomeUnsafe(): void { diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts new file mode 100644 index 00000000000..e7878777bff --- /dev/null +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -0,0 +1,87 @@ +import { execFileSync } from 'node:child_process' +import { win32 as pathWin32 } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' + +export type CodexWslRuntimeHookTarget = { + runtime?: 'host' | 'wsl' + wslDistro?: string | null +} + +export type CodexWslRuntimeHookInstallPlan = { + configPath: string + tomlPath: string + scriptPath: string + commandScriptPath: string + trustConfigPath: string +} + +export type CanonicalizeWslLinuxPath = (distro: string, linuxPath: string) => string | null + +function trimTrailingSlash(value: string): string { + return value.length > 1 ? value.replace(/\/+$/, '') : value +} + +function toDefaultWslLinuxPath(windowsPath: string): string { + const driveMatch = windowsPath.match(/^([A-Za-z]):[/\\](.*)$/) + if (!driveMatch) { + return windowsPath + } + return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2].replace(/\\/g, '/')}` +} + +function canonicalizeWslLinuxPath(distro: string, linuxPath: string): string | null { + if (process.platform !== 'win32') { + return linuxPath + } + try { + const canonicalPath = execFileSync( + 'wsl.exe', + ['-d', distro, '--', 'readlink', '-f', '--', linuxPath], + { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 5000 + } + ).trim() + return canonicalPath.startsWith('/') ? canonicalPath : null + } catch { + return null + } +} + +export function createCodexWslRuntimeHookInstallPlan( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget, + canonicalize: CanonicalizeWslLinuxPath = canonicalizeWslLinuxPath +): CodexWslRuntimeHookInstallPlan | null { + if (!runtimeHomePath) { + return null + } + + const wslInfo = parseWslUncPath(runtimeHomePath) + if (!wslInfo && target?.runtime !== 'wsl') { + return null + } + const distro = wslInfo?.distro || (target?.runtime === 'wsl' ? target.wslDistro?.trim() : null) + if (!distro) { + return null + } + + const logicalLinuxRuntimeHome = wslInfo?.linuxPath ?? toDefaultWslLinuxPath(runtimeHomePath) + if (!logicalLinuxRuntimeHome.startsWith('/')) { + return null + } + // Why: Codex canonicalizes hook sources inside WSL; resolving there keeps + // trust keys valid when HOME or the runtime directory crosses a symlink. + const linuxRuntimeHome = trimTrailingSlash( + canonicalize(distro, logicalLinuxRuntimeHome) ?? logicalLinuxRuntimeHome + ) + + return { + configPath: pathWin32.join(runtimeHomePath, 'hooks.json'), + tomlPath: pathWin32.join(runtimeHomePath, 'config.toml'), + scriptPath: pathWin32.join(runtimeHomePath, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: `${linuxRuntimeHome}/.orca/agent-hooks/codex-hook.sh`, + trustConfigPath: `${linuxRuntimeHome}/hooks.json` + } +} diff --git a/src/main/codex/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts new file mode 100644 index 00000000000..f81866d8751 --- /dev/null +++ b/src/main/codex/config-settings-promotion.test.ts @@ -0,0 +1,296 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock + } +}) + +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { upsertTopLevelSettingsInContent } from './config-settings-promotion' + +let tmpHome: string +let userDataDir: string +let previousUserDataPath: string | undefined + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-settings-home-')) + userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-settings-user-data-')) + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + process.env.ORCA_USER_DATA_PATH = userDataDir + homedirMock.mockReturnValue(tmpHome) + // Why: promotion writes into homedir()/.codex — if the mock ever fails to + // intercept, these tests would rewrite the developer's real Codex config. + if (homedir() !== tmpHome) { + throw new Error('node:os homedir mock is not active; refusing to touch the real ~/.codex') + } +}) + +afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }) + rmSync(userDataDir, { recursive: true, force: true }) + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + vi.clearAllMocks() +}) + +function systemConfigPath(): string { + return join(tmpHome, '.codex', 'config.toml') +} + +function runtimeHomeDir(): string { + return join(userDataDir, 'codex-runtime-home', 'home') +} + +function runtimeConfigPath(): string { + return join(runtimeHomeDir(), 'config.toml') +} + +function baselinePath(): string { + return join(runtimeHomeDir(), '.orca-config-settings-baseline.json') +} + +function writeSystemConfig(content: string): void { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + writeFileSync(systemConfigPath(), content, 'utf-8') +} + +function readSystemConfig(): string { + return readFileSync(systemConfigPath(), 'utf-8') +} + +function readRuntimeConfig(): string { + return readFileSync(runtimeConfigPath(), 'utf-8') +} + +// Mimics how Codex (toml_edit) persists a /model or /approvals change: the +// top-level key line is rewritten in place, or created when absent. +function simulateCodexSettingWrite(key: string, rawValue: string): void { + mkdirSync(runtimeHomeDir(), { recursive: true }) + const existing = existsSync(runtimeConfigPath()) ? readFileSync(runtimeConfigPath(), 'utf-8') : '' + const linePattern = new RegExp(`^${key}[ \\t]*=.*$`, 'm') + const rendered = `${key} = ${rawValue}` + const next = linePattern.test(existing) + ? existing.replace(linePattern, rendered) + : `${rendered}\n${existing}` + writeFileSync(runtimeConfigPath(), next, 'utf-8') +} + +function simulateCodexSettingRemoval(key: string): void { + const existing = readFileSync(runtimeConfigPath(), 'utf-8') + const linePattern = new RegExp(`^${key}[ \\t]*=.*\\n?`, 'm') + writeFileSync(runtimeConfigPath(), existing.replace(linePattern, ''), 'utf-8') +} + +describe('codex settings write-back promotion', () => { + it('promotes an in-Codex model change to ~/.codex and reaches a steady state', () => { + writeSystemConfig( + 'model = "gpt-5"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n' + ) + syncSystemConfigIntoManagedCodexHome() + expect(existsSync(baselinePath())).toBe(true) + + simulateCodexSettingWrite('model', '"gpt-5.5-codex"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + 'model = "gpt-5.5-codex"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n' + ) + expect(readRuntimeConfig()).toContain('model = "gpt-5.5-codex"') + + const settledSystem = readSystemConfig() + const settledRuntime = readRuntimeConfig() + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe(settledSystem) + expect(readRuntimeConfig()).toBe(settledRuntime) + }) + + it('promotes multiple approvals keys in one pass', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('approval_policy', '"never"') + simulateCodexSettingWrite('sandbox_mode', '"danger-full-access"') + syncSystemConfigIntoManagedCodexHome() + + const system = readSystemConfig() + expect(system).toContain('approval_policy = "never"') + expect(system).toContain('sandbox_mode = "danger-full-access"') + expect(system).toContain('model = "gpt-5"') + }) + + it('does not promote on the first pass without a baseline, then promotes after one', () => { + writeSystemConfig('model = "gpt-5"\n') + mkdirSync(runtimeHomeDir(), { recursive: true }) + // Pre-upgrade state: runtime already diverged, but no baseline exists. + writeFileSync(runtimeConfigPath(), 'model = "user-changed-before-upgrade"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(readRuntimeConfig()).toContain('model = "gpt-5"') + expect(existsSync(baselinePath())).toBe(true) + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "o4"\n') + }) + + it('treats a corrupt baseline as missing and rewrites it', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + writeFileSync(baselinePath(), 'not json', 'utf-8') + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 1 }) + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + expect(readSystemConfig()).toBe('model = "o4"\n') + }) + + it('lets an outside ~/.codex edit win over a conflicting in-Codex change', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"in-codex-choice"') + writeSystemConfig('model = "outside-edit"\n') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "outside-edit"\n') + expect(readRuntimeConfig()).toContain('model = "outside-edit"') + }) + + it('inserts a key ~/.codex lacks into the preamble without disturbing the rest', () => { + writeSystemConfig('# my codex config\nmodel = "gpt-5"\n\n[features]\nhooks = true\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('approval_policy', '"on-request"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe( + '# my codex config\nmodel = "gpt-5"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n' + ) + }) + + it('creates ~/.codex/config.toml when a user without one changes a setting', () => { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }) + syncSystemConfigIntoManagedCodexHome() + expect(existsSync(baselinePath())).toBe(true) + + // Codex itself creates the runtime config.toml on the first /model write. + simulateCodexSettingWrite('model', '"gpt-5.5-codex"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5.5-codex"\n') + expect(readRuntimeConfig()).toContain('model = "gpt-5.5-codex"') + }) + + it('does not promote a key deletion', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingRemoval('model') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + expect(readRuntimeConfig()).toContain('model = "gpt-5"') + }) + + it('ignores keys outside the allowlist', () => { + writeSystemConfig('model = "gpt-5"\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('notify', '["custom-notifier"]') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n') + }) + + it('ignores allowlisted keys inside tables such as [profiles.*]', () => { + writeSystemConfig('model = "gpt-5"\n\n[profiles.dev]\nmodel = "profile-model"\n') + syncSystemConfigIntoManagedCodexHome() + + const runtime = readRuntimeConfig() + writeFileSync( + runtimeConfigPath(), + runtime.replace('model = "profile-model"', 'model = "profile-changed"'), + 'utf-8' + ) + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[profiles.dev]\nmodel = "profile-model"\n') + }) + + it('never rewrites a multiline system value', () => { + writeSystemConfig('model = """\nodd\nmultiline\n"""\n') + syncSystemConfigIntoManagedCodexHome() + + writeFileSync(runtimeConfigPath(), 'model = "single"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = """\nodd\nmultiline\n"""\n') + }) + + it('preserves CRLF line endings when replacing a value', () => { + writeSystemConfig('model = "gpt-5"\r\napproval_policy = "on-request"\r\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toContain('model = "o4"\r\n') + expect(readSystemConfig()).toContain('approval_policy = "on-request"\r\n') + }) + + it('promotes over a value that carried an inline comment', () => { + writeSystemConfig('model = "gpt-5" # my favorite\n') + syncSystemConfigIntoManagedCodexHome() + + simulateCodexSettingWrite('model', '"o4"') + syncSystemConfigIntoManagedCodexHome() + + expect(readSystemConfig()).toBe('model = "o4"\n') + }) +}) + +describe('upsertTopLevelSettingsInContent', () => { + it('writes into empty content', () => { + expect(upsertTopLevelSettingsInContent('', new Map([['model', '"x"']]))).toBe('model = "x"\n') + }) + + it('inserts before the first table with a separating blank line', () => { + expect( + upsertTopLevelSettingsInContent('[features]\nhooks = true\n', new Map([['model', '"x"']])) + ).toBe('model = "x"\n\n[features]\nhooks = true\n') + }) + + it('appends to a preamble-only file without a trailing newline', () => { + expect( + upsertTopLevelSettingsInContent('approval_policy = "never"', new Map([['model', '"x"']])) + ).toBe('approval_policy = "never"\nmodel = "x"\n') + }) + + it('replaces the existing line in place', () => { + expect( + upsertTopLevelSettingsInContent( + '# keep\nmodel = "old"\n\n[t]\nk = 1\n', + new Map([['model', '"new"']]) + ) + ).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n') + }) +}) diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts new file mode 100644 index 00000000000..934d57ed39e --- /dev/null +++ b/src/main/codex/config-settings-promotion.ts @@ -0,0 +1,242 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import { + createTomlLineScanState, + getTomlTableHeader, + isTomlStructuralLine, + updateTomlLineScanState +} from './config-toml-line-scan' + +// Why: the config mirror rewrites the runtime config.toml from ~/.codex on +// every launch (and on background rate-limit fetches), so settings the user +// changes inside Orca-launched Codex silently revert. Promotion diffs the +// runtime file against a baseline of what Orca last wrote — anything that +// differs is a change Codex persisted for the user and belongs in ~/.codex. + +// Why: only the user-preference scalars the Codex TUI itself persists +// (/model writes model + model_reasoning_effort, /approvals writes +// approval_policy + sandbox_mode). Every key added here gets written into the +// user's real ~/.codex/config.toml, so grow this list deliberately. +export const PROMOTED_CODEX_SETTING_KEYS = [ + 'model', + 'model_reasoning_effort', + 'approval_policy', + 'sandbox_mode' +] as const + +type TopLevelSettingValue = { + raw: string + // Why: a value that opens a multiline string/array cannot be replaced or + // copied line-by-line safely, so it is excluded from promotion entirely. + multiline: boolean +} + +type SettingsBaselineFile = { + version: 1 + settings: Record +} + +function getSettingsBaselinePath(runtimeHomePath: string): string { + return join(runtimeHomePath, '.orca-config-settings-baseline.json') +} + +function readSettingsBaseline(runtimeHomePath: string): Map | null { + const baselinePath = getSettingsBaselinePath(runtimeHomePath) + if (!existsSync(baselinePath)) { + return null + } + try { + const parsed: unknown = JSON.parse(readFileSync(baselinePath, 'utf-8')) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const settings = (parsed as SettingsBaselineFile).settings + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + return null + } + const result = new Map() + for (const [key, value] of Object.entries(settings)) { + if (typeof value === 'string') { + result.set(key, value) + } + } + return result + } catch { + return null + } +} + +// Why: only keys in the top-level preamble are scanned — Codex writes profile +// overrides into [profiles.*] tables, and rewriting nested tables surgically +// is not worth the risk for stage-1 promotion. +function readTopLevelSettingValues(configPath: string): Map { + const result = new Map() + if (!existsSync(configPath)) { + return result + } + const lines = readFileSync(configPath, 'utf-8').split('\n') + let state = createTomlLineScanState() + for (const line of lines) { + if (isTomlStructuralLine(state)) { + if (getTomlTableHeader(line)) { + break + } + const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t\r]*$/.exec(line) + const key = match?.[1] + if (key && (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(key)) { + const nextState = updateTomlLineScanState(state, line) + result.set(key, { raw: match?.[2] ?? '', multiline: !isTomlStructuralLine(nextState) }) + state = nextState + continue + } + } + state = updateTomlLineScanState(state, line) + } + return result +} + +/** + * Records the promotable top-level settings the runtime config.toml holds + * after a mirror, so the next promotion can tell "value Orca mirrored" apart + * from "value Codex wrote for the user". Call after a successful mirror only — + * advancing the baseline past an unpromoted change would strand it forever. + */ +export function snapshotCodexRuntimeSettingsBaseline(): void { + try { + const runtimeHomePath = getOrcaManagedCodexHomePath() + const runtimeTomlPath = join(runtimeHomePath, 'config.toml') + // Why: a missing runtime config still records an empty baseline — when + // Codex later creates the file for a user with no ~/.codex/config.toml, + // that first change must diff against "Orca left nothing" and promote. + const settings: Record = {} + for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) { + if (!value.multiline) { + settings[key] = value.raw + } + } + const file: SettingsBaselineFile = { version: 1, settings } + writeFileSync(getSettingsBaselinePath(runtimeHomePath), `${JSON.stringify(file, null, 2)}\n`, { + encoding: 'utf-8', + mode: 0o600 + }) + } catch (error) { + console.warn('[codex-settings-promotion] failed to snapshot settings baseline', error) + } +} + +/** + * Promotes setting changes the user made inside Orca-launched Codex (written + * by Codex into the runtime config.toml) into ~/.codex/config.toml. Runs + * before the config mirror so the promoted values survive the same mirror + * pass instead of reverting. + */ +export function promoteCodexRuntimeSettingsToSystem(): void { + try { + promoteCodexRuntimeSettingsToSystemUnsafe() + } catch (error) { + // Why: promotion is best-effort launch prep; a malformed runtime file + // must not block the config mirror or the Codex launch itself. + console.warn('[codex-settings-promotion] failed to promote runtime settings', error) + } +} + +function promoteCodexRuntimeSettingsToSystemUnsafe(): void { + const runtimeHomePath = getOrcaManagedCodexHomePath() + const systemHomePath = getSystemCodexHomePath() + const runtimeTomlPath = join(runtimeHomePath, 'config.toml') + const systemTomlPath = join(systemHomePath, 'config.toml') + if (resolve(runtimeTomlPath) === resolve(systemTomlPath)) { + return + } + if (!existsSync(runtimeTomlPath)) { + return + } + // Why: without a baseline of what Orca last mirrored (first launch after + // upgrading to a build with promotion, or a corrupted snapshot), a stale + // runtime value is indistinguishable from a fresh in-Codex change. Skip + // this pass — the mirror writes the first baseline and promotion starts on + // the next one. + const baseline = readSettingsBaseline(runtimeHomePath) + if (!baseline) { + return + } + const runtimeValues = readTopLevelSettingValues(runtimeTomlPath) + const systemValues = readTopLevelSettingValues(systemTomlPath) + const updates = new Map() + for (const key of PROMOTED_CODEX_SETTING_KEYS) { + const runtime = runtimeValues.get(key) + if (!runtime || runtime.multiline) { + continue + } + if (runtime.raw === baseline.get(key)) { + // Orca mirrored this value and nothing touched it since — not a change. + continue + } + const system = systemValues.get(key) + if (system?.multiline) { + continue + } + // Why: ~/.codex stays source of truth — if the user also edited it there + // since the baseline, the outside edit wins over the in-Codex change. + if (system?.raw !== baseline.get(key)) { + continue + } + updates.set(key, runtime.raw) + } + if (updates.size === 0) { + return + } + const systemContent = existsSync(systemTomlPath) ? readFileSync(systemTomlPath, 'utf-8') : '' + writeFileAtomically(systemTomlPath, upsertTopLevelSettingsInContent(systemContent, updates)) +} + +export function upsertTopLevelSettingsInContent( + content: string, + updates: Map +): string { + const lines = content.split('\n') + let state = createTomlLineScanState() + let preambleEnd = lines.length + const keyLineIndexes = new Map() + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? '' + if (isTomlStructuralLine(state)) { + if (getTomlTableHeader(line)) { + preambleEnd = index + break + } + const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=/.exec(line) + if (match?.[1] && updates.has(match[1])) { + keyLineIndexes.set(match[1], index) + } + } + state = updateTomlLineScanState(state, line) + } + + const insertions: string[] = [] + for (const [key, raw] of updates) { + const existingIndex = keyLineIndexes.get(key) + const rendered = `${key} = ${raw}` + if (existingIndex !== undefined) { + // Why: CRLF configs keep a trailing \r after the split; preserve it so + // the rewritten line matches the file's existing endings. + lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered + } else { + insertions.push(rendered) + } + } + if (insertions.length > 0) { + let insertAt = preambleEnd + while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') { + insertAt -= 1 + } + if (insertAt === preambleEnd && preambleEnd < lines.length) { + insertions.push('') + } + lines.splice(insertAt, 0, ...insertions) + } + const result = lines.join('\n') + return result.endsWith('\n') || result.length === 0 ? result : `${result}\n` +} diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts new file mode 100644 index 00000000000..2bbf2bdd20d --- /dev/null +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -0,0 +1,295 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { dirname, join, win32 as pathWin32 } from 'node:path' + +import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' +import { + computeTrustKey, + computeTrustedHash, + readHookTrustEntries, + type CodexTrustEntry +} from './config-toml-trust' +import { + _internals, + createCodexWslRuntimeHookInstallPlan, + type CodexWslRuntimeHookInstallPlan +} from './hook-service' + +type HooksConfig = { + hooks: Record +} + +const managedEvents = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PermissionRequest', + 'PostToolUse', + 'Stop' +] as const + +let tempRoots: string[] = [] + +afterEach(() => { + for (const root of tempRoots) { + rmSync(root, { recursive: true, force: true }) + } + tempRoots = [] +}) + +function createTestPlan(): CodexWslRuntimeHookInstallPlan { + const root = mkdtempSync(join(tmpdir(), 'orca-codex-wsl-hooks-')) + tempRoots.push(root) + const linuxHome = '/home/alice/.local/share/orca/codex-runtime-home/home' + return { + configPath: join(root, 'hooks.json'), + tomlPath: join(root, 'config.toml'), + scriptPath: join(root, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: `${linuxHome}/.orca/agent-hooks/codex-hook.sh`, + trustConfigPath: `${linuxHome}/hooks.json` + } +} + +function getManagedTrustEntry( + plan: CodexWslRuntimeHookInstallPlan, + command: string +): CodexTrustEntry { + return { + sourcePath: plan.trustConfigPath, + eventLabel: 'user_prompt_submit', + groupIndex: 0, + handlerIndex: 0, + command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + } +} + +describe('Codex WSL runtime hook install', () => { + it('plans WSL hook files with Linux command and trust paths', () => { + const runtimeHome = + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' + + expect( + createCodexWslRuntimeHookInstallPlan(runtimeHome, undefined, (_distro, path) => path) + ).toEqual({ + configPath: pathWin32.join(runtimeHome, 'hooks.json'), + tomlPath: pathWin32.join(runtimeHome, 'config.toml'), + scriptPath: pathWin32.join(runtimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: + '/home/alice/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh', + trustConfigPath: '/home/alice/.local/share/orca/codex-runtime-home/home/hooks.json' + }) + }) + + it('plans WSL hooks when the distro home is mounted on a Windows drive', () => { + const runtimeHome = 'D:\\wsl-home\\.local\\share\\orca\\codex-runtime-home\\home' + + expect( + createCodexWslRuntimeHookInstallPlan( + runtimeHome, + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + (_distro, path) => path + ) + ).toEqual({ + configPath: pathWin32.join(runtimeHome, 'hooks.json'), + tomlPath: pathWin32.join(runtimeHome, 'config.toml'), + scriptPath: pathWin32.join(runtimeHome, '.orca', 'agent-hooks', 'codex-hook.sh'), + commandScriptPath: + '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home/.orca/agent-hooks/codex-hook.sh', + trustConfigPath: '/mnt/d/wsl-home/.local/share/orca/codex-runtime-home/home/hooks.json' + }) + }) + + it('uses WSL-canonical paths for hook commands and trust keys', () => { + const runtimeHome = + '\\\\wsl.localhost\\Ubuntu\\home\\alias\\.local\\share\\orca\\codex-runtime-home\\home' + const canonicalHome = '/home/alice/.local/share/orca/codex-runtime-home/home' + + const plan = createCodexWslRuntimeHookInstallPlan( + runtimeHome, + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + (distro, linuxPath) => { + expect(distro).toBe('Ubuntu') + expect(linuxPath).toBe('/home/alias/.local/share/orca/codex-runtime-home/home') + return canonicalHome + } + ) + + expect(plan?.commandScriptPath).toBe(`${canonicalHome}/.orca/agent-hooks/codex-hook.sh`) + expect(plan?.trustConfigPath).toBe(`${canonicalHome}/hooks.json`) + expect(plan?.configPath).toBe(pathWin32.join(runtimeHome, 'hooks.json')) + }) + + it('generates a POSIX hook that bridges WSL loopback failures through Windows curl', () => { + const script = _internals.getManagedScript('posix') + expect(script).toContain('load_hook_endpoint()') + expect(script).toContain('"set ORCA_AGENT_HOOK_TOKEN="*)') + expect(script).toContain('post_codex_hook()') + expect(script).toContain('is_wsl_runtime()') + expect(script).toContain('WSL_DISTRO_NAME') + expect(script).toContain('windows_curl=$(command -v curl.exe 2>/dev/null || true)') + expect(script).toContain('--data-urlencode "payload@-"') + expect(script).toContain('if post_codex_hook curl >/dev/null 2>&1; then') + expect(script).toContain('post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true') + }) + + it.skipIf(process.platform === 'win32')( + 'refreshes stale hook coordinates from a Windows endpoint file', + () => { + const plan = createTestPlan() + const root = dirname(plan.configPath) + const endpointPath = join(root, 'endpoint.cmd') + const binDir = join(root, 'bin') + const curlPath = join(binDir, 'curl') + const capturePath = join(root, 'curl-args.txt') + mkdirSync(binDir, { recursive: true }) + writeFileSync( + endpointPath, + [ + 'set ORCA_AGENT_HOOK_PORT=43210', + 'set ORCA_AGENT_HOOK_TOKEN=fresh-token', + 'set ORCA_AGENT_HOOK_ENV=development', + 'set ORCA_AGENT_HOOK_VERSION=1', + '' + ].join('\r\n'), + 'utf-8' + ) + writeFileSync( + curlPath, + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ORCA_TEST_CAPTURE"\ncat >> "$ORCA_TEST_CAPTURE"\n', + 'utf-8' + ) + chmodSync(curlPath, 0o755) + mkdirSync(dirname(plan.scriptPath), { recursive: true }) + writeFileSync(plan.scriptPath, _internals.getManagedScript('posix'), 'utf-8') + + const result = spawnSync('/bin/sh', [plan.scriptPath], { + encoding: 'utf-8', + input: '{"hook_event_name":"UserPromptSubmit","prompt":"restart"}', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + ORCA_AGENT_HOOK_ENDPOINT: endpointPath, + ORCA_AGENT_HOOK_PORT: '1', + ORCA_AGENT_HOOK_TOKEN: 'stale-token', + ORCA_PANE_KEY: 'pane-1', + ORCA_TEST_CAPTURE: capturePath + } + }) + + expect(result.status).toBe(0) + const posted = readFileSync(capturePath, 'utf-8') + expect(posted).toContain('http://127.0.0.1:43210/hook/codex') + expect(posted).toContain('X-Orca-Agent-Hook-Token: fresh-token') + expect(posted).not.toContain('stale-token') + } + ) + + it.skipIf(process.platform === 'win32')( + 'uses the Windows curl discovered from the WSL PATH after loopback fails', + () => { + const plan = createTestPlan() + const root = dirname(plan.configPath) + const binDir = join(root, 'bin') + const capturePath = join(root, 'windows-curl-args.txt') + mkdirSync(binDir, { recursive: true }) + writeFileSync(join(binDir, 'curl'), '#!/bin/sh\nexit 7\n', 'utf-8') + writeFileSync( + join(binDir, 'curl.exe'), + '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$ORCA_TEST_CAPTURE"\ncat >> "$ORCA_TEST_CAPTURE"\n', + 'utf-8' + ) + chmodSync(join(binDir, 'curl'), 0o755) + chmodSync(join(binDir, 'curl.exe'), 0o755) + mkdirSync(dirname(plan.scriptPath), { recursive: true }) + writeFileSync(plan.scriptPath, _internals.getManagedScript('posix'), 'utf-8') + + const result = spawnSync('/bin/sh', [plan.scriptPath], { + encoding: 'utf-8', + input: '{"hook_event_name":"Stop"}', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ''}`, + WSL_DISTRO_NAME: 'Ubuntu', + ORCA_AGENT_HOOK_PORT: '43210', + ORCA_AGENT_HOOK_TOKEN: 'token', + ORCA_PANE_KEY: 'pane-1', + ORCA_TEST_CAPTURE: capturePath + } + }) + + expect(result.status).toBe(0) + const posted = readFileSync(capturePath, 'utf-8') + expect(posted).toContain('http://127.0.0.1:43210/hook/codex') + expect(posted).toContain('X-Orca-Agent-Hook-Token: token') + } + ) + + it('installs trusted WSL hooks and removes only Orca entries when disabled', () => { + const plan = createTestPlan() + const userCommand = '/bin/sh /home/alice/user-hook.sh' + writeFileSync( + plan.configPath, + `${JSON.stringify({ + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: userCommand }] }], + PreCompact: [ + { + hooks: [ + { + type: 'command', + command: + "if [ -x '/old/.orca/agent-hooks/codex-hook.sh' ]; then /bin/sh '/old/.orca/agent-hooks/codex-hook.sh'; fi" + } + ] + } + ] + } + })}\n`, + 'utf-8' + ) + const unrelatedTrustKey = '/home/alice/custom/hooks.json:stop:0:0' + writeFileSync( + plan.tomlPath, + `[hooks.state."${unrelatedTrustKey}"]\nenabled = false\ntrusted_hash = "sha256:user"\n`, + 'utf-8' + ) + + expect(_internals.installManagedHooksIntoWslRuntime(plan).state).toBe('installed') + + const installed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig + expect(Object.keys(installed.hooks).sort()).toEqual([...managedEvents].sort()) + const managedCommand = installed.hooks.UserPromptSubmit[0]?.hooks?.[0]?.command + expect(managedCommand).toBe( + `if [ -r '${plan.commandScriptPath}' ]; then /bin/sh '${plan.commandScriptPath}'; fi` + ) + expect(installed.hooks.UserPromptSubmit[1]?.hooks?.[0]?.command).toBe(userCommand) + expect(readFileSync(plan.scriptPath, 'utf-8')).toContain('command -v curl.exe') + + const managedTrustEntry = getManagedTrustEntry(plan, managedCommand!) + const trustEntries = readHookTrustEntries(plan.tomlPath) + expect(trustEntries.get(computeTrustKey(managedTrustEntry))).toEqual({ + enabled: true, + trustedHash: computeTrustedHash(managedTrustEntry) + }) + expect(trustEntries.get(unrelatedTrustKey)).toEqual({ + enabled: false, + trustedHash: 'sha256:user' + }) + + expect(_internals.refreshWslRuntimeUserHooks(plan).state).toBe('not_installed') + + const refreshed = JSON.parse(readFileSync(plan.configPath, 'utf-8')) as HooksConfig + expect(refreshed.hooks).toEqual({ + UserPromptSubmit: [{ hooks: [{ type: 'command', command: userCommand }] }] + }) + const refreshedTrustEntries = readHookTrustEntries(plan.tomlPath) + expect(refreshedTrustEntries.has(computeTrustKey(managedTrustEntry))).toBe(false) + expect(refreshedTrustEntries.get(unrelatedTrustKey)).toEqual({ + enabled: false, + trustedHash: 'sha256:user' + }) + }) +}) diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index ef812ab7ff0..31e552df55c 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -43,6 +43,11 @@ import { } from './config-toml-trust' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' +import { + createCodexWslRuntimeHookInstallPlan, + type CodexWslRuntimeHookInstallPlan, + type CodexWslRuntimeHookTarget +} from './codex-wsl-hook-install-plan' import { CODEX_HOOK_EVENT_LABEL, createCodexHookTrustEntry, @@ -123,6 +128,16 @@ function getManagedCommand(scriptPath: string): string { : wrapPosixHookCommand(scriptPath) } +export { createCodexWslRuntimeHookInstallPlan } +export type { CodexWslRuntimeHookInstallPlan } + +function wrapReadablePosixHookCommand(scriptPath: string): string { + const quoted = `'${scriptPath.replaceAll("'", "'\\''")}'` + // Why: WSL runtime hooks are written from Windows through UNC, where the + // executable bit is not reliable. /bin/sh only needs the script to be readable. + return `if [ -r ${quoted} ]; then /bin/sh ${quoted}; fi` +} + function getSystemConfigPath(): string { return join(getSystemCodexHomePath(), 'hooks.json') } @@ -662,6 +677,52 @@ function removeRuntimeManagedHookTrustEntries(configPath: string): void { } } +function removeWslRuntimeManagedHookTrustEntries(plan: CodexWslRuntimeHookInstallPlan): void { + try { + const existingEntries = readHookTrustEntries(plan.tomlPath) + const command = wrapReadablePosixHookCommand(plan.commandScriptPath) + const managedEventLabels = new Set( + CODEX_EVENTS.map((event) => CODEX_EVENT_LABEL[event]) + ) + const canonicalConfigPath = getCodexCanonicalTrustPath(plan.trustConfigPath) + const ourKeys: string[] = [] + for (const [key, state] of existingEntries) { + const parts = parseTrustKey(key) + if (parts === null) { + continue + } + if (getCodexCanonicalTrustPath(parts.sourcePath) !== canonicalConfigPath) { + continue + } + if (!managedEventLabels.has(parts.eventLabel)) { + continue + } + const expectedEntry: CodexTrustEntry = { + sourcePath: plan.trustConfigPath, + eventLabel: parts.eventLabel, + groupIndex: parts.groupIndex, + handlerIndex: parts.handlerIndex, + command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + } + const recognizedHashes = new Set([ + computeTrustedHash(expectedEntry), + computeTrustedHash({ ...expectedEntry, timeoutSec: undefined }) + ]) + if (state.trustedHash && recognizedHashes.has(state.trustedHash)) { + ourKeys.push(key) + } + } + if (ourKeys.length > 0) { + removeHookTrustEntries(plan.tomlPath, ourKeys) + } + } catch (error) { + // Why: removing disabled WSL status hooks should be best-effort like the + // host cleanup path; stale trust is inert once hooks.json no longer points at us. + console.warn('[codex-hook-service] failed to clean WSL trust entries', error) + } +} + function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { return [ @@ -686,8 +747,30 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: see claude/hook-service.ts for rationale. Sourcing refreshes // PORT/TOKEN/ENV/VERSION from the current Orca so a surviving PTY keeps // reporting after a restart. + 'load_hook_endpoint() {', + ' endpoint_path="$1"', + ' case "$endpoint_path" in', + ' *.cmd)', + // Why: Windows passes endpoint.cmd into WSL through WSLENV path translation. + // Parse only Orca's known assignments; cmd.exe `set` lines are not shell syntax. + ' endpoint_cr=$(printf "\\r")', + ' while IFS= read -r endpoint_line || [ -n "$endpoint_line" ]; do', + ' endpoint_line=${endpoint_line%"$endpoint_cr"}', + ' case "$endpoint_line" in', + ' "set ORCA_AGENT_HOOK_PORT="*) ORCA_AGENT_HOOK_PORT=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_TOKEN="*) ORCA_AGENT_HOOK_TOKEN=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_ENV="*) ORCA_AGENT_HOOK_ENV=${endpoint_line#*=} ;;', + ' "set ORCA_AGENT_HOOK_VERSION="*) ORCA_AGENT_HOOK_VERSION=${endpoint_line#*=} ;;', + ' esac', + ' done < "$endpoint_path"', + ' ;;', + ' *)', + ' . "$endpoint_path" 2>/dev/null || :', + ' ;;', + ' esac', + '}', 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', - ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + ' load_hook_endpoint "$ORCA_AGENT_HOOK_ENDPOINT"', 'fi', 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', ' exit 0', @@ -696,6 +779,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if [ -z "$payload" ]; then', ' exit 0', 'fi', + 'post_codex_hook() {', + ' curl_bin="$1"', + ' connect_timeout="${2:-0.5}"', + ' max_time="${3:-1.5}"', // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX // shell is not safe once a path contains quotes or newlines. Post the raw // hook payload plus metadata as form fields and let the receiver parse it. @@ -703,23 +790,164 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl // command line (EDR command-line false positives). Wire body is identical. - 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', - ' --connect-timeout 0.5 --max-time 1.5 \\', - ' -H "Content-Type: application/x-www-form-urlencoded" \\', - ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', - ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', - ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', - ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', - ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', - ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', - ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', - ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + ' printf \'%s\' "$payload" | "$curl_bin" -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\', + ' --connect-timeout "$connect_timeout" --max-time "$max_time" \\', + ' --noproxy "127.0.0.1" \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload@-"', + '}', + 'is_wsl_runtime() {', + ' [ -n "$WSL_DISTRO_NAME" ] && return 0', + ' grep -qiE "microsoft|wsl" /proc/sys/kernel/osrelease /proc/version 2>/dev/null', + '}', + 'if post_codex_hook curl >/dev/null 2>&1; then', + ' exit 0', + 'fi', + 'if is_wsl_runtime; then', + ' windows_curl=$(command -v curl.exe 2>/dev/null || true)', + ' if [ -n "$windows_curl" ] && [ -x "$windows_curl" ]; then', + ' post_codex_hook "$windows_curl" 3 5 >/dev/null 2>&1 || true', + ' fi', + 'fi', 'exit 0', '' ].join('\n') } +function installManagedHooksIntoWslRuntime( + plan: CodexWslRuntimeHookInstallPlan +): AgentHookInstallStatus { + const config = readHooksJson(plan.configPath) + if (!config) { + return { + agent: 'codex', + state: 'error', + configPath: plan.configPath, + managedHooksPresent: false, + detail: 'Could not parse Codex hooks.json' + } + } + + const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') + const command = wrapReadablePosixHookCommand(plan.commandScriptPath) + const nextHooks = { ...config.hooks } + const managedEvents = new Set(CODEX_EVENTS) + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (managedEvents.has(eventName) || !Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + + const trustEntries: CodexTrustEntry[] = [] + for (const eventName of CODEX_EVENTS) { + const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : [] + const cleaned = removeManagedCommands(current, isManagedCommand) + const definition: HookDefinition = { + hooks: [buildManagedCommandHook(command)] + } + nextHooks[eventName] = [definition, ...cleaned] + trustEntries.push({ + sourcePath: plan.trustConfigPath, + eventLabel: CODEX_EVENT_LABEL[eventName], + groupIndex: 0, + handlerIndex: 0, + command, + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) + } + + config.hooks = nextHooks + writeManagedScript(plan.scriptPath, getManagedScript('posix')) + writeCodexHooksJson(plan.configPath, nextHooks) + try { + // Why: WSL runtime homes may carry user hook approvals we did not rebuild + // here; only upsert Orca's entries instead of sweeping the whole source. + upsertHookTrustEntries(plan.tomlPath, trustEntries) + } catch (error) { + return { + agent: 'codex', + state: 'error', + configPath: plan.configPath, + managedHooksPresent: true, + detail: `Hooks installed but trust entries could not be written: ${error instanceof Error ? error.message : String(error)}. Run /hooks in Codex to approve.` + } + } + + return { + agent: 'codex', + state: 'installed', + configPath: plan.configPath, + managedHooksPresent: true, + detail: null + } +} + +function refreshWslRuntimeUserHooks(plan: CodexWslRuntimeHookInstallPlan): AgentHookInstallStatus { + const config = readHooksJson(plan.configPath) + if (!config) { + return { + agent: 'codex', + state: 'error', + configPath: plan.configPath, + managedHooksPresent: false, + detail: 'Could not parse Codex hooks.json' + } + } + + const isManagedCommand = createManagedCommandMatcher('codex-hook.sh') + const nextHooks = { ...config.hooks } + for (const [eventName, definitions] of Object.entries(nextHooks)) { + if (!Array.isArray(definitions)) { + continue + } + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (cleaned.length === 0) { + delete nextHooks[eventName] + } else { + nextHooks[eventName] = cleaned + } + } + writeCodexHooksJson(plan.configPath, nextHooks) + removeWslRuntimeManagedHookTrustEntries(plan) + return { + agent: 'codex', + state: 'not_installed', + configPath: plan.configPath, + managedHooksPresent: false, + detail: null + } +} + export class CodexHookService { + installForRuntimeHome( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget + ): AgentHookInstallStatus | null { + const wslPlan = createCodexWslRuntimeHookInstallPlan(runtimeHomePath, target) + return wslPlan ? installManagedHooksIntoWslRuntime(wslPlan) : null + } + + refreshRuntimeUserHooksForRuntimeHome( + runtimeHomePath: string | null | undefined, + target?: CodexWslRuntimeHookTarget + ): AgentHookInstallStatus | null { + const wslPlan = createCodexWslRuntimeHookInstallPlan(runtimeHomePath, target) + return wslPlan ? refreshWslRuntimeUserHooks(wslPlan) : null + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() @@ -1155,3 +1383,9 @@ export class CodexHookService { } export const codexHookService = new CodexHookService() + +export const _internals = { + getManagedScript, + installManagedHooksIntoWslRuntime, + refreshWslRuntimeUserHooks +} diff --git a/src/main/external-editor-launch.test.ts b/src/main/external-editor-launch.test.ts index 507e9b5700d..8ced95ffd2f 100644 --- a/src/main/external-editor-launch.test.ts +++ b/src/main/external-editor-launch.test.ts @@ -1,8 +1,22 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { resolveCliCommandMock } = vi.hoisted(() => ({ + resolveCliCommandMock: vi.fn((command: string) => command) +})) + +vi.mock('./codex-cli/command', () => ({ + resolveCliCommand: resolveCliCommandMock +})) + import { getCmdExePath } from './win32-utils' import { resolveExternalEditorLaunchSpec } from './external-editor-launch' describe('resolveExternalEditorLaunchSpec', () => { + beforeEach(() => { + resolveCliCommandMock.mockReset() + resolveCliCommandMock.mockImplementation((command: string) => command) + }) + it('keeps simple CLI commands on the executable launch path', () => { const spec = resolveExternalEditorLaunchSpec('cursor', '/tmp/workspace', { platform: 'darwin' @@ -121,4 +135,123 @@ describe('resolveExternalEditorLaunchSpec', () => { spawnArgs: ['/d', '/s', '/c', 'nvim --clean C:\\workspaces\\orca'] }) }) + + it.each(['code', 'code-insiders'])( + 'opens modern WSL UNC workspaces with %s in the matching VS Code remote', + (editorCommand) => { + expect( + resolveExternalEditorLaunchSpec( + editorCommand, + '\\\\wsl.localhost\\Ubuntu\\home\\aliuq\\project', + { platform: 'win32' } + ).spawnArgs + ).toEqual(['--remote', 'wsl+Ubuntu', '/home/aliuq/project']) + } + ) + + it.each([ + [ + 'legacy WSL UNC workspace', + '\\\\wsl$\\Debian\\home\\ada\\project', + 'wsl+Debian', + '/home/ada/project' + ], + ['modern WSL distro root', '\\\\wsl.localhost\\Ubuntu', 'wsl+Ubuntu', '/'], + ['legacy WSL distro root', '\\\\wsl$\\Debian', 'wsl+Debian', '/'] + ])('opens a %s in the matching VS Code remote', (_label, pathValue, authority, linuxPath) => { + expect( + resolveExternalEditorLaunchSpec('code', pathValue, { platform: 'win32' }).spawnArgs + ).toEqual(['--remote', authority, linuxPath]) + }) + + it.each([ + 'C:\\Program Files\\Microsoft VS Code\\Code.exe', + 'C:\\Program Files\\Microsoft VS Code Insiders\\Code - Insiders.exe', + 'C:\\Tools\\CODE.CMD', + 'C:\\Tools\\code.bat', + 'C:\\Tools\\code-insiders.cmd' + ])('recognizes the direct Windows VS Code launcher %s', (editorCommand) => { + expect( + resolveExternalEditorLaunchSpec( + editorCommand, + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project', + { platform: 'win32' } + ).spawnArgs + ).toEqual(['--remote', 'wsl+Ubuntu', '/home/ada/project']) + }) + + it.each([ + ['code', 'C:\\Tools\\CODE.CMD'], + ['code', 'C:\\Tools\\code.bat'], + ['code-insiders', 'C:\\Tools\\code-insiders.cmd'] + ])('recognizes the resolved Windows VS Code launcher %s', (editorCommand, resolvedCommand) => { + resolveCliCommandMock.mockReturnValueOnce(resolvedCommand) + + expect( + resolveExternalEditorLaunchSpec( + editorCommand, + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project', + { platform: 'win32' } + ) + ).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: resolvedCommand, + spawnArgs: ['--remote', 'wsl+Ubuntu', '/home/ada/project'] + }) + }) + + it('preserves spaces in WSL distro and folder arguments', () => { + expect( + resolveExternalEditorLaunchSpec( + 'code', + '\\\\wsl.localhost\\Ubuntu Preview\\home\\Ada Lovelace\\project', + { platform: 'win32' } + ).spawnArgs + ).toEqual(['--remote', 'wsl+Ubuntu Preview', '/home/Ada Lovelace/project']) + }) + + it.each(['darwin', 'linux'] as const)('keeps WSL-looking paths local on %s', (platform) => { + const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' + expect(resolveExternalEditorLaunchSpec('code', pathValue, { platform }).spawnArgs).toEqual([ + pathValue + ]) + }) + + it.each(['C:\\workspaces\\orca', '\\\\server\\share\\project'])( + 'keeps the non-WSL Windows path %s local', + (pathValue) => { + expect( + resolveExternalEditorLaunchSpec('code', pathValue, { platform: 'win32' }).spawnArgs + ).toEqual([pathValue]) + } + ) + + it('does not add VS Code remote arguments to other editors', () => { + const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' + + expect( + resolveExternalEditorLaunchSpec('C:\\Tools\\cursor.exe', pathValue, { + platform: 'win32' + }).spawnArgs + ).toEqual(['--new-window', pathValue]) + expect( + resolveExternalEditorLaunchSpec('C:\\Tools\\codium.exe', pathValue, { + platform: 'win32' + }).spawnArgs + ).toEqual([pathValue]) + }) + + it('does not rewrite compound VS Code commands', () => { + const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' + + expect( + resolveExternalEditorLaunchSpec('code --reuse-window', pathValue, { platform: 'win32' }) + ).toEqual({ + kind: 'shell', + hideWindowsConsole: true, + spawnCmd: getCmdExePath(), + spawnArgs: ['/d', '/s', '/c', `code --reuse-window ${pathValue}`] + }) + }) }) diff --git a/src/main/external-editor-launch.ts b/src/main/external-editor-launch.ts index 8e5c4a0b397..b86ca3e896f 100644 --- a/src/main/external-editor-launch.ts +++ b/src/main/external-editor-launch.ts @@ -1,10 +1,12 @@ import { existsSync } from 'node:fs' import { basename, posix, win32 } from 'node:path' +import { parseWslUncPath } from '../shared/wsl-paths' import { resolveCliCommand } from './codex-cli/command' import { getCmdExePath } from './win32-utils' export const EXTERNAL_EDITOR_CLI_COMMAND = 'code' const WINDOWS_CONSOLE_EDITORS = new Set(['nvim', 'vim']) +const VSCODE_REMOTE_EDITORS = new Set(['code', 'code-insiders', 'code - insiders']) export type ExternalEditorLaunchSpec = | { @@ -106,12 +108,24 @@ function shouldShowWindowsConsole( return platform === 'win32' && WINDOWS_CONSOLE_EDITORS.has(getLauncherBaseName(command, options)) } -function buildExecutableArgs(editorCommand: string, pathValue: string): string[] { - if (getLauncherBaseName(editorCommand) === 'cursor') { +function buildExecutableArgs( + editorCommand: string, + pathValue: string, + platform: NodeJS.Platform +): string[] { + const launcherBaseName = getLauncherBaseName(editorCommand) + if (launcherBaseName === 'cursor') { // Why: Cursor can route bare folder launches through the last active // workbench. A new window keeps "Open in Cursor" scoped to this worktree. return ['--new-window', pathValue] } + if (platform === 'win32' && VSCODE_REMOTE_EDITORS.has(launcherBaseName)) { + const wslPath = parseWslUncPath(pathValue) + if (wslPath) { + // Why: VS Code otherwise treats a WSL UNC path as a local Windows folder. + return ['--remote', `wsl+${wslPath.distro}`, wslPath.linuxPath] + } + } return [pathValue] } @@ -156,7 +170,7 @@ export function resolveExternalEditorLaunchSpec( kind: 'executable', hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), spawnCmd: editorCommand, - spawnArgs: buildExecutableArgs(editorCommand, pathValue) + spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform) } } @@ -169,6 +183,6 @@ export function resolveExternalEditorLaunchSpec( kind: 'executable', hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform), spawnCmd: editorCommand, - spawnArgs: buildExecutableArgs(editorCommand, pathValue) + spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform) } } diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 3aae182432c..c7710858838 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -352,6 +352,29 @@ describe('ghExecFileAsync WSL fallback', () => { ) }) + it('does not wake the default WSL distro for host-only GitLab diagnostics', async () => { + getDefaultWslDistroMock.mockReturnValue('Ubuntu') + execFileMock + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' })) + }) + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(null, { stdout: 'Logged in to gitlab.com', stderr: '' }) + }) + + await expect( + glabExecFileAsync(['auth', 'status'], { allowDefaultWslFallback: false }) + ).rejects.toThrow('spawn glab ENOENT') + + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(execFileMock).toHaveBeenCalledWith( + 'glab', + ['auth', 'status'], + expect.objectContaining({ cwd: undefined }), + expect.any(Function) + ) + }) + it('still retries idempotent glab transient failures', async () => { execFileMock .mockImplementationOnce((_binary, _args, _options, callback) => { diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index c9009845369..fca0db2ef65 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -1480,6 +1480,7 @@ type GlabExecOptions = Omit & { cwd?: string wslDistro?: string idempotent?: boolean + allowDefaultWslFallback?: boolean } /** @@ -1544,6 +1545,7 @@ export async function glabExecFileAsync( resolved.wsl === null && !options.cwd && !options.wslDistro && + options.allowDefaultWslFallback !== false && isHostCommandMissing(err, 'glab') ) { const wslResolved = resolveDefaultWslCli('glab', args) diff --git a/src/main/git/status-wsl-pathspecs.test.ts b/src/main/git/status-wsl-pathspecs.test.ts new file mode 100644 index 00000000000..e802fb72b5b --- /dev/null +++ b/src/main/git/status-wsl-pathspecs.test.ts @@ -0,0 +1,87 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileAsyncBuffer: vi.fn(), + gitOptionalLocksDisabledEnv: (env: NodeJS.ProcessEnv = process.env) => env, + gitStreamStdout: vi.fn() +})) + +import { + bulkDiscardChanges, + bulkStageFiles, + bulkUnstageFiles, + discardChanges, + stageFile, + unstageFile +} from './status' + +const worktreePath = path.resolve('repo') +const windowsRelativePath = 'tests\\breakgit' +const wslOptions = { wslDistro: 'Ubuntu' } + +describe('WSL git pathspecs', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'tests/breakgit\0', stderr: '' }) + }) + + it('uses POSIX separators for tracked file mutations executed inside WSL', async () => { + await stageFile(worktreePath, windowsRelativePath, wslOptions) + await unstageFile(worktreePath, windowsRelativePath, wslOptions) + await bulkStageFiles(worktreePath, [windowsRelativePath], wslOptions) + await bulkUnstageFiles(worktreePath, [windowsRelativePath], wslOptions) + await discardChanges(worktreePath, windowsRelativePath, wslOptions) + await bulkDiscardChanges(worktreePath, [windowsRelativePath], wslOptions) + + const pathspecs = gitExecFileAsyncMock.mock.calls.flatMap(([args]) => + (args as string[]).filter((arg) => arg.startsWith(':(literal)')) + ) + expect(pathspecs).toEqual(Array(8).fill(':(literal)tests/breakgit')) + }) + + it('uses POSIX separators when discarding untracked files inside WSL', async () => { + const repo = await mkdtemp(path.join(tmpdir(), 'orca-wsl-pathspec-')) + const targetPath = path.resolve(repo, windowsRelativePath) + await mkdir(path.dirname(targetPath), { recursive: true }) + await writeFile(targetPath, 'untracked') + + try { + gitExecFileAsyncMock + .mockRejectedValueOnce(new Error('not tracked')) + .mockResolvedValue({ stdout: '', stderr: '' }) + await discardChanges(repo, windowsRelativePath, wslOptions) + + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ + ['ls-files', '--error-unmatch', '--', ':(literal)tests/breakgit'], + ['clean', '-ffdx', '--', ':(literal)tests/breakgit'] + ]) + + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + await bulkDiscardChanges(repo, [windowsRelativePath], wslOptions) + + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ + ['ls-files', '-z', '--', ':(literal)tests/breakgit'], + ['clean', '-ffdx', '--', ':(literal)tests/breakgit'] + ]) + } finally { + await rm(repo, { recursive: true, force: true }) + } + }) + + it('preserves backslashes for host Git where they can be literal filename characters', async () => { + await stageFile(worktreePath, windowsRelativePath) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['add', '--', ':(literal)tests\\breakgit'], { + cwd: worktreePath + }) + }) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 862c8c9b76b..6ec73ef8a3b 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1837,7 +1837,7 @@ export async function stageFile( clearGitReadInvalidationState() try { await gitExecFileAsync( - ['add', '--', literalPathspec(filePath)], + ['add', '--', literalPathspec(filePath, options)], gitOptionsForWorktree(worktreePath, options) ) } finally { @@ -1855,7 +1855,7 @@ export async function unstageFile( ): Promise { clearGitReadInvalidationState() try { - await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath)], { + await gitExecFileAsync(['restore', '--staged', '--', literalPathspec(filePath, options)], { ...gitOptionsForWorktree(worktreePath, options) }) } finally { @@ -1961,9 +1961,12 @@ export async function discardChanges( let tracked = false try { - await gitExecFileAsync(['ls-files', '--error-unmatch', '--', literalPathspec(filePath)], { - ...gitOptionsForWorktree(worktreePath, options) - }) + await gitExecFileAsync( + ['ls-files', '--error-unmatch', '--', literalPathspec(filePath, options)], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) tracked = true } catch { // File is not tracked by git @@ -1971,7 +1974,7 @@ export async function discardChanges( if (tracked) { await gitExecFileAsync( - ['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath)], + ['restore', '--worktree', '--source=HEAD', '--', literalPathspec(filePath, options)], { ...gitOptionsForWorktree(worktreePath, options) } @@ -1991,9 +1994,11 @@ function normalizeGitPathForCompare(filePath: string): string { return filePath.replace(/\\/g, '/').replace(/\/+$/, '') } -function literalPathspec(filePath: string): string { - // Why: source-control selections are concrete paths, not user-authored Git globs. - return `:(literal)${filePath}` +function literalPathspec(filePath: string, options: GitRuntimeOptions): string { + // Why: Windows validation produces backslashes, but Git running inside WSL + // needs POSIX paths. Host paths stay untouched so POSIX filenames remain literal. + const runtimePath = options.wslDistro ? filePath.replace(/\\/g, '/') : filePath + return `:(literal)${runtimePath}` } function isTrackedPathSpec(filePath: string, trackedPaths: readonly string[]): boolean { @@ -2013,7 +2018,7 @@ async function listTrackedPathSpecs( for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) const { stdout } = await gitExecFileAsync( - ['ls-files', '-z', '--', ...chunk.map(literalPathspec)], + ['ls-files', '-z', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], { ...gitOptionsForWorktree(worktreePath, options) } @@ -2038,9 +2043,12 @@ async function cleanUntrackedPaths( const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) if (chunk.length > 0) { // Why: Git pathspec cleanup avoids raw recursive deletion through symlinked parents. - await gitExecFileAsync(['clean', '-ffdx', '--', ...chunk.map(literalPathspec)], { - ...gitOptionsForWorktree(worktreePath, options) - }) + await gitExecFileAsync( + ['clean', '-ffdx', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) } } } @@ -2082,7 +2090,13 @@ export async function bulkDiscardChanges( for (let i = 0; i < trackedPaths.length; i += BULK_CHUNK_SIZE) { const chunk = trackedPaths.slice(i, i + BULK_CHUNK_SIZE) await gitExecFileAsync( - ['restore', '--worktree', '--source=HEAD', '--', ...chunk.map(literalPathspec)], + [ + 'restore', + '--worktree', + '--source=HEAD', + '--', + ...chunk.map((filePath) => literalPathspec(filePath, options)) + ], { ...gitOptionsForWorktree(worktreePath, options) } @@ -2125,7 +2139,7 @@ export async function bulkStageFiles( for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) await gitExecFileAsync( - ['add', '--', ...chunk.map(literalPathspec)], + ['add', '--', ...chunk.map((filePath) => literalPathspec(filePath, options))], gitOptionsForWorktree(worktreePath, options) ) } @@ -2149,9 +2163,17 @@ export async function bulkUnstageFiles( try { for (let i = 0; i < filePaths.length; i += BULK_CHUNK_SIZE) { const chunk = filePaths.slice(i, i + BULK_CHUNK_SIZE) - await gitExecFileAsync(['restore', '--staged', '--', ...chunk.map(literalPathspec)], { - ...gitOptionsForWorktree(worktreePath, options) - }) + await gitExecFileAsync( + [ + 'restore', + '--staged', + '--', + ...chunk.map((filePath) => literalPathspec(filePath, options)) + ], + { + ...gitOptionsForWorktree(worktreePath, options) + } + ) } } finally { clearGitReadInvalidationState() diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts index 21747de6a26..a090ec7963b 100644 --- a/src/main/gitlab/client-mr.test.ts +++ b/src/main/gitlab/client-mr.test.ts @@ -245,6 +245,9 @@ describe('gitlab client — MR operations', () => { hosts: ['gitlab.com'], activeHost: 'gitlab.com' }) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { + allowDefaultWslFallback: false + }) }) }) diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index ec4a178ea64..b6b32dfb8c3 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -97,7 +97,10 @@ export async function diagnoseAuth(): Promise { ? 'GLAB_TOKEN' : null try { - const { stdout, stderr } = await glabExecFileAsync(['auth', 'status']) + // Why: a host-global diagnostic must not wake an unrelated default WSL distro. + const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { + allowDefaultWslFallback: false + }) const output = `${stdout}\n${stderr}` const hosts = parseGlabAuthStatusHosts(output) return { diff --git a/src/main/index.ts b/src/main/index.ts index 9e80bcc2944..0eb2d9d9a4f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -122,6 +122,7 @@ import { } from './codex-accounts/runtime-selection' import { normalizeClaudeRuntimeSelection } from './claude-accounts/runtime-selection' import { codexHookService } from './codex/hook-service' +import { getDefaultWslDistro } from './wsl' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import { @@ -694,13 +695,22 @@ async function startServeAgentHookServer(): Promise { function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) + const hookTarget = + target?.runtime === 'wsl' + ? { + runtime: 'wsl' as const, + wslDistro: target.wslDistro?.trim() || getDefaultWslDistro() + } + : target const hooksEnabled = isAgentStatusHooksEnabled(store?.getSettings()) try { // Why: launch prep is reachable after startup via PTY/runtime paths; honor // the persisted off switch so those launches cannot reinstall removed hooks. const status = hooksEnabled - ? codexHookService.install() - : codexHookService.refreshRuntimeUserHooks() + ? (codexHookService.installForRuntimeHome(runtimeHomePath, hookTarget) ?? + codexHookService.install()) + : (codexHookService.refreshRuntimeUserHooksForRuntimeHome(runtimeHomePath, hookTarget) ?? + codexHookService.refreshRuntimeUserHooks()) if (status.state === 'error') { console.warn( `[codex-hook-service] failed to ${ @@ -2047,7 +2057,9 @@ app.whenReady().then(async () => { // a random OS-assigned port — breaking deterministic mobile pairing/repro // scripts against the dev instance. Pin the first dev instance to 6769 so // ws://127.0.0.1:6769 is stable; a second dev instance still falls back via - // ws-transport's EADDRINUSE handler. + // ws-transport's EADDRINUSE handler. Note: once an instance has ever fallen + // back, the persisted fallback port is re-bound in preference to 6769 + // (STA-1511) until mobile-ws-fallback-port.json is removed from userData. const devWsPort = is.dev && !isE2E ? 6769 : undefined let serveOptions: ServeOptions | null = null try { diff --git a/src/main/ipc/notification-authorization-status.ts b/src/main/ipc/notification-authorization-status.ts index bdaba67efae..d51f97ede17 100644 --- a/src/main/ipc/notification-authorization-status.ts +++ b/src/main/ipc/notification-authorization-status.ts @@ -80,6 +80,7 @@ function runStatusHelper(helperPath: string): Promise { it('resolves unsubscribe on the child ack', async () => { const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) const child = currentChild() - const id = ackSubscribe(child) + const id = ackSubscribe(child, 0) const subscription = await promise + // Why: hold a second subscription so this unsubscribe exercises the ack + // path instead of the last-subscriber idle kill. + const keepAlivePromise = subscribeViaWatcherProcess('/other', vi.fn(), {}) + ackSubscribe(child) + await keepAlivePromise const unsubPromise = subscription.unsubscribe() expect(child.sent.at(-1)).toEqual({ op: 'unsubscribe', id }) @@ -115,8 +120,11 @@ describe('subscribeViaWatcherProcess', () => { it('resolves a pending unsubscribe when the child dies', async () => { const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) const child = currentChild() - ackSubscribe(child) + ackSubscribe(child, 0) const subscription = await promise + const keepAlivePromise = subscribeViaWatcherProcess('/other', vi.fn(), {}) + ackSubscribe(child) + await keepAlivePromise const unsubPromise = subscription.unsubscribe() child.connected = false @@ -187,6 +195,80 @@ describe('subscribeViaWatcherProcess', () => { ) }) + it('kills the idle child after the last unsubscribe and respawns on the next subscribe', async () => { + const promise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + const first = currentChild() + ackSubscribe(first) + const subscription = await promise + + await expect(subscription.unsubscribe()).resolves.toBeUndefined() + expect(first.kill).toHaveBeenCalledTimes(1) + + // Why: the deliberate idle kill must not count as a crash — no respawn + // and no crash-fuse advance when the killed child's exit event lands. + first.connected = false + first.emit('exit', null, 'SIGTERM') + expect(forkMock).toHaveBeenCalledTimes(1) + + const respawnPromise = subscribeViaWatcherProcess('/repo2', vi.fn(), {}) + expect(forkMock).toHaveBeenCalledTimes(2) + const second = currentChild() + expect(second).not.toBe(first) + expect(second.sent[0]).toMatchObject({ op: 'subscribe', dir: '/repo2' }) + ackSubscribe(second) + await expect(respawnPromise).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + }) + + it('kills the idle child when the last pending subscribe fails', async () => { + const promise = subscribeViaWatcherProcess('/gone', vi.fn(), {}) + const first = currentChild() + first.emit('message', { + op: 'subscribe-failed', + id: first.sent[0].id, + message: 'Error opening directory' + }) + await expect(promise).rejects.toThrow('Error opening directory') + expect(first.kill).toHaveBeenCalledTimes(1) + + const respawnPromise = subscribeViaWatcherProcess('/repo', vi.fn(), {}) + expect(forkMock).toHaveBeenCalledTimes(2) + ackSubscribe(currentChild()) + await expect(respawnPromise).resolves.toMatchObject({ unsubscribe: expect.any(Function) }) + }) + + it('keeps the child alive when a subscribe fails while other subscriptions remain', async () => { + const firstPromise = subscribeViaWatcherProcess('/a', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + await firstPromise + + const failingPromise = subscribeViaWatcherProcess('/gone', vi.fn(), {}) + child.emit('message', { op: 'subscribe-failed', id: child.sent.at(-1)!.id, message: 'boom' }) + await expect(failingPromise).rejects.toThrow('boom') + expect(child.kill).not.toHaveBeenCalled() + }) + + it('keeps the child alive while other subscriptions remain', async () => { + const firstPromise = subscribeViaWatcherProcess('/a', vi.fn(), {}) + const child = currentChild() + ackSubscribe(child) + const first = await firstPromise + const secondPromise = subscribeViaWatcherProcess('/b', vi.fn(), {}) + ackSubscribe(child) + const second = await secondPromise + + const firstUnsub = first.unsubscribe() + expect(child.kill).not.toHaveBeenCalled() + expect(child.sent.at(-1)).toMatchObject({ op: 'unsubscribe' }) + + // The last unsubscribe kills the idle child, which also completes the + // still-pending unsubscribe ack from the first record. + const secondUnsub = second.unsubscribe() + expect(child.kill).toHaveBeenCalledTimes(1) + await expect(firstUnsub).resolves.toBeUndefined() + await expect(secondUnsub).resolves.toBeUndefined() + }) + it('uses the in-process watcher under vitest', async () => { vi.stubEnv('VITEST', 'true') const unsubscribe = vi.fn().mockResolvedValue(undefined) diff --git a/src/main/ipc/parcel-watcher-process.ts b/src/main/ipc/parcel-watcher-process.ts index 25e52794a8d..58ad6097011 100644 --- a/src/main/ipc/parcel-watcher-process.ts +++ b/src/main/ipc/parcel-watcher-process.ts @@ -183,6 +183,9 @@ function handleChildMessage(message: WatcherToHostMessage): void { } else { record.callback(new Error(message.message), []) } + // Why: a failed last subscribe empties the records map without any + // unsubscribe ever being called — tear down the idle child here too. + killWatcherChildIfIdle() return } if (message.op === 'events') { @@ -243,6 +246,25 @@ function handleChildGone(proc: ChildProcess): void { } } +// Why: the last record gone leaves the child idle until app shutdown. Killing +// it releases native handles without running watcher.node's crash-prone async +// teardown (same rationale as disposeWatcherProcess) and reclaims the process; +// the next subscribe forks a fresh child. `child = null` before kill so the +// exit event neither counts as a crash nor respawns. Process death also +// completes any still-pending unsubscribe acks. +function killWatcherChildIfIdle(): void { + const proc = child + if (!proc || records.size > 0) { + return + } + child = null + for (const resolve of pendingUnsubscribes.values()) { + resolve() + } + pendingUnsubscribes.clear() + proc.kill() +} + function makeSubscription(record: SubscriptionRecord): WatcherProcessSubscription { return { unsubscribe: (): Promise => { @@ -253,6 +275,10 @@ function makeSubscription(record: SubscriptionRecord): WatcherProcessSubscriptio if (!proc?.connected) { return Promise.resolve() } + if (records.size === 0) { + killWatcherChildIfIdle() + return Promise.resolve() + } return new Promise((resolve) => { pendingUnsubscribes.set(record.id, resolve) sendToChild(proc, { op: 'unsubscribe', id: record.id }) diff --git a/src/main/ipc/preflight-command-exec.test.ts b/src/main/ipc/preflight-command-exec.test.ts new file mode 100644 index 00000000000..204b8a5f6e1 --- /dev/null +++ b/src/main/ipc/preflight-command-exec.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' + +const { runPreflightCommandInWslMock } = vi.hoisted(() => ({ + runPreflightCommandInWslMock: vi.fn() +})) + +vi.mock('./preflight-wsl-command', () => ({ + runPreflightCommandInWsl: runPreflightCommandInWslMock +})) + +import { isCommandOnPath } from './preflight-command-exec' + +describe('isCommandOnPath', () => { + const sentinel = '__ORCA_PREFLIGHT_COMMAND_PATH__' + + beforeEach(() => { + runPreflightCommandInWslMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses the shared literal lookup and accepts a sentinel-prefixed POSIX path', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + runPreflightCommandInWslMock.mockResolvedValue({ + stdout: `shell startup chatter\n${sentinel}/home/user/.local/bin/codex\n`, + stderr: '' + }) + + const found = await isCommandOnPath('codex', { distro: 'Ubuntu' }) + + expect(found).toBe(true) + expect(runPreflightCommandInWslMock).toHaveBeenCalledOnce() + const [, command] = runPreflightCommandInWslMock.mock.calls[0] as [{ distro: string }, string] + expect(command).toContain( + buildPosixCommandPathLookupScript({ kind: 'literal', value: 'codex' }) + ) + expect(command).toContain( + ['if [ -n "$resolved" ]; then', `printf '${sentinel}%s\\n' "$resolved"`, 'fi'].join('\n') + ) + }) + + it.each([ + ['/absolute/startup/chatter', false], + [`${sentinel}relative/path`, false], + ['codex', false], + ["alias codex='codex --wrapped'", false] + ])('parses WSL lookup output %s as available: %s', async (stdout, expected) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + runPreflightCommandInWslMock.mockResolvedValue({ stdout: `${stdout}\n`, stderr: '' }) + + await expect(isCommandOnPath('codex', { distro: 'Ubuntu' })).resolves.toBe(expected) + }) +}) diff --git a/src/main/ipc/preflight-command-exec.ts b/src/main/ipc/preflight-command-exec.ts index b3c3ac990b7..b5fa8dee40a 100644 --- a/src/main/ipc/preflight-command-exec.ts +++ b/src/main/ipc/preflight-command-exec.ts @@ -1,12 +1,14 @@ import { execFile } from 'node:child_process' import path from 'node:path' import { promisify } from 'node:util' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' import { buildLocalPreflightEnv } from './preflight-local-env' import { runPreflightCommandInWsl } from './preflight-wsl-command' import type { WslPreflightTarget } from './preflight-wsl-agent-detection' const execFileAsync = promisify(execFile) export const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000 +const WSL_COMMAND_PATH_SENTINEL = '__ORCA_PREFLIGHT_COMMAND_PATH__' export type PreflightCommandResult = { stdout: string; stderr: string } @@ -81,8 +83,27 @@ export async function isCommandOnPath( const finder = process.platform === 'win32' ? 'where' : 'which' try { const { stdout } = wslTarget - ? await execCommandInWsl(wslTarget, `command -v ${shellQuote(command)}`) + ? // Why: preflight must validate the executable on PATH, not a shell alias or function. + await execCommandInWsl( + wslTarget, + [ + buildPosixCommandPathLookupScript({ kind: 'literal', value: command }), + 'if [ -n "$resolved" ]; then', + `printf '${WSL_COMMAND_PATH_SENTINEL}%s\\n' "$resolved"`, + 'fi' + ].join('\n') + ) : await execLocalPreflightCommand(finder, [command]) + if (wslTarget) { + // Why: WSL startup chatter can contain unrelated absolute paths. + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith(WSL_COMMAND_PATH_SENTINEL)) + .map((line) => line.slice(WSL_COMMAND_PATH_SENTINEL.length)) + .some((line) => path.posix.isAbsolute(line)) + } + return stdout .split(/\r?\n/) .map((line) => line.trim()) diff --git a/src/main/ipc/preflight-wsl-agent-detection.test.ts b/src/main/ipc/preflight-wsl-agent-detection.test.ts index 5c544ef5c63..a1f9d7cfda5 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.test.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.test.ts @@ -16,6 +16,8 @@ vi.mock('child_process', () => { }) import { detectWslCommandsOnPath } from './preflight-wsl-agent-detection' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' +import { escapeWslShCommandForWindows } from '../../shared/wsl-login-shell-command' function lastShCommandPayload(): string { const call = execFileAsyncMock.mock.calls.at(-1) @@ -47,6 +49,20 @@ describe('detectWslCommandsOnPath', () => { expect(payload).toContain('fi\ndone') }) + it('uses the shared alias- and function-neutral PATH lookup', async () => { + execFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await detectWslCommandsOnPath({ distro: 'Ubuntu' }, ['claude', 'codex']) + + const payload = lastShCommandPayload() + const lookupScript = buildPosixCommandPathLookupScript({ + kind: 'shell-variable', + name: 'cmd' + }) + expect(payload).toContain(escapeWslShCommandForWindows(lookupScript)) + expect(payload).not.toContain('type -P') + }) + it('parses detected commands from prefixed stdout', async () => { execFileAsyncMock.mockResolvedValue({ stdout: diff --git a/src/main/ipc/preflight-wsl-agent-detection.ts b/src/main/ipc/preflight-wsl-agent-detection.ts index ed7d3f5399b..be1f216ed9c 100644 --- a/src/main/ipc/preflight-wsl-agent-detection.ts +++ b/src/main/ipc/preflight-wsl-agent-detection.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' import path from 'node:path' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' import { buildWslLoginShellCommand, escapeWslShCommandForWindows @@ -24,13 +25,15 @@ export async function detectWslCommandsOnPath( } const commandList = uniqueCommands.map(shellQuote).join(' ') - // Why: join with newlines, not spaces. zsh treats `fi done` as a parse error - // (it needs a separator before `done`); the login shell may be zsh, so a - // space-joined script silently fails for every agent. Newlines are valid - // statement separators in every POSIX shell and zsh. + const lookupScript = buildPosixCommandPathLookupScript({ + kind: 'shell-variable', + name: 'cmd' + }) + // Newlines keep the loop valid in zsh and every POSIX shell used here. const script = [ `for cmd in ${commandList}; do`, - 'if resolved=$(command -v "$cmd" 2>/dev/null); then', + lookupScript, + 'if [ -n "$resolved" ]; then', `printf '${WSL_AGENT_DETECTION_PREFIX}%s\\t%s\\n' "$cmd" "$resolved";`, 'fi', 'done' diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 2e1b842904d..b3afc70b4a5 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -321,6 +321,23 @@ describe('registerShellHandlers', () => { ]) }) + it.runIf(process.platform === 'win32')( + 'forwards WSL remote arguments with spaces through the Windows launcher shim', + async () => { + const workspacePath = '\\\\wsl.localhost\\Ubuntu Preview\\home\\Ada Lovelace\\project' + const codeShim = 'C:\\Tools\\CODE.CMD' + resolveCliCommandMock.mockReturnValueOnce(codeShim) + const handler = getHandler('shell:openInExternalEditor') + + await expect(handler({}, workspacePath, 'code')).resolves.toEqual({ ok: true }) + expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(codeShim, [ + '--remote', + 'wsl+Ubuntu Preview', + '/home/Ada Lovelace/project' + ]) + } + ) + it('shows the Windows console for NeoVim executable launchers on Windows', async () => { const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) diff --git a/src/main/ipc/workspace-cleanup-activity.test.ts b/src/main/ipc/workspace-cleanup-activity.test.ts new file mode 100644 index 00000000000..72f20291f4a --- /dev/null +++ b/src/main/ipc/workspace-cleanup-activity.test.ts @@ -0,0 +1,163 @@ +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { Repo, Worktree } from '../../shared/types' +import { resolveWorkspaceCleanupActivityWorktree } from './workspace-cleanup-activity' + +const REPO: Repo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000', + addedAt: 1 +} + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'repo-1::/repo-feature', + repoId: 'repo-1', + path: '/repo-feature', + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false, + displayName: 'feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + workspaceStatus: 'in-progress', + ...overrides + } +} + +describe('resolveWorkspaceCleanupActivityWorktree', () => { + it('uses local worktree filesystem metadata when persisted activity is missing', async () => { + const statPath = vi.fn(async (targetPath: string) => ({ + mtimeMs: targetPath.endsWith('.git') ? 20_000 : 10_000 + })) + + const worktree = await resolveWorkspaceCleanupActivityWorktree(REPO, makeWorktree(), statPath) + + expect(statPath).toHaveBeenCalledWith('/repo-feature') + expect(statPath).toHaveBeenCalledWith(path.join('/repo-feature', '.git')) + expect(worktree.lastActivityAt).toBe(20_000) + }) + + it('uses linked worktree gitdir metadata when the .git pointer is stale', async () => { + const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') + const gitDirHeadPath = path.join(gitDirPath, 'HEAD') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => { + const mtimes: Record = { + '/repo-feature': 10_000, + [path.join('/repo-feature', '.git')]: 20_000, + [gitDirPath]: 50_000, + [gitDirHeadPath]: 60_000, + [gitDirLogsHeadPath]: 70_000 + } + return { mtimeMs: mtimes[targetPath] ?? 0 } + }) + const readTextFile = vi.fn(async () => `gitdir: ${gitDirPath}\n`) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(readTextFile).toHaveBeenCalledWith(path.join('/repo-feature', '.git')) + expect(statPath).toHaveBeenCalledWith(gitDirPath) + expect(statPath).toHaveBeenCalledWith(gitDirHeadPath) + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(worktree.lastActivityAt).toBe(70_000) + }) + + it('resolves relative linked worktree gitdir pointers from the worktree path', async () => { + const gitDirPath = path.resolve('/repo-feature', '.repo/gitdir') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => ({ + mtimeMs: targetPath === gitDirLogsHeadPath ? 40_000 : 10_000 + })) + const readTextFile = vi.fn(async () => 'gitdir: .repo/gitdir\n') + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(worktree.lastActivityAt).toBe(40_000) + }) + + it('converts WSL linked worktree gitdir pointers before reading metadata', async () => { + const worktreePath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo-feature` + const gitDirPath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo\.git\worktrees\repo-feature` + const gitDirHeadPath = path.join(gitDirPath, 'HEAD') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => { + const mtimes: Record = { + [worktreePath]: 10_000, + [path.join(worktreePath, '.git')]: 20_000, + [gitDirPath]: 50_000, + [gitDirHeadPath]: 60_000, + [gitDirLogsHeadPath]: 70_000 + } + return { mtimeMs: mtimes[targetPath] ?? 0 } + }) + const readTextFile = vi.fn(async () => 'gitdir: /home/me/repo/.git/worktrees/repo-feature\n') + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree({ path: worktreePath }), + statPath, + readTextFile + ) + + expect(readTextFile).toHaveBeenCalledWith(path.join(worktreePath, '.git')) + expect(statPath).toHaveBeenCalledWith(gitDirPath) + expect(statPath).toHaveBeenCalledWith(gitDirHeadPath) + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(statPath).not.toHaveBeenCalledWith('/home/me/repo/.git/worktrees/repo-feature') + expect(worktree.lastActivityAt).toBe(70_000) + }) + + it('keeps persisted activity when it is newer than local metadata', async () => { + const statPath = vi.fn(async () => ({ mtimeMs: 10_000 })) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree({ lastActivityAt: 30_000 }), + statPath + ) + + expect(worktree.lastActivityAt).toBe(30_000) + }) + + it('does not stat remote worktree paths', async () => { + const statPath = vi.fn(async () => ({ mtimeMs: 20_000 })) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + { ...REPO, connectionId: 'ssh-1' }, + makeWorktree({ createdAt: 10_000 }), + statPath + ) + + expect(statPath).not.toHaveBeenCalled() + expect(worktree.lastActivityAt).toBe(10_000) + }) +}) diff --git a/src/main/ipc/workspace-cleanup-activity.ts b/src/main/ipc/workspace-cleanup-activity.ts new file mode 100644 index 00000000000..9026b4464d9 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-activity.ts @@ -0,0 +1,126 @@ +import { lstat, readFile } from 'node:fs/promises' +import path from 'node:path' +import type { Repo, Worktree } from '../../shared/types' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' + +type StatPath = (targetPath: string) => Promise<{ mtimeMs: number }> +type ReadTextFile = (targetPath: string) => Promise + +export function getPersistedWorkspaceCleanupActivityAt( + worktree: Pick +): number { + const persistedActivityAt = Number.isFinite(worktree.lastActivityAt) ? worktree.lastActivityAt : 0 + const createdAt = Number.isFinite(worktree.createdAt) ? (worktree.createdAt ?? 0) : 0 + return Math.max(persistedActivityAt, createdAt) +} + +export function resolvePersistedWorkspaceCleanupActivityWorktree(worktree: Worktree): Worktree { + const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) + if (persistedActivityAt <= worktree.lastActivityAt) { + return worktree + } + return { ...worktree, lastActivityAt: persistedActivityAt } +} + +export async function resolveWorkspaceCleanupActivityWorktree( + repo: Repo, + worktree: Worktree, + statPath: StatPath = statLocalPath, + readTextFile: ReadTextFile = readLocalTextFile +): Promise { + const activityAt = await resolveWorkspaceCleanupActivityAt(repo, worktree, statPath, readTextFile) + if (activityAt <= worktree.lastActivityAt) { + return worktree + } + return { ...worktree, lastActivityAt: activityAt } +} + +async function statLocalPath(targetPath: string): Promise<{ mtimeMs: number }> { + const stats = await lstat(targetPath) + return { mtimeMs: Number(stats.mtimeMs) } +} + +async function readLocalTextFile(targetPath: string): Promise { + return readFile(targetPath, 'utf8') +} + +async function resolveWorkspaceCleanupActivityAt( + repo: Repo, + worktree: Worktree, + statPath: StatPath, + readTextFile: ReadTextFile +): Promise { + const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree) + if (repo.connectionId) { + return persistedActivityAt + } + + const filesystemActivityAt = await getNewestLocalWorktreeStatMtime( + worktree.path, + statPath, + readTextFile + ) + return Math.max(persistedActivityAt, filesystemActivityAt) +} + +// Why: best-effort only. Win32 stat over \\wsl.localhost (9P) can falsely +// report ENOENT (see wslUncDirectoryExists), so a failed stat degrades to the +// persisted activity timestamp instead of blocking or mislabeling the row. +async function getNewestLocalWorktreeStatMtime( + worktreePath: string, + statPath: StatPath, + readTextFile: ReadTextFile +): Promise { + const gitPath = path.join(worktreePath, '.git') + const gitDirPath = await readLocalWorktreeGitDir(worktreePath, gitPath, readTextFile) + const gitDirMtimePromises = gitDirPath + ? [ + readMtime(gitDirPath, statPath), + readMtime(path.join(gitDirPath, 'HEAD'), statPath), + readMtime(path.join(gitDirPath, 'logs', 'HEAD'), statPath) + ] + : [] + const mtimes = await Promise.all([ + readMtime(worktreePath, statPath), + readMtime(gitPath, statPath), + ...gitDirMtimePromises + ]) + return Math.max(0, ...mtimes) +} + +async function readLocalWorktreeGitDir( + worktreePath: string, + gitPath: string, + readTextFile: ReadTextFile +): Promise { + try { + const contents = await readTextFile(gitPath) + const match = /^gitdir:\s*(.+)\s*$/im.exec(contents) + if (!match) { + return null + } + const gitDir = match[1]?.trim() + if (!gitDir) { + return null + } + // Why: linked worktrees keep mutable git state outside the worktree; the + // pointer file mtime alone can miss recent external commits. + const wslWorktree = parseWslUncPath(worktreePath) + if (wslWorktree && gitDir.startsWith('/')) { + return toWindowsWslPath(gitDir, wslWorktree.distro) + } + return path.isAbsolute(gitDir) ? gitDir : path.resolve(worktreePath, gitDir) + } catch { + return null + } +} + +async function readMtime(targetPath: string, statPath: StatPath): Promise { + try { + const stats = await statPath(targetPath) + return Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : 0 + } catch { + return 0 + } +} diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index 33866e6cd73..a9b6ff704ca 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -3,7 +3,7 @@ import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { IGitProvider } from '../providers/types' import { isFolderRepo } from '../../shared/repo-kind' -import type { GitWorktreeInfo, Repo } from '../../shared/types' +import type { GitWorktreeInfo, Repo, Worktree } from '../../shared/types' import { mergeWorktree } from './worktree-logic' import { splitWorktreeId } from '../../shared/worktree-id' import type { @@ -13,6 +13,11 @@ import type { WorkspaceCleanupScanProgress, WorkspaceCleanupScanResult } from '../../shared/workspace-cleanup' +import { + getPersistedWorkspaceCleanupActivityAt, + resolvePersistedWorkspaceCleanupActivityWorktree, + resolveWorkspaceCleanupActivityWorktree +} from './workspace-cleanup-activity' import { buildWorkspaceCleanupCandidate, buildWorkspaceCleanupCandidateFromError, @@ -124,48 +129,92 @@ async function scanRepoWorkspaces( return { scannedAt, candidates, errors: [] } } - const worktrees = gitWorktrees - .map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - const meta = store.getWorktreeMeta(worktreeId) - return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) - }) - .filter((worktree) => { - if (targetWorktreeId) { - return worktree.id === targetWorktreeId - } - return ( - !repoIsFolder && - !worktree.isMainWorktree && - isWorkspaceInactiveForCleanup(worktree, scannedAt) + const mergedWorktrees = gitWorktrees.map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + const meta = store.getWorktreeMeta(worktreeId) + return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + }) + const candidateWorktrees = targetWorktreeId + ? mergedWorktrees.filter((worktree) => worktree.id === targetWorktreeId) + : mergedWorktrees.filter((worktree) => + shouldResolveBroadWorkspaceCleanupActivity(repoIsFolder, worktree, scannedAt) ) - }) - - onWorktreesDiscovered?.(worktrees.length) - - const candidates = await mapWorkspaceCleanupWithConcurrency( - worktrees, + // Why: fs stat has no cancellation, so on a hung network/WSL mount every + // timed-out row would abandon more threadpool work. After the first timeout, + // stop statting this repo and use persisted activity only. + let activityStatsUnavailable = false + const candidatesWithSkipped = await mapWorkspaceCleanupWithConcurrency( + candidateWorktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { + // Why: externally-created worktrees can miss Orca activity stamps; local + // filesystem metadata is a conservative guard before suggesting deletion. + const worktreeWithActivity = activityStatsUnavailable + ? resolvePersistedWorkspaceCleanupActivityWorktree(worktree) + : await resolveCleanupActivityWithTimeout(repo, worktree, () => { + activityStatsUnavailable = true + }) + if (!targetWorktreeId && !isWorkspaceInactiveForCleanup(worktreeWithActivity, scannedAt)) { + return null + } + onWorktreesDiscovered?.(1) const candidate = await buildWorkspaceCleanupCandidate({ repo, - worktree, + worktree: worktreeWithActivity, scannedAt, provider, - skipGit: skipGitWorktreeIds.has(worktree.id), + skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id), forceGitCheck: Boolean(targetWorktreeId) }).catch((error) => { console.error('Workspace cleanup candidate scan failed', error) - return buildWorkspaceCleanupCandidateFromError(repo, worktree, scannedAt) + return buildWorkspaceCleanupCandidateFromError(repo, worktreeWithActivity, scannedAt) }) onCandidateScanned?.(candidate) return candidate } ) + const candidates = candidatesWithSkipped.filter( + (candidate): candidate is WorkspaceCleanupCandidate => candidate !== null + ) return { scannedAt, candidates, errors } } +async function resolveCleanupActivityWithTimeout( + repo: Repo, + worktree: Worktree, + onActivityStatsUnavailable: () => void +): Promise { + try { + return await withWorkspaceCleanupTimeout( + () => resolveWorkspaceCleanupActivityWorktree(repo, worktree), + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + 'Timed out reading worktree activity.' + ) + } catch (error) { + onActivityStatsUnavailable() + console.warn('Workspace cleanup activity scan failed', error) + return resolvePersistedWorkspaceCleanupActivityWorktree(worktree) + } +} + +function shouldResolveBroadWorkspaceCleanupActivity( + repoIsFolder: boolean, + worktree: Worktree, + scannedAt: number +): boolean { + if (repoIsFolder || worktree.isMainWorktree) { + return false + } + return isWorkspaceInactiveForCleanup( + { + isArchived: worktree.isArchived, + lastActivityAt: getPersistedWorkspaceCleanupActivityAt(worktree) + }, + scannedAt + ) +} + async function listCleanupGitWorktrees( repo: Repo, repoIsFolder: boolean diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index af084713a0b..1d02e59db29 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ipcMain } from 'electron' import type { Store } from '../persistence' @@ -11,6 +12,8 @@ import type { import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup' const { + lstatMock, + readFileMock, listRepoWorktreesMock, getStatusMock, gitExecFileAsyncMock, @@ -18,6 +21,8 @@ const { getSshPtyProviderMock, listRegisteredPtysMock } = vi.hoisted(() => ({ + lstatMock: vi.fn(), + readFileMock: vi.fn(), listRepoWorktreesMock: vi.fn(), getStatusMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), @@ -26,6 +31,11 @@ const { listRegisteredPtysMock: vi.fn() })) +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + readFile: readFileMock +})) + vi.mock('electron', () => ({ ipcMain: { handle: vi.fn(), @@ -92,6 +102,22 @@ function buildWorktreeIds(repoId: string, count: number): string[] { return worktreeIds } +function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { + return { + displayName: 'Feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + ...overrides + } +} + function makeStore( options: { baseRef?: string @@ -123,6 +149,8 @@ describe('workspace cleanup scan', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(NOW) + lstatMock.mockReset() + readFileMock.mockReset() listRepoWorktreesMock.mockReset() getStatusMock.mockReset() gitExecFileAsyncMock.mockReset() @@ -130,6 +158,8 @@ describe('workspace cleanup scan', () => { getSshPtyProviderMock.mockReset() listRegisteredPtysMock.mockReset() listRegisteredPtysMock.mockReturnValue([]) + lstatMock.mockResolvedValue({ mtimeMs: 0 }) + readFileMock.mockRejectedValue(new Error('not a gitdir pointer')) vi.mocked(ipcMain.handle).mockReset() vi.mocked(ipcMain.removeHandler).mockReset() listRepoWorktreesMock.mockResolvedValue([ @@ -199,7 +229,7 @@ describe('workspace cleanup scan', () => { expect(progress[0]).toMatchObject({ scanId: 'scan-1', scannedWorktreeCount: 0, - totalWorktreeCount: 2, + totalWorktreeCount: 1, candidates: [] }) const candidateProgress = progress.filter( @@ -222,6 +252,55 @@ describe('workspace cleanup scan', () => { }) }) + it('emits ready rows while another activity metadata read is stalled', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo-feature-a', + head: 'abc123', + branch: 'refs/heads/feature-a', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-feature-b', + head: 'def456', + branch: 'refs/heads/feature-b', + isBare: false, + isMainWorktree: false + } + ]) + lstatMock.mockImplementation((targetPath: string) => { + if (targetPath.startsWith('/repo-feature-a')) { + return new Promise(() => undefined) + } + return Promise.resolve({ mtimeMs: 0 }) + }) + const progress: WorkspaceCleanupScanProgress[] = [] + + const scanPromise = scanWorkspaceCleanup( + makeStore(), + { scanId: 'scan-1' }, + { onProgress: (event) => progress.push(event) } + ) + + await vi.waitFor(() => { + expect( + progress.some((event) => event.candidates[0]?.worktreeId === 'repo-1::/repo-feature-b') + ).toBe(true) + }) + expect(progress.at(-1)).toMatchObject({ + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + }) + + await vi.advanceTimersByTimeAsync(8_000) + const result = await scanPromise + + expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual( + expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b']) + ) + }) + it('keeps raw scan errors out of renderer-facing results', async () => { listRepoWorktreesMock.mockRejectedValue(new Error('fatal: path /Users/alice/private failed')) @@ -273,18 +352,10 @@ describe('workspace cleanup scan', () => { it('uses direct metadata lookup for focused disconnected remote preflight', async () => { const targetWorktreeId = 'repo-1::/remote/repo-feature' - const targetMeta: WorktreeMeta = { + const targetMeta = makeWorktreeMeta({ displayName: 'Remote Feature', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000 - } + }) const getWorktreeMeta = vi.fn((worktreeId: string) => worktreeId === targetWorktreeId ? targetMeta : undefined ) @@ -313,6 +384,35 @@ describe('workspace cleanup scan', () => { }) }) + it('stats only the requested worktree during focused local preflight scans', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo-feature-a', + head: 'abc123', + branch: 'refs/heads/feature-a', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-feature-b', + head: 'def456', + branch: 'refs/heads/feature-b', + isBare: false, + isMainWorktree: false + } + ]) + + const result = await scanWorkspaceCleanup(makeStore(), { + worktreeId: 'repo-1::/repo-feature-b' + }) + + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]?.worktreeId).toBe('repo-1::/repo-feature-b') + expect(lstatMock).toHaveBeenCalledTimes(2) + expect(lstatMock).toHaveBeenCalledWith('/repo-feature-b') + expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-feature-b', '.git')) + }) + it('scans connected remote workspaces through the SSH git provider', async () => { const provider = { listWorktrees: vi.fn().mockResolvedValue([ @@ -385,6 +485,116 @@ describe('workspace cleanup scan', () => { expect(result.candidates).toEqual([]) }) + it('stats only broad-scan rows that remain possible cleanup candidates', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo', + head: 'main123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/repo-old', + head: 'old123', + branch: 'refs/heads/old', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-recent', + head: 'recent123', + branch: 'refs/heads/recent', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-new-created', + head: 'created123', + branch: 'refs/heads/created', + isBare: false, + isMainWorktree: false + } + ]) + const metadataByWorktreeId: Record = { + 'repo-1::/repo-old': makeWorktreeMeta({ + lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }), + 'repo-1::/repo-recent': makeWorktreeMeta({ + lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }), + 'repo-1::/repo-new-created': makeWorktreeMeta({ + createdAt: NOW - 1_000, + lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }) + } + const store = { + ...makeStore(), + getWorktreeMeta: (worktreeId: string) => metadataByWorktreeId[worktreeId] + } as unknown as Store + + const result = await scanWorkspaceCleanup(store) + + expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([ + 'repo-1::/repo-old' + ]) + expect(lstatMock).toHaveBeenCalledTimes(2) + expect(lstatMock).toHaveBeenCalledWith('/repo-old') + expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-old', '.git')) + expect(getStatusMock).toHaveBeenCalledTimes(1) + }) + + it('falls back when broad-scan activity metadata stalls', async () => { + lstatMock.mockReturnValue(new Promise(() => undefined)) + const progress: unknown[] = [] + + const scanPromise = scanWorkspaceCleanup( + makeStore(), + { scanId: 'scan-1' }, + { onProgress: (event) => progress.push(event) } + ) + await vi.advanceTimersByTimeAsync(8_000) + + const result = await scanPromise + + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]?.worktreeId).toBe('repo-1::/repo-feature') + expect(progress[0]).toMatchObject({ + scanId: 'scan-1', + scannedWorktreeCount: 0, + totalWorktreeCount: 1 + }) + expect(progress.at(-1)).toMatchObject({ + scanId: 'scan-1', + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + }) + }) + + it('stops statting a repo after the first activity metadata timeout', async () => { + listRepoWorktreesMock.mockResolvedValue( + ['/repo-a', '/repo-b', '/repo-c', '/repo-d'].map((worktreePath, index) => ({ + path: worktreePath, + head: `head-${index}`, + branch: `refs/heads/branch-${index}`, + isBare: false, + isMainWorktree: false + })) + ) + lstatMock.mockReturnValue(new Promise(() => undefined)) + + const scanPromise = scanWorkspaceCleanup(makeStore()) + await vi.advanceTimersByTimeAsync(8_000) + const result = await scanPromise + + expect(result.candidates).toHaveLength(4) + expect(lstatMock).not.toHaveBeenCalledWith('/repo-d') + expect(lstatMock).not.toHaveBeenCalledWith(path.join('/repo-d', '.git')) + }) + it('includes focused remove preflight rows even when they are recent', async () => { const result = await scanWorkspaceCleanup( makeStore({ diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 22f20157d8f..366c9d4256e 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -527,7 +527,7 @@ describe('Store', () => { expect(settings.experimentalActivity).toBe(false) expect(settings.experimentalActivityDefaultedOffForAllUsers).toBe(true) expect(settings.experimentalTerminalAttention).toBe(false) - expect(settings.experimentalNewWorktreeCardStyle).toBe(true) + expect(settings.experimentalNewWorktreeCardStyle).toBe(false) expect(settings.floatingTerminalEnabled).toBe(true) expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true) expect(settings.notifications.customSoundPath).toBeNull() @@ -658,7 +658,7 @@ describe('Store', () => { expect(store.getUI().setupGuideSidebarDismissed).toBe(false) }) - it('defaults new worktree card style on while onboarding is open', async () => { + it('keeps new worktree card style off while onboarding is open', async () => { writeDataFile({ settings: {}, onboarding: { @@ -673,7 +673,7 @@ describe('Store', () => { const store = await createStore() - expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(true) + expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) it('preserves explicit new worktree card style opt-out while onboarding is open', async () => { @@ -696,6 +696,20 @@ describe('Store', () => { expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(false) }) + it('preserves explicit new worktree card style opt-in on load', async () => { + writeDataFile({ + schemaVersion: 1, + settings: { + experimentalNewWorktreeCardStyle: true + }, + ui: {} + }) + + const store = await createStore() + + expect(store.getSettings().experimentalNewWorktreeCardStyle).toBe(true) + }) + it('keeps new worktree card style off for existing users backfilled as completed', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 67fdd0c8215..0af7920b1a2 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -1350,10 +1350,6 @@ function resolveSetupGuideSidebarDismissedOnLoad( return onboarding.closedAt !== null || persistedDismissed === true } -function shouldDefaultNewWorktreeCardStyleOn(onboarding: OnboardingState): boolean { - return onboarding.closedAt === null -} - // Why: read a settings field that was removed from GlobalSettings but can // still exist on disk. One-shot use for the inline-agents migration. function readDeprecatedExperimentFlag(parsed: PersistedState | undefined): boolean { @@ -3067,16 +3063,6 @@ export class Store { if (!parsed.onboarding) { this.loadNeedsSave = true } - const defaultNewWorktreeCardStyle = - shouldDefaultNewWorktreeCardStyleOn(normalizedOnboarding) - const migratedExperimentalNewWorktreeCardStyle = - parsed.settings?.experimentalNewWorktreeCardStyle ?? defaultNewWorktreeCardStyle - if ( - parsed.settings?.experimentalNewWorktreeCardStyle === undefined && - defaultNewWorktreeCardStyle - ) { - this.loadNeedsSave = true - } const normalizedProjectGroups = normalizeProjectGroups(parsed.projectGroups) const loadedCompactWorktreeCards = parsed.settings?.compactWorktreeCards ?? @@ -3130,9 +3116,6 @@ export class Store { ...migratedTerminalTuiScrollSensitivity.settings, experimentalActivity: migratedExperimentalActivity, experimentalActivityDefaultedOffForAllUsers: true, - // Why: open first-run onboarding is the local fresh-install signal; - // closed/backfilled onboarding identifies existing profiles. - experimentalNewWorktreeCardStyle: migratedExperimentalNewWorktreeCardStyle, // Why: compact worktree cards graduated from Experimental; preserve // the old opt-in for profiles written during the rollout. compactWorktreeCards: loadedCompactWorktreeCards, @@ -3417,18 +3400,7 @@ export class Store { } if (result === null) { - const defaults = getDefaultPersistedState(homedir()) - const isFreshDefaultProfile = - !fileExistedOnLoad && shouldDefaultNewWorktreeCardStyleOn(defaults.onboarding) - result = { - ...defaults, - settings: { - ...defaults.settings, - // Why: a corrupt existing data file also falls back to defaults; only - // the absent-file path is a true fresh install. - experimentalNewWorktreeCardStyle: isFreshDefaultProfile - } - } + result = getDefaultPersistedState(homedir()) } const workspaceSession = pruneWorkspaceSessionBrowserHistory( diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index daaec0ce7fd..77532918702 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -536,13 +536,23 @@ describe('fetchCodexRateLimits', () => { const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] expect(spawnFile).toBe('wsl.exe') - const bashCommand = spawnArgs.at(-1) as string - expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') - expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') - expect(bashCommand).toContain( - "export CODEX_HOME='/home/alice/.local/share/orca/account/home'" + expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'sh', '-c']) + const shellCommand = spawnArgs.at(-1) as string + expect(shellCommand).toContain('_orca_wsl_shell=\\$(getent passwd') + expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "\\$_orca_wsl_shell" -ilc') + expect(shellCommand).toContain( + 'exec 3<&0\nexec 4>&1\nexec /dev/null\n_orca_wsl_shell=' ) - expect(bashCommand).toContain("exec codex '-s' 'read-only' '-a' 'untrusted' 'app-server'") + expect(shellCommand).toContain('mkdir -p "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain('cd "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain( + "export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''" + ) + expect(shellCommand).toContain( + "exec codex '\\''-s'\\'' '\\''read-only'\\'' '\\''-a'\\'' '\\''untrusted'\\'' '\\''app-server'\\'' <&3 >&4 3<&- 4>&-" + ) + expect(shellCommand.match(/<&3 >&4 3<&- 4>&-/g)).toHaveLength(3) + expect(shellCommand.match(/exec codex [^\n]+<&3 >&4 3<&- 4>&-/g)).toHaveLength(3) expect(spawnOptions).toEqual( expect.objectContaining({ cwd: expect.stringContaining('rate-limit-pty-cwd'), @@ -557,6 +567,62 @@ describe('fetchCodexRateLimits', () => { } }) + it('routes Windows host Codex homes through the host RPC path', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { rateLimits: { primary: { usedPercent: 13 } } } + })}\n` + ) + ) + }, 0) + } + }) + + try { + const resultPromise = fetchCodexRateLimits({ codexHomePath: 'C:\\Users\\alice\\.codex' }) + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + await resultPromise + + const [spawnFile, spawnArgs, spawnOptions] = childSpawnMock.mock.calls[0] + expect(spawnFile).toBe('codex') + expect(spawnArgs).toEqual(['-s', 'read-only', '-a', 'untrusted', 'app-server']) + expect(spawnOptions).toEqual( + expect.objectContaining({ + env: expect.objectContaining({ CODEX_HOME: 'C:\\Users\\alice\\.codex' }) + }) + ) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: originalPlatform + }) + } + }) + it('runs rate-limit PTY fallback through WSL when RPC cannot read usage', async () => { const originalPlatform = process.platform const originalCodexHome = process.env.CODEX_HOME @@ -588,13 +654,20 @@ describe('fetchCodexRateLimits', () => { const [spawnFile, spawnArgs, spawnOptions] = ptySpawnMock.mock.calls[0] expect(spawnFile).toBe('wsl.exe') - const bashCommand = spawnArgs.at(-1) as string - expect(bashCommand).toContain('mkdir -p "$orca_rate_limit_cwd"') - expect(bashCommand).toContain('cd "$orca_rate_limit_cwd"') - expect(bashCommand).toContain( - "export CODEX_HOME='/home/alice/.local/share/orca/account/home'" + expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'sh', '-c']) + const shellCommand = spawnArgs.at(-1) as string + expect(shellCommand).toContain('_orca_wsl_shell=\\$(getent passwd') + expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "\\$_orca_wsl_shell" -ilc') + expect(shellCommand).not.toContain('exec 3<&0') + expect(shellCommand).not.toContain('exec /dev/null') + expect(shellCommand).not.toContain('<&3 >&4 3<&- 4>&-') + expect(shellCommand).toContain('mkdir -p "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain('cd "\\$orca_rate_limit_cwd"') + expect(shellCommand).toContain( + "export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''" ) - expect(bashCommand).toContain('exec codex ') + expect(shellCommand).toContain('exec codex ') expect(spawnOptions).toEqual( expect.objectContaining({ cwd: expect.stringContaining('rate-limit-pty-cwd'), diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index 28ef1b710fd..c75be611fe4 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -17,6 +17,10 @@ import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils' import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' import { parseWslUncPath } from '../../shared/wsl-paths' import { extractCodexAuthError, isCodexAuthError } from '../../shared/codex-auth-errors' +import { + buildWslLoginShellCommand, + escapeWslShCommandForWindows +} from '../../shared/wsl-login-shell-command' import { getHiddenRateLimitWslCwdSetupCommands, resolveHiddenRateLimitPtyCwd @@ -112,7 +116,8 @@ function shellQuote(value: string): string { function buildWslCodexCommand( codexHomePath: string, - args: string[] + args: string[], + options?: { isolateRpcStdio?: boolean } ): { command: string args: string[] @@ -124,11 +129,19 @@ function buildWslCodexCommand( const script = [ ...getHiddenRateLimitWslCwdSetupCommands(), `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}`, - `exec codex ${args.map(shellQuote).join(' ')}` + `exec codex ${args.map(shellQuote).join(' ')}${ + options?.isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : '' + }` ].join(' && ') + const loginShellCommand = buildWslLoginShellCommand(script) + // Why: keep the outer sh non-login and hide RPC pipes before the configured + // shell startup can read input or print banners. + const command = options?.isolateRpcStdio + ? ['exec 3<&0', 'exec 4>&1', 'exec /dev/null', loginShellCommand].join('\n') + : loginShellCommand return { command: 'wsl.exe', - args: ['-d', wslInfo.distro, '--', 'bash', '-lc', script] + args: ['-d', wslInfo.distro, '--', 'sh', '-c', escapeWslShCommandForWindows(command)] } } @@ -411,7 +424,7 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { listWorktreesMock.mockResolvedValue([ { path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true } ]) + const signal = new AbortController().signal - const result = await listRepoWorktrees({ - id: 'repo-1', - path: '/workspace/repo', - displayName: 'repo', - badgeColor: '#000', - addedAt: 0, - kind: 'git' - }) + const result = await listRepoWorktrees( + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + kind: 'git' + }, + { signal } + ) - expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo') + expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { signal }) expect(result).toHaveLength(1) }) diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index e02ba35f9c7..4f45e7d8564 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -9,6 +9,10 @@ type LocalRepoWorktreeListOptions = { signal?: AbortSignal } +function hasLocalRepoWorktreeListOptions(options: LocalRepoWorktreeListOptions | undefined) { + return options?.wslDistro !== undefined || options?.signal !== undefined +} + export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean { return repos.some( (repo) => !repo.connectionId && areWorktreePathsEqual(repo.path, resolvedTarget) @@ -30,7 +34,7 @@ export function createFolderWorktree(repo: Repo): GitWorktreeInfo { export async function listRepoWorktrees( repo: Repo, - options: LocalRepoWorktreeListOptions = {} + options?: LocalRepoWorktreeListOptions ): Promise { if (isFolderRepo(repo)) { return [createFolderWorktree(repo)] @@ -42,7 +46,7 @@ export async function listRepoWorktrees( // local git against a server path. return provider ? await provider.listWorktrees(repo.path) : [] } - return options.wslDistro + return hasLocalRepoWorktreeListOptions(options) ? await listWorktrees(repo.path, options) : await listWorktrees(repo.path) } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 6150d8de8dc..eb7df12e023 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -11335,6 +11335,121 @@ describe('OrcaRuntimeService', () => { unsubscribe() }) + // #7970: on a headless serve there is no renderer syncing tab.agentStatus, so + // hook-only transitions must republish the PTY-backed snapshot themselves and + // the snapshot must carry the retained hook payload, not just title evidence. + it('republishes mobile session tabs with hook payloads for title-less OSC 9999 transitions', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'hook-only-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'hook-tab', + leafId: HEADLESS_LEAF_ID + }) + events.length = 0 + + runtime.onPtyData( + 'hook-only-pty', + '\x1b]9999;{"state":"working","prompt":"fix the tests","agentType":"opencode"}\x07', + 100 + ) + + expect(events).toHaveLength(1) + expect(events[0]?.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.objectContaining({ + state: 'working', + prompt: 'fix the tests', + agentType: 'opencode' + }) + }) + ) + + runtime.onPtyData( + 'hook-only-pty', + '\x1b]9999;{"state":"waiting","prompt":"fix the tests","agentType":"opencode"}\x07', + 101 + ) + + expect(events).toHaveLength(2) + expect(events[1]?.tabs[0]?.type === 'terminal' && events[1].tabs[0].agentStatus).toEqual( + expect.objectContaining({ state: 'waiting' }) + ) + + unsubscribe() + }) + + it('does not republish mobile session tabs for repeated identical OSC 9999 payloads', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'hook-ping-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'hook-ping-tab', + leafId: HEADLESS_LEAF_ID + }) + events.length = 0 + + const payload = '\x1b]9999;{"state":"working","prompt":"same","agentType":"codex"}\x07' + runtime.onPtyData('hook-ping-pty', payload, 100) + runtime.onPtyData('hook-ping-pty', payload, 101) + runtime.onPtyData('hook-ping-pty', payload, 102) + + expect(events).toHaveLength(1) + + unsubscribe() + }) + + it('suppresses a retained hook working status once the shell owns the pane title again', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'hook-exit-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'hook-exit-tab', + leafId: HEADLESS_LEAF_ID + }) + events.length = 0 + + runtime.onPtyData( + 'hook-exit-pty', + '\x1b]9999;{"state":"working","prompt":"long task","agentType":"codex"}\x07', + 100 + ) + // Agent exits without a hook done event; the shell takes the title back. + // The stuck-spinner guard (#1437) must win over the retained hook row. + runtime.onPtyData('hook-exit-pty', '\x1b]0;zsh\x07', 101) + + const last = events.at(-1)?.tabs[0] + expect(last?.type).toBe('terminal') + expect(last?.type === 'terminal' ? last.agentStatus : null).toBeFalsy() + + unsubscribe() + }) + it('stores normalized Pi idle OSC titles that still classify as idle', async () => { const runtime = new OrcaRuntimeService(store) syncSinglePty(runtime) @@ -14781,6 +14896,90 @@ describe('OrcaRuntimeService', () => { expect(activateWorktree).not.toHaveBeenCalled() }) + it('wakes slept agents on the host renderer when a phone activates a worktree', async () => { + // Seed isUnread:false so the unread-clear branch stays quiet and the + // assertions isolate the mobile slept-agent wake. + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false }) + } + const activateWorktree = vi.fn() + const resumeSleepingAgents = vi.fn() + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] + } as never) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree, + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + resumeSleepingAgents, + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + // A renderer must be attached to receive the wake (headless serve is a + // deliberate non-goal — no renderer, no wake). + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'mobile' + }) + + // INV-2: mobile wake never navigates the desktop (no activateWorktree); it + // routes exclusively through the renderer's own navigation-free wake. + expect(resumeSleepingAgents).toHaveBeenCalledWith(TEST_WORKTREE_ID) + expect(activateWorktree).not.toHaveBeenCalled() + }) + + it('does not wake slept agents for non-mobile session-only activation', async () => { + const metaById: Record = { + [TEST_WORKTREE_ID]: makeWorktreeMeta({ isUnread: false }) + } + const resumeSleepingAgents = vi.fn() + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] + } as never) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + resumeSleepingAgents, + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) + runtime.attachWindow(TEST_WINDOW_ID) + runtime.markGraphReady(TEST_WINDOW_ID) + + // INV-3: web/desktop runtime clients keep their existing wake-on-activation + // paths untouched — the renderer notifier wake is mobile-scoped. + await runtime.activateManagedWorktree(`id:${TEST_WORKTREE_ID}`, { + notifyClients: false, + clientKind: 'runtime' + }) + + expect(resumeSleepingAgents).not.toHaveBeenCalled() + }) + it('does not rewrite unread metadata when a mobile activation finds the worktree already read', async () => { // Why: seed instanceId so worktree resolution does not emit its own // metadata-stamp write, isolating the assertion to the unread clear. diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index af4274df498..1bcbcec3077 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1294,6 +1294,11 @@ type RuntimeNotifier = { ): Promise closeTerminal(tabId: string, paneRuntimeId?: number): void sleepWorktree(worktreeId: string): void + // Why: a phone opening a worktree wakes its slept agents by asking the host + // renderer to run its own navigation-free wake (experimental agent sleep); + // the runtime has no in-memory sleeping records or wake authority. Optional to + // match the many renderer-backed notifier methods only the real bridge wires. + resumeSleepingAgents?(worktreeId: string): void terminalFitOverrideChanged( ptyId: string, mode: 'mobile-fit' | 'desktop-fit', @@ -5533,8 +5538,11 @@ export class OrcaRuntimeService { } } - this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) - if (shouldTouchPtyBackedSessionTabs) { + const retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + // Why: hook (OSC 9999) transitions often arrive without a title change, so + // headless-serve snapshots would never republish and paired remote clients + // kept the stale agent state until the next title change (#7970). + if (shouldTouchPtyBackedSessionTabs || retainedAgentStatusChanged) { this.touchMobileSessionSnapshotsForPty(ptyId) } @@ -5631,12 +5639,14 @@ export class OrcaRuntimeService { return worktreePath && isWindowsAbsolutePathLike(worktreePath) ? 'win32' : 'posix' } - private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void { + /** Returns true when any retained agent-row snapshot changed in a + * client-visible way, so the caller can republish session snapshots. */ + private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): boolean { // Why: snapshot retention (for mobile worktree.ps) must run even when no // renderer listener is attached, so we don't early-return on a missing // onTerminalAgentStatus — only the per-target emit below is gated on it. if (chunk.payloads.length === 0) { - return + return false } const targets = new Map< string, @@ -5669,9 +5679,17 @@ export class OrcaRuntimeService { connectionId }) } + let retainedChanged = false for (const payload of chunk.payloads) { for (const target of targets.values()) { - this.retainAgentRowSnapshot(ptyId, target.paneKey, target.worktreeId, target.tabId, payload) + retainedChanged = + this.retainAgentRowSnapshot( + ptyId, + target.paneKey, + target.worktreeId, + target.tabId, + payload + ) || retainedChanged if (!this.onTerminalAgentStatus) { continue } @@ -5692,6 +5710,7 @@ export class OrcaRuntimeService { } } } + return retainedChanged } private retainAgentRowSnapshot( @@ -5700,7 +5719,7 @@ export class OrcaRuntimeService { worktreeId: string | undefined, tabId: string | undefined, payload: ParsedAgentStatusPayload - ): void { + ): boolean { const now = Date.now() const previous = this.latestAgentStatusByPaneKey.get(paneKey) // Why: stateStartedAt must mark the transition into the current state, not @@ -5717,6 +5736,17 @@ export class OrcaRuntimeService { stateStartedAt, updatedAt: now }) + // Client-visible change detection: snapshot republish is gated on this so + // repeated same-state hook pings don't fan a rebuild out to every client. + return ( + !previous || + previous.payload.state !== payload.state || + previous.payload.prompt !== payload.prompt || + (previous.payload.agentType ?? null) !== (payload.agentType ?? null) || + (previous.payload.toolName ?? null) !== (payload.toolName ?? null) || + (previous.payload.interactivePrompt ?? null) !== (payload.interactivePrompt ?? null) || + (previous.payload.interrupted ?? false) !== (payload.interrupted ?? false) + ) } private clearAgentRowSnapshotsForPty(ptyId: string): void { @@ -12592,7 +12622,7 @@ export class OrcaRuntimeService { async activateManagedWorktree( worktreeSelector: string, - opts: { notifyClients?: boolean } = {} + opts: { notifyClients?: boolean; clientKind?: 'mobile' | 'runtime' } = {} ): Promise<{ repoId: string worktreeId: string @@ -12624,6 +12654,13 @@ export class OrcaRuntimeService { }) await this.refreshMobileSessionPtyRecords() this.notifyMobileSessionTabsChanged(worktree.id) + // Why: a phone open must also wake the worktree's slept agents (experimental + // agent sleep). Only the host renderer holds the sleeping records + wake + // authority, so fire-and-forget ask it — mobile-scoped so web/desktop are + // unaffected, and only when a renderer is attached to receive it. + if (opts.clientKind === 'mobile' && this.getAvailableAuthoritativeWindow()) { + this.notifier?.resumeSleepingAgents?.(worktree.id) + } } return { repoId: repo.id, worktreeId: worktree.id, activated: true } } @@ -19350,45 +19387,117 @@ export class OrcaRuntimeService { tab: RuntimeMobileSessionTerminalTab, terminalHandle: string | null ): { agentStatus: AgentStatusEntry } | Record { - if (!pty?.lastAgentStatus) { + const paneKey = this.getMobileTerminalPaneKey(tab) + const retained = this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab) + if (!pty?.lastAgentStatus && !retained) { return {} } - const ptyTitle = getLatestAgentCandidateTitle( - { title: pty.title, updatedAt: pty.titleUpdatedAt }, - { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } - ) + const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null + const ptyTitle = pty + ? getLatestAgentCandidateTitle( + { title: pty.title, updatedAt: pty.titleUpdatedAt }, + { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + ) + : leaf + ? getLatestAgentCandidateTitle( + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } + ) + : null const ptyTitleClassification = classifyAgentTitle(ptyTitle) if (ptyTitle !== null && ptyTitleClassification !== 'agent') { - return {} + // Why: a non-agent title means the shell owns the pane again (the agent + // exited or was replaced) — suppressing here is what clears stuck + // spinners (#1437). A live hook signal (question card / active tool) is + // authoritative agent activity even under a task-named title, so it + // survives the suppression, mirroring the renderer-synced branch above. + const hasLiveHookSignal = + retained?.payload.interactivePrompt != null || retained?.payload.toolName != null + if (!hasLiveHookSignal) { + return {} + } } - const now = pty.lastOutputAt ?? Date.now() - const ownerAgent = tab.launchAgent ?? pty.launchAgent ?? pty.foregroundAgent ?? null + const ownerAgent = tab.launchAgent ?? pty?.launchAgent ?? pty?.foregroundAgent ?? null + const terminalTitle = normalizeCompatibleAgentTitleForOwner( + (pty ? getLatestPtyTitle(pty) : null) ?? tab.title, + ownerAgent + ) + // Why: hook (OSC 9999) payloads carry the real state, prompt, and agent + // identity; the title heuristic below is a fallback with none of that. + // Without this, headless-serve clients only ever saw title-derived rows + // and hook-only transitions (e.g. opencode waiting) never surfaced (#7970). + if (retained) { + return { + agentStatus: normalizeCompatibleAgentStatusEntryForOwner( + { + ...retained.payload, + paneKey, + updatedAt: retained.updatedAt, + stateStartedAt: retained.stateStartedAt, + stateHistory: [], + ...(terminalHandle ? { terminalHandle } : {}), + ...((pty?.worktreeId ?? retained.worktreeId) + ? { worktreeId: pty?.worktreeId ?? retained.worktreeId } + : {}), + tabId: tab.parentTabId, + terminalTitle + }, + ownerAgent + ) + } + } + const now = pty!.lastOutputAt ?? Date.now() const agentType = ownerAgent ?? undefined return { agentStatus: { state: - pty.lastAgentStatus === 'working' + pty!.lastAgentStatus === 'working' ? 'working' - : pty.lastAgentStatus === 'permission' + : pty!.lastAgentStatus === 'permission' ? 'blocked' : 'done', prompt: '', updatedAt: now, stateStartedAt: now, - paneKey: this.getMobileTerminalPaneKey(tab), + paneKey, ...(terminalHandle ? { terminalHandle } : {}), ...(agentType ? { agentType } : {}), - worktreeId: pty.worktreeId, + worktreeId: pty!.worktreeId, tabId: tab.parentTabId, - terminalTitle: normalizeCompatibleAgentTitleForOwner( - getLatestPtyTitle(pty) ?? tab.title, - ownerAgent - ), + terminalTitle, stateHistory: [] } } } + /** The retained OSC 9999 hook row for this mobile tab, when fresh enough to + * trust. Looked up by pane identity first, then by PTY ownership because + * legacy `pane:N` leaf ids can drift from the hook-side pane key. */ + private getFreshRetainedAgentStatusForMobileTab( + paneKey: string, + pty: RuntimePtyWorktreeRecord | null, + tab: RuntimeMobileSessionTerminalTab + ): RuntimeAgentRowSnapshot | null { + let retained = this.latestAgentStatusByPaneKey.get(paneKey) ?? null + if (!retained) { + const ptyId = pty?.ptyId ?? tab.ptyId ?? null + if (ptyId) { + for (const snapshot of this.latestAgentStatusByPaneKey.values()) { + if (snapshot.ptyId !== ptyId) { + continue + } + if (!retained || snapshot.updatedAt > retained.updatedAt) { + retained = snapshot + } + } + } + } + if (!retained || Date.now() - retained.updatedAt > AGENT_STATUS_STALE_AFTER_MS) { + return null + } + return retained + } + private findPtyForMobileTerminalTab( worktreeId: string, tab: RuntimeMobileSessionTerminalTab, diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index a54bd404141..ccdde78ee3c 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -38,7 +38,32 @@ describe('worktree RPC methods', () => { expect(response).toMatchObject({ ok: true }) expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', { - notifyClients: false + notifyClients: false, + clientKind: undefined + }) + }) + + it('forwards the mobile clientKind to the runtime on session-only activation', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + activateManagedWorktree: vi + .fn() + .mockResolvedValue({ repoId: 'repo-1', worktreeId: 'wt-1', activated: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + + // The mobile WebSocket path always uses dispatchStreaming, which threads the + // authenticated device scope as clientKind even for non-streaming methods. + const replies: string[] = [] + await dispatcher.dispatchStreaming( + makeRequest('worktree.activate', { worktree: 'id:wt-1', notifyClients: false }), + (response) => replies.push(response), + { clientKind: 'mobile' } + ) + + expect(runtime.activateManagedWorktree).toHaveBeenCalledWith('id:wt-1', { + notifyClients: false, + clientKind: 'mobile' }) }) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index f90f599b22f..0d002b1596b 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -59,9 +59,12 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.activate', params: WorktreeActivate, - handler: async (params, { runtime }) => + handler: async (params, { runtime, clientKind }) => + // Why: clientKind ('mobile'|'runtime') scopes the host-renderer slept-agent + // wake to phones so web/desktop activation behavior is unchanged. runtime.activateManagedWorktree(params.worktree, { - notifyClients: params.notifyClients !== false + notifyClients: params.notifyClients !== false, + clientKind }) }), defineMethod({ diff --git a/src/main/runtime/rpc/ws-fallback-port-store.ts b/src/main/runtime/rpc/ws-fallback-port-store.ts index 93871cad631..beedcea8cf2 100644 --- a/src/main/runtime/rpc/ws-fallback-port-store.ts +++ b/src/main/runtime/rpc/ws-fallback-port-store.ts @@ -5,7 +5,9 @@ import { join } from 'node:path' // assigns a random port. Paired mobile devices store ws://ip:port endpoints, // so a port that changes on every restart permanently orphans those pairings // (STA-1511). Persist the assigned fallback so the same instance re-binds the -// same port next launch. +// same port next launch — the transport binds a persisted fallback BEFORE the +// preferred port, so pairings survive even when the preferred port is free +// again. const FALLBACK_PORT_FILE = 'mobile-ws-fallback-port.json' diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index a44469d1fcc..391e5e18284 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -454,6 +454,77 @@ describe('WebSocketTransport', () => { return port } + it('binds the persisted fallback port even when the preferred port is free', async () => { + // Why: regression for the STA-1511 follow-up — devices paired while the + // fallback port was active store ws://ip:. A later launch that + // finds the preferred port free must still bind the fallback, or those + // pairings go permanently dead until the user re-pairs. + const preferredPort = await reserveFreePort() + const fallbackPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(fallbackPort) + }) + + it('binds the preferred port when the persisted fallback is taken', async () => { + const fallbackHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) + transports.push(fallbackHolder) + await fallbackHolder.start() + const takenFallbackPort = fallbackHolder.resolvedPort + const preferredPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort: takenFallbackPort + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(preferredPort) + }) + + it('falls through to the preferred port when the fallback bind fails with a non-EADDRINUSE error', async () => { + // Why: a persisted fallback can land in an OS-reserved range on a later + // launch (Windows Hyper-V excluded ports → EACCES). That must degrade to + // the preferred port instead of disabling the transport for the session. + const preferredPort = await reserveFreePort() + const fallbackPort = await reserveFreePort() + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort + }) + transports.push(transport) + const withListen = transport as unknown as { tryListen(port: number): Promise } + const realTryListen = withListen.tryListen.bind(transport) + withListen.tryListen = (port: number) => + port === fallbackPort + ? Promise.reject(Object.assign(new Error('listen EACCES'), { code: 'EACCES' })) + : realTryListen(port) + + await transport.start() + expect(transport.resolvedPort).toBe(preferredPort) + }) + + it('still throws when the preferred port fails with a non-EADDRINUSE error', async () => { + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: await reserveFreePort() + }) + transports.push(transport) + const withListen = transport as unknown as { tryListen(port: number): Promise } + withListen.tryListen = () => + Promise.reject(Object.assign(new Error('listen EACCES'), { code: 'EACCES' })) + + await expect(transport.start()).rejects.toThrow('listen EACCES') + }) + it('retries the persisted fallback port before an OS-assigned one', async () => { const holder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) transports.push(holder) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 7b1d8b8ead0..8b88c7a820e 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -48,10 +48,11 @@ export type WebSocketTransportOptions = { // Why: the pairing server can also serve the browser client, so users do // not need a second dev/static server once the web bundle is built. staticRoot?: string - // Why: when the preferred port is taken, an unstable OS-assigned port would - // change on every restart and permanently orphan existing mobile pairings - // (their stored ws://ip:port endpoint goes dead — STA-1511). Callers pass - // the previously assigned fallback port so it is retried first. + // Why: paired mobile devices store the full ws://ip:port endpoint. Once a + // fallback port has been assigned and persisted, devices paired while it was + // active point at it, so it must be bound FIRST on later launches — binding + // the (now free) preferred port instead would strand those pairings + // (STA-1511). Callers pass the previously assigned fallback port here. fallbackPort?: number } @@ -149,39 +150,40 @@ export class WebSocketTransport implements RpcTransport { return } - // Why: when the preferred port is occupied (e.g. another Orca instance is - // already running), fall back to an OS-assigned port so mobile pairing - // still works. The QR code reads resolvedPort after start, so it will - // advertise the correct port regardless. A persisted fallback port is - // retried before port 0 so paired devices keep a stable endpoint across - // restarts (STA-1511). - let port = this.port - try { - await this.tryListen(port) - return - } catch (error: unknown) { - if (!isEAddressInUse(error) || port === 0) { - throw error - } - } - if ( - this.fallbackPort !== undefined && - this.fallbackPort !== 0 && - this.fallbackPort !== this.port - ) { + // Why: a persisted fallback port is bound FIRST — devices paired while it + // was active store ws://ip: and would be permanently stranded if + // a later launch grabbed the (now free) preferred port instead (STA-1511). + // Without a persisted fallback the preferred port is tried first. On + // EADDRINUSE each candidate falls through to the next, ending at port 0 + // (OS-assigned) so mobile pairing still works when everything is taken. + // The QR code reads resolvedPort after start, so it always advertises the + // port that actually bound. + const persistedFallbackPort = + this.fallbackPort !== undefined && this.fallbackPort !== 0 && this.fallbackPort !== this.port + ? this.fallbackPort + : undefined + const candidatePorts = + persistedFallbackPort !== undefined ? [persistedFallbackPort, this.port] : [this.port] + for (const port of candidatePorts) { try { - console.warn( - `[ws-transport] Port ${port} is in use, retrying previous fallback port ${this.fallbackPort}` - ) - await this.tryListen(this.fallbackPort) + await this.tryListen(port) return } catch (error: unknown) { - if (!isEAddressInUse(error)) { + // Why: a persisted fallback can become unbindable for reasons beyond + // EADDRINUSE (e.g. Windows reserves dynamic-range ports for Hyper-V + // after a reboot → EACCES). Any fallback failure must degrade to the + // next candidate — aborting would disable the transport every launch + // while the store still names that port. Only preferred-port failures + // other than EADDRINUSE are fatal. + if (port !== persistedFallbackPort && (!isEAddressInUse(error) || port === 0)) { throw error } + console.warn( + `[ws-transport] Failed to bind port ${port} (${error instanceof Error ? error.message : String(error)}), trying next candidate` + ) } } - console.warn(`[ws-transport] Port ${port} is in use, falling back to OS-assigned port`) + console.warn('[ws-transport] All configured ports failed to bind, using an OS-assigned port') await this.tryListen(0) } diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 8e05aaeec02..f8b3c75c7cf 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -705,8 +705,10 @@ export class OrcaRuntimeRpcServer { port: this.wsPort, staticRoot: this.webClientRoot, // Why: keep the fallback port stable across restarts so paired - // devices' stored endpoints stay valid (STA-1511). wsPort 0 means - // the caller explicitly wants a random port (E2E) — don't pin it. + // devices' stored endpoints stay valid (STA-1511) — the transport + // binds a persisted fallback before the preferred port. wsPort 0 + // means the caller explicitly wants a random port (E2E) — don't + // pin it. ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) }) this.wsTransport = wsTransport diff --git a/src/main/win32-utils.test.ts b/src/main/win32-utils.test.ts index d291198a0e0..0a6d381d3c0 100644 --- a/src/main/win32-utils.test.ts +++ b/src/main/win32-utils.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { + getCmdExePath, getSpawnArgsForWindows, isPermissionError, isWindowsBatchScript, @@ -65,6 +66,26 @@ describe('getSpawnArgsForWindows', () => { } }) + it('preserves VS Code WSL remote arguments with spaces through .cmd launchers', () => { + withPlatform('win32', () => { + const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\code.cmd', [ + '--remote', + 'wsl+Ubuntu Preview', + '/home/Ada Lovelace/project' + ]) + + expect(spawnCmd).toBe(getCmdExePath()) + expect(spawnArgs).toEqual([ + '/d', + '/c', + 'C:\\tools\\code.cmd', + '--remote', + 'wsl+Ubuntu Preview', + '/home/Ada Lovelace/project' + ]) + }) + }) + it('passes .exe through unchanged on win32', () => { withPlatform('win32', () => { const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\codex.exe', ['login']) diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 2d34a4c9fd0..cc03c40a904 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -389,6 +389,7 @@ function registerRuntimeWindowLifecycle( }) as Promise, closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }), sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }), + resumeSleepingAgents: (worktreeId) => send('ui:resumeSleepingAgents', { worktreeId }), terminalFitOverrideChanged: (ptyId, mode, cols, rows) => send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows }), terminalDriverChanged: (ptyId, driver) => diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 146aa7f80dc..b392ea7a3d7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2751,6 +2751,7 @@ export type PreloadApi = { callback: (data: { tabId: string; paneRuntimeId?: number }) => void ) => () => void onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void + onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void onSystemResumed: (callback: () => void) => () => void readClipboardText: (options?: ReadClipboardTextOptions) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 39756074c74..d7385f18fc2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3478,6 +3478,12 @@ const api = { ipcRenderer.on('ui:sleepWorktree', listener) return () => ipcRenderer.removeListener('ui:sleepWorktree', listener) }, + onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => + callback(data) + ipcRenderer.on('ui:resumeSleepingAgents', listener) + return () => ipcRenderer.removeListener('ui:resumeSleepingAgents', listener) + }, onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') => callback(direction) diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 0847c2ae8e7..07b84db43f9 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -24,7 +24,11 @@ type GitBufferSpyTarget = { } type GitSpyTarget = { - git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> + git( + args: string[], + cwd: string, + opts?: { signal?: AbortSignal } + ): Promise<{ stdout: string; stderr: string }> } function deferredRelayBuffer(content: string): { @@ -1882,6 +1886,24 @@ describe('GitHandler', () => { expect(result[0].isMainWorktree).toBe(true) }) + it('passes request cancellation to the git worktree list subprocess', async () => { + const controller = new AbortController() + const gitSpy = vi + .spyOn(handler as unknown as GitSpyTarget, 'git') + .mockRejectedValue(new Error('aborted')) + + const result = await dispatcher.callRequest( + 'git.listWorktrees', + { repoPath: tmpDir }, + { isStale: () => false, signal: controller.signal } + ) + + expect(result).toEqual([]) + expect(gitSpy).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], tmpDir, { + signal: controller.signal + }) + }) + it.skipIf(process.platform === 'win32')( 'normalizes the main worktree path for a separate-git-dir repo', async () => { diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 746dbffd803..2d4bb2bb09b 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -216,7 +216,7 @@ export class GitHandler { this.dispatcher.onRequest('git.rebaseFromBase', (p) => this.rebaseFromBase(p)) this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p)) this.dispatcher.onRequest('git.commitDiff', (p) => this.commitDiff(p)) - this.dispatcher.onRequest('git.listWorktrees', (p) => this.listWorktrees(p)) + this.dispatcher.onRequest('git.listWorktrees', (p, context) => this.listWorktrees(p, context)) this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p)) this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p)) this.dispatcher.onRequest('git.worktreeIsClean', (p) => this.worktreeIsClean(p)) @@ -1263,10 +1263,12 @@ export class GitHandler { return normalized } - private async listWorktrees(params: Record) { + private async listWorktrees(params: Record, context?: RequestContext) { const repoPath = params.repoPath as string try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath) + const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, { + signal: context?.signal + }) return this.normalizeMainWorktreePath( repoPath, parseWorktreeList(stdout, { nulDelimited: true }) @@ -1280,7 +1282,9 @@ export class GitHandler { // Why: `-z` keeps newline-containing SSH worktree paths intact, but older // Git rejects it. Fall back to the original line-block parser there. try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath) + const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { + signal: context?.signal + }) return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout)) } catch { return [] diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts index 8833ddcb97f..80bc2ba490a 100644 --- a/src/relay/preflight-handler.test.ts +++ b/src/relay/preflight-handler.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' const { execFileAsyncMock } = vi.hoisted(() => ({ execFileAsyncMock: vi.fn() @@ -38,7 +39,8 @@ function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] { return [ mode, [ - `if resolved=$(command -v ${command} 2>/dev/null); then`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: command }), + 'if [ -n "$resolved" ]; then', 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', 'fi' ].join('\n') @@ -77,14 +79,14 @@ describe('buildCommandLookupSpec', () => { it('falls back to sh for POSIX probes without a configured shell', () => { expect(buildCommandLookupSpec('codex', 'linux', {}, null)).toEqual({ file: '/bin/sh', - args: lookupArgs("'codex'") + args: lookupArgs('codex') }) }) it('uses the configured remote shell for POSIX probes', () => { expect(buildCommandLookupSpec('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual({ file: '/bin/zsh', - args: lookupArgs("'codex'", '-ilc') + args: lookupArgs('codex', '-ilc') }) }) @@ -93,7 +95,7 @@ describe('buildCommandLookupSpec', () => { buildCommandLookupSpec("agent'cli", 'linux', { SHELL: '/bin/bash' }, '/bin/bash') ).toEqual({ file: '/bin/bash', - args: lookupArgs("'agent'\\''cli'", '-ilc') + args: lookupArgs("agent'cli", '-ilc') }) }) }) @@ -101,8 +103,8 @@ describe('buildCommandLookupSpec', () => { describe('buildCommandLookupSpecs', () => { it('falls back to inherited PATH after a trusted configured POSIX shell', () => { expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/bin/zsh' }, '/bin/zsh')).toEqual([ - { file: '/bin/zsh', args: lookupArgs("'codex'", '-ilc') }, - { file: '/bin/sh', args: lookupArgs("'codex'") } + { file: '/bin/zsh', args: lookupArgs('codex', '-ilc') }, + { file: '/bin/sh', args: lookupArgs('codex') } ]) }) @@ -115,15 +117,15 @@ describe('buildCommandLookupSpecs', () => { '/opt/homebrew/bin/zsh' ) ).toEqual([ - { file: '/opt/homebrew/bin/zsh', args: lookupArgs("'codex'", '-ilc') }, - { file: '/bin/sh', args: lookupArgs("'codex'") } + { file: '/opt/homebrew/bin/zsh', args: lookupArgs('codex', '-ilc') }, + { file: '/bin/sh', args: lookupArgs('codex') } ]) }) it('allows conservative system shell paths when account lookup is unavailable', () => { expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/bash' }, null)[0]).toEqual({ file: '/usr/bin/bash', - args: lookupArgs("'codex'", '-ilc') + args: lookupArgs('codex', '-ilc') }) }) @@ -136,14 +138,14 @@ describe('buildCommandLookupSpecs', () => { it('ignores untrusted temp shell paths even when the basename is supported', () => { expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/tmp/zsh' }, '/bin/bash')).toEqual([ - { file: '/bin/sh', args: lookupArgs("'codex'") } + { file: '/bin/sh', args: lookupArgs('codex') } ]) }) it('ignores untrusted home-bin shell paths even when the basename is supported', () => { expect( buildCommandLookupSpecs('codex', 'linux', { SHELL: '/home/test/bin/bash' }, '/bin/bash') - ).toEqual([{ file: '/bin/sh', args: lookupArgs("'codex'") }]) + ).toEqual([{ file: '/bin/sh', args: lookupArgs('codex') }]) }) }) @@ -160,17 +162,12 @@ describe('isCommandOnPathForRelay', () => { accountLoginShell: '/bin/zsh' }) ).resolves.toBe(true) - expect(execFileAsyncMock).toHaveBeenNthCalledWith( - 1, - '/bin/zsh', - lookupArgs("'codex'", '-ilc'), - { - encoding: 'utf-8', - env: expect.objectContaining({ SHELL: '/bin/zsh' }), - timeout: 5000 - } - ) - expect(execFileAsyncMock).toHaveBeenNthCalledWith(2, '/bin/sh', lookupArgs("'codex'"), { + expect(execFileAsyncMock).toHaveBeenNthCalledWith(1, '/bin/zsh', lookupArgs('codex', '-ilc'), { + encoding: 'utf-8', + env: expect.objectContaining({ SHELL: '/bin/zsh' }), + timeout: 5000 + }) + expect(execFileAsyncMock).toHaveBeenNthCalledWith(2, '/bin/sh', lookupArgs('codex'), { encoding: 'utf-8', env: expect.objectContaining({ SHELL: '/bin/zsh' }), timeout: 5000 @@ -203,7 +200,7 @@ describe('isCommandOnPathForRelay', () => { }) ).resolves.toBe(true) expect(execFileAsyncMock).toHaveBeenCalledTimes(1) - expect(execFileAsyncMock).toHaveBeenCalledWith('/bin/sh', lookupArgs("'codex'"), { + expect(execFileAsyncMock).toHaveBeenCalledWith('/bin/sh', lookupArgs('codex'), { encoding: 'utf-8', env: expect.objectContaining({ SHELL: '/tmp/zsh' }), timeout: 5000 diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index 27e7713343c..101c796137c 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -7,6 +7,7 @@ import { buildRelayCommandEnv } from './relay-command-env' import { isPwshAvailable } from '../main/pwsh' import { isWslAvailable, listWslDistros } from '../main/wsl' import { isGitBashAvailable } from '../main/git-bash' +import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' const execFileAsync = promisify(execFile) @@ -219,9 +220,10 @@ function buildPosixCommandLookupSpec(command: string, shell: string): CommandLoo } function buildShCommandLookupScript(command: string): string { - const quotedCommand = shellQuote(command) + // Why: login shells may define aliases or functions that mask the PATH executable. return [ - `if resolved=$(command -v ${quotedCommand} 2>/dev/null); then`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: command }), + 'if [ -n "$resolved" ]; then', `printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`, 'fi' ].join('\n') diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 44671b28a50..a85d7fb6c43 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -529,27 +529,32 @@ /* ── Editor-style scrollbar (matches Monaco) ────────── */ -.scrollbar-editor { +.scrollbar-editor, +.xterm .xterm-viewport { scrollbar-width: thin; scrollbar-color: rgba(121, 121, 121, 0.4) transparent; } -.scrollbar-editor::-webkit-scrollbar { +.scrollbar-editor::-webkit-scrollbar, +.xterm .xterm-viewport::-webkit-scrollbar { width: 14px; } -.scrollbar-editor::-webkit-scrollbar-track { +.scrollbar-editor::-webkit-scrollbar-track, +.xterm .xterm-viewport::-webkit-scrollbar-track { background: transparent; } -.scrollbar-editor::-webkit-scrollbar-thumb { +.scrollbar-editor::-webkit-scrollbar-thumb, +.xterm .xterm-viewport::-webkit-scrollbar-thumb { background: rgba(121, 121, 121, 0.4); border: 3px solid transparent; border-radius: 7px; background-clip: padding-box; } -.scrollbar-editor::-webkit-scrollbar-thumb:hover { +.scrollbar-editor::-webkit-scrollbar-thumb:hover, +.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { background: rgba(121, 121, 121, 0.7); border: 3px solid transparent; background-clip: padding-box; diff --git a/src/renderer/src/assets/terminal-scrollbar-style.test.ts b/src/renderer/src/assets/terminal-scrollbar-style.test.ts new file mode 100644 index 00000000000..642678e264c --- /dev/null +++ b/src/renderer/src/assets/terminal-scrollbar-style.test.ts @@ -0,0 +1,17 @@ +import fs from 'node:fs' +import { describe, expect, it } from 'vitest' + +const terminalCss = fs.readFileSync(new URL('./terminal.css', import.meta.url), 'utf8') +const mainCss = fs.readFileSync(new URL('./main.css', import.meta.url), 'utf8') + +describe('terminal scrollbar styling', () => { + it('reuses the canonical editor scrollbar with a transparent gutter', () => { + expect(mainCss).toMatch( + /\.scrollbar-editor,\s*\.xterm \.xterm-viewport\s*{[^}]*scrollbar-color:\s*rgba\(121, 121, 121, 0\.4\) transparent/s + ) + expect(mainCss).toMatch( + /\.scrollbar-editor::-webkit-scrollbar-track,\s*\.xterm \.xterm-viewport::-webkit-scrollbar-track\s*{[^}]*background:\s*transparent/s + ) + expect(terminalCss).not.toContain('--xterm-scrollbar-thumb') + }) +}) diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index d9763b3e341..31c2d142e43 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -36,8 +36,8 @@ the install terminals inside centered setup modals), that black layer anti-aliases into a faint 1px line above/below the themed .xterm. Match xterm's own selector so this also covers terminals mounted outside the pane - manager, and let the themed .xterm background show through; our scrollbar is - already custom-styled below. */ + manager, and let the themed .xterm background show through; the viewport + reuses the canonical editor scrollbar rules in main.css. */ .xterm:not(.allow-transparency) .xterm-viewport { background-color: transparent; } diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts index 8c50887a090..b0a3816688a 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.test.ts @@ -250,6 +250,43 @@ describe('attachRestoredTabConflictScan', () => { } }) + it('caps concurrent verification reads and drains the queue without dropping tabs', async () => { + // Why: a restored session with many dirty tabs must not fire one disk + // read per tab at once — on SSH/remote runtimes that competes with + // connection recovery. The cap is 3; the rest queue and all complete. + const pendingReads: ((value: { content: string; isBinary: boolean }) => void)[] = [] + mocks.readRuntimeFileContent.mockImplementation( + () => + new Promise<{ content: string; isBinary: boolean }>((resolve) => { + pendingReads.push(resolve) + }) + ) + const store = createEditorStore() + for (let i = 0; i < 6; i++) { + openRestoredDirtyTab(store, `/repo/file-${i}.ts`, 'original baseline') + } + + const detach = attachRestoredTabConflictScan(store) + try { + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(3) + + pendingReads.shift()!({ content: 'original baseline', isBinary: false }) + await vi.advanceTimersByTimeAsync(10) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(4) + + while (pendingReads.length > 0) { + pendingReads.shift()!({ content: 'original baseline', isBinary: false }) + await vi.advanceTimersByTimeAsync(10) + } + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(6) + for (const file of store.getState().openFiles) { + expect(file.pendingDiskBaselineVerification).toBeUndefined() + } + } finally { + detach() + } + }) + it('does not mark a tab that was saved while the read was in flight', async () => { let resolveRead: (value: { content: string; isBinary: boolean }) => void = () => {} mocks.readRuntimeFileContent.mockReturnValue( @@ -270,4 +307,55 @@ describe('attachRestoredTabConflictScan', () => { detach() } }) + + it('verifies a queued tab against its live baseline, not the stale queued snapshot', async () => { + // Why: ids are paths, so a tab re-baselined (saved / reopened) while it + // waits behind the concurrency cap must be compared against its current + // baseline. Queuing the OpenFile snapshot would mark the live tab using the + // old lastKnownDiskSignature. + const pending: { + filePath: string + resolve: (value: { content: string; isBinary: boolean }) => void + }[] = [] + mocks.readRuntimeFileContent.mockImplementation( + ({ filePath }: { filePath: string }) => + new Promise<{ content: string; isBinary: boolean }>((resolve) => { + pending.push({ filePath, resolve }) + }) + ) + const store = createEditorStore() + // Three reads fill the concurrency cap; the fourth waits in the queue. + for (let i = 0; i < 4; i++) { + openRestoredDirtyTab(store, `/repo/file-${i}.ts`, 'stale baseline') + } + + const detach = attachRestoredTabConflictScan(store) + try { + expect(mocks.readRuntimeFileContent).toHaveBeenCalledTimes(3) + + // The queued tab is re-baselined to match current disk while it waits. + store + .getState() + .setLastKnownDiskSignature('/repo/file-3.ts', getDiskBaselineSignature('current disk')) + + // Drain the in-flight reads; each frees a slot so the queued tab dispatches. + for (const filePath of ['/repo/file-0.ts', '/repo/file-1.ts', '/repo/file-2.ts']) { + pending + .find((p) => p.filePath === filePath)! + .resolve({ content: 'stale baseline', isBinary: false }) + await vi.advanceTimersByTimeAsync(10) + } + + pending + .find((p) => p.filePath === '/repo/file-3.ts')! + .resolve({ content: 'current disk', isBinary: false }) + await vi.advanceTimersByTimeAsync(10) + + const tab = store.getState().openFiles.find((f) => f.id === '/repo/file-3.ts') + expect(tab?.externalMutation).toBeUndefined() + expect(tab?.pendingDiskBaselineVerification).toBeUndefined() + } finally { + detach() + } + }) }) diff --git a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts index bc4029321f4..26d7e4db0ca 100644 --- a/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts +++ b/src/renderer/src/components/editor/editor-restored-tab-conflict-scan.ts @@ -29,13 +29,23 @@ type AppStoreApi = Pick, 'getState' | 'subscribe'> const VERIFY_RETRY_MS = 2_000 const VERIFY_SLOW_RETRY_MS = 15_000 const VERIFY_FAST_ATTEMPTS = 30 +// Why: a session restored with many dirty tabs would otherwise fire one disk +// read per tab simultaneously — on SSH/remote runtimes that's N concurrent +// 15s-timeout RPCs competing with connection recovery at startup. Verifies +// beyond the cap queue and start as slots free up; none are dropped. +const MAX_CONCURRENT_VERIFY_READS = 3 export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { - // Why: dedupes in-flight verifications; the store's pending flag is the - // durable "needs verification" signal. + // Why: dedupes queued + in-flight verifications; the store's pending flag is + // the durable "needs verification" signal. const inFlightFileIds = new Set() const attemptsByFileId = new Map() const retryTimers = new Set>() + // Why: queue ids, not OpenFile snapshots. Ids are paths, so a snapshot can go + // stale (close/reopen or save at the same path) while it waits for a slot; + // the live file is re-read at dispatch instead. + const verifyQueue: string[] = [] + let activeVerifyReads = 0 let disposed = false // Why: distinguishes "file was deleted while the app was closed" (a @@ -144,6 +154,36 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { } } + const pumpVerifyQueue = (): void => { + while (!disposed && activeVerifyReads < MAX_CONCURRENT_VERIFY_READS && verifyQueue.length > 0) { + const fileId = verifyQueue.shift()! + // Why: re-read the live file when the slot opens. A queued id may now + // resolve to a reopened or saved same-path tab, so re-validate it against + // the current state before spending a disk read; skipping frees the dedupe + // marker so a later scan can re-queue it if it needs verification again. + const file = store.getState().openFiles.find((f) => f.id === fileId) + if ( + !file || + !file.pendingDiskBaselineVerification || + !file.isDirty || + !file.lastKnownDiskSignature || + file.externalMutation === 'changed' || + !canAutoSaveOpenFile(file) + ) { + inFlightFileIds.delete(fileId) + continue + } + activeVerifyReads += 1 + const onSettled = (): void => { + activeVerifyReads -= 1 + pumpVerifyQueue() + } + // Why: verify() never rejects by design, but a rejection here must still + // release the slot or the queue stalls permanently. + void verify(file).then(onSettled, onSettled) + } + } + const scan = (): void => { if (disposed) { return @@ -160,8 +200,9 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { continue } inFlightFileIds.add(file.id) - void verify(file) + verifyQueue.push(file.id) } + pumpVerifyQueue() } let previousOpenFiles = store.getState().openFiles @@ -182,5 +223,6 @@ export function attachRestoredTabConflictScan(store: AppStoreApi): () => void { clearTimeout(timer) } retryTimers.clear() + verifyQueue.length = 0 } } diff --git a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts index 9f19c4f5813..bff4e78bbdc 100644 --- a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts +++ b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { createCoalescedPollRunner } from './coalesced-poll-runner' +import { type CoalescedPollRunner, createCoalescedPollRunner } from './coalesced-poll-runner' function deferred(): { promise: Promise @@ -88,6 +88,189 @@ describe('createCoalescedPollRunner', () => { vi.useRealTimers() }) + describe('slowTaskBackoff', () => { + const BACKOFF = { idleMultiplier: 5, changeSignalMultiplier: 1, maxIntervalMs: 300_000 } + + function makeSlowRunner(): { + runner: CoalescedPollRunner + task: ReturnType + calls: ReturnType[] + } { + const calls: ReturnType[] = [] + const task = vi.fn(() => { + const call = deferred() + calls.push(call) + return call.promise + }) + const runner = createCoalescedPollRunner(task, { + minIntervalMs: 3000, + slowTaskBackoff: BACKOFF + }) + return { runner, task, calls } + } + + async function completeRunOfDuration( + runner: CoalescedPollRunner, + calls: ReturnType[], + durationMs: number + ): Promise { + runner.run() + await vi.advanceTimersByTimeAsync(durationMs) + calls.at(-1)?.resolve() + await flushMicrotasks() + } + + it('scales the tick idle gap with the previous run duration', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + // A 35s run (large-monorepo git status) with poll ticks arriving during it. + runner.run() + runner.run() + await vi.advanceTimersByTimeAsync(35_000) + calls[0]?.resolve() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(1) + + // Ticks during the backoff window must not start a run early. + await vi.advanceTimersByTimeAsync(3000) + runner.run() + await vi.advanceTimersByTimeAsync(171_998) + runner.run() + expect(task).toHaveBeenCalledTimes(1) + + // 5x the 35s duration → next tick-driven run only after 175s idle. + await vi.advanceTimersByTimeAsync(2) + expect(task).toHaveBeenCalledTimes(2) + + calls[1]?.resolve() + await flushMicrotasks() + vi.useRealTimers() + }) + + it('caps the backoff at maxIntervalMs', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + await completeRunOfDuration(runner, calls, 120_000) + expect(task).toHaveBeenCalledTimes(1) + + // 5 x 120s = 600s uncapped; the cap holds the gap to 300s. + runner.run() + await vi.advanceTimersByTimeAsync(299_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + calls[1]?.resolve() + await flushMicrotasks() + vi.useRealTimers() + }) + + it('keeps minIntervalMs as the gap for fast tasks', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + await completeRunOfDuration(runner, calls, 100) + expect(task).toHaveBeenCalledTimes(1) + + // max(3000, 5 x 100ms) = 3000 → normal-repo cadence is unchanged. + runner.run() + await vi.advanceTimersByTimeAsync(2999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + calls[1]?.resolve() + await flushMicrotasks() + vi.useRealTimers() + }) + + it('lets change signals run after the short backoff instead of the tick backoff', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + await completeRunOfDuration(runner, calls, 35_000) + expect(task).toHaveBeenCalledTimes(1) + + // A change signal waits 1 x 35s, not 5 x 35s. + runner.run({ changeSignal: true }) + await vi.advanceTimersByTimeAsync(34_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + calls[1]?.resolve() + await flushMicrotasks() + vi.useRealTimers() + }) + + it('pulls an already-scheduled tick run earlier when a change signal arrives', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + await completeRunOfDuration(runner, calls, 35_000) + runner.run() // tick scheduled for +175s + await vi.advanceTimersByTimeAsync(10_000) + + runner.run({ changeSignal: true }) // reschedules for end + 35s + await vi.advanceTimersByTimeAsync(24_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + // The superseded tick timer must not fire an extra run later. + calls[1]?.resolve() + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(600_000) + expect(task).toHaveBeenCalledTimes(2) + vi.useRealTimers() + }) + + it('does not let a later tick delay a scheduled change-signal run', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + await completeRunOfDuration(runner, calls, 35_000) + runner.run({ changeSignal: true }) // scheduled for +35s + await vi.advanceTimersByTimeAsync(10_000) + runner.run() // tick must not push the schedule to +175s + + await vi.advanceTimersByTimeAsync(24_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + calls[1]?.resolve() + await flushMicrotasks() + vi.useRealTimers() + }) + + it('keeps change-signal pacing for a signal that arrived mid-run', async () => { + vi.useFakeTimers() + const { runner, task, calls } = makeSlowRunner() + + runner.run() + await vi.advanceTimersByTimeAsync(20_000) + runner.run({ changeSignal: true }) // during flight + await vi.advanceTimersByTimeAsync(15_000) + runner.run() // a later tick must not downgrade the pending signal + calls[0]?.resolve() + await flushMicrotasks() + expect(task).toHaveBeenCalledTimes(1) + + // Trailing rerun waits 1 x 35s (signal), not 5 x 35s (tick). + await vi.advanceTimersByTimeAsync(34_999) + expect(task).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(task).toHaveBeenCalledTimes(2) + + calls[1]?.resolve() + await flushMicrotasks() + vi.useRealTimers() + }) + }) + it('cancels scheduled deferred run on dispose', async () => { vi.useFakeTimers() const calls: ReturnType[] = [] diff --git a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts index b18eb634fe6..1040ed279e2 100644 --- a/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts +++ b/src/renderer/src/components/right-sidebar/coalesced-poll-runner.ts @@ -1,42 +1,95 @@ +export type CoalescedPollRunnerTrigger = { + // True when the caller has evidence something changed (file-watch event, + // repo metadata push signal, finished terminal command) rather than a bare + // timer tick. Change signals wait out a shorter slow-task backoff. + changeSignal?: boolean +} + export type CoalescedPollRunner = { - run: () => void + run: (trigger?: CoalescedPollRunnerTrigger) => void dispose: () => void } +export type SlowTaskBackoffOptions = { + // Idle gap before an evidence-free timer tick may re-run, as a multiple of + // the previous run's duration. + idleMultiplier: number + // Idle gap before a change-signal run may re-run. Kept low so slow repos + // still refresh promptly after real changes, while sustained churn can + // never keep the task running back-to-back. + changeSignalMultiplier: number + maxIntervalMs: number +} + +// Why: on large monorepos a poll task (git status) can take tens of seconds, +// so a fixed idle gap makes polling effectively continuous (#7983). Scale the +// gap with the previous run's duration — aggressively for evidence-free timer +// ticks, mildly for change signals — capped so results never go minutes-stale. export function createCoalescedPollRunner( task: () => Promise, - options?: { minIntervalMs?: number } + options?: { + minIntervalMs?: number + slowTaskBackoff?: SlowTaskBackoffOptions + } ): CoalescedPollRunner { let disposed = false let inFlight = false - let rerun = false + let rerunTrigger: CoalescedPollRunnerTrigger | null = null let lastRunEndedAt = -Infinity + let lastRunDurationMs = 0 let timeoutId: ReturnType | null = null + let timeoutFiresAt = Infinity + const minIntervalMs = options?.minIntervalMs ?? 0 - const run = (): void => { + const requiredIdleMsFor = (trigger?: CoalescedPollRunnerTrigger): number => { + const backoff = options?.slowTaskBackoff + if (!backoff) { + return minIntervalMs + } + const multiplier = trigger?.changeSignal + ? backoff.changeSignalMultiplier + : backoff.idleMultiplier + return Math.max(minIntervalMs, Math.min(lastRunDurationMs * multiplier, backoff.maxIntervalMs)) + } + + const clearScheduledRun = (): void => { + if (timeoutId !== null) { + clearTimeout(timeoutId) + timeoutId = null + timeoutFiresAt = Infinity + } + } + + const run = (trigger?: CoalescedPollRunnerTrigger): void => { if (disposed) { return } if (inFlight) { - rerun = true - return - } - if (timeoutId !== null) { + // Why: keep the strongest pending trigger so a change signal arriving + // mid-run is not downgraded to tick pacing by a later timer tick. + rerunTrigger = { changeSignal: rerunTrigger?.changeSignal || trigger?.changeSignal } return } const now = Date.now() - const timeSinceLastEnd = now - lastRunEndedAt - const minInterval = options?.minIntervalMs ?? 0 - if (timeSinceLastEnd < minInterval) { - const delay = minInterval - timeSinceLastEnd + const allowedAt = lastRunEndedAt + requiredIdleMsFor(trigger) + if (now < allowedAt) { + // Why: a change signal may pull an already-scheduled evidence-free run + // earlier; a weaker trigger must never push a scheduled run later. + if (allowedAt >= timeoutFiresAt) { + return + } + clearScheduledRun() + timeoutFiresAt = allowedAt timeoutId = setTimeout(() => { timeoutId = null - run() - }, delay) + timeoutFiresAt = Infinity + run(trigger) + }, allowedAt - now) return } + clearScheduledRun() inFlight = true void task() .catch(() => { @@ -46,10 +99,11 @@ export function createCoalescedPollRunner( .finally(() => { inFlight = false lastRunEndedAt = Date.now() - const shouldRerun = rerun && !disposed - rerun = false - if (shouldRerun) { - run() + lastRunDurationMs = lastRunEndedAt - now + const trailingTrigger = disposed ? null : rerunTrigger + rerunTrigger = null + if (trailingTrigger) { + run(trailingTrigger) } }) } @@ -58,11 +112,8 @@ export function createCoalescedPollRunner( run, dispose: () => { disposed = true - rerun = false - if (timeoutId !== null) { - clearTimeout(timeoutId) - timeoutId = null - } + rerunTrigger = null + clearScheduledRun() } } } diff --git a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts index 895b69012a1..b2726dd4380 100644 --- a/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts +++ b/src/renderer/src/components/right-sidebar/useGitStatusPolling.ts @@ -7,7 +7,7 @@ import { getConnectionId } from '@/lib/connection-context' import { getRuntimeGitConflictOperation } from '@/runtime/runtime-git-client' import { refreshGitStatusForWorktree } from './git-status-refresh' import { type CoalescedPollRunner, createCoalescedPollRunner } from './coalesced-poll-runner' -import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' +import { installWindowVisibilityInterval, isWindowVisible } from '@/lib/window-visibility-interval' import { hasInteractiveActiveGitStatusConsumer, shouldPollActiveGitStatus @@ -22,6 +22,16 @@ const INTERACTIVE_STATUS_POLL_INTERVAL_MS = MIN_STATUS_REFRESH_INTERVAL_MS // metadata watch, shell command completion) cover branch switches; the // terminal-only poll is a last-resort backstop for shells without either. const TERMINAL_ONLY_STATUS_POLL_INTERVAL_MS = 30_000 +// Why: on a large monorepo one status refresh can take tens of seconds, so the +// fixed 3s gap kept a git process running almost continuously while the +// workspace was idle (#7983). Evidence-free ticks wait 5x the previous poll +// duration (~1/6 duty cycle); change signals wait only 1x so real changes in a +// slow repo still surface promptly; the cap bounds worst-case staleness. +const SLOW_GIT_POLL_BACKOFF = { + idleMultiplier: 5, + changeSignalMultiplier: 1, + maxIntervalMs: 5 * 60_000 +} export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const enabled = options.enabled ?? true @@ -100,6 +110,12 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { }, [allWorktrees, conflictOperationByWorktree, activeWorktreeId, repoMap]) const runFetchStatus = useCallback(async () => { + // Why: a backoff-deferred run can fire long after the window hides; skip + // the scan instead of running tens of seconds of git work nobody can see. + // The becoming-visible run catches up via the change-signal lane. + if (!isWindowVisible()) { + return + } if (!shouldPollActiveWorktreeGitStatus || !activeWorktreeId || !worktreePath) { return } @@ -144,7 +160,8 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { const statusPollRunnerRef = useRef(null) useEffect(() => { const runner = createCoalescedPollRunner(() => runFetchStatusRef.current(), { - minIntervalMs: MIN_STATUS_REFRESH_INTERVAL_MS + minIntervalMs: MIN_STATUS_REFRESH_INTERVAL_MS, + slowTaskBackoff: SLOW_GIT_POLL_BACKOFF }) statusPollRunnerRef.current = runner return () => { @@ -157,24 +174,33 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { statusPollRunnerRef.current?.run() }, []) + // Why: file-watch and push signals carry evidence something changed, so they + // take the runner's short-backoff lane instead of evidence-free tick pacing. + const fetchStatusOnChangeSignal = useCallback(() => { + statusPollRunnerRef.current?.run({ changeSignal: true }) + }, []) + useEffect(() => { if (!activeStatusPollScope) { return } // Why: this root-level poll should pause while hidden, but visible // unfocused windows still need fresh status for second-display workflows. + // Change signals are dropped while hidden, so the becoming-visible run + // rides the short-backoff lane to catch up on anything that was missed. return installWindowVisibilityInterval({ run: fetchStatus, + runOnVisible: fetchStatusOnChangeSignal, intervalMs: activeStatusPollIntervalMs }) - }, [activeStatusPollIntervalMs, activeStatusPollScope, fetchStatus]) + }, [activeStatusPollIntervalMs, activeStatusPollScope, fetchStatus, fetchStatusOnChangeSignal]) useGitStatusFileWatchRefresh({ activeConnectionId, activeRepoSupportsGit, activeWorktreeId, enabled, - fetchStatus, + fetchStatus: fetchStatusOnChangeSignal, gitStatusHugeByWorktree, isConnectionReady, openFiles, @@ -188,7 +214,7 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { activeRepoId, activeWorktreeId, enabled: shouldPollActiveWorktreeGitStatus, - fetchStatus + fetchStatus: fetchStatusOnChangeSignal }) // Why: poll conflict operation for non-active worktrees that have a stale @@ -203,6 +229,12 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { } const pollStale = async (): Promise => { + // Why: a backoff-deferred run can fire long after the window hides; skip + // the probe instead of running SSH/RPC work nobody can see. The + // becoming-visible run catches up via the change-signal lane. + if (!isWindowVisible()) { + return + } for (const { id, path } of staleConflictWorktrees) { try { const connectionId = getConnectionId(id) ?? undefined @@ -225,13 +257,18 @@ export function useGitStatusPolling(options: { enabled?: boolean } = {}): void { } // Why: remote conflict probes can exceed the 3s interval. Keep one poll in - // flight and coalesce skipped ticks into one trailing pass so stale badges - // catch up without stacking SSH/RPC work. - const pollRunner = createCoalescedPollRunner(pollStale) + // flight, coalesce skipped ticks into one trailing pass, and back off after + // slow probe chains so stale badges catch up without stacking SSH/RPC work. + const pollRunner = createCoalescedPollRunner(pollStale, { + slowTaskBackoff: SLOW_GIT_POLL_BACKOFF + }) // Why: conflict badges are visible sidebar state; keep them fresh in // visible unfocused windows, but do not poll disconnected hidden windows. + // The becoming-visible run rides the short-backoff lane so badges catch + // up promptly after a hidden stretch. const stopVisiblePoll = installWindowVisibilityInterval({ run: () => pollRunner.run(), + runOnVisible: () => pollRunner.run({ changeSignal: true }), intervalMs: MIN_STATUS_REFRESH_INTERVAL_MS }) return () => { diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index 3e247f88db5..d694b39ffc0 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -70,4 +70,39 @@ describe('AccountsPane', () => { ) expect(markup).not.toContain('This device') }) + + it('scopes account copy to the active remote server and disables local sign-in actions', () => { + // Note: static SSR markup reads the store's initial state (zustand v5), so + // this exercises the fallback server label; the named-server path is + // covered by live validation against a paired server. + const markup = renderPane( + { + ...getDefaultSettings('/tmp'), + activeRuntimeEnvironmentId: 'env-1' + }, + { wslSupportedPlatform: true } + ) + + expect(markup).toContain( + 'Showing accounts managed by the remote server. Add or re-authenticate accounts on that server.' + ) + // The WSL account-location toggle is a local concern; a remote owner hides it. + expect(markup).not.toContain('aria-label="Account location"') + const addAccountIndex = markup.indexOf('Add Account') + expect(addAccountIndex).toBeGreaterThan(0) + expect(markup.slice(markup.lastIndexOf(' { + const markup = renderPane(getDefaultSettings('/tmp')) + + expect(markup).toContain('Showing accounts for this device. New accounts are added there.') + const addAccountIndex = markup.indexOf('Add Account') + expect(addAccountIndex).toBeGreaterThan(0) + expect( + markup.slice(markup.lastIndexOf(' s.rateLimits.minimax) const recordFeatureInteraction = useAppStore((s) => s.recordFeatureInteraction) const fetchSettings = useAppStore((s) => s.fetchSettings) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const recordedOpenCodeSettingEditsRef = useRef>(new Set()) const [miniMaxCookieDraft, setMiniMaxCookieDraft] = useState('') const [miniMaxConfigured, setMiniMaxConfigured] = useState(false) const [miniMaxCredentialBusy, setMiniMaxCredentialBusy] = useState(false) - const accountRuntime = getSelectedAccountRuntime( + const localAccountRuntime = getSelectedAccountRuntime( settings, wslSupportedPlatform, wslAvailable, wslDistros, wslCapabilitiesLoading ) + // Why: with a Remote Orca Server active the server owns provider accounts + // (see #7973); every list/select/remove below must scope to it, not host/WSL. + const isRemoteAccountScope = hasRemoteProviderAccountOwner(settings) + const activeRuntimeEnvironmentId = settings.activeRuntimeEnvironmentId?.trim() || null + const remoteServerLabel = isRemoteAccountScope + ? (runtimeEnvironments.find((environment) => environment.id === activeRuntimeEnvironmentId) + ?.name ?? + translate('auto.components.settings.AccountsPane.remoteServerFallback', 'the remote server')) + : null + const accountRuntime: LocalAccountRuntime = isRemoteAccountScope + ? { runtime: 'host', label: remoteServerLabel ?? '' } + : localAccountRuntime // Why: host runtime labels are standalone UI labels; interpolated prose needs sentence casing. const accountRuntimeSentenceLabel = - accountRuntime.runtime === 'host' && !navigator.userAgent.includes('Windows') + !isRemoteAccountScope && + accountRuntime.runtime === 'host' && + !navigator.userAgent.includes('Windows') ? `${accountRuntime.label.charAt(0).toLocaleLowerCase()}${accountRuntime.label.slice(1)}` : accountRuntime.label + const localAccountRuntimeSentenceLabel = + localAccountRuntime.runtime === 'host' && !navigator.userAgent.includes('Windows') + ? `${localAccountRuntime.label.charAt(0).toLocaleLowerCase()}${localAccountRuntime.label.slice(1)}` + : localAccountRuntime.label const [codexAccounts, setCodexAccounts] = useState({ accounts: [], @@ -384,15 +413,18 @@ export function AccountsPane({ ) const activeCodexAccountId = getActiveCodexAccountIdForRuntime(codexAccounts, accountRuntime) const activeClaudeAccountId = getActiveClaudeAccountIdForRuntime(claudeAccounts, accountRuntime) - const activeCodexAuthWarning = codexAccountsLoaded - ? getCodexAccountAuthWarning({ - limits: codexRateLimits, - target: codexRateLimitTarget, - runtime: accountRuntime, - activeAccountId: activeCodexAccountId, - accountId: activeCodexAccountId - }) - : null + // Why: the auth warning is derived from the desktop's own rate-limit poll; + // with a remote owner it would misattribute local auth state to the server. + const activeCodexAuthWarning = + codexAccountsLoaded && !isRemoteAccountScope + ? getCodexAccountAuthWarning({ + limits: codexRateLimits, + target: codexRateLimitTarget, + runtime: accountRuntime, + activeAccountId: activeCodexAccountId, + accountId: activeCodexAccountId + }) + : null const systemCodexNeedsReauthentication = activeCodexAccountId === null && Boolean(activeCodexAuthWarning) const accountRuntimeUnavailable = @@ -477,21 +509,22 @@ export function AccountsPane({ }, []) useEffect(() => { - let stale = false - - const loadCodexAccounts = async (): Promise => { - try { - const nextCodex = await window.api.codexAccounts.list() - if (!stale) { - setCodexAccounts(nextCodex) + // Why: remote snapshots stream usage refreshes after the synchronous ready + // message, so the watcher stays open for the pane's lifetime; the local + // path resolves once and the close() is a no-op. + const watcher = watchProviderAccounts( + { activeRuntimeEnvironmentId }, + { + onSnapshot: (snapshot) => { + setCodexAccounts(snapshot.codex) + setClaudeAccounts(snapshot.claude) setCodexAccountsLoaded(true) - } - } catch (error) { - if (!stale) { + }, + onError: (error) => { toast.error( translate( - 'auto.components.settings.AccountsPane.b8c2905c2b', - 'Could not load Codex accounts.' + 'auto.components.settings.AccountsPane.loadAccountsFailed', + 'Could not load provider accounts.' ), { description: String((error as Error)?.message ?? error) @@ -499,46 +532,27 @@ export function AccountsPane({ ) } } - } - - const loadClaudeAccounts = async (): Promise => { - try { - const nextClaude = await window.api.claudeAccounts.list() - if (!stale) { - setClaudeAccounts(nextClaude) - } - } catch (error) { - if (!stale) { - toast.error( - translate( - 'auto.components.settings.AccountsPane.9107406589', - 'Could not load Claude accounts.' - ), - { - description: String((error as Error)?.message ?? error) - } - ) - } - } - } - - void loadCodexAccounts() - void loadClaudeAccounts() + ) return () => { - stale = true + watcher.close() } - }, []) + }, [activeRuntimeEnvironmentId]) const syncCodexAccounts = async (next: CodexRateLimitAccountsState): Promise => { setCodexAccounts(next) setCodexAccountsLoaded(true) - await fetchSettings() + // Why: remote mutations never change local GlobalSettings account fields. + if (!isRemoteAccountScope) { + await fetchSettings() + } } const syncClaudeAccounts = async (next: ClaudeRateLimitAccountsState): Promise => { setClaudeAccounts(next) - await fetchSettings() + if (!isRemoteAccountScope) { + await fetchSettings() + } } const formatAccountTimestamp = (timestamp: number): string => { @@ -735,6 +749,7 @@ export function AccountsPane({ const visibleSections = [ wslSupportedPlatform && + !isRemoteAccountScope && matchesSettingsSearch(searchQuery, getAccountsLocationSearchEntries()) ? (
{accountRuntimeControls} @@ -770,11 +785,17 @@ export function AccountsPane({ {translate('auto.components.settings.AccountsPane.94d351af4a', 'Accounts')}

- {translate( - 'auto.components.settings.AccountsPane.c0a52abfc5', - 'Showing accounts for {{value0}}. New accounts are added there.', - { value0: accountRuntimeSentenceLabel } - )} + {isRemoteAccountScope + ? translate( + 'auto.components.settings.AccountsPane.remoteScopeAccounts', + 'Showing accounts managed by {{value0}}. Add or re-authenticate accounts on that server.', + { value0: accountRuntimeSentenceLabel } + ) + : translate( + 'auto.components.settings.AccountsPane.c0a52abfc5', + 'Showing accounts for {{value0}}. New accounts are added there.', + { value0: accountRuntimeSentenceLabel } + )}

@@ -790,7 +811,12 @@ export function AccountsPane({ ) } disabled={ - claudeAction !== 'idle' || wslCapabilitiesLoading || accountRuntimeUnavailable + // Why: interactive `claude login` needs a desktop browser and + // would authenticate against this device, not the server. + isRemoteAccountScope || + claudeAction !== 'idle' || + wslCapabilitiesLoading || + accountRuntimeUnavailable } className="gap-1.5" > @@ -820,7 +846,7 @@ export function AccountsPane({ type="button" onClick={() => void runClaudeAccountAction('select:system', () => - window.api.claudeAccounts.select({ + selectClaudeProviderAccount(settings, { accountId: null, runtime: accountRuntime.runtime, wslDistro: accountRuntime.wslDistro @@ -862,11 +888,17 @@ export function AccountsPane({ {visibleClaudeAccounts.length === 0 ? (
- {translate( - 'auto.components.settings.AccountsPane.3fe7862418', - "No managed Claude accounts for {{value0}}. Orca will use that environment's system default Claude login until you add one here.", - { value0: accountRuntimeSentenceLabel } - )} + {isRemoteAccountScope + ? translate( + 'auto.components.settings.AccountsPane.remoteEmptyClaudeAccounts', + 'No managed Claude accounts on {{value0}}. It uses its system default Claude login; add accounts on that server.', + { value0: accountRuntimeSentenceLabel } + ) + : translate( + 'auto.components.settings.AccountsPane.3fe7862418', + "No managed Claude accounts for {{value0}}. Orca will use that environment's system default Claude login until you add one here.", + { value0: accountRuntimeSentenceLabel } + )}
) : ( visibleClaudeAccounts.map((account) => { @@ -888,7 +920,7 @@ export function AccountsPane({ type="button" onClick={() => void runClaudeAccountAction(`select:${account.id}`, () => - window.api.claudeAccounts.select({ + selectClaudeProviderAccount(settings, { accountId: account.id, runtime: account.managedAuthRuntime ?? 'host', wslDistro: account.wslDistro ?? null @@ -904,7 +936,7 @@ export function AccountsPane({ variant="outline" className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/70" > - {getClaudeAccountRuntimeLabel(account)} + {getClaudeAccountRuntimeLabel(account, accountRuntime.label)} {isActive ? ( {isReauthing ? ( @@ -984,10 +1016,16 @@ export function AccountsPane({ )}

- {translate( - 'auto.components.settings.AccountsPane.340d6f7a85', - 'Each account keeps its own local sign-in context in Orca. Account auth stays on this device.' - )} + {isRemoteAccountScope + ? translate( + 'auto.components.settings.AccountsPane.remoteScopeAuthContext', + 'Each account keeps its own sign-in context on {{value0}}.', + { value0: accountRuntimeSentenceLabel } + ) + : translate( + 'auto.components.settings.AccountsPane.340d6f7a85', + 'Each account keeps its own local sign-in context in Orca. Account auth stays on this device.' + )}

@@ -1036,11 +1074,17 @@ export function AccountsPane({ {translate('auto.components.settings.AccountsPane.94d351af4a', 'Accounts')}

- {translate( - 'auto.components.settings.AccountsPane.c0a52abfc5', - 'Showing accounts for {{value0}}. New accounts are added there.', - { value0: accountRuntimeSentenceLabel } - )} + {isRemoteAccountScope + ? translate( + 'auto.components.settings.AccountsPane.remoteScopeAccounts', + 'Showing accounts managed by {{value0}}. Add or re-authenticate accounts on that server.', + { value0: accountRuntimeSentenceLabel } + ) + : translate( + 'auto.components.settings.AccountsPane.c0a52abfc5', + 'Showing accounts for {{value0}}. New accounts are added there.', + { value0: accountRuntimeSentenceLabel } + )}

{visibleCodexAccounts.length === 0 ? (
- {translate( - 'auto.components.settings.AccountsPane.b4c9450319', - "No managed Codex accounts for {{value0}}. Orca will use that environment's system default Codex login until you add one here.", - { value0: accountRuntimeSentenceLabel } - )} + {isRemoteAccountScope + ? translate( + 'auto.components.settings.AccountsPane.remoteEmptyCodexAccounts', + 'No managed Codex accounts on {{value0}}. It uses its system default Codex login; add accounts on that server.', + { value0: accountRuntimeSentenceLabel } + ) + : translate( + 'auto.components.settings.AccountsPane.b4c9450319', + "No managed Codex accounts for {{value0}}. Orca will use that environment's system default Codex login until you add one here.", + { value0: accountRuntimeSentenceLabel } + )}
) : ( visibleCodexAccounts.map((account) => { const isActive = activeCodexAccountId === account.id - const accountAuthWarning = getCodexAccountAuthWarning({ - limits: codexRateLimits, - target: codexRateLimitTarget, - runtime: accountRuntime, - activeAccountId: activeCodexAccountId, - accountId: account.id - }) + // Why: same remote gate as the section-level warning — the + // desktop's rate-limit poll says nothing about server accounts. + const accountAuthWarning = isRemoteAccountScope + ? null + : getCodexAccountAuthWarning({ + limits: codexRateLimits, + target: codexRateLimitTarget, + runtime: accountRuntime, + activeAccountId: activeCodexAccountId, + accountId: account.id + }) const needsReauthentication = Boolean(accountAuthWarning) const isReauthing = codexAction === `reauth:${account.id}` const isRemoving = codexAction === `remove:${account.id}` @@ -1175,7 +1234,7 @@ export function AccountsPane({ type="button" onClick={() => void runCodexAccountAction(`select:${account.id}`, () => - window.api.codexAccounts.select({ + selectCodexProviderAccount(settings, { accountId: account.id, runtime: account.managedHomeRuntime ?? 'host', wslDistro: account.wslDistro ?? null @@ -1191,7 +1250,7 @@ export function AccountsPane({ variant="outline" className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/70" > - {getCodexAccountRuntimeLabel(account)} + {getCodexAccountRuntimeLabel(account, accountRuntime.label)} {isActive ? ( {isReauthing ? ( @@ -1339,7 +1398,7 @@ export function AccountsPane({ {translate( 'auto.components.settings.AccountsPane.c2aee76420', 'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google for {{value0}}. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.', - { value0: accountRuntimeSentenceLabel } + { value0: localAccountRuntimeSentenceLabel } )}

@@ -1774,7 +1833,7 @@ export function AccountsPane({ } setRemoveAccountId(null) void runCodexAccountAction(`remove:${accountId}`, () => - window.api.codexAccounts.remove({ accountId }) + removeCodexProviderAccount(settings, accountId) ) }} > @@ -1815,7 +1874,7 @@ export function AccountsPane({ } setRemoveClaudeAccountId(null) void runClaudeAccountAction(`remove:${accountId}`, () => - window.api.claudeAccounts.remove({ accountId }) + removeClaudeProviderAccount(settings, accountId) ) }} > diff --git a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts index 5b7b8e020cd..8992e73499a 100644 --- a/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts +++ b/src/renderer/src/components/settings/agent-skill-installed-command-callers.test.ts @@ -44,8 +44,10 @@ const updateCapableCallers = new Map([ ['ORCA_CLI_SKILL_UPDATE_COMMAND', 'installedCommand={updateCommand}'] ], [ + // Why: the single-skill update command selection moved into + // getLinearAgentSkillUpdateCommand so the settings install CTA shares it. 'src/renderer/src/components/sidebar/LinearAgentSkillSetupPrompt.tsx', - ['ORCA_LINEAR_SKILL_UPDATE_COMMAND', 'installedCommand={installedCommand}'] + ['getLinearAgentSkillUpdateCommand', 'installedCommand={installedCommand}'] ], [ 'src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx', diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx new file mode 100644 index 00000000000..a3a4a1db3dd --- /dev/null +++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.test.tsx @@ -0,0 +1,185 @@ +// @vitest-environment happy-dom + +import { act, type ComponentProps } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import type { DiscoveredSkill } from '../../../../shared/skills' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '../ui/tooltip' +import { LinearAgentSkillInstallCta } from './linear-agent-skill-install-cta' + +const mocks = vi.hoisted(() => ({ + skillState: { + installed: false, + loading: false, + error: null as string | null, + skills: [] as DiscoveredSkill[], + refresh: vi.fn(async () => true) + }, + useInstalledAgentSkillNames: vi.fn(), + clipboardWrite: vi.fn(async () => {}), + toastSuccess: vi.fn(), + toastError: vi.fn() +})) + +vi.mock('@/hooks/useInstalledAgentSkills', async (importOriginal) => ({ + ...(await importOriginal()), + useInstalledAgentSkillNames: mocks.useInstalledAgentSkillNames +})) + +vi.mock('./CliSkillRuntimeSetup', () => ({ + buildSkillCommandForRuntime: (command: string) => command +})) + +vi.mock('sonner', () => ({ + toast: { success: mocks.toastSuccess, error: mocks.toastError } +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function discoveredSkill(overrides: Partial): DiscoveredSkill { + return { + id: 'skill-1', + name: 'orca-linear', + description: null, + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + rootPath: '/Users/test/.agents/skills', + directoryPath: '/Users/test/.agents/skills/orca-linear', + skillFilePath: '/Users/test/.agents/skills/orca-linear/SKILL.md', + installed: true, + fileCount: 1, + updatedAt: null, + ...overrides + } +} + +async function renderCta( + props: Partial> = {} +): Promise { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render( + + + + ) + }) + if (!container) { + throw new Error('Container was not created') + } + return container +} + +describe('LinearAgentSkillInstallCta', () => { + beforeEach(() => { + mocks.skillState.installed = false + mocks.skillState.loading = false + mocks.skillState.error = null + mocks.skillState.skills = [] + mocks.skillState.refresh.mockClear() + mocks.useInstalledAgentSkillNames.mockReset() + mocks.useInstalledAgentSkillNames.mockReturnValue(mocks.skillState) + mocks.clipboardWrite.mockClear() + mocks.toastSuccess.mockClear() + mocks.toastError.mockClear() + Object.defineProperty(window, 'api', { + configurable: true, + value: { ui: { writeClipboardText: mocks.clipboardWrite } } + }) + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + root = null + container?.remove() + container = null + Reflect.deleteProperty(window, 'api') + }) + + it('shows the install command and explanation when the skill is missing', async () => { + const rendered = await renderCta() + + expect(rendered.textContent).toContain('Agent skill:') + expect(rendered.textContent).toContain('orca-linear') + expect(rendered.textContent).toContain('Not installed') + expect(rendered.textContent).toContain('Let your agents read and edit Linear tasks.') + expect(rendered.textContent).toContain( + 'npx skills add https://github.com/stablyai/orca --skill orca-linear --global' + ) + }) + + it('copies the install command to the clipboard', async () => { + const rendered = await renderCta() + + await act(async () => { + rendered + .querySelector('button[aria-label="Copy command"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.clipboardWrite).toHaveBeenCalledWith( + 'npx skills add https://github.com/stablyai/orca --skill orca-linear --global' + ) + expect(mocks.toastSuccess).toHaveBeenCalled() + }) + + it('shows a subtle confirmation and the update command when installed', async () => { + mocks.skillState.installed = true + mocks.skillState.skills = [discoveredSkill({ name: 'orca-linear' })] + + const rendered = await renderCta() + + expect(rendered.textContent).toContain('Installed') + expect(rendered.textContent).toContain('Agent skill installed. To update it, run:') + expect(rendered.textContent).toContain('npx skills update orca-linear --global') + expect(rendered.textContent).not.toContain('Not installed') + }) + + it('updates through the legacy skill name when only linear-tickets is installed', async () => { + mocks.skillState.installed = true + mocks.skillState.skills = [ + discoveredSkill({ + name: 'linear-tickets', + directoryPath: '/Users/test/.agents/skills/linear-tickets', + skillFilePath: '/Users/test/.agents/skills/linear-tickets/SKILL.md' + }) + ] + + const rendered = await renderCta() + + expect(rendered.textContent).toContain('npx skills update linear-tickets --global') + }) + + it('notes that remote agent environments need their own setup', async () => { + const rendered = await renderCta({ + settings: { activeRuntimeEnvironmentId: 'runtime-1', terminalWindowsShell: '' } + }) + + expect(rendered.textContent).toContain( + 'This installs host setup; remote agent environments may need separate setup.' + ) + }) + + it('re-checks the skill scan on demand', async () => { + const rendered = await renderCta() + + await act(async () => { + Array.from(rendered.querySelectorAll('button')) + .find((button) => button.textContent?.includes('Re-check')) + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.skillState.refresh).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx new file mode 100644 index 00000000000..43e755dcbe2 --- /dev/null +++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx @@ -0,0 +1,186 @@ +import { useMemo } from 'react' +import { Copy, RefreshCw } from 'lucide-react' +import { toast } from 'sonner' +import { IntegrationStatusPill } from '@/components/integration-status-pill' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + useInstalledAgentSkillNames +} from '@/hooks/useInstalledAgentSkills' +import { + LINEAR_AGENT_SKILL_NAMES, + ORCA_LINEAR_SKILL_INSTALL_COMMAND, + ORCA_LINEAR_SKILL_NAME +} from '@/lib/agent-feature-install-commands' +import { getLinearAgentSkillUpdateCommand } from '@/lib/linear-agent-skill-update-command' +import { cn } from '@/lib/utils' +import { getLinearAgentSkillSetupInlineRuntimeCopy } from '../sidebar/linear-agent-skill-setup-copy' +import { + getCurrentPlatform, + getLinearPromptAgentRuntime, + getLinearPromptSkillDiscoveryTarget, + type LinearAgentSkillPromptSettings +} from '../sidebar/linear-agent-skill-runtime' +import { buildSkillCommandForRuntime } from './CliSkillRuntimeSetup' +import { + useIntegrationCommandRowClass, + useIntegrationSubordinateRowClass +} from './integration-card-presentation' +import { translate } from '@/i18n/i18n' + +type LinearAgentSkillInstallCtaProps = { + settings: LinearAgentSkillPromptSettings | null | undefined +} + +// Why: the Linear task provider and the orca-linear agent skill are decoupled +// setups; this section bridges them so connecting the provider also surfaces +// the skill agents need to read and edit Linear tasks. +export function LinearAgentSkillInstallCta({ + settings +}: LinearAgentSkillInstallCtaProps): React.JSX.Element { + // Why: mirror the sidebar setup prompt's runtime resolution so both surfaces + // agree on which machine (host vs. WSL distro) is scanned and installed to. + const remote = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) + const agentRuntime = useMemo( + () => getLinearPromptAgentRuntime(settings, getCurrentPlatform(), remote), + [remote, settings] + ) + const skillDiscoveryTarget = useMemo( + () => getLinearPromptSkillDiscoveryTarget(agentRuntime), + [agentRuntime] + ) + const skill = useInstalledAgentSkillNames(LINEAR_AGENT_SKILL_NAMES, { + discoveryTarget: skillDiscoveryTarget, + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) + const command = useMemo( + () => + buildSkillCommandForRuntime( + skill.installed + ? getLinearAgentSkillUpdateCommand(skill.skills, skill.installed) + : ORCA_LINEAR_SKILL_INSTALL_COMMAND, + agentRuntime + ), + [agentRuntime, skill.installed, skill.skills] + ) + const subordinateRowClass = useIntegrationSubordinateRowClass('space-y-1.5') + const commandRowClass = useIntegrationCommandRowClass() + + const copyCommand = async (): Promise => { + try { + await window.api.ui.writeClipboardText(command) + toast.success( + translate( + 'auto.components.settings.linear.agent.skill.install.cta.copiedCommand', + 'Copied command.' + ) + ) + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.settings.linear.agent.skill.install.cta.copyFailed', + 'Failed to copy command.' + ) + ) + } + } + + return ( +
+
+

+ {translate( + 'auto.components.settings.linear.agent.skill.install.cta.skillLabel', + 'Agent skill:' + )}{' '} + {ORCA_LINEAR_SKILL_NAME} +

+ {skill.loading ? ( + + {translate( + 'auto.components.settings.linear.agent.skill.install.cta.checking', + 'Checking...' + )} + + ) : skill.installed ? ( + + {translate( + 'auto.components.settings.linear.agent.skill.install.cta.installed', + 'Installed' + )} + + ) : ( + + {translate( + 'auto.components.settings.linear.agent.skill.install.cta.notInstalled', + 'Not installed' + )} + + )} + +
+ {skill.error ?

{skill.error}

: null} + {!skill.loading && ( + <> +

+ {skill.installed + ? translate( + 'auto.components.settings.linear.agent.skill.install.cta.installedDescription', + 'Agent skill installed. To update it, run:' + ) + : translate( + 'auto.components.settings.linear.agent.skill.install.cta.description', + 'Let your agents read and edit Linear tasks.' + )} +

+
+ + {command} + + + + + + + {translate( + 'auto.components.settings.linear.agent.skill.install.cta.copyCommand', + 'Copy command' + )} + + +
+ {remote || agentRuntime.runtime === 'wsl' ? ( +

+ {getLinearAgentSkillSetupInlineRuntimeCopy(remote, agentRuntime)} +

+ ) : null} + + )} +
+ ) +} diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx index 0dd26191914..9fce3fc84fc 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx @@ -2,9 +2,10 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getExecutionHostLabel } from '../../../../shared/execution-host' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { TooltipProvider } from '../ui/tooltip' import { LinearIntegrationCard } from './task-tracker-integration-cards' const LOCAL_HOST_LABEL = getExecutionHostLabel('local') @@ -86,12 +87,27 @@ async function renderCard(): Promise { document.body.appendChild(container) root = createRoot(container) await act(async () => { - root?.render() + root?.render( + + + + ) }) return container } describe('LinearIntegrationCard account scope', () => { + beforeEach(() => { + // Why: the embedded agent-skill CTA scans installed skills through + // window.api; without a stub it renders a scan error instead of a state. + Object.defineProperty(window, 'api', { + configurable: true, + value: { + skills: { discover: async () => ({ skills: [], sources: [], scannedAt: 0 }) } + } + }) + }) + afterEach(async () => { if (root) { await act(async () => { @@ -102,6 +118,7 @@ describe('LinearIntegrationCard account scope', () => { container?.remove() container = null mocks.store.current = null + Reflect.deleteProperty(window, 'api') }) it('shows local-client account ownership when Linear is disconnected', async () => { diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx index aca7be03f84..6c2cc1b9575 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.tsx @@ -8,6 +8,7 @@ import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { useAppStore } from '@/store' import { IntegrationCardDetails, IntegrationCardShell } from './integration-card-shell' import { useIntegrationSubordinateRowClass } from './integration-card-presentation' +import { LinearAgentSkillInstallCta } from './linear-agent-skill-install-cta' import { getProviderAccountScope } from './provider-account-scope' import { ProviderHostScopeControl } from './ProviderHostScopeControl' import { translate } from '@/i18n/i18n' @@ -200,6 +201,7 @@ export function LinearIntegrationCard(): React.JSX.Element { ) : null} + buildSkillCommandForRuntime(ORCA_LINEAR_SKILL_INSTALL_COMMAND, agentRuntime), [agentRuntime] ) - const canonicalSkillInstalled = hasInstalledAgentSkill(skill.skills, ORCA_LINEAR_SKILL_NAME, { - sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS - }) - const legacySkillInstalled = hasInstalledAgentSkill(skill.skills, LINEAR_TICKETS_SKILL_NAME, { - sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS - }) - // Why: legacy-only installs must update the installed legacy skill, while - // fresh/canonical/both-name states should move through the canonical name. - const updateCommand = - !skill.installed || canonicalSkillInstalled || !legacySkillInstalled - ? ORCA_LINEAR_SKILL_UPDATE_COMMAND - : LINEAR_TICKETS_SKILL_UPDATE_COMMAND const installedCommand = useMemo( - () => buildSkillCommandForRuntime(updateCommand, agentRuntime), - [agentRuntime, updateCommand] + () => + buildSkillCommandForRuntime( + getLinearAgentSkillUpdateCommand(skill.skills, skill.installed), + agentRuntime + ), + [agentRuntime, skill.installed, skill.skills] ) const terminalShellOverride = getLinearPromptTerminalShellOverride( currentPlatform, diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 003878d3acf..20cf2861051 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -23,6 +23,7 @@ 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' @@ -606,6 +607,10 @@ const WorktreeCard = React.memo(function WorktreeCard({ const legacyCardTitleDisplay = coerceWorktreeCardVisibleTitle(worktree.displayName) const visibleCardTitle = newCardStyle ? cardTitleDisplay : legacyCardTitleDisplay const isDeleting = deleteState?.isDeleting ?? false + const isQueuedForDeletion = deleteState?.phase === 'queued' + const deleteLabel = isQueuedForDeletion + ? translate('auto.components.sidebar.WorktreeCard.ef18787206', 'Queued for deletion') + : translate('auto.components.sidebar.WorktreeCard.691ccfd622', 'Deleting…') const deleteModifierPressed = useWorkspaceDeleteModifierPressed() const showStatus = cardProps.includes('status') @@ -1723,6 +1728,15 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} + {isActive && worktree.linkedLinearIssue ? ( + + ) : null} + {/* Why: inline agent list. Gated on the 'inline-agents' card property so users can hide it. Layout coupling: this block grows the card height dynamically — WorktreeList uses @@ -1877,8 +1891,10 @@ const WorktreeCard = React.memo(function WorktreeCard({ {isDeleting && (
- - {translate('auto.components.sidebar.WorktreeCard.691ccfd622', 'Deleting…')} + {!isQueuedForDeletion ? ( + + ) : null} + {deleteLabel}
)} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 5e333f2ff4b..d3dd012135e 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -72,6 +72,11 @@ import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { + fetchProviderAccountsSnapshot, + selectClaudeProviderAccount, + selectCodexProviderAccount +} from '@/runtime/runtime-provider-accounts-client' import { translate } from '@/i18n/i18n' type StatusBarProps = { @@ -132,6 +137,7 @@ export type ClaudeStatusSwitchGroup = { type StatusSwitchGroupOptions = { fallbackWslDistro?: string | null includeFallbackWsl?: boolean + hostLabel?: string } function getHostRuntimeLabel(): string { @@ -157,9 +163,12 @@ function getCodexStatusWslKey(wslDistro: string | null | undefined): string { return trimmed ? trimmed : '__default__' } -function getCodexStatusRuntimeLabel(target: CodexStatusRuntimeTarget): string { +function getCodexStatusRuntimeLabel( + target: CodexStatusRuntimeTarget, + hostLabel = getHostRuntimeLabel() +): string { if (target.runtime === 'host') { - return getHostRuntimeLabel() + return hostLabel } return target.wslDistro ? `WSL ${target.wslDistro}` : 'WSL default' } @@ -261,7 +270,7 @@ export function buildCodexStatusSwitchGroups( const accountsForTarget = getCodexStatusAccountsForTarget(state, target) return { key: getCodexStatusRuntimeKey(target), - label: getCodexStatusRuntimeLabel(target), + label: getCodexStatusRuntimeLabel(target, options.hostLabel), runtimeTarget: target, targets: [ { @@ -420,7 +429,7 @@ export function buildClaudeStatusSwitchGroups( const accountsForTarget = getClaudeStatusAccountsForTarget(state, target) return { key: getCodexStatusRuntimeKey(target), - label: getCodexStatusRuntimeLabel(target), + label: getCodexStatusRuntimeLabel(target, options.hostLabel), runtimeTarget: target, targets: [ { @@ -510,6 +519,29 @@ function getClaudeStatusAccountsFromSettings( } } +// Why: with a Remote Orca Server active, accounts persisted in local +// GlobalSettings describe this desktop, not the account owner; the snapshot +// fetched from the server must win (#7973). +export function resolveCodexStatusAccountState( + settings: GlobalSettings | null | undefined, + runtimeState: CodexRateLimitAccountsState +): CodexRateLimitAccountsState { + if (settings?.activeRuntimeEnvironmentId?.trim()) { + return runtimeState + } + return getCodexStatusAccountsFromSettings(settings) ?? runtimeState +} + +export function resolveClaudeStatusAccountState( + settings: GlobalSettings | null | undefined, + runtimeState: ClaudeRateLimitAccountsState +): ClaudeRateLimitAccountsState { + if (settings?.activeRuntimeEnvironmentId?.trim()) { + return runtimeState + } + return getClaudeStatusAccountsFromSettings(settings) ?? runtimeState +} + function CodexRestartStatusPrompt(): React.JSX.Element | null { const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) @@ -648,8 +680,15 @@ function ClaudeSwitcherMenu({ const inactiveClaudeAccounts = useAppStore((s) => s.rateLimits.inactiveClaudeAccounts) const claudeTarget = useAppStore((s) => s.rateLimits.claudeTarget) const settings = useAppStore((s) => s.settings) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) + const providerAccountHostLabel = hasActiveRuntimeEnvironment + ? (runtimeEnvironments.find( + (environment) => environment.id === settings?.activeRuntimeEnvironmentId?.trim() + )?.name ?? + translate('auto.components.status.bar.StatusBar.remoteServerLabel', 'Remote server')) + : undefined const windowsTerminalCapabilities = useWindowsTerminalCapabilities( navigator.userAgent.includes('Windows') || hasActiveRuntimeEnvironment, false, @@ -661,9 +700,9 @@ function ClaudeSwitcherMenu({ if (!settings) { return 'no-settings' } - return `${settings.activeClaudeManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeClaudeManagedAccountIdsByRuntime ?? null)}:${settings.claudeManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` + return `${settings.activeRuntimeEnvironmentId?.trim() || 'local'}:${settings.activeClaudeManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeClaudeManagedAccountIdsByRuntime ?? null)}:${settings.claudeManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` }) - const accountState = getClaudeStatusAccountsFromSettings(settings) ?? accounts + const accountState = resolveClaudeStatusAccountState(settings, accounts) useEffect(() => { mountedRef.current = true @@ -672,12 +711,15 @@ function ClaudeSwitcherMenu({ } }, []) + const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null + // Why: keyed on the owner id, not the settings object identity, so routine + // settings mutations don't re-run the remote snapshot round trip. const loadAccounts = useCallback(async () => { - const next = await window.api.claudeAccounts.list() + const snapshot = await fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId }) if (mountedRef.current) { - setAccounts(next) + setAccounts(snapshot.claude) } - }, []) + }, [activeRuntimeEnvironmentId]) useEffect(() => { void loadAccounts().catch((error) => { @@ -694,13 +736,14 @@ function ClaudeSwitcherMenu({ // Why: inactive-account usage is needed only for the explicit switcher // expansion, so fetch it on that event instead of one render later. + // Remote-owned accounts have no local inactive-usage cache to fill. const handleAccountsExpandedToggle = useCallback((): void => { const nextExpanded = !accountsExpanded setAccountsExpanded(nextExpanded) - if (nextExpanded) { + if (nextExpanded && !hasActiveRuntimeEnvironment) { void fetchInactiveClaudeAccountUsage() } - }, [accountsExpanded, fetchInactiveClaudeAccountUsage]) + }, [accountsExpanded, fetchInactiveClaudeAccountUsage, hasActiveRuntimeEnvironment]) const handleSelectAccount = async ( accountId: string | null, @@ -711,7 +754,7 @@ function ClaudeSwitcherMenu({ } setIsSwitching(true) try { - const next = await window.api.claudeAccounts.select({ + const next = await selectClaudeProviderAccount(settings, { accountId, runtime: target.runtime, wslDistro: target.wslDistro @@ -720,7 +763,11 @@ function ClaudeSwitcherMenu({ if (mountedRef.current) { setAccounts(next) } - await fetchSettings() + // Why: remote selections live on the server; local GlobalSettings + // account fields are untouched, so refetching them is pure churn. + if (!hasActiveRuntimeEnvironment) { + await fetchSettings() + } if (mountedRef.current) { setAccountsExpanded(false) } @@ -760,7 +807,8 @@ function ClaudeSwitcherMenu({ toCodexStatusRuntimeTarget(claudeTarget), { fallbackWslDistro, - includeFallbackWsl: shouldIncludeSettingsWslRuntime(settings) + includeFallbackWsl: !hasActiveRuntimeEnvironment && shouldIncludeSettingsWslRuntime(settings), + hostLabel: providerAccountHostLabel } ) const selectedGroup = @@ -1201,8 +1249,15 @@ function CodexSwitcherMenu({ const inactiveCodexAccounts = useAppStore((s) => s.rateLimits.inactiveCodexAccounts) const codexTarget = useAppStore((s) => s.rateLimits.codexTarget) const settings = useAppStore((s) => s.settings) + const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const hasActiveRuntimeEnvironment = Boolean(settings?.activeRuntimeEnvironmentId?.trim()) const runtimeTarget = useMemo(() => getActiveRuntimeTarget(settings), [settings]) + const providerAccountHostLabel = hasActiveRuntimeEnvironment + ? (runtimeEnvironments.find( + (environment) => environment.id === settings?.activeRuntimeEnvironmentId?.trim() + )?.name ?? + translate('auto.components.status.bar.StatusBar.remoteServerLabel', 'Remote server')) + : undefined const windowsTerminalCapabilities = useWindowsTerminalCapabilities( navigator.userAgent.includes('Windows') || hasActiveRuntimeEnvironment, false, @@ -1214,16 +1269,19 @@ function CodexSwitcherMenu({ if (!settings) { return 'no-settings' } - return `${settings.activeCodexManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeCodexManagedAccountIdsByRuntime ?? null)}:${settings.codexManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` + return `${settings.activeRuntimeEnvironmentId?.trim() || 'local'}:${settings.activeCodexManagedAccountId ?? 'system'}:${JSON.stringify(settings.activeCodexManagedAccountIdsByRuntime ?? null)}:${settings.codexManagedAccounts.map((account) => `${account.id}:${account.updatedAt}`).join('|')}` }) - const accountState = getCodexStatusAccountsFromSettings(settings) ?? accounts + const accountState = resolveCodexStatusAccountState(settings, accounts) + const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId?.trim() || null + // Why: keyed on the owner id, not the settings object identity, so routine + // settings mutations don't re-run the remote snapshot round trip. const loadAccounts = useCallback(async () => { - const next = await window.api.codexAccounts.list() + const snapshot = await fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId }) if (mountedRef.current) { - setAccounts(next) + setAccounts(snapshot.codex) } - }, []) + }, [activeRuntimeEnvironmentId]) useEffect(() => { mountedRef.current = true @@ -1256,7 +1314,7 @@ function CodexSwitcherMenu({ const previousActiveAccountId = getCodexStatusActiveId(accountState, target) setIsSwitching(true) try { - const next = await window.api.codexAccounts.select({ + const next = await selectCodexProviderAccount(settings, { accountId, runtime: target.runtime, wslDistro: target.wslDistro @@ -1265,7 +1323,11 @@ function CodexSwitcherMenu({ if (mountedRef.current) { setAccounts(next) } - await fetchSettings() + // Why: remote selections live on the server; local GlobalSettings + // account fields are untouched, so refetching them is pure churn. + if (!hasActiveRuntimeEnvironment) { + await fetchSettings() + } const nextActiveAccountId = getCodexStatusActiveId(next, target) if (previousActiveAccountId !== nextActiveAccountId) { await markLiveCodexSessionsForRestart({ @@ -1381,12 +1443,13 @@ function CodexSwitcherMenu({ const handleAccountsExpandedToggle = useCallback((): void => { const nextExpanded = !accountsExpanded setAccountsExpanded(nextExpanded) - if (nextExpanded) { + if (nextExpanded && !hasActiveRuntimeEnvironment) { // Why: inactive-account usage is needed only for the explicit switcher // expansion, so fetch it on that event instead of one render later. + // Remote-owned accounts have no local inactive-usage cache to fill. void fetchInactiveCodexAccountUsage() } - }, [accountsExpanded, fetchInactiveCodexAccountUsage]) + }, [accountsExpanded, fetchInactiveCodexAccountUsage, hasActiveRuntimeEnvironment]) const selectedRuntimeKey = getCodexStatusRuntimeKey( normalizeCodexStatusRuntimeTarget(accountState, toCodexStatusRuntimeTarget(codexTarget)) @@ -1400,7 +1463,8 @@ function CodexSwitcherMenu({ toCodexStatusRuntimeTarget(codexTarget), { fallbackWslDistro, - includeFallbackWsl: shouldIncludeSettingsWslRuntime(settings) + includeFallbackWsl: !hasActiveRuntimeEnvironment && shouldIncludeSettingsWslRuntime(settings), + hostLabel: providerAccountHostLabel } ) const selectedGroup = @@ -1411,7 +1475,10 @@ function CodexSwitcherMenu({ resetCreditCount !== null ? formatResetCreditExpiry(codex.rateLimitResetCredits?.nextExpiresAt, resetCreditCount) : null - const canRedeemReset = resetCreditCount !== null && resetCreditCount > 0 + // Why: reset credits redeem against the desktop's own Codex login; with a + // remote account owner the credit does not apply to the server's account. + const canRedeemReset = + !hasActiveRuntimeEnvironment && resetCreditCount !== null && resetCreditCount > 0 return ( a.accountId === target.id) : null + // Why: sign-in spawns a local `codex login`; a remote-owned + // account cannot be re-authenticated from this desktop. const showSignInAction = + !hasActiveRuntimeEnvironment && !target.active && target.id !== null && isUnavailableInactiveUsage(inactiveUsage?.rateLimits) diff --git a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts index 652b7f7448d..db8b96abaa2 100644 --- a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts @@ -7,7 +7,9 @@ import type { import { buildClaudeStatusSwitchGroups, buildCodexStatusSwitchGroups, - getStatusBarPreferredWslDistro + getStatusBarPreferredWslDistro, + resolveClaudeStatusAccountState, + resolveCodexStatusAccountState } from './StatusBar' const hostLabel = navigator.userAgent.includes('Windows') ? 'Windows' : 'This device' @@ -133,4 +135,97 @@ describe('status bar runtime switch groups', () => { ) ).toBe('Fedora') }) + + it('labels the host account group with the active remote server name', () => { + const state: CodexRateLimitAccountsState = { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + + expect( + buildCodexStatusSwitchGroups( + state, + { runtime: 'host', wslDistro: null }, + { hostLabel: 'Repro Server' } + )[0]?.label + ).toBe('Repro Server') + }) + + it('prefers the runtime snapshot over local settings accounts when a remote server is active', () => { + const remoteState: CodexRateLimitAccountsState = { + accounts: [ + { + id: 'server-codex-1', + email: 'server@example.com', + managedHomeRuntime: 'host', + wslDistro: null, + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeAccountId: 'server-codex-1', + activeAccountIdsByRuntime: { host: 'server-codex-1', wsl: {} } + } + const settings = { + activeRuntimeEnvironmentId: 'env-1', + activeCodexManagedAccountId: 'desktop-codex-1', + activeCodexManagedAccountIdsByRuntime: { host: 'desktop-codex-1', wsl: {} }, + codexManagedAccounts: [ + { + id: 'desktop-codex-1', + email: 'desktop@example.com', + managedHomePath: '/tmp/desktop-codex-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + } as GlobalSettings + + expect(resolveCodexStatusAccountState(settings, remoteState)).toBe(remoteState) + // Without a remote owner, settings-derived accounts win as before. + expect( + resolveCodexStatusAccountState( + { ...settings, activeRuntimeEnvironmentId: null }, + remoteState + ).accounts.map((account) => account.id) + ).toEqual(['desktop-codex-1']) + }) + + it('prefers the runtime snapshot for Claude accounts when a remote server is active', () => { + const remoteState: ClaudeRateLimitAccountsState = { + accounts: [], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: {} } + } + const settings = { + activeRuntimeEnvironmentId: 'env-1', + activeClaudeManagedAccountId: 'desktop-claude-1', + activeClaudeManagedAccountIdsByRuntime: { host: 'desktop-claude-1', wsl: {} }, + claudeManagedAccounts: [ + { + id: 'desktop-claude-1', + email: 'desktop@example.com', + managedAuthPath: '/tmp/desktop-claude-1', + authMethod: 'subscription-oauth', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ] + } as GlobalSettings + + expect(resolveClaudeStatusAccountState(settings, remoteState)).toBe(remoteState) + expect( + resolveClaudeStatusAccountState( + { ...settings, activeRuntimeEnvironmentId: ' ' }, + remoteState + ).accounts.map((account) => account.id) + ).toEqual(['desktop-claude-1']) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ce885163fed..63a28b7819a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -1872,6 +1872,64 @@ describe('connectPanePty', () => { expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) }) + it('resumes a hibernated agent from a navigation-free wake without a visibility reveal', async () => { + // Mobile wake fanout drives wakeHibernatedAgentIfArmed on a still-hidden pane + // (no isVisible flip): the armed cold-restore --resume must fire exactly once + // even when the wake is delivered twice (INV-1 idempotency). + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps({ + consumeSuppressedPtyExit: vi.fn(() => true), + isVisibleRef: { current: false } + }) + const pane = createPane(2) + const paneKey = `tab-1:${leafIdForPane(2)}` + mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] = { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-hibernated-bg' }, + prompt: 'test prompt', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep' + } + + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + wakeHibernatedAgentIfArmed: () => void + dispose: () => void + } + await flushAsyncTicks() + + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect((transport.getPtyId as unknown as () => string | null)()).toBe('tab-pty') + const connectCallsBeforeExit = transport.connect.mock.calls.length + onPtyExit?.('tab-pty') + await flushAsyncTicks() + // Still hidden: no reveal happened, so nothing respawned on exit. + expect(transport.connect.mock.calls.length).toBe(connectCallsBeforeExit) + + binding.wakeHibernatedAgentIfArmed() + await flushAsyncTicks() + + expect(transport.connect.mock.calls.length).toBeGreaterThan(connectCallsBeforeExit) + const resumeConnectOptions = transport.connect.mock.calls.at(-1)?.[0] as + | { command?: string } + | undefined + expect(resumeConnectOptions?.command).toContain('--resume') + expect(resumeConnectOptions?.command).toContain('sess-hibernated-bg') + + // A second navigation-free wake must not spawn again (one-pane/one-PTY). + const connectCallsAfterWake = transport.connect.mock.calls.length + binding.wakeHibernatedAgentIfArmed() + await flushAsyncTicks() + expect(transport.connect.mock.calls.length).toBe(connectCallsAfterWake) + }) + it('auto-resumes a hibernated pane when its kill lands after the pane is already revealed', async () => { // Race: the user reveals the background tab in the window between the // coordinator confirming the candidate and the kill's exit arriving. The diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 2d534757a41..df753a47114 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -673,6 +673,11 @@ let inactiveForegroundImmediateBudgetWindowStart = 0 type PanePtyBinding = IDisposable & { syncProcessTracking: () => void noteVisibilityResume: () => void + /** Navigation-free hibernation wake: fires the armed cold-restore --resume + * without the size-reassert/foreground-sample side effects of a real reveal. + * Used by the mobile wake fanout so a hidden hibernated pane resumes with no + * desktop hidden→visible transition. */ + wakeHibernatedAgentIfArmed: () => void /** Re-sample process identity when the pane gains intra-tab focus: the tab * icon follows the active leaf, and a shell-marked entry on a still-running * agent pane has no OSC boundary left to correct it. */ @@ -6244,6 +6249,11 @@ export function connectPanePty( consumeHibernatedAgentWake() sampleVisiblePaneForegroundAgent() }, + // Why: mobile wake reaches this pane while it stays hidden on the desktop, so + // it must consume only the armed hibernation wake — no size/foreground reads. + wakeHibernatedAgentIfArmed() { + consumeHibernatedAgentWake() + }, sampleForegroundAgentOnFocus() { sampleVisiblePaneForegroundAgent() }, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index e342778eed5..4f9d93ed934 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -113,8 +113,10 @@ import { import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT, + WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, type SplitTerminalPaneDetail, - type CloseTerminalPaneDetail + type CloseTerminalPaneDetail, + type WakeHibernatedAgentsWorktreeDetail } from '@/constants/terminal' import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry' import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' @@ -881,11 +883,12 @@ export function useTerminalPaneLifecycle({ imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder) pane.terminal.attachCustomKeyEventHandler((e) => { const now = Date.now() - const pendingCandidateReleaseGuardActive = shouldApplyTerminalImePendingCandidateKeyRelease( - e, - pendingTerminalImeCandidateKeyReleases, - now - ) + const pendingCandidateReleaseGuardActive = + shouldApplyTerminalImePendingCandidateKeyRelease( + e, + pendingTerminalImeCandidateKeyReleases, + now + ) const imeKeyboardOptions = { compositionActive: imeCompositionTracker.isActive(), candidateKeyGuardActive: @@ -1746,6 +1749,28 @@ export function useTerminalPaneLifecycle({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [tabId, cwd]) + // Why: mobile wake fanout — this pane self-selects by worktreeId and fires its + // own armed hibernation --resume while staying hidden on the desktop (no + // reveal, no focus/navigation change). Not-yet-mounted panes are covered by + // the background-mount fresh-connect cold-restore path instead. + useEffect(() => { + const onWakeHibernatedAgents = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (!detail || detail.worktreeId !== worktreeId) { + return + } + for (const panePtyBinding of panePtyBindingsRef.current.values()) { + ;( + panePtyBinding as IDisposable & { wakeHibernatedAgentIfArmed?: () => void } + ).wakeHibernatedAgentIfArmed?.() + } + } + window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents) + return () => { + window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents) + } + }, [worktreeId, panePtyBindingsRef]) + useEffect(() => { const previousIsVisible = getPreviousVisibleForTerminalPane({ previous: previousVisibleForReconcileRef.current, diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index efedfb65665..d5ec61ca3f3 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -4,8 +4,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertTriangle, - Check, - EyeOff, Loader2, RefreshCcw, Search, @@ -38,6 +36,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' +import { Progress } from '@/components/ui/progress' import RepoMultiCombobox from '@/components/ui/repo-multi-combobox' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -48,16 +47,13 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { isGitRepoKind } from '../../../../shared/repo-kind' import { canQueueWorkspaceCleanupCandidate, - type WorkspaceCleanupBlocker, type WorkspaceCleanupCandidate, type WorkspaceCleanupScanError, type WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup' import { filterWorkspaceCleanupCandidates, - getWorkspaceCleanupGitLabel, getWorkspaceCleanupReviewInfo, - hasWorkspaceCleanupLocalContext, sortWorkspaceCleanupCandidates, type WorkspaceCleanupContextFilter, type WorkspaceCleanupFilters, @@ -68,11 +64,25 @@ import { type WorkspaceCleanupSortKey, type WorkspaceCleanupTimeFilter } from './workspace-cleanup-presentation' +import { + startWorkspaceCleanupBackgroundRemoval, + type WorkspaceCleanupRemovalProgress +} from './workspace-cleanup-background-removal' +import { CandidateRow } from './workspace-cleanup-candidate-row' +import { + getCandidateStatus, + getContextPillLabel, + getDirtyGitLabel, + getReviewPillTone, + shouldShowGitMetadataChip +} from './workspace-cleanup-candidate-row-data' +import { StatusPill } from './workspace-cleanup-status-pill' import { resolveWorkspaceCleanupActiveView, type WorkspaceCleanupView, type WorkspaceCleanupViewCounts } from './workspace-cleanup-view-selection' +import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' import { translate } from '@/i18n/i18n' const DEFAULT_FILTERS: WorkspaceCleanupFilters = { @@ -91,25 +101,6 @@ const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = { title: null } -const BLOCKER_LABELS: Record = { - 'main-worktree': 'Main workspace', - 'folder-repo': 'Folder project', - pinned: 'Pinned', - 'active-workspace': 'Active workspace', - 'running-terminal': 'Running terminal process', - 'terminal-liveness-unknown': 'Terminal liveness unknown', - 'dirty-editor-buffer': 'Unsaved editor buffer', - 'volatile-local-context': 'Volatile local context', - 'recent-visible-context': 'Recently visited tabs', - 'live-agent': 'Active agent', - 'ssh-disconnected': 'Remote unavailable', - 'git-status-error': 'Git status unavailable', - 'dirty-files': 'Changed files', - 'unpushed-commits': 'Unpushed commits', - 'unknown-base': 'Could not verify unpushed commits', - dismissed: 'Ignored' -} - function formatRelativeTime(timestamp: number): string { if (!timestamp) { return 'Never' @@ -204,13 +195,29 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const dismissCandidates = useAppStore((s) => s.dismissWorkspaceCleanupCandidates) const resetDismissals = useAppStore((s) => s.resetWorkspaceCleanupDismissals) const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates) + const markWorktreesQueuedForDeletion = useAppStore((s) => s.markWorktreesQueuedForDeletion) + const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState) + const deletingWorktreeIds = useAppStore( + useShallow( + (s) => + new Set( + Object.entries(s.deleteStateByWorktreeId) + .filter(([, state]) => state.isDeleting) + .map(([worktreeId]) => worktreeId) + ) + ) + ) const open = activeModal === 'workspace-cleanup' const openRef = useRef(open) const [selectedIds, setSelectedIds] = useState>(() => new Set()) + const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) const [activeView, setActiveView] = useState('ready') const [confirming, setConfirming] = useState(false) - const [removing, setRemoving] = useState(false) + const [confirmCandidates, setConfirmCandidates] = useState([]) + const [removalProgress, setRemovalProgress] = useState( + null + ) const [rowFailures, setRowFailures] = useState>({}) const [repoSelection, setRepoSelection] = useState>(() => new Set()) const [filters, setFilters] = useState(DEFAULT_FILTERS) @@ -220,6 +227,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const autoScanAttemptedForOpenRef = useRef(false) const latestReadyToastScanAtRef = useRef(null) const wasOpenRef = useRef(false) + const removalInFlightRef = useRef(false) const mountedRef = useMountedRef() const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) const eligibleRepoIds = useMemo(() => eligibleRepos.map((repo) => repo.id), [eligibleRepos]) @@ -289,21 +297,24 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { if (!wasOpenRef.current) { wasOpenRef.current = true autoScanAttemptedForOpenRef.current = false - setActiveView('ready') - setConfirming(false) - setRowFailures({}) - setFilters(DEFAULT_FILTERS) - setSortKey('activity') - setSortDirection('asc') - if (scan) { - setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates)) + if (!removalInFlightRef.current) { + setActiveView('ready') + setConfirming(false) + setRowFailures({}) + setFilters(DEFAULT_FILTERS) + setSortKey('activity') + setSortDirection('asc') + setSelectedIds(new Set()) } } - if (!loading && !autoScanAttemptedForOpenRef.current) { + // Why: reopening mid-batch keeps the deletion progress view; a broad scan + // started here would be discarded by the removal's scan invalidation, so + // skip it while a removal batch is running (matches the reset guard above). + if (!loading && !autoScanAttemptedForOpenRef.current && !removalInFlightRef.current) { autoScanAttemptedForOpenRef.current = true startWorkspaceCleanupScan({ notifyWhenReady: true }) } - }, [loading, open, scan, startWorkspaceCleanupScan]) + }, [loading, open, startWorkspaceCleanupScan]) useEffect(() => { if (!open) { @@ -337,14 +348,17 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { }, [candidates, effectiveRepoSelection, eligibleRepoIds.length]) useEffect(() => { - if (!scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { + if (loading || !scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { return } selectedDefaultsScanAtRef.current = scan.scannedAt - setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates)) + if (removalInFlightRef.current) { + return + } + setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds)) setConfirming(false) setRowFailures({}) - }, [scan]) + }, [deletingWorktreeIds, loading, scan]) const visibleCandidates = useMemo(() => { const rows = filteredCandidates.filter((candidate) => !candidate.blockers.includes('dismissed')) @@ -426,9 +440,11 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { .map((id) => byId.get(id)) .filter( (candidate): candidate is WorkspaceCleanupCandidate => - candidate != null && canQueueWorkspaceCleanupCandidate(candidate) + candidate != null && + canQueueWorkspaceCleanupCandidate(candidate) && + !deletingWorktreeIds.has(candidate.worktreeId) ) - }, [activeRows, selectedIds]) + }, [activeRows, deletingWorktreeIds, selectedIds]) useEffect(() => { if (!open || confirming) { return @@ -436,18 +452,20 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { // Why: destructive selection must stay scoped to the rows the user can // currently review after tier/filter changes. setSelectedIds((current) => { - const next = new Set([...current].filter((id) => activeRowIds.has(id))) + const next = new Set( + [...current].filter((id) => activeRowIds.has(id) && !deletingWorktreeIds.has(id)) + ) return next.size === current.size ? current : next }) - }, [activeRowIds, confirming, open]) + }, [activeRowIds, confirming, deletingWorktreeIds, open]) const handleOpenChange = useCallback( (nextOpen: boolean) => { - if (!nextOpen && !removing) { + if (!nextOpen) { closeModal() } }, - [closeModal, removing] + [closeModal] ) const refresh = useCallback(() => { @@ -483,65 +501,114 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { [dismissCandidates, mountedRef] ) - const confirmRemove = useCallback(async () => { - if (selectedCandidates.length === 0) { + const toggleExpandedRow = useCallback((worktreeId: string) => { + setExpandedRowIds((current) => toggleSetMember(current, worktreeId)) + }, []) + + const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { + const nextCandidates = filterWorkspaceCleanupRemovalCandidates( + candidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (nextCandidates.length === 0) { return } - setRemoving(true) - setRowFailures({}) - try { - const result = await removeCandidates( - selectedCandidates.map((candidate) => candidate.worktreeId) - ) - const nextFailures: Record = {} - for (const failure of result.failures) { - nextFailures[failure.worktreeId] = failure.message - } - if (mountedRef.current) { - setRowFailures(nextFailures) - setSelectedIds((current) => { - const next = new Set(current) - for (const id of result.removedIds) { - next.delete(id) - } - return next - }) - } - if (result.removedIds.length > 0) { - if (mountedRef.current) { - toast.success( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0f00612b6d', - 'Removed {{value0}} workspace{{value1}}', - { - value0: result.removedIds.length, - value1: result.removedIds.length === 1 ? '' : 's' - } - ) - ) - } - } - if (result.failures.length > 0) { - if (mountedRef.current) { - toast.error( - translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.41d594d01e', - '{{value0}} workspace{{value1}} could not be removed', - { value0: result.failures.length, value1: result.failures.length === 1 ? '' : 's' } - ) - ) - } - } else { - if (mountedRef.current) { - setConfirming(false) - } - } - } finally { - if (mountedRef.current) { - setRemoving(false) - } + setConfirmCandidates(nextCandidates) + setConfirming(true) + }, []) + + const cancelConfirmRemove = useCallback(() => { + if (removalProgress) { + closeModal() + return } - }, [mountedRef, removeCandidates, selectedCandidates]) + setConfirming(false) + setConfirmCandidates([]) + }, [closeModal, removalProgress]) + + const clearQueuedDeleteState = useCallback( + (worktreeId: string) => { + const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId] + // Why: candidates that fail before removal starts would otherwise stay + // marked "Queued for deletion" in the sidebar; rows already in the + // 'deleting' phase or failed with an error keep their own state. + if (deleteState?.isDeleting && deleteState.error === null && deleteState.phase === 'queued') { + clearWorktreeDeleteState(worktreeId) + } + }, + [clearWorktreeDeleteState] + ) + + const confirmRemove = useCallback(() => { + if (confirmCandidates.length === 0 || removalInFlightRef.current) { + return + } + const removableCandidates = filterWorkspaceCleanupRemovalCandidates( + confirmCandidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (removableCandidates.length === 0) { + setConfirming(false) + setConfirmCandidates([]) + return + } + removalInFlightRef.current = true + setRowFailures({}) + markWorktreesQueuedForDeletion(removableCandidates.map((candidate) => candidate.worktreeId)) + startWorkspaceCleanupBackgroundRemoval({ + candidates: removableCandidates, + removeCandidates, + onProgress: (progress) => { + if (mountedRef.current) { + setRemovalProgress(progress) + } + }, + onRowFailed: (failure) => { + clearQueuedDeleteState(failure.worktreeId) + }, + onResult: (result) => { + const nextFailures: Record = {} + for (const failure of result.failures) { + nextFailures[failure.worktreeId] = failure.message + clearQueuedDeleteState(failure.worktreeId) + } + if (mountedRef.current) { + setRowFailures(nextFailures) + setSelectedIds((current) => { + const next = new Set(current) + for (const id of result.removedIds) { + next.delete(id) + } + return next + }) + } + if (mountedRef.current) { + setRemovalProgress(null) + setConfirming(false) + setConfirmCandidates([]) + } + removalInFlightRef.current = false + }, + onError: () => { + for (const candidate of removableCandidates) { + clearWorktreeDeleteState(candidate.worktreeId) + } + if (mountedRef.current) { + setRemovalProgress(null) + setConfirming(false) + setConfirmCandidates([]) + } + removalInFlightRef.current = false + } + }) + }, [ + clearQueuedDeleteState, + clearWorktreeDeleteState, + confirmCandidates, + markWorktreesQueuedForDeletion, + mountedRef, + removeCandidates + ]) const selectedCount = selectedCandidates.length @@ -594,7 +661,6 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { 'Close' )} onClick={() => closeModal()} - disabled={removing} > @@ -649,8 +715,8 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { - ) : ( - - - ) -} - -function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { - label: string - tone: 'neutral' | 'ready' | 'review' | 'destructive' -} { - if (candidate.blockers.includes('dismissed')) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', - 'Ignored' - ), - tone: 'neutral' - } - } - if (candidate.tier === 'ready') { - return { label: candidate.reasons.includes('archived') ? 'Archived' : 'Clean', tone: 'ready' } - } - if (candidate.blockers.length > 0) { - return { label: BLOCKER_LABELS[candidate.blockers[0]], tone: 'neutral' } - } - if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d', - 'Unpushed commits' - ), - tone: 'review' - } - } - if (candidate.git.clean === false) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e97e4580c7', - 'Dirty' - ), - tone: 'review' - } - } - if (candidate.tier === 'review') { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0a2e3c7cba', - 'Review' - ), - tone: 'review' - } - } - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4f4782c02', - 'Not suggested' - ), - tone: 'neutral' - } -} - -function formatGitStatus(candidate: WorkspaceCleanupCandidate): string { - const label = getWorkspaceCleanupGitLabel(candidate) - switch (label) { - case 'Clean': - return 'Clean git' - case 'Dirty': - return 'Dirty git' - case 'Unpushed': - return 'Unpushed commits' - case 'Unknown': - return 'Git unknown' - } - return 'Git unknown' -} - -function formatBranchSafetyDetails(candidate: WorkspaceCleanupCandidate): string[] { - const details: string[] = [] - if (candidate.git.upstreamAhead !== null) { - details.push( - candidate.git.upstreamAhead === 0 - ? 'No unpushed commits' - : `${candidate.git.upstreamAhead} unpushed commit${ - candidate.git.upstreamAhead === 1 ? '' : 's' - }` - ) - } - return details -} - -function formatContextDetails(candidate: WorkspaceCleanupCandidate): string | null { - const parts: string[] = [] - if (candidate.localContext.terminalTabCount > 0) { - parts.push( - `${candidate.localContext.terminalTabCount} terminal tab${ - candidate.localContext.terminalTabCount === 1 ? '' : 's' - }` - ) - } - if (candidate.localContext.cleanEditorTabCount > 0) { - parts.push( - `${candidate.localContext.cleanEditorTabCount} editor tab${ - candidate.localContext.cleanEditorTabCount === 1 ? '' : 's' - }` - ) - } - if (candidate.localContext.browserTabCount > 0) { - parts.push( - `${candidate.localContext.browserTabCount} browser tab${ - candidate.localContext.browserTabCount === 1 ? '' : 's' - }` - ) - } - if (candidate.localContext.diffCommentCount > 0) { - parts.push( - `${candidate.localContext.diffCommentCount} diff note${ - candidate.localContext.diffCommentCount === 1 ? '' : 's' - }` - ) - } - if (candidate.localContext.retainedDoneAgentCount > 0) { - parts.push( - `${candidate.localContext.retainedDoneAgentCount} completed agent${ - candidate.localContext.retainedDoneAgentCount === 1 ? '' : 's' - }` - ) - } - return parts.length > 0 ? parts.join(', ') : null -} - function ConfirmRemove({ candidates, reviewInfoByWorktreeId, - removing, + progress, onCancel, onConfirm }: { candidates: WorkspaceCleanupCandidate[] reviewInfoByWorktreeId: ReadonlyMap - removing: boolean + progress: WorkspaceCleanupRemovalProgress | null onCancel: () => void onConfirm: () => void }): React.JSX.Element { const count = candidates.length - const noun = count === 1 ? 'workspace' : 'workspaces' + const deleting = progress !== null + const progressValue = progress + ? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100)) + : 0 return ( <> -
-
- -
-
- - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.cbf2f664e2', - 'Delete' - )}{' '} - {count} {noun}? - - - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38ca0b1400', - "This permanently deletes their local files. You can't undo this." +
+
+
+ {deleting ? ( + + ) : ( + )} - +
+
+ + {deleting + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deletingCount', + 'Deleting workspaces: {{value0}}', + { value0: count } + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deleteCount', + 'Delete workspaces: {{value0}}?', + { value0: count } + )} + + + {deleting + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1d3503357d', + 'You can close this and come back while deletion continues.' + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38ca0b1400', + "This permanently deletes their local files. You can't undo this." + )} + +
+
+ {progress ? ( +
+
+ + + {formatWorkspaceCleanupRemovalProgress(progress)} + +
+ +
+ ) : null}
- {count} {noun}{' '} {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.dba753e94f', - 'to delete' + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.selectedForDeletionCount', + 'Selected for deletion: {{value0}}', + { value0: count } )}
@@ -1541,20 +1327,27 @@ function ConfirmRemove({
- - + {!deleting ? ( + + ) : null} ) @@ -1572,6 +1365,7 @@ function ConfirmRemoveRow({ const dirtyLabel = getDirtyGitLabel(candidate) const branchDiffersFromName = candidate.branch !== candidate.displayName const contextPillLabel = getContextPillLabel(candidate) + const showGitMetadataChip = shouldShowGitMetadataChip(candidate) const status = getCandidateStatus(candidate) return (
@@ -1589,7 +1383,9 @@ function ConfirmRemoveRow({ {reviewInfo.label} ) : null} {contextPillLabel ? {contextPillLabel} : null} - {dirtyLabel ? {dirtyLabel} : null} + {dirtyLabel && showGitMetadataChip ? ( + {dirtyLabel} + ) : null}
{candidate.repoName} @@ -1607,55 +1403,6 @@ function ConfirmRemoveRow({ ) } -function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | null { - if (candidate.blockers.includes('unpushed-commits')) { - if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { - return `${candidate.git.upstreamAhead} unpushed commit${ - candidate.git.upstreamAhead === 1 ? '' : 's' - }` - } - return 'Unpushed commits' - } - if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { - return `${candidate.git.upstreamAhead} unpushed commit${ - candidate.git.upstreamAhead === 1 ? '' : 's' - }` - } - if (candidate.git.clean === false) { - return 'Uncommitted changes' - } - if ( - candidate.git.clean == null || - candidate.blockers.includes('unknown-base') || - candidate.blockers.includes('git-status-error') - ) { - return 'Git status unknown' - } - return null -} - -function getReviewPillTone( - reviewInfo: WorkspaceCleanupReviewInfo -): 'neutral' | 'ready' | 'review' | 'destructive' { - if (reviewInfo.state === 'open' || reviewInfo.state === 'draft') { - return 'review' - } - return 'neutral' -} - -function getContextPillLabel(candidate: WorkspaceCleanupCandidate): string | null { - if (!hasWorkspaceCleanupLocalContext(candidate)) { - return null - } - const count = - candidate.localContext.terminalTabCount + - candidate.localContext.cleanEditorTabCount + - candidate.localContext.browserTabCount + - candidate.localContext.diffCommentCount + - candidate.localContext.retainedDoneAgentCount - return `${count} context` -} - function hasActiveWorkspaceCleanupFilters(filters: WorkspaceCleanupFilters): boolean { return ( filters.query.trim() !== '' || @@ -1682,11 +1429,14 @@ function hasActiveWorkspaceCleanupPanelControls( } function getDefaultSelectedWorkspaceCleanupIds( - candidates: readonly WorkspaceCleanupCandidate[] + candidates: readonly WorkspaceCleanupCandidate[], + deletingWorktreeIds: ReadonlySet = new Set() ): Set { return new Set( candidates - .filter((candidate) => candidate.selectedByDefault) + .filter( + (candidate) => candidate.selectedByDefault && !deletingWorktreeIds.has(candidate.worktreeId) + ) .map((candidate) => candidate.worktreeId) ) } @@ -1703,21 +1453,40 @@ function formatWorkspaceCleanupReadyToastDescription( return `${inactiveCount} inactive ${inactiveNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.` } +function formatWorkspaceCleanupRemovalProgress(progress: WorkspaceCleanupRemovalProgress): string { + const deletedText = translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4c2990886e', + '{{value0}}/{{value1}} deleted', + { + value0: progress.removedCount, + value1: progress.totalCount + } + ) + if (progress.failedCount === 0) { + return deletedText + } + return translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.86ba852118', + '{{value0}}, {{value1}} failed', + { + value0: deletedText, + value1: progress.failedCount + } + ) +} + function formatWorkspaceCleanupProgress(progress: WorkspaceCleanupScanProgress | null): string { - if (!progress || progress.totalWorktreeCount === 0) { + if (!progress || progress.scannedWorktreeCount === 0) { return translate( 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe', 'Finding inactive workspaces...' ) } - const noun = progress.totalWorktreeCount === 1 ? 'workspace' : 'workspaces' return translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.5bf2e88480', - '{{value0}}/{{value1}} {{value2}} scanned', + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7b7bde5181', + 'Checked workspaces so far: {{value0}}', { - value0: progress.scannedWorktreeCount, - value1: progress.totalWorktreeCount, - value2: noun + value0: progress.scannedWorktreeCount } ) } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts new file mode 100644 index 00000000000..abe1c5ab396 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts @@ -0,0 +1,424 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { + startWorkspaceCleanupBackgroundRemoval, + type WorkspaceCleanupBackgroundRemovalArgs +} from './workspace-cleanup-background-removal' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + success: vi.fn() + } +})) + +async function settleBackgroundRemoval(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve() + } +} + +describe('startWorkspaceCleanupBackgroundRemoval', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('reports an empty result without starting removal when there are no candidates', async () => { + const removeCandidates = vi.fn() + const onProgress = vi.fn() + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [], + removeCandidates, + onProgress, + onResult + }) + await settleBackgroundRemoval() + + expect(removeCandidates).not.toHaveBeenCalled() + expect(onProgress).not.toHaveBeenCalled() + expect(onResult).toHaveBeenCalledWith({ removedIds: [], failures: [] }) + expect(toast.success).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('reports deletion progress while the slow removal promise is pending', async () => { + let resolveRemoval: ( + result: Awaited> + ) => void + const removeCandidates = vi.fn( + () => + new Promise>>( + (resolve) => { + resolveRemoval = resolve + } + ) + ) + const onProgress = vi.fn() + const onResult = vi.fn() + const candidate = makeCandidate() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates, + onProgress, + onResult + }) + + expect(removeCandidates).toHaveBeenCalledWith([candidate.worktreeId], { + approvedCandidates: [candidate] + }) + expect(onProgress).toHaveBeenCalledWith({ + totalCount: 1, + processedCount: 0, + removedCount: 0, + failedCount: 0 + }) + expect(onResult).not.toHaveBeenCalled() + + resolveRemoval!({ removedIds: [candidate.worktreeId], failures: [] }) + await settleBackgroundRemoval() + + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 1, + processedCount: 1, + removedCount: 1, + failedCount: 0 + }) + expect(toast.success).toHaveBeenCalled() + expect(onResult).toHaveBeenCalledWith({ removedIds: [candidate.worktreeId], failures: [] }) + }) + + it('removes candidates one at a time for per-row progress', async () => { + const first = makeCandidate() + const second = makeCandidate({ + worktreeId: 'repo-1::/repo/beta', + displayName: 'beta', + branch: 'beta', + path: '/repo/beta' + }) + const removeCandidates = vi + .fn() + .mockResolvedValueOnce({ removedIds: [first.worktreeId], failures: [] }) + .mockResolvedValueOnce({ removedIds: [second.worktreeId], failures: [] }) + const onProgress = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [first, second], + removeCandidates, + onProgress + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [first.worktreeId], { + approvedCandidates: [first] + }) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [second.worktreeId], { + approvedCandidates: [second] + }) + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 2, + processedCount: 2, + removedCount: 2, + failedCount: 0 + }) + }) + + it('removes nested candidates before their parent workspace', async () => { + const parent = makeCandidate({ + worktreeId: 'repo-1::/repo/parent', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child' + }) + const removeCandidates = vi.fn(async (worktreeIds: readonly string[]) => ({ + removedIds: [...worktreeIds], + failures: [] + })) + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress: vi.fn() + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [child.worktreeId], { + approvedCandidates: [child] + }) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], { + approvedCandidates: [parent] + }) + }) + + it('skips an ancestor after a nested workspace removal fails', async () => { + const parent = makeCandidate({ + worktreeId: 'repo-1::C:\\repo\\parent', + displayName: 'parent', + branch: 'parent', + path: 'C:\\repo\\parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::C:\\repo\\parent\\child', + displayName: 'child', + branch: 'child', + path: 'C:\\repo\\parent\\child' + }) + const removeCandidates = vi.fn().mockResolvedValueOnce({ + removedIds: [], + failures: [{ worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }] + }) + const onProgress = vi.fn() + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress, + onResult + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenCalledTimes(1) + expect(removeCandidates).toHaveBeenCalledWith([child.worktreeId], { + approvedCandidates: [child] + }) + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 2, + processedCount: 2, + removedCount: 0, + failedCount: 2 + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [], + failures: [ + { worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }, + { + worktreeId: parent.worktreeId, + displayName: parent.displayName, + message: 'Skipped because a nested workspace could not be removed.' + } + ] + }) + }) + + it('reports each failure as it happens so queued rows can clear before the batch ends', async () => { + const parent = makeCandidate({ + worktreeId: 'repo-1::/repo/parent', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child' + }) + const removeCandidates = vi.fn().mockResolvedValueOnce({ + removedIds: [], + failures: [{ worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }] + }) + const onRowFailed = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress: vi.fn(), + onRowFailed + }) + await settleBackgroundRemoval() + + // Child fails at removal (per-row result), then parent is skipped as its + // ancestor — both reported incrementally, not only in the final result. + expect(onRowFailed.mock.calls.map(([failure]) => failure.worktreeId)).toEqual([ + child.worktreeId, + parent.worktreeId + ]) + }) + + it('does not skip same-path ancestors from another connection after a nested failure', async () => { + const failedChild = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child', + connectionId: 'ssh-a' + }) + const unrelatedParent = makeCandidate({ + worktreeId: 'repo-2::/repo/parent', + repoId: 'repo-2', + repoName: 'Repo 2', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent', + connectionId: 'ssh-b' + }) + const removeCandidates = vi + .fn() + .mockResolvedValueOnce({ + removedIds: [], + failures: [ + { + worktreeId: failedChild.worktreeId, + displayName: failedChild.displayName, + message: 'busy' + } + ] + }) + .mockResolvedValueOnce({ removedIds: [unrelatedParent.worktreeId], failures: [] }) + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [unrelatedParent, failedChild], + removeCandidates, + onProgress: vi.fn(), + onResult + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [failedChild.worktreeId], { + approvedCandidates: [failedChild] + }) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [unrelatedParent.worktreeId], { + approvedCandidates: [unrelatedParent] + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [unrelatedParent.worktreeId], + failures: [{ worktreeId: failedChild.worktreeId, displayName: 'child', message: 'busy' }] + }) + }) + + it('reports removal failures after dismissing the pending toast', async () => { + const candidate = makeCandidate() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates: vi.fn().mockResolvedValue({ + removedIds: [], + failures: [ + { worktreeId: candidate.worktreeId, displayName: candidate.displayName, message: 'busy' } + ] + }), + onProgress: vi.fn() + }) + await settleBackgroundRemoval() + + expect(toast.error).toHaveBeenCalledWith( + 'Workspaces not removed: 1', + expect.objectContaining({ description: 'busy' }) + ) + }) + + it('times out a stalled row removal and continues reporting progress', async () => { + vi.useFakeTimers() + const candidate = makeCandidate() + const onProgress = vi.fn() + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates: vi.fn( + () => + new Promise< + Awaited> + >(() => undefined) + ), + onProgress, + onResult, + removalTimeoutMs: 5 + }) + + await vi.advanceTimersByTimeAsync(5) + await settleBackgroundRemoval() + + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 1, + processedCount: 1, + removedCount: 0, + failedCount: 1 + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [], + failures: [ + { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: + 'Removing alpha is taking longer than expected. It will keep running in the background.' + } + ] + }) + }) + + it('keeps removal outcome toasts when the result callback throws', async () => { + const candidate = makeCandidate() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates: vi.fn().mockResolvedValue({ + removedIds: [candidate.worktreeId], + failures: [] + }), + onProgress: vi.fn(), + onResult: () => { + throw new Error('callback failed') + }, + onError: vi.fn() + }) + await settleBackgroundRemoval() + + expect(toast.success).toHaveBeenCalledWith('Removed workspaces: 1') + expect(toast.error).not.toHaveBeenCalledWith( + 'Workspace cleanup failed', + expect.objectContaining({ description: 'callback failed' }) + ) + }) + + it('shows every failure message in the failure toast description', async () => { + const first = makeCandidate() + const second = makeCandidate({ + worktreeId: 'repo-1::/repo/beta', + displayName: 'beta', + branch: 'beta', + path: '/repo/beta' + }) + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [first, second], + removeCandidates: vi + .fn() + .mockResolvedValueOnce({ + removedIds: [], + failures: [ + { worktreeId: first.worktreeId, displayName: first.displayName, message: 'busy' } + ] + }) + .mockResolvedValueOnce({ + removedIds: [], + failures: [ + { worktreeId: second.worktreeId, displayName: second.displayName, message: 'dirty' } + ] + }), + onProgress: vi.fn() + }) + await settleBackgroundRemoval() + + expect(toast.error).toHaveBeenCalledWith( + 'Workspaces not removed: 2', + expect.objectContaining({ description: 'busy; dirty' }) + ) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts new file mode 100644 index 00000000000..9823aa04033 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -0,0 +1,236 @@ +import { toast } from 'sonner' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison +} from '../../../../shared/cross-platform-path' +import type { + WorkspaceCleanupFailure, + WorkspaceCleanupRemoveOptions, + WorkspaceCleanupRemoveResult +} from '@/store/slices/workspace-cleanup' +import { translate } from '@/i18n/i18n' + +const DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS = 120_000 + +export type WorkspaceCleanupRemovalProgress = { + totalCount: number + processedCount: number + removedCount: number + failedCount: number +} + +export type WorkspaceCleanupBackgroundRemovalArgs = { + candidates: readonly WorkspaceCleanupCandidate[] + removeCandidates: ( + worktreeIds: readonly string[], + options?: WorkspaceCleanupRemoveOptions + ) => Promise + onProgress: (progress: WorkspaceCleanupRemovalProgress) => void + onResult?: (result: WorkspaceCleanupRemoveResult) => void + onError?: (error: unknown) => void + // Why: a row can fail before its removal starts (preflight failure or a + // skipped nested workspace); report it now so its queued overlay can clear + // instead of waiting for the whole batch to settle. + onRowFailed?: (failure: WorkspaceCleanupFailure) => void + removalTimeoutMs?: number +} + +export function startWorkspaceCleanupBackgroundRemoval({ + candidates, + removeCandidates, + onProgress, + onResult, + onError, + onRowFailed, + removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS +}: WorkspaceCleanupBackgroundRemovalArgs): void { + if (candidates.length === 0) { + try { + onResult?.({ removedIds: [], failures: [] }) + } catch (callbackError) { + console.error('Workspace cleanup result callback failed', callbackError) + } + return + } + + const count = candidates.length + const removedIds: string[] = [] + const failures: WorkspaceCleanupFailure[] = [] + const failedCandidates: WorkspaceCleanupCandidate[] = [] + let processedCount = 0 + + const emitProgress = (): void => { + onProgress({ + totalCount: count, + processedCount, + removedCount: removedIds.length, + failedCount: failures.length + }) + } + + const reportFailures = (rowFailures: readonly WorkspaceCleanupFailure[]): void => { + for (const failure of rowFailures) { + failures.push(failure) + try { + onRowFailed?.(failure) + } catch (callbackError) { + console.error('Workspace cleanup row failure callback failed', callbackError) + } + } + } + + emitProgress() + + // Why: keep the store's nested-worktree delete invariant even though progress + // is emitted per row; children must be removed before parent workspaces. + const candidatesInRemovalOrder = [...candidates].sort((a, b) => b.path.length - a.path.length) + + void (async () => { + for (const candidate of candidatesInRemovalOrder) { + if ( + failedCandidates.some((failedCandidate) => + isStrictWorkspaceCleanupDescendant(candidate, failedCandidate) + ) + ) { + failedCandidates.push(candidate) + reportFailures([ + { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: translate( + 'auto.components.workspace.cleanup.backgroundRemoval.skippedAncestor', + 'Skipped because a nested workspace could not be removed.' + ) + } + ]) + processedCount += 1 + emitProgress() + continue + } + try { + const result = await withWorkspaceCleanupRemovalTimeout( + removeCandidates([candidate.worktreeId], { approvedCandidates: [candidate] }), + candidate, + removalTimeoutMs + ) + removedIds.push(...result.removedIds) + reportFailures(result.failures) + if (result.failures.length > 0) { + failedCandidates.push(candidate) + } + } catch (error: unknown) { + failedCandidates.push(candidate) + reportFailures([ + { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: error instanceof Error ? error.message : String(error) + } + ]) + } finally { + processedCount += 1 + emitProgress() + } + } + + const result = { removedIds, failures } + try { + onResult?.(result) + } catch (callbackError) { + console.error('Workspace cleanup result callback failed', callbackError) + } + + if (result.removedIds.length > 0) { + toast.success( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.removed', + 'Removed workspaces: {{value0}}', + { + value0: result.removedIds.length + } + ) + ) + } + + if (result.failures.length > 0) { + toast.error( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.failed', + 'Workspaces not removed: {{value0}}', + { + value0: result.failures.length + } + ), + { + description: result.failures.map((failure) => failure.message).join('; ') + } + ) + } + })().catch((error: unknown) => { + onError?.(error) + toast.error( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.error', + 'Workspace cleanup failed' + ), + { + description: error instanceof Error ? error.message : String(error) + } + ) + }) +} + +async function withWorkspaceCleanupRemovalTimeout( + promise: Promise, + candidate: WorkspaceCleanupCandidate, + timeoutMs: number +): Promise { + if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) { + return promise + } + + let timeout: ReturnType | null = null + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + // Why: the underlying removal cannot be cancelled from the renderer, + // so the row stays "Deleting" and this message must not claim the + // removal stopped. + reject( + new Error( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.timedOut', + 'Removing {{value0}} is taking longer than expected. It will keep running in the background.', + { value0: candidate.displayName } + ) + ) + ) + }, timeoutMs) + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +function isStrictWorkspaceCleanupDescendant( + parent: WorkspaceCleanupCandidate, + child: WorkspaceCleanupCandidate +): boolean { + return ( + parent.connectionId === child.connectionId && + isStrictWorkspaceCleanupDescendantPath(parent.path, child.path) + ) +} + +function isStrictWorkspaceCleanupDescendantPath(parentPath: string, childPath: string): boolean { + return ( + normalizeRuntimePathForComparison(parentPath) !== + normalizeRuntimePathForComparison(childPath) && isPathInsideOrEqual(parentPath, childPath) + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts new file mode 100644 index 00000000000..78b98f9b4d0 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts @@ -0,0 +1,176 @@ +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupBlocker } from '../../../../shared/workspace-cleanup' + +type ContextDetailKind = 'terminal' | 'editor' | 'browser' | 'diff' | 'agent' + +export function getWorkspaceCleanupBlockerLabel(blocker: WorkspaceCleanupBlocker): string { + switch (blocker) { + case 'main-worktree': + return translate( + 'auto.components.workspace.cleanup.candidateRow.mainWorkspaceBlocker', + 'Main workspace' + ) + case 'folder-repo': + return translate( + 'auto.components.workspace.cleanup.candidateRow.folderProjectBlocker', + 'Folder project' + ) + case 'pinned': + return translate('auto.components.workspace.cleanup.candidateRow.pinnedBlocker', 'Pinned') + case 'active-workspace': + return translate( + 'auto.components.workspace.cleanup.candidateRow.activeWorkspaceBlocker', + 'Active workspace' + ) + case 'running-terminal': + return translate( + 'auto.components.workspace.cleanup.candidateRow.runningTerminalBlocker', + 'Running terminal process' + ) + case 'terminal-liveness-unknown': + return translate( + 'auto.components.workspace.cleanup.candidateRow.terminalLivenessUnknownBlocker', + 'Terminal liveness unknown' + ) + case 'dirty-editor-buffer': + return translate( + 'auto.components.workspace.cleanup.candidateRow.dirtyEditorBufferBlocker', + 'Unsaved editor buffer' + ) + case 'volatile-local-context': + return translate( + 'auto.components.workspace.cleanup.candidateRow.volatileLocalContextBlocker', + 'Volatile local context' + ) + case 'recent-visible-context': + return translate( + 'auto.components.workspace.cleanup.candidateRow.recentVisibleContextBlocker', + 'Recently visited tabs' + ) + case 'live-agent': + return translate( + 'auto.components.workspace.cleanup.candidateRow.liveAgentBlocker', + 'Active agent' + ) + case 'ssh-disconnected': + return translate( + 'auto.components.workspace.cleanup.candidateRow.sshDisconnectedBlocker', + 'Remote unavailable' + ) + case 'git-status-error': + return translate( + 'auto.components.workspace.cleanup.candidateRow.gitStatusErrorBlocker', + 'Git status unavailable' + ) + case 'dirty-files': + return translate( + 'auto.components.workspace.cleanup.candidateRow.dirtyFilesBlocker', + 'Changed files' + ) + case 'unpushed-commits': + return getUnpushedCommitsLabel() + case 'unknown-base': + return translate( + 'auto.components.workspace.cleanup.candidateRow.unknownBaseBlocker', + 'Could not verify unpushed commits' + ) + case 'dismissed': + return translate('auto.components.workspace.cleanup.candidateRow.dismissedBlocker', 'Ignored') + } +} + +export function formatWorkspaceCleanupGitStatusLabel(label: string): string { + switch (label) { + case 'Clean': + return translate('auto.components.workspace.cleanup.candidateRow.cleanGit', 'Clean git') + case 'Dirty': + return translate('auto.components.workspace.cleanup.candidateRow.dirtyGit', 'Dirty git') + case 'Unpushed': + return getUnpushedCommitsLabel() + case 'Unknown': + return translate('auto.components.workspace.cleanup.candidateRow.gitUnknown', 'Git unknown') + } + return translate('auto.components.workspace.cleanup.candidateRow.gitUnknown', 'Git unknown') +} + +export function getNoUnpushedCommitsLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.noUnpushedCommits', + 'No unpushed commits' + ) +} + +export function getUnpushedCommitsLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.unpushedCommits', + 'Unpushed commits' + ) +} + +export function formatUnpushedCommitCount(count: number): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.unpushedCommitsCount', + 'Unpushed commits: {{value0}}', + { value0: count } + ) +} + +export function getUncommittedChangesLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.uncommittedChanges', + 'Uncommitted changes' + ) +} + +export function getGitStatusUnknownLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.gitStatusUnknown', + 'Git status unknown' + ) +} + +export function formatWorkspaceCleanupContextDetail( + kind: ContextDetailKind, + count: number +): string { + switch (kind) { + case 'terminal': + return translate( + 'auto.components.workspace.cleanup.candidateRow.terminalTabsCount', + 'Terminal tabs: {{value0}}', + { value0: count } + ) + case 'editor': + return translate( + 'auto.components.workspace.cleanup.candidateRow.editorTabsCount', + 'Editor tabs: {{value0}}', + { value0: count } + ) + case 'browser': + return translate( + 'auto.components.workspace.cleanup.candidateRow.browserTabsCount', + 'Browser tabs: {{value0}}', + { value0: count } + ) + case 'diff': + return translate( + 'auto.components.workspace.cleanup.candidateRow.diffNotesCount', + 'Diff notes: {{value0}}', + { value0: count } + ) + case 'agent': + return translate( + 'auto.components.workspace.cleanup.candidateRow.completedAgentsCount', + 'Completed agents: {{value0}}', + { value0: count } + ) + } +} + +export function formatWorkspaceCleanupContextCount(count: number): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.contextCount', + 'Context: {{value0}}', + { value0: count } + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts new file mode 100644 index 00000000000..3cd5309ac54 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { getDirtyGitLabel, shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +describe('workspace cleanup candidate row data', () => { + it('does not duplicate git status blockers as a separate git icon label', () => { + const gitStatusError = makeCandidate({ + blockers: ['git-status-error'], + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + }) + const unknownBase = makeCandidate({ + blockers: ['unknown-base'], + git: { clean: true, upstreamAhead: null, upstreamBehind: null, checkedAt: 1 } + }) + + expect(getDirtyGitLabel(gitStatusError)).toBeNull() + expect(shouldShowGitMetadataChip(gitStatusError)).toBe(false) + expect(getDirtyGitLabel(unknownBase)).toBeNull() + expect(shouldShowGitMetadataChip(unknownBase)).toBe(false) + }) + + it('keeps the git metadata chip for ordinary clean rows', () => { + expect( + shouldShowGitMetadataChip( + makeCandidate({ + git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toBe(true) + }) + + it('suppresses the git metadata chip when the status pill already names git risk', () => { + expect( + shouldShowGitMetadataChip( + makeCandidate({ + blockers: ['unpushed-commits'], + git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toBe(false) + expect( + shouldShowGitMetadataChip( + makeCandidate({ + tier: 'review', + blockers: [], + git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toBe(false) + expect( + shouldShowGitMetadataChip( + makeCandidate({ + blockers: ['dirty-files'], + git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts new file mode 100644 index 00000000000..9e819d90017 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts @@ -0,0 +1,204 @@ +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + getWorkspaceCleanupGitLabel, + hasWorkspaceCleanupLocalContext, + type WorkspaceCleanupReviewInfo +} from './workspace-cleanup-presentation' +import { + formatUnpushedCommitCount, + formatWorkspaceCleanupContextCount, + formatWorkspaceCleanupContextDetail, + formatWorkspaceCleanupGitStatusLabel, + getGitStatusUnknownLabel, + getNoUnpushedCommitsLabel, + getUncommittedChangesLabel, + getUnpushedCommitsLabel, + getWorkspaceCleanupBlockerLabel +} from './workspace-cleanup-candidate-labels' + +export type StatusPillTone = 'neutral' | 'ready' | 'review' | 'destructive' + +export function getWorkspaceCleanupBlockerLabels(candidate: WorkspaceCleanupCandidate): string[] { + return candidate.blockers.map((blocker) => getWorkspaceCleanupBlockerLabel(blocker)) +} + +export function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { + label: string + tone: StatusPillTone +} { + if (candidate.blockers.includes('dismissed')) { + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', + 'Ignored' + ), + tone: 'neutral' + } + } + if (candidate.tier === 'ready') { + return { + label: candidate.reasons.includes('archived') + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', + 'Archived' + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.readyStatus', + 'Ready' + ), + tone: 'ready' + } + } + if (candidate.blockers.length > 0) { + return { label: getWorkspaceCleanupBlockerLabel(candidate.blockers[0]), tone: 'neutral' } + } + if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d', + 'Unpushed commits' + ), + tone: 'review' + } + } + if (candidate.git.clean === false) { + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e97e4580c7', + 'Dirty' + ), + tone: 'review' + } + } + if (candidate.tier === 'review') { + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0a2e3c7cba', + 'Review' + ), + tone: 'review' + } + } + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4f4782c02', + 'Not suggested' + ), + tone: 'neutral' + } +} + +export function formatGitStatus(candidate: WorkspaceCleanupCandidate): string { + return formatWorkspaceCleanupGitStatusLabel(getWorkspaceCleanupGitLabel(candidate)) +} + +export function formatBranchSafetyDetails(candidate: WorkspaceCleanupCandidate): string[] { + const details: string[] = [] + if (candidate.git.upstreamAhead !== null) { + details.push( + candidate.git.upstreamAhead === 0 + ? getNoUnpushedCommitsLabel() + : formatUnpushedCommitCount(candidate.git.upstreamAhead) + ) + } + return details +} + +export function formatContextDetails(candidate: WorkspaceCleanupCandidate): string | null { + const parts: string[] = [] + if (candidate.localContext.terminalTabCount > 0) { + parts.push( + formatWorkspaceCleanupContextDetail('terminal', candidate.localContext.terminalTabCount) + ) + } + if (candidate.localContext.cleanEditorTabCount > 0) { + parts.push( + formatWorkspaceCleanupContextDetail('editor', candidate.localContext.cleanEditorTabCount) + ) + } + if (candidate.localContext.browserTabCount > 0) { + parts.push( + formatWorkspaceCleanupContextDetail('browser', candidate.localContext.browserTabCount) + ) + } + if (candidate.localContext.diffCommentCount > 0) { + parts.push(formatWorkspaceCleanupContextDetail('diff', candidate.localContext.diffCommentCount)) + } + if (candidate.localContext.retainedDoneAgentCount > 0) { + parts.push( + formatWorkspaceCleanupContextDetail('agent', candidate.localContext.retainedDoneAgentCount) + ) + } + return parts.length > 0 ? parts.join(', ') : null +} + +export function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | null { + if ( + candidate.blockers.includes('unknown-base') || + candidate.blockers.includes('git-status-error') + ) { + return null + } + if (candidate.blockers.includes('unpushed-commits')) { + if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { + return formatUnpushedCommitCount(candidate.git.upstreamAhead) + } + return getUnpushedCommitsLabel() + } + if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { + return formatUnpushedCommitCount(candidate.git.upstreamAhead) + } + if (candidate.git.clean === false) { + return getUncommittedChangesLabel() + } + if (candidate.git.clean == null) { + return getGitStatusUnknownLabel() + } + return null +} + +export function shouldShowGitMetadataChip(candidate: WorkspaceCleanupCandidate): boolean { + return ( + !candidate.blockers.includes('unknown-base') && + !candidate.blockers.includes('git-status-error') && + !hasGitStatusPill(candidate) + ) +} + +function hasGitStatusPill(candidate: WorkspaceCleanupCandidate): boolean { + if ( + candidate.blockers.includes('dirty-files') || + candidate.blockers.includes('unpushed-commits') + ) { + return true + } + if (candidate.blockers.length > 0 || candidate.tier === 'ready') { + return false + } + return (candidate.git.upstreamAhead ?? 0) > 0 || candidate.git.clean === false +} + +export function getReviewPillTone(reviewInfo: WorkspaceCleanupReviewInfo): StatusPillTone { + if (reviewInfo.state === 'open' || reviewInfo.state === 'draft') { + return 'review' + } + return 'neutral' +} + +export function getContextPillLabel(candidate: WorkspaceCleanupCandidate): string | null { + if (!hasWorkspaceCleanupLocalContext(candidate)) { + return null + } + return formatWorkspaceCleanupContextCount(getContextCount(candidate)) +} + +export function getContextCount(candidate: WorkspaceCleanupCandidate): number { + return ( + candidate.localContext.terminalTabCount + + candidate.localContext.cleanEditorTabCount + + candidate.localContext.browserTabCount + + candidate.localContext.diffCommentCount + + candidate.localContext.retainedDoneAgentCount + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-details.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-details.tsx new file mode 100644 index 00000000000..8862d2ad1e4 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-details.tsx @@ -0,0 +1,107 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { formatGitStatus } from './workspace-cleanup-candidate-row-data' + +type CandidateRowDetailsProps = { + blockers: string[] + branchSafetyDetails: string[] + candidate: WorkspaceCleanupCandidate + contextDetails: string | null + expanded: boolean +} + +export function CandidateRowDetails({ + blockers, + branchSafetyDetails, + candidate, + contextDetails, + expanded +}: CandidateRowDetailsProps): React.JSX.Element { + return ( +
+
+
+
+ + + + {branchSafetyDetails.slice(0, 1).map((detail) => ( + + ))} + {contextDetails ? ( + + ) : null} + {blockers.length > 0 ? ( + + ) : null} +
+
+ {candidate.path} +
+
+
+
+ ) +} + +function DetailLine({ + label, + mono = false, + value +}: { + label: string + mono?: boolean + value: string +}): React.JSX.Element { + return ( +
+ + {label} + + {value} +
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx new file mode 100644 index 00000000000..b65e495a7ea --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CandidateRow } from './workspace-cleanup-candidate-row' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +describe('CandidateRow', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('hides selection and remove controls while the workspace is already deleting', () => { + const candidate = makeCandidate() + + act(() => { + root?.render( + + ) + }) + + expect(container?.querySelector(`[aria-label="Select ${candidate.displayName}"]`)).toBeNull() + expect(container?.querySelector(`[aria-label="Remove ${candidate.displayName}"]`)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx new file mode 100644 index 00000000000..04fb02d28c2 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -0,0 +1,342 @@ +import React from 'react' +import { + AlertTriangle, + Check, + ChevronDown, + Clock3, + EyeOff, + FileWarning, + GitBranch, + GitPullRequest, + Search, + SquareTerminal, + Trash2, + type LucideIcon +} from 'lucide-react' +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 { + canQueueWorkspaceCleanupCandidate, + type WorkspaceCleanupCandidate +} from '../../../../shared/workspace-cleanup' +import { + getWorkspaceCleanupGitLabel, + type WorkspaceCleanupReviewInfo +} from './workspace-cleanup-presentation' +import { CandidateRowDetails } from './workspace-cleanup-candidate-row-details' +import { + formatBranchSafetyDetails, + formatContextDetails, + formatGitStatus, + getCandidateStatus, + getContextCount, + getDirtyGitLabel, + getReviewPillTone, + getWorkspaceCleanupBlockerLabels, + shouldShowGitMetadataChip, + type StatusPillTone +} from './workspace-cleanup-candidate-row-data' +import { StatusPill } from './workspace-cleanup-status-pill' + +type CandidateRowProps = { + candidate: WorkspaceCleanupCandidate + expanded: boolean + failure?: string + last: boolean + lastActivityLabel: string + removing?: boolean + reviewInfo: WorkspaceCleanupReviewInfo + selected: boolean + onIgnore: (candidate: WorkspaceCleanupCandidate) => void + onRemove: (candidate: WorkspaceCleanupCandidate) => void + onToggleExpanded: (worktreeId: string) => void + onToggleSelected: (worktreeId: string) => void + onView: (candidate: WorkspaceCleanupCandidate) => void +} + +function MetadataIconChip({ + icon: Icon, + label, + value, + tone = 'neutral' +}: { + icon: LucideIcon + label: string + value?: string + tone?: StatusPillTone +}): React.JSX.Element { + return ( + + + + + + + {label} + + + ) +} + +export function CandidateRow({ + candidate, + expanded, + failure, + last, + lastActivityLabel, + removing = false, + reviewInfo, + selected, + onIgnore, + onRemove, + onToggleExpanded, + onToggleSelected, + onView +}: CandidateRowProps): React.JSX.Element { + const selectable = canQueueWorkspaceCleanupCandidate(candidate) && !removing + const ignored = candidate.blockers.includes('dismissed') + const blockers = getWorkspaceCleanupBlockerLabels(candidate) + const contextDetails = formatContextDetails(candidate) + const branchSafetyDetails = formatBranchSafetyDetails(candidate) + const status = getCandidateStatus(candidate) + const dirtyLabel = getDirtyGitLabel(candidate) + const showGitMetadataChip = shouldShowGitMetadataChip(candidate) + const contextCount = getContextCount(candidate) + const hasExpandableDetails = + blockers.length > 0 || + candidate.path.length > 0 || + candidate.branch.length > 0 || + contextDetails !== null || + branchSafetyDetails.length > 0 + + return ( +
+
+ {selectable ? ( + + ) : ( + +
+ ) +} + +function formatCompactActivityLabel(label: string): string { + if (label === 'Just now') { + return 'now' + } + return label.replace(/ ago$/, '') +} + +function getReviewTooltip(reviewInfo: WorkspaceCleanupReviewInfo): string { + const parts = [reviewInfo.label] + if (reviewInfo.state) { + parts.push(reviewInfo.state) + } + if (reviewInfo.title) { + parts.push(reviewInfo.title) + } + return parts.filter(Boolean).join(' · ') +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts new file mode 100644 index 00000000000..5b5f000c19c --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' +import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' + +describe('workspace cleanup removal candidates', () => { + it('excludes workspaces already being deleted', () => { + const deleting = makeCandidate({ worktreeId: 'repo-1::/repo/deleting' }) + const ready = makeCandidate({ worktreeId: 'repo-1::/repo/ready' }) + + expect( + filterWorkspaceCleanupRemovalCandidates([deleting, ready], { + [deleting.worktreeId]: { isDeleting: true } + }) + ).toEqual([ready]) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.ts new file mode 100644 index 00000000000..36c70257929 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.ts @@ -0,0 +1,13 @@ +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { WorktreeDeleteState } from '@/store/slices/worktrees' + +type DeletionFlagState = Pick + +export function filterWorkspaceCleanupRemovalCandidates( + candidates: readonly WorkspaceCleanupCandidate[], + deleteStateByWorktreeId: Record +): WorkspaceCleanupCandidate[] { + return candidates.filter( + (candidate) => deleteStateByWorktreeId[candidate.worktreeId]?.isDeleting !== true + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx new file mode 100644 index 00000000000..1e5786fda49 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import type { StatusPillTone } from './workspace-cleanup-candidate-row-data' + +export function StatusPill({ + children, + tone = 'neutral' +}: { + children: React.ReactNode + tone?: StatusPillTone +}): React.JSX.Element { + return ( + + {children} + + ) +} diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts index 0baa8a777e7..ed1a89aa634 100644 --- a/src/renderer/src/constants/terminal.ts +++ b/src/renderer/src/constants/terminal.ts @@ -8,6 +8,13 @@ export const REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT = 'orca-request-active-ter export const CLOSE_TERMINAL_PANE_EVENT = 'orca-close-terminal-pane' export const BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT = 'orca-background-mount-terminal-worktree' +// Why: mobile wake (experimental agent sleep) must fire the cold-restore +// --resume of a worktree's mounted hidden hibernated panes without a desktop +// hidden→visible reveal. Each mounted TerminalPane self-selects on this event +// by worktreeId and invokes its own armed hibernation wake — a fanout, since +// pane bindings are per-instance with no global registry. +export const WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT = 'orca-wake-hibernated-agents-worktree' + // Why: sidebar open/close is an instantaneous width change. If we wait for // the ResizeObserver rAF (and the 150ms debounced global fit) to catch up, // the user sees the terminal in a wrongly-fit state for ~16ms+ then a snap @@ -68,3 +75,7 @@ export type CloseTerminalPaneDetail = { export type BackgroundMountTerminalWorktreeDetail = { worktreeId: string } + +export type WakeHibernatedAgentsWorktreeDetail = { + worktreeId: string +} diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 0623acbba53..c4c51907ccf 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -978,6 +978,7 @@ describe('useIpcEvents browser tab create routing', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: ( @@ -1198,6 +1199,7 @@ describe('useIpcEvents updater integration', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -1441,6 +1443,7 @@ describe('useIpcEvents updater integration', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -1928,6 +1931,7 @@ describe('useIpcEvents updater integration', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -2779,6 +2783,7 @@ describe('useIpcEvents browser tab close routing', () => { return () => {} }, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -3257,6 +3262,7 @@ describe('useIpcEvents browser tab close routing', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -3473,6 +3479,7 @@ describe('useIpcEvents browser tab close routing', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -3684,6 +3691,7 @@ describe('useIpcEvents browser tab close routing', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -3922,6 +3930,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -4168,6 +4177,7 @@ describe('useIpcEvents CLI-created worktree activation', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, @@ -4400,6 +4410,7 @@ describe('useIpcEvents agent status snapshot integration', () => { onOpenDiffFromMobile: () => () => {}, onCloseTerminal: () => () => {}, onSleepWorktree: () => () => {}, + onResumeSleepingAgents: () => () => {}, onNewBrowserTab: () => () => {}, onNewMarkdownTab: () => () => {}, onRequestTabCreate: () => () => {}, diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index e318b7120d2..f918744e62c 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -9,6 +9,7 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item' import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow' import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow' +import { wakeSleepingAgentsForWorktreeInBackground } from '@/lib/wake-sleeping-agents-in-background' import { OPEN_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel' import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, @@ -1032,6 +1033,12 @@ export function useIpcEvents(): void { getDesiredEnvironmentIds: getRuntimeClientEventEnvironmentIds, subscribe: (environmentId, onEvent, onError) => subscribeRuntimeClientEvents(environmentId, onEvent, onError, () => { + // Why: worktreesChanged/reposChanged during the transport gap are + // lost, not queued. A quick drop can replay without ever flipping the + // env unreachable, so the reachability-transition refetch never runs + // and a server-created worktree stays invisible until relaunch + // (#7970). The scheduler debounces, so this stays cheap. + runtimeProjectRefreshScheduler.request(environmentId) if (isPairedWebClientWindow()) { return } @@ -1942,6 +1949,14 @@ export function useIpcEvents(): void { }) ) + unsubs.push( + window.api.ui.onResumeSleepingAgents(({ worktreeId }) => { + // Why: a phone opened this worktree; wake its slept agents on the host + // renderer navigation-free (no desktop worktree/tab/view change). + wakeSleepingAgentsForWorktreeInBackground(worktreeId) + }) + ) + // Hydrate initial update status then subscribe to changes window.api.updater.getStatus().then((status) => { useAppStore.getState().setUpdateStatus(status as UpdateStatus) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b167206b2fd..fd44e256e32 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "Workspace no longer exists." + "9d6e531da6": "Workspace no longer exists.", + "changedSinceConfirmation": "Workspace changed after confirmation. Refresh to review it before removing." } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "Context", "sortBy": "Sort by", - "sortDirection": "Direction" + "sortDirection": "Direction", + "1d3503357d": "You can close this and come back while deletion continues.", + "4c2990886e": "{{value0}}/{{value1}} deleted", + "86ba852118": "{{value0}}, {{value1}} failed", + "7b7bde5181": "Checked workspaces so far: {{value0}}", + "deletingCount": "Deleting workspaces: {{value0}}", + "deleteCount": "Delete workspaces: {{value0}}?", + "selectedForDeletionCount": "Selected for deletion: {{value0}}", + "deleteButtonCount": "Delete {{value0}}", + "archivedStatus": "Archived", + "readyStatus": "Ready" + }, + "backgroundRemoval": { + "removed": "Removed workspaces: {{value0}}", + "failed": "Workspaces not removed: {{value0}}", + "error": "Workspace cleanup failed", + "skippedAncestor": "Skipped because a nested workspace could not be removed.", + "timedOut": "Removing {{value0}} is taking longer than expected. It will keep running in the background." + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "Commits", + "contextLabel": "Context", + "flagsLabel": "Flags", + "collapseDetails": "Collapse details", + "expandDetails": "Expand details", + "cleanGit": "Clean git", + "dirtyGit": "Dirty git", + "unpushedCommits": "Unpushed commits", + "gitUnknown": "Git unknown", + "gitStatusUnknown": "Git status unknown", + "noUnpushedCommits": "No unpushed commits", + "unpushedCommitsCount": "Unpushed commits: {{value0}}", + "uncommittedChanges": "Uncommitted changes", + "terminalTabsCount": "Terminal tabs: {{value0}}", + "editorTabsCount": "Editor tabs: {{value0}}", + "browserTabsCount": "Browser tabs: {{value0}}", + "diffNotesCount": "Diff notes: {{value0}}", + "completedAgentsCount": "Completed agents: {{value0}}", + "contextCount": "Context: {{value0}}", + "mainWorkspaceBlocker": "Main workspace", + "folderProjectBlocker": "Folder project", + "pinnedBlocker": "Pinned", + "activeWorkspaceBlocker": "Active workspace", + "runningTerminalBlocker": "Running terminal process", + "terminalLivenessUnknownBlocker": "Terminal liveness unknown", + "dirtyEditorBufferBlocker": "Unsaved editor buffer", + "volatileLocalContextBlocker": "Volatile local context", + "recentVisibleContextBlocker": "Recently visited tabs", + "liveAgentBlocker": "Active agent", + "sshDisconnectedBlocker": "Remote unavailable", + "gitStatusErrorBlocker": "Git status unavailable", + "dirtyFilesBlocker": "Changed files", + "unknownBaseBlocker": "Could not verify unpushed commits", + "dismissedBlocker": "Ignored" } } }, @@ -2973,7 +3028,8 @@ "f077f586db": "Don't ask again", "c0e972d726": "Cancel", "06741a2f3d": "Open MiniMax usage details", - "3bbf140864": "MiniMax Usage" + "3bbf140864": "MiniMax Usage", + "remoteServerLabel": "Remote server" }, "StatusBarUsageEmptyCta": { "828c764a79": "Connect an account", @@ -4027,7 +4083,8 @@ "runtimeHostProject": "Project on Orca server", "automationCreated": "Created by automation", "branchIdentity": "Branch", - "branchFolderPathIdentity": "Branch or folder path" + "branchFolderPathIdentity": "Branch or folder path", + "ef18787206": "Queued for deletion" }, "WorktreeCardAgents": { "1b0a156717": "Agents" @@ -4735,7 +4792,13 @@ "435df0ee51": "Under Request Headers, copy the Cookie value.", "7492fb3bba": "Paste it here and click Save.", "9fec52de4b": "How to copy the cookie", - "4e32e030b2": "Stored locally. Orca sends it only to platform.minimax.io for usage refreshes." + "4e32e030b2": "Stored locally. Orca sends it only to platform.minimax.io for usage refreshes.", + "remoteServerFallback": "the remote server", + "loadAccountsFailed": "Could not load provider accounts.", + "remoteScopeAccounts": "Showing accounts managed by {{value0}}. Add or re-authenticate accounts on that server.", + "remoteScopeAuthContext": "Each account keeps its own sign-in context on {{value0}}.", + "remoteEmptyClaudeAccounts": "No managed Claude accounts on {{value0}}. It uses its system default Claude login; add accounts on that server.", + "remoteEmptyCodexAccounts": "No managed Codex accounts on {{value0}}. It uses its system default Codex login; add accounts on that server." }, "AdvancedPane": { "40b29e0bf3": "Restart", @@ -8842,6 +8905,26 @@ "reviewFirst": "Review first", "singular": "Global saves won't change 1 repository with its own recipe.", "tooltipTitle": "Repository overrides" + }, + "linear": { + "agent": { + "skill": { + "install": { + "cta": { + "copiedCommand": "Copied command.", + "copyFailed": "Failed to copy command.", + "skillLabel": "Agent skill:", + "checking": "Checking...", + "installed": "Installed", + "notInstalled": "Not installed", + "recheck": "Re-check", + "installedDescription": "Agent skill installed. To update it, run:", + "description": "Let your agents read and edit Linear tasks.", + "copyCommand": "Copy command" + } + } + } + } } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 383b51d76f6..b2dc0f16c3d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "El espacio de trabajo ya no existe." + "9d6e531da6": "El espacio de trabajo ya no existe.", + "changedSinceConfirmation": "El espacio de trabajo cambió después de la confirmación. Actualiza para revisarlo antes de eliminarlo." } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "Contexto", "sortBy": "Ordenar por", - "sortDirection": "Dirección" + "sortDirection": "Dirección", + "1d3503357d": "Puedes cerrar esto y volver mientras la eliminación continúa.", + "4c2990886e": "{{value0}}/{{value1}} eliminados", + "86ba852118": "{{value0}}, {{value1}} fallidos", + "7b7bde5181": "Espacios de trabajo comprobados hasta ahora: {{value0}}", + "deletingCount": "Eliminando espacios de trabajo: {{value0}}", + "deleteCount": "¿Eliminar espacios de trabajo: {{value0}}?", + "selectedForDeletionCount": "Seleccionados para eliminar: {{value0}}", + "deleteButtonCount": "Eliminar {{value0}}", + "archivedStatus": "Archivado", + "readyStatus": "Listo" + }, + "backgroundRemoval": { + "removed": "Espacios de trabajo eliminados: {{value0}}", + "failed": "Espacios de trabajo no eliminados: {{value0}}", + "error": "Error en la limpieza del espacio de trabajo", + "skippedAncestor": "Se omitió porque no se pudo eliminar un espacio de trabajo anidado.", + "timedOut": "La eliminación de {{value0}} está tardando más de lo esperado. Continuará ejecutándose en segundo plano." + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "Confirmaciones", + "contextLabel": "Contexto", + "flagsLabel": "Indicadores", + "collapseDetails": "Contraer detalles", + "expandDetails": "Expandir detalles", + "cleanGit": "Git limpio", + "dirtyGit": "Git con cambios", + "unpushedCommits": "Confirmaciones sin enviar", + "gitUnknown": "Git desconocido", + "gitStatusUnknown": "Estado de Git desconocido", + "noUnpushedCommits": "Sin confirmaciones sin enviar", + "unpushedCommitsCount": "Confirmaciones sin enviar: {{value0}}", + "uncommittedChanges": "Cambios sin confirmar", + "terminalTabsCount": "Pestañas de terminal: {{value0}}", + "editorTabsCount": "Pestañas de editor: {{value0}}", + "browserTabsCount": "Pestañas de navegador: {{value0}}", + "diffNotesCount": "Notas de diff: {{value0}}", + "completedAgentsCount": "Agentes completados: {{value0}}", + "contextCount": "Contexto: {{value0}}", + "mainWorkspaceBlocker": "Espacio de trabajo principal", + "folderProjectBlocker": "Proyecto de carpeta", + "pinnedBlocker": "Fijado", + "activeWorkspaceBlocker": "Espacio de trabajo activo", + "runningTerminalBlocker": "Proceso de terminal en ejecución", + "terminalLivenessUnknownBlocker": "Estado del terminal desconocido", + "dirtyEditorBufferBlocker": "Búfer del editor sin guardar", + "volatileLocalContextBlocker": "Contexto local volátil", + "recentVisibleContextBlocker": "Pestañas visitadas recientemente", + "liveAgentBlocker": "Agente activo", + "sshDisconnectedBlocker": "Remoto no disponible", + "gitStatusErrorBlocker": "Estado de Git no disponible", + "dirtyFilesBlocker": "Archivos modificados", + "unknownBaseBlocker": "No se pudieron verificar confirmaciones sin enviar", + "dismissedBlocker": "Ignorado" } } }, @@ -2973,7 +3028,8 @@ "f077f586db": "No volver a preguntar", "c0e972d726": "Cancelar", "06741a2f3d": "Abrir detalles de uso de MiniMax", - "3bbf140864": "Uso de MiniMax" + "3bbf140864": "Uso de MiniMax", + "remoteServerLabel": "Servidor remoto" }, "StatusBarUsageEmptyCta": { "828c764a79": "Conectar una cuenta", @@ -4004,7 +4060,8 @@ "runtimeHostProject": "Proyecto en servidor de Orca", "automationCreated": "Creado por automatización", "branchIdentity": "Rama", - "branchFolderPathIdentity": "Rama o ruta de carpeta" + "branchFolderPathIdentity": "Rama o ruta de carpeta", + "ef18787206": "En cola para eliminación" }, "WorktreeCardAgents": { "1b0a156717": "Agents" @@ -4735,7 +4792,13 @@ "435df0ee51": "En Encabezados de solicitud, copia el valor del encabezado Cookie.", "7492fb3bba": "Pégalo aquí y haz clic en Guardar.", "9fec52de4b": "Cómo copiar la cookie", - "4e32e030b2": "Se almacena localmente. Orca solo la envía a platform.minimax.io para actualizar el uso." + "4e32e030b2": "Se almacena localmente. Orca solo la envía a platform.minimax.io para actualizar el uso.", + "remoteServerFallback": "el servidor remoto", + "loadAccountsFailed": "No se pudieron cargar las cuentas de proveedor.", + "remoteScopeAccounts": "Mostrando las cuentas administradas por {{value0}}. Agrega o vuelve a autenticar cuentas en ese servidor.", + "remoteScopeAuthContext": "Cada cuenta mantiene su propio contexto de inicio de sesión en {{value0}}.", + "remoteEmptyClaudeAccounts": "No hay cuentas de Claude administradas en {{value0}}. Usa su inicio de sesión de Claude predeterminado del sistema; agrega cuentas en ese servidor.", + "remoteEmptyCodexAccounts": "No hay cuentas de Codex administradas en {{value0}}. Usa su inicio de sesión de Codex predeterminado del sistema; agrega cuentas en ese servidor." }, "AdvancedPane": { "40b29e0bf3": "Reiniciar", @@ -8842,6 +8905,26 @@ "reviewFirst": "Revisar primero", "singular": "Guardar a nivel global no cambiará 1 repositorio con su propia receta.", "tooltipTitle": "Anulaciones de repositorio" + }, + "linear": { + "agent": { + "skill": { + "install": { + "cta": { + "copiedCommand": "Copied command.", + "copyFailed": "Failed to copy command.", + "skillLabel": "Agent skill:", + "checking": "Checking...", + "installed": "Installed", + "notInstalled": "Not installed", + "recheck": "Re-check", + "installedDescription": "Agent skill installed. To update it, run:", + "description": "Let your agents read and edit Linear tasks.", + "copyCommand": "Copy command" + } + } + } + } } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c2d85ce1dc5..5a4a6de3e9c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "ワークスペースはもう存在しません。" + "9d6e531da6": "ワークスペースはもう存在しません。", + "changedSinceConfirmation": "確認後にワークスペースが変更されました。削除する前に更新して確認してください。" } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "コンテクスト", "sortBy": "並べ替え", - "sortDirection": "方向" + "sortDirection": "方向", + "1d3503357d": "削除の続行中にこれを閉じて後で戻れます。", + "4c2990886e": "{{value0}}/{{value1}} 件を削除済み", + "86ba852118": "{{value0}}、{{value1}} 件が失敗", + "7b7bde5181": "これまでに確認したワークスペース: {{value0}}", + "deletingCount": "削除中のワークスペース: {{value0}}", + "deleteCount": "ワークスペースを削除しますか: {{value0}}?", + "selectedForDeletionCount": "削除対象として選択済み: {{value0}}", + "deleteButtonCount": "{{value0}} 件を削除", + "archivedStatus": "アーカイブ済み", + "readyStatus": "準備完了" + }, + "backgroundRemoval": { + "removed": "削除済みワークスペース: {{value0}}", + "failed": "削除できなかったワークスペース: {{value0}}", + "error": "ワークスペースのクリーンアップに失敗しました", + "skippedAncestor": "ネストされたワークスペースを削除できなかったためスキップしました。", + "timedOut": "{{value0}} の削除に予想より時間がかかっています。バックグラウンドで継続されます。" + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "コミット", + "contextLabel": "コンテキスト", + "flagsLabel": "フラグ", + "collapseDetails": "詳細を折りたたむ", + "expandDetails": "詳細を展開", + "cleanGit": "Git はクリーン", + "dirtyGit": "Git に変更あり", + "unpushedCommits": "未プッシュのコミット", + "gitUnknown": "Git 不明", + "gitStatusUnknown": "Git 状態不明", + "noUnpushedCommits": "未プッシュのコミットなし", + "unpushedCommitsCount": "未プッシュのコミット: {{value0}}", + "uncommittedChanges": "未コミットの変更", + "terminalTabsCount": "ターミナルタブ: {{value0}}", + "editorTabsCount": "エディタタブ: {{value0}}", + "browserTabsCount": "ブラウザタブ: {{value0}}", + "diffNotesCount": "差分メモ: {{value0}}", + "completedAgentsCount": "完了済みエージェント: {{value0}}", + "contextCount": "コンテキスト: {{value0}}", + "mainWorkspaceBlocker": "メインワークスペース", + "folderProjectBlocker": "フォルダープロジェクト", + "pinnedBlocker": "ピン留め済み", + "activeWorkspaceBlocker": "アクティブなワークスペース", + "runningTerminalBlocker": "実行中のターミナルプロセス", + "terminalLivenessUnknownBlocker": "ターミナルの状態不明", + "dirtyEditorBufferBlocker": "未保存のエディタバッファ", + "volatileLocalContextBlocker": "揮発性のローカルコンテキスト", + "recentVisibleContextBlocker": "最近表示したタブ", + "liveAgentBlocker": "アクティブなエージェント", + "sshDisconnectedBlocker": "リモート利用不可", + "gitStatusErrorBlocker": "Git 状態を利用できません", + "dirtyFilesBlocker": "変更済みファイル", + "unknownBaseBlocker": "未プッシュのコミットを確認できませんでした", + "dismissedBlocker": "無視済み" } } }, @@ -2973,7 +3028,8 @@ "f077f586db": "今後表示しない", "c0e972d726": "キャンセル", "06741a2f3d": "MiniMax の使用量詳細を開く", - "3bbf140864": "MiniMax 使用量" + "3bbf140864": "MiniMax 使用量", + "remoteServerLabel": "リモートサーバー" }, "StatusBarUsageEmptyCta": { "828c764a79": "アカウントを接続する", @@ -3985,7 +4041,8 @@ "runtimeHostProject": "Orca サーバー上のプロジェクト", "automationCreated": "自動化により作成", "branchIdentity": "ブランチ", - "branchFolderPathIdentity": "ブランチまたはフォルダパス" + "branchFolderPathIdentity": "ブランチまたはフォルダパス", + "ef18787206": "削除待ち" }, "WorktreeCardAgents": { "1b0a156717": "エージェント" @@ -4720,7 +4777,13 @@ "435df0ee51": "リクエストヘッダーの Cookie の値をコピーします。", "7492fb3bba": "ここに貼り付けて保存をクリックします。", "9fec52de4b": "Cookie のコピー方法", - "4e32e030b2": "ローカルに保存されます。Orca は使用量の更新のために platform.minimax.io のみに送信します。" + "4e32e030b2": "ローカルに保存されます。Orca は使用量の更新のために platform.minimax.io のみに送信します。", + "remoteServerFallback": "リモートサーバー", + "loadAccountsFailed": "プロバイダーアカウントを読み込めませんでした。", + "remoteScopeAccounts": "{{value0}} が管理するアカウントを表示しています。アカウントの追加や再認証はそのサーバー上で行ってください。", + "remoteScopeAuthContext": "各アカウントは {{value0}} 上に独自のサインインコンテキストを保持します。", + "remoteEmptyClaudeAccounts": "{{value0}} に管理対象の Claude アカウントはありません。システム既定の Claude ログインを使用します。アカウントの追加はそのサーバー上で行ってください。", + "remoteEmptyCodexAccounts": "{{value0}} に管理対象の Codex アカウントはありません。システム既定の Codex ログインを使用します。アカウントの追加はそのサーバー上で行ってください。" }, "AdvancedPane": { "40b29e0bf3": "再起動", @@ -8842,6 +8905,26 @@ "reviewFirst": "Review first", "singular": "Global saves won't change 1 repository with its own recipe.", "tooltipTitle": "Repository overrides" + }, + "linear": { + "agent": { + "skill": { + "install": { + "cta": { + "copiedCommand": "Copied command.", + "copyFailed": "Failed to copy command.", + "skillLabel": "Agent skill:", + "checking": "Checking...", + "installed": "Installed", + "notInstalled": "Not installed", + "recheck": "Re-check", + "installedDescription": "Agent skill installed. To update it, run:", + "description": "Let your agents read and edit Linear tasks.", + "copyCommand": "Copy command" + } + } + } + } } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8c860bd0c48..addb20bf914 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "워크스페이스가 더 이상 존재하지 않습니다." + "9d6e531da6": "워크스페이스가 더 이상 존재하지 않습니다.", + "changedSinceConfirmation": "확인 후 워크스페이스가 변경되었습니다. 제거하기 전에 새로 고쳐 검토하세요." } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "문맥", "sortBy": "정렬 기준", - "sortDirection": "방향" + "sortDirection": "방향", + "1d3503357d": "삭제가 계속되는 동안 이 창을 닫고 나중에 돌아올 수 있습니다.", + "4c2990886e": "{{value0}}/{{value1}}개 삭제됨", + "86ba852118": "{{value0}}, {{value1}}개 실패", + "7b7bde5181": "지금까지 확인한 워크스페이스: {{value0}}", + "deletingCount": "삭제 중인 워크스페이스: {{value0}}", + "deleteCount": "워크스페이스 삭제: {{value0}}?", + "selectedForDeletionCount": "삭제 대상으로 선택됨: {{value0}}", + "deleteButtonCount": "{{value0}}개 삭제", + "archivedStatus": "보관됨", + "readyStatus": "준비됨" + }, + "backgroundRemoval": { + "removed": "삭제된 워크스페이스: {{value0}}", + "failed": "삭제되지 않은 워크스페이스: {{value0}}", + "error": "워크스페이스 정리 실패", + "skippedAncestor": "중첩된 워크스페이스를 제거할 수 없어 건너뛰었습니다.", + "timedOut": "{{value0}} 제거가 예상보다 오래 걸리고 있습니다. 백그라운드에서 계속 실행됩니다." + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "커밋", + "contextLabel": "문맥", + "flagsLabel": "플래그", + "collapseDetails": "세부 정보 접기", + "expandDetails": "세부 정보 펼치기", + "cleanGit": "Git 깨끗함", + "dirtyGit": "Git 변경 있음", + "unpushedCommits": "푸시되지 않은 커밋", + "gitUnknown": "Git 알 수 없음", + "gitStatusUnknown": "Git 상태 알 수 없음", + "noUnpushedCommits": "푸시되지 않은 커밋 없음", + "unpushedCommitsCount": "푸시되지 않은 커밋: {{value0}}", + "uncommittedChanges": "커밋되지 않은 변경 사항", + "terminalTabsCount": "터미널 탭: {{value0}}", + "editorTabsCount": "편집기 탭: {{value0}}", + "browserTabsCount": "브라우저 탭: {{value0}}", + "diffNotesCount": "Diff 메모: {{value0}}", + "completedAgentsCount": "완료된 에이전트: {{value0}}", + "contextCount": "문맥: {{value0}}", + "mainWorkspaceBlocker": "기본 워크스페이스", + "folderProjectBlocker": "폴더 프로젝트", + "pinnedBlocker": "고정됨", + "activeWorkspaceBlocker": "활성 워크스페이스", + "runningTerminalBlocker": "실행 중인 터미널 프로세스", + "terminalLivenessUnknownBlocker": "터미널 상태 알 수 없음", + "dirtyEditorBufferBlocker": "저장되지 않은 편집기 버퍼", + "volatileLocalContextBlocker": "휘발성 로컬 문맥", + "recentVisibleContextBlocker": "최근 방문한 탭", + "liveAgentBlocker": "활성 에이전트", + "sshDisconnectedBlocker": "원격을 사용할 수 없음", + "gitStatusErrorBlocker": "Git 상태를 사용할 수 없음", + "dirtyFilesBlocker": "변경된 파일", + "unknownBaseBlocker": "푸시되지 않은 커밋을 확인할 수 없음", + "dismissedBlocker": "무시됨" } } }, @@ -2973,7 +3028,8 @@ "f077f586db": "다시 묻지 않기", "c0e972d726": "취소", "06741a2f3d": "MiniMax 사용량 세부 정보 열기", - "3bbf140864": "MiniMax 사용량" + "3bbf140864": "MiniMax 사용량", + "remoteServerLabel": "원격 서버" }, "StatusBarUsageEmptyCta": { "828c764a79": "계정 연결", @@ -3985,7 +4041,8 @@ "runtimeHostProject": "Orca 서버의 프로젝트", "automationCreated": "자동화로 생성됨", "branchIdentity": "브랜치", - "branchFolderPathIdentity": "브랜치 또는 폴더 경로" + "branchFolderPathIdentity": "브랜치 또는 폴더 경로", + "ef18787206": "삭제 대기 중" }, "WorktreeCardAgents": { "1b0a156717": "에이전트" @@ -4720,7 +4777,13 @@ "435df0ee51": "Request Headers에서 Cookie 값을 복사합니다.", "7492fb3bba": "여기에 붙여넣고 저장을 클릭합니다.", "9fec52de4b": "Cookie 복사 방법", - "4e32e030b2": "로컬에 저장됩니다. Orca는 사용량 새로 고침을 위해 platform.minimax.io로만 전송합니다." + "4e32e030b2": "로컬에 저장됩니다. Orca는 사용량 새로 고침을 위해 platform.minimax.io로만 전송합니다.", + "remoteServerFallback": "원격 서버", + "loadAccountsFailed": "프로바이더 계정을 불러오지 못했습니다.", + "remoteScopeAccounts": "{{value0}}에서 관리하는 계정을 표시하고 있습니다. 계정 추가나 재인증은 해당 서버에서 진행하세요.", + "remoteScopeAuthContext": "각 계정은 {{value0}}에 자체 로그인 컨텍스트를 유지합니다.", + "remoteEmptyClaudeAccounts": "{{value0}}에 관리되는 Claude 계정이 없습니다. 해당 서버의 시스템 기본 Claude 로그인을 사용하며, 계정 추가는 그 서버에서 진행하세요.", + "remoteEmptyCodexAccounts": "{{value0}}에 관리되는 Codex 계정이 없습니다. 해당 서버의 시스템 기본 Codex 로그인을 사용하며, 계정 추가는 그 서버에서 진행하세요." }, "AdvancedPane": { "40b29e0bf3": "다시 시작", @@ -8842,6 +8905,26 @@ "reviewFirst": "Review first", "singular": "Global saves won't change 1 repository with its own recipe.", "tooltipTitle": "Repository overrides" + }, + "linear": { + "agent": { + "skill": { + "install": { + "cta": { + "copiedCommand": "Copied command.", + "copyFailed": "Failed to copy command.", + "skillLabel": "Agent skill:", + "checking": "Checking...", + "installed": "Installed", + "notInstalled": "Not installed", + "recheck": "Re-check", + "installedDescription": "Agent skill installed. To update it, run:", + "description": "Let your agents read and edit Linear tasks.", + "copyCommand": "Copy command" + } + } + } + } } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index aa1e7374bc2..6c3abce34a6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -210,7 +210,8 @@ }, "workspace": { "cleanup": { - "9d6e531da6": "工作区不再存在。" + "9d6e531da6": "工作区不再存在。", + "changedSinceConfirmation": "工作区在确认后发生了更改。请刷新并在删除前重新检查。" } }, "worktrees": { @@ -2339,7 +2340,61 @@ "gitFilter": "Git", "contextFilter": "上下文", "sortBy": "排序依据", - "sortDirection": "方向" + "sortDirection": "方向", + "1d3503357d": "删除继续期间,你可以关闭此窗口并稍后回来。", + "4c2990886e": "已删除 {{value0}}/{{value1}}", + "86ba852118": "{{value0}},{{value1}} 个失败", + "7b7bde5181": "到目前为止已检查工作区:{{value0}}", + "deletingCount": "正在删除工作区:{{value0}}", + "deleteCount": "删除工作区:{{value0}}?", + "selectedForDeletionCount": "已选择删除:{{value0}}", + "deleteButtonCount": "删除 {{value0}}", + "archivedStatus": "已归档", + "readyStatus": "就绪" + }, + "backgroundRemoval": { + "removed": "已删除工作区:{{value0}}", + "failed": "未删除工作区:{{value0}}", + "error": "工作区清理失败", + "skippedAncestor": "由于无法删除嵌套工作区,已跳过。", + "timedOut": "删除 {{value0}} 的时间比预期长。它将在后台继续运行。" + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "提交", + "contextLabel": "上下文", + "flagsLabel": "标记", + "collapseDetails": "收起详情", + "expandDetails": "展开详情", + "cleanGit": "Git 干净", + "dirtyGit": "Git 有更改", + "unpushedCommits": "未推送提交", + "gitUnknown": "Git 未知", + "gitStatusUnknown": "Git 状态未知", + "noUnpushedCommits": "没有未推送提交", + "unpushedCommitsCount": "未推送提交:{{value0}}", + "uncommittedChanges": "未提交的更改", + "terminalTabsCount": "终端标签页:{{value0}}", + "editorTabsCount": "编辑器标签页:{{value0}}", + "browserTabsCount": "浏览器标签页:{{value0}}", + "diffNotesCount": "Diff 备注:{{value0}}", + "completedAgentsCount": "已完成智能体:{{value0}}", + "contextCount": "上下文:{{value0}}", + "mainWorkspaceBlocker": "主工作区", + "folderProjectBlocker": "文件夹项目", + "pinnedBlocker": "已固定", + "activeWorkspaceBlocker": "活动工作区", + "runningTerminalBlocker": "正在运行的终端进程", + "terminalLivenessUnknownBlocker": "终端状态未知", + "dirtyEditorBufferBlocker": "未保存的编辑器缓冲区", + "volatileLocalContextBlocker": "易变本地上下文", + "recentVisibleContextBlocker": "最近访问的标签页", + "liveAgentBlocker": "活动智能体", + "sshDisconnectedBlocker": "远程不可用", + "gitStatusErrorBlocker": "Git 状态不可用", + "dirtyFilesBlocker": "已更改文件", + "unknownBaseBlocker": "无法验证未推送提交", + "dismissedBlocker": "已忽略" } } }, @@ -2973,7 +3028,8 @@ "f077f586db": "不再询问", "c0e972d726": "取消", "06741a2f3d": "打开 MiniMax 使用量详情", - "3bbf140864": "MiniMax 使用量" + "3bbf140864": "MiniMax 使用量", + "remoteServerLabel": "远程服务器" }, "StatusBarUsageEmptyCta": { "828c764a79": "连接账户", @@ -3985,7 +4041,8 @@ "runtimeHostProject": "Orca 服务器上的项目", "automationCreated": "由自动化创建", "branchIdentity": "分支", - "branchFolderPathIdentity": "分支或文件夹路径" + "branchFolderPathIdentity": "分支或文件夹路径", + "ef18787206": "已排队删除" }, "WorktreeCardAgents": { "1b0a156717": "智能体" @@ -4720,7 +4777,13 @@ "435df0ee51": "在请求头中,复制 Cookie 的值。", "7492fb3bba": "在此粘贴并点击保存。", "9fec52de4b": "如何复制 Cookie", - "4e32e030b2": "存储在本地。Orca 仅将其发送到 platform.minimax.io 以刷新使用量。" + "4e32e030b2": "存储在本地。Orca 仅将其发送到 platform.minimax.io 以刷新使用量。", + "remoteServerFallback": "远程服务器", + "loadAccountsFailed": "无法加载提供商账户。", + "remoteScopeAccounts": "正在显示由 {{value0}} 管理的账户。请在该服务器上添加或重新验证账户。", + "remoteScopeAuthContext": "每个账户在 {{value0}} 上保留自己的登录上下文。", + "remoteEmptyClaudeAccounts": "{{value0}} 上没有受管理的 Claude 账户。它使用其系统默认的 Claude 登录;请在该服务器上添加账户。", + "remoteEmptyCodexAccounts": "{{value0}} 上没有受管理的 Codex 账户。它使用其系统默认的 Codex 登录;请在该服务器上添加账户。" }, "AdvancedPane": { "40b29e0bf3": "重新启动", @@ -8842,6 +8905,26 @@ "reviewFirst": "Review first", "singular": "Global saves won't change 1 repository with its own recipe.", "tooltipTitle": "Repository overrides" + }, + "linear": { + "agent": { + "skill": { + "install": { + "cta": { + "copiedCommand": "Copied command.", + "copyFailed": "Failed to copy command.", + "skillLabel": "Agent skill:", + "checking": "Checking...", + "installed": "Installed", + "notInstalled": "Not installed", + "recheck": "Re-check", + "installedDescription": "Agent skill installed. To update it, run:", + "description": "Let your agents read and edit Linear tasks.", + "copyCommand": "Copy command" + } + } + } + } } }, "right": { diff --git a/src/renderer/src/lib/linear-agent-skill-update-command.ts b/src/renderer/src/lib/linear-agent-skill-update-command.ts new file mode 100644 index 00000000000..69dbcce84d7 --- /dev/null +++ b/src/renderer/src/lib/linear-agent-skill-update-command.ts @@ -0,0 +1,28 @@ +import type { DiscoveredSkill } from '../../../shared/skills' +import { + GLOBAL_AGENT_SKILL_SOURCE_KINDS, + hasInstalledAgentSkill +} from '@/hooks/useInstalledAgentSkills' +import { + LINEAR_TICKETS_SKILL_NAME, + LINEAR_TICKETS_SKILL_UPDATE_COMMAND, + ORCA_LINEAR_SKILL_NAME, + ORCA_LINEAR_SKILL_UPDATE_COMMAND +} from '@/lib/agent-feature-install-commands' + +// Why: legacy-only installs must update the installed legacy skill, while +// fresh/canonical/both-name states should move through the canonical name. +export function getLinearAgentSkillUpdateCommand( + skills: readonly DiscoveredSkill[], + installed: boolean +): string { + const canonicalSkillInstalled = hasInstalledAgentSkill(skills, ORCA_LINEAR_SKILL_NAME, { + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) + const legacySkillInstalled = hasInstalledAgentSkill(skills, LINEAR_TICKETS_SKILL_NAME, { + sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS + }) + return !installed || canonicalSkillInstalled || !legacySkillInstalled + ? ORCA_LINEAR_SKILL_UPDATE_COMMAND + : LINEAR_TICKETS_SKILL_UPDATE_COMMAND +} diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts new file mode 100644 index 00000000000..bc5affb0c50 --- /dev/null +++ b/src/renderer/src/lib/resume-sleeping-agent-session-suppress-navigation.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { useAppStore } from '@/store' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +const initialAppStoreState = useAppStore.getState() + +afterEach(() => { + vi.unstubAllGlobals() + useAppStore.setState(initialAppStoreState, true) +}) + +function makeRecord( + overrides: Partial = {} +): SleepingAgentSessionRecord { + return { + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeTerminalTab(id: string, worktreeId: string): Record { + return { + id, + ptyId: null, + worktreeId, + title: 'shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +describe('resumeSleepingAgentSessionsForWorktree navigation suppression', () => { + it('resumes without navigating the desktop when navigation is suppressed', () => { + // Mobile-scoped wake: the desktop sits on a different worktree/view. The + // resume must spawn the recovery tab without changing the active surface. + const record = makeRecord({ origin: 'quit' }) + useAppStore.setState({ + activeWorktreeId: 'wt-other', + activeTabId: 'other-tab', + activeTabType: 'browser', + activeTabIdByWorktree: { 'wt-other': 'other-tab' }, + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1', { suppressNavigation: true }) + + expect(launched).toBe(1) + const state = useAppStore.getState() + const resumedTab = state.tabsByWorktree['wt-1']?.find((tab) => tab.id !== 'tab-1') + // A resume tab is created for the slept worktree... + expect(resumedTab?.launchAgent).toBe('claude') + // ...but the desktop's active worktree/tab/view are untouched (INV-2). + expect(state.activeWorktreeId).toBe('wt-other') + expect(state.activeTabId).toBe('other-tab') + expect(state.activeTabType).toBe('browser') + }) + + it('still navigates to the resumed tab for default (desktop) callers', () => { + // Regression guard: the suppress-navigation flag must be opt-in — desktop + // resume keeps flipping the active view to the recovered terminal. + const record = makeRecord({ origin: 'quit' }) + useAppStore.setState({ + activeWorktreeId: 'wt-1', + activeTabId: 'tab-1', + activeTabType: 'browser', + activeTabIdByWorktree: { 'wt-1': 'tab-1' }, + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + resumeSleepingAgentSessionsForWorktree('wt-1') + + expect(useAppStore.getState().activeTabType).toBe('terminal') + }) +}) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 3579ca80400..ba283ab370b 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -57,7 +57,12 @@ function appendTabToWorktreeOrder(worktreeId: string, tabId: string): void { state.setTabBarOrder(worktreeId, order) } -function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean { +// Why: mobile-driven wake runs on the desktop host renderer, so it must create +// the resume tab without stealing the desktop's active worktree/tab/view. +function launchSleepingAgentSession( + record: SleepingAgentSessionRecord, + options?: { suppressNavigation?: boolean } +): boolean { const state = useAppStore.getState() const launchConfig = record.launchConfig const startupPlan = buildAgentResumeStartupPlan({ @@ -86,7 +91,8 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean } const tab = state.createTab(record.worktreeId, undefined, undefined, { - launchAgent: record.agent + launchAgent: record.agent, + ...(options?.suppressNavigation ? { activate: false, recordInteraction: false } : {}) }) state.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, @@ -110,7 +116,9 @@ function launchSleepingAgentSession(record: SleepingAgentSessionRecord): boolean providerSession: record.providerSession }) state.clearSleepingAgentSession(record.paneKey) - state.setActiveTabType('terminal') + if (!options?.suppressNavigation) { + state.setActiveTabType('terminal') + } appendTabToWorktreeOrder(record.worktreeId, tab.id) return true } @@ -239,7 +247,10 @@ function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord): ) } -export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): number { +export function resumeSleepingAgentSessionsForWorktree( + worktreeId: string, + options?: { suppressNavigation?: boolean } +): number { const state = useAppStore.getState() const worktreeRecords = Object.values(state.sleepingAgentSessionsByPaneKey) .filter((record) => record.worktreeId === worktreeId) @@ -298,7 +309,7 @@ export function resumeSleepingAgentSessionsForWorktree(worktreeId: string): numb if (isPaneOwned) { continue } - if (launchSleepingAgentSession(record)) { + if (launchSleepingAgentSession(record, options)) { launched += 1 freshlyLaunchedClaimKeys.add(claimKey) clearPassiveCompletedRecordsForClaimKey(worktreeRecords, claimKey, record.paneKey) diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts new file mode 100644 index 00000000000..4fbe281dfbc --- /dev/null +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT +} from '@/constants/terminal' + +const resumeSpy = vi.fn() +vi.mock('./resume-sleeping-agent-session', () => ({ + resumeSleepingAgentSessionsForWorktree: (worktreeId: string, options?: unknown) => + resumeSpy(worktreeId, options) +})) + +// Why: control passive-vs-non-passive classification directly so the test asserts +// the gating, not the predicate internals. +const isPassiveSpy = vi.fn() +vi.mock('./sleeping-agent-pane-ownership', () => ({ + isPassiveCompletedHibernationEvidence: (record: unknown) => isPassiveSpy(record) +})) + +let sleepingRecords: Record = {} +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => ({ sleepingAgentSessionsByPaneKey: sleepingRecords }) + } +})) + +import { wakeSleepingAgentsForWorktreeInBackground } from './wake-sleeping-agents-in-background' + +function recordEvents(): { events: string[]; stop: () => void } { + const events: string[] = [] + const onWake = (event: Event): void => { + events.push(`wake:${(event as CustomEvent<{ worktreeId: string }>).detail.worktreeId}`) + } + const onMount = (event: Event): void => { + events.push(`mount:${(event as CustomEvent<{ worktreeId: string }>).detail.worktreeId}`) + } + window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) + window.addEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount) + return { + events, + stop: () => { + window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWake) + window.removeEventListener(BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, onMount) + } + } +} + +beforeEach(() => { + sleepingRecords = {} + isPassiveSpy.mockReset() + resumeSpy.mockReset() +}) + +afterEach(() => { + resumeSpy.mockReset() +}) + +describe('wakeSleepingAgentsForWorktreeInBackground', () => { + it('fires wake, background-mount, then resume when a passive record exists', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1' } } + isPassiveSpy.mockReturnValue(true) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + // (a) pane-level wake of mounted hidden panes fires before (b) background-mount + // of not-yet-mounted panes. + expect(rec.events).toEqual(['wake:wt-1', 'mount:wt-1']) + // (c) non-passive records resume with navigation suppressed (INV-2). + expect(resumeSpy).toHaveBeenCalledWith('wt-1', { suppressNavigation: true }) + }) + + it('skips background-mount when only non-passive records exist', () => { + sleepingRecords = { k1: { worktreeId: 'wt-1' } } + isPassiveSpy.mockReturnValue(false) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + // Why: no passive record → no not-yet-mounted pane to fresh-connect, so + // background-mount must not run (it would strand a plain shell / mount work). + expect(rec.events).toEqual(['wake:wt-1']) + expect(resumeSpy).toHaveBeenCalledWith('wt-1', { suppressNavigation: true }) + }) + + it('does nothing when the worktree has no sleeping records', () => { + sleepingRecords = { k1: { worktreeId: 'other-wt' } } + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + // Why: mobile browsing a worktree with nothing slept must not mount it (and + // its PTYs) on the desktop host. + expect(rec.events).toEqual([]) + expect(resumeSpy).not.toHaveBeenCalled() + expect(isPassiveSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts new file mode 100644 index 00000000000..05369adbfe1 --- /dev/null +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts @@ -0,0 +1,57 @@ +import { + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, + type BackgroundMountTerminalWorktreeDetail, + type WakeHibernatedAgentsWorktreeDetail +} from '@/constants/terminal' +import { useAppStore } from '@/store' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' +import { isPassiveCompletedHibernationEvidence } from './sleeping-agent-pane-ownership' + +/** + * Wakes a worktree's slept agents on the desktop host renderer with NO desktop + * navigation — used when a phone (`clientKind: 'mobile'`) opens the worktree. + * Runs up to three steps, in order: + * (a) fire the armed cold-restore `--resume` of the worktree's mounted hidden + * hibernated panes (the experimental agent-sleep records; the primary + * wake mechanism, since those records are passive for path C); + * (b) background-mount so a hibernated pane that is NOT currently mounted + * (post-restart / evicted) mounts offscreen and takes the fresh-connect + * cold-restore path; + * (c) resume the non-passive record classes (manual sleep of a still-working + * agent, `origin: 'quit'`) with navigation suppressed. + * Woken PTYs auto-publish to mobile via the renderer graph republish, so no + * spawn is awaited. + */ +export function wakeSleepingAgentsForWorktreeInBackground(worktreeId: string): void { + const worktreeRecords = Object.values( + useAppStore.getState().sleepingAgentSessionsByPaneKey + ).filter((record) => record.worktreeId === worktreeId) + // Why: nothing is slept here, so there is no wake work. Skipping is what keeps + // a phone browsing many worktrees from permanently background-mounting each one + // (and reattaching its PTYs) on the desktop host it is paired to. + if (worktreeRecords.length === 0) { + return + } + + window.dispatchEvent( + new CustomEvent(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, { + detail: { worktreeId } + }) + ) + // Why: only a passive completed-hibernation record has a not-yet-mounted pane + // that needs a fresh-connect cold-restore (step b). Gating on it avoids mounting + // the worktree for non-passive records — which step (c) recovers into a fresh + // tab — so background-mount can't strand a plain shell in the stale tab. + if (worktreeRecords.some(isPassiveCompletedHibernationEvidence)) { + window.dispatchEvent( + new CustomEvent( + BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT, + { + detail: { worktreeId } + } + ) + ) + } + resumeSleepingAgentSessionsForWorktree(worktreeId, { suppressNavigation: true }) +} diff --git a/src/renderer/src/lib/window-visibility-interval.test.ts b/src/renderer/src/lib/window-visibility-interval.test.ts index cd4a6eb8bc2..cb870794cd8 100644 --- a/src/renderer/src/lib/window-visibility-interval.test.ts +++ b/src/renderer/src/lib/window-visibility-interval.test.ts @@ -59,6 +59,51 @@ describe('installWindowVisibilityInterval', () => { ) }) + it('uses runOnVisible for the becoming-visible run and run for interval ticks', () => { + let visibilityState: DocumentVisibilityState = 'hidden' + const documentListeners = new Map void>() + const intervalCallbacks: (() => void)[] = [] + const setIntervalMock = vi.fn((callback: () => void) => { + intervalCallbacks.push(callback) + return 1 as unknown as ReturnType + }) + const run = vi.fn() + const runOnVisible = vi.fn() + + vi.stubGlobal('window', { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + vi.stubGlobal('document', { + get visibilityState() { + return visibilityState + }, + addEventListener: vi.fn((event: string, listener: () => void) => { + documentListeners.set(event, listener) + }), + removeEventListener: vi.fn() + }) + + const cleanup = installWindowVisibilityInterval({ + run, + runOnVisible, + intervalMs: 3000, + setIntervalFn: setIntervalMock, + clearIntervalFn: vi.fn() + }) + + visibilityState = 'visible' + documentListeners.get('visibilitychange')?.() + expect(runOnVisible).toHaveBeenCalledTimes(1) + expect(run).not.toHaveBeenCalled() + + intervalCallbacks.at(0)?.() + expect(run).toHaveBeenCalledTimes(1) + expect(runOnVisible).toHaveBeenCalledTimes(1) + + cleanup() + }) + it('starts while visible even when the window is not focused', () => { const run = vi.fn() const setIntervalMock = vi.fn(() => 1 as unknown as ReturnType) diff --git a/src/renderer/src/lib/window-visibility-interval.ts b/src/renderer/src/lib/window-visibility-interval.ts index 966fc91dc96..bdd250d5c16 100644 --- a/src/renderer/src/lib/window-visibility-interval.ts +++ b/src/renderer/src/lib/window-visibility-interval.ts @@ -10,6 +10,10 @@ export function isWindowVisible(): boolean { export function installWindowVisibilityInterval(args: { run: () => void + // Why: callers that drop refresh signals while hidden can treat the + // becoming-visible run as evidence-bearing (something may have been missed) + // instead of a bare interval tick. Defaults to `run`. + runOnVisible?: () => void intervalMs: number setIntervalFn?: (callback: () => void, intervalMs: number) => WindowVisibilityIntervalTimer clearIntervalFn?: (handle: WindowVisibilityIntervalTimer) => void @@ -33,7 +37,7 @@ export function installWindowVisibilityInterval(args: { if (intervalId || !isWindowVisible()) { return } - args.run() + ;(args.runOnVisible ?? args.run)() // Why: many callers shell out or cross IPC. Keep their interval alive only // while Orca can present the refreshed data, but still refresh a visible // unfocused window so status UI does not go stale on a second display. diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts new file mode 100644 index 00000000000..663d052e30a --- /dev/null +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts @@ -0,0 +1,302 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState +} from '../../../shared/types' +import { + fetchProviderAccountsSnapshot, + removeClaudeProviderAccount, + removeCodexProviderAccount, + selectClaudeProviderAccount, + selectCodexProviderAccount, + watchProviderAccounts, + type ProviderAccountsSnapshot +} from './runtime-provider-accounts-client' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from './runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' + +const LOCAL = { activeRuntimeEnvironmentId: null } +const REMOTE = { activeRuntimeEnvironmentId: 'env-1' } + +function emptyClaudeState(): ClaudeRateLimitAccountsState { + return { accounts: [], activeAccountId: null, activeAccountIdsByRuntime: { host: null, wsl: {} } } +} + +function emptyCodexState(): CodexRateLimitAccountsState { + return { accounts: [], activeAccountId: null, activeAccountIdsByRuntime: { host: null, wsl: {} } } +} + +function snapshotFixture(marker: string): ProviderAccountsSnapshot { + return { + claude: { + ...emptyClaudeState(), + activeAccountId: `claude-${marker}` + }, + codex: { + ...emptyCodexState(), + activeAccountId: `codex-${marker}` + }, + rateLimits: null + } +} + +type SubscriptionCallbacks = { + onResponse: (response: unknown) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void +} + +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() +const runtimeEnvironmentSubscribe = vi.fn() +const claudeListLocal = vi.fn() +const codexListLocal = vi.fn() +const claudeSelectLocal = vi.fn() +const codexSelectLocal = vi.fn() +const claudeRemoveLocal = vi.fn() +const codexRemoveLocal = vi.fn() +const unsubscribe = vi.fn() + +let subscriptionCallbacks: SubscriptionCallbacks | null = null + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.restoreAllMocks() + for (const mock of [ + runtimeEnvironmentCall, + runtimeEnvironmentTransportCall, + runtimeEnvironmentSubscribe, + claudeListLocal, + codexListLocal, + claudeSelectLocal, + codexSelectLocal, + claudeRemoveLocal, + codexRemoveLocal, + unsubscribe + ]) { + mock.mockReset() + } + subscriptionCallbacks = null + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + }) + runtimeEnvironmentSubscribe.mockImplementation( + async (_args: unknown, callbacks: SubscriptionCallbacks) => { + subscriptionCallbacks = callbacks + return { unsubscribe, sendBinary: () => false } + } + ) + vi.stubGlobal('window', { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + api: { + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + subscribe: runtimeEnvironmentSubscribe + }, + claudeAccounts: { + list: claudeListLocal, + select: claudeSelectLocal, + remove: claudeRemoveLocal + }, + codexAccounts: { + list: codexListLocal, + select: codexSelectLocal, + remove: codexRemoveLocal + } + } + }) +}) + +async function flushMicrotasks(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +describe('watchProviderAccounts', () => { + it('reads local services once when no runtime environment is active', async () => { + claudeListLocal.mockResolvedValue(emptyClaudeState()) + codexListLocal.mockResolvedValue(emptyCodexState()) + const snapshots: ProviderAccountsSnapshot[] = [] + + watchProviderAccounts(LOCAL, { + onSnapshot: (snapshot) => snapshots.push(snapshot), + onError: () => { + throw new Error('unexpected error') + } + }) + await flushMicrotasks() + + expect(snapshots).toHaveLength(1) + expect(snapshots[0]?.rateLimits).toBeNull() + expect(claudeListLocal).toHaveBeenCalledTimes(1) + expect(codexListLocal).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentSubscribe).not.toHaveBeenCalled() + }) + + it('does not deliver a late local snapshot after close', async () => { + let resolveClaude: (state: ClaudeRateLimitAccountsState) => void = () => {} + claudeListLocal.mockImplementation( + () => new Promise((resolve) => (resolveClaude = resolve)) + ) + codexListLocal.mockResolvedValue(emptyCodexState()) + const snapshots: ProviderAccountsSnapshot[] = [] + + const watcher = watchProviderAccounts(LOCAL, { + onSnapshot: (snapshot) => snapshots.push(snapshot), + onError: () => {} + }) + watcher.close() + resolveClaude(emptyClaudeState()) + await flushMicrotasks() + + expect(snapshots).toHaveLength(0) + }) + + it('streams remote snapshots from accounts.subscribe and unsubscribes on close', async () => { + const snapshots: ProviderAccountsSnapshot[] = [] + const watcher = watchProviderAccounts(REMOTE, { + onSnapshot: (snapshot) => snapshots.push(snapshot), + onError: () => { + throw new Error('unexpected error') + } + }) + await flushMicrotasks() + + expect(runtimeEnvironmentSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ selector: 'env-1', method: 'accounts.subscribe' }), + expect.any(Object) + ) + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'ready', snapshot: snapshotFixture('ready') } + }) + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'snapshot', snapshot: snapshotFixture('refresh') } + }) + + expect(snapshots.map((s) => s.codex.activeAccountId)).toEqual(['codex-ready', 'codex-refresh']) + expect(claudeListLocal).not.toHaveBeenCalled() + + watcher.close() + expect(unsubscribe).toHaveBeenCalledTimes(1) + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'snapshot', snapshot: snapshotFixture('late') } + }) + expect(snapshots).toHaveLength(2) + }) + + it('surfaces remote subscription failures as errors', async () => { + const errors: unknown[] = [] + watchProviderAccounts(REMOTE, { + onSnapshot: () => { + throw new Error('unexpected snapshot') + }, + onError: (error) => errors.push(error) + }) + await flushMicrotasks() + + subscriptionCallbacks?.onResponse({ + ok: false, + error: { code: 'forbidden', message: 'denied' } + }) + + expect(errors).toHaveLength(1) + expect(String((errors[0] as Error).message)).toContain('denied') + }) +}) + +describe('fetchProviderAccountsSnapshot', () => { + it('resolves with the first remote snapshot and closes the subscription', async () => { + const pending = fetchProviderAccountsSnapshot(REMOTE) + await flushMicrotasks() + subscriptionCallbacks?.onResponse({ + ok: true, + result: { type: 'ready', snapshot: snapshotFixture('ready') } + }) + + await expect(pending).resolves.toMatchObject({ + codex: { activeAccountId: 'codex-ready' } + }) + expect(unsubscribe).toHaveBeenCalled() + }) + + it('rejects when the remote subscription closes before any snapshot', async () => { + const pending = fetchProviderAccountsSnapshot(REMOTE) + await flushMicrotasks() + subscriptionCallbacks?.onClose?.() + + await expect(pending).rejects.toThrow('subscription closed') + }) +}) + +describe('provider account mutations', () => { + it('routes select through local IPC with the full runtime target when local', async () => { + codexSelectLocal.mockResolvedValue(emptyCodexState()) + claudeSelectLocal.mockResolvedValue(emptyClaudeState()) + + await selectCodexProviderAccount(LOCAL, { + accountId: 'acc-1', + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + await selectClaudeProviderAccount(LOCAL, { accountId: null, runtime: 'host', wslDistro: null }) + + expect(codexSelectLocal).toHaveBeenCalledWith({ + accountId: 'acc-1', + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(claudeSelectLocal).toHaveBeenCalledWith({ + accountId: null, + runtime: 'host', + wslDistro: null + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('routes select and remove through the active runtime accounts RPC when remote', async () => { + runtimeEnvironmentCall.mockImplementation((args: { method: string }) => ({ + id: 'call', + ok: true, + result: args.method.startsWith('accounts.select') ? emptyCodexState() : emptyClaudeState() + })) + + await selectCodexProviderAccount(REMOTE, { + accountId: 'server-codex-2', + runtime: 'host', + wslDistro: null + }) + await selectClaudeProviderAccount(REMOTE, { + accountId: null, + runtime: 'host', + wslDistro: null + }) + await removeCodexProviderAccount(REMOTE, 'server-codex-1') + await removeClaudeProviderAccount(REMOTE, 'server-claude-1') + + const methods = runtimeEnvironmentCall.mock.calls.map( + (call) => (call[0] as { method: string; params: unknown }).method + ) + expect(methods).toEqual([ + 'accounts.selectCodex', + 'accounts.selectClaude', + 'accounts.removeCodex', + 'accounts.removeClaude' + ]) + expect(runtimeEnvironmentCall.mock.calls[0]?.[0]).toMatchObject({ + selector: 'env-1', + // Why this matters: the server API takes only accountId; host/WSL + // targeting is a desktop-local concept and must not leak into params. + params: { accountId: 'server-codex-2' } + }) + expect(codexSelectLocal).not.toHaveBeenCalled() + expect(claudeSelectLocal).not.toHaveBeenCalled() + expect(codexRemoveLocal).not.toHaveBeenCalled() + expect(claudeRemoveLocal).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.ts new file mode 100644 index 00000000000..1bb85fdea85 --- /dev/null +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.ts @@ -0,0 +1,227 @@ +import type { + ClaudeRateLimitAccountsState, + CodexRateLimitAccountsState, + GlobalSettings +} from '../../../shared/types' +import type { RateLimitState } from '../../../shared/rate-limit-types' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import { callRuntimeRpc, getActiveRuntimeTarget, RuntimeRpcCallError } from './runtime-rpc-client' + +// Mirrors OrcaRuntime.getAccountsSnapshot() / the accounts.subscribe payload. +export type ProviderAccountsSnapshot = { + claude: ClaudeRateLimitAccountsState + codex: CodexRateLimitAccountsState + rateLimits: RateLimitState | null +} + +type ProviderAccountSelection = { + accountId: string | null + runtime: 'host' | 'wsl' + wslDistro?: string | null +} + +type ProviderAccountsSubscriptionMessage = { + type: 'ready' | 'snapshot' | 'end' + snapshot?: ProviderAccountsSnapshot +} + +const REMOTE_ACCOUNTS_FIRST_SNAPSHOT_TIMEOUT_MS = 15_000 +// Why: the server applies a selection before it awaits provider usage +// refreshes, and those refreshes can crawl behind broken auth. Give the call +// room to finish instead of reporting failure for an applied switch. +const REMOTE_ACCOUNT_MUTATION_TIMEOUT_MS = 30_000 + +export function hasRemoteProviderAccountOwner( + settings: Pick | null | undefined +): boolean { + return getActiveRuntimeTarget(settings).kind === 'environment' +} + +export type ProviderAccountsWatcher = { + close: () => void +} + +// Watches the provider-account snapshot for whichever runtime owns accounts. +// Local: one-shot reads of the desktop services (they have no push channel +// here; the pane refetches after each mutation). Remote: a live +// accounts.subscribe stream — the ready message carries the current snapshot +// synchronously and later broadcasts deliver refreshed usage. accounts.list is +// deliberately avoided: it blocks behind provider usage refreshes on the +// server and can hang for minutes behind broken auth. +export function watchProviderAccounts( + settings: Pick | null | undefined, + handlers: { + onSnapshot: (snapshot: ProviderAccountsSnapshot) => void + onError: (error: unknown) => void + } +): ProviderAccountsWatcher { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'local') { + let closed = false + void Promise.all([window.api.claudeAccounts.list(), window.api.codexAccounts.list()]) + .then(([claude, codex]) => { + if (!closed) { + handlers.onSnapshot({ claude, codex, rateLimits: null }) + } + }) + .catch((error: unknown) => { + if (!closed) { + handlers.onError(error) + } + }) + return { + close: () => { + closed = true + } + } + } + + let closed = false + let unsubscribe: (() => void) | null = null + let receivedSnapshot = false + // Why: a subscription that never produces a first snapshot looks identical + // to a loading state; surface it as an error so the pane can say so. + const firstSnapshotTimer = window.setTimeout(() => { + if (!closed && !receivedSnapshot) { + handlers.onError(new Error('Timed out waiting for remote provider accounts.')) + } + }, REMOTE_ACCOUNTS_FIRST_SNAPSHOT_TIMEOUT_MS) + + void window.api.runtimeEnvironments + .subscribe( + { + selector: target.environmentId, + method: 'accounts.subscribe', + timeoutMs: REMOTE_ACCOUNTS_FIRST_SNAPSHOT_TIMEOUT_MS + }, + { + onResponse: (response) => { + if (closed) { + return + } + const typed = response as RuntimeRpcResponse + if (typed.ok === false) { + handlers.onError(new RuntimeRpcCallError(typed)) + return + } + const message = typed.result + if ((message.type === 'ready' || message.type === 'snapshot') && message.snapshot) { + receivedSnapshot = true + handlers.onSnapshot(message.snapshot) + } + }, + onError: (error) => { + if (!closed) { + handlers.onError(new Error(error.message)) + } + }, + onClose: () => { + if (!closed && !receivedSnapshot) { + handlers.onError(new Error('Remote provider account subscription closed.')) + } + } + } + ) + .then((handle) => { + unsubscribe = handle.unsubscribe + if (closed) { + unsubscribe() + } + }) + .catch((error: unknown) => { + if (!closed) { + handlers.onError(error) + } + }) + + return { + close: () => { + closed = true + window.clearTimeout(firstSnapshotTimer) + unsubscribe?.() + } + } +} + +// One-shot convenience over watchProviderAccounts for surfaces that only need +// the current snapshot (status-bar switcher menus). +export async function fetchProviderAccountsSnapshot( + settings: Pick | null | undefined +): Promise { + return await new Promise((resolve, reject) => { + const watcher = watchProviderAccounts(settings, { + onSnapshot: (snapshot) => { + watcher.close() + resolve(snapshot) + }, + onError: (error) => { + watcher.close() + reject(error instanceof Error ? error : new Error(String(error))) + } + }) + }) +} + +export async function selectClaudeProviderAccount( + settings: Pick | null | undefined, + selection: ProviderAccountSelection +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'environment') { + return callRuntimeRpc( + target, + 'accounts.selectClaude', + { accountId: selection.accountId }, + { timeoutMs: REMOTE_ACCOUNT_MUTATION_TIMEOUT_MS } + ) + } + return window.api.claudeAccounts.select(selection) +} + +export async function selectCodexProviderAccount( + settings: Pick | null | undefined, + selection: ProviderAccountSelection +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'environment') { + return callRuntimeRpc( + target, + 'accounts.selectCodex', + { accountId: selection.accountId }, + { timeoutMs: REMOTE_ACCOUNT_MUTATION_TIMEOUT_MS } + ) + } + return window.api.codexAccounts.select(selection) +} + +export async function removeClaudeProviderAccount( + settings: Pick | null | undefined, + accountId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'environment') { + return callRuntimeRpc( + target, + 'accounts.removeClaude', + { accountId }, + { timeoutMs: REMOTE_ACCOUNT_MUTATION_TIMEOUT_MS } + ) + } + return window.api.claudeAccounts.remove({ accountId }) +} + +export async function removeCodexProviderAccount( + settings: Pick | null | undefined, + accountId: string +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'environment') { + return callRuntimeRpc( + target, + 'accounts.removeCodex', + { accountId }, + { timeoutMs: REMOTE_ACCOUNT_MUTATION_TIMEOUT_MS } + ) + } + return window.api.codexAccounts.remove({ accountId }) +} diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 145f72ce38d..2bb9463daff 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -221,6 +221,37 @@ describe('removeWorktree cascade', () => { }) }) + it('can suppress preserved branch warning toasts for batched cleanup removal', async () => { + const store = createTestStore() + const worktreeId = 'repo1::/path/wt1' + mockApi.worktrees.remove.mockResolvedValueOnce({ + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) + + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: '/path/wt1', + displayName: 'Review cleanup' + }) + ] + } + }) + + const result = await store + .getState() + .removeWorktree(worktreeId, false, { suppressPreservedBranchToast: true }) + + expect(result).toEqual({ + ok: true, + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) + expect(toast.warning).not.toHaveBeenCalled() + }) + it('sets delete state with dirty/untracked error and canForceDelete=true on failure', async () => { const store = createTestStore() const worktreeId = 'repo1::/path/wt1' @@ -281,6 +312,55 @@ describe('removeWorktree cascade', () => { }) }) + it('marks multiple worktrees queued for deletion in one optimistic state update', () => { + const store = createTestStore() + const first = 'repo1::/path/wt1' + const second = 'repo1::/path/wt2' + + seedStore(store, { + deleteStateByWorktreeId: { + [first]: { + isDeleting: false, + error: 'old failure', + canForceDelete: true, + forceDeleteReason: 'dirty' + } + } + }) + + store.getState().markWorktreesQueuedForDeletion([first, second, first]) + + expect(store.getState().deleteStateByWorktreeId).toMatchObject({ + [first]: { isDeleting: true, phase: 'queued', error: null, canForceDelete: false }, + [second]: { isDeleting: true, phase: 'queued', error: null, canForceDelete: false } + }) + }) + + it('keeps active deletion state when cleanup queues stale rows', () => { + const store = createTestStore() + const active = 'repo1::/path/deleting' + const queued = 'repo1::/path/queued' + + seedStore(store, { + deleteStateByWorktreeId: { + [active]: { + isDeleting: true, + phase: 'deleting', + error: null, + canForceDelete: false, + forceDeleteReason: null + } + } + }) + + store.getState().markWorktreesQueuedForDeletion([active, queued]) + + expect(store.getState().deleteStateByWorktreeId).toMatchObject({ + [active]: { isDeleting: true, phase: 'deleting', error: null, canForceDelete: false }, + [queued]: { isDeleting: true, phase: 'queued', error: null, canForceDelete: false } + }) + }) + it('offers force delete for Electron-wrapped local dirty preflight errors', async () => { const store = createTestStore() const worktreeId = 'repo1::/workspace/feature-wt' diff --git a/src/renderer/src/store/slices/workspace-cleanup.test.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts similarity index 50% rename from src/renderer/src/store/slices/workspace-cleanup.test.ts rename to src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts index 3a5199e2adc..cae7d7c8fce 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts @@ -1,376 +1,17 @@ -import { create } from 'zustand' import { describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' -import type { - WorkspaceCleanupCandidate, - WorkspaceCleanupScanProgress, - WorkspaceCleanupScanResult -} from '../../../../shared/workspace-cleanup' -import { createWorkspaceCleanupSlice, enrichWorkspaceCleanupCandidates } from './workspace-cleanup' - -const WORKTREE_ID = 'repo1::/tmp/old-workspace' -const NOW = 1_700_000_000_000 - -function makeCandidate( - overrides: Partial = {} -): WorkspaceCleanupCandidate { - return { - worktreeId: WORKTREE_ID, - repoId: 'repo1', - repoName: 'Repo 1', - connectionId: null, - displayName: 'old-workspace', - branch: 'old-workspace', - path: '/tmp/old-workspace', - tier: 'ready', - selectedByDefault: true, - reasons: ['idle-clean'], - blockers: [], - lastActivityAt: NOW - 30 * 24 * 60 * 60 * 1000, - localContext: { - terminalTabCount: 0, - cleanEditorTabCount: 0, - browserTabCount: 0, - diffCommentCount: 0, - newestDiffCommentAt: null, - retainedDoneAgentCount: 0 - }, - git: { - clean: true, - upstreamAhead: 0, - upstreamBehind: 0, - checkedAt: NOW - }, - fingerprint: 'fingerprint-1', - ...overrides - } -} - -function makeState(overrides: Partial = {}): AppState { - return { - tabsByWorktree: {}, - ptyIdsByTabId: {}, - openFiles: [], - editorDrafts: {}, - browserTabsByWorktree: {}, - retainedAgentsByPaneKey: {}, - activeWorktreeId: null, - agentStatusByPaneKey: {}, - runtimePaneTitlesByTabId: {}, - lastVisitedAtByWorktreeId: {}, - workspaceCleanupDismissals: {}, - workspaceCleanupViewedCandidates: {}, - ...overrides - } as AppState -} - -function createCleanupTestStore(removeWorktree = vi.fn()) { - return create()( - (...a) => - ({ - tabsByWorktree: {}, - ptyIdsByTabId: {}, - openFiles: [], - editorDrafts: {}, - browserTabsByWorktree: {}, - retainedAgentsByPaneKey: {}, - activeWorktreeId: null, - agentStatusByPaneKey: {}, - runtimePaneTitlesByTabId: {}, - lastVisitedAtByWorktreeId: {}, - removeWorktree, - ...createWorkspaceCleanupSlice(...a) - }) as unknown as AppState - ) -} - -function installWorkspaceCleanupApi(scan: ReturnType) { - ;(globalThis as { window: unknown }).window = { - api: { - workspaceCleanup: { - scan, - dismiss: vi.fn().mockResolvedValue(undefined), - clearDismissals: vi.fn().mockResolvedValue(undefined), - hasKillableLocalProcesses: vi.fn().mockResolvedValue({ - hasKillableProcesses: false - }) - } - } - } -} - -function deferred(): { - promise: Promise - resolve: (value: T) => void - reject: (error: unknown) => void -} { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((innerResolve, innerReject) => { - resolve = innerResolve - reject = innerReject - }) - return { promise, resolve, reject } -} - -describe('workspace cleanup viewed rows', () => { - it('joins duplicate broad cleanup scans', async () => { - const pending = deferred() - const scan = vi.fn().mockReturnValue(pending.promise) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - const first = store.getState().scanWorkspaceCleanup() - const second = store.getState().scanWorkspaceCleanup() - - expect(scan).toHaveBeenCalledTimes(1) - expect(store.getState().workspaceCleanupLoading).toBe(true) - - const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } - pending.resolve(result) - - await expect(Promise.all([first, second])).resolves.toEqual([result, result]) - expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(1) - expect(store.getState().workspaceCleanupLoading).toBe(false) - }) - - it('does not leave cleanup loading stuck when a reopen joins a just-settled scan', async () => { - const pending = deferred() - const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } - const scan = vi.fn().mockReturnValue(pending.promise) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - let joinedScan: Promise | null = null - - const unsubscribe = store.subscribe((state, previousState) => { - if ( - previousState.workspaceCleanupLoading && - !state.workspaceCleanupLoading && - joinedScan === null - ) { - joinedScan = state.scanWorkspaceCleanup() - } - }) - - const firstScan = store.getState().scanWorkspaceCleanup() - pending.resolve(result) - - await expect(firstScan).resolves.toEqual(result) - await expect(joinedScan).resolves.toEqual(result) - unsubscribe() - - expect(scan).toHaveBeenCalledTimes(1) - expect(store.getState().workspaceCleanupLoading).toBe(false) - }) - - it('shows scanned cleanup candidates before the final broad scan resolves', async () => { - const pending = deferred() - let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined - const partialCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/partial' }) - const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) - const scan = vi.fn((_args, progressCallback) => { - onProgress = progressCallback - return pending.promise - }) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - const scanPromise = store.getState().scanWorkspaceCleanup() - onProgress?.({ - scanId: 'scan-1', - scannedAt: NOW, - scannedWorktreeCount: 1, - totalWorktreeCount: 2, - candidates: [partialCandidate], - errors: [] - }) - - expect(store.getState().workspaceCleanupLoading).toBe(true) - await vi.waitFor(() => { - expect(store.getState().workspaceCleanupProgress).toMatchObject({ - scannedWorktreeCount: 1, - totalWorktreeCount: 2 - }) - }) - expect(store.getState().workspaceCleanupScan?.candidates).toEqual([partialCandidate]) - - pending.resolve({ - scannedAt: NOW, - candidates: [partialCandidate, finalCandidate], - errors: [] - }) - - await expect(scanPromise).resolves.toEqual({ - scannedAt: NOW, - candidates: [partialCandidate, finalCandidate], - errors: [] - }) - expect(store.getState().workspaceCleanupLoading).toBe(false) - expect(store.getState().workspaceCleanupProgress).toMatchObject({ - scannedWorktreeCount: 2, - totalWorktreeCount: 2 - }) - }) - - it('does not re-probe previously enriched rows during cumulative progress updates', async () => { - const pending = deferred() - let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined - const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) - const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' }) - const scan = vi.fn((_args, progressCallback) => { - onProgress = progressCallback - return pending.promise - }) - installWorkspaceCleanupApi(scan) - const hasChildProcesses = vi.fn().mockResolvedValue(false) - const getForegroundProcess = vi.fn().mockResolvedValue('zsh') - ;( - globalThis.window as unknown as { - api: { - pty?: { - hasChildProcesses: typeof hasChildProcesses - getForegroundProcess: typeof getForegroundProcess - } - } - } - ).api.pty = { hasChildProcesses, getForegroundProcess } - const store = createCleanupTestStore() - store.setState({ - tabsByWorktree: { - 'repo1::/tmp/terminal': [ - { id: 'tab-1', title: 'zsh' } - ] as AppState['tabsByWorktree'][string] - }, - ptyIdsByTabId: { 'tab-1': ['pty-1'] } - } as Partial) - - const scanPromise = store.getState().scanWorkspaceCleanup() - onProgress?.({ - scanId: 'scan-1', - scannedAt: NOW, - scannedWorktreeCount: 1, - totalWorktreeCount: 2, - candidates: [terminalCandidate], - errors: [], - candidateMode: 'append' - }) - - await vi.waitFor(() => { - expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1) - }) - expect(hasChildProcesses).toHaveBeenCalledTimes(1) - expect(getForegroundProcess).toHaveBeenCalledTimes(1) - - onProgress?.({ - scanId: 'scan-1', - scannedAt: NOW, - scannedWorktreeCount: 2, - totalWorktreeCount: 2, - candidates: [laterCandidate], - errors: [], - candidateMode: 'append' - }) - - await vi.waitFor(() => { - expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2) - }) - expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2) - expect(hasChildProcesses).toHaveBeenCalledTimes(1) - expect(getForegroundProcess).toHaveBeenCalledTimes(1) - - pending.resolve({ - scannedAt: NOW, - candidates: [terminalCandidate, laterCandidate], - errors: [] - }) - await scanPromise - - expect(hasChildProcesses).toHaveBeenCalledTimes(1) - expect(getForegroundProcess).toHaveBeenCalledTimes(1) - }) - - it('does not join broad cleanup scans with different explicit args', async () => { - const firstPending = deferred() - const secondPending = deferred() - const firstResult = { - scannedAt: NOW, - candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/first' })], - errors: [] - } - const secondResult = { - scannedAt: NOW + 1, - candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/second' })], - errors: [] - } - const scan = vi.fn((args?: { skipGitWorktreeIds?: string[] }) => - args?.skipGitWorktreeIds?.includes('repo1::/tmp/first') - ? firstPending.promise - : secondPending.promise - ) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - const first = store - .getState() - .scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/first'] }) - const second = store - .getState() - .scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/second'] }) - - expect(scan).toHaveBeenCalledTimes(2) - secondPending.resolve(secondResult) - await second - expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult) - - firstPending.resolve(firstResult) - await expect(Promise.all([first, second])).resolves.toEqual([firstResult, secondResult]) - expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult) - }) - - it('keeps stale cleanup results visible after a broad refresh failure', async () => { - const previous = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } - const scan = vi - .fn() - .mockResolvedValueOnce(previous) - .mockRejectedValueOnce(new Error('scan failed')) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - await store.getState().scanWorkspaceCleanup() - await expect(store.getState().scanWorkspaceCleanup()).rejects.toThrow('scan failed') - - expect(store.getState().workspaceCleanupScan).toMatchObject(previous) - expect(store.getState().workspaceCleanupError).toBe('scan failed') - expect(store.getState().workspaceCleanupLoading).toBe(false) - }) - - it('keeps focused cleanup preflight scans separate from broad scans', async () => { - const broad = deferred() - const scan = vi.fn((args?: { worktreeId?: string }) => { - if (args?.worktreeId) { - return Promise.resolve({ - scannedAt: NOW + 1, - candidates: [makeCandidate({ worktreeId: args.worktreeId })], - errors: [] - } satisfies WorkspaceCleanupScanResult) - } - return broad.promise - }) - installWorkspaceCleanupApi(scan) - const store = createCleanupTestStore() - - const broadScan = store.getState().scanWorkspaceCleanup() - const focusedScan = await store.getState().scanWorkspaceCleanup({ worktreeId: WORKTREE_ID }) - - expect(scan).toHaveBeenCalledTimes(2) - expect(focusedScan.candidates[0]?.worktreeId).toBe(WORKTREE_ID) - expect(store.getState().workspaceCleanupScan).toBeNull() - - broad.resolve({ scannedAt: NOW, candidates: [], errors: [] }) - await broadScan - expect(store.getState().workspaceCleanupScan?.scannedAt).toBe(NOW) - }) +import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' +import { enrichWorkspaceCleanupCandidates } from './workspace-cleanup' +import { + NOW, + WORKTREE_ID, + createCleanupTestStore, + installWorkspaceCleanupApi, + makeCandidate, + makeState +} from './workspace-cleanup-slice-test-harness' +describe('workspace cleanup removal and protection', () => { it('preflights cleanup removals concurrently and deletes nested workspaces globally deepest first', async () => { let activePreflights = 0 let maxActivePreflights = 0 @@ -442,7 +83,9 @@ describe('workspace cleanup viewed rows', () => { 'repo-a::/repo/parent', 'repo-c::/other' ]) - expect(removeWorktree).toHaveBeenCalledWith('repo-c::/other', true) + expect(removeWorktree).toHaveBeenCalledWith('repo-c::/other', true, { + suppressPreservedBranchToast: true + }) expect(store.getState().workspaceCleanupScan?.candidates).toEqual([]) }) @@ -555,7 +198,9 @@ describe('workspace cleanup viewed rows', () => { removedIds: [WORKTREE_ID], failures: [] }) - expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false) + expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false, { + suppressPreservedBranchToast: true + }) }) it('defers git checks for locally active workspaces on initial scans', async () => { @@ -661,7 +306,104 @@ describe('workspace cleanup viewed rows', () => { failures: [] } ) - expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false) + expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false, { + suppressPreservedBranchToast: true + }) + }) + + it('fails a queued removal that now needs a force the user never approved', async () => { + const approvedCandidate = makeCandidate() + const dirtySinceConfirmation = makeCandidate({ + tier: 'review', + blockers: ['dirty-files'], + git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW } + }) + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [dirtySinceConfirmation], + errors: [] + } satisfies WorkspaceCleanupScanResult) + installWorkspaceCleanupApi(scan) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + await expect( + store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [approvedCandidate] + }) + ).resolves.toEqual({ + removedIds: [], + failures: [ + { + worktreeId: WORKTREE_ID, + displayName: 'old-workspace', + message: 'Workspace changed after confirmation. Refresh to review it before removing.' + } + ] + }) + expect(removeWorktree).not.toHaveBeenCalled() + }) + + it('still force-removes rows whose approved candidate already carried git risk', async () => { + const approvedCandidate = makeCandidate({ + tier: 'review', + blockers: ['dirty-files'], + git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW } + }) + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [approvedCandidate], + errors: [] + } satisfies WorkspaceCleanupScanResult) + installWorkspaceCleanupApi(scan) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + await expect( + store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [approvedCandidate] + }) + ).resolves.toEqual({ removedIds: [WORKTREE_ID], failures: [] }) + expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, true, { + suppressPreservedBranchToast: true + }) + }) + + it('fails a removal that reveals concrete git risk after an unverified force approval', async () => { + const approvedCandidate = makeCandidate({ + tier: 'review', + blockers: ['git-status-error'], + git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null } + }) + const nowRevealsUnpushed = makeCandidate({ + tier: 'review', + blockers: ['unpushed-commits'], + git: { clean: true, upstreamAhead: 3, upstreamBehind: 0, checkedAt: NOW } + }) + const scan = vi.fn().mockResolvedValue({ + scannedAt: NOW, + candidates: [nowRevealsUnpushed], + errors: [] + } satisfies WorkspaceCleanupScanResult) + installWorkspaceCleanupApi(scan) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + + await expect( + store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], { + approvedCandidates: [approvedCandidate] + }) + ).resolves.toEqual({ + removedIds: [], + failures: [ + { + worktreeId: WORKTREE_ID, + displayName: 'old-workspace', + message: 'Workspace changed after confirmation. Refresh to review it before removing.' + } + ] + }) + expect(removeWorktree).not.toHaveBeenCalled() }) it('protects old workspaces when an agent process is still foregrounded', async () => { diff --git a/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts new file mode 100644 index 00000000000..f38a10f86e5 --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts @@ -0,0 +1,598 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AppState } from '../types' +import type { + WorkspaceCleanupScanProgress, + WorkspaceCleanupScanResult +} from '../../../../shared/workspace-cleanup' +import { + NOW, + WORKTREE_ID, + createCleanupTestStore, + deferred, + installWorkspaceCleanupApi, + makeCandidate +} from './workspace-cleanup-slice-test-harness' + +describe('workspace cleanup scan progress', () => { + it('joins duplicate broad cleanup scans', async () => { + const pending = deferred() + const scan = vi.fn().mockReturnValue(pending.promise) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const first = store.getState().scanWorkspaceCleanup() + const second = store.getState().scanWorkspaceCleanup() + + expect(scan).toHaveBeenCalledTimes(1) + expect(store.getState().workspaceCleanupLoading).toBe(true) + + const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } + pending.resolve(result) + + await expect(Promise.all([first, second])).resolves.toEqual([result, result]) + expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(1) + expect(store.getState().workspaceCleanupLoading).toBe(false) + }) + + it('does not leave cleanup loading stuck when a reopen joins a just-settled scan', async () => { + const pending = deferred() + const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } + const scan = vi.fn().mockReturnValue(pending.promise) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + let joinedScan: Promise | null = null + + const unsubscribe = store.subscribe((state, previousState) => { + if ( + previousState.workspaceCleanupLoading && + !state.workspaceCleanupLoading && + joinedScan === null + ) { + joinedScan = state.scanWorkspaceCleanup() + } + }) + + const firstScan = store.getState().scanWorkspaceCleanup() + pending.resolve(result) + + await expect(firstScan).resolves.toEqual(result) + await expect(joinedScan).resolves.toEqual(result) + unsubscribe() + + expect(scan).toHaveBeenCalledTimes(1) + expect(store.getState().workspaceCleanupLoading).toBe(false) + }) + + it('shows scanned cleanup candidates before the final broad scan resolves', async () => { + const pending = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const partialCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/partial' }) + const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [partialCandidate], + errors: [] + }) + + expect(store.getState().workspaceCleanupLoading).toBe(true) + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress).toMatchObject({ + scannedWorktreeCount: 1, + totalWorktreeCount: 2 + }) + }) + expect(store.getState().workspaceCleanupScan?.candidates).toEqual([partialCandidate]) + + pending.resolve({ + scannedAt: NOW, + candidates: [partialCandidate, finalCandidate], + errors: [] + }) + + await expect(scanPromise).resolves.toEqual({ + scannedAt: NOW, + candidates: [partialCandidate, finalCandidate], + errors: [] + }) + expect(store.getState().workspaceCleanupLoading).toBe(false) + expect(store.getState().workspaceCleanupProgress).toMatchObject({ + scannedWorktreeCount: 2, + totalWorktreeCount: 2 + }) + }) + + it('does not re-probe previously enriched rows during cumulative progress updates', async () => { + const pending = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockResolvedValue(false) + const getForegroundProcess = vi.fn().mockResolvedValue('zsh') + ;( + globalThis.window as unknown as { + api: { + pty?: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: typeof getForegroundProcess + } + } + } + ).api.pty = { hasChildProcesses, getForegroundProcess } + const store = createCleanupTestStore() + store.setState({ + tabsByWorktree: { + 'repo1::/tmp/terminal': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1) + }) + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 2, + totalWorktreeCount: 2, + candidates: [laterCandidate], + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2) + }) + expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2) + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + + pending.resolve({ + scannedAt: NOW, + candidates: [terminalCandidate, laterCandidate], + errors: [] + }) + await scanPromise + + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + }) + + it('updates count-only append progress without replacing existing candidate rows', async () => { + const pending = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const candidate = makeCandidate({ worktreeId: 'repo1::/tmp/alpha' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [candidate], + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1) + }) + const candidatesAfterFirstAppend = store.getState().workspaceCleanupScan?.candidates + + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 2, + totalWorktreeCount: 2, + candidates: [], + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2) + }) + expect(store.getState().workspaceCleanupScan?.candidates).toBe(candidatesAfterFirstAppend) + + pending.resolve({ + scannedAt: NOW, + candidates: [candidate], + errors: [] + }) + await scanPromise + }) + + it('keeps append progress rows when terminal enrichment resolves out of order', async () => { + const pending = deferred() + const terminalProbe = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockReturnValue(terminalProbe.promise) + const getForegroundProcess = vi.fn().mockResolvedValue('zsh') + ;( + globalThis.window as unknown as { + api: { + pty?: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: typeof getForegroundProcess + } + } + } + ).api.pty = { hasChildProcesses, getForegroundProcess } + const store = createCleanupTestStore() + store.setState({ + tabsByWorktree: { + 'repo1::/tmp/terminal': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + await vi.waitFor(() => { + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + }) + + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 2, + totalWorktreeCount: 2, + candidates: [laterCandidate], + errors: [], + candidateMode: 'append' + }) + await Promise.resolve() + expect(store.getState().workspaceCleanupProgress).toBeNull() + + terminalProbe.resolve(false) + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2) + }) + expect( + store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId) + ).toEqual(['repo1::/tmp/terminal', 'repo1::/tmp/later']) + + pending.resolve({ + scannedAt: NOW, + candidates: [terminalCandidate, laterCandidate], + errors: [] + }) + await scanPromise + }) + + it('publishes the final scan result without waiting for queued progress', async () => { + const pending = deferred() + const terminalProbe = deferred() + let scanSettled = false + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockReturnValue(terminalProbe.promise) + const getForegroundProcess = vi.fn().mockResolvedValue('zsh') + ;( + globalThis.window as unknown as { + api: { + pty?: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: typeof getForegroundProcess + } + } + } + ).api.pty = { hasChildProcesses, getForegroundProcess } + const store = createCleanupTestStore() + store.setState({ + tabsByWorktree: { + 'repo1::/tmp/terminal': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + const scanPromise = store + .getState() + .scanWorkspaceCleanup() + .finally(() => { + scanSettled = true + }) + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + await vi.waitFor(() => { + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + }) + + pending.resolve({ + scannedAt: NOW, + candidates: [finalCandidate], + errors: [] + }) + await scanPromise + expect(scanSettled).toBe(true) + + terminalProbe.resolve(false) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect( + store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId) + ).toEqual(['repo1::/tmp/final']) + expect(store.getState().workspaceCleanupProgress).toMatchObject({ + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + }) + }) + + it('ignores stale progress without replacing the active scan progress queue', async () => { + const firstPending = deferred() + const secondPending = deferred() + const terminalProbe = deferred() + const progressCallbacks: ((progress: WorkspaceCleanupScanProgress) => void)[] = [] + let secondScanSettled = false + const firstCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/first' }) + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) + const scan = vi.fn((args, progressCallback) => { + progressCallbacks.push(progressCallback) + return args?.skipGitWorktreeIds?.includes('second') + ? secondPending.promise + : firstPending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockReturnValue(terminalProbe.promise) + const getForegroundProcess = vi.fn().mockResolvedValue('zsh') + ;( + globalThis.window as unknown as { + api: { + pty?: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: typeof getForegroundProcess + } + } + } + ).api.pty = { hasChildProcesses, getForegroundProcess } + const store = createCleanupTestStore() + store.setState({ + tabsByWorktree: { + 'repo1::/tmp/terminal': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + const firstScan = store.getState().scanWorkspaceCleanup({ skipGitWorktreeIds: ['first'] }) + const secondScan = store + .getState() + .scanWorkspaceCleanup({ skipGitWorktreeIds: ['second'] }) + .finally(() => { + secondScanSettled = true + }) + + progressCallbacks[1]?.({ + scanId: 'scan-2', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + await vi.waitFor(() => { + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + }) + progressCallbacks[0]?.({ + scanId: 'scan-1', + scannedAt: NOW - 1, + scannedWorktreeCount: 1, + totalWorktreeCount: 1, + candidates: [firstCandidate], + errors: [], + candidateMode: 'append' + }) + + secondPending.resolve({ + scannedAt: NOW, + candidates: [terminalCandidate, finalCandidate], + errors: [] + }) + await Promise.resolve() + expect(secondScanSettled).toBe(false) + + terminalProbe.resolve(false) + await secondScan + firstPending.resolve({ scannedAt: NOW - 1, candidates: [firstCandidate], errors: [] }) + await firstScan + + expect( + store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId) + ).toEqual(['repo1::/tmp/terminal', 'repo1::/tmp/final']) + }) + + it('does not let an in-flight broad scan revive removed cleanup rows', async () => { + const firstBroadScan = deferred() + const secondBroadScan = deferred() + const candidate = makeCandidate() + const refreshedCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/refreshed' }) + const broadScans = [firstBroadScan, secondBroadScan] + const scan = vi.fn((args?: { worktreeId?: string }) => { + if (args?.worktreeId) { + return Promise.resolve({ scannedAt: NOW, candidates: [candidate], errors: [] }) + } + const nextBroadScan = broadScans.shift() + if (!nextBroadScan) { + throw new Error('unexpected broad scan') + } + return nextBroadScan.promise + }) + installWorkspaceCleanupApi(scan) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + store.setState({ + workspaceCleanupScan: { scannedAt: NOW - 1, candidates: [candidate], errors: [] } + } as Partial) + + const pendingRefresh = store.getState().scanWorkspaceCleanup() + await store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId]) + const replacementRefresh = store.getState().scanWorkspaceCleanup() + + expect(scan).toHaveBeenCalledTimes(3) + + secondBroadScan.resolve({ scannedAt: NOW, candidates: [refreshedCandidate], errors: [] }) + await expect(replacementRefresh).resolves.toMatchObject({ + candidates: [refreshedCandidate] + }) + + firstBroadScan.resolve({ scannedAt: NOW - 1, candidates: [candidate], errors: [] }) + await pendingRefresh + + expect(store.getState().workspaceCleanupLoading).toBe(false) + expect(store.getState().workspaceCleanupScan?.candidates).toEqual([refreshedCandidate]) + }) + + it('does not join broad cleanup scans with different explicit args', async () => { + const firstPending = deferred() + const secondPending = deferred() + const firstResult = { + scannedAt: NOW, + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/first' })], + errors: [] + } + const secondResult = { + scannedAt: NOW + 1, + candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/second' })], + errors: [] + } + const scan = vi.fn((args?: { skipGitWorktreeIds?: string[] }) => + args?.skipGitWorktreeIds?.includes('repo1::/tmp/first') + ? firstPending.promise + : secondPending.promise + ) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const first = store + .getState() + .scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/first'] }) + const second = store + .getState() + .scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/second'] }) + + expect(scan).toHaveBeenCalledTimes(2) + secondPending.resolve(secondResult) + await second + expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult) + + firstPending.resolve(firstResult) + await expect(Promise.all([first, second])).resolves.toEqual([firstResult, secondResult]) + expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult) + }) + + it('keeps stale cleanup results visible after a broad refresh failure', async () => { + const previous = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } + const scan = vi + .fn() + .mockResolvedValueOnce(previous) + .mockRejectedValueOnce(new Error('scan failed')) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + await store.getState().scanWorkspaceCleanup() + await expect(store.getState().scanWorkspaceCleanup()).rejects.toThrow('scan failed') + + expect(store.getState().workspaceCleanupScan).toMatchObject(previous) + expect(store.getState().workspaceCleanupError).toBe('scan failed') + expect(store.getState().workspaceCleanupLoading).toBe(false) + }) + + it('keeps focused cleanup preflight scans separate from broad scans', async () => { + const broad = deferred() + const scan = vi.fn((args?: { worktreeId?: string }) => { + if (args?.worktreeId) { + return Promise.resolve({ + scannedAt: NOW + 1, + candidates: [makeCandidate({ worktreeId: args.worktreeId })], + errors: [] + } satisfies WorkspaceCleanupScanResult) + } + return broad.promise + }) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const broadScan = store.getState().scanWorkspaceCleanup() + const focusedScan = await store.getState().scanWorkspaceCleanup({ worktreeId: WORKTREE_ID }) + + expect(scan).toHaveBeenCalledTimes(2) + expect(focusedScan.candidates[0]?.worktreeId).toBe(WORKTREE_ID) + expect(store.getState().workspaceCleanupScan).toBeNull() + + broad.resolve({ scannedAt: NOW, candidates: [], errors: [] }) + await broadScan + expect(store.getState().workspaceCleanupScan?.scannedAt).toBe(NOW) + }) +}) diff --git a/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts b/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts new file mode 100644 index 00000000000..8e0f0ca83dc --- /dev/null +++ b/src/renderer/src/store/slices/workspace-cleanup-slice-test-harness.ts @@ -0,0 +1,110 @@ +import { create } from 'zustand' +import { vi } from 'vitest' +import type { AppState } from '../types' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { createWorkspaceCleanupSlice } from './workspace-cleanup' + +export const WORKTREE_ID = 'repo1::/tmp/old-workspace' +export const NOW = 1_700_000_000_000 + +export function makeCandidate( + overrides: Partial = {} +): WorkspaceCleanupCandidate { + return { + worktreeId: WORKTREE_ID, + repoId: 'repo1', + repoName: 'Repo 1', + connectionId: null, + displayName: 'old-workspace', + branch: 'old-workspace', + path: '/tmp/old-workspace', + tier: 'ready', + selectedByDefault: true, + reasons: ['idle-clean'], + blockers: [], + lastActivityAt: NOW - 30 * 24 * 60 * 60 * 1000, + localContext: { + terminalTabCount: 0, + cleanEditorTabCount: 0, + browserTabCount: 0, + diffCommentCount: 0, + newestDiffCommentAt: null, + retainedDoneAgentCount: 0 + }, + git: { + clean: true, + upstreamAhead: 0, + upstreamBehind: 0, + checkedAt: NOW + }, + fingerprint: 'fingerprint-1', + ...overrides + } +} + +export function makeState(overrides: Partial = {}): AppState { + return { + tabsByWorktree: {}, + ptyIdsByTabId: {}, + openFiles: [], + editorDrafts: {}, + browserTabsByWorktree: {}, + retainedAgentsByPaneKey: {}, + activeWorktreeId: null, + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + lastVisitedAtByWorktreeId: {}, + workspaceCleanupDismissals: {}, + workspaceCleanupViewedCandidates: {}, + ...overrides + } as AppState +} + +export function createCleanupTestStore(removeWorktree: ReturnType = vi.fn()) { + return create()( + (...a) => + ({ + tabsByWorktree: {}, + ptyIdsByTabId: {}, + openFiles: [], + editorDrafts: {}, + browserTabsByWorktree: {}, + retainedAgentsByPaneKey: {}, + activeWorktreeId: null, + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + lastVisitedAtByWorktreeId: {}, + removeWorktree, + ...createWorkspaceCleanupSlice(...a) + }) as unknown as AppState + ) +} + +export function installWorkspaceCleanupApi(scan: ReturnType) { + ;(globalThis as { window: unknown }).window = { + api: { + workspaceCleanup: { + scan, + dismiss: vi.fn().mockResolvedValue(undefined), + clearDismissals: vi.fn().mockResolvedValue(undefined), + hasKillableLocalProcesses: vi.fn().mockResolvedValue({ + hasKillableProcesses: false + }) + } + } + } +} + +export function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve + reject = innerReject + }) + return { promise, resolve, reject } +} diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index 502cead194a..ed2e12d49be 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -35,6 +35,12 @@ export type WorkspaceCleanupRemoveResult = { failures: WorkspaceCleanupFailure[] } +export type WorkspaceCleanupRemoveOptions = { + // Why: rows are removed long after the confirm click; the confirm-time + // candidate records how much git risk the user actually approved. + approvedCandidates?: readonly WorkspaceCleanupCandidate[] +} + type WorkspaceCleanupViewedCandidate = { viewedAt: number fingerprint: string @@ -55,7 +61,8 @@ export type WorkspaceCleanupSlice = { ) => Promise resetWorkspaceCleanupDismissals: () => Promise removeWorkspaceCleanupCandidates: ( - worktreeIds: readonly string[] + worktreeIds: readonly string[], + options?: WorkspaceCleanupRemoveOptions ) => Promise } @@ -72,16 +79,33 @@ type WorkspaceCleanupEnrichmentCacheEntry = { const RECENT_VISIBLE_CONTEXT_MS = 24 * 60 * 60 * 1000 const VIEWED_FROM_CLEANUP_MS = 2 * 60 * 60 * 1000 const WORKSPACE_CLEANUP_PREFLIGHT_CONCURRENCY = 4 +// Why: dirty-files/unpushed-commits are concrete known work at risk; unknown-base +// and git-status-error only mean "couldn't verify". A row approved while +// unverifiable must still fail if real work becomes visible before removal. +const WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS = ['dirty-files', 'unpushed-commits'] as const let inFlightWorkspaceCleanupScan: { key: string promise: Promise } | null = null let latestWorkspaceCleanupScanToken = 0 +let finalizedWorkspaceCleanupScanToken = 0 +let workspaceCleanupProgressQueue: { + scanToken: number + promise: Promise +} | null = null let workspaceCleanupEnrichmentCache: { scanToken: number entries: Map } | null = null +// Why: cleanup progress can append thousands of rows; keep one scan-local +// index so each streamed row does not rebuild a map of every previous row. +let workspaceCleanupProgressCandidateIndex: { + scanToken: number + scanId: string + candidates: WorkspaceCleanupCandidate[] + indexesByWorktreeId: Map +} | null = null const SHELL_PROCESS_NAMES = new Set([ 'bash', @@ -160,10 +184,13 @@ export const createWorkspaceCleanupSlice: StateCreator { const scan = await window.api.workspaceCleanup.scan(scanArgs, (progress) => { - void applyWorkspaceCleanupProgress(progress, scanToken, get, set) + enqueueWorkspaceCleanupProgress(progress, scanToken, get, set) }) const enriched = await enrichWorkspaceCleanupCandidatesForScan( scan.candidates, @@ -172,10 +199,13 @@ export const createWorkspaceCleanupSlice: StateCreator { + removeWorkspaceCleanupCandidates: async (worktreeIds, options) => { const removedIds: string[] = [] const failures: WorkspaceCleanupFailure[] = [] + const approvedCandidatesByWorktreeId = new Map( + (options?.approvedCandidates ?? []).map((candidate) => [candidate.worktreeId, candidate]) + ) const preflights = await mapWithConcurrency( worktreeIds, WORKSPACE_CLEANUP_PREFLIGHT_CONCURRENCY, - (worktreeId) => preflightWorkspaceCleanupCandidate(worktreeId, get) + (worktreeId) => + preflightWorkspaceCleanupCandidate( + worktreeId, + get, + approvedCandidatesByWorktreeId.get(worktreeId) + ) ) const candidatesToRemove: WorkspaceCleanupCandidate[] = [] @@ -290,7 +328,10 @@ export const createWorkspaceCleanupSlice: StateCreator b.path.length - a.path.length)) { const result = await get().removeWorktree( candidate.worktreeId, - shouldForceWorkspaceCleanupRemoval(candidate) + shouldForceWorkspaceCleanupRemoval(candidate), + // Why: cleanup reports outcomes in its own summary toasts; per-row + // preserved-branch warnings would stack one toast per removed row. + { suppressPreservedBranchToast: true } ) if (result.ok) { removedIds.push(candidate.worktreeId) @@ -304,8 +345,10 @@ export const createWorkspaceCleanupSlice: StateCreator 0) { + invalidateWorkspaceCleanupScanProgress() const removedIdSet = new Set(removedIds) set((state) => ({ + workspaceCleanupLoading: false, workspaceCleanupScan: state.workspaceCleanupScan ? { ...state.workspaceCleanupScan, @@ -327,6 +370,43 @@ function getWorkspaceCleanupScanKey(args: WorkspaceCleanupScanArgs): string { }) } +function invalidateWorkspaceCleanupScanProgress(): void { + latestWorkspaceCleanupScanToken += 1 + finalizedWorkspaceCleanupScanToken = 0 + inFlightWorkspaceCleanupScan = null + workspaceCleanupProgressQueue = null + workspaceCleanupEnrichmentCache = null + workspaceCleanupProgressCandidateIndex = null +} + +function enqueueWorkspaceCleanupProgress( + progress: WorkspaceCleanupScanProgress, + scanToken: number, + getState: () => AppState, + setState: ( + partial: Partial | ((state: AppState) => Partial), + replace?: false + ) => void +): void { + if ( + scanToken !== latestWorkspaceCleanupScanToken || + scanToken === finalizedWorkspaceCleanupScanToken + ) { + return + } + const previous = + workspaceCleanupProgressQueue?.scanToken === scanToken + ? workspaceCleanupProgressQueue.promise + : Promise.resolve() + const promise = previous + .catch(() => undefined) + .then(() => applyWorkspaceCleanupProgress(progress, scanToken, getState, setState)) + .catch((error: unknown) => { + console.error('Workspace cleanup progress update failed', error) + }) + workspaceCleanupProgressQueue = { scanToken, promise } +} + async function applyWorkspaceCleanupProgress( progress: WorkspaceCleanupScanProgress, scanToken: number, @@ -336,6 +416,12 @@ async function applyWorkspaceCleanupProgress( replace?: false ) => void ): Promise { + if ( + scanToken !== latestWorkspaceCleanupScanToken || + scanToken === finalizedWorkspaceCleanupScanToken + ) { + return + } const state = getState() const previousCandidates = progress.candidateMode === 'append' && @@ -347,11 +433,23 @@ async function applyWorkspaceCleanupProgress( state, scanToken ) - const candidates = - progress.candidateMode === 'append' - ? appendWorkspaceCleanupProgressCandidates(previousCandidates, enrichedProgressCandidates) - : enrichedProgressCandidates - if (scanToken !== latestWorkspaceCleanupScanToken) { + if ( + scanToken !== latestWorkspaceCleanupScanToken || + scanToken === finalizedWorkspaceCleanupScanToken + ) { + return + } + const candidates = mergeWorkspaceCleanupProgressCandidates({ + previousCandidates, + nextCandidates: enrichedProgressCandidates, + progress, + scanToken + }) + if ( + scanToken !== latestWorkspaceCleanupScanToken || + scanToken === finalizedWorkspaceCleanupScanToken + ) { + workspaceCleanupProgressCandidateIndex = null return } setState((state) => { @@ -387,30 +485,74 @@ async function enrichWorkspaceCleanupCandidatesForScan( ) } -function appendWorkspaceCleanupProgressCandidates( - previousCandidates: readonly WorkspaceCleanupCandidate[], +function mergeWorkspaceCleanupProgressCandidates({ + previousCandidates, + nextCandidates, + progress, + scanToken +}: { + previousCandidates: readonly WorkspaceCleanupCandidate[] nextCandidates: readonly WorkspaceCleanupCandidate[] -): WorkspaceCleanupCandidate[] { - if (nextCandidates.length === 0) { - return [...previousCandidates] + progress: WorkspaceCleanupScanProgress + scanToken: number +}): WorkspaceCleanupCandidate[] { + if (progress.candidateMode !== 'append') { + workspaceCleanupProgressCandidateIndex = null + return [...nextCandidates] } - const indexesByWorktreeId = new Map( - previousCandidates.map((candidate, index) => [candidate.worktreeId, index]) + if (nextCandidates.length === 0) { + return previousCandidates as WorkspaceCleanupCandidate[] + } + + const indexCache = getWorkspaceCleanupProgressCandidateIndex( + previousCandidates, + progress.scanId, + scanToken ) - const merged = [...previousCandidates] + const merged = [...indexCache.candidates] for (const candidate of nextCandidates) { - const existingIndex = indexesByWorktreeId.get(candidate.worktreeId) + const existingIndex = indexCache.indexesByWorktreeId.get(candidate.worktreeId) if (existingIndex === undefined) { - indexesByWorktreeId.set(candidate.worktreeId, merged.length) + indexCache.indexesByWorktreeId.set(candidate.worktreeId, merged.length) merged.push(candidate) continue } merged[existingIndex] = candidate } + workspaceCleanupProgressCandidateIndex = { + scanToken, + scanId: progress.scanId, + candidates: merged, + indexesByWorktreeId: indexCache.indexesByWorktreeId + } return merged } +function getWorkspaceCleanupProgressCandidateIndex( + candidates: readonly WorkspaceCleanupCandidate[], + scanId: string, + scanToken: number +): { + candidates: WorkspaceCleanupCandidate[] + indexesByWorktreeId: Map +} { + if ( + workspaceCleanupProgressCandidateIndex?.scanToken === scanToken && + workspaceCleanupProgressCandidateIndex.scanId === scanId && + workspaceCleanupProgressCandidateIndex.candidates === candidates + ) { + return workspaceCleanupProgressCandidateIndex + } + + return { + candidates: [...candidates], + indexesByWorktreeId: new Map( + candidates.map((candidate, index) => [candidate.worktreeId, index]) + ) + } +} + async function mapWithConcurrency( items: readonly T[], limit: number, @@ -681,7 +823,8 @@ function applyDismissal( async function preflightWorkspaceCleanupCandidate( worktreeId: string, - getState: () => AppState + getState: () => AppState, + approvedCandidate?: WorkspaceCleanupCandidate ): Promise< | { ok: true; candidate: WorkspaceCleanupCandidate } | { ok: false; failure: WorkspaceCleanupFailure } @@ -715,6 +858,33 @@ async function preflightWorkspaceCleanupCandidate( } } } + // Why: this row may be removed minutes after the confirm click. If it now + // needs a force removal the user never approved (new dirt, unpushed work, + // or a git error since confirmation), fail it instead of force-deleting. + if (approvedCandidate) { + const escalatedToForce = + shouldForceWorkspaceCleanupRemoval(candidate) && + !shouldForceWorkspaceCleanupRemoval(approvedCandidate) + // Why: an approved row that was already force-flagged for an unverifiable + // reason must still fail when real dirt/unpushed work is now visible. + const revealedConcreteRisk = WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS.some( + (blocker) => + candidate.blockers.includes(blocker) && !approvedCandidate.blockers.includes(blocker) + ) + if (escalatedToForce || revealedConcreteRisk) { + return { + ok: false, + failure: { + worktreeId, + displayName: candidate.displayName, + message: translate( + 'auto.store.slices.workspace.cleanup.changedSinceConfirmation', + 'Workspace changed after confirmation. Refresh to review it before removing.' + ) + } + } + } + } return { ok: true, candidate } } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 696cd0717e9..40cb2af630d 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -31,6 +31,7 @@ export { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' export type WorktreeDeleteState = { isDeleting: boolean + phase?: 'deleting' | 'queued' error: string | null canForceDelete: boolean forceDeleteReason: WorktreeForceDeleteReason | null @@ -184,9 +185,14 @@ export type WorktreeSlice = { // 'forget-local' drops the workspace from Orca only (no remote Git/FS work) // for workspaces pinned to a removed/disconnected SSH host. Reuses the same // renderer-side teardown/purge as a normal remove. - options?: { mode?: 'remove' | 'forget-local'; overrideLock?: boolean } + options?: { + mode?: 'remove' | 'forget-local' + overrideLock?: boolean + suppressPreservedBranchToast?: boolean + } ) => Promise<({ ok: true } & RemoveWorktreeResult) | { ok: false; error: string }> markWorktreesDeleting: (worktreeIds: readonly string[]) => void + markWorktreesQueuedForDeletion: (worktreeIds: readonly string[]) => void forceDeletePreservedBranch: ( worktreeId: string, branchName: string, diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 9d3786f37ff..39903af3ee2 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3131,6 +3131,7 @@ export const createWorktreeSlice: StateCreator ...s.deleteStateByWorktreeId, [worktreeId]: { isDeleting: true, + phase: 'deleting', error: null, canForceDelete: false, forceDeleteReason: null @@ -3499,7 +3500,7 @@ export const createWorktreeSlice: StateCreator // prune effect cannot be the only stale-draft cleanup path. clearSessionCommitDraftForWorktree(worktreeId) const preservedBranch = removalResult?.preservedBranch - if (preservedBranch) { + if (preservedBranch && options?.suppressPreservedBranchToast !== true) { showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => { void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead) }) @@ -3548,6 +3549,32 @@ export const createWorktreeSlice: StateCreator } nextDeleteState[worktreeId] = { isDeleting: true, + phase: 'deleting', + error: null, + canForceDelete: false, + forceDeleteReason: null + } + changed = true + } + return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} + }) + }, + + markWorktreesQueuedForDeletion: (worktreeIds) => { + if (worktreeIds.length === 0) { + return + } + set((s) => { + const nextDeleteState = { ...s.deleteStateByWorktreeId } + let changed = false + for (const worktreeId of new Set(worktreeIds)) { + const current = nextDeleteState[worktreeId] + if (current?.isDeleting && current.error === null && !current.canForceDelete) { + continue + } + nextDeleteState[worktreeId] = { + isDeleting: true, + phase: 'queued', error: null, canForceDelete: false, forceDeleteReason: null diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 4c754a74786..920a47dd53f 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2346,6 +2346,9 @@ function createWebUiApi(): NonNullable['ui']> { respondMobileMarkdownRequest: () => {}, onCloseTerminal: () => noopUnsubscribe, onSleepWorktree: () => noopUnsubscribe, + // Why: paired web is a full renderer that wakes on activation; mobile wake is + // desktop-host-scoped, so the web client never receives this signal. + onResumeSleepingAgents: () => noopUnsubscribe, onTerminalZoom: () => noopUnsubscribe, // Why: a paired web client has no OS sleep signal; occlusion-driven // visibilitychange already covers its wake recovery. diff --git a/src/shared/posix-command-path-lookup.test.ts b/src/shared/posix-command-path-lookup.test.ts new file mode 100644 index 00000000000..1ec2a398d32 --- /dev/null +++ b/src/shared/posix-command-path-lookup.test.ts @@ -0,0 +1,312 @@ +import { execFileSync } from 'node:child_process' +import { + accessSync, + chmodSync, + constants, + existsSync, + mkdtempSync, + mkdirSync, + realpathSync, + rmSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, delimiter, dirname, isAbsolute, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { buildPosixCommandPathLookupScript } from './posix-command-path-lookup' +import { buildWslLoginShellCommand, escapeWslShCommandForWindows } from './wsl-login-shell-command' + +type ShellCase = { + name: string + path: string | null +} + +const isWindows = process.platform === 'win32' +const WSL_TEST_COMMAND_TIMEOUT_MS = 10_000 +let wslShAvailable: boolean | null = null +const shellCases: ShellCase[] = [ + { name: 'sh', path: executablePath(['/bin/sh']) }, + { name: 'bash', path: executablePath(['/bin/bash', '/usr/bin/bash']) }, + { name: 'zsh', path: executablePath(['/bin/zsh', '/usr/bin/zsh']) }, + { name: 'dash', path: executablePath(['/bin/dash', '/usr/bin/dash']) } +] + +describe('buildPosixCommandPathLookupScript', () => { + for (const shell of shellCases) { + it.skipIf(isWindows || shell.path === null)( + `resolves without mutating alias and function masks in ${shell.name}`, + () => { + const commandName = basename(process.execPath) + const script = [ + `${commandName}() { printf '%s\\n' masked-function; }`, + `alias ${commandName}='printf "%s\\n" masked-alias'`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: commandName }), + `printf '%s\\n' "$resolved"`, + `alias ${commandName} >/dev/null`, + `unalias ${commandName}`, + `${commandName}` + ].join('\n') + + const resolved = execFileSync(shell.path!, ['-c', script], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ''}` + } + }) + .trim() + .split('\n') + + expect(isAbsolute(resolved[0])).toBe(true) + expect(realpathSync(resolved[0])).toBe(realpathSync(process.execPath)) + expect(resolved[1]).toBe('masked-function') + } + ) + } + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'resolves a command held in a validated shell variable', + () => { + const commandName = basename(process.execPath) + const script = [ + `cmd='${commandName}'`, + `${commandName}() { printf '%s\\n' masked-function; }`, + `alias ${commandName}='printf "%s\\n" masked-alias'`, + buildPosixCommandPathLookupScript({ kind: 'shell-variable', name: 'cmd' }), + `printf '%s\\n' "$resolved"` + ].join('\n') + + const resolved = execFileSync('/bin/sh', ['-c', script], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ''}` + } + }).trim() + + expect(isAbsolute(resolved)).toBe(true) + expect(realpathSync(resolved)).toBe(realpathSync(process.execPath)) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/bash', '/usr/bin/bash']) === null)( + 'resolves past a readonly bash function mask', + () => { + const commandName = basename(process.execPath) + const script = [ + `${commandName}() { printf '%s\\n' masked-function; }`, + `readonly -f ${commandName}`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: commandName }), + `printf '%s\\n' "$resolved"` + ].join('\n') + + const resolved = execFileSync( + executablePath(['/bin/bash', '/usr/bin/bash'])!, + ['-c', script], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH ?? ''}` + } + } + ).trim() + + expect(realpathSync(resolved)).toBe(realpathSync(process.execPath)) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'prefers the first external executable even when its name is a shell builtin', + () => { + withExecutableFixture('printf', (directory, executable, root) => { + const secondDirectory = join(root, 'second-bin') + const secondExecutable = join(secondDirectory, 'printf') + mkdirSync(secondDirectory) + writeFileSync(secondExecutable, '#!/bin/sh\nexit 0\n') + chmodSync(secondExecutable, 0o755) + const script = [ + buildPosixCommandPathLookupScript({ kind: 'literal', value: 'printf' }), + `printf '%s\\n' "$resolved"` + ].join('\n') + const resolved = execFileSync('/bin/sh', ['-c', script], { + encoding: 'utf8', + env: { ...process.env, PATH: `${directory}:${secondDirectory}` } + }).trim() + + expectResolvedExecutable(resolved, executable) + }) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'makes relative and trailing-empty PATH matches absolute', + () => { + withExecutableFixture('relative-agent', (directory, executable, root) => { + const relativeDirectory = basename(directory) + const script = [ + buildPosixCommandPathLookupScript({ kind: 'literal', value: 'relative-agent' }), + `printf '%s\\n' "$resolved"` + ].join('\n') + const relativeResolved = execFileSync('/bin/sh', ['-c', script], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, PATH: `${relativeDirectory}:` } + }).trim() + const trailingResolved = execFileSync('/bin/sh', ['-c', script], { + cwd: directory, + encoding: 'utf8', + env: { ...process.env, PATH: '/missing:' } + }).trim() + const emptyResolved = execFileSync('/bin/sh', ['-c', script], { + cwd: directory, + encoding: 'utf8', + env: { ...process.env, PATH: ':/missing' } + }).trim() + + expectResolvedExecutable(relativeResolved, executable) + expectResolvedExecutable(trailingResolved, executable) + expectResolvedExecutable(emptyResolved, executable) + }) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'handles leading-dash names and explicit relative paths', + () => { + withExecutableFixture('-agent', (directory, executable) => { + const script = [ + buildPosixCommandPathLookupScript({ kind: 'shell-variable', name: 'cmd' }), + `printf '%s\\n' "$resolved"`, + buildPosixCommandPathLookupScript({ kind: 'literal', value: './-agent' }), + `printf '%s\\n' "$resolved"` + ].join('\n') + const output = execFileSync('/bin/sh', ['-c', script], { + cwd: directory, + encoding: 'utf8', + env: { ...process.env, PATH: directory, cmd: '-agent' } + }) + .trim() + .split('\n') + + expectResolvedExecutable(output[0], executable) + expectResolvedExecutable(output[1], executable) + }) + } + ) + + it.skipIf(isWindows || executablePath(['/bin/sh']) === null)( + 'keeps set -e callers running when the command is absent', + () => { + const script = [ + 'set -e', + buildPosixCommandPathLookupScript({ + kind: 'literal', + value: '__orca_missing_command_path_lookup__' + }), + `printf '%s\\n' survived` + ].join('\n') + + expect(execFileSync('/bin/sh', ['-c', script], { encoding: 'utf8' }).trim()).toBe('survived') + } + ) + + it.each(['', '$cmd', 'cmd-name', 'cmd; echo injected'])( + 'rejects an unsafe shell variable name: %s', + (name) => { + expect(() => buildPosixCommandPathLookupScript({ kind: 'shell-variable', name })).toThrow( + 'Invalid shell variable name' + ) + } + ) + + it('quotes literal targets before assigning them in the generated shell fragment', () => { + const script = buildPosixCommandPathLookupScript({ + kind: 'literal', + value: "agent'; echo injected; '" + }) + + expect(script).toContain(`_orca_lookup_command='agent'\\''; echo injected; '\\'''`) + }) + + it.skipIf(!canRunWslSh())( + 'resolves through the Windows-to-WSL login-shell boundary with inline masks', + () => { + const lookup = buildPosixCommandPathLookupScript({ kind: 'literal', value: 'sh' }) + const command = buildWslLoginShellCommand( + [ + `sh() { printf '%s\\n' masked-function; }`, + `alias sh='printf masked-alias'`, + lookup, + `printf '%s' "$resolved"` + ].join('\n') + ) + const resolved = execFileSync( + 'wsl.exe', + ['--', 'sh', '-lc', escapeWslShCommandForWindows(command)], + { encoding: 'utf8', timeout: WSL_TEST_COMMAND_TIMEOUT_MS } + ).trim() + + expect(resolved).toMatch(/^\/.+\/sh$/) + }, + 30_000 + ) +}) + +function expectResolvedExecutable(resolved: string, executable: string): void { + expect(isAbsolute(resolved)).toBe(true) + expect(realpathSync(resolved)).toBe(realpathSync(executable)) +} + +function canRunWslSh(): boolean { + if (!isWindows) { + return false + } + if (wslShAvailable !== null) { + return wslShAvailable + } + try { + execFileSync('wsl.exe', ['--', 'sh', '-lc', 'true'], { + timeout: WSL_TEST_COMMAND_TIMEOUT_MS + }) + wslShAvailable = true + } catch { + wslShAvailable = false + } + return wslShAvailable +} + +function withExecutableFixture( + name: string, + run: (directory: string, executable: string, root: string) => void +): void { + const root = mkdtempSync(join(tmpdir(), 'orca-path-lookup-')) + const directory = join(root, 'bin') + const executable = join(directory, name) + try { + mkdirSync(directory) + writeFileSync(executable, '#!/bin/sh\nexit 0\n') + chmodSync(executable, 0o755) + run(directory, executable, root) + } finally { + rmSync(root, { force: true, recursive: true }) + } +} + +function executablePath(candidates: readonly string[]): string | null { + if (isWindows) { + return null + } + for (const candidate of candidates) { + if (!existsSync(candidate)) { + continue + } + try { + accessSync(candidate, constants.X_OK) + return candidate + } catch { + // Keep checking alternate standard locations when this entry is not executable. + } + } + return null +} diff --git a/src/shared/posix-command-path-lookup.ts b/src/shared/posix-command-path-lookup.ts new file mode 100644 index 00000000000..fb29be82294 --- /dev/null +++ b/src/shared/posix-command-path-lookup.ts @@ -0,0 +1,65 @@ +export type PosixCommandPathLookupTarget = + | { kind: 'literal'; value: string } + | { kind: 'shell-variable'; name: string } + +const SHELL_VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ + +export function buildPosixCommandPathLookupScript(target: PosixCommandPathLookupTarget): string { + const commandAssignment = buildCommandAssignment(target) + // Shell command resolution can be masked by aliases, functions, and builtins, so inspect PATH. + return [ + `_orca_lookup_command=${commandAssignment}`, + 'resolved=', + 'case "$_orca_lookup_command" in', + ' */*)', + ' case "$_orca_lookup_command" in', + ' /*) _orca_lookup_candidate=$_orca_lookup_command ;;', + ' *) _orca_lookup_candidate=${PWD%/}/$_orca_lookup_command ;;', + ' esac', + ' if [ -x "$_orca_lookup_candidate" ] && [ ! -d "$_orca_lookup_candidate" ]; then', + ' resolved=$_orca_lookup_candidate', + ' fi', + ' ;;', + ' *)', + ' _orca_lookup_remaining=${PATH-}', + ' while :; do', + ' case "$_orca_lookup_remaining" in', + ' *:*)', + ' _orca_lookup_component=${_orca_lookup_remaining%%:*}', + ' _orca_lookup_remaining=${_orca_lookup_remaining#*:}', + ' _orca_lookup_has_more=1', + ' ;;', + ' *)', + ' _orca_lookup_component=$_orca_lookup_remaining', + ' _orca_lookup_has_more=', + ' ;;', + ' esac', + ' [ -n "$_orca_lookup_component" ] || _orca_lookup_component=.', + ' case "$_orca_lookup_component" in', + ' /*) _orca_lookup_candidate=$_orca_lookup_component/$_orca_lookup_command ;;', + ' *) _orca_lookup_candidate=${PWD%/}/$_orca_lookup_component/$_orca_lookup_command ;;', + ' esac', + ' if [ -x "$_orca_lookup_candidate" ] && [ ! -d "$_orca_lookup_candidate" ]; then', + ' resolved=$_orca_lookup_candidate', + ' break', + ' fi', + ' [ -n "$_orca_lookup_has_more" ] || break', + ' done', + ' ;;', + 'esac' + ].join('\n') +} + +function buildCommandAssignment(target: PosixCommandPathLookupTarget): string { + if (target.kind === 'literal') { + return shellQuote(target.value) + } + if (!SHELL_VARIABLE_NAME_PATTERN.test(target.name)) { + throw new Error(`Invalid shell variable name: ${target.name}`) + } + return `\${${target.name}-}` +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +}