fix(web): improve UI performance on Windows with long conversations (#311)

This commit is contained in:
DengQi
2026-03-18 19:11:36 +08:00
committed by weishu
parent f967bd9928
commit ecc7236692
4 changed files with 73 additions and 11 deletions
@@ -326,7 +326,7 @@ export function HappyThread(props: {
) : null}
</>
)}
<div className="flex flex-col gap-3">
<div className="happy-thread-messages flex flex-col gap-3">
<ThreadPrimitive.Messages components={THREAD_MESSAGE_COMPONENTS} />
</div>
</div>
+10
View File
@@ -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; }
+59 -8
View File
@@ -38,6 +38,48 @@ const states = new Map<string, InternalState>()
const listeners = new Map<string, Set<() => void>>()
const pendingVisibilityCacheBySession = new Map<string, Map<string, PendingVisibilityCacheEntry>>()
// 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<string>()
let notifyRafId: ReturnType<typeof requestAnimationFrame> | 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<typeof requestAnimationFrame>
}
}
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<string, PendingVisibilityCacheEntry> {
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 {
+3 -2
View File
@@ -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)