mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): improve UI performance on Windows with long conversations (#311)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user