diff --git a/CHANGELOG.md b/CHANGELOG.md
index 28d2a05..0eadc34 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/static/messages.js b/static/messages.js
index 48662bd..276ab0e 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -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:'',close:''},
@@ -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();
});
diff --git a/static/style.css b/static/style.css
index 1f11d42..1c826db 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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;
diff --git a/static/ui.js b/static/ui.js
index de5aa77..191f553 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -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='
';
+ 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);