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')