From fcd015ac0e2687ded39a83647463c495d1ab1e2f Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:52:53 +0800 Subject: [PATCH] fix: stop running marked.parse on every streaming text delta The streaming path used to call _renderAssistantMarkdown on every incoming text delta, which re-ran marked.parse on the WHOLE accumulated markdown source and re-post-processed (added the Copy/Insert/Replace toolbar to) every
block. This is a partial-render problem: a markdown source mid-stream (e.g. an opening ```py fence with no closing fence yet, or a partial heading) classifies DIFFERENTLY than the complete message. Each new delta could re-classify the same text into a different DOM shape, causing visible flicker -s appearing and disappearing, the toolbar popping in and out, paragraphs re-grouping - while the model is still typing. The bug surfaced because the user could see the glitchy view during a stream but everything looked correct after closing+reopening the panel (which goes through the static setMessages -> marked.parse path on the full text). Fix: keep the streaming path dumb. _appendToAssistantText now just appends the raw delta to the assistant element's textContent. The full markdown render happens exactly once at the end of the turn, inside _resetStreamPointers (which the prompt invokes when session.idle arrives, before calling onStreamEnd). Result: - The user still sees real-time text growth (textContent += delta). - No more intermediate-state flicker. - The rendered view at turn end is byte-for-byte equivalent to what setMessages would produce from the stored history (they share _renderAssistantMarkdown). The corresponding regression test is updated: it now asserts the DURING-stream state is raw text (no, no toolbar) and the POST-idle state is the rendered DOM with the toolbar attached. Tests: 72 jest (unchanged count, the one streaming test was rewritten), 52 pytest, build green. Co-Authored-By: Claude--- src/__tests__/opencode_cell_actions.spec.ts | 73 +++++++++++++-------- src/components/opencode_inline_prompt.ts | 34 +++++++--- 2 files changed, 71 insertions(+), 36 deletions(-) diff --git a/src/__tests__/opencode_cell_actions.spec.ts b/src/__tests__/opencode_cell_actions.spec.ts index 6c139e9..2953ae2 100644 --- a/src/__tests__/opencode_cell_actions.spec.ts +++ b/src/__tests__/opencode_cell_actions.spec.ts @@ -1005,13 +1005,17 @@ describe('OpenCodeInlinePrompt', () => { warnSpy.mockRestore(); }); - it('applyEvent: streaming markdown is rendered as HTML in real-time (not raw ```fence``` source)', () => { - // Regression: previously the streaming path appended deltas to - // .textContent, so the user saw raw ```py\n...\n``` source while - // the model was still typing. Only after closing+reopening the - // panel (which goes through setMessages + marked.parse) did the - // code block render as . The fix: re-render via - // marked.parse on every delta. + it('applyEvent: streaming shows raw text in real-time, then renders to HTML on session.idle', () => { + // Regression: a previous version ran marked.parse on every text + // delta. That produced glitchy partial-render states (e.g. an + // opening ```py fence with no closing fence yet is a different + // DOM shape than the complete code block, so the toolbar + // post-processing flickered in and out between deltas). The fix + // is to keep the streaming path dumb (append raw text only) and + // run the markdown render exactly once at the end of the turn + // (session.idle). The user sees real-time text grow during the + // stream, and a clean rendered view the moment the turn ends — + // matching what setMessages would produce from the stored history. const cell = makeFakeCell('x = 1'); const prompt = new OpenCodeInlinePrompt(cell, { onSubmit: jest.fn(), @@ -1020,43 +1024,58 @@ describe('OpenCodeInlinePrompt', () => { providers: null }); prompt.setSessionId('s1'); - // Simulate a streaming reply that contains a fenced code block. - const deltas = [ - 'Here is the fix:\n\n', - '```python\n', - 'import pandas as pd\n', - 'import numpy as np\n', - '```\n' - ]; - for (const d of deltas) { + const fullSource = + 'Here is the fix:\n\n```python\nimport pandas as pd\nimport numpy as np\n```\n'; + // Stream every delta of the source. + for (const d of fullSource.split(/(?=H|```|\n)/)) { + if (!d) continue; prompt.applyEvent({ type: 'session.next.text.delta', properties: { sessionID: 's1', delta: d } }); } - // Theblock is rendered DURING streaming, not just at - // the end. The raw ```fence``` characters must NOT appear as visible - // text content in the message body. + // During streaming: raw text, NO markdown render yet. + const asstDuring = prompt.node.querySelector( + '.opencode-msg-assistant' + ) as HTMLElement; + expect(asstDuring.textContent).toBe(fullSource); + // The ```fence``` characters are present as literal text — the + // user is looking at the source, not the rendered view. + expect(asstDuring.textContent).toContain('```python'); + // Noelement yet (no markdown render was attempted). + expect(asstDuring.querySelector('pre')).toBeNull(); + // No toolbar yet either. + expect(asstDuring.querySelector('.opencode-code-toolbar')).toBeNull(); + // Still a single assistant element (no forks per delta). + expect( + prompt.node.querySelectorAll( + '.opencode-msg-assistant' + ).length + ).toBe(1); + + // Turn end -> session.idle triggers the final markdown render + // (via _resetStreamPointers, which the prompt invokes before + // calling onStreamEnd). + prompt.applyEvent({ + type: 'session.idle', + properties: { sessionID: 's1' } + }); + + // After idle: the assistant message is now a fully rendered + // markdown DOM, identical to what setMessages would produce. const pre = prompt.node.querySelector( '.opencode-msg-assistant .opencode-msg-content pre' ) as HTMLElement; expect(pre).not.toBeNull(); const code = pre.querySelector('code') as HTMLElement; - expect(code).not.toBeNull(); expect(code.textContent).toBe('import pandas as pd\nimport numpy as np'); // The Copy / Insert / Replace toolbar is attached to the- // block during streaming, same as in setMessages. + // block on the final render, same as in setMessages. const toolbar = pre.parentElement!.querySelector( '.opencode-code-toolbar' ) as HTMLElement; expect(toolbar).not.toBeNull(); expect(toolbar.querySelectorAll('button').length).toBe(3); - // The wrapper still has a single assistant element (not one per delta). - expect( - prompt.node.querySelectorAll( - '.opencode-inline-prompt .opencode-msg-assistant' - ).length - ).toBe(1); }); it('applyEvent: message.part.delta with field=text routes to the assistant message', () => { diff --git a/src/components/opencode_inline_prompt.ts b/src/components/opencode_inline_prompt.ts index f04a155..d0785a4 100644 --- a/src/components/opencode_inline_prompt.ts +++ b/src/components/opencode_inline_prompt.ts @@ -596,16 +596,19 @@ export class OpenCodeInlinePrompt extends Widget { if (!this._currentAssistantEl) { this._currentAssistantEl = this._createMessageElement('assistant', ''); } - // Accumulate the raw markdown source, then re-render the whole - // message through marked.parse. This way the user sees properly - // formatted output (code blocks as, headings, lists) - // in real-time as text deltas arrive — NOT the raw ```fence``` - // source that an append-only .textContent would show. + // During streaming, append the raw delta to the assistant element's + // textContent. We do NOT run marked.parse on every delta: the + // partial source (e.g. an opening ```py fence with no closing + // fence yet) is structurally different from the complete message + // and the toolbar post-processing creates visible flicker — + //s appear and disappear as the renderer re-classifies the + // incomplete markdown between deltas. The user gets stable + // real-time text here, and the full markdown render happens once + // at the end of the turn (see _resetStreamPointers) so the result + // is identical to what setMessages would produce from the stored + // history. this._currentAssistantText += text; - this._renderAssistantMarkdown( - this._currentAssistantEl, - this._currentAssistantText - ); + this._currentAssistantEl.textContent = this._currentAssistantText; this._scrollToBottom(); } @@ -807,6 +810,19 @@ export class OpenCodeInlinePrompt extends Widget { } private _resetStreamPointers(): void { + // Finalize the in-progress assistant message by running the + // markdown render ONCE on the accumulated raw text. This is the + // moment where the streaming path converges to the same DOM + // structure that setMessages would produce from the stored + // history (i.e. identical to "reopen" behavior). Without this + // final pass the user would see raw ```fence``` source + // permanently until they close+reopen the panel. + if (this._currentAssistantEl && this._currentAssistantText) { + this._renderAssistantMarkdown( + this._currentAssistantEl, + this._currentAssistantText + ); + } this._currentAssistantEl = null; this._currentAssistantText = ''; this._activeBlocks = {};