fix: correct interleaved streaming order (Text → Thinking → Tool → Text) (#913)

* fix: correct interleaved streaming order (Text → Thinking → Tool → Text)

During live streaming, tool cards were inserted before their associated
thinking cards instead of after them. The root cause was that
appendLiveToolCard's anchor selector didn't include .thinking-card-row,
so finalized thinking cards were skipped when finding the insertion point.

Changes:
- messages.js: Add segment splitting (segmentStart/_freshSegment) so each
  text segment after a tool call renders only its own slice, not the full
  accumulated text. Sync thinking card render in reasoning handler to
  avoid rAF race with tool events. Guard removeThinking() to preserve
  finalized cards when reasoningText is active.
- ui.js: Add .thinking-card-row to appendLiveToolCard anchor selector so
  tool cards land after finalized thinking. Add anchor-based positioning
  to appendThinking for correct interleaved placement. Clean up empty
  spinner-only thinking rows in finalizeThinkingCard. Add 3-dot waiting
  indicator (toolRunningRow) after tool cards for visual feedback.
- style.css: Scope blinking cursor to last live-assistant segment only.
  Add spacing for toolRunningRow.

* chore: CHANGELOG for v0.50.174

---------

Co-authored-by: bsgdigital <bsgdigital@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-04-23 13:23:43 -07:00
committed by GitHub
parent 537c8271db
commit 5082f426f2
4 changed files with 85 additions and 13 deletions

View File

@@ -29,6 +29,11 @@
workspace subtree) and never enumerate blocked system roots. (`api/routes.py`,
`api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616)
## [v0.50.174] — 2026-04-23
### Fixed
- **Interleaved streaming order (Text → Thinking → Tool → Text)** — after a tool call completes, new text tokens now create a new DOM segment below the tool card instead of updating the old segment above it. Adds `segmentStart`/`_freshSegment` flags to track segment boundaries; scopes the streaming cursor to the last live assistant segment only; adds a 3-dot waiting indicator below each tool card; fixes `appendLiveToolCard`/`appendThinking` anchor logic for multi-tool sequences. (`static/messages.js`, `static/ui.js`, `static/style.css`) Co-authored by @bsgdigital.
## [v0.50.173] — 2026-04-23
### Fixed

View File

@@ -187,6 +187,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
let liveReasoningText='';
let assistantRow=null;
let assistantBody=null;
let segmentStart=0; // char offset in assistantText where current segment begins
let _freshSegment=false; // true after a tool call — forces a new DOM segment
// Thinking tag patterns for streaming display
const _thinkPairs=[
{open:'<think>',close:'</think>'},
@@ -245,10 +247,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const blocks=(typeof _assistantTurnBlocks==='function')?_assistantTurnBlocks(turn):null;
if(!blocks) return;
if(!assistantRow){
const existing=blocks.querySelector('[data-live-assistant="1"]');
if(existing){
assistantRow=existing;
assistantBody=existing.querySelector('.msg-body');
// Only reuse an existing segment on the very first creation (e.g. reconnect).
// After a tool call _freshSegment=true, so we always create a new segment
// below the tool card rather than re-attaching to the old one above it.
if(!_freshSegment){
const existing=blocks.querySelector('[data-live-assistant="1"]');
if(existing){
assistantRow=existing;
assistantBody=existing.querySelector('.msg-body');
}
}
}
if(assistantRow){
@@ -264,6 +271,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
assistantBody=document.createElement('div');assistantBody.className='msg-body';
assistantRow.appendChild(assistantBody);
blocks.appendChild(assistantRow);
_freshSegment=false; // consumed — next reuse check is normal again
}
// ── Shared SSE handler wiring (used for initial connection and reconnect) ──
@@ -352,7 +360,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
else appendThinking();
return;
}
removeThinking();
// Only remove thinking if we're not in an active reasoning phase.
// When reasoningText is set but liveReasoningText was just reset (post-tool),
// don't wipe the finalized thinking card — it has no id anymore so
// removeThinking() won't find it anyway, but guard explicitly.
if(!reasoningText) removeThinking();
}
function _scheduleRender(){
if(_renderPending) return;
@@ -364,7 +376,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const parsed=_parseStreamState();
_renderLiveThinking(parsed);
if(assistantBody){
assistantBody.innerHTML=parsed.displayText?renderMd(parsed.displayText):'';
// Render only the text belonging to the current segment (after the last tool call).
// segmentStart=0 for the first segment, or assistantText.length-at-last-tool for later ones.
const segText = segmentStart===0
? parsed.displayText // first segment: use full display (handles think-tag stripping)
: renderMd ? renderMd(assistantText.slice(segmentStart)) : assistantText.slice(segmentStart);
assistantBody.innerHTML = segText || '';
}
scrollIfPinned();
});
@@ -403,6 +420,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
liveReasoningText += d.text || '';
syncInflightAssistantMessage();
if(!S.session||S.session.session_id!==activeSid) return;
// Render thinking card synchronously — not via rAF — so the DOM is
// up-to-date before a 'tool' event in the same microtask batch calls
// finalizeThinkingCard(). The old rAF-only path caused a race where
// the thinking row was still a spinner when finalized.
if(window._showThinking!==false){
if(typeof updateThinking==='function') updateThinking(liveReasoningText||'Thinking…');
else appendThinking(liveReasoningText);
}
_scheduleRender();
});
@@ -429,6 +454,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
liveReasoningText='';
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
appendLiveToolCard(tc);
// Reset the live assistant row reference so that any text tokens arriving
// after this tool call create a NEW segment appended below the tool card,
// rather than updating the old segment that sits above it in the DOM.
assistantRow=null;
assistantBody=null;
segmentStart=assistantText.length; // new segment starts at current text length
_freshSegment=true; // prevent reuse of old DOM node
scrollIfPinned();
});

View File

@@ -1834,8 +1834,9 @@ body.resizing{user-select:none;cursor:col-resize;}
/* ── Streaming cursor at the end of the live assistant body ── */
@keyframes hermes-cursor-blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0; } }
[data-live-assistant="1"] .msg-body > :last-child::after,
[data-live-assistant="1"] .msg-body:not(:has(> *))::after {
#toolRunningRow { margin-top: 10px; }
[data-live-assistant="1"]:last-child .msg-body > :last-child::after,
[data-live-assistant="1"]:last-child .msg-body:not(:has(> *))::after {
content: '';
display: inline-block;
width: 7px;

View File

@@ -2017,16 +2017,33 @@ function appendLiveToolCard(tc){
const replacement=buildToolCard(tc);
replacement.dataset.liveTid=tid;
existing.replaceWith(replacement);
// Keep #toolRunningRow alive — dots stay until text starts streaming
// or the next tool fires (which replaces them). Removing here caused
// a gap between tool completion and the first text token arriving.
return;
}
}
const row=buildToolCard(tc);
if(tid) row.dataset.liveTid=tid;
// Insert BEFORE the live assistant segment if it exists, so tool cards stay
// between the current thinking block(s) and the streaming response.
const liveAssistant=inner.querySelector('[data-live-assistant="1"]');
if(liveAssistant) inner.insertBefore(row, liveAssistant);
// Insert after whichever comes last: the current live assistant segment or
// the last tool card. This handles both cases:
// text → tool1 → tool2 (no text between tools: anchor is card1)
// text1 → tool1 → text2 → tool2 (text between tools: anchor is text2)
const children=Array.from(inner.children);
// Include .thinking-card-row so tool cards land AFTER a finalized thinking
// card, not between the text segment and thinking.
const anchor=children.filter(el=>el.matches('[data-live-assistant="1"],.tool-card-row,.thinking-card-row')).pop();
if(anchor) anchor.insertAdjacentElement('afterend', row);
else inner.appendChild(row);
// Add a 3-dot waiting indicator below the tool card so there's visual
// feedback while the tool is running. Removed when text starts streaming
// (ensureAssistantRow) or when tool_complete fires.
const oldWait=$('toolRunningRow');if(oldWait)oldWait.remove();
const waitRow=document.createElement('div');
waitRow.id='toolRunningRow';
waitRow.className='assistant-segment';
waitRow.innerHTML='<div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>';
row.insertAdjacentElement('afterend', waitRow);
if(typeof scrollIfPinned==='function') scrollIfPinned();
}
@@ -2264,6 +2281,13 @@ function _thinkingMarkup(text=''){
function finalizeThinkingCard(){
const row=$('thinkingRow');
if(!row) return;
// If the row is still just a spinner (no thinking content rendered),
// remove it entirely — it's the initial waiting dots.
const hasContent=row.querySelector('.thinking-card') || row.classList.contains('thinking-card-row');
if(!hasContent && row.getAttribute('data-thinking-active')==='1'){
row.remove();
return;
}
row.removeAttribute('id');
row.removeAttribute('data-thinking-active');
}
@@ -2282,7 +2306,17 @@ function appendThinking(text=''){
row.className='assistant-segment';
row.id='thinkingRow';
row.setAttribute('data-thinking-active','1');
blocks.appendChild(row);
// Insert after whichever comes last: a live assistant segment or a tool card.
// This mirrors appendLiveToolCard's anchor logic so thinking always appears
// in the right position in the interleaved sequence.
// Also skip #toolRunningRow (dots) — thinking should go before dots, not after.
const allChildren=Array.from(blocks.children);
const anchor=allChildren.filter(el=>
el.id!=='toolRunningRow' &&
el.matches('[data-live-assistant="1"],.tool-card-row')
).pop();
if(anchor) anchor.insertAdjacentElement('afterend', row);
else blocks.appendChild(row);
}
row.className=(text&&String(text).trim())?'assistant-segment thinking-card-row':'assistant-segment';
row.innerHTML=_thinkingMarkup(text);