From a6276b590082d06480434a8ea002c335ea1cfb59 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:49:51 +0200 Subject: [PATCH] feat: smooth bursty AI chat streaming with a typewriter reveal (#9991) Co-authored-by: Claude Opus 4.8 (1M context) --- .../copilot/chat/AIChatManager.svelte.ts | 36 +++- .../copilot/chat/typewriterReveal.test.ts | 200 +++++++++++++++++ .../copilot/chat/typewriterReveal.ts | 204 ++++++++++++++++++ 3 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/typewriterReveal.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/typewriterReveal.ts diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index b455d0158e..177cc5b1ed 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -58,6 +58,7 @@ import { BROWSER } from 'esm-env' import { workspaceStore, type DBSchemas } from '$lib/stores' import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core' import { readDocsPageTool, searchDocsTool } from './docs/core' +import { TypewriterReveal } from './typewriterReveal' import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte' import { createAppBackendRunnableContextElement, @@ -102,6 +103,11 @@ import { getLocalSetting, storeLocalSetting } from '$lib/utils' import { AttachedFilesStore } from './files/attachedFiles.svelte' import { appendAttachedFilesRoster } from './files/fileTools' +// SSR and users who prefer reduced motion get no typewriter pacing. +function prefersInstantReveal(): boolean { + return !BROWSER || (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false) +} + // Compaction of the stored history: once the projected request size // (contextTokens — the provider's report when current, a fresh chars/4 // estimate otherwise — plus the new user message) reaches the trigger ratio of @@ -277,6 +283,19 @@ export class AIChatManager { currentReply = $state('') currentReasoning = $state('') currentReasoningActive = $state(false) + // Smooths the provider's bursty delivery into continuous typing by revealing + // buffered text a slice per frame. The reply and the reasoning/thinking stream + // each get their own reveal (independent buffers, both append to their own + // $state). Reduced-motion (sampled once — the pref never changes mid-session) + // and SSR fall back to instant. + private replyReveal = new TypewriterReveal({ + onReveal: (chunk) => (this.currentReply += chunk), + instant: prefersInstantReveal() + }) + private reasoningReveal = new TypewriterReveal({ + onReveal: (chunk) => (this.currentReasoning += chunk), + instant: prefersInstantReveal() + }) displayMessages = $state([]) messages = $state([]) /** Provider-reported context size of the last committed turn (prompt + @@ -1746,6 +1765,8 @@ export class AIChatManager { this.modifiedItems ? [...this.modifiedItems] : undefined ) + this.replyReveal.reset() + this.reasoningReveal.reset() this.currentReply = '' this.currentReasoning = '' this.currentReasoningActive = false @@ -1806,10 +1827,16 @@ export class AIChatManager { messages: [...this.messages], abortController: this.abortController, callbacks: { - onNewToken: (token) => (this.currentReply += token), - onReasoningDelta: (token) => (this.currentReasoning += token), + onNewToken: (token) => this.replyReveal.push(token), + onReasoningDelta: (token) => this.reasoningReveal.push(token), onReasoningStart: () => (this.currentReasoningActive = true), onMessageEnd: () => { + // Drain any un-revealed backlog into currentReply first, so the reads + // below see the full text. This funnel covers clean completion, tool + // boundaries, and abort/error — flush-before-read is the invariant that + // keeps text from being lost or duplicated on any exit path. + this.replyReveal.flush() + this.reasoningReveal.flush() // Keep the streamed text for the abort/error paths. Non-empty only: // parsers flush (and reset) when a tool call starts after text, and // the catch's later empty call would wipe it — stale keeps are @@ -2012,6 +2039,11 @@ export class AIChatManager { sendUserToast(getSendRequestErrorMessage(err, webSearchUnavailable), true) } finally { this.loading = false + // Turn teardown: cancel any in-flight reveal frame and drop leftover + // backlog. onMessageEnd already flushed on every outcome, so this only + // releases the loop; it never discards uncommitted text. + this.replyReveal.reset() + this.reasoningReveal.reset() } // Flush the queued message. Send it after a cleanly committed turn OR a // deliberate user cancel (Esc / Stop) — in both cases the user is ready diff --git a/frontend/src/lib/components/copilot/chat/typewriterReveal.test.ts b/frontend/src/lib/components/copilot/chat/typewriterReveal.test.ts new file mode 100644 index 0000000000..b99048c360 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/typewriterReveal.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest' +import { TypewriterReveal, type TypewriterRevealOptions } from './typewriterReveal' + +// A fake clock + manual scheduler modelling requestAnimationFrame: each frame() +// advances the clock by `dt` ms, then fires whatever callback is currently +// scheduled (which may reschedule the next). This drives the pacing +// deterministically without a browser. +class FakeScheduler { + t = 0 + scheduleCount = 0 + private queue = new Map void>() + private id = 0 + + now = () => this.t + schedule = (cb: () => void) => { + this.scheduleCount++ + const h = ++this.id + this.queue.set(h, cb) + return h + } + cancel = (h: unknown) => { + this.queue.delete(h as number) + } + pending() { + return this.queue.size + } + frame(dt: number) { + this.t += dt + const cbs = [...this.queue.values()] + this.queue.clear() + cbs.forEach((cb) => cb()) + } + frames(count: number, dt = 16) { + for (let i = 0; i < count; i++) this.frame(dt) + } +} + +function makeReveal(sched: FakeScheduler, opts: Partial = {}) { + const chunks: string[] = [] + const reveal = new TypewriterReveal({ + onReveal: (c) => chunks.push(c), + now: sched.now, + schedule: sched.schedule, + cancel: sched.cancel, + ...opts + }) + return { reveal, chunks, revealed: () => chunks.join('') } +} + +// No lone surrogate at any string end (a split pair would leave one). +function hasLoneSurrogate(s: string): boolean { + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i) + if (c >= 0xd800 && c <= 0xdbff) { + const next = s.charCodeAt(i + 1) + if (!(next >= 0xdc00 && next <= 0xdfff)) return true + i++ + } else if (c >= 0xdc00 && c <= 0xdfff) { + return true + } + } + return false +} + +describe('TypewriterReveal', () => { + it('reveals gradually — a burst is not fully painted on the first frame', () => { + const sched = new FakeScheduler() + const { reveal, revealed } = makeReveal(sched) + const text = 'x'.repeat(300) + reveal.push(text) + sched.frame(16) + expect(revealed().length).toBeGreaterThan(0) + expect(revealed().length).toBeLessThan(text.length) + }) + + it('preserves text exactly after flush (no loss, no duplication)', () => { + const sched = new FakeScheduler() + const { reveal, revealed } = makeReveal(sched) + const parts = ['Here are ', 'some names: ', 'Charles, George, ', 'Alfred, Harold.'] + parts.forEach((p) => reveal.push(p)) + sched.frames(3) // reveal only part of it + reveal.flush() + expect(revealed()).toBe(parts.join('')) + }) + + it('flushes repeatedly across tool boundaries without loss or duplication', () => { + const sched = new FakeScheduler() + const { reveal, revealed } = makeReveal(sched) + // Each segment is a message that ends in a flush (as onMessageEnd does at a + // tool-call boundary); the buffer is compacted between them. + const segments = ['first reply', 'second reply', 'third reply'] + segments.forEach((seg) => { + reveal.push(seg) + sched.frames(2) // partially reveal + reveal.flush() + }) + expect(revealed()).toBe(segments.join('')) + }) + + it('reset() drops un-revealed backlog', () => { + const sched = new FakeScheduler() + const { reveal, revealed } = makeReveal(sched) + const text = 'y'.repeat(300) + reveal.push(text) + sched.frame(16) // reveal a prefix + const afterOneFrame = revealed() + expect(afterOneFrame.length).toBeLessThan(text.length) + expect(text.startsWith(afterOneFrame)).toBe(true) + reveal.reset() + sched.frames(20) + expect(revealed()).toBe(afterOneFrame) // nothing further reached onReveal + }) + + it('fast-path: a backlog above the cap is revealed whole in one emit', () => { + const sched = new FakeScheduler() + const { reveal, chunks, revealed } = makeReveal(sched, { maxBacklogChars: 1500 }) + const text = 'z'.repeat(2000) + reveal.push(text) + sched.frame(16) + expect(revealed()).toBe(text) + expect(chunks.length).toBe(1) // dumped, not stretched + }) + + it('never splits a surrogate pair across chunks', () => { + const sched = new FakeScheduler() + const { reveal, chunks, revealed } = makeReveal(sched, { smoothingMs: 5000 }) // force ~1 char/frame + const text = 'a😀b👨‍👩‍👧c' + reveal.push(text) + sched.frames(60) + reveal.flush() + expect(revealed()).toBe(text) + chunks.forEach((c) => expect(hasLoneSurrogate(c)).toBe(false)) + }) + + it('instant mode reveals synchronously and never schedules', () => { + const sched = new FakeScheduler() + const { reveal, revealed } = makeReveal(sched, { instant: true }) + reveal.push('hello ') + reveal.push('world') + expect(revealed()).toBe('hello world') + expect(sched.scheduleCount).toBe(0) + }) + + it('steady-state backlog converges to ~arrivalRate × smoothingMs (no runaway, no stall)', () => { + const sched = new FakeScheduler() + const { reveal, revealed } = makeReveal(sched, { smoothingMs: 500 }) + const perFrame = 10 // chars pushed each 16ms frame → 0.625 chars/ms + let pushed = 0 + for (let i = 0; i < 200; i++) { + reveal.push('c'.repeat(perFrame)) + pushed += perFrame + sched.frame(16) + } + const backlog = pushed - revealed().length + // Target ≈ 0.625 * 500 = ~312. Assert it neither ran away nor drained to zero. + expect(backlog).toBeGreaterThan(50) + expect(backlog).toBeLessThan(900) + }) + + it('emit frequency stays bounded by the throttle', () => { + const sched = new FakeScheduler() + const minEmitIntervalMs = 33 + const { reveal, chunks } = makeReveal(sched, { minEmitIntervalMs }) + const frames = 60 + const dt = 16 + for (let i = 0; i < frames; i++) { + reveal.push('c'.repeat(10)) + sched.frame(dt) + } + const windowMs = frames * dt + expect(chunks.length).toBeLessThanOrEqual(Math.ceil(windowMs / minEmitIntervalMs) + 2) + }) + + it('suspends when fully revealed and resumes on the next push', () => { + const sched = new FakeScheduler() + const { reveal, revealed } = makeReveal(sched) + reveal.push('short') + sched.frames(30) + expect(revealed()).toBe('short') + expect(sched.pending()).toBe(0) // idle: no live frame scheduled + reveal.push(' more') + expect(sched.pending()).toBe(1) // restarted + sched.frames(30) + expect(revealed()).toBe('short more') + }) + + it('clamps a large elapsed time (backgrounded-tab resume) instead of blasting the backlog', () => { + const sched = new FakeScheduler() + const { reveal, revealed } = makeReveal(sched, { smoothingMs: 500, maxCatchupMs: 100 }) + const text = 'q'.repeat(300) + reveal.push(text) + sched.frame(16) // first (nominal) emit; lastEmit now set + const before = revealed().length + sched.frame(5000) // loop kept running but the frame fired seconds late + const revealedInBigFrame = revealed().length - before + // Without the clamp, rate × 5000ms would reveal the entire backlog at once. + expect(revealed().length).toBeLessThan(text.length) + expect(revealedInBigFrame).toBeLessThan(text.length / 2) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/typewriterReveal.ts b/frontend/src/lib/components/copilot/chat/typewriterReveal.ts new file mode 100644 index 0000000000..a4c71212f1 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/typewriterReveal.ts @@ -0,0 +1,204 @@ +// Perceived-smoothness layer for streamed assistant text. +// +// Providers (notably Anthropic for some model/tier combos) deliver text in +// coarse bursts — tens of tokens batched into one delta every ~450 ms — so the +// raw stream reads as freeze→jump→freeze. This module decouples *display* from +// *arrival*: pushed text lands in a non-reactive buffer, and a paint loop +// reveals a slice at a time through `onReveal`, so the bursts read as continuous +// typing. It is deliberately free of Svelte — the only coupling to reactive +// state is the `onReveal` callback — so the pacing is unit-testable with an +// injected clock and scheduler. + +type Schedule = (cb: () => void) => unknown +type Cancel = (handle: unknown) => void + +export interface TypewriterRevealOptions { + /** Called with each revealed slice; the owner appends it to reactive state. */ + onReveal: (chunk: string) => void + /** Reveal synchronously on push with no pacing (reduced-motion / SSR). */ + instant?: boolean + /** Target lag between arrival and display, in ms. The one meaningful knob. */ + smoothingMs?: number + /** Backlog at/above which the whole buffer is dumped in one emit (fast path). */ + maxBacklogChars?: number + /** Minimum gap between emits, in ms — caps downstream re-parse/reflow frequency. */ + minEmitIntervalMs?: number + /** Upper bound on the per-emit elapsed time, so a backgrounded-tab resume + * catches up over several emits instead of one large jump. */ + maxCatchupMs?: number + // Injectables for tests: + now?: () => number + schedule?: Schedule + cancel?: Cancel +} + +const defaultNow: () => number = + typeof performance !== 'undefined' && typeof performance.now === 'function' + ? () => performance.now() + : () => Date.now() + +const hasRAF = typeof requestAnimationFrame !== 'undefined' +const defaultSchedule: Schedule = hasRAF + ? (cb) => requestAnimationFrame(cb) + : (cb) => setTimeout(cb, 16) +const defaultCancel: Cancel = hasRAF + ? (h) => cancelAnimationFrame(h as number) + : (h) => clearTimeout(h as ReturnType) + +export class TypewriterReveal { + private readonly onReveal: (chunk: string) => void + private readonly instant: boolean + private readonly smoothingMs: number + private readonly maxBacklogChars: number + private readonly minEmitIntervalMs: number + private readonly maxCatchupMs: number + private readonly now: () => number + private readonly schedule: Schedule + private readonly cancel: Cancel + + // A stable buffer + an index into it: reveal advances `revealed` (O(1) per + // emit, no re-split). Everything before `revealed` has been emitted. + private buffer = '' + private revealed = 0 + private lastEmit: number | null = null + private handle: unknown = null + private running = false + + constructor(opts: TypewriterRevealOptions) { + this.onReveal = opts.onReveal + this.instant = opts.instant ?? false + this.smoothingMs = opts.smoothingMs ?? 500 + this.maxBacklogChars = opts.maxBacklogChars ?? 1500 + this.minEmitIntervalMs = opts.minEmitIntervalMs ?? 33 + this.maxCatchupMs = opts.maxCatchupMs ?? 100 + this.now = opts.now ?? defaultNow + this.schedule = opts.schedule ?? defaultSchedule + this.cancel = opts.cancel ?? defaultCancel + } + + /** Enqueue received text. In instant mode it is revealed synchronously. */ + push(text: string): void { + if (!text) return + if (this.instant) { + this.onReveal(text) + return + } + this.buffer += text + this.ensureRunning() + } + + /** Reveal everything still buffered now and stop. Call before reading the + * owner's reactive state into committed state, so the read sees the full text. */ + flush(): void { + this.stop() + if (this.instant) return + if (this.revealed < this.buffer.length) { + this.onReveal(this.buffer.slice(this.revealed)) + } + // Everything is revealed now, so drop the buffer rather than carry an + // ever-growing turn's worth of text: onMessageEnd fires flush() at every + // tool-call boundary, and without this the buffer would keep every prior + // segment until the turn's reset(). The next push starts a fresh buffer. + this.buffer = '' + this.revealed = 0 + } + + /** Drop un-revealed backlog and stop. Call at turn boundaries. */ + reset(): void { + this.stop() + this.buffer = '' + this.revealed = 0 + this.lastEmit = null + } + + private ensureRunning(): void { + if (this.running) return + this.running = true + // Re-anchor on resume from idle: a long gap since the last emit must not + // count as elapsed reveal time (the maxCatchupMs clamp only covers a + // still-running loop whose frame fired late). + this.lastEmit = null + this.handle = this.schedule(this.tick) + } + + private stop(): void { + if (this.handle != null) { + this.cancel(this.handle) + this.handle = null + } + this.running = false + } + + private tick = (): void => { + this.handle = null + const t = this.now() + const backlog = this.buffer.length - this.revealed + if (backlog <= 0) { + this.running = false + return + } + + if (backlog >= this.maxBacklogChars) { + // Fast path: a cached/non-streaming reply dumped as one big delta shows + // instantly instead of being stretched. Steady streaming settles well + // under the cap, so smoothing only ever applies to genuinely bursty input. + this.emit(backlog) + this.lastEmit = t + } else { + const first = this.lastEmit === null + // First emit after (re)start reveals a small nominal slice one frame + // after arrival, keeping first paint essentially immediate. + const sinceLast = first ? this.minEmitIntervalMs : t - this.lastEmit! + if (sinceLast < this.minEmitIntervalMs) { + // Throttle: too soon since the last emit — wait another frame. + this.handle = this.schedule(this.tick) + return + } + const elapsedMs = Math.min(sinceLast, this.maxCatchupMs) + const rate = backlog / this.smoothingMs // chars per ms; grows with backlog + const n = Math.min(backlog, Math.max(1, Math.floor(rate * elapsedMs))) + const emitted = this.emit(n) + this.lastEmit = t + if (emitted === 0) { + // Nothing revealable yet (a lone trailing high surrogate awaiting its + // low half). Suspend; the next push restarts the loop. + this.running = false + return + } + } + + if (this.revealed < this.buffer.length) { + this.handle = this.schedule(this.tick) + } else { + this.running = false + } + } + + // Reveal up to `n` chars from `revealed`, never splitting a surrogate pair. + // Returns the number of chars actually emitted (0 only when the buffer ends on + // a lone high surrogate whose low half hasn't arrived). + private emit(n: number): number { + let end = Math.min(this.revealed + n, this.buffer.length) + if (end < this.buffer.length) { + const c = this.buffer.charCodeAt(end) + // Landed on a low surrogate → cut before its high half. + if (c >= 0xdc00 && c <= 0xdfff) end -= 1 + } + if (end <= this.revealed) { + // A floor-1 slice landed inside a pair; take the whole pair so we still + // make progress rather than stalling on the same boundary each tick. + end = Math.min(this.revealed + 2, this.buffer.length) + } + if (end === this.buffer.length && end - 1 >= this.revealed) { + // Hold back a lone trailing high surrogate: its low half may still be + // streaming in, and revealing it alone would emit a broken code unit. + const last = this.buffer.charCodeAt(end - 1) + if (last >= 0xd800 && last <= 0xdbff) end -= 1 + } + if (end <= this.revealed) return 0 + const chunk = this.buffer.slice(this.revealed, end) + this.revealed = end + this.onReveal(chunk) + return chunk.length + } +}