From ecc72366924395e34cadf8a30ad3d330bd9cf01b Mon Sep 17 00:00:00 2001 From: DengQi Date: Wed, 18 Mar 2026 18:57:10 +0800 Subject: [PATCH] fix(web): improve UI performance on Windows with long conversations (#311) --- .../components/AssistantChat/HappyThread.tsx | 2 +- web/src/index.css | 10 +++ web/src/lib/message-window-store.ts | 67 ++++++++++++++++--- web/src/lib/shiki.ts | 5 +- 4 files changed, 73 insertions(+), 11 deletions(-) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 09654f17..c2957840 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -326,7 +326,7 @@ export function HappyThread(props: { ) : null} )} -
+
diff --git a/web/src/index.css b/web/src/index.css index d74b031b..cfe1df16 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -132,6 +132,16 @@ body { } } +/* + * content-visibility: auto lets the browser skip layout/paint for messages + * scrolled out of the viewport. Big win on Windows with long conversations (#310). + * contain-intrinsic-size gives a rough height hint so scrollbar doesn't jump. + */ +.happy-thread-messages > * { + content-visibility: auto; + contain-intrinsic-size: auto 80px; +} + /* Markdown styles */ .markdown-content a { color: var(--app-link); text-decoration: underline; } .markdown-content code { background: var(--app-inline-code-bg); padding: 0.1em 0.3em; border-radius: 4px; font-size: 0.9em; } diff --git a/web/src/lib/message-window-store.ts b/web/src/lib/message-window-store.ts index e1b3efd9..0951fde2 100644 --- a/web/src/lib/message-window-store.ts +++ b/web/src/lib/message-window-store.ts @@ -38,6 +38,48 @@ const states = new Map() const listeners = new Map void>>() const pendingVisibilityCacheBySession = new Map>() +// Throttled notification: coalesce rapid state updates into at most one +// notification per NOTIFY_THROTTLE_MS during streaming. This prevents +// Windows UI jank caused by excessive React re-renders during SSE streaming. +const NOTIFY_THROTTLE_MS = 150 +const pendingNotifySessionIds = new Set() +let notifyRafId: ReturnType | null = null +let lastNotifyAt = 0 + +function scheduleNotify(sessionId: string): void { + pendingNotifySessionIds.add(sessionId) + if (notifyRafId !== null) { + return + } + const elapsed = Date.now() - lastNotifyAt + if (elapsed >= NOTIFY_THROTTLE_MS) { + // Enough time has passed — flush on next animation frame + notifyRafId = requestAnimationFrame(flushNotifications) + } else { + // Too soon — delay until the throttle window expires, then use rAF + const remaining = NOTIFY_THROTTLE_MS - elapsed + setTimeout(() => { + notifyRafId = requestAnimationFrame(flushNotifications) + }, remaining) + // Use a sentinel so we don't double-schedule + notifyRafId = -1 as unknown as ReturnType + } +} + +function flushNotifications(): void { + notifyRafId = null + lastNotifyAt = Date.now() + const sessionIds = Array.from(pendingNotifySessionIds) + pendingNotifySessionIds.clear() + for (const sessionId of sessionIds) { + const subs = listeners.get(sessionId) + if (!subs) continue + for (const listener of subs) { + listener() + } + } +} + function getPendingVisibilityCache(sessionId: string): Map { const existing = pendingVisibilityCacheBySession.get(sessionId) if (existing) { @@ -117,6 +159,11 @@ function getState(sessionId: string): InternalState { } function notify(sessionId: string): void { + scheduleNotify(sessionId) +} + +function notifyImmediate(sessionId: string): void { + // Bypass throttle for user-initiated actions (flush, clear, etc.) const subs = listeners.get(sessionId) if (!subs) return for (const listener of subs) { @@ -124,16 +171,20 @@ function notify(sessionId: string): void { } } -function setState(sessionId: string, next: InternalState): void { +function setState(sessionId: string, next: InternalState, immediate?: boolean): void { states.set(sessionId, next) - notify(sessionId) + if (immediate) { + notifyImmediate(sessionId) + } else { + notify(sessionId) + } } -function updateState(sessionId: string, updater: (prev: InternalState) => InternalState): void { +function updateState(sessionId: string, updater: (prev: InternalState) => InternalState, immediate?: boolean): void { const prev = getState(sessionId) const next = updater(prev) if (next !== prev) { - setState(sessionId, next) + setState(sessionId, next, immediate) } } @@ -294,7 +345,7 @@ export function clearMessageWindow(sessionId: string): void { if (!states.has(sessionId)) { return } - setState(sessionId, createState(sessionId)) + setState(sessionId, createState(sessionId), true) } export function seedMessageWindowFromSession(fromSessionId: string, toSessionId: string): void { @@ -438,7 +489,7 @@ export function flushPendingMessages(sessionId: string): boolean { pendingOverflowVisibleCount: 0, warning: needsRefresh ? (prev.warning ?? PENDING_OVERFLOW_WARNING) : prev.warning, }) - }) + }, true) return needsRefresh } @@ -448,7 +499,7 @@ export function setAtBottom(sessionId: string, atBottom: boolean): void { return prev } return buildState(prev, { atBottom }) - }) + }, true) } export function appendOptimisticMessage(sessionId: string, message: DecryptedMessage): void { @@ -457,7 +508,7 @@ export function appendOptimisticMessage(sessionId: string, message: DecryptedMes const trimmed = trimVisible(merged, 'append') const pending = filterPendingAgainstVisible(prev.pending, trimmed) return buildState(prev, { messages: trimmed, pending, atBottom: true }) - }) + }, true) } export function updateMessageStatus(sessionId: string, localId: string, status: MessageStatus): void { diff --git a/web/src/lib/shiki.ts b/web/src/lib/shiki.ts index 7ae51583..255fb9f1 100644 --- a/web/src/lib/shiki.ts +++ b/web/src/lib/shiki.ts @@ -152,8 +152,9 @@ export function useShikiHighlighter( setHighlighted(rendered as ReactNode) } - // Debounce highlighting - const timer = setTimeout(highlight, 50) + // Debounce highlighting — 150ms reduces CPU pressure on Windows during + // streaming where code blocks update rapidly (see #310) + const timer = setTimeout(highlight, 150) return () => { cancelled = true clearTimeout(timer)