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 after9b9d7069c) - project switcher names are left-aligned again (wrapper lost in26dbc2f30) - 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:
@@ -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 150–200% 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 & {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user