Skip to content

Commit 64dfdab

Browse files
committed
feat(chat): expose per-turn prose bubbles so hosts stop guessing from the visual tree
A turn is split into several bubbles when tool rounds run between prose, but OnResponseReceived hands the host the whole turn concatenated — nothing says which part landed in which bubble. Hosts that post-process bubbles had to rediscover them by CSS class and text matching, and that guesswork breaks exactly when a turn is split: a shipping host re-rendered the whole response into the LAST bubble, showing the pre-tool prose twice while the sealed bubble stayed raw. TurnStreamingBubbles exposes the bubbles in open order. Cleared on turn start, on ClearChat and on UI rebind, so a host never gets detached elements.
1 parent d8568cc commit 64dfdab

2 files changed

Lines changed: 46 additions & 0 deletions

File tree

Assets/CoreAiUnity/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ Unity host: **CoreAI.Source** build, EditMode / PlayMode tests, Editor menus, do
66

77
### Added
88

9+
- **`CoreAiChatPanel.TurnStreamingBubbles`** — the prose bubbles of the current turn, in the order
10+
they were opened. A turn is split into several bubbles when tool rounds run between prose
11+
(`SealStreamingBubbleIfAny`), but `OnResponseReceived` hands the host the whole turn
12+
CONCATENATED, with nothing to say which part landed in which bubble. Hosts that post-process
13+
bubbles (markdown rendering, syntax highlighting, per-bubble actions) had to rediscover them
14+
from the visual tree by CSS class and text matching — guesswork that breaks exactly when a turn
15+
is split. A shipping host hit that: the whole response was re-rendered into the LAST bubble, so
16+
the prose before the tool call appeared twice and the sealed bubble stayed unrendered (raw
17+
`**`). The list is cleared when a turn starts, when the chat is cleared, and when the UI is
18+
rebound, so a host never receives detached elements.
919
- **Runtime log filtering: `GameLogFilter`.** Static, thread-safe entry point that works in a player:
1020
`MinimumLevel`, `EnabledFeatures`, `SetFeatureEnabled(feature, enabled)`, `IsFeatureEnabled`,
1121
`Snapshot()`, `ResetToAuthored()`. `CoreAILifetimeScope` copies `GameLogSettingsAsset` into it

Assets/CoreAiUnity/Runtime/Source/Features/Chat/CoreAiChatPanel.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)