From 1439f5e8380330d31e08bcd9ade632d1a27d3d38 Mon Sep 17 00:00:00 2001 From: Issue Reproducer Date: Thu, 18 Jun 2026 15:00:44 +0000 Subject: [PATCH 01/66] 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 02/66] 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 03/66] 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 04/66] 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 05/66] 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 06/66] 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 07/66] 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 08/66] 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 ccb74d83662beee23ffbc79127dc787c5e171617 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 10:58:33 +0000 Subject: [PATCH 09/66] fix(ui): detect VS Code from bootstrap config in shared runtime helpers d2efa707 fixed projects-store detection via __VSCODE_CONFIG__, but lib/desktop.isVSCodeRuntime (used by useDirectoryStore) still required RuntimeAPIs. At webview startup that left directory init on stale localStorage paths from other windows (#2359). Share bootstrap detection in lib/vscodeBootstrap and use it from both desktop runtime checks and the projects-store helper. Co-authored-by: Serhii Dziupin --- packages/ui/src/lib/desktop.ts | 7 +++ .../ui/src/lib/desktop.vscodeRuntime.test.ts | 46 +++++++++++++++++++ packages/ui/src/lib/vscodeBootstrap.test.ts | 29 ++++++++++++ packages/ui/src/lib/vscodeBootstrap.ts | 20 ++++++++ packages/ui/src/stores/useDirectoryStore.ts | 3 +- .../ui/src/stores/utils/vscodeRuntime.test.ts | 8 ++++ packages/ui/src/stores/utils/vscodeRuntime.ts | 22 ++++----- 7 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 packages/ui/src/lib/desktop.vscodeRuntime.test.ts create mode 100644 packages/ui/src/lib/vscodeBootstrap.test.ts create mode 100644 packages/ui/src/lib/vscodeBootstrap.ts diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index d8b41983..27ccb18b 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -4,6 +4,7 @@ import type { DraftStarterRef } from '@/lib/draftStarters'; import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { isVSCodeBootstrapPresent } from '@/lib/vscodeBootstrap'; type ManagedRemoteTunnelPreset = { id: string; @@ -538,6 +539,12 @@ export const startDesktopWindowDrag = async (): Promise => { }; export const isVSCodeRuntime = (): boolean => { + // Prefer extension-host bootstrap config: it is injected in webview HTML + // before any store module evaluates, so startup does not depend on + // RuntimeAPIs registration order (see #2359). + if (isVSCodeBootstrapPresent()) { + return true; + } const apis = getRegisteredRuntimeAPIs(); return apis?.runtime?.isVSCode === true; }; diff --git a/packages/ui/src/lib/desktop.vscodeRuntime.test.ts b/packages/ui/src/lib/desktop.vscodeRuntime.test.ts new file mode 100644 index 00000000..fb02417f --- /dev/null +++ b/packages/ui/src/lib/desktop.vscodeRuntime.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; + +type RuntimeApisStub = { runtime?: { isVSCode?: boolean } } | null; + +let registeredRuntimeApis: RuntimeApisStub = null; + +mock.module('@/contexts/runtimeAPIRegistry', () => ({ + getRegisteredRuntimeAPIs: (): RuntimeApisStub => registeredRuntimeApis, +})); + +const { isVSCodeRuntime } = await import('./desktop'); + +describe('desktop isVSCodeRuntime bootstrap detection', () => { + afterEach(() => { + registeredRuntimeApis = null; + delete (globalThis as { window?: unknown }).window; + }); + + test('detects VS Code from bootstrap config before RuntimeAPIs register', () => { + registeredRuntimeApis = null; + (globalThis as { window: unknown }).window = { + __VSCODE_CONFIG__: { + workspaceFolder: '/Users/me/project-a', + workspaceFolders: [{ name: 'project-a', path: '/Users/me/project-a' }], + }, + }; + + expect(isVSCodeRuntime()).toBe(true); + }); + + test('falls back to registered RuntimeAPIs when bootstrap is absent', () => { + registeredRuntimeApis = { + runtime: { isVSCode: true }, + }; + (globalThis as { window: unknown }).window = {}; + + expect(isVSCodeRuntime()).toBe(true); + }); + + test('does not classify an unregistered web runtime as VS Code', () => { + registeredRuntimeApis = null; + (globalThis as { window: unknown }).window = {}; + + expect(isVSCodeRuntime()).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/vscodeBootstrap.test.ts b/packages/ui/src/lib/vscodeBootstrap.test.ts new file mode 100644 index 00000000..3e67c3b8 --- /dev/null +++ b/packages/ui/src/lib/vscodeBootstrap.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { getVSCodeBootstrapConfig, isVSCodeBootstrapPresent } from './vscodeBootstrap'; + +describe('VS Code bootstrap config', () => { + afterEach(() => { + delete (globalThis as { window?: unknown }).window; + }); + + test('reads extension-host __VSCODE_CONFIG__ before RuntimeAPIs exist', () => { + (globalThis as { window: unknown }).window = { + __VSCODE_CONFIG__: { + workspaceFolder: '/workspace/project-one', + workspaceFolders: [{ name: 'project-one', path: '/workspace/project-one' }], + }, + }; + + expect(getVSCodeBootstrapConfig()).toEqual({ + workspaceFolder: '/workspace/project-one', + workspaceFolders: [{ name: 'project-one', path: '/workspace/project-one' }], + }); + expect(isVSCodeBootstrapPresent()).toBe(true); + }); + + test('treats missing window/bootstrap as not VS Code', () => { + expect(getVSCodeBootstrapConfig()).toBeNull(); + expect(isVSCodeBootstrapPresent()).toBe(false); + expect(isVSCodeBootstrapPresent(null)).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/vscodeBootstrap.ts b/packages/ui/src/lib/vscodeBootstrap.ts new file mode 100644 index 00000000..1c9e7a8b --- /dev/null +++ b/packages/ui/src/lib/vscodeBootstrap.ts @@ -0,0 +1,20 @@ +/** + * Extension-host bootstrap config injected into the VS Code webview HTML + * before any bundled module evaluates. Prefer this over RuntimeAPIs for + * early VS Code detection during store module initialization. + */ +export interface VSCodeBootstrapConfig { + workspaceFolder?: unknown; + workspaceFolders?: unknown; +} + +export const getVSCodeBootstrapConfig = (): VSCodeBootstrapConfig | null => { + if (typeof window === 'undefined') { + return null; + } + return (window as unknown as { __VSCODE_CONFIG__?: VSCodeBootstrapConfig }).__VSCODE_CONFIG__ ?? null; +}; + +export const isVSCodeBootstrapPresent = ( + bootstrapConfig: VSCodeBootstrapConfig | null = getVSCodeBootstrapConfig(), +): boolean => Boolean(bootstrapConfig); diff --git a/packages/ui/src/stores/useDirectoryStore.ts b/packages/ui/src/stores/useDirectoryStore.ts index b0b32af5..865fa97a 100644 --- a/packages/ui/src/stores/useDirectoryStore.ts +++ b/packages/ui/src/stores/useDirectoryStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { opencodeClient } from '@/lib/opencode/client'; import { getDesktopHomeDirectory, isVSCodeRuntime } from '@/lib/desktop'; +import { getVSCodeBootstrapConfig } from '@/lib/vscodeBootstrap'; import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { updateDesktopSettings } from '@/lib/persistence'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; @@ -227,7 +228,7 @@ const getVsCodeWorkspaceFolder = (): string | null => { if (!isVSCodeRuntime()) { return null; } - const workspaceFolder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder; + const workspaceFolder = getVSCodeBootstrapConfig()?.workspaceFolder; if (typeof workspaceFolder !== 'string' || workspaceFolder.trim().length === 0) { return null; } diff --git a/packages/ui/src/stores/utils/vscodeRuntime.test.ts b/packages/ui/src/stores/utils/vscodeRuntime.test.ts index aebe538c..a1cb518f 100644 --- a/packages/ui/src/stores/utils/vscodeRuntime.test.ts +++ b/packages/ui/src/stores/utils/vscodeRuntime.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; +import type { RuntimeAPIs } from '@/lib/api/types'; import { isVSCodeRuntime } from './vscodeRuntime'; describe('VS Code runtime detection', () => { @@ -9,6 +10,13 @@ describe('VS Code runtime detection', () => { })).toBe(true); }); + test('uses registered runtime APIs when bootstrap is absent', () => { + const runtimeApis = { + runtime: { platform: 'vscode', isDesktop: false, isVSCode: true }, + } as RuntimeAPIs; + expect(isVSCodeRuntime(runtimeApis, null)).toBe(true); + }); + test('does not classify an unregistered web runtime as VS Code', () => { expect(isVSCodeRuntime(null, null)).toBe(false); }); diff --git a/packages/ui/src/stores/utils/vscodeRuntime.ts b/packages/ui/src/stores/utils/vscodeRuntime.ts index 91446ce6..2e7a5d7b 100644 --- a/packages/ui/src/stores/utils/vscodeRuntime.ts +++ b/packages/ui/src/stores/utils/vscodeRuntime.ts @@ -1,18 +1,14 @@ import type { RuntimeAPIs } from '@/lib/api/types'; +import { + getVSCodeBootstrapConfig, + isVSCodeBootstrapPresent, + type VSCodeBootstrapConfig, +} from '@/lib/vscodeBootstrap'; -export interface VSCodeBootstrapConfig { - workspaceFolder?: unknown; - workspaceFolders?: unknown; -} - -export const getVSCodeBootstrapConfig = (): VSCodeBootstrapConfig | null => { - if (typeof window === 'undefined') { - return null; - } - return (window as unknown as { __VSCODE_CONFIG__?: VSCodeBootstrapConfig }).__VSCODE_CONFIG__ ?? null; -}; +export type { VSCodeBootstrapConfig }; +export { getVSCodeBootstrapConfig }; export const isVSCodeRuntime = ( runtimeApis: RuntimeAPIs | null, - bootstrapConfig = getVSCodeBootstrapConfig(), -): boolean => Boolean(bootstrapConfig || runtimeApis?.runtime?.isVSCode); + bootstrapConfig: VSCodeBootstrapConfig | null = getVSCodeBootstrapConfig(), +): boolean => Boolean(isVSCodeBootstrapPresent(bootstrapConfig) || runtimeApis?.runtime?.isVSCode); From 696a381d3af7686c6b83a49270893bfe15154433 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 11:36:48 +0000 Subject: [PATCH 10/66] test(ui): cover VS Code store init before RuntimeAPIs registration Adds focused regression coverage for #2359 bootstrap-only detection used by directory/projects startup when RuntimeAPIs are not registered yet. Co-authored-by: Serhii Dziupin --- .../src/stores/vscodeStoreInit.2359.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 packages/ui/src/stores/vscodeStoreInit.2359.test.ts diff --git a/packages/ui/src/stores/vscodeStoreInit.2359.test.ts b/packages/ui/src/stores/vscodeStoreInit.2359.test.ts new file mode 100644 index 00000000..6571c162 --- /dev/null +++ b/packages/ui/src/stores/vscodeStoreInit.2359.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; + +/** + * Integration-style coverage for #2359: store modules evaluate before + * RuntimeAPIs registration, with only extension-host __VSCODE_CONFIG__ present + * and a stale lastDirectory in storage. + */ + +const WORKSPACE = '/tmp/oc-ws-project-a'; +const STALE = '/tmp/oc-ws-other'; + +const storage = new Map([ + ['lastDirectory', STALE], + ['homeDirectory', STALE], +]); + +const installWindow = () => { + (globalThis as { window: unknown }).window = { + __VSCODE_CONFIG__: { + workspaceFolder: WORKSPACE, + workspaceFolders: [{ name: 'oc-ws-project-a', path: WORKSPACE }], + }, + __OPENCHAMBER_HOME__: WORKSPACE, + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, String(value)); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + }, + matchMedia: () => ({ matches: false, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {} }), + }; + (globalThis as { localStorage: unknown }).localStorage = (globalThis as { window: { localStorage: unknown } }).window.localStorage; +}; + +mock.module('@/contexts/runtimeAPIRegistry', () => ({ + getRegisteredRuntimeAPIs: () => null, +})); + +mock.module('@/lib/opencode/client', () => ({ + opencodeClient: { + setDirectory: () => undefined, + getDirectory: () => WORKSPACE, + getFilesystemHome: async () => WORKSPACE, + getSystemInfo: async () => ({ homeDirectory: WORKSPACE }), + }, +})); + +mock.module('@/lib/persistence', () => ({ + updateDesktopSettings: async () => undefined, +})); + +mock.module('@/lib/runtime-switch', () => ({ + subscribeRuntimeEndpointChanged: () => () => undefined, + getRuntimeApiBaseUrl: () => 'http://127.0.0.1:9', + getRuntimeKey: () => 'test', +})); + +mock.module('@/stores/useFileSearchStore', () => ({ + useFileSearchStore: { + getState: () => ({ clearCache: () => undefined }), + }, +})); + +describe('VS Code store init before RuntimeAPIs (#2359)', () => { + afterEach(() => { + delete (globalThis as { window?: unknown }).window; + delete (globalThis as { localStorage?: unknown }).localStorage; + }); + + test('desktop isVSCodeRuntime prefers bootstrap config', async () => { + installWindow(); + const { isVSCodeRuntime } = await import('@/lib/desktop'); + expect(isVSCodeRuntime()).toBe(true); + }); + + test('projects helper derives workspace projects without RuntimeAPIs', async () => { + installWindow(); + const { getVSCodeBootstrapConfig, isVSCodeRuntime } = await import('@/stores/utils/vscodeRuntime'); + const config = getVSCodeBootstrapConfig(); + expect(isVSCodeRuntime(null, config)).toBe(true); + expect(config?.workspaceFolder).toBe(WORKSPACE); + }); +}); From 2b67be0a07d23bf6efa00af23294d7620753134d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 11:35:42 +0000 Subject: [PATCH 11/66] fix(ui): prevent shiki template-call OOM on backtick JS Neutralize the catastrophic TextMate template-call lookahead when loading JS/TS grammars in the markdown Shiki worker, and terminate hung highlight requests after 5s so unbounded Oniguruma WASM matching cannot OOM the renderer (openchamber/openchamber#2587). Co-authored-by: Serhii Dziupin --- .../chat/markdown/markdown-shiki.worker.ts | 25 ++++++- .../chat/markdown/markdown-worker-timeout.ts | 6 ++ .../markdown/markdown-worker.hang.test.ts | 12 +++ .../chat/markdown/markdown-worker.ts | 45 ++++++++--- .../shiki/sanitizeTemplateCallGrammar.test.ts | 75 +++++++++++++++++++ .../lib/shiki/sanitizeTemplateCallGrammar.ts | 45 +++++++++++ 6 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 packages/ui/src/components/chat/markdown/markdown-worker-timeout.ts create mode 100644 packages/ui/src/components/chat/markdown/markdown-worker.hang.test.ts create mode 100644 packages/ui/src/lib/shiki/sanitizeTemplateCallGrammar.test.ts create mode 100644 packages/ui/src/lib/shiki/sanitizeTemplateCallGrammar.ts diff --git a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts index a06b81fc..bf0891f0 100644 --- a/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-shiki.worker.ts @@ -1,6 +1,10 @@ /// -import { bundledLanguages, createHighlighter, type BundledLanguage, type ThemedToken } from 'shiki'; +import { bundledLanguages, createHighlighter, type BundledLanguage, type LanguageRegistration, type ThemedToken } from 'shiki'; +import { + isTemplateCallLanguageId, + sanitizeTemplateCallGrammar, +} from '../../../lib/shiki/sanitizeTemplateCallGrammar'; import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition'; import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; @@ -60,11 +64,28 @@ self.onmessage = (event: MessageEvent) => { type Instance = Awaited>; +type BundledLanguageModule = { default: LanguageRegistration[] }; + +/** + * Load a language, neutralizing the catastrophic JS/TS `template-call` rule + * before it reaches the Oniguruma scanner (see sanitizeTemplateCallGrammar). + */ +const loadLanguageSafe = async (instance: Instance, lang: BundledLanguage): Promise => { + if (!isTemplateCallLanguageId(lang)) { + await instance.loadLanguage(bundledLanguages[lang]); + return; + } + + const mod = (await bundledLanguages[lang]()) as BundledLanguageModule; + const grammars = mod.default.map((grammar) => sanitizeTemplateCallGrammar(grammar)); + await instance.loadLanguage(...grammars); +}; + const resolveLanguage = async (instance: Instance, requested: string): Promise => { let lang = requested in bundledLanguages ? requested : 'text'; if (lang !== 'text' && !instance.getLoadedLanguages().includes(lang)) { try { - await instance.loadLanguage(bundledLanguages[lang as BundledLanguage]); + await loadLanguageSafe(instance, lang as BundledLanguage); } catch { lang = 'text'; } diff --git a/packages/ui/src/components/chat/markdown/markdown-worker-timeout.ts b/packages/ui/src/components/chat/markdown/markdown-worker-timeout.ts new file mode 100644 index 00000000..1e82763c --- /dev/null +++ b/packages/ui/src/components/chat/markdown/markdown-worker-timeout.ts @@ -0,0 +1,6 @@ +/** + * Safety-net budget for a single Shiki worker tokenize request. + * Healthy files finish well under this; catastrophic Oniguruma backtracking + * must not run unbounded (openchamber/openchamber#2587). + */ +export const HIGHLIGHT_REQUEST_TIMEOUT_MS = 5_000; diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.hang.test.ts b/packages/ui/src/components/chat/markdown/markdown-worker.hang.test.ts new file mode 100644 index 00000000..df591b2c --- /dev/null +++ b/packages/ui/src/components/chat/markdown/markdown-worker.hang.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from 'bun:test'; + +import { HIGHLIGHT_REQUEST_TIMEOUT_MS } from './markdown-worker-timeout'; + +describe('markdown-worker hang safety', () => { + test('exposes a finite highlight timeout budget', () => { + // Catastrophic Oniguruma backtracking must not run unbounded; the main + // thread terminates the worker after this budget (openchamber/openchamber#2587). + expect(HIGHLIGHT_REQUEST_TIMEOUT_MS).toBeGreaterThan(0); + expect(HIGHLIGHT_REQUEST_TIMEOUT_MS).toBeLessThan(15_001); + }); +}); diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index 85d061bd..b97372e3 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -1,23 +1,42 @@ import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url'; import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol'; +import { HIGHLIGHT_REQUEST_TIMEOUT_MS } from './markdown-worker-timeout'; -// Main-thread client for the markdown Shiki worker. Moves syntax tokenization +export { HIGHLIGHT_REQUEST_TIMEOUT_MS } from './markdown-worker-timeout'; + +// Main-thread client for the markdown Shiki Web Worker. Moves syntax tokenization // off the UI thread: a closed code block is shipped to the worker, which returns // ready-to-splice Shiki HTML. On any failure (no worker support, worker crash, -// tokenization error) the promise resolves to `null` and the caller keeps the -// escaped plain-text code — highlighting never falls back onto the main thread. +// tokenization error, or hang timeout) the promise resolves to `null` and the +// caller keeps the escaped plain-text code — highlighting never falls back onto +// the main thread. +// +// The timeout exists because TextMate grammars can enter catastrophic backtracking +// on the Oniguruma WASM engine (openchamber/openchamber#2587). Matching is sync +// inside the worker, so the only way to reclaim memory is to terminate it from +// this thread when a request exceeds the budget. type PendingResolver = (response: MarkdownWorkerResponse | null) => void; +type PendingEntry = { + resolve: PendingResolver; + timer: ReturnType; +}; + let worker: Worker | undefined; let nextId = 0; -const pending = new Map(); +const pending = new Map(); // Theme names whose full definition we've already shipped to the live worker, so // repeat tokenization sends only the name (not the whole theme object) again. const sentThemes = new Set(); +const clearPendingTimers = (): void => { + pending.forEach((entry) => clearTimeout(entry.timer)); +}; + const failAll = (): void => { - pending.forEach((resolve) => resolve(null)); + clearPendingTimers(); + pending.forEach((entry) => entry.resolve(null)); pending.clear(); sentThemes.clear(); worker?.terminate(); @@ -34,10 +53,11 @@ const getWorker = (): Worker | undefined => { return undefined; } worker.onmessage = (event: MessageEvent) => { - const resolve = pending.get(event.data.id); - if (!resolve) return; + const entry = pending.get(event.data.id); + if (!entry) return; + clearTimeout(entry.timer); pending.delete(event.data.id); - resolve(event.data); + entry.resolve(event.data); }; worker.onerror = failAll; worker.onmessageerror = failAll; @@ -50,7 +70,14 @@ const request = (payload: (id: number) => MarkdownWorkerRequest): Promise((resolve) => { - pending.set(id, resolve); + const timer = setTimeout(() => { + if (!pending.has(id)) return; + // Hung tokenize (e.g. catastrophic backtracking): kill the worker so the + // WASM heap is freed instead of growing until the renderer OOMs. + console.warn(`Shiki worker highlight timed out after ${HIGHLIGHT_REQUEST_TIMEOUT_MS}ms; terminating worker`); + failAll(); + }, HIGHLIGHT_REQUEST_TIMEOUT_MS); + pending.set(id, { resolve, timer }); instance.postMessage(payload(id)); }); }; diff --git a/packages/ui/src/lib/shiki/sanitizeTemplateCallGrammar.test.ts b/packages/ui/src/lib/shiki/sanitizeTemplateCallGrammar.test.ts new file mode 100644 index 00000000..bc10ceaf --- /dev/null +++ b/packages/ui/src/lib/shiki/sanitizeTemplateCallGrammar.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test'; +import { bundledLanguages, createHighlighter, type LanguageRegistration } from 'shiki'; + +import { + hasCatastrophicTemplateCall, + isTemplateCallLanguageId, + sanitizeTemplateCallGrammar, + TEMPLATE_CALL_LANGUAGE_IDS, +} from './sanitizeTemplateCallGrammar'; + +type BundledLanguageModule = { default: LanguageRegistration[] }; + +const loadBundledGrammar = async (id: (typeof TEMPLATE_CALL_LANGUAGE_IDS)[number]): Promise => { + const mod = (await bundledLanguages[id]()) as BundledLanguageModule; + return mod.default[0]; +}; + +describe('sanitizeTemplateCallGrammar', () => { + test('detects template-call on bundled JS/TS grammars', async () => { + for (const id of TEMPLATE_CALL_LANGUAGE_IDS) { + const grammar = await loadBundledGrammar(id); + expect(isTemplateCallLanguageId(id)).toBe(true); + expect(hasCatastrophicTemplateCall(grammar)).toBe(true); + } + }); + + test('clears template-call patterns without dropping the repository key', async () => { + const grammar = await loadBundledGrammar('javascript'); + const patched = sanitizeTemplateCallGrammar(grammar); + + expect(hasCatastrophicTemplateCall(patched)).toBe(false); + expect(patched.repository?.['template-call']).toEqual({ patterns: [] }); + // Original left intact (structured clone / spread, not mutate-in-place). + expect(hasCatastrophicTemplateCall(grammar)).toBe(true); + }); + + test('is a no-op when template-call is already empty', () => { + const grammar = { + name: 'javascript', + scopeName: 'source.js', + patterns: [], + repository: { 'template-call': { patterns: [] } }, + } satisfies LanguageRegistration; + expect(sanitizeTemplateCallGrammar(grammar)).toBe(grammar); + }); + + test('highlights template-literal fixtures within a tight budget after sanitize', async () => { + const mod = (await bundledLanguages.javascript()) as BundledLanguageModule; + const patched = mod.default.map((grammar) => sanitizeTemplateCallGrammar(grammar)); + + const highlighter = await createHighlighter({ + themes: ['github-dark'], + langs: patched, + }); + + // Representative content from openchamber/openchamber#2587, scaled to ~14KB. + const fixture = `const snapshot = { source: \`\${session.source}\`, fetchedAt: \`\${Date.now()}\` }; +const label = \`Account \${index + 1}\`; +function render(account) { + return html\`
\${account.name}
\`; +} +`.repeat(80); + + expect(fixture.length).toBeGreaterThan(10_000); + + const started = performance.now(); + const html = highlighter.codeToHtml(fixture, { lang: 'javascript', theme: 'github-dark' }); + const elapsedMs = performance.now() - started; + highlighter.dispose(); + + expect(html.length).toBeGreaterThan(0); + // Catastrophic backtracking hangs for seconds–minutes; healthy tokenize is well under 1s. + expect(elapsedMs).toBeLessThan(2_000); + }); +}); \ No newline at end of file diff --git a/packages/ui/src/lib/shiki/sanitizeTemplateCallGrammar.ts b/packages/ui/src/lib/shiki/sanitizeTemplateCallGrammar.ts new file mode 100644 index 00000000..dafb60de --- /dev/null +++ b/packages/ui/src/lib/shiki/sanitizeTemplateCallGrammar.ts @@ -0,0 +1,45 @@ +/** + * Neutralize the JavaScript/TypeScript TextMate `template-call` rule. + * + * Upstream grammars use a triple-nested `{()[]}` lookahead to detect tagged + * templates with type arguments (`foo\`...\``). On the Oniguruma WASM engine + * shipped with Shiki — which does not expose `setRetryLimit` / match-stack + * limits — that pattern can enter exponential backtracking on ordinary + * backtick template literals, grow the WASM heap without bound, and OOM the + * renderer (openchamber/openchamber#2587). + * + * Clearing `template-call` is safe: the plain `#template` rule still highlights + * backticks and simple tagged templates. Only the rare `ident\`...\`` + * form loses its specialized type-argument coloring and falls through to + * normal tokenization. + */ + +type GrammarRepository = Record; + +export type TemplateCallGrammar = { + name?: string; + repository?: GrammarRepository; +}; + +const TEMPLATE_CALL_KEY = 'template-call'; + +export const hasCatastrophicTemplateCall = (grammar: TemplateCallGrammar): boolean => { + const patterns = grammar.repository?.[TEMPLATE_CALL_KEY]?.patterns; + return Array.isArray(patterns) && patterns.length > 0; +}; + +export const sanitizeTemplateCallGrammar = (grammar: T): T => { + if (!hasCatastrophicTemplateCall(grammar)) return grammar; + + const repository = { ...grammar.repository }; + repository[TEMPLATE_CALL_KEY] = { patterns: [] }; + return { ...grammar, repository }; +}; + +/** Language ids whose bundled grammars ship the catastrophic `template-call` rule. */ +export const TEMPLATE_CALL_LANGUAGE_IDS = ['javascript', 'typescript', 'jsx', 'tsx'] as const; + +export type TemplateCallLanguageId = (typeof TEMPLATE_CALL_LANGUAGE_IDS)[number]; + +export const isTemplateCallLanguageId = (lang: string): lang is TemplateCallLanguageId => + (TEMPLATE_CALL_LANGUAGE_IDS as readonly string[]).includes(lang); From ef11ab5b14367dc4f72795137546a873db37ff7c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 11:38:02 +0000 Subject: [PATCH 12/66] feat(ui): attach large text pastes as virtual files Offer to turn sufficiently large plain-text clipboard pastes into pasted-context-N.txt attachments instead of inserting them into the composer, with ask/attach/inline composer settings. Co-authored-by: Serhii Dziupin --- packages/ui/src/components/chat/ChatInput.tsx | 127 +++++++++++++++++- .../__tests__/attachmentCitations.test.ts | 7 + .../components/chat/attachmentCitations.ts | 14 ++ .../components/chat/composer/DOCUMENTATION.md | 14 +- .../composer/__tests__/largeTextPaste.test.ts | 45 +++++++ .../chat/composer/largeTextPaste.ts | 55 ++++++++ .../sections/openchamber/OpenChamberPage.tsx | 1 + .../openchamber/OpenChamberVisualSettings.tsx | 45 ++++++- .../ui/src/lib/i18n/messages/de.settings.ts | 7 + packages/ui/src/lib/i18n/messages/de.ts | 5 + .../ui/src/lib/i18n/messages/en.settings.ts | 7 + packages/ui/src/lib/i18n/messages/en.ts | 5 + .../ui/src/lib/i18n/messages/es.settings.ts | 7 + packages/ui/src/lib/i18n/messages/es.ts | 5 + .../ui/src/lib/i18n/messages/fr.settings.ts | 7 + packages/ui/src/lib/i18n/messages/fr.ts | 5 + .../ui/src/lib/i18n/messages/ja.settings.ts | 7 + packages/ui/src/lib/i18n/messages/ja.ts | 5 + .../ui/src/lib/i18n/messages/ko.settings.ts | 7 + packages/ui/src/lib/i18n/messages/ko.ts | 5 + .../ui/src/lib/i18n/messages/pl.settings.ts | 7 + packages/ui/src/lib/i18n/messages/pl.ts | 5 + .../src/lib/i18n/messages/pt-BR.settings.ts | 7 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 5 + .../ui/src/lib/i18n/messages/uk.settings.ts | 7 + packages/ui/src/lib/i18n/messages/uk.ts | 5 + .../src/lib/i18n/messages/zh-CN.settings.ts | 7 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 5 + .../src/lib/i18n/messages/zh-TW.settings.ts | 7 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 5 + packages/ui/src/lib/settings/search.ts | 9 +- packages/ui/src/stores/useUIStore.ts | 18 +++ packages/ui/src/sync/DOCUMENTATION.md | 2 +- 33 files changed, 458 insertions(+), 11 deletions(-) create mode 100644 packages/ui/src/components/chat/composer/__tests__/largeTextPaste.test.ts create mode 100644 packages/ui/src/components/chat/composer/largeTextPaste.ts diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index ba493f55..cc4c6d08 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -82,7 +82,13 @@ import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from import { assignImageAttachmentFilenames, buildAttachmentCitationText, + nextPastedContextFilename, } from './attachmentCitations'; +import { + createPastedContextFile, + isLargePlainTextPaste, +} from './composer/largeTextPaste'; +import type { LargeTextPasteBehavior } from '@/stores/useUIStore'; import type { FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState'; import { classifyMention, @@ -288,6 +294,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const messageRef = React.useRef(message); const currentChatDraftIdentityRef = React.useRef(initialDraftIdentityRef.current); const pendingPastedAttachmentFilenamesRef = React.useRef>(new Set()); + const largeTextPasteToastIdRef = React.useRef(null); + const largeTextPasteOfferIdRef = React.useRef(0); // TODO: port sendMessage to session-actions (complex — creates sessions, handles attachments, etc.) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -358,6 +366,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const inputBarOffset = useUIStore((state) => state.inputBarOffset); const persistChatDraft = useUIStore((state) => state.persistChatDraft); const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled); + const largeTextPasteBehavior = useUIStore((state) => state.largeTextPasteBehavior); const isExpandedInput = useUIStore((state) => state.isExpandedInput); const setExpandedInput = useUIStore((state) => state.setExpandedInput); const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen); @@ -1723,14 +1732,124 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const imageFiles = Array.from(fileMap.values()); const pastedText = e.clipboardData.getData('text'); + const sessionReady = Boolean(currentSessionId || newSessionDraftOpen); + if (imageFiles.length === 0) { - if (pastedText.includes('@')) { - markFileMentionPasteSuppression(); + const behavior: LargeTextPasteBehavior = largeTextPasteBehavior; + const shouldOfferLargePaste = sessionReady + && inputMode === 'normal' + && behavior !== 'inline' + && isLargePlainTextPaste(pastedText); + + if (!shouldOfferLargePaste) { + if (pastedText.includes('@')) { + markFileMentionPasteSuppression(); + } + return; } + + // Must run synchronously — ComposerEditor does not consume paste. + e.preventDefault(); + + const pasteInline = () => { + if (pastedText.includes('@')) { + markFileMentionPasteSuppression(); + } + insertTextAtSelection( + pastedText, + getFileMentionInputSourceForInsertedText(pastedText), + ); + }; + + const attachAsFile = async () => { + const filename = nextPastedContextFilename([ + ...attachedFiles.map((file) => file.filename), + ...pendingPastedAttachmentFilenamesRef.current, + ]); + const citationText = buildAttachmentCitationText([filename]); + const textarea = composerRef.current; + const selectionStart = textarea?.getSelection().start ?? message.length; + const selectionEnd = textarea?.getSelection().end ?? message.length; + const insertionText = withInlineInsertionBoundaries( + citationText, + message.slice(0, selectionStart), + message.slice(selectionEnd), + ); + + insertTextAtSelection( + insertionText, + getFileMentionInputSourceForInsertedText(insertionText), + ); + + const file = createPastedContextFile(pastedText, filename); + pendingPastedAttachmentFilenamesRef.current.add(filename); + try { + await addAttachedFile(file); + } catch (error) { + console.error('Clipboard text attach failed', error); + toast.error( + error instanceof Error + ? error.message + : t('chat.chatInput.toast.clipboardTextAttachFailed'), + ); + } finally { + pendingPastedAttachmentFilenamesRef.current.delete(filename); + } + }; + + if (behavior === 'attach') { + await attachAsFile(); + return; + } + + const offerId = largeTextPasteOfferIdRef.current + 1; + largeTextPasteOfferIdRef.current = offerId; + + if (largeTextPasteToastIdRef.current !== null) { + // Invalidate first so a synchronous onDismiss from dismiss() + // cannot apply the superseded paste. + toast.dismiss(largeTextPasteToastIdRef.current); + largeTextPasteToastIdRef.current = null; + } + + const resolveLargePaste = (action: 'attach' | 'inline') => { + if (offerId !== largeTextPasteOfferIdRef.current) { + return; + } + // Invalidate this offer so a later onDismiss cannot double-apply. + largeTextPasteOfferIdRef.current += 1; + largeTextPasteToastIdRef.current = null; + if (action === 'attach') { + void attachAsFile(); + return; + } + pasteInline(); + }; + + largeTextPasteToastIdRef.current = toast.info( + t('chat.chatInput.toast.largeTextPaste.title'), + { + description: t('chat.chatInput.toast.largeTextPaste.description'), + duration: Infinity, + action: { + label: t('chat.chatInput.toast.largeTextPaste.attach'), + onClick: () => resolveLargePaste('attach'), + }, + cancel: { + label: t('chat.chatInput.toast.largeTextPaste.inline'), + onClick: () => resolveLargePaste('inline'), + }, + onDismiss: () => { + // Dismissing without a choice keeps the paste — insert inline + // so clipboard content is not lost. + resolveLargePaste('inline'); + }, + }, + ); return; } - if (!currentSessionId && !newSessionDraftOpen) { + if (!sessionReady) { if (pastedText.includes('@')) { markFileMentionPasteSuppression(); } @@ -1771,7 +1890,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo pendingPastedAttachmentFilenamesRef.current.delete(filename); } } - }, [addAttachedFile, attachedFiles, currentSessionId, inputMode, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]); + }, [addAttachedFile, attachedFiles, currentSessionId, inputMode, largeTextPasteBehavior, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]); const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => { diff --git a/packages/ui/src/components/chat/__tests__/attachmentCitations.test.ts b/packages/ui/src/components/chat/__tests__/attachmentCitations.test.ts index 96221b61..92d88ecd 100644 --- a/packages/ui/src/components/chat/__tests__/attachmentCitations.test.ts +++ b/packages/ui/src/components/chat/__tests__/attachmentCitations.test.ts @@ -5,6 +5,7 @@ import { buildAttachmentCitationText, findAttachmentCitationRanges, isGenericImageFilename, + nextPastedContextFilename, } from '../attachmentCitations'; describe('attachment citations', () => { @@ -53,4 +54,10 @@ describe('attachment citations', () => { ['desktop.jpg'], )).toEqual([{ start: 8, end: 21 }]); }); + + test('assigns sequential pasted-context filenames', () => { + expect(nextPastedContextFilename([])).toBe('pasted-context-1.txt'); + expect(nextPastedContextFilename(['pasted-context-1.txt', 'notes.md'])).toBe('pasted-context-2.txt'); + expect(nextPastedContextFilename(['PASTED-CONTEXT-2.TXT'])).toBe('pasted-context-1.txt'); + }); }); diff --git a/packages/ui/src/components/chat/attachmentCitations.ts b/packages/ui/src/components/chat/attachmentCitations.ts index 1faf6925..e3e380e1 100644 --- a/packages/ui/src/components/chat/attachmentCitations.ts +++ b/packages/ui/src/components/chat/attachmentCitations.ts @@ -144,6 +144,20 @@ export const assignImageAttachmentFilenames = ( }); }; +/** Next unused `pasted-context-N.txt` name for a large text paste attachment. */ +export const nextPastedContextFilename = (existingFilenames: string[]): string => { + const used = new Set(existingFilenames.map(normalizeFilenameKey)); + + for (let index = 1; index < Number.MAX_SAFE_INTEGER; index += 1) { + const candidate = `pasted-context-${index}.txt`; + if (!used.has(normalizeFilenameKey(candidate))) { + return candidate; + } + } + + return `pasted-context-${Date.now()}.txt`; +}; + export const buildAttachmentCitationText = (filenames: string[]): string => ( filenames.map((filename) => `[${filename}]`).join(' ') ); diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 3544932a..db521edb 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -18,6 +18,16 @@ belongs to one of them. | `attachments/` | Files: paths, drop payloads | | `ui/` | Presentation | | `text.ts` | How inserted text meets the text already there | +| `largeTextPaste.ts` | Detect large plain-text pastes and build virtual `.txt` files | + +`ChatInput.handlePaste` owns paste orchestration: URL-over-selection markdown +links, clipboard images (attach + citation), and large plain-text pastes. +Large pastes (about 2,000 characters or 25 lines) follow the composer setting +`largeTextPasteBehavior` (`ask` / `attach` / `inline`). Attaching creates an +in-memory `text/plain` file named `pasted-context-N.txt`, inserts a bracket +citation, and sends it through the same attachment pipeline as a manually +picked `.txt` file. Short text, images, and URL wraps keep their existing +paths. ## The prompt language @@ -119,8 +129,8 @@ hardware. The package has no DOM test environment, so coverage stops at the state and logic layers: the language, the submit assembly, path and drop handling, text -splicing, message history, and the CodeMirror language extension at the -`EditorState` level. +splicing, large-paste detection, message history, and the CodeMirror language +extension at the `EditorState` level. Rendering, focus, keyboard behavior, IME and WKWebView are **not covered by tests** and are verified by hand. Do not report a change to them as validated diff --git a/packages/ui/src/components/chat/composer/__tests__/largeTextPaste.test.ts b/packages/ui/src/components/chat/composer/__tests__/largeTextPaste.test.ts new file mode 100644 index 00000000..b205394e --- /dev/null +++ b/packages/ui/src/components/chat/composer/__tests__/largeTextPaste.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; + +import { + LARGE_TEXT_PASTE_CHAR_THRESHOLD, + LARGE_TEXT_PASTE_LINE_THRESHOLD, + createPastedContextFile, + isLargePlainTextPaste, +} from '../largeTextPaste'; + +describe('large text paste helpers', () => { + test('treats short text as not large', () => { + expect(isLargePlainTextPaste('hello world')).toBe(false); + expect(isLargePlainTextPaste('line1\nline2\nline3')).toBe(false); + }); + + test('treats empty and whitespace-only pastes as not large', () => { + expect(isLargePlainTextPaste('')).toBe(false); + expect(isLargePlainTextPaste(' \n\t ')).toBe(false); + }); + + test('detects pastes at the character threshold', () => { + const text = 'a'.repeat(LARGE_TEXT_PASTE_CHAR_THRESHOLD); + expect(isLargePlainTextPaste(text)).toBe(true); + expect(isLargePlainTextPaste(text.slice(0, -1))).toBe(false); + }); + + test('detects pastes at the line threshold', () => { + const lines = Array.from({ length: LARGE_TEXT_PASTE_LINE_THRESHOLD }, (_, index) => `line ${index}`); + expect(isLargePlainTextPaste(lines.join('\n'))).toBe(true); + expect(isLargePlainTextPaste(lines.slice(0, -1).join('\n'))).toBe(false); + }); + + test('honors custom thresholds', () => { + expect(isLargePlainTextPaste('abcdef', { charThreshold: 5 })).toBe(true); + expect(isLargePlainTextPaste('a\nb\nc', { lineThreshold: 3 })).toBe(true); + expect(isLargePlainTextPaste('a\nb', { lineThreshold: 3, charThreshold: 100 })).toBe(false); + }); + + test('creates a text/plain file with the given name', async () => { + const file = createPastedContextFile('architecture notes', 'pasted-context-1.txt'); + expect(file.name).toBe('pasted-context-1.txt'); + expect(file.type.startsWith('text/plain')).toBe(true); + expect(await file.text()).toBe('architecture notes'); + }); +}); diff --git a/packages/ui/src/components/chat/composer/largeTextPaste.ts b/packages/ui/src/components/chat/composer/largeTextPaste.ts new file mode 100644 index 00000000..2b180d52 --- /dev/null +++ b/packages/ui/src/components/chat/composer/largeTextPaste.ts @@ -0,0 +1,55 @@ +/** + * Large plain-text paste → virtual file attachment helpers. + * + * Detect when clipboard text is large enough that inserting it into the + * composer would clutter the prompt, and build an in-memory text/plain File + * the attachment pipeline can send like any other .txt attachment. + */ + +export const LARGE_TEXT_PASTE_CHAR_THRESHOLD = 2000; +export const LARGE_TEXT_PASTE_LINE_THRESHOLD = 25; + +const countLines = (text: string): number => { + let lines = 1; + for (let index = 0; index < text.length; index += 1) { + if (text.charCodeAt(index) === 10) { + lines += 1; + } + } + return lines; +}; + +/** + * Whether pasted plain text should be offered (or auto-handled) as a file + * attachment instead of being inserted into the composer. + * + * Empty / whitespace-only pastes are never large. Thresholds are OR'd: + * character count or line count is enough. + */ +export const isLargePlainTextPaste = ( + text: string, + options?: { + charThreshold?: number; + lineThreshold?: number; + }, +): boolean => { + if (!text || !text.trim()) { + return false; + } + + const charThreshold = options?.charThreshold ?? LARGE_TEXT_PASTE_CHAR_THRESHOLD; + const lineThreshold = options?.lineThreshold ?? LARGE_TEXT_PASTE_LINE_THRESHOLD; + + if (text.length >= charThreshold) { + return true; + } + + return countLines(text) >= lineThreshold; +}; + +export const createPastedContextFile = (text: string, filename: string): File => ( + new File([text], filename, { + type: 'text/plain', + lastModified: Date.now(), + }) +); diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 73f870ca..5e6e2071 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -206,6 +206,7 @@ const ChatSectionContent: React.FC = () => { 'followUpBehavior', 'persistDraft', 'inputSpellcheck', + 'largeTextPaste', ]} /> ); diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 73d76470..fee6c784 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -3,7 +3,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import type { ThemeMode } from '@/types/theme'; -import { useUIStore } from '@/stores/useUIStore'; +import { useUIStore, type LargeTextPasteBehavior } from '@/stores/useUIStore'; import { useMessageQueueStore, type FollowUpBehavior } from '@/stores/messageQueueStore'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -275,11 +275,26 @@ const FOLLOW_UP_BEHAVIOR_OPTIONS: Option[] = [ }, ]; +const LARGE_TEXT_PASTE_BEHAVIOR_OPTIONS: Option[] = [ + { + id: 'ask', + labelKey: 'settings.openchamber.visual.option.largeTextPaste.ask.label', + }, + { + id: 'attach', + labelKey: 'settings.openchamber.visual.option.largeTextPaste.attach.label', + }, + { + id: 'inline', + labelKey: 'settings.openchamber.visual.option.largeTextPaste.inline.label', + }, +]; + const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => { return mode === 'markdown' ? 'markdown' : 'plain'; }; -type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar' | 'autoSaveEnabled'; +type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'reportUsage' | 'expandedEditorToolbar' | 'autoSaveEnabled'; const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [ { id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' }, @@ -372,6 +387,8 @@ export const OpenChamberVisualSettings: React.FC const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft); const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled); const setInputSpellcheckEnabled = useUIStore(state => state.setInputSpellcheckEnabled); + const largeTextPasteBehavior = useUIStore(state => state.largeTextPasteBehavior); + const setLargeTextPasteBehavior = useUIStore(state => state.setLargeTextPasteBehavior); const showToolFileIcons = useUIStore(state => state.showToolFileIcons); const setShowToolFileIcons = useUIStore(state => state.setShowToolFileIcons); const showTurnChangedFiles = useUIStore(state => state.showTurnChangedFiles); @@ -665,6 +682,7 @@ export const OpenChamberVisualSettings: React.FC || shouldShow('reasoning') || shouldShow('followUpBehavior') || shouldShow('persistDraft') + || shouldShow('largeTextPaste') || shouldShow('showToolFileIcons') || shouldShow('expandedTools') || (!isMobile && shouldShow('inputSpellcheck')); @@ -687,6 +705,7 @@ export const OpenChamberVisualSettings: React.FC || shouldShow('dotfiles') || shouldShow('fileViewerPreview') || shouldShow('persistDraft') + || shouldShow('largeTextPaste') || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) @@ -2036,7 +2055,7 @@ export const OpenChamberVisualSettings: React.FC )} - {(shouldShow('persistDraft') || (!isMobile && shouldShow('inputSpellcheck'))) && ( + {(shouldShow('persistDraft') || shouldShow('largeTextPaste') || (!isMobile && shouldShow('inputSpellcheck'))) && ( settingsItem="chat.spellcheck" /> )} + + {shouldShow('largeTextPaste') && ( + + + {LARGE_TEXT_PASTE_BEHAVIOR_OPTIONS.map((option) => ( + setLargeTextPasteBehavior(option.id)} + label={tUnsafe(option.labelKey)} + ariaLabel={t('settings.openchamber.visual.field.largeTextPasteOptionAria', { option: tUnsafe(option.labelKey) })} + /> + ))} + + + )} )} diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index fc3cfa86..81862cbf 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1878,6 +1878,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': 'Entwurfsnachrichten speichern', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Rechtschreibprüfung in Texteingaben aktivieren', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Rechtschreibprüfung in Texteingaben aktivieren', + 'settings.openchamber.visual.field.largeTextPaste': 'Großes Texteinfügen', + 'settings.openchamber.visual.field.largeTextPasteHint': 'Beim Einfügen von mehr als etwa 2.000 Zeichen oder 25 Zeilen wählen, ob der Text als Datei angehängt, direkt eingefügt oder jedes Mal nachgefragt werden soll.', + 'settings.openchamber.visual.field.largeTextPasteAria': 'Verhalten bei großem Texteinfügen', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Großes Texteinfügen: {option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Jedes Mal fragen', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Als Datei anhängen', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Direkt einfügen', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Anonyme Nutzungsberichte senden', 'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Anonyme Nutzungsberichte senden', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Hilft uns zu verstehen, welche App-Versionen aktiv genutzt werden, damit wir Verbesserungen priorisieren können. Es werden nur die App-Version, Plattform und Laufzeit gesammelt - keine persönlichen Daten oder Code.', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 0db76c86..679c1812 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1963,6 +1963,11 @@ export const dict = { 'chat.chatInput.toast.sendAttachmentsFailed': 'Fehler beim Senden der Anhänge. Versuche weniger Dateien oder kleinere Bilder.', 'chat.chatInput.toast.messageSendFailed': 'Nachricht konnte nicht gesendet werden. Anhänge wurden wiederhergestellt.', 'chat.chatInput.toast.clipboardAttachFailed': 'Fehler beim Anhängen des Bildes aus der Zwischenablage', + 'chat.chatInput.toast.clipboardTextAttachFailed': 'Fehler beim Anhängen des eingefügten Texts als Datei', + 'chat.chatInput.toast.largeTextPaste.title': 'Großes Texteinfügen', + 'chat.chatInput.toast.largeTextPaste.description': 'Als Datei anhängen, um das Eingabefeld übersichtlich zu halten, oder den Text direkt einfügen.', + 'chat.chatInput.toast.largeTextPaste.attach': 'Als Datei anhängen', + 'chat.chatInput.toast.largeTextPaste.inline': 'Direkt einfügen', 'chat.chatInput.toast.addedFileMentions': '{count} Datei(er) hinzugefügt', 'chat.chatInput.toast.attachFileFailed': 'Fehler beim Anhängen der Datei', 'chat.chatInput.toast.attachNamedFailed': 'Fehler beim Anhängen von {name}', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 4a1deb81..cbff96df 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1964,6 +1964,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': 'Persist Draft Messages', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Enable spellcheck in text inputs', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Enable Spellcheck in Text Inputs', + 'settings.openchamber.visual.field.largeTextPaste': 'Large text paste', + 'settings.openchamber.visual.field.largeTextPasteHint': 'When pasting more than about 2,000 characters or 25 lines, choose whether to attach the text as a file, paste it inline, or ask each time.', + 'settings.openchamber.visual.field.largeTextPasteAria': 'Large text paste behavior', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Large text paste: {option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Ask each time', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Attach as file', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Paste inline', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Send anonymous usage reports', 'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Send anonymous usage reports', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Helps us understand which app versions are actively used so we can prioritize improvements. Only app version, platform, and runtime are collected - no personal data or code.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 17d137e5..9953d07d 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2124,6 +2124,11 @@ export const dict = { 'chat.chatInput.toast.sendAttachmentsFailed': 'Failed to send attachments. Try fewer files or smaller images.', 'chat.chatInput.toast.messageSendFailed': 'Message failed to send. Attachments restored.', 'chat.chatInput.toast.clipboardAttachFailed': 'Failed to attach image from clipboard', + 'chat.chatInput.toast.clipboardTextAttachFailed': 'Failed to attach pasted text as a file', + 'chat.chatInput.toast.largeTextPaste.title': 'Large text paste', + 'chat.chatInput.toast.largeTextPaste.description': 'Attach as a file to keep the composer clear, or paste the text inline.', + 'chat.chatInput.toast.largeTextPaste.attach': 'Attach as file', + 'chat.chatInput.toast.largeTextPaste.inline': 'Paste inline', 'chat.chatInput.toast.addedFileMentions': 'Added {count} file mention(s)', 'chat.chatInput.toast.attachFileFailed': 'Failed to attach file', 'chat.chatInput.toast.attachNamedFailed': 'Failed to attach {name}', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 31b07208..4cfed7ed 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1940,6 +1940,13 @@ export const settingsDict = { "settings.openchamber.visual.field.persistDraftMessages": "Conservar borradores de mensajes", "settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Habilitar ortografía en campos de texto", "settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Habilitar ortografía en campos de texto", + "settings.openchamber.visual.field.largeTextPaste": "Pegado de texto grande", + "settings.openchamber.visual.field.largeTextPasteHint": "Al pegar más de unos 2000 caracteres o 25 líneas, elige si adjuntar el texto como archivo, pegarlo en línea o preguntar cada vez.", + "settings.openchamber.visual.field.largeTextPasteAria": "Comportamiento del pegado de texto grande", + "settings.openchamber.visual.field.largeTextPasteOptionAria": "Pegado de texto grande: {option}", + "settings.openchamber.visual.option.largeTextPaste.ask.label": "Preguntar cada vez", + "settings.openchamber.visual.option.largeTextPaste.attach.label": "Adjuntar como archivo", + "settings.openchamber.visual.option.largeTextPaste.inline.label": "Pegar en línea", "settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Enviar informes anónimos de uso", "settings.openchamber.visual.field.sendAnonymousUsageReports": "Enviar informes anónimos de uso", "settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Nos ayuda a entender qué versiones de la aplicación se usan activamente para priorizar mejoras. Solo se recopilan la versión de la aplicación, la plataforma y el entorno de ejecución ; no se recopilan datos personales ni código.", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index ea5086d0..a4e2ea13 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2090,6 +2090,11 @@ export const dict: Record = { "chat.chatInput.toast.sendAttachmentsFailed": "No se pudieron enviar los adjuntos. Intenta con menos archivos o imágenes más pequeñas.", "chat.chatInput.toast.messageSendFailed": "El mensaje no se pudo enviar. Los adjuntos se restauraron.", "chat.chatInput.toast.clipboardAttachFailed": "No se pudo adjuntar la imagen desde el portapapeles", + "chat.chatInput.toast.clipboardTextAttachFailed": "No se pudo adjuntar el texto pegado como archivo", + "chat.chatInput.toast.largeTextPaste.title": "Pegado de texto grande", + "chat.chatInput.toast.largeTextPaste.description": "Adjunta como archivo para mantener el compositor despejado, o pega el texto en línea.", + "chat.chatInput.toast.largeTextPaste.attach": "Adjuntar como archivo", + "chat.chatInput.toast.largeTextPaste.inline": "Pegar en línea", "chat.chatInput.toast.addedFileMentions": "Se añadieron {count} mención(es) de archivo", "chat.chatInput.toast.attachFileFailed": "No se pudo adjuntar el archivo", "chat.chatInput.toast.attachNamedFailed": "No se pudo adjuntar {name}", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index f5dc26d4..2db269c5 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1843,6 +1843,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': 'Conserver les brouillons de messages', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Activer la vérification orthographique dans les saisies de texte', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Activer la vérification orthographique dans les entrées de texte', + 'settings.openchamber.visual.field.largeTextPaste': 'Collage de texte volumineux', + 'settings.openchamber.visual.field.largeTextPasteHint': 'Lors d’un collage de plus d’environ 2 000 caractères ou 25 lignes, choisir de joindre le texte comme fichier, de le coller en ligne ou de demander à chaque fois.', + 'settings.openchamber.visual.field.largeTextPasteAria': 'Comportement du collage de texte volumineux', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Collage de texte volumineux : {option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Demander à chaque fois', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Joindre comme fichier', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Coller en ligne', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Envoyer des rapports d\'utilisation anonymes', 'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Envoyer des rapports d\'utilisation anonymes', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Nous aide à comprendre quelles versions de l\'application sont activement utilisées afin que nous puissions prioriser les améliorations. Seules la version de l’application, la plate-forme et le runtime sont collectés – aucune donnée personnelle ni code.', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 3a625450..a56f5434 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1893,6 +1893,11 @@ export const dict = { 'chat.chatInput.toast.sendAttachmentsFailed': 'Échec de l\'envoi des pièces jointes. Essayez moins de fichiers ou des images plus petites.', 'chat.chatInput.toast.messageSendFailed': 'Le message n\'a pas pu être envoyé. Pièces jointes restaurées.', 'chat.chatInput.toast.clipboardAttachFailed': 'Échec de la pièce jointe de l\'image du presse-papiers', + 'chat.chatInput.toast.clipboardTextAttachFailed': 'Échec de la pièce jointe du texte collé comme fichier', + 'chat.chatInput.toast.largeTextPaste.title': 'Collage de texte volumineux', + 'chat.chatInput.toast.largeTextPaste.description': 'Joindre comme fichier pour garder la zone de saisie claire, ou coller le texte en ligne.', + 'chat.chatInput.toast.largeTextPaste.attach': 'Joindre comme fichier', + 'chat.chatInput.toast.largeTextPaste.inline': 'Coller en ligne', 'chat.chatInput.toast.addedFileMentions': 'Ajout des mentions du fichier {count}', 'chat.chatInput.toast.attachFileFailed': 'Impossible de joindre le fichier', 'chat.chatInput.toast.attachNamedFailed': 'Échec de la connexion du {name}', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 3d7565a9..6a64dd40 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1973,6 +1973,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': '下書きメッセージを保持', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'テキスト入力のスペルチェックを有効化', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'テキスト入力のスペルチェックを有効化', + 'settings.openchamber.visual.field.largeTextPaste': '大きなテキストの貼り付け', + 'settings.openchamber.visual.field.largeTextPasteHint': '約 2,000 文字または 25 行を超えるテキストを貼り付けるとき、ファイルとして添付するか、そのまま貼り付けるか、毎回確認するかを選べます。', + 'settings.openchamber.visual.field.largeTextPasteAria': '大きなテキスト貼り付けの動作', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': '大きなテキストの貼り付け: {option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': '毎回確認する', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': 'ファイルとして添付', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': 'そのまま貼り付け', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '匿名使用状況レポートを送信', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '匿名使用状況レポートを送信', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'どのアプリバージョンがアクティブに使用されているかを把握し、改善の優先順位を決めるのに役立ちます。収集されるのはアプリバージョン、プラットフォーム、ランタイムのみで、個人データやコードは収集されません。', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 181ec3e9..a8e8e4b9 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2120,6 +2120,11 @@ export const dict: Record = { 'chat.chatInput.toast.sendAttachmentsFailed': '添付ファイルの送信に失敗しました。ファイルを減らすかサイズを小さくしてください。', 'chat.chatInput.toast.messageSendFailed': 'メッセージの送信に失敗しました。添付ファイルは復元されました。', 'chat.chatInput.toast.clipboardAttachFailed': 'クリップボードからの画像添付に失敗しました', + 'chat.chatInput.toast.clipboardTextAttachFailed': '貼り付けたテキストのファイル添付に失敗しました', + 'chat.chatInput.toast.largeTextPaste.title': '大きなテキストの貼り付け', + 'chat.chatInput.toast.largeTextPaste.description': '入力欄をすっきり保つためにファイルとして添付するか、そのまま貼り付けます。', + 'chat.chatInput.toast.largeTextPaste.attach': 'ファイルとして添付', + 'chat.chatInput.toast.largeTextPaste.inline': 'そのまま貼り付け', 'chat.chatInput.toast.addedFileMentions': '{count}件のファイルメンションを追加しました', 'chat.chatInput.toast.attachFileFailed': 'ファイルの添付に失敗しました', 'gitView.commit.aiHighlights.insertAria': '挿入のariaラベル', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index df181c60..bf3b665a 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1940,6 +1940,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': '초안 메시지 유지', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '텍스트 입력에서 맞춤법 검사 활성화', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '텍스트 입력에서 맞춤법 검사 활성화', + 'settings.openchamber.visual.field.largeTextPaste': '긴 텍스트 붙여넣기', + 'settings.openchamber.visual.field.largeTextPasteHint': '약 2,000자 또는 25줄을 넘는 텍스트를 붙여넣을 때 파일로 첨부할지, 본문에 붙여넣을지, 매번 물어볼지 선택합니다.', + 'settings.openchamber.visual.field.largeTextPasteAria': '긴 텍스트 붙여넣기 동작', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': '긴 텍스트 붙여넣기: {option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': '매번 묻기', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': '파일로 첨부', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': '본문에 붙여넣기', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '익명 사용량 보고서 보내기', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '익명 사용량 보고서 보내기', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '활성 사용 앱 버전을 파악해 개선 우선순위를 정하는 데 도움이 됩니다. 앱 버전, 플랫폼, 런타임만 수집되며 개인 데이터나 코드는 수집되지 않습니다.', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 150a56ce..5794969b 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2124,6 +2124,11 @@ export const dict: Record = { 'chat.chatInput.toast.sendAttachmentsFailed': '첨부 파일 전송 실패. 파일 수나 이미지 크기를 줄여 보세요.', 'chat.chatInput.toast.messageSendFailed': '메시지 전송에 실패했습니다. 첨부 파일을 복원했습니다.', 'chat.chatInput.toast.clipboardAttachFailed': '클립보드 이미지 첨부 실패', + 'chat.chatInput.toast.clipboardTextAttachFailed': '붙여넣은 텍스트를 파일로 첨부하지 못했습니다', + 'chat.chatInput.toast.largeTextPaste.title': '긴 텍스트 붙여넣기', + 'chat.chatInput.toast.largeTextPaste.description': '입력창을 깔끔하게 유지하려면 파일로 첨부하거나, 본문에 붙여넣으세요.', + 'chat.chatInput.toast.largeTextPaste.attach': '파일로 첨부', + 'chat.chatInput.toast.largeTextPaste.inline': '본문에 붙여넣기', 'chat.chatInput.toast.addedFileMentions': '파일 멘션 {count}개 추가됨', 'chat.chatInput.toast.attachFileFailed': '첨부 파일 실패', 'chat.chatInput.toast.attachNamedFailed': '첨부 {name} 실패', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index dcc3d774..47249c01 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1024,6 +1024,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Włącz sprawdzanie pisowni w polach tekstowych', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Włącz sprawdzanie pisowni w polach tekstowych', + 'settings.openchamber.visual.field.largeTextPaste': 'Wklejanie dużego tekstu', + 'settings.openchamber.visual.field.largeTextPasteHint': 'Przy wklejaniu ponad około 2000 znaków lub 25 wierszy wybierz, czy dołączyć tekst jako plik, wkleić go w treści, czy pytać za każdym razem.', + 'settings.openchamber.visual.field.largeTextPasteAria': 'Zachowanie przy wklejaniu dużego tekstu', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': 'Wklejanie dużego tekstu: {option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': 'Pytaj za każdym razem', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': 'Dołącz jako plik', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': 'Wklej w treści', 'settings.openchamber.visual.field.fontSizePercentageAria': 'Procentowy rozmiar czcionki', 'settings.openchamber.visual.field.inputBarOffset': 'Przesunięcie paska wpisywania', 'settings.openchamber.visual.field.inputBarOffsetTooltip': 'Podnieś pasek wpisywania, aby uniknąć zasłaniania przez systemowe elementy ekranu, takie jak pasek gestów.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 8f93e1f6..fe6bad3e 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1221,6 +1221,11 @@ export const dict: Record = { 'chat.chatInput.toast.unsupportedAttachmentModalities': 'Model {model} nie obsługuje danych wejściowych {modalities} wymaganych przez {files}. Nadal możesz wysłać wiadomość, ale te załączniki mogą zostać zignorowane.', 'chat.chatInput.toast.attachmentsTooLarge': 'Załączniki są zbyt duże, aby je wysłać. Spróbuj zmniejszyć liczbę lub rozmiar obrazów.', 'chat.chatInput.toast.clipboardAttachFailed': 'Nie udało się dołączyć obrazu ze schowka', + 'chat.chatInput.toast.clipboardTextAttachFailed': 'Nie udało się dołączyć wklejonego tekstu jako pliku', + 'chat.chatInput.toast.largeTextPaste.title': 'Wklejanie dużego tekstu', + 'chat.chatInput.toast.largeTextPaste.description': 'Dołącz jako plik, aby nie zaśmiecać pola wiadomości, albo wklej tekst w treści.', + 'chat.chatInput.toast.largeTextPaste.attach': 'Dołącz jako plik', + 'chat.chatInput.toast.largeTextPaste.inline': 'Wklej w treści', 'chat.chatInput.toast.compactFailed': 'Nie udało się skompaktować sesji', 'chat.chatInput.toast.messageSendFailed': 'Nie udało się wysłać wiadomości. Załączniki zostały przywrócone.', 'chat.chatInput.toast.openSessionFirst': 'Najpierw otwórz sesję', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 5950ec10..e9b0b8dc 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1940,6 +1940,13 @@ export const settingsDict = { "settings.openchamber.visual.field.persistDraftMessages": "Manter rascunhos de mensagens", "settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Ativar ortografia em campos de texto", "settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Ativar ortografia em campos de texto", + "settings.openchamber.visual.field.largeTextPaste": "Colagem de texto grande", + "settings.openchamber.visual.field.largeTextPasteHint": "Ao colar mais de cerca de 2.000 caracteres ou 25 linhas, escolha anexar o texto como arquivo, colar no corpo da mensagem ou perguntar sempre.", + "settings.openchamber.visual.field.largeTextPasteAria": "Comportamento da colagem de texto grande", + "settings.openchamber.visual.field.largeTextPasteOptionAria": "Colagem de texto grande: {option}", + "settings.openchamber.visual.option.largeTextPaste.ask.label": "Perguntar sempre", + "settings.openchamber.visual.option.largeTextPaste.attach.label": "Anexar como arquivo", + "settings.openchamber.visual.option.largeTextPaste.inline.label": "Colar no corpo", "settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Enviar relatórios anônimos de uso", "settings.openchamber.visual.field.sendAnonymousUsageReports": "Enviar relatórios anônimos de uso", "settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Ajuda-nos a entender quais versões do aplicativo são usadas ativamente para priorizar melhorias. Coletamos apenas a versão do aplicativo, a plataforma e o ambiente de execução; não coletamos dados pessoais nem código.", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 44fef67a..3ad5cb36 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2090,6 +2090,11 @@ export const dict: Record = { "chat.chatInput.toast.sendAttachmentsFailed": "Não foi possível enviar os anexos. Tente com menos arquivos ou imagens menores.", "chat.chatInput.toast.messageSendFailed": "A mensagem não pôde ser enviada. Os anexos foram restaurados.", "chat.chatInput.toast.clipboardAttachFailed": "Não foi possível anexar a imagem da área de transferência", + "chat.chatInput.toast.clipboardTextAttachFailed": "Não foi possível anexar o texto colado como arquivo", + "chat.chatInput.toast.largeTextPaste.title": "Colagem de texto grande", + "chat.chatInput.toast.largeTextPaste.description": "Anexe como arquivo para manter o compositor limpo, ou cole o texto no corpo da mensagem.", + "chat.chatInput.toast.largeTextPaste.attach": "Anexar como arquivo", + "chat.chatInput.toast.largeTextPaste.inline": "Colar no corpo", "chat.chatInput.toast.addedFileMentions": "Foram adicionadas {count} menção(es) de arquivo", "chat.chatInput.toast.attachFileFailed": "Não foi possível anexar o arquivo", "chat.chatInput.toast.attachNamedFailed": "Não foi possível anexar {name}", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 306608d2..5c398ee8 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1940,6 +1940,13 @@ export const settingsDict = { "settings.openchamber.visual.field.persistDraftMessages": "Зберігати чернетки повідомлень", "settings.openchamber.visual.field.enableSpellcheckInTextInputsAria": "Увімкнути перевірку орфографії під час введення тексту", "settings.openchamber.visual.field.enableSpellcheckInTextInputs": "Увімкнути перевірку орфографії в текстових полях", + "settings.openchamber.visual.field.largeTextPaste": "Вставлення великого тексту", + "settings.openchamber.visual.field.largeTextPasteHint": "Під час вставлення понад приблизно 2000 символів або 25 рядків виберіть, чи долучити текст як файл, вставити його в повідомлення чи запитувати щоразу.", + "settings.openchamber.visual.field.largeTextPasteAria": "Поведінка вставлення великого тексту", + "settings.openchamber.visual.field.largeTextPasteOptionAria": "Вставлення великого тексту: {option}", + "settings.openchamber.visual.option.largeTextPaste.ask.label": "Запитувати щоразу", + "settings.openchamber.visual.option.largeTextPaste.attach.label": "Долучити як файл", + "settings.openchamber.visual.option.largeTextPaste.inline.label": "Вставити в повідомлення", "settings.openchamber.visual.field.sendAnonymousUsageReportsAria": "Надсилати анонімні звіти про використання", "settings.openchamber.visual.field.sendAnonymousUsageReports": "Надсилати анонімні звіти про використання", "settings.openchamber.visual.field.sendAnonymousUsageReportsHint": "Допомагає нам зрозуміти, які версії застосунків активно використовуються, щоб ми могли визначити пріоритети покращень. Збираються лише версія застосунку, платформа та середовище виконання – без особистих даних чи коду.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index c8663cea..4d779c76 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2090,6 +2090,11 @@ export const dict: Record = { "chat.chatInput.toast.sendAttachmentsFailed": "Не вдалося надіслати вкладення. Спробуйте зменшити кількість файлів або зображень.", "chat.chatInput.toast.messageSendFailed": "Не вдалося надіслати повідомлення. Вкладення відновлено.", "chat.chatInput.toast.clipboardAttachFailed": "Не вдалося вкласти зображення з буфера обміну", + "chat.chatInput.toast.clipboardTextAttachFailed": "Не вдалося долучити вставлений текст як файл", + "chat.chatInput.toast.largeTextPaste.title": "Вставлення великого тексту", + "chat.chatInput.toast.largeTextPaste.description": "Долучіть як файл, щоб не захаращувати поле вводу, або вставте текст у повідомлення.", + "chat.chatInput.toast.largeTextPaste.attach": "Долучити як файл", + "chat.chatInput.toast.largeTextPaste.inline": "Вставити в повідомлення", "chat.chatInput.toast.addedFileMentions": "Додано згадки файлів {count}", "chat.chatInput.toast.attachFileFailed": "Не вдалося прикріпити файл", "chat.chatInput.toast.attachNamedFailed": "Не вдалося прикріпити {name}", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 42aa7d0a..c6a54155 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1940,6 +1940,13 @@ export const settingsDict = { 'settings.openchamber.visual.field.persistDraftMessages': '保留草稿消息', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '在文本输入框启用拼写检查', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '在文本输入框启用拼写检查', + 'settings.openchamber.visual.field.largeTextPaste': '粘贴大段文本', + 'settings.openchamber.visual.field.largeTextPasteHint': '粘贴超过约 2000 个字符或 25 行时,可选择附加为文件、直接粘贴到输入框,或每次询问。', + 'settings.openchamber.visual.field.largeTextPasteAria': '大段文本粘贴行为', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': '大段文本粘贴:{option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': '每次询问', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': '附加为文件', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': '直接粘贴', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '发送匿名使用报告', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '发送匿名使用报告', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '帮助我们了解哪些应用版本正在被积极使用,以便优先改进。仅收集应用版本、平台和运行时信息,不收集个人数据或代码。', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 76ffac2f..77fdb126 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2090,6 +2090,11 @@ export const dict: Record = { 'chat.chatInput.toast.sendAttachmentsFailed': '发送附件失败。请尝试更少文件或更小图片。', 'chat.chatInput.toast.messageSendFailed': '消息发送失败,附件已恢复。', 'chat.chatInput.toast.clipboardAttachFailed': '从剪贴板附加图片失败', + 'chat.chatInput.toast.clipboardTextAttachFailed': '无法将粘贴的文本附加为文件', + 'chat.chatInput.toast.largeTextPaste.title': '粘贴大段文本', + 'chat.chatInput.toast.largeTextPaste.description': '附加为文件以保持输入框简洁,或直接粘贴到输入框。', + 'chat.chatInput.toast.largeTextPaste.attach': '附加为文件', + 'chat.chatInput.toast.largeTextPaste.inline': '直接粘贴', 'chat.chatInput.toast.addedFileMentions': '已添加 {count} 个文件提及', 'chat.chatInput.toast.attachFileFailed': '附加文件失败', 'chat.chatInput.toast.attachNamedFailed': '附加 {name} 失败', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index a1d814b0..258fe44c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1846,6 +1846,13 @@ 'settings.openchamber.visual.field.persistDraftMessages': '保留草稿訊息', 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': '在文字輸入方塊啟用拼寫檢查', 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': '在文字輸入方塊啟用拼寫檢查', + 'settings.openchamber.visual.field.largeTextPaste': '貼上大段文字', + 'settings.openchamber.visual.field.largeTextPasteHint': '貼上超過約 2000 個字元或 25 行時,可選擇附加為檔案、直接貼到輸入框,或每次詢問。', + 'settings.openchamber.visual.field.largeTextPasteAria': '大段文字貼上行為', + 'settings.openchamber.visual.field.largeTextPasteOptionAria': '大段文字貼上:{option}', + 'settings.openchamber.visual.option.largeTextPaste.ask.label': '每次詢問', + 'settings.openchamber.visual.option.largeTextPaste.attach.label': '附加為檔案', + 'settings.openchamber.visual.option.largeTextPaste.inline.label': '直接貼上', 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': '送出匿名使用報告', 'settings.openchamber.visual.field.sendAnonymousUsageReports': '送出匿名使用報告', 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': '協助我們了解哪些應用程式版本仍在被積極使用,以便優先改進。僅收集應用程式版本、平台與執行階段資訊,不收集個人資料或程式碼。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 3538d59c..e9a4a2cd 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2094,6 +2094,11 @@ export const dict: Record = { 'chat.chatInput.toast.sendAttachmentsFailed': '傳送附件失敗。請嘗試更少檔案或更小圖片。', 'chat.chatInput.toast.messageSendFailed': '訊息傳送失敗,附件已恢復。', 'chat.chatInput.toast.clipboardAttachFailed': '從剪貼簿附加圖片失敗', + 'chat.chatInput.toast.clipboardTextAttachFailed': '無法將貼上的文字附加為檔案', + 'chat.chatInput.toast.largeTextPaste.title': '貼上大段文字', + 'chat.chatInput.toast.largeTextPaste.description': '附加為檔案以保持輸入框簡潔,或直接貼到輸入框。', + 'chat.chatInput.toast.largeTextPaste.attach': '附加為檔案', + 'chat.chatInput.toast.largeTextPaste.inline': '直接貼上', 'chat.chatInput.toast.addedFileMentions': '已加入 {count} 個檔案提及', 'chat.chatInput.toast.attachFileFailed': '附加檔案失敗', 'chat.chatInput.toast.attachNamedFailed': '附加 {name} 失敗', diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index eecca22c..5c8980e3 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -338,7 +338,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ id: 'chat.composer', page: 'chat', titleKey: 'settings.openchamber.visual.section.composer', - keywords: ['input', 'draft', 'spellcheck'], + keywords: ['input', 'draft', 'spellcheck', 'paste'], }, { id: 'chat.spellcheck', @@ -347,6 +347,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ keywords: ['spelling', 'input'], isAvailable: (ctx) => !ctx.isMobile, }, + { + id: 'chat.large-text-paste', + page: 'chat', + titleKey: 'settings.openchamber.visual.field.largeTextPaste', + descriptionKey: 'settings.openchamber.visual.field.largeTextPasteHint', + keywords: ['paste', 'clipboard', 'attachment', 'large', 'text', 'file'], + }, { id: 'sessions.default-model', page: 'sessions', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index d00bb6ff..7a69bc77 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -25,6 +25,16 @@ export type WeekStartPreference = 'auto' | 'sunday' | 'monday'; export type DesktopWindowControlsPosition = 'left' | 'right'; export type DesktopWindowControlsStyle = 'classic' | 'traffic-lights'; export type FileEditorKeymap = 'default' | 'vim'; +export type LargeTextPasteBehavior = 'ask' | 'attach' | 'inline'; + +export const DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR: LargeTextPasteBehavior = 'ask'; + +export const normalizeLargeTextPasteBehavior = (value: unknown): LargeTextPasteBehavior => { + if (value === 'attach' || value === 'inline' || value === 'ask') { + return value; + } + return DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR; +}; function normalizeFileEditorKeymap(value: unknown): FileEditorKeymap { return value === 'vim' ? 'vim' : 'default'; @@ -690,6 +700,7 @@ interface UIStore { showOpenCodeUpdateNotifications: boolean; agentControlToolEnabled: boolean; inputSpellcheckEnabled: boolean; + largeTextPasteBehavior: LargeTextPasteBehavior; wideChatLayoutEnabled: boolean; codeBlockLineWrap: boolean; showToolFileIcons: boolean; @@ -851,6 +862,7 @@ interface UIStore { setShowOpenCodeUpdateNotifications: (value: boolean) => void; setAgentControlToolEnabled: (value: boolean) => void; setInputSpellcheckEnabled: (value: boolean) => void; + setLargeTextPasteBehavior: (value: LargeTextPasteBehavior) => void; setWideChatLayoutEnabled: (value: boolean) => void; setCodeBlockLineWrap: (value: boolean) => void; setShowToolFileIcons: (value: boolean) => void; @@ -1001,6 +1013,7 @@ export const useUIStore = create()( showOpenCodeUpdateNotifications: !isWindowsArm64(), agentControlToolEnabled: true, inputSpellcheckEnabled: false, + largeTextPasteBehavior: DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR, wideChatLayoutEnabled: false, codeBlockLineWrap: true, showToolFileIcons: true, @@ -2152,6 +2165,9 @@ export const useUIStore = create()( setInputSpellcheckEnabled: (value) => { set({ inputSpellcheckEnabled: value }); }, + setLargeTextPasteBehavior: (value) => { + set({ largeTextPasteBehavior: normalizeLargeTextPasteBehavior(value) }); + }, setWideChatLayoutEnabled: (value) => { set({ wideChatLayoutEnabled: value }); }, @@ -2377,6 +2393,7 @@ export const useUIStore = create()( } state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap); + state.largeTextPasteBehavior = normalizeLargeTextPasteBehavior(state.largeTextPasteBehavior); if (typeof state.autoSaveEnabled !== 'boolean') { state.autoSaveEnabled = true; @@ -2461,6 +2478,7 @@ export const useUIStore = create()( showOpenCodeUpdateNotifications: state.showOpenCodeUpdateNotifications, agentControlToolEnabled: state.agentControlToolEnabled, inputSpellcheckEnabled: state.inputSpellcheckEnabled, + largeTextPasteBehavior: state.largeTextPasteBehavior, wideChatLayoutEnabled: state.wideChatLayoutEnabled, codeBlockLineWrap: state.codeBlockLineWrap, showToolFileIcons: state.showToolFileIcons, diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index cc9b4abb..a8f96b13 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -54,7 +54,7 @@ So: | `selection-store.ts` | Model/agent/variant selections | App UI state | | `voice-store.ts` | Voice state | App UI state | -Local chat attachments are normalized by `attachment-files.ts` before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; HEIC/HEIF is converted to JPEG; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Jupyter notebooks become readable markdown with non-text outputs omitted. HAR credentials, cookies, and sensitive URL parameters are redacted, while request/response body text is omitted. SVG and Draw.io files are attached as source text, not executable/rendered content. Browser and VS Code pickers expose the same allowlist, while drag-and-drop may still accept an unknown extension after content inspection. +Local chat attachments are normalized by `attachment-files.ts` before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; HEIC/HEIF is converted to JPEG; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Jupyter notebooks become readable markdown with non-text outputs omitted. HAR credentials, cookies, and sensitive URL parameters are redacted, while request/response body text is omitted. SVG and Draw.io files are attached as source text, not executable/rendered content. Browser and VS Code pickers expose the same allowlist, while drag-and-drop may still accept an unknown extension after content inspection. Large plain-text clipboard pastes can become in-memory `text/plain` attachments named `pasted-context-N.txt` through the composer paste path; they use the same normalization and send pipeline as manually attached `.txt` files. Office and OpenDocument packages are metadata-validated before asynchronous extraction, with limits of 20 MB compressed input, 5,000 archive entries, 25 MB per entry, 8 MB per XML part, and 100 MB total uncompressed content. Unsafe or non-canonical archive paths reject the whole attachment, and only XML, relationship, and supported image entries are decompressed and retained. Extracted text, including its explicit truncation notice, is bounded to 2,000,000 characters. At most 50 signature-validated PNG, JPEG, GIF, or WebP images and 40 MB of image bytes are retained, with a 20 MB per-image limit; unsupported, invalid, omitted, and truncated content remains explicit in the extracted text. Images whose citations fall beyond text truncation are not attached. Extracted document content remains a `text/plain` file attachment with the original document filename, rather than becoming visible user-message text. Supported embedded images become separate image file parts; the extracted text contains `[filename]` citations at the source paragraph, slide object, spreadsheet cell anchor, or OpenDocument text position. Generated image filenames are re-evaluated if the composer changes during asynchronous preparation, avoiding collisions. The store publishes all generated parts atomically only after every data URL is ready. From 471d0a8ecb005f37983ecdb979b8b33333969b8e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 11:57:38 +0000 Subject: [PATCH 13/66] fix(ui): neaten large text paste toast Drop the grey description and info icon, and widen the toast so the title and two actions sit on one clean row. Co-authored-by: Serhii Dziupin --- packages/ui/src/components/chat/ChatInput.tsx | 2 +- packages/ui/src/lib/i18n/messages/de.ts | 1 - packages/ui/src/lib/i18n/messages/en.ts | 1 - packages/ui/src/lib/i18n/messages/es.ts | 1 - packages/ui/src/lib/i18n/messages/fr.ts | 1 - packages/ui/src/lib/i18n/messages/ja.ts | 1 - packages/ui/src/lib/i18n/messages/ko.ts | 1 - packages/ui/src/lib/i18n/messages/pl.ts | 1 - packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 - packages/ui/src/lib/i18n/messages/uk.ts | 1 - packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 - packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 - 12 files changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index cc4c6d08..b5dee769 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1829,8 +1829,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo largeTextPasteToastIdRef.current = toast.info( t('chat.chatInput.toast.largeTextPaste.title'), { - description: t('chat.chatInput.toast.largeTextPaste.description'), duration: Infinity, + className: '!min-w-[22rem] !w-auto [&_[data-icon]]:!hidden', action: { label: t('chat.chatInput.toast.largeTextPaste.attach'), onClick: () => resolveLargePaste('attach'), diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 679c1812..9bcdd585 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1965,7 +1965,6 @@ export const dict = { 'chat.chatInput.toast.clipboardAttachFailed': 'Fehler beim Anhängen des Bildes aus der Zwischenablage', 'chat.chatInput.toast.clipboardTextAttachFailed': 'Fehler beim Anhängen des eingefügten Texts als Datei', 'chat.chatInput.toast.largeTextPaste.title': 'Großes Texteinfügen', - 'chat.chatInput.toast.largeTextPaste.description': 'Als Datei anhängen, um das Eingabefeld übersichtlich zu halten, oder den Text direkt einfügen.', 'chat.chatInput.toast.largeTextPaste.attach': 'Als Datei anhängen', 'chat.chatInput.toast.largeTextPaste.inline': 'Direkt einfügen', 'chat.chatInput.toast.addedFileMentions': '{count} Datei(er) hinzugefügt', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 9953d07d..22c51d6e 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2126,7 +2126,6 @@ export const dict = { 'chat.chatInput.toast.clipboardAttachFailed': 'Failed to attach image from clipboard', 'chat.chatInput.toast.clipboardTextAttachFailed': 'Failed to attach pasted text as a file', 'chat.chatInput.toast.largeTextPaste.title': 'Large text paste', - 'chat.chatInput.toast.largeTextPaste.description': 'Attach as a file to keep the composer clear, or paste the text inline.', 'chat.chatInput.toast.largeTextPaste.attach': 'Attach as file', 'chat.chatInput.toast.largeTextPaste.inline': 'Paste inline', 'chat.chatInput.toast.addedFileMentions': 'Added {count} file mention(s)', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index a4e2ea13..a45604e5 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2092,7 +2092,6 @@ export const dict: Record = { "chat.chatInput.toast.clipboardAttachFailed": "No se pudo adjuntar la imagen desde el portapapeles", "chat.chatInput.toast.clipboardTextAttachFailed": "No se pudo adjuntar el texto pegado como archivo", "chat.chatInput.toast.largeTextPaste.title": "Pegado de texto grande", - "chat.chatInput.toast.largeTextPaste.description": "Adjunta como archivo para mantener el compositor despejado, o pega el texto en línea.", "chat.chatInput.toast.largeTextPaste.attach": "Adjuntar como archivo", "chat.chatInput.toast.largeTextPaste.inline": "Pegar en línea", "chat.chatInput.toast.addedFileMentions": "Se añadieron {count} mención(es) de archivo", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index a56f5434..7b5d5ed4 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1895,7 +1895,6 @@ export const dict = { 'chat.chatInput.toast.clipboardAttachFailed': 'Échec de la pièce jointe de l\'image du presse-papiers', 'chat.chatInput.toast.clipboardTextAttachFailed': 'Échec de la pièce jointe du texte collé comme fichier', 'chat.chatInput.toast.largeTextPaste.title': 'Collage de texte volumineux', - 'chat.chatInput.toast.largeTextPaste.description': 'Joindre comme fichier pour garder la zone de saisie claire, ou coller le texte en ligne.', 'chat.chatInput.toast.largeTextPaste.attach': 'Joindre comme fichier', 'chat.chatInput.toast.largeTextPaste.inline': 'Coller en ligne', 'chat.chatInput.toast.addedFileMentions': 'Ajout des mentions du fichier {count}', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index a8e8e4b9..b3bf1ccd 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2122,7 +2122,6 @@ export const dict: Record = { 'chat.chatInput.toast.clipboardAttachFailed': 'クリップボードからの画像添付に失敗しました', 'chat.chatInput.toast.clipboardTextAttachFailed': '貼り付けたテキストのファイル添付に失敗しました', 'chat.chatInput.toast.largeTextPaste.title': '大きなテキストの貼り付け', - 'chat.chatInput.toast.largeTextPaste.description': '入力欄をすっきり保つためにファイルとして添付するか、そのまま貼り付けます。', 'chat.chatInput.toast.largeTextPaste.attach': 'ファイルとして添付', 'chat.chatInput.toast.largeTextPaste.inline': 'そのまま貼り付け', 'chat.chatInput.toast.addedFileMentions': '{count}件のファイルメンションを追加しました', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 5794969b..3fe5a80c 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2126,7 +2126,6 @@ export const dict: Record = { 'chat.chatInput.toast.clipboardAttachFailed': '클립보드 이미지 첨부 실패', 'chat.chatInput.toast.clipboardTextAttachFailed': '붙여넣은 텍스트를 파일로 첨부하지 못했습니다', 'chat.chatInput.toast.largeTextPaste.title': '긴 텍스트 붙여넣기', - 'chat.chatInput.toast.largeTextPaste.description': '입력창을 깔끔하게 유지하려면 파일로 첨부하거나, 본문에 붙여넣으세요.', 'chat.chatInput.toast.largeTextPaste.attach': '파일로 첨부', 'chat.chatInput.toast.largeTextPaste.inline': '본문에 붙여넣기', 'chat.chatInput.toast.addedFileMentions': '파일 멘션 {count}개 추가됨', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index fe6bad3e..057b86b7 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1223,7 +1223,6 @@ export const dict: Record = { 'chat.chatInput.toast.clipboardAttachFailed': 'Nie udało się dołączyć obrazu ze schowka', 'chat.chatInput.toast.clipboardTextAttachFailed': 'Nie udało się dołączyć wklejonego tekstu jako pliku', 'chat.chatInput.toast.largeTextPaste.title': 'Wklejanie dużego tekstu', - 'chat.chatInput.toast.largeTextPaste.description': 'Dołącz jako plik, aby nie zaśmiecać pola wiadomości, albo wklej tekst w treści.', 'chat.chatInput.toast.largeTextPaste.attach': 'Dołącz jako plik', 'chat.chatInput.toast.largeTextPaste.inline': 'Wklej w treści', 'chat.chatInput.toast.compactFailed': 'Nie udało się skompaktować sesji', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 3ad5cb36..9116573a 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2092,7 +2092,6 @@ export const dict: Record = { "chat.chatInput.toast.clipboardAttachFailed": "Não foi possível anexar a imagem da área de transferência", "chat.chatInput.toast.clipboardTextAttachFailed": "Não foi possível anexar o texto colado como arquivo", "chat.chatInput.toast.largeTextPaste.title": "Colagem de texto grande", - "chat.chatInput.toast.largeTextPaste.description": "Anexe como arquivo para manter o compositor limpo, ou cole o texto no corpo da mensagem.", "chat.chatInput.toast.largeTextPaste.attach": "Anexar como arquivo", "chat.chatInput.toast.largeTextPaste.inline": "Colar no corpo", "chat.chatInput.toast.addedFileMentions": "Foram adicionadas {count} menção(es) de arquivo", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 4d779c76..2b93fa06 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2092,7 +2092,6 @@ export const dict: Record = { "chat.chatInput.toast.clipboardAttachFailed": "Не вдалося вкласти зображення з буфера обміну", "chat.chatInput.toast.clipboardTextAttachFailed": "Не вдалося долучити вставлений текст як файл", "chat.chatInput.toast.largeTextPaste.title": "Вставлення великого тексту", - "chat.chatInput.toast.largeTextPaste.description": "Долучіть як файл, щоб не захаращувати поле вводу, або вставте текст у повідомлення.", "chat.chatInput.toast.largeTextPaste.attach": "Долучити як файл", "chat.chatInput.toast.largeTextPaste.inline": "Вставити в повідомлення", "chat.chatInput.toast.addedFileMentions": "Додано згадки файлів {count}", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 77fdb126..58cc1898 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2092,7 +2092,6 @@ export const dict: Record = { 'chat.chatInput.toast.clipboardAttachFailed': '从剪贴板附加图片失败', 'chat.chatInput.toast.clipboardTextAttachFailed': '无法将粘贴的文本附加为文件', 'chat.chatInput.toast.largeTextPaste.title': '粘贴大段文本', - 'chat.chatInput.toast.largeTextPaste.description': '附加为文件以保持输入框简洁,或直接粘贴到输入框。', 'chat.chatInput.toast.largeTextPaste.attach': '附加为文件', 'chat.chatInput.toast.largeTextPaste.inline': '直接粘贴', 'chat.chatInput.toast.addedFileMentions': '已添加 {count} 个文件提及', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index e9a4a2cd..8753d3bc 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2096,7 +2096,6 @@ export const dict: Record = { 'chat.chatInput.toast.clipboardAttachFailed': '從剪貼簿附加圖片失敗', 'chat.chatInput.toast.clipboardTextAttachFailed': '無法將貼上的文字附加為檔案', 'chat.chatInput.toast.largeTextPaste.title': '貼上大段文字', - 'chat.chatInput.toast.largeTextPaste.description': '附加為檔案以保持輸入框簡潔,或直接貼到輸入框。', 'chat.chatInput.toast.largeTextPaste.attach': '附加為檔案', 'chat.chatInput.toast.largeTextPaste.inline': '直接貼上', 'chat.chatInput.toast.addedFileMentions': '已加入 {count} 個檔案提及', From 29420a7e7e74445e165b8c739473212633655429 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 12:15:01 +0000 Subject: [PATCH 14/66] fix(ui): rename large paste toast to Large text detected Use clearer toast title copy across all locales. Co-authored-by: Serhii Dziupin --- packages/ui/src/lib/i18n/messages/de.ts | 2 +- 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/ja.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 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 9bcdd585..293e3225 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1964,7 +1964,7 @@ export const dict = { 'chat.chatInput.toast.messageSendFailed': 'Nachricht konnte nicht gesendet werden. Anhänge wurden wiederhergestellt.', 'chat.chatInput.toast.clipboardAttachFailed': 'Fehler beim Anhängen des Bildes aus der Zwischenablage', 'chat.chatInput.toast.clipboardTextAttachFailed': 'Fehler beim Anhängen des eingefügten Texts als Datei', - 'chat.chatInput.toast.largeTextPaste.title': 'Großes Texteinfügen', + 'chat.chatInput.toast.largeTextPaste.title': 'Großer Text erkannt', 'chat.chatInput.toast.largeTextPaste.attach': 'Als Datei anhängen', 'chat.chatInput.toast.largeTextPaste.inline': 'Direkt einfügen', 'chat.chatInput.toast.addedFileMentions': '{count} Datei(er) hinzugefügt', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 22c51d6e..84a8f38d 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2125,7 +2125,7 @@ export const dict = { 'chat.chatInput.toast.messageSendFailed': 'Message failed to send. Attachments restored.', 'chat.chatInput.toast.clipboardAttachFailed': 'Failed to attach image from clipboard', 'chat.chatInput.toast.clipboardTextAttachFailed': 'Failed to attach pasted text as a file', - 'chat.chatInput.toast.largeTextPaste.title': 'Large text paste', + 'chat.chatInput.toast.largeTextPaste.title': 'Large text detected', 'chat.chatInput.toast.largeTextPaste.attach': 'Attach as file', 'chat.chatInput.toast.largeTextPaste.inline': 'Paste inline', 'chat.chatInput.toast.addedFileMentions': 'Added {count} file mention(s)', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index a45604e5..7099dd5a 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2091,7 +2091,7 @@ export const dict: Record = { "chat.chatInput.toast.messageSendFailed": "El mensaje no se pudo enviar. Los adjuntos se restauraron.", "chat.chatInput.toast.clipboardAttachFailed": "No se pudo adjuntar la imagen desde el portapapeles", "chat.chatInput.toast.clipboardTextAttachFailed": "No se pudo adjuntar el texto pegado como archivo", - "chat.chatInput.toast.largeTextPaste.title": "Pegado de texto grande", + "chat.chatInput.toast.largeTextPaste.title": "Texto grande detectado", "chat.chatInput.toast.largeTextPaste.attach": "Adjuntar como archivo", "chat.chatInput.toast.largeTextPaste.inline": "Pegar en línea", "chat.chatInput.toast.addedFileMentions": "Se añadieron {count} mención(es) de archivo", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 7b5d5ed4..badf21ec 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1894,7 +1894,7 @@ export const dict = { 'chat.chatInput.toast.messageSendFailed': 'Le message n\'a pas pu être envoyé. Pièces jointes restaurées.', 'chat.chatInput.toast.clipboardAttachFailed': 'Échec de la pièce jointe de l\'image du presse-papiers', 'chat.chatInput.toast.clipboardTextAttachFailed': 'Échec de la pièce jointe du texte collé comme fichier', - 'chat.chatInput.toast.largeTextPaste.title': 'Collage de texte volumineux', + 'chat.chatInput.toast.largeTextPaste.title': 'Texte volumineux détecté', 'chat.chatInput.toast.largeTextPaste.attach': 'Joindre comme fichier', 'chat.chatInput.toast.largeTextPaste.inline': 'Coller en ligne', 'chat.chatInput.toast.addedFileMentions': 'Ajout des mentions du fichier {count}', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index b3bf1ccd..c07f604f 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2121,7 +2121,7 @@ export const dict: Record = { 'chat.chatInput.toast.messageSendFailed': 'メッセージの送信に失敗しました。添付ファイルは復元されました。', 'chat.chatInput.toast.clipboardAttachFailed': 'クリップボードからの画像添付に失敗しました', 'chat.chatInput.toast.clipboardTextAttachFailed': '貼り付けたテキストのファイル添付に失敗しました', - 'chat.chatInput.toast.largeTextPaste.title': '大きなテキストの貼り付け', + 'chat.chatInput.toast.largeTextPaste.title': '大きなテキストを検出', 'chat.chatInput.toast.largeTextPaste.attach': 'ファイルとして添付', 'chat.chatInput.toast.largeTextPaste.inline': 'そのまま貼り付け', 'chat.chatInput.toast.addedFileMentions': '{count}件のファイルメンションを追加しました', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 3fe5a80c..32787b7d 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2125,7 +2125,7 @@ export const dict: Record = { 'chat.chatInput.toast.messageSendFailed': '메시지 전송에 실패했습니다. 첨부 파일을 복원했습니다.', 'chat.chatInput.toast.clipboardAttachFailed': '클립보드 이미지 첨부 실패', 'chat.chatInput.toast.clipboardTextAttachFailed': '붙여넣은 텍스트를 파일로 첨부하지 못했습니다', - 'chat.chatInput.toast.largeTextPaste.title': '긴 텍스트 붙여넣기', + 'chat.chatInput.toast.largeTextPaste.title': '긴 텍스트 감지됨', 'chat.chatInput.toast.largeTextPaste.attach': '파일로 첨부', 'chat.chatInput.toast.largeTextPaste.inline': '본문에 붙여넣기', 'chat.chatInput.toast.addedFileMentions': '파일 멘션 {count}개 추가됨', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 057b86b7..a1ebaf3d 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1222,7 +1222,7 @@ export const dict: Record = { 'chat.chatInput.toast.attachmentsTooLarge': 'Załączniki są zbyt duże, aby je wysłać. Spróbuj zmniejszyć liczbę lub rozmiar obrazów.', 'chat.chatInput.toast.clipboardAttachFailed': 'Nie udało się dołączyć obrazu ze schowka', 'chat.chatInput.toast.clipboardTextAttachFailed': 'Nie udało się dołączyć wklejonego tekstu jako pliku', - 'chat.chatInput.toast.largeTextPaste.title': 'Wklejanie dużego tekstu', + 'chat.chatInput.toast.largeTextPaste.title': 'Wykryto duży tekst', 'chat.chatInput.toast.largeTextPaste.attach': 'Dołącz jako plik', 'chat.chatInput.toast.largeTextPaste.inline': 'Wklej w treści', 'chat.chatInput.toast.compactFailed': 'Nie udało się skompaktować sesji', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 9116573a..72aa9589 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2091,7 +2091,7 @@ export const dict: Record = { "chat.chatInput.toast.messageSendFailed": "A mensagem não pôde ser enviada. Os anexos foram restaurados.", "chat.chatInput.toast.clipboardAttachFailed": "Não foi possível anexar a imagem da área de transferência", "chat.chatInput.toast.clipboardTextAttachFailed": "Não foi possível anexar o texto colado como arquivo", - "chat.chatInput.toast.largeTextPaste.title": "Colagem de texto grande", + "chat.chatInput.toast.largeTextPaste.title": "Texto grande detectado", "chat.chatInput.toast.largeTextPaste.attach": "Anexar como arquivo", "chat.chatInput.toast.largeTextPaste.inline": "Colar no corpo", "chat.chatInput.toast.addedFileMentions": "Foram adicionadas {count} menção(es) de arquivo", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 2b93fa06..6f25914b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2091,7 +2091,7 @@ export const dict: Record = { "chat.chatInput.toast.messageSendFailed": "Не вдалося надіслати повідомлення. Вкладення відновлено.", "chat.chatInput.toast.clipboardAttachFailed": "Не вдалося вкласти зображення з буфера обміну", "chat.chatInput.toast.clipboardTextAttachFailed": "Не вдалося долучити вставлений текст як файл", - "chat.chatInput.toast.largeTextPaste.title": "Вставлення великого тексту", + "chat.chatInput.toast.largeTextPaste.title": "Виявлено великий текст", "chat.chatInput.toast.largeTextPaste.attach": "Долучити як файл", "chat.chatInput.toast.largeTextPaste.inline": "Вставити в повідомлення", "chat.chatInput.toast.addedFileMentions": "Додано згадки файлів {count}", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 58cc1898..bd1bcc72 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2091,7 +2091,7 @@ export const dict: Record = { 'chat.chatInput.toast.messageSendFailed': '消息发送失败,附件已恢复。', 'chat.chatInput.toast.clipboardAttachFailed': '从剪贴板附加图片失败', 'chat.chatInput.toast.clipboardTextAttachFailed': '无法将粘贴的文本附加为文件', - 'chat.chatInput.toast.largeTextPaste.title': '粘贴大段文本', + 'chat.chatInput.toast.largeTextPaste.title': '检测到大段文本', 'chat.chatInput.toast.largeTextPaste.attach': '附加为文件', 'chat.chatInput.toast.largeTextPaste.inline': '直接粘贴', 'chat.chatInput.toast.addedFileMentions': '已添加 {count} 个文件提及', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 8753d3bc..ad054f0d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2095,7 +2095,7 @@ export const dict: Record = { 'chat.chatInput.toast.messageSendFailed': '訊息傳送失敗,附件已恢復。', 'chat.chatInput.toast.clipboardAttachFailed': '從剪貼簿附加圖片失敗', 'chat.chatInput.toast.clipboardTextAttachFailed': '無法將貼上的文字附加為檔案', - 'chat.chatInput.toast.largeTextPaste.title': '貼上大段文字', + 'chat.chatInput.toast.largeTextPaste.title': '偵測到大段文字', 'chat.chatInput.toast.largeTextPaste.attach': '附加為檔案', 'chat.chatInput.toast.largeTextPaste.inline': '直接貼上', 'chat.chatInput.toast.addedFileMentions': '已加入 {count} 個檔案提及', From d26ff65c5849229001d740cf031dcc82252f3292 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 13:18:56 +0000 Subject: [PATCH 15/66] fix(ui): harden large-paste toast for mobile and stale state Scope toast width overrides to sm+ so Sonner keeps full-width mobile toasts, resolve ask actions from live composer/attachment state, and extract offer-id invalidation into a unit-tested helper. Co-authored-by: Serhii Dziupin --- packages/ui/src/components/chat/ChatInput.tsx | 43 +++++++++----- .../components/chat/composer/DOCUMENTATION.md | 10 ++-- .../__tests__/largeTextPasteOffer.test.ts | 56 +++++++++++++++++++ .../chat/composer/largeTextPasteOffer.ts | 33 +++++++++++ 4 files changed, 124 insertions(+), 18 deletions(-) create mode 100644 packages/ui/src/components/chat/composer/__tests__/largeTextPasteOffer.test.ts create mode 100644 packages/ui/src/components/chat/composer/largeTextPasteOffer.ts diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index b5dee769..4f4cde5c 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -88,6 +88,11 @@ import { createPastedContextFile, isLargePlainTextPaste, } from './composer/largeTextPaste'; +import { + LARGE_TEXT_PASTE_TOAST_CLASSNAME, + beginLargeTextPasteOffer, + resolveLargeTextPasteOffer, +} from './composer/largeTextPasteOffer'; import type { LargeTextPasteBehavior } from '@/stores/useUIStore'; import type { FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState'; import { @@ -1591,21 +1596,24 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (!editor) { // No mounted editor (collapsed mobile pill): append to the state // the editor will be seeded from. - const nextValue = message + text; + const nextValue = messageRef.current + text; setMessage(nextValue); updateAutocompleteState(nextValue, nextValue.length, inputSource, text); return; } const { start, end } = editor.getSelection(); - const nextValue = `${message.substring(0, start)}${text}${message.substring(end)}`; + // Read the live document — delayed toast actions must not use a + // paste-time React `message` closure. + const currentMessage = editor.getValue(); + const nextValue = `${currentMessage.substring(0, start)}${text}${currentMessage.substring(end)}`; const cursorPosition = start + text.length; // One dispatch places both the text and the caret, so there is no // frame where the caret sits at a stale offset. editor.insertText(text); updateAutocompleteState(nextValue, cursorPosition, inputSource, text); - }, [message, updateAutocompleteState]); + }, [updateAutocompleteState]); const clearDropTextSuppression = React.useCallback(() => { suppressNextFileDropTextInsertRef.current = false; @@ -1762,18 +1770,22 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }; const attachAsFile = async () => { + // Read live attachment + composer state at action time — the ask + // toast can outlive the paste while the user types or attaches more. + const liveAttachedFiles = useInputStore.getState().attachedFiles; const filename = nextPastedContextFilename([ - ...attachedFiles.map((file) => file.filename), + ...liveAttachedFiles.map((file) => file.filename), ...pendingPastedAttachmentFilenamesRef.current, ]); const citationText = buildAttachmentCitationText([filename]); - const textarea = composerRef.current; - const selectionStart = textarea?.getSelection().start ?? message.length; - const selectionEnd = textarea?.getSelection().end ?? message.length; + const editor = composerRef.current; + const currentMessage = editor?.getValue() ?? messageRef.current; + const selectionStart = editor?.getSelection().start ?? currentMessage.length; + const selectionEnd = editor?.getSelection().end ?? currentMessage.length; const insertionText = withInlineInsertionBoundaries( citationText, - message.slice(0, selectionStart), - message.slice(selectionEnd), + currentMessage.slice(0, selectionStart), + currentMessage.slice(selectionEnd), ); insertTextAtSelection( @@ -1802,7 +1814,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return; } - const offerId = largeTextPasteOfferIdRef.current + 1; + const offerId = beginLargeTextPasteOffer(largeTextPasteOfferIdRef.current); largeTextPasteOfferIdRef.current = offerId; if (largeTextPasteToastIdRef.current !== null) { @@ -1813,11 +1825,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } const resolveLargePaste = (action: 'attach' | 'inline') => { - if (offerId !== largeTextPasteOfferIdRef.current) { + const resolution = resolveLargeTextPasteOffer( + largeTextPasteOfferIdRef.current, + offerId, + ); + largeTextPasteOfferIdRef.current = resolution.nextOfferId; + if (!resolution.accepted) { return; } - // Invalidate this offer so a later onDismiss cannot double-apply. - largeTextPasteOfferIdRef.current += 1; largeTextPasteToastIdRef.current = null; if (action === 'attach') { void attachAsFile(); @@ -1830,7 +1845,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo t('chat.chatInput.toast.largeTextPaste.title'), { duration: Infinity, - className: '!min-w-[22rem] !w-auto [&_[data-icon]]:!hidden', + className: LARGE_TEXT_PASTE_TOAST_CLASSNAME, action: { label: t('chat.chatInput.toast.largeTextPaste.attach'), onClick: () => resolveLargePaste('attach'), diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index db521edb..759067ee 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -19,6 +19,7 @@ belongs to one of them. | `ui/` | Presentation | | `text.ts` | How inserted text meets the text already there | | `largeTextPaste.ts` | Detect large plain-text pastes and build virtual `.txt` files | +| `largeTextPasteOffer.ts` | Ask-toast offer id begin/resolve (supersede + double-apply guards) | `ChatInput.handlePaste` owns paste orchestration: URL-over-selection markdown links, clipboard images (attach + citation), and large plain-text pastes. @@ -26,8 +27,9 @@ Large pastes (about 2,000 characters or 25 lines) follow the composer setting `largeTextPasteBehavior` (`ask` / `attach` / `inline`). Attaching creates an in-memory `text/plain` file named `pasted-context-N.txt`, inserts a bracket citation, and sends it through the same attachment pipeline as a manually -picked `.txt` file. Short text, images, and URL wraps keep their existing -paths. +picked `.txt` file. Ask-toast actions read live composer/attachment state so +typing or other attaches between paste and choice stay consistent. Short text, +images, and URL wraps keep their existing paths. ## The prompt language @@ -129,8 +131,8 @@ hardware. The package has no DOM test environment, so coverage stops at the state and logic layers: the language, the submit assembly, path and drop handling, text -splicing, large-paste detection, message history, and the CodeMirror language -extension at the `EditorState` level. +splicing, large-paste detection, paste-offer invalidation, message history, and +the CodeMirror language extension at the `EditorState` level. Rendering, focus, keyboard behavior, IME and WKWebView are **not covered by tests** and are verified by hand. Do not report a change to them as validated diff --git a/packages/ui/src/components/chat/composer/__tests__/largeTextPasteOffer.test.ts b/packages/ui/src/components/chat/composer/__tests__/largeTextPasteOffer.test.ts new file mode 100644 index 00000000..2b453a31 --- /dev/null +++ b/packages/ui/src/components/chat/composer/__tests__/largeTextPasteOffer.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test'; + +import { + LARGE_TEXT_PASTE_TOAST_CLASSNAME, + beginLargeTextPasteOffer, + resolveLargeTextPasteOffer, +} from '../largeTextPasteOffer'; + +describe('large text paste offer state', () => { + test('begin allocates the next offer id', () => { + expect(beginLargeTextPasteOffer(0)).toBe(1); + expect(beginLargeTextPasteOffer(3)).toBe(4); + }); + + test('resolve accepts a matching active offer and invalidates it', () => { + expect(resolveLargeTextPasteOffer(2, 2)).toEqual({ + accepted: true, + nextOfferId: 3, + }); + }); + + test('resolve rejects a superseded offer without advancing', () => { + expect(resolveLargeTextPasteOffer(5, 4)).toEqual({ + accepted: false, + nextOfferId: 5, + }); + }); + + test('second resolve after accept is rejected (double-apply guard)', () => { + const first = resolveLargeTextPasteOffer(1, 1); + expect(first.accepted).toBe(true); + expect(resolveLargeTextPasteOffer(first.nextOfferId, 1)).toEqual({ + accepted: false, + nextOfferId: first.nextOfferId, + }); + }); + + test('begin then resolve of the old id is rejected', () => { + const previous = 2; + const next = beginLargeTextPasteOffer(previous); + expect(resolveLargeTextPasteOffer(next, previous)).toEqual({ + accepted: false, + nextOfferId: next, + }); + expect(resolveLargeTextPasteOffer(next, next).accepted).toBe(true); + }); + + test('toast class widens only from the sm breakpoint', () => { + const classes = LARGE_TEXT_PASTE_TOAST_CLASSNAME.split(/\s+/); + expect(classes).toContain('sm:!min-w-[22rem]'); + expect(classes).toContain('sm:!w-auto'); + expect(classes).toContain('[&_[data-icon]]:!hidden'); + expect(classes.includes('!min-w-[22rem]')).toBe(false); + expect(classes.includes('!w-auto')).toBe(false); + }); +}); diff --git a/packages/ui/src/components/chat/composer/largeTextPasteOffer.ts b/packages/ui/src/components/chat/composer/largeTextPasteOffer.ts new file mode 100644 index 00000000..976a9d41 --- /dev/null +++ b/packages/ui/src/components/chat/composer/largeTextPasteOffer.ts @@ -0,0 +1,33 @@ +/** + * Offer-id state for the large-text paste ask toast. + * + * The toast can outlive the paste event (duration Infinity), and a second + * large paste can supersede an unanswered offer. These helpers keep that + * invalidation pure so ChatInput only wires toast UI to attach/inline actions. + */ + +export type LargeTextPasteOfferAction = 'attach' | 'inline'; + +/** Allocate a new offer id, superseding any unanswered previous offer. */ +export const beginLargeTextPasteOffer = (activeOfferId: number): number => ( + activeOfferId + 1 +); + +/** + * Attempt to resolve an offer. Returns whether this call won the race, and the + * next active id. A superseded or already-resolved offer is rejected so + * dismiss/action cannot double-apply. + */ +export const resolveLargeTextPasteOffer = ( + activeOfferId: number, + offerId: number, +): { accepted: boolean; nextOfferId: number } => { + if (offerId !== activeOfferId) { + return { accepted: false, nextOfferId: activeOfferId }; + } + return { accepted: true, nextOfferId: activeOfferId + 1 }; +}; + +/** Toast chrome: widen on desktop only; leave mobile full-width to Sonner. */ +export const LARGE_TEXT_PASTE_TOAST_CLASSNAME = + '[&_[data-icon]]:!hidden sm:!min-w-[22rem] sm:!w-auto'; From 1ad81a2c7223076b327b783dc62857fafcbc62c6 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 10:33:54 +0300 Subject: [PATCH 16/66] fix(chat): show context text before a pending question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn blocked on a question never reaches finish 'stop', so in sorted render mode the model's text was classified as justification and the inline-text deferral rule hid it inside the collapsible Activity group until the turn completed — with a pending question that never happens, leaving the context produced before the question invisible (OpenCode shows it inline). Keep text inline for messages that contain a question tool part: exclude them from justification classification and from the sorted-mode text deferral. Refs OPE-199 --- .../chat/lib/turns/projectTurnActivity.ts | 11 +++++++ .../chat/lib/turns/projectTurnRecords.test.ts | 30 +++++++++++++++++++ .../components/chat/message/MessageBody.tsx | 11 ++++++- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts b/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts index 86921e31..dd43fc86 100644 --- a/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts +++ b/packages/ui/src/components/chat/lib/turns/projectTurnActivity.ts @@ -97,6 +97,16 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit input.assistantMessages.forEach((message) => { const finish = getMessageFinish(message); const messageHasTool = message.parts.some((part) => part.type === 'tool'); + // A turn blocked on a question never reaches finish === 'stop' (the + // user must answer first). Treating the text the model produced + // before the question as 'justification' would bury it inside the + // collapsible Activity group — the context stays invisible until the + // turn completes (OPE-199). Keep it inline like OpenCode. + const messageHasQuestion = message.parts.some((part) => ( + part.type === 'tool' + && typeof part.tool === 'string' + && part.tool.toLowerCase() === 'question' + )); const messageIsCompactionSummary = isCompactionSummaryMessage(message); message.parts.forEach((part, partIndex) => { @@ -137,6 +147,7 @@ export const projectTurnActivity = (input: ProjectActivityInput): ProjectActivit input.showTextJustificationActivity && part.type === 'text' && text + && !messageHasQuestion && ( messageIsCompactionSummary || ( diff --git a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts index f7d9f22b..f5831138 100644 --- a/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts +++ b/packages/ui/src/components/chat/lib/turns/projectTurnRecords.test.ts @@ -221,4 +221,34 @@ describe('projectTurnRecords', () => { const finalActivity = turn?.activityParts.find((activity) => activity.messageId === 'a2'); expect(finalActivity).toBe(undefined); }); + + test('keeps text inline (not justification) when a message is blocked on a pending question', () => { + const user = createMessageEntry({ id: 'u1', role: 'user', createdAt: 1 }); + user.parts = [{ id: 'p1', type: 'text', text: 'prompt' } as Part]; + const assistant = createMessageEntry({ id: 'a1', role: 'assistant', parentID: 'u1', createdAt: 2 }); + // The turn is blocked waiting for the user's answer: no finish and a + // pending question tool part, with context text before the question. + assistant.parts = [ + { id: 'ap1', type: 'text', text: 'context before the question' } as Part, + { + id: 'ap2', + type: 'tool', + callID: 'c1', + tool: 'question', + state: { status: 'pending' }, + } as Part, + ]; + + const projection = projectTurnRecords([user, assistant], { + showTextJustificationActivity: true, + }); + + const turn = projection.turns[0]; + expect(turn).toBeDefined(); + const textActivity = turn?.activityParts.find((activity) => activity.partIndex === 0); + expect(textActivity?.kind).not.toBe('justification'); + // The question tool itself still participates in the activity group. + const questionActivity = turn?.activityParts.find((activity) => activity.partIndex === 1); + expect(questionActivity?.kind).toBe('tool'); + }); }); diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index ae701111..7a3c96ad 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -1690,7 +1690,16 @@ const AssistantMessageBody = React.memo(({ && hasAnchoredActivitySegments && Boolean(toggleActivityGroup); - const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish; + // A message that asked a question is blocked until the user answers — it + // never reaches finish === 'stop', so the normal "defer text until final + // output" rule would hide the context the model produced before the + // question indefinitely (OPE-199). Render such messages' text inline, + // matching OpenCode's display. + const hasQuestionTool = React.useMemo(() => { + return toolParts.some((toolPart) => toolPart.tool === 'question'); + }, [toolParts]); + + const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish && !hasQuestionTool; const showErrorMessage = Boolean(errorMessage); const errorIconName = errorVariant === 'info' ? 'information' : 'error-warning'; const shouldShowMessageActions = hasCopyableText; From 498a029e51c7d396e5d4fa987efd3f28ca641d34 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 11:57:12 +0300 Subject: [PATCH 17/66] fix(sync): settle completed turns and finished messages promptly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two remaining stuck/incorrect busy-state edge cases from the post-#483 spinner audit (OPE-193): - B1: when a turn ended but the session.idle SSE event was delayed or lost, the busy spinner kept showing until the next watchdog poll tick (~5s) and its escalation (~10s). An assistant message.updated that carries time.completed now triggers one immediate directory status poll (monotonic confirm, authoritative settle when the snapshot reports the session idle) — recovery drops to a single round-trip, with one in-flight fetch per directory and the watchdog poll as the backstop. - C1: the streaming derivation marked the trailing assistant message as streaming while the session stayed busy even after the server stamped time.completed (whole response incl. tools finished) — the typing indicator and streaming part-update suspension lingered on finished content until the session settled or the next message started. A completed trailing message is now never marked streaming; both the full and incremental derivations complete the previous streaming message instead. Refs OPE-193 --- packages/ui/src/sync/DOCUMENTATION.md | 4 + .../message-completion-status-poll.test.ts | 149 ++++++++++++++++++ packages/ui/src/sync/streaming.test.ts | 74 +++++++++ packages/ui/src/sync/streaming.ts | 32 ++++ packages/ui/src/sync/sync-context.tsx | 54 +++++++ 5 files changed, 313 insertions(+) create mode 100644 packages/ui/src/sync/__tests__/message-completion-status-poll.test.ts diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 5699fc93..f05573d6 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -200,6 +200,10 @@ The event pipeline delivers each ordered per-directory flush as one reducer batc Streaming lifecycle derivation has two paths. Directory attach, switch, bootstrap, and reconnect may perform a full reconciliation. Normal store publications reconcile only sessions whose `session_status` or `message` bucket changed; part-only events update the affected streaming message heartbeat directly and must not rescan all busy sessions. +A trailing assistant message that the server stamped `time.completed` is never marked as streaming: the stamp means the whole response (text plus every tool call) finished, so even while the session stays busy for the next step of the turn, the typing indicator and the streaming part-update suspension must not linger on finished content. The message-level streaming state (`streamingMessageIds` / `messageStreamStates`) is therefore a *message* lifecycle, not a turn lifecycle — it is completed by an explicit `time.completed`, by a newer trailing message, or by the session leaving `busy`. + +When an assistant `message.updated` event carries `time.completed` and the store still believes the session busy, sync fires one immediate directory status poll (`maybePollStatusAfterMessageCompletion`): the monotonic pass confirms/raises active status but never lowers it, and when the snapshot reports the session idle while the store believes it busy — a delayed or lost `session.idle` — an authoritative resync settles the status at once. This narrows the stuck-spinner window after turn completion from a full watchdog poll interval to a single round-trip; one in-flight fetch per directory bounds the fan-out and the 5s watchdog poll remains the backstop. + Incomplete-session materialization is deduplicated by runtime, directory, and session for the full cooldown window, including after a fast success or failure. A settled-running-tool recovery may supersede a different request in that window so an earlier pre-settlement refresh cannot consume the only terminal recovery signal. Deferred recovery is dropped if its captured runtime is no longer active. If recovery requests a tail refresh while an older load is in flight, one refresh runs after that load instead of losing the newer authority demand. Completion retains the cooldown marker until expiry, and an older completion cannot clear a newer request marker. Recovery starts after the current ordered event batch and rechecks whether local state already contains the requested entity before starting HTTP. An explicit empty part bucket is authoritative fetched-empty state, not a missing snapshot. This prevents repeated orphan/missing-part events from creating message-tail and status request storms while preserving later recovery. When `session.idle` or `session.error` settles a session but the trailing assistant message still contains a `pending` or `running` tool, sync refreshes that session tail. This narrowly reconciles a missed terminal tool-part event without refetching normally completed turns or stale tools from older turns. A stale refresh or delayed part event cannot regress a locally observed terminal tool to an active status. diff --git a/packages/ui/src/sync/__tests__/message-completion-status-poll.test.ts b/packages/ui/src/sync/__tests__/message-completion-status-poll.test.ts new file mode 100644 index 00000000..e40d3065 --- /dev/null +++ b/packages/ui/src/sync/__tests__/message-completion-status-poll.test.ts @@ -0,0 +1,149 @@ +/** + * Tests for the immediate status poll fired when an assistant message + * completes (issue OPE-193, B1): the busy spinner must not linger for up to a + * full watchdog poll interval after a turn completed when the session.idle + * event was delayed or lost. + */ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import { create, type StoreApi } from "zustand" +import type { SessionStatus } from "@opencode-ai/sdk/v2/client" +import { INITIAL_STATE } from "../types" +import type { DirectoryStore } from "../child-store" + +type StatusSnapshot = Record + +let statusSnapshotResult: StatusSnapshot | null = { ses_1: { type: "idle" } } +let statusSnapshotErrors = 0 +const statusSnapshotCalls: string[] = [] + +mock.module("@/lib/opencode/client", () => ({ + opencodeClient: { + getSessionStatusForDirectory: mock((directory: string) => { + statusSnapshotCalls.push(directory) + if (statusSnapshotErrors > 0) { + statusSnapshotErrors -= 1 + return Promise.resolve(null) + } + return Promise.resolve(statusSnapshotResult) + }), + }, +})) + +mock.module("@/lib/runtime-switch", () => ({ + getRuntimeKey: () => "test-runtime", +})) + +import { maybePollStatusAfterMessageCompletion } from "../sync-context" + +const createStore = (status: SessionStatus | undefined): StoreApi => { + return create()((set) => ({ + ...INITIAL_STATE, + ...(status ? { session_status: { ses_1: status } } : {}), + patch: (partial) => set(partial), + replace: (next) => set(next), + })) +} + +const waitForPollSettled = async (): Promise => { + // The helper runs under the background-network concurrency gate; give the + // task chain (and any promise-based snapshot) real time to finish. + await new Promise((resolve) => setTimeout(resolve, 25)) + await new Promise((resolve) => setTimeout(resolve, 25)) +} + +describe("maybePollStatusAfterMessageCompletion (issue OPE-193)", () => { + beforeEach(() => { + statusSnapshotResult = { ses_1: { type: "idle" } } + statusSnapshotErrors = 0 + statusSnapshotCalls.length = 0 + }) + + test("does not poll when the store believes the session is already idle", async () => { + const store = createStore({ type: "idle" }) + + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + await waitForPollSettled() + + expect(statusSnapshotCalls).toEqual([]) + expect(store.getState().session_status?.ses_1?.type).toBe("idle") + }) + + test("does not poll without a directory or session id", async () => { + const store = createStore({ type: "busy" }) + + maybePollStatusAfterMessageCompletion("", store, "ses_1") + maybePollStatusAfterMessageCompletion("global", store, "ses_1") + maybePollStatusAfterMessageCompletion("/test/project", store, "") + await waitForPollSettled() + + expect(statusSnapshotCalls).toEqual([]) + }) + + test("settles a busy session to idle immediately when the snapshot omits it", async () => { + const store = createStore({ type: "busy" }) + + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + await waitForPollSettled() + + expect(statusSnapshotCalls).toEqual(["/test/project", "/test/project"]) + expect(store.getState().session_status?.ses_1?.type).toBe("idle") + }) + + test("keeps the session busy when the snapshot confirms it is still active", async () => { + const store = createStore({ type: "busy" }) + statusSnapshotResult = { ses_1: { type: "busy" } } + + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + await waitForPollSettled() + + // Monotonic poll confirms busy; the snapshot is not idle, so no + // authoritative escalation runs. + expect(statusSnapshotCalls).toEqual(["/test/project"]) + expect(store.getState().session_status?.ses_1?.type).toBe("busy") + }) + + test("preserves the busy status when the status fetch fails", async () => { + const store = createStore({ type: "busy" }) + statusSnapshotErrors = 1 + + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + await waitForPollSettled() + + expect(statusSnapshotCalls).toEqual(["/test/project"]) + // Failure is not treated as authoritative empty: the busy status stays + // until the watchdog poll (or a live event) corrects it. + expect(store.getState().session_status?.ses_1?.type).toBe("busy") + }) + + test("deduplicates concurrent polls for the same directory", async () => { + const store = createStore({ type: "busy" }) + statusSnapshotResult = new Promise((resolve) => { + setTimeout(() => resolve({ ses_1: { type: "idle" } }), 10) + }) as unknown as StatusSnapshot + + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + await waitForPollSettled() + + expect(statusSnapshotCalls).toEqual(["/test/project", "/test/project"]) + }) + + test("does not poll again while a previous poll for the directory is in flight", async () => { + const store = createStore({ type: "busy" }) + let release: () => void = () => {} + statusSnapshotResult = new Promise((resolve) => { + release = () => resolve({ ses_1: { type: "idle" } }) + }) as unknown as StatusSnapshot + + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + maybePollStatusAfterMessageCompletion("/test/project", store, "ses_1") + release() + await waitForPollSettled() + + // First call ran the poll; the second call was deduped by the in-flight + // guard. The escalation (second fetch) is the authoritative resync. + expect(statusSnapshotCalls).toEqual(["/test/project", "/test/project"]) + expect(store.getState().session_status?.ses_1?.type).toBe("idle") + }) +}) diff --git a/packages/ui/src/sync/streaming.test.ts b/packages/ui/src/sync/streaming.test.ts index 326f07d6..5db05915 100644 --- a/packages/ui/src/sync/streaming.test.ts +++ b/packages/ui/src/sync/streaming.test.ts @@ -18,6 +18,12 @@ const message = (id: string, role: "user" | "assistant"): Message => ({ role, } as unknown as Message) +const completedAssistantMessage = (id: string): Message => ({ + id, + role: "assistant", + time: { created: 1, completed: 100 }, +} as unknown as Message) + const stateWithMessages = (messages: Message[], status: SessionStatus = { type: "busy" } as SessionStatus): State => ({ ...INITIAL_STATE, session_status: { @@ -163,4 +169,72 @@ describe("updateStreamingState", () => { expect(streaming.messageStreamStates.get("msg_assistant_1")?.phase).toBe("completed") expect(streaming.messageStreamStates.get("msg_assistant_2")?.phase).toBe("streaming") }) + + test("completes a streaming message when the trailing assistant message finishes while the session stays busy", () => { + updateStreamingState(stateWithMessages([ + message("msg_user_1", "user"), + message("msg_assistant_1", "assistant"), + ])) + expect(useStreamingStore.getState().streamingMessageIds.get("ses_1")).toBe("msg_assistant_1") + + // The message completed (time.completed) but the turn keeps running + // (next step / tool phase) — the finished message must not stay marked + // as streaming with the typing indicator and part-update suspension on it. + updateStreamingState(stateWithMessages([ + message("msg_user_1", "user"), + completedAssistantMessage("msg_assistant_1"), + ])) + + const streaming = useStreamingStore.getState() + expect(streaming.streamingMessageIds.get("ses_1")).toBeNull() + expect(streaming.messageStreamStates.get("msg_assistant_1")?.phase).toBe("completed") + }) + + test("does not mark an already-completed trailing assistant message as streaming", () => { + updateStreamingState(stateWithMessages([ + message("msg_user_1", "user"), + completedAssistantMessage("msg_assistant_1"), + ])) + + const streaming = useStreamingStore.getState() + expect(streaming.streamingMessageIds.get("ses_1") ?? null).toBeNull() + expect(streaming.messageStreamStates.has("msg_assistant_1")).toBe(false) + }) + + test("incrementally clears the streaming marker when the trailing message completes while busy", () => { + const previous = stateWithMessages([ + message("msg_user_1", "user"), + message("msg_assistant_1", "assistant"), + ]) + updateStreamingState(previous, 10) + expect(useStreamingStore.getState().streamingMessageIds.get("ses_1")).toBe("msg_assistant_1") + + const next = stateWithMessages([ + message("msg_user_1", "user"), + completedAssistantMessage("msg_assistant_1"), + ]) + updateChangedStreamingSessions(next, previous, 20) + + const streaming = useStreamingStore.getState() + expect(streaming.streamingMessageIds.get("ses_1")).toBeNull() + expect(streaming.messageStreamStates.get("msg_assistant_1")?.phase).toBe("completed") + }) + + test("keeps the next assistant message streaming after an intermediate message completed while busy", () => { + const previous = stateWithMessages([ + message("msg_user_1", "user"), + completedAssistantMessage("msg_assistant_1"), + ]) + updateStreamingState(previous, 10) + expect(useStreamingStore.getState().streamingMessageIds.get("ses_1") ?? null).toBeNull() + + const next = stateWithMessages([ + message("msg_user_1", "user"), + completedAssistantMessage("msg_assistant_1"), + message("msg_assistant_2", "assistant"), + ]) + updateChangedStreamingSessions(next, previous, 20) + + expect(useStreamingStore.getState().streamingMessageIds.get("ses_1")).toBe("msg_assistant_2") + }) }) diff --git a/packages/ui/src/sync/streaming.ts b/packages/ui/src/sync/streaming.ts index ab62ac99..3c396292 100644 --- a/packages/ui/src/sync/streaming.ts +++ b/packages/ui/src/sync/streaming.ts @@ -58,6 +58,18 @@ const findTrailingAssistantMessage = (messages: Message[] | undefined): Message return null } +/** + * The server stamps `time.completed` on an assistant message only after its + * whole response (text + every tool call) finished. A completed trailing + * message therefore means the message itself is done even when the turn keeps + * running (next step, follow-up tool phase) — it must not stay marked as + * streaming, or the typing indicator and the part-update suspension linger on + * finished content until the session settles. + */ +const isTrailingMessageComplete = (message: Message): boolean => { + return typeof (message as { time?: { completed?: unknown } }).time?.completed === "number" +} + export function updateStreamingState(state: State, now = Date.now()) { countSyncPerformance("streamingFullReconciliations") const currentStore = useStreamingStore.getState() @@ -108,6 +120,18 @@ export function updateStreamingState(state: State, now = Date.now()) { continue } + // The trailing assistant message already finished (time.completed), so + // nothing is streaming right now even though the session stays busy for + // the rest of the turn. Complete any previously streaming message instead + // of re-marking the finished one as streaming. + if (isTrailingMessageComplete(streamingMsg)) { + const prevId = currentStreamingIds.get(sessionID) + if (prevId) { + completeStreamingMessage(sessionID, prevId) + } + continue + } + const prevId = currentStreamingIds.get(sessionID) if (prevId !== streamingMsg.id) changed = true nextStreamingIds.set(sessionID, streamingMsg.id) @@ -222,6 +246,14 @@ export function updateChangedStreamingSessions(state: State, previous: State, no continue } + // Completed trailing message while the turn keeps running: nothing is + // streaming — clear the marker and any previous streaming message instead + // of keeping the finished message flagged as streaming. + if (isTrailingMessageComplete(streamingMessage)) { + if (previousMessageID) complete(sessionID, previousMessageID) + continue + } + if (previousMessageID && previousMessageID !== streamingMessage.id) { complete(sessionID, previousMessageID) } diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index b4ff6c19..909c798a 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -290,6 +290,11 @@ type PendingSessionMaterialization = { const SESSION_MATERIALIZATION_COOLDOWN_MS = 5_000 const pendingSessionMaterializations = new Map() +// In-flight guard for the immediate status poll fired when an assistant +// message completes (see maybePollStatusAfterMessageCompletion). Keyed by +// directory so a burst of completing messages shares one status fetch. +const messageCompletionStatusPolls = new Set() + function enqueueSessionMaterialization( directory: string, sessionID: string, @@ -632,6 +637,49 @@ async function resyncDirectorySessionStatuses( return nextStatuses } +/** + * Immediately re-check the session status after an assistant message + * completes. The turn-ending `session.idle` event can be delayed or lost; left + * alone, the busy spinner keeps showing until the next watchdog poll tick + * (up to ~5s) and its escalation (up to ~10s). One cheap status fetch right + * after the completion confirms the turn really ended, mirroring the watchdog + * escalation: the monotonic pass confirms/raises busy but never lowers it, and + * when the snapshot reports the session idle while the store still believes it + * busy, an authoritative resync settles the status immediately. + * + * Bounded: one in-flight fetch per directory, only for sessions the store + * currently believes active, best-effort (the watchdog poll remains the + * backstop). This narrows recovery latency without restructuring the polling + * design. + */ +export function maybePollStatusAfterMessageCompletion( + directory: string, + store: StoreApi, + sessionID: string, +): void { + if (!directory || directory === "global" || !sessionID) return + const current = store.getState().session_status?.[sessionID] + if (!current || current.type === "idle") return + if (messageCompletionStatusPolls.has(directory)) return + + messageCompletionStatusPolls.add(directory) + void (async () => { + try { + const statuses = await runBackgroundNetworkTask(() => + resyncDirectorySessionStatuses(directory, store, [sessionID], "monotonic")) + if (!statuses) return + if (needsSnapshotAfterStatusPoll(store.getState(), sessionID, statuses[sessionID])) { + await runBackgroundNetworkTask(() => + resyncDirectorySessionStatuses(directory, store, [sessionID], "authoritative")) + } + } catch { + // Best-effort — the watchdog poll retries on its own cadence. + } finally { + messageCompletionStatusPolls.delete(directory) + } + })() +} + // After a monotonic poll, decide whether to escalate to a full authoritative // resync: the store believes the session is active but the snapshot reports it // idle/absent — a suspected missed idle that the monotonic poll deliberately @@ -1722,6 +1770,12 @@ function handleEvent( messageID, }) } + // An assistant message that finished is strong evidence the turn may + // have ended; if the session.idle event was delayed or lost, settle the + // busy status immediately instead of waiting for the next watchdog poll. + if (info.role === "assistant" && typeof info.time?.completed === "number") { + maybePollStatusAfterMessageCompletion(resolvedDirectory, store, sessionID) + } } } else { const sessionID = getSessionIdFromPayload(payload) ?? undefined From 6622d8889df10d13a680b46b0cc18c380d4fad2d Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 13:35:10 +0300 Subject: [PATCH 18/66] fix(chat): keep sticky header gradient inside its padding The gradient fade under the sticky user header was absolutely positioned at top-full with h-4/sm:h-8, so it overlapped the first rows of the assistant content below and obscured readable text (especially for headerless messages with pt-0). Reserve the fade as bottom padding on the sticky container and anchor the gradient to bottom-0, so it only covers the header's own padding box and stays purely decorative with pointer-events-none. Fixes #2524 --- packages/ui/src/components/chat/components/TurnItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/chat/components/TurnItem.tsx b/packages/ui/src/components/chat/components/TurnItem.tsx index d90d0f99..fcf7de6d 100644 --- a/packages/ui/src/components/chat/components/TurnItem.tsx +++ b/packages/ui/src/components/chat/components/TurnItem.tsx @@ -18,13 +18,13 @@ const TurnItem: React.FC = ({ turn, stickyUserHeader = true, rend data-scroll-spy-id={turn.turnId} > {stickyUserHeader ? ( -
+
{renderMessage(turn.userMessage)}
) : ( From 81e8ee7c33e863c17513090d5893084bc80b53cb Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 13:43:04 +0300 Subject: [PATCH 19/66] feat(sidebar): show compact timestamp in recent activity rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar's recent activity list (SidebarActivitySections) rendered its session rows without an inline timestamp on web/desktop — the compact relative label only appeared in the hover tooltip and on touch runtimes. Render the existing i18n-backed formatSessionCompactDateLabel inline in the recent rows' metadata slot, alongside the goal/branch glyphs, for web/desktop too. It keeps the same hover-fade as the other metadata, so the hover-revealed row actions never overlap it, and the full date stays available in the row tooltip. No new strings: the label reuses common.relative.* keys. Fixes #2560 --- .../session/sidebar/SessionNodeItem.test.ts | 41 +++++++++++++++++++ .../session/sidebar/SessionNodeItem.tsx | 14 ++++++- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/session/sidebar/SessionNodeItem.test.ts diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.test.ts b/packages/ui/src/components/session/sidebar/SessionNodeItem.test.ts new file mode 100644 index 00000000..dc77851c --- /dev/null +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('./SessionNodeItem.tsx', import.meta.url), 'utf8'); + +describe('SessionNodeItem recent-activity timestamp', () => { + test('the recent activity rows render the compact timestamp in the inline metadata slot', () => { + // The right-slot guard must open for recent rows even when no activity, + // goal glyph, or branch marker is present. + const guard = source.indexOf("showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent'"); + expect(guard).toBeGreaterThan(-1); + // The recent-only block sits inside that slot… + const guardOpen = source.indexOf("{renderContext === 'recent' ? (", guard); + expect(guardOpen).toBeGreaterThan(guard); + // …and the compact label rendered there is the first one after it. + const label = source.indexOf('{sessionCompactUpdatedLabel}', guardOpen); + expect(label).toBeGreaterThan(guardOpen); + // The only later occurrence is the pre-existing row tooltip (which shows + // the full date), not a second inline render. + const tooltipLabel = source.indexOf('{sessionCompactUpdatedLabel}', label + 1); + expect(tooltipLabel).toBeGreaterThan(label); + expect(source.indexOf('title={sessionUpdatedLabel}', tooltipLabel - 80)).toBeGreaterThan(-1); + }); + + test('the timestamp shares the hover-fade of the other metadata so revealed actions never overlap it', () => { + const guard = source.indexOf("showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent'"); + // The slot content fades out while the row is hovered (hideOnHoverClass) + // and while the row menu is open — the same span that now carries the + // recent timestamp. + const hideOnHover = source.indexOf('hideOnHoverClass', guard); + expect(hideOnHover).toBeGreaterThan(guard); + expect(hideOnHover).toBeLessThan(source.indexOf("{renderContext === 'recent' ? (", guard)); + }); + + test('the compact label uses the existing i18n-backed relative time helper', () => { + // formatSessionCompactDateLabel (already used by touch runtimes and the + // row tooltip) is the source of the label — no new formatting code. + expect(source.indexOf('const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);')).toBeGreaterThan(-1); + expect(source.indexOf('{sessionCompactUpdatedLabel}')).toBeGreaterThan(-1); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 6521feba..3a502597 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -1257,7 +1257,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { )} - ) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker) ? ( + ) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker || renderContext === 'recent') ? (
) : null} + {/* The recent activity list shows its compact + timestamp inline (touch runtimes already get + it through the alwaysShowActions branch); + it shares the slot with the goal/branch + metadata and hides on hover exactly like + them, so the revealed row actions never + overlap it. */} + {renderContext === 'recent' ? ( + + {sessionCompactUpdatedLabel} + + ) : null} )} From 8a850732613ea3027320995e3060d0b9f56609f7 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 13:46:30 +0300 Subject: [PATCH 20/66] fix(server): forward Small Model override to managed OpenCode config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenChamber's Settings → Chat → Small Model override only fed OpenChamber's own /api/small-model/generate utility service; it never reached the managed OpenCode server, whose internal title/summary generation reads small_model from its config. With the override injected into OPENCODE_CONFIG_CONTENT at managed-process launch, session title generation uses the user's explicit model instead of falling back (or failing to resolve) — fixing sessions that stayed untitled even with a Small Model configured. Only an explicit override (smallModelUseDefault === false with a non-empty smallModelOverride) is injected; "use default" leaves the config untouched so OpenCode's own resolution chain stays authoritative. Malformed user config is left unmodified. External OpenCode servers are unaffected (they are not launched with this env). Fixes #2497 --- packages/web/server/index.js | 19 ++-- .../server/lib/small-model/DOCUMENTATION.md | 11 +++ .../lib/small-model/config-injection.js | 51 +++++++++++ .../lib/small-model/config-injection.test.js | 90 +++++++++++++++++++ 4 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 packages/web/server/lib/small-model/config-injection.js create mode 100644 packages/web/server/lib/small-model/config-injection.test.js diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 1c386b3c..f08f1089 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -75,6 +75,7 @@ import { createSessionRuntime } from './lib/opencode/session-runtime.js'; import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js'; import { createSessionAssistRuntime } from './lib/session-assist/runtime.js'; import { createSessionGoalRuntime } from './lib/session-goal/runtime.js'; +import { applySmallModelOverrideToOpenCodeConfig } from './lib/small-model/config-injection.js'; import { createContextObligatoryRuntime } from './lib/context-obligatory/runtime.js'; import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js'; import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js'; @@ -1084,11 +1085,19 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({ const managedEnv = settings?.agentControlToolEnabled === false ? {} : await (agentToolRuntime?.prepareManagedOpenCodeEnv() || {}); - if (settings?.optimizeSystemPrompt !== true) return managedEnv; - - const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT; - const systemPromptEnv = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent); - return { ...managedEnv, ...systemPromptEnv }; + const env = settings?.optimizeSystemPrompt === true + ? { ...managedEnv, ...(await systemPromptRuntime.prepareManagedOpenCodeEnv(managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT)) } + : managedEnv; + // Apply the explicit Small Model override to the managed OpenCode config + // so OpenCode's own title/summary generation uses the user's chosen model. + const configContent = env.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT; + const withSmallModel = applySmallModelOverrideToOpenCodeConfig({ + configContent, + smallModelUseDefault: settings?.smallModelUseDefault, + smallModelOverride: settings?.smallModelOverride, + }); + if (withSmallModel === configContent) return env; + return { ...env, OPENCODE_CONFIG_CONTENT: withSmallModel }; }, }); diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index 1776f1e1..9709fa37 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -114,6 +114,17 @@ other runtime API. - `routes.js` — `GET /api/small-model` (resolution preview) and `POST /api/small-model/generate` (`{ prompt, system?, maxOutputTokens?, model?, directory? }` → `{ text, providerID, modelID, source }`). +- `config-injection.js` — applies the Settings → Chat → Small Model override + to the config injected into the **managed OpenCode process** + (`OPENCODE_CONFIG_CONTENT`), so OpenCode's own internal `small_model` + consumers — session title and summary generation — use the user's explicit + choice instead of OpenCode's fallback chain. Only an explicit override + (`smallModelUseDefault === false` with a non-empty `smallModelOverride`) is + injected; "use default" leaves the config untouched so OpenCode's own + resolution stays authoritative. Wired into `getManagedOpenCodeEnv` in + `server/index.js`; the pure helper is unit-tested in + `config-injection.test.js`. External OpenCode servers are unaffected (they + are not launched with this env). ## Registration diff --git a/packages/web/server/lib/small-model/config-injection.js b/packages/web/server/lib/small-model/config-injection.js new file mode 100644 index 00000000..3738ec8e --- /dev/null +++ b/packages/web/server/lib/small-model/config-injection.js @@ -0,0 +1,51 @@ +/** + * Applies the user's explicit Small Model override (Settings → Chat → Small + * Model) to the configuration injected into the managed OpenCode process. + * + * OpenCode's own session-title and summary generation reads `small_model` + * from its config layers. Previously the OpenChamber settings override only + * fed OpenChamber's own `/api/small-model/generate` utility service, so a + * configured Small Model never reached OpenCode's title generation and + * sessions kept their fallback/untitled state. Injecting the override as + * `small_model` in the managed `OPENCODE_CONFIG_CONTENT` closes that gap for + * the managed server. + * + * Only an explicit override applies (`smallModelUseDefault === false` with a + * non-empty `smallModelOverride`). "Use default" leaves the config untouched, + * so OpenCode's own resolution chain (config `small_model`, then its family + * scan) stays authoritative — this mirrors the precedence documented in + * `packages/web/server/lib/small-model/DOCUMENTATION.md`. + * + * Malformed user config is left untouched rather than rewritten: OpenCode's + * own loader is the right place to surface it, and silently rewriting it + * would hide the error. + */ +export const applySmallModelOverrideToOpenCodeConfig = ({ + configContent, + smallModelUseDefault, + smallModelOverride, +}) => { + if (smallModelUseDefault !== false) { + return configContent; + } + const override = typeof smallModelOverride === 'string' ? smallModelOverride.trim() : ''; + if (!override) { + return configContent; + } + + const current = (() => { + if (typeof configContent !== 'string' || configContent.trim().length === 0) { + return {}; + } + try { + return JSON.parse(configContent); + } catch { + return null; + } + })(); + if (current === null || typeof current !== 'object' || Array.isArray(current)) { + return configContent; + } + + return JSON.stringify({ ...current, small_model: override }); +}; diff --git a/packages/web/server/lib/small-model/config-injection.test.js b/packages/web/server/lib/small-model/config-injection.test.js new file mode 100644 index 00000000..22342038 --- /dev/null +++ b/packages/web/server/lib/small-model/config-injection.test.js @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { applySmallModelOverrideToOpenCodeConfig } from './config-injection.js'; + +describe('applySmallModelOverrideToOpenCodeConfig', () => { + it('leaves config unchanged when use-default is not explicitly disabled', () => { + const config = '{"model":"anthropic/claude-sonnet-4-5"}'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: true, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(config); + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: undefined, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(config); + }); + + it('leaves config unchanged when the override is empty or whitespace', () => { + const config = '{"model":"anthropic/claude-sonnet-4-5"}'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: false, + smallModelOverride: ' ', + }), + ).toBe(config); + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: false, + smallModelOverride: undefined, + }), + ).toBe(config); + }); + + it('injects small_model into an empty config', () => { + const result = applySmallModelOverrideToOpenCodeConfig({ + configContent: undefined, + smallModelUseDefault: false, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }); + expect(JSON.parse(result)).toEqual({ small_model: 'anthropic/claude-haiku-4-5' }); + }); + + it('injects small_model while preserving existing config keys and plugins', () => { + const result = applySmallModelOverrideToOpenCodeConfig({ + configContent: '{"model":"anthropic/claude-sonnet-4-5","plugin":["file:///tool.js"]}', + smallModelUseDefault: false, + smallModelOverride: 'google/gemini-2.5-flash', + }); + expect(JSON.parse(result)).toEqual({ + model: 'anthropic/claude-sonnet-4-5', + plugin: ['file:///tool.js'], + small_model: 'google/gemini-2.5-flash', + }); + }); + + it('replaces an existing small_model with the override', () => { + const result = applySmallModelOverrideToOpenCodeConfig({ + configContent: '{"small_model":"anthropic/claude-haiku-4-5"}', + smallModelUseDefault: false, + smallModelOverride: 'google/gemini-2.5-flash', + }); + expect(JSON.parse(result)).toEqual({ small_model: 'google/gemini-2.5-flash' }); + }); + + it('leaves malformed config untouched instead of rewriting it', () => { + const config = '{not-valid-json'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: config, + smallModelUseDefault: false, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(config); + const arrayConfig = '["not","an","object"]'; + expect( + applySmallModelOverrideToOpenCodeConfig({ + configContent: arrayConfig, + smallModelUseDefault: false, + smallModelOverride: 'anthropic/claude-haiku-4-5', + }), + ).toBe(arrayConfig); + }); +}); From f64c4a74af623599d65cf051e66d85cfc5c86d3f Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 13:49:12 +0300 Subject: [PATCH 21/66] fix(chat): do not hijack ctrl/cmd+digit while typing in an input The numbered context-surface switcher (mod+digit) fired even while focus was in an editable target, stealing the browser's own tab-switching chord and opening the changes pane mid-typing (issue #2503). Guard the digit branch with an editable-target check (input/textarea/contenteditable, covering the CodeMirror composer) so the chord keeps its normal meaning while the user types; surface switching still works from any non-editable focus, and the shortcut remains rebindable/unassignable in Settings. Fixes #2503 --- .../src/hooks/keyboard-shortcut-dom.test.ts | 26 ++++++++++++++++++- .../ui/src/hooks/keyboard-shortcut-dom.ts | 14 ++++++++++ packages/ui/src/hooks/useKeyboardShortcuts.ts | 9 ++++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts b/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts index 608bb203..f68aa331 100644 --- a/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts +++ b/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'bun:test'; -import { hasOpenDropdown } from './keyboard-shortcut-dom'; +import { hasOpenDropdown, isTypingInEditableTarget } from './keyboard-shortcut-dom'; test('does not treat an unrelated visible listbox as an open dropdown', () => { const promptNavigator = {} as Element; @@ -28,3 +28,27 @@ test('detects an open select popup', () => { expect(hasOpenDropdown(root)).toBe(true); }); + +// isTypingInEditableTarget — the mod+digit surface switcher guard (issue +// #2503): while the user is typing in an editable target, ctrl/cmd+digit +// must keep its normal meaning (browser tab switching, in-input chords) +// instead of switching the context panel surface. +const targetWithClosest = (result: Element | null): EventTarget => + ({ closest: (selector: string) => (selector === 'input, textarea, [contenteditable="true"]' ? result : null) }) as unknown as EventTarget; + +test('editable guard is false for a null target', () => { + expect(isTypingInEditableTarget(null)).toBe(false); +}); + +test('editable guard is false for a target without closest', () => { + expect(isTypingInEditableTarget({} as EventTarget)).toBe(false); +}); + +test('editable guard is true inside an input, textarea or contenteditable', () => { + const editable = {} as Element; + expect(isTypingInEditableTarget(targetWithClosest(editable))).toBe(true); +}); + +test('editable guard is false outside editable surfaces', () => { + expect(isTypingInEditableTarget(targetWithClosest(null))).toBe(false); +}); diff --git a/packages/ui/src/hooks/keyboard-shortcut-dom.ts b/packages/ui/src/hooks/keyboard-shortcut-dom.ts index 413b6be2..c53dfe23 100644 --- a/packages/ui/src/hooks/keyboard-shortcut-dom.ts +++ b/packages/ui/src/hooks/keyboard-shortcut-dom.ts @@ -6,3 +6,17 @@ const OPEN_DROPDOWN_SELECTOR = [ export function hasOpenDropdown(root: ParentNode = document): boolean { return Boolean(root.querySelector(OPEN_DROPDOWN_SELECTOR)); } + +// Editable surfaces (the chat composer is a contenteditable CodeMirror view; +// CommitInput, searches and dialogs use textareas/inputs). Global shortcuts +// must not hijack keystrokes while the user is typing in one of these — +// mod+digit in particular is the browser's own tab-switching chord. +const EDITABLE_TARGET_SELECTOR = 'input, textarea, [contenteditable="true"]'; + +export function isTypingInEditableTarget(target: EventTarget | null): boolean { + const element = target as Element | null; + if (!element || typeof element.closest !== 'function') { + return false; + } + return Boolean(element.closest(EDITABLE_TARGET_SELECTOR)); +} diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 8e3ac00a..a3eb5410 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -26,7 +26,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { addSelectionToChat } from '@/lib/addSelectionToChat'; -import { hasOpenDropdown } from './keyboard-shortcut-dom'; +import { hasOpenDropdown, isTypingInEditableTarget } from './keyboard-shortcut-dom'; export const useKeyboardShortcuts = () => { const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); @@ -493,6 +493,13 @@ export const useKeyboardShortcuts = () => { if (switchSurfaceDigit !== null && !e.repeat && eventMatchesShortcutPrefix(e, switchSurfacePrefix, heldKeysRef.current)) { + // mod+digit is the browser's own tab-switching chord and a common + // in-input chord; never hijack it while the user is typing (the chat + // composer, CommitInput, searches, dialogs). Surface switching still + // works from anywhere that is not an editable target. + if (isTypingInEditableTarget(e.target)) { + return; + } const state = useUIStore.getState(); if (state.isMobile || !effectiveDirectory) { return; From 264fc16f2c079d4d4ab0bb3739784dd2ddeb0cb7 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 13:50:23 +0300 Subject: [PATCH 22/66] fix(ui): keep the selected model when switching agent modes Switching between Build and Plan modes reset the model selector to the settings default because setAgent fell through to the settings-default fallback whenever the target agent had no saved override, and the explicit-switch path in ModelControls force-applied the agent's default model, overwriting any per-agent override. setAgent now keeps the current model selection when the user has a live manual selection and the target agent configures no model of its own, and the explicit-switch handler no longer clobbers saved per-agent overrides with the agent default. Startup and pin behavior are unchanged: the settings-default and agent-pin cascade still applies when no manual selection exists yet. Fixes #2531 --- .../ui/src/components/chat/ModelControls.tsx | 35 ------------------- packages/ui/src/stores/useConfigStore.test.ts | 24 +++++++++++++ packages/ui/src/stores/useConfigStore.ts | 14 ++++++-- 3 files changed, 36 insertions(+), 37 deletions(-) diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 262664b9..a5df7d0d 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -641,7 +641,6 @@ export const ModelControls: React.FC = ({ ]; const prevAgentNameRef = React.useRef(undefined); - const explicitAgentSwitchRef = React.useRef(null); const latestLoadedUserChoiceRestoreRef = React.useRef(null); const currentSessionDirectory = currentSessionId ? getDirectoryForSession(currentSessionId) : undefined; @@ -1032,9 +1031,6 @@ export const ModelControls: React.FC = ({ prevAgentNameRef.current = currentAgentName; if (currentAgentName && currentSessionId) { - const shouldPreferAgentModel = explicitAgentSwitchRef.current === currentAgentName; - explicitAgentSwitchRef.current = null; - await new Promise((resolve) => { const timer = setTimeout(resolve, 50); abortController.signal.addEventListener('abort', () => { @@ -1047,33 +1043,6 @@ export const ModelControls: React.FC = ({ return; } - const selectedAgent = shouldPreferAgentModel - ? agents.find((agent) => agent.name === currentAgentName) - : undefined; - if (selectedAgent?.model?.providerID && selectedAgent.model.modelID) { - const result = tryApplyModelSelection( - selectedAgent.model.providerID, - selectedAgent.model.modelID, - currentAgentName, - ); - if (result === 'applied' || result === 'provider-missing') { - if (result === 'applied') { - saveSessionModelSelection( - currentSessionId, - selectedAgent.model.providerID, - selectedAgent.model.modelID, - ); - saveAgentModelForSession( - currentSessionId, - currentAgentName, - selectedAgent.model.providerID, - selectedAgent.model.modelID, - ); - } - return; - } - } - const persistedChoice = getAgentModelForSession(currentSessionId, currentAgentName); if (persistedChoice) { @@ -1099,12 +1068,9 @@ export const ModelControls: React.FC = ({ abortController.abort(); }; }, [ - agents, currentAgentName, currentSessionId, getAgentModelForSession, - saveAgentModelForSession, - saveSessionModelSelection, tryApplyModelSelection, contextHydrated, ]); @@ -1185,7 +1151,6 @@ export const ModelControls: React.FC = ({ const handleAgentChange = React.useCallback((agentName: string, options?: { closeModelSelector?: boolean }) => { try { - explicitAgentSwitchRef.current = agentName; setAgent(agentName); addRecentAgent(agentName); if (options?.closeModelSelector ?? true) { diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index 52dbe3e6..72857f4d 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -589,6 +589,30 @@ describe('useConfigStore provider persistence', () => { expect(state.currentModelId).toBe('model-a'); }); + test('[issue-2531] setAgent keeps the manual model when switching to an agent without an override', () => { + const sessionId = 'ses_2531_mode_switch'; + useSessionUIStore.setState({ currentSessionId: sessionId }); + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('deepseek', 'deepseek-v4-pro'), provider('kimi', 'kimi-k3')], + agents: [testAgent('build'), testAgent('plan')], + settingsDefaultModel: 'deepseek/deepseek-v4-pro', + currentProviderId: 'kimi', + currentModelId: 'kimi-k3', + currentAgentName: 'build', + selectionSource: 'manual', + currentVariant: undefined, + directoryScoped: {}, + }); + + useConfigStore.getState().setAgent('plan'); + + const state = useConfigStore.getState(); + expect(state.currentAgentName).toBe('plan'); + expect(state.currentProviderId).toBe('kimi'); + expect(state.currentModelId).toBe('kimi-k3'); + }); + test('loadAgents does not fetch OpenCode config directly', async () => { useConfigStore.setState({ activeDirectoryKey: DIRECTORY, diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 4a7dfd2e..9714b497 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -2387,6 +2387,9 @@ export const useConfigStore = create()( currentProviderId, currentModelId, } = get(); + // Captured before the first set below, which unconditionally + // marks the selection as manual. + const hadManualSelection = get().selectionSource === "manual"; set((state) => { const directoryKey = state.activeDirectoryKey; @@ -2508,8 +2511,7 @@ export const useConfigStore = create()( // Prefer a session-level manual override for this agent over the // agent's configured default. Re-applying setAgent after subtask // completion / rematerialization must not clobber the override - // (issue #2404). Explicit agent-picker switches still force the - // agent default via ModelControls' shouldPreferAgentModel path. + // (issue #2404). if (currentSessionId) { const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName); if (existingAgentModel && hasProviderModel(providers, existingAgentModel.providerId, existingAgentModel.modelId)) { @@ -2538,6 +2540,14 @@ export const useConfigStore = create()( } } + // The user has a live manual model selection and the target + // agent configures no model of its own. Switching modes or + // agents must not reset the selection to the settings default + // (issue #2531) — mode switches are not model changes. + if (hadManualSelection && currentProviderId && currentModelId) { + return; + } + // If the agent has no preferred model, use settings default. if (settingsDefaultModel) { const parsed = parseModelString(settingsDefaultModel); From 17d5b90d834493c9d355d5ba7b5b5d18781f50b7 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Wed, 5 Aug 2026 14:05:12 +0300 Subject: [PATCH 23/66] fix(ui): add in-document search to the Markdown file preview The rendered Markdown preview had no way to search: the Electron desktop shell implements no find-in-page at all, and CodeMirror's search panel only exists in edit mode, so Ctrl/Cmd+F in the preview was a dead shortcut (web browsers happen to find plain-DOM text natively, but desktop does not). Adds a compact find bar for the rendered preview (Ctrl/Cmd+F or the search button): case-insensitive match highlighting with a live count, Enter / Shift+Enter and arrow buttons to navigate matches, Esc to close. Matches are wrapped in elements and re-applied via MutationObserver when the markdown renderer re-morphs the container (theme/content changes); svg (mermaid) and script/style text is skipped. The pure match-range logic is unit-tested. Fixes #2401 --- .../ui/src/components/views/FilesView.tsx | 102 ++++-- .../views/MarkdownPreviewSearch.test.ts | 41 +++ .../views/MarkdownPreviewSearch.tsx | 302 ++++++++++++++++++ .../components/views/markdownPreviewFind.ts | 23 ++ packages/ui/src/lib/i18n/messages/de.ts | 6 + packages/ui/src/lib/i18n/messages/en.ts | 6 + packages/ui/src/lib/i18n/messages/es.ts | 6 + packages/ui/src/lib/i18n/messages/fr.ts | 6 + packages/ui/src/lib/i18n/messages/ja.ts | 6 + packages/ui/src/lib/i18n/messages/ko.ts | 6 + packages/ui/src/lib/i18n/messages/pl.ts | 6 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 6 + packages/ui/src/lib/i18n/messages/uk.ts | 6 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 6 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 6 + 15 files changed, 512 insertions(+), 22 deletions(-) create mode 100644 packages/ui/src/components/views/MarkdownPreviewSearch.test.ts create mode 100644 packages/ui/src/components/views/MarkdownPreviewSearch.tsx create mode 100644 packages/ui/src/components/views/markdownPreviewFind.ts diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 951e9dd2..74887230 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -24,6 +24,7 @@ import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor'; import { GoToLineDialog } from './GoToLineDialog'; +import { MarkdownPreviewSearch } from './MarkdownPreviewSearch'; import { PreviewToggleButton } from './PreviewToggleButton'; import { JsonTreeView } from '@/components/ui/JsonTreeView'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; @@ -956,6 +957,10 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const [copiedContent, setCopiedContent] = React.useState(false); const [copiedPath, setCopiedPath] = React.useState(false); const [isGoToLineOpen, setIsGoToLineOpen] = React.useState(false); + // In-preview find for the rendered Markdown preview (Ctrl/Cmd+F). + const [mdPreviewFindOpen, setMdPreviewFindOpen] = React.useState(false); + const [mdPreviewFindFocusNonce, setMdPreviewFindFocusNonce] = React.useState(0); + const mdPreviewContainerRef = React.useRef(null); const canCreateFile = Boolean(files.writeFile); const canCreateFolder = Boolean(files.createDirectory); @@ -2945,6 +2950,34 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return () => window.removeEventListener('keydown', handleKeyDown); }, [canEdit, isMobile, shortcutOverrides, textViewMode]); + // Ctrl/Cmd+F opens the in-preview find bar for the rendered Markdown + // preview. In edit mode CodeMirror owns the shortcut, so this handler is + // active only while the preview is shown. + React.useEffect(() => { + if (!isMarkdown || getMdViewMode() !== 'preview') { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.shiftKey || event.altKey) { + return; + } + if (event.key.toLowerCase() !== 'f') { + return; + } + const target = event.target as Element | null; + if (target?.closest('[role="dialog"]')) { + return; + } + event.preventDefault(); + setMdPreviewFindOpen(true); + setMdPreviewFindFocusNonce((value) => value + 1); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [getMdViewMode, isMarkdown]); + const editorFontSize = useUIStore((state) => state.editorFontSize); const editorExtensions = React.useMemo(() => { @@ -3392,6 +3425,23 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { /> )} + {isMarkdown && getMdViewMode() === 'preview' && ( + withTooltip(t('filesView.editor.findInFile'), + + ) + )} + {isMarkdown && getMdViewMode() === 'preview' && showMessageTTSButtons && ( @@ -3932,29 +3982,37 @@ export const FilesView: React.FC = ({ mode = 'full' }) => {
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? ( -
- {fileContent.length > 500 * 1024 && ( -
- {t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })} -
- )} - -
{t('filesView.error.previewUnavailable')}
-
- {t('filesView.error.switchToEditMode')} -
+
+
+ {fileContent.length > 500 * 1024 && ( +
+ {t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })}
- } - > - - + )} + +
{t('filesView.error.previewUnavailable')}
+
+ {t('filesView.error.switchToEditMode')} +
+
+ } + > + + +
+
) : selectedFile && isHtml && htmlViewMode === 'preview' ? ( isHtmlAssetAuthLoading ? ( diff --git a/packages/ui/src/components/views/MarkdownPreviewSearch.test.ts b/packages/ui/src/components/views/MarkdownPreviewSearch.test.ts new file mode 100644 index 00000000..11c0e3a4 --- /dev/null +++ b/packages/ui/src/components/views/MarkdownPreviewSearch.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test'; + +import { findMatchRanges } from './markdownPreviewFind'; + +describe('findMatchRanges', () => { + test('returns no ranges for an empty or whitespace-only query', () => { + expect(findMatchRanges('hello world', '')).toEqual([]); + expect(findMatchRanges('hello world', ' ')).toEqual([]); + }); + + test('returns no ranges when the query does not occur', () => { + expect(findMatchRanges('hello world', 'nope')).toEqual([]); + }); + + test('finds all non-overlapping occurrences', () => { + expect(findMatchRanges('the quick brown fox jumps over the lazy dog', 'the')).toEqual([ + { start: 0, end: 3 }, + { start: 31, end: 34 }, + ]); + }); + + test('matches case-insensitively', () => { + expect(findMatchRanges('Hello HELLO hello', 'hello')).toEqual([ + { start: 0, end: 5 }, + { start: 6, end: 11 }, + { start: 12, end: 17 }, + ]); + }); + + test('scans non-overlapping matches like standard find-in-page', () => { + expect(findMatchRanges('aaaa', 'aaa')).toEqual([{ start: 0, end: 3 }]); + }); + + test('trims the query before matching', () => { + expect(findMatchRanges('alpha beta', ' beta ')).toEqual([{ start: 6, end: 10 }]); + }); + + test('handles a query longer than the text', () => { + expect(findMatchRanges('abc', 'abcdef')).toEqual([]); + }); +}); diff --git a/packages/ui/src/components/views/MarkdownPreviewSearch.tsx b/packages/ui/src/components/views/MarkdownPreviewSearch.tsx new file mode 100644 index 00000000..c8e4bf48 --- /dev/null +++ b/packages/ui/src/components/views/MarkdownPreviewSearch.tsx @@ -0,0 +1,302 @@ +import React from 'react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { findMatchRanges } from './markdownPreviewFind'; + +/** + * In-preview text search for the rendered Markdown file preview. + * + * The preview renders as plain DOM (no iframe/shadow root), so browser-native + * find works on web — but the Electron desktop shell has no find-in-page + * implementation at all, and CodeMirror's search only exists in edit mode. + * This widget provides the find shortcut behavior (Ctrl/Cmd+F) and a compact + * search bar with match highlighting, navigation, and a live count, scoped to + * the preview container. + * + * The rendered DOM is owned by the markdown renderer (block-level morphdom + * reconciliation), so highlights are re-applied whenever the renderer mutates + * the container (theme or content changes) via a MutationObserver; mutations + * produced by this widget itself are ignored. + */ +const MARK_ATTR = 'data-md-find'; +const CURRENT_MARK_ATTR = 'data-md-find-current'; +const MARK_CLASS = 'rounded-[2px] bg-[var(--status-warning)]/40'; +const CURRENT_MARK_CLASS = 'rounded-[2px] bg-[var(--status-warning)]/80'; + +const isMarkElement = (node: Node): boolean => { + return node instanceof Element && node.hasAttribute(MARK_ATTR); +}; + +const clearHighlights = (container: HTMLElement): void => { + container.querySelectorAll(`mark[${MARK_ATTR}]`).forEach((mark) => { + const parent = mark.parentNode; + if (!parent) { + return; + } + parent.replaceChild(document.createTextNode(mark.textContent ?? ''), mark); + parent.normalize(); + }); +}; + +const applySearch = (container: HTMLElement, query: string): HTMLElement[] => { + clearHighlights(container); + + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return []; + } + + const marks: HTMLElement[] = []; + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement; + if (!parent) { + return NodeFilter.FILTER_REJECT; + } + // Skipping svg (mermaid) keeps the highlight pass from corrupting + // diagram rendering; script/style content is never visible anyway. + if (parent.closest('svg, script, style')) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + + const textNodes: Text[] = []; + while (walker.nextNode()) { + textNodes.push(walker.currentNode as Text); + } + + for (const node of textNodes) { + const text = node.nodeValue ?? ''; + if (!text) { + continue; + } + const ranges = findMatchRanges(text, normalized); + if (ranges.length === 0) { + continue; + } + + const parent = node.parentNode; + if (!parent) { + continue; + } + const fragment = document.createDocumentFragment(); + let cursor = 0; + for (const range of ranges) { + if (range.start > cursor) { + fragment.appendChild(document.createTextNode(text.slice(cursor, range.start))); + } + const mark = document.createElement('mark'); + mark.setAttribute(MARK_ATTR, ''); + mark.className = MARK_CLASS; + mark.textContent = text.slice(range.start, range.end); + fragment.appendChild(mark); + marks.push(mark); + cursor = range.end; + } + if (cursor < text.length) { + fragment.appendChild(document.createTextNode(text.slice(cursor))); + } + parent.replaceChild(fragment, node); + } + + return marks; +}; + +type MarkdownPreviewSearchProps = { + /** The scrollable preview container whose rendered text is searched. */ + containerRef: React.RefObject; + open: boolean; + onOpenChange: (open: boolean) => void; + /** Bumped every time the find shortcut is pressed to re-focus the input. */ + focusNonce: number; +}; + +export const MarkdownPreviewSearch: React.FC = ({ + containerRef, + open, + onOpenChange, + focusNonce, +}) => { + const { t } = useI18n(); + const [query, setQuery] = React.useState(''); + const [total, setTotal] = React.useState(0); + const [index, setIndex] = React.useState(0); + const inputRef = React.useRef(null); + const marksRef = React.useRef([]); + const queryRef = React.useRef(query); + queryRef.current = query; + + const runSearch = React.useCallback((nextQuery: string) => { + const container = containerRef.current; + if (!container) { + marksRef.current = []; + setTotal(0); + setIndex(0); + return; + } + marksRef.current = applySearch(container, nextQuery); + setTotal(marksRef.current.length); + setIndex(0); + }, [containerRef]); + + // Re-apply highlights when the renderer re-morphs the container (theme or + // content changes), ignoring mutations this widget produces itself. Only + // active while the bar is open; closing clears the highlights. + React.useEffect(() => { + const container = containerRef.current; + if (!open || !container) { + return; + } + const observer = new MutationObserver((records) => { + const fromUs = records.some((record) => { + if (record.target instanceof Element && record.target.hasAttribute(MARK_ATTR)) { + return true; + } + return [...record.addedNodes].some((node) => isMarkElement(node)); + }); + if (fromUs) { + return; + } + runSearch(queryRef.current); + }); + observer.observe(container, { childList: true, subtree: true, characterData: true }); + return () => { + observer.disconnect(); + clearHighlights(container); + }; + }, [containerRef, open, runSearch]); + + // Focus the input when the bar opens. + React.useEffect(() => { + if (open) { + inputRef.current?.focus(); + } + }, [open]); + + // Pressing the find shortcut again re-focuses and re-selects the query. + React.useEffect(() => { + if (open && focusNonce > 0) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [open, focusNonce]); + + // Keep the current-match highlight and scroll it into view. + React.useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + container.querySelectorAll(`mark[${CURRENT_MARK_ATTR}]`).forEach((mark) => { + mark.removeAttribute(CURRENT_MARK_ATTR); + mark.className = MARK_CLASS; + }); + if (total === 0) { + return; + } + const current = marksRef.current[Math.min(Math.max(index, 0), total - 1)]; + if (!current) { + return; + } + current.setAttribute(CURRENT_MARK_ATTR, ''); + current.className = CURRENT_MARK_CLASS; + current.scrollIntoView({ block: 'nearest' }); + }, [containerRef, index, total]); + + const goToNext = React.useCallback(() => { + setIndex((current) => (total === 0 ? 0 : (current + 1) % total)); + }, [total]); + + const goToPrevious = React.useCallback(() => { + setIndex((current) => (total === 0 ? 0 : (current - 1 + total) % total)); + }, [total]); + + const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + if (event.shiftKey) { + goToPrevious(); + } else { + goToNext(); + } + } else if (event.key === 'Escape') { + event.preventDefault(); + onOpenChange(false); + } + }, [goToNext, goToPrevious, onOpenChange]); + + if (!open) { + return null; + } + + return ( +
+ + { + setQuery(event.target.value); + runSearch(event.target.value); + }} + onKeyDown={handleKeyDown} + placeholder={t('filesView.preview.find.placeholder')} + aria-label={t('filesView.preview.find.placeholder')} + className="h-7 w-40 rounded-md px-2 py-0 text-sm md:w-56" + /> + 0 + ? t('filesView.preview.find.countAria', { current: index + 1, total }) + : undefined} + > + {query.trim() && total === 0 + ? t('filesView.preview.find.noMatches') + : total > 0 + ? `${index + 1}/${total}` + : ''} + + + + +
+ ); +}; diff --git a/packages/ui/src/components/views/markdownPreviewFind.ts b/packages/ui/src/components/views/markdownPreviewFind.ts new file mode 100644 index 00000000..0e876a08 --- /dev/null +++ b/packages/ui/src/components/views/markdownPreviewFind.ts @@ -0,0 +1,23 @@ +/** + * Case-insensitive substring match ranges over a single text string, using + * the same non-overlapping `String.prototype.indexOf` scan semantics as + * standard find-in-page (e.g. "aaa" in "aaaa" yields a single [0,3]). + */ +export const findMatchRanges = (text: string, query: string): Array<{ start: number; end: number }> => { + const normalized = query.trim().toLowerCase(); + const ranges: Array<{ start: number; end: number }> = []; + if (!normalized) { + return ranges; + } + const lower = text.toLowerCase(); + let cursor = 0; + while (true) { + const index = lower.indexOf(normalized, cursor); + if (index === -1) { + break; + } + ranges.push({ start: index, end: index + normalized.length }); + cursor = index + normalized.length; + } + return ranges; +}; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 57a22de2..ba2dfa50 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1160,6 +1160,12 @@ export const dict = { 'filesView.editor.disableLineWrap': 'Zeilenumbruch deaktivieren', 'filesView.editor.enableLineWrap': 'Zeilenumbruch aktivieren', 'filesView.editor.findInFile': 'In Datei suchen', + 'filesView.preview.find.placeholder': 'In Vorschau suchen', + 'filesView.preview.find.nextAria': 'Nächster Treffer', + 'filesView.preview.find.previousAria': 'Vorheriger Treffer', + 'filesView.preview.find.closeAria': 'Suche schließen', + 'filesView.preview.find.noMatches': 'Keine Treffer', + 'filesView.preview.find.countAria': '{current} von {total}', 'filesView.editor.goToLine': 'Gehe zu Zeile', 'filesView.editor.switchToEditMode': 'Zum Bearbeitungsmodus wechseln', 'filesView.editor.switchToPreviewMode': 'Zum Vorschau-Modus wechseln', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index c77d6a07..50089591 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1307,6 +1307,12 @@ export const dict = { 'filesView.editor.disableLineWrap': 'Disable line wrap', 'filesView.editor.enableLineWrap': 'Enable line wrap', 'filesView.editor.findInFile': 'Find in file', + 'filesView.preview.find.placeholder': 'Find in preview', + 'filesView.preview.find.nextAria': 'Next match', + 'filesView.preview.find.previousAria': 'Previous match', + 'filesView.preview.find.closeAria': 'Close search', + 'filesView.preview.find.noMatches': 'No matches', + 'filesView.preview.find.countAria': '{current} of {total}', 'filesView.editor.goToLine': 'Go to line', 'filesView.editor.switchToEditMode': 'Switch to edit mode', 'filesView.editor.switchToPreviewMode': 'Switch to preview mode', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 72985914..113d5c00 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1273,6 +1273,12 @@ export const dict: Record = { "filesView.editor.disableLineWrap": "Desactivar ajuste de línea", "filesView.editor.enableLineWrap": "Activar ajuste de línea", "filesView.editor.findInFile": "Buscar en el archivo", + "filesView.preview.find.placeholder": "Buscar en la vista previa", + "filesView.preview.find.nextAria": "Siguiente coincidencia", + "filesView.preview.find.previousAria": "Coincidencia anterior", + "filesView.preview.find.closeAria": "Cerrar búsqueda", + "filesView.preview.find.noMatches": "Sin coincidencias", + "filesView.preview.find.countAria": "{current} de {total}", "filesView.editor.goToLine": "Ir a línea", "filesView.editor.switchToEditMode": "Cambiar al modo de edición", "filesView.editor.switchToPreviewMode": "Cambiar al modo de vista previa", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 986a4cd4..b5e1b549 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1129,6 +1129,12 @@ export const dict = { 'filesView.editor.disableLineWrap': 'Désactiver le retour à la ligne', 'filesView.editor.enableLineWrap': 'Activer le retour à la ligne', 'filesView.editor.findInFile': 'Rechercher dans le fichier', + 'filesView.preview.find.placeholder': 'Rechercher dans l\'aperçu', + 'filesView.preview.find.nextAria': 'Correspondance suivante', + 'filesView.preview.find.previousAria': 'Correspondance précédente', + 'filesView.preview.find.closeAria': 'Fermer la recherche', + 'filesView.preview.find.noMatches': 'Aucune correspondance', + 'filesView.preview.find.countAria': '{current} sur {total}', 'filesView.editor.goToLine': 'Aller à la ligne', 'filesView.editor.switchToEditMode': 'Passer en mode édition', 'filesView.editor.switchToPreviewMode': 'Passer en mode aperçu', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 0f4da4e7..813094d1 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1303,6 +1303,12 @@ export const dict: Record = { 'filesView.editor.disableLineWrap': '行の折り返しを無効にする', 'filesView.editor.enableLineWrap': '行の折り返しを有効にする', 'filesView.editor.findInFile': 'ファイル内を検索', + 'filesView.preview.find.placeholder': 'プレビュー内を検索', + 'filesView.preview.find.nextAria': '次の一致', + 'filesView.preview.find.previousAria': '前の一致', + 'filesView.preview.find.closeAria': '検索を閉じる', + 'filesView.preview.find.noMatches': '一致なし', + 'filesView.preview.find.countAria': '{total}件中{current}件目', 'filesView.editor.goToLine': '指定行に移動', 'filesView.editor.switchToEditMode': '編集モードに切り替え', 'filesView.editor.switchToPreviewMode': 'プレビューモードに切り替え', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 0f09fba2..c7af0a4a 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1310,6 +1310,12 @@ export const dict: Record = { 'filesView.editor.disableLineWrap': '줄 바꿈 끄기', 'filesView.editor.enableLineWrap': '줄 바꿈 켜기', 'filesView.editor.findInFile': '파일에서 찾기', + 'filesView.preview.find.placeholder': '미리보기에서 찾기', + 'filesView.preview.find.nextAria': '다음 일치 항목', + 'filesView.preview.find.previousAria': '이전 일치 항목', + 'filesView.preview.find.closeAria': '검색 닫기', + 'filesView.preview.find.noMatches': '일치 항목 없음', + 'filesView.preview.find.countAria': '{total}개 중 {current}번째', 'filesView.editor.goToLine': '줄로 이동', 'filesView.editor.switchToEditMode': '편집 모드로 전환', 'filesView.editor.switchToPreviewMode': '미리보기 모드로 전환', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 12f727b1..d3f78b5b 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1784,6 +1784,12 @@ export const dict: Record = { 'filesView.editor.enableLineWrap': 'Włącz zawijanie linii', 'filesView.editor.exitFullscreen': 'Wyjdź z pełnego ekranu', 'filesView.editor.findInFile': 'Znajdź w pliku', + 'filesView.preview.find.placeholder': 'Szukaj w podglądzie', + 'filesView.preview.find.nextAria': 'Następne dopasowanie', + 'filesView.preview.find.previousAria': 'Poprzednie dopasowanie', + 'filesView.preview.find.closeAria': 'Zamknij wyszukiwanie', + 'filesView.preview.find.noMatches': 'Brak dopasowań', + 'filesView.preview.find.countAria': '{current} z {total}', 'filesView.editor.fullscreen': 'Pełny ekran', 'filesView.editor.goToLine': 'Przejdź do linii', 'filesView.editor.htmlPreviewTitle': 'Podgląd HTML', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 10dd7709..f65b4b43 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1273,6 +1273,12 @@ export const dict: Record = { "filesView.editor.disableLineWrap": "Desativar ajuste de linha", "filesView.editor.enableLineWrap": "Ativar ajuste de linha", "filesView.editor.findInFile": "Buscar no arquivo", + "filesView.preview.find.placeholder": "Buscar na pré-visualização", + "filesView.preview.find.nextAria": "Próxima correspondência", + "filesView.preview.find.previousAria": "Correspondência anterior", + "filesView.preview.find.closeAria": "Fechar busca", + "filesView.preview.find.noMatches": "Sem correspondências", + "filesView.preview.find.countAria": "{current} de {total}", "filesView.editor.goToLine": "Ir para linha", "filesView.editor.switchToEditMode": "Alternar para o modo de edição", "filesView.editor.switchToPreviewMode": "Alternar para o modo de visualização", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index dd52bcb9..f2ee6c1b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1273,6 +1273,12 @@ export const dict: Record = { "filesView.editor.disableLineWrap": "Вимкнути перенос рядків", "filesView.editor.enableLineWrap": "Увімкнути перенос рядків", "filesView.editor.findInFile": "Знайти у файлі", + "filesView.preview.find.placeholder": "Пошук у попередньому перегляді", + "filesView.preview.find.nextAria": "Наступний збіг", + "filesView.preview.find.previousAria": "Попередній збіг", + "filesView.preview.find.closeAria": "Закрити пошук", + "filesView.preview.find.noMatches": "Збігів немає", + "filesView.preview.find.countAria": "{current} із {total}", "filesView.editor.goToLine": "Перейти до рядка", "filesView.editor.switchToEditMode": "Перемкнутися в режим редагування", "filesView.editor.switchToPreviewMode": "Перемкнутися в режим попереднього перегляду", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 35de2c9c..da730d2f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1273,6 +1273,12 @@ export const dict: Record = { 'filesView.editor.disableLineWrap': '关闭自动换行', 'filesView.editor.enableLineWrap': '开启自动换行', 'filesView.editor.findInFile': '文件内查找', + 'filesView.preview.find.placeholder': '在预览中查找', + 'filesView.preview.find.nextAria': '下一个匹配', + 'filesView.preview.find.previousAria': '上一个匹配', + 'filesView.preview.find.closeAria': '关闭搜索', + 'filesView.preview.find.noMatches': '无匹配项', + 'filesView.preview.find.countAria': '第 {current} 个,共 {total} 个', 'filesView.editor.goToLine': '跳转到行', 'filesView.editor.switchToEditMode': '切换到编辑模式', 'filesView.editor.switchToPreviewMode': '切换到预览模式', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 6641606b..27c8d00c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1284,6 +1284,12 @@ export const dict: Record = { 'filesView.editor.disableLineWrap': '關閉自動換行', 'filesView.editor.enableLineWrap': '開啟自動換行', 'filesView.editor.findInFile': '檔案內尋找', + 'filesView.preview.find.placeholder': '在預覽中尋找', + 'filesView.preview.find.nextAria': '下一個相符項目', + 'filesView.preview.find.previousAria': '上一個相符項目', + 'filesView.preview.find.closeAria': '關閉搜尋', + 'filesView.preview.find.noMatches': '無相符項目', + 'filesView.preview.find.countAria': '第 {current} 個,共 {total} 個', 'filesView.editor.goToLine': '跳轉到行', 'filesView.editor.switchToEditMode': '切換到編輯模式', 'filesView.editor.switchToPreviewMode': '切換到預覽模式', From 6183ca662998ef17663b0234b48c5cc4fa2bb204 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Thu, 6 Aug 2026 19:46:21 +0000 Subject: [PATCH 24/66] fix(chat): clamp text selection menu Y position to the viewport (#2257) Co-authored-by: Serhii Dziupin --- .../chat/message/TextSelectionMenu.tsx | 55 +++++++++------ .../__tests__/selectionMenuPosition.test.ts | 67 +++++++++++++++++++ .../chat/message/selectionMenuPosition.ts | 29 ++++++++ 3 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 packages/ui/src/components/chat/message/__tests__/selectionMenuPosition.test.ts create mode 100644 packages/ui/src/components/chat/message/selectionMenuPosition.ts diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index c8ecc9f4..11c503ce 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -16,6 +16,12 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown'; +import { + DESKTOP_MENU_FALLBACK_HEIGHT_PX, + DESKTOP_MENU_FALLBACK_WIDTH_PX, + getDesktopClampedX, + getDesktopClampedY, +} from './selectionMenuPosition'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; interface TextSelectionMenuProps { @@ -44,8 +50,6 @@ const appendDistilledInsightToNotes = (existingNotes: string, insight: string): return trimmedNotes ? `${trimmedNotes}\n${trimmedInsight}` : trimmedInsight; }; -const DESKTOP_MENU_SIDE_MARGIN_PX = 8; -const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280; export const TextSelectionMenu: React.FC = ({ containerRef }) => { const { t } = useI18n(); const [position, setPosition] = React.useState({ x: 0, y: 0, show: false }); @@ -56,6 +60,7 @@ export const TextSelectionMenu: React.FC = ({ containerR const [isAddingToNotes, setIsAddingToNotes] = React.useState(false); const menuRef = React.useRef(null); const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX); + const menuHeightRef = React.useRef(DESKTOP_MENU_FALLBACK_HEIGHT_PX); const pendingSelectionRef = React.useRef(null); const openRafRef = React.useRef(null); const mouseUpTimeoutRef = React.useRef(null); @@ -105,22 +110,20 @@ export const TextSelectionMenu: React.FC = ({ containerR isMenuVisibleRef.current = false; }, []); - const getDesktopClampedX = React.useCallback((anchorX: number) => { + const clampDesktopX = React.useCallback((anchorX: number) => { if (typeof window === 'undefined') { return anchorX; } - const viewportWidth = window.innerWidth; - const menuWidth = menuWidthRef.current; - const halfWidth = menuWidth / 2; - const minX = DESKTOP_MENU_SIDE_MARGIN_PX + halfWidth; - const maxX = viewportWidth - DESKTOP_MENU_SIDE_MARGIN_PX - halfWidth; + return getDesktopClampedX(anchorX, window.innerWidth, menuWidthRef.current); + }, []); - if (minX > maxX) { - return viewportWidth / 2; + const clampDesktopY = React.useCallback((anchorY: number) => { + if (typeof window === 'undefined') { + return anchorY; } - return Math.min(Math.max(anchorX, minX), maxX); + return getDesktopClampedY(anchorY, window.innerHeight, menuHeightRef.current); }, []); const showMenu = React.useCallback(() => { @@ -132,8 +135,10 @@ export const TextSelectionMenu: React.FC = ({ containerR // Position menu above the selection const menuX = isMobile ? rect.left + rect.width / 2 - : getDesktopClampedX(rect.left + rect.width / 2); - const menuY = rect.top - 10; + : clampDesktopX(rect.left + rect.width / 2); + const menuY = isMobile + ? rect.top - 10 + : clampDesktopY(rect.top - 10); setSelectedText(plainText); setSelectedTextMarkdown(markdownText); @@ -154,7 +159,7 @@ export const TextSelectionMenu: React.FC = ({ containerR openRafRef.current = null; }); } - }, [getDesktopClampedX, isMobile, position.show]); + }, [clampDesktopX, clampDesktopY, isMobile, position.show]); React.useLayoutEffect(() => { if (!position.show || isMobile || !menuRef.current) { @@ -162,16 +167,25 @@ export const TextSelectionMenu: React.FC = ({ containerR } const measuredWidth = menuRef.current.offsetWidth; - if (!Number.isFinite(measuredWidth) || measuredWidth <= 0 || measuredWidth === menuWidthRef.current) { + const measuredHeight = menuRef.current.offsetHeight; + const widthChanged = Number.isFinite(measuredWidth) && measuredWidth > 0 && measuredWidth !== menuWidthRef.current; + const heightChanged = Number.isFinite(measuredHeight) && measuredHeight > 0 && measuredHeight !== menuHeightRef.current; + if (!widthChanged && !heightChanged) { return; } - menuWidthRef.current = measuredWidth; + if (widthChanged) { + menuWidthRef.current = measuredWidth; + } + if (heightChanged) { + menuHeightRef.current = measuredHeight; + } setPosition((prev) => ({ ...prev, - x: getDesktopClampedX(prev.x), + x: clampDesktopX(prev.x), + y: clampDesktopY(prev.y), })); - }, [getDesktopClampedX, isMobile, position.show]); + }, [clampDesktopX, clampDesktopY, isMobile, position.show]); React.useEffect(() => { if (!position.show || isMobile) { @@ -181,7 +195,8 @@ export const TextSelectionMenu: React.FC = ({ containerR const handleViewportResize = () => { setPosition((prev) => ({ ...prev, - x: getDesktopClampedX(prev.x), + x: clampDesktopX(prev.x), + y: clampDesktopY(prev.y), })); }; @@ -189,7 +204,7 @@ export const TextSelectionMenu: React.FC = ({ containerR return () => { window.removeEventListener('resize', handleViewportResize); }; - }, [getDesktopClampedX, isMobile, position.show]); + }, [clampDesktopX, clampDesktopY, isMobile, position.show]); const handleSelectionChange = React.useCallback(() => { const selection = window.getSelection(); diff --git a/packages/ui/src/components/chat/message/__tests__/selectionMenuPosition.test.ts b/packages/ui/src/components/chat/message/__tests__/selectionMenuPosition.test.ts new file mode 100644 index 00000000..eb3f1a0f --- /dev/null +++ b/packages/ui/src/components/chat/message/__tests__/selectionMenuPosition.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test'; +import { + DESKTOP_MENU_FALLBACK_HEIGHT_PX, + DESKTOP_MENU_FALLBACK_WIDTH_PX, + DESKTOP_MENU_SIDE_MARGIN_PX, + getDesktopClampedX, + getDesktopClampedY, +} from '../selectionMenuPosition'; + +const VIEWPORT_WIDTH = 1024; +const VIEWPORT_HEIGHT = 768; +const MENU_WIDTH = DESKTOP_MENU_FALLBACK_WIDTH_PX; +const MENU_HEIGHT = DESKTOP_MENU_FALLBACK_HEIGHT_PX; + +// Regression coverage for issue #2257: selecting a long assistant response +// across a scroll boundary makes range.getBoundingClientRect().top negative, +// and the unclamped anchor (rect.top - 10) placed the menu above the viewport. +describe('getDesktopClampedY (issue #2257)', () => { + test('keeps the menu on screen when the selection starts above the viewport', () => { + const clamped = getDesktopClampedY(-210, VIEWPORT_HEIGHT, MENU_HEIGHT); + expect(clamped).toBe(DESKTOP_MENU_SIDE_MARGIN_PX + MENU_HEIGHT); + }); + + test('keeps the menu fully visible for selections near the top edge', () => { + // The menu renders with translate(-50%, -100%), so it extends upward from + // the anchor; anchors smaller than margin + menu height clip the menu. + const clamped = getDesktopClampedY(5, VIEWPORT_HEIGHT, MENU_HEIGHT); + expect(clamped).toBe(DESKTOP_MENU_SIDE_MARGIN_PX + MENU_HEIGHT); + }); + + test('clamps anchors below the viewport back to the bottom margin', () => { + const clamped = getDesktopClampedY(VIEWPORT_HEIGHT + 500, VIEWPORT_HEIGHT, MENU_HEIGHT); + expect(clamped).toBe(VIEWPORT_HEIGHT - DESKTOP_MENU_SIDE_MARGIN_PX); + }); + + test('leaves in-viewport anchors unchanged', () => { + expect(getDesktopClampedY(300, VIEWPORT_HEIGHT, MENU_HEIGHT)).toBe(300); + expect(getDesktopClampedY(MENU_HEIGHT + DESKTOP_MENU_SIDE_MARGIN_PX, VIEWPORT_HEIGHT, MENU_HEIGHT)) + .toBe(MENU_HEIGHT + DESKTOP_MENU_SIDE_MARGIN_PX); + }); + + test('falls back to the viewport middle when the viewport is shorter than the menu', () => { + const tinyViewportHeight = MENU_HEIGHT; + expect(getDesktopClampedY(10, tinyViewportHeight, MENU_HEIGHT)).toBe(tinyViewportHeight / 2); + }); +}); + +describe('getDesktopClampedX', () => { + test('clamps anchors past the left edge to the left margin', () => { + const clamped = getDesktopClampedX(-500, VIEWPORT_WIDTH, MENU_WIDTH); + expect(clamped).toBe(DESKTOP_MENU_SIDE_MARGIN_PX + MENU_WIDTH / 2); + }); + + test('clamps anchors past the right edge to the right margin', () => { + const clamped = getDesktopClampedX(VIEWPORT_WIDTH + 500, VIEWPORT_WIDTH, MENU_WIDTH); + expect(clamped).toBe(VIEWPORT_WIDTH - DESKTOP_MENU_SIDE_MARGIN_PX - MENU_WIDTH / 2); + }); + + test('leaves in-viewport anchors unchanged', () => { + expect(getDesktopClampedX(VIEWPORT_WIDTH / 2, VIEWPORT_WIDTH, MENU_WIDTH)).toBe(VIEWPORT_WIDTH / 2); + }); + + test('falls back to the viewport middle when the viewport is narrower than the menu', () => { + const tinyViewportWidth = MENU_WIDTH / 2; + expect(getDesktopClampedX(10, tinyViewportWidth, MENU_WIDTH)).toBe(tinyViewportWidth / 2); + }); +}); diff --git a/packages/ui/src/components/chat/message/selectionMenuPosition.ts b/packages/ui/src/components/chat/message/selectionMenuPosition.ts new file mode 100644 index 00000000..7a431e6d --- /dev/null +++ b/packages/ui/src/components/chat/message/selectionMenuPosition.ts @@ -0,0 +1,29 @@ +export const DESKTOP_MENU_SIDE_MARGIN_PX = 8; +export const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280; +export const DESKTOP_MENU_FALLBACK_HEIGHT_PX = 38; + +export const getDesktopClampedX = (anchorX: number, viewportWidth: number, menuWidth: number): number => { + const halfWidth = menuWidth / 2; + const minX = DESKTOP_MENU_SIDE_MARGIN_PX + halfWidth; + const maxX = viewportWidth - DESKTOP_MENU_SIDE_MARGIN_PX - halfWidth; + + if (minX > maxX) { + return viewportWidth / 2; + } + + return Math.min(Math.max(anchorX, minX), maxX); +}; + +// The desktop menu renders with `transform: translate(-50%, -100%)`, so the +// anchor Y marks the menu's bottom edge and the menu extends `menuHeight` +// upward from it. The minimum keeps the whole menu below the top margin. +export const getDesktopClampedY = (anchorY: number, viewportHeight: number, menuHeight: number): number => { + const minY = DESKTOP_MENU_SIDE_MARGIN_PX + menuHeight; + const maxY = viewportHeight - DESKTOP_MENU_SIDE_MARGIN_PX; + + if (minY > maxY) { + return viewportHeight / 2; + } + + return Math.min(Math.max(anchorY, minY), maxY); +}; From 90512d0e06d93b1cc87ac1692a05b82990154ec4 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Thu, 6 Aug 2026 19:48:51 +0000 Subject: [PATCH 25/66] fix(settings): flush pending debounced settings writes on page unload (#2197) --- packages/ui/src/lib/persistence.test.ts | 84 +++++++++++++++++++++++++ packages/ui/src/lib/persistence.ts | 31 +++++++++ packages/ui/src/stores/DOCUMENTATION.md | 2 +- 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/lib/persistence.test.ts b/packages/ui/src/lib/persistence.test.ts index 38057e9c..5a29f976 100644 --- a/packages/ui/src/lib/persistence.test.ts +++ b/packages/ui/src/lib/persistence.test.ts @@ -568,3 +568,87 @@ describe('updateDesktopSettings', () => { expect(saveCalls.some((changes) => changes.autoSaveEnabled === true)).toBe(true); }); }); + +describe('unload lifecycle flush (#2197)', () => { + beforeEach(() => { + getWindow(); + registerRuntimeAPIs(null); + invalidateSettingsCache(); + }); + + test('flushes a pending debounced settings save on pagehide without a double write', async () => { + const saveCalls: Array> = []; + registerSettingsSave(async (changes) => { + saveCalls.push(changes); + return {}; + }); + + const update = updateDesktopSettings({ showDeletionDialog: false }); + expect(saveCalls).toEqual([]); + + getWindow().dispatchEvent(new Event('pagehide')); + + // The flush must hand the pending changes to the settings backend + // synchronously inside the lifecycle listener — an unloading window has + // no later turn for the debounce timer. + expect(saveCalls).toEqual([{ showDeletionDialog: false }]); + + await update; + await delay(300); + // The canceled debounce timer must not replay the same write. + expect(saveCalls).toHaveLength(1); + }); + + test('flushes a pending debounced settings save on beforeunload without a double write', async () => { + const saveCalls: Array> = []; + registerSettingsSave(async (changes) => { + saveCalls.push(changes); + return {}; + }); + + const update = updateDesktopSettings({ gitChangesViewMode: 'tree' }); + expect(saveCalls).toEqual([]); + + getWindow().dispatchEvent(new Event('beforeunload')); + + expect(saveCalls).toEqual([{ gitChangesViewMode: 'tree' }]); + + await update; + await delay(300); + expect(saveCalls).toHaveLength(1); + }); + + test('persists a showDeletionDialog toggle followed by an immediate unload', async () => { + const saveCalls: Array> = []; + registerSettingsSave(async (changes) => { + saveCalls.push(changes); + return {}; + }); + startAppearanceAutoSave(); + + try { + useUIStore.getState().setShowDeletionDialog(false); + getWindow().dispatchEvent(new Event('pagehide')); + + expect(saveCalls.some((changes) => changes.showDeletionDialog === false)).toBe(true); + } finally { + useUIStore.getState().setShowDeletionDialog(true); + // Let the restore write drain so it cannot leak into other tests. + await delay(300); + } + }); + + test('ignores lifecycle events when no settings write is pending', async () => { + const saveCalls: Array> = []; + registerSettingsSave(async (changes) => { + saveCalls.push(changes); + return {}; + }); + + getWindow().dispatchEvent(new Event('pagehide')); + getWindow().dispatchEvent(new Event('beforeunload')); + await delay(50); + + expect(saveCalls).toEqual([]); + }); +}); diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 5dcc40ab..9375a468 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1625,6 +1625,21 @@ const isSettingsRuntimeContextCurrent = (context: SettingsRuntimeContext): boole context.generation === _settingsRuntimeGeneration && context.runtimeKey === getRuntimeKey() ); +// Best-effort flush of the pending debounced settings write at a lifecycle +// boundary. Clearing the timer before flushing means the write happens exactly +// once — the flush consumes the pending changes, so a timer that already fired +// cannot double-write. A hard process kill (crash, task-manager kill) can +// still lose the in-flight request; this narrows the loss window to the +// request itself instead of the whole debounce interval (#2197). +const flushPendingSettingsBeforeSuspend = (): void => { + if (!_pendingSettingsChanges) return; + if (_settingsFlushTimer) { + clearTimeout(_settingsFlushTimer); + _settingsFlushTimer = null; + } + void _flushSettingsUpdate(); +}; + const ensureSettingsRuntimeLifecycle = (): void => { if (_settingsLifecycleInitialized || typeof window === 'undefined') return; _settingsLifecycleInitialized = true; @@ -1640,6 +1655,22 @@ const ensureSettingsRuntimeLifecycle = (): void => { _settingsCache = null; _settingsInflight = null; }); + + // Mirror the deferred safe-storage lifecycle: without these listeners, a + // settings change made within SETTINGS_DEBOUNCE_MS of closing the window is + // silently dropped, and the stale server snapshot wins on next startup. + try { + window.addEventListener('pagehide', flushPendingSettingsBeforeSuspend, { capture: true }); + window.addEventListener('beforeunload', flushPendingSettingsBeforeSuspend, { capture: true }); + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') flushPendingSettingsBeforeSuspend(); + }); + document.addEventListener('freeze', flushPendingSettingsBeforeSuspend); + } + } catch { + // Restricted environments can reject listeners; the debounce timer still flushes. + } }; const fetchWebSettings = async (context = captureSettingsRuntimeContext()): Promise => { diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index a578d61f..eccda5ea 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -71,7 +71,7 @@ Permission auto-accept policy is authoritative in the active Web server or VS Co Shared safe storage treats durable failures per key. A quota or access failure creates an ephemeral override or tombstone for that key without disabling reads and writes for unrelated keys; later writes retry the durable backend. Deferred adapters retain failed operations for a later flush, and malformed Zustand JSON is removed and treated as missing so hydration can recover. -Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty; transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. +Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty; transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. Debounced settings writes flush best-effort on page hide, document hidden, app freeze, and unload — canceling the pending timer so the write happens exactly once — because a write lost inside the debounce window lets the stale server snapshot override the change on next startup; a hard process kill can still lose the in-flight request. Project ordering defaults to manual. Session display persistence v3 migrates the previously shipped `recent` project order to `manual` while preserving every other explicit sort mode. From 510472951af9b0c2a41e8f48f6cd029079481bf1 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Thu, 6 Aug 2026 19:49:02 +0000 Subject: [PATCH 26/66] fix(git): include remote-only branches from ls-remote in branch lists (#2098) Co-authored-by: Serhii Dziupin --- packages/web/server/lib/git/DOCUMENTATION.md | 2 +- packages/web/server/lib/git/service.js | 21 +++++++++- packages/web/server/lib/git/service.test.js | 41 ++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 98368370..659a34f5 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -112,7 +112,7 @@ The following functions are internal helpers used by exported functions: - `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress. ### Branches Response -- `all`: Local branches plus remote-tracking branches that still exist on their remote. A remote that fails to answer keeps its branches in the list: "we could not ask" must not be reported as "these branches are gone", because callers use this list to decide whether a base branch exists at all. +- `all`: Local branches plus every branch each reachable remote reports via `ls-remote --heads`, formatted as `remotes//`. This is a union: local remote-tracking refs deleted on the remote are pruned, and branches that exist on the remote without a local tracking ref (never fetched) are still included, so a freshly pushed branch appears without requiring a fetch. A remote that fails to answer keeps its locally known branches in the list: "we could not ask" must not be reported as "these branches are gone", because callers use this list to decide whether a base branch exists at all. - `current`: Current branch name. - `branches`: Per-branch detail keyed by branch name, as reported by `git branch`. - `defaultBranches`: Each remote's default branch, keyed by remote name. Read from the local `remotes//HEAD` symbolic ref; for a remote that has none — clone writes it, a hand-added remote may not — the remote itself is asked once with `ls-remote --symref`. A remote that answers neither is absent rather than guessed, and consumers fall back to conventional branch names. Omitted entirely by runtimes that do not provide this Git metadata. diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index d058dd91..bcd0f545 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -3490,7 +3490,7 @@ async function filterActiveRemoteBranches(git, remoteBranches) { } })); - return remoteBranches.filter(remoteBranch => { + const activeBranches = remoteBranches.filter(remoteBranch => { const match = remoteBranch.match(/^remotes\/[^\/]+\/(.+)$/); if (!match) return false; const remoteName = remoteBranch.split('/')[1]; @@ -3498,6 +3498,25 @@ async function filterActiveRemoteBranches(git, remoteBranches) { if (unreachableRemotes.has(remoteName)) return true; return branchesByRemote.get(remoteName)?.has(branchName) ?? false; }); + + // A branch pushed to the remote that was never fetched locally has no + // remote-tracking ref, so `git branch` never reports it — but ls-remote + // just told us it exists. Add those so a freshly pushed branch shows up + // without requiring a fetch first (#2098). Unreachable remotes have no + // ls-remote data and therefore add nothing here; their local view above + // is preserved unchanged. + const seenBranches = new Set(activeBranches); + for (const [remoteName, actualRemoteBranches] of branchesByRemote) { + for (const branchName of actualRemoteBranches) { + const qualifiedBranch = `remotes/${remoteName}/${branchName}`; + if (!seenBranches.has(qualifiedBranch)) { + seenBranches.add(qualifiedBranch); + activeBranches.push(qualifiedBranch); + } + } + } + + return activeBranches; } catch (error) { console.warn('Failed to filter active remote branches, returning all:', error.message); return remoteBranches; diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 7b06d022..b4893aa4 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -1043,6 +1043,47 @@ describe.runIf(canRunGit())('getBranches', () => { // decide whether a base branch exists at all. expect(branches.all).toContain('remotes/origin/react'); }); + + it('includes remote branches with no local tracking ref and prunes refs deleted on the remote (#2098)', async () => { + const remote = createTempDir(); + runGit(remote, ['init', '--bare', '--initial-branch=main']); + + const repository = createTempDir(); + runGit(repository, ['init', '-b', 'main']); + runGit(repository, ['config', 'user.email', 'test@example.com']); + runGit(repository, ['config', 'user.name', 'Test']); + fs.writeFileSync(path.join(repository, 'README.md'), '# Test\n'); + runGit(repository, ['add', 'README.md']); + runGit(repository, ['commit', '-m', 'init']); + runGit(repository, ['remote', 'add', 'origin', remote]); + runGit(repository, ['push', '-u', 'origin', 'main']); + runGit(repository, ['checkout', '-b', 'feature-known']); + runGit(repository, ['push', '-u', 'origin', 'feature-known']); + // This tracking ref will go stale: the collaborator deletes the branch on + // the remote below, and the list must prune it. + runGit(repository, ['checkout', '-b', 'feature-stale']); + runGit(repository, ['push', '-u', 'origin', 'feature-stale']); + runGit(repository, ['checkout', 'main']); + runGit(repository, ['branch', '-D', 'feature-stale']); + + // A collaborator pushes a branch straight to the remote and deletes + // another; this repository never fetches, so it has no local + // remote-tracking ref for feature-remote-only. + const collaborator = createTempDir(); + runGit(collaborator, ['clone', remote, '.']); + runGit(collaborator, ['config', 'user.email', 'test@example.com']); + runGit(collaborator, ['config', 'user.name', 'Test']); + runGit(collaborator, ['checkout', '-b', 'feature-remote-only']); + runGit(collaborator, ['push', 'origin', 'feature-remote-only']); + runGit(collaborator, ['push', 'origin', ':feature-stale']); + + const branches = await getBranches(repository); + + expect(branches.all).toContain('remotes/origin/feature-remote-only'); + expect(branches.all).toContain('remotes/origin/feature-known'); + expect(branches.all).toContain('feature-known'); + expect(branches.all).not.toContain('remotes/origin/feature-stale'); + }); }); describe.runIf(canRunGit())('getRangeDiff', () => { From c006f968117936e6637306b354f03d6435277cf3 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Thu, 6 Aug 2026 19:54:33 +0000 Subject: [PATCH 27/66] fix(settings): let fixed-width controls scale with font size and density (#2320) --- .../ui/src/components/sections/agents/AgentsPage.tsx | 4 ++-- .../src/components/sections/behavior/BehaviorPage.tsx | 3 ++- .../sections/openchamber/KeyboardShortcutsSettings.tsx | 3 ++- .../sections/openchamber/OpenChamberVisualSettings.tsx | 9 +++++++-- .../sections/openchamber/SessionRetentionSettings.tsx | 2 +- .../components/sections/openchamber/VoiceSettings.tsx | 4 ++-- .../sections/remote-instances/RemoteInstancesPage.tsx | 10 +++++----- 7 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/components/sections/agents/AgentsPage.tsx b/packages/ui/src/components/sections/agents/AgentsPage.tsx index f8cb2b09..43904234 100644 --- a/packages/ui/src/components/sections/agents/AgentsPage.tsx +++ b/packages/ui/src/components/sections/agents/AgentsPage.tsx @@ -445,7 +445,7 @@ export const AgentsPage: React.FC = () => { inputMode="decimal" placeholder="—" emptyLabel="—" - className="w-16" + className="w-20" /> {temperature !== undefined && (
) : null} {pendingPermissionCount > 0 ? ( - + {pendingPermissionCount} ) : null} {pendingQuestionCount > 0 ? ( - + {pendingQuestionCount} diff --git a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts index fe43a537..d71e6ca3 100644 --- a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts +++ b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import type { Session } from '@opencode-ai/sdk/v2'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore'; -import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils'; +import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes, selectQuestionBadgeSessionScopes, selectRowBadgeVisibilityClass } from './sessionNodeItemUtils'; import type { SessionNode } from './types'; const session = (id: string, title: string): Session => ({ @@ -158,3 +158,42 @@ describe('selectFolderRootNodes', () => { expect(selectFolderRootNodes(['missing-root', 'child'], new Map([['child', child]]))).toEqual([child]); }); }); + +describe('selectRowBadgeVisibilityClass', () => { + const hideOnHoverClass = 'group-hover:opacity-0 group-focus-within:opacity-0'; + + test('hides the badge while hover-revealed actions are shown, like the date label (#2284)', () => { + const className = selectRowBadgeVisibilityClass({ + actionsAlwaysVisible: false, + menuOpen: false, + hideOnHoverClass, + }); + + expect(className).toContain(hideOnHoverClass); + expect(className).toContain('transition-opacity'); + }); + + test('hides the badge while the row menu keeps the actions visible without hover', () => { + const className = selectRowBadgeVisibilityClass({ + actionsAlwaysVisible: false, + menuOpen: true, + hideOnHoverClass, + }); + + expect(className).toContain('opacity-0'); + expect(className).not.toContain('group-hover'); + }); + + test('keeps the badge always visible when actions have reserved permanent padding', () => { + expect(selectRowBadgeVisibilityClass({ + actionsAlwaysVisible: true, + menuOpen: false, + hideOnHoverClass, + })).toBe(''); + expect(selectRowBadgeVisibilityClass({ + actionsAlwaysVisible: true, + menuOpen: true, + hideOnHoverClass, + })).toBe(''); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts index 3de83638..e2f3f15b 100644 --- a/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts +++ b/packages/ui/src/components/session/sidebar/sessionNodeItemUtils.ts @@ -196,6 +196,25 @@ export const nodeHasPinnedMembershipChange = ( return visit(prevNode, nextNode); }; +/** + * Visibility classes for the row's right-edge badges (pending permissions / + * questions). The hover actions paint over the row's right edge, and they are + * also forced visible while the row menu is open — without hover, so the + * hover reveal padding does not apply and the actions would cover the badges. + * The badges therefore yield exactly like the date/branch metadata label: + * hidden while the actions are hover-revealed or the menu is open. Rows with + * always-visible actions reserve permanent padding instead, so their badges + * never conflict and must stay visible. + */ +export const selectRowBadgeVisibilityClass = (input: { + actionsAlwaysVisible: boolean; + menuOpen: boolean; + hideOnHoverClass: string; +}): string => { + if (input.actionsAlwaysVisible) return ''; + return `transition-opacity duration-150 ${input.menuOpen ? 'opacity-0' : input.hideOnHoverClass}`; +}; + /** * Resolve the session id whose sidebar menu is open, or null if no * menu is open. Only one row can have its menu open at a time. From 696349d606db152ead74390fa62acb5ddc0292c1 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Thu, 6 Aug 2026 20:04:20 +0000 Subject: [PATCH 31/66] fix(git): make post-mutation status refresh authoritative (#2281) Co-authored-by: Serhii Dziupin --- packages/ui/src/lib/gitApiHttp.test.ts | 214 +++++++++++++++++++ packages/ui/src/lib/gitApiHttp.ts | 63 +++--- packages/ui/src/lib/gitStatusInvalidation.ts | 34 +++ packages/ui/src/stores/DOCUMENTATION.md | 5 +- packages/ui/src/stores/useGitStore.test.ts | 80 +++++++ packages/ui/src/stores/useGitStore.ts | 38 +++- 6 files changed, 395 insertions(+), 39 deletions(-) create mode 100644 packages/ui/src/lib/gitStatusInvalidation.ts diff --git a/packages/ui/src/lib/gitApiHttp.test.ts b/packages/ui/src/lib/gitApiHttp.test.ts index 4a58391c..4dc618b3 100644 --- a/packages/ui/src/lib/gitApiHttp.test.ts +++ b/packages/ui/src/lib/gitApiHttp.test.ts @@ -1,10 +1,30 @@ import { describe, expect, test } from 'bun:test'; import { + abortMerge, + abortRebase, + applyGitStash, + checkoutBranch, + checkoutCommit, + cherryPick, + continueMerge, + continueRebase, + createBranch, + deleteGitBranch, + deleteRemoteBranch, + dropGitStash, getGitBranches, getGitStatus, gitFetch, + merge, + popGitStash, + rebase, + removeRemote, + renameBranch, + resetToCommit, + revertCommit, stageGitFile, stageGitFiles, + stashGitChanges, unstageGitFile, unstageGitFiles, } from './gitApiHttp'; @@ -169,6 +189,200 @@ describe('gitApiHttp status cache', () => { }); }); +const statusPayload = (overrides: Record = {}) => ({ + current: 'main', + tracking: null, + ahead: 0, + behind: 0, + files: [], + isClean: true, + ...overrides, +}); + +const jsonResponse = (payload: unknown) => new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, +}); + +const installStatusMutationFetchMock = () => { + const mock = { + statusUrls: [] as string[], + behind: 0, + }; + globalThis.fetch = (async (input) => { + const url = String(input); + if (url.startsWith('/api/git/status')) { + mock.statusUrls.push(url); + return jsonResponse(statusPayload({ behind: mock.behind })); + } + return jsonResponse({ success: true }); + }) as typeof fetch; + return mock; +}; + +/** + * Seeds the status cache, performs the mutation, and asserts the next status + * read issues a fresh request that observes the post-mutation state instead of + * serving the pre-mutation cache entry. + */ +const expectStatusInvalidatedBy = async ( + directory: string, + mutate: () => Promise +): Promise => { + const mock = installStatusMutationFetchMock(); + + const seeded = await getGitStatus(directory); + expect(seeded.behind).toBe(0); + + mock.behind = 2; + const cached = await getGitStatus(directory); + expect(cached.behind).toBe(0); + expect(mock.statusUrls).toHaveLength(1); + + await mutate(); + + const refreshed = await getGitStatus(directory); + expect(refreshed.behind).toBe(2); + expect(mock.statusUrls).toHaveLength(2); +}; + +describe('gitApiHttp post-mutation status invalidation (#2281)', () => { + test('checkout and branch mutations invalidate cached status', async () => { + installWindowMock(); + try { + await expectStatusInvalidatedBy('/repo-2281-checkout', () => checkoutBranch('/repo-2281-checkout', 'feature')); + await expectStatusInvalidatedBy('/repo-2281-create-branch', () => createBranch('/repo-2281-create-branch', 'feature/new')); + await expectStatusInvalidatedBy('/repo-2281-rename-branch', () => renameBranch('/repo-2281-rename-branch', 'old', 'new')); + await expectStatusInvalidatedBy('/repo-2281-delete-branch', () => deleteGitBranch('/repo-2281-delete-branch', { branch: 'feature/old' })); + } finally { + restoreMocks(); + } + }); + + test('stash lifecycle mutations invalidate cached status', async () => { + installWindowMock(); + try { + await expectStatusInvalidatedBy('/repo-2281-stash', () => stashGitChanges('/repo-2281-stash', { message: 'WIP' })); + await expectStatusInvalidatedBy('/repo-2281-stash-apply', () => applyGitStash('/repo-2281-stash-apply', { ref: 'stash@{0}' })); + await expectStatusInvalidatedBy('/repo-2281-stash-pop', () => popGitStash('/repo-2281-stash-pop', { ref: 'stash@{0}' })); + await expectStatusInvalidatedBy('/repo-2281-stash-drop', () => dropGitStash('/repo-2281-stash-drop', { ref: 'stash@{0}' })); + } finally { + restoreMocks(); + } + }); + + test('merge and rebase lifecycle mutations invalidate cached status', async () => { + installWindowMock(); + try { + await expectStatusInvalidatedBy('/repo-2281-merge', () => merge('/repo-2281-merge', { branch: 'feature' })); + await expectStatusInvalidatedBy('/repo-2281-merge-abort', () => abortMerge('/repo-2281-merge-abort')); + await expectStatusInvalidatedBy('/repo-2281-merge-continue', () => continueMerge('/repo-2281-merge-continue')); + await expectStatusInvalidatedBy('/repo-2281-rebase', () => rebase('/repo-2281-rebase', { onto: 'main' })); + await expectStatusInvalidatedBy('/repo-2281-rebase-abort', () => abortRebase('/repo-2281-rebase-abort')); + await expectStatusInvalidatedBy('/repo-2281-rebase-continue', () => continueRebase('/repo-2281-rebase-continue')); + } finally { + restoreMocks(); + } + }); + + test('history mutations invalidate cached status', async () => { + installWindowMock(); + try { + await expectStatusInvalidatedBy('/repo-2281-checkout-commit', () => checkoutCommit('/repo-2281-checkout-commit', 'abc123')); + await expectStatusInvalidatedBy('/repo-2281-cherry-pick', () => cherryPick('/repo-2281-cherry-pick', 'abc123')); + await expectStatusInvalidatedBy('/repo-2281-revert-commit', () => revertCommit('/repo-2281-revert-commit', 'abc123')); + await expectStatusInvalidatedBy('/repo-2281-reset', () => resetToCommit('/repo-2281-reset', 'abc123', 'mixed')); + } finally { + restoreMocks(); + } + }); + + test('remote-side mutations invalidate cached status', async () => { + installWindowMock(); + try { + await expectStatusInvalidatedBy('/repo-2281-delete-remote-branch', () => deleteRemoteBranch('/repo-2281-delete-remote-branch', { branch: 'feature', remote: 'origin' })); + await expectStatusInvalidatedBy('/repo-2281-remove-remote', () => removeRemote('/repo-2281-remove-remote', { remote: 'origin' })); + } finally { + restoreMocks(); + } + }); + + test('a failed mutation does not invalidate cached status', async () => { + installWindowMock(); + const statusUrls: string[] = []; + globalThis.fetch = (async (input) => { + const url = String(input); + if (url.startsWith('/api/git/status')) { + statusUrls.push(url); + return jsonResponse(statusPayload()); + } + return new Response(JSON.stringify({ error: 'checkout failed' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + try { + const directory = '/repo-2281-failed-checkout'; + await getGitStatus(directory); + + const error = await captureError(async () => { + await checkoutBranch(directory, 'feature'); + }); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('checkout failed'); + + await getGitStatus(directory); + expect(statusUrls).toHaveLength(1); + } finally { + restoreMocks(); + } + }); + + test('a status request admitted before a mutation cannot satisfy the post-mutation refresh', async () => { + installWindowMock(); + const statusResolvers: Array<(response: Response) => void> = []; + const statusUrls: string[] = []; + globalThis.fetch = (async (input) => { + const url = String(input); + if (url.startsWith('/api/git/status')) { + statusUrls.push(url); + return new Promise((resolve) => { + statusResolvers.push(resolve); + }); + } + return jsonResponse({ success: true }); + }) as typeof fetch; + + try { + const directory = '/repo-2281-deferred'; + const preMutationRead = getGitStatus(directory); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(statusUrls).toHaveLength(1); + + await checkoutBranch(directory, 'feature'); + + const postMutationRead = getGitStatus(directory); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(statusUrls).toHaveLength(2); + + statusResolvers[1](jsonResponse(statusPayload({ current: 'feature' }))); + statusResolvers[0](jsonResponse(statusPayload({ current: 'main' }))); + + const [preMutationStatus, postMutationStatus] = await Promise.all([preMutationRead, postMutationRead]); + expect(preMutationStatus.current).toBe('main'); + expect(postMutationStatus.current).toBe('feature'); + + // The late pre-mutation response must not repopulate the cache. + const cachedRead = await getGitStatus(directory); + expect(cachedRead.current).toBe('feature'); + expect(statusUrls).toHaveLength(2); + } finally { + restoreMocks(); + } + }); +}); + describe('gitApiHttp request priority', () => { test('leaves low-level reads outside the background policy', async () => { installWindowMock(); diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 24317b24..80385dc0 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -37,6 +37,7 @@ import type { import { runtimeFetch } from './runtime-fetch'; import { getRuntimeUrlResolver } from './runtime-url'; import { getRuntimeKey } from './runtime-switch'; +import { notifyGitStatusInvalidated } from './gitStatusInvalidation'; const API_BASE = '/api/git'; const GIT_STATUS_CACHE_TTL_MS = 1200; @@ -65,6 +66,16 @@ const invalidateGitStatusCache = (directory: string): void => { gitStatusCache.delete(statusKey); gitStatusInFlight.delete(statusKey); } + notifyGitStatusInvalidated(directory); +}; + +// Shared success path for status-affecting mutations. The payload is parsed +// before invalidating so a failed mutation (non-ok response handled by the +// caller, or a malformed body) cannot publish a false state change. +const completeStatusMutation = async (directory: string, response: Response): Promise => { + const result = await response.json() as T; + invalidateGitStatusCache(directory); + return result; }; function buildUrl( @@ -418,7 +429,7 @@ export async function deleteGitBranch(directory: string, payload: GitDeleteBranc throw new Error(error.error || 'Failed to delete branch'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> { @@ -437,7 +448,7 @@ export async function deleteRemoteBranch(directory: string, payload: GitDeleteRe throw new Error(error.error || 'Failed to delete remote branch'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }> { @@ -457,7 +468,7 @@ export async function removeRemote(directory: string, payload: GitRemoveRemotePa throw new Error(error.error || 'Failed to remove remote'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function generateCommitMessage( @@ -664,9 +675,7 @@ export async function createGitCommit( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to create commit'); } - const result = await response.json(); - invalidateGitStatusCache(directory); - return result; + return completeStatusMutation(directory, response); } export async function gitPush( @@ -682,9 +691,7 @@ export async function gitPush( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to push'); } - const result = await response.json(); - invalidateGitStatusCache(directory); - return result; + return completeStatusMutation(directory, response); } export async function gitPull( @@ -700,9 +707,7 @@ export async function gitPull( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to pull'); } - const result = await response.json(); - invalidateGitStatusCache(directory); - return result; + return completeStatusMutation(directory, response); } export async function gitFetch( @@ -718,9 +723,7 @@ export async function gitFetch( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to fetch'); } - const result = await response.json(); - invalidateGitStatusCache(directory); - return result; + return completeStatusMutation(directory, response); } export async function listGitStashes(directory: string): Promise<{ stashes: GitStashEntry[] }> { @@ -755,7 +758,7 @@ export async function stashGitChanges(directory: string, options: { message?: st const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to stash changes'); } - return response.json(); + return completeStatusMutation(directory, response); } const postStashRef = async (directory: string, path: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> => { @@ -768,7 +771,7 @@ const postStashRef = async (directory: string, path: string, options: { ref: str const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || `Failed to ${path}`); } - return response.json(); + return completeStatusMutation(directory, response); }; export const applyGitStash = (directory: string, options: { ref: string }) => postStashRef(directory, 'stash/apply', options); @@ -785,7 +788,7 @@ export async function checkoutBranch(directory: string, branch: string): Promise const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to checkout branch'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function createBranch( @@ -802,7 +805,7 @@ export async function createBranch( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to create branch'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function renameBranch( @@ -819,7 +822,7 @@ export async function renameBranch( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to rename branch'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function getGitLog( @@ -1022,7 +1025,7 @@ export async function rebase( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to rebase'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function abortRebase(directory: string): Promise<{ success: boolean }> { @@ -1033,7 +1036,7 @@ export async function abortRebase(directory: string): Promise<{ success: boolean const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to abort rebase'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function merge( @@ -1049,7 +1052,7 @@ export async function merge( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to merge'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function checkoutCommit( @@ -1065,7 +1068,7 @@ export async function checkoutCommit( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to checkout commit'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function cherryPick( @@ -1081,7 +1084,7 @@ export async function cherryPick( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to cherry-pick'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function revertCommit( @@ -1097,7 +1100,7 @@ export async function revertCommit( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to revert commit'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function resetToCommit( @@ -1115,7 +1118,7 @@ export async function resetToCommit( const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to reset'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function abortMerge(directory: string): Promise<{ success: boolean }> { @@ -1126,7 +1129,7 @@ export async function abortMerge(directory: string): Promise<{ success: boolean const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to abort merge'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { @@ -1137,7 +1140,7 @@ export async function continueRebase(directory: string): Promise<{ success: bool const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to continue rebase'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> { @@ -1148,7 +1151,7 @@ export async function continueMerge(directory: string): Promise<{ success: boole const error = await response.json().catch(() => ({ error: response.statusText })); throw new Error(error.error || 'Failed to continue merge'); } - return response.json(); + return completeStatusMutation(directory, response); } export async function stash( diff --git a/packages/ui/src/lib/gitStatusInvalidation.ts b/packages/ui/src/lib/gitStatusInvalidation.ts new file mode 100644 index 00000000..1bd672ff --- /dev/null +++ b/packages/ui/src/lib/gitStatusInvalidation.ts @@ -0,0 +1,34 @@ +/** + * Minimal notification channel for git status invalidation. + * + * A runtime adapter that caches git status (currently only the HTTP adapter in + * `gitApiHttp.ts`) must call `notifyGitStatusInvalidated` whenever a successful + * status-affecting mutation invalidates its cache. `useGitStore` subscribes and + * bumps its per-directory status mutation revision so an immediate refresh + * cannot join an in-flight status request admitted before the mutation, and a + * stale response cannot commit over newer authoritative state. + * + * Runtime parity: the VS Code bridge adapter performs no client-side status + * caching (every `getGitStatus` is a fresh bridge request), so it has no cache + * to invalidate and does not emit this signal today. Any adapter that adds + * caching must emit on invalidation. + */ + +type GitStatusInvalidationListener = (directory: string) => void; + +const listeners = new Set(); + +export const subscribeGitStatusInvalidations = ( + listener: GitStatusInvalidationListener +): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const notifyGitStatusInvalidated = (directory: string): void => { + for (const listener of listeners) { + listener(directory); + } +}; diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index a578d61f..91387fff 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -132,10 +132,12 @@ Important properties: - `directories: Map` is the source of truth - loading state is per-directory, not global - `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers -- in-flight dedupe exists for status and `ensureAll()` +- in-flight dedupe exists for status and `ensureAll()`; status dedupe is scoped to the per-directory status mutation revision, so a refresh requested after a mutation never joins a pre-mutation in-flight request - runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions - status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations - status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes +- a successful status-affecting git mutation also advances that revision: the HTTP adapter's cache invalidation notifies the store through `lib/gitStatusInvalidation.ts` (the VS Code bridge adapter has no client-side status cache, so it emits nothing today) +- `fetchAll({ force: true })` forces the status fetch as well as the log refresh - branch persistence is versioned, bounded, runtime-scoped, and claims the ambiguous legacy cache once - diff data has per-directory and aggregate count/UTF-8-byte limits; oversized single entries are rejected @@ -255,6 +257,7 @@ Expected model: - `GitView` / `DiffView` ensure current-directory Git state when visible - explicit Git actions refresh status/branches/log as needed +- every status-affecting git mutation invalidates the HTTP adapter's status cache on its success path (failed mutations invalidate nothing), so the follow-up refresh is authoritative instead of the pre-mutation cache entry - a mounted file-mutating tool issues a one-shot Git refresh hint when it transitions from active to successfully finalized; remounting historical completed tools does not replay the hint - a successful dirty save from the in-app file editor issues a path-scoped Git refresh hint; clean autosave checks remain no-ops - refresh hints with authoritative file paths invalidate only those cached and currently rendered diffs before status refresh; pathless tools request status reconciliation without broadly remounting DiffView diff --git a/packages/ui/src/stores/useGitStore.test.ts b/packages/ui/src/stores/useGitStore.test.ts index 2512989a..15e22afd 100644 --- a/packages/ui/src/stores/useGitStore.test.ts +++ b/packages/ui/src/stores/useGitStore.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test } from 'bun:test'; import type { GitStatus } from '@/lib/api/types'; import { useGitStore } from './useGitStore'; import { getRuntimeKey } from '@/lib/runtime-switch'; +import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation'; type Deferred = { promise: Promise; @@ -126,6 +127,85 @@ describe('useGitStore', () => { expect(lightResult).toBe(fullResult); }); + test('deduplicates concurrent status requests when no mutation occurs', async () => { + setDirectoryStatus(createStatus()); + let statusCalls = 0; + const request = createDeferred(); + const git = createGitApi(() => { + statusCalls += 1; + return request.promise; + }); + + const first = useGitStore.getState().fetchStatus('/repo', git, { silent: true }); + const second = useGitStore.getState().fetchStatus('/repo', git, { silent: true }); + await Promise.resolve(); + + expect(statusCalls).toBe(1); + + request.resolve(createStatus()); + await Promise.all([first, second]); + expect(statusCalls).toBe(1); + }); + + test('a refresh after a mutation does not join the pre-mutation in-flight status request', async () => { + setDirectoryStatus(createStatus()); + const requests: Deferred[] = []; + let statusCalls = 0; + const git = createGitApi(() => { + statusCalls += 1; + const request = createDeferred(); + requests.push(request); + return request.promise; + }); + + const preMutation = useGitStore.getState().fetchStatus('/repo', git, { silent: true }); + await Promise.resolve(); + expect(statusCalls).toBe(1); + + // A successful git mutation invalidates the adapter status cache, which + // notifies the store that the in-flight request predates the mutation. + notifyGitStatusInvalidated('/repo'); + + const postMutation = useGitStore.getState().fetchStatus('/repo', git, { silent: true }); + await Promise.resolve(); + expect(statusCalls).toBe(2); + + requests[1].resolve({ ...createStatus(), current: 'feature' }); + await postMutation; + expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature'); + + // The late pre-mutation response cannot overwrite the newer authoritative one. + requests[0].resolve(createStatus()); + await preMutation; + expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature'); + }); + + test('fetchAll({ force: true }) forces a fresh status fetch past the in-flight dedup', async () => { + setDirectoryStatus(createStatus()); + const requests: Deferred[] = []; + let statusCalls = 0; + const git = createGitApi(() => { + statusCalls += 1; + const request = createDeferred(); + requests.push(request); + return request.promise; + }); + + const inFlight = useGitStore.getState().fetchStatus('/repo', git, { silent: true }); + await Promise.resolve(); + expect(statusCalls).toBe(1); + + const all = useGitStore.getState().fetchAll('/repo', git, { force: true }); + await Promise.resolve(); + expect(statusCalls).toBe(2); + + requests[1].resolve({ ...createStatus(), current: 'feature' }); + requests[0].resolve(createStatus()); + await Promise.allSettled([inFlight, all]); + + expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature'); + }); + test('does not let an older status fetch undo an optimistic mutation', async () => { const initial = createStatus(undefined, [{ path: 'src/index.ts', index: ' ', working_dir: 'M' }]); setDirectoryStatus(initial); diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index aec942ba..dbffb679 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -9,6 +9,7 @@ import type { } from '@/lib/api/types'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { getRuntimeKey } from '@/lib/runtime-switch'; +import { subscribeGitStatusInvalidations } from '@/lib/gitStatusInvalidation'; const LOG_STALE_THRESHOLD = 10000; const REPO_CHECK_STALE_THRESHOLD = 60_000; @@ -57,7 +58,7 @@ interface GitStore { setActiveDirectory: (directory: string | null) => void; getDirectoryState: (directory: string) => DirectoryGitState | null; - fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light' }) => Promise; + fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light'; force?: boolean }) => Promise; fetchBranches: (directory: string, git: GitAPI) => Promise; fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise; fetchIdentity: (directory: string, git: GitAPI) => Promise; @@ -99,7 +100,7 @@ interface GitAPI { const inFlightDiffFetchesByDirectory = new Map>(); const diffFetchGenerationByDirectory = new Map(); -const inFlightStatusFetches = new Map>(); +const inFlightStatusFetches = new Map; statusMutationRevision: number }>(); const inFlightEnsureAllByDirectory = new Map>(); const requestGenerationByChannel = new Map(); const statusMutationRevisionByDirectory = new Map(); @@ -150,6 +151,18 @@ const bumpStatusMutationRevision = (runtimeKey: string, directory: string): void statusMutationRevisionByDirectory.set(key, (statusMutationRevisionByDirectory.get(key) ?? 0) + 1); }; +const getStatusMutationRevision = (runtimeKey: string, directory: string): number => + statusMutationRevisionByDirectory.get(runtimeDirectoryKey(runtimeKey, directory)) ?? 0; + +// A successful status-affecting git mutation invalidates the runtime adapter's +// status cache (see lib/gitStatusInvalidation.ts). Bump the per-directory +// mutation revision so a status request admitted before the mutation can +// neither be joined by a post-mutation refresh nor commit its stale payload +// over the refreshed state. +subscribeGitStatusInvalidations((directory) => { + bumpStatusMutationRevision(getRuntimeKey(), directory); +}); + const getDiffFetchGeneration = (directory: string): number => diffFetchGenerationByDirectory.get(runtimeDirectoryKey(getRuntimeKey(), directory)) ?? 0; @@ -590,10 +603,16 @@ export const useGitStore = create()( const statusFetchMode: GitStatusFetchMode = options.mode ?? 'full'; const runtimeKey = getRuntimeKey(); const statusFetchKey = getStatusFetchKey(runtimeKey, directory, statusFetchMode); - const existing = inFlightStatusFetches.get(statusFetchKey) - ?? (statusFetchMode === 'light' ? inFlightStatusFetches.get(getStatusFetchKey(runtimeKey, directory, 'full')) : undefined); - if (existing) { - return existing; + const statusMutationRevision = getStatusMutationRevision(runtimeKey, directory); + if (!options.force) { + const existing = inFlightStatusFetches.get(statusFetchKey) + ?? (statusFetchMode === 'light' ? inFlightStatusFetches.get(getStatusFetchKey(runtimeKey, directory, 'full')) : undefined); + // Join an in-flight request only when it was admitted at the current + // mutation revision; a request that predates a mutation must not + // satisfy the post-mutation refresh. + if (existing && existing.statusMutationRevision === statusMutationRevision) { + return existing.promise; + } } const token = startRequest(directory, 'status', true); @@ -727,12 +746,12 @@ export const useGitStore = create()( return statusChanged; })(); - inFlightStatusFetches.set(statusFetchKey, fetchPromise); + inFlightStatusFetches.set(statusFetchKey, { promise: fetchPromise, statusMutationRevision }); try { return await fetchPromise; } finally { - if (inFlightStatusFetches.get(statusFetchKey) === fetchPromise) { + if (inFlightStatusFetches.get(statusFetchKey)?.promise === fetchPromise) { inFlightStatusFetches.delete(statusFetchKey); } } @@ -936,8 +955,11 @@ export const useGitStore = create()( const { force = false, silentIfCached = false } = options; const now = Date.now(); + // `force` applies to status as well as log: a forced refresh must not + // resolve from an in-flight status request admitted earlier. await get().fetchStatus(directory, git, { silent: silentIfCached && Boolean(dirState?.status), + force, }); const updatedDirState = get().directories.get(directory); From 3edc765ddd075e4b32e796129d43783f526d201b Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:14:41 +0100 Subject: [PATCH 32/66] fix: restore fast-path comment indentation, correct reproduce script claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address bot review findings: - Indent the three 'Fast path' comment blocks to match surrounding code - Reproduce script header no longer claims the fast path catches brew paths with a minimal PATH — the hardcoded fallbacks do that; the fast path only sees binaries already in the inherited PATH --- .../web/server/lib/opencode/env-runtime.js | 24 +++++++++---------- scripts/reproduce-issue-1720.mjs | 7 +++--- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/web/server/lib/opencode/env-runtime.js b/packages/web/server/lib/opencode/env-runtime.js index 34043275..c14557bd 100644 --- a/packages/web/server/lib/opencode/env-runtime.js +++ b/packages/web/server/lib/opencode/env-runtime.js @@ -455,10 +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 -// brew paths when the Electron login shell env merge already augmented PATH -// or when /bin/sh has a broader default PATH than the process. + // 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'], { @@ -548,10 +548,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 -// brew paths when the Electron login shell env merge already augmented PATH -// or when /bin/sh has a broader default PATH than the process. + // 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'], { @@ -653,10 +653,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 -// brew paths when the Electron login shell env merge already augmented PATH -// or when /bin/sh has a broader default PATH than the process. + // 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 7fff57a1..40be62bb 100644 --- a/scripts/reproduce-issue-1720.mjs +++ b/scripts/reproduce-issue-1720.mjs @@ -25,9 +25,10 @@ * 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 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. + * out and PATH stays minimal. The hardcoded fallback paths catch standard + * brew locations, the fast-path (Step 3b) catches binaries already visible + * in the inherited PATH without sourcing shell config, and all shell probes + * now have a 5s timeout to prevent blocking startup indefinitely. */ import fs from 'node:fs'; From 3532cef049efca7b03ec219f25d715b5e3304d5f Mon Sep 17 00:00:00 2001 From: Mayuresh Kadu <23300+mskadu@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:16:18 +0100 Subject: [PATCH 33/66] changelog: fix 'catch catches' phrasing --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2670c9c..c0fcdfed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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). +- 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` probe catches brew paths without sourcing shell config, and brew path ordering is consistent across runtimes (issue #1720). - Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117). ## [1.19.0] - 2026-08-19 From 9b2d02c3a725f7fc55ffb9a8db9e7d3f9ced02fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 27 Aug 2026 21:36:00 +0200 Subject: [PATCH 34/66] fix(chat): preserve explicit scroll release intent --- .../src/hooks/useChatTimelineScroll.test.ts | 41 ++++ .../ui/src/hooks/useChatTimelineScroll.ts | 192 ++++++++++++++++-- 2 files changed, 214 insertions(+), 19 deletions(-) create mode 100644 packages/ui/src/hooks/useChatTimelineScroll.test.ts diff --git a/packages/ui/src/hooks/useChatTimelineScroll.test.ts b/packages/ui/src/hooks/useChatTimelineScroll.test.ts new file mode 100644 index 00000000..80f5ee19 --- /dev/null +++ b/packages/ui/src/hooks/useChatTimelineScroll.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test'; + +import { + isAutoFollowReleaseKey, + shouldDelayAutoFollowRepin, + shouldRepinReleasedAutoFollow, +} from './useChatTimelineScroll'; + +const keyEvent = ( + key: string, + modifiers: Partial> = {}, +): Pick => ({ + altKey: false, + ctrlKey: false, + key, + metaKey: false, + shiftKey: false, + ...modifiers, +}); + +describe('chat timeline scroll intent', () => { + test('recognizes upward navigation without stealing modified shortcuts', () => { + expect(isAutoFollowReleaseKey(keyEvent('ArrowUp'))).toBe(true); + expect(isAutoFollowReleaseKey(keyEvent('PageUp'))).toBe(true); + expect(isAutoFollowReleaseKey(keyEvent('Home'))).toBe(true); + expect(isAutoFollowReleaseKey(keyEvent(' ', { shiftKey: true }))).toBe(true); + expect(isAutoFollowReleaseKey(keyEvent('Pause'))).toBe(true); + expect(isAutoFollowReleaseKey(keyEvent('Break'))).toBe(true); + expect(isAutoFollowReleaseKey(keyEvent('ArrowUp', { ctrlKey: true }))).toBe(false); + expect(isAutoFollowReleaseKey(keyEvent(' ', { shiftKey: false }))).toBe(false); + }); + + test('delays re-pinning only for downward or exact-bottom movement', () => { + expect(shouldRepinReleasedAutoFollow(false, false)).toBe(false); + expect(shouldRepinReleasedAutoFollow(true, false)).toBe(true); + expect(shouldRepinReleasedAutoFollow(false, true)).toBe(true); + expect(shouldDelayAutoFollowRepin(null, 100, 1200)).toBe(false); + expect(shouldDelayAutoFollowRepin(100, 500, 1200)).toBe(true); + expect(shouldDelayAutoFollowRepin(100, 1300, 1200)).toBe(false); + }); +}); diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 46da713f..b82a0a40 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -14,6 +14,46 @@ import { type TimelineScrollMode, } from '@/components/chat/lib/scroll/timelineScrollAnchoring'; +export const isAutoFollowReleaseKey = ( + event: Pick, +): boolean => { + if (event.altKey || event.ctrlKey || event.metaKey) return false; + if (event.key === ' ' && event.shiftKey) return true; + return event.key === 'ArrowUp' + || event.key === 'PageUp' + || event.key === 'Home' + || event.key === 'Pause' + || event.key === 'Break'; +}; + +const nestedScrollableTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => { + if (!(target instanceof Element)) return null; + const nested = target.closest('[data-scrollable]'); + if (!(nested instanceof HTMLElement) || nested === root) return null; + return nested; +}; + +export const isMiddleButtonAutoScrollIntent = ( + root: HTMLElement, + event: Pick, +): boolean => event.button === 1 && !nestedScrollableTarget(root, event.target); + +export const shouldRepinReleasedAutoFollow = ( + scrollingDown: boolean, + atTrueBottom: boolean, +): boolean => scrollingDown || atTrueBottom; + +const nestedScrollableCanConsumeUp = (root: HTMLElement, target: EventTarget | null): boolean => { + const nested = nestedScrollableTarget(root, target); + return nested !== null && nested.scrollTop > 0; +}; + +export const shouldDelayAutoFollowRepin = ( + releasedAt: number | null, + currentTime: number, + graceMs: number, +): boolean => releasedAt !== null && currentTime - releasedAt < graceMs; + // ────────────────────────────────────────────────────────────────────────── // Chat timeline scroll ownership. // @@ -36,8 +76,8 @@ import { // gesture bumps a generation counter; any in-flight automatic movement compares // its captured generation against the current one and aborts if they differ. // That comparison replaces the timer windows the previous implementation needed -// to tell its own writes apart from the user's, which is why there are no -// guard/settle/entry-stick timers here. +// to tell its own writes apart from the user's; the only timer here is the short +// grace period for an explicit release near the live edge. // ────────────────────────────────────────────────────────────────────────── // The subset of the list ref this hook drives. Declared structurally so the @@ -101,6 +141,9 @@ export interface UseChatTimelineScrollResult { // Hiding is always immediate. const SHOW_SCROLL_BUTTON_DELAY_MS = 150; const SAVE_DEBOUNCE_MS = 150; +const TOUCH_FINGER_DOWN_THRESHOLD_PX = 2; +const AUTO_MATCH_TOLERANCE_PX = 2; +const REPIN_GRACE_AFTER_RELEASE_MS = 1200; // The anchor scroll is animated; `scrollend` is the authoritative completion // signal, and this bounds the wait for browsers that drop it. const ANCHOR_SETTLE_FALLBACK_MS = 750; @@ -163,6 +206,10 @@ export const useChatTimelineScroll = ({ currentSessionIdRef.current = currentSessionId; const currentSessionKeyRef = React.useRef(currentSessionKey); currentSessionKeyRef.current = currentSessionKey; + const lastScrollOffsetRef = React.useRef(0); + const lastScrollDirectionDownRef = React.useRef(false); + const delayedRepinTimerRef = React.useRef | null>(null); + const lastExplicitReleaseAtRef = React.useRef(null); const updateViewportAnchor = useViewportStore((state) => state.updateViewportAnchor); @@ -173,6 +220,20 @@ export const useChatTimelineScroll = ({ } }, []); + const clearDelayedRepin = React.useCallback(() => { + if (delayedRepinTimerRef.current !== null) { + clearTimeout(delayedRepinTimerRef.current); + delayedRepinTimerRef.current = null; + } + }, []); + + const recordScrollDirection = React.useCallback((scrollOffset: number) => { + if (scrollOffset === lastScrollOffsetRef.current) return; + const previousOffset = lastScrollOffsetRef.current; + lastScrollOffsetRef.current = scrollOffset; + lastScrollDirectionDownRef.current = scrollOffset > previousOffset + 0.5; + }, []); + const hideScrollButton = React.useCallback(() => { cancelShowButtonTimer(); setShowScrollButton(false); @@ -204,9 +265,12 @@ export const useChatTimelineScroll = ({ // in. The anchored END SPACE stays — collapsing it mid-gesture clamps the // viewport back to the end — only the anchor machinery is disarmed. const onManualNavigation = React.useCallback(() => { + clearDelayedRepin(); + lastExplicitReleaseAtRef.current = null; userGenerationRef.current += 1; modeRef.current = 'free-scrolling'; liveFollowGenerationRef.current = null; + userOwnsScrollRef.current = true; setUserOwnsScroll(true); // The end may already have been left by our own movement, in which // case no further at-end transition will fire — and while an animated @@ -217,6 +281,7 @@ export const useChatTimelineScroll = ({ const atEndNow = (listState ? resolveTimelineIsAtEnd(listState) : undefined) ?? isAtEndRef.current; isAtEndRef.current = atEndNow; if (!atEndNow) { + setIsPinned(false); cancelShowButtonTimer(); setShowScrollButton(true); } @@ -230,7 +295,12 @@ export const useChatTimelineScroll = ({ cancelAnimationFrame(anchorRestoreFrameRef.current); anchorRestoreFrameRef.current = null; } - }, [cancelShowButtonTimer]); + }, [cancelShowButtonTimer, clearDelayedRepin]); + + const releaseFromUserIntent = React.useCallback(() => { + onManualNavigation(); + lastExplicitReleaseAtRef.current = performance.now(); + }, [onManualNavigation]); const isLiveFollowActive = React.useCallback(() => ( liveFollowGenerationRef.current === userGenerationRef.current @@ -292,8 +362,11 @@ export const useChatTimelineScroll = ({ }, []); const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => { + clearDelayedRepin(); + lastExplicitReleaseAtRef.current = null; isAtEndRef.current = true; setIsPinned(true); + userOwnsScrollRef.current = false; setUserOwnsScroll(false); modeRef.current = 'following-end'; // Returning to the end is an explicit opt back IN to live follow. @@ -316,7 +389,19 @@ export const useChatTimelineScroll = ({ void listRef.current?.scrollToEnd({ animated: false }); }, delay)); } - }, [clearAnchor, clearGoToBottomReasserts, hideScrollButton]); + }, [clearAnchor, clearDelayedRepin, clearGoToBottomReasserts, hideScrollButton]); + + const scheduleRepinAfterGrace = React.useCallback((delayMs: number) => { + if (delayedRepinTimerRef.current !== null) return; + const generation = userGenerationRef.current; + delayedRepinTimerRef.current = setTimeout(() => { + delayedRepinTimerRef.current = null; + if (userGenerationRef.current !== generation || modeRef.current !== 'free-scrolling') return; + const state = listRef.current?.getState(); + if (!state || resolveTimelineIsAtEnd(state) !== true) return; + goToBottom('instant'); + }, Math.max(0, delayMs)); + }, [goToBottom]); // Sending arms the anchor. The message id is not known here (the optimistic // row is created by the store), so the next new user message id claims it. @@ -328,8 +413,11 @@ export const useChatTimelineScroll = ({ const anchorPositionInstantRef = React.useRef(false); const scrollToBottomOnSend = React.useCallback(() => { + clearDelayedRepin(); + lastExplicitReleaseAtRef.current = null; anchorPositionInstantRef.current = !isAtEndRef.current; isAtEndRef.current = true; + userOwnsScrollRef.current = false; setUserOwnsScroll(false); modeRef.current = 'anchoring-new-turn'; liveFollowGenerationRef.current = userGenerationRef.current; @@ -343,7 +431,7 @@ export const useChatTimelineScroll = ({ settledAnchorRef.current = null; activeAnchorIndexRef.current = null; hideScrollButton(); - }, [hideScrollButton]); + }, [clearDelayedRepin, hideScrollButton]); // Claim the anchor as soon as the sent row exists in the timeline. The // comparison is against the baseline captured when the send armed the @@ -366,7 +454,10 @@ export const useChatTimelineScroll = ({ // Entering a session always returns to the live edge. Late async growth // is handled by the list staying at the end, not by a timed hold. + clearDelayedRepin(); + lastExplicitReleaseAtRef.current = null; isAtEndRef.current = true; + userOwnsScrollRef.current = false; setUserOwnsScroll(false); modeRef.current = 'following-end'; liveFollowGenerationRef.current = userGenerationRef.current; @@ -374,7 +465,7 @@ export const useChatTimelineScroll = ({ hideScrollButton(); void listRef.current?.scrollToEnd({ animated: false }); return false; - }, [clearAnchor, hideScrollButton]); + }, [clearAnchor, clearDelayedRepin, hideScrollButton]); // ── list callbacks ────────────────────────────────────────────────────── const registerList = React.useCallback((list: TimelineListHandle | null) => { @@ -385,6 +476,9 @@ export const useChatTimelineScroll = ({ }, []); const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => { + const listState = listRef.current?.getState(); + if (listState) recordScrollDirection(listState.scroll); + // While an automatic movement owns the viewport, leaving the end is our // own doing (the anchored turn parks mid-timeline, the glide trails its // target between corrections) — not a reason to offer the pill. Only a @@ -393,6 +487,35 @@ export const useChatTimelineScroll = ({ hideScrollButton(); return; } + + if (isAtEnd && modeRef.current === 'free-scrolling') { + const releasedAt = lastExplicitReleaseAtRef.current; + const scrollNode = listRef.current?.getScrollableNode() ?? scrollRef.current; + const atTrueBottom = scrollNode !== null + && scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= AUTO_MATCH_TOLERANCE_PX; + + isAtEndRef.current = true; + setIsPinned(true); + if (releasedAt !== null) { + if (!shouldRepinReleasedAutoFollow(lastScrollDirectionDownRef.current, atTrueBottom)) { + clearDelayedRepin(); + hideScrollButton(); + queueSave(); + return; + } + const currentTime = performance.now(); + if (shouldDelayAutoFollowRepin(releasedAt, currentTime, REPIN_GRACE_AFTER_RELEASE_MS)) { + scheduleRepinAfterGrace(REPIN_GRACE_AFTER_RELEASE_MS - (currentTime - releasedAt)); + hideScrollButton(); + queueSave(); + return; + } + lastExplicitReleaseAtRef.current = null; + } + clearDelayedRepin(); + } + + if (!isAtEnd) clearDelayedRepin(); if (isAtEndRef.current === isAtEnd) return; isAtEndRef.current = isAtEnd; setIsPinned(isAtEnd); @@ -401,6 +524,7 @@ export const useChatTimelineScroll = ({ modeRef.current = 'following-end'; } liveFollowGenerationRef.current = userGenerationRef.current; + userOwnsScrollRef.current = false; setUserOwnsScroll(false); hideScrollButton(); } else { @@ -409,7 +533,7 @@ export const useChatTimelineScroll = ({ scheduleShowScrollButton(); } queueSave(); - }, [hideScrollButton, isLiveFollowActive, queueSave, scheduleShowScrollButton]); + }, [clearDelayedRepin, hideScrollButton, isLiveFollowActive, queueSave, recordScrollDirection, scheduleRepinAfterGrace, scheduleShowScrollButton]); // Park the anchored row near the top once the list has measured it. const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => { @@ -716,11 +840,14 @@ export const useChatTimelineScroll = ({ }, [scrollNode]); // ── gesture opt-out ───────────────────────────────────────────────────── - const onManualNavigationRef = React.useRef(onManualNavigation); - onManualNavigationRef.current = onManualNavigation; + const releaseFromUserIntentRef = React.useRef(releaseFromUserIntent); + releaseFromUserIntentRef.current = releaseFromUserIntent; React.useEffect(() => { if (!scrollNode) return; + const initialScroll = listRef.current?.getState().scroll ?? scrollNode.scrollTop; + lastScrollOffsetRef.current = initialScroll; + lastScrollDirectionDownRef.current = false; // A gesture is meaningful when the viewport can move up AT ALL: // either the real rows overflow the viewport, or there is scrolled @@ -736,11 +863,11 @@ export const useChatTimelineScroll = ({ return realContentOverflowsViewport(list); }; const gesture = () => { - onManualNavigationRef.current(); + releaseFromUserIntentRef.current(); }; const handleWheel = (event: WheelEvent) => { // Scrolling toward the end is not opting out of follow. - if (event.deltaY < 0 && canScrollUp()) gesture(); + if (event.deltaY < 0 && !nestedScrollableCanConsumeUp(scrollNode, event.target) && canScrollUp()) gesture(); }; // Touch mirrors wheel by finger direction, not by having already left // the end: while a stream keeps re-pinning the viewport, waiting for @@ -757,25 +884,44 @@ export const useChatTimelineScroll = ({ touchLastY = y; if (y === null) return; // A downward finger drags the content up — the touch wheel-up. - const draggedUp = lastY !== null && y > lastY; - if ((draggedUp || !isAtEndRef.current) && canScrollUp()) gesture(); + const draggedUp = lastY !== null && y - lastY > TOUCH_FINGER_DOWN_THRESHOLD_PX; + if ((draggedUp || !isAtEndRef.current) + && !nestedScrollableCanConsumeUp(scrollNode, event.target) + && canScrollUp()) gesture(); }; const handleTouchEnd = () => { touchLastY = null; }; const handlePointerDown = (event: PointerEvent) => { + if (event.button === 1) { + if (isMiddleButtonAutoScrollIntent(scrollNode, event) && canScrollUp()) gesture(); + return; + } // The scrollbar track is the scroll node itself; a tap on a row // only breaks follow when the viewport already left the end. if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture(); }; const handleKeyDown = (event: KeyboardEvent) => { - if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && canScrollUp()) { - gesture(); - } + if (isAutoFollowReleaseKey(event) && canScrollUp()) gesture(); }; const handleScroll = () => { + const scrollOffset = listRef.current?.getState().scroll ?? scrollNode.scrollTop; + const previousOffset = lastScrollOffsetRef.current; + recordScrollDirection(scrollOffset); + if (scrollOffset !== previousOffset && scrollOffset <= previousOffset + 0.5) { + clearDelayedRepin(); + } queueSave(); }; + const handleMouseDown = (event: MouseEvent) => { + if ('PointerEvent' in globalThis) return; + if (isMiddleButtonAutoScrollIntent(scrollNode, event) && canScrollUp()) gesture(); + }; + const handleOverlayScrollbarPointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Element) || !target.closest('[data-overlay-scrollbar-thumb]')) return; + if (canScrollUp()) gesture(); + }; scrollNode.addEventListener('wheel', handleWheel, { passive: true }); scrollNode.addEventListener('touchstart', handleTouchStart, { passive: true }); @@ -783,8 +929,10 @@ export const useChatTimelineScroll = ({ scrollNode.addEventListener('touchend', handleTouchEnd, { passive: true }); scrollNode.addEventListener('touchcancel', handleTouchEnd, { passive: true }); scrollNode.addEventListener('pointerdown', handlePointerDown, { passive: true }); + scrollNode.addEventListener('mousedown', handleMouseDown, { passive: true }); scrollNode.addEventListener('keydown', handleKeyDown); scrollNode.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('pointerdown', handleOverlayScrollbarPointerDown, true); return () => { scrollNode.removeEventListener('wheel', handleWheel); @@ -793,10 +941,12 @@ export const useChatTimelineScroll = ({ scrollNode.removeEventListener('touchend', handleTouchEnd); scrollNode.removeEventListener('touchcancel', handleTouchEnd); scrollNode.removeEventListener('pointerdown', handlePointerDown); + scrollNode.removeEventListener('mousedown', handleMouseDown); scrollNode.removeEventListener('keydown', handleKeyDown); scrollNode.removeEventListener('scroll', handleScroll); + window.removeEventListener('pointerdown', handleOverlayScrollbarPointerDown, true); }; - }, [queueSave, realContentOverflowsViewport, scrollNode]); + }, [clearDelayedRepin, queueSave, realContentOverflowsViewport, recordScrollDirection, scrollNode]); // ── session lifecycle ─────────────────────────────────────────────────── const lastSessionKeyRef = React.useRef(null); @@ -808,13 +958,16 @@ export const useChatTimelineScroll = ({ MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId); // Persist the outgoing session's position before the new one takes over. flushSave(); + clearDelayedRepin(); + lastExplicitReleaseAtRef.current = null; isAtEndRef.current = true; + userOwnsScrollRef.current = false; setUserOwnsScroll(false); modeRef.current = 'following-end'; liveFollowGenerationRef.current = userGenerationRef.current; clearAnchor(); hideScrollButton(); - }, [clearAnchor, currentSessionId, currentSessionKey, flushSave, hideScrollButton]); + }, [clearAnchor, clearDelayedRepin, currentSessionId, currentSessionKey, flushSave, hideScrollButton]); // Suppress the overlay scrollbar thumb while automatic movement owns the // scroll position, so it does not jump on each correction. @@ -824,12 +977,13 @@ export const useChatTimelineScroll = ({ React.useEffect(() => () => { cancelShowButtonTimer(); + clearDelayedRepin(); if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current); if (anchorRestoreFrameRef.current !== null) cancelAnimationFrame(anchorRestoreFrameRef.current); const frames = dataChangeFramesRef.current; if (frames.first !== null) cancelAnimationFrame(frames.first); if (frames.second !== null) cancelAnimationFrame(frames.second); - }, [cancelShowButtonTimer]); + }, [cancelShowButtonTimer, clearDelayedRepin]); // ── active-turn spy ───────────────────────────────────────────────────── // Reads turn positions straight from the DOM, so it is unaffected by which From 03a76c9de061dedf6921990e7ae0a20d6e9d00bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 27 Aug 2026 21:51:10 +0200 Subject: [PATCH 35/66] fix(chat): rearm follow after returning to end --- .../ui/src/hooks/useChatTimelineScroll.ts | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index b82a0a40..d3b20c83 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -272,6 +272,7 @@ export const useChatTimelineScroll = ({ liveFollowGenerationRef.current = null; userOwnsScrollRef.current = true; setUserOwnsScroll(true); + setIsPinned(false); // The end may already have been left by our own movement, in which // case no further at-end transition will fire — and while an animated // follow glide trails the live edge, isAtEndRef is deliberately not @@ -399,6 +400,10 @@ export const useChatTimelineScroll = ({ if (userGenerationRef.current !== generation || modeRef.current !== 'free-scrolling') return; const state = listRef.current?.getState(); if (!state || resolveTimelineIsAtEnd(state) !== true) return; + const scrollNode = listRef.current?.getScrollableNode() ?? scrollRef.current; + const atTrueBottom = scrollNode !== null + && scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= AUTO_MATCH_TOLERANCE_PX; + if (!shouldRepinReleasedAutoFollow(lastScrollDirectionDownRef.current, atTrueBottom)) return; goToBottom('instant'); }, Math.max(0, delayMs)); }, [goToBottom]); @@ -495,14 +500,15 @@ export const useChatTimelineScroll = ({ && scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= AUTO_MATCH_TOLERANCE_PX; isAtEndRef.current = true; - setIsPinned(true); + if (!shouldRepinReleasedAutoFollow(lastScrollDirectionDownRef.current, atTrueBottom)) { + clearDelayedRepin(); + setIsPinned(false); + hideScrollButton(); + queueSave(); + return; + } + setIsPinned(false); if (releasedAt !== null) { - if (!shouldRepinReleasedAutoFollow(lastScrollDirectionDownRef.current, atTrueBottom)) { - clearDelayedRepin(); - hideScrollButton(); - queueSave(); - return; - } const currentTime = performance.now(); if (shouldDelayAutoFollowRepin(releasedAt, currentTime, REPIN_GRACE_AFTER_RELEASE_MS)) { scheduleRepinAfterGrace(REPIN_GRACE_AFTER_RELEASE_MS - (currentTime - releasedAt)); @@ -513,6 +519,8 @@ export const useChatTimelineScroll = ({ lastExplicitReleaseAtRef.current = null; } clearDelayedRepin(); + goToBottom('instant'); + return; } if (!isAtEnd) clearDelayedRepin(); @@ -533,7 +541,7 @@ export const useChatTimelineScroll = ({ scheduleShowScrollButton(); } queueSave(); - }, [clearDelayedRepin, hideScrollButton, isLiveFollowActive, queueSave, recordScrollDirection, scheduleRepinAfterGrace, scheduleShowScrollButton]); + }, [clearDelayedRepin, goToBottom, hideScrollButton, isLiveFollowActive, queueSave, recordScrollDirection, scheduleRepinAfterGrace, scheduleShowScrollButton]); // Park the anchored row near the top once the list has measured it. const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => { @@ -911,6 +919,10 @@ export const useChatTimelineScroll = ({ if (scrollOffset !== previousOffset && scrollOffset <= previousOffset + 0.5) { clearDelayedRepin(); } + const state = listRef.current?.getState(); + if (modeRef.current === 'free-scrolling' && resolveTimelineIsAtEnd(state) === true) { + onIsAtEndChange(true); + } queueSave(); }; const handleMouseDown = (event: MouseEvent) => { @@ -946,7 +958,7 @@ export const useChatTimelineScroll = ({ scrollNode.removeEventListener('scroll', handleScroll); window.removeEventListener('pointerdown', handleOverlayScrollbarPointerDown, true); }; - }, [clearDelayedRepin, queueSave, realContentOverflowsViewport, recordScrollDirection, scrollNode]); + }, [clearDelayedRepin, onIsAtEndChange, queueSave, realContentOverflowsViewport, recordScrollDirection, scrollNode]); // ── session lifecycle ─────────────────────────────────────────────────── const lastSessionKeyRef = React.useRef(null); From 20cda28cac98429788143542b0c2e7e09745bdf1 Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Fri, 28 Aug 2026 18:45:40 +0300 Subject: [PATCH 36/66] fix(opencode): name the release when upgrading OpenCode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since opencode 1.18.x, `POST /global/upgrade` requires a `target` semver in the body. OpenChamber sent an empty object, so every "Update OpenCode" click came back 400. The rejection arrives as `{name, data:{message}}`, which has no `error` field, so the user was left with the bare status text: "Bad Request". Resolve the target from the latest release — the same lookup the upgrade prompt already uses to decide there is anything to offer — and fail with an explicit code when it cannot be resolved, rather than sending a body opencode is guaranteed to reject. Read the upstream rejection message so a refused upgrade explains itself. The VS Code extension carries its own copy of this flow and had the same two defects; both are fixed there. fixes #3121 --- CHANGELOG.md | 1 + packages/vscode/CHANGELOG.md | 1 + .../src/opencode-upgrade-runtime.test.ts | 58 +++++++++- .../vscode/src/opencode-upgrade-runtime.ts | 38 +++++- .../lib/opencode/routes-upgrade.test.js | 108 ++++++++++++++++-- packages/web/server/lib/opencode/routes.js | 58 +++++++++- 6 files changed, 242 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb4cc429..e38ff2ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. - Git/Worktrees: session menus can now move an idle session and its sub-sessions into an existing worktree, and opening the target list discovers worktrees created outside OpenChamber without a restart (thanks to @mattv8). - Files: opening a file over 5,000 lines is no longer blocked — the line-count guard now allows up to 20,000 lines, letting large files reach the virtualized full-file preview instead of being rejected at the open step (thanks @gaojunran). - Usage: GitHub Copilot now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss). +- Updates: "Update OpenCode" no longer fails with a bare "Bad Request". OpenChamber now names the release to install, which recent OpenCode versions require, and when an update is refused the reason from OpenCode is shown instead of the HTTP status. This affects setups where OpenChamber runs an OpenCode you installed yourself; the desktop app bundles OpenCode and never offered the button. - Settings: fixed the Cloudflare Tunnel download link shown when cloudflared is not installed (thanks to @AyoubAchour). - Git: picking a remote branch such as `origin/main` in the branch selector now switches you to that branch instead of leaving the repository on a detached `HEAD` with no branch name. - Desktop: "Restart to Update" no longer looks dead when the update cannot be installed — the update window now shows the reason, including when the running copy was not installed from an official signed release, and the button stays available to retry. diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 2d079e7a..2d2696df 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -3,6 +3,7 @@ - Picking a remote branch such as `origin/main` in the Git branch selector now switches you to that branch instead of leaving the repository on a detached `HEAD` with no branch name. - GitHub Copilot usage now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss). - The context usage readout now reports the session cost including everything its subagents spent, matching the work status panel instead of showing a lower figure. +- Updating OpenCode no longer fails with a bare "Bad Request": the extension names the release to install, which recent OpenCode versions require, and shows OpenCode's own reason when an update is refused. ## [1.21.0] - 2026-08-26 diff --git a/packages/vscode/src/opencode-upgrade-runtime.test.ts b/packages/vscode/src/opencode-upgrade-runtime.test.ts index 6d872f52..5f1be9f2 100644 --- a/packages/vscode/src/opencode-upgrade-runtime.test.ts +++ b/packages/vscode/src/opencode-upgrade-runtime.test.ts @@ -75,16 +75,72 @@ describe('VS Code OpenCode upgrades', () => { assert.equal((request?.headers as Record).Authorization, 'Basic test'); }); + test('names the latest release when the caller sends no target', async () => { + const { manager } = createManager(); + let upgradeBody: unknown; + // SAFETY: the stub answers the only two call shapes this test exercises — + // a URL string and an init bag — which is all `fetch` is used with here. + globalThis.fetch = (async (input: Parameters[0], init?: RequestInit) => { + const url = String(input); + if (url.includes('registry.npmjs.org')) return new Response(JSON.stringify({ version: '1.18.23' })); + if (url.includes('api.github.com')) return new Response(JSON.stringify({ tag_name: 'v1.18.23' })); + upgradeBody = JSON.parse(String(init?.body)); + return new Response(JSON.stringify({ success: true, version: '1.18.23' })); + }) as typeof fetch; + + assert.equal((await upgradeManagedOpenCode(manager)).status, 200); + assert.deepEqual(upgradeBody, { target: '1.18.23' }); + }); + + test('fails without calling the updater when the latest release cannot be resolved', async () => { + const { manager, getRestartCount } = createManager(); + // SAFETY: the stub answers the only call shape this test exercises — a URL + // string — and fails loudly if the updater is reached at all. + globalThis.fetch = (async (input: Parameters[0]) => { + if (String(input).endsWith('/global/upgrade')) throw new Error('the updater must not be called without a target'); + return new Response('nope', { status: 503 }); + }) as typeof fetch; + + const result = await upgradeManagedOpenCode(manager); + assert.equal(result.status, 502); + assert.equal(result.body.code, 'OPENCODE_UPGRADE_TARGET_UNRESOLVED'); + assert.equal(getRestartCount(), 0); + }); + + test('surfaces the rejection OpenCode reported instead of the bare HTTP status', async () => { + const { manager } = createManager(); + // SAFETY: the stub ignores its arguments and answers every call with the + // rejection shape under test, so no call signature is misrepresented. + globalThis.fetch = (async () => new Response( + JSON.stringify({ name: 'BadRequest', data: { message: 'Expected a semantic version', kind: 'Payload' } }), + { status: 400 }, + )) as typeof fetch; + + assert.deepEqual(await upgradeManagedOpenCode(manager, '1.18.9'), { + status: 400, + body: { success: false, error: 'Expected a semantic version' }, + }); + }); + test('serializes concurrent managed upgrades', async () => { const { manager } = createManager(); let release: (response: Response) => void = () => {}; - globalThis.fetch = (() => new Promise((resolve) => { release = resolve; })) as typeof fetch; + let upgradeCalled: () => void = () => {}; + const upgradeReached = new Promise((resolve) => { upgradeCalled = resolve; }); + globalThis.fetch = ((input: Parameters[0]) => { + if (!String(input).endsWith('/global/upgrade')) { + return Promise.resolve(new Response(JSON.stringify({ version: '1.18.9' }))); + } + upgradeCalled(); + return new Promise((resolve) => { release = resolve; }); + }) as typeof fetch; const first = upgradeManagedOpenCode(manager); const second = await upgradeManagedOpenCode(manager); assert.equal(second.status, 409); assert.equal(second.body.code, 'OPENCODE_UPGRADE_IN_PROGRESS'); + await upgradeReached; release(new Response(JSON.stringify({ success: true }))); assert.equal((await first).status, 200); }); diff --git a/packages/vscode/src/opencode-upgrade-runtime.ts b/packages/vscode/src/opencode-upgrade-runtime.ts index 883957f1..7ea0433e 100644 --- a/packages/vscode/src/opencode-upgrade-runtime.ts +++ b/packages/vscode/src/opencode-upgrade-runtime.ts @@ -77,6 +77,19 @@ const fetchLatestVersion = async (): Promise => { return versions.sort((left, right) => compareVersions(right, left))[0]; }; +// OpenCode reports a rejected upgrade as `{ name, data: { message, kind } }`, +// which carries no `error` field. Reading only `error` left the user with the +// bare HTTP status text ("Bad Request") and nothing to act on. +const readUpgradeErrorMessage = ( + payload: { error?: unknown; message?: unknown; data?: { message?: unknown } } | null, + response: Response, +): string => { + for (const candidate of [payload?.error, payload?.data?.message, payload?.message]) { + if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim(); + } + return response.statusText || 'Failed to upgrade OpenCode'; +}; + export const getOpenCodeUpgradeStatus = async (manager?: OpenCodeUpgradeManager): Promise> => { const upgrade = getCapability(manager); const apiUrl = getApiUrl(manager); @@ -107,16 +120,33 @@ export const upgradeManagedOpenCode = async (manager: OpenCodeUpgradeManager | u if (openCodeUpgradePromise) { return { status: 409, body: { success: false, code: 'OPENCODE_UPGRADE_IN_PROGRESS', error: 'An OpenCode upgrade is already in progress.' } }; } - const targetVersion = typeof target === 'string' ? target.trim() : ''; + const requestedTarget = typeof target === 'string' ? target.trim() : ''; const operation = (async (): Promise => { + // The lookup runs inside the operation so the in-flight lock above already + // holds while the release version is resolved. + let targetVersion = requestedTarget; + if (!targetVersion) { + try { + targetVersion = await fetchLatestVersion(); + } catch (error) { + return { + status: 502, + body: { + success: false, + code: 'OPENCODE_UPGRADE_TARGET_UNRESOLVED', + error: `Could not determine which OpenCode version to install: ${error instanceof Error ? error.message : String(error)}`, + }, + }; + } + } try { const response = await fetch(new URL('global/upgrade', apiUrl).toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...manager.getOpenCodeAuthHeaders() }, - body: JSON.stringify(targetVersion ? { target: targetVersion } : {}), + body: JSON.stringify({ target: targetVersion }), }); - const payload = await response.json().catch(() => null) as { error?: unknown } | null; - if (!response.ok) return { status: response.status, body: { success: false, error: typeof payload?.error === 'string' ? payload.error : response.statusText || 'Failed to upgrade OpenCode' } }; + const payload = await response.json().catch(() => null) as { error?: unknown; message?: unknown; data?: { message?: unknown } } | null; + if (!response.ok) return { status: response.status, body: { success: false, error: readUpgradeErrorMessage(payload, response) } }; try { await manager.restart(); } catch (error) { diff --git a/packages/web/server/lib/opencode/routes-upgrade.test.js b/packages/web/server/lib/opencode/routes-upgrade.test.js index cb18b833..f25d2895 100644 --- a/packages/web/server/lib/opencode/routes-upgrade.test.js +++ b/packages/web/server/lib/opencode/routes-upgrade.test.js @@ -9,6 +9,13 @@ afterEach(() => { globalThis.fetch = originalFetch; }); +const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, +}); + +const supportedCapability = { supported: true, manager: 'opencode', reason: null }; + const createApp = (overrides = {}) => { const app = express(); app.use(express.json()); @@ -67,22 +74,99 @@ describe('OpenCode upgrade routes', () => { }); }); + it('names the latest release as the upgrade target when the caller sends none', async () => { + const requests = []; + globalThis.fetch = vi.fn(async (url, init) => { + requests.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : null }); + if (String(url).includes('registry.npmjs.org')) { + return jsonResponse({ version: '1.18.23' }); + } + if (String(url).includes('api.github.com')) { + return jsonResponse({ tag_name: 'v1.18.23' }); + } + return jsonResponse({ success: true, version: '1.18.23' }); + }); + const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability }); + + await request(app) + .post('/api/opencode/upgrade') + .send({}) + .expect(200, { success: true, version: '1.18.23', restarted: true }); + + const upgradeRequest = requests.find((entry) => entry.url.includes('/global/upgrade')); + expect(upgradeRequest?.body).toEqual({ target: '1.18.23' }); + }); + + it('keeps an explicitly requested target instead of resolving the latest release', async () => { + const requests = []; + globalThis.fetch = vi.fn(async (url, init) => { + requests.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : null }); + return jsonResponse({ success: true, version: '1.18.20' }); + }); + const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability }); + + await request(app) + .post('/api/opencode/upgrade') + .send({ target: '1.18.20' }) + .expect(200); + + expect(requests).toHaveLength(1); + expect(requests[0].url).toContain('/global/upgrade'); + expect(requests[0].body).toEqual({ target: '1.18.20' }); + }); + + it('fails without calling the updater when the latest release cannot be resolved', async () => { + globalThis.fetch = vi.fn(async (url) => { + if (String(url).includes('/global/upgrade')) { + throw new Error('the updater must not be called without a target'); + } + return new Response('nope', { status: 503 }); + }); + const { app, dependencies } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability }); + + const response = await request(app) + .post('/api/opencode/upgrade') + .send({}) + .expect(502); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe('OPENCODE_UPGRADE_TARGET_UNRESOLVED'); + expect(response.body.error).toContain('Could not determine which OpenCode version to install'); + expect(dependencies.refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled(); + }); + + it('surfaces the rejection OpenCode reported instead of the bare HTTP status', async () => { + globalThis.fetch = vi.fn(async (url) => { + if (String(url).includes('/global/upgrade')) { + return jsonResponse( + { name: 'BadRequest', data: { message: 'Expected a semantic version', kind: 'Payload' } }, + 400, + ); + } + return jsonResponse({ version: '1.18.23' }); + }); + const { app } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability }); + + await request(app) + .post('/api/opencode/upgrade') + .send({}) + .expect(400, { success: false, error: 'Expected a semantic version' }); + }); + it('serializes supported upgrades and preserves the in-flight lock', async () => { let releaseUpgrade; const upstreamResponse = new Promise((resolve) => { - releaseUpgrade = () => resolve(new Response(JSON.stringify({ success: true, version: '1.18.9' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })); + releaseUpgrade = () => resolve(jsonResponse({ success: true, version: '1.18.9' })); }); - globalThis.fetch = vi.fn(() => upstreamResponse); - const { app, dependencies } = createApp({ - getOpenCodeUpgradeCapability: () => ({ - supported: true, - manager: 'opencode', - reason: null, - }), + const upgradeCalls = vi.fn(); + globalThis.fetch = vi.fn((url) => { + if (String(url).includes('/global/upgrade')) { + upgradeCalls(); + return upstreamResponse; + } + return Promise.resolve(jsonResponse({ version: '1.18.9' })); }); + const { app, dependencies } = createApp({ getOpenCodeUpgradeCapability: () => supportedCapability }); const first = request(app) .post('/api/opencode/upgrade') @@ -94,7 +178,7 @@ describe('OpenCode upgrade routes', () => { }) .then((response) => response); await vi.waitFor(() => { - expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(upgradeCalls).toHaveBeenCalledTimes(1); }); await request(app) diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index 7c48b060..fdb7d95c 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -164,6 +164,41 @@ ${desktopReturn ? `Return return versions.sort((left, right) => compareVersions(right, left))[0]; }; + // OpenCode's `/global/upgrade` requires an explicit semver target and rejects + // a bodyless call, so "update to the latest" has to name the version. The + // release lookup is the same one the upgrade-status check already uses to + // decide there is anything to offer. + const resolveOpenCodeUpgradeTarget = async (requestedTarget) => { + if (typeof requestedTarget === 'string' && requestedTarget.trim().length > 0) { + return { resolved: true, target: requestedTarget.trim() }; + } + try { + const latest = await fetchLatestOpenCodeVersion(); + if (!latest) { + return { resolved: false, reason: 'The latest OpenCode version could not be determined.' }; + } + return { resolved: true, target: latest }; + } catch (error) { + return { + resolved: false, + reason: error instanceof Error ? error.message : 'The latest OpenCode version could not be determined.', + }; + } + }; + + // OpenCode reports a rejected upgrade as `{ name, data: { message, kind } }`, + // which carries no `error` field. Reading only `error` left the user with the + // bare HTTP status text ("Bad Request") and nothing to act on. + const readOpenCodeUpgradeErrorMessage = (payload, response) => { + const candidates = [payload?.error, payload?.data?.message, payload?.message]; + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return response.statusText || 'Failed to upgrade OpenCode'; + }; + const pruneExpiredPendingMcpAuthContexts = () => { const now = Date.now(); for (const [state, entry] of pendingMcpAuthContextByState.entries()) { @@ -218,10 +253,23 @@ ${desktopReturn ? `Return }); } - const target = typeof req.body?.target === 'string' && req.body.target.trim().length > 0 - ? req.body.target.trim() - : undefined; + const requestedTarget = req.body?.target; + // The target lookup reaches the network, so it runs inside the operation: + // the in-flight lock is taken synchronously above, and a second click + // cannot slip past while the release version is being resolved. const upgradeOperation = (async () => { + const targetResolution = await resolveOpenCodeUpgradeTarget(requestedTarget); + if (!targetResolution.resolved) { + return { + status: 502, + body: { + success: false, + code: 'OPENCODE_UPGRADE_TARGET_UNRESOLVED', + error: `Could not determine which OpenCode version to install: ${targetResolution.reason}`, + }, + }; + } + const response = await fetch(buildOpenCodeUrl('/global/upgrade', ''), { method: 'POST', headers: { @@ -229,7 +277,7 @@ ${desktopReturn ? `Return Accept: 'application/json', ...getOpenCodeAuthHeaders(), }, - body: JSON.stringify(target ? { target } : {}), + body: JSON.stringify({ target: targetResolution.target }), }); const payload = await response.json().catch(() => null); if (!response.ok) { @@ -237,7 +285,7 @@ ${desktopReturn ? `Return status: response.status, body: { success: false, - error: payload?.error || response.statusText || 'Failed to upgrade OpenCode', + error: readOpenCodeUpgradeErrorMessage(payload, response), }, }; } From eca9353382f803960ef5940fe28db62decdbcbf2 Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Fri, 28 Aug 2026 19:14:02 +0300 Subject: [PATCH 37/66] fix(composer): keep the caret inside the normalized document CodeMirror collapses a CRLF pair into one line break, so the document is shorter than the string it was given. The composer derived the caret from the JS string length, which put it past the end of the document and made dispatch throw `RangeError: Selection points outside of document`. Because the exception fires before the transaction applies, the document never updates, the un-normalized text stays in React state, and the draft persists as-is: every later visit to the session restores it and crashes again, with no way out from the UI. Derive the caret from the change set instead, in the controlled writeback and in the imperative insert/replace handles. fixes #3013 # Conflicts: # CHANGELOG.md # packages/vscode/CHANGELOG.md --- CHANGELOG.md | 1 + .../components/chat/composer/DOCUMENTATION.md | 9 +++ .../chat/composer/editor/ComposerEditor.tsx | 30 +++++----- .../editor/__tests__/documentEdits.test.ts | 58 +++++++++++++++++++ .../writebackCompositionGuard.test.ts | 2 +- .../chat/composer/editor/documentEdits.ts | 33 +++++++++++ packages/vscode/CHANGELOG.md | 1 + 7 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 packages/ui/src/components/chat/composer/editor/__tests__/documentEdits.test.ts create mode 100644 packages/ui/src/components/chat/composer/editor/documentEdits.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e38ff2ab..a501e816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - Files: opening a file over 5,000 lines is no longer blocked — the line-count guard now allows up to 20,000 lines, letting large files reach the virtualized full-file preview instead of being rejected at the open step (thanks @gaojunran). - Usage: GitHub Copilot now shows a single AI Credits window, matching Copilot's token-based quota, in place of the old Chat Requests and Completions windows (thanks to @jakoss). - Updates: "Update OpenCode" no longer fails with a bare "Bad Request". OpenChamber now names the release to install, which recent OpenCode versions require, and when an update is refused the reason from OpenCode is shown instead of the HTTP status. This affects setups where OpenChamber runs an OpenCode you installed yourself; the desktop app bundles OpenCode and never offered the button. +- Chat: a saved draft or recalled message containing Windows line endings no longer replaces the chat with a "Selection points outside of document" error — text like this reached the input from reverted messages, message history and plugin output, and once it was saved as a draft the error came back on every visit to that session. - Settings: fixed the Cloudflare Tunnel download link shown when cloudflared is not installed (thanks to @AyoubAchour). - Git: picking a remote branch such as `origin/main` in the branch selector now switches you to that branch instead of leaving the repository on a detached `HEAD` with no branch name. - Desktop: "Restart to Update" no longer looks dead when the update cannot be installed — the update window now shows the reason, including when the running copy was not installed from an official signed release, and the button stays available to retry. diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index a3d0854e..2302e3a0 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -60,6 +60,15 @@ copy. exactly what gets sent, so nothing downstream serializes a rich document model back into a prompt. +The document is not, however, the string it was given: CodeMirror normalizes +line endings, so a `\r\n` pair becomes one break and the document ends up +shorter than the inserted string. **Never derive a caret position from the +length of text you are inserting** — a caret past the end makes `dispatch` +throw, the transaction never applies, and the un-normalized text stays in React +state to crash again on the next restore. Every edit that moves the caret goes +through `replaceWithCaret` (`editor/documentEdits.ts`), which measures the +change instead of the string. + The composer previously painted a transparent `