From 46a61e427d4dd724697dbaf8df94435864a9d181 Mon Sep 17 00:00:00 2001 From: Oakleaf Date: Sun, 12 Jul 2026 15:29:27 +0200 Subject: [PATCH 1/2] Add revealRate reveal pacing to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenUI Lang renders progressively as the response arrives, so the arrival rate is the reveal pace. When the response arrives bursty or whole — a fast tool-call generation, or a transport that coalesces writes (serverless response streaming buffers small writes, CDNs re-clump) — the components pop in all at once instead of assembling. revealRate decouples paint from arrival: while isStreaming, the Renderer feeds the streaming parser a growing prefix of the response on a clock, so completed statements stay stable (the parser caches them) while the pending one fills in and the UI assembles the way it would over a smooth stream. The revealed length is count-based and never resets mid-stream, so a non-append-only DSL change (a new element growing the root's child-ref list) keeps typing forward instead of re-assembling from blank; the streaming parser reconciles the shifted prefix by statement id. Settled content and prefers-reduced-motion render immediately. Default off — fully backward compatible. Pure pacing math lives in reveal.ts (unit-tested); the rAF and reduced-motion glue lives in useRevealedResponse. Refs #751 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../content/docs/api-reference/react-lang.mdx | 18 +++ packages/react-lang/src/Renderer.tsx | 18 ++- .../react-lang/src/__tests__/reveal.test.ts | 112 ++++++++++++++++++ .../src/hooks/useRevealedResponse.ts | 76 ++++++++++++ packages/react-lang/src/index.ts | 2 + packages/react-lang/src/reveal.ts | 91 ++++++++++++++ 6 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 packages/react-lang/src/__tests__/reveal.test.ts create mode 100644 packages/react-lang/src/hooks/useRevealedResponse.ts create mode 100644 packages/react-lang/src/reveal.ts diff --git a/docs/content/docs/api-reference/react-lang.mdx b/docs/content/docs/api-reference/react-lang.mdx index 1a329b7d5..789955f1e 100644 --- a/docs/content/docs/api-reference/react-lang.mdx +++ b/docs/content/docs/api-reference/react-lang.mdx @@ -98,6 +98,7 @@ interface RendererProps { response: string | null; library: Library; isStreaming?: boolean; + revealRate?: boolean | { targetMs?: number; minMsPerChar?: number; minTotalMs?: number }; onAction?: (event: ActionEvent) => void; onStateUpdate?: (state: Record) => void; initialState?: Record; @@ -111,6 +112,23 @@ interface RendererProps { } ``` +## Reveal pacing + +The Renderer renders progressively as the response arrives, so the arrival rate is the reveal pace. When the response arrives bursty or whole — a fast tool-call generation, or a transport that coalesces writes — the components pop in all at once instead of assembling. + +`revealRate` decouples paint from arrival: while `isStreaming`, the Renderer feeds the streaming parser a growing prefix of the response on a clock, so completed statements stay stable while the pending one fills in, and the UI assembles the way it would over a smooth stream. + +```tsx + + +``` + +- `true` — enable with the defaults (`targetMs: 1000`, `minMsPerChar: 1.2`, `minTotalMs: 400`). +- object — enable, overriding individual knobs. +- `false` / omitted — no pacing (render as the response arrives, the default). + +Settled content (not streaming, e.g. hydrated on load) and `prefers-reduced-motion` render immediately — no fake assembly over stable content. + ## Tool Provider Handles `Query()` and `Mutation()` tool calls at runtime. The `toolProvider` prop accepts two forms: diff --git a/packages/react-lang/src/Renderer.tsx b/packages/react-lang/src/Renderer.tsx index 0c06a8c93..ddc84a016 100644 --- a/packages/react-lang/src/Renderer.tsx +++ b/packages/react-lang/src/Renderer.tsx @@ -10,7 +10,9 @@ import { ToolNotFoundError, extractToolResult } from "@openuidev/lang-core"; import React, { Component, Fragment, useEffect, useInsertionEffect, useRef } from "react"; import { OpenUIContext, useOpenUI, useRenderNode } from "./context"; import { useOpenUIState } from "./hooks/useOpenUIState"; +import { useRevealedResponse } from "./hooks/useRevealedResponse"; import type { ComponentRenderer, Library } from "./library"; +import type { RevealRate } from "./reveal"; export interface RendererProps { /** Raw response text (openui-lang code). */ @@ -19,6 +21,15 @@ export interface RendererProps { library: Library; /** Whether the LLM is still streaming (form interactions disabled during streaming). */ isStreaming?: boolean; + /** + * Pace the reveal of a streamed response so components assemble instead of + * popping in when the response arrives bursty or whole (a fast generation, or a + * transport that coalesces writes). `true` uses the defaults; an object tunes + * the timing; `false`/omitted keeps the current behavior (render as it arrives). + * Only active while `isStreaming`; settled content and prefers-reduced-motion + * render immediately. See RevealRateOptions. + */ + revealRate?: RevealRate; /** Callback when a component triggers an action. */ onAction?: (event: ActionEvent) => void; /** @@ -202,6 +213,7 @@ export function Renderer({ response, library, isStreaming = false, + revealRate, onAction, onStateUpdate, initialState, @@ -214,6 +226,10 @@ export function Renderer({ ensureLoadingStyle(); }, []); + // Pace the reveal so components assemble instead of popping in on a bursty + // response. No-op unless revealRate is set; feeds the parser a growing prefix. + const revealedResponse = useRevealedResponse(response, isStreaming, revealRate); + const onParseResultRef = useRef(onParseResult); onParseResultRef.current = onParseResult; @@ -247,7 +263,7 @@ export function Renderer({ const { result, parseResult, contextValue, isQueryLoading } = useOpenUIState( { - response, + response: revealedResponse, library, isStreaming, onAction, diff --git a/packages/react-lang/src/__tests__/reveal.test.ts b/packages/react-lang/src/__tests__/reveal.test.ts new file mode 100644 index 000000000..9d6d03d07 --- /dev/null +++ b/packages/react-lang/src/__tests__/reveal.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_REVEAL_RATE, + resolveRevealRate, + revealDurationMs, + revealedCountAt, +} from "../reveal"; + +// ── resolveRevealRate ──────────────────────────────────────────────────────── + +describe("resolveRevealRate", () => { + it("returns null when pacing is off (undefined / false)", () => { + expect(resolveRevealRate(undefined)).toBeNull(); + expect(resolveRevealRate(false)).toBeNull(); + }); + + it("returns the defaults for `true`", () => { + expect(resolveRevealRate(true)).toEqual(DEFAULT_REVEAL_RATE); + }); + + it("fills unspecified knobs from the defaults", () => { + expect(resolveRevealRate({ targetMs: 1100 })).toEqual({ + targetMs: 1100, + minMsPerChar: DEFAULT_REVEAL_RATE.minMsPerChar, + minTotalMs: DEFAULT_REVEAL_RATE.minTotalMs, + }); + }); + + it("passes all knobs through when fully specified", () => { + const opts = { targetMs: 800, minMsPerChar: 2, minTotalMs: 200 }; + expect(resolveRevealRate(opts)).toEqual(opts); + }); +}); + +// ── revealDurationMs ───────────────────────────────────────────────────────── + +describe("revealDurationMs", () => { + const opts = DEFAULT_REVEAL_RATE; // targetMs 1000, minMsPerChar 1.2, minTotalMs 400 + + it("is 0 when there is nothing left to reveal", () => { + expect(revealDurationMs(0, opts)).toBe(0); + expect(revealDurationMs(-5, opts)).toBe(0); + }); + + it("floors a tiny burst at minTotalMs (so it still animates)", () => { + // 10 chars * 1.2 = 12ms, below the 400ms floor + expect(revealDurationMs(10, opts)).toBe(opts.minTotalMs); + }); + + it("caps a large burst at targetMs (so it still finishes promptly)", () => { + // 5000 chars * 1.2 = 6000ms, above the 1000ms cap + expect(revealDurationMs(5000, opts)).toBe(opts.targetMs); + }); + + it("scales linearly with the burst size between the floor and the cap", () => { + // 500 chars * 1.2 = 600ms, inside [400, 1000] + expect(revealDurationMs(500, opts)).toBe(600); + }); +}); + +// ── revealedCountAt ────────────────────────────────────────────────────────── + +describe("revealedCountAt", () => { + it("reveals nothing new at elapsed 0", () => { + expect(revealedCountAt(20, 30, 0, 1000)).toBe(20); + }); + + it("reveals everything once the duration has elapsed", () => { + expect(revealedCountAt(20, 30, 1000, 1000)).toBe(50); + expect(revealedCountAt(20, 30, 5000, 1000)).toBe(50); + }); + + it("never exceeds startLen + remaining", () => { + expect(revealedCountAt(20, 30, 999999, 1000)).toBe(50); + }); + + it("returns startLen when there is nothing to reveal", () => { + expect(revealedCountAt(50, 0, 500, 1000)).toBe(50); + }); + + it("reveals everything immediately for a non-positive duration", () => { + expect(revealedCountAt(0, 40, 0, 0)).toBe(40); + }); + + it("clamps negative elapsed to the start", () => { + expect(revealedCountAt(10, 20, -100, 1000)).toBe(10); + }); + + it("is monotonic non-decreasing in elapsed time", () => { + let prev = -1; + for (let elapsed = 0; elapsed <= 1200; elapsed += 25) { + const count = revealedCountAt(0, 100, elapsed, 1000); + expect(count).toBeGreaterThanOrEqual(prev); + expect(count).toBeLessThanOrEqual(100); + prev = count; + } + }); + + it("carries a growing target forward without ever going backwards (the never-reset invariant)", () => { + // Simulate the hook's use: reveal to the end of one burst, then a second + // burst grows the target. The count continues from where it was, so the + // revealed length is monotonic across the whole sequence. + const first = revealedCountAt(0, 40, 1000, 1000); // burst 1: 0 → 40 + expect(first).toBe(40); + // burst 2 appends 30 chars → new remaining is 30 from startLen 40 + const secondStart = revealedCountAt(first, 30, 0, 600); // still 40 at elapsed 0 + expect(secondStart).toBe(40); + const secondEnd = revealedCountAt(first, 30, 600, 600); // 40 → 70 + expect(secondEnd).toBe(70); + expect(secondEnd).toBeGreaterThanOrEqual(first); + }); +}); diff --git a/packages/react-lang/src/hooks/useRevealedResponse.ts b/packages/react-lang/src/hooks/useRevealedResponse.ts new file mode 100644 index 000000000..11a7fd086 --- /dev/null +++ b/packages/react-lang/src/hooks/useRevealedResponse.ts @@ -0,0 +1,76 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { type RevealRate, resolveRevealRate, revealDurationMs, revealedCountAt } from "../reveal"; + +function prefersReducedMotion(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +/** + * Pace `response` into a growing prefix while streaming, so the parser assembles + * the UI over `revealRate.targetMs` instead of painting a bursty response at once. + * See reveal.ts for the why. Returns the response unchanged when pacing is off, + * `prefers-reduced-motion` is set, or the stream has settled. + * + * The revealed length is count-based and only ever advances toward the current + * response length (clamped down only if the response itself gets shorter, e.g. a + * new turn). It never resets to zero mid-stream, so a non-append-only DSL change + * (a new element growing the root's child-ref list) keeps typing forward instead + * of re-assembling from blank; the streaming parser reconciles the shift by + * statement id. + */ +export function useRevealedResponse( + response: string | null, + isStreaming: boolean, + revealRate: RevealRate | undefined, +): string | null { + const opts = useMemo(() => resolveRevealRate(revealRate), [revealRate]); + const enabled = useMemo(() => opts !== null && !prefersReducedMotion(), [opts]); + + const full = response ?? ""; + const [count, setCount] = useState(() => (enabled && isStreaming ? 0 : full.length)); + + const countRef = useRef(count); + countRef.current = count; + const rafRef = useRef(null); + + useEffect(() => { + if (!enabled || !isStreaming || opts === null) { + setCount(full.length); + return; + } + // Continue from the current count toward the current length — never reset. + const startLen = Math.min(countRef.current, full.length); + const remaining = full.length - startLen; + if (remaining <= 0) { + setCount(full.length); + return; + } + const durationMs = revealDurationMs(remaining, opts); + let startTs: number | null = null; + const step = (ts: number) => { + if (startTs === null) startTs = ts; + const next = revealedCountAt(startLen, remaining, ts - startTs, durationMs); + setCount(next); + if (next < startLen + remaining) { + rafRef.current = requestAnimationFrame(step); + } else { + rafRef.current = null; + } + }; + rafRef.current = requestAnimationFrame(step); + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + rafRef.current = null; + }; + // Re-run when the target grows (more streamed in) — the reveal continues + // from the current count, never restarting. + }, [enabled, isStreaming, full, opts]); + + if (response === null) return null; + if (!enabled) return response; + return response.slice(0, Math.min(count, response.length)); +} diff --git a/packages/react-lang/src/index.ts b/packages/react-lang/src/index.ts index c6ecf8c29..59eb8a1fa 100644 --- a/packages/react-lang/src/index.ts +++ b/packages/react-lang/src/index.ts @@ -16,6 +16,8 @@ export type { // openui-lang renderer export { Renderer } from "./Renderer"; export type { RendererProps } from "./Renderer"; +export { DEFAULT_REVEAL_RATE } from "./reveal"; +export type { RevealRate, RevealRateOptions } from "./reveal"; // openui-lang action types export { ACTION_STEPS, BuiltinActionType } from "@openuidev/lang-core"; diff --git a/packages/react-lang/src/reveal.ts b/packages/react-lang/src/reveal.ts new file mode 100644 index 000000000..d3052ee1d --- /dev/null +++ b/packages/react-lang/src/reveal.ts @@ -0,0 +1,91 @@ +/** + * Reveal pacing for streamed openui-lang. + * + * OpenUI Lang renders progressively as the response arrives, so the arrival + * rate *is* the reveal pace. That is ideal when the model streams the code + * smoothly. In practice the response often arrives bursty or whole — a fast + * tool-call generation, or a transport that coalesces writes (serverless + * response streaming buffers small writes, CDNs re-clump) — and then the + * components pop in all at once instead of assembling. + * + * `revealRate` decouples paint from arrival: the Renderer holds the full + * response but feeds the streaming parser a growing *prefix* on a clock. The + * parser caches completed statements (see createStreamingParser), so already + * complete components stay stable while the pending statement fills in — the + * components visibly assemble, the same way they would over a smooth stream. + * + * The math here is pure and framework-free; the requestAnimationFrame and + * prefers-reduced-motion glue lives in useRevealedResponse. + */ + +export interface RevealRateOptions { + /** Target duration (ms) to reveal a burst of newly-arrived text. Default 1000. */ + targetMs?: number; + /** + * Never slower than this per character (ms) — a large burst reveals faster + * than targetMs would imply, so long responses still finish promptly. + * Default 1.2. + */ + minMsPerChar?: number; + /** Floor (ms) so a tiny burst still animates perceptibly rather than snapping. Default 400. */ + minTotalMs?: number; +} + +/** + * Reveal-pacing setting for ``. + * - `true` — enable pacing with the defaults. + * - object — enable pacing, overriding individual knobs. + * - `false` / omitted — no pacing (render as the response arrives, the default). + */ +export type RevealRate = boolean | RevealRateOptions; + +export const DEFAULT_REVEAL_RATE: Required = { + targetMs: 1000, + minMsPerChar: 1.2, + minTotalMs: 400, +}; + +/** Normalize the public RevealRate prop into concrete options, or null when pacing is off. */ +export function resolveRevealRate( + rate: RevealRate | undefined, +): Required | null { + if (!rate) return null; + if (rate === true) return DEFAULT_REVEAL_RATE; + return { + targetMs: rate.targetMs ?? DEFAULT_REVEAL_RATE.targetMs, + minMsPerChar: rate.minMsPerChar ?? DEFAULT_REVEAL_RATE.minMsPerChar, + minTotalMs: rate.minTotalMs ?? DEFAULT_REVEAL_RATE.minTotalMs, + }; +} + +/** + * Duration (ms) to reveal `remaining` characters. Scales with the burst size + * (minMsPerChar) but is clamped to [minTotalMs, targetMs] so tiny bursts still + * animate perceptibly and huge ones still finish promptly. `remaining <= 0` → 0. + */ +export function revealDurationMs(remaining: number, opts: Required): number { + if (remaining <= 0) return 0; + return Math.max(opts.minTotalMs, Math.min(opts.targetMs, remaining * opts.minMsPerChar)); +} + +/** + * How many characters are revealed `elapsedMs` into a reveal that started at + * `startLen` and must cover `remaining` more characters over `durationMs`. + * + * Monotonic non-decreasing in `elapsedMs`; equals `startLen` at elapsed 0 and + * never exceeds `startLen + remaining`. Advancing a character COUNT (rather than + * diffing a string prefix) is deliberate: the openui-lang DSL is not append-only + * — a new element rewrites the root's child-ref list — so a prefix diff would + * blank-and-rebuild on every new element, whereas a monotonic count lets the + * streaming parser reconcile the shifted prefix by statement id and keep going. + */ +export function revealedCountAt( + startLen: number, + remaining: number, + elapsedMs: number, + durationMs: number, +): number { + if (remaining <= 0) return startLen; + const ratio = durationMs <= 0 ? 1 : Math.min(1, Math.max(0, elapsedMs) / durationMs); + return startLen + Math.ceil(ratio * remaining); +} From 945056d1ff7c89731881d2e9ffc9d987f35c2154 Mon Sep 17 00:00:00 2001 From: Oakleaf Date: Sun, 12 Jul 2026 15:49:41 +0200 Subject: [PATCH 2/2] Prove revealRate assembly against the real streaming parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive createStreamingParser with clock-paced growing prefixes (the exact sequence the Renderer feeds it) and assert the tree genuinely assembles: the root renders mid-stream, before every statement has completed, and the completed-statement count is monotonic — a completed component is never un-rendered ("never blanks"). The paced stream lands exactly where a fresh full parse does. Uses a ref-list-first DSL so the string is non-monotonic, the shape a naive prefix reveal would blank-and-rebuild on. Refs #751 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/reveal-integration.test.ts | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 packages/react-lang/src/__tests__/reveal-integration.test.ts diff --git a/packages/react-lang/src/__tests__/reveal-integration.test.ts b/packages/react-lang/src/__tests__/reveal-integration.test.ts new file mode 100644 index 000000000..75aaa772b --- /dev/null +++ b/packages/react-lang/src/__tests__/reveal-integration.test.ts @@ -0,0 +1,97 @@ +import { createStreamingParser } from "@openuidev/lang-core"; +import { describe, expect, it } from "vitest"; +import { z } from "zod/v4"; +import { createLibrary, defineComponent } from "../library"; +import { DEFAULT_REVEAL_RATE, revealDurationMs, revealedCountAt } from "../reveal"; + +// Components never render in a parser-level test — a trivial FC satisfies the type. +const Dummy = () => null; + +const Section = defineComponent({ + name: "Section", + props: z.object({ title: z.string(), children: z.array(z.any()) }), + description: "A section with children", + component: Dummy, +}); +const Card = defineComponent({ + name: "Card", + props: z.object({ label: z.string() }), + description: "A card", + component: Dummy, +}); +const library = createLibrary({ components: [Section, Card], root: "Section" }); +const schema = library.toJSONSchema(); + +// Ref-list-first DSL: the root line lists its children, so appending a child +// rewrites the root line — the string is NON-monotonic, the exact shape a naive +// prefix reveal blanks-and-rebuilds on. +const DSL = [ + 'root = Section("Weekly review", [b0, b1])', + 'b0 = Card("Invoices")', + 'b1 = Card("Alerts")', +].join("\n"); + +function fullParse() { + return createStreamingParser(schema, "Section").set(DSL); +} + +describe("revealRate mechanism — a clock-paced prefix drives real assembly", () => { + it("a full parse resolves the whole tree", () => { + const full = fullParse(); + expect(full.root).not.toBeNull(); + expect(full.meta.incomplete).toBe(false); + expect(full.meta.unresolved).toEqual([]); + expect(full.meta.statementCount).toBe(3); // root + b0 + b1 + }); + + it("reveals nothing at elapsed 0", () => { + const sp = createStreamingParser(schema, "Section"); + const count = revealedCountAt( + 0, + DSL.length, + 0, + revealDurationMs(DSL.length, DEFAULT_REVEAL_RATE), + ); + expect(count).toBe(0); + expect(sp.set(DSL.slice(0, count)).root).toBeNull(); + }); + + it("feeding the real parser clock-paced growing prefixes assembles the UI and never blanks", () => { + const finalStmt = fullParse().meta.statementCount; + const sp = createStreamingParser(schema, "Section"); + const durationMs = revealDurationMs(DSL.length, DEFAULT_REVEAL_RATE); + + let prevStmt = -1; + let sawRoot = false; + let sawPartialAssembly = false; // root present while the tree is still filling in + + const STEPS = 60; + for (let i = 0; i <= STEPS; i++) { + const elapsed = (durationMs * i) / STEPS; + const count = revealedCountAt(0, DSL.length, elapsed, durationMs); + const r = sp.set(DSL.slice(0, count)); + + // Never blanks: the completed-statement count is monotonic non-decreasing + // across the whole reveal — a completed component is never un-rendered. + expect(r.meta.statementCount).toBeGreaterThanOrEqual(prevStmt); + prevStmt = r.meta.statementCount; + + if (r.root) { + sawRoot = true; + if (r.meta.statementCount < finalStmt) sawPartialAssembly = true; + } + } + + // The root rendered mid-stream, before every statement had completed — i.e. + // the components genuinely ASSEMBLED rather than popping in only at the end. + expect(sawRoot).toBe(true); + expect(sawPartialAssembly).toBe(true); + + // And the paced stream lands exactly where a fresh full parse does. + const final = sp.set(DSL); + expect(final.root).not.toBeNull(); + expect(final.meta.incomplete).toBe(false); + expect(final.meta.unresolved).toEqual([]); + expect(final.meta.statementCount).toBe(finalStmt); + }); +});