@assistant-ui/react 0.14.21 → 0.14.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/index.d.ts +3 -2
  2. package/dist/index.js +3 -2
  3. package/dist/internal.d.ts +3 -3
  4. package/dist/internal.js +2 -4
  5. package/dist/internal.js.map +1 -1
  6. package/dist/primitives/composer/trigger/TriggerPopover.d.ts +4 -2
  7. package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
  8. package/dist/primitives/composer/trigger/TriggerPopover.js +125 -119
  9. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  10. package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts +3 -1
  11. package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts.map +1 -1
  12. package/dist/primitives/composer/trigger/TriggerPopoverResource.js +22 -20
  13. package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
  14. package/dist/primitives/composer/trigger/index.d.ts +1 -0
  15. package/dist/unstable/useLiveCompletionAdapter.d.ts +47 -0
  16. package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -0
  17. package/dist/unstable/useLiveCompletionAdapter.js +116 -0
  18. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -0
  19. package/dist/utils/smooth/useSmooth.d.ts +8 -0
  20. package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
  21. package/dist/utils/smooth/useSmooth.js +11 -2
  22. package/dist/utils/smooth/useSmooth.js.map +1 -1
  23. package/package.json +2 -2
  24. package/src/index.ts +7 -0
  25. package/src/internal.ts +0 -2
  26. package/src/primitives/composer/trigger/TriggerPopover.tsx +11 -1
  27. package/src/primitives/composer/trigger/TriggerPopoverResource.ts +5 -0
  28. package/src/tests/ExternalStoreThreadRuntimeCore.test.ts +113 -0
  29. package/src/unstable/useLiveCompletionAdapter.test.tsx +176 -0
  30. package/src/unstable/useLiveCompletionAdapter.ts +145 -0
  31. package/src/utils/smooth/useSmooth.test.tsx +106 -2
  32. package/src/utils/smooth/useSmooth.ts +20 -2
@@ -569,4 +569,117 @@ describe("ExternalStoreThreadRuntimeCore", () => {
569
569
  );
570
570
  });
571
571
  });
572
+
573
+ describe("messageRepository incremental sync", () => {
574
+ const u1 = createUserMessage("u1");
575
+ const a1 = createAssistantMessage("a1");
576
+
577
+ const repoAdapter = (
578
+ messages: { message: ThreadMessage; parentId: string | null }[],
579
+ headId: string | null,
580
+ overrides: Partial<ExternalStoreAdapter<ThreadMessage>> = {},
581
+ ): ExternalStoreAdapter<ThreadMessage> => ({
582
+ messageRepository: { headId, messages },
583
+ onNew: vi.fn(async () => {}),
584
+ ...overrides,
585
+ });
586
+
587
+ it("imports the initial repository", () => {
588
+ const core = new ExternalStoreThreadRuntimeCore(
589
+ contextProvider,
590
+ repoAdapter([{ message: u1, parentId: null }], "u1"),
591
+ );
592
+ expect(core.messages.map((m) => m.id)).toEqual(["u1"]);
593
+ });
594
+
595
+ it("adds a message when the repository is replaced", () => {
596
+ const core = new ExternalStoreThreadRuntimeCore(
597
+ contextProvider,
598
+ repoAdapter([{ message: u1, parentId: null }], "u1"),
599
+ );
600
+ core.__internal_setAdapter(
601
+ repoAdapter(
602
+ [
603
+ { message: u1, parentId: null },
604
+ { message: a1, parentId: "u1" },
605
+ ],
606
+ "a1",
607
+ ),
608
+ );
609
+ expect(core.messages.map((m) => m.id)).toEqual(["u1", "a1"]);
610
+ });
611
+
612
+ it("deletes a message no longer present in the new repository", () => {
613
+ const core = new ExternalStoreThreadRuntimeCore(
614
+ contextProvider,
615
+ repoAdapter(
616
+ [
617
+ { message: u1, parentId: null },
618
+ { message: a1, parentId: "u1" },
619
+ ],
620
+ "a1",
621
+ ),
622
+ );
623
+ expect(core.messages.map((m) => m.id)).toEqual(["u1", "a1"]);
624
+
625
+ core.__internal_setAdapter(
626
+ repoAdapter([{ message: u1, parentId: null }], "u1"),
627
+ );
628
+ expect(core.messages.map((m) => m.id)).toEqual(["u1"]);
629
+ });
630
+
631
+ it("matches a fresh import when built incrementally", () => {
632
+ const incremental = new ExternalStoreThreadRuntimeCore(
633
+ contextProvider,
634
+ repoAdapter([{ message: u1, parentId: null }], "u1"),
635
+ );
636
+ incremental.__internal_setAdapter(
637
+ repoAdapter(
638
+ [
639
+ { message: u1, parentId: null },
640
+ { message: a1, parentId: "u1" },
641
+ ],
642
+ "a1",
643
+ ),
644
+ );
645
+ const fresh = new ExternalStoreThreadRuntimeCore(
646
+ contextProvider,
647
+ repoAdapter(
648
+ [
649
+ { message: u1, parentId: null },
650
+ { message: a1, parentId: "u1" },
651
+ ],
652
+ "a1",
653
+ ),
654
+ );
655
+ expect(incremental.messages.map((m) => m.id)).toEqual(
656
+ fresh.messages.map((m) => m.id),
657
+ );
658
+ });
659
+
660
+ it("coalesces an isRunning flip on an unchanged repository reference", () => {
661
+ const messageRepository = {
662
+ headId: "u1",
663
+ messages: [{ message: u1, parentId: null }],
664
+ };
665
+ const onNew = vi.fn(async () => {});
666
+ const core = new ExternalStoreThreadRuntimeCore(contextProvider, {
667
+ messageRepository,
668
+ onNew,
669
+ isRunning: false,
670
+ });
671
+ expect(core.messages.map((m) => m.id)).toEqual(["u1"]);
672
+
673
+ core.__internal_setAdapter({ messageRepository, onNew, isRunning: true });
674
+ expect(core.messages.length).toBe(2);
675
+ expect(core.messages[1]!.role).toBe("assistant");
676
+
677
+ core.__internal_setAdapter({
678
+ messageRepository,
679
+ onNew,
680
+ isRunning: false,
681
+ });
682
+ expect(core.messages.map((m) => m.id)).toEqual(["u1"]);
683
+ });
684
+ });
572
685
  });
@@ -0,0 +1,176 @@
1
+ /** @vitest-environment jsdom */
2
+ import { act, renderHook } from "@testing-library/react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { Unstable_TriggerItem } from "@assistant-ui/core";
5
+ import { unstable_useLiveCompletionAdapter } from "./useLiveCompletionAdapter";
6
+
7
+ const item = (id: string): Unstable_TriggerItem => ({
8
+ id,
9
+ type: "x",
10
+ label: id,
11
+ });
12
+
13
+ describe("unstable_useLiveCompletionAdapter", () => {
14
+ beforeEach(() => vi.useFakeTimers());
15
+ afterEach(() => vi.useRealTimers());
16
+
17
+ it("returns cached items synchronously and schedules a debounced fetch", async () => {
18
+ let resolve!: (value: readonly Unstable_TriggerItem[]) => void;
19
+ const fetcher = vi.fn(
20
+ () =>
21
+ new Promise<readonly Unstable_TriggerItem[]>((r) => {
22
+ resolve = r;
23
+ }),
24
+ );
25
+ const { result } = renderHook(() =>
26
+ unstable_useLiveCompletionAdapter({ fetcher, debounceMs: 50 }),
27
+ );
28
+
29
+ let returned: readonly Unstable_TriggerItem[] = [];
30
+ await act(async () => {
31
+ returned = result.current.adapter.search!("ab");
32
+ });
33
+ expect(returned).toEqual([]);
34
+ expect(result.current.isLoading).toBe(true);
35
+ expect(fetcher).not.toHaveBeenCalled();
36
+
37
+ await act(async () => {
38
+ await vi.advanceTimersByTimeAsync(50);
39
+ });
40
+ expect(fetcher).toHaveBeenCalledTimes(1);
41
+ expect(fetcher).toHaveBeenCalledWith("ab");
42
+
43
+ await act(async () => {
44
+ resolve([item("ab")]);
45
+ });
46
+ expect(result.current.isLoading).toBe(false);
47
+ expect(result.current.adapter.search!("ab")).toEqual([item("ab")]);
48
+ expect(fetcher).toHaveBeenCalledTimes(1);
49
+ });
50
+
51
+ it("defers its state update out of search() so it is safe to call during render", () => {
52
+ const fetcher = vi.fn(async () => []);
53
+ const { result } = renderHook(() =>
54
+ unstable_useLiveCompletionAdapter({ fetcher, debounceMs: 0 }),
55
+ );
56
+
57
+ const returned = result.current.adapter.search!("ab");
58
+ expect(returned).toEqual([]);
59
+ // the fetch (and its setIsLoading) is queued, not run synchronously
60
+ expect(result.current.isLoading).toBe(false);
61
+ expect(fetcher).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it("does not fetch when disabled and clears cached items", async () => {
65
+ const fetcher = vi.fn(async () => [item("a")]);
66
+ const { result, rerender } = renderHook(
67
+ ({ enabled }) =>
68
+ unstable_useLiveCompletionAdapter({ fetcher, enabled, debounceMs: 0 }),
69
+ { initialProps: { enabled: true } },
70
+ );
71
+
72
+ await act(async () => {
73
+ result.current.adapter.search!("ab");
74
+ });
75
+ await act(async () => {
76
+ await vi.advanceTimersByTimeAsync(0);
77
+ });
78
+ expect(result.current.adapter.search!("ab")).toEqual([item("a")]);
79
+
80
+ fetcher.mockClear();
81
+ await act(async () => {
82
+ rerender({ enabled: false });
83
+ });
84
+ expect(result.current.adapter.search!("ab")).toEqual([]);
85
+ await act(async () => {
86
+ await vi.advanceTimersByTimeAsync(10);
87
+ });
88
+ expect(fetcher).not.toHaveBeenCalled();
89
+ expect(result.current.isLoading).toBe(false);
90
+ });
91
+
92
+ it("drops a stale in-flight result when the query changes", async () => {
93
+ const resolvers: Record<
94
+ string,
95
+ (value: readonly Unstable_TriggerItem[]) => void
96
+ > = {};
97
+ const fetcher = vi.fn(
98
+ (q: string) =>
99
+ new Promise<readonly Unstable_TriggerItem[]>((r) => {
100
+ resolvers[q] = r;
101
+ }),
102
+ );
103
+ const { result } = renderHook(() =>
104
+ unstable_useLiveCompletionAdapter({ fetcher, debounceMs: 0 }),
105
+ );
106
+
107
+ await act(async () => {
108
+ result.current.adapter.search!("a");
109
+ });
110
+ await act(async () => {
111
+ await vi.advanceTimersByTimeAsync(0);
112
+ });
113
+ await act(async () => {
114
+ result.current.adapter.search!("ab");
115
+ });
116
+ await act(async () => {
117
+ await vi.advanceTimersByTimeAsync(0);
118
+ });
119
+ expect(fetcher).toHaveBeenCalledTimes(2);
120
+
121
+ await act(async () => {
122
+ resolvers["a"]!([item("a")]);
123
+ });
124
+ expect(result.current.adapter.search!("ab")).toEqual([]);
125
+
126
+ await act(async () => {
127
+ resolvers["ab"]!([item("ab")]);
128
+ });
129
+ expect(result.current.adapter.search!("ab")).toEqual([item("ab")]);
130
+ });
131
+
132
+ it("drops an in-flight fetch when the query returns to a cached value", async () => {
133
+ const resolvers: Record<
134
+ string,
135
+ (value: readonly Unstable_TriggerItem[]) => void
136
+ > = {};
137
+ const fetcher = vi.fn(
138
+ (q: string) =>
139
+ new Promise<readonly Unstable_TriggerItem[]>((r) => {
140
+ resolvers[q] = r;
141
+ }),
142
+ );
143
+ const { result } = renderHook(() =>
144
+ unstable_useLiveCompletionAdapter({ fetcher, debounceMs: 0 }),
145
+ );
146
+
147
+ await act(async () => {
148
+ result.current.adapter.search!("ab");
149
+ });
150
+ await act(async () => {
151
+ await vi.advanceTimersByTimeAsync(0);
152
+ });
153
+ await act(async () => {
154
+ resolvers["ab"]!([item("ab")]);
155
+ });
156
+ expect(result.current.adapter.search!("ab")).toEqual([item("ab")]);
157
+
158
+ // type "abc": a fetch goes in flight
159
+ await act(async () => {
160
+ result.current.adapter.search!("abc");
161
+ });
162
+ await act(async () => {
163
+ await vi.advanceTimersByTimeAsync(0);
164
+ });
165
+ expect(resolvers["abc"]).toBeTypeOf("function");
166
+
167
+ // delete back to the cached "ab": the in-flight "abc" must be invalidated
168
+ await act(async () => {
169
+ result.current.adapter.search!("ab");
170
+ });
171
+ await act(async () => {
172
+ resolvers["abc"]!([item("abc")]);
173
+ });
174
+ expect(result.current.adapter.search!("ab")).toEqual([item("ab")]);
175
+ });
176
+ });
@@ -0,0 +1,145 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import type {
5
+ Unstable_TriggerAdapter,
6
+ Unstable_TriggerItem,
7
+ } from "@assistant-ui/core";
8
+
9
+ export type Unstable_UseLiveCompletionAdapterOptions = {
10
+ /**
11
+ * Fetches the items for a query from an async source. Called debounced; the
12
+ * resolved items are cached and returned synchronously to the popover on the
13
+ * next render.
14
+ */
15
+ readonly fetcher: (query: string) => Promise<readonly Unstable_TriggerItem[]>;
16
+ /** Debounce applied before a fetch fires, in milliseconds. @default 60 */
17
+ readonly debounceMs?: number | undefined;
18
+ /** When `false`, no fetch is scheduled and the adapter stays empty. @default true */
19
+ readonly enabled?: boolean | undefined;
20
+ };
21
+
22
+ /** Sentinel that no real query (including the empty string) equals, so the first query always fetches. */
23
+ const NO_QUERY = "\u0000";
24
+
25
+ /**
26
+ * @deprecated Under active development and may change without notice.
27
+ *
28
+ * Bridges an async completion source (a server search, a gateway RPC) into the
29
+ * synchronous `Unstable_TriggerAdapter` that `ComposerTriggerPopover` consumes.
30
+ * `search(query)` returns the last fetched items synchronously and schedules a
31
+ * debounced fetch when the query changes; when results arrive the returned
32
+ * `adapter` identity changes, which re-runs the popover's lookup so the fresh
33
+ * items render. This is a search-only adapter (`categories` are empty).
34
+ *
35
+ * `isLoading` is `true` while a fetch is in flight. Pass it to the popover's
36
+ * `isLoading` prop to render a loading state.
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * const mentions = unstable_useLiveCompletionAdapter({
41
+ * fetcher: (query) => searchUsers(query),
42
+ * });
43
+ *
44
+ * <ComposerTriggerPopover
45
+ * char="@"
46
+ * adapter={mentions.adapter}
47
+ * isLoading={mentions.isLoading}
48
+ * directive={{ onInserted }}
49
+ * />
50
+ * ```
51
+ */
52
+ export function unstable_useLiveCompletionAdapter(
53
+ options: Unstable_UseLiveCompletionAdapterOptions,
54
+ ): { adapter: Unstable_TriggerAdapter; isLoading: boolean } {
55
+ const { fetcher, debounceMs = 60, enabled = true } = options;
56
+
57
+ const [state, setState] = useState<{
58
+ query: string;
59
+ items: readonly Unstable_TriggerItem[];
60
+ }>({ query: NO_QUERY, items: [] });
61
+ const [isLoading, setIsLoading] = useState(false);
62
+
63
+ const fetcherRef = useRef(fetcher);
64
+ fetcherRef.current = fetcher;
65
+
66
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
67
+ const tokenRef = useRef(0);
68
+ const pendingQueryRef = useRef<string | null>(null);
69
+
70
+ const cancelTimer = useCallback(() => {
71
+ if (timerRef.current !== null) {
72
+ clearTimeout(timerRef.current);
73
+ timerRef.current = null;
74
+ }
75
+ }, []);
76
+
77
+ const scheduleFetch = useCallback(
78
+ (query: string) => {
79
+ if (!enabled) return;
80
+ if (pendingQueryRef.current === query) return;
81
+ pendingQueryRef.current = query;
82
+ cancelTimer();
83
+ const token = ++tokenRef.current;
84
+ setIsLoading(true);
85
+ timerRef.current = setTimeout(() => {
86
+ timerRef.current = null;
87
+ fetcherRef.current(query).then(
88
+ (items) => {
89
+ if (token !== tokenRef.current) return;
90
+ setState({ query, items });
91
+ setIsLoading(false);
92
+ },
93
+ () => {
94
+ if (token !== tokenRef.current) return;
95
+ setState({ query, items: [] });
96
+ setIsLoading(false);
97
+ },
98
+ );
99
+ }, debounceMs);
100
+ },
101
+ [enabled, debounceMs, cancelTimer],
102
+ );
103
+
104
+ const invalidatePending = useCallback(() => {
105
+ cancelTimer();
106
+ pendingQueryRef.current = null;
107
+ tokenRef.current += 1;
108
+ setIsLoading(false);
109
+ }, [cancelTimer]);
110
+
111
+ useEffect(() => {
112
+ if (enabled) return;
113
+ invalidatePending();
114
+ setState((s) =>
115
+ s.query === NO_QUERY ? s : { query: NO_QUERY, items: [] },
116
+ );
117
+ }, [enabled, invalidatePending]);
118
+
119
+ useEffect(() => cancelTimer, [cancelTimer]);
120
+
121
+ const adapter = useMemo<Unstable_TriggerAdapter>(
122
+ () => ({
123
+ categories: () => [],
124
+ categoryItems: () => [],
125
+ search: (query: string) => {
126
+ // search() runs inside the popover's render; defer state updates with
127
+ // queueMicrotask so they are not dispatched while another component renders.
128
+ if (query !== state.query) {
129
+ queueMicrotask(() => scheduleFetch(query));
130
+ } else if (
131
+ pendingQueryRef.current !== null &&
132
+ pendingQueryRef.current !== query
133
+ ) {
134
+ // the query returned to a cached value while a fetch for a different
135
+ // query is in flight; drop it so its result cannot overwrite the cache
136
+ queueMicrotask(invalidatePending);
137
+ }
138
+ return state.items;
139
+ },
140
+ }),
141
+ [state, scheduleFetch, invalidatePending],
142
+ );
143
+
144
+ return { adapter, isLoading };
145
+ }
@@ -1,6 +1,6 @@
1
1
  /** @vitest-environment jsdom */
2
- import { renderHook } from "@testing-library/react";
3
- import { describe, expect, it, vi } from "vitest";
2
+ import { act, renderHook } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import type {
5
5
  MessagePartState,
6
6
  ReasoningMessagePart,
@@ -32,7 +32,61 @@ const reasoningState = (text: string) =>
32
32
  status: { type: "complete", reason: "stop" },
33
33
  }) as MessagePartState & ReasoningMessagePart;
34
34
 
35
+ const runningState = (text: string) =>
36
+ ({
37
+ type: "text",
38
+ text,
39
+ status: { type: "running" },
40
+ }) as MessagePartState & TextMessagePart;
41
+
42
+ const driveAndCount = (minCommitMs: number) => {
43
+ const raf: FrameRequestCallback[] = [];
44
+ const rafSpy = vi
45
+ .spyOn(globalThis, "requestAnimationFrame")
46
+ .mockImplementation((cb) => {
47
+ raf.push(cb);
48
+ return raf.length;
49
+ });
50
+ const cafSpy = vi
51
+ .spyOn(globalThis, "cancelAnimationFrame")
52
+ .mockImplementation(() => {});
53
+ let now = 1000;
54
+ const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
55
+
56
+ const state = runningState("0123456789");
57
+ const { result, unmount } = renderHook(() =>
58
+ useSmooth(state, {
59
+ minCommitMs,
60
+ maxCharsPerFrame: 1,
61
+ maxCharIntervalMs: 1,
62
+ }),
63
+ );
64
+
65
+ const seen = new Set<string>();
66
+ let guard = 0;
67
+ while (raf.length > 0 && guard < 100) {
68
+ guard++;
69
+ const cb = raf.shift()!;
70
+ now += 16;
71
+ act(() => {
72
+ cb(now);
73
+ });
74
+ seen.add(result.current.text);
75
+ }
76
+
77
+ const final = result.current.text;
78
+ unmount();
79
+ rafSpy.mockRestore();
80
+ cafSpy.mockRestore();
81
+ nowSpy.mockRestore();
82
+ return { final, commits: seen.size };
83
+ };
84
+
35
85
  describe("useSmooth", () => {
86
+ afterEach(() => {
87
+ vi.restoreAllMocks();
88
+ });
89
+
36
90
  it("returns the input state unchanged when disabled", () => {
37
91
  const state = textState("hello");
38
92
  const { result } = renderHook(() => useSmooth(state, false));
@@ -92,4 +146,54 @@ describe("useSmooth", () => {
92
146
  expect(result.current.text).toBe("hello");
93
147
  expect(result.current.status).toBe(state.status);
94
148
  });
149
+
150
+ it("commits fewer times under minCommitMs without losing characters", () => {
151
+ const everyFrame = driveAndCount(0);
152
+ const floored = driveAndCount(50);
153
+
154
+ expect(everyFrame.final).toBe("0123456789");
155
+ expect(floored.final).toBe("0123456789");
156
+ expect(floored.commits).toBeLessThan(everyFrame.commits);
157
+ });
158
+
159
+ it("commits the first frame after a discontinuity without waiting out minCommitMs", () => {
160
+ const raf: FrameRequestCallback[] = [];
161
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
162
+ raf.push(cb);
163
+ return raf.length;
164
+ });
165
+ vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => {});
166
+ let now = 1_000_000;
167
+ vi.spyOn(Date, "now").mockImplementation(() => now);
168
+
169
+ const frame = () => {
170
+ if (raf.length === 0) return;
171
+ const cb = raf.shift()!;
172
+ now += 16;
173
+ act(() => {
174
+ cb(now);
175
+ });
176
+ };
177
+
178
+ const options: SmoothOptions = {
179
+ minCommitMs: 10000,
180
+ maxCharsPerFrame: 1,
181
+ maxCharIntervalMs: 1,
182
+ };
183
+ const { result, rerender } = renderHook(
184
+ (part) => useSmooth(part, options),
185
+ {
186
+ initialProps: runningState("aaaaaaaaaa"),
187
+ },
188
+ );
189
+
190
+ frame();
191
+ expect(result.current.text).toBe("a");
192
+ frame();
193
+ expect(result.current.text).toBe("a");
194
+
195
+ rerender(runningState("zzzzzzzzzz"));
196
+ frame();
197
+ expect(result.current.text).toBe("z");
198
+ });
95
199
  });
@@ -33,6 +33,14 @@ export type SmoothOptions = {
33
33
  * @default Infinity
34
34
  */
35
35
  maxCharsPerFrame?: number | undefined;
36
+ /**
37
+ * Minimum time in milliseconds between committed updates. The reveal keeps
38
+ * advancing every frame, but the visible text (and the downstream re-render
39
+ * and markdown re-parse it triggers) is committed at most once per interval.
40
+ * The final frame always commits. `0` commits every frame.
41
+ * @default 0
42
+ */
43
+ minCommitMs?: number | undefined;
36
44
  };
37
45
 
38
46
  const DEFAULT_DRAIN_MS = 250;
@@ -41,11 +49,13 @@ const DEFAULT_MAX_CHAR_INTERVAL_MS = 5;
41
49
  class TextStreamAnimator {
42
50
  private animationFrameId: number | null = null;
43
51
  private lastUpdateTime: number = Date.now();
52
+ public lastCommitTime: number = 0;
44
53
 
45
54
  public targetText: string = "";
46
55
  public drainMs: number = DEFAULT_DRAIN_MS;
47
56
  public maxCharIntervalMs: number = DEFAULT_MAX_CHAR_INTERVAL_MS;
48
57
  public maxCharsPerFrame: number = Infinity;
58
+ public minCommitMs: number = 0;
49
59
 
50
60
  constructor(
51
61
  public currentText: string,
@@ -100,7 +110,12 @@ class TextStreamAnimator {
100
110
  this.currentText.length + charsToAdd,
101
111
  );
102
112
  this.lastUpdateTime = currentTime - timeToConsume;
103
- this.setText(this.currentText);
113
+
114
+ const isComplete = charsToAdd === remainingChars;
115
+ if (isComplete || currentTime - this.lastCommitTime >= this.minCommitMs) {
116
+ this.lastCommitTime = currentTime;
117
+ this.setText(this.currentText);
118
+ }
104
119
  };
105
120
  }
106
121
 
@@ -141,6 +156,7 @@ export const useSmooth = (
141
156
  DEFAULT_MAX_CHAR_INTERVAL_MS,
142
157
  );
143
158
  const maxCharsPerFrame = positiveOr(options?.maxCharsPerFrame, Infinity);
159
+ const minCommitMs = positiveOr(options?.minCommitMs, 0);
144
160
 
145
161
  const [displayedText, setDisplayedText] = useState(
146
162
  state.status.type === "running" ? "" : text,
@@ -194,7 +210,8 @@ export const useSmooth = (
194
210
  animatorRef.drainMs = drainMs;
195
211
  animatorRef.maxCharIntervalMs = maxCharIntervalMs;
196
212
  animatorRef.maxCharsPerFrame = maxCharsPerFrame;
197
- }, [animatorRef, drainMs, maxCharIntervalMs, maxCharsPerFrame]);
213
+ animatorRef.minCommitMs = minCommitMs;
214
+ }, [animatorRef, drainMs, maxCharIntervalMs, maxCharsPerFrame, minCommitMs]);
198
215
 
199
216
  const animatorPartRef = useRef(part);
200
217
  useEffect(() => {
@@ -214,6 +231,7 @@ export const useSmooth = (
214
231
  if (state.status.type === "running") {
215
232
  animatorRef.currentText = "";
216
233
  animatorRef.targetText = text;
234
+ animatorRef.lastCommitTime = 0;
217
235
  animatorRef.start();
218
236
  } else {
219
237
  animatorRef.currentText = text;