mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
Merge origin/main into manual-worktree-delete
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 <socket>"); process.exit(1)\n',
|
||||
'utf8'
|
||||
)
|
||||
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
|
||||
|
||||
await electronBuilderConfig.afterPack({
|
||||
|
||||
@@ -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')
|
||||
// <appOutDir>/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 }
|
||||
|
||||
@@ -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 <socket>"); 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/)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.1m">
|
||||
<title>downloads: 4.1m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.2m">
|
||||
<title>downloads: 4.2m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">4.1m</text>
|
||||
<text x="93.5" y="14">4.1m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">4.2m</text>
|
||||
<text x="93.5" y="14">4.2m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
@@ -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 <UNC path>` with no remote authority. VS Code's supported Windows CLI form is `code --remote wsl+<distro> <Linux path>`.
|
||||
|
||||
## 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+<distro>', '<linuxPath>']`. 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\<distro>\...` and legacy `\\wsl$\<distro>\...` 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.
|
||||
@@ -177,6 +177,7 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="notifications" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="about" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="h" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
|
||||
@@ -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<HostProfile[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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 (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Connection log</Text>
|
||||
</View>
|
||||
|
||||
{hosts.length > 1 && (
|
||||
<View style={styles.hostPicker}>
|
||||
{hosts.map((host) => (
|
||||
<Pressable
|
||||
key={host.id}
|
||||
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
|
||||
onPress={() => setSelectedId(host.id)}
|
||||
>
|
||||
<Text
|
||||
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{host.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{selected ? (
|
||||
<>
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.statusText}>
|
||||
{state}
|
||||
{reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''}
|
||||
</Text>
|
||||
<Pressable style={styles.copyButton} onPress={() => void copyDiagnostics()}>
|
||||
{copied ? (
|
||||
<Check size={14} color={colors.statusGreen} />
|
||||
) : (
|
||||
<Copy size={14} color={colors.textSecondary} />
|
||||
)}
|
||||
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy diagnostics'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{entries.length > 0 ? (
|
||||
<ConnectionLog entries={[...entries]} title={selected.name} />
|
||||
) : (
|
||||
<Text style={styles.emptyText}>
|
||||
No connection events yet this session. Events appear as the app dials this host.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text style={styles.emptyText}>No paired hosts.</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -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<MobileCommitRow[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [filesById, setFilesById] = useState<Record<string, GitBranchChangeEntry[] | 'loading'>>({})
|
||||
|
||||
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 (
|
||||
<View style={styles.commit}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.commitHeader, pressed && styles.commitHeaderPressed]}
|
||||
onPress={() => toggleCommit(item)}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={colors.textMuted} />
|
||||
)}
|
||||
<View style={styles.commitMain}>
|
||||
<Text style={styles.commitSubject} numberOfLines={1}>
|
||||
{item.subject}
|
||||
</Text>
|
||||
<Text style={styles.commitMeta} numberOfLines={1}>
|
||||
{item.shortId} · {item.author} · {item.relativeTime}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
{isOpen ? (
|
||||
<View style={styles.files}>
|
||||
{files === 'loading' || files === undefined ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : files.length === 0 ? (
|
||||
<Text style={styles.empty}>No file changes</Text>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<View key={file.path} style={styles.fileRow}>
|
||||
<Text style={styles.filePath} numberOfLines={1}>
|
||||
{file.path}
|
||||
</Text>
|
||||
<Text style={styles.fileStat}>
|
||||
{file.added ? <Text style={styles.add}>+{file.added} </Text> : null}
|
||||
{file.removed ? <Text style={styles.del}>-{file.removed}</Text> : null}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
[expanded, filesById, toggleCommit]
|
||||
)
|
||||
|
||||
const view = resolveMobileHistoryScreenView({
|
||||
connected: client !== null && connState === 'connected',
|
||||
rows,
|
||||
error
|
||||
})
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable style={styles.back} onPress={() => router.back()} accessibilityLabel="Back">
|
||||
<ChevronLeft size={22} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Text style={styles.title}>Commit History</Text>
|
||||
</View>
|
||||
{view.kind === 'error' || view.kind === 'waiting' ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>
|
||||
{view.kind === 'waiting' ? 'Waiting for desktop...' : view.message}
|
||||
</Text>
|
||||
<Pressable style={styles.retryButton} onPress={retry} accessibilityLabel="Retry">
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : view.kind === 'loading' ? (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
</View>
|
||||
) : view.kind === 'empty' ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>No commits.</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={view.rows}
|
||||
renderItem={renderCommit}
|
||||
keyExtractor={(row) => row.id}
|
||||
contentContainerStyle={{ paddingBottom: spacing.lg + insets.bottom }}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
<Redirect
|
||||
href={{
|
||||
pathname: '/h/[hostId]/source-control/[worktreeId]',
|
||||
params: {
|
||||
hostId: firstParam(params.hostId),
|
||||
worktreeId: firstParam(params.worktreeId),
|
||||
name: firstParam(params.name),
|
||||
origin: firstParam(params.origin),
|
||||
tab: 'history'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<MobilePrViewPanel
|
||||
client={client}
|
||||
connState={connState}
|
||||
worktreeId={worktreeId}
|
||||
branch={branch}
|
||||
headSha={headSha}
|
||||
gitStatus={status}
|
||||
isGithubRepo={isGithubRepo}
|
||||
branchContextLoaded={loaded && repoLoaded}
|
||||
embedded={false}
|
||||
<Redirect
|
||||
href={{
|
||||
pathname: '/h/[hostId]/source-control/[worktreeId]',
|
||||
params: {
|
||||
hostId: firstParam(params.hostId),
|
||||
worktreeId: firstParam(params.worktreeId),
|
||||
name: firstParam(params.name),
|
||||
origin: firstParam(params.origin),
|
||||
tab: 'pr'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Map<string, string>>(new Map())
|
||||
const initialModesSeenRef = useRef<Set<string>>(new Set())
|
||||
const deviceTokenRef = useRef<string | null>(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<string | null>(null)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const connStateRef = useRef<ConnectionState>(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}
|
||||
|
||||
@@ -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 (
|
||||
<MobileSourceControlPanel
|
||||
@@ -15,6 +17,7 @@ export default function MobileSourceControlScreen() {
|
||||
worktreeId={firstParam(params.worktreeId)}
|
||||
name={firstParam(params.name)}
|
||||
origin={firstParam(params.origin)}
|
||||
initialTab={parseSourceControlHubTab(params.tab)}
|
||||
embedded={false}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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() {
|
||||
<View style={styles.hostMeta}>
|
||||
<StatusDot state={state} verdict={verdict} />
|
||||
<Text style={[styles.hostMetaItem, isError && { color: colors.statusRed }]}>
|
||||
{verdict.label}
|
||||
{verdictDisplayLabel(verdict)}
|
||||
{connected && info
|
||||
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
|
||||
: ''}
|
||||
|
||||
+20
-68
@@ -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: <WifiOff size={16} color={colors.textSecondary} />,
|
||||
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: <Shield size={16} color={colors.textSecondary} />,
|
||||
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: <Monitor size={16} color={colors.textSecondary} />,
|
||||
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: <Clock size={16} color={colors.textSecondary} />,
|
||||
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: <Globe size={16} color={colors.textSecondary} />,
|
||||
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() {
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.diagnosticButton,
|
||||
pressed && styles.diagnosticButtonPressed
|
||||
]}
|
||||
onPress={() => router.push('/connection-log')}
|
||||
>
|
||||
<ScrollText size={16} color={colors.textPrimary} />
|
||||
<Text style={styles.diagnosticButtonLabel}>View connection log</Text>
|
||||
</Pressable>
|
||||
|
||||
{checks.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
{checks.map((check, i) => (
|
||||
@@ -295,7 +247,7 @@ export default function TroubleshootScreen() {
|
||||
<Text style={styles.sectionHeading}>Common issues</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
{sections.map((section, i) => (
|
||||
{troubleshootCommonIssues.map((section, i) => (
|
||||
<View key={section.id}>
|
||||
{i > 0 && <View style={styles.separator} />}
|
||||
<Pressable
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [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'
|
||||
}
|
||||
>
|
||||
<ExternalLink size={18} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
) : null
|
||||
const sidebar = (
|
||||
<MobilePRSidebar
|
||||
state={sidebarState}
|
||||
onRetry={controller.retryPRSidebar}
|
||||
refetch={controller.refetchPRSidebar}
|
||||
client={client}
|
||||
connState={connState}
|
||||
worktreeId={worktreeId}
|
||||
gitBranch={branch}
|
||||
gitStatus={gitStatus}
|
||||
headSha={headSha}
|
||||
bottomInset={insets.bottom}
|
||||
/>
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.topBar}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
|
||||
onPress={onRequestClose}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Close pull request panel"
|
||||
>
|
||||
<X size={20} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
Pull Request
|
||||
</Text>
|
||||
{openPrControl}
|
||||
</View>
|
||||
</View>
|
||||
{sidebar}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.topBar}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
|
||||
onPress={() => router.back()}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Back to session"
|
||||
>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
Pull Request
|
||||
</Text>
|
||||
{openPrControl}
|
||||
</View>
|
||||
</View>
|
||||
{sidebar}
|
||||
</SafeAreaView>
|
||||
<View style={styles.container}>
|
||||
<MobilePRSidebar
|
||||
state={sidebarState}
|
||||
onRetry={controller.retryPRSidebar}
|
||||
refetch={controller.refetchPRSidebar}
|
||||
client={client}
|
||||
connState={connState}
|
||||
worktreeId={worktreeId}
|
||||
gitBranch={branch}
|
||||
gitStatus={gitStatus}
|
||||
headSha={headSha}
|
||||
bottomInset={insets.bottom}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionBody}>
|
||||
<Pressable
|
||||
onPress={openPr}
|
||||
disabled={!openPr}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`Open pull request #${pr.number} on the web`}
|
||||
style={({ pressed }) => [
|
||||
styles.badge,
|
||||
{ borderColor: badgeColor },
|
||||
pressed && { opacity: 0.6 }
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.badgeText, { color: badgeColor }]}>{badge.label}</Text>
|
||||
</Pressable>
|
||||
<View style={styles.badgeRow}>
|
||||
<Pressable
|
||||
onPress={openPr}
|
||||
disabled={!openPr}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`Open pull request #${pr.number} on the web`}
|
||||
style={({ pressed }) => [
|
||||
styles.badge,
|
||||
{ borderColor: badgeColor },
|
||||
pressed && { opacity: 0.6 }
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.badgeText, { color: badgeColor }]}>{badge.label}</Text>
|
||||
</Pressable>
|
||||
{openPr ? (
|
||||
<Pressable
|
||||
onPress={openPr}
|
||||
hitSlop={8}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`Open pull request #${pr.number} in browser`}
|
||||
style={({ pressed }) => [styles.iconButton, pressed && { opacity: 0.6 }]}
|
||||
>
|
||||
<ExternalLink size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
<PRTitle
|
||||
title={title}
|
||||
number={pr.number}
|
||||
|
||||
@@ -56,6 +56,13 @@ export const mobilePrSidebarStyles = StyleSheet.create({
|
||||
fontWeight: '600'
|
||||
},
|
||||
// Header section: state badge, title, author, base<-head branches.
|
||||
// Badge + trailing open-on-web icon (hub chromeless has no panel chrome for it).
|
||||
badgeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm
|
||||
},
|
||||
badge: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: spacing.sm,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildConnectionDiagnosticsReport } from './connection-diagnostics-report'
|
||||
|
||||
const NOW = Date.UTC(2026, 6, 9, 22, 0, 0)
|
||||
|
||||
describe('buildConnectionDiagnosticsReport', () => {
|
||||
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.')
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
|
||||
@@ -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: <WifiOff size={16} color={colors.textSecondary} />,
|
||||
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: <Shield size={16} color={colors.textSecondary} />,
|
||||
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: <Monitor size={16} color={colors.textSecondary} />,
|
||||
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: <Clock size={16} color={colors.textSecondary} />,
|
||||
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: <Globe size={16} color={colors.textSecondary} />,
|
||||
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: <Shield size={16} color={colors.textSecondary} />,
|
||||
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".'
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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<ActivePanel, null>
|
||||
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 (
|
||||
<MobileSourceControlPanel
|
||||
hostId={hostId}
|
||||
@@ -104,33 +75,18 @@ const DockPanelContent = memo(function DockPanelContent({
|
||||
name={name}
|
||||
origin="session"
|
||||
embedded
|
||||
initialTab={activePanel === 'pr' ? 'pr' : 'changes'}
|
||||
onRequestClose={onRequestClose}
|
||||
onFileOpenStart={onFileOpenStart}
|
||||
onOpenedFileDiff={onOpenedFileDiff}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (activePanel === 'files') {
|
||||
return (
|
||||
<MobileFileExplorerPanel
|
||||
hostId={hostId}
|
||||
worktreeId={worktreeId}
|
||||
name={name}
|
||||
embedded
|
||||
onRequestClose={onRequestClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<MobilePrViewPanel
|
||||
client={client}
|
||||
connState={connState}
|
||||
<MobileFileExplorerPanel
|
||||
hostId={hostId}
|
||||
worktreeId={worktreeId}
|
||||
branch={branch}
|
||||
headSha={headSha}
|
||||
gitStatus={gitStatus}
|
||||
isGithubRepo={isGithubRepo}
|
||||
branchContextLoaded={branchContextLoaded}
|
||||
name={name}
|
||||
embedded
|
||||
onRequestClose={onRequestClose}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<ActivePanel, null>): { 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<ActivePanel, null>): {
|
||||
pathname: string
|
||||
params?: Record<string, string>
|
||||
} {
|
||||
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' } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MobilePrBranchContext>({
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<typeof useMobilePrSidebarController>[0]) {
|
||||
captured = useMobilePrSidebarController(props)
|
||||
return null
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
@@ -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<T>(result: T): GitHubPrReadOutcome<T> {
|
||||
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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<PrSidebarState>({ 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<string | null>(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<typeof useMobilePrSidebarController>
|
||||
|
||||
@@ -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<MobileCommitRow[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [filesById, setFilesById] = useState<Record<string, GitBranchChangeEntry[] | 'loading'>>({})
|
||||
|
||||
// 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 (
|
||||
<View style={styles.commit}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.commitHeader, pressed && styles.commitHeaderPressed]}
|
||||
onPress={() => toggleCommit(item)}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown size={14} color={colors.textMuted} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={colors.textMuted} />
|
||||
)}
|
||||
<View style={styles.commitMain}>
|
||||
<Text style={styles.commitSubject} numberOfLines={1}>
|
||||
{item.subject}
|
||||
</Text>
|
||||
<Text style={styles.commitMeta} numberOfLines={1}>
|
||||
{item.shortId} · {item.author} · {item.relativeTime}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
{isOpen ? (
|
||||
<View style={styles.files}>
|
||||
{files === 'loading' || files === undefined ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : files.length === 0 ? (
|
||||
<Text style={styles.empty}>No file changes</Text>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<View key={file.path} style={styles.fileRow}>
|
||||
<Text style={styles.filePath} numberOfLines={1}>
|
||||
{file.path}
|
||||
</Text>
|
||||
<Text style={styles.fileStat}>
|
||||
{file.added ? <Text style={styles.add}>+{file.added} </Text> : null}
|
||||
{file.removed ? <Text style={styles.del}>-{file.removed}</Text> : null}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
[expanded, filesById, toggleCommit]
|
||||
)
|
||||
|
||||
const view = resolveMobileHistoryScreenView({
|
||||
connected: client !== null && connState === 'connected',
|
||||
rows,
|
||||
error
|
||||
})
|
||||
|
||||
if (view.kind === 'error' || view.kind === 'waiting') {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>
|
||||
{view.kind === 'waiting' ? 'Waiting for desktop...' : view.message}
|
||||
</Text>
|
||||
<Pressable style={styles.retryButton} onPress={retry} accessibilityLabel="Retry">
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (view.kind === 'loading') {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (view.kind === 'empty') {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>No commits.</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<FlatList
|
||||
data={view.rows}
|
||||
renderItem={renderCommit}
|
||||
keyExtractor={(row) => 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 }
|
||||
})
|
||||
@@ -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 (
|
||||
<View style={styles.summaryCard}>
|
||||
<View style={styles.summaryHeader}>
|
||||
<View style={styles.branchLine}>
|
||||
<GitBranch size={15} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
<Text style={styles.branchText} numberOfLines={1}>
|
||||
{branchLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{syncLabel ? <Text style={styles.syncText}>{syncLabel}</Text> : null}
|
||||
</View>
|
||||
<View style={styles.countRow}>
|
||||
<Text style={styles.countText}>{unstagedCount} changed</Text>
|
||||
<Text style={styles.countText}>{stagedCount} staged</Text>
|
||||
{branchCount > 0 ? <Text style={styles.countText}>{branchCount} on branch</Text> : null}
|
||||
{showConflict ? (
|
||||
<View style={styles.conflictRow}>
|
||||
<Text style={styles.conflictText}>{conflictOperation}</Text>
|
||||
{conflictOperation === 'merge' || conflictOperation === 'rebase' ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.abortButton, pressed && styles.abortPressed]}
|
||||
disabled={conflictBusy}
|
||||
onPress={() => onAbortConflict(conflictOperation)}
|
||||
>
|
||||
<Text style={styles.abortText}>
|
||||
{mobileConflictAbortLabel(conflictOperation, conflictAborting)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{prChip ? <MobileSourceControlPrChip summary={prChip} onPress={onOpenPr} /> : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
<Text style={styles.reconnectBannerText}>Reconnecting to desktop...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.summaryCard}>
|
||||
<View style={styles.summaryHeader}>
|
||||
<View style={styles.branchLine}>
|
||||
<GitBranch size={15} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
<Text style={styles.branchText} numberOfLines={1}>
|
||||
{branchLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{syncLabel ? <Text style={styles.syncText}>{syncLabel}</Text> : null}
|
||||
</View>
|
||||
<View style={styles.countRow}>
|
||||
<Text style={styles.countText}>{unstagedCount} changed</Text>
|
||||
<Text style={styles.countText}>{stagedCount} staged</Text>
|
||||
{branchEntries.length > 0 ? (
|
||||
<Text style={styles.countText}>{branchEntries.length} on branch</Text>
|
||||
) : null}
|
||||
{status && status.conflictOperation !== 'unknown' ? (
|
||||
<View style={styles.conflictRow}>
|
||||
<Text style={styles.conflictText}>{status.conflictOperation}</Text>
|
||||
{(status.conflictOperation === 'merge' || status.conflictOperation === 'rebase') && (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.abortButton, pressed && styles.abortPressed]}
|
||||
disabled={busyAction !== null}
|
||||
onPress={() => void abortConflictOperation(status.conflictOperation)}
|
||||
>
|
||||
<Text style={styles.abortText}>
|
||||
{busyAction === `abort-${status.conflictOperation}`
|
||||
? 'Aborting…'
|
||||
: `Abort ${status.conflictOperation}`}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={hubStyles.changesControls}>
|
||||
{commitFailureRecovery ? (
|
||||
<MobileCommitFailurePanel
|
||||
failure={commitFailureRecovery}
|
||||
@@ -170,6 +131,7 @@ export function MobileSourceControlContent({ state }: Props) {
|
||||
</View>
|
||||
) : (
|
||||
<SectionList
|
||||
style={hubStyles.tabBody}
|
||||
sections={sections}
|
||||
renderItem={makeRenderFileRow({
|
||||
busyAction,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { ChevronLeft, RefreshCw, X } from 'lucide-react-native'
|
||||
import { ChevronLeft, ExternalLink, RefreshCw, X } from 'lucide-react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-source-control-styles'
|
||||
|
||||
@@ -9,6 +9,10 @@ type Props = {
|
||||
ioBusy: boolean
|
||||
onBack: () => 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 (
|
||||
<View style={styles.topBar}>
|
||||
@@ -40,6 +46,21 @@ export function MobileSourceControlHeader({
|
||||
{worktreeLabel}
|
||||
</Text>
|
||||
</View>
|
||||
{onOpenPrWeb ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [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'
|
||||
}
|
||||
>
|
||||
<ExternalLink size={18} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.refreshButton,
|
||||
|
||||
@@ -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<SourceControlHubTab>(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<ReadonlySet<SourceControlHubTab>>(
|
||||
() => new Set<SourceControlHubTab>([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<string | null>(null)
|
||||
const lastPrHeadRef = useRef<string | null>(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 = (
|
||||
<MobileSourceControlHeader
|
||||
embedded={embedded}
|
||||
worktreeLabel={worktreeLabel}
|
||||
ioBusy={ioBusy}
|
||||
onBack={onBack}
|
||||
onRefresh={() => void loadStatus()}
|
||||
onRefresh={onRefresh}
|
||||
onOpenPrWeb={prWebUrl ? () => openMobilePrUrl(prWebUrl) : undefined}
|
||||
prNumber={prWebNumber}
|
||||
/>
|
||||
)
|
||||
|
||||
const statusGate =
|
||||
screenState.kind === 'loading' ? (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateTitle}>
|
||||
{screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'}
|
||||
</Text>
|
||||
<Text style={styles.stateText}>{screenState.message}</Text>
|
||||
{screenState.kind === 'error' ? (
|
||||
<Pressable
|
||||
style={styles.retryButton}
|
||||
onPress={() => {
|
||||
// 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()
|
||||
}}
|
||||
>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : 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 (
|
||||
<View ref={setRootRef} style={styles.container}>
|
||||
{embedded ? (
|
||||
@@ -80,38 +311,64 @@ export function MobileSourceControlPanel({
|
||||
</SafeAreaView>
|
||||
)}
|
||||
|
||||
{screenState.kind === 'loading' ? (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
<MobileSourceControlSegments active={activeTab} onSelect={selectTab} />
|
||||
|
||||
{ready ? (
|
||||
<MobileSourceControlBranchCard
|
||||
branchLabel={branchLabel}
|
||||
syncLabel={syncLabel}
|
||||
unstagedCount={unstagedCount}
|
||||
stagedCount={stagedCount}
|
||||
branchCount={branchEntries.length}
|
||||
conflictOperation={conflictOperation}
|
||||
conflictBusy={busyAction !== null}
|
||||
conflictAborting={conflictAborting}
|
||||
onAbortConflict={(operation) => void abortConflictOperation(operation)}
|
||||
prChip={prChip}
|
||||
onOpenPr={openPrTab}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showChanges ? (
|
||||
<View style={activeTab === 'changes' ? hubStyles.tabBody : hubStyles.tabBodyHidden}>
|
||||
<MobileSourceControlContent state={state} />
|
||||
</View>
|
||||
) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateTitle}>
|
||||
{screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'}
|
||||
</Text>
|
||||
<Text style={styles.stateText}>{screenState.message}</Text>
|
||||
{screenState.kind === 'error' ? (
|
||||
<Pressable
|
||||
style={styles.retryButton}
|
||||
onPress={() => {
|
||||
// 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()
|
||||
}}
|
||||
>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
) : activeTab === 'changes' ? (
|
||||
statusGate
|
||||
) : null}
|
||||
|
||||
{showPrBody ? (
|
||||
<View style={hubStyles.tabBody}>
|
||||
<MobilePrViewPanelBody
|
||||
client={client}
|
||||
connState={connState}
|
||||
worktreeId={worktreeId}
|
||||
branch={prBranch}
|
||||
headSha={prHeadSha}
|
||||
gitStatus={status}
|
||||
isGithubRepo={isHostedRepo}
|
||||
// Gate on the probe too: isGithubRepo=false mid-probe must render as
|
||||
// loading, not flash "unavailable for this provider" (old /pr parity).
|
||||
branchContextLoaded={ready && prController.prSidebarRepoProbeLoaded}
|
||||
controller={prController}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<MobileSourceControlContent state={state} />
|
||||
)}
|
||||
) : activeTab === 'pr' ? (
|
||||
statusGate
|
||||
) : null}
|
||||
|
||||
{showHistory ? (
|
||||
<View style={activeTab === 'history' ? hubStyles.tabBody : hubStyles.tabBodyHidden}>
|
||||
<MobileGitHistoryList
|
||||
client={client}
|
||||
connState={connState}
|
||||
worktreeId={worktreeId}
|
||||
hostId={hostId}
|
||||
bottomInset={insets.bottom}
|
||||
refreshNonce={historyRefreshNonce}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<MobileSourceControlModals state={state} actionSheetActions={actionSheetActions} />
|
||||
</View>
|
||||
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
style={({ pressed }) => [hubStyles.chip, pressed && hubStyles.chipPressed]}
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={chipAccessibilityLabel(summary)}
|
||||
>
|
||||
<View style={hubStyles.chipIcon}>
|
||||
<GitPullRequest size={15} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</View>
|
||||
{summary.kind === 'loading' ? (
|
||||
<>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
<Text style={hubStyles.chipMutedText} numberOfLines={1}>
|
||||
Loading pull request…
|
||||
</Text>
|
||||
</>
|
||||
) : summary.kind === 'none' ? (
|
||||
<>
|
||||
<Text style={hubStyles.chipCreateText}>Create pull request</Text>
|
||||
<View style={hubStyles.chipSpacer} />
|
||||
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.1} />
|
||||
</>
|
||||
) : summary.kind === 'unavailable' ? (
|
||||
<>
|
||||
<Text style={hubStyles.chipMutedText} numberOfLines={1}>
|
||||
{summary.message}
|
||||
</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.1} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={hubStyles.chipNumber}>#{summary.number}</Text>
|
||||
<View style={[hubStyles.statePill, { borderColor: statusColor(summary.stateToken) }]}>
|
||||
<Text style={[hubStyles.statePillText, { color: statusColor(summary.stateToken) }]}>
|
||||
{summary.stateLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<ChipRollup rollup={summary.rollup} />
|
||||
{summary.commentCount != null && summary.commentCount > 0 ? (
|
||||
<View style={hubStyles.comment}>
|
||||
<MessageSquare size={13} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
<Text style={hubStyles.commentText}>{summary.commentCount}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={hubStyles.chipSpacer} />
|
||||
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.1} />
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function ChipRollup({ rollup }: { rollup: MobilePrChipRollup }) {
|
||||
const color = statusColor(rollup.token)
|
||||
return (
|
||||
<View style={hubStyles.rollup}>
|
||||
<RollupIcon kind={rollup.kind} color={color} />
|
||||
<Text style={[hubStyles.rollupText, { color }]}>{rollup.text}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function RollupIcon({ kind, color }: { kind: MobilePrChipRollup['kind']; color: string }) {
|
||||
const size = 13
|
||||
const strokeWidth = 2.3
|
||||
switch (kind) {
|
||||
case 'conflict':
|
||||
return <AlertTriangle size={size} color={color} strokeWidth={strokeWidth} />
|
||||
case 'failing':
|
||||
return <X size={size} color={color} strokeWidth={strokeWidth} />
|
||||
case 'running':
|
||||
return <CircleDot size={size} color={color} strokeWidth={strokeWidth} />
|
||||
case 'passed':
|
||||
return <Check size={size} color={color} strokeWidth={strokeWidth} />
|
||||
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.`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<View style={hubStyles.segments} accessibilityRole="tablist">
|
||||
{SOURCE_CONTROL_HUB_TABS.map((tab) => {
|
||||
const isActive = tab === active
|
||||
return (
|
||||
<Pressable
|
||||
key={tab}
|
||||
style={({ pressed }) => [
|
||||
hubStyles.segment,
|
||||
isActive && hubStyles.segmentActive,
|
||||
pressed && !isActive && hubStyles.segmentPressed
|
||||
]}
|
||||
onPress={() => onSelect(tab)}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: isActive }}
|
||||
accessibilityLabel={SOURCE_CONTROL_HUB_TAB_LABELS[tab]}
|
||||
>
|
||||
<Text
|
||||
style={[hubStyles.segmentText, isActive && hubStyles.segmentTextActive]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{SOURCE_CONTROL_HUB_TAB_LABELS[tab]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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> = {}): 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>): 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)
|
||||
})
|
||||
})
|
||||
@@ -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<string>()
|
||||
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<PrSidebarState, { kind: 'ready' }>): 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' }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<SourceControlHubTab, string> = {
|
||||
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'
|
||||
}
|
||||
@@ -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<typeof router.push>[0])
|
||||
}
|
||||
}, [hostId, onOpenHistory, router, setShowActionSheet, worktreeId])
|
||||
|
||||
// Switch to a local branch, then reload status.
|
||||
const checkoutBranch = useCallback(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ---')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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, '--')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, ConnectionLogEntry[]>()
|
||||
const listenersByHost = new Map<string, Set<() => 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<string, readonly ConnectionLogEntry[]>()
|
||||
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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,16 +31,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
|
||||
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)
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
}
|
||||
@@ -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<typeof Os>()
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, string>
|
||||
}
|
||||
|
||||
function getSettingsBaselinePath(runtimeHomePath: string): string {
|
||||
return join(runtimeHomePath, '.orca-config-settings-baseline.json')
|
||||
}
|
||||
|
||||
function readSettingsBaseline(runtimeHomePath: string): Map<string, string> | 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<string, string>()
|
||||
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<string, TopLevelSettingValue> {
|
||||
const result = new Map<string, TopLevelSettingValue>()
|
||||
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<string, string> = {}
|
||||
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<string, string>()
|
||||
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, string>
|
||||
): string {
|
||||
const lines = content.split('\n')
|
||||
let state = createTomlLineScanState()
|
||||
let preambleEnd = lines.length
|
||||
const keyLineIndexes = new Map<string, number>()
|
||||
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`
|
||||
}
|
||||
@@ -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<string, { hooks?: { command?: string }[] }[]>
|
||||
}
|
||||
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
+246
-12
@@ -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<CodexEventLabel>(
|
||||
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<string>(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
|
||||
}
|
||||
|
||||
@@ -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}`]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1480,6 +1480,7 @@ type GlabExecOptions = Omit<GitExecOptions, 'cwd'> & {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
+40
-18
@@ -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<void> {
|
||||
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()
|
||||
|
||||
@@ -245,6 +245,9 @@ describe('gitlab client — MR operations', () => {
|
||||
hosts: ['gitlab.com'],
|
||||
activeHost: 'gitlab.com'
|
||||
})
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], {
|
||||
allowDefaultWslFallback: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -97,7 +97,10 @@ export async function diagnoseAuth(): Promise<GitLabAuthDiagnostic> {
|
||||
? '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 {
|
||||
|
||||
+15
-3
@@ -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<void> {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -80,6 +80,7 @@ function runStatusHelper(helperPath: string): Promise<NotificationAuthorizationS
|
||||
case 'not-determined':
|
||||
resolve('not-determined')
|
||||
return
|
||||
case undefined:
|
||||
default:
|
||||
resolve('unknown')
|
||||
}
|
||||
|
||||
@@ -103,8 +103,13 @@ describe('subscribeViaWatcherProcess', () => {
|
||||
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)
|
||||
|
||||
@@ -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<void> => {
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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> = {}): 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<string, number> = {
|
||||
'/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<string, number> = {
|
||||
[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)
|
||||
})
|
||||
})
|
||||
@@ -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<string>
|
||||
|
||||
export function getPersistedWorkspaceCleanupActivityAt(
|
||||
worktree: Pick<Worktree, 'createdAt' | 'lastActivityAt'>
|
||||
): 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<Worktree> {
|
||||
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<string> {
|
||||
return readFile(targetPath, 'utf8')
|
||||
}
|
||||
|
||||
async function resolveWorkspaceCleanupActivityAt(
|
||||
repo: Repo,
|
||||
worktree: Worktree,
|
||||
statPath: StatPath,
|
||||
readTextFile: ReadTextFile
|
||||
): Promise<number> {
|
||||
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<number> {
|
||||
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<string | null> {
|
||||
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<number> {
|
||||
try {
|
||||
const stats = await statPath(targetPath)
|
||||
return Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -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<Worktree> {
|
||||
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
|
||||
|
||||
@@ -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> = {}): 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<string, WorktreeMeta> = {
|
||||
'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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-29
@@ -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(
|
||||
|
||||
@@ -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\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('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'),
|
||||
|
||||
@@ -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', '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<Provi
|
||||
|
||||
const codexArgs = ['-s', 'read-only', '-a', 'untrusted', 'app-server']
|
||||
const wslCodex = options?.codexHomePath
|
||||
? buildWslCodexCommand(options.codexHomePath, codexArgs)
|
||||
? buildWslCodexCommand(options.codexHomePath, codexArgs, { isolateRpcStdio: true })
|
||||
: null
|
||||
// Why: cold WSL process startup plus Codex app-server initialization can
|
||||
// exceed the host RPC budget, causing a false "unavailable" on app launch.
|
||||
|
||||
@@ -60,17 +60,21 @@ describe('repo-worktrees', () => {
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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<GitWorktreeInfo[]> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<string, WorktreeMeta> = {
|
||||
[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<string, WorktreeMeta> = {
|
||||
[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.
|
||||
|
||||
@@ -1294,6 +1294,11 @@ type RuntimeNotifier = {
|
||||
): Promise<RuntimeMarkdownSaveTabResult>
|
||||
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<string, never> {
|
||||
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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user