fix: harden and de-slop the merged sidebar/chat/settings batch

Post-merge follow-ups for #2740 #2735 #2734 #2690 #2676 #2738 #2684
#2689 #2733 #2739 #2462 #2687 #2736 #2618 #2697, plus three regressions
found while reviewing them:

- ctrl/cmd+digit while typing no longer switches session tabs (#2503 was
  still open in practice: the guard only covered the mod+alt surface binding)
- Shiki template-call sanitizer now covers every bundled grammar, including
  the js/ts aliases and embedding grammars; timed-out highlight requests are
  memoized and no longer cancel unrelated in-flight requests
- settings flush on suspend uses keepalive and also fires on Capacitor
  appStateChange; keeps the selected model persisted across mode switches
- remote-only branches fetch before checkout; range helpers fail clearly
- git status invalidation now fires for runtime adapters too
- settings number inputs and select triggers size in ch so they scale with
  the interface font
- recent-activity timestamps tick from one list-level ticker
- Markdown preview find goes through the shared find_in_file keybind with
  containment, no longer counts its own bar, and debounces observer runs
- #2676 reverted; #2524 fixed by fading the sticky header's own background
  instead of overlaying the content below it
- sticky group headers in the model picker and sidebar render again
  (oc-sticky-fade-scroller class restored after 9b9d7069c)
- project switcher names are left-aligned again (wrapper lost in 26dbc2f30)
- tool card quick-open icon is always visible and opens the same line as the
  expanded card's button
- tautological tests replaced or removed; new oxlint findings fixed
This commit is contained in:
Bohdan Triapitsyn
2026-08-29 01:06:43 +03:00
parent a182f4f4ff
commit 48bcac1758
53 changed files with 1125 additions and 375 deletions
@@ -9,6 +9,20 @@ interface TurnItemProps {
renderMessage: (message: ChatMessageEntry) => React.ReactNode;
}
/**
* The sticky user header paints the chat background so assistant content scrolling
* underneath disappears behind it. The soft edge lives in the header's own background
* instead of an overlay below it: the bottom 0.75rem of the header box fades the
* background out, and that strip sits over the empty space the user bubble already
* reserves below itself. At rest the strip reveals the identical page background
* (`--background` is generated from the same `surface.background` token), so it is
* invisible and can never wash over the assistant content that follows.
*/
const STICKY_HEADER_BACKGROUND: React.CSSProperties = {
backgroundImage:
'linear-gradient(to bottom, var(--surface-background) calc(100% - 0.75rem), transparent)',
};
const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, renderMessage }) => {
return (
<section
@@ -18,14 +32,13 @@ const TurnItem: React.FC<TurnItemProps> = ({ turn, stickyUserHeader = true, rend
data-scroll-spy-id={turn.turnId}
>
{stickyUserHeader ? (
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] pb-4 sm:pb-8 [overflow-anchor:none]">
<div
className="sticky top-0 z-20 [overflow-anchor:none]"
style={STICKY_HEADER_BACKGROUND}
>
<div className="relative z-10">
{renderMessage(turn.userMessage)}
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-4 bg-gradient-to-b from-[var(--surface-background)] to-transparent sm:h-8"
/>
</div>
) : (
renderMessage(turn.userMessage)
@@ -1,10 +1,7 @@
/// <reference lib="webworker" />
import { bundledLanguages, createHighlighter, type BundledLanguage, type LanguageRegistration, type ThemedToken } from 'shiki';
import {
isTemplateCallLanguageId,
sanitizeTemplateCallGrammar,
} from '../../../lib/shiki/sanitizeTemplateCallGrammar';
import { sanitizeTemplateCallGrammar } from '../../../lib/shiki/sanitizeTemplateCallGrammar';
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
@@ -69,15 +66,17 @@ type BundledLanguageModule = { default: LanguageRegistration[] };
/**
* Load a language, neutralizing the catastrophic JS/TS `template-call` rule
* before it reaches the Oniguruma scanner (see sanitizeTemplateCallGrammar).
*
* Every bundled language is resolved and sanitized rather than a fixed id
* list: Shiki keys the JS/TS grammars under aliases too (`js`, `ts`, `cjs`,
* `mjs`, `mts`, `cts`), and embedding grammars (`vue`, `svelte`, `mdx`,
* `astro`, `html`) ship them as extra entries in their own module. Sanitizing
* every entry is free for the rest — `hasCatastrophicTemplateCall` returns the
* grammar untouched when the rule is absent.
*/
const loadLanguageSafe = async (instance: Instance, lang: BundledLanguage): Promise<void> => {
if (!isTemplateCallLanguageId(lang)) {
await instance.loadLanguage(bundledLanguages[lang]);
return;
}
// SAFETY: every Shiki bundled-language module default-exports its grammar
// array; `lang` is narrowed to a bundled id above.
// array; `lang` is narrowed to a bundled id by the caller.
const mod = (await bundledLanguages[lang]()) as BundledLanguageModule;
const grammars = mod.default.map((grammar) => sanitizeTemplateCallGrammar(grammar));
await instance.loadLanguage(...grammars);
@@ -1,12 +1,137 @@
import { describe, expect, test } from 'bun:test';
import { describe, expect, mock, test } from 'bun:test';
import { HIGHLIGHT_REQUEST_TIMEOUT_MS } from './markdown-worker-timeout';
import type { MarkdownWorkerRequest } from './markdown-worker-protocol';
/**
* Hang safety for the markdown Shiki worker client
* (openchamber/openchamber#2587, follow-up on #2618).
*
* Catastrophic Oniguruma backtracking is synchronous inside the worker, so the
* only recovery is terminating it from this thread. Two properties matter and
* neither is observable from the timeout constant alone: the hung request must
* resolve `null` after the worker is terminated, and the block that caused it
* must not be retried — a retry respawns a worker (Shiki + Oniguruma init) and
* burns another full budget of a core on every render and every scroll past it.
*/
const TEST_TIMEOUT_MS = 50;
mock.module('./markdown-shiki.worker.ts?worker&url', () => ({ default: 'blob:test-shiki-worker' }));
mock.module('./markdown-worker-timeout', () => ({ HIGHLIGHT_REQUEST_TIMEOUT_MS: TEST_TIMEOUT_MS }));
/** A worker that accepts everything and answers nothing. */
class SilentWorker {
static created = 0;
static terminated = 0;
static messages: MarkdownWorkerRequest[] = [];
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
onmessageerror: (() => void) | null = null;
constructor() {
SilentWorker.created += 1;
}
postMessage(message: MarkdownWorkerRequest): void {
SilentWorker.messages.push(message);
}
terminate(): void {
SilentWorker.terminated += 1;
}
}
/**
* bun test has no `window` or `Worker`; defining the properties directly
* installs the stubs without asserting they are the platform globals.
* `SilentWorker` implements exactly the members `markdown-worker` uses:
* postMessage, terminate, and the three handler slots.
*/
const installWorkerStub = (): void => {
Object.defineProperty(globalThis, 'window', { value: {}, configurable: true, writable: true });
Object.defineProperty(globalThis, 'Worker', { value: SilentWorker, configurable: true, writable: true });
};
/**
* Silent on the first instance, answering on every later one — so a request
* that was only queued behind the hung one can be observed being replayed
* against the replacement worker.
*/
class ReplayWorker {
static created = 0;
static terminated = 0;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
onmessageerror: (() => void) | null = null;
private readonly answers: boolean;
constructor() {
ReplayWorker.created += 1;
this.answers = ReplayWorker.created > 1;
}
postMessage(message: MarkdownWorkerRequest): void {
if (!this.answers || message.type !== 'highlight') return;
setTimeout(() => {
this.onmessage?.(new MessageEvent('message', {
data: { type: 'highlight', id: message.id, html: '<pre>ok</pre>' },
}));
}, 0);
}
terminate(): void {
ReplayWorker.terminated += 1;
}
}
/** Same reasoning as installWorkerStub. */
const installReplayWorkerStub = (): void => {
Object.defineProperty(globalThis, 'window', { value: {}, configurable: true, writable: true });
Object.defineProperty(globalThis, 'Worker', { value: ReplayWorker, configurable: true, writable: true });
};
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);
test('a hung block resolves null, terminates the worker, and is not retried', async () => {
installWorkerStub();
const { highlightCodeInWorker, resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker');
resetMarkdownWorkerClientCacheForTests();
const code = 'const label = `Account ${index + 1}`;';
const first = await highlightCodeInWorker(code, 'javascript');
expect(first).toBeNull();
expect(SilentWorker.terminated).toBe(1);
const createdAfterFirst = SilentWorker.created;
const messagesAfterFirst = SilentWorker.messages.length;
// Same content again: the timed-out key is memoized as failed, so nothing
// reaches a worker and none is spawned.
const second = await highlightCodeInWorker(code, 'javascript');
expect(second).toBeNull();
expect(SilentWorker.created).toBe(createdAfterFirst);
expect(SilentWorker.messages.length).toBe(messagesAfterFirst);
});
test('a timeout fails only the offending request and replays the queued one', async () => {
installReplayWorkerStub();
const { highlightCodeInWorker, resetMarkdownWorkerClientCacheForTests } = await import('./markdown-worker');
resetMarkdownWorkerClientCacheForTests();
const results = await Promise.all([
highlightCodeInWorker('const a = `one`;', 'javascript'),
highlightCodeInWorker('const b = `two`;', 'javascript'),
]);
// Whichever request owns the first timer is the offender; the other was
// merely queued behind it and must survive on the replacement worker
// rather than being cancelled with it.
expect(results.filter((value) => value === null)).toHaveLength(1);
expect(results.filter((value) => value === '<pre>ok</pre>')).toHaveLength(1);
expect(ReplayWorker.terminated).toBe(1);
expect(ReplayWorker.created).toBe(2);
});
});
@@ -19,8 +19,14 @@ import { HIGHLIGHT_REQUEST_TIMEOUT_MS } from './markdown-worker-timeout';
// The per-request timeout exists because TextMate grammars can enter catastrophic
// backtracking on the Oniguruma WASM engine (openchamber/openchamber#2587).
// Matching is synchronous inside the worker, so the only way to reclaim its heap
// is to terminate it from this thread once a request exceeds the budget. A timed
// out request resolves `null` like any other failure, so nothing is memoized.
// is to terminate it from this thread once a request exceeds the budget.
//
// A timeout is scoped to the block that caused it: only that request resolves
// `null`, and the requests that were merely queued behind it are re-dispatched
// against the fresh worker. The timed-out key is memoized as failed, because a
// block that hangs the grammar hangs it every time — without that, every
// re-render and every scroll past the block would pay another worker spawn
// (Shiki + Oniguruma init) plus the full timeout budget of a core.
//
// Results are memoized by content fingerprint (+ lang / theme). Unchanged
// content must not re-enter the worker — that was the sustained ~40 msg/s
@@ -37,17 +43,30 @@ import { HIGHLIGHT_REQUEST_TIMEOUT_MS } from './markdown-worker-timeout';
// repaints via CSS and must not invalidate these entries. Only
// `highlightTokens` resolves concrete colors, so only its key carries a theme.
type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
/**
* Why a request stopped, kept distinct so a hang can be memoized while a
* transient "no worker yet" failure is retried on the next render.
*/
type RequestOutcome =
| { status: 'ok'; response: MarkdownWorkerResponse }
| { status: 'failed' }
| { status: 'timeout' };
type PendingResolver = (outcome: RequestOutcome) => void;
type PendingEntry = {
resolve: PendingResolver;
timer: ReturnType<typeof setTimeout>;
payload: MarkdownWorkerRequest;
};
type CachedHighlight =
| { type: 'highlight'; html: string }
| { type: 'highlightLines'; lines: string[] }
| { type: 'highlightTokens'; lines: MarkdownTokenRun[][] };
| { type: 'highlightTokens'; lines: MarkdownTokenRun[][] }
// A block that timed out the worker. Memoized so it is attempted once per
// session instead of respawning a worker on every render.
| { type: 'failed' };
const CLIENT_CACHE_MAX_ENTRIES = 2000;
const CLIENT_CACHE_MAX_BYTES = 24 * 1024 * 1024;
@@ -74,6 +93,7 @@ const clearPendingTimers = (): void => {
const entryBytes = (key: string, value: CachedHighlight): number => {
const keyBytes = utf16Bytes(key);
if (value.type === 'failed') return keyBytes;
if (value.type === 'highlight') return keyBytes + utf16Bytes(value.html);
if (value.type === 'highlightLines') {
let total = keyBytes;
@@ -83,13 +103,8 @@ const entryBytes = (key: string, value: CachedHighlight): number => {
return keyBytes + estimateTokenRunsBytes(value.lines);
};
const failAll = (): void => {
clearPendingTimers();
pending.forEach((entry) => entry.resolve(null));
pending.clear();
const disposeWorker = (): void => {
sentThemes.clear();
// Drop in-flight waiters; cached results remain valid (pure fn of inputs).
inflight.clear();
worker?.terminate();
worker = undefined;
workerCreation = undefined;
@@ -99,6 +114,16 @@ const failAll = (): void => {
}
};
/** Worker crash / message error: nothing in flight can still be answered. */
const failAll = (): void => {
clearPendingTimers();
pending.forEach((entry) => entry.resolve({ status: 'failed' }));
pending.clear();
// Drop in-flight waiters; cached results remain valid (pure fn of inputs).
inflight.clear();
disposeWorker();
};
const createWorker = async (): Promise<Worker | undefined> => {
if (typeof window === 'undefined' || typeof Worker === 'undefined') return undefined;
try {
@@ -117,7 +142,7 @@ const createWorker = async (): Promise<Worker | undefined> => {
if (!entry) return;
clearTimeout(entry.timer);
pending.delete(event.data.id);
entry.resolve(event.data);
entry.resolve({ status: 'ok', response: event.data });
};
instance.onerror = failAll;
instance.onmessageerror = failAll;
@@ -141,20 +166,56 @@ const getWorker = async (): Promise<Worker | undefined> => {
return workerCreation;
};
const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
/**
* Post one request and arm its timeout. The timer starts here, not when the
* caller enqueued, so a request re-dispatched after someone else's hang gets a
* whole budget on the fresh worker rather than an already-spent one.
*/
const dispatch = async (id: number, resolve: PendingResolver, payload: MarkdownWorkerRequest): Promise<void> => {
const instance = await getWorker();
if (!instance) return Promise.resolve(null);
if (!instance) {
resolve({ status: 'failed' });
return;
}
const timer = setTimeout(() => handleTimeout(id), HIGHLIGHT_REQUEST_TIMEOUT_MS);
pending.set(id, { resolve, timer, payload });
instance.postMessage(payload);
};
/**
* One request exceeded the budget. Kill the worker so the WASM heap is freed
* instead of growing until the renderer OOMs, fail only the offending request,
* and replay the requests that were only waiting behind it.
*/
function handleTimeout(id: number): void {
const offender = pending.get(id);
if (!offender) return;
console.warn(`Shiki worker highlight timed out after ${HIGHLIGHT_REQUEST_TIMEOUT_MS}ms; terminating worker`);
const survivors = Array.from(pending.entries()).filter(([pendingId]) => pendingId !== id);
clearPendingTimers();
pending.clear();
disposeWorker();
offender.resolve({ status: 'timeout' });
for (const [survivorId, entry] of survivors) {
// A `highlightTokens` payload whose theme was already shipped to the dead
// worker cannot be replayed — the definition went with it, and the fresh
// worker would reject the bare theme name.
if (entry.payload.type === 'highlightTokens' && entry.payload.theme === undefined) {
entry.resolve({ status: 'failed' });
continue;
}
void dispatch(survivorId, entry.resolve, entry.payload);
}
}
const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise<RequestOutcome> => {
const id = ++nextId;
return new Promise<MarkdownWorkerResponse | null>((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));
const message = payload(id);
return new Promise<RequestOutcome>((resolve) => {
void dispatch(id, resolve, message);
});
};
@@ -171,6 +232,12 @@ const coalesce = (
return pendingRequest;
};
const memoizeFailure = (key: string): CachedHighlight => {
const entry: CachedHighlight = { type: 'failed' };
resultCache.set(key, entry, entryBytes(key, entry));
return entry;
};
const cacheKeyFor = (kind: string, lang: string, code: string, themeName?: string): string => {
const fp = contentFingerprint(code);
return themeName === undefined ? `${kind}:${lang}:${fp}` : `${kind}:${themeName}:${lang}:${fp}`;
@@ -190,11 +257,13 @@ export const highlightCodeInWorker = async (code: string, lang: string): Promise
const key = cacheKeyFor('highlight', lang, code);
const cached = resultCache.get(key);
if (cached?.type === 'highlight') return cached.html;
if (cached?.type === 'failed') return null;
const result = await coalesce(key, async () => {
const response = await request((id) => ({ type: 'highlight', id, code, lang }));
if (response?.type !== 'highlight') return null;
const entry: CachedHighlight = { type: 'highlight', html: response.html };
const outcome = await request((id) => ({ type: 'highlight', id, code, lang }));
if (outcome.status === 'timeout') return memoizeFailure(key);
if (outcome.status !== 'ok' || outcome.response.type !== 'highlight') return null;
const entry: CachedHighlight = { type: 'highlight', html: outcome.response.html };
resultCache.set(key, entry, entryBytes(key, entry));
return entry;
});
@@ -210,11 +279,13 @@ export const highlightLinesInWorker = async (code: string, lang: string): Promis
const key = cacheKeyFor('highlightLines', lang, code);
const cached = resultCache.get(key);
if (cached?.type === 'highlightLines') return cached.lines;
if (cached?.type === 'failed') return null;
const result = await coalesce(key, async () => {
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
if (response?.type !== 'highlightLines') return null;
const entry: CachedHighlight = { type: 'highlightLines', lines: response.lines };
const outcome = await request((id) => ({ type: 'highlightLines', id, code, lang }));
if (outcome.status === 'timeout') return memoizeFailure(key);
if (outcome.status !== 'ok' || outcome.response.type !== 'highlightLines') return null;
const entry: CachedHighlight = { type: 'highlightLines', lines: outcome.response.lines };
resultCache.set(key, entry, entryBytes(key, entry));
return entry;
});
@@ -242,10 +313,11 @@ export const highlightTokensInWorker = async (
const key = cacheKeyFor('highlightTokens', lang, code, themeName);
const cached = resultCache.get(key);
if (cached?.type === 'highlightTokens') return cached.lines;
if (cached?.type === 'failed') return null;
const result = await coalesce(key, async () => {
const needsTheme = !sentThemes.has(themeName);
const response = await request((id) => ({
const outcome = await request((id) => ({
type: 'highlightTokens',
id,
code,
@@ -253,9 +325,10 @@ export const highlightTokensInWorker = async (
themeName,
...(needsTheme ? { theme } : {}),
}));
if (response?.type !== 'highlightTokens') return null;
if (outcome.status === 'timeout') return memoizeFailure(key);
if (outcome.status !== 'ok' || outcome.response.type !== 'highlightTokens') return null;
sentThemes.add(themeName);
const entry: CachedHighlight = { type: 'highlightTokens', lines: response.lines };
const entry: CachedHighlight = { type: 'highlightTokens', lines: outcome.response.lines };
resultCache.set(key, entry, entryBytes(key, entry));
return entry;
});
@@ -202,11 +202,15 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
}, []);
const getClampedX = React.useCallback((anchorX: number) => (
getDesktopClampedX(anchorX, window.innerWidth, menuWidthRef.current)
typeof window === 'undefined'
? anchorX
: getDesktopClampedX(anchorX, window.innerWidth, menuWidthRef.current)
), []);
const getClampedY = React.useCallback((anchorY: number) => (
getDesktopClampedY(anchorY, window.innerHeight, menuHeightRef.current)
typeof window === 'undefined'
? anchorY
: getDesktopClampedY(anchorY, window.innerHeight, menuHeightRef.current)
), []);
const addMarkdownToChat = React.useCallback((markdownText: string) => {
@@ -287,7 +291,10 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
x: getClampedX(prev.x),
y: getClampedY(prev.y),
}));
}, [getClampedX, getClampedY, isMobile, position.show]);
// Entering comment mode and typing into the comment box both grow the
// popup, so remeasuring on those keeps the cached height (and the Y clamp
// built from it) honest.
}, [commentMode, commentText, getClampedX, getClampedY, isMobile, position.show]);
// The desktop popup hangs above its anchor, so a tall comment box near the
// top of the chat can climb over the app header. On the desktop shell the
@@ -87,6 +87,7 @@ Use this doc when you ask an agent to change tool/header/description behavior.
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render.
- The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`.
- The `@pierre/diffs` stack is knowingly unprotected against the JS/TS `template-call` backtracking that OOM'd the renderer in openchamber/openchamber#2587. Our own markdown Shiki worker sanitizes every grammar it loads (`@/lib/shiki/sanitizeTemplateCallGrammar`), but the diff worker pool runs `preferredHighlighter: 'shiki-wasm'` (`DiffWorkerProvider.tsx`) and resolves its languages by id through `@pierre/diffs`' own registry — `langs` accepts `SupportedLanguages` strings only, so there is no seam to hand it a pre-sanitized `LanguageRegistration`. A pathological template literal inside a rendered diff can therefore still hang that pool's Oniguruma engine. The available levers are upstream (a `langs` overload accepting grammar objects) or switching that pool to the JS regex engine; neither is done.
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its output viewport grows with the content up to `46vh`, then scrolls and follows new output until the user scrolls up; following resumes when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
- Reasoning streaming presentation derives from the live stream phase (`streaming`/`cooldown`), never from missing persisted timing: a cached part without `time.end` is not live, and a part whose `time.end` is set never streams (issue #2020).
@@ -1,12 +1,67 @@
import React from 'react';
import React, { act } from 'react';
import { describe, expect, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { createRoot } from 'react-dom/client';
import { Window } from 'happy-dom';
import type { Part } from '@opencode-ai/sdk/v2';
import { I18nProvider } from '@/lib/i18n';
import ReasoningPart, { ReasoningTimelineBlock } from './ReasoningPart';
import type { StreamPhase } from '../types';
type ReasoningPartFixture = Extract<Part, { type: 'reasoning' }>;
/**
* Mounts a real client root against a happy-dom document so mount/unmount
* lifecycle is observable. bun test shares globalThis across a file, so the
* globals React DOM reads are defined here and restored afterwards; defining
* them directly avoids asserting that happy-dom's objects are the platform
* `Window`/`Document`.
*/
const DOM_GLOBAL_NAMES = [
'window',
'document',
'navigator',
'Node',
'Element',
'HTMLElement',
'IS_REACT_ACT_ENVIRONMENT',
] as const;
const installDomStub = () => {
const happyWindow = new Window({ url: 'http://localhost' });
const previous = DOM_GLOBAL_NAMES.map(
(name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const,
);
const values = {
window: happyWindow,
document: happyWindow.document,
navigator: happyWindow.navigator,
Node: happyWindow.Node,
Element: happyWindow.Element,
HTMLElement: happyWindow.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
};
for (const name of DOM_GLOBAL_NAMES) {
Object.defineProperty(globalThis, name, { value: values[name], configurable: true, writable: true });
}
// Read back through the global bindings just installed, so the container is
// typed as the DOM element React expects rather than happy-dom's own class.
const container = document.createElement('div');
document.body.appendChild(container);
return {
container,
restore: () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
};
// A reasoning text whose summary (first 120 chars) fits in the header but
// whose expanded body content should only appear when the disclosure is open.
const LONG_REASONING =
@@ -126,20 +181,22 @@ describe('ReasoningPart streaming gating (issue #2020)', () => {
const BUSY_INDICATOR = 'animate-busy-pulse';
const makeReasoningPart = (time?: { start?: number; end?: number }): Part =>
({
id: 'prt_reasoning_2020',
sessionID: 'ses_2020',
messageID: 'msg_2020',
type: 'reasoning',
text: SHORT_REASONING,
time,
}) as unknown as Part;
const makeReasoningPart = (
time: ReasoningPartFixture['time'],
text: string = SHORT_REASONING,
): ReasoningPartFixture => ({
id: 'prt_reasoning_2020',
sessionID: 'ses_2020',
messageID: 'msg_2020',
type: 'reasoning',
text,
time,
});
// Server rendering reads the UI store's initial state, which is
// chatRenderMode 'live' — the mode in which the streaming presentation is
// reachable and the issue reproduces.
const renderPart = (part: Part, streamPhase?: StreamPhase): string =>
const renderPart = (part: ReasoningPartFixture, streamPhase?: StreamPhase): string =>
renderToStaticMarkup(
<I18nProvider>
<ReasoningPart part={part} messageId="msg_2020" streamPhase={streamPhase} />
@@ -182,13 +239,60 @@ describe('ReasoningPart streaming gating (issue #2020)', () => {
expect(markup).toContain('aria-expanded="true"');
});
test('remounting a completed reasoning part does not re-trigger the streaming presentation', () => {
const part = makeReasoningPart({ start: 1_000 });
const first = renderPart(part, undefined);
const second = renderPart(part, undefined);
test('a live part with no committed text yet shows the busy header and no empty summary', () => {
// The streaming early-return keeps the block mounted before the block-level
// reveal commits a first line. The header must read as busy and must not
// paint an empty summary row.
const markup = renderPart(makeReasoningPart({ start: 1_000 }, ''), 'streaming');
const withText = renderPart(makeReasoningPart({ start: 1_000 }), undefined);
expect(second).toBe(first);
expect(second).not.toContain(BUSY_INDICATOR);
expect(second).toContain(SHORT_REASONING);
expect(markup).toContain(BUSY_INDICATOR);
expect(markup).toContain('role="button"');
// The summary span carries `title="<summary>"`; with no text there must be
// no summary span at all rather than an empty one.
expect(withText).toContain('title="');
expect(markup).not.toContain('title="');
});
test('remounting a completed reasoning part does not re-trigger the streaming presentation', async () => {
// renderToStaticMarkup cannot observe this: it has no mount lifecycle, so
// comparing two server renders is true by construction. Mount, unmount and
// remount a real client root instead, watching the busy indicator across
// every commit.
const dom = installDomStub();
const part = makeReasoningPart({ start: 1_000 });
const busySeen: boolean[] = [];
const root = createRoot(dom.container);
const renderTree = () =>
React.createElement(
I18nProvider,
null,
React.createElement(ReasoningPart, { part, messageId: 'msg_2020', streamPhase: undefined }),
);
try {
await act(async () => {
root.render(renderTree());
});
busySeen.push(dom.container.innerHTML.includes(BUSY_INDICATOR));
expect(dom.container.textContent).toContain(SHORT_REASONING);
await act(async () => {
root.render(null);
});
await act(async () => {
root.render(renderTree());
});
busySeen.push(dom.container.innerHTML.includes(BUSY_INDICATOR));
expect(busySeen).toEqual([false, false]);
expect(dom.container.textContent).toContain(SHORT_REASONING);
} finally {
await act(async () => {
root.unmount();
});
dom.restore();
}
});
});
@@ -61,6 +61,8 @@ import {
getPatchText,
getPrimaryDiffFromMetadata,
getPrimaryToolPath,
getToolFallbackDiff,
resolveToolQuickOpenTarget,
type DiffPatchEntry,
} from './toolDiffUtils';
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
@@ -1248,12 +1250,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
});
const outputString = isStreamingBash ? throttledOutputString : rawOutputString;
const attachments = stateWithData.attachments;
const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined;
const diffContent = getPatchText((metadata as { patch?: unknown } | undefined)?.patch)
?? getPatchText(metadata?.diff)
?? getPatchText(fileDiff?.patch)
?? getPatchText(fileDiff?.diff)
?? null;
const diffContent = getToolFallbackDiff(metadata) ?? null;
const diffEntries = React.useMemo(
() => getDiffPatchEntries(metadata, diffContent ?? undefined, (path) => getRelativePath(path, currentDirectory)),
[currentDirectory, diffContent, metadata]
@@ -2055,16 +2052,14 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
const quickOpenTarget = React.useMemo<{ absolutePath: string; line?: number; toolDiff?: string; toolName: string } | null>(() => {
if (isTaskTool) return null;
const toolName = normalizedPartTool || part.tool;
const filePath = getPrimaryToolPath(toolName, input, metadata);
if (typeof filePath !== 'string') return null;
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
let line: number | undefined;
let toolDiff: string | undefined;
if (toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch') {
line = getFirstChangedLineFromMetadata(toolName, metadata, filePath);
toolDiff = getPrimaryDiffFromMetadata(toolName, metadata, filePath);
}
return { absolutePath, line, toolDiff, toolName };
const target = resolveToolQuickOpenTarget(toolName, input, metadata);
if (!target) return null;
return {
absolutePath: toAbsoluteFilePath(currentDirectory, target.filePath),
line: target.line,
toolDiff: target.patch,
toolName,
};
}, [isTaskTool, normalizedPartTool, part.tool, input, metadata, currentDirectory]);
const openQuickTarget = () => {
@@ -2202,9 +2197,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
onClick={handleQuickOpen}
className={cn(
'flex-shrink-0 inline-flex h-4 w-4 items-center justify-center rounded transition-opacity hover:bg-[var(--surface-hover)]',
// Coarse pointers never hover, so the icon has to rest visible
// there or it stays invisible while remaining tappable.
'opacity-0 group-hover/tool:opacity-60 hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-60',
'opacity-60 hover:opacity-100 focus-visible:opacity-100',
)}
style={{ color: 'var(--tools-icon)' }}
title={t('chat.toolPart.openFile')}
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test';
import {
extractFirstChangedLineFromDiff,
getApplyPatchFilePath,
getDiffPatchEntries,
getFirstChangedLineFromMetadata,
@@ -8,6 +9,7 @@ import {
getPrimaryDiffFromMetadata,
getPrimaryToolPath,
getRenderablePatchInfo,
resolveToolQuickOpenTarget,
} from './toolDiffUtils';
const identity = (path: string) => path;
@@ -203,4 +205,52 @@ describe('toolDiffUtils', () => {
expect(entries[0]?.renderMode).toBe('text');
expect(entries[0]?.patch).toContain('@@');
});
test('resolves the quick-open target from the same entry the expanded card renders', () => {
const patch = [
'--- a/src/file.ts',
'+++ b/src/file.ts',
'@@ -10,3 +12,4 @@',
' context',
'+added',
].join('\n');
const metadata = {
files: [{
filePath: '/workspace/project/src/file.ts',
relativePath: 'src/file.ts',
patch,
type: 'update',
}],
};
const entries = getDiffPatchEntries(metadata, undefined, identity);
expect(resolveToolQuickOpenTarget('apply_patch', undefined, metadata)).toEqual({
filePath: '/workspace/project/src/file.ts',
line: extractFirstChangedLineFromDiff(entries[0]?.patch ?? ''),
patch: entries[0]?.patch,
});
});
test('picks the entry matching the primary path in a multi-file apply_patch', () => {
const firstPatch = ['--- a/src/a.ts', '+++ b/src/a.ts', '@@ -1,2 +1,3 @@', ' a', '+first'].join('\n');
const secondPatch = ['--- a/src/b.ts', '+++ b/src/b.ts', '@@ -30,2 +40,3 @@', ' b', '+second'].join('\n');
const metadata = {
files: [
{ filePath: '/workspace/project/src/a.ts', relativePath: 'src/a.ts', patch: firstPatch, type: 'delete' },
{ filePath: '/workspace/project/src/b.ts', relativePath: 'src/b.ts', patch: secondPatch, type: 'update' },
],
};
const target = resolveToolQuickOpenTarget('apply_patch', undefined, metadata);
expect(target?.filePath).toBe('/workspace/project/src/b.ts');
expect(target?.line).toBe(41);
});
test('reports no line when the tool has no diff entry', () => {
expect(resolveToolQuickOpenTarget('write', { filePath: '/workspace/project/src/new.ts' }, undefined))
.toEqual({ filePath: '/workspace/project/src/new.ts', line: undefined, patch: undefined });
});
test('returns no quick-open target without a primary path', () => {
expect(resolveToolQuickOpenTarget('bash', { command: 'ls' }, undefined)).toBeNull();
});
});
@@ -261,6 +261,15 @@ export const getPrimaryDiffFromMetadata = (
return getPatchText(metadata.patch) ?? getPatchText(metadata.diff);
};
/** Top-level patch a tool card falls back to when metadata carries no per-file entries. */
export const getToolFallbackDiff = (metadata: Record<string, unknown> | undefined): string | undefined => {
const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined;
return getPatchText(metadata?.patch)
?? getPatchText(metadata?.diff)
?? getPatchText(fileDiff?.patch)
?? getPatchText(fileDiff?.diff);
};
export const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => {
if (!diffText) {
return undefined;
@@ -330,6 +339,33 @@ export const getFirstChangedLineFromMetadata = (
return firstPatch ? extractFirstChangedLineFromDiff(firstPatch) : undefined;
};
/**
* Quick-open target for a tool card: the primary mutated file plus the diff
* entry the expanded card renders for it. Both the collapsed header icon and
* the expanded "open file" button resolve their line from the same entry
* patch, so they always land on the same line.
*/
export const resolveToolQuickOpenTarget = (
toolName: string,
input: Record<string, unknown> | undefined,
metadata: Record<string, unknown> | undefined,
): { filePath: string; line?: number; patch?: string } | null => {
const filePath = getPrimaryToolPath(toolName, input, metadata);
if (!filePath) {
return null;
}
const entries = getDiffPatchEntries(metadata, getToolFallbackDiff(metadata), (path) => path);
const matchedEntry = entries.find((entry) => entry.filePath === filePath)
?? (entries.length === 1 ? entries[0] : undefined);
const patch = matchedEntry?.patch;
return {
filePath,
line: patch ? extractFirstChangedLineFromDiff(patch) : undefined,
patch,
};
};
const normalizeParsedPath = (path: string | undefined): string => {
const trimmed = (path ?? '').trim().replace(/\t.*$/, '');
if (!trimmed || trimmed === '/dev/null') {
@@ -891,7 +891,7 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
hideBottomScrollShadow
scrollShadowSize={12}
outerClassName={maxHeightClassName}
className="overlay-scrollbar-target--no-gutter"
className="oc-sticky-fade-scroller overlay-scrollbar-target--no-gutter"
style={maxHeightStyle}
onScroll={stickyHeaders ? (event) => syncStickyFade(event.currentTarget) : undefined}
>
@@ -18,6 +18,7 @@ import {
SettingsStackedField,
SettingsChipGroup,
SETTINGS_SELECT_SIZE,
SETTINGS_NUMBER_INPUT_CLASS,
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
SETTINGS_ICON_BUTTON_CLASS,
SETTINGS_CUSTOM_TRIGGER_CLASS,
@@ -450,7 +451,7 @@ export const AgentsPage: React.FC = () => {
inputMode="decimal"
placeholder="—"
emptyLabel="—"
className="w-20"
className={SETTINGS_NUMBER_INPUT_CLASS}
/>
{temperature !== undefined && (
<Button
@@ -488,7 +489,7 @@ export const AgentsPage: React.FC = () => {
inputMode="decimal"
placeholder="—"
emptyLabel="—"
className="w-20"
className={SETTINGS_NUMBER_INPUT_CLASS}
/>
{topP !== undefined && (
<Button
@@ -3,7 +3,6 @@ import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { reportSettingsSaveState } from '@/lib/persistence';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import {
@@ -376,7 +375,7 @@ export const BehaviorPage: React.FC = () => {
onValueChange={(value) => setResponseStylePreset(value)}
disabled={isLoading || !responseStyleEnabled}
>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_ROW_TRIGGER_CLASS, 'max-w-72')}>
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
<SelectValue>
{(value) => {
if (value === 'custom') return t('settings.behavior.page.responseStyle.option.custom');
@@ -54,6 +54,7 @@ import {
SETTINGS_CLUSTER_CONTROL_CLASS,
SETTINGS_NUMBER_STEPPER_ROW_CLASS,
SETTINGS_NUMBER_UNIT_CLASS,
SETTINGS_NUMBER_INPUT_CLASS,
SETTINGS_FIELDS_STACK_CLASS,
SETTINGS_OPTION_STACK_CLASS,
} from '@/components/sections/shared/SettingsSection';
@@ -1217,7 +1218,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
controlClassName="w-full"
>
<Select value={uiFont} onValueChange={(value) => setUiFont(value as UiFontOption)}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectInterfaceFontAria')} size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'max-w-full')}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectInterfaceFontAria')} size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_TRIGGER_CLASS}>
<SelectValue>{UI_FONT_OPTIONS.find((option) => option.id === uiFont)?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -1247,7 +1248,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
controlClassName="w-full"
>
<Select value={monoFont} onValueChange={(value) => setMonoFont(value as MonoFontOption)}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectCodeFontAria')} size={SETTINGS_SELECT_SIZE} className={cn(SETTINGS_SELECT_TRIGGER_CLASS, 'max-w-full')}>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.selectCodeFontAria')} size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_TRIGGER_CLASS}>
<SelectValue>{CODE_FONT_OPTIONS.find((option) => option.id === monoFont)?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -1288,7 +1289,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={50}
max={200}
step={5}
className="w-20"
className={SETTINGS_NUMBER_INPUT_CLASS}
aria-label={t('settings.openchamber.visual.field.fontSizePercentageAria')}
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>%</span>
@@ -1319,7 +1320,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={9}
max={52}
step={1}
className="w-20"
className={SETTINGS_NUMBER_INPUT_CLASS}
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>px</span>
<Button size="sm"
@@ -1349,7 +1350,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={9}
max={32}
step={1}
className="w-20"
className={SETTINGS_NUMBER_INPUT_CLASS}
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>px</span>
<Button size="sm"
@@ -1384,7 +1385,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={50}
max={200}
step={5}
className="w-20"
className={SETTINGS_NUMBER_INPUT_CLASS}
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>%</span>
<Button size="sm"
@@ -1415,7 +1416,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
min={0}
max={100}
step={5}
className="w-20"
className={SETTINGS_NUMBER_INPUT_CLASS}
/>
<span className={SETTINGS_NUMBER_UNIT_CLASS}>px</span>
<Button size="sm"
@@ -10,7 +10,9 @@ import {
SettingsChipGroup,
SettingsInset,
SETTINGS_ICON_BUTTON_CLASS,
SETTINGS_NUMBER_INPUT_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
import { useI18n, type I18nKey } from '@/lib/i18n';
@@ -87,7 +89,7 @@ export const SessionRetentionSettings: React.FC = () => {
max={MAX_DAYS}
step={1}
aria-label={t('settings.openchamber.sessionRetention.field.retentionPeriodAria')}
className="w-24 tabular-nums"
className={cn(SETTINGS_NUMBER_INPUT_CLASS, 'tabular-nums')}
/>
<span className="typography-ui-label text-muted-foreground">{t('settings.openchamber.sessionRetention.field.days')}</span>
<Button
@@ -20,6 +20,7 @@ import {
SettingsControlGroup,
SettingsChipGroup,
SETTINGS_SELECT_SIZE,
SETTINGS_NUMBER_INPUT_CLASS,
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
SETTINGS_CONTROL_CLUSTER_CLASS,
SETTINGS_FIELD_LABEL_CLASS,
@@ -1051,20 +1052,20 @@ export const VoiceSettings: React.FC = () => {
{/* Speech Rate */}
<SettingsFieldRow label={t('settings.voice.page.field.speechRate')}>
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechRate} onChange={(e) => setSpeechRate(Number(e.target.value))} className={sliderClass} />}
<NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className="w-20 tabular-nums" />
<NumberInput value={speechRate} onValueChange={setSpeechRate} min={0.5} max={2} step={0.1} className={cn(SETTINGS_NUMBER_INPUT_CLASS, 'tabular-nums')} />
</SettingsFieldRow>
{/* Speech Pitch */}
<SettingsFieldRow label={t('settings.voice.page.field.speechPitch')}>
{!isMobile && <input type="range" min={0.5} max={2} step={0.1} value={speechPitch} onChange={(e) => setSpeechPitch(Number(e.target.value))} className={sliderClass} />}
<NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className="w-20 tabular-nums" />
<NumberInput value={speechPitch} onValueChange={setSpeechPitch} min={0.5} max={2} step={0.1} className={cn(SETTINGS_NUMBER_INPUT_CLASS, 'tabular-nums')} />
</SettingsFieldRow>
{/* Speech Volume */}
<SettingsFieldRow label={t('settings.voice.page.field.speechVolume')}>
{!isMobile && <input type="range" min={0} max={1} step={0.1} value={speechVolume} onChange={(e) => setSpeechVolume(Number(e.target.value))} className={sliderClass} />}
{isMobile ? (
<NumberInput value={Math.round(speechVolume * 100)} onValueChange={(v) => setSpeechVolume(v / 100)} min={0} max={100} step={10} className="w-16 tabular-nums" />
<NumberInput value={Math.round(speechVolume * 100)} onValueChange={(v) => setSpeechVolume(v / 100)} min={0} max={100} step={10} className={cn(SETTINGS_NUMBER_INPUT_CLASS, 'tabular-nums')} />
) : (
<span className="typography-ui-label text-foreground tabular-nums min-w-[3rem] text-right">
{Math.round(speechVolume * 100)}%
@@ -29,6 +29,7 @@ import {
SETTINGS_SECTION_TITLE_CLASS,
SETTINGS_FIELD_LABEL_CLASS,
SETTINGS_SELECT_SIZE,
SETTINGS_NUMBER_INPUT_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
@@ -2322,7 +2323,7 @@ export const RemoteInstancesPage: React.FC = () => {
min={5}
max={240}
step={1}
className="w-20 tabular-nums"
className={cn(SETTINGS_NUMBER_INPUT_CLASS, 'tabular-nums')}
value={draft.connectionTimeoutSec}
onValueChange={(next) => {
updateDraft((current) => ({
@@ -2350,7 +2351,7 @@ export const RemoteInstancesPage: React.FC = () => {
min={1}
max={65535}
step={1}
className="w-32 tabular-nums"
className={cn(SETTINGS_NUMBER_INPUT_CLASS, 'tabular-nums')}
value={draft.remoteOpenchamber.preferredPort}
onValueChange={(next) => {
updateDraft((current) => ({
@@ -2517,7 +2518,7 @@ export const RemoteInstancesPage: React.FC = () => {
min={1}
max={65535}
step={1}
className="w-32 tabular-nums"
className={cn(SETTINGS_NUMBER_INPUT_CLASS, 'tabular-nums')}
value={draft.localForward.preferredLocalPort}
onValueChange={(next) => {
updateDraft((current) => ({
@@ -2775,7 +2776,7 @@ export const RemoteInstancesPage: React.FC = () => {
min={1}
max={65535}
step={1}
className="w-32 tabular-nums"
className={cn(SETTINGS_NUMBER_INPUT_CLASS, 'tabular-nums')}
value={forward.localPort}
onValueChange={(next) => {
updateForward((item) => ({
@@ -2817,7 +2818,7 @@ export const RemoteInstancesPage: React.FC = () => {
min={1}
max={65535}
step={1}
className="w-32 tabular-nums"
className={cn(SETTINGS_NUMBER_INPUT_CLASS, 'tabular-nums')}
value={forward.remotePort}
onValueChange={(next) => {
updateForward((item) => ({
@@ -6,12 +6,33 @@ import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { cn } from '@/lib/utils';
import { SettingsInfoHint } from './SettingsInfoHint';
/**
* Width cap shared by every settings value picker.
*
* `applyTypography` scales the `--typography-*` vars but never the root rem,
* so a rem-based cap (`max-w-48`) stays 192px while the label inside it grows,
* and the value clips at 150200% interface font size. `ch` is measured
* against the trigger's own `typography-ui-label` font, so the cap grows with
* the setting. Below `@xl` the field row stacks and the control is plain
* `w-full`, so this cap only binds on wide panes.
*/
const SETTINGS_TRIGGER_WIDTH_CLASS = 'w-full min-w-[22ch] max-w-[40ch]';
/** Settings select trigger: full column width in stacked cells; capped in field rows via parent. */
export const SETTINGS_SELECT_TRIGGER_CLASS = 'w-full min-w-40 max-w-48';
export const SETTINGS_SELECT_TRIGGER_CLASS = SETTINGS_TRIGGER_WIDTH_CLASS;
export const SETTINGS_SELECT_SIZE = 'settings' as const;
/** Fixed-width select used inside full-width SettingsFieldRow control columns. */
export const SETTINGS_SELECT_ROW_TRIGGER_CLASS = 'w-full min-w-40 max-w-48';
export const SETTINGS_SELECT_ROW_TRIGGER_CLASS = SETTINGS_TRIGGER_WIDTH_CLASS;
/**
* Width for every settings NumberInput stepper. Font-relative for the same
* reason as the trigger cap above: a rem-fixed `w-16`/`w-20`/`w-24`/`w-32`
* clips its own digits once the interface font is scaled up. The explicit
* `typography-ui-label` pins `ch` to the same scaled font the inner numeric
* field renders in, since the wrapper would otherwise inherit an unscaled one.
*/
export const SETTINGS_NUMBER_INPUT_CLASS = 'typography-ui-label w-[16ch]';
/** Compact reset / icon action next to a settings control (matches h-8 controls). */
export const SETTINGS_ICON_BUTTON_CLASS =
@@ -21,7 +42,7 @@ export const SETTINGS_ICON_BUTTON_CLASS =
// eslint-disable-next-line react-refresh/only-export-components
export const SETTINGS_CUSTOM_TRIGGER_CLASS = cn(
dropdownTriggerVariants(),
'w-full min-w-40 max-w-48',
SETTINGS_TRIGGER_WIDTH_CLASS,
);
/** Shared width for stacked control clusters (select/input + reset). */
@@ -74,4 +74,4 @@ make every row observe unrelated streaming updates.
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action.
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
- Pending-permission/question row badges fade with the same hover/menu-open rule as the date label, except on always-visible-actions rows, which reserve permanent padding and keep the badges shown (`selectRowBadgeVisibilityClass` in `sessions/sessionNodeItemUtils.ts`).
- Pending-permission/question row badges fade with the same hover/menu-open rule as the date label, except on non-VS Code always-visible-actions rows, which reserve permanent padding and keep the badges shown. VS Code hover-reveals its actions over the row's right edge even under `alwaysShowActions`, so its badges keep fading (`selectRowBadgeVisibilityClass` in `sessions/sessionNodeItemUtils.ts`).
@@ -248,7 +248,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
hideTopScrollShadow={!enableStickyFade}
scrollShadowSize={96}
outerClassName="flex-1 min-h-0"
className="oc-sidebar-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]"
className="oc-sidebar-scroller oc-sticky-fade-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]"
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
>
{model.topContent}
@@ -295,7 +295,9 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
<DropdownMenuContent align="start" className="max-h-[70vh] min-w-[220px] overflow-y-auto">
{projectPickerOptions?.map((option) => (
<DropdownMenuItem key={option.id} onClick={() => onProjectSelect?.(option.id)} className="flex items-center justify-between gap-3" title={option.projectDescription}>
<ProjectHeaderIdentity {...option} />
<span className="flex min-w-0 items-center gap-1.5">
<ProjectHeaderIdentity {...option} />
</span>
{option.id === id ? <Icon name="check" className="h-4 w-4 flex-shrink-0 text-primary" /> : null}
</DropdownMenuItem>
))}
@@ -70,6 +70,27 @@ type RenderExtras = SessionNodeRenderExtras;
const MAX_VISIBLE_RECENT_SESSIONS = 7;
const RELATIVE_TIME_TICK_INTERVAL_MS = 60_000;
/**
* One ticker for the whole Recent list. The rows render their compact
* timestamp ("5m") at render time, and the row memo only re-renders on
* session changes, so without a tick the label freezes at the value it had
* when the row mounted. A single minute interval per list keeps every
* visible row current at a cost independent of the row count never one
* interval per row.
*/
const useRelativeTimeTick = (): number => {
const [tick, setTick] = React.useState(0);
React.useEffect(() => {
const timer = setInterval(() => {
setTick((previous) => previous + 1);
}, RELATIVE_TIME_TICK_INTERVAL_MS);
return () => clearInterval(timer);
}, []);
return tick;
};
export function SidebarActivitySections(props: Props): React.ReactNode {
const {
sections,
@@ -119,6 +140,8 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
});
}, [batchSize]);
const relativeTimeTick = useRelativeTimeTick();
const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => {
const subtreeContainsEditing = new Set<string>();
collectSubtreeContainingId(nodes, props.editingId, subtreeContainsEditing);
@@ -134,6 +157,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: nodeStructureKeyByNode.get(child) ?? '',
relativeTimeTick,
childRenderExtrasFor,
});
@@ -141,9 +165,10 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
subtreeContainsEditing,
menuOpenSessionId,
nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '',
relativeTimeTick,
childRenderExtrasFor,
});
}, [props.editingId, props.openSidebarMenuKey]);
}, [props.editingId, props.openSidebarMenuKey, relativeTimeTick]);
const visibleSections = sections.filter((section) => section.items.length > 0 || section.key === 'chats');
if (visibleSections.length === 0) {
@@ -1,41 +0,0 @@
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);
});
});
@@ -117,6 +117,12 @@ export type SessionNodeItemProps = {
* if no menu is open. Only one row can have its menu open at a time.
*/
menuOpenSessionId: string | null;
/**
* Bumped once a minute by the Recent list so the compact relative
* timestamp rendered below recomputes instead of freezing at the value it
* had when the row first mounted.
*/
relativeTimeTick?: number;
/**
* Precomputed structural key for this node. Encodes the IDs and child
* counts of all descendants so a reference-only change to `node` (e.g.
@@ -1381,7 +1387,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
{alwaysShowActions ? (
// Touch runtimes have no hover tooltip, so the compact
// date stays inline there.
<span className="ml-2 inline-flex flex-shrink-0 items-center gap-1 text-[0.72rem] text-muted-foreground/75">
<span className="ml-2 inline-flex flex-shrink-0 items-center gap-1 typography-micro text-muted-foreground/75">
{showActivityDuration ? (
<SessionActivityDuration sessionId={session.id} running={isStreaming} />
) : (
@@ -1410,7 +1416,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
<SessionActivityDuration
sessionId={session.id}
running={isStreaming}
className="text-[0.72rem]"
className="typography-micro"
/>
) : (
<>
@@ -1430,7 +1436,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
them, so the revealed row actions never
overlap it. */}
{renderContext === 'recent' ? (
<span className="flex-shrink-0 text-[0.72rem] leading-none text-muted-foreground/75 tabular-nums">
<span className="flex-shrink-0 typography-micro leading-none text-muted-foreground/75 tabular-nums">
{sessionCompactUpdatedLabel}
</span>
) : null}
@@ -1716,6 +1722,7 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
if (prev.nodeStructureKey !== next.nodeStructureKey) return false;
if (prev.relativeTimeTick !== next.relativeTimeTick) return false;
if (getNodeSessionDirectory(prev.node) !== getNodeSessionDirectory(next.node)) return false;
if (!isSecondaryMetaEqual(prev.secondaryMeta, next.secondaryMeta)) return false;
@@ -178,6 +178,7 @@ export function SessionTreeItem({
subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_CONTAINS_EDITING}
menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null}
nodeStructureKey={renderExtras?.nodeStructureKey ?? ''}
relativeTimeTick={renderExtras?.relativeTimeTick}
>
{node.children.map((child) => (
<SessionTreeItem
@@ -170,7 +170,7 @@ describe('selectFolderRootNodes', () => {
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)', () => {
test('defers to the caller hover rule so the badge fades with the date label (#2284)', () => {
const className = selectRowBadgeVisibilityClass({
actionsAlwaysVisible: false,
menuOpen: false,
@@ -178,18 +178,17 @@ describe('selectRowBadgeVisibilityClass', () => {
});
expect(className).toContain(hideOnHoverClass);
expect(className).toContain('transition-opacity');
});
test('hides the badge while the row menu keeps the actions visible without hover', () => {
test('hides the badge unconditionally 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');
expect(className).not.toBe('');
expect(className).not.toContain(hideOnHoverClass);
});
test('keeps the badge always visible when actions have reserved permanent padding', () => {
@@ -19,6 +19,12 @@ export type SessionNodeChildRenderExtras = {
subtreeContainsEditing: Set<string>;
menuOpenSessionId: string | null;
nodeStructureKey: string;
/**
* Bumped once a minute by the owning list so rows that render a relative
* timestamp ("5m") re-render and recompute it. Only the Recent list
* supplies it; elsewhere the rows carry no time-dependent label.
*/
relativeTimeTick?: number;
};
export type SessionNodeRenderExtras<TNode = SessionNode> = SessionNodeChildRenderExtras & {
+30 -36
View File
@@ -1783,7 +1783,24 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
},
find_in_file: (event) => {
if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false;
if (!(event.target instanceof Node)) return false;
// Rendered Markdown preview: open the in-preview find bar instead of the
// editor search. Registered through the keybind schema rather than a raw
// window listener so it cannot swallow Cmd/Ctrl+F app-wide while a
// Markdown file happens to be selected behind another panel tab.
if (isMarkdown && getMdViewMode() === 'preview') {
if (isMobile) return false;
const previewContainer = isFullscreen
? mdFullscreenPreviewContainerRef.current
: mdPreviewContainerRef.current;
if (!previewContainer?.contains(event.target)) return false;
setMdPreviewFindOpen(true);
setMdPreviewFindFocusNonce((value) => value + 1);
return;
}
if (!editorWrapperRef.current?.contains(event.target)) return false;
setIsSearchOpen(true);
},
});
@@ -2921,34 +2938,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setIsGoToLineOpen(true);
});
// 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;
if (target instanceof Element && 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(() => {
@@ -4280,6 +4269,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
) : null}
</div>
) : isMarkdown && getMdViewMode() === 'preview' ? (
// The find bar is a sibling of the scroll container, never a child:
// inside it, its own "1/3" and "No matches" text would be walked and
// highlighted by the search it drives.
<div className="relative h-full min-h-0">
<div
className="oc-file-preview h-full overflow-auto p-4"
ref={(node) => {
@@ -4287,13 +4280,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
mdFullscreenPreviewContainerRef.current = node;
}}
>
<MarkdownPreviewSearch
containerRef={mdFullscreenPreviewContainerRef}
open={mdPreviewFindOpen}
onOpenChange={setMdPreviewFindOpen}
focusNonce={mdPreviewFindFocusNonce}
className="right-4 top-16"
/>
{selectedFile ? (
<FilePreviewCommentMenu
containerRef={markdownPreviewRef}
@@ -4324,6 +4310,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
/>
</ErrorBoundary>
</div>
<MarkdownPreviewSearch
containerRef={mdFullscreenPreviewContainerRef}
open={mdPreviewFindOpen}
onOpenChange={setMdPreviewFindOpen}
focusNonce={mdPreviewFindFocusNonce}
className="right-4 top-16"
/>
</div>
) : canUseShikiFileView && textViewMode === 'view' ? (
renderShikiFileView(selectedFile, isLargeFile ? fileContent : draftContent, fullscreenViewVirtualizer)
) : (
@@ -33,15 +33,29 @@ const isMarkElement = (node: Node): boolean => {
return node instanceof Element && node.hasAttribute(MARK_ATTR);
};
/** True when this widget's own highlight surgery produced the record. */
const isSelfProducedMutation = (record: MutationRecord): boolean => {
if (record.target instanceof Element && record.target.hasAttribute(MARK_ATTR)) {
return true;
}
return [...record.addedNodes].some((node) => isMarkElement(node));
};
const clearHighlights = (container: HTMLElement): void => {
const touchedParents = new Set<Node>();
container.querySelectorAll(`mark[${MARK_ATTR}]`).forEach((mark) => {
const parent = mark.parentNode;
if (!parent) {
return;
}
parent.replaceChild(document.createTextNode(mark.textContent ?? ''), mark);
parent.normalize();
touchedParents.add(parent);
});
// Once per affected parent instead of once per mark. Merging the split text
// nodes back together is safe under the renderer's morphdom path: it diffs
// against a tree freshly parsed from HTML, where the merged single text node
// is exactly the shape it expects.
touchedParents.forEach((parent) => parent.normalize());
};
const applySearch = (container: HTMLElement, query: string): HTMLElement[] => {
@@ -143,7 +157,12 @@ export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
// Focus returns here when the bar closes, so Escape does not strand focus.
const returnFocusRef = React.useRef<HTMLElement | null>(null);
const runSearch = React.useCallback((nextQuery: string) => {
/**
* `keepIndex` distinguishes a new query (start at match 1) from a re-search
* of the same query after the renderer re-morphed the container: a theme
* toggle or content refresh must not yank the reader back to match 1.
*/
const runSearch = React.useCallback((nextQuery: string, keepIndex = false) => {
const container = containerRef.current;
if (!container) {
marksRef.current = [];
@@ -152,17 +171,23 @@ export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
return;
}
marksRef.current = applySearch(container, nextQuery);
setTotal(marksRef.current.length);
setIndex(0);
const nextTotal = marksRef.current.length;
setTotal(nextTotal);
setIndex((current) => {
if (!keepIndex || nextTotal === 0) {
return 0;
}
return Math.min(current, nextTotal - 1);
});
}, [containerRef]);
const scheduleSearch = React.useCallback((nextQuery: string) => {
const scheduleSearch = React.useCallback((nextQuery: string, keepIndex = false) => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
debounceRef.current = null;
runSearch(nextQuery);
runSearch(nextQuery, keepIndex);
}, SEARCH_DEBOUNCE_MS);
}, [runSearch]);
@@ -190,23 +215,25 @@ export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
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) {
if (!queryRef.current.trim()) {
return;
}
runSearch(queryRef.current);
// Per record, not per batch: the renderer can deliver a genuine mutation
// in the same batch as one of ours, and `.some` would swallow it.
const rendererTouched = records.some((record) => !isSelfProducedMutation(record));
if (!rendererTouched) {
return;
}
// Debounced like typing — a morph batch would otherwise pay a full
// TreeWalker plus DOM surgery per mutation batch.
scheduleSearch(queryRef.current, true);
});
observer.observe(container, { childList: true, subtree: true, characterData: true });
return () => {
observer.disconnect();
clearHighlights(container);
};
}, [containerRef, open, runSearch]);
}, [containerRef, open, scheduleSearch]);
// Focus the input when the bar opens, remembering what to restore on close.
React.useEffect(() => {
@@ -296,7 +323,7 @@ export const MarkdownPreviewSearch: React.FC<MarkdownPreviewSearchProps> = ({
aria-live="polite"
aria-label={total > 0
? t('filesView.preview.find.countAria', { current: index + 1, total })
: undefined}
: t('filesView.preview.find.noMatches')}
>
{query.trim() && total === 0
? t('filesView.preview.find.noMatches')
@@ -4,7 +4,11 @@ import { Window } from 'happy-dom';
import { hasOpenDropdown, isEditableEventTarget, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom';
const domWindow = new Window();
Object.assign(globalThis, { document: domWindow.document, HTMLElement: domWindow.HTMLElement });
Object.assign(globalThis, {
document: domWindow.document,
HTMLElement: domWindow.HTMLElement,
KeyboardEvent: domWindow.KeyboardEvent,
});
test('does not treat an unrelated visible listbox as an open dropdown', () => {
const promptNavigator = {} as Element;
@@ -55,3 +59,31 @@ test('does not treat a plain element or non-element target as editable', () => {
expect(isEditableEventTarget(document.createElement('button'))).toBe(false);
expect(isEditableEventTarget(null)).toBe(false);
});
// Both digit shortcuts (switch_context_surface and switch_session_tab) gate on
// isEditableEventTarget(event.target). switch_session_tab's default prefix is a
// bare modifier, so plain ctrl/cmd+1 reaches the handler while the composer has
// focus; the guard only holds if a dispatched keydown reports the focused
// textarea as its target rather than the element the listener sits on (#2689).
test('reports the focused editable element as the target of a bubbled ctrl/cmd+digit keydown', () => {
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
let observedTarget: EventTarget | null = null;
const listener = (event: Event) => {
observedTarget = event.target;
};
document.addEventListener('keydown', listener);
textarea.dispatchEvent(new KeyboardEvent('keydown', {
key: '1',
metaKey: true,
bubbles: true,
}));
document.removeEventListener('keydown', listener);
textarea.remove();
expect(observedTarget).toBe(textarea);
expect(isEditableEventTarget(observedTarget)).toBe(true);
});
@@ -520,6 +520,10 @@ export const useKeyboardShortcuts = () => {
sessionTabDigit !== null
&& !event.repeat
&& !isVSCodeRuntime()
// Typing a digit in a textarea/input must stay text, never a tab
// switch: the default prefix here is a bare modifier, so this fires
// on plain ctrl/cmd+1 while the composer has focus (#2689).
&& !isEditableEventTarget(event.target)
&& useUIStore.getState().sessionTabsEnabled
&& eventMatchesShortcutPrefix(
event,
@@ -8,22 +8,39 @@ mock.module('@/contexts/runtimeAPIRegistry', () => ({
getRegisteredRuntimeAPIs: (): RuntimeApisStub => registeredRuntimeApis,
}));
interface TestWindow {
__VSCODE_CONFIG__?: { workspaceFolder: string; workspaceFolders: { name: string; path: string }[] };
}
/**
* bun test runs without a DOM, so `globalThis` has no `window` binding to
* assign through. Defining the property directly installs the stub without
* asserting that it is a real `Window`.
*/
const setTestWindow = (value: TestWindow | undefined): void => {
if (value === undefined) {
Reflect.deleteProperty(globalThis, 'window');
return;
}
Object.defineProperty(globalThis, 'window', { value, configurable: true, writable: true });
};
const { isVSCodeRuntime } = await import('./desktop');
describe('desktop isVSCodeRuntime bootstrap detection', () => {
afterEach(() => {
registeredRuntimeApis = null;
delete (globalThis as { window?: unknown }).window;
setTestWindow(undefined);
});
test('detects VS Code from bootstrap config before RuntimeAPIs register', () => {
registeredRuntimeApis = null;
(globalThis as { window: unknown }).window = {
setTestWindow({
__VSCODE_CONFIG__: {
workspaceFolder: '/Users/me/project-a',
workspaceFolders: [{ name: 'project-a', path: '/Users/me/project-a' }],
},
};
});
expect(isVSCodeRuntime()).toBe(true);
});
@@ -32,14 +49,14 @@ describe('desktop isVSCodeRuntime bootstrap detection', () => {
registeredRuntimeApis = {
runtime: { isVSCode: true },
};
(globalThis as { window: unknown }).window = {};
setTestWindow({});
expect(isVSCodeRuntime()).toBe(true);
});
test('does not classify an unregistered web runtime as VS Code', () => {
registeredRuntimeApis = null;
(globalThis as { window: unknown }).window = {};
setTestWindow({});
expect(isVSCodeRuntime()).toBe(false);
});
+44 -32
View File
@@ -8,6 +8,7 @@ import { useSelectionStore } from '@/sync/selection-store';
import { useConfigStore } from '@/stores/useConfigStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { notifyGitStatusInvalidated } from './gitStatusInvalidation';
export type {
GitRemote,
@@ -19,6 +20,17 @@ const getRuntimeGit = () => {
return getRegisteredRuntimeAPIs()?.git ?? null;
};
// Runtime git adapters (the VS Code bridge today) do not go through the HTTP
// adapter's cache, so the invalidation signal `useGitStore` relies on has to be
// emitted here, at the dispatch layer, once a runtime mutation succeeds. The
// HTTP adapter keeps emitting it itself when it clears its own cache, so a
// mutation is announced exactly once on either path.
const runtimeStatusMutation = async <T>(directory: string, mutation: Promise<T>): Promise<T> => {
const result = await mutation;
notifyGitStatusInvalidated(directory);
return result;
};
const requestChatForceScrollBottom = (sessionId: string) => {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent('openchamber:chat-force-scroll-bottom', {
@@ -144,49 +156,49 @@ export async function revertGitFile(
options?: { scope?: 'all' | 'working' }
): Promise<void> {
const runtime = getRuntimeGit();
if (runtime) return runtime.revertGitFile(directory, filePath, options);
if (runtime) return runtimeStatusMutation(directory, runtime.revertGitFile(directory, filePath, options));
return gitHttp.revertGitFile(directory, filePath, options);
}
export async function stageGitFile(directory: string, filePath: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.stageGitFile) return runtime.stageGitFile(directory, filePath);
if (runtime?.stageGitFile) return runtimeStatusMutation(directory, runtime.stageGitFile(directory, filePath));
return gitHttp.stageGitFile(directory, filePath);
}
export async function stageGitFiles(directory: string, filePaths: string[]): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.stageGitFiles) return runtime.stageGitFiles(directory, filePaths);
if (runtime?.stageGitFiles) return runtimeStatusMutation(directory, runtime.stageGitFiles(directory, filePaths));
return gitHttp.stageGitFiles(directory, filePaths);
}
export async function unstageGitFile(directory: string, filePath: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.unstageGitFile) return runtime.unstageGitFile(directory, filePath);
if (runtime?.unstageGitFile) return runtimeStatusMutation(directory, runtime.unstageGitFile(directory, filePath));
return gitHttp.unstageGitFile(directory, filePath);
}
export async function unstageGitFiles(directory: string, filePaths: string[]): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.unstageGitFiles) return runtime.unstageGitFiles(directory, filePaths);
if (runtime?.unstageGitFiles) return runtimeStatusMutation(directory, runtime.unstageGitFiles(directory, filePaths));
return gitHttp.unstageGitFiles(directory, filePaths);
}
export async function stageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.stageGitHunk) return runtime.stageGitHunk(directory, filePath, patch);
if (runtime?.stageGitHunk) return runtimeStatusMutation(directory, runtime.stageGitHunk(directory, filePath, patch));
return gitHttp.stageGitHunk(directory, filePath, patch);
}
export async function unstageGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.unstageGitHunk) return runtime.unstageGitHunk(directory, filePath, patch);
if (runtime?.unstageGitHunk) return runtimeStatusMutation(directory, runtime.unstageGitHunk(directory, filePath, patch));
return gitHttp.unstageGitHunk(directory, filePath, patch);
}
export async function revertGitHunk(directory: string, filePath: string, patch: string): Promise<void> {
const runtime = getRuntimeGit();
if (runtime?.revertGitHunk) return runtime.revertGitHunk(directory, filePath, patch);
if (runtime?.revertGitHunk) return runtimeStatusMutation(directory, runtime.revertGitHunk(directory, filePath, patch));
return gitHttp.revertGitHunk(directory, filePath, patch);
}
@@ -204,13 +216,13 @@ export async function getGitBranches(directory: string): Promise<import('./api/t
export async function deleteGitBranch(directory: string, payload: import('./api/types').GitDeleteBranchPayload): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.deleteGitBranch(directory, payload);
if (runtime) return runtimeStatusMutation(directory, runtime.deleteGitBranch(directory, payload));
return gitHttp.deleteGitBranch(directory, payload);
}
export async function deleteRemoteBranch(directory: string, payload: import('./api/types').GitDeleteRemoteBranchPayload): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.deleteRemoteBranch(directory, payload);
if (runtime) return runtimeStatusMutation(directory, runtime.deleteRemoteBranch(directory, payload));
return gitHttp.deleteRemoteBranch(directory, payload);
}
@@ -855,7 +867,7 @@ export async function createGitCommit(
options: import('./api/types').CreateGitCommitOptions = {}
): Promise<import('./api/types').GitCommitResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.createGitCommit(directory, message, options);
if (runtime) return runtimeStatusMutation(directory, runtime.createGitCommit(directory, message, options));
return gitHttp.createGitCommit(directory, message, options);
}
@@ -864,7 +876,7 @@ export async function gitPush(
options: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> } = {}
): Promise<import('./api/types').GitPushResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.gitPush(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.gitPush(directory, options));
return gitHttp.gitPush(directory, options);
}
@@ -873,7 +885,7 @@ export async function gitPull(
options: import('./api/types').GitPullOptions = {}
): Promise<import('./api/types').GitPullResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.gitPull(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.gitPull(directory, options));
return gitHttp.gitPull(directory, options);
}
@@ -882,7 +894,7 @@ export async function gitFetch(
options: { remote?: string; branch?: string } = {}
): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.gitFetch(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.gitFetch(directory, options));
return gitHttp.gitFetch(directory, options);
}
@@ -900,31 +912,31 @@ export async function countGitStashFiles(directory: string, refs: string[]): Pro
export async function stashGitChanges(directory: string, options: { message?: string } = {}): Promise<{ success: boolean; created: boolean; message: string; output: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.stashGitChanges(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.stashGitChanges(directory, options));
return gitHttp.stashGitChanges(directory, options);
}
export async function applyGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.applyGitStash(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.applyGitStash(directory, options));
return gitHttp.applyGitStash(directory, options);
}
export async function popGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.popGitStash(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.popGitStash(directory, options));
return gitHttp.popGitStash(directory, options);
}
export async function dropGitStash(directory: string, options: { ref: string }): Promise<{ success: boolean; ref: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.dropGitStash(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.dropGitStash(directory, options));
return gitHttp.dropGitStash(directory, options);
}
export async function checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.checkoutBranch(directory, branch);
if (runtime) return runtimeStatusMutation(directory, runtime.checkoutBranch(directory, branch));
return gitHttp.checkoutBranch(directory, branch);
}
@@ -934,7 +946,7 @@ export async function createBranch(
startPoint?: string
): Promise<{ success: boolean; branch: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.createBranch(directory, name, startPoint);
if (runtime) return runtimeStatusMutation(directory, runtime.createBranch(directory, name, startPoint));
return gitHttp.createBranch(directory, name, startPoint);
}
@@ -944,7 +956,7 @@ export async function renameBranch(
newName: string
): Promise<{ success: boolean; branch: string }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.renameBranch(directory, oldName, newName);
if (runtime) return runtimeStatusMutation(directory, runtime.renameBranch(directory, oldName, newName));
return gitHttp.renameBranch(directory, oldName, newName);
}
@@ -1051,7 +1063,7 @@ export async function removeRemote(
payload: import('./api/types').GitRemoveRemotePayload
): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.removeRemote(directory, payload);
if (runtime) return runtimeStatusMutation(directory, runtime.removeRemote(directory, payload));
return gitHttp.removeRemote(directory, payload);
}
@@ -1060,13 +1072,13 @@ export async function rebase(
options: { onto: string }
): Promise<import('./api/types').GitRebaseResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.rebase(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.rebase(directory, options));
return gitHttp.rebase(directory, options);
}
export async function abortRebase(directory: string): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.abortRebase(directory);
if (runtime) return runtimeStatusMutation(directory, runtime.abortRebase(directory));
return gitHttp.abortRebase(directory);
}
@@ -1075,7 +1087,7 @@ export async function merge(
options: { branch: string }
): Promise<import('./api/types').GitMergeResult> {
const runtime = getRuntimeGit();
if (runtime) return runtime.merge(directory, options);
if (runtime) return runtimeStatusMutation(directory, runtime.merge(directory, options));
return gitHttp.merge(directory, options);
}
@@ -1084,7 +1096,7 @@ export async function checkoutCommit(
hash: string
): Promise<import('./api/types').CheckoutCommitResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.checkoutCommit(directory, hash);
if (runtime) return runtimeStatusMutation(directory, runtime.checkoutCommit(directory, hash));
return gitHttp.checkoutCommit(directory, hash);
}
@@ -1093,7 +1105,7 @@ export async function cherryPick(
hash: string
): Promise<import('./api/types').CherryPickResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.cherryPick(directory, hash);
if (runtime) return runtimeStatusMutation(directory, runtime.cherryPick(directory, hash));
return gitHttp.cherryPick(directory, hash);
}
@@ -1102,7 +1114,7 @@ export async function revertCommit(
hash: string
): Promise<import('./api/types').RevertCommitResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.revertCommit(directory, hash);
if (runtime) return runtimeStatusMutation(directory, runtime.revertCommit(directory, hash));
return gitHttp.revertCommit(directory, hash);
}
@@ -1113,25 +1125,25 @@ export async function resetToCommit(
force?: boolean
): Promise<import('./api/types').ResetToCommitResponse> {
const runtime = getRuntimeGit();
if (runtime) return runtime.resetToCommit(directory, hash, mode, force);
if (runtime) return runtimeStatusMutation(directory, runtime.resetToCommit(directory, hash, mode, force));
return gitHttp.resetToCommit(directory, hash, mode, force);
}
export async function abortMerge(directory: string): Promise<{ success: boolean }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.abortMerge(directory);
if (runtime) return runtimeStatusMutation(directory, runtime.abortMerge(directory));
return gitHttp.abortMerge(directory);
}
export async function continueRebase(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.continueRebase(directory);
if (runtime) return runtimeStatusMutation(directory, runtime.continueRebase(directory));
return gitHttp.continueRebase(directory);
}
export async function continueMerge(directory: string): Promise<{ success: boolean; conflict: boolean; conflictFiles?: string[] }> {
const runtime = getRuntimeGit();
if (runtime) return runtime.continueMerge(directory);
if (runtime) return runtimeStatusMutation(directory, runtime.continueMerge(directory));
return gitHttp.continueMerge(directory);
}
+15 -4
View File
@@ -28,6 +28,7 @@ import {
unstageGitFile,
unstageGitFiles,
} from './gitApiHttp';
import type { GitStatus } from './api/types';
type FetchCall = {
input: RequestInfo | URL;
@@ -189,7 +190,7 @@ describe('gitApiHttp status cache', () => {
});
});
const statusPayload = (overrides: Record<string, unknown> = {}) => ({
const statusPayload = (overrides: Partial<GitStatus> = {}): GitStatus => ({
current: 'main',
tracking: null,
ahead: 0,
@@ -199,16 +200,21 @@ const statusPayload = (overrides: Record<string, unknown> = {}) => ({
...overrides,
});
const jsonResponse = (payload: unknown) => new Response(JSON.stringify(payload), {
const jsonResponse = <T>(payload: T) => new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
const installStatusMutationFetchMock = () => {
// SAFETY: `statusUrls` starts empty and only ever receives request URLs, which
// are strings; the annotation names that element type up front.
const mock = {
statusUrls: [] as string[],
behind: 0,
};
// SAFETY: the mock receives only the (input, init) pair production code passes
// and always resolves to a Response, so it honours the fetch contract; the
// assertion supplies the overload signatures a plain arrow function cannot.
globalThis.fetch = (async (input) => {
const url = String(input);
if (url.startsWith('/api/git/status')) {
@@ -225,9 +231,9 @@ const installStatusMutationFetchMock = () => {
* read issues a fresh request that observes the post-mutation state instead of
* serving the pre-mutation cache entry.
*/
const expectStatusInvalidatedBy = async (
const expectStatusInvalidatedBy = async <T>(
directory: string,
mutate: () => Promise<unknown>
mutate: () => Promise<T>
): Promise<void> => {
const mock = installStatusMutationFetchMock();
@@ -310,6 +316,8 @@ describe('gitApiHttp post-mutation status invalidation (#2281)', () => {
test('a failed mutation does not invalidate cached status', async () => {
installWindowMock();
const statusUrls: string[] = [];
// SAFETY: see installStatusMutationFetchMock - the mock honours the fetch
// contract; the assertion supplies its overload signatures.
globalThis.fetch = (async (input) => {
const url = String(input);
if (url.startsWith('/api/git/status')) {
@@ -330,6 +338,7 @@ describe('gitApiHttp post-mutation status invalidation (#2281)', () => {
await checkoutBranch(directory, 'feature');
});
expect(error).toBeInstanceOf(Error);
// SAFETY: the assertion above established that `error` is an Error.
expect((error as Error).message).toBe('checkout failed');
await getGitStatus(directory);
@@ -343,6 +352,8 @@ describe('gitApiHttp post-mutation status invalidation (#2281)', () => {
installWindowMock();
const statusResolvers: Array<(response: Response) => void> = [];
const statusUrls: string[] = [];
// SAFETY: see installStatusMutationFetchMock - the mock honours the fetch
// contract; the assertion supplies its overload signatures.
globalThis.fetch = (async (input) => {
const url = String(input);
if (url.startsWith('/api/git/status')) {
+4
View File
@@ -74,6 +74,10 @@ const invalidateGitStatusCache = (directory: string): void => {
// 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 <T>(directory: string, response: Response): Promise<T> => {
// SAFETY: every caller rejects non-ok responses before reaching here, and on
// success each git route returns the body declared by that route's return
// type in `./api/types`. The assertion names that per-route contract; there is
// no narrower type available at this shared success path.
const result = await response.json() as T;
invalidateGitStatusCache(directory);
return result;
+11 -10
View File
@@ -1,17 +1,18 @@
/**
* 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.
* Every successful status-affecting git mutation must call
* `notifyGitStatusInvalidated`. `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.
* Runtime parity: this is about the store's in-flight status request, not about
* adapter caching, so it applies to every runtime. The HTTP adapter in
* `gitApiHttp.ts` emits it where it clears its own cache; runtime adapters (the
* VS Code bridge) have no cache of their own, so the dispatch layer in
* `gitApi.ts` emits it for them after a successful runtime mutation. Either
* path announces a mutation exactly once.
*/
type GitStatusInvalidationListener = (directory: string) => void;
+35
View File
@@ -914,6 +914,41 @@ describe('unload lifecycle flush (#2197)', () => {
}
});
test('sends the unload flush with keepalive so the browser cannot cancel it', async () => {
// No runtime settings API: the write has to take the HTTP branch, which is
// the one the browser cancels on unload without `keepalive`.
registerRuntimeAPIs(null);
const inits: RequestInit[] = [];
const previousFetch = globalThis.fetch;
// SAFETY: the mock receives only the (input, init) pair production code
// passes and always resolves to a Response; the assertion supplies the
// overload signatures a plain arrow function cannot declare.
globalThis.fetch = (async (_input, init) => {
inits.push(init ?? {});
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}) as typeof fetch;
try {
const update = updateDesktopSettings({ gitChangesViewMode: 'flat' });
getWindow().dispatchEvent(new Event('pagehide'));
await update;
await delay(50);
expect(inits).toHaveLength(1);
expect(inits[0].method).toBe('PUT');
expect(inits[0].keepalive).toBe(true);
// The ordinary debounced write stays a plain fetch.
inits.length = 0;
await updateDesktopSettings({ gitChangesViewMode: 'tree' });
await delay(300);
expect(inits).toHaveLength(1);
expect(inits[0].keepalive).toBe(false);
} finally {
globalThis.fetch = previousFetch;
}
});
test('ignores lifecycle events when no settings write is pending', async () => {
const saveCalls: Array<Partial<SettingsPayload>> = [];
registerSettingsSave(async (changes) => {
+22 -2
View File
@@ -17,6 +17,7 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { sanitizeStarterRefs } from '@/lib/draftStarters';
import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isCapacitorApp } from '@/lib/platform';
import { isTerminalShell } from '@/lib/terminalShell';
import { getRuntimeKey, subscribeRuntimeEndpointChanged, subscribeRuntimeEndpointWillChange } from '@/lib/runtime-switch';
import { DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '@/lib/theme/themes';
@@ -1784,7 +1785,12 @@ const flushPendingSettingsBeforeSuspend = (): void => {
clearTimeout(_settingsFlushTimer);
_settingsFlushTimer = null;
}
void _flushSettingsUpdate();
// `keepalive` is what makes this flush actually land: a plain fetch started
// from pagehide/beforeunload is cancelled with the document. Settings payloads
// are a few KB, far under the 64 KB keepalive budget. `navigator.sendBeacon`
// is not an option here — it cannot carry the runtime bearer header, so the
// write would be rejected as unauthenticated.
void _flushSettingsUpdate({ keepalive: true });
};
const ensureSettingsRuntimeLifecycle = (): void => {
@@ -1817,6 +1823,17 @@ const ensureSettingsRuntimeLifecycle = (): void => {
});
document.addEventListener('freeze', flushPendingSettingsBeforeSuspend);
}
// Capacitor: iOS/Android suspend the app without firing pagehide or
// beforeunload, and `visibilitychange` alone is not dependable in a
// WKWebView. `App.appStateChange` is the authoritative foreground signal on
// native (same source `usePushVisibilityBeacon` trusts), so flush there too.
if (isCapacitorApp()) {
void import('@capacitor/app')
.then(({ App }) => App.addListener('appStateChange', ({ isActive }) => {
if (!isActive) flushPendingSettingsBeforeSuspend();
}))
.catch(() => undefined);
}
} catch {
// Restricted environments can reject listeners; the debounce timer still flushes.
}
@@ -2030,7 +2047,9 @@ export const syncDesktopSettings = async (options?: { adoptWorkspace?: boolean }
};
// Coalesce rapid updateDesktopSettings calls into a single PUT
async function _flushSettingsUpdate(): Promise<void> {
// `keepalive` is set only on the lifecycle-suspend path, where the document may
// be torn down mid-request; the ordinary debounced write uses a plain fetch.
async function _flushSettingsUpdate({ keepalive = false }: { keepalive?: boolean } = {}): Promise<void> {
const changes = _pendingSettingsChanges;
const context = _pendingSettingsContext;
const revision = _pendingSettingsRevision;
@@ -2077,6 +2096,7 @@ async function _flushSettingsUpdate(): Promise<void> {
Accept: 'application/json',
},
body: JSON.stringify(changes),
keepalive,
});
if (!isSettingsRuntimeContextCurrent(context)) return;
@@ -1,38 +1,54 @@
import { describe, expect, test } from 'bun:test';
import { bundledLanguages, createHighlighter, type LanguageRegistration } from 'shiki';
import { bundledLanguages, type BundledLanguage, type LanguageRegistration } from 'shiki';
import {
hasCatastrophicTemplateCall,
isTemplateCallLanguageId,
sanitizeTemplateCallGrammar,
TEMPLATE_CALL_LANGUAGE_IDS,
} from './sanitizeTemplateCallGrammar';
import { hasCatastrophicTemplateCall, sanitizeTemplateCallGrammar } from './sanitizeTemplateCallGrammar';
type BundledLanguageModule = { default: LanguageRegistration[] };
const loadBundledGrammar = async (id: (typeof TEMPLATE_CALL_LANGUAGE_IDS)[number]): Promise<LanguageRegistration> => {
// SAFETY: `id` comes from TEMPLATE_CALL_LANGUAGE_IDS, and every Shiki bundled
// language module default-exports its grammar array.
const loadBundledGrammars = async (id: BundledLanguage): Promise<LanguageRegistration[]> => {
// SAFETY: `id` is a Shiki bundled-language key and every bundled language
// module default-exports its grammar array.
const mod = (await bundledLanguages[id]()) as BundledLanguageModule;
return mod.default[0];
return mod.default;
};
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);
for (const id of ['javascript', 'typescript', 'jsx', 'tsx'] as const) {
const [grammar] = await loadBundledGrammars(id);
expect(hasCatastrophicTemplateCall(grammar)).toBe(true);
}
});
test('a bundled alias request yields sanitized grammars too', async () => {
// `js` is a separate key in bundledLanguages resolving to the same grammar
// module; the worker sanitizes whatever id was requested, so the alias must
// come out clean as well.
const grammars = await loadBundledGrammars('js');
const patched = grammars.map((grammar) => sanitizeTemplateCallGrammar(grammar));
expect(grammars.some((grammar) => hasCatastrophicTemplateCall(grammar))).toBe(true);
expect(patched.some((grammar) => hasCatastrophicTemplateCall(grammar))).toBe(false);
});
test('an embedding grammar carries JS/TS entries that are sanitized as well', async () => {
// `vue` ships the JS/TS grammars alongside its own, so gating on the
// requested id alone would leave them unpatched.
const grammars = await loadBundledGrammars('vue');
const affected = grammars.filter((grammar) => hasCatastrophicTemplateCall(grammar));
expect(affected.length).toBeGreaterThan(0);
const patched = grammars.map((grammar) => sanitizeTemplateCallGrammar(grammar));
expect(patched.some((grammar) => hasCatastrophicTemplateCall(grammar))).toBe(false);
});
test('clears template-call patterns without dropping the repository key', async () => {
const grammar = await loadBundledGrammar('javascript');
const [grammar] = await loadBundledGrammars('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).
// Original left intact (spread, not mutate-in-place).
expect(hasCatastrophicTemplateCall(grammar)).toBe(true);
});
@@ -45,34 +61,4 @@ describe('sanitizeTemplateCallGrammar', () => {
} satisfies LanguageRegistration;
expect(sanitizeTemplateCallGrammar(grammar)).toBe(grammar);
});
test('highlights template-literal fixtures within a tight budget after sanitize', async () => {
// SAFETY: the javascript bundle default-exports its grammar array.
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\`<div class="\${account.cls}">\${account.name}</div>\`;
}
`.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 secondsminutes; healthy tokenize is well under 1s.
expect(elapsedMs).toBeLessThan(2_000);
});
});
});
@@ -35,11 +35,3 @@ export const sanitizeTemplateCallGrammar = <T extends TemplateCallGrammar>(gramm
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.some((id) => id === lang);
@@ -31,6 +31,8 @@ The default layout follows three modes: single chords for everyday actions, the
The internal `switch_tab_*` bindings remain available to mobile handlers. Desktop numeric context-surface switching is resolved by the configurable `switch_context_surface` prefix before normal dispatcher matching and falls through on mobile.
Both digit prefixes yield when the event target is editable: an input, textarea, select, or contenteditable element. `switch_session_tab` defaults to a bare `mod` prefix, so without that guard plain ctrl/cmd+digit would switch tabs while the user is typing in the composer.
The settings recorder captures up to two chords with at most three simultaneous physical keys per chord and checks the complete schema, not only customizable actions. After the first chord it waits up to 3000ms for a second; conflict and browser-risk feedback appears only when the second chord, timeout, or Confirm settles the recording. It keeps the recording local until the user clicks Confirm, allows an exact customizable conflict to replace the previous assignment, and blocks prefix conflicts unless the single-chord action explicitly allows sequence fallback. Those contextual prefixes remain saveable with a warning because their handler yields outside its owning context. Internal bindings are authoritative: persisted overrides cannot change or unassign them, and recorder conflicts with them cannot be replaced.
`add_selection_to_chat` is contextual. A visible text-selection toolbar publishes its Add to chat and dismiss actions, suspends the shared application registry, and clears both synchronously when hidden or unmounted. The main application route also gates directly on active toolbar ownership before global dispatch, so unrelated shortcuts cannot escape the scoped interaction even if runtime bundling isolates registry state. The newest visible toolbar owns a dedicated scoped dispatcher; it ignores IME composition, stops IME Escape before the global Escape route without preventing its native default, handles non-IME Escape and the configured Add to chat binding (including a two-chord binding), and lets native input continue for unrelated keys. The application handler returns `false` when no toolbar action is active, so an unselected or stale DOM range can instead become a sequence leader. Opening, closing, or replacing a toolbar invalidates any pending scoped or global prefix.
+20 -3
View File
@@ -1,18 +1,35 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { getVSCodeBootstrapConfig, isVSCodeBootstrapPresent } from './vscodeBootstrap';
interface TestWindow {
__VSCODE_CONFIG__?: { workspaceFolder: string; workspaceFolders: { name: string; path: string }[] };
}
/**
* bun test runs without a DOM, so `globalThis` has no `window` binding to
* assign through. Defining the property directly installs the stub without
* asserting that it is a real `Window`.
*/
const setTestWindow = (value: TestWindow | undefined): void => {
if (value === undefined) {
Reflect.deleteProperty(globalThis, 'window');
return;
}
Object.defineProperty(globalThis, 'window', { value, configurable: true, writable: true });
};
describe('VS Code bootstrap config', () => {
afterEach(() => {
delete (globalThis as { window?: unknown }).window;
setTestWindow(undefined);
});
test('reads extension-host __VSCODE_CONFIG__ before RuntimeAPIs exist', () => {
(globalThis as { window: unknown }).window = {
setTestWindow({
__VSCODE_CONFIG__: {
workspaceFolder: '/workspace/project-one',
workspaceFolders: [{ name: 'project-one', path: '/workspace/project-one' }],
},
};
});
expect(getVSCodeBootstrapConfig()).toEqual({
workspaceFolder: '/workspace/project-one',
+1 -1
View File
@@ -84,7 +84,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. 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 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. The unload flush uses `keepalive: true` on the HTTP write, because a plain fetch started from `pagehide`/`beforeunload` is cancelled with the document; `navigator.sendBeacon` is not used, as it cannot carry the runtime bearer header. On Capacitor neither `pagehide` nor `beforeunload` fires when the OS suspends the app, so the flush also runs on `App.appStateChange` going inactive.
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.
@@ -722,6 +722,55 @@ describe('useConfigStore provider persistence', () => {
expect(state.currentModelId).toBe('kimi-k3');
});
test('[issue-2690] setAgent persists the kept manual model for the session and agent', () => {
const sessionId = 'ses_2690_persist_kept_model';
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');
// Keeping the pair only in memory loses it on reload; the write is what
// makes the choice survive.
const selection = useSelectionStore.getState();
expect(selection.getSessionModelSelection(sessionId)).toEqual({ providerId: 'kimi', modelId: 'kimi-k3' });
expect(selection.getAgentModelForSession(sessionId, 'plan')).toEqual({ providerId: 'kimi', modelId: 'kimi-k3' });
});
test('[issue-2690] setAgent falls back to the settings default when the kept model is gone', () => {
const sessionId = 'ses_2690_stale_model';
useSessionUIStore.setState({ currentSessionId: sessionId });
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('deepseek', 'deepseek-v4-pro')],
agents: [testAgent('build'), testAgent('plan')],
settingsDefaultModel: 'deepseek/deepseek-v4-pro',
// The provider still exists but this model was removed from it.
currentProviderId: 'deepseek',
currentModelId: 'retired-model',
currentAgentName: 'build',
selectionSource: 'manual',
currentVariant: undefined,
directoryScoped: {},
});
useConfigStore.getState().setAgent('plan');
const state = useConfigStore.getState();
expect(state.currentProviderId).toBe('deepseek');
expect(state.currentModelId).toBe('deepseek-v4-pro');
});
test('loadAgents does not fetch OpenCode config directly', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
+14 -1
View File
@@ -2590,7 +2590,20 @@ export const useConfigStore = create<ConfigStore>()(
// 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) {
if (
hadManualSelection
&& currentProviderId
&& currentModelId
&& hasProviderModel(providers, currentProviderId, currentModelId)
) {
// Keeping the pair in memory is not enough: without a write
// the settings default wins again after a reload. The removed
// ModelControls path persisted here, so this must too.
if (currentSessionId) {
const selection = useSelectionStore.getState();
selection.saveSessionModelSelection(currentSessionId, currentProviderId, currentModelId);
selection.saveAgentModelForSession(currentSessionId, agentName, currentProviderId, currentModelId);
}
return;
}
+4 -1
View File
@@ -107,7 +107,10 @@ const statusMutationRevisionByDirectory = new Map<string, number>();
let gitRuntimeGeneration = 0;
let activeGitRuntimeKey = getRuntimeKey();
const runtimeDirectoryKey = (runtimeKey: string, directory: string) => JSON.stringify([runtimeKey, directory]);
// Trimmed to match `gitApiHttp`'s cache keys, so an invalidation notified for a
// directory keys the same entry the store's own lookups do.
const runtimeDirectoryKey = (runtimeKey: string, directory: string) =>
JSON.stringify([runtimeKey, directory.trim()]);
const getStatusFetchKey = (runtimeKey: string, directory: string, mode: GitStatusFetchMode): string =>
JSON.stringify([runtimeKey, directory, mode]);
const channelKey = (runtimeKey: string, directory: string, channel: string) =>
+2 -1
View File
@@ -13,7 +13,8 @@ import { PROJECT_COLORS } from '@/lib/projectMeta';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { getVSCodeBootstrapConfig, isVSCodeRuntime } from './utils/vscodeRuntime';
import { getVSCodeBootstrapConfig } from '@/lib/vscodeBootstrap';
import { isVSCodeRuntime } from './utils/vscodeRuntime';
/** Pick a color key that's least used among existing projects */
const pickAutoColor = (projects: ProjectEntry[]): string => {
@@ -5,9 +5,6 @@ import {
type VSCodeBootstrapConfig,
} from '@/lib/vscodeBootstrap';
export type { VSCodeBootstrapConfig };
export { getVSCodeBootstrapConfig };
export const isVSCodeRuntime = (
runtimeApis: RuntimeAPIs | null,
bootstrapConfig: VSCodeBootstrapConfig | null = getVSCodeBootstrapConfig(),
@@ -3,7 +3,8 @@ 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.
* and a stale lastDirectory in storage. The directory store must settle on the
* VS Code workspace folder rather than the stale persisted directory.
*/
const WORKSPACE = '/tmp/oc-ws-project-a';
@@ -14,25 +15,62 @@ const storage = new Map<string, string>([
['homeDirectory', STALE],
]);
interface TestWindow {
__VSCODE_CONFIG__?: { workspaceFolder: string; workspaceFolders: { name: string; path: string }[] };
__OPENCHAMBER_HOME__?: string;
localStorage: Storage;
matchMedia: () => { matches: boolean };
addEventListener: () => void;
removeEventListener: () => void;
}
const testLocalStorage = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, String(value));
},
removeItem: (key: string) => {
storage.delete(key);
},
clear: () => {
storage.clear();
},
key: () => null,
length: 0,
} satisfies Storage;
/**
* bun test runs without a DOM, so `globalThis` has neither `window` nor
* `localStorage` to assign through, and these store modules read both at module
* evaluation time. Defining the properties directly installs a stub carrying
* exactly the members they touch, without asserting it is a real `Window`.
*/
const setTestWindow = (value: TestWindow | undefined): void => {
if (value === undefined) {
Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'localStorage');
return;
}
Object.defineProperty(globalThis, 'window', { value, configurable: true, writable: true });
Object.defineProperty(globalThis, 'localStorage', {
value: value.localStorage,
configurable: true,
writable: true,
});
};
const installWindow = () => {
(globalThis as { window: unknown }).window = {
setTestWindow({
__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;
localStorage: testLocalStorage,
matchMedia: () => ({ matches: false }),
addEventListener: () => undefined,
removeEventListener: () => undefined,
});
};
mock.module('@/contexts/runtimeAPIRegistry', () => ({
@@ -60,27 +98,23 @@ mock.module('@/lib/runtime-switch', () => ({
mock.module('@/stores/useFileSearchStore', () => ({
useFileSearchStore: {
getState: () => ({ clearCache: () => undefined }),
getState: () => ({ clearCache: () => undefined, invalidateDirectory: () => undefined }),
},
}));
describe('VS Code store init before RuntimeAPIs (#2359)', () => {
afterEach(() => {
delete (globalThis as { window?: unknown }).window;
delete (globalThis as { localStorage?: unknown }).localStorage;
setTestWindow(undefined);
});
test('desktop isVSCodeRuntime prefers bootstrap config', async () => {
test('directory store starts on the workspace folder, not the stale persisted directory', async () => {
installWindow();
const { isVSCodeRuntime } = await import('@/lib/desktop');
expect(isVSCodeRuntime()).toBe(true);
});
const { useDirectoryStore } = await import('@/stores/useDirectoryStore');
const state = useDirectoryStore.getState();
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);
expect(state.currentDirectory).toBe(WORKSPACE);
expect(state.homeDirectory).toBe(WORKSPACE);
expect(state.directoryHistory).toEqual([WORKSPACE]);
expect(state.currentDirectory).not.toBe(STALE);
});
});
+2 -1
View File
@@ -123,7 +123,8 @@ The following functions are internal helpers used by exported functions:
### Branches Response
- `all`: Local branches plus every branch each reachable remote reports via `ls-remote --heads`, formatted as `remotes/<remote>/<branch>`. 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`.
- `branches`: Per-branch detail keyed by branch name, as reported by `git branch`. Remote-only entries in `all` — branches `ls-remote` reported that were never fetched — have **no** entry here, because `git branch` never saw them. Consumers must treat a missing detail entry as normal and read the name from `all`.
- Never-fetched remote-only branches also have no local ref, so any operation that resolves one locally has to account for that: `checkoutBranch` fetches the single branch (`git fetch <remote> <branch>`) before creating the tracking branch, and the range helpers (`getRangeDiff`, `getRangeFiles`) reject an unresolvable ref with `Ref "<ref>" is not available locally. Fetch it before comparing.` instead of surfacing git's "ambiguous argument".
- `defaultBranches`: Each remote's default branch, keyed by remote name. Read from the local `remotes/<name>/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.
### Runtime availability of range diffs
+39 -4
View File
@@ -2599,6 +2599,25 @@ export async function getUntrackedDiffs(directory, filePaths = [], { concurrency
return results;
}
const refResolvesToCommit = async (git, ref) => git
.raw(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
.then((value) => Boolean(String(value || '').trim()))
.catch(() => false);
/**
* The branch list includes remote-only branches that `ls-remote` reported but
* the repository never fetched (#2098), so a comparison can name a ref that does
* not exist locally. Say that plainly instead of letting git's "ambiguous
* argument" surface as an opaque failure.
*/
async function assertRangeRefsResolve(git, refs) {
for (const ref of refs) {
if (!(await refResolvesToCommit(git, ref))) {
throw new Error(`Ref "${ref}" is not available locally. Fetch it before comparing.`);
}
}
}
export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3 } = {}) {
const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory);
const baseRef = typeof base === 'string' ? base.trim() : '';
@@ -2641,6 +2660,8 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
}
}
await assertRangeRefsResolve(git, [resolvedBase, headRef]);
const args = ['diff', '--no-color'];
if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) {
args.push(`-U${Math.max(0, contextLines)}`);
@@ -2741,6 +2762,8 @@ export async function getRangeFiles(directory, { base, head } = {}) {
// ignore
}
await assertRangeRefsResolve(git, [resolvedBase, headRef]);
// `-C` (copy detection among changed files only, so cheap) makes copies
// surface as C entries instead of plain additions; rename detection is on
// by default.
@@ -3824,10 +3847,6 @@ const resolveBranchCheckoutTarget = async (git, branchName) => {
}
const remoteRef = requested.replace(/^remotes\//, '');
if (!(await gitRefExists(git, `refs/remotes/${remoteRef}`))) {
return asRequested;
}
const remotes = await git.getRemotes();
const remote = remotes.find((entry) => entry?.name && remoteRef.startsWith(`${entry.name}/`));
if (!remote) {
@@ -3840,6 +3859,22 @@ const resolveBranchCheckoutTarget = async (git, branchName) => {
return asRequested;
}
// The branch list also carries branches that only `ls-remote` knows about
// (#2098): they exist on the remote but were never fetched, so there is no
// remote-tracking ref and a literal checkout fails with a pathspec error.
// Fetch the single branch first so the tracking ref exists, then fall through
// to the normal create-with-tracking path.
if (!(await gitRefExists(git, `refs/remotes/${remoteRef}`))) {
try {
await git.fetch(remote.name, localBranch);
} catch (error) {
throw new Error(`Failed to fetch ${localBranch} from ${remote.name}: ${error?.message || error}`);
}
if (!(await gitRefExists(git, `refs/remotes/${remoteRef}`))) {
throw new Error(`Branch ${localBranch} no longer exists on remote ${remote.name}`);
}
}
const localExists = await gitRefExists(git, `refs/heads/${localBranch}`);
return { branch: localBranch, remoteRef: localExists ? null : remoteRef };
};
@@ -1111,6 +1111,32 @@ describe('checkoutBranch', () => {
const { repository } = createRepositoryWithRemote();
await expect(checkoutBranch(repository, 'does-not-exist')).rejects.toThrow();
});
it('fetches a remote-only branch that was never fetched locally (#2735)', async () => {
const { repository, remote } = createRepositoryWithRemote({ defaultBranch: 'react' });
// A collaborator pushes straight to the remote; this repository never
// fetches, so `remotes/origin/collab` is listed (#2098) with no local ref.
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', 'collab']);
runGit(collaborator, ['push', 'origin', 'collab']);
const result = await checkoutBranch(repository, 'remotes/origin/collab');
expect(result).toEqual({ success: true, branch: 'collab' });
expect(runGit(repository, ['rev-parse', '--abbrev-ref', 'HEAD']).trim()).toBe('collab');
expect(runGit(repository, ['rev-parse', '--abbrev-ref', 'collab@{upstream}']).trim()).toBe('origin/collab');
});
it('reports a clear failure when the remote branch no longer exists', async () => {
const { repository } = createRepositoryWithRemote({ defaultBranch: 'react' });
await expect(checkoutBranch(repository, 'remotes/origin/never-pushed')).rejects.toThrow(
/Failed to fetch never-pushed from origin/
);
});
});
// ---------------------------------------------------------------------------
@@ -1485,6 +1511,14 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
expect(diff).toContain('feature.txt');
});
it('names an unfetched remote-only ref instead of failing with git\'s ambiguous argument (#2735)', async () => {
const { repository } = createRepositoryWithRemote({ defaultBranch: 'react' });
await expect(
getRangeDiff(repository, { base: 'remotes/origin/never-fetched', head: 'next' })
).rejects.toThrow(/is not available locally/);
});
});
describe('parseBranchCreationSource', () => {
@@ -147,7 +147,10 @@ other runtime API.
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).
are not launched with this env). The injected `small_model` is baked into
`OPENCODE_CONFIG_CONTENT` when the managed process spawns, so changing the
override in Settings applies on the next managed OpenCode restart, not to
the process already running.
## Which providers the pickers may offer