@@ -127,6 +127,29 @@ public class CoreAiChatPanel : MonoBehaviour
127127 /// </summary>
128128 private bool _streamingBubbleSealed ;
129129
130+ /// <summary>
131+ /// Prose bubbles opened during the current turn, in order. A turn is split into several bubbles
132+ /// when tool rounds run between prose (see <see cref="_streamingBubbleSealed"/>), and only this
133+ /// class knows where those boundaries are: <see cref="OnResponseReceived"/> receives the whole
134+ /// turn CONCATENATED, with nothing to say which part landed in which bubble.
135+ /// <para>
136+ /// Without this list a host that post-processes bubbles (markdown rendering, syntax highlighting,
137+ /// per-bubble actions) has to rediscover them from the visual tree by CSS class and text
138+ /// matching — brittle guesswork that silently breaks exactly when a turn is split. A shipping
139+ /// host hit that: the whole response was re-rendered into the LAST bubble, so the prose before
140+ /// the tool call was shown twice and the sealed bubble stayed unrendered.
141+ /// </para>
142+ /// </summary>
143+ private readonly List < Label > _turnStreamingBubbles = new ( ) ;
144+
145+ /// <summary>
146+ /// Prose bubbles of the current (or last completed) turn, in the order they were opened. A turn
147+ /// split by tool rounds has more than one, and each holds only its own segment of the answer.
148+ /// Valid from the first streamed chunk until the next turn starts, so it can be read from
149+ /// <see cref="OnResponseReceived"/> and from work scheduled shortly after it.
150+ /// </summary>
151+ protected IReadOnlyList < Label > TurnStreamingBubbles => _turnStreamingBubbles ;
152+
130153 /// <summary>Tracks the in-flight tool round so ToolRoundStarted can carry the last tool name.</summary>
131154 private string _lastToolNameInTurn ;
132155
@@ -1170,6 +1193,9 @@ private void ResetUiReferences()
11701193 _apiProfileToggle = null ;
11711194 _longRequestHint = null ;
11721195 _streamingLabel = null ;
1196+ // WHY: those bubbles belonged to the old visual tree; keeping them would hand a host
1197+ // detached elements to post-process.
1198+ _turnStreamingBubbles . Clear ( ) ;
11731199 // WHY: pending scheduler jobs die with the old visual tree; a stuck flag would block every
11741200 // scroll after a UI rebuild.
11751201 _scrollToBottomScheduled = false ;
@@ -2431,6 +2457,9 @@ protected virtual bool ShouldUseStreamingForRole(string roleId, bool uiConfigWan
24312457 ResetThinkFilter ( ) ;
24322458 _streamingStartedVisible = false ;
24332459 _streamingBubbleSealed = false ;
2460+ // WHY: bubbles of the PREVIOUS turn must not leak into this one — a host reading
2461+ // TurnStreamingBubbles would re-render already finished bubbles with the new answer.
2462+ _turnStreamingBubbles . Clear ( ) ;
24342463
24352464 // WHY: yield so the UI thread can repaint (stop affordance) before ultra-fast stubs finish
24362465 // the enumerator.
@@ -3495,6 +3524,9 @@ private void StartStreaming()
34953524 MakeTextSelectable ( _streamingLabel ) ;
34963525
34973526 AddBubbleContent ( templateRow , contentSlot , _streamingLabel ) ;
3527+ // WHY: record the bubble in open order — after the turn a host needs EVERY segment, not
3528+ // just the last one (see TurnStreamingBubbles).
3529+ _turnStreamingBubbles . Add ( _streamingLabel ) ;
34983530 MessageScroll . Add ( templateRow ) ;
34993531 ScrollToBottom ( ) ;
35003532 }
@@ -3645,6 +3677,10 @@ public void ClearChat(bool clearChatHistory, bool clearLongTermMemory)
36453677 MessageScroll . Clear ( ) ;
36463678 }
36473679
3680+ // WHY: the bubbles were just removed from the tree; a host must not be handed
3681+ // detached elements to post-process.
3682+ _turnStreamingBubbles . Clear ( ) ;
3683+
36483684 string roleId = ActiveRoleId ;
36493685 // WHY: keep the in-memory cache consistent with the now-empty scroll, otherwise switching
36503686 // away and back would resurrect the cleared conversation.
0 commit comments