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:
committed by
brooklyn!
parent
01e9b9abb1
commit
68518c1f9b
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user