feat(ui): smooth streaming of agent message tokens#2685
Conversation
Streamed assistant tokens arrive in bursty chunks, so the message text jumped forward unevenly as it rendered. Add a useSmoothText hook that reveals the accumulated text at a steady character rate using requestAnimationFrame, so bursts read as smooth typing. - Text present on mount renders immediately, so history and completed messages never replay a typewriter effect; only growth while mounted is animated. - Snaps instantly when the source is replaced (not a prefix) or when the user prefers reduced motion. - Auto-scroll-to-bottom is preserved: the virtualized list follows the rendered height as the smoothed text grows. Closes PostHog#2517
Prompt To Fix All With AIFix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
packages/ui/src/primitives/hooks/useSmoothText.test.ts:8-31
The `nextRevealLength` cases are all testing a pure function with varying inputs and a single expected output — a textbook fit for `it.each`. The current form repeats the `expect(nextRevealLength(...)).toBe(...)` structure five times and buries the "caught-up-past-target" case as a second assertion inside the first test rather than its own row. Parameterising them would make the contract of the function easier to scan and add new cases to.
```suggestion
describe("nextRevealLength", () => {
it.each([
// label current target elapsedMs rate expected
["caught up: equal", 10, 10, 16, 120, 10],
["caught up: already past", 12, 10, 16, 120, 10],
["advances proportionally", 0, 100, 100, 120, 12],
["never overshoots", 95, 100, 1000, 120, 100],
["always advances at least one", 0, 100, 0, 120, 1],
["snaps when lag is too large", 0, 5000, 16, 120, 5000],
])("%s", (_, current, target, elapsedMs, rate, expected) => {
expect(nextRevealLength(current, target, elapsedMs, rate)).toBe(expected);
});
});
```
Reviews (1): Last reviewed commit: "feat(ui): smooth streaming of agent mess..." | Re-trigger Greptile |
| describe("nextRevealLength", () => { | ||
| it("returns the target when already caught up", () => { | ||
| expect(nextRevealLength(10, 10, 16, 120)).toBe(10); | ||
| expect(nextRevealLength(12, 10, 16, 120)).toBe(10); | ||
| }); | ||
|
|
||
| it("advances proportionally to elapsed time and rate", () => { | ||
| // 120 chars/sec over 100ms = 12 chars. | ||
| expect(nextRevealLength(0, 100, 100, 120)).toBe(12); | ||
| }); | ||
|
|
||
| it("never overshoots the target", () => { | ||
| expect(nextRevealLength(95, 100, 1000, 120)).toBe(100); | ||
| }); | ||
|
|
||
| it("always advances at least one char when behind", () => { | ||
| // Tiny elapsed time would round to zero; keep forward progress. | ||
| expect(nextRevealLength(0, 100, 0, 120)).toBe(1); | ||
| }); | ||
|
|
||
| it("snaps when the lag is too large to ease pleasantly", () => { | ||
| expect(nextRevealLength(0, 5000, 16, 120)).toBe(5000); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
The
nextRevealLength cases are all testing a pure function with varying inputs and a single expected output — a textbook fit for it.each. The current form repeats the expect(nextRevealLength(...)).toBe(...) structure five times and buries the "caught-up-past-target" case as a second assertion inside the first test rather than its own row. Parameterising them would make the contract of the function easier to scan and add new cases to.
| describe("nextRevealLength", () => { | |
| it("returns the target when already caught up", () => { | |
| expect(nextRevealLength(10, 10, 16, 120)).toBe(10); | |
| expect(nextRevealLength(12, 10, 16, 120)).toBe(10); | |
| }); | |
| it("advances proportionally to elapsed time and rate", () => { | |
| // 120 chars/sec over 100ms = 12 chars. | |
| expect(nextRevealLength(0, 100, 100, 120)).toBe(12); | |
| }); | |
| it("never overshoots the target", () => { | |
| expect(nextRevealLength(95, 100, 1000, 120)).toBe(100); | |
| }); | |
| it("always advances at least one char when behind", () => { | |
| // Tiny elapsed time would round to zero; keep forward progress. | |
| expect(nextRevealLength(0, 100, 0, 120)).toBe(1); | |
| }); | |
| it("snaps when the lag is too large to ease pleasantly", () => { | |
| expect(nextRevealLength(0, 5000, 16, 120)).toBe(5000); | |
| }); | |
| }); | |
| describe("nextRevealLength", () => { | |
| it.each([ | |
| // label current target elapsedMs rate expected | |
| ["caught up: equal", 10, 10, 16, 120, 10], | |
| ["caught up: already past", 12, 10, 16, 120, 10], | |
| ["advances proportionally", 0, 100, 100, 120, 12], | |
| ["never overshoots", 95, 100, 1000, 120, 100], | |
| ["always advances at least one", 0, 100, 0, 120, 1], | |
| ["snaps when lag is too large", 0, 5000, 16, 120, 5000], | |
| ])("%s", (_, current, target, elapsedMs, rate, expected) => { | |
| expect(nextRevealLength(current, target, elapsedMs, rate)).toBe(expected); | |
| }); | |
| }); |
Context Used: Do not attempt to comment on incorrect alphabetica... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/ui/src/primitives/hooks/useSmoothText.test.ts
Line: 8-31
Comment:
The `nextRevealLength` cases are all testing a pure function with varying inputs and a single expected output — a textbook fit for `it.each`. The current form repeats the `expect(nextRevealLength(...)).toBe(...)` structure five times and buries the "caught-up-past-target" case as a second assertion inside the first test rather than its own row. Parameterising them would make the contract of the function easier to scan and add new cases to.
```suggestion
describe("nextRevealLength", () => {
it.each([
// label current target elapsedMs rate expected
["caught up: equal", 10, 10, 16, 120, 10],
["caught up: already past", 12, 10, 16, 120, 10],
["advances proportionally", 0, 100, 100, 120, 12],
["never overshoots", 95, 100, 1000, 120, 100],
["always advances at least one", 0, 100, 0, 120, 1],
["snaps when lag is too large", 0, 5000, 16, 120, 5000],
])("%s", (_, current, target, elapsedMs, rate, expected) => {
expect(nextRevealLength(current, target, elapsedMs, rate)).toBe(expected);
});
});
```
**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
|
hey @archievi, thanks for this! excited to try it out, can you handle greptile comments and do a second review pass on it? |
Problem
Closes #2517
Streamed assistant tokens arrive from the agent in bursty chunks. Because the message component re-renders the full accumulated text on every chunk, the text jumps forward unevenly instead of reading as smooth, steady typing. The issue asks for smooth token streaming (per the Upstash smooth-streaming approach) while keeping the conversation pinned to the bottom as it streams.
Changes
useSmoothTexthook (packages/ui/src/primitives/hooks/useSmoothText.ts) that decouples the render rate from the token-arrival rate. It reveals the accumulated text a few characters per frame viarequestAnimationFrame, so bursts of tokens are eased out at a steady rate (~120 chars/sec).AgentMessageto render the smoothed text. Copy-to-clipboard still copies the fullcontent, not the partially revealed prefix.Design notes:
prefers-reduced-motion: reduceset.The reveal rate easing is extracted into a pure
nextRevealLengthfunction so it is unit-testable without timers.How did you test this?
packages/ui/src/primitives/hooks/useSmoothText.test.tscovering the pure easing function and the hook (mockedrequestAnimationFrame/matchMedia): immediate render on mount, gradual reveal of an appended burst, full catch-up, snap-on-replace, and reduced-motion. Verified the gradual-reveal and snap-on-replace tests fail when the hook is reduced to a pass-through, confirming they guard the behavior.pnpm --filter @posthog/ui test-> 82 files, 737 tests pass.pnpm --filter @posthog/ui typecheck(tsc --noEmit) passes; the pre-commit hook's fullpnpm typecheckalso passes.biome checkpasses on the changed files.Automatic notifications