Stabilize streaming message controls
Keep message action buttons mounted while streamed updates refresh their handlers, preserving hover, focus, and extension controls. Render the first user turn optimistically and serialize WebSocket state pushes so welcome-screen messages remain visible.
Alessandro committed
Aug 27, 2026 at 06:31 UTC
6d791cda472a287a4bb174deb3068fe91ca9f595
11 files changed
+92
-36
tests/test_message_action_buttons_static.py
+18
@@ -15,3 +15,21 @@ def test_message_action_buttons_are_not_text_selectable() -> None:
15
16
block = css[css.index(".step-action-buttons {"):css.index("}", css.index(".step-action-buttons {"))]
17
assert "user-select: none;" in block
18
+
19
+
20
+def test_streaming_updates_keep_action_button_nodes_mounted() -> None:
21
+ actions = PROJECT_ROOT.joinpath(
22
+ "webui",
23
+ "components",
24
+ "messages",
25
+ "action-buttons",
26
+ "simple-action-buttons.js",
27
+ ).read_text(encoding="utf-8")
28
+ messages = PROJECT_ROOT.joinpath("webui", "js", "messages.js").read_text(
29
+ encoding="utf-8"
30
+ )
31
+
32
+ assert "existing.__actionHandler = button.__actionHandler" in actions
33
+ assert "syncActionButtons(stepActionBtns, actionButtons);" in messages
34
+ assert "syncActionButtons(container, actionButtons);" in messages
35
+ assert 'stepActionBtns.textContent = "";' not in messages
tests/test_welcome_composer_static.py
+3
@@ -96,6 +96,9 @@ def test_welcome_composer_can_create_a_chat_before_sending() -> None:
96
assert 'return "Ask anything to start a new chat";' in input_store
97
assert "if (!chatsStore.selected" in input_store
98
assert "await chatsStore.newChat()" in input_store
99
+ optimistic_user = 'await setMessages([{ id: messageId, type: "user"'
100
+ assert index_js.count(optimistic_user) == 1
101
+ assert index_js.index(optimistic_user) < index_js.index("if (hasAttachments)")
102
assert "return response.ctxid;" in chats_store
103
assert 'return "arrow_forward";' in input_store
104
assert "modelGateStore.canSendToModel()" in index_js
tests/test_ws_client_api_surface.py
+12
@@ -52,6 +52,18 @@ def test_completed_state_push_cannot_overwrite_disconnected_mode() -> None:
52
assert "if (!stateSocket.isConnected()) return;" in apply_end
53
54
55
+def test_state_push_handlers_are_serialized() -> None:
56
+ source = (
57
+ PROJECT_ROOT / "webui" / "components" / "sync" / "sync-store.js"
58
+ ).read_text(encoding="utf-8")
59
+
60
+ subscription = source.split('stateSocket.on("state_push"', 1)[1].split(
61
+ 'debug("[syncStore] subscribed to state_push")', 1
62
+ )[0]
63
+ assert "this._pushQueue = this._pushQueue" in subscription
64
+ assert ".then(() => this._handlePush(envelope))" in subscription
65
+
66
+
67
def test_partial_snapshot_retains_sidebar_collections_and_extension_shape() -> None:
68
source = (PROJECT_ROOT / "webui" / "index.js").read_text(encoding="utf-8")
69
request_builder = source.split(
webui/components/chat/AGENTS.md
+1
@@ -19,6 +19,7 @@
19
- Use shared API, WebSocket, notification, and attachment helpers where available.
20
- Do not bypass CSRF or WebSocket state-sync expectations.
21
- The shared composer can be mounted on the Welcome screen with no selected chat; sending from that state must create and select a chat context before dispatch.
22
+- Text-only and attachment sends must render the first user turn immediately with the request message ID so the backend log merges into the same row.
23
- Unsent composer text is kept as a separate browser-session draft for each selected chat and restored when switching contexts; a Welcome-screen prompt must follow the chat created for its first send.
24
- Composer text uses the main UI font by default; typing a triple-backtick fence and pressing Enter turns that line into a visual code block that serializes back to fenced Markdown, while pasted fenced Markdown stays plain text.
25
- Missing model setup is gated at send intent: the first unconfigured send renders an in-thread setup card, keeps the pending prompt in browser session storage for refresh recovery, and must not call `/message_async` until a chat model is configured.
webui/components/messages/AGENTS.md
+1
@@ -16,6 +16,7 @@
16
- Sanitize or safely render model/user-provided content through shared rendering paths.
17
- Avoid layout shifts that break long-running message streaming.
18
- Keep message action chrome out of text selection so copy/paste captures message content without button labels or icons.
19
+- Reconcile standard action buttons in place during streamed updates so hover, focus, tooltips, and click feedback survive while handlers receive the latest message data; preserve extension-owned buttons in the same action bar.
20
- Order standard message actions as Detail, Copy, then Speak; omit unavailable actions without changing the relative order of the remaining controls. Plugin-rendered message actions must follow the same order.
21
- Keep collapsed process-step detail text out of the DOM; opening a step may materialize its current cached log data and collapsing it must discard that heavy detail again without removing extension action hooks.
22
- Preference-driven process detail modes must await the same materialization path as manual expansion and accept an explicit chat-history target for off-screen window staging. `STEP` opens only the current non-utility step at the live tail; historical windows must not invent a current step at their boundary.
webui/components/messages/action-buttons/simple-action-buttons.js
+41
-16
@@ -59,6 +59,30 @@ export function showButtonFeedback(button, success, originalIcon) {
59
}, 1000);
60
}
61
62
+export function syncActionButtons(container, actionButtons = []) {
63
+ const previous = container.__managedActionButtons || [];
64
+ const next = actionButtons.filter(Boolean).map((button, index) => {
65
+ const existing = previous[index];
66
+ if (
67
+ existing?.isConnected &&
68
+ existing.dataset.actionKey === button.dataset.actionKey
69
+ ) {
70
+ existing.__actionHandler = button.__actionHandler;
71
+ return existing;
72
+ }
73
+ existing?.remove();
74
+ return button;
75
+ });
76
+ const anchor = [...container.children].find(
77
+ (child) =>
78
+ !previous.includes(child) && !child.classList.contains("expand-btn"),
79
+ );
80
+
81
+ previous.slice(next.length).forEach((button) => button.remove());
82
+ next.forEach((button) => container.insertBefore(button, anchor || null));
83
+ container.__managedActionButtons = next;
84
+}
85
+
86
/**
87
* Create action button element
88
*
@@ -73,6 +97,8 @@ export function createActionButton(icon, text = "", handler = null) {
97
const button = document.createElement("button");
98
button.type = "button";
99
button.className = `action-button action-${icon}`;
100
+ button.dataset.actionKey = `${icon}:${text}`;
101
+ button.__actionHandler = handler;
102
const label = buildActionLabel(icon, text);
103
if (label) {
104
button.setAttribute("aria-label", label);
@@ -87,23 +113,22 @@ export function createActionButton(icon, text = "", handler = null) {
113
button.textContent = text;
114
}
115
90
- if (typeof handler === "function") {
91
- button.addEventListener("click", async (event) => {
92
- event.stopPropagation();
93
- const shouldShowFeedback = Boolean(iconName); // icon === "copy" || icon === "speak";
94
- try {
95
- await handler();
96
- if (shouldShowFeedback) {
97
- showButtonFeedback(button, true, iconName);
98
- }
99
- } catch (err) {
100
- console.error("Action button failed:", err);
101
- if (shouldShowFeedback) {
102
- showButtonFeedback(button, false, iconName);
103
- }
116
+ button.addEventListener("click", async (event) => {
117
+ event.stopPropagation();
118
+ if (typeof button.__actionHandler !== "function") return;
119
+ const shouldShowFeedback = Boolean(iconName); // icon === "copy" || icon === "speak";
120
+ try {
121
+ await button.__actionHandler();
122
+ if (shouldShowFeedback) {
123
+ showButtonFeedback(button, true, iconName);
124
}
105
- });
106
- }
125
+ } catch (err) {
126
+ console.error("Action button failed:", err);
127
+ if (shouldShowFeedback) {
128
+ showButtonFeedback(button, false, iconName);
129
+ }
130
+ }
131
+ });
132
133
return button;
134
}
webui/components/sync/AGENTS.md
+1
@@ -13,6 +13,7 @@
13
## Local Contracts
14
15
- Keep sync state compatible with WebSocket state-sync events.
16
+- Apply `state_push` snapshots sequentially because WebSocket subscribers do not await async callbacks; later pushes must not race earlier full renders.
17
- A queued state push that finishes after transport loss must not overwrite the
18
`DISCONNECTED` mode or flush reconnect notifications.
19
- Avoid noisy user-facing alerts for transient sync state unless existing UX expects them.
webui/components/sync/sync-store.js
+6
-3
@@ -51,6 +51,7 @@ const model = {
51
initialized: false,
52
needsHandshake: false,
53
handshakePromise: null,
54
+ _pushQueue: Promise.resolve(),
55
_handshakeQueued: false,
56
_queuedPayload: null,
57
_inFlightPayload: null,
@@ -301,9 +302,11 @@ const model = {
302
});
303
304
await stateSocket.on("state_push", (envelope) => {
304
- this._handlePush(envelope).catch((error) => {
305
- console.error("[syncStore] state_push handler failed:", error);
306
- });
305
+ this._pushQueue = this._pushQueue
306
+ .then(() => this._handlePush(envelope))
307
+ .catch((error) => {
308
+ console.error("[syncStore] state_push handler failed:", error);
309
+ });
310
});
311
debug("[syncStore] subscribed to state_push");
312
webui/index.js
+4
-10
@@ -149,18 +149,12 @@ export async function sendMessage(options = {}) {
149
adjustTextareaHeight();
150
}
151
152
+ // Render immediately; the backend log reuses messageId and merges into this row.
153
+ const heading = hasAttachments ? "Uploading attachments..." : "";
154
+ await setMessages([{ id: messageId, type: "user", heading, content: message, kvps: {} }]);
155
+
156
// Include attachments in the user message
157
if (hasAttachments) {
154
- const heading =
155
- attachmentsWithUrls.length > 0
156
- ? "Uploading attachments..."
157
- : "";
158
-
159
- // Render user message with attachments
160
- await setMessages([{ id: messageId, type: "user", heading, content: message, kvps: {
161
- // attachments: attachmentsWithUrls, // skip here, let the backend properly log them
162
- }}]);
163
-
158
// sleep one frame to render the message before upload starts - better UX
159
sleep(0);
160
webui/js/AGENTS.md
+2
@@ -55,6 +55,8 @@
55
- Full message snapshots that start at backend log `no` 0 must replace the current message DOM before rendering; incremental snapshots should keep patching existing messages.
56
- The state request builder advertises collection-delta support. An incremental snapshot may carry `contexts: null` and `tasks: null`; retain the current stores and skip sidebar selection/fallback reconciliation in that case. Extension hooks still receive the cached full collections so existing plugin contracts remain list-shaped.
57
- Root responses with explicit `finished: false` render as escaped plain text while streaming, then switch to Markdown and LaTeX when finished; legacy responses without the flag remain formatted.
58
+- User sends render optimistically with their backend message ID; the matching log update must merge into that existing row rather than duplicate it.
59
+- Incremental message rendering must update standard action handlers without remounting unchanged button nodes or removing extension-owned actions.
60
- Long histories stay cached as raw log data but render a contiguous tail-first DOM window. The initial base view contains one 60-entry page; after paging, the base window contains two aligned pages, retaining the adjacent page and discarding only the far page in either direction. Visible boundaries expand to whole logical process groups so a page never reconstructs a partial group; the unit classifier must include plugin-backed process steps such as `code_exe`, and oversized groups use their own 50-step incremental window. A capped process group rebuilds only when a newly added step advances that window; updates to an existing step patch it in place. Paging must preserve a visible anchor and occur at the scroll boundary after user intent, using passive loading indicators rather than count-bearing controls. Live entries and late content growth follow the tail until the reader deliberately moves away; historical window rebuilds must cancel pending auto-scroll effects, render in an off-screen staging history, and atomically swap fully laid-out content into the live scroller before restoring its anchor.
61
- Message-window cache identity must keep different log types distinct even when they share a backend ID; root-agent GEN and response records intentionally use the same ID and must both survive replay, while same-ID/same-type updates still replace their earlier cached version.
62
- Utility records join a process render unit only when a substantive process step follows before the next standalone boundary. Group visibility must use that full-log classification rather than infer utility-only state from partially mounted DOM children. Standalone utility-only runs must not wrap root responses or reopen completed groups, and their group chrome stays hidden unless utility messages are enabled.
webui/js/messages.js
+3
-7
@@ -7,6 +7,7 @@ import { ttsService } from "/js/tts-service.js";
7
import {
8
createActionButton,
9
copyToClipboard,
10
+ syncActionButtons,
11
} from "/components/messages/action-buttons/simple-action-buttons.js";
12
import { store as stepDetailStore } from "/components/modals/process-step-detail/step-detail-store.js";
13
import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
@@ -1458,10 +1459,7 @@ export function drawProcessStep({
1459
"step-detail-actions",
1460
"step-action-buttons",
1461
);
1461
- stepActionBtns.textContent = "";
1462
- (actionButtons || [])
1463
- .filter(Boolean)
1464
- .forEach((button) => stepActionBtns.appendChild(button));
1462
+ syncActionButtons(stepActionBtns, actionButtons);
1463
1464
let detailResult = {
1465
content: undefined,
@@ -3388,8 +3386,6 @@ function setupCollapsible(
3386
"div",
3387
"step-action-buttons",
3388
);
3391
- container.textContent = "";
3392
-
3389
const btn = ensureChild(container, ".expand-btn", "button", "expand-btn");
3390
const syncBtn = () => {
3391
const exp = messageDiv.classList.contains("expanded");
@@ -3411,7 +3407,7 @@ function setupCollapsible(
3407
btn.onclick = () =>
3408
setExpanded(!messageDiv.classList.contains("expanded"));
3409
3414
- actionButtons.filter(Boolean).forEach((b) => container.appendChild(b));
3410
+ syncActionButtons(container, actionButtons);
3411
3412
const refreshOverflow = () => {
3413
const hasOverflow = measureMessageCollapseOverflow(collapseContent, {