fix(desktop): hold the sessions list refresh for the whole typing burst

The starvation cap in the original patch re-ran the heavy pass at the
same ~10s mark the freeze is measured at. Hold until the keyboard is
quiet, then land one coalesced pass.
This commit is contained in:
Brooklyn Nicholson
2026-08-26 13:40:12 -05:00
committed by brooklyn!
parent 01e9b9abb1
commit 68518c1f9b
2 changed files with 30 additions and 47 deletions
@@ -698,8 +698,7 @@ describe('typing-aware sessions.changed deferral', () => {
// A ~6s continuous burst: keys every 200ms, broadcasts every ~1s. The
// first broadcast finds the throttle gap already elapsed (primed), so the
// deferral engages immediately and must hold for the whole burst — well
// short of the one-gap starvation cap.
// deferral engages immediately and must hold for the whole burst.
for (let index = 0; index < 30; index += 1) {
typeKey()
@@ -735,7 +734,7 @@ describe('typing-aware sessions.changed deferral', () => {
expect(refreshSessions).toHaveBeenCalledTimes(1)
})
it('caps the typing deferral at one throttle gap so continuous typing cannot starve list freshness', async () => {
it('holds through a burst longer than the throttle gap and lands once after the keyboard quiets', async () => {
vi.useFakeTimers()
$changeEventsAvailable.set(true)
const refreshSessions = vi.fn(async () => undefined)
@@ -743,8 +742,8 @@ describe('typing-aware sessions.changed deferral', () => {
renderTypingSync(refreshSessions)
await primeThrottle(refreshSessions)
// Keys every 200ms for ~22s straight — a burst that outlives one full
// SESSIONS_LIST_TICK_GAP_MS of deferral. Broadcasts keep flowing.
// Keys every 200ms for ~22s — longer than SESSIONS_LIST_TICK_GAP_MS.
// Broadcasts keep flowing; the heavy pass must not land under them.
for (let index = 0; index < 110; index += 1) {
typeKey()
@@ -758,24 +757,21 @@ describe('typing-aware sessions.changed deferral', () => {
})
}
// The starvation cap forces exactly ONE mid-burst pass (~one gap after
// the deferral began) — worst-case freshness equals the plain cadence.
expect(refreshSessions).toHaveBeenCalledTimes(1)
expect(refreshSessions).not.toHaveBeenCalled()
// Burst ends → the still-held pass lands once more, then silence.
await act(async () => {
vi.advanceTimersByTime(2_000)
await Promise.resolve()
})
expect(refreshSessions).toHaveBeenCalledTimes(2)
expect(refreshSessions).toHaveBeenCalledTimes(1)
await act(async () => {
vi.advanceTimersByTime(10_000)
await Promise.resolve()
})
expect(refreshSessions).toHaveBeenCalledTimes(2)
expect(refreshSessions).toHaveBeenCalledTimes(1)
})
it('does not defer anything when the keyboard has been idle', async () => {
@@ -276,15 +276,13 @@ const SESSIONS_LIST_TICK_GAP_MS = 10_000
// A typing burst keeps the composer's contentEditable input handling on the
// same renderer main thread as the list refresh above (#95033): with a large
// session store, one refresh pass can block keystroke echo long enough that
// input visibly stalls. While the keyboard is warm, hold that pass and land it
// once shortly after the last keypress — mirroring the throttle's own trailing
// edge. Two guardrails keep this from costing freshness: the deferral never
// outlives SESSIONS_LIST_TICK_GAP_MS (worst case equals the plain cadence,
// even for someone typing continuously), and the lighter polls (active_list
// snapshot, cron, transcript backstops) are untouched — they carry liveness
// signals, not the heavy list reconciliation.
// input visibly stalls. While the keyboard is warm — any keydown in this
// renderer window, not just the composer — hold that pass and land it once
// shortly after the last keypress. Sidebar staleness during a burst is
// accepted; the lighter polls (active_list snapshot, cron, transcript
// backstops) keep their cadence because they carry liveness, not the heavy
// list reconciliation.
const TYPING_BURST_QUIET_MS = 1_500
const TYPING_DEFER_RECHECK_MS = 300
interface LiveSessionStatusItem {
id?: string
@@ -321,6 +319,10 @@ export function isTypingBurstActive(nowMs = Date.now()): boolean {
return nowMs - lastRendererInputAt < TYPING_BURST_QUIET_MS
}
function remainingTypingQuietMs(nowMs: number): number {
return Math.max(0, TYPING_BURST_QUIET_MS - (nowMs - lastRendererInputAt))
}
/** Forget keyboard history — test isolation only (mirrors
* resetLiveRuntimeTracking). */
export function resetTypingActivityTracking(): void {
@@ -684,7 +686,6 @@ export function useBackgroundSync({
let lastRunAt = 0
let timer: null | number = null
let typingDeferTimer: null | number = null
let deferStartedAt = 0
const run = () => {
lastRunAt = Date.now()
@@ -707,40 +708,30 @@ export function useBackgroundSync({
})
}
// Land one coalesced refresh pass — but hold it while a typing burst is
// warm (#95033), so the heavy list pass never lands under the user's
// keystrokes. A single recheck timer services every caller: ticks that
// arrive mid-deferral just find the timer already armed and return.
// Hold the coalesced pass while a typing burst is warm (#95033) so the
// heavy list work never lands under keystrokes. One timer services every
// caller: ticks that arrive mid-deferral find it already armed and return.
// Fire time is the remaining quiet window, not a poll — a later key
// extends lastRendererInputAt, and the firing callback re-arms if still
// warm. There is no starvation cap: a continuous burst keeps holding.
const runWhenKeyboardQuiet = () => {
const now = Date.now()
// Starvation cap: a deferral never outlives one full throttle gap, so
// worst-case list freshness equals the plain cadence even if the user
// types continuously.
if (!isTypingBurstActive(now) || (deferStartedAt > 0 && now - deferStartedAt >= SESSIONS_LIST_TICK_GAP_MS)) {
deferStartedAt = 0
// A tick may hit the terminal branch while a recheck is still pending
// (cap reached mid-burst); retire it so it cannot land a second pass.
if (!isTypingBurstActive(now)) {
if (typingDeferTimer !== null) {
window.clearTimeout(typingDeferTimer)
typingDeferTimer = null
}
run()
return
}
if (typingDeferTimer === null) {
if (deferStartedAt === 0) {
deferStartedAt = now
}
typingDeferTimer = window.setTimeout(() => {
typingDeferTimer = null
runWhenKeyboardQuiet()
}, TYPING_DEFER_RECHECK_MS)
}, remainingTypingQuietMs(now))
}
}
@@ -750,10 +741,8 @@ export function useBackgroundSync({
if (since >= SESSIONS_LIST_TICK_GAP_MS) {
runWhenKeyboardQuiet()
} else if (typingDeferTimer === null && timer === null) {
// Within the gap a pass is already scheduled — either the plain
// trailing timer or a typing deferral that will land no later than
// this tick's own deadline. Arming another one here would only stack
// extra passes behind the promised one.
// Within the gap a pass is already scheduled — trailing timer or a
// typing deferral. Arming another one here would stack extra passes.
timer = window.setTimeout(() => {
timer = null
runWhenKeyboardQuiet()
@@ -771,8 +760,6 @@ export function useBackgroundSync({
if (typingDeferTimer !== null) {
window.clearTimeout(typingDeferTimer)
}
deferStartedAt = 0
}
}, [
changeEventsAvailable,
@@ -783,9 +770,9 @@ export function useBackgroundSync({
updateSessionState
])
// Keyboard warmth for the deferral above: capture phase on window catches
// keydowns targeted at any focused element (composer included) on the way
// down. Pure timestamp write — no React state, negligible per-key cost.
// Keyboard warmth for the deferral above: capture phase on window. Any
// keydown in this renderer (composer, modal, settings) counts — conservative
// on purpose. Pure timestamp write, no React state.
useEffect(() => {
const markInput = (): void => noteRendererKeyboardActivity()