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);