From b794c9e5527f6afc71dd8b59be42473939c9e63e Mon Sep 17 00:00:00 2001 From: JD Date: Tue, 26 May 2026 18:07:55 +0000 Subject: [PATCH 1/3] fix: throttle SSE part deltas to reduce mobile freezes Accumulate text deltas in a buffer and flush every 50ms, so rapid streaming chunks don't trigger a Solid store mutation + re-render on every single delta event. On mobile this cuts the main-thread blocking caused by KaTeX / highlight.js re-renders during streaming responses. --- packages/ui/src/stores/session-events.ts | 29 ++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 90430b1f5..cc660d49a 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -327,7 +327,7 @@ function findPendingSyntheticMessageId( if (!record) continue if (record.sessionId !== sessionId) continue if (record.role !== role) continue - if (record.status !== "sending") continue + if (record.status !== "sending" && record.status !== "sent") continue if (!record.isEphemeral) continue return record.id } @@ -453,12 +453,37 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes } } +const DELTA_FLUSH_INTERVAL = 50 + +const pendingDeltas = new Map() +let deltaFlushTimer: ReturnType | null = null + +function enqueueDelta(instanceId: string, messageId: string, partId: string, field: string, delta: string) { + const key = `${instanceId}:${messageId}:${partId}:${field}` + const existing = pendingDeltas.get(key) + const accumulated = existing ? existing.delta + delta : delta + pendingDeltas.set(key, { instanceId, messageId, partId, field, delta: accumulated }) + if (deltaFlushTimer === null) { + deltaFlushTimer = setTimeout(flushDeltas, DELTA_FLUSH_INTERVAL) + } +} + +function flushDeltas() { + deltaFlushTimer = null + if (pendingDeltas.size === 0) return + const batch = Array.from(pendingDeltas.values()) + pendingDeltas.clear() + for (const { instanceId, messageId, partId, field, delta } of batch) { + applyPartDeltaV2(instanceId, { messageId, partId, field, delta }) + } +} + function handleMessagePartDelta(instanceId: string, event: MessagePartDeltaEvent): void { const props = event.properties if (!props) return const { messageID, partID, field, delta } = props if (!messageID || !partID || !field || typeof delta !== "string") return - applyPartDeltaV2(instanceId, { messageId: messageID, partId: partID, field, delta }) + enqueueDelta(instanceId, messageID, partID, field, delta) } function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void { From 9fc0de1af8fd1635a965fcc0375ec200ac2f3212 Mon Sep 17 00:00:00 2001 From: JD Date: Wed, 27 May 2026 01:27:56 +0000 Subject: [PATCH 2/3] fix: prevent text duplication from stale deltas after part updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When message.part.updated arrives with a complete part, applyPartUpdate replaces the part entirely with the server's state. But if deltas were enqueued before the part update arrived and haven't flushed yet, those stale deltas would be applied AFTER the replacement, causing text duplication at the end of assistant messages. Fix: clear pending deltas for a part before applying the full part update, since the update already contains the complete state and any accumulated deltas are now stale. This prevents the race where: 1. Deltas accumulate in the 50ms throttle window 2. message.part.updated arrives and replaces the part with complete text 3. Delta timer expires and concatenates stale deltas → duplicate text --- packages/ui/src/stores/session-events.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index cc660d49a..2f473eac7 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -384,6 +384,12 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" }) } + // Clear any pending deltas for this part before applying the full part update. + // The part update contains the complete state from the server, so accumulated + // deltas would be stale and cause duplication if flushed later. + if (part.id) { + clearPendingDeltasForPart(instanceId, messageId, part.id) + } applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId }) handleConversationAssistantPartUpdated(instanceId, { ...part, sessionID: sessionId, messageID: messageId }, messageInfo) @@ -468,6 +474,18 @@ function enqueueDelta(instanceId: string, messageId: string, partId: string, fie } } +function clearPendingDeltasForPart(instanceId: string, messageId: string, partId: string) { + const keysToDelete: string[] = [] + for (const key of pendingDeltas.keys()) { + if (key.startsWith(`${instanceId}:${messageId}:${partId}:`)) { + keysToDelete.push(key) + } + } + for (const key of keysToDelete) { + pendingDeltas.delete(key) + } +} + function flushDeltas() { deltaFlushTimer = null if (pendingDeltas.size === 0) return From 105838b5cdc5a4bb99ef9375e5f025967fde06cf Mon Sep 17 00:00:00 2001 From: JD Date: Tue, 9 Jun 2026 10:30:52 +0000 Subject: [PATCH 3/3] fix(ui): flush pending deltas before message.updated to preserve event ordering When deltas are buffered for up to 50ms and message.updated arrives before the flush timer fires, the message could be marked complete/error before pending text mutations are applied. This causes the UI to observe a terminal-status message with stale content. Fix: flush any pending deltas for the message before applying the message.updated event. This preserves the server's event ordering: all delta content is applied first, then the message status/metadata update runs on the complete content. Addresses gatekeeper review blocking finding #1 on PR #536. --- packages/ui/src/stores/session-events.ts | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 2f473eac7..0df24cc6e 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -408,6 +408,14 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes const messageId = typeof info.id === "string" ? info.id : undefined if (!sessionId || !messageId) return + // Flush any pending deltas for this message before applying the update. + // Deltas are buffered for up to 50ms; if message.updated arrives before + // the buffer flushes, the message could be marked complete/error with + // stale text mutations still pending. Flushing first preserves the + // server's event ordering: all delta content is applied, then the + // message status/metadata update runs on the complete content. + flushPendingDeltasForMessage(instanceId, messageId) + const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number } const nextUpdated = typeof timeInfo.end === "number" && timeInfo.end > 0 @@ -486,6 +494,24 @@ function clearPendingDeltasForPart(instanceId: string, messageId: string, partId } } +function flushPendingDeltasForMessage(instanceId: string, messageId: string): void { + const prefix = `${instanceId}:${messageId}:` + for (const key of pendingDeltas.keys()) { + if (key.startsWith(prefix)) { + const pending = pendingDeltas.get(key) + if (pending) { + applyPartDeltaV2(instanceId, { + messageId: pending.messageId, + partId: pending.partId, + field: pending.field, + delta: pending.delta, + }) + pendingDeltas.delete(key) + } + } + } +} + function flushDeltas() { deltaFlushTimer = null if (pendingDeltas.size === 0) return