feat(chat): block-level streaming reveal with a gliding follow
Token-by-token streaming mutates the trailing paragraph in place on every tick: words rewrap, the last line jitters, and the whole reply reads as flicker. Streamed text now commits only up to the last complete line — prose arrives a paragraph at a time (a markdown paragraph is one logical line), code fences reveal line by line, tables row by row — and a shown block never changes again. A paragraph that runs long without a newline releases at the last sentence (then word) boundary so the stream never stalls. Applies to assistant text and reasoning; tool output keeps its raw tail. With growth arriving in block steps, the end follow switches to the list's animated mode so each step is a glide — reveal and scroll read as one continuous motion. The gesture opt-out now measures at-end from the live list state instead of the cached flag, which the animated glide deliberately leaves stale while trailing the edge; without that, a drag during a glide could leave the scroll-to-bottom pill unshown. Measured: end-following holds at distance 0 for the whole stream, and the mobile drag opt-out shows the pill in three of three runs. The continuous glide costs ~15% more main-thread time per streamed character than the instant follow — the price of the motion.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { commitStreamedText } from './streamTextCommit';
|
||||
|
||||
describe('commitStreamedText', () => {
|
||||
test('holds an incomplete short paragraph entirely', () => {
|
||||
expect(commitStreamedText('An unfinished thought abo')).toBe('');
|
||||
});
|
||||
|
||||
test('commits up to the last complete line', () => {
|
||||
expect(commitStreamedText('First paragraph.\n\nSecond par')).toBe('First paragraph.\n\n');
|
||||
});
|
||||
|
||||
test('reveals code fences line by line', () => {
|
||||
const text = '```py\nprint("a")\nprint("b';
|
||||
expect(commitStreamedText(text)).toBe('```py\nprint("a")\n');
|
||||
});
|
||||
|
||||
test('releases a long held paragraph at the last sentence boundary', () => {
|
||||
const sentence = 'A finished sentence lives here. ';
|
||||
const text = sentence.repeat(12) + 'and an unfinished trail';
|
||||
expect(commitStreamedText(text)).toBe(sentence.repeat(12));
|
||||
});
|
||||
|
||||
test('falls back to the last word boundary without sentences', () => {
|
||||
const words = 'word '.repeat(70);
|
||||
const text = words + 'unfinishe';
|
||||
expect(commitStreamedText(text)).toBe(words);
|
||||
});
|
||||
|
||||
test('keeps unbreakable runs intact rather than splitting them', () => {
|
||||
const run = 'x'.repeat(400);
|
||||
expect(commitStreamedText(run)).toBe(run);
|
||||
});
|
||||
|
||||
test('empty input stays empty', () => {
|
||||
expect(commitStreamedText('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// Block-level streaming reveal.
|
||||
//
|
||||
// Token-by-token streaming mutates the trailing paragraph in place on every
|
||||
// tick: words rewrap, the last line jitters, and the reader's eye fights the
|
||||
// motion. Committing only up to the last COMPLETE line keeps every rendered
|
||||
// block immutable once it appears — prose arrives a paragraph at a time (a
|
||||
// markdown paragraph is one logical line), code fences reveal line by line,
|
||||
// tables row by row — and the only remaining motion is the follow scroll.
|
||||
//
|
||||
// A paragraph with no newline for a long stretch must not stall the stream,
|
||||
// so once the held tail outgrows a threshold it is committed at the last
|
||||
// sentence boundary (falling back to the last word boundary).
|
||||
|
||||
const HOLD_MAX_CHARS = 320;
|
||||
|
||||
const SENTENCE_END = /[.!?…][)"'»”’]?\s/g;
|
||||
|
||||
export const commitStreamedText = (text: string): string => {
|
||||
if (text.length === 0) return text;
|
||||
|
||||
const lastNewline = text.lastIndexOf('\n');
|
||||
const committed = lastNewline === -1 ? '' : text.slice(0, lastNewline + 1);
|
||||
const held = text.slice(committed.length);
|
||||
|
||||
if (held.length <= HOLD_MAX_CHARS) {
|
||||
return committed;
|
||||
}
|
||||
|
||||
// The held paragraph got long: release it up to the last finished
|
||||
// sentence so the block still never mutates mid-sentence.
|
||||
let lastSentenceEnd = -1;
|
||||
for (const match of held.matchAll(SENTENCE_END)) {
|
||||
lastSentenceEnd = match.index + match[0].length;
|
||||
}
|
||||
if (lastSentenceEnd > 0) {
|
||||
return committed + held.slice(0, lastSentenceEnd);
|
||||
}
|
||||
|
||||
// No sentence boundary either (a URL, a very long token run): release up
|
||||
// to the last word boundary, keeping only the incomplete word held.
|
||||
const lastSpace = held.lastIndexOf(' ');
|
||||
if (lastSpace > 0) {
|
||||
return committed + held.slice(0, lastSpace + 1);
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
Reference in New Issue
Block a user