From 5082f426f20cd5bd3d171fbc4bce094ecc72b1ee Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 23 Apr 2026 13:23:43 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20correct=20interleaved=20streaming=20orde?= =?UTF-8?q?r=20(Text=20=E2=86=92=20Thinking=20=E2=86=92=20Tool=20=E2=86=92?= =?UTF-8?q?=20Text)=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: nesquena-hermes --- CHANGELOG.md | 5 +++++ static/messages.js | 44 ++++++++++++++++++++++++++++++++++++++------ static/style.css | 5 +++-- static/ui.js | 44 +++++++++++++++++++++++++++++++++++++++----- 4 files changed, 85 insertions(+), 13 deletions(-) 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);