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 <pre> 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 - <pre>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 <pre>, 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1005,13 +1005,17 @@ describe('OpenCodeInlinePrompt', () => {
|
|||||||
warnSpy.mockRestore();
|
warnSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applyEvent: streaming markdown is rendered as HTML in real-time (not raw ```fence``` source)', () => {
|
it('applyEvent: streaming shows raw text in real-time, then renders to HTML on session.idle', () => {
|
||||||
// Regression: previously the streaming path appended deltas to
|
// Regression: a previous version ran marked.parse on every text
|
||||||
// .textContent, so the user saw raw ```py\n...\n``` source while
|
// delta. That produced glitchy partial-render states (e.g. an
|
||||||
// the model was still typing. Only after closing+reopening the
|
// opening ```py fence with no closing fence yet is a different
|
||||||
// panel (which goes through setMessages + marked.parse) did the
|
// DOM shape than the complete code block, so the toolbar
|
||||||
// code block render as <pre><code>. The fix: re-render via
|
// post-processing flickered in and out between deltas). The fix
|
||||||
// marked.parse on every delta.
|
// 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 cell = makeFakeCell('x = 1');
|
||||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
onSubmit: jest.fn(),
|
onSubmit: jest.fn(),
|
||||||
@@ -1020,43 +1024,58 @@ describe('OpenCodeInlinePrompt', () => {
|
|||||||
providers: null
|
providers: null
|
||||||
});
|
});
|
||||||
prompt.setSessionId('s1');
|
prompt.setSessionId('s1');
|
||||||
// Simulate a streaming reply that contains a fenced code block.
|
const fullSource =
|
||||||
const deltas = [
|
'Here is the fix:\n\n```python\nimport pandas as pd\nimport numpy as np\n```\n';
|
||||||
'Here is the fix:\n\n',
|
// Stream every delta of the source.
|
||||||
'```python\n',
|
for (const d of fullSource.split(/(?=H|```|\n)/)) {
|
||||||
'import pandas as pd\n',
|
if (!d) continue;
|
||||||
'import numpy as np\n',
|
|
||||||
'```\n'
|
|
||||||
];
|
|
||||||
for (const d of deltas) {
|
|
||||||
prompt.applyEvent({
|
prompt.applyEvent({
|
||||||
type: 'session.next.text.delta',
|
type: 'session.next.text.delta',
|
||||||
properties: { sessionID: 's1', delta: d }
|
properties: { sessionID: 's1', delta: d }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// The <pre><code> block is rendered DURING streaming, not just at
|
// During streaming: raw text, NO markdown render yet.
|
||||||
// the end. The raw ```fence``` characters must NOT appear as visible
|
const asstDuring = prompt.node.querySelector(
|
||||||
// text content in the message body.
|
'.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 <pre> 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(
|
const pre = prompt.node.querySelector(
|
||||||
'.opencode-msg-assistant .opencode-msg-content pre'
|
'.opencode-msg-assistant .opencode-msg-content pre'
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
expect(pre).not.toBeNull();
|
expect(pre).not.toBeNull();
|
||||||
const code = pre.querySelector('code') as HTMLElement;
|
const code = pre.querySelector('code') as HTMLElement;
|
||||||
expect(code).not.toBeNull();
|
|
||||||
expect(code.textContent).toBe('import pandas as pd\nimport numpy as np');
|
expect(code.textContent).toBe('import pandas as pd\nimport numpy as np');
|
||||||
// The Copy / Insert / Replace toolbar is attached to the <pre>
|
// The Copy / Insert / Replace toolbar is attached to the <pre>
|
||||||
// block during streaming, same as in setMessages.
|
// block on the final render, same as in setMessages.
|
||||||
const toolbar = pre.parentElement!.querySelector(
|
const toolbar = pre.parentElement!.querySelector(
|
||||||
'.opencode-code-toolbar'
|
'.opencode-code-toolbar'
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
expect(toolbar).not.toBeNull();
|
expect(toolbar).not.toBeNull();
|
||||||
expect(toolbar.querySelectorAll('button').length).toBe(3);
|
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', () => {
|
it('applyEvent: message.part.delta with field=text routes to the assistant message', () => {
|
||||||
|
|||||||
@@ -596,16 +596,19 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
if (!this._currentAssistantEl) {
|
if (!this._currentAssistantEl) {
|
||||||
this._currentAssistantEl = this._createMessageElement('assistant', '');
|
this._currentAssistantEl = this._createMessageElement('assistant', '');
|
||||||
}
|
}
|
||||||
// Accumulate the raw markdown source, then re-render the whole
|
// During streaming, append the raw delta to the assistant element's
|
||||||
// message through marked.parse. This way the user sees properly
|
// textContent. We do NOT run marked.parse on every delta: the
|
||||||
// formatted output (code blocks as <pre><code>, headings, lists)
|
// partial source (e.g. an opening ```py fence with no closing
|
||||||
// in real-time as text deltas arrive — NOT the raw ```fence```
|
// fence yet) is structurally different from the complete message
|
||||||
// source that an append-only .textContent would show.
|
// and the toolbar post-processing creates visible flicker —
|
||||||
|
// <pre>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._currentAssistantText += text;
|
||||||
this._renderAssistantMarkdown(
|
this._currentAssistantEl.textContent = this._currentAssistantText;
|
||||||
this._currentAssistantEl,
|
|
||||||
this._currentAssistantText
|
|
||||||
);
|
|
||||||
this._scrollToBottom();
|
this._scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,6 +810,19 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _resetStreamPointers(): void {
|
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._currentAssistantEl = null;
|
||||||
this._currentAssistantText = '';
|
this._currentAssistantText = '';
|
||||||
this._activeBlocks = {};
|
this._activeBlocks = {};
|
||||||
|
|||||||
Reference in New Issue
Block a user