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 }
       });
     }
-    // The 
 block 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');
+    // No 
 element 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 = {};