From a3af35af19c21369b27bcbb706acf77fa016f7db Mon Sep 17 00:00:00 2001 From: ecokayiza Date: Wed, 16 Sep 2026 15:29:16 +0800 Subject: [PATCH 1/5] Cut streaming render cost A streamed answer arrives as a series of cumulative snapshots and every snapshot re-rendered the whole answer, so a long reply re-parsed from the top on each chunk. Coalesce the chunks into one render per animation frame (`answer-buffer.mjs`) and always flush the newest text before the answer is finalized. Buffered frames are dropped when the card unmounts, is cleared, or starts a new question, so a stale frame cannot resurrect an old answer. The buffer falls back to a timer where the host has no animation frames (jsdom and other headless renderers), which keeps the lifecycle tests working. Syntax auto-detection is limited to a common-language subset in `highlight-options.mjs`. `detect: true` compiles every registered grammar on first use; narrowing the scan keeps auto-detection and drops that cost. Explicitly labelled blocks are unaffected. Measured on a 394-character answer delivered as 19 chunks of 20 characters: - First code block on a cold page: ~132 ms before, ~4 ms after. - A burst: 48.6 ms of CPU over 19 renders before, 11.0 ms over 6 after. - A slow stream (60 ms per chunk): 48.6 ms before, 29.6 ms after. --- .../ConversationCard/answer-buffer.mjs | 71 +++++++++ src/components/ConversationCard/index.jsx | 21 ++- .../MarkdownRender/highlight-options.mjs | 34 +++++ .../MarkdownRender/markdown-without-katex.jsx | 13 +- src/components/MarkdownRender/markdown.jsx | 14 +- tests/unit/components/answer-buffer.test.mjs | 139 ++++++++++++++++++ .../components/highlight-options.test.mjs | 62 ++++++++ 7 files changed, 330 insertions(+), 24 deletions(-) create mode 100644 src/components/ConversationCard/answer-buffer.mjs create mode 100644 src/components/MarkdownRender/highlight-options.mjs create mode 100644 tests/unit/components/answer-buffer.test.mjs create mode 100644 tests/unit/components/highlight-options.test.mjs diff --git a/src/components/ConversationCard/answer-buffer.mjs b/src/components/ConversationCard/answer-buffer.mjs new file mode 100644 index 000000000..13f8341f8 --- /dev/null +++ b/src/components/ConversationCard/answer-buffer.mjs @@ -0,0 +1,71 @@ +/** + * Animation frames are the fastest way to coalesce a burst of updates, but not every host + * has them (jsdom and other headless renderers do not). Fall back to a timer there so the + * buffer can always be created. + * @param {typeof globalThis} [host] + * @returns {{requestFrame: (callback: () => void) => unknown, cancelFrame: (handle: unknown) => void}} + */ +export function createFrameScheduler(host = globalThis) { + if ( + typeof host.requestAnimationFrame === 'function' && + typeof host.cancelAnimationFrame === 'function' + ) { + return { + requestFrame: (callback) => host.requestAnimationFrame(callback), + cancelFrame: (handle) => host.cancelAnimationFrame(handle), + } + } + return { + requestFrame: (callback) => setTimeout(callback, 16), + cancelFrame: (handle) => clearTimeout(handle), + } +} + +/** + * Coalesces streamed answer text so a burst of chunks renders once per frame instead of + * once per chunk, while never losing the newest text. + * @param {object} params + * @param {(callback: () => void) => unknown} params.requestFrame + * @param {(handle: unknown) => void} params.cancelFrame + * @param {(answer: string) => void} params.render + */ +export function createAnswerBuffer({ requestFrame, cancelFrame, render }) { + let pending = null + let frame = null + + const cancelPendingFrame = () => { + if (frame === null) return + cancelFrame(frame) + frame = null + } + + const takePending = () => { + const answer = pending + pending = null + return answer + } + + return { + /** Queue the newest answer, scheduling a render only when none is already scheduled. */ + push(answer) { + pending = answer + if (frame !== null) return + frame = requestFrame(() => { + frame = null + const latest = takePending() + if (latest !== null) render(latest) + }) + }, + /** Render the newest answer before the conversation is finalized. */ + flush() { + cancelPendingFrame() + const latest = takePending() + if (latest !== null) render(latest) + }, + /** Drop the newest answer without rendering it, e.g. when a retry starts. */ + discard() { + cancelPendingFrame() + pending = null + }, + } +} diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index dbba60187..d6fd3c64a 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -50,6 +50,7 @@ import { isSupersededGenerationMessage, isSupersededRequestMessage, } from './session.mjs' +import { createAnswerBuffer, createFrameScheduler } from './answer-buffer.mjs' const logo = Browser.runtime.getURL('logo.png') const UNMATCHED_API_MODE_VALUE = '__current-session-api-mode__' @@ -221,6 +222,19 @@ function ConversationCard(props) { }) } + const answerBufferRef = useRef(null) + if (answerBufferRef.current === null) { + answerBufferRef.current = createAnswerBuffer({ + ...createFrameScheduler(), + render: (answer) => updateAnswer(answer, false, 'answer'), + }) + } + + // A buffered frame can outlive a hidden page, so drop it when the card goes away. + useEffect(() => { + return () => answerBufferRef.current?.discard() + }, []) + const portMessageListener = (msg) => { if (disposedRef.current) return if (isSupersededRequestMessage(msg, requestGenerationIdRef.current)) return @@ -228,12 +242,13 @@ function ConversationCard(props) { if (msg.answer) { partialAnswerRef.current = msg.answer - updateAnswer(msg.answer, false, 'answer') + answerBufferRef.current.push(msg.answer) } if (msg.session) { setSession(msg.done ? { ...msg.session, isRetry: false } : msg.session) } if (msg.done) { + answerBufferRef.current.flush() const partialAnswer = partialAnswerRef.current const retryRecord = retryRecordRef.current const completionState = getInterruptedCompletionState(msg, partialAnswer, retryRecord) @@ -249,6 +264,7 @@ function ConversationCard(props) { setIsReady(true) } if (msg.error) { + answerBufferRef.current.flush() const retryRecord = retryRecordRef.current setSession((currentSession) => finalizeInterruptedSession(currentSession, '', retryRecord)) switch (msg.error) { @@ -479,6 +495,7 @@ function ConversationCard(props) { }, [port, conversationItemData]) const getRetryFn = (session) => async () => { + answerBufferRef.current.discard() updateAnswer(`

${t('Waiting for response...')}

`, false, 'answer') setIsReady(false) @@ -671,6 +688,7 @@ function ConversationCard(props) { } partialAnswerRef.current = '' retryRecordRef.current = null + answerBufferRef.current.discard() Browser.runtime.sendMessage({ type: 'DELETE_CONVERSATION', data: { @@ -797,6 +815,7 @@ function ConversationCard(props) { ) partialAnswerRef.current = '' retryRecordRef.current = null + answerBufferRef.current.discard() setConversationItemData([...conversationItemData, newQuestion, newAnswer]) setIsReady(false) diff --git a/src/components/MarkdownRender/highlight-options.mjs b/src/components/MarkdownRender/highlight-options.mjs new file mode 100644 index 000000000..f8ffb937f --- /dev/null +++ b/src/components/MarkdownRender/highlight-options.mjs @@ -0,0 +1,34 @@ +/** + * Shared rehype-highlight options. + * + * Auto-detection compiles every registered grammar the first time it runs, which costs + * >100ms on a cold page. Scanning only common languages keeps that near 3ms. Explicitly + * labelled code blocks are unaffected: the subset only narrows automatic detection, they + * still use every grammar lowlight has registered. + */ +export const highlightOptions = { + detect: true, + subset: [ + 'bash', + 'c', + 'cpp', + 'csharp', + 'css', + 'go', + 'java', + 'javascript', + 'json', + 'kotlin', + 'php', + 'python', + 'ruby', + 'rust', + 'sql', + 'swift', + 'typescript', + 'xml', + 'yaml', + ], + ignoreMissing: true, + plainText: ['diagnostic'], +} diff --git a/src/components/MarkdownRender/markdown-without-katex.jsx b/src/components/MarkdownRender/markdown-without-katex.jsx index 0733b7129..498bcc673 100644 --- a/src/components/MarkdownRender/markdown-without-katex.jsx +++ b/src/components/MarkdownRender/markdown-without-katex.jsx @@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { Pre } from './Pre' import { Hyperlink } from './Hyperlink' +import { highlightOptions } from './highlight-options.mjs' import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -176,17 +177,7 @@ export function MarkdownRender(props) { ]} unwrapDisallowed={true} remarkPlugins={[remarkGfm, remarkBreaks]} - rehypePlugins={[ - rehypeRaw, - [ - rehypeHighlight, - { - detect: true, - ignoreMissing: true, - plainText: ['diagnostic'], - }, - ], - ]} + rehypePlugins={[rehypeRaw, [rehypeHighlight, highlightOptions]]} components={{ a: Hyperlink, pre: Pre, diff --git a/src/components/MarkdownRender/markdown.jsx b/src/components/MarkdownRender/markdown.jsx index f9cb8eb3a..8f92ac47b 100644 --- a/src/components/MarkdownRender/markdown.jsx +++ b/src/components/MarkdownRender/markdown.jsx @@ -8,6 +8,7 @@ import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { Pre } from './Pre' import { Hyperlink } from './Hyperlink' +import { highlightOptions } from './highlight-options.mjs' import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -179,18 +180,7 @@ export function MarkdownRender(props) { ]} unwrapDisallowed={true} remarkPlugins={[remarkMath, remarkGfm, remarkBreaks]} - rehypePlugins={[ - rehypeKatex, - rehypeRaw, - [ - rehypeHighlight, - { - detect: true, - ignoreMissing: true, - plainText: ['diagnostic'], - }, - ], - ]} + rehypePlugins={[rehypeKatex, rehypeRaw, [rehypeHighlight, highlightOptions]]} components={{ a: Hyperlink, pre: Pre, diff --git a/tests/unit/components/answer-buffer.test.mjs b/tests/unit/components/answer-buffer.test.mjs new file mode 100644 index 000000000..3e2749354 --- /dev/null +++ b/tests/unit/components/answer-buffer.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + createAnswerBuffer, + createFrameScheduler, +} from '../../../src/components/ConversationCard/answer-buffer.mjs' + +function createFakeFrames() { + let nextHandle = 1 + const callbacks = new Map() + const cancelled = [] + + return { + cancelled, + requestFrame(callback) { + const handle = nextHandle++ + callbacks.set(handle, callback) + return handle + }, + cancelFrame(handle) { + cancelled.push(handle) + callbacks.delete(handle) + }, + runFrames() { + const pending = [...callbacks.values()] + callbacks.clear() + for (const callback of pending) callback() + }, + } +} + +function setup() { + const frames = createFakeFrames() + const renders = [] + const buffer = createAnswerBuffer({ + requestFrame: frames.requestFrame, + cancelFrame: frames.cancelFrame, + render: (answer) => renders.push(answer), + }) + return { frames, renders, buffer } +} + +test('a burst of chunks renders once per frame with only the newest text', () => { + const { frames, renders, buffer } = setup() + + buffer.push('a') + buffer.push('ab') + buffer.push('abc') + assert.deepEqual(renders, []) + + frames.runFrames() + + assert.deepEqual(renders, ['abc']) +}) + +test('a chunk arriving after a frame schedules the next render', () => { + const { frames, renders, buffer } = setup() + + buffer.push('a') + frames.runFrames() + buffer.push('ab') + frames.runFrames() + + assert.deepEqual(renders, ['a', 'ab']) +}) + +test('flush renders the newest chunk so completion cannot drop the last one', () => { + const { frames, renders, buffer } = setup() + + buffer.push('a') + buffer.push('ab') + buffer.flush() + assert.deepEqual(renders, ['ab']) + assert.equal(frames.cancelled.length, 1) + + frames.runFrames() + assert.deepEqual(renders, ['ab'], 'the cancelled frame must not render again') +}) + +test('flush without pending text does not render', () => { + const { renders, buffer } = setup() + + buffer.flush() + + assert.deepEqual(renders, []) +}) + +test('discard drops the pending text without rendering it', () => { + const { frames, renders, buffer } = setup() + + buffer.push('a') + buffer.discard() + frames.runFrames() + + assert.deepEqual(renders, []) +}) + +test('pushing after a discard schedules a fresh frame', () => { + const { frames, renders, buffer } = setup() + + buffer.push('a') + buffer.discard() + buffer.push('b') + frames.runFrames() + + assert.deepEqual(renders, ['b']) +}) + +test('createFrameScheduler uses the host animation frames when available', () => { + const requested = [] + const cancelled = [] + const scheduler = createFrameScheduler({ + requestAnimationFrame: (callback) => { + requested.push(callback) + return 7 + }, + cancelAnimationFrame: (handle) => cancelled.push(handle), + }) + + const handle = scheduler.requestFrame(() => {}) + scheduler.cancelFrame(handle) + + assert.equal(requested.length, 1) + assert.deepEqual(cancelled, [7]) +}) + +test('createFrameScheduler falls back to a timer without animation frames', async () => { + const scheduler = createFrameScheduler({}) + const renders = [] + + await new Promise((resolve) => { + scheduler.requestFrame(() => { + renders.push('tick') + resolve() + }) + }) + + assert.deepEqual(renders, ['tick']) +}) diff --git a/tests/unit/components/highlight-options.test.mjs b/tests/unit/components/highlight-options.test.mjs new file mode 100644 index 000000000..31ad92ccc --- /dev/null +++ b/tests/unit/components/highlight-options.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { unified } from 'unified' +import remarkParse from 'remark-parse' +import remarkRehype from 'remark-rehype' +import rehypeHighlight from 'rehype-highlight' +import { highlightOptions } from '../../../src/components/MarkdownRender/highlight-options.mjs' + +async function renderCodeNodes(markdown) { + const processor = unified() + .use(remarkParse) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeHighlight, highlightOptions) + + const tree = await processor.run(processor.parse(markdown)) + const nodes = [] + const walk = (node) => { + if (node.type === 'element' && node.tagName === 'code') nodes.push(node) + for (const child of node.children ?? []) walk(child) + } + walk(tree) + return nodes +} + +function classNames(node) { + return node.properties?.className ?? [] +} + +test('auto-detection still labels blocks from the configured subset', async () => { + const [code] = await renderCodeNodes('```\nconst value = computeSomething(alpha, beta)\n```\n') + + const detected = classNames(code).find((name) => name.startsWith('language-')) + assert.ok( + detected && highlightOptions.subset.includes(detected.replace('language-', '')), + `expected a detection within the subset, got ${classNames(code).join(', ')}`, + ) + // A failure inside highlightAuto is swallowed by ignoreMissing, so token spans are the + // evidence that the whole subset resolved. + assert.ok( + code.children.some((child) => classNames(child).some((name) => name.startsWith('hljs-'))), + 'expected token spans from auto-detection', + ) +}) + +test('the subset only narrows detection, not labelled blocks', async () => { + // `lua` is registered by lowlight but deliberately left out of the detection subset. + assert.equal(highlightOptions.subset.includes('lua'), false) + + const [code] = await renderCodeNodes('```lua\nlocal value = 1\n```\n') + + assert.ok( + code.children.some((child) => classNames(child).some((name) => name.startsWith('hljs-'))), + 'expected token spans for a labelled language outside the detection subset', + ) +}) + +test('an unknown language label is ignored instead of failing the render', async () => { + const [code] = await renderCodeNodes('```not-a-language\nconst value = 1\n```\n') + + assert.equal(code.children.length, 1) + assert.equal(code.children[0].type, 'text') +}) From 815a0754544935b7d8be3f5921e19a21bf54336b Mon Sep 17 00:00:00 2001 From: Ecokayiza Date: Sat, 3 Oct 2026 18:35:20 +0800 Subject: [PATCH 2/5] Keep common languages in syntax auto-detection The detection subset was meant to avoid compiling every grammar on first use, but it dropped languages that show up unlabelled in answers. A `diff` block rendered as `css`, `lua` as `sql`, `ini` as `csharp`, and `markdown` and `perl` as `bash`. Use lowlight's registered set minus the entries that only mislead detection: `shell` (a `bash` alias), `plaintext`/`python-repl`/ `php-template` (not useful for detection), and `arduino`/`objectivec`/ `vbnet`/`wasm` (rare in answers, and they win ambiguous `c`, `cpp`, `ini` and `sql` matches away). Keep lowlight's registration order, because `highlightAuto` settles equal-relevance candidates by subset order. On the sampled answers detection now matches or beats the full set while still compiling well under half the grammars. --- .../MarkdownRender/highlight-options.mjs | 23 +++++++++++++++--- .../components/highlight-options.test.mjs | 24 ++++++++++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/components/MarkdownRender/highlight-options.mjs b/src/components/MarkdownRender/highlight-options.mjs index f8ffb937f..075ef8da5 100644 --- a/src/components/MarkdownRender/highlight-options.mjs +++ b/src/components/MarkdownRender/highlight-options.mjs @@ -2,9 +2,16 @@ * Shared rehype-highlight options. * * Auto-detection compiles every registered grammar the first time it runs, which costs - * >100ms on a cold page. Scanning only common languages keeps that near 3ms. Explicitly - * labelled code blocks are unaffected: the subset only narrows automatic detection, they - * still use every grammar lowlight has registered. + * >100ms on a cold page. Scanning a curated subset keeps that near a few milliseconds. + * Explicitly labelled code blocks are unaffected: the subset only narrows automatic + * detection, they still use every grammar lowlight has registered. + * + * The subset is lowlight's registered set minus entries that only mislead detection: + * `shell` (a `bash` alias), `plaintext`/`python-repl`/`php-template` (not useful for + * detection), and `arduino`/`objectivec`/`vbnet`/`wasm` (rare in answers, and they win + * ambiguous `c`, `cpp`, `ini` and `sql` matches away from the right language). Keep the + * order as lowlight registers them: `highlightAuto` settles equal-relevance candidates by + * subset order, so reordering this list changes which language an ambiguous block detects as. */ export const highlightOptions = { detect: true, @@ -14,15 +21,25 @@ export const highlightOptions = { 'cpp', 'csharp', 'css', + 'diff', 'go', + 'graphql', + 'ini', 'java', 'javascript', 'json', 'kotlin', + 'less', + 'lua', + 'makefile', + 'markdown', + 'perl', 'php', 'python', + 'r', 'ruby', 'rust', + 'scss', 'sql', 'swift', 'typescript', diff --git a/tests/unit/components/highlight-options.test.mjs b/tests/unit/components/highlight-options.test.mjs index 31ad92ccc..fca17cec9 100644 --- a/tests/unit/components/highlight-options.test.mjs +++ b/tests/unit/components/highlight-options.test.mjs @@ -43,10 +43,10 @@ test('auto-detection still labels blocks from the configured subset', async () = }) test('the subset only narrows detection, not labelled blocks', async () => { - // `lua` is registered by lowlight but deliberately left out of the detection subset. - assert.equal(highlightOptions.subset.includes('lua'), false) + // `objectivec` is registered by lowlight but deliberately left out of the detection subset. + assert.equal(highlightOptions.subset.includes('objectivec'), false) - const [code] = await renderCodeNodes('```lua\nlocal value = 1\n```\n') + const [code] = await renderCodeNodes('```objectivec\nNSString *value = @"hi";\n```\n') assert.ok( code.children.some((child) => classNames(child).some((name) => name.startsWith('hljs-'))), @@ -54,6 +54,24 @@ test('the subset only narrows detection, not labelled blocks', async () => { ) }) +test('auto-detection keeps the common unlabelled languages', async () => { + const cases = [ + ['diff', 'diff --git a/x b/x\n@@ -1,3 +1,3 @@\n-old line\n+new line'], + ['markdown', '# Title\n\nSome *emphasis* and a [link](http://example.com).\n\n- item'], + ['lua', 'local function add(a, b)\n return a + b\nend\nprint(add(1, 2))'], + ] + + for (const [language, snippet] of cases) { + const [code] = await renderCodeNodes(`\`\`\`\n${snippet}\n\`\`\`\n`) + const detected = classNames(code).find((name) => name.startsWith('language-')) + assert.equal( + detected, + `language-${language}`, + `expected ${language} to stay auto-detectable, got ${classNames(code).join(', ')}`, + ) + } +}) + test('an unknown language label is ignored instead of failing the render', async () => { const [code] = await renderCodeNodes('```not-a-language\nconst value = 1\n```\n') From f2fae2f7a60c82f7712170d0758cc6d3fc1cd759 Mon Sep 17 00:00:00 2001 From: Ecokayiza Date: Sat, 3 Oct 2026 18:35:20 +0800 Subject: [PATCH 3/5] Discard buffered streamed frames when the card switches question The `props.question` effect reset the partial answer but left the answer buffer alone, so a frame queued by the previous answer could render after the new question started. `FloatingToolbar` and `DecisionCard` reuse the card for a new question, so the path is reachable. Also cover the wiring the unit tests missed: a burst stays buffered until completion flushes it, switching the question drops the previous answer, and the scheduler's timer fallback cancels a pending frame. --- src/components/ConversationCard/index.jsx | 1 + ...nversation-card-lifecycle-loader-hooks.mjs | 9 +++- tests/unit/components/answer-buffer.test.mjs | 11 +++++ .../conversation-card-lifecycle.test.mjs | 48 +++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index d6fd3c64a..9f34d4f97 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -188,6 +188,7 @@ function ConversationCard(props) { const newSession = initSession({ ...session, question: props.question }) partialAnswerRef.current = '' retryRecordRef.current = null + answerBufferRef.current.discard() setSession(newSession) await postMessage({ session: newSession }) } diff --git a/tests/setup/conversation-card-lifecycle-loader-hooks.mjs b/tests/setup/conversation-card-lifecycle-loader-hooks.mjs index 2cd6889c0..f6c36fa7e 100644 --- a/tests/setup/conversation-card-lifecycle-loader-hooks.mjs +++ b/tests/setup/conversation-card-lifecycle-loader-hooks.mjs @@ -40,7 +40,14 @@ const sources = { return null } `, - 'test:conversation-item': 'export default function ConversationItem() { return null }', + 'test:conversation-item': ` + export default function ConversationItem(props) { + if (props.type === 'answer') { + globalThis.__CONVERSATION_LIFECYCLE_TEST__.answerContents.push(props.content) + } + return null + } + `, 'test:conversation-utils': ` export const apiModeToModelName = () => 'test-model' export const createElementAtPosition = () => document.createElement('div') diff --git a/tests/unit/components/answer-buffer.test.mjs b/tests/unit/components/answer-buffer.test.mjs index 3e2749354..2cad9e5f8 100644 --- a/tests/unit/components/answer-buffer.test.mjs +++ b/tests/unit/components/answer-buffer.test.mjs @@ -137,3 +137,14 @@ test('createFrameScheduler falls back to a timer without animation frames', asyn assert.deepEqual(renders, ['tick']) }) + +test('the timer fallback cancels a pending frame', async () => { + const scheduler = createFrameScheduler({}) + const renders = [] + const handle = scheduler.requestFrame(() => renders.push('tick')) + + scheduler.cancelFrame(handle) + await new Promise((resolve) => setTimeout(resolve, 32)) + + assert.deepEqual(renders, []) +}) diff --git a/tests/unit/components/conversation-card-lifecycle.test.mjs b/tests/unit/components/conversation-card-lifecycle.test.mjs index fb5d37e39..d17a26c4e 100644 --- a/tests/unit/components/conversation-card-lifecycle.test.mjs +++ b/tests/unit/components/conversation-card-lifecycle.test.mjs @@ -126,6 +126,7 @@ const resetState = () => { state.generateAnswersCount += 1 } state.runtimeOnMessage.clear() + state.answerContents = [] } const mountCard = (container, props = {}) => { @@ -730,3 +731,50 @@ test('provider failure disconnects fake Port and removes stale listeners', async act(() => render(null, container)) }) + +test('a burst of streamed chunks stays buffered until completion flushes it', () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + const session = { + ...baseSession(), + question: 'why?', + conversationRecords: [{ question: 'why?', answer: 'partial' }], + } + + mountCard(container, { question: 'why?', session }) + const port = state.ports[0] + + act(() => port.onMessage.trigger({ answer: 'a' })) + act(() => port.onMessage.trigger({ answer: 'ab' })) + assert.equal(state.answerContents.includes('ab'), false, 'the burst must not render per chunk') + + act(() => port.onMessage.trigger({ answer: 'abc', done: true, session })) + + assert.equal(state.answerContents.includes('abc'), true, 'completion must flush the newest chunk') +}) + +test('switching the question drops a buffered answer from the previous one', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + + mountCard(container, { question: 'first', session: { ...baseSession(), question: 'first' } }) + const port = state.ports[0] + + act(() => port.onMessage.trigger({ answer: 'stale answer' })) + + act(() => { + render( + h(ConversationCard, { + session: { ...baseSession(), question: 'second' }, + question: 'second', + }), + container, + ) + }) + + await new Promise((resolve) => setTimeout(resolve, 32)) + + assert.equal(state.answerContents.includes('stale answer'), false) +}) From 7d73b325bc0a97f0e6ccff9e5a30f3431f100404 Mon Sep 17 00:00:00 2001 From: Ecokayiza Date: Sat, 3 Oct 2026 18:38:56 +0800 Subject: [PATCH 4/5] Flush the answer buffer when the transport drops A stream can end without a `done`/`error` message: an extension reload, a background crash or a dropped port. The buffered chunk then waited for the next animation frame, which never runs on a hidden page, so the card kept a stale answer until the tab became visible. Flush before reconnecting so the newest chunk renders synchronously either way. A lifecycle test drops the port mid-stream and asserts the chunk is rendered before the replacement Port is used. --- src/components/ConversationCard/index.jsx | 3 ++ .../conversation-card-lifecycle.test.mjs | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index 9f34d4f97..130fdb122 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -448,6 +448,9 @@ function ConversationCard(props) { return } if (disposedRef.current) return + // A dropped transport ends the stream without a final message. Flush here so the newest + // chunk renders even on a hidden page, where animation frames are paused. + answerBufferRef.current.flush() const nextPort = Browser.runtime.connect() portRef.current = nextPort setPort(nextPort) diff --git a/tests/unit/components/conversation-card-lifecycle.test.mjs b/tests/unit/components/conversation-card-lifecycle.test.mjs index d17a26c4e..384a16f0f 100644 --- a/tests/unit/components/conversation-card-lifecycle.test.mjs +++ b/tests/unit/components/conversation-card-lifecycle.test.mjs @@ -778,3 +778,33 @@ test('switching the question drops a buffered answer from the previous one', asy assert.equal(state.answerContents.includes('stale answer'), false) }) + +test('a dropped transport flushes the buffered answer before reconnecting', () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + const session = { + ...baseSession(), + question: 'why?', + conversationRecords: [{ question: 'why?', answer: 'partial' }], + } + + mountCard(container, { question: 'why?', session }) + const port = state.ports[0] + + act(() => port.onMessage.trigger({ answer: 'newest chunk' })) + assert.equal( + state.answerContents.includes('newest chunk'), + false, + 'the chunk must be buffered first', + ) + + act(() => port.emitRemoteDisconnect()) + + assert.equal(state.ports.length, 2) + assert.equal( + state.answerContents.includes('newest chunk'), + true, + 'a dropped transport must flush the newest chunk synchronously', + ) +}) From e3f81bfe8ee2900d60c3798245be29860f5b3fa9 Mon Sep 17 00:00:00 2001 From: Ecokayiza Date: Sun, 4 Oct 2026 00:10:35 +0800 Subject: [PATCH 5/5] Keep a foreground generation open when the keepalive Port drops A runtime Port that drops mid-generation is replaced, but a foreground generation (Bing web) streams through its own transport and only uses this Port as a keepalive. Flushing the buffer and calling setIsReady(true) here finalized that answer early and unlocked sending while chunks were still arriving, so guard both behind "no foreground generation is in flight". The highlight subset note also described `shell` as a `bash` alias; it is the Shell Session console-prompt grammar, and `bash` only aliases `sh`/`zsh`. --- src/components/ConversationCard/index.jsx | 12 +++++--- .../MarkdownRender/highlight-options.mjs | 7 +++-- .../conversation-card-lifecycle.test.mjs | 28 +++++++++++++++++++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index 130fdb122..f3476d756 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -448,13 +448,17 @@ function ConversationCard(props) { return } if (disposedRef.current) return - // A dropped transport ends the stream without a final message. Flush here so the newest - // chunk renders even on a hidden page, where animation frames are paused. - answerBufferRef.current.flush() + // A dropped transport ends the stream without a final message, so flush here: the newest + // chunk still renders on a hidden page, where animation frames are paused. A foreground + // generation (Bing web) streams through its own transport, though, so this keepalive + // port dropping must not unlock sending. + if (foregroundPortsRef.current.size === 0) { + answerBufferRef.current.flush() + setIsReady(true) + } const nextPort = Browser.runtime.connect() portRef.current = nextPort setPort(nextPort) - setIsReady(true) } const closeChatsMessageListener = (message) => { diff --git a/src/components/MarkdownRender/highlight-options.mjs b/src/components/MarkdownRender/highlight-options.mjs index 075ef8da5..bedb598fb 100644 --- a/src/components/MarkdownRender/highlight-options.mjs +++ b/src/components/MarkdownRender/highlight-options.mjs @@ -7,9 +7,10 @@ * detection, they still use every grammar lowlight has registered. * * The subset is lowlight's registered set minus entries that only mislead detection: - * `shell` (a `bash` alias), `plaintext`/`python-repl`/`php-template` (not useful for - * detection), and `arduino`/`objectivec`/`vbnet`/`wasm` (rare in answers, and they win - * ambiguous `c`, `cpp`, `ini` and `sql` matches away from the right language). Keep the + * `shell` (the Shell Session console-prompt grammar, not a `bash` alias; `bash` aliases + * only `sh`/`zsh`), `plaintext`/`python-repl`/`php-template` (not useful for detection), + * and `arduino`/`objectivec`/`vbnet`/`wasm` (rare in answers, and they win ambiguous `c`, + * `cpp`, `ini` and `sql` matches away from the right language). Keep the * order as lowlight registers them: `highlightAuto` settles equal-relevance candidates by * subset order, so reordering this list changes which language an ambiguous block detects as. */ diff --git a/tests/unit/components/conversation-card-lifecycle.test.mjs b/tests/unit/components/conversation-card-lifecycle.test.mjs index 384a16f0f..e91dd8322 100644 --- a/tests/unit/components/conversation-card-lifecycle.test.mjs +++ b/tests/unit/components/conversation-card-lifecycle.test.mjs @@ -238,6 +238,34 @@ test('remote runtime Port disconnect reconnects and unmount cleans the replaceme assert.equal(state.ports.length, 2) }) +test('a remote Port disconnect during a foreground generation does not unlock sending', async () => { + const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ + const container = document.createElement('div') + document.body.append(container) + state.foreground = true + state.generateAnswers = () => { + state.generateAnswersCount += 1 + return new Promise(() => {}) + } + + mountCard(container) + await waitFor( + () => typeof state.inputBoxProps?.onSubmit === 'function', + 'InputBox did not render', + ) + + state.inputBoxProps.onSubmit('question') + await waitFor(() => state.generateAnswersCount === 1, 'foreground provider did not start') + await waitFor(() => state.inputBoxProps.enabled === false, 'sending was not locked') + + act(() => state.ports[0].emitRemoteDisconnect()) + + // The keepalive Port is replaced, but the foreground stream still owns the answer, so + // sending stays locked until that stream ends on its own. + assert.equal(state.ports.length, 2) + assert.equal(state.inputBoxProps.enabled, false) +}) + test('close button disposes foreground transport before onClose', async () => { const state = globalThis.__CONVERSATION_LIFECYCLE_TEST__ const container = document.createElement('div')