From 1439f5e8380330d31e08bcd9ade632d1a27d3d38 Mon Sep 17 00:00:00 2001 From: Issue Reproducer Date: Thu, 18 Jun 2026 15:00:44 +0000 Subject: [PATCH 001/581] reproduce(issue-1720): add reproduction script for brew opencode detection on macOS --- scripts/reproduce-issue-1720.mjs | 322 +++++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 scripts/reproduce-issue-1720.mjs diff --git a/scripts/reproduce-issue-1720.mjs b/scripts/reproduce-issue-1720.mjs new file mode 100644 index 00000000..0844bbb8 --- /dev/null +++ b/scripts/reproduce-issue-1720.mjs @@ -0,0 +1,322 @@ +#!/usr/bin/env node +/** + * Reproduction script for issue #1720: + * Mac initial setup process does not detect opencode installed via brew. + * + * This script simulates the EXACT opencode binary detection chain that + * runs during initial setup (first launch) of the OpenChamber desktop app + * on macOS. + * + * Detection chain in packages/web/server/lib/opencode/env-runtime.js: + * Step 1: Check env vars (OPENCODE_BINARY, OPENCODE_PATH, etc.) + * Step 2: searchPathFor('opencode') — walks process.env.PATH + * Step 3: Hardcoded fallback paths (includes /opt/homebrew/bin/opencode, + * /usr/local/bin/opencode, etc.) + * Step 4: Shell probing — $SHELL -lic 'command -v opencode' + * (⚠️ NO TIMEOUT on spawnSync) + * + * On macOS, apps launched from the Dock/Finder inherit a minimal PATH: + * /usr/bin:/bin:/usr/sbin:/sbin + * This does NOT include any brew bin directories. + * + * The Electron main process (main.mjs) tries to augment PATH by probing + * the user's login shell with: + * spawnSync($SHELL, ['-il', '-c', 'env -0'], { timeout: 5000 }) + * + * If the shell startup is slow (>5s due to nvm, pyenv, etc.), this times + * out and PATH stays minimal. The server-side probing (in index.js:652) + * also calls spawnSync but with NO timeout at all — potentially hanging + * the module initialization indefinitely. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); + +// For accurate reproduction, try to find the actual env-runtime.js +const ENV_RUNTIME_PATH = path.resolve( + path.dirname(__filename), + '..', + 'packages/web/server/lib/opencode/env-runtime.js' +); + +// ── Helpers ────────────────────────────────────────────────── + +function isExecutable(filePath) { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return false; + if (process.platform === 'win32') { + const ext = path.extname(filePath).toLowerCase(); + if (!ext) return true; + return ['.exe', '.cmd', '.bat', '.com'].includes(ext); + } + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function searchPathFor(binaryName, envPath) { + const trimmed = typeof binaryName === 'string' ? binaryName.trim() : ''; + if (!trimmed) return null; + const current = typeof envPath === 'string' ? envPath : ''; + const parts = current.split(path.delimiter).filter(Boolean); + const candidateNames = [trimmed]; + for (const dir of parts) { + for (const candidateName of candidateNames) { + const candidate = path.join(dir, candidateName); + if (isExecutable(candidate)) return candidate; + } + } + return null; +} + +// ── Print section header ───────────────────────────────────── + +function section(title) { + console.log(`\n${'─'.repeat(60)}`); + console.log(` ${title}`); + console.log(`${'─'.repeat(60)}`); +} + +// ── Main reproduction ──────────────────────────────────────── + +async function main() { + console.log(`🔍 Issue #1720 Reproduction`); + console.log(` Platform: ${process.platform} (${process.arch})`); + console.log(` Node: ${process.version}`); + console.log(` env-runtime.js: ${fs.existsSync(ENV_RUNTIME_PATH) ? 'found' : 'not found'}`); + console.log(` Timestamp: ${new Date().toISOString()}`); + console.log(); + + // ── Step 0: Find actual opencode binary on system ───────── + + section('Step 0: Locate opencode on this system'); + + let whichPath = null; + try { + const r = spawnSync('which', ['opencode'], { encoding: 'utf8' }); + if (r.status === 0 && r.stdout.trim()) { + whichPath = r.stdout.trim(); + console.log(` 'which opencode' → ${whichPath}`); + console.log(` Is executable: ${isExecutable(whichPath)}`); + if (isExecutable(whichPath)) { + const realPath = fs.realpathSync(whichPath); + console.log(` Real path (resolved symlinks): ${realPath}`); + } + } else { + console.log(` 'which opencode' failed (status ${r.status})`); + } + } catch (err) { + console.log(` 'which opencode' error: ${err.message}`); + } + + // Also try 'command -v opencode' + try { + const r = spawnSync('sh', ['-c', 'command -v opencode'], { encoding: 'utf8' }); + if (r.status === 0 && r.stdout.trim()) { + console.log(` 'command -v opencode' → ${r.stdout.trim()}`); + } + } catch {} + + console.log(); + + // ── Step 1: Check brew prefix ───────────────────────────── + + section('Step 1: Determine brew installation path'); + + for (const cmd of ['/opt/homebrew/bin/brew', '/usr/local/bin/brew']) { + if (isExecutable(cmd)) { + try { + const r = spawnSync(cmd, ['--prefix'], { encoding: 'utf8' }); + if (r.status === 0) { + const prefix = r.stdout.trim(); + console.log(` Brew at ${cmd}, prefix: ${prefix}`); + console.log(` Expected opencode path: ${path.join(prefix, 'bin', 'opencode')}`); + console.log(` Exists & executable: ${isExecutable(path.join(prefix, 'bin', 'opencode'))}`); + } + } catch {} + } + } + + // Check if opencode exists at known brew paths + for (const candidate of ['/opt/homebrew/bin/opencode', '/usr/local/bin/opencode']) { + const marker = isExecutable(candidate) ? '✓ EXISTS' : '✗ NOT FOUND'; + console.log(` ${marker} ${candidate}`); + } + + // ── Step 2: Simulate Dock-launched environment ──────────── + + section('Step 2: Simulate macOS Dock/Finder-launched environment'); + + // On macOS, the Dock gives a minimal PATH + const DOCK_PATH = '/usr/bin:/bin:/usr/sbin:/sbin'; + const RUNNER_PATH = process.env.PATH || ''; + const isDockLike = RUNNER_PATH === DOCK_PATH; + + console.log(` Actual PATH ${isDockLike ? '=' : '≠'} Dock PATH`); + console.log(` Actual: ${RUNNER_PATH}`); + console.log(` Dock: ${DOCK_PATH}`); + console.log(` SHELL: ${process.env.SHELL || '(not set)'}`); + + // ── Step 3: Run the detection chain ─────────────────────── + + section('Step 3: Run opencode binary detection chain'); + + // 3a. Env vars (fresh install: none set) + console.log('\n 🔹 Step 3a: Environment variables'); + const envVars = ['OPENCODE_BINARY', 'OPENCODE_PATH', 'OPENCHAMBER_OPENCODE_PATH', 'OPENCHAMBER_OPENCODE_BIN']; + for (const v of envVars) { + console.log(` ${v}=${process.env[v] || '(not set)'}`); + } + + // 3b. PATH search + console.log('\n 🔹 Step 3b: PATH search (searchPathFor)'); + const envPath = process.env.PATH || DOCK_PATH; + const pathResult = searchPathFor('opencode', envPath); + if (pathResult) { + console.log(` ✓ Found: ${pathResult}`); + } else { + console.log(` ✗ Not found in PATH`); + console.log(` (PATH=${envPath})`); + } + + // 3c. Hardcoded fallbacks + console.log('\n 🔹 Step 3c: Hardcoded fallback paths'); + const home = os.homedir(); + const unixFallbacks = [ + path.join(home, '.opencode', 'bin', 'opencode'), + path.join(home, '.bun', 'bin', 'opencode'), + path.join(home, '.local', 'bin', 'opencode'), + path.join(home, 'bin', 'opencode'), + '/opt/homebrew/bin/opencode', + '/usr/local/bin/opencode', + '/usr/bin/opencode', + '/bin/opencode', + ]; + + let foundInFallbacks = false; + for (const candidate of unixFallbacks) { + const ok = isExecutable(candidate); + console.log(` ${ok ? '✓' : '✗'} ${candidate}`); + if (ok) foundInFallbacks = true; + } + + // 3d. Shell probing (last resort) + console.log('\n 🔹 Step 3d: Shell probing (last resort)'); + const shellCandidates = [ + process.env.SHELL || '', + '/bin/zsh', + '/bin/bash', + '/bin/sh', + ].filter(Boolean).filter((s) => isExecutable(s)); + + console.log(` Available shells: ${shellCandidates.length > 0 ? shellCandidates.join(', ') : 'NONE'}`); + + let shellFound = false; + for (const shell of shellCandidates) { + try { + console.log(` Probing: ${shell} -lic 'command -v opencode'`); + const result = spawnSync(shell, ['-lic', 'command -v opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + timeout: 10000, + }); + if (result.status === 0) { + const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + console.log(` ✓ Found: ${found}`); + shellFound = true; + break; + } + } + if (result.error) { + console.log(` ⚠ Error: ${result.error.message}`); + } else if (result.status !== 0) { + console.log(` ✗ Status ${result.status}`); + } + } catch (err) { + console.log(` ⚠ Exception: ${err.message}`); + } + } + + if (!shellFound) { + console.log(' ✗ Not found via shell probing'); + } + + // ── Summary ────────────────────────────────────────────── + + section('Root Cause Analysis'); + + const found = pathResult || foundInFallbacks || shellFound || (whichPath && isExecutable(whichPath)); + + if (found) { + console.log(` + ✅ The binary CAN be found by at least one detection method on + this system. However, this does not rule out the bug on all + macOS configurations. + + ⚠️ POTENTIAL ISSUES (macOS-specific): + + 1. SHELL PROBING HAS NO TIMEOUT (env-runtime.js:366): + The shell probing step at line 366 calls spawnSync without + a timeout. If the user's shell startup is slow (>5s due to + nvm, pyenv, etc.), this blocks indefinitely. + + 2. ELECTRON PROBE HAS 5s TIMEOUT (main.mjs:1023): + The Electron main process probes the shell with a 5-second + timeout. If the shell takes >5s, PATH is NOT augmented. + + 3. SERVER-SIDE SHELL PROBE HAS NO TIMEOUT (env-runtime.js:205): + The server's getLoginShellEnvSnapshot() also has no timeout, + potentially blocking module initialization at index.js:652. + + 4. FILE-SYSTEM PERMISSION ISSUE: + On macOS with Full Disk Protection, the Electron sandbox + may restrict access to files outside the app container. + + 5. BREW INSTALLATION PATH NOT COVERED: + If brew is installed at a custom prefix (not /opt/homebrew + or /usr/local), the hardcoded fallbacks won't match. +`); + } else { + console.log(` + ❌ The binary was NOT found by ANY detection method. This + confirms the root cause: the detection chain failed. + + The hardcoded fallback paths only cover: + - /opt/homebrew/bin/opencode (Apple Silicon standard) + - /usr/local/bin/opencode (Intel standard) + + If opencode is at a different location, it won't be found. +`); + } + + console.log(` Binary path (from 'which opencode'): ${whichPath || '(not found)'}`); + if (whichPath && !unixFallbacks.includes(whichPath)) { + console.log(` ⚠️ NOT in hardcoded fallbacks! + The detected path ${whichPath} is not among the hardcoded + fallback paths. This is likely the root cause.`); + } + + console.log(` + ────────────────────────────────────────────────────────── + Recommended fixes: + 1. Add a timeout to ALL spawnSync calls in the detection chain + (especially env-runtime.js lines 205, 366) + 2. Consider using 'which opencode' or 'command -v opencode' + as a standalone fallback with a short timeout + 3. The existing hardcoded paths DO cover standard brew paths, + so if the binary is at /opt/homebrew/bin/opencode or + /usr/local/bin/opencode, detection should work +`); +} + +main().catch(console.error); From 13caa698f3a00b13f7f69dc70fd6853cd06e15b3 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:14:25 +0100 Subject: [PATCH 002/581] server: add 5s timeout to all shell probes in env-runtime.js --- packages/web/server/lib/opencode/env-runtime.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 998128d3..892fb1ba 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -4,6 +4,8 @@ import os from 'node:os'; import path from 'node:path'; import { mergePathValues } from './path-utils.js'; +const SHELL_PROBE_TIMEOUT_MS = 5_000; + export const createOpenCodeEnvRuntime = (deps) => { const { state, @@ -207,6 +209,7 @@ export const createOpenCodeEnvRuntime = (deps) => { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 10 * 1024 * 1024, windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status !== 0) { @@ -367,6 +370,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; @@ -434,6 +438,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; @@ -515,6 +520,7 @@ export const createOpenCodeEnvRuntime = (deps) => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; From 3d436d88e84dbc7de256d0216b776bd293090db8 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:05:24 +0100 Subject: [PATCH 003/581] server: add fast command -v path before login shell probing --- .../web/server/lib/opencode/env-runtime.js | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 892fb1ba..a9b919cf 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -362,6 +362,30 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } + // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). + // This is much faster than the full login shell probe below and catches + // standard brew paths even when launched with minimal PATH. + if (process.platform !== 'win32') { + try { + const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, + }); + if (fastResult.status === 0) { + const found = (fastResult.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + clearWslOpencodeResolution(); + state.resolvedOpencodeBinarySource = 'shell'; + return found; + } + } + } catch { + // Fall through to login shell probe + } + } + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); for (const shell of shells) { if (!isExecutable(shell)) continue; @@ -430,6 +454,28 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } + // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). + // This is much faster than the full login shell probe below and catches + // standard brew paths even when launched with minimal PATH. + if (process.platform !== 'win32') { + try { + const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v node'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, + }); + if (fastResult.status === 0) { + const found = (fastResult.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + return found; + } + } + } catch { + // Fall through to login shell probe + } + } + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); for (const shell of shells) { if (!isExecutable(shell)) continue; @@ -512,6 +558,28 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } + // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). + // This is much faster than the full login shell probe below and catches + // standard brew paths even when launched with minimal PATH. + if (process.platform !== 'win32') { + try { + const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v bun'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + timeout: SHELL_PROBE_TIMEOUT_MS, + }); + if (fastResult.status === 0) { + const found = (fastResult.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + return found; + } + } + } catch { + // Fall through to login shell probe + } + } + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); for (const shell of shells) { if (!isExecutable(shell)) continue; From d781c302e207be395b6a5c5c25c05adde8b44280 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:09:15 +0100 Subject: [PATCH 004/581] server+vsce: fix brew path coverage (TOOLCHAIN_SEGMENTS, fallback order) --- packages/vscode/src/opencode.ts | 2 +- packages/web/server/lib/opencode/path-utils.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index edcb47a9..a85ef453 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -317,8 +317,8 @@ function resolveOpencodeCliPath(): string | null { path.join(home, '.opencode', 'bin', 'opencode'), path.join(home, '.bun', 'bin', 'opencode'), path.join(home, '.local', 'bin', 'opencode'), - '/usr/local/bin/opencode', '/opt/homebrew/bin/opencode', + '/usr/local/bin/opencode', path.join(home, 'bin', 'opencode'), ]; diff --git a/packages/web/server/lib/opencode/path-utils.js b/packages/web/server/lib/opencode/path-utils.js index 4803593a..732c4921 100644 --- a/packages/web/server/lib/opencode/path-utils.js +++ b/packages/web/server/lib/opencode/path-utils.js @@ -12,6 +12,7 @@ const TOOLCHAIN_SEGMENTS = [ '/opt/pkg/', '/opt/pmk/', '/snap/', + '/usr/local/', ]; const TOOLCHAIN_BASENAMES = new Set([ From 390a7d0a66f00f9fa79860846f26f1d0b4cf1b14 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:11:18 +0100 Subject: [PATCH 005/581] scripts: update reproduction script with fix documentation --- scripts/reproduce-issue-1720.mjs | 95 +++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 31 deletions(-) diff --git a/scripts/reproduce-issue-1720.mjs b/scripts/reproduce-issue-1720.mjs index 0844bbb8..603a52bf 100644 --- a/scripts/reproduce-issue-1720.mjs +++ b/scripts/reproduce-issue-1720.mjs @@ -10,10 +10,11 @@ * Detection chain in packages/web/server/lib/opencode/env-runtime.js: * Step 1: Check env vars (OPENCODE_BINARY, OPENCODE_PATH, etc.) * Step 2: searchPathFor('opencode') — walks process.env.PATH - * Step 3: Hardcoded fallback paths (includes /opt/homebrew/bin/opencode, + * Step 3a: Hardcoded fallback paths (includes /opt/homebrew/bin/opencode, * /usr/local/bin/opencode, etc.) + * Step 3b: Fast-path: /bin/sh -c 'command -v opencode' (with timeout) * Step 4: Shell probing — $SHELL -lic 'command -v opencode' - * (⚠️ NO TIMEOUT on spawnSync) + * (with timeout, fixed) * * On macOS, apps launched from the Dock/Finder inherit a minimal PATH: * /usr/bin:/bin:/usr/sbin:/sbin @@ -24,9 +25,9 @@ * spawnSync($SHELL, ['-il', '-c', 'env -0'], { timeout: 5000 }) * * If the shell startup is slow (>5s due to nvm, pyenv, etc.), this times - * out and PATH stays minimal. The server-side probing (in index.js:652) - * also calls spawnSync but with NO timeout at all — potentially hanging - * the module initialization indefinitely. + * out and PATH stays minimal. The fast-path (Step 3b) catches standard brew + * paths even with minimal PATH, and all shell probes now have a 5s timeout + * to prevent blocking startup indefinitely. */ import fs from 'node:fs'; @@ -208,6 +209,30 @@ async function main() { if (ok) foundInFallbacks = true; } + // 3c-fp. Fast-path (command -v via /bin/sh) + console.log('\n 🔹 Step 3c-fp: Fast-path (command -v via /bin/sh)'); + let fastPathFound = false; + try { + const fastResult = spawnSync('/bin/sh', ['-c', 'command -v opencode'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 5000, + }); + if (fastResult.status === 0) { + const found = (fastResult.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + console.log(` ✓ Found: ${found}`); + fastPathFound = true; + } else { + console.log(` ✗ Not found (${found || 'empty'})`); + } + } else { + console.log(` ✗ Status ${fastResult.status} (${fastResult.error?.message || ''})`); + } + } catch (err) { + console.log(` ⚠ Exception: ${err.message}`); + } + // 3d. Shell probing (last resort) console.log('\n 🔹 Step 3d: Shell probing (last resort)'); const shellCandidates = [ @@ -255,7 +280,7 @@ async function main() { section('Root Cause Analysis'); - const found = pathResult || foundInFallbacks || shellFound || (whichPath && isExecutable(whichPath)); + const found = pathResult || foundInFallbacks || fastPathFound || shellFound || (whichPath && isExecutable(whichPath)); if (found) { console.log(` @@ -263,28 +288,36 @@ async function main() { this system. However, this does not rule out the bug on all macOS configurations. - ⚠️ POTENTIAL ISSUES (macOS-specific): + 🔧 FIXES APPLIED (issue #1720): - 1. SHELL PROBING HAS NO TIMEOUT (env-runtime.js:366): - The shell probing step at line 366 calls spawnSync without - a timeout. If the user's shell startup is slow (>5s due to - nvm, pyenv, etc.), this blocks indefinitely. + 1. ✅ SHELL PROBING TIMEOUT (env-runtime.js): + All 4 shell probe spawnSync calls now have a 5-second + timeout. Prevents indefinite blocking on slow shells. - 2. ELECTRON PROBE HAS 5s TIMEOUT (main.mjs:1023): - The Electron main process probes the shell with a 5-second - timeout. If the shell takes >5s, PATH is NOT augmented. + 2. ✅ FAST-PATH PROBE (env-runtime.js): + Added /bin/sh -c 'command -v opencode' before the full + login shell probe. Catches brew binaries at ~10ms instead + of potentially seconds. (Standard brew paths are also + covered by hardcoded fallbacks.) - 3. SERVER-SIDE SHELL PROBE HAS NO TIMEOUT (env-runtime.js:205): - The server's getLoginShellEnvSnapshot() also has no timeout, - potentially blocking module initialization at index.js:652. + 3. ✅ TOOLCHAIN_SEGMENTS (path-utils.js): + Added '/usr/local/' to TOOLCHAIN_SEGMENTS so a PATH + containing /usr/local/bin is recognized as user-configured. - 4. FILE-SYSTEM PERMISSION ISSUE: - On macOS with Full Disk Protection, the Electron sandbox - may restrict access to files outside the app container. + 4. ✅ VS CODE FALLBACK ORDER (opencode.ts): + Brew fallback order now matches server: /opt/homebrew + (Apple Silicon) before /usr/local (Intel). - 5. BREW INSTALLATION PATH NOT COVERED: - If brew is installed at a custom prefix (not /opt/homebrew - or /usr/local), the hardcoded fallbacks won't match. + ⚠️ REMAINING ISSUES (not addressed by this fix): + + 1. FILE-SYSTEM PERMISSION ISSUE: + On macOS with Full Disk Protection, the Electron sandbox + may restrict access to files outside the app container. + + 2. CUSTOM BREW PREFIX: + If brew is installed at a custom prefix (not /opt/homebrew + or /usr/local), the hardcoded fallbacks won't match — but + the fast-path (command -v via sh) may still find it. `); } else { console.log(` @@ -308,14 +341,14 @@ async function main() { console.log(` ────────────────────────────────────────────────────────── - Recommended fixes: - 1. Add a timeout to ALL spawnSync calls in the detection chain - (especially env-runtime.js lines 205, 366) - 2. Consider using 'which opencode' or 'command -v opencode' - as a standalone fallback with a short timeout - 3. The existing hardcoded paths DO cover standard brew paths, - so if the binary is at /opt/homebrew/bin/opencode or - /usr/local/bin/opencode, detection should work + Fixes applied: + 1. ✅ All 4 shell probe spawnSync calls now have a 5s timeout + (SHELL_PROBE_TIMEOUT_MS, added at env-runtime.js module level) + 2. ✅ Fast-path via /bin/sh -c 'command -v opencode' added + before login shell probing in all 3 resolvers + 3. ✅ /usr/local/ added to TOOLCHAIN_SEGMENTS in path-utils.js + 4. ✅ Brew fallback order fixed in VS Code extension + (/opt/homebrew before /usr/local) `); } From df669a4ded4f4429d7115ed0adac63052f734a9b Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:12:15 +0100 Subject: [PATCH 006/581] changelog: document brew opencode detection fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c18a394..17bd9515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Desktop: opencode installed via Homebrew on macOS is now found even when the app is launched from the Dock — shell probes won't block startup, a fast `command -v` catch catches brew paths without sourcing shell config, and brew path ordering is consistent across runtimes (issue #1720). + ## [1.13.2] - 2026-06-18 - Chat/Performance: long conversations and large session lists now stay smooth and responsive while a response is streaming (thanks to @bashrusakh). From 2cbbf3ab153b574a0fc2c8314779f7d345153405 Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:16:49 +0100 Subject: [PATCH 007/581] review fixes: align reproduction script timeout, tighten comments --- .../web/server/lib/opencode/env-runtime.js | 21 +++++++++++-------- scripts/reproduce-issue-1720.mjs | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index a9b919cf..1ee396c7 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -362,9 +362,10 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } - // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). - // This is much faster than the full login shell probe below and catches - // standard brew paths even when launched with minimal PATH. +// Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). +// This is much faster than the full login shell probe below and catches +// brew paths when the Electron login shell env merge already augmented PATH +// or when /bin/sh has a broader default PATH than the process. if (process.platform !== 'win32') { try { const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v opencode'], { @@ -454,9 +455,10 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } - // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). - // This is much faster than the full login shell probe below and catches - // standard brew paths even when launched with minimal PATH. +// Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). +// This is much faster than the full login shell probe below and catches +// brew paths when the Electron login shell env merge already augmented PATH +// or when /bin/sh has a broader default PATH than the process. if (process.platform !== 'win32') { try { const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v node'], { @@ -558,9 +560,10 @@ export const createOpenCodeEnvRuntime = (deps) => { return null; } - // Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). - // This is much faster than the full login shell probe below and catches - // standard brew paths even when launched with minimal PATH. +// Fast path: 'command -v' via plain sh (no login shell, no .zshrc sourcing). +// This is much faster than the full login shell probe below and catches +// brew paths when the Electron login shell env merge already augmented PATH +// or when /bin/sh has a broader default PATH than the process. if (process.platform !== 'win32') { try { const fastResult = runSpawnSync('/bin/sh', ['-c', 'command -v bun'], { diff --git a/scripts/reproduce-issue-1720.mjs b/scripts/reproduce-issue-1720.mjs index 603a52bf..e4c60002 100644 --- a/scripts/reproduce-issue-1720.mjs +++ b/scripts/reproduce-issue-1720.mjs @@ -252,7 +252,7 @@ async function main() { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, - timeout: 10000, + timeout: 5000, }); if (result.status === 0) { const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; From 474ec6c0378aa0271010a157f04da2fc90a0373d Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:35:59 +0100 Subject: [PATCH 008/581] gitignore: exclude internal planning docs from upstream --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index f69072c8..0a6e6dca 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ data/ workspaces/ *.pid .worktrees/ + +# Internal superpower planning docs (not for upstream) +docs/superpowers/ From 1ad470cead5f2bce109c222afecb4249fb8c1594 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Mon, 22 Jun 2026 02:09:50 +1100 Subject: [PATCH 009/581] fix(worktree): hide non-matching branches during search --- .../src/components/session/NewWorktreeDialog.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 70be749d..363c91c4 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -1142,7 +1142,7 @@ export function NewWorktreeDialog({ )} - {existingBranchRankedGroups.otherLocal.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
{hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')} @@ -1174,7 +1174,7 @@ export function NewWorktreeDialog({
)} - {existingBranchRankedGroups.otherRemote.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
{hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')} @@ -1401,7 +1401,7 @@ export function NewWorktreeDialog({
)} - {sourceBranchRankedGroups.otherLocal.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
{hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')} @@ -1428,7 +1428,7 @@ export function NewWorktreeDialog({
)} - {sourceBranchRankedGroups.otherRemote.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
{hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')} @@ -1607,7 +1607,7 @@ export function NewWorktreeDialog({
)} - {existingBranchRankedGroups.otherLocal.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && ( <> {hasExistingBranchQuery && } @@ -1632,7 +1632,7 @@ export function NewWorktreeDialog({ )} - {existingBranchRankedGroups.otherRemote.length > 0 && ( + {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && ( <> {(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && ( @@ -1843,7 +1843,7 @@ export function NewWorktreeDialog({
)} - {sourceBranchRankedGroups.otherLocal.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && ( <> {hasSourceBranchQuery && } @@ -1863,7 +1863,7 @@ export function NewWorktreeDialog({ )} - {sourceBranchRankedGroups.otherRemote.length > 0 && ( + {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && ( <> {(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && ( From 20f2a2635baf2d5f7dc013cb00700a661c9c0aa7 Mon Sep 17 00:00:00 2001 From: Leonid Skorobogatyy Date: Wed, 24 Jun 2026 03:51:56 +1100 Subject: [PATCH 010/581] chore(worktree): drop dead otherBranches labels after search guard The mobile and desktop (cmdk) pickers in NewWorktreeDialog now hide non-matching branches during search via a !hasExistingBranchQuery / !hasSourceBranchQuery outer guard. That makes the inner heading ternaries (which switched between 'localBranches' and 'otherLocalBranches' depending on the search state) unreachable: they always resolve to the non-query label, and the 'hasExistingBranchQuery && ' lines were dead code. This commit: - Replaces 8 heading ternaries with static non-query labels (mobile and desktop, existing/source, local/remote). - Drops the two unreachable renders in the otherLocal blocks. - Simplifies the otherRemote separator conditions by removing the always-false '|| hasExistingBranchQuery' / '|| hasSourceBranchQuery' terms. - Removes the now-unused 'session.newWorktree.otherLocalBranches' and 'session.newWorktree.otherRemoteBranches' keys from all 9 locales. Behavior is unchanged; this is dead-branch cleanup only. --- .../components/session/NewWorktreeDialog.tsx | 22 +++++++++---------- packages/ui/src/lib/i18n/messages/en.ts | 2 -- packages/ui/src/lib/i18n/messages/es.ts | 2 -- packages/ui/src/lib/i18n/messages/fr.ts | 2 -- packages/ui/src/lib/i18n/messages/ko.ts | 2 -- packages/ui/src/lib/i18n/messages/pl.ts | 2 -- packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 -- packages/ui/src/lib/i18n/messages/uk.ts | 2 -- packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 -- packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 -- 10 files changed, 10 insertions(+), 30 deletions(-) diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index 363c91c4..8d4c16f5 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -1145,7 +1145,7 @@ export function NewWorktreeDialog({ {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && (
- {hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')} + {t('session.newWorktree.localBranches')}
{existingBranchRankedGroups.otherLocal.map((branch) => ( @@ -1177,7 +1177,7 @@ export function NewWorktreeDialog({ {!hasExistingBranchQuery && existingBranchRankedGroups.otherRemote.length > 0 && (
- {hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')} + {t('session.newWorktree.remoteBranches')}
{existingBranchRankedGroups.otherRemote.map((branch) => ( @@ -1404,7 +1404,7 @@ export function NewWorktreeDialog({ {!hasSourceBranchQuery && sourceBranchRankedGroups.otherLocal.length > 0 && (
- {hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')} + {t('session.newWorktree.localBranches')}
{sourceBranchRankedGroups.otherLocal.map((branch) => ( @@ -1431,7 +1431,7 @@ export function NewWorktreeDialog({ {!hasSourceBranchQuery && sourceBranchRankedGroups.otherRemote.length > 0 && (
- {hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')} + {t('session.newWorktree.remoteBranches')}
{sourceBranchRankedGroups.otherRemote.map((branch) => ( @@ -1609,8 +1609,7 @@ export function NewWorktreeDialog({ {!hasExistingBranchQuery && existingBranchRankedGroups.otherLocal.length > 0 && ( <> - {hasExistingBranchQuery && } - + {existingBranchRankedGroups.otherLocal.map((branch) => ( 0 && ( <> - {(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && ( + {existingBranchRankedGroups.otherLocal.length > 0 && ( )} - + {existingBranchRankedGroups.otherRemote.map((branch) => ( 0 && ( <> - {hasSourceBranchQuery && } - + {sourceBranchRankedGroups.otherLocal.map((branch) => ( 0 && ( <> - {(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && ( + {sourceBranchRankedGroups.otherLocal.length > 0 && ( )} - + {sourceBranchRankedGroups.otherRemote.map((branch) => ( = { "session.newWorktree.noMatchingBranches": "No hay ramas coincidentes", "session.newWorktree.localBranches": "Ramas locales", "session.newWorktree.remoteBranches": "Ramas remotas", - "session.newWorktree.otherLocalBranches": "Otras ramas locales", - "session.newWorktree.otherRemoteBranches": "Otras ramas remotas", "session.newWorktree.branchName": "Nombre de la rama", "session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature", "session.newWorktree.actions.change": "Cambiar", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 9d66af07..a37fb929 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1414,8 +1414,6 @@ export const dict = { 'session.newWorktree.noMatchingBranches': 'Aucune branche correspondante', 'session.newWorktree.localBranches': 'Branches locales', 'session.newWorktree.remoteBranches': 'Branches du dépôt distant', - 'session.newWorktree.otherLocalBranches': 'Autres branches locales', - 'session.newWorktree.otherRemoteBranches': 'Autres branches du remote', 'session.newWorktree.branchName': 'Nom de la branche', 'session.newWorktree.branchNamePlaceholder': 'fonctionnalité/ma-fonctionnalité-géniale', 'session.newWorktree.actions.change': 'Changement', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 06532993..1e41603f 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1551,8 +1551,6 @@ export const dict: Record = { 'session.newWorktree.noMatchingBranches': '일치하는 브랜치가 없습니다', 'session.newWorktree.localBranches': '로컬 브랜치', 'session.newWorktree.remoteBranches': '리모트 브랜치', - 'session.newWorktree.otherLocalBranches': '기타 로컬 브랜치', - 'session.newWorktree.otherRemoteBranches': '기타 리모트 브랜치', 'session.newWorktree.branchName': '브랜치 이름', 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.actions.change': '변경', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 664f62ed..b0bb914e 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2354,8 +2354,6 @@ export const dict: Record = { 'session.newWorktree.newSessionTitle': 'Nowa sesja', 'session.newWorktree.noBranchesFound': 'Nie znaleziono gałęzi', 'session.newWorktree.noMatchingBranches': 'Brak pasujących gałęzi', - 'session.newWorktree.otherLocalBranches': 'Pozostałe lokalne gałęzie', - 'session.newWorktree.otherRemoteBranches': 'Pozostałe zdalne gałęzie', 'session.newWorktree.prNumber': 'PR #{number}', 'session.newWorktree.remoteBranches': 'Zdalne gałęzie', 'session.newWorktree.resetToMatchBranchName': 'Zresetuj do nazwy zgodnej z gałęzią', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 8f345e85..c86dd4a6 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1515,8 +1515,6 @@ export const dict: Record = { "session.newWorktree.noMatchingBranches": "Não há branches coincidentes", "session.newWorktree.localBranches": "Branches locais", "session.newWorktree.remoteBranches": "Branches remotas", - "session.newWorktree.otherLocalBranches": "Outras branches locais", - "session.newWorktree.otherRemoteBranches": "Outras branches remotas", "session.newWorktree.branchName": "Nome da branch", "session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature", "session.newWorktree.actions.change": "Alterar", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 42c09339..ce5b08e2 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1515,8 +1515,6 @@ export const dict: Record = { "session.newWorktree.noMatchingBranches": "Немає відповідних гілок", "session.newWorktree.localBranches": "Локальні гілки", "session.newWorktree.remoteBranches": "Віддалені гілки", - "session.newWorktree.otherLocalBranches": "Інші локальні гілки", - "session.newWorktree.otherRemoteBranches": "Інші віддалені гілки", "session.newWorktree.branchName": "Назва гілки", "session.newWorktree.branchNamePlaceholder": "feature/my-awesome-feature", "session.newWorktree.actions.change": "Змінити", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 1ffd934d..5e4c71b5 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1515,8 +1515,6 @@ export const dict: Record = { 'session.newWorktree.noMatchingBranches': '没有匹配分支', 'session.newWorktree.localBranches': '本地分支', 'session.newWorktree.remoteBranches': '远程分支', - 'session.newWorktree.otherLocalBranches': '其他本地分支', - 'session.newWorktree.otherRemoteBranches': '其他远程分支', 'session.newWorktree.branchName': '分支名', 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.actions.change': '更改', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 08d30254..825fad27 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1519,8 +1519,6 @@ export const dict: Record = { 'session.newWorktree.noMatchingBranches': '沒有符合分支', 'session.newWorktree.localBranches': '本地分支', 'session.newWorktree.remoteBranches': '遠端分支', - 'session.newWorktree.otherLocalBranches': '其他本地分支', - 'session.newWorktree.otherRemoteBranches': '其他遠端分支', 'session.newWorktree.branchName': '分支名稱', 'session.newWorktree.branchNamePlaceholder': 'feature/my-awesome-feature', 'session.newWorktree.actions.change': '變更', From ecc70fa15d967bf0a43abed519a05a574b29d80f Mon Sep 17 00:00:00 2001 From: Tom Rochette Date: Wed, 24 Jun 2026 05:24:48 +0000 Subject: [PATCH 011/581] feat(debug): add fetch requests-in-flight tracker with age percentiles Adds a 'Requests' tab to the debug panel (Ctrl/Cmd+Shift+D) that wraps window.fetch while the panel is open to track every request as in-flight from call to promise settle, sampled once per second over a 5-minute rolling window. Charts: - in-flight request count over time (current + peak) - p50/p90/p99/max age distribution of currently in-flight requests, with the legend below the chart and a y-axis max label for scale Tracking is fully gated behind the panel: closing it stops the sampling interval, unwraps window.fetch, and drops all state (zero overhead when not debugging). i18n keys added to all 9 locales. --- packages/ui/src/App.tsx | 8 + .../ui/src/components/ui/MemoryDebugPanel.tsx | 180 +++++++++- packages/ui/src/lib/i18n/messages/en.ts | 11 + packages/ui/src/lib/i18n/messages/es.ts | 11 + packages/ui/src/lib/i18n/messages/fr.ts | 11 + packages/ui/src/lib/i18n/messages/ko.ts | 11 + packages/ui/src/lib/i18n/messages/pl.ts | 11 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 11 + packages/ui/src/lib/i18n/messages/uk.ts | 11 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 11 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 11 + .../ui/src/stores/utils/requestsInFlight.ts | 314 ++++++++++++++++++ 12 files changed, 594 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/stores/utils/requestsInFlight.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 583d4b9d..a0c977ce 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -6,6 +6,7 @@ import { Toaster } from '@/components/ui/sonner'; import { Button } from '@/components/ui/button'; import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel'; import { setStreamPerfEnabled } from '@/stores/utils/streamDebug'; +import { setRequestsInFlightTrackingEnabled } from '@/stores/utils/requestsInFlight'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; // useEventStream removed — replaced by SyncProvider + SyncBridge import { useMenuActions } from '@/hooks/useMenuActions'; @@ -256,6 +257,13 @@ function App({ apis }: AppProps) { }; }, [showMemoryDebug]); + React.useEffect(() => { + setRequestsInFlightTrackingEnabled(showMemoryDebug); + return () => { + setRequestsInFlightTrackingEnabled(false); + }; + }, [showMemoryDebug]); + React.useEffect(() => { applyMobileKeyboardMode(mobileKeyboardMode); }, [mobileKeyboardMode]); diff --git a/packages/ui/src/components/ui/MemoryDebugPanel.tsx b/packages/ui/src/components/ui/MemoryDebugPanel.tsx index 901d8f80..957e654b 100644 --- a/packages/ui/src/components/ui/MemoryDebugPanel.tsx +++ b/packages/ui/src/components/ui/MemoryDebugPanel.tsx @@ -7,6 +7,7 @@ import { MEMORY_LIMITS } from '@/stores/types/sessionTypes'; import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { getBackgroundTrimLimit } from '@/stores/types/sessionTypes'; import { getStreamPerfSnapshot, getVsCodeStreamPerfSnapshot, resetStreamPerf, type StreamPerfSnapshot } from '@/stores/utils/streamDebug'; +import { getRequestsInFlightSnapshot, resetRequestsInFlight, type RequestsInFlightSnapshot } from '@/stores/utils/requestsInFlight'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; @@ -18,7 +19,7 @@ interface DebugPanelProps { onClose?: () => void; } -type DebugTab = 'memory' | 'streaming'; +type DebugTab = 'memory' | 'streaming' | 'requests'; const formatDuration = (durationMs: number): string => { if (durationMs < 1000) { @@ -35,6 +36,10 @@ const formatDuration = (durationMs: number): string => { return `${minutes}m ${remainderSeconds}s`; }; +// Fixed-width seconds format ("XX.XX s") for the percentile series so the +// legend/labels don't jitter as values change. Pair with `tabular-nums`. +const formatSeconds = (durationMs: number): string => `${(durationMs / 1000).toFixed(2)} s`; + const MetricCard: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => { return (
= ({ series, peak, windowSeconds, ariaLabel, maxLabel }) => { + const width = windowSeconds; + const height = 56; + const padTop = 4; + const n = series.reduce((max, s) => Math.max(max, s.samples.length), 0); + const scale = peak > 0 ? (height - padTop) / peak : 0; + const xFor = (i: number): number => width - n + i; + const yFor = (v: number): number => height - v * scale; + const baseline = height; + + return ( +
+ {maxLabel} + + + {series.map((s, si) => { + const sn = s.samples.length; + if (sn === 0) return null; + const points = s.samples.map((v, i) => `${xFor(i)},${yFor(v).toFixed(2)}`); + const linePath = `M ${points.join(' L ')}`; + return ( + + {s.filled ? ( + + ) : null} + + + ); + })} + +
+ ); +}; + export const DebugPanel: React.FC = ({ onClose }) => { const { t } = useI18n(); const [activeTab, setActiveTab] = React.useState('memory'); @@ -110,6 +183,15 @@ export const DebugPanel: React.FC = ({ onClose }) => { const totalGitHubRequests = useGitHubPrStatusStore((state) => state.totalRequestCount); const [streamSnapshot, setStreamSnapshot] = React.useState(() => getStreamPerfSnapshot()); const [vscodeStreamSnapshot, setVsCodeStreamSnapshot] = React.useState(() => getVsCodeStreamPerfSnapshot()); + const [requestsSnapshot, setRequestsSnapshot] = React.useState(() => getRequestsInFlightSnapshot()); + const ageLines = [ + { label: 'p50', current: requestsSnapshot.ageP50, samples: requestsSnapshot.p50Samples, color: 'var(--status-success)' }, + { label: 'p90', current: requestsSnapshot.ageP90, samples: requestsSnapshot.p90Samples, color: 'var(--status-info)' }, + { label: 'p99', current: requestsSnapshot.ageP99, samples: requestsSnapshot.p99Samples, color: 'var(--status-warning)' }, + { label: 'max', current: requestsSnapshot.ageMax, samples: requestsSnapshot.maxSamples, color: 'var(--status-error)' }, + ]; + const countMax = requestsSnapshot.samples.reduce((m, v) => Math.max(m, v), 0); + const percentileMax = ageLines.reduce((m, l) => l.samples.reduce((mm, v) => Math.max(mm, v), m), 0); const streamMetricCounts = React.useMemo(() => { const counts = new Map(); streamSnapshot.entries.forEach((entry) => { @@ -130,6 +212,7 @@ export const DebugPanel: React.FC = ({ onClose }) => { const refresh = () => { setStreamSnapshot(getStreamPerfSnapshot()); setVsCodeStreamSnapshot(getVsCodeStreamPerfSnapshot()); + setRequestsSnapshot(getRequestsInFlightSnapshot()); }; refresh(); @@ -218,11 +301,10 @@ export const DebugPanel: React.FC = ({ onClose }) => { >
- {activeTab === 'memory' ? ( - - ) : ( - - )} +

{t('memoryDebugPanel.title')}

@@ -244,6 +326,18 @@ export const DebugPanel: React.FC = ({ onClose }) => { ) : null} + {activeTab === 'requests' ? ( + + ) : null} {onClose ? ( +
{activeTab === 'memory' ? ( @@ -366,7 +468,7 @@ export const DebugPanel: React.FC = ({ onClose }) => {
- ) : ( + ) : activeTab === 'streaming' ? (
@@ -409,6 +511,70 @@ export const DebugPanel: React.FC = ({ onClose }) => { /> ) : null}
+ ) : ( +
+
+ + +
+ + {requestsSnapshot.samples.length === 0 ? ( +
+ {t('memoryDebugPanel.requests.noSamples')} +
+ ) : ( +
+
+ {t('memoryDebugPanel.requests.inFlight')} + + {requestsSnapshot.inFlight} + · {t('memoryDebugPanel.requests.peak')} + {requestsSnapshot.peak} + +
+ + +
+ {t('memoryDebugPanel.requests.duration')} + {formatSeconds(requestsSnapshot.peakAgeMs)} +
+ ({ samples: line.samples, color: line.color }))} + peak={percentileMax} + windowSeconds={requestsSnapshot.windowSeconds} + ariaLabel={t('memoryDebugPanel.requests.percentileChartLabel')} + maxLabel={formatSeconds(percentileMax)} + /> + +
+ {ageLines.map((line) => ( + + + {line.label} + {formatSeconds(line.current)} + + ))} +
+ +
+ {t('memoryDebugPanel.requests.windowHint', { seconds: requestsSnapshot.windowSeconds })} + {t('memoryDebugPanel.requests.now')} +
+
+ )} +
)} ); diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index dba49cb5..c0f6b69e 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2537,6 +2537,7 @@ export const dict = { 'memoryDebugPanel.title': 'Debug Panel', 'memoryDebugPanel.tabs.memory': 'Memory', 'memoryDebugPanel.tabs.streaming': 'Streaming', + 'memoryDebugPanel.tabs.requests': 'Requests', 'memoryDebugPanel.section.sessionsInMemory': 'Sessions in Memory', 'memoryDebugPanel.section.uiStreamingMetrics': 'UI Streaming Metrics', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code Bridge Metrics', @@ -2574,6 +2575,16 @@ export const dict = { 'memoryDebugPanel.streaming.copy.copied': 'Streaming debug JSON copied', 'memoryDebugPanel.streaming.copy.failed': 'Failed to copy JSON', 'memoryDebugPanel.streaming.copy.hint': 'Copy exports both UI and VS Code streaming metrics as JSON', + 'memoryDebugPanel.requests.inFlight': 'In flight', + 'memoryDebugPanel.requests.peak': 'Peak', + 'memoryDebugPanel.requests.duration': 'Duration', + 'memoryDebugPanel.requests.totalRequests': 'Total Requests', + 'memoryDebugPanel.requests.tracking': 'Tracking', + 'memoryDebugPanel.requests.now': 'now', + 'memoryDebugPanel.requests.noSamples': 'No requests tracked yet. Keep this panel open to record fetch activity.', + 'memoryDebugPanel.requests.chartLabel': 'Fetch requests in flight over time, peak {peak}', + 'memoryDebugPanel.requests.windowHint': 'last {seconds}s', + 'memoryDebugPanel.requests.percentileChartLabel': 'In-flight request age percentiles (p50, p90, p99, max) over time', 'memoryDebugPanel.common.idle': 'idle', 'memoryDebugPanel.common.live': 'live', 'memoryDebugPanel.common.notAvailable': 'n/a', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index a92e4127..4df60271 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2503,6 +2503,7 @@ export const dict: Record = { "memoryDebugPanel.title": "Panel de depuración", "memoryDebugPanel.tabs.memory": "Memoria", "memoryDebugPanel.tabs.streaming": "Transmisión", + "memoryDebugPanel.tabs.requests": "Solicitudes", "memoryDebugPanel.section.sessionsInMemory": "Sesiones en memoria", "memoryDebugPanel.section.uiStreamingMetrics": "Métricas de streaming de UI", "memoryDebugPanel.section.vscodeBridgeMetrics": "Métricas del puente de VS Code", @@ -2540,6 +2541,16 @@ export const dict: Record = { "memoryDebugPanel.streaming.copy.copied": "JSON de depuración en streaming copiado", "memoryDebugPanel.streaming.copy.failed": "No se pudo copiar JSON", "memoryDebugPanel.streaming.copy.hint": "Copia exportaciones de métricas de UI como de métricas de VS Code en formato JSON", + "memoryDebugPanel.requests.inFlight": "En curso", + "memoryDebugPanel.requests.peak": "Pico", + "memoryDebugPanel.requests.duration": "Duración", + "memoryDebugPanel.requests.totalRequests": "Solicitudes totales", + "memoryDebugPanel.requests.tracking": "Seguimiento", + "memoryDebugPanel.requests.now": "ahora", + "memoryDebugPanel.requests.noSamples": "Aún no se han registrado solicitudes. Mantén este panel abierto para registrar la actividad de fetch.", + "memoryDebugPanel.requests.chartLabel": "Solicitudes fetch en curso a lo largo del tiempo, pico {peak}", + "memoryDebugPanel.requests.windowHint": "últimos {seconds}s", + "memoryDebugPanel.requests.percentileChartLabel": "Percentiles de antigüedad de solicitudes en curso (p50, p90, p99, máx) a lo largo del tiempo", "memoryDebugPanel.common.idle": "inactivo", "memoryDebugPanel.common.live": "en vivo", "memoryDebugPanel.common.notAvailable": "n/a", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 9d66af07..08026779 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2346,6 +2346,7 @@ export const dict = { 'memoryDebugPanel.title': 'Panneau de débogage', 'memoryDebugPanel.tabs.memory': 'Mémoire', 'memoryDebugPanel.tabs.streaming': 'Streaming', + 'memoryDebugPanel.tabs.requests': 'Requêtes', 'memoryDebugPanel.section.sessionsInMemory': 'Sessions en mémoire', 'memoryDebugPanel.section.uiStreamingMetrics': 'Métriques de streaming de l\'interface utilisateur', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'Métriques du pont VS Code', @@ -2383,6 +2384,16 @@ export const dict = { 'memoryDebugPanel.streaming.copy.copied': 'Débogage en streaming JSON copié', 'memoryDebugPanel.streaming.copy.failed': 'Échec de la copie de JSON', 'memoryDebugPanel.streaming.copy.hint': 'La copie exporte les métriques de streaming de l\'interface utilisateur et de VS Code en tant que JSON.', + 'memoryDebugPanel.requests.inFlight': 'En cours', + 'memoryDebugPanel.requests.peak': 'Pic', + 'memoryDebugPanel.requests.duration': 'Durée', + 'memoryDebugPanel.requests.totalRequests': 'Requêtes totales', + 'memoryDebugPanel.requests.tracking': 'Suivi', + 'memoryDebugPanel.requests.now': 'maintenant', + 'memoryDebugPanel.requests.noSamples': 'Aucune requête enregistrée. Gardez ce panneau ouvert pour enregistrer l\'activité fetch.', + 'memoryDebugPanel.requests.chartLabel': 'Requêtes fetch en cours dans le temps, pic {peak}', + 'memoryDebugPanel.requests.windowHint': '{seconds}s dernières', + 'memoryDebugPanel.requests.percentileChartLabel': 'Percentiles d\'âge des requêtes en cours (p50, p90, p99, max) dans le temps', 'memoryDebugPanel.common.idle': 'inactif', 'memoryDebugPanel.common.live': 'en direct', 'memoryDebugPanel.common.notAvailable': 'n / A', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 06532993..d0d69989 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2537,6 +2537,7 @@ export const dict: Record = { 'memoryDebugPanel.title': '디버그 패널', 'memoryDebugPanel.tabs.memory': '메모리', 'memoryDebugPanel.tabs.streaming': '스트리밍', + 'memoryDebugPanel.tabs.requests': '요청', 'memoryDebugPanel.section.sessionsInMemory': '메모리 내 세션', 'memoryDebugPanel.section.uiStreamingMetrics': 'UI 스트리밍 지표', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code 브리지 지표', @@ -2574,6 +2575,16 @@ export const dict: Record = { 'memoryDebugPanel.streaming.copy.copied': '스트리밍 디버그 JSON 복사 완료', 'memoryDebugPanel.streaming.copy.failed': 'JSON 복사 실패', 'memoryDebugPanel.streaming.copy.hint': 'UI와 VS Code 스트리밍 메트릭을 JSON으로 복사합니다', + 'memoryDebugPanel.requests.inFlight': '진행 중', + 'memoryDebugPanel.requests.peak': '최대', + 'memoryDebugPanel.requests.duration': '지속 시간', + 'memoryDebugPanel.requests.totalRequests': '전체 요청', + 'memoryDebugPanel.requests.tracking': '추적 중', + 'memoryDebugPanel.requests.now': '현재', + 'memoryDebugPanel.requests.noSamples': '아직 기록된 요청이 없습니다. fetch 활동을 기록하려면 이 패널을 열어 두세요.', + 'memoryDebugPanel.requests.chartLabel': '시간에 따른 진행 중인 fetch 요청, 최대 {peak}', + 'memoryDebugPanel.requests.windowHint': '최근 {seconds}초', + 'memoryDebugPanel.requests.percentileChartLabel': '진행 중 요청 수명 백분위수(p50, p90, p99, max)의 시간별 변화', 'memoryDebugPanel.common.idle': '유휴', 'memoryDebugPanel.common.live': '실시간', 'memoryDebugPanel.common.notAvailable': 'n/a', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 664f62ed..cf228b44 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2135,8 +2135,19 @@ export const dict: Record = { 'memoryDebugPanel.streaming.copy.copied': 'Skopiowano JSON debugowania streamingu', 'memoryDebugPanel.streaming.copy.failed': 'Nie udało się skopiować JSON', 'memoryDebugPanel.streaming.copy.hint': 'Kopiowanie eksportuje metryki streamingu zarówno UI, jak i VS Code w formacie JSON', + 'memoryDebugPanel.requests.inFlight': 'W trakcie', + 'memoryDebugPanel.requests.peak': 'Szczyt', + 'memoryDebugPanel.requests.duration': 'Czas trwania', + 'memoryDebugPanel.requests.totalRequests': 'Łączne żądania', + 'memoryDebugPanel.requests.tracking': 'Śledzenie', + 'memoryDebugPanel.requests.now': 'teraz', + 'memoryDebugPanel.requests.noSamples': 'Brak żądań. Utrzymuj ten panel otwarty, aby rejestrować aktywność fetch.', + 'memoryDebugPanel.requests.chartLabel': 'Żądania fetch w trakcie w czasie, szczyt {peak}', + 'memoryDebugPanel.requests.windowHint': 'ostatnie {seconds}s', + 'memoryDebugPanel.requests.percentileChartLabel': 'Percentyle wieku żądań w trakcie (p50, p90, p99, max) w czasie', 'memoryDebugPanel.tabs.memory': 'Pamięć', 'memoryDebugPanel.tabs.streaming': 'Streaming', + 'memoryDebugPanel.tabs.requests': 'Żądania', 'memoryDebugPanel.title': 'Panel debugowania', 'memoryDebugPanel.tooltip.logCurrentState': 'Zaloguj bieżący stan pamięci do konsoli przeglądarki', 'openChamberLogo.aria.logo': 'Logo OpenChamber', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 8f345e85..08591730 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2503,6 +2503,7 @@ export const dict: Record = { "memoryDebugPanel.title": "Painel de depuração", "memoryDebugPanel.tabs.memory": "Memória", "memoryDebugPanel.tabs.streaming": "Transmissão", + "memoryDebugPanel.tabs.requests": "Solicitações", "memoryDebugPanel.section.sessionsInMemory": "Sessões em memória", "memoryDebugPanel.section.uiStreamingMetrics": "Métricas de streaming de UI", "memoryDebugPanel.section.vscodeBridgeMetrics": "Métricas da ponte do VS Code", @@ -2540,6 +2541,16 @@ export const dict: Record = { "memoryDebugPanel.streaming.copy.copied": "JSON de depuração em streaming copiado", "memoryDebugPanel.streaming.copy.failed": "Não foi possível copiar JSON", "memoryDebugPanel.streaming.copy.hint": "Copia exportações de métricas da UI e do VS Code em formato JSON", + "memoryDebugPanel.requests.inFlight": "Em curso", + "memoryDebugPanel.requests.peak": "Pico", + "memoryDebugPanel.requests.duration": "Duração", + "memoryDebugPanel.requests.totalRequests": "Solicitações totais", + "memoryDebugPanel.requests.tracking": "Rastreamento", + "memoryDebugPanel.requests.now": "agora", + "memoryDebugPanel.requests.noSamples": "Nenhuma solicitação registrada. Mantenha este painel aberto para registrar a atividade de fetch.", + "memoryDebugPanel.requests.chartLabel": "Solicitações fetch em curso ao longo do tempo, pico {peak}", + "memoryDebugPanel.requests.windowHint": "últimos {seconds}s", + "memoryDebugPanel.requests.percentileChartLabel": "Percentis de idade das solicitações em curso (p50, p90, p99, máx) ao longo do tempo", "memoryDebugPanel.common.idle": "inativo", "memoryDebugPanel.common.live": "ao vivo", "memoryDebugPanel.common.notAvailable": "n/a", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 42c09339..d2232dae 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2503,6 +2503,7 @@ export const dict: Record = { "memoryDebugPanel.title": "Панель налагодження", "memoryDebugPanel.tabs.memory": "Пам'ять", "memoryDebugPanel.tabs.streaming": "Потокове передавання", + "memoryDebugPanel.tabs.requests": "Запити", "memoryDebugPanel.section.sessionsInMemory": "Сесії в пам'яті", "memoryDebugPanel.section.uiStreamingMetrics": "Потокові показники інтерфейсу користувача", "memoryDebugPanel.section.vscodeBridgeMetrics": "Метрики мосту VS Code", @@ -2540,6 +2541,16 @@ export const dict: Record = { "memoryDebugPanel.streaming.copy.copied": "Потокове налагодження JSON скопійовано", "memoryDebugPanel.streaming.copy.failed": "Не вдалося скопіювати JSON", "memoryDebugPanel.streaming.copy.hint": "Копіювання експортує метрики потокового інтерфейсу користувача та VS Code як JSON", + "memoryDebugPanel.requests.inFlight": "Виконуються", + "memoryDebugPanel.requests.peak": "Пік", + "memoryDebugPanel.requests.duration": "Тривалість", + "memoryDebugPanel.requests.totalRequests": "Усього запитів", + "memoryDebugPanel.requests.tracking": "Відстеження", + "memoryDebugPanel.requests.now": "зараз", + "memoryDebugPanel.requests.noSamples": "Запитів ще немає. Тримайте цю панель відкритою, щоб фіксувати активність fetch.", + "memoryDebugPanel.requests.chartLabel": "Запити fetch у виконанні з часом, пік {peak}", + "memoryDebugPanel.requests.windowHint": "останні {seconds}s", + "memoryDebugPanel.requests.percentileChartLabel": "Перцентилі віку запитів у виконанні (p50, p90, p99, max) з часом", "memoryDebugPanel.common.idle": "очікування", "memoryDebugPanel.common.live": "live", "memoryDebugPanel.common.notAvailable": "n/a", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 1ffd934d..3fd91956 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2503,6 +2503,7 @@ export const dict: Record = { 'memoryDebugPanel.title': '调试面板', 'memoryDebugPanel.tabs.memory': '内存', 'memoryDebugPanel.tabs.streaming': '流式', + 'memoryDebugPanel.tabs.requests': '请求', 'memoryDebugPanel.section.sessionsInMemory': '内存中的会话', 'memoryDebugPanel.section.uiStreamingMetrics': 'UI 流式指标', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code 桥接指标', @@ -2540,6 +2541,16 @@ export const dict: Record = { 'memoryDebugPanel.streaming.copy.copied': '流式调试 JSON 已复制', 'memoryDebugPanel.streaming.copy.failed': '复制 JSON 失败', 'memoryDebugPanel.streaming.copy.hint': '复制会导出 UI 与 VS Code 的流式指标 JSON', + 'memoryDebugPanel.requests.inFlight': '进行中', + 'memoryDebugPanel.requests.peak': '峰值', + 'memoryDebugPanel.requests.duration': '时长', + 'memoryDebugPanel.requests.totalRequests': '请求总数', + 'memoryDebugPanel.requests.tracking': '跟踪', + 'memoryDebugPanel.requests.now': '当前', + 'memoryDebugPanel.requests.noSamples': '尚未记录请求。保持此面板打开以记录 fetch 活动。', + 'memoryDebugPanel.requests.chartLabel': '随时间变化的进行中 fetch 请求,峰值 {peak}', + 'memoryDebugPanel.requests.windowHint': '最近 {seconds}秒', + 'memoryDebugPanel.requests.percentileChartLabel': '进行中请求年龄百分位(p50、p90、p99、最大值)随时间的变化', 'memoryDebugPanel.common.idle': '空闲', 'memoryDebugPanel.common.live': '实时', 'memoryDebugPanel.common.notAvailable': '无', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 08d30254..407ed0e1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2500,6 +2500,7 @@ export const dict: Record = { 'memoryDebugPanel.title': '偵錯面板', 'memoryDebugPanel.tabs.memory': '記憶體', 'memoryDebugPanel.tabs.streaming': '串流', + 'memoryDebugPanel.tabs.requests': '請求', 'memoryDebugPanel.section.sessionsInMemory': '記憶體中的會話', 'memoryDebugPanel.section.uiStreamingMetrics': 'UI 串流指標', 'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code 橋接指標', @@ -2537,6 +2538,16 @@ export const dict: Record = { 'memoryDebugPanel.streaming.copy.copied': '串流偵錯 JSON 已複製', 'memoryDebugPanel.streaming.copy.failed': '複製 JSON 失敗', 'memoryDebugPanel.streaming.copy.hint': '複製會匯出 UI 與 VS Code 的串流指標 JSON', + 'memoryDebugPanel.requests.inFlight': '進行中', + 'memoryDebugPanel.requests.peak': '峰值', + 'memoryDebugPanel.requests.duration': '時長', + 'memoryDebugPanel.requests.totalRequests': '請求總數', + 'memoryDebugPanel.requests.tracking': '追蹤', + 'memoryDebugPanel.requests.now': '目前', + 'memoryDebugPanel.requests.noSamples': '尚未記錄請求。保持此面板開啟以記錄 fetch 活動。', + 'memoryDebugPanel.requests.chartLabel': '隨時間變化的進行中 fetch 請求,峰值 {peak}', + 'memoryDebugPanel.requests.windowHint': '最近 {seconds}秒', + 'memoryDebugPanel.requests.percentileChartLabel': '進行中請求年齡百分位(p50、p90、p99、最大值)隨時間的變化', 'memoryDebugPanel.common.idle': '閒置', 'memoryDebugPanel.common.live': '即時', 'memoryDebugPanel.common.notAvailable': '無', diff --git a/packages/ui/src/stores/utils/requestsInFlight.ts b/packages/ui/src/stores/utils/requestsInFlight.ts new file mode 100644 index 00000000..9b263f93 --- /dev/null +++ b/packages/ui/src/stores/utils/requestsInFlight.ts @@ -0,0 +1,314 @@ +// Tracks every fetch() request as "in flight" from call to promise settle, +// samples two series once per second, and keeps a 5-minute rolling window for +// plotting: +// 1. in-flight request count +// 2. percentile distribution of currently in-flight request ages: p50, p90, +// p99, max (ms since each unsettled fetch started; 0 when nothing is in +// flight) +// Mirrors the streamDebug.ts pattern: collection is gated behind an +// enable/disable toggle (driven by the debug panel), state lives on `window` +// to survive HMR, and the UI polls a serializable snapshot instead of +// subscribing to a store (this is high-frequency debug data, see stores docs). + +const STORAGE_KEY = 'openchamber_requests_in_flight'; +const SAMPLE_INTERVAL_MS = 1000; +const WINDOW_MS = 5 * 60 * 1000; +const MAX_SAMPLES = Math.ceil(WINDOW_MS / SAMPLE_INTERVAL_MS); + +type RequestsInFlightState = { + enabled: boolean; + startedAt: number; + inFlight: number; + peak: number; + totalStarted: number; + totalSettled: number; + samples: number[]; + p50Samples: number[]; + p90Samples: number[]; + p99Samples: number[]; + maxSamples: number[]; + peakAgeMs: number; + inFlightStarts: Map; + sampleCount: number; + lastSampleAt: number | null; + fetchWrapped: boolean; + originalFetch: typeof window.fetch | null; + sampleTimer: number | null; +}; + +export type RequestsInFlightSnapshot = { + enabled: boolean; + startedAt: number | null; + durationMs: number; + inFlight: number; + peak: number; + totalStarted: number; + totalSettled: number; + samples: number[]; + ageP50: number; + ageP90: number; + ageP99: number; + ageMax: number; + peakAgeMs: number; + p50Samples: number[]; + p90Samples: number[]; + p99Samples: number[]; + maxSamples: number[]; + sampleCount: number; + lastSampleAt: number | null; + windowSeconds: number; +}; + +declare global { + interface Window { + __openchamberRequestsInFlight__?: RequestsInFlightState; + } +} + +export const requestsInFlightEnabled = (): boolean => { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem(STORAGE_KEY) === '1'; + } catch { + return false; + } +}; + +const createState = (): RequestsInFlightState => { + const startedAt = Date.now(); + return { + enabled: true, + startedAt, + inFlight: 0, + peak: 0, + totalStarted: 0, + totalSettled: 0, + samples: [], + p50Samples: [], + p90Samples: [], + p99Samples: [], + maxSamples: [], + peakAgeMs: 0, + inFlightStarts: new Map(), + sampleCount: 0, + lastSampleAt: null, + fetchWrapped: false, + originalFetch: null, + sampleTimer: null, + }; +}; + +let nextRequestId = 1; + +const recordStart = (id: number, startMs: number): void => { + const state = window.__openchamberRequestsInFlight__; + if (!state || !state.enabled) return; + state.inFlight += 1; + state.totalStarted += 1; + if (state.inFlight > state.peak) state.peak = state.inFlight; + state.inFlightStarts.set(id, startMs); +}; + +const recordSettle = (id: number): void => { + const state = window.__openchamberRequestsInFlight__; + if (!state || !state.enabled) return; + state.inFlight = Math.max(0, state.inFlight - 1); + state.totalSettled += 1; + state.inFlightStarts.delete(id); +}; + +// Sorted ages (ms) of every currently in-flight request. Empty when nothing +// is in flight. Used both for live snapshot reporting and per-second sampling. +const currentAges = (state: RequestsInFlightState): number[] => { + if (state.inFlightStarts.size === 0) return []; + const now = Date.now(); + const ages: number[] = []; + for (const start of state.inFlightStarts.values()) { + ages.push(Math.max(0, now - start)); + } + ages.sort((a, b) => a - b); + return ages; +}; + +// Linear-interpolation percentile of a pre-sorted array. +const percentile = (sorted: number[], p: number): number => { + const n = sorted.length; + if (n === 0) return 0; + if (n === 1) return sorted[0]; + const rank = (p / 100) * (n - 1); + const lo = Math.floor(rank); + const hi = Math.ceil(rank); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo); +}; + +const installFetchTracker = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state || state.fetchWrapped) return; + const original = window.fetch.bind(window); + state.originalFetch = original; + state.fetchWrapped = true; + const tracker = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const id = nextRequestId++; + recordStart(id, Date.now()); + try { + return await original(input, init); + } finally { + recordSettle(id); + } + }; + window.fetch = tracker as typeof window.fetch; +}; + +const uninstallFetchTracker = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state || !state.fetchWrapped || !state.originalFetch) return; + window.fetch = state.originalFetch; + state.fetchWrapped = false; + state.originalFetch = null; +}; + +const trimSamples = (arr: number[]): void => { + if (arr.length > MAX_SAMPLES) arr.splice(0, arr.length - MAX_SAMPLES); +}; + +const pushSample = (): void => { + const state = window.__openchamberRequestsInFlight__; + if (!state || !state.enabled) return; + state.samples.push(state.inFlight); + const ages = currentAges(state); + const mx = ages.length > 0 ? ages[ages.length - 1] : 0; + state.p50Samples.push(percentile(ages, 50)); + state.p90Samples.push(percentile(ages, 90)); + state.p99Samples.push(percentile(ages, 99)); + state.maxSamples.push(mx); + if (mx > state.peakAgeMs) state.peakAgeMs = mx; + state.sampleCount += 1; + trimSamples(state.samples); + trimSamples(state.p50Samples); + trimSamples(state.p90Samples); + trimSamples(state.p99Samples); + trimSamples(state.maxSamples); + state.lastSampleAt = Date.now(); +}; + +const startSampling = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state || state.sampleTimer != null) return; + state.sampleTimer = window.setInterval(pushSample, SAMPLE_INTERVAL_MS); +}; + +const stopSampling = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state || state.sampleTimer == null) return; + window.clearInterval(state.sampleTimer); + state.sampleTimer = null; +}; + +export const setRequestsInFlightTrackingEnabled = (enabled: boolean): void => { + if (typeof window === 'undefined') return; + + try { + if (enabled) { + // Idempotent: tear down any prior tracking first so a repeated + // enable can never wrap window.fetch twice (which would double-count). + stopSampling(); + uninstallFetchTracker(); + window.localStorage.setItem(STORAGE_KEY, '1'); + window.__openchamberRequestsInFlight__ = createState(); + installFetchTracker(); + startSampling(); + return; + } + + window.localStorage.removeItem(STORAGE_KEY); + stopSampling(); + uninstallFetchTracker(); + delete window.__openchamberRequestsInFlight__; + } catch { + // ignore storage failures in debug helper + } +}; + +export const resetRequestsInFlight = (): void => { + if (typeof window === 'undefined') return; + const state = window.__openchamberRequestsInFlight__; + if (!state) return; + const fresh = createState(); + state.startedAt = fresh.startedAt; + state.inFlight = fresh.inFlight; + state.peak = fresh.peak; + state.totalStarted = fresh.totalStarted; + state.totalSettled = fresh.totalSettled; + state.samples = fresh.samples; + state.p50Samples = fresh.p50Samples; + state.p90Samples = fresh.p90Samples; + state.p99Samples = fresh.p99Samples; + state.maxSamples = fresh.maxSamples; + state.peakAgeMs = fresh.peakAgeMs; + state.inFlightStarts = fresh.inFlightStarts; + state.sampleCount = fresh.sampleCount; + state.lastSampleAt = fresh.lastSampleAt; +}; + +export const getRequestsInFlightSnapshot = (): RequestsInFlightSnapshot => { + if (typeof window === 'undefined') { + return emptySnapshot(); + } + + const state = window.__openchamberRequestsInFlight__; + if (!requestsInFlightEnabled() || !state) { + return emptySnapshot(); + } + + const ages = currentAges(state); + return { + enabled: true, + startedAt: state.startedAt, + durationMs: Math.max(0, Date.now() - state.startedAt), + inFlight: state.inFlight, + peak: state.peak, + totalStarted: state.totalStarted, + totalSettled: state.totalSettled, + samples: state.samples.slice(), + ageP50: percentile(ages, 50), + ageP90: percentile(ages, 90), + ageP99: percentile(ages, 99), + ageMax: ages.length > 0 ? ages[ages.length - 1] : 0, + peakAgeMs: state.peakAgeMs, + p50Samples: state.p50Samples.slice(), + p90Samples: state.p90Samples.slice(), + p99Samples: state.p99Samples.slice(), + maxSamples: state.maxSamples.slice(), + sampleCount: state.sampleCount, + lastSampleAt: state.lastSampleAt, + windowSeconds: MAX_SAMPLES, + }; +}; + +const emptySnapshot = (): RequestsInFlightSnapshot => ({ + enabled: false, + startedAt: null, + durationMs: 0, + inFlight: 0, + peak: 0, + totalStarted: 0, + totalSettled: 0, + samples: [], + ageP50: 0, + ageP90: 0, + ageP99: 0, + ageMax: 0, + peakAgeMs: 0, + p50Samples: [], + p90Samples: [], + p99Samples: [], + maxSamples: [], + sampleCount: 0, + lastSampleAt: null, + windowSeconds: MAX_SAMPLES, +}); From c6c9338acacba2c1fa9237199640854556b7cae0 Mon Sep 17 00:00:00 2001 From: bashrusakh Date: Sat, 27 Jun 2026 05:50:54 +1100 Subject: [PATCH 012/581] fix(web): make managed process reaper async --- packages/web/server/lib/opencode/lifecycle.js | 4 +- .../web/server/lib/opencode/lifecycle.test.js | 5 + .../lib/opencode/managed-process-registry.js | 93 ++++-- .../managed-process-registry.test.mjs | 306 ++++++++++++++++++ 4 files changed, 377 insertions(+), 31 deletions(-) create mode 100644 packages/web/server/lib/opencode/managed-process-registry.test.mjs diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index d01a9fd3..31f3341f 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -221,7 +221,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { // Drop it from the registry only once it has actually exited, so a child // that survived teardown stays eligible for the next run's reaper. if (Number.isInteger(pid) && hasChildProcessExited(child)) { - unregisterManagedProcess(pid); + await unregisterManagedProcess(pid); } } }; @@ -343,7 +343,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { // actual host (Electron sets OPENCHAMBER_RUNTIME='desktop'; the standalone // web CLI leaves it unset → 'web'; SSH remote → 'ssh-remote') rather than a // hardcoded label, matching the server's existing runtimeName convention. - registerManagedProcess({ + await registerManagedProcess({ pid: child.pid, ownerPid: process.pid, port, diff --git a/packages/web/server/lib/opencode/lifecycle.test.js b/packages/web/server/lib/opencode/lifecycle.test.js index 19dbe18d..39ebcdc7 100644 --- a/packages/web/server/lib/opencode/lifecycle.test.js +++ b/packages/web/server/lib/opencode/lifecycle.test.js @@ -6,6 +6,11 @@ const spawnMock = vi.fn(); vi.mock('node:child_process', () => ({ spawn: spawnMock, spawnSync: vi.fn(), + // `managed-process-registry.js` (imported transitively via lifecycle.js) + // calls `promisify(execFile)` at module load, so the mock must expose a + // function here. Lifecycle tests don't exercise the reaper path, so a plain + // stub is enough; the registry's best-effort writes are no-ops on errors. + execFile: vi.fn(), })); const { createOpenCodeLifecycleRuntime } = await import('./lifecycle.js'); diff --git a/packages/web/server/lib/opencode/managed-process-registry.js b/packages/web/server/lib/opencode/managed-process-registry.js index 2e225bce..a6b6927b 100644 --- a/packages/web/server/lib/opencode/managed-process-registry.js +++ b/packages/web/server/lib/opencode/managed-process-registry.js @@ -32,13 +32,26 @@ // been reparented to init/pid 1, or the recorded owner pid is dead. A // child still owned by a live instance is left untouched. // +// All filesystem and child-process operations here are ASYNCHRONOUS. The web +// server runs in-process inside the Electron main event loop (and other hosts), +// so any `spawnSync`/`*Sync` FS call blocks the single event loop — which also +// serves UI asset requests and realtime SSE traffic. The startup reaper can +// iterate several registry entries and, on Windows, each one spawns `tasklist` +// (100-500ms) and possibly `taskkill`; doing that synchronously stalls the +// whole process and is what caused the 1.13.3 `openchamber-ui://` lag +// regression (#1841). `execFile`/`fsp.*` keep the event loop responsive while +// the reaper waits on the kernel. +// // The VS Code extension cannot import this module (it does not bundle the web // package); it carries a parity implementation that reads/writes the SAME dir. -import fs from 'node:fs'; +import fsp from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); const resolveRegistryDir = () => { const override = process.env.OPENCHAMBER_MANAGED_PROCESS_REGISTRY; @@ -48,49 +61,53 @@ const resolveRegistryDir = () => { const entryFilePath = (pid) => path.join(resolveRegistryDir(), `${pid}.json`); -const writeEntryFile = (entry) => { +const writeEntryFile = async (entry) => { const dir = resolveRegistryDir(); try { - fs.mkdirSync(dir, { recursive: true }); + await fsp.mkdir(dir, { recursive: true }); const filePath = path.join(dir, `${entry.pid}.json`); const tmp = `${filePath}.tmp-${process.pid}`; - fs.writeFileSync(tmp, JSON.stringify(entry, null, 2)); - fs.renameSync(tmp, filePath); + await fsp.writeFile(tmp, JSON.stringify(entry, null, 2)); + await fsp.rename(tmp, filePath); } catch { // Best-effort: a failed registry write must never break spawn/shutdown. } }; -const readAllEntries = () => { +const readAllEntries = async () => { const dir = resolveRegistryDir(); let names = []; try { - names = fs.readdirSync(dir).filter((name) => name.endsWith('.json')); + names = await fsp.readdir(dir); } catch { return []; } const out = []; - for (const name of names) { + for (const name of names.filter((value) => value.endsWith('.json'))) { const filePath = path.join(dir, name); try { - const entry = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const entry = JSON.parse(await fsp.readFile(filePath, 'utf8')); if (entry && Number.isInteger(entry.pid)) { out.push({ entry, filePath }); } else { - fs.rmSync(filePath, { force: true }); + await fsp.rm(filePath, { force: true }); } } catch { // Corrupt/partial file — drop it. - try { fs.rmSync(filePath, { force: true }); } catch {} + try { + await fsp.rm(filePath, { force: true }); + } catch { + // ignore + } } } return out; }; /** Record an OpenCode process WE spawned so a future run can reap it if orphaned. */ -export const registerManagedProcess = ({ pid, ownerPid, port, binary, runtime } = {}) => { +export const registerManagedProcess = async ({ pid, ownerPid, port, binary, runtime } = {}) => { if (!Number.isInteger(pid)) return; - writeEntryFile({ + await writeEntryFile({ pid, ownerPid: Number.isInteger(ownerPid) ? ownerPid : process.pid, port: Number.isInteger(port) ? port : null, @@ -101,11 +118,12 @@ export const registerManagedProcess = ({ pid, ownerPid, port, binary, runtime } }; /** Drop a pid from the registry (after we have killed/closed it ourselves). */ -export const unregisterManagedProcess = (pid) => { +export const unregisterManagedProcess = async (pid) => { if (!Number.isInteger(pid)) return; try { - fs.rmSync(entryFilePath(pid), { force: true }); + await fsp.rm(entryFilePath(pid), { force: true }); } catch { + // Best-effort: dropping a missing file is not an error. } }; @@ -123,14 +141,14 @@ const isPidAlive = (pid) => { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Returns { ppid, command } for a live pid on Unix, or null if it can't be read. -const readUnixProcInfo = (pid) => { +const readUnixProcInfo = async (pid) => { try { - const result = spawnSync('ps', ['-p', String(pid), '-o', 'ppid=,command='], { + const { stdout } = await execFileAsync('ps', ['-p', String(pid), '-o', 'ppid=,command='], { encoding: 'utf8', timeout: 3000, windowsHide: true, }); - const line = (result.stdout || '').trim(); + const line = (stdout || '').trim(); if (!line) return null; const match = line.match(/^\s*(\d+)\s+(.*)$/); if (!match) return null; @@ -141,14 +159,14 @@ const readUnixProcInfo = (pid) => { }; // Windows image name for a pid (e.g. "opencode.exe"), or null. -const readWindowsImageName = (pid) => { +const readWindowsImageName = async (pid) => { try { - const result = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { + const { stdout } = await execFileAsync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { encoding: 'utf8', timeout: 3000, windowsHide: true, }); - return (result.stdout || '').trim() || null; + return (stdout || '').trim() || null; } catch { return null; } @@ -167,15 +185,28 @@ const commandIdentifiesOurServer = (command, entry) => { const killOrphan = async (pid) => { if (process.platform === 'win32') { try { - spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', timeout: 5000, windowsHide: true }); + await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', + timeout: 5000, + windowsHide: true, + }); } catch { + // Best-effort: a failed kill is not fatal (startup reaper is a backstop). } return; } const signalTree = (signal) => { - try { process.kill(-pid, signal); } catch {} - try { process.kill(pid, signal); } catch {} + try { + process.kill(-pid, signal); + } catch { + // process group may already be gone + } + try { + process.kill(pid, signal); + } catch { + // pid may already be gone + } }; signalTree('SIGTERM'); @@ -196,7 +227,7 @@ const processEntry = async (entry, { log }) => { const ownerGone = Number.isInteger(entry.ownerPid) && !isPidAlive(entry.ownerPid); if (process.platform === 'win32') { - const image = readWindowsImageName(entry.pid); + const image = await readWindowsImageName(entry.pid); const looksLikeOpencode = typeof image === 'string' && image.toLowerCase().includes('opencode'); // Windows lacks reliable reparent-to-1 semantics (job objects usually kill // children with the parent), so we reap only when the owner is provably dead @@ -209,7 +240,7 @@ const processEntry = async (entry, { log }) => { return false; } - const info = readUnixProcInfo(entry.pid); + const info = await readUnixProcInfo(entry.pid); // Can't verify identity (or it's not our server) → leave it alone. if (!info || !commandIdentifiesOurServer(info.command, entry)) return false; @@ -227,7 +258,7 @@ const processEntry = async (entry, { log }) => { * server. Returns { inspected, reaped }. */ export const reapOrphanedProcesses = async ({ log } = {}) => { - const records = readAllEntries(); + const records = await readAllEntries(); if (records.length === 0) return { inspected: 0, reaped: 0 }; let reaped = 0; @@ -243,7 +274,11 @@ export const reapOrphanedProcesses = async ({ log } = {}) => { log?.(`[lifecycle] reap check failed for pid ${entry.pid}: ${error?.message ?? error}`); } if (drop) { - try { fs.rmSync(filePath, { force: true }); } catch {} + try { + await fsp.rm(filePath, { force: true }); + } catch { + // best-effort + } } } diff --git a/packages/web/server/lib/opencode/managed-process-registry.test.mjs b/packages/web/server/lib/opencode/managed-process-registry.test.mjs new file mode 100644 index 00000000..7f1891c7 --- /dev/null +++ b/packages/web/server/lib/opencode/managed-process-registry.test.mjs @@ -0,0 +1,306 @@ +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mocks must be in place before the module under test is imported, because the +// module calls `promisify(execFile)` at module-load time and binds `fsp.*` at +// call time. +// +// NOTE on `promisify.custom`: the real `child_process.execFile` carries a +// `[util.promisify.custom]` symbol so that `promisify(execFile)` resolves to +// `{ stdout, stderr }` (not the generic multi-arg array). A plain `vi.fn()` +// mock lacks that symbol, so `const { stdout } = await execFileAsync(...)` +// would destructure `undefined`. We attach the symbol to the mock so the +// promisified helper used by the module resolves to the same `{ stdout, +// stderr }` shape. + +const readdirMock = vi.fn(); +const readFileMock = vi.fn(); +const rmMock = vi.fn(); +const mkdirMock = vi.fn(); +const writeFileMock = vi.fn(); +const renameMock = vi.fn(); + +vi.mock('node:fs/promises', () => ({ + default: { + readdir: readdirMock, + readFile: readFileMock, + rm: rmMock, + mkdir: mkdirMock, + writeFile: writeFileMock, + rename: renameMock, + }, +})); + +// `execFileImpl` is the swappable per-test implementation; `execFileMock` is +// what the mocked module sees. `promisify(execFileMock)` returns the custom +// function, which delegates to `execFileImpl` with a (err, stdout, stderr) +// callback and resolves to `{ stdout, stderr }`. +const execFileImpl = vi.fn(); +const execFileMock = vi.fn(); +execFileMock[promisify.custom] = (cmd, args, opts) => + new Promise((resolve, reject) => { + execFileImpl(cmd, args, opts, (err, stdout, stderr) => + err ? reject(err) : resolve({ stdout: stdout ?? '', stderr: stderr ?? '' })); + }); + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, +})); + +const { + registerManagedProcess, + unregisterManagedProcess, + reapOrphanedProcesses, +} = await import('./managed-process-registry.js'); + +const ORIGINAL_PLATFORM = Object.getOwnPropertyDescriptor(process, 'platform'); +const ORIGINAL_KILL = process.kill; +const killMock = vi.fn(); + +const setPlatform = (platform) => { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); +}; + +const restorePlatform = () => { + if (ORIGINAL_PLATFORM) { + Object.defineProperty(process, 'platform', ORIGINAL_PLATFORM); + } +}; + +const installKillMock = () => { + Object.defineProperty(process, 'kill', { value: killMock, configurable: true }); +}; + +const restoreKill = () => { + Object.defineProperty(process, 'kill', { value: ORIGINAL_KILL, configurable: true }); +}; + +// Helper to make given pids look alive on signal-0 (returns true); any other +// pid throws ESRCH (dead). Non-zero signals always "succeed" so `killOrphan`'s +// signalTree is inert under test. +const killAliveFor = (alivePids) => + killMock.mockImplementation((pid, signal) => { + if (signal === 0 || signal === undefined) { + if (alivePids.includes(pid)) return true; + const error = new Error('ESRCH'); + error.code = 'ESRCH'; + throw error; + } + return true; + }); + +// Configure `execFileImpl` with a (cmd, args, opts, cb) dispatcher. +const execFileYields = (dispatch) => + execFileImpl.mockImplementation((cmd, args, opts, cb) => dispatch(cmd, args, opts, cb)); + +beforeEach(() => { + readdirMock.mockReset(); + readFileMock.mockReset(); + rmMock.mockReset(); + mkdirMock.mockReset(); + writeFileMock.mockReset(); + renameMock.mockReset(); + execFileImpl.mockReset(); + killMock.mockReset(); + installKillMock(); +}); + +afterEach(() => { + restoreKill(); + restorePlatform(); +}); + +describe('reapOrphanedProcesses', () => { + it('returns zero counts when the registry directory is missing', async () => { + readdirMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + + const result = await reapOrphanedProcesses(); + + expect(result).toEqual({ inspected: 0, reaped: 0 }); + expect(execFileImpl).not.toHaveBeenCalled(); + }); + + it('drops registry entries whose pid is already dead, without spawning anything', async () => { + readdirMock.mockResolvedValue(['99999.json']); + readFileMock.mockResolvedValue( + JSON.stringify({ pid: 99999, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'web' }), + ); + killMock.mockImplementation(() => { + const error = new Error('ESRCH'); + error.code = 'ESRCH'; + throw error; + }); + rmMock.mockResolvedValue(); + + const result = await reapOrphanedProcesses(); + + expect(result).toEqual({ inspected: 1, reaped: 0 }); + expect(rmMock).toHaveBeenCalledTimes(1); + expect(execFileImpl).not.toHaveBeenCalled(); + }); + + describe('on Windows', () => { + beforeEach(() => setPlatform('win32')); + + it('reaps an opencode image whose owner is gone', async () => { + readdirMock.mockResolvedValue(['777.json']); + readFileMock.mockResolvedValue( + JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: 'opencode.exe', runtime: 'desktop' }), + ); + // pid 777 alive, owner 12345 dead. + killAliveFor([777]); + execFileYields((cmd, _args, _opts, cb) => { + if (cmd === 'tasklist') return cb(null, 'opencode.exe', ''); + if (cmd === 'taskkill') return cb(null, '', ''); + cb(new Error(`unexpected cmd: ${cmd}`)); + }); + rmMock.mockResolvedValue(); + + const result = await reapOrphanedProcesses({ log: () => {} }); + + expect(result).toEqual({ inspected: 1, reaped: 1 }); + expect(execFileImpl).toHaveBeenCalledWith( + 'tasklist', + expect.any(Array), + expect.objectContaining({ windowsHide: true }), + expect.any(Function), + ); + expect(execFileImpl).toHaveBeenCalledWith( + 'taskkill', + expect.any(Array), + expect.objectContaining({ windowsHide: true }), + expect.any(Function), + ); + }); + + it('leaves a non-opencode image alone even if the owner is gone', async () => { + readdirMock.mockResolvedValue(['777.json']); + readFileMock.mockResolvedValue( + JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: 'opencode.exe', runtime: 'desktop' }), + ); + killAliveFor([777]); + execFileYields((_cmd, _args, _opts, cb) => cb(null, 'notepad.exe', '')); + rmMock.mockResolvedValue(); + + const result = await reapOrphanedProcesses({ log: () => {} }); + + expect(result).toEqual({ inspected: 1, reaped: 0 }); + const calls = execFileImpl.mock.calls.filter(([cmd]) => cmd === 'taskkill'); + expect(calls).toHaveLength(0); + }); + + it('leaves an opencode image whose owner is still alive', async () => { + readdirMock.mockResolvedValue(['777.json']); + readFileMock.mockResolvedValue( + JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: 'opencode.exe', runtime: 'desktop' }), + ); + // Both alive. + killAliveFor([777, 12345]); + execFileYields((_cmd, _args, _opts, cb) => cb(null, 'opencode.exe', '')); + + const result = await reapOrphanedProcesses({ log: () => {} }); + + expect(result).toEqual({ inspected: 1, reaped: 0 }); + const calls = execFileImpl.mock.calls.filter(([cmd]) => cmd === 'taskkill'); + expect(calls).toHaveLength(0); + }); + }); + + describe('on Unix', () => { + beforeEach(() => setPlatform('linux')); + + it('reaps a reparented opencode serve matching the recorded port', async () => { + readdirMock.mockResolvedValue(['777.json']); + readFileMock.mockResolvedValue( + JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'web' }), + ); + // pid 777 stays "alive"; killOrphan's signalTree is inert (mock returns + // true for non-zero signals), and its wait loop sees isPidAlive true so + // it exhausts the SIGTERM wait then sends SIGKILL and sleeps 300ms. + killAliveFor([777]); + execFileYields((cmd, _args, _opts, cb) => { + if (cmd === 'ps') return cb(null, '1 /usr/bin/opencode serve --port 4096\n', ''); + cb(new Error(`unexpected cmd: ${cmd}`)); + }); + rmMock.mockResolvedValue(); + + const result = await reapOrphanedProcesses({ log: () => {} }); + + expect(result).toEqual({ inspected: 1, reaped: 1 }); + }); + + it('leaves a process whose command is not our opencode serve', async () => { + readdirMock.mockResolvedValue(['777.json']); + readFileMock.mockResolvedValue( + JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'web' }), + ); + killAliveFor([777]); + execFileYields((cmd, _args, _opts, cb) => { + if (cmd === 'ps') return cb(null, '1 /some/other/binary serve\n', ''); + cb(new Error(`unexpected cmd: ${cmd}`)); + }); + + const result = await reapOrphanedProcesses({ log: () => {} }); + + expect(result).toEqual({ inspected: 1, reaped: 0 }); + }); + + it('leaves a process still owned by a live owner (not reparented)', async () => { + readdirMock.mockResolvedValue(['777.json']); + readFileMock.mockResolvedValue( + JSON.stringify({ pid: 777, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'web' }), + ); + killAliveFor([777, 12345]); + execFileYields((cmd, _args, _opts, cb) => { + if (cmd === 'ps') return cb(null, '12345 /usr/bin/opencode serve --port 4096\n', ''); + cb(new Error(`unexpected cmd: ${cmd}`)); + }); + + const result = await reapOrphanedProcesses({ log: () => {} }); + + expect(result).toEqual({ inspected: 1, reaped: 0 }); + }); + }); +}); + +describe('registerManagedProcess', () => { + it('writes an entry file atomically via tmp + rename', async () => { + mkdirMock.mockResolvedValue(); + writeFileMock.mockResolvedValue(); + renameMock.mockResolvedValue(); + + await registerManagedProcess({ pid: 4242, ownerPid: 12345, port: 4096, binary: '/opencode', runtime: 'desktop' }); + + expect(mkdirMock).toHaveBeenCalledWith(expect.any(String), { recursive: true }); + expect(writeFileMock).toHaveBeenCalledWith( + expect.stringContaining('4242.json.tmp-'), + expect.any(String), + ); + expect(renameMock).toHaveBeenCalledWith( + expect.stringContaining('4242.json.tmp-'), + expect.stringContaining('4242.json'), + ); + }); + + it('is a no-op for a non-integer pid', async () => { + await registerManagedProcess({ pid: 'not-a-pid' }); + + expect(writeFileMock).not.toHaveBeenCalled(); + }); +}); + +describe('unregisterManagedProcess', () => { + it('removes the entry file', async () => { + rmMock.mockResolvedValue(); + + await unregisterManagedProcess(4242); + + expect(rmMock).toHaveBeenCalledWith(expect.stringContaining('4242.json'), { force: true }); + }); + + it('is a no-op for a non-integer pid', async () => { + await unregisterManagedProcess(undefined); + + expect(rmMock).not.toHaveBeenCalled(); + }); +}); From 94e4bce990c888c401725dabaa558b9825564927 Mon Sep 17 00:00:00 2001 From: "PC2\\micha" Date: Sun, 28 Jun 2026 20:07:39 +0800 Subject: [PATCH 013/581] fix: kill process tree on Windows via taskkill before SIGTERM fallback On Windows, child.kill('SIGTERM') only terminates the cmd.exe wrapper, leaving the inner opencode.exe serve running as an orphan. This adds killProcessTree() which runs taskkill /PID /T /F first, then falls back to child.kill('SIGTERM') for the close() method. Fixes #1889 --- packages/vscode/src/opencode.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index 540a9aba..95f7f239 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -133,6 +133,19 @@ function shouldUseWindowsShell(binary: string): boolean { return !ext && !trimmed.includes('\\') && !trimmed.includes('/'); } +function killProcessTree(pid: number | undefined): void { + if (!Number.isInteger(pid)) return; + if (process.platform === 'win32') { + try { + spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', timeout: 5000, windowsHide: true, + }); + } catch { + // ignore + } + } +} + function appendToPath(dir: string) { const trimmed = (dir || '').trim(); if (!trimmed) return; @@ -695,6 +708,7 @@ async function spawnManagedOpenCodeServer( return { url, close: () => { + killProcessTree(child.pid); try { child.kill('SIGTERM'); } catch { From 224a948693febbf977ac87878d27d0c3d2a4e6ae Mon Sep 17 00:00:00 2001 From: Divyam <47589864+divyam234@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:41:07 +0530 Subject: [PATCH 014/581] fix: load symlinked custom themes --- .../web/server/lib/opencode/theme-runtime.js | 2 +- .../server/lib/opencode/theme-runtime.test.js | 129 ++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 packages/web/server/lib/opencode/theme-runtime.test.js diff --git a/packages/web/server/lib/opencode/theme-runtime.js b/packages/web/server/lib/opencode/theme-runtime.js index df2639e3..2655f826 100644 --- a/packages/web/server/lib/opencode/theme-runtime.js +++ b/packages/web/server/lib/opencode/theme-runtime.js @@ -116,7 +116,7 @@ export const createThemeRuntime = (dependencies) => { const seen = new Set(); for (const entry of entries) { - if (!entry.isFile()) continue; + if (!entry.isFile() && !entry.isSymbolicLink()) continue; if (!entry.name.toLowerCase().endsWith('.json')) continue; const filePath = path.join(themesDir, entry.name); diff --git a/packages/web/server/lib/opencode/theme-runtime.test.js b/packages/web/server/lib/opencode/theme-runtime.test.js new file mode 100644 index 00000000..38316a14 --- /dev/null +++ b/packages/web/server/lib/opencode/theme-runtime.test.js @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { createThemeRuntime } from './theme-runtime.js'; + +const validTheme = (id = 'custom-theme') => ({ + metadata: { + id, + name: 'Custom Theme', + variant: 'dark', + }, + colors: { + primary: { + base: '#ffffff', + foreground: '#000000', + }, + surface: { + background: '#000000', + foreground: '#ffffff', + muted: '#111111', + mutedForeground: '#eeeeee', + elevated: '#222222', + elevatedForeground: '#dddddd', + subtle: '#333333', + }, + interactive: { + border: '#444444', + selection: '#555555', + selectionForeground: '#ffffff', + focusRing: '#666666', + hover: '#777777', + }, + status: { + error: '#ff0000', + errorForeground: '#ffffff', + errorBackground: '#330000', + errorBorder: '#660000', + warning: '#ffaa00', + warningForeground: '#000000', + warningBackground: '#332200', + warningBorder: '#664400', + success: '#00ff00', + successForeground: '#000000', + successBackground: '#003300', + successBorder: '#006600', + info: '#0000ff', + infoForeground: '#ffffff', + infoBackground: '#000033', + infoBorder: '#000066', + }, + syntax: { + base: { + background: '#000000', + foreground: '#ffffff', + keyword: '#ff00ff', + string: '#00ff00', + number: '#ffaa00', + function: '#00ffff', + variable: '#ffffff', + type: '#ffff00', + comment: '#888888', + operator: '#ffffff', + }, + highlights: { + diffAdded: '#003300', + diffRemoved: '#330000', + lineNumber: '#888888', + }, + }, + }, +}); + +const fileEntry = (name, type = 'file') => ({ + name, + isFile: () => type === 'file', + isDirectory: () => type === 'directory', + isSymbolicLink: () => type === 'symlink', +}); + +const createTestRuntime = ({ entries, files, stats }) => createThemeRuntime({ + fsPromises: { + readdir: async () => entries, + stat: async (filePath) => stats[filePath], + readFile: async (filePath) => files[filePath], + }, + path: { join: (...parts) => parts.join('/') }, + themesDir: '/themes', + maxThemeJsonBytes: 512 * 1024, + logger: { warn: () => {} }, +}); + +describe('theme runtime', () => { + describe('readCustomThemesFromDisk', () => { + it('loads valid theme files', async () => { + const runtime = createTestRuntime({ + entries: [fileEntry('direct.json')], + files: { '/themes/direct.json': JSON.stringify(validTheme('direct-theme')) }, + stats: { '/themes/direct.json': { isFile: () => true, size: 1024 } }, + }); + + const themes = await runtime.readCustomThemesFromDisk(); + + expect(themes.map((theme) => theme.metadata.id)).toEqual(['direct-theme']); + }); + + it('loads JSON themes whose directory entry is a symbolic link', async () => { + const runtime = createTestRuntime({ + entries: [fileEntry('linked.json', 'symlink')], + files: { '/themes/linked.json': JSON.stringify(validTheme('linked-theme')) }, + stats: { '/themes/linked.json': { isFile: () => true, size: 1024 } }, + }); + + const themes = await runtime.readCustomThemesFromDisk(); + + expect(themes.map((theme) => theme.metadata.id)).toEqual(['linked-theme']); + }); + + it('skips JSON directories after stat resolution', async () => { + const runtime = createTestRuntime({ + entries: [fileEntry('directory.json', 'directory')], + files: { '/themes/directory.json': JSON.stringify(validTheme('directory-theme')) }, + stats: { '/themes/directory.json': { isFile: () => false, size: 1024 } }, + }); + + const themes = await runtime.readCustomThemesFromDisk(); + + expect(themes).toEqual([]); + }); + }); +}); From 72a24c388f6dbb6eb4ccc93619edf91b4dcf9c88 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Tue, 30 Jun 2026 23:26:51 -0400 Subject: [PATCH 015/581] fix(pwa): focus existing window on notification click The service worker's notificationclick handler called self.clients.openWindow(url) unconditionally, spawning a new window/PWA instance on every notification click even when one was already open. Focus an existing window client and navigate it to the (relative) deep-link, resolved against self.location.origin, falling back to openWindow only when no window is available. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/web/src/sw.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/web/src/sw.ts b/packages/web/src/sw.ts index 7ed2a8cc..3583d41b 100644 --- a/packages/web/src/sw.ts +++ b/packages/web/src/sw.ts @@ -67,5 +67,30 @@ self.addEventListener('notificationclick', (event) => { const data = (event.notification.data ?? null) as { url?: string } | null; const url = data?.url ?? '/'; - event.waitUntil(self.clients.openWindow(url)); + event.waitUntil((async () => { + // Prefer focusing an already-open window (e.g. the installed PWA) and + // navigating it to the target, instead of always spawning a new window. + const target = new URL(url, self.location.origin).href; + const windowClients = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true, + }); + + for (const client of windowClients) { + try { + if ('navigate' in client) { + await client.navigate(target); + } + } catch { + // navigate() can reject for uncontrolled clients; fall back to focus. + } + if ('focus' in client) { + return client.focus(); + } + } + + if (self.clients.openWindow) { + return self.clients.openWindow(target); + } + })()); }); From adb6ca2b08686fe043a325643c71cec38845a6ab Mon Sep 17 00:00:00 2001 From: Tang <1024830255@qq.com> Date: Sat, 4 Jul 2026 10:32:19 +0800 Subject: [PATCH 016/581] fix(cli): externalize Windows startup PowerShell to .ps1 wrapper (schtasks /TR 261-char limit) The inline PowerShell env-parsing script exceeded the Task Scheduler /TR 261-char limit, causing startup enable to fail on Windows. Extract the script into a .ps1 wrapper file and reduce /TR to a short powershell.exe -File command (~115 chars). Mirrors the macOS writeMacosStartupWrapper pattern. Adds regression tests pinning /TR < 200 (default) and < 261 (worst-case). Apply fix to refactored lib/cli-startup.js (was cli.js before refactor). --- packages/web/bin/cli.test.js | 35 ++++++++++++++++++++++++++ packages/web/bin/lib/cli-startup.js | 38 ++++++++++++++++++++++------- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/packages/web/bin/cli.test.js b/packages/web/bin/cli.test.js index 9faa2100..ff7e291d 100644 --- a/packages/web/bin/cli.test.js +++ b/packages/web/bin/cli.test.js @@ -30,6 +30,7 @@ import { parseArgs, resolveServeHost, } from './cli.js'; +import { buildWindowsStartupTaskCommand } from './lib/cli-startup.js'; async function withTempOpenChamberDataDir(fn) { const previous = process.env.OPENCHAMBER_DATA_DIR; @@ -884,3 +885,37 @@ describe('lifecycle commands with unmanaged explicit ports', () => { }); }); }); + +describe('Windows startup task command builder', () => { + it('default-path length stays under 200 chars', () => { + const cmd = buildWindowsStartupTaskCommand( + 'C:\\Users\\test\\.config\\openchamber\\bin\\OpenChamber.ps1' + ); + expect(cmd).toMatch(/^powershell\.exe -NoProfile -ExecutionPolicy Bypass -File /); + expect(cmd.length).toBeLessThan(200); + }); + + it('worst-case long path stays under 261-char Task Scheduler ceiling', () => { + // Build a wrapper path >= 180 chars (simulates long OPENCHAMBER_DATA_DIR) + // Overhead = 57 chars (prefix + closing quote), so max wrapper for <261 total is 203 + const longPath = + 'C:\\Users\\' + + 'a'.repeat(139) + + '\\.config\\openchamber\\bin\\OpenChamber.ps1'; + expect(longPath.length).toBeGreaterThanOrEqual(180); + + const cmd = buildWindowsStartupTaskCommand(longPath); + expect(cmd.length).toBeLessThan(261); + }); + + it('does NOT inline SetEnvironmentVariable (externalization invariant)', () => { + const cmd = buildWindowsStartupTaskCommand('C:\\wrapper.ps1'); + expect(cmd).not.toContain('SetEnvironmentVariable'); + }); + + it('uses -File form, not -Command', () => { + const cmd = buildWindowsStartupTaskCommand('C:\\wrapper.ps1'); + expect(cmd).toContain('-File '); + expect(cmd).not.toContain('-Command '); + }); +}); diff --git a/packages/web/bin/lib/cli-startup.js b/packages/web/bin/lib/cli-startup.js index 8a2df874..a7a5f929 100644 --- a/packages/web/bin/lib/cli-startup.js +++ b/packages/web/bin/lib/cli-startup.js @@ -74,6 +74,10 @@ function getMacosStartupWrapperPath() { return path.join(getDataDir(), 'bin', 'OpenChamber'); } +function getWindowsStartupWrapperPath() { + return path.join(getDataDir(), 'bin', 'OpenChamber.ps1'); +} + function collectStartupEnv(options = {}) { const env = options.envSnapshot === false ? {} : Object.fromEntries( Object.entries(process.env) @@ -189,6 +193,24 @@ exec ${startupShellQuote(process.execPath)} ${args} return wrapperPath; } +function writeWindowsStartupWrapper(options = {}) { + const wrapperPath = getWindowsStartupWrapperPath(); + const envFilePath = getStartupEnvFilePath(); + const startupArgs = buildStartupArgs(options).map(powershellQuote).join(' '); + const ps1Content = [ + `$envFile=${powershellQuote(envFilePath)}`, + `if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`, + `& ${powershellQuote(process.execPath)} ${startupArgs}`, + ].join('; '); + fs.mkdirSync(path.dirname(wrapperPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync(wrapperPath, ps1Content, { mode: 0o700 }); + return wrapperPath; +} + +function buildWindowsStartupTaskCommand(wrapperPath) { + return `powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${wrapperPath}"`; +} + function buildMacosLaunchAgent(options = {}) { const wrapperPath = writeMacosStartupWrapper(options); const args = [wrapperPath]; @@ -318,21 +340,16 @@ function enableStartupService(options = {}) { return getStartupStatus(); } - const envFilePath = writeStartupEnvFile(options); - const startupArgs = buildStartupArgs(options).map(powershellQuote).join(', '); - const powerShellCommand = [ - `$envFile=${powershellQuote(envFilePath)}`, - `if (Test-Path $envFile) { Get-Content $envFile | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { $v=$matches[2]; if ($v.StartsWith("'") -and $v.EndsWith("'")) { $v=$v.Substring(1,$v.Length-2).Replace("'\\''","'") }; [Environment]::SetEnvironmentVariable($matches[1], $v, 'Process') } } }`, - `& ${powershellQuote(process.execPath)} ${startupArgs}`, - ].join('; '); - const taskArgs = `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "${powerShellCommand.replace(/"/g, '\\"')}"`; + writeStartupEnvFile(options); + const wrapperPath = writeWindowsStartupWrapper(options); + const taskCommand = buildWindowsStartupTaskCommand(wrapperPath); runStartupCommand('schtasks.exe', [ '/Create', '/TN', STARTUP_SERVICE_ID, '/SC', 'ONLOGON', '/RL', 'LIMITED', '/F', - '/TR', taskArgs, + '/TR', taskCommand, ]); runStartupCommand('schtasks.exe', ['/Run', '/TN', STARTUP_SERVICE_ID], { allowFailure: true }); return getStartupStatus(); @@ -359,6 +376,8 @@ function disableStartupService() { runStartupCommand('schtasks.exe', ['/End', '/TN', STARTUP_SERVICE_ID], { allowFailure: true }); runStartupCommand('schtasks.exe', ['/Delete', '/TN', STARTUP_SERVICE_ID, '/F'], { allowFailure: true }); + try { fs.unlinkSync(getWindowsStartupWrapperPath()); } catch {} + removeStartupEnvFile(); return getStartupStatus(); } @@ -367,4 +386,5 @@ export { getStartupStatus, enableStartupService, disableStartupService, + buildWindowsStartupTaskCommand, }; From 83d4bc7b59c015e672eac63c2e0f3a145cb461c1 Mon Sep 17 00:00:00 2001 From: herjarsa Date: Thu, 9 Jul 2026 21:55:57 +0200 Subject: [PATCH 017/581] fix(chat): save previous-session anchor in microtask with bail check When switching sessions, the previous session's viewport anchor save was deferred via setTimeout(..., 0). This races with the new session's restoreSnapshot effect: the timer can fire after React has flushed the new session's render and before the restore effect runs, leaving the saved anchor and the restored scroll position fighting over the same viewport store entry. The save reads messages (can be expensive) on the same tick as the new session's skeleton render. Replace setTimeout(..., 0) with queueMicrotask() so the save runs immediately after the current synchronous call stack and before the next macrotask / paint. This guarantees the save completes before the new session's restoreSnapshot effect fires. Add a bail check: if the user switched sessions again between the microtask scheduling and execution (rapid switching), the save is now stale. Comparing the captured newId to the current currentSessionId at microtask runtime avoids clobbering the in-flight session's anchor with data from a session that is no longer "previous". This is the queueMicrotask + bail change acknowledged as 'great' in the review of #1675, extracted as a focused single-file PR. --- packages/ui/src/sync/session-ui-store.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 397fb484..154ae984 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -617,7 +617,16 @@ export const useSessionUIStore = create()((set, get) => ({ // skeleton to render and reads messages which can be expensive. if (previousSessionId && previousSessionId !== id) { const prevId = previousSessionId - setTimeout(() => { + const newId = id + // queueMicrotask runs after the current synchronous call stack (and + // before the next macrotask / setTimeout(0) / paint), so the previous + // session's anchor is saved before the new session's restoreSnapshot + // effect fires. This eliminates the race where save and restore + // interleave against the same viewport store entry. + queueMicrotask(() => { + // Bail if the user already switched again — save is now stale. + const current = get().currentSessionId + if (current !== newId) return const memState = getViewportSessionMemory(prevId) if (!memState?.isStreaming) { const prevMessages = getSyncMessages(prevId) @@ -625,7 +634,7 @@ export const useSessionUIStore = create()((set, get) => ({ useViewportStore.getState().updateViewportAnchor(prevId, prevMessages.length - 1) } } - }, 0) + }); } // Mark session viewed in notification store + update active session ref From 128122fdd92bbd02d4eb26fb07648d1a2caa3365 Mon Sep 17 00:00:00 2001 From: Greg Haynes Date: Sun, 12 Jul 2026 19:54:35 -0700 Subject: [PATCH 018/581] fix(web): shorten PWA install app name --- packages/web/index.html | 2 +- packages/web/public/site.webmanifest | 2 +- packages/web/server/lib/opencode/pwa-manifest-routes.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/web/index.html b/packages/web/index.html index 2434decd..9ee0b466 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -24,7 +24,7 @@ - + diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 272f775a..60877c12 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -157,6 +157,28 @@ export default defineConfig({ const segments = match.split('/'); const packageName = match.startsWith('@') ? `${segments[0]}/${segments[1]}` : segments[0]; + // Shiki grammars/themes and CodeMirror legacy modes are dynamically + // imported one at a time by their registries. Forcing them into a + // single vendor chunk makes the first language request download every + // grammar (7.4 MB raw for @shikijs/langs). Let Rollup split them per + // dynamically imported module so only used languages are fetched — + // the worker build already behaves this way. + if ( + packageName === '@shikijs/langs' || + packageName === '@shikijs/themes' || + packageName === '@codemirror/legacy-modes' + ) { + return undefined; + } + + // Split @pierre/diffs by usage as well: the eager tool renderer needs + // only its pure patch parser, while the Shiki-importing render stack + // must stay loadable on demand. One merged vendor chunk would make + // the parser import download the whole stack eagerly. + if (packageName === '@pierre/diffs') { + return undefined; + } + if (packageName === 'react' || packageName === 'react-dom') return 'vendor-react'; if (packageName === 'zustand' || packageName === 'zustand/middleware') return 'vendor-zustand'; diff --git a/vite.config.ts b/vite.config.ts index ae9721f4..bcaf5d27 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -55,6 +55,28 @@ export default defineConfig({ const segments = match.split('/') const packageName = match.startsWith('@') ? `${segments[0]}/${segments[1]}` : segments[0] + // Shiki grammars/themes and CodeMirror legacy modes are dynamically + // imported one at a time by their registries. Forcing them into a + // single vendor chunk makes the first language request download every + // grammar (7.4 MB raw for @shikijs/langs). Let Rollup split them per + // dynamically imported module so only used languages are fetched — + // the worker build already behaves this way. + if ( + packageName === '@shikijs/langs' || + packageName === '@shikijs/themes' || + packageName === '@codemirror/legacy-modes' + ) { + return undefined + } + + // Split @pierre/diffs by usage as well: the eager tool renderer needs + // only its pure patch parser, while the Shiki-importing render stack + // must stay loadable on demand. One merged vendor chunk would make + // the parser import download the whole stack eagerly. + if (packageName === '@pierre/diffs') { + return undefined + } + if (packageName === 'react' || packageName === 'react-dom') return 'vendor-react' if (packageName === 'zustand' || packageName === 'zustand/middleware') return 'vendor-zustand' if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk' From 3fc136c95e7fd3ac27d5b39653363bc7eaea3fa1 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Fri, 7 Aug 2026 09:09:32 +0300 Subject: [PATCH 074/581] fix(terminal): keep default terminal tab names unique after closing tabs (#2718) (#2731) --- .../ui/src/stores/useTerminalStore.test.ts | 35 +++++++++++++++++++ packages/ui/src/stores/useTerminalStore.ts | 24 +++++++++++-- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/stores/useTerminalStore.test.ts b/packages/ui/src/stores/useTerminalStore.test.ts index 773f1152..c60f0ffd 100644 --- a/packages/ui/src/stores/useTerminalStore.test.ts +++ b/packages/ui/src/stores/useTerminalStore.test.ts @@ -123,3 +123,38 @@ describe('terminal state reconciliation', () => { expect(useTerminalStore.getState().buffers.size).toBe(0); }); }); + +describe('default terminal tab labels', () => { + afterEach(() => useTerminalStore.getState().clearAll()); + + const labels = () => + useTerminalStore.getState().getDirectoryState('/repo')!.tabs.map((tab) => tab.label); + + // Regression for https://github.com/openchamber/openchamber/issues/2718 + test('does not reuse the number of a closed tab', () => { + const first = setup(); + useTerminalStore.getState().createTab('/repo'); + expect(labels()).toEqual(['Terminal', 'Terminal 2']); + + useTerminalStore.getState().closeTab('/repo', first); + useTerminalStore.getState().createTab('/repo'); + + expect(labels()).toEqual(['Terminal 2', 'Terminal 3']); + }); + + test('numbers past a user-renamed "Terminal N" label instead of duplicating it', () => { + const first = setup(); + useTerminalStore.getState().setTabLabel('/repo', first, 'Terminal 5'); + useTerminalStore.getState().createTab('/repo'); + + expect(labels()).toEqual(['Terminal 5', 'Terminal 6']); + }); + + test('ignores custom labels and starts over at "Terminal" when no default-labeled tabs remain', () => { + const first = setup(); + useTerminalStore.getState().setTabLabel('/repo', first, 'build'); + useTerminalStore.getState().createTab('/repo'); + + expect(labels()).toEqual(['build', 'Terminal']); + }); +}); diff --git a/packages/ui/src/stores/useTerminalStore.ts b/packages/ui/src/stores/useTerminalStore.ts index 6b68d32a..485717ff 100644 --- a/packages/ui/src/stores/useTerminalStore.ts +++ b/packages/ui/src/stores/useTerminalStore.ts @@ -157,6 +157,27 @@ function normalizeDirectory(dir: string): string { return normalized; } +const DEFAULT_TAB_LABEL_PATTERN = /^Terminal(?: (\d+))?$/; + +/** + * Default labels must stay unique among the directory's open tabs even after + * closes (#2718), so number from the highest existing "Terminal N" suffix + * instead of the live tab count. Labels are persisted with the tabs, so the + * derivation also survives reloads without a dedicated counter. User-renamed + * labels only participate when they match the default pattern; they are never + * rewritten. + */ +const nextDefaultTabLabel = (tabs: readonly TerminalTab[]): string => { + let highest = 0; + for (const tab of tabs) { + const match = DEFAULT_TAB_LABEL_PATTERN.exec(tab.label); + if (!match) continue; + const value = match[1] ? Number.parseInt(match[1], 10) : 1; + if (Number.isSafeInteger(value)) highest = Math.max(highest, value); + } + return highest === 0 ? 'Terminal' : `Terminal ${highest + 1}`; +}; + const createEmptyTab = (id: string, label: string): TerminalTab => ({ id, terminalSessionId: null, @@ -295,8 +316,7 @@ export const useTerminalStore = create()( const existing = newSessions.get(key); const nextTabId = state.nextTabId + 1; - const labelIndex = (existing?.tabs.length ?? 0) + 1; - const label = `Terminal ${labelIndex}`; + const label = nextDefaultTabLabel(existing?.tabs ?? []); const tab = createEmptyTab(tabId, label); if (!existing) { From 87dbc59bf18016d094c5ad9861c67044831444b3 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Fri, 7 Aug 2026 09:16:34 +0300 Subject: [PATCH 075/581] fix(chat): do not replay entry animations for already-seen fresh messages (#2124) (#2732) --- packages/ui/src/lib/messageFreshness.test.ts | 85 ++++++++++++++++++++ packages/ui/src/lib/messageFreshness.ts | 11 ++- 2 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/lib/messageFreshness.test.ts diff --git a/packages/ui/src/lib/messageFreshness.test.ts b/packages/ui/src/lib/messageFreshness.test.ts new file mode 100644 index 00000000..bd59bffd --- /dev/null +++ b/packages/ui/src/lib/messageFreshness.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; + +import { MessageFreshnessDetector } from './messageFreshness'; + +import type { Message } from '@opencode-ai/sdk/v2'; + +const makeAssistantMessage = (id: string, created: number): Message => + ({ + id, + role: 'assistant', + sessionID: 'session-a', + time: { created }, + }) as unknown as Message; + +describe('MessageFreshnessDetector.shouldAnimateMessage', () => { + let detector: MessageFreshnessDetector; + + beforeEach(() => { + detector = MessageFreshnessDetector.getInstance(); + detector.clearAll(); + }); + + test('fresh message animates once and is recorded as seen', () => { + detector.recordSessionStart('session-a'); + const message = makeAssistantMessage('msg-fresh', Date.now()); + + expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(true); + expect(detector.hasBeenAnimated('msg-fresh')).toBe(true); + }); + + test('regression #2124: fresh message does not re-animate when returning to the session', () => { + detector.recordSessionStart('session-a'); + const message = makeAssistantMessage('msg-fresh', Date.now()); + + // First visit: the message is fresh and animates. + expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(true); + + // User switches away and back; ChatViewport remounts and re-evaluates + // before recordSessionStart runs again, so the old session start time + // is still in effect. The message must not animate a second time. + expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(false); + }); + + test('stale history message never animates and is recorded as seen', () => { + detector.recordSessionStart('session-a'); + const message = makeAssistantMessage('msg-old', Date.now() - 60_000); + + expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(false); + expect(detector.hasBeenAnimated('msg-old')).toBe(true); + expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(false); + }); + + test('message evaluated without a recorded session start does not animate and is recorded', () => { + const message = makeAssistantMessage('msg-no-session', Date.now()); + + expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(false); + expect(detector.hasBeenAnimated('msg-no-session')).toBe(true); + + // Recording the session start afterwards must not resurrect the animation. + detector.recordSessionStart('session-a'); + expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(false); + }); + + test('non-assistant messages never animate', () => { + detector.recordSessionStart('session-a'); + const message = { + id: 'msg-user', + role: 'user', + sessionID: 'session-a', + time: { created: Date.now() }, + } as unknown as Message; + + expect(detector.shouldAnimateMessage(message, 'session-a')).toBe(false); + }); + + test('a new fresh message still animates after older fresh messages were seen', () => { + detector.recordSessionStart('session-a'); + const first = makeAssistantMessage('msg-first', Date.now()); + const second = makeAssistantMessage('msg-second', Date.now()); + + expect(detector.shouldAnimateMessage(first, 'session-a')).toBe(true); + expect(detector.shouldAnimateMessage(second, 'session-a')).toBe(true); + expect(detector.shouldAnimateMessage(second, 'session-a')).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/messageFreshness.ts b/packages/ui/src/lib/messageFreshness.ts index e32c5843..230a747c 100644 --- a/packages/ui/src/lib/messageFreshness.ts +++ b/packages/ui/src/lib/messageFreshness.ts @@ -44,10 +44,13 @@ export class MessageFreshnessDetector { const isFresh = message.time.created > (sessionStartTime - 5000); - if (!isFresh) { - this.seenMessageIds.add(message.id); - this.messageCreationTimes.set(message.id, message.time.created); - } + // Record fresh messages too so they animate at most once per detector + // lifetime. The detector is a module singleton that outlives ChatViewport + // remounts; without this, switching away and back re-evaluates the same + // message against the stale session start time (recordSessionStart runs + // in an effect after the first render) and replays the entry animation. + this.seenMessageIds.add(message.id); + this.messageCreationTimes.set(message.id, message.time.created); return isFresh; } From 10606d79d31b2f19cecd62495cd1d94077b60644 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Fri, 7 Aug 2026 09:52:48 +0300 Subject: [PATCH 076/581] fix(git): enable core.longpaths for worktree population (#2746) (#2747) * fix(git): enable core.longpaths for worktree population Worktrees live under a deep OpenCode data-dir path, so Windows checkouts of deeply nested repos failed bootstrap with "Filename too long". Enable Git core.longpaths before git reset --hard (web + VS Code) and surface clearer path-length guidance when the filesystem still rejects a path. Fixes #2746 Co-authored-by: Serhii Dziupin * chore(vscode): keep ensureWorktreeLongpaths private Avoid an unused export in the VS Code git service; the helper stays local to populateWorktreeWithLockRecovery. Co-authored-by: Serhii Dziupin --------- Co-authored-by: Cursor Agent Co-authored-by: Serhii Dziupin --- packages/vscode/src/DOCUMENTATION.md | 1 + packages/vscode/src/gitService.ts | 54 ++++- packages/web/server/lib/git/DOCUMENTATION.md | 1 + .../lib/git/issue-2746-longpaths.test.js | 206 ++++++++++++++++++ packages/web/server/lib/git/service.js | 53 ++++- 5 files changed, 301 insertions(+), 14 deletions(-) create mode 100644 packages/web/server/lib/git/issue-2746-longpaths.test.js diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 9218e072..d611986c 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -25,6 +25,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t - Owns VS Code Git and worktree operations. - Fast worktree creation reports bootstrap phases explicitly: `directory-created`, then `git-ready` after Git population/upstream work, and `setup-ready` after setup commands. Existing worktrees without tracked bootstrap state fall back to `ready`/`setup-ready`; shared webview consumers also accept legacy responses without `phase`. - Worktree removal waits for an active create/bootstrap task for the same directory so background Git and setup work cannot race deletion or restore stale bootstrap state. + - Worktree population enables Git `core.longpaths` (local repo config plus `-c core.longpaths=true` on `git reset --hard`) so deeply nested checkouts under the managed data-dir worktree root do not fail on Windows MAX_PATH with "Filename too long". - `bridge-fs-runtime.ts` - Bridge handlers for filesystem-related message routes. diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 7f2a67d6..97b3a4d1 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -1117,34 +1117,71 @@ const getFileIdentity = async (filePath: string): Promise => { } }; +// OpenChamber places managed worktrees under a deep data-dir path +// (`/opencode/worktree/<40-char project id>//`). On +// Windows that prefix plus a deeply nested repo file routinely exceeds +// MAX_PATH (260). Git can check those paths out when core.longpaths is +// enabled; without it, `git reset --hard` during bootstrap fails with +// "Filename too long" and leaves a half-populated worktree (issue #2746). +const WORKTREE_POPULATE_RESET_ARGS = ['-c', 'core.longpaths=true', 'reset', '--hard'] as const; + +const isFilenameTooLongError = (message: string | null | undefined): boolean => + /file ?name too long/i.test(String(message || '')); + +const formatWorktreePopulateError = (message: string | null | undefined): string => { + const text = String(message || '').trim() || 'Failed to populate worktree'; + if (!isFilenameTooLongError(text)) { + return text; + } + return [ + text, + 'The worktree checkout path exceeds this system\'s path-length limit.', + 'OpenChamber enables Git `core.longpaths` for worktree population; if this still fails on Windows, enable OS long paths (LongPathsEnabled) or open the repository from a shorter absolute path.', + ].join('\n'); +}; + +const ensureWorktreeLongpaths = async (directory: string): Promise => { + const current = await runGitCommand(directory, ['config', '--get', 'core.longpaths']); + if (String(current.stdout || '').trim().toLowerCase() === 'true') { + return; + } + // Local config is shared across linked worktrees via the common git dir, so + // subsequent OpenChamber and CLI git operations in this repo also get long + // path support. Failures here are non-fatal: populate still passes + // `-c core.longpaths=true` on reset. + await runGitCommand(directory, ['config', 'core.longpaths', 'true']); +}; + const populateWorktreeWithLockRecovery = async (directory: string): Promise => { - let result = await runGitCommand(directory, ['reset', '--hard']); + await ensureWorktreeLongpaths(directory); + + let result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]); if (result.success) { return; } if (!isIndexLockError(result)) { - throw new Error(result.message || 'Failed to populate worktree'); + throw new Error(formatWorktreePopulateError(result.message)); } await wait(WORKTREE_INDEX_LOCK_RETRY_DELAY_MS); - result = await runGitCommand(directory, ['reset', '--hard']); + result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]); if (result.success) { return; } if (!isIndexLockError(result)) { - throw new Error(result.message || 'Failed to populate worktree'); + throw new Error(formatWorktreePopulateError(result.message)); } const lockPath = await getWorktreeIndexLockPath(directory); const identity = lockPath ? await getFileIdentity(lockPath) : null; await wait(WORKTREE_INDEX_LOCK_STALE_DELAY_MS); - result = await runGitCommand(directory, ['reset', '--hard']); + result = await runGitCommand(directory, [...WORKTREE_POPULATE_RESET_ARGS]); if (result.success) { return; } if (!isIndexLockError(result) || !lockPath || !identity || await getFileIdentity(lockPath) !== identity) { - throw new Error(result.message || 'Failed to populate worktree'); + throw new Error(formatWorktreePopulateError(result.message)); } await fs.promises.unlink(lockPath).catch((error) => { @@ -1152,7 +1189,10 @@ const populateWorktreeWithLockRecovery = async (directory: string): Promise/opencode/worktree/<40-char root commit hash>/ +// and populates them with `git reset --hard`. On Windows, that deep prefix plus +// a deeply nested repo file (e.g. yudao ~173 chars) exceeds MAX_PATH (260) and +// git aborts with "Filename too long" unless `core.longpaths` is enabled. +// --------------------------------------------------------------------------- + +const tempDirs = []; + +const createTempDir = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-issue2746-')); + tempDirs.push(dir); + return dir; +}; + +const runGit = (cwd, args, input) => + execFileSync('git', args, { + cwd, + encoding: 'utf8', + input, + stdio: ['pipe', 'pipe', 'pipe'], + }); + +const canRunGit = () => { + try { + execFileSync('git', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +}; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('issue #2746 - worktree long path support', () => { + it('enables core.longpaths and populates a deeply nested worktree checkout', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + process.env.XDG_DATA_HOME = dataHome; + + try { + const repo = createTempDir(); + runGit(repo, ['init', '-b', 'main']); + runGit(repo, ['config', 'user.email', 'test@example.com']); + runGit(repo, ['config', 'user.name', 'Test User']); + + // Realistic reporter path: many nested segments, each component well under + // NAME_MAX. On Windows the managed worktree prefix + this relative path + // exceeds MAX_PATH unless core.longpaths is enabled. + const deepRelative = path.join( + 'server', + 'yudao-framework', + 'yudao-spring-boot-starter-biz-data-permission', + 'src', + 'main', + 'java', + 'cn', + 'iocoder', + 'yudao', + 'framework', + 'datapermission', + 'config', + 'YudaoDataPermissionAutoConfiguration.java', + ); + fs.mkdirSync(path.dirname(path.join(repo, deepRelative)), { recursive: true }); + fs.writeFileSync(path.join(repo, deepRelative), '// yudao\n'); + fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n'); + runGit(repo, ['add', 'README.md', deepRelative]); + runGit(repo, ['commit', '-qm', 'init']); + + const created = await createWorktree(repo, { + mode: 'new', + worktreeName: 'issue-2746', + branchName: 'openchamber/issue-2746', + }); + expect(created.directoryCreated).toBe(true); + + await expect.poll(async () => { + const status = await getWorktreeBootstrapStatus(created.path); + return status?.status; + }, { timeout: 10_000 }).toBe('ready'); + + const longpaths = runGit(created.path, ['config', '--get', 'core.longpaths']).trim(); + expect(longpaths).toBe('true'); + expect(fs.existsSync(path.join(created.path, deepRelative))).toBe(true); + expect(fs.existsSync(path.join(created.path, 'README.md'))).toBe(true); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }); + + it('ensureWorktreeLongpaths is idempotent when already enabled', async () => { + if (!canRunGit()) return; + + const repo = createTempDir(); + runGit(repo, ['init', '-b', 'main']); + runGit(repo, ['config', 'user.email', 'test@example.com']); + runGit(repo, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n'); + runGit(repo, ['add', 'README.md']); + runGit(repo, ['commit', '-qm', 'init']); + runGit(repo, ['config', 'core.longpaths', 'true']); + + await expect(ensureWorktreeLongpaths(repo)).resolves.toBeUndefined(); + expect(runGit(repo, ['config', '--get', 'core.longpaths']).trim()).toBe('true'); + }); + + it('surfaces guided bootstrap failure when a path component exceeds the filesystem name limit', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + process.env.XDG_DATA_HOME = dataHome; + + try { + const repo = createTempDir(); + runGit(repo, ['init', '-b', 'main']); + runGit(repo, ['config', 'user.email', 'test@example.com']); + runGit(repo, ['config', 'user.name', 'Test User']); + + // Linux/macOS NAME_MAX equivalent of the Windows failure mode: a single + // path component longer than 255 cannot be materialized. core.longpaths + // cannot fix this; bootstrap must fail clearly instead of leaving a + // silent half-populated worktree. + const longComponent = 'x'.repeat(300); + const longPath = `server/${longComponent}/YudaoDataPermissionAutoConfiguration.java`; + const blobHash = runGit(repo, ['hash-object', '-w', '--stdin'], '// test\n').trim(); + runGit(repo, ['update-index', '--add', '--cacheinfo', `100644,${blobHash},${longPath}`]); + runGit(repo, ['commit', '-qm', 'init']); + fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n'); + runGit(repo, ['add', 'README.md']); + runGit(repo, ['commit', '-qm', 'add readme']); + + const created = await createWorktree(repo, { + mode: 'new', + worktreeName: 'issue-2746-namemax', + branchName: 'openchamber/issue-2746-namemax', + }); + expect(created.directoryCreated).toBe(true); + + await expect.poll(async () => { + const status = await getWorktreeBootstrapStatus(created.path); + return status?.status; + }, { timeout: 10_000 }).toBe('failed'); + + const status = await getWorktreeBootstrapStatus(created.path); + expect(status?.error).toMatch(/file name too long|filename too long/i); + expect(status?.error).toMatch(/path-length limit/i); + expect(runGit(created.path, ['config', '--get', 'core.longpaths']).trim()).toBe('true'); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }); + + it('populateWorktreeWithLockRecovery enables longpaths before reset', async () => { + if (!canRunGit()) return; + + const repo = createTempDir(); + runGit(repo, ['init', '-b', 'main']); + runGit(repo, ['config', 'user.email', 'test@example.com']); + runGit(repo, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n'); + runGit(repo, ['add', 'README.md']); + runGit(repo, ['commit', '-qm', 'init']); + + const worktree = createTempDir(); + fs.rmSync(worktree, { recursive: true, force: true }); + runGit(repo, ['worktree', 'add', '--no-checkout', '-b', 'feature/longpaths-populate', worktree, 'HEAD']); + + await expect(populateWorktreeWithLockRecovery(worktree)).resolves.toBeUndefined(); + expect(runGit(worktree, ['config', '--get', 'core.longpaths']).trim()).toBe('true'); + expect(fs.readFileSync(path.join(worktree, 'README.md'), 'utf8')).toBe('# Test\n'); + }); +}); diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index bd790acb..04baa715 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -991,34 +991,70 @@ const getFileIdentity = async (filePath) => { } }; +// OpenChamber places managed worktrees under a deep data-dir path +// (`/opencode/worktree/<40-char project id>//`). On +// Windows that prefix plus a deeply nested repo file routinely exceeds +// MAX_PATH (260). Git can check those paths out when core.longpaths is +// enabled; without it, `git reset --hard` during bootstrap fails with +// "Filename too long" and leaves a half-populated worktree (issue #2746). +const WORKTREE_POPULATE_RESET_ARGS = ['-c', 'core.longpaths=true', 'reset', '--hard']; + +const isFilenameTooLongError = (message) => /file ?name too long/i.test(String(message || '')); + +const formatWorktreePopulateError = (message) => { + const text = String(message || '').trim() || 'Failed to populate worktree'; + if (!isFilenameTooLongError(text)) { + return text; + } + return [ + text, + 'The worktree checkout path exceeds this system\'s path-length limit.', + 'OpenChamber enables Git `core.longpaths` for worktree population; if this still fails on Windows, enable OS long paths (LongPathsEnabled) or open the repository from a shorter absolute path.', + ].join('\n'); +}; + +export const ensureWorktreeLongpaths = async (directory) => { + const current = await runGitCommand(directory, ['config', '--get', 'core.longpaths']); + if (String(current.stdout || '').trim().toLowerCase() === 'true') { + return; + } + // Local config is shared across linked worktrees via the common git dir, so + // subsequent OpenChamber and CLI git operations in this repo also get long + // path support. Failures here are non-fatal: populate still passes + // `-c core.longpaths=true` on reset. + await runGitCommand(directory, ['config', 'core.longpaths', 'true']); +}; + export const populateWorktreeWithLockRecovery = async (directory) => { - let result = await runGitCommand(directory, ['reset', '--hard']); + await ensureWorktreeLongpaths(directory); + + let result = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS); if (result.success) { return; } if (!isIndexLockError(result)) { - throw new Error(result.message || 'Failed to populate worktree'); + throw new Error(formatWorktreePopulateError(result.message)); } await wait(WORKTREE_INDEX_LOCK_RETRY_DELAY_MS); - result = await runGitCommand(directory, ['reset', '--hard']); + result = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS); if (result.success) { return; } if (!isIndexLockError(result)) { - throw new Error(result.message || 'Failed to populate worktree'); + throw new Error(formatWorktreePopulateError(result.message)); } const lockPath = await getWorktreeIndexLockPath(directory); const identity = lockPath ? await getFileIdentity(lockPath) : null; await wait(WORKTREE_INDEX_LOCK_STALE_DELAY_MS); - result = await runGitCommand(directory, ['reset', '--hard']); + result = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS); if (result.success) { return; } if (!isIndexLockError(result) || !lockPath || !identity || await getFileIdentity(lockPath) !== identity) { - throw new Error(result.message || 'Failed to populate worktree'); + throw new Error(formatWorktreePopulateError(result.message)); } await fsp.unlink(lockPath).catch((error) => { @@ -1026,7 +1062,10 @@ export const populateWorktreeWithLockRecovery = async (directory) => { throw error; } }); - await runGitCommandOrThrow(directory, ['reset', '--hard'], 'Failed to populate worktree'); + const finalResult = await runGitCommand(directory, WORKTREE_POPULATE_RESET_ARGS); + if (!finalResult.success) { + throw new Error(formatWorktreePopulateError(finalResult.message || 'Failed to populate worktree')); + } }; // Worktrees are created with `git worktree add --no-checkout` and populated From 493a618efc01d4964d15f0e52e2371d3ddd87456 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 8 Aug 2026 17:26:59 +0300 Subject: [PATCH 077/581] perf(terminal): relax transport keepalive from 20s to 45s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ping has no pong-timeout on either side — it exists only to keep intermediaries from idling out the socket. 45s stays safely under the relay host-data idle reaper (90s) and common proxy read timeouts (60s), while halving the events that wake the relay Durable Object during an open session. --- packages/ui/src/lib/terminalApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/lib/terminalApi.ts b/packages/ui/src/lib/terminalApi.ts index 4bfce9d5..72e25849 100644 --- a/packages/ui/src/lib/terminalApi.ts +++ b/packages/ui/src/lib/terminalApi.ts @@ -343,7 +343,7 @@ export class TerminalTransport { this.idleCloseTimer = null; } - private startKeepalive(): void { this.stopKeepalive(); this.keepaliveTimer = setInterval(() => this.send({ t: 'ping', v: 3 }), 20_000); } + private startKeepalive(): void { this.stopKeepalive(); this.keepaliveTimer = setInterval(() => this.send({ t: 'ping', v: 3 }), 45_000); } private stopKeepalive(): void { if (this.keepaliveTimer) clearInterval(this.keepaliveTimer); this.keepaliveTimer = null; } private cancelReconnect(): void { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = null; this.wakeCleanup?.(); this.wakeCleanup = null; } private closeSocket(): void { this.stopKeepalive(); const socket = this.socket; this.socket = null; if (socket && (socket.readyState === SOCKET_CONNECTING || socket.readyState === SOCKET_OPEN)) socket.close(); } From 7a165fd0bb5f15346940c5ab9b8c9b3dcfd5d01e Mon Sep 17 00:00:00 2001 From: quiz152 <3325745159@qq.com> Date: Sun, 9 Aug 2026 11:16:30 +0800 Subject: [PATCH 078/581] fix(cli): atomic settings writes and gate relay key regeneration in connect-url The CLI's settings accessors wrote settings.json directly with writeFile and read it leniently, with no strict-reader gate on relay identity. Running 'openchamber connect-url' while the desktop app is up could: - tear the file for a concurrent reader in the app, tripping the relay service's read and mapping it to {} (first-run); - then regenerate the relay signing/encryption keys, changing serverId and orphaning every paired device and push binding. Move the accessors into a dedicated module that mirrors the settings runtime's guarantees: atomic tmp+rename writes (with the Windows fallback) so no reader can observe a partial file, and a strict reader that throws on corrupt/unreadable payloads so identity regeneration is gated exactly like the server runtime. Wire the strict reader into the CLI relay identity path. Adds unit tests covering atomic writes under concurrent readers, strict-read behavior, and that a corrupt settings file makes getRelayIdentity fail instead of minting a replacement keypair. --- .../web/bin/lib/cli-settings-accessors.js | 101 ++++++++++++++ .../bin/lib/cli-settings-accessors.test.js | 127 ++++++++++++++++++ packages/web/bin/lib/commands-connect-url.js | 26 ++-- 3 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 packages/web/bin/lib/cli-settings-accessors.js create mode 100644 packages/web/bin/lib/cli-settings-accessors.test.js diff --git a/packages/web/bin/lib/cli-settings-accessors.js b/packages/web/bin/lib/cli-settings-accessors.js new file mode 100644 index 00000000..f86d4101 --- /dev/null +++ b/packages/web/bin/lib/cli-settings-accessors.js @@ -0,0 +1,101 @@ +// Minimal settings.json access for CLI contexts (connect-url, pairing +// candidate building) that must not load the full web settings runtime. +// +// The running app already treats settings.json as a shared store — the relay +// identity, tunnels, notifications, the Electron main, and ssh-manager all +// read-modify-write it. This accessor must therefore mirror the settings +// runtime's guarantees or it will corrupt or regenerate shared state: +// +// - ATOMIC writes (write tmp, rename into place). A plain writeFile can +// interleave with a concurrent reader in the running app; the reader sees +// a half-written file, its lenient read maps it to `{}`, and relay +// identity logic then mints a NEW serverId — orphaning every paired +// device. The tmp+rename below means no reader can ever observe a partial +// file. +// +// - A STRICT read that THROWS on corrupt/unreadable payloads, gating relay +// identity regeneration. Only a genuinely missing file means "no +// settings"; any other failure (corrupt JSON, EACCES, transient I/O, +// non-object payload) must propagate so callers never confuse a broken +// read with first run and mint a replacement signing/encryption keypair. + +export const createSettingsAccessors = ({ fsPromises, path, dataDir, settingsFileName }) => { + const settingsPath = path.join(dataDir, settingsFileName); + + const readSettingsFromDiskMigrated = async () => { + try { + return JSON.parse(await fsPromises.readFile(settingsPath, 'utf8')); + } catch { + return {}; + } + }; + + const readSettingsStrict = async () => { + let raw; + try { + raw = await fsPromises.readFile(settingsPath, 'utf8'); + } catch (error) { + if (error && typeof error === 'object' && error.code === 'ENOENT') { + return {}; + } + throw error; + } + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') { + throw new Error('Settings file is malformed (non-object payload)'); + } + return parsed; + }; + + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + const isTransientWindowsReplaceError = (error) => { + if (process.platform !== 'win32' || !error || typeof error !== 'object') { + return false; + } + return error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY'; + }; + + const replaceFile = async (tmp, target) => { + const maxAttempts = process.platform === 'win32' ? 6 : 1; + let lastError = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await fsPromises.rename(tmp, target); + return; + } catch (error) { + lastError = error; + if (!isTransientWindowsReplaceError(error) || attempt === maxAttempts) { + break; + } + await sleep(25 * attempt); + } + } + + if (!isTransientWindowsReplaceError(lastError)) { + throw lastError; + } + + // Windows can transiently reject the atomic replace while another process + // briefly holds the target open. Copy the COMPLETE tmp file into place so + // persistence never wedges; a reader can still never see partial content. + await fsPromises.copyFile(tmp, target); + await fsPromises.rm(tmp, { force: true }); + }; + + const writeSettingsToDisk = async (settings) => { + await fsPromises.mkdir(path.dirname(settingsPath), { recursive: true }); + const tmp = `${settingsPath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), { encoding: 'utf8', mode: 0o600 }); + if (process.platform !== 'win32') { + await fsPromises.chmod(tmp, 0o600); + } + await replaceFile(tmp, settingsPath); + if (process.platform !== 'win32') { + await fsPromises.chmod(settingsPath, 0o600); + } + }; + + return { readSettingsFromDiskMigrated, readSettingsStrict, writeSettingsToDisk }; +}; diff --git a/packages/web/bin/lib/cli-settings-accessors.test.js b/packages/web/bin/lib/cli-settings-accessors.test.js new file mode 100644 index 00000000..36899186 --- /dev/null +++ b/packages/web/bin/lib/cli-settings-accessors.test.js @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import crypto from 'crypto'; + +import { createSettingsAccessors } from './cli-settings-accessors.js'; +import { createRelayIdentityRuntime } from '../../server/lib/relay/identity.js'; + +const withTempDir = async (fn) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-settings-accessors-')); + try { + return await fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}; + +const makeAccessors = (dir) => + createSettingsAccessors({ fsPromises: fs.promises, path, dataDir: dir, settingsFileName: 'settings.json' }); + +describe('cli settings accessors', () => { + it('persists the full object atomically and cleans up its tmp file', async () => { + await withTempDir(async (dir) => { + const accessors = makeAccessors(dir); + await accessors.writeSettingsToDisk({ theme: 'dark', count: 3 }); + + const raw = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8')); + expect(raw).toEqual({ theme: 'dark', count: 3 }); + + const leftovers = fs.readdirSync(dir).filter((name) => name.startsWith('settings.json.tmp-')); + expect(leftovers).toEqual([]); + }); + }); + + it('never leaves a partial file observable by a concurrent reader during writes', async () => { + await withTempDir(async (dir) => { + const accessors = makeAccessors(dir); + const filePath = path.join(dir, 'settings.json'); + + // Hammer reads concurrently with writes; every observed payload must be a + // complete, parseable object (the old plain writeFile could surface a + // torn file mid-rename, which is what tripped the relay identity logic). + const stop = { value: false }; + const reader = (async () => { + while (!stop.value) { + try { + const parsed = JSON.parse(await fs.promises.readFile(filePath, 'utf8')); + if (parsed && typeof parsed === 'object') { + // A complete object is always fine; anything else would be a tear. + expect(parsed.theme).toBe('dark'); + } + } catch { + // ENOENT during the very first write is acceptable. + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + })(); + + const big = { theme: 'dark', filler: 'x'.repeat(4096) }; + await Promise.all( + Array.from({ length: 50 }, (_, i) => + accessors.writeSettingsToDisk({ ...big, n: i }).catch(() => {}), + ), + ); + stop.value = true; + await reader; + + const final = JSON.parse(fs.readFileSync(filePath, 'utf8')); + expect(final.theme).toBe('dark'); + }); + }); + + it('lenient read maps a corrupt file to {} for config lookup', async () => { + await withTempDir(async (dir) => { + const accessors = makeAccessors(dir); + fs.writeFileSync(path.join(dir, 'settings.json'), '{"unfinished": "trunc'); + expect(await accessors.readSettingsFromDiskMigrated()).toEqual({}); + }); + }); + + it('strict read throws on a corrupt file instead of reporting "no settings"', async () => { + await withTempDir(async (dir) => { + const accessors = makeAccessors(dir); + fs.writeFileSync(path.join(dir, 'settings.json'), '{"unfinished": "trunc'); + await expect(accessors.readSettingsStrict()).rejects.toThrow(); + }); + }); + + it('strict read throws on a non-object payload', async () => { + await withTempDir(async (dir) => { + const accessors = makeAccessors(dir); + fs.writeFileSync(path.join(dir, 'settings.json'), '"just a string"'); + await expect(accessors.readSettingsStrict()).rejects.toThrow(/non-object payload/); + }); + }); + + it('strict read treats only a genuinely missing file as no settings', async () => { + await withTempDir(async (dir) => { + const accessors = makeAccessors(dir); + expect(await accessors.readSettingsStrict()).toEqual({}); + }); + }); + + it('does not regenerate the relay identity off a corrupt settings file', async () => { + await withTempDir(async (dir) => { + const accessors = makeAccessors(dir); + fs.writeFileSync( + path.join(dir, 'settings.json'), + JSON.stringify({ + relaySigningKey: { + privateJwk: crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ format: 'jwk' }), + publicJwk: crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }).publicKey.export({ format: 'jwk' }), + }, + }), + ); + const identity = await createRelayIdentityRuntime({ crypto, ...accessors }).getRelayIdentity(); + const serverIdBefore = identity.serverId; + + // Corrupt the file, then ask for the identity again: the strict gate must + // make this FAIL rather than mint a replacement keypair. + fs.writeFileSync(path.join(dir, 'settings.json'), '{"relaySigningKey": {"unfinished'); + await expect(createRelayIdentityRuntime({ crypto, ...accessors }).getRelayIdentity()).rejects.toThrow(); + expect(serverIdBefore).toBeTruthy(); + }); + }); +}); diff --git a/packages/web/bin/lib/commands-connect-url.js b/packages/web/bin/lib/commands-connect-url.js index 2fe84f7e..a050b396 100644 --- a/packages/web/bin/lib/commands-connect-url.js +++ b/packages/web/bin/lib/commands-connect-url.js @@ -17,6 +17,7 @@ import { createClientPairingRuntime } from '../../server/lib/client-auth/pairing import { createRelayIdentityRuntime } from '../../server/lib/relay/identity.js'; import { DEFAULT_RELAY_URL } from '../../server/lib/relay/service.js'; import { bytesToBase64Url } from '../../server/lib/relay/e2ee.js'; +import { createSettingsAccessors as createSettingsAccessorsModule } from './cli-settings-accessors.js'; import { intro as clackIntro, outro as clackOutro, @@ -28,7 +29,6 @@ import { } from '../cli-output.js'; const REMOTE_CLIENTS_FILE_NAME = 'remote-clients.json'; -const SETTINGS_FILE_NAME = 'settings.json'; const PAIRING_SESSIONS_FILE_NAME = 'client-pairing-sessions.json'; function isValidRelayUrl(value) { @@ -55,20 +55,18 @@ function resolveRelayUrl(settings) { // Minimal settings.json read/write for the relay identity runtime. It reads the // whole object and writes it back with the relay keys added, so other settings // are preserved. Enough for the CLI without wiring the full settings runtime. +// +// Mirrors the settings runtime's guarantees: atomic writes (tmp + rename) so +// concurrent readers in the running app never observe a half-written file, and +// a STRICT reader gating relay identity regeneration so a swallowed read +// failure can never mint a new serverId and orphan paired devices. function createSettingsAccessors() { - const settingsPath = path.join(getOpenChamberDataDir(), SETTINGS_FILE_NAME); - const readSettingsFromDiskMigrated = async () => { - try { - return JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')); - } catch { - return {}; - } - }; - const writeSettingsToDisk = async (settings) => { - await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true }); - await fs.promises.writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf8'); - }; - return { readSettingsFromDiskMigrated, writeSettingsToDisk }; + return createSettingsAccessorsModule({ + fsPromises: fs.promises, + path, + dataDir: getOpenChamberDataDir(), + settingsFileName: 'settings.json', + }); } // Resolves the instance's relay identity (serverId + encryption public key, From 95338dbbb109b2b2fe2fee2175ecea0c3f4e042b Mon Sep 17 00:00:00 2001 From: quiz152 <3325745159@qq.com> Date: Sun, 9 Aug 2026 11:39:42 +0800 Subject: [PATCH 079/581] test(cli): deterministic torn-write regression coverage; document module Address the openchamber-ai review's non-blocking notes: - Concurrency evidence: the torn-write test now injects a slow, chunked writeFile (one open handle, file grows prefix->full) so a torn read is deterministically observable in the 30ms window. A companion test runs the naive direct writer under the same load and asserts torn reads ARE produced, proving the atomicity test can actually fail on the pre-fix writer. - Windows fallback comment: no longer claims the copyFile fallback is atomic; it is called out as a last resort confined to Windows. - Module map: document cli-settings-accessors.js in bin/lib/DOCUMENTATION.md. --- packages/web/bin/lib/DOCUMENTATION.md | 12 ++ .../web/bin/lib/cli-settings-accessors.js | 6 +- .../bin/lib/cli-settings-accessors.test.js | 117 +++++++++++++----- 3 files changed, 101 insertions(+), 34 deletions(-) diff --git a/packages/web/bin/lib/DOCUMENTATION.md b/packages/web/bin/lib/DOCUMENTATION.md index 24141432..a85d6a8d 100644 --- a/packages/web/bin/lib/DOCUMENTATION.md +++ b/packages/web/bin/lib/DOCUMENTATION.md @@ -78,6 +78,18 @@ These modules hold reusable, non-presentational logic for commands. - `cli-paths.js` - Data, run, log, settings, tunnel profile, and managed-local config paths. +- `cli-settings-accessors.js` + - Minimal settings.json read/write for CLI contexts that must not load the + full web settings runtime (`connect-url` relay identity resolution). + - Mirrors the settings runtime's guarantees so a CLI read-modify-write can + never corrupt shared state: atomic tmp+rename writes (no concurrent reader + in the running app can observe a torn file), a strict read that throws on + corrupt/unreadable payloads, and the same `0600` file mode. + - The strict read gates relay identity regeneration exactly like the server + runtime: a swallowed read failure can never mint a replacement signing or + encryption keypair, which would change `serverId` and orphan every paired + device and push binding. + - `cli-process.js` - PID files, instance registry files, process identity checks, runtime metadata checks, and process termination helpers. diff --git a/packages/web/bin/lib/cli-settings-accessors.js b/packages/web/bin/lib/cli-settings-accessors.js index f86d4101..50d415bd 100644 --- a/packages/web/bin/lib/cli-settings-accessors.js +++ b/packages/web/bin/lib/cli-settings-accessors.js @@ -78,8 +78,10 @@ export const createSettingsAccessors = ({ fsPromises, path, dataDir, settingsFil } // Windows can transiently reject the atomic replace while another process - // briefly holds the target open. Copy the COMPLETE tmp file into place so - // persistence never wedges; a reader can still never see partial content. + // briefly holds the target open. Fall back to copying the COMPLETE tmp file + // so persistence never wedges. Note: copyFile is NOT atomic — this is a + // last-resort path confined to Windows, matching the settings runtime's + // fallback, not a substitute for the atomic rename used everywhere else. await fsPromises.copyFile(tmp, target); await fsPromises.rm(tmp, { force: true }); }; diff --git a/packages/web/bin/lib/cli-settings-accessors.test.js b/packages/web/bin/lib/cli-settings-accessors.test.js index 36899186..a48829fc 100644 --- a/packages/web/bin/lib/cli-settings-accessors.test.js +++ b/packages/web/bin/lib/cli-settings-accessors.test.js @@ -16,8 +16,66 @@ const withTempDir = async (fn) => { } }; -const makeAccessors = (dir) => - createSettingsAccessors({ fsPromises: fs.promises, path, dataDir: dir, settingsFileName: 'settings.json' }); +const makeAccessors = (dir, overrides = {}) => + createSettingsAccessors({ + fsPromises: fs.promises, + path, + dataDir: dir, + settingsFileName: 'settings.json', + ...overrides, + }); + +// Wraps writeFile so each write lands in two chunks with a pause in between — +// a stand-in for a large, slow write on a real disk (one open handle, so the +// file grows from the prefix to the full payload). With a non-atomic writer a +// concurrent reader deterministically catches the half-written file in that +// window; with the atomic tmp+rename writer the target only ever changes via a +// complete rename, so the window is never observable. +const makeSlowWriteFs = () => { + const realFs = fs.promises; + const slowWriteFile = async (filePath, data) => { + const handle = await realFs.open(filePath, 'w'); + try { + const half = Math.floor(data.length / 2); + await handle.writeFile(data.slice(0, half), 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 30)); + await handle.writeFile(data.slice(half), 'utf8'); + } finally { + await handle.close(); + } + }; + return { slowWriteFile, fsPromises: { ...realFs, writeFile: slowWriteFile } }; +}; + +// Runs `writer` against filePath while a concurrent reader hammers it; returns +// how many times the reader observed an unparseable (torn) payload. ENOENT +// during the very first write is not a tear and is excluded. +const countTornReads = async (filePath, writer, iterations) => { + const big = { theme: 'dark', filler: 'x'.repeat(4096) }; + let torn = 0; + let stop = false; + const reader = (async () => { + while (!stop) { + try { + const parsed = JSON.parse(await fs.promises.readFile(filePath, 'utf8')); + if (parsed && typeof parsed === 'object') { + expect(parsed.theme).toBe('dark'); + } + } catch (error) { + if (error?.code !== 'ENOENT') { + torn += 1; + } + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + })(); + for (let i = 0; i < iterations; i += 1) { + await writer({ ...big, n: i }); + } + stop = true; + await reader; + return torn; +}; describe('cli settings accessors', () => { it('persists the full object atomically and cleans up its tmp file', async () => { @@ -33,41 +91,36 @@ describe('cli settings accessors', () => { }); }); - it('never leaves a partial file observable by a concurrent reader during writes', async () => { + it('atomic writes: concurrent readers never observe a torn file, even under slow writes', async () => { await withTempDir(async (dir) => { - const accessors = makeAccessors(dir); + const { fsPromises } = makeSlowWriteFs(); + const accessors = makeAccessors(dir, { fsPromises }); const filePath = path.join(dir, 'settings.json'); - // Hammer reads concurrently with writes; every observed payload must be a - // complete, parseable object (the old plain writeFile could surface a - // torn file mid-rename, which is what tripped the relay identity logic). - const stop = { value: false }; - const reader = (async () => { - while (!stop.value) { - try { - const parsed = JSON.parse(await fs.promises.readFile(filePath, 'utf8')); - if (parsed && typeof parsed === 'object') { - // A complete object is always fine; anything else would be a tear. - expect(parsed.theme).toBe('dark'); - } - } catch { - // ENOENT during the very first write is acceptable. - } - await new Promise((resolve) => setTimeout(resolve, 0)); - } - })(); + // Each write is chunked with a pause, yet the reader must never see a + // partial payload: the target only changes via a complete atomic rename. + const torn = await countTornReads(filePath, (settings) => accessors.writeSettingsToDisk(settings), 20); + expect(torn).toBe(0); - const big = { theme: 'dark', filler: 'x'.repeat(4096) }; - await Promise.all( - Array.from({ length: 50 }, (_, i) => - accessors.writeSettingsToDisk({ ...big, n: i }).catch(() => {}), - ), + const leftovers = fs.readdirSync(dir).filter((name) => name.startsWith('settings.json.tmp-')); + expect(leftovers).toEqual([]); + }); + }); + + it('demonstrates the protected failure mode: a naive direct writer tears under the same slow write', async () => { + await withTempDir(async (dir) => { + const { slowWriteFile } = makeSlowWriteFs(); + const filePath = path.join(dir, 'settings.json'); + + // The old CLI accessor wrote straight to settings.json with writeFile. + // The same slow-write load therefore MUST produce torn reads — proving + // the concurrency test above can actually fail on the pre-fix writer. + const torn = await countTornReads( + filePath, + (settings) => slowWriteFile(filePath, JSON.stringify(settings)), + 20, ); - stop.value = true; - await reader; - - const final = JSON.parse(fs.readFileSync(filePath, 'utf8')); - expect(final.theme).toBe('dark'); + expect(torn).toBeGreaterThan(0); }); }); From 0bbbcd6445a8b56448149b38c20c2c52093ab5bc Mon Sep 17 00:00:00 2001 From: Zeying Tian Date: Sun, 9 Aug 2026 03:03:05 -0400 Subject: [PATCH 080/581] fix(small-model): omit thinkingConfig for non-reasoning Google models --- .../web/server/lib/small-model/DOCUMENTATION.md | 3 ++- packages/web/server/lib/small-model/call.js | 15 +++++++-------- packages/web/server/lib/small-model/call.test.js | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index 1776f1e1..23fdbfdd 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -96,7 +96,8 @@ other runtime API. `auth.openai.com` (single-flight) and written back to `auth.json`. - **Anthropic** (`type: api`): `/v1/messages` with `x-api-key`. - **Google** (`type: api`): `generateContent` with `x-goog-api-key`; Gemini 3 - uses `thinkingLevel` while older Flash models use `thinkingBudget: 0`. + uses `thinkingLevel`, Gemini 2.x uses `thinkingBudget: 0`, and all other + models omit `thinkingConfig` entirely. - Everything else: OpenAI-compatible `/chat/completions` against the provider's base URL, resolved from (1) `provider..options.baseURL` in the OpenCode config, (2) the hardcoded `https://api.openai.com/v1` diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index c3a090a1..75dc315f 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -402,9 +402,10 @@ const getCopilotEndpoint = async ({ baseURL, headers, modelID }) => { const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) => { const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelID)}:generateContent`; - const thinkingConfig = modelID.toLowerCase().startsWith('gemini-3') - ? { thinkingLevel: modelID.toLowerCase().includes('flash') ? 'minimal' : 'low' } - : { thinkingBudget: 0 }; + const lowerModelID = modelID.toLowerCase(); + const thinkingConfig = lowerModelID.startsWith('gemini-3') + ? { thinkingLevel: lowerModelID.includes('flash') ? 'minimal' : 'low' } + : lowerModelID.startsWith('gemini-2') ? { thinkingBudget: 0 } : null; const response = await fetch(url, { method: 'POST', headers: { @@ -414,13 +415,11 @@ const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens, re }, body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: prompt }] }], - ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}), + ...(system && { systemInstruction: { parts: [{ text: system }] } }), generationConfig: { maxOutputTokens, - thinkingConfig, - ...(responseSchema - ? { responseMimeType: 'application/json', responseSchema: toGoogleSchema(responseSchema) } - : {}), + ...(thinkingConfig && { thinkingConfig }), + ...(responseSchema && { responseMimeType: 'application/json', responseSchema: toGoogleSchema(responseSchema) }), }, }), signal: requestSignal(timeoutMs, signal), diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 154fd986..bbb6b49a 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -457,6 +457,22 @@ describe('callSmallModel — Google thinking configuration', () => { const body = JSON.parse(lastCall(fetchMock).init.body); expect(body.generationConfig.thinkingConfig).toEqual({ thinkingBudget: 0 }); }); + + it('omits thinkingConfig for other Google/Gemini models', async () => { + fetchMock.mockResolvedValue(googleResponse('generated commit')); + + await callSmallModel({ + auth: { google: { type: 'api', key: 'google-key' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'google', + modelID: 'gemini-1.5-flash', + prompt: 'generate', + }); + + const body = JSON.parse(lastCall(fetchMock).init.body); + expect(body.generationConfig.thinkingConfig).toBeUndefined(); + }); }); describe('callSmallModel — GitHub Copilot endpoint routing', () => { From 901d34bc5f2e439d0e4818e427e923b25718e9b2 Mon Sep 17 00:00:00 2001 From: Floze Date: Sun, 9 Aug 2026 15:41:58 +0400 Subject: [PATCH 081/581] fix: keep GitHub settings mounted during refresh --- .../openchamber/GitHubSettings.test.tsx | 61 +++++++++++++++++++ .../sections/openchamber/GitHubSettings.tsx | 2 +- 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/sections/openchamber/GitHubSettings.test.tsx diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.test.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.test.tsx new file mode 100644 index 00000000..36ac2368 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.test.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n"; +import { useGitHubAuthStore } from "@/stores/useGitHubAuthStore"; + +import { GitHubSettings } from "./GitHubSettings"; + +const serverAuthState = useGitHubAuthStore.getInitialState(); + +const resetServerAuthState = () => { + Object.assign(serverAuthState, { + status: null, + isLoading: false, + hasChecked: false, + }); +}; + +const renderSettings = () => + renderToStaticMarkup( + + + , + ); + +describe("GitHubSettings", () => { + beforeEach(resetServerAuthState); + afterEach(resetServerAuthState); + + test("stays hidden during the initial auth status load", () => { + serverAuthState.isLoading = true; + + expect(renderSettings()).toBe(""); + }); + + test("stays mounted while a checked status is refreshing, then shows reconnect state", () => { + Object.assign(serverAuthState, { + status: { + connected: true, + user: { login: "octocat" }, + }, + isLoading: true, + hasChecked: true, + }); + + const refreshingMarkup = renderSettings(); + expect(refreshingMarkup).toContain("octocat"); + expect(refreshingMarkup).toContain("Disconnect"); + + Object.assign(serverAuthState, { + status: { connected: false }, + isLoading: false, + hasChecked: true, + }); + + const disconnectedMarkup = renderSettings(); + expect(disconnectedMarkup).toContain("Not Connected"); + expect(disconnectedMarkup).toContain("Connect GitHub"); + }); +}); diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx index a9500d45..e2a06bda 100644 --- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx @@ -256,7 +256,7 @@ export const GitHubSettings: React.FC = () => { } }, [runtimeGitHub, setStatus, t]); - if (isLoading) { + if (isLoading && !hasChecked) { return null; } From f4743ea06039584245da10683d2e634c05528d7c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 9 Aug 2026 19:30:25 +0300 Subject: [PATCH 082/581] feat(chat): work-status panel, and MCP auth and settings fixes (#2776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a work-status panel beside the transcript. Context fill, model and cost, todos, running subagents and the permission requests blocking them, branch and working-tree state, MCP servers, pinned messages and context sources were scattered across the header, the composer and the context panel — a blocked subagent was reported nowhere at all. The panel reads them from live channels rather than persisted history, and becomes an overlay where the chat is too narrow to seat a column. It is on by default, including for existing installs. Because it now carries these readouts, the desktop header and composer drop the ones it duplicates: todo and changed-files chips, usage and MCP tabs. VS Code and mobile keep theirs — neither hosts the panel. Fixes MCP authorization, which was broken from the panel, invalidated by a directory switch through a redirect URI that encoded the working directory, and left the desktop app in the background because browsers will not follow a custom-protocol link without a user gesture. The settings page no longer asks the user to understand the MCP spec before adding a server: one field takes the command or the link, with the kind inferred and a visible override, and client-registration fields appear only when a server actually asks for its own credentials. Also: skills load from the panel instead of only when the composer's slash autocomplete opens; the header button names the current instance rather than falling through to the word "Instance" for relay hosts. Three new optional UI settings keys, all migrated. No change to stored MCP server configuration. --- packages/electron/main.mjs | 33 ++ .../ui/src/apps/MobileSessionMetadata.tsx | 136 +---- .../ui/src/components/chat/ChatContainer.tsx | 71 ++- packages/ui/src/components/chat/ChatInput.tsx | 50 +- .../src/components/chat/SessionGoalButton.tsx | 17 +- .../chat/work-status/DOCUMENTATION.md | 344 ++++++++++++ .../work-status/WorkStatusContextSection.tsx | 130 +++++ .../chat/work-status/WorkStatusGoalRow.tsx | 76 +++ .../chat/work-status/WorkStatusMcpSection.tsx | 142 +++++ .../chat/work-status/WorkStatusPanel.tsx | 251 +++++++++ .../work-status/WorkStatusPinnedSection.tsx | 111 ++++ .../work-status/WorkStatusPrimaryGroup.tsx | 323 +++++++++++ .../chat/work-status/WorkStatusPrimitives.tsx | 266 +++++++++ .../work-status/WorkStatusSectionsDialog.tsx | 56 ++ .../WorkStatusSubagentsSection.tsx | 110 ++++ .../work-status/WorkStatusTasksSection.tsx | 112 ++++ .../work-status/WorkStatusUsageSection.tsx | 152 ++++++ .../chat/work-status/contextUsage.test.ts | 64 +++ .../chat/work-status/contextUsage.ts | 71 +++ .../components/chat/work-status/presence.tsx | 25 + .../chat/work-status/presenceContext.ts | 23 + .../chat/work-status/sections.test.ts | 48 ++ .../components/chat/work-status/sections.ts | 59 ++ .../chat/work-status/usageHeadline.test.ts | 95 ++++ .../chat/work-status/usageHeadline.ts | 66 +++ .../useWorkStatusVisibility.test.ts | 329 +++++++++++ .../work-status/useWorkStatusVisibility.ts | 116 ++++ .../desktop/DesktopHostSwitcher.tsx | 94 +--- packages/ui/src/components/icon/sprite.ts | 1 + .../components/layout/ContextPanelRail.tsx | 14 +- packages/ui/src/components/layout/Header.tsx | 514 ++++-------------- .../ui/src/components/layout/MainLayout.tsx | 6 +- packages/ui/src/components/layout/Sidebar.tsx | 7 +- .../ui/src/components/mcp/McpDropdown.tsx | 26 +- .../sections/mcp/McpOAuthCallbackPage.tsx | 49 +- .../src/components/sections/mcp/McpPage.tsx | 454 ++++++++-------- .../sections/mcp/startMcpAuthorization.ts | 212 ++++++++ .../sections/shared/SettingsPageLayout.tsx | 14 +- .../session/GitHubIssuePickerDialog.tsx | 20 +- .../components/session/NewWorktreeDialog.tsx | 31 ++ .../components/usage/UsageProviderCards.tsx | 87 +++ .../ui/src/components/usage/usageGroups.ts | 84 +++ packages/ui/src/lib/appearanceAutoSave.ts | 14 + packages/ui/src/lib/desktop.ts | 21 + packages/ui/src/lib/desktopCurrentHost.ts | 97 ++++ packages/ui/src/lib/i18n/messages/de.ts | 92 +++- packages/ui/src/lib/i18n/messages/en.ts | 92 +++- packages/ui/src/lib/i18n/messages/es.ts | 92 +++- packages/ui/src/lib/i18n/messages/fr.ts | 92 +++- packages/ui/src/lib/i18n/messages/ja.ts | 92 +++- packages/ui/src/lib/i18n/messages/ko.ts | 92 +++- packages/ui/src/lib/i18n/messages/pl.ts | 92 +++- packages/ui/src/lib/i18n/messages/pt-BR.ts | 92 +++- packages/ui/src/lib/i18n/messages/uk.ts | 92 +++- packages/ui/src/lib/i18n/messages/zh-CN.ts | 92 +++- packages/ui/src/lib/i18n/messages/zh-TW.ts | 92 +++- packages/ui/src/lib/linkedIssues.test.ts | 145 +++++ packages/ui/src/lib/linkedIssues.ts | 111 ++++ packages/ui/src/lib/persistence.ts | 21 + .../ui/src/stores/skillVisibility.test.ts | 75 +++ packages/ui/src/stores/skillVisibility.ts | 74 +++ packages/ui/src/stores/useMcpStore.ts | 9 + packages/ui/src/stores/useSkillsStore.ts | 17 +- packages/ui/src/stores/useUIStore.ts | 115 ++++ packages/ui/src/sync/session-actions.ts | 14 + packages/ui/src/sync/sync-context.tsx | 12 +- packages/web/server/lib/opencode/routes.js | 6 + .../server/lib/opencode/settings-helpers.js | 10 + .../web/server/lib/opencode/skill-routes.js | 29 +- 69 files changed, 5777 insertions(+), 894 deletions(-) create mode 100644 packages/ui/src/components/chat/work-status/DOCUMENTATION.md create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusGoalRow.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusPanel.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusPinnedSection.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusPrimitives.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusSectionsDialog.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusSubagentsSection.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusTasksSection.tsx create mode 100644 packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx create mode 100644 packages/ui/src/components/chat/work-status/contextUsage.test.ts create mode 100644 packages/ui/src/components/chat/work-status/contextUsage.ts create mode 100644 packages/ui/src/components/chat/work-status/presence.tsx create mode 100644 packages/ui/src/components/chat/work-status/presenceContext.ts create mode 100644 packages/ui/src/components/chat/work-status/sections.test.ts create mode 100644 packages/ui/src/components/chat/work-status/sections.ts create mode 100644 packages/ui/src/components/chat/work-status/usageHeadline.test.ts create mode 100644 packages/ui/src/components/chat/work-status/usageHeadline.ts create mode 100644 packages/ui/src/components/chat/work-status/useWorkStatusVisibility.test.ts create mode 100644 packages/ui/src/components/chat/work-status/useWorkStatusVisibility.ts create mode 100644 packages/ui/src/components/sections/mcp/startMcpAuthorization.ts create mode 100644 packages/ui/src/components/usage/UsageProviderCards.tsx create mode 100644 packages/ui/src/components/usage/usageGroups.ts create mode 100644 packages/ui/src/lib/desktopCurrentHost.ts create mode 100644 packages/ui/src/lib/linkedIssues.test.ts create mode 100644 packages/ui/src/lib/linkedIssues.ts create mode 100644 packages/ui/src/stores/skillVisibility.test.ts create mode 100644 packages/ui/src/stores/skillVisibility.ts diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 8be0f076..0467a165 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -2193,6 +2193,23 @@ const dispatchDeepLink = (link) => { log.warn('[electron] invalid connect deep-link payload'); return; } + // Sent by the MCP OAuth callback page after it completes authorization in + // the system browser. The work is already done server-side; all this has to + // do is bring the app back to the front, since the user's attention is in a + // browser tab at that moment. + if (link.type === 'focus') { + const target = state.mainWindow && !state.mainWindow.isDestroyed() + ? state.mainWindow + : BrowserWindow.getAllWindows().find((window) => !window.isDestroyed()); + if (target) { + if (target.isMinimized()) target.restore(); + target.show(); + target.focus(); + } + emitToAllWindows('openchamber:deep-link-focus', { reason: link.value || null }); + return; + } + if (link.type === 'session' && link.value) { emitToAllWindows('openchamber:open-session', { sessionId: link.value }); return; @@ -3689,6 +3706,22 @@ const handleInvoke = async (browserWindow, command, args = {}) => { case 'desktop_start_window_drag': return null; + // Used after an MCP authorization finishes in the system browser: the app + // raises itself rather than relying on the browser to hand control back. + // A browser will not follow a custom-protocol link without a user gesture, + // and the completion page has none. + case 'desktop_focus_window': { + const target = browserWindow && !browserWindow.isDestroyed() + ? browserWindow + : (state.mainWindow && !state.mainWindow.isDestroyed() ? state.mainWindow : null); + if (!target) return false; + if (target.isMinimized()) target.restore(); + target.show(); + target.focus(); + app.focus?.({ steal: true }); + return true; + } + case 'desktop_is_window_fullscreen': return Boolean(browserWindow?.isFullScreen()); diff --git a/packages/ui/src/apps/MobileSessionMetadata.tsx b/packages/ui/src/apps/MobileSessionMetadata.tsx index 85a93ff5..9ffe0d66 100644 --- a/packages/ui/src/apps/MobileSessionMetadata.tsx +++ b/packages/ui/src/apps/MobileSessionMetadata.tsx @@ -2,16 +2,15 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import type { IconName } from '@/components/icon/icons'; -import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { preloadProviderLogos } from '@/hooks/useProviderLogo'; import { useTabletLayout } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; -import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota'; -import { getDisplayModelName } from '@/lib/quota/model-families'; +import { clampPercent, resolveUsageTone } from '@/lib/quota'; +import { UsageProviderCards } from '@/components/usage/UsageProviderCards'; +import { useUsageProviderGroups, type UsageProviderGroup } from '@/components/usage/usageGroups'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; -import type { QuotaProviderId, UsageWindow } from '@/types'; import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; import { useSelectionStore } from '@/sync/selection-store'; import { useSessionMessages } from '@/sync/sync-context'; @@ -34,34 +33,12 @@ const formatTokens = (value: number): string => { return String(value); }; -type MobileUsageLimitRow = { - key: string; - label: string; - subtitle?: string; - window: UsageWindow; -}; - -type MobileUsageProviderGroup = { - providerId: QuotaProviderId; - providerName: string; - rows: MobileUsageLimitRow[]; - status: string | null; -}; - type ContextDisplay = { percentage: number; tokens: string; colorClass: string; } | null; -const getWindowValueClass = (window: UsageWindow): string => { - const usedPercent = window.usedPercent; - if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) return 'text-foreground'; - if (usedPercent >= 80) return 'text-[var(--status-error)]'; - if (usedPercent >= 50) return 'text-[var(--status-warning)]'; - return 'text-foreground'; -}; - const ContextProgressIcon: React.FC<{ percentage: number }> = ({ percentage }) => { const progressPct = clampPercent(percentage) ?? 0; const tone = resolveUsageTone(percentage); @@ -130,7 +107,7 @@ const SessionMetadataOverlay: React.FC<{ onClose: () => void; anchorRef: React.RefObject; contextDisplay: ContextDisplay; - usageGroups: MobileUsageProviderGroup[]; + usageGroups: UsageProviderGroup[]; usageDisplayMode: 'usage' | 'remaining'; isUsageLoading: boolean; timeFormatPreference: TimeFormatPreference; @@ -283,7 +260,7 @@ const SessionMetadataOverlay: React.FC<{ }; const MobileUsageLimits: React.FC<{ - groups: MobileUsageProviderGroup[]; + groups: UsageProviderGroup[]; displayMode: 'usage' | 'remaining'; isLoading: boolean; timeFormatPreference: TimeFormatPreference; @@ -318,54 +295,11 @@ const MobileUsageLimits: React.FC<{
-
- {groups.map((group) => ( -
-
- - - {group.providerName} - - {group.status && group.rows.length === 0 ? ( - - {group.status} - - ) : null} -
- {group.rows.length > 0 ? ( -
- {group.rows.map((row) => { - const displayPercent = displayMode === 'remaining' ? row.window.remainingPercent : row.window.usedPercent; - const metricLabel = formatQuotaValueLabel(row.window.valueLabel, displayPercent); - const resetLabel = formatQuotaResetLabel( - row.window.resetAt, - row.window.resetAfterFormatted ?? row.window.resetAtFormatted, - timeFormatPreference, - ); - return ( -
- - - {row.subtitle ? `${row.subtitle} · ${row.label}` : row.label} - - {resetLabel ? ( - {resetLabel} - ) : null} - - - {metricLabel === '-' ? '' : metricLabel} - -
- ); - })} -
- ) : null} - {group.status && group.rows.length > 0 ? ( -
{group.status}
- ) : null} -
- ))} -
+
); }; @@ -403,7 +337,6 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta const isQuotaLoading = useQuotaStore((state) => state.isLoading); const quotaDisplayMode = useQuotaStore((state) => state.displayMode); const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); - const selectedQuotaModels = useQuotaStore((state) => state.selectedModels); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); useQuotaAutoRefresh(); @@ -491,54 +424,7 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta ? { percentage: contextPercentage, tokens: contextTokens, colorClass: contextColorClass } : null; - const usageGroups = React.useMemo(() => { - const resultsByProvider = new Map(quotaResults.map((result) => [result.providerId, result])); - return QUOTA_PROVIDERS - .filter((providerMeta) => dropdownProviderIds.includes(providerMeta.id)) - .filter((providerMeta) => resultsByProvider.get(providerMeta.id)?.configured === true) - .map((providerMeta) => { - const result = resultsByProvider.get(providerMeta.id)!; - const rows: MobileUsageLimitRow[] = []; - - for (const [label, window] of Object.entries(result?.usage?.windows ?? {})) { - rows.push({ - key: `window-${label}`, - label: formatWindowLabel(label), - window, - }); - } - - const modelEntries = Object.entries(result?.usage?.models ?? {}); - const providerSelectedModels = selectedQuotaModels[providerMeta.id] ?? []; - const visibleModelEntries = providerSelectedModels.length > 0 - ? modelEntries.filter(([modelName]) => providerSelectedModels.includes(modelName)) - : modelEntries; - for (const [modelName, modelUsage] of visibleModelEntries) { - const entries = Object.entries(modelUsage.windows ?? {}); - if (entries.length === 0) continue; - const [label, window] = entries[0]; - rows.push({ - key: `model-${modelName}-${label}`, - label: formatWindowLabel(label), - subtitle: getDisplayModelName(modelName), - window, - }); - } - - const status = !result.ok && result.error - ? result.error - : rows.length === 0 - ? t('header.services.noRateLimitsReported') - : null; - - return { - providerId: providerMeta.id, - providerName: providerMeta.name, - rows, - status, - }; - }); - }, [dropdownProviderIds, quotaResults, selectedQuotaModels, t]); + const usageGroups = useUsageProviderGroups(); React.useEffect(() => { if (!open || usageGroups.length === 0) return; diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 17aee5c4..6799093a 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -50,6 +50,8 @@ import { usePlanDetection } from '@/hooks/usePlanDetection'; import { useI18n } from '@/lib/i18n'; import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { WorkStatusPanel } from './work-status/WorkStatusPanel'; +import { useWorkStatusVisibility } from './work-status/useWorkStatusVisibility'; import { getEmbeddedSessionChatOriginSessionId } from '@/components/layout/contextPanelEmbeddedChat'; import { isFullySyntheticMessage } from '@/lib/messages/synthetic'; import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts'; @@ -694,6 +696,49 @@ export const ChatContainer: React.FC = ({ active = true, aut // composer enters the same fullscreen-input mode via its drag handle. const isDesktopExpandedInput = isExpandedInput; const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat'; + // Work-status panel: a borderless column to the right of the transcript. + // It yields to the context panel and to a narrow chat; `rowRef` goes on the + // row that holds both columns, so its width never depends on the panel's + // own visibility. + const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({ + directory: effectiveSessionDirectory, + isMobile, + isVSCode, + }); + // Session view only. The draft branch returns its own layout before this + // one, so the panel has no place there yet. + // Surfaces that never host the panel skip it entirely; the rest keep it + // mounted so its visibility can animate rather than snap. + const workStatusPanelMountable = !isMobile + && !isVSCode + && chatSurfaceMode !== 'mini-chat' + && !isDesktopExpandedInput; + const showWorkStatusPanel = workStatusPanelMountable && workStatusVisible; + + // Offered over the chat when there is no room beside it. The panel is still + // switched on; only the layout refuses it. + const workStatusPanelEnabled = useUIStore((state) => state.workStatusPanelEnabled); + const workStatusOverlayOpen = useUIStore((state) => state.workStatusOverlayOpen); + const setWorkStatusPanelFits = useUIStore((state) => state.setWorkStatusPanelFits); + // Mounted whenever it could be shown, not only while it is: an element + // that appears and disappears with the condition has nothing to animate. + const workStatusOverlayMountable = workStatusPanelMountable + && workStatusPanelEnabled + && !workStatusFits; + const showWorkStatusOverlay = workStatusOverlayMountable && workStatusOverlayOpen; + + React.useEffect(() => { + setWorkStatusPanelFits(workStatusPanelMountable && workStatusFits); + return () => setWorkStatusPanelFits(false); + }, [setWorkStatusPanelFits, workStatusFits, workStatusPanelMountable]); + + // Published so the header can drop the readouts the panel already carries. + // Cleared on unmount: a chat that goes away is not showing anything. + const setWorkStatusPanelVisible = useUIStore((state) => state.setWorkStatusPanelVisible); + React.useEffect(() => { + setWorkStatusPanelVisible(showWorkStatusPanel); + return () => setWorkStatusPanelVisible(false); + }, [setWorkStatusPanelVisible, showWorkStatusPanel]); const messageListRef = React.useRef(null); const currentSession = useSession(currentSessionId, effectiveSessionDirectory); const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory); @@ -1152,7 +1197,8 @@ export const ChatContainer: React.FC = ({ active = true, aut } return ( -
+
+
{returnToParentButton} = ({ active = true, aut {promptReadOnly ? : }
+ {/* Inside the chat column, not beside it: as a row sibling it took + part in the flex layout and pushed the transcript, which is the + one thing an overlay must not do. */} + {workStatusOverlayMountable ? ( + + ) : null} + = ({ active = true, aut onLoadEarlier={handleLoadOlderClick} />
+ {/* Kept mounted while it could ever show, so it can animate its own + collapse; `visible` drives that. Unmounting on the spot is what made + the chat jump wide before easing narrow again. */} + {workStatusPanelMountable ? ( + + ) : null} +
); }; diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 9e055d41..89c7fcf6 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -16,6 +16,7 @@ import { } from '@/sync/attachment-files'; import type { AttachedFile } from '@/stores/types/sessionTypes'; import * as sessionActions from '@/sync/session-actions'; +import { buildLinkedIssue } from '@/lib/linkedIssues'; import { useUserMessageHistory } from "@/sync/sync-context"; import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; import { useSnippetsStore } from '@/stores/useSnippetsStore'; @@ -1227,6 +1228,45 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } void sendPromise.then(() => { + // Record what this session was pointed at, so the work-status panel + // can show it as a context source long after the message scrolled + // away. A snapshot only — never re-fetched, never authoritative. + // Failures are swallowed: the message went out, and a missing + // bookkeeping entry must not surface as a send error. + const attachedThread = linkedIssue + ? { attachment: linkedIssue, kind: 'issue' as const } + : linkedPr + ? { attachment: linkedPr, kind: 'pull' as const } + : null; + // On a draft there is no session yet in this closure: the send path + // creates one and makes it current before resolving, so the id is + // read from the store. The fallback is used only when the closure + // had no session at all, so a mid-send session switch cannot + // redirect the write to an unrelated session. + const sessionState = useSessionUIStore.getState(); + const linkTargetSessionId = currentSessionId ?? sessionState.currentSessionId; + const linkTargetDirectory = currentSessionId + ? currentSessionDirectoryForSync ?? currentDirectory + : sessionState.currentSessionDirectory + ?? (linkTargetSessionId ? sessionState.getDirectoryForSession(linkTargetSessionId) : null) + ?? currentDirectory; + + if (attachedThread && linkTargetSessionId) { + void sessionActions.setLinkedIssue( + linkTargetSessionId, + linkTargetDirectory, + buildLinkedIssue({ + url: attachedThread.attachment.url, + number: attachedThread.attachment.number, + title: attachedThread.attachment.title, + kind: attachedThread.kind, + author: attachedThread.attachment.author, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + } + // Clear linked issue after successful message send if (linkedIssue) { setLinkedIssue(null); @@ -2221,6 +2261,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const footerGapClass = 'gap-x-1.5 gap-y-0'; const isVSCode = isVSCodeRuntime(); + // The work-status panel carries the agent's todos and the changed-file + // count, but only on the desktop/web layout — VS Code and mobile have no + // panel, so these keep their place above the composer there. + const composerStatusExtrasEnabled = isVSCode || isMobile; const showDraftTargetSelectors = newSessionDraftOpen && !isVSCode; // Which project and directory a new session will target. @@ -2485,8 +2529,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } + showTodos={composerStatusExtrasEnabled} + leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges + ? null + : } /> {!isMobile && showDraftTargetSelectors && selectedDraftProject ? ( = React.memo(({ const liveGoal = goal && goal.status !== 'complete' ? goal : null; const isEngaged = armed || Boolean(liveGoal); - const colorClass = (() => { - if (goal?.status === 'complete') return 'text-[var(--status-success)]'; - if (goal?.status === 'blocked' || goal?.status === 'budgetLimited') return 'text-[var(--status-error)]'; - if (armed || goal?.status === 'active' || goal?.status === 'paused') return 'text-[var(--status-info)]'; - return ''; - })(); + // One mapping for every goal surface. This button used to carry its own, + // which painted `paused` the same info colour as `active` — so a paused goal + // was indistinguishable from a running one — and `blocked` as an error rather + // than a warning. `armed` is not a goal status, so it keeps its own case. + const iconColor = goal + ? sessionGoalStatusColor[goal.status] + : (armed ? 'var(--status-info)' : undefined); const label = goal ? t('chat.goal.button.manageAria') @@ -74,7 +76,8 @@ export const SessionGoalButton: React.FC = React.memo(({ const button = ( + + {contentMounted ? ( + + + } + /> + {sectionVisible('usage') ? : null} + {sectionVisible('subagents') ? : null} + {sectionVisible('tasks') ? : null} + {sectionVisible('mcp') ? : null} + {sectionVisible('pinned') ? : null} + {sectionVisible('contextSources') ? : null} + + + ) : null} + + + + ); +}; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPinnedSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPinnedSection.tsx new file mode 100644 index 00000000..90e9cbaf --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusPinnedSection.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { toast } from 'sonner'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { useDirectorySync, useEnsureSessionMessages, useSession } from '@/sync/sync-context'; +import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages'; +import { setContextObligatoryMessage } from '@/sync/session-actions'; +import { WorkStatusRow, WorkStatusSection } from './WorkStatusPrimitives'; +import { useReportWorkStatusPresence } from './presenceContext'; +import type { State } from '@/sync/types'; + +type Props = { + sessionId: string | null; + directory: string | null; +}; + +/** + * Messages pinned into the context. + * + * The row carries two destinations, so the pin is its own button: pressing the + * pin unpins, pressing the text takes you to the message. + */ +export const WorkStatusPinnedSection: React.FC = ({ sessionId, directory }) => { + const { t } = useI18n(); + const session = useSession(sessionId ?? '', directory ?? undefined); + const parts = useDirectorySync(React.useCallback((state: State) => state.part, [])); + const [busyId, setBusyId] = React.useState(null); + + const pinned = React.useMemo(() => { + const entries = getContextObligatoryMessages(session); + if (entries.length === 0) return []; + return entries.map((entry) => { + const messageParts = parts[entry.id] ?? []; + const text = messageParts.find( + (part): part is Extract => part.type === 'text', + )?.text?.trim(); + return { id: entry.id, text: text || null }; + }); + }, [session, parts]); + + // Pinned messages are most useful on a long session — which is exactly when + // the pinned message has scrolled far enough back not to be loaded, leaving + // the row with a placeholder instead of its text. Materialise the session, + // but only when a pin actually resolves to nothing: having pins is not a + // reason to fetch, and neither is something being unloaded in general. + const hasUnresolvedPin = pinned.length > 0 && pinned.some((entry) => entry.text === null); + useEnsureSessionMessages(sessionId ?? '', directory ?? undefined, hasUnresolvedPin); + + const handleUnpin = React.useCallback(async (messageId: string) => { + if (!sessionId || busyId) return; + setBusyId(messageId); + try { + // Only the id matters when unpinning — `withContextObligatoryMessage` + // filters by it and discards the rest of the payload. + await setContextObligatoryMessage( + sessionId, + directory, + { id: messageId, createdAt: 0, role: 'user' }, + false, + ); + } catch { + toast.error(t('chat.workStatus.pinned.unpinFailed')); + } finally { + setBusyId((current) => (current === messageId ? null : current)); + } + }, [busyId, directory, sessionId, t]); + + // The transcript listens for `#message-` and scrolls there; it is the + // only cross-component jump the chat exposes. An unchanged hash fires no + // event, so clear it first to make a repeat press work. + const handleReveal = React.useCallback((messageId: string) => { + if (typeof window === 'undefined') return; + const target = `#message-${messageId}`; + if (window.location.hash === target) { + window.history.replaceState(null, '', window.location.pathname + window.location.search); + } + window.location.hash = target; + }, []); + + useReportWorkStatusPresence('pinned', pinned.length > 0); + + if (pinned.length === 0) return null; + + return ( + + {pinned.map((entry) => ( + { + event.stopPropagation(); + void handleUnpin(entry.id); + }} + className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-40" + > + + + )} + muted + label={entry.text ?? t('chat.workStatus.pinned.unavailable')} + onClick={() => handleReveal(entry.id)} + ariaLabel={t('chat.workStatus.pinned.reveal')} + /> + ))} + + ); +}; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx new file mode 100644 index 00000000..559ba05a --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx @@ -0,0 +1,323 @@ +import React from 'react'; +import { useI18n } from '@/lib/i18n'; +import { useGitStore } from '@/stores/useGitStore'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { runBackgroundNetworkTask } from '@/lib/background-network'; +import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore'; +import { useSession, useSessionMessages } from '@/sync/sync-context'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { normalizeProjectPath } from '@/lib/projectResolution'; +import { resolveUsageTone } from '@/lib/quota'; +import { computeContextUsage } from './contextUsage'; +import { + WorkStatusCallout, + WorkStatusMeter, + WorkStatusPill, + WorkStatusRow, + WorkStatusSection, + WorkStatusValue, +} from './WorkStatusPrimitives'; +import { useReportWorkStatusPresence } from './presenceContext'; + +type Props = { + sessionId: string | null; + directory: string | null; + /** Rendered first inside the Session section; owns its own dialog. */ + goalRow: React.ReactNode; + showSession: boolean; + showRepository: boolean; +}; + +// Spend is read against a budget, so it keeps its real precision instead of +// collapsing to two decimals. Trailing zeros are dropped so exact values stay +// short. +const trimZeros = (value: string): string => (value.includes('.') ? value.replace(/0+$/, '').replace(/\.$/, '') : value); +const formatCost = (cost: number): string => `$${trimZeros(cost.toFixed(4))}`; +// Matches the header readout exactly: one decimal, capped the same way, so the +// two places that report context fill never disagree by a rounding step. +const formatPercent = (percent: number): string => `${Math.min(percent, 999).toFixed(1)}%`; + +/** + * The persistent readouts — how full the context is, what the working tree and + * the pull request look like. All of it stays true for as long as the session + * is open, so it sits above anything episodic. + */ +export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, goalRow, showSession, showRepository }) => { + const { t } = useI18n(); + const session = useSession(sessionId ?? '', directory ?? undefined); + const { git } = useRuntimeAPIs(); + const ensureStatus = useGitStore((state) => state.ensureStatus); + + const gitStatus = useGitStore( + React.useCallback( + (state) => (directory ? state.directories.get(directory)?.status ?? null : null), + [directory], + ), + ); + + // Warm the shared git cache through the background-network gate so the panel + // never competes with the chat's own bootstrap traffic for sockets. + React.useEffect(() => { + if (!directory || !git) return; + void runBackgroundNetworkTask(() => ensureStatus(directory, git)); + }, [directory, git, ensureStatus]); + + const branch = gitStatus?.current?.trim() || null; + + // The panel's directory can be a worktree, so the project is the registered + // one whose path contains it — longest match wins, since projects can nest. + const projectLabel = useProjectsStore( + React.useCallback((state) => { + const normalizedDirectory = normalizeProjectPath(directory ?? null); + if (!normalizedDirectory) return null; + let best: { path: string; label: string } | null = null; + for (const project of state.projects) { + const projectPath = normalizeProjectPath(project.path); + if (!projectPath) continue; + const contains = normalizedDirectory === projectPath + || normalizedDirectory.startsWith(`${projectPath}/`); + if (!contains) continue; + if (best && best.path.length >= projectPath.length) continue; + const label = project.label?.trim() + || projectPath.split('/').filter(Boolean).pop() + || projectPath; + best = { path: projectPath, label }; + } + return best?.label ?? null; + }, [directory]), + ); + + // Read-only: PR watching is owned by the background tracker. Starting a watch + // here would multiply GitHub requests per open session, which is exactly the + // fan-out the PR-status concurrency gate exists to prevent. + const prKey = React.useMemo( + () => (directory && branch ? getGitHubPrStatusKey(directory, branch) : null), + [directory, branch], + ); + const prSummary = usePrVisualSummary(prKey); + + // `getCurrentModel` is an imperative getter: its reference never changes, so + // calling it in render subscribes to nothing. Subscribe to the selected model + // ids and recompute the limits from those. + const getCurrentModel = useConfigStore((state) => state.getCurrentModel); + const currentProviderId = useConfigStore((state) => state.currentProviderId); + const currentModelId = useConfigStore((state) => state.currentModelId); + const sessionMessages = useSessionMessages(sessionId ?? '', directory ?? undefined); + + const contextLimit = React.useMemo(() => { + const currentModel = getCurrentModel(); + const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null + ? (currentModel.limit as Record) + : null; + return limit && typeof limit.context === 'number' ? limit.context : 0; + // eslint-disable-next-line react-hooks/exhaustive-deps -- getter output tracks the selected model ids + }, [getCurrentModel, currentProviderId, currentModelId]); + + // Computed from this session's own messages rather than through + // `useSessionUIStore.getContextUsage`, which reads the *current* directory's + // store and so loses the readout for any session held elsewhere. See + // `contextUsage.ts`. + const contextUsage = React.useMemo( + () => computeContextUsage(sessionMessages, contextLimit), + [sessionMessages, contextLimit], + ); + + const openContextSurface = useUIStore((state) => state.openContextSurface); + const openContextOverview = useUIStore((state) => state.openContextOverview); + const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); + const openSurface = React.useCallback( + (mode: 'git' | 'pr') => { if (directory) openContextSurface(directory, mode); }, + [directory, openContextSurface], + ); + // Working-tree diff without a target path: the panel opens on the whole + // change set rather than picking a file on the user's behalf. + // Same destination as the header's context readout. + const openContext = React.useCallback(() => { + if (directory) openContextOverview(directory); + }, [directory, openContextOverview]); + + const openChanges = React.useCallback(() => { + if (directory) openContextPanelTab(directory, { mode: 'diff', diffScope: 'working' }); + }, [directory, openContextPanelTab]); + + // Working-tree changes, from the same git status the Git panel reads. + // + // `Session.summary` looks like the natural source and is not: OpenCode resets + // it to zeros at the start of every turn and only ever fills per-message + // `summary.diffs`, so session-level totals are always 0/0/0. The `session.diff` + // event is reset to an empty array too, and carries real content only on + // revert. Git status is the one authoritative, already-cached answer. + const changed = React.useMemo(() => { + const files = gitStatus?.files ?? []; + if (files.length === 0) return null; + const stats = gitStatus?.diffStats; + let additions = 0; + let deletions = 0; + if (stats) { + for (const entry of Object.values(stats)) { + additions += entry?.insertions ?? 0; + deletions += entry?.deletions ?? 0; + } + } + return { files: files.length, additions, deletions, hasStats: Boolean(stats) }; + }, [gitStatus?.files, gitStatus?.diffStats]); + + const attentionReason = gitStatus?.attentionReason + ?? (gitStatus?.rebaseInProgress ? 'rebase' : null) + ?? (gitStatus?.mergeInProgress ? 'merge' : null); + const attentionLabel = attentionReason === 'merge' ? t('chat.workStatus.attention.merge') + : attentionReason === 'rebase' ? t('chat.workStatus.attention.rebase') + : attentionReason === 'cherry-pick' ? t('chat.workStatus.attention.cherryPick') + : attentionReason === 'revert' ? t('chat.workStatus.attention.revert') + : attentionReason === 'bisect' ? t('chat.workStatus.attention.bisect') + : null; + + const usagePercent = contextUsage?.percent ?? null; + // Colour threshold uses the rounded percentage, matching what the header + // feeds `resolveUsageTone`; the displayed number stays unrounded. + const usageTone = usagePercent === null ? null : resolveUsageTone(Math.round(usagePercent)); + // Same tone ramp as the header's context icon — healthy is success, not + // primary, so a full bar reads as a warning rather than as brand colour. + const meterColor = usageTone === 'critical' ? 'var(--status-error)' + : usageTone === 'warn' ? 'var(--status-warning)' + : 'var(--status-success)'; + + const cost = typeof session?.cost === 'number' && session.cost > 0 ? session.cost : null; + const hasSession = showSession && (usagePercent !== null || cost !== null || Boolean(goalRow)); + const hasRepository = showRepository && Boolean(branch || changed || prSummary || attentionLabel); + + useReportWorkStatusPresence('session-repository', hasSession || hasRepository); + + if (!hasSession && !hasRepository) return null; + + return ( + <> + {hasSession ? ( + + {usagePercent !== null ? ( + <> + + {formatPercent(usagePercent)} + {/* No icon of its own: the sprite has no currency glyph, and + spend belongs with consumption anyway. The `$` labels it. */} + {cost !== null ? {formatCost(cost)} : null} + + )} + /> + + + ) : null} + {/* Below the context readout: the goal is a standing instruction, + while context is the live number the reader came for. */} + {goalRow} + + ) : null} + + {hasRepository ? ( + + {attentionLabel ? {attentionLabel} : null} + + {/* Branch first: the changes below are the changes *on it*, and the + row reads as a caption to the branch rather than a loose number. */} + {branch ? ( + openSurface('git') : undefined} + ariaLabel={t('chat.workStatus.action.openGit')} + label={branch} + value={(gitStatus?.ahead ?? 0) > 0 || (gitStatus?.behind ?? 0) > 0 ? ( + <> + {(gitStatus?.ahead ?? 0) > 0 + ? {`↑${gitStatus?.ahead}`} : null} + {(gitStatus?.behind ?? 0) > 0 + ? {`↓${gitStatus?.behind}`} : null} + + ) : undefined} + /> + ) : null} + + {changed ? ( + 0 || changed.deletions > 0) ? ( + <> + {`+${changed.additions}`} + {/* Neutral separator: colouring it would imply it carries a + status of its own. */} + / + {`−${changed.deletions}`} + + ) : undefined} + /> + ) : null} + + {prSummary ? ( + <> + openSurface('pr') : undefined} + ariaLabel={t('chat.workStatus.action.openPr')} + iconColor={`var(--pr-${prSummary.visualState})`} + label={prSummary.title ?? t('chat.workStatus.pr.untitled')} + value={( + + {prSummary.draft ? t('chat.workStatus.pr.draft') : `#${prSummary.number}`} + + )} + /> + {prSummary.checks && prSummary.checks.total > 0 ? ( + openSurface('pr') : undefined} + ariaLabel={t('chat.workStatus.action.openPr')} + label={t('chat.workStatus.pr.checks')} + muted + value={( + <> + {prSummary.checks.failure > 0 ? ( + + {t('chat.workStatus.pr.checksFailed', { count: prSummary.checks.failure })} + + ) : null} + {prSummary.checks.pending > 0 ? ( + + {t('chat.workStatus.pr.checksPending', { count: prSummary.checks.pending })} + + ) : null} + {prSummary.checks.failure === 0 && prSummary.checks.pending === 0 ? ( + + {t('chat.workStatus.pr.checksPassed', { count: prSummary.checks.success })} + + ) : null} + + )} + /> + ) : null} + + ) : null} + + ) : null} + + ); +}; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimitives.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimitives.tsx new file mode 100644 index 00000000..24fe76d7 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimitives.tsx @@ -0,0 +1,266 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import { Icon } from '@/components/icon/Icon'; +import { useUIStore } from '@/stores/useUIStore'; +import type { IconName } from '@/components/icon/icons'; + +/** + * Row/section vocabulary for the work-status panel. + * + * Every readout is a labelled row — icon, name, trailing value — so a glance + * answers "what is this number" without hovering. Sections carry a heading and + * are separated by a hairline; the panel itself stays chrome-less, since it is + * an object inside the chat rather than a docked pane. + */ + +/** + * Sections are direct siblings inside the panel (fragments add no DOM nodes), + * so the separator is a first-child CSS rule. Passing "am I first?" down as a + * prop would mean every group tracking what the groups above it decided to + * render. + */ +const SECTION_CLASS = cn( + 'flex flex-col', + '[&:not(:first-child)]:mt-3 [&:not(:first-child)]:border-t', + '[&:not(:first-child)]:border-[var(--interactive-border)] [&:not(:first-child)]:pt-3', +); + +const HEADING_CLASS = 'text-xs font-normal text-muted-foreground'; + +export const WorkStatusSection: React.FC<{ + title: string; + /** Aggregate for the whole section; belongs on the heading, not on a row. */ + summary?: React.ReactNode; + children: React.ReactNode; +}> = ({ title, summary, children }) => ( +
+
+

{title}

+ {summary !== undefined && summary !== null ? ( + {summary} + ) : null} +
+ {children} +
+); + +/** + * Section whose body folds away. The chevron swaps on expand exactly as the + * transcript's tool blocks do, so the two collapsibles read as the same + * control rather than two conventions in one window. + * + * Expanded state lives in the persisted UI store, not in component state: the + * panel unmounts whenever the context panel opens, and local state would + * silently discard the user's arrangement every time. + */ +export const WorkStatusCollapsibleSection: React.FC<{ + /** Stable key for persisting expanded state. */ + id: string; + title: string; + icon?: IconName; + /** For glyphs that live outside the sprite, such as the MCP mark. */ + iconNode?: React.ReactNode; + iconColor?: string; + /** Shown on the header while collapsed and expanded alike. */ + summary?: React.ReactNode; + defaultExpanded?: boolean; + children: React.ReactNode; +}> = ({ id, title, icon, iconNode, iconColor, summary, defaultExpanded = false, children }) => { + const stored = useUIStore( + React.useCallback((state) => state.workStatusExpandedSections[id], [id]), + ); + const setExpandedInStore = useUIStore((state) => state.setWorkStatusSectionExpanded); + const expanded = stored ?? defaultExpanded; + return ( +
+ + {expanded ? children : null} +
+ ); +}; + +type RowProps = { + icon?: IconName; + iconColor?: string; + leading?: React.ReactNode; + label: React.ReactNode; + value?: React.ReactNode; + muted?: boolean; + /** Turns the row into a button; the caller decides what it opens. */ + onClick?: () => void; + ariaLabel?: string; + className?: string; +}; + +/** + * A single readout. `value` sits hard right; `label` truncates before it, so a + * long branch name never pushes its own ahead/behind counts out of view. + */ +export const WorkStatusRow: React.FC = ({ + icon, + iconColor, + leading, + label, + value, + muted, + onClick, + ariaLabel, + className, +}) => { + const body = ( + <> + {leading ?? (icon ? ( + + ) : null)} + + {label} + + {value !== undefined && value !== null ? ( + {value} + ) : null} + + ); + + const shared = cn('flex h-7 w-full items-center gap-2 rounded-md px-1 text-left', className); + + if (!onClick) return
{body}
; + + return ( + + ); +}; + +type WorkStatusTone = 'default' | 'muted' | 'success' | 'error' | 'warning' | 'info'; + +const TONE_COLOR: Record, string> = { + success: 'var(--status-success)', + error: 'var(--status-error)', + warning: 'var(--status-warning)', + info: 'var(--status-info)', +}; + +export const WorkStatusValue: React.FC<{ + children: React.ReactNode; + tone?: WorkStatusTone; +}> = ({ children, tone = 'default' }) => ( + + {children} + +); + +/** + * Trailing control shaped like the PR badge: a status that is also the thing + * you press. Used where the state itself is the affordance — an MCP server + * asking for sign-in, a goal waiting to be resumed. + */ +export const WorkStatusRowAction: React.FC<{ + children: React.ReactNode; + onClick: () => void; + tone?: 'default' | 'warning' | 'error' | 'info'; + disabled?: boolean; + ariaLabel?: string; +}> = ({ children, onClick, tone = 'default', disabled, ariaLabel }) => { + const color = tone === 'default' ? undefined : TONE_COLOR[tone]; + return ( + + ); +}; + +export const WorkStatusPill: React.FC<{ + children: React.ReactNode; + color?: string; + background?: string; +}> = ({ children, color, background }) => ( + + {children} + +); + +/** Full-width callout for states that block the branch (merge, rebase, …). */ +export const WorkStatusCallout: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
+ + {children} +
+); + +/** Context-window fill, drawn under its row rather than inside it. */ +export const WorkStatusMeter: React.FC<{ percent: number; color: string }> = ({ percent, color }) => ( +
+
+
+); diff --git a/packages/ui/src/components/chat/work-status/WorkStatusSectionsDialog.tsx b/packages/ui/src/components/chat/work-status/WorkStatusSectionsDialog.tsx new file mode 100644 index 00000000..a2370c02 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusSectionsDialog.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useI18n } from '@/lib/i18n'; +import { useUIStore } from '@/stores/useUIStore'; +import { SettingsCheckboxRow } from '@/components/sections/shared/SettingsSection'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + WORK_STATUS_SECTION_IDS, + WORK_STATUS_SECTION_LABEL_KEYS, + isWorkStatusSectionVisible, +} from './sections'; + +/** + * Which sections the work-status panel may show. + * + * Everything is on by default and the choice is stored as the *hidden* set, so + * a section added in a later release appears for everyone rather than staying + * invisible to whoever had saved settings before it existed. + */ +export const WorkStatusSectionsDialog: React.FC<{ + open: boolean; + onOpenChange: (open: boolean) => void; +}> = ({ open, onOpenChange }) => { + const { t } = useI18n(); + const hidden = useUIStore((state) => state.workStatusHiddenSections); + const setSectionVisible = useUIStore((state) => state.setWorkStatusSectionVisible); + + return ( + + + + {t('chat.workStatus.sections.dialogTitle')} + {t('chat.workStatus.sections.dialogDescription')} + + +
+ {WORK_STATUS_SECTION_IDS.map((sectionId) => ( + setSectionVisible(sectionId, checked)} + label={t(WORK_STATUS_SECTION_LABEL_KEYS[sectionId])} + ariaLabel={t(WORK_STATUS_SECTION_LABEL_KEYS[sectionId])} + /> + ))} +
+
+
+ ); +}; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusSubagentsSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusSubagentsSection.tsx new file mode 100644 index 00000000..f670efeb --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusSubagentsSection.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { useI18n } from '@/lib/i18n'; +import { useAllLiveSessions, useAllSessionStatuses, useDirectorySync } from '@/sync/sync-context'; +import { useUIStore } from '@/stores/useUIStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { isVSCodeRuntime } from '@/lib/desktop'; +import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; +import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives'; +import { useReportWorkStatusPresence } from './presenceContext'; +import type { State } from '@/sync/types'; + +type Props = { + sessionId: string | null; + directory: string | null; +}; + +const SECTION_ID = 'subagents'; + +/** + * Running subagents and, more importantly, their blockers: a permission request + * raised by a child session has no representation in the transcript, so this + * panel is the only place it becomes visible. + */ +export const WorkStatusSubagentsSection: React.FC = ({ sessionId, directory }) => { + const { t } = useI18n(); + const isMobile = useUIStore((state) => state.isMobile); + + const liveSessions = useAllLiveSessions(); + const statuses = useAllSessionStatuses(); + const children = React.useMemo( + () => (sessionId ? liveSessions.filter((candidate) => candidate.parentID === sessionId) : []), + [liveSessions, sessionId], + ); + + // One subscription covers every child: per-session hooks would multiply + // store subscriptions by the number of subagents. + const permissions = useDirectorySync(React.useCallback((state: State) => state.permission, [])); + const questions = useDirectorySync(React.useCallback((state: State) => state.question, [])); + + const openContextPanelTab = useUIStore((state) => state.openContextPanelTab); + const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); + const setSectionExpanded = useUIStore((state) => state.setWorkStatusSectionExpanded); + + // Subagents appearing where there were none is the one moment this section + // has something urgent to say, so it opens itself. Only on the empty→present + // edge: re-expanding on every count change would fight a user who just + // collapsed it. + const hadChildren = React.useRef(children.length > 0); + React.useEffect(() => { + const present = children.length > 0; + if (present && !hadChildren.current) setSectionExpanded(SECTION_ID, true); + hadChildren.current = present; + }, [children.length, setSectionExpanded]); + + // Same branch the transcript's Task tool takes: surfaces that cannot host an + // embedded panel navigate to the child session instead of opening a tab. + const openChildSession = React.useCallback((childId: string, label: string) => { + if (!directory) return; + if (isEmbeddedSessionChat() || isMobile || isVSCodeRuntime()) { + setCurrentSession(childId, directory); + return; + } + openContextPanelTab(directory, { + mode: 'chat', + dedupeKey: `session:${childId}`, + label, + readOnly: true, + }); + }, [directory, isMobile, openContextPanelTab, setCurrentSession]); + + useReportWorkStatusPresence('subagents', children.length > 0); + + if (children.length === 0) return null; + + const busyChildren = children.filter((child) => statuses[child.id]?.type === 'busy').length; + + return ( + 0 ? `${busyChildren}/${children.length}` : children.length} + > + {children.map((child) => { + const blocked = (permissions[child.id]?.length ?? 0) > 0; + const asked = (questions[child.id]?.length ?? 0) > 0; + const busy = statuses[child.id]?.type === 'busy'; + const label = child.title?.trim() || t('chat.workStatus.subagent.untitled'); + return ( + openChildSession(child.id, label) : undefined} + ariaLabel={t('chat.workStatus.action.openSubagent', { name: label })} + label={label} + value={blocked ? ( + {t('chat.workStatus.subagent.needsPermission')} + ) : asked ? ( + {t('chat.workStatus.subagent.askedQuestion')} + ) : busy ? ( + {t('chat.workStatus.subagent.working')} + ) : ( + {t('chat.workStatus.subagent.done')} + )} + /> + ); + })} + + ); +}; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusTasksSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusTasksSection.tsx new file mode 100644 index 00000000..31d1e71f --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusTasksSection.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useDirectorySync } from '@/sync/sync-context'; +import { useTodosPersistStore } from '@/stores/useTodosPersistStore'; +import { WorkStatusRow, WorkStatusSection } from './WorkStatusPrimitives'; +import { useReportWorkStatusPresence } from './presenceContext'; +import type { State } from '@/sync/types'; +import type { Todo } from '@opencode-ai/sdk/v2'; + +type Props = { + sessionId: string | null; + directory: string | null; +}; + +const EMPTY_TODOS: Todo[] = []; + +/** + * Work first, then what is waiting, then what is done — the panel is read + * top-down for "what is happening", and a finished item never answers that. + * Unlike the composer's dropdown, completed items stay: this is a record of the + * session, not a queue to work through. + */ +const STATUS_RANK: Record = { + in_progress: 0, + pending: 1, + completed: 2, +}; + +/** Same icons the composer's todo dropdown uses, so one list does not read as two. */ +const statusIcon = (status: string): { name: 'record-circle' | 'checkbox-circle' | 'time'; color?: string } => { + if (status === 'in_progress') return { name: 'record-circle', color: 'var(--status-info)' }; + if (status === 'completed') return { name: 'checkbox-circle', color: 'var(--status-success)' }; + return { name: 'time' }; +}; + +export const WorkStatusTasksSection: React.FC = ({ sessionId, directory }) => { + const { t } = useI18n(); + + const liveTodos = useDirectorySync( + React.useCallback( + (state: State) => (sessionId ? state.todo[sessionId] ?? EMPTY_TODOS : EMPTY_TODOS), + [sessionId], + ), + ); + const persistedTodos = useTodosPersistStore( + React.useCallback( + (state) => (sessionId && directory ? state.getSessionTodos(directory, sessionId) : undefined), + [directory, sessionId], + ), + ); + // Live channel wins; persistence only restores context for a session whose + // todo events predate this client's connection. + const todos = liveTodos.length > 0 ? liveTodos : persistedTodos ?? EMPTY_TODOS; + + const visibleTodos = React.useMemo(() => { + const kept = todos + .map((todo, index) => ({ todo, index })) + .filter(({ todo }) => todo.status !== 'cancelled'); + // Stable within a rank: the agent's own ordering carries meaning, so only + // the status grouping is imposed on top of it. + return kept + .sort((left, right) => { + const rank = (STATUS_RANK[left.todo.status] ?? 1) - (STATUS_RANK[right.todo.status] ?? 1); + return rank !== 0 ? rank : left.index - right.index; + }) + .map(({ todo }) => todo); + }, [todos]); + + useReportWorkStatusPresence('tasks', visibleTodos.length > 0); + + if (visibleTodos.length === 0) return null; + + const doneCount = visibleTodos.filter((todo) => todo.status === 'completed').length; + + return ( + + {visibleTodos.map((todo, index) => { + const done = todo.status === 'completed'; + const icon = statusIcon(todo.status); + return ( + + +
+ + )} + muted={done} + label={{todo.content}} + /> +
+
+ {/* Rows truncate at this width; the tooltip is the only way to read + a long task in full. */} + + {todo.content} + +
+ ); + })} +
+ ); +}; diff --git a/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx new file mode 100644 index 00000000..f35719e0 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { ProviderLogo } from '@/components/ui/ProviderLogo'; +import { preloadProviderLogos } from '@/hooks/useProviderLogo'; +import { formatQuotaResetLabel, formatQuotaValueLabel } from '@/lib/quota'; +import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useUsageProviderGroups } from '@/components/usage/usageGroups'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { pickUsageHeadline } from './usageHeadline'; +import { runBackgroundNetworkTask } from '@/lib/background-network'; +import { WorkStatusRow, WorkStatusCollapsibleSection, WorkStatusValue } from './WorkStatusPrimitives'; +import { useReportWorkStatusPresence } from './presenceContext'; +import type { UsageWindow } from '@/types'; + +/** + * Provider rate limits. + * + * The mobile popover renders these as filled cards; that language does not + * survive here — the fills and their padding fight the panel's flat rows and + * cost roughly twice the height. Only the data is shared + * (`useUsageProviderGroups`); the presentation is the panel's own row + * vocabulary, with each provider as a quiet sub-heading. + * + * Sits above Subagents and MCP: a spent quota stops the work outright, so it + * belongs with the readouts that hold for the whole session rather than with + * whatever happens to be running. + */ + +const windowTone = (window: UsageWindow): 'default' | 'warning' | 'error' => { + const used = window.usedPercent; + if (typeof used !== 'number' || !Number.isFinite(used)) return 'default'; + if (used >= 80) return 'error'; + if (used >= 50) return 'warning'; + return 'default'; +}; + +export const WorkStatusUsageSection: React.FC = () => { + const { t } = useI18n(); + const groups = useUsageProviderGroups(); + const displayMode = useQuotaStore((state) => state.displayMode); + const isLoading = useQuotaStore((state) => state.isLoading); + const quotaResults = useQuotaStore((state) => state.results); + const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); + const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); + const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); + const currentProviderId = useConfigStore((state) => state.currentProviderId); + + // Keeps the periodic refresh running while the panel is mounted. + useQuotaAutoRefresh(); + + // `useQuotaAutoRefresh` only schedules an interval — it never performs the + // first fetch. That was owned by the header dropdown's open handler, so the + // panel stayed empty until the user opened it. Kick off the initial load for + // any enabled provider that has not reported yet, background-gated so it + // cannot compete with chat bootstrap traffic. + React.useEffect(() => { + if (isLoading || dropdownProviderIds.length === 0) return; + const missingProvider = dropdownProviderIds.some( + (providerId) => !quotaResults.some((result) => result.providerId === providerId), + ); + if (!missingProvider) return; + void runBackgroundNetworkTask(() => fetchAllQuotas()); + }, [dropdownProviderIds, fetchAllQuotas, isLoading, quotaResults]); + + React.useEffect(() => { + if (groups.length === 0) return; + preloadProviderLogos(groups.map((group) => group.providerId)); + }, [groups]); + + useReportWorkStatusPresence('usage', groups.length > 0); + + if (groups.length === 0) return null; + + const modeLabel = displayMode === 'remaining' + ? t('header.services.remaining') + : t('header.services.used'); + + // Collapsed, the section shows the tightest quota of the provider the + // composer is pointed at — the number that decides whether the next turn + // lands. With no match it falls back to the display-mode label rather than + // showing some other provider's quota as if it were the active one. + const headline = pickUsageHeadline(groups, currentProviderId); + const headlineMetric = headline + ? formatQuotaValueLabel( + headline.row.window.valueLabel, + displayMode === 'remaining' ? headline.row.window.remainingPercent : headline.row.window.usedPercent, + ) + : null; + + return ( + + {isLoading ? : null} + {headline && headlineMetric && headlineMetric !== '-' ? ( + <> + {headline.row.label} + {headlineMetric} + + ) : modeLabel} + + )} + > + {groups.map((group) => ( + + } + label={group.providerName} + muted + value={group.status && group.rows.length === 0 ? ( + {group.status} + ) : undefined} + /> + {group.rows.map((row) => { + const displayPercent = displayMode === 'remaining' + ? row.window.remainingPercent + : row.window.usedPercent; + const metricLabel = formatQuotaValueLabel(row.window.valueLabel, displayPercent); + const resetLabel = formatQuotaResetLabel( + row.window.resetAt, + row.window.resetAfterFormatted ?? row.window.resetAtFormatted, + timeFormatPreference, + ); + return ( + + + {row.subtitle ? `${row.subtitle} · ${row.label}` : row.label} + + {resetLabel ? ( + {resetLabel} + ) : null} + + )} + value={metricLabel === '-' ? undefined : ( + {metricLabel} + )} + /> + ); + })} + + ))} + + ); +}; diff --git a/packages/ui/src/components/chat/work-status/contextUsage.test.ts b/packages/ui/src/components/chat/work-status/contextUsage.test.ts new file mode 100644 index 00000000..c2e0a6e4 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/contextUsage.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test'; +import { computeContextUsage, DEFAULT_CONTEXT_LIMIT } from './contextUsage'; + +const assistant = (tokens: Record, id = 'msg') => ({ id, role: 'assistant', tokens }); + +describe('computeContextUsage', () => { + test('sums every token bucket of the newest reporting assistant message', () => { + const usage = computeContextUsage( + [assistant({ input: 100, output: 20, reasoning: 5, cache: { read: 800, write: 75 } })], + 2000, + ); + expect(usage?.totalTokens).toBe(1000); + expect(usage?.percent).toBe(50); + }); + + test('reports the latest turn rather than a sum across turns', () => { + // Each assistant turn reports the whole window it saw, so adding them up + // would report several times the real fill. + const usage = computeContextUsage( + [ + assistant({ input: 400, output: 0, reasoning: 0 }, 'old'), + assistant({ input: 900, output: 0, reasoning: 0 }, 'new'), + ], + 1000, + ); + expect(usage?.totalTokens).toBe(900); + }); + + test('skips user messages and assistant turns that reported nothing', () => { + const usage = computeContextUsage( + [ + assistant({ input: 300, output: 0, reasoning: 0 }, 'real'), + assistant({ input: 0, output: 0, reasoning: 0 }, 'zeroed'), + { id: 'user', role: 'user' }, + ], + 1000, + ); + expect(usage?.totalTokens).toBe(300); + }); + + test('leaves the percentage unrounded', () => { + // Rounding here is what made the panel print "34.0%" against the header's + // "33.6%". + const usage = computeContextUsage([assistant({ input: 336, output: 0, reasoning: 0 })], 1000); + expect(usage?.percent.toFixed(1)).toBe('33.6'); + }); + + test('falls back to the default limit when the model exposes none', () => { + const usage = computeContextUsage([assistant({ input: 20_000, output: 0, reasoning: 0 })], 0); + expect(usage?.limit).toBe(DEFAULT_CONTEXT_LIMIT); + expect(usage?.percent).toBe(10); + }); + + test('returns null when no message carries usable tokens', () => { + expect(computeContextUsage([], 1000)).toBeNull(); + expect(computeContextUsage([{ id: 'u', role: 'user' }], 1000)).toBeNull(); + expect(computeContextUsage([assistant({ input: 0, output: 0, reasoning: 0 })], 1000)).toBeNull(); + }); + + test('tolerates partial token payloads', () => { + const usage = computeContextUsage([assistant({ input: 10 })], 100); + expect(usage?.totalTokens).toBe(10); + }); +}); diff --git a/packages/ui/src/components/chat/work-status/contextUsage.ts b/packages/ui/src/components/chat/work-status/contextUsage.ts new file mode 100644 index 00000000..c30d57f6 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/contextUsage.ts @@ -0,0 +1,71 @@ +/** + * Context-window usage for a specific session. + * + * `useSessionUIStore.getContextUsage` cannot serve this panel. It reads + * `getSyncMessages(sessionId)` with **no directory**, which resolves to the + * *current* directory's child store, and it keys off the store's own + * `currentSessionId`. A session held by another directory — a worktree, or any + * moment right after a directory switch — therefore reads as "no messages" and + * the readout silently disappears while the header still shows a value. + * + * This computes the same quantity from messages the caller has already + * subscribed to for a known session and directory, so there is no hidden + * global read to race with. + */ + +type MessageTokens = { + input?: number; + output?: number; + reasoning?: number; + cache?: { read?: number; write?: number }; +}; + +type MessageLike = { + id?: string; + role?: string; + tokens?: MessageTokens; +}; + +type WorkStatusContextUsage = { + totalTokens: number; + /** Context limit actually used for the ratio, after the default fallback. */ + limit: number; + /** Unrounded, so the panel and the header cannot disagree by a rounding step. */ + percent: number; +}; + +/** The store's own fallback when a model exposes no context limit. */ +export const DEFAULT_CONTEXT_LIMIT = 200_000; + +const sumTokens = (tokens: MessageTokens): number => ( + (tokens.input ?? 0) + + (tokens.output ?? 0) + + (tokens.reasoning ?? 0) + + (tokens.cache?.read ?? 0) + + (tokens.cache?.write ?? 0) +); + +/** + * Usage from the newest assistant message that reported a non-zero token count. + * Each assistant turn reports the whole window it saw, so the latest one is the + * current fill — not a sum across turns. + */ +export const computeContextUsage = ( + messages: readonly MessageLike[], + contextLimit: number, +): WorkStatusContextUsage | null => { + if (messages.length === 0) return null; + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role !== 'assistant' || !message.tokens) continue; + + const totalTokens = sumTokens(message.tokens); + if (totalTokens <= 0) continue; + + const limit = contextLimit > 0 ? contextLimit : DEFAULT_CONTEXT_LIMIT; + return { totalTokens, limit, percent: (totalTokens / limit) * 100 }; + } + + return null; +}; diff --git a/packages/ui/src/components/chat/work-status/presence.tsx b/packages/ui/src/components/chat/work-status/presence.tsx new file mode 100644 index 00000000..0138e4f1 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/presence.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { PresenceContext } from './presenceContext'; + +/** + * Collects which sections rendered, so the panel can hide its card entirely + * when none did. See `presenceContext.ts` for why sections report rather than + * the panel deriving it. + */ +export const WorkStatusPresenceProvider: React.FC<{ + onChange: (count: number) => void; + children: React.ReactNode; +}> = ({ onChange, children }) => { + const presentRef = React.useRef(new Set()); + + const report = React.useCallback((id: string, present: boolean) => { + const set = presentRef.current; + const had = set.has(id); + if (present === had) return; + if (present) set.add(id); + else set.delete(id); + onChange(set.size); + }, [onChange]); + + return {children}; +}; diff --git a/packages/ui/src/components/chat/work-status/presenceContext.ts b/packages/ui/src/components/chat/work-status/presenceContext.ts new file mode 100644 index 00000000..cf40e83b --- /dev/null +++ b/packages/ui/src/components/chat/work-status/presenceContext.ts @@ -0,0 +1,23 @@ +import React from 'react'; + +/** + * Whether any section actually rendered. + * + * Every section decides for itself that it has nothing to say and returns + * null, so the panel cannot know in advance whether it is empty — and an empty + * panel is a bordered card holding nothing but its settings icon, which reads + * as a fault. Re-deriving each section's emptiness at the panel level would + * mean duplicating every data source it reads, so sections report instead. + */ +export const PresenceContext = React.createContext<((id: string, present: boolean) => void) | null>(null); + +/** Call from a section with whether it rendered anything this pass. */ +export const useReportWorkStatusPresence = (id: string, present: boolean): void => { + const report = React.useContext(PresenceContext); + React.useEffect(() => { + report?.(id, present); + // Leaving the set on unmount, so a section that stops rendering entirely + // does not keep the panel alive. + return () => report?.(id, false); + }, [id, present, report]); +}; diff --git a/packages/ui/src/components/chat/work-status/sections.test.ts b/packages/ui/src/components/chat/work-status/sections.test.ts new file mode 100644 index 00000000..015f79ea --- /dev/null +++ b/packages/ui/src/components/chat/work-status/sections.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test'; +import { + WORK_STATUS_SECTION_IDS, + WORK_STATUS_SECTION_LABEL_KEYS, + isWorkStatusSectionVisible, + sanitizeWorkStatusHiddenSections, +} from './sections'; + +describe('section registry', () => { + test('every section has a label, and every label a section', () => { + // One list drives the panel and the dialog; a mismatch means a section the + // user cannot switch, or a switch for nothing. + expect(Object.keys(WORK_STATUS_SECTION_LABEL_KEYS).sort()) + .toEqual([...WORK_STATUS_SECTION_IDS].sort()); + }); +}); + +describe('isWorkStatusSectionVisible', () => { + test('everything is visible by default', () => { + // Storing the hidden set means a section added later is on for everyone, + // rather than invisible to whoever had settings saved before it existed. + expect(isWorkStatusSectionVisible([], 'usage')).toBe(true); + expect(isWorkStatusSectionVisible(undefined, 'usage')).toBe(true); + expect(isWorkStatusSectionVisible(null, 'usage')).toBe(true); + }); + + test('hides exactly the listed section', () => { + expect(isWorkStatusSectionVisible(['usage'], 'usage')).toBe(false); + expect(isWorkStatusSectionVisible(['usage'], 'tasks')).toBe(true); + }); +}); + +describe('sanitizeWorkStatusHiddenSections', () => { + test('keeps known ids and drops everything else', () => { + expect(sanitizeWorkStatusHiddenSections(['usage', 'nope', 42, null, 'tasks'])) + .toEqual(['usage', 'tasks']); + }); + + test('deduplicates', () => { + expect(sanitizeWorkStatusHiddenSections(['usage', 'usage'])).toEqual(['usage']); + }); + + test('treats a non-array payload as no preference', () => { + expect(sanitizeWorkStatusHiddenSections(undefined)).toEqual([]); + expect(sanitizeWorkStatusHiddenSections('usage')).toEqual([]); + expect(sanitizeWorkStatusHiddenSections({ usage: true })).toEqual([]); + }); +}); diff --git a/packages/ui/src/components/chat/work-status/sections.ts b/packages/ui/src/components/chat/work-status/sections.ts new file mode 100644 index 00000000..358d4338 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/sections.ts @@ -0,0 +1,59 @@ +import type { I18nKey } from '@/lib/i18n/messages/en'; + +/** + * Every section the work-status panel can render, in display order. + * + * One list drives both the panel and its settings dialog, so a section cannot + * exist in the panel without being switchable, or appear in the dialog without + * existing. + * + * The ids are persisted in user settings — renaming one silently resets that + * user's choice for it. + */ +export const WORK_STATUS_SECTION_IDS = [ + 'session', + 'repository', + 'usage', + 'subagents', + 'tasks', + 'mcp', + 'pinned', + 'contextSources', +] as const; + +type WorkStatusSectionId = (typeof WORK_STATUS_SECTION_IDS)[number]; + +export const WORK_STATUS_SECTION_LABEL_KEYS: Record = { + session: 'chat.workStatus.section.session', + repository: 'chat.workStatus.section.repository', + usage: 'chat.workStatus.section.usage', + subagents: 'chat.workStatus.section.subagents', + tasks: 'chat.workStatus.section.tasks', + mcp: 'chat.workStatus.section.mcp', + pinned: 'chat.workStatus.section.pinned', + contextSources: 'chat.workStatus.section.contextBreakdown', +}; + +const KNOWN_IDS = new Set(WORK_STATUS_SECTION_IDS); + +const isWorkStatusSectionId = (value: unknown): value is WorkStatusSectionId => + typeof value === 'string' && KNOWN_IDS.has(value); + +/** + * Hidden sections are stored, not visible ones: everything is on by default, so + * an empty list means "the user has changed nothing" and a section added later + * appears without touching anyone's saved settings. + */ +export const isWorkStatusSectionVisible = ( + hidden: readonly string[] | null | undefined, + id: WorkStatusSectionId, +): boolean => !hidden?.includes(id); + +export const sanitizeWorkStatusHiddenSections = (value: unknown): WorkStatusSectionId[] => { + if (!Array.isArray(value)) return []; + const seen = new Set(); + for (const entry of value) { + if (isWorkStatusSectionId(entry)) seen.add(entry); + } + return [...seen]; +}; diff --git a/packages/ui/src/components/chat/work-status/usageHeadline.test.ts b/packages/ui/src/components/chat/work-status/usageHeadline.test.ts new file mode 100644 index 00000000..998808a0 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/usageHeadline.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test'; +import { pickUsageHeadline, resolveQuotaProviderId } from './usageHeadline'; +import type { UsageProviderGroup } from '@/components/usage/usageGroups'; + +const HOUR = 3600; + +const window = (windowSeconds: number | null) => ({ + usedPercent: 10, + remainingPercent: 90, + windowSeconds, + resetAfterSeconds: null, + resetAt: null, + resetAtFormatted: null, + resetAfterFormatted: null, +}); + +const group = (providerId: string, rows: Array<{ key: string; label: string; subtitle?: string; seconds: number | null }>): UsageProviderGroup => ({ + providerId: providerId as UsageProviderGroup['providerId'], + providerName: providerId, + status: null, + rows: rows.map((row) => ({ + key: row.key, + label: row.label, + subtitle: row.subtitle, + window: window(row.seconds), + })), +}); + +describe('resolveQuotaProviderId', () => { + test('passes through ids that already match a quota provider', () => { + expect(resolveQuotaProviderId('opencode-go')).toBe('opencode-go'); + }); + + test('maps the known divergences', () => { + expect(resolveQuotaProviderId('openai')).toBe('codex'); + expect(resolveQuotaProviderId('anthropic')).toBe('claude'); + }); + + test('is case and whitespace tolerant, and rejects empties', () => { + expect(resolveQuotaProviderId(' OpenAI ')).toBe('codex'); + expect(resolveQuotaProviderId('')).toBeNull(); + expect(resolveQuotaProviderId(null)).toBeNull(); + }); +}); + +describe('pickUsageHeadline', () => { + const groups = [ + group('codex', [{ key: 'w', label: 'Weekly Limit', seconds: 7 * 24 * HOUR }]), + group('opencode-go', [ + { key: 'm', label: 'Monthly Limit', seconds: 30 * 24 * HOUR }, + { key: 'h', label: '5-Hour', seconds: 5 * HOUR }, + { key: 'w', label: 'Weekly Limit', seconds: 7 * 24 * HOUR }, + ]), + ]; + + test('picks the shortest window of the matching provider', () => { + // The tightest bucket is the one that decides whether the next turn lands. + expect(pickUsageHeadline(groups, 'opencode-go')?.row.label).toBe('5-Hour'); + }); + + test('resolves the provider through the alias table', () => { + expect(pickUsageHeadline(groups, 'openai')?.group.providerId).toBe('codex'); + }); + + test('returns null when no group matches the composer provider', () => { + // Showing another provider's quota would read as the active one. + expect(pickUsageHeadline(groups, 'mistral')).toBeNull(); + expect(pickUsageHeadline(groups, null)).toBeNull(); + }); + + test('ignores model-scoped rows while any provider-level row exists', () => { + const scoped = [group('zai-coding-plan', [ + { key: 'model', label: '5-Hour', subtitle: 'GLM-5', seconds: 5 * HOUR }, + { key: 'provider', label: 'Weekly Limit', seconds: 7 * 24 * HOUR }, + ])]; + expect(pickUsageHeadline(scoped, 'zai-coding-plan')?.row.label).toBe('Weekly Limit'); + }); + + test('falls back to a durationless row when nothing reports a window', () => { + const balances = [group('codex', [{ key: 'credits', label: 'Credits Balance', seconds: null }])]; + expect(pickUsageHeadline(balances, 'codex')?.row.label).toBe('Credits Balance'); + }); + + test('prefers any real window over a durationless row', () => { + const mixed = [group('codex', [ + { key: 'credits', label: 'Credits Balance', seconds: null }, + { key: 'w', label: 'Weekly Limit', seconds: 7 * 24 * HOUR }, + ])]; + expect(pickUsageHeadline(mixed, 'codex')?.row.label).toBe('Weekly Limit'); + }); + + test('returns null for a matched provider that reported no rows', () => { + expect(pickUsageHeadline([group('codex', [])], 'codex')).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/chat/work-status/usageHeadline.ts b/packages/ui/src/components/chat/work-status/usageHeadline.ts new file mode 100644 index 00000000..ad80e553 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/usageHeadline.ts @@ -0,0 +1,66 @@ +import type { UsageProviderGroup, UsageLimitRow } from '@/components/usage/usageGroups'; + +/** + * Picking the one quota worth showing while the Usage section is collapsed. + * + * The interesting limit is the one that runs out first, which is the shortest + * window a provider reports — a 5-hour bucket says more about whether the next + * turn will land than a monthly one. Rows without a window duration (credit + * balances, tool counters) are kept only as a last resort, since they never + * answer "can I keep working right now". + */ + +/** + * Quota provider ids mostly match OpenCode provider ids; these are the ones + * that do not. Unmatched providers simply produce no headline. + */ +const QUOTA_PROVIDER_ALIASES = new Map([ + ['openai', 'codex'], + ['chatgpt', 'codex'], + ['anthropic', 'claude'], + ['gemini', 'google'], +]); + +const normalize = (value: string | null | undefined): string => (value ?? '').trim().toLowerCase(); + +export const resolveQuotaProviderId = (modelProviderId: string | null | undefined): string | null => { + const normalized = normalize(modelProviderId); + if (!normalized) return null; + return QUOTA_PROVIDER_ALIASES.get(normalized) ?? normalized; +}; + +/** + * Shortest reported window for the provider the composer is pointed at. + * + * Returns null when nothing matches — the section then falls back to its + * display-mode label rather than showing a quota belonging to some other + * provider, which would read as the active one. + */ +export const pickUsageHeadline = ( + groups: readonly UsageProviderGroup[], + modelProviderId: string | null | undefined, +): { group: UsageProviderGroup; row: UsageLimitRow } | null => { + const quotaProviderId = resolveQuotaProviderId(modelProviderId); + if (!quotaProviderId) return null; + + const group = groups.find((candidate) => normalize(candidate.providerId) === quotaProviderId); + if (!group || group.rows.length === 0) return null; + + // Provider-level rows only: a model-scoped row describes one model, not the + // provider the composer is pointed at. + const providerRows = group.rows.filter((row) => !row.subtitle); + const rows = providerRows.length > 0 ? providerRows : group.rows; + + let best: UsageLimitRow | null = null; + let bestSeconds = Number.POSITIVE_INFINITY; + for (const row of rows) { + const seconds = row.window.windowSeconds; + if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) continue; + if (seconds < bestSeconds) { + best = row; + bestSeconds = seconds; + } + } + + return { group, row: best ?? rows[0] }; +}; diff --git a/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.test.ts b/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.test.ts new file mode 100644 index 00000000..ca502ab2 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.test.ts @@ -0,0 +1,329 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; + +type PanelState = { + isOpen: boolean; + tabs: { id: string; mode: string }[]; + activeTabId: string | null; +}; + +let panelByDirectory: Record = {}; +let panelEnabled = true; + +mock.module('@/stores/useUIStore', () => ({ + useUIStore: (selector: (state: unknown) => unknown) => + selector({ contextPanelByDirectory: panelByDirectory, workStatusPanelEnabled: panelEnabled }), +})); + +mock.module('@/lib/pathNormalization', () => ({ + normalizePath: (value?: string | null) => value ?? null, +})); + +const { useWorkStatusVisibility, WORK_STATUS_REQUIRED_ROW_WIDTH: REQUIRED } = await import( + './useWorkStatusVisibility' +); + +/** Elements the stubbed ResizeObserver was asked to observe, in order. */ +let observed: unknown[] = []; +let notify: ((entries: { contentRect: { width: number } }[]) => void) | null = null; + +class StubResizeObserver { + constructor(callback: (entries: { contentRect: { width: number } }[]) => void) { + notify = callback; + } + + observe(element: unknown) { + observed.push(element); + } + + disconnect() { + notify = null; + } +} + +const installMinimalDom = () => { + const descriptors = new Map(); + const setGlobal = (name: string, value: unknown) => { + descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + }; + class ElementStub {} + const documentStub: Record = { + nodeType: 9, + defaultView: globalThis, + activeElement: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }; + const container = { + nodeType: 1, + tagName: 'DIV', + nodeName: 'DIV', + namespaceURI: 'http://www.w3.org/1999/xhtml', + ownerDocument: documentStub, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }; + documentStub.documentElement = container; + documentStub.body = container; + setGlobal('document', documentStub); + setGlobal('window', globalThis); + setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' }); + setGlobal('Element', ElementStub); + setGlobal('HTMLElement', ElementStub); + setGlobal('HTMLIFrameElement', ElementStub); + setGlobal('IS_REACT_ACT_ENVIRONMENT', true); + setGlobal('ResizeObserver', StubResizeObserver); + setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0)); + setGlobal('cancelAnimationFrame', (id: ReturnType) => clearTimeout(id)); + return { + container: container as unknown as Element, + restore: () => { + for (const [name, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + }, + }; +}; + +type Args = { directory: string | null; isMobile: boolean; isVSCode: boolean }; + +/** + * Renders the hook with a stand-in row node, attached through the returned + * callback ref exactly as the real tree does. + */ +const renderVisibility = (args: Args, rowWidth: number) => { + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + // `closest` returns null here, so the hook falls back to the row itself — + // the fallback path is what these cases exercise. + const rowNode = { + getBoundingClientRect: () => ({ width: rowWidth }), + closest: () => null, + } as unknown as HTMLDivElement; + const result = { visible: false, fits: false }; + + const Probe: React.FC = () => { + const { rowRef, visible, fits } = useWorkStatusVisibility(args); + result.visible = visible; + result.fits = fits; + React.useLayoutEffect(() => { + rowRef(rowNode); + return () => rowRef(null); + }, [rowRef]); + return null; + }; + + act(() => { root.render(React.createElement(Probe)); }); + return { + result, + rowNode, + teardown: () => { + act(() => { root.unmount(); }); + dom.restore(); + }, + }; +}; + +beforeEach(() => { + panelByDirectory = {}; + panelEnabled = true; + observed = []; + notify = null; +}); + +afterEach(() => { + observed = []; + notify = null; +}); + +describe('useWorkStatusVisibility', () => { + test('shows the panel when the row can afford both columns', () => { + const { result, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED, + ); + expect(result.visible).toBe(true); + teardown(); + }); + + test('hides the panel when the row cannot afford both columns', () => { + const { result, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED - 1, + ); + expect(result.visible).toBe(false); + teardown(); + }); + + test('prefers the marked chat area over the row it was handed', () => { + // The row is what the context panel squeezes, over an animation. Measuring + // it made the panel reappear only once that number caught up, so the chat + // widened first and narrowed again afterwards. + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const chatArea = { getBoundingClientRect: () => ({ width: REQUIRED }) }; + const rowNode = { + getBoundingClientRect: () => ({ width: 0 }), + closest: () => chatArea, + } as unknown as HTMLDivElement; + const result = { visible: false }; + + const Probe: React.FC = () => { + const { rowRef, visible } = useWorkStatusVisibility({ + directory: '/repo', + isMobile: false, + isVSCode: false, + }); + result.visible = visible; + React.useLayoutEffect(() => { + rowRef(rowNode); + return () => rowRef(null); + }, [rowRef]); + return null; + }; + + act(() => { root.render(React.createElement(Probe)); }); + expect(observed).toEqual([chatArea]); + expect(result.visible).toBe(true); + + act(() => { root.unmount(); }); + dom.restore(); + }); + + test('measures a container the panel cannot resize, never the chat column', () => { + // The measured element must not depend on whether the panel is showing: + // otherwise hiding the panel widens it and re-shows the panel, forever. + // In the app this is the chat area (chat + context panel); here `closest` + // finds nothing, so the hook falls back to the row it was given. + const { rowNode, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED, + ); + expect(observed).toHaveLength(1); + expect(observed[0]).toBe(rowNode); + teardown(); + }); + + test('reacts to a live resize across the threshold', () => { + const { result, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED, + ); + expect(result.visible).toBe(true); + act(() => { notify?.([{ contentRect: { width: REQUIRED - 40 } }]); }); + expect(result.visible).toBe(false); + act(() => { notify?.([{ contentRect: { width: REQUIRED + 200 } }]); }); + expect(result.visible).toBe(true); + teardown(); + }); + + test('yields to an open context panel while still measuring the row', () => { + // Measurement continues so the panel can come back in the same commit that + // reveals it. Stopping cost a frame: closing the context panel widened the + // chat, and only then did the panel reappear and narrow it again. + panelByDirectory = { + '/repo': { isOpen: true, tabs: [{ id: 'tab-1', mode: 'git' }], activeTabId: 'tab-1' }, + }; + const { result, rowNode, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED, + ); + expect(result.visible).toBe(false); + expect(observed).toEqual([rowNode]); + teardown(); + }); + + test('ignores an open context panel that has no resolvable tab', () => { + // ContextPanel renders nothing in that state, so it displaces nothing. + panelByDirectory = { '/repo': { isOpen: true, tabs: [], activeTabId: null } }; + const { result, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED, + ); + expect(result.visible).toBe(true); + teardown(); + }); + + test('measures a row that attaches after the first render', () => { + // Regression: with an object ref the measuring effect read `.current` + // once, found nothing when the row mounted late, and only recovered when + // some unrelated dependency changed — in practice, opening and closing the + // context panel. The panel must appear as soon as the row exists. + const dom = installMinimalDom(); + const root: Root = createRoot(dom.container); + const rowNode = { + getBoundingClientRect: () => ({ width: REQUIRED }), + closest: () => null, + } as unknown as HTMLDivElement; + const result = { visible: false }; + let attach: (value: boolean) => void = () => undefined; + + const Probe: React.FC = () => { + const [attached, setAttached] = React.useState(false); + const { rowRef, visible } = useWorkStatusVisibility({ + directory: '/repo', + isMobile: false, + isVSCode: false, + }); + result.visible = visible; + attach = setAttached; + React.useLayoutEffect(() => { + if (attached) rowRef(rowNode); + }, [attached, rowRef]); + return null; + }; + + act(() => { root.render(React.createElement(Probe)); }); + expect(result.visible).toBe(false); + + act(() => { attach(true); }); + expect(result.visible).toBe(true); + + act(() => { root.unmount(); }); + dom.restore(); + }); + + test('stays hidden when the user switched the panel off, but still reports the fit', () => { + // The header offers the panel as an overlay when layout refuses it, so it + // needs the two answers apart: whether the user wants it, and whether + // there is room for it. + panelEnabled = false; + const { result, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED * 2, + ); + expect(result.visible).toBe(false); + expect(result.fits).toBe(true); + teardown(); + }); + + test('reports no fit when the row is too narrow, whatever the switch says', () => { + const { result, teardown } = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: false }, + REQUIRED - 1, + ); + expect(result.fits).toBe(false); + expect(result.visible).toBe(false); + teardown(); + }); + + test('stays hidden on mobile and in VS Code regardless of width', () => { + const mobile = renderVisibility( + { directory: '/repo', isMobile: true, isVSCode: false }, + REQUIRED * 2, + ); + expect(mobile.result.visible).toBe(false); + mobile.teardown(); + + observed = []; + const vscode = renderVisibility( + { directory: '/repo', isMobile: false, isVSCode: true }, + REQUIRED * 2, + ); + expect(vscode.result.visible).toBe(false); + vscode.teardown(); + }); +}); diff --git a/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.ts b/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.ts new file mode 100644 index 00000000..541e4ae7 --- /dev/null +++ b/packages/ui/src/components/chat/work-status/useWorkStatusVisibility.ts @@ -0,0 +1,116 @@ +import React from 'react'; +import { useUIStore } from '@/stores/useUIStore'; +import { normalizePath } from '@/lib/pathNormalization'; + +/** + * Fixed panel width. The panel is not user-resizable: it is an object inside + * the chat rather than a docked pane, so it has no resizer and no persisted + * width. + */ +export const WORK_STATUS_PANEL_WIDTH = 300; + +/** + * Minimum width the message column must keep for itself. Below this the panel + * yields — a squeezed transcript costs more than the status it displaces. + */ +const WORK_STATUS_MIN_CHAT_WIDTH = 560; + +/** The card's own horizontal margins (`ml-2` + `mr-4`). */ +const WORK_STATUS_PANEL_GUTTER = 8 + 16; + +/** Row width below which the panel gives its space back to the transcript. */ +export const WORK_STATUS_REQUIRED_ROW_WIDTH = + WORK_STATUS_PANEL_WIDTH + WORK_STATUS_PANEL_GUTTER + WORK_STATUS_MIN_CHAT_WIDTH; + +type Options = { + directory: string | null | undefined; + isMobile: boolean; + isVSCode: boolean; +}; + +type Result = { + /** Layout can host the panel inline, regardless of the user's switch. */ + fits: boolean; + /** + * Attach to the flex row that contains the chat column and the panel. + * + * A callback ref, not an object ref: an object ref gives no signal when the + * node attaches, so a measuring effect that reads `.current` would silently + * observe nothing whenever the row mounts after the effect first ran, and + * would only recover on the next unrelated dependency change. + */ + rowRef: (node: HTMLDivElement | null) => void; + visible: boolean; +}; + +/** + * Decides whether the work-status panel may occupy space inside the chat. + * + * The width test measures the ROW (chat column + panel), never the chat column + * alone. The chat column's width is an output of this decision: hiding the + * panel widens it, which would re-satisfy a chat-width test and re-show the + * panel, oscillating forever. The row width is independent of the panel, so it + * is the only stable input. + */ +export const useWorkStatusVisibility = ({ directory, isMobile, isVSCode }: Options): Result => { + const [rowNode, setRowNode] = React.useState(null); + const [rowWidth, setRowWidth] = React.useState(null); + const rowRef = React.useCallback((node: HTMLDivElement | null) => { setRowNode(node); }, []); + + const directoryKey = React.useMemo(() => normalizePath(directory ?? null), [directory]); + + // Mirrors ContextPanel's own derivation: a panel with `isOpen` but no + // resolvable active tab renders nothing, and must not displace this panel. + const contextPanelOpen = useUIStore( + React.useCallback( + (state) => { + const panel = directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined; + if (!panel?.isOpen) return false; + const activeTab = panel.tabs.find((tab) => tab.id === panel.activeTabId) + ?? panel.tabs[panel.tabs.length - 1] + ?? null; + return Boolean(activeTab); + }, + [directoryKey], + ), + ); + + // The user's own switch, persisted to server settings, gates everything + // before layout is even measured. + const panelEnabled = useUIStore((state) => state.workStatusPanelEnabled); + + // Split from the switch: a narrow chat is a layout fact, and the header needs + // it to offer the panel as an overlay instead of pretending it is off. + const layoutAllows = !isMobile && !isVSCode && !contextPanelOpen; + + // Measures the chat AREA — the container holding the chat and the context + // panel together — not the chat row inside it. + // + // The row is what the context panel squeezes, and it squeezes it over a + // 200ms animation. Measuring the row therefore reported a width that was + // still catching up while the context panel collapsed, so this panel only + // reappeared once that number crossed the threshold: the chat widened first + // and narrowed again afterwards. The chat area's width does not move when + // the context panel opens, so the reading is correct the instant it closes. + // + // It is also the stable input the oscillation argument needs: this panel's + // own visibility cannot change the width being measured. + React.useEffect(() => { + if (!rowNode || typeof ResizeObserver === 'undefined') return undefined; + + const measured = rowNode.closest('[data-chat-area]') ?? rowNode; + setRowWidth(measured.getBoundingClientRect().width); + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + setRowWidth(entry.contentRect.width); + }); + observer.observe(measured); + return () => observer.disconnect(); + }, [rowNode]); + + const fits = layoutAllows && rowWidth !== null && rowWidth >= WORK_STATUS_REQUIRED_ROW_WIDTH; + const visible = panelEnabled && fits; + + return { rowRef, visible, fits }; +}; diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index 76e53af9..8cd3022c 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -23,7 +23,6 @@ import { desktopOpenNewWindowAtUrl, desktopOpenNewWindowForHost, getDesktopHostApiUrl, - locationMatchesHost, normalizeHostUrl, probeRelayDesktopHost, redactSensitiveUrl, @@ -31,10 +30,17 @@ import { type DesktopHost, type HostProbeResult, } from '@/lib/desktopHosts'; +import { + LOCAL_HOST_ID, + buildLocalDesktopHost, + getLocalDesktopOrigin, + resolveCurrentDesktopHost, + runtimeKeyForDesktopHost, +} from '@/lib/desktopCurrentHost'; import { scheduleDesktopHostCandidateRefresh } from '@/lib/desktopRelayRestore'; import { adoptRelayTunnel } from '@/lib/relay/runtime-tunnel'; import { createRelayTunnelClient } from '@/lib/relay/tunnel-client'; -import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; +import { subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { desktopSshConnect, desktopSshDisconnect, @@ -43,15 +49,9 @@ import { type DesktopSshInstanceStatus, } from '@/lib/desktopSsh'; -const LOCAL_HOST_ID = 'local'; const SSH_CONNECT_TIMEOUT_MS = 90_000; const SSH_CONNECT_CANCELLED_ERROR = 'SSH connection cancelled'; -const runtimeKeyForHost = (host: DesktopHost): string => { - if (host.id === LOCAL_HOST_ID) return 'local'; - return `host:${host.id}`; -}; - type HostStatus = { status: HostProbeResult['status']; latencyMs: number; @@ -83,11 +83,6 @@ const toNavigationUrl = (rawUrl: string): string => { } }; -const getLocalOrigin = (): string => { - if (typeof window === 'undefined') return ''; - return window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin; -}; - const getLocalClientToken = async (): Promise => { if (!isElectronShell()) return ''; return desktopLocalClientTokenGet().catch(() => ''); @@ -236,67 +231,6 @@ const waitForSshReady = async ( throw new Error('Timed out waiting for SSH connection'); }; -const buildLocalHost = (localOrigin?: string | null): DesktopHost => ({ - id: LOCAL_HOST_ID, - label: 'Local', - url: localOrigin || getLocalOrigin(), -}); - -const resolveCurrentHost = (hosts: DesktopHost[]) => { - const currentHref = typeof window === 'undefined' ? '' : window.location.href; - const localOrigin = hosts.find((host) => host.id === LOCAL_HOST_ID)?.url || getLocalOrigin(); - const runtimeApiBaseUrl = getRuntimeApiBaseUrl(); - const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin; - const normalizedCurrent = normalizeHostUrl(currentHref) || currentHref; - - // Relay hosts share the window origin as their (virtual) API base, so URL - // matching can't distinguish them — identify the active relay host by its - // stable runtime key instead. - const activeRuntimeKey = getRuntimeKey(); - const relayMatch = hosts.find((h) => h.relay && runtimeKeyForHost(h) === activeRuntimeKey); - if (relayMatch) { - return { id: relayMatch.id, label: relayMatch.label, url: relayMatch.url }; - } - - if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) { - return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal }; - } - - const runtimeMatch = hosts.find((h) => { - return runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(h)) : false; - }); - - if (runtimeMatch) { - return { - id: runtimeMatch.id, - label: runtimeMatch.label, - url: normalizeHostUrl(getDesktopHostApiUrl(runtimeMatch)) || getDesktopHostApiUrl(runtimeMatch), - }; - } - - if (currentHref && locationMatchesHost(currentHref, localOrigin)) { - return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal }; - } - - const match = hosts.find((h) => { - return currentHref ? locationMatchesHost(currentHref, h.url) : false; - }); - - if (match) { - return { id: match.id, label: match.label, url: normalizeHostUrl(match.url) || match.url }; - } - - if (currentHref.startsWith('openchamber-ui://')) { - return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal }; - } - - return { - id: 'custom', - label: redactSensitiveUrl(normalizedCurrent || 'Instance'), - url: normalizedCurrent, - }; -}; - type DesktopHostSwitcherDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; @@ -342,7 +276,7 @@ export function DesktopHostSwitcherDialog({ error: null, }); const [error, setError] = React.useState(''); - const [localOrigin, setLocalOrigin] = React.useState(() => getLocalOrigin()); + const [localOrigin, setLocalOrigin] = React.useState(() => getLocalDesktopOrigin()); const [editingId, setEditingId] = React.useState(null); const [editLabel, setEditLabel] = React.useState(''); @@ -352,7 +286,7 @@ export function DesktopHostSwitcherDialog({ const sshSwitchTokenRef = React.useRef(0); const allHosts = React.useMemo(() => { - const local = buildLocalHost(localOrigin); + const local = buildLocalDesktopHost(localOrigin); const normalizedRemote = configHosts.map((h) => ({ ...h, url: normalizeHostUrl(h.url) || h.url, @@ -366,7 +300,7 @@ export function DesktopHostSwitcherDialog({ const current = React.useMemo(() => { void runtimeEndpointEpoch; - return resolveCurrentHost(allHosts); + return resolveCurrentDesktopHost(allHosts); }, [allHosts, runtimeEndpointEpoch]); const currentDefaultLabel = React.useMemo(() => { const id = defaultHostId || LOCAL_HOST_ID; @@ -525,7 +459,7 @@ export function DesktopHostSwitcherDialog({ switchRuntimeEndpoint({ apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '', clientToken: host.clientToken || null, - runtimeKey: runtimeKeyForHost(host), + runtimeKey: runtimeKeyForDesktopHost(host), relay, }); // On the relay: learn the server's current LAN address in the background @@ -551,7 +485,7 @@ export function DesktopHostSwitcherDialog({ if (cached.via === 'relay' && host.relay) { activateRelay(host.relay); } else if (apiOrigin) { - switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) }); + switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForDesktopHost(host) }); } else if (host.relay) { activateRelay(host.relay); } @@ -590,7 +524,7 @@ export function DesktopHostSwitcherDialog({ if (transport === 'relay' && host.relay) { activateRelay(host.relay, relayProbeTunnel); } else { - switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) }); + switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForDesktopHost(host) }); } onHostSwitched?.(); setSwitchingHostId(null); diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index c1b957b7..769b58cd 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -150,6 +150,7 @@ export const iconSpriteData = { "link-unlink-m": ``, "list-check-2": ``, "list-check-3": ``, + "list-indefinite": ``, "list-unordered": ``, "loader": ``, "loader-4": ``, diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx index f7f5ce0d..6b3c147a 100644 --- a/packages/ui/src/components/layout/ContextPanelRail.tsx +++ b/packages/ui/src/components/layout/ContextPanelRail.tsx @@ -120,7 +120,14 @@ const ContextPanelRailItem: React.FC = ({ ) : displayBadgeCount ? ( @@ -152,6 +159,7 @@ export const ContextPanelRail: React.FC = () => { const directoryKey = effectiveDirectory ? normalizeContextPanelDirectoryKey(effectiveDirectory) : ''; const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined)); + const workStatusPanelVisible = useUIStore((state) => state.workStatusPanelVisible); const contextRailOrder = useUIStore((state) => state.contextRailOrder); const setContextRailOrder = useUIStore((state) => state.setContextRailOrder); const openContextSurface = useUIStore((state) => state.openContextSurface); @@ -286,7 +294,9 @@ export const ContextPanelRail: React.FC = () => { const label = t(surface.labelKey); // Git shows a numeric badge instead of the old activity dot. // Other surfaces never inherit git's changed-files signal. - const gitChangedCount = surface.id === 'git' ? changedFilesCount : 0; + // The work-status panel reports the same count in words a few + // pixels away; two live counts for one fact is one too many. + const gitChangedCount = surface.id === 'git' && !workStatusPanelVisible ? changedFilesCount : 0; const badgeCount = gitChangedCount > 0 ? gitChangedCount : null; return ( >; refreshCurrentInstanceLabel: () => Promise; - desktopServicesTab: 'instance' | 'usage' | 'mcp'; - setDesktopServicesTab: React.Dispatch>; - quotaResultsLength: number; - fetchAllQuotas: () => Promise; - servicesTabItems: SortableTabsStripItem[]; - quotaLastUpdated: number | null; - quotaDisplayMode: 'usage' | 'remaining'; - quotaDisplayTabItems: SortableTabsStripItem[]; - handleDisplayModeChange: (mode: 'usage' | 'remaining') => Promise; - handleUsageRefresh: () => void; - isQuotaLoading: boolean; - isUsageRefreshSpinning: boolean; - hasRateLimits: boolean; - rateLimitGroups: RateLimitGroup[]; - expandedFamilies: Record; - toggleFamilyExpanded: (providerId: string, familyId: string) => void; shortcutLabel: (actionId: string) => string; - showDevShutdown: boolean; - isDevShutdownInFlight: boolean; - onDevShutdown: () => Promise; remoteUpdateInfo: UpdateInfo | null; remoteUpdateChecking: boolean; remoteUpdateError: string | null; onOpenRemoteUpdate: () => void; - showPredValues: boolean; - timeFormatPreference: TimeFormatPreference; }; const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ @@ -305,32 +288,11 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ isDesktopServicesOpen, setIsDesktopServicesOpen, refreshCurrentInstanceLabel, - desktopServicesTab, - setDesktopServicesTab, - quotaResultsLength, - fetchAllQuotas, - servicesTabItems, - quotaLastUpdated, - quotaDisplayMode, - quotaDisplayTabItems, - handleDisplayModeChange, - handleUsageRefresh, - isQuotaLoading, - isUsageRefreshSpinning, - hasRateLimits, - rateLimitGroups, - expandedFamilies, - toggleFamilyExpanded, shortcutLabel, - showDevShutdown, - isDevShutdownInFlight, - onDevShutdown, remoteUpdateInfo, remoteUpdateChecking, remoteUpdateError, onOpenRemoteUpdate, - showPredValues, - timeFormatPreference, }: DesktopServicesMenuProps) { const { t } = useI18n(); return ( @@ -340,9 +302,6 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ setIsDesktopServicesOpen(open); if (open) { void refreshCurrentInstanceLabel(); - if (desktopServicesTab === 'usage' && quotaResultsLength === 0) { - void fetchAllQuotas(); - } } }} > @@ -359,7 +318,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ isDesktopApp ? 'w-auto max-w-[14rem] justify-start gap-1.5 px-2.5' : 'h-8 w-8' )} > - + {isDesktopApp ? ( {compactCurrentInstanceLabel} ) : null} @@ -368,16 +327,10 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({

- {isDesktopApp - ? t('header.services.tooltip.currentInstanceWithShortcuts', { - current: currentInstanceLabel, - toggle: shortcutLabel('toggle_services_menu'), - nextTab: shortcutLabel('cycle_services_tab'), - }) - : t('header.services.tooltip.servicesWithShortcuts', { - toggle: shortcutLabel('toggle_services_menu'), - nextTab: shortcutLabel('cycle_services_tab'), - })} + {t('header.services.tooltip.currentInstance', { + current: currentInstanceLabel, + toggle: shortcutLabel('toggle_services_menu'), + })}

@@ -385,28 +338,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ align="end" className="w-[min(27rem,calc(100vw-2rem))] max-h-[75vh] overflow-y-auto bg-[var(--surface-elevated)] p-0" > -
-
- { - const value = tabID as 'instance' | 'usage' | 'mcp'; - setDesktopServicesTab(value); - if (value === 'usage' && quotaResultsLength === 0) { - void fetchAllQuotas(); - } - }} - layoutMode="fit" - variant="active-pill" - activePillInsetClassName="gap-0.5 px-px py-0" - activePillButtonClassName="h-8" - className="h-full" - /> -
-
- - {isDesktopApp && desktopServicesTab === 'instance' ? ( + {isDesktopApp ? (
{!currentInstanceIsLocal ? (
@@ -435,185 +367,13 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ ) : null} {}} onHostSwitched={() => setIsDesktopServicesOpen(false)} />
) : null} - {desktopServicesTab === 'mcp' ? ( - - ) : null} - - {desktopServicesTab === 'usage' ? ( -
-
-
- {t('header.services.rateLimits')} - {formatTime(quotaLastUpdated, timeFormatPreference)} -
-
-
- void handleDisplayModeChange(tabID as 'usage' | 'remaining')} - layoutMode="fit" - variant="active-pill" - activePillInsetClassName="gap-0.5 px-px py-0" - className="h-full" - /> -
- -
-
- - {!hasRateLimits ? ( -
- {t('header.services.noRateLimits')} -
- ) : null} - - {/* One elevated card per provider (same card language as the mobile - usage popover) instead of a flat run of divider-separated rows. */} -
- {rateLimitGroups.map((group) => { - const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; - return ( -
-
- - {group.providerName} -
- {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? ( -
- {group.error ?? t('header.services.noRateLimitsReported')} -
- ) : ( -
- {group.entries.map(([label, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent; - const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); - const expectedMarker = paceInfo?.dailyAllocationPercent != null - ? (quotaDisplayMode === 'remaining' - ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) - : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) - : null; - const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent); - const resetLabel = formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference); - return ( -
-
-
- {formatWindowLabel(label)} - {resetLabel ? ( - - {resetLabel} - - ) : null} -
- - {metricLabel === '-' ? '' : metricLabel} - -
- - {paceInfo && showPredValues ? : null} -
- ); - })} - {group.modelFamilies && group.modelFamilies.length > 0 ? ( -
- {group.modelFamilies.map((family) => { - const familyKey = family.familyId ?? 'other'; - const isExpanded = providerExpandedFamilies.includes(familyKey); - return ( - toggleFamilyExpanded(group.providerId, familyKey)} - > - - {family.familyLabel} - {isExpanded ? : } - - -
- {family.models.map(([modelName, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent; - const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds); - const expectedMarker = paceInfo?.dailyAllocationPercent != null - ? (quotaDisplayMode === 'remaining' - ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) - : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) - : null; - const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent); - return ( -
-
- {getDisplayModelName(modelName)} - - {metricLabel === '-' ? '' : metricLabel} - -
- - {paceInfo && showPredValues ? : null} -
- ); - })} -
-
-
- ); - })} -
- ) : null} -
- )} -
- ); - })} -
-
- ) : null} - - {showDevShutdown ? ( - <> -
-
- { - void onDevShutdown(); - }} - > - {t('header.services.shutdownDev')} - -
- - ) : null} ); @@ -740,7 +500,6 @@ export const Header: React.FC = ({ const getCurrentModel = useConfigStore((state) => state.getCurrentModel); const runtimeApis = useRuntimeAPIs(); - const [isDevShutdownInFlight, setIsDevShutdownInFlight] = React.useState(false); const getContextUsage = useSessionUIStore((state) => state.getContextUsage); const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); @@ -894,18 +653,38 @@ export const Header: React.FC = ({ const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false); const [remoteUpdateError, setRemoteUpdateError] = React.useState(null); const compactCurrentInstanceLabel = React.useMemo(() => formatCompactHeaderLabel(currentInstanceLabel), [currentInstanceLabel]); - const [desktopServicesTab, setDesktopServicesTab] = React.useState<'instance' | 'usage' | 'mcp'>( - isDesktopApp ? 'instance' : 'usage' - ); const [mobileServicesTab, setMobileServicesTab] = React.useState<'usage' | 'mcp'>('usage'); - useEffect(() => { - if (!isDesktopApp && desktopServicesTab === 'instance') { - setDesktopServicesTab('usage'); - } - }, [desktopServicesTab, isDesktopApp]); - const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); - const showDesktopHeaderContextUsage = !isVSCode && activeMainTab === 'chat' && !!stableDesktopContextUsage && stableDesktopContextUsage.totalTokens > 0; + // While the work-status panel is on screen it already reports the project, + // the branch and the context fill — three paces away in the same window. + // These yield to it rather than saying the same thing twice, and return the + // moment the panel is switched off or squeezed out by a narrow chat. + const workStatusPanelVisible = useUIStore((state) => state.workStatusPanelVisible); + const workStatusPanelEnabled = useUIStore((state) => state.workStatusPanelEnabled); + const setWorkStatusPanelEnabled = useUIStore((state) => state.setWorkStatusPanelEnabled); + const workStatusPanelFits = useUIStore((state) => state.workStatusPanelFits); + const workStatusOverlayOpen = useUIStore((state) => state.workStatusOverlayOpen); + const setWorkStatusOverlayOpen = useUIStore((state) => state.setWorkStatusOverlayOpen); + + // Two meanings for one button. With room beside the chat it switches the + // panel on and off. Without room it cannot be shown inline at all, so it + // reads as off and opens the panel over the chat instead — the stored + // preference is left alone, so the panel comes back on its own once the + // window is wide enough again. + const workStatusPanelShownInline = workStatusPanelEnabled && workStatusPanelFits; + const workStatusToggleActive = workStatusPanelShownInline || workStatusOverlayOpen; + const handleWorkStatusToggle = React.useCallback(() => { + if (workStatusPanelEnabled && !workStatusPanelFits) { + setWorkStatusOverlayOpen(!workStatusOverlayOpen); + return; + } + setWorkStatusPanelEnabled(!workStatusPanelEnabled); + }, [setWorkStatusOverlayOpen, setWorkStatusPanelEnabled, workStatusOverlayOpen, workStatusPanelEnabled, workStatusPanelFits]); + const showDesktopHeaderContextUsage = !isVSCode + && !workStatusPanelVisible + && activeMainTab === 'chat' + && !!stableDesktopContextUsage + && stableDesktopContextUsage.totalTokens > 0; const desktopHeaderDisplayPercentage = stableDesktopContextUsage && stableDesktopContextUsage.contextLimit > 0 ? Math.min(999, (stableDesktopContextUsage.totalTokens / stableDesktopContextUsage.contextLimit) * 100) : 0; @@ -923,26 +702,19 @@ export const Header: React.FC = ({ } setCurrentInstanceIsLocal(false); + // Same resolution the host switcher's own header uses, so the button and + // the panel it opens can never disagree about which instance this is. const cfg = await desktopHostsGet(); - const localOrigin = window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin; - const runtimeApiBaseUrl = getRuntimeApiBaseUrl(); + const localOrigin = getLocalDesktopOrigin(); + const resolved = resolveCurrentDesktopHost([buildLocalDesktopHost(localOrigin), ...cfg.hosts]); - if (runtimeApiBaseUrl && locationMatchesHost(runtimeApiBaseUrl, localOrigin)) { + if (resolved.id === LOCAL_HOST_ID) { setCurrentInstanceLabel('Local'); setCurrentInstanceIsLocal(true); return; } - const match = cfg.hosts.find((host) => { - return runtimeApiBaseUrl ? locationMatchesHost(runtimeApiBaseUrl, getDesktopHostApiUrl(host)) : false; - }); - - if (match?.label?.trim()) { - setCurrentInstanceLabel(redactSensitiveUrl(match.label.trim())); - return; - } - - setCurrentInstanceLabel('Instance'); + setCurrentInstanceLabel(redactSensitiveUrl(resolved.label.trim() || 'Instance')); } catch { setCurrentInstanceLabel('Local'); setCurrentInstanceIsLocal(true); @@ -951,6 +723,11 @@ export const Header: React.FC = ({ useEffect(() => { void refreshCurrentInstanceLabel(); + // Switching instances does not remount the header, so without this the + // button would keep naming the instance the window left behind. + return subscribeRuntimeEndpointChanged(() => { + void refreshCurrentInstanceLabel(); + }); }, [refreshCurrentInstanceLabel]); const checkRemoteInstanceUpdate = React.useCallback(async () => { @@ -1306,6 +1083,12 @@ export const Header: React.FC = ({ const gitBranchForDirectory = useGitBranchLabel(openDirectory || null); const currentBranchLabel = gitBranchForDirectory || currentSessionWorktreeBranch || catalogWorktreeBranch; + // Whether the title carries a second line under it. Hoisted because the + // session menu's vertical alignment depends on the same answer. + const showHeaderMetaRow = !workStatusPanelVisible + && Boolean(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind)); + + const currentSessionTitle = React.useMemo(() => { if (!currentSessionId) { return activeProjectLabel ?? 'OpenChamber'; @@ -1948,93 +1731,17 @@ export const Header: React.FC = ({ } }, [activeMainTab, isMobile, setActiveMainTab]); + // Desktop keeps instances only: quota and MCP now live in the work-status + // panel, which reports them per session rather than per window. The mobile + // menu below is untouched — it has no panel to defer to. const servicesTabs = React.useMemo(() => { const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: React.ReactNode }> = []; if (isDesktopApp) { base.push({ value: 'instance', label: t('layout.services.instance'), icon: }); } - base.push( - { value: 'usage', label: t('layout.services.usage'), icon: }, - { value: 'mcp', label: 'MCP', icon: } - ); return base; }, [isDesktopApp, t]); - const servicesTabItems = React.useMemo(() => { - return servicesTabs.map((tab) => ({ - id: tab.value, - label: tab.label, - icon: tab.icon, - })); - }, [servicesTabs]); - - const showDevShutdown = React.useMemo(() => { - if (typeof window === 'undefined') return false; - if (isDesktopApp) return false; - if (isVSCode) return false; - const host = window.location.hostname; - return host === 'localhost' || host === '127.0.0.1' || host === '::1'; - }, [isDesktopApp, isVSCode]); - - const handleDevShutdown = React.useCallback(async () => { - if (isDevShutdownInFlight) return; - setIsDevShutdownInFlight(true); - setIsDesktopServicesOpen(false); - - const previewUrls: string[] = []; - let shutdownRequested = false; - try { - try { - for (const [, dirState] of useTerminalStore.getState().sessions.entries()) { - for (const tab of dirState.tabs) { - if (tab.previewUrl) { - previewUrls.push(tab.previewUrl); - } - } - } - } catch { - // ignore - } - - try { - // Ensure preview/dev terminals don't linger. - await runtimeApis.terminal.forceKill?.({}); - } catch { - // ignore - } - - try { - const devRes = await runtimeFetch('/api/system/dev-shutdown', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ previewUrls }), - }); - if (devRes.ok) { - shutdownRequested = true; - } else { - const shutdownRes = await runtimeFetch('/api/system/shutdown', { method: 'POST' }); - shutdownRequested = shutdownRes.ok; - } - } catch { - // ignore - } - } finally { - if (!shutdownRequested) { - setIsDevShutdownInFlight(false); - } - } - }, [isDevShutdownInFlight, runtimeApis.terminal, setIsDesktopServicesOpen]); - - const quotaDisplayTabs = React.useMemo(() => { - return [ - { value: 'usage' as const, label: t('header.services.used') }, - { value: 'remaining' as const, label: t('header.services.remaining') }, - ]; - }, [t]); - - const quotaDisplayTabItems = React.useMemo(() => { - return quotaDisplayTabs.map((tab) => ({ id: tab.value, label: tab.label })); - }, [quotaDisplayTabs]); const mobileServicesTabItems = React.useMemo(() => { return [ @@ -2072,31 +1779,19 @@ export const Header: React.FC = ({ } else { setIsDesktopServicesOpen(true); void refreshCurrentInstanceLabel(); - if (desktopServicesTab === 'usage' && quotaResults.length === 0) { - void fetchAllQuotas(); - } } return; } + // The desktop menu holds one destination now, so this shortcut opens it + // rather than cycling. The binding is kept: it is user-configurable and + // silently dropping it would break existing setups. const cycleServicesCombo = getEffectiveShortcutCombo('cycle_services_tab', shortcutOverrides); if (eventMatchesShortcut(e, cycleServicesCombo)) { e.preventDefault(); - - const tabValues = servicesTabs.map((tab) => tab.value) as Array<'instance' | 'usage' | 'mcp'>; - if (tabValues.length === 0) { - return; - } - - const currentIndex = tabValues.indexOf(desktopServicesTab); - const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % tabValues.length; - const nextTab = tabValues[nextIndex]; - setDesktopServicesTab(nextTab); + if (servicesTabs.length === 0) return; setIsDesktopServicesOpen(true); void refreshCurrentInstanceLabel(); - if (nextTab === 'usage' && quotaResults.length === 0) { - void fetchAllQuotas(); - } return; } @@ -2112,7 +1807,6 @@ export const Header: React.FC = ({ }, [ shortcutOverrides, isDesktopServicesOpen, - desktopServicesTab, servicesTabs, quotaResults.length, fetchAllQuotas, @@ -2172,6 +1866,10 @@ export const Header: React.FC = ({ const desktopSidebarActions = ( <> + {/* Instances only exist in the desktop app. On web the menu was left + holding a single dev-only shutdown action, which is not a reason to + keep a dropdown in the header. */} + {isDesktopApp ? ( = ({ isDesktopServicesOpen={isDesktopServicesOpen} setIsDesktopServicesOpen={setIsDesktopServicesOpen} refreshCurrentInstanceLabel={refreshCurrentInstanceLabel} - desktopServicesTab={desktopServicesTab} - setDesktopServicesTab={setDesktopServicesTab} - quotaResultsLength={quotaResults.length} - fetchAllQuotas={fetchAllQuotas} - servicesTabItems={servicesTabItems} - quotaLastUpdated={quotaLastUpdated} - quotaDisplayMode={quotaDisplayMode} - showPredValues={showPredValues} - quotaDisplayTabItems={quotaDisplayTabItems} - handleDisplayModeChange={handleDisplayModeChange} - handleUsageRefresh={handleUsageRefresh} - isQuotaLoading={isQuotaLoading} - isUsageRefreshSpinning={isUsageRefreshSpinning} - hasRateLimits={hasRateLimits} - rateLimitGroups={rateLimitGroups} - expandedFamilies={expandedFamilies} - toggleFamilyExpanded={toggleFamilyExpanded} shortcutLabel={shortcutLabel} - showDevShutdown={showDevShutdown} - isDevShutdownInFlight={isDevShutdownInFlight} - onDevShutdown={handleDevShutdown} remoteUpdateInfo={remoteUpdateInfo} remoteUpdateChecking={remoteUpdateChecking} remoteUpdateError={remoteUpdateError} onOpenRemoteUpdate={openRemoteInstanceUpdate} - timeFormatPreference={timeFormatPreference} /> + ) : null} = ({ {isNewSessionDraftOpen ? t('sessions.switcher.draftTitle') : currentSessionTitle} )} - {(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind)) ? ( + {showHeaderMetaRow ? ( {activeProjectLabel ? {activeProjectLabel} : null} {currentBranchLabel ? ( @@ -2342,7 +2020,12 @@ export const Header: React.FC = ({ ) : null}
-
+
{currentSessionId && !isNewSessionDraftOpen && !isRenamingHeaderSession ? ( = ({ percentIconClassName="h-4.5 w-4.5" /> ) : null} + = ({ className={cn(desktopHeaderIconButtonClass, 'mr-1')} Icon={'picture-in-picture-2'} /> + {activeMainTab === 'chat' && !isVSCode ? ( + + + + + + {workStatusPanelEnabled && !workStatusPanelFits + ? (workStatusOverlayOpen + ? t('header.workStatusPanel.hide') + : t('header.workStatusPanel.showOverlay')) + : workStatusPanelEnabled + ? t('header.workStatusPanel.hide') + : t('header.workStatusPanel.show')} + + + ) : null} + {desktopSidebarActions}
diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 8755aae4..28e44ce8 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -437,7 +437,11 @@ export const MainLayout: React.FC = () => {
-
+ {/* Holds the chat and the context panel together, so its + width does not move when the context panel opens. The + work-status panel measures this rather than the chat, + which the context panel animates. */} +
diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index b867598b..92b7183b 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -128,7 +128,6 @@ export const Sidebar: React.FC = ({ isOpen, isMobile, children, cl className={cn( 'relative flex h-full overflow-hidden border-r border-border will-change-[width] motion-reduce:transition-none', 'bg-sidebar oc-vibrancy-surface', - isOpen && 'shadow-[inset_-2px_0_10px_-2px_rgb(0_0_0_/_0.06)]', !isOpen && 'border-r-0', className, )} @@ -144,6 +143,12 @@ export const Sidebar: React.FC = ({ isOpen, isMobile, children, cl }} aria-hidden={!isOpen || appliedWidth === 0} > + {isOpen && ( +