From 51d13ff829881fd194ae6f04647eef2d6524b604 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Mon, 26 Oct 2026 11:37:39 +0100 Subject: [PATCH] fix(ai-components): restart useHeardText for a new text and treat Infinity as fully heard - Start the reveal over when `text` is replaced by a different text (the next reply rendered by the same component): the length shown and skip() reset, and `live` is read again. Growing or truncating the text keeps the reveal, capped at the new length. - `chars: Infinity` reveals the whole text, as the type doc says. - Document that `undefined` is only for text that is not spoken, that `subscribe` is bound once (remount to switch sources) and is the only wake-up for a settled hook, and that `heard` runs during render. --- src/plugins/AIComponents/README.md | 23 ++-- .../__tests__/useHeardText.test.ts | 86 +++++++++++- .../AIComponents/hooks/useHeardText.ts | 126 +++++++++++++----- 3 files changed, 192 insertions(+), 43 deletions(-) diff --git a/src/plugins/AIComponents/README.md b/src/plugins/AIComponents/README.md index 74440f17d..7c8ac9529 100644 --- a/src/plugins/AIComponents/README.md +++ b/src/plugins/AIComponents/README.md @@ -588,17 +588,17 @@ type HeardTextSubscribe = (listener: () => void) => () => void; const useHeardText: (params: UseHeardTextParams) => UseHeardTextResult; ``` -`heard(now)` returns how many characters of the text the user has heard by `now` (a `performance.now()` timestamp) and whether the speech for it has finished. `chars` counts Unicode code points (`Array.from(text).length`), not UTF-16 code units. Return `undefined` when the text is not paced, and `{ chars: 0, done: false }` for a spoken text whose audio has not started yet. +`heard(now)` returns how many characters of the text the user has heard by `now` (a `performance.now()` timestamp) and whether the speech for it has finished. `chars` counts Unicode code points (`Array.from(text).length`), not UTF-16 code units; `Infinity` means the whole text. Return `undefined` only for a text that is not spoken at all. A text that will be spoken but whose audio has not started, or that the voice source does not know yet, must return `{ chars: 0, done: false }`: while `heard` returns `undefined` the full text is shown, and the reveal never moves back past what has been shown. `heard` is also called during render, so keep it free of side effects. #### Props -| Name | Type | Required | Description | -| ------------ | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | -| `text` | `string` | yes | The full text to reveal. | -| `heard` | `HeardTextFunction` | yes | The speech progress of `text`. | -| `subscribe` | `HeardTextSubscribe` | no | Change notifications from the speech progress source; each one makes the hook check `heard` again. | -| `generating` | `boolean` | no | `true` while the text is still being written. The full text is revealed once `heard` reports `done` and this is falsy. | -| `live` | `boolean` | no | Defaults to `true`. `false` reveals the full text at once, for example for a message loaded from history. Read at mount. | +| Name | Type | Required | Description | +| ------------ | -------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | +| `text` | `string` | yes | The full text to reveal. | +| `heard` | `HeardTextFunction` | yes | The speech progress of `text`. | +| `subscribe` | `HeardTextSubscribe` | no | Change notifications from the speech progress source; each one makes the hook check `heard` again. See below. | +| `generating` | `boolean` | no | `true` while the text is still being written. The full text is revealed once `heard` reports `done` and this is falsy. | +| `live` | `boolean` | no | Defaults to `true`. `false` reveals the full text at once, for example for a message loaded from history. See below. | #### Returns @@ -610,10 +610,11 @@ const useHeardText: (params: UseHeardTextParams) => UseHeardTextResult; Behaviour: -- It reveals the first `floor(chars)` code points of `text` and never moves backwards. Emoji and other astral characters are never split. -- While the text is paced and its speech is not done, it samples `heard` on every animation frame, and stops when the speech is done or the component unmounts. `subscribe` notifications and changes of `text` or `generating` make it check again. +- It reveals the first `floor(chars)` code points of `text` and never moves backwards while `text` grows or is truncated. Emoji and other astral characters are never split. +- When `text` is replaced by a different text (the next reply rendered by the same component), the reveal starts over: the length shown and `skip()` are reset, and `live` is read again. `live` is otherwise read only at mount. +- While the text is paced and its speech is not done, it samples `heard` on every animation frame, and stops when the speech is done or the component unmounts. `subscribe` notifications and changes of `text` or `generating` make it check again. Without `subscribe`, nothing else does: pass it when `heard` can change after the speech is done, or start returning a value after returning `undefined`. - If `heard` returns `undefined` at first and a value later, the reveal continues from the length already shown. -- `heard` and `subscribe` are read through refs: passing new functions on every render (inline arrow functions) does not restart the pacing. `subscribe` is called once and unsubscribed on unmount. +- `heard` and `subscribe` are read through refs: passing new functions on every render (inline arrow functions) does not restart the pacing. `subscribe` is called once and unsubscribed on unmount; to switch to another speech source, remount the component (for example with a `key`). #### Example diff --git a/src/plugins/AIComponents/__tests__/useHeardText.test.ts b/src/plugins/AIComponents/__tests__/useHeardText.test.ts index 4c72494f3..8d3301964 100644 --- a/src/plugins/AIComponents/__tests__/useHeardText.test.ts +++ b/src/plugins/AIComponents/__tests__/useHeardText.test.ts @@ -72,7 +72,7 @@ describe('useHeardText', () => { expect(result.current.heardText).toBe(TEXT.slice(0, 4)); }); - it('treats non-finite and negative counts as nothing heard', () => { + it('treats NaN, negative and non-numeric counts as nothing heard', () => { let progress: HeardTextProgress = { chars: Number.NaN, done: false }; const { result } = renderHeardText({ heard: () => progress, text: TEXT }); expect(result.current.heardText).toBe(''); @@ -80,6 +80,23 @@ describe('useHeardText', () => { progress = { chars: -3, done: false }; advance(FRAME_MS); expect(result.current.heardText).toBe(''); + + progress = { chars: Number.NEGATIVE_INFINITY, done: false }; + advance(FRAME_MS); + expect(result.current.heardText).toBe(''); + + // a JS caller returning no count at all + progress = { done: false } as unknown as HeardTextProgress; + advance(FRAME_MS); + expect(result.current.heardText).toBe(''); + }); + + it('treats an infinite count as the whole text heard', () => { + const { result } = renderHeardText({ + heard: () => ({ chars: Number.POSITIVE_INFINITY, done: false }), + text: TEXT, + }); + expect(result.current).toMatchObject({ heardText: TEXT, isPaced: true }); }); it('never moves the reveal backwards', () => { @@ -290,6 +307,73 @@ describe('useHeardText', () => { expect(result.current.heardText).toBe(TEXT); }); + describe('when the text is replaced by a different text', () => { + const NEXT_TEXT = 'Sure, here is the forecast for tomorrow.'; + + it('starts a new reveal instead of carrying over the length already shown', () => { + let progress: HeardTextProgress = { chars: 100, done: false }; + const heard: HeardTextFunction = () => progress; + const { rerender, result } = renderHeardText({ heard, text: TEXT }); + expect(result.current.heardText).toBe(TEXT); + + // the next reply, whose audio has not started yet + progress = { chars: 0, done: false }; + rerender({ heard, text: NEXT_TEXT }); + expect(result.current).toMatchObject({ heardText: '', isPaced: true }); + + progress = { chars: 4, done: false }; + advance(FRAME_MS); + expect(result.current.heardText).toBe('Sure'); + }); + + it('starts a new reveal after the previous text was shown unpaced', () => { + let progress: HeardTextProgress | undefined = undefined; + const heard: HeardTextFunction = () => progress; + const { rerender, result } = renderHeardText({ heard, text: TEXT }); + expect(result.current).toMatchObject({ heardText: TEXT, isPaced: false }); + + progress = { chars: 2, done: false }; + rerender({ heard, text: NEXT_TEXT }); + expect(result.current).toMatchObject({ heardText: 'Su', isPaced: true }); + }); + + it('clears a previous skip', () => { + const heard: HeardTextFunction = () => ({ chars: 3, done: false }); + const { rerender, result } = renderHeardText({ heard, text: TEXT }); + act(() => result.current.skip()); + expect(result.current.heardText).toBe(TEXT); + + rerender({ heard, text: NEXT_TEXT }); + expect(result.current.heardText).toBe('Sur'); + }); + + it('reads live again for the new text', () => { + const heard: HeardTextFunction = () => ({ chars: 3, done: false }); + const { rerender, result } = renderHeardText({ heard, live: false, text: TEXT }); + expect(result.current.heardText).toBe(TEXT); + + rerender({ heard, live: true, text: NEXT_TEXT }); + expect(result.current.heardText).toBe('Sur'); + }); + + it('keeps the reveal when the text is truncated, and does not reuse the cut length', () => { + let progress: HeardTextProgress = { chars: 12, done: false }; + const heard: HeardTextFunction = () => progress; + const { rerender, result } = renderHeardText({ heard, text: TEXT }); + expect(result.current.heardText).toBe(TEXT.slice(0, 12)); + + // e.g. the reply was cut short when the user stopped it + rerender({ heard, text: TEXT.slice(0, 8) }); + expect(result.current.heardText).toBe(TEXT.slice(0, 8)); + + // growing again from the truncated text continues from 8, not from 12 + progress = { chars: 3, done: false }; + rerender({ heard, text: `${TEXT.slice(0, 8)} again and again` }); + advance(FRAME_MS); + expect(result.current.heardText).toBe(TEXT.slice(0, 8)); + }); + }); + describe('with emoji and other astral characters', () => { // waving hand + skin tone modifier, mathematical script X, grinning face, the // US flag (two regional indicators) and a BMP character: all but the last are diff --git a/src/plugins/AIComponents/hooks/useHeardText.ts b/src/plugins/AIComponents/hooks/useHeardText.ts index dcf79fae0..29521f6e0 100644 --- a/src/plugins/AIComponents/hooks/useHeardText.ts +++ b/src/plugins/AIComponents/hooks/useHeardText.ts @@ -15,9 +15,14 @@ export type HeardTextProgress = { /** * Returns how much of the text the user has heard by `now` (a `performance.now()` - * timestamp). Return `undefined` when the text is not paced (not spoken, or not known - * to the voice source yet); return `{ chars: 0, done: false }` for a spoken text whose - * audio has not started yet. + * timestamp). + * + * Return `undefined` only for a text that is not spoken at all: the full text is then + * shown, and the reveal never moves back past what has been shown. A text that will be + * spoken but whose audio has not started, or that the voice source does not know yet, + * must return `{ chars: 0, done: false }`, or it is shown in full before it is heard. + * + * Also called during render, so keep it free of side effects. */ export type HeardTextFunction = (now: number) => HeardTextProgress | undefined; @@ -39,10 +44,16 @@ export type UseHeardTextParams = { generating?: boolean; /** * Defaults to `true`. `false` reveals the full text at once, for example for a message - * loaded from history. Read once, when the hook mounts. + * loaded from history. Read when the hook mounts and when `text` is replaced by a + * different text, not on every change. */ live?: boolean; - /** Optional change notifications from the speech progress source. */ + /** + * Optional change notifications from the speech progress source. Without it, `heard` + * is only checked again while the text is paced and its speech is not done, and when + * `text` or `generating` change. Subscribed once; to switch to another source, remount + * the component (for example with a `key`). + */ subscribe?: HeardTextSubscribe; }; @@ -56,13 +67,22 @@ export type UseHeardTextResult = { }; type PacingState = { - /** Number of code points revealed. Never decreases. */ + /** Number of code points revealed. Never decreases while the text continues. */ count: number; isPaced: boolean; /** No more ticking is needed until the inputs change or the source notifies. */ settled: boolean; }; +/** The reveal of one text. Starts over when the text is replaced by a different text. */ +type RevealState = PacingState & { + /** `live` as read when the reveal started. */ + live: boolean; + skipped: boolean; + /** The text `count` refers to. */ + text: string; +}; + const now = () => typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() @@ -121,9 +141,10 @@ const sample = ({ return { count: Math.max(previousCount, textLength), isPaced: true, settled: true }; } - const heardChars = Number.isFinite(progress.chars) - ? Math.max(0, Math.floor(progress.chars)) - : 0; + // `+Infinity` means the whole text has been heard; NaN, negative and non-numeric + // counts mean nothing has + const flooredChars = Math.floor(progress.chars); + const heardChars = flooredChars > 0 ? flooredChars : 0; return { count: Math.max(previousCount, Math.min(heardChars, textLength)), @@ -132,6 +153,39 @@ const sample = ({ }; }; +const startReveal = ({ + generating, + heard, + live, + text, + textLength, +}: { + generating: boolean; + heard: HeardTextFunction; + live: boolean; + text: string; + textLength: number; +}): RevealState => ({ + ...sample({ generating, heard, live, previousCount: 0, skipped: false, textLength }), + live, + skipped: false, + text, +}); + +/** + * Whether `text` continues the reveal of `previous.text`: it extends or truncates the part + * already shown. Anything else (the next reply rendered by the same component) is a new + * text, whose reveal must not start from the length shown for the previous one. + */ +const continuesReveal = (previous: RevealState, text: string) => { + const offsets = getCodePointOffsets(previous.text); + const shown = previous.text.slice( + 0, + offsets[Math.min(previous.count, offsets.length - 1)], + ); + return text.startsWith(shown) || shown.startsWith(text); +}; + const noop = () => undefined; /** @@ -141,20 +195,24 @@ const noop = () => undefined; * backend and has no dependency on the chat SDK. * * - Reveals the first `floor(chars)` Unicode code points of `text` (emoji and other - * astral characters are never split) and never moves backwards. + * astral characters are never split) and never moves backwards while `text` grows or + * is truncated. When `text` is replaced by a different text (the next reply rendered + * by the same component), the reveal starts over: the length shown, `skip()` and + * `live` are reset. * - While the text is paced and its speech is not done, samples `heard` on every * animation frame (a 16 ms timeout without `requestAnimationFrame`). It stops ticking * when the speech is done or the component unmounts; `subscribe` notifications, and * changes of `text` or `generating`, make it check again. * - Reveals the full text once `heard` reports `done` and `generating` is falsy, when - * `live` is `false` at mount, and after `skip()`. + * `live` is `false`, and after `skip()`. * - When `heard` returns `undefined`, `isPaced` is `false` and `heardText` is the full * text. If it becomes paced later, the reveal continues from the length already shown. * * `heard` and `subscribe` are read through refs, so passing new function identities on * every render (inline arrow functions) does not restart the pacing. `subscribe` is * called once on mount (and again only if it goes from absent to present) and its - * unsubscribe function runs on unmount. + * unsubscribe function runs on unmount; to switch to another speech source, remount the + * component. `heard` is also called during render, so keep it free of side effects. * * @example * ```tsx @@ -186,21 +244,23 @@ export const useHeardText = ({ const subscribeRef = useRef(subscribe); subscribeRef.current = subscribe; - const [isLive] = useState(live); - const [skipped, setSkipped] = useState(false); - const [pacingState, setPacingState] = useState(() => - sample({ - generating, - heard, - live: isLive, - previousCount: 0, - skipped, - textLength, - }), + const [revealState, setRevealState] = useState(() => + startReveal({ generating, heard, live, text, textLength }), ); - const pacingStateRef = useRef(pacingState); + let reveal = revealState; + if (revealState.text !== text) { + // Updated during render so that the new text never renders with the previous reveal. + reveal = continuesReveal(revealState, text) + ? { ...revealState, count: Math.min(revealState.count, textLength), text } + : startReveal({ generating, heard, live, text, textLength }); + setRevealState(reveal); + } + const revealRef = useRef(reveal); + revealRef.current = reveal; const wakeRef = useRef<() => void>(noop); + const { live: isLive, skipped } = reveal; + useEffect(() => { let cancelFrame: (() => void) | undefined; let disposed = false; @@ -209,7 +269,7 @@ export const useHeardText = ({ cancelFrame = undefined; if (disposed) return; - const previous = pacingStateRef.current; + const previous = revealRef.current; const next = sample({ generating, heard: heardRef.current, @@ -224,8 +284,9 @@ export const useHeardText = ({ next.isPaced !== previous.isPaced || next.settled !== previous.settled ) { - pacingStateRef.current = next; - setPacingState(next); + const updated = { ...previous, ...next }; + revealRef.current = updated; + setRevealState(updated); } if (!next.settled) cancelFrame = scheduleFrame(tick); @@ -249,13 +310,16 @@ export const useHeardText = ({ return subscribeRef.current?.(() => wakeRef.current()); }, [hasSubscribe]); - const skip = useCallback(() => setSkipped(true), []); + const skip = useCallback( + () => setRevealState((previous) => ({ ...previous, skipped: true })), + [], + ); return { - heardText: pacingState.isPaced - ? text.slice(0, codePointOffsets[Math.min(pacingState.count, textLength)]) + heardText: reveal.isPaced + ? text.slice(0, codePointOffsets[Math.min(reveal.count, textLength)]) : text, - isPaced: pacingState.isPaced, + isPaced: reveal.isPaced, skip, }; };