@assistant-ui/react 0.14.20 → 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 (36) hide show
  1. package/dist/context/react/utils/createContextStoreHook.d.ts.map +1 -1
  2. package/dist/context/react/utils/createContextStoreHook.js +3 -18
  3. package/dist/context/react/utils/createContextStoreHook.js.map +1 -1
  4. package/dist/index.d.ts +3 -2
  5. package/dist/index.js +3 -2
  6. package/dist/internal.d.ts +3 -3
  7. package/dist/internal.js +2 -4
  8. package/dist/internal.js.map +1 -1
  9. package/dist/primitives/composer/trigger/TriggerPopover.d.ts +4 -2
  10. package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
  11. package/dist/primitives/composer/trigger/TriggerPopover.js +125 -119
  12. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  13. package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts +3 -1
  14. package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts.map +1 -1
  15. package/dist/primitives/composer/trigger/TriggerPopoverResource.js +22 -20
  16. package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
  17. package/dist/primitives/composer/trigger/index.d.ts +1 -0
  18. package/dist/unstable/useLiveCompletionAdapter.d.ts +47 -0
  19. package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -0
  20. package/dist/unstable/useLiveCompletionAdapter.js +116 -0
  21. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -0
  22. package/dist/utils/smooth/useSmooth.d.ts +8 -0
  23. package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
  24. package/dist/utils/smooth/useSmooth.js +11 -2
  25. package/dist/utils/smooth/useSmooth.js.map +1 -1
  26. package/package.json +4 -4
  27. package/src/context/react/utils/createContextStoreHook.ts +4 -3
  28. package/src/index.ts +7 -0
  29. package/src/internal.ts +0 -2
  30. package/src/primitives/composer/trigger/TriggerPopover.tsx +11 -1
  31. package/src/primitives/composer/trigger/TriggerPopoverResource.ts +5 -0
  32. package/src/tests/ExternalStoreThreadRuntimeCore.test.ts +113 -0
  33. package/src/unstable/useLiveCompletionAdapter.test.tsx +176 -0
  34. package/src/unstable/useLiveCompletionAdapter.ts +145 -0
  35. package/src/utils/smooth/useSmooth.test.tsx +106 -2
  36. package/src/utils/smooth/useSmooth.ts +20 -2
package/src/index.ts CHANGED
@@ -124,6 +124,7 @@ export type {
124
124
 
125
125
  // --- external-store ---
126
126
  export type { ThreadMessageLike } from "@assistant-ui/core";
127
+ export { fromThreadMessageLike, generateId } from "@assistant-ui/core";
127
128
  export {
128
129
  getExternalStoreMessages,
129
130
  bindExternalStoreMessage,
@@ -414,6 +415,12 @@ export {
414
415
  type Unstable_UseSlashCommandAdapterOptions,
415
416
  } from "./unstable/useSlashCommandAdapter";
416
417
 
418
+ // Unstable - live (async) completion adapter helper
419
+ export {
420
+ unstable_useLiveCompletionAdapter,
421
+ type Unstable_UseLiveCompletionAdapterOptions,
422
+ } from "./unstable/useLiveCompletionAdapter";
423
+
417
424
  export type { ToolExecutionStatus } from "./internal";
418
425
 
419
426
  // Unstable - trigger popover (unified root for @ mentions, / slash commands, etc.)
package/src/internal.ts CHANGED
@@ -10,10 +10,8 @@ export {
10
10
  CompositeContextProvider,
11
11
  MessageRepository,
12
12
  BaseAssistantRuntimeCore,
13
- generateId,
14
13
  AssistantRuntimeImpl,
15
14
  ThreadRuntimeImpl,
16
- fromThreadMessageLike,
17
15
  getAutoStatus,
18
16
  } from "@assistant-ui/core/internal";
19
17
  export type {
@@ -72,6 +72,8 @@ export namespace ComposerPrimitiveTriggerPopover {
72
72
  readonly char: string;
73
73
  /** Adapter providing categories and items. */
74
74
  readonly adapter?: Unstable_TriggerAdapter | undefined;
75
+ /** Whether the adapter is resolving items, surfaced to the popover scope for async sources. @default false */
76
+ readonly isLoading?: boolean | undefined;
75
77
  };
76
78
  }
77
79
 
@@ -107,7 +109,14 @@ export const ComposerPrimitiveTriggerPopover = forwardRef<
107
109
  ComposerPrimitiveTriggerPopover.Props
108
110
  >(
109
111
  (
110
- { char, adapter, "aria-label": ariaLabel, children, ...props },
112
+ {
113
+ char,
114
+ adapter,
115
+ isLoading = false,
116
+ "aria-label": ariaLabel,
117
+ children,
118
+ ...props
119
+ },
111
120
  forwardedRef,
112
121
  ) => {
113
122
  const aui = useAui();
@@ -159,6 +168,7 @@ export const ComposerPrimitiveTriggerPopover = forwardRef<
159
168
  behavior: behavior ?? undefined,
160
169
  aui,
161
170
  popoverId,
171
+ isLoading,
162
172
  }),
163
173
  );
164
174
 
@@ -29,6 +29,8 @@ export type TriggerPopoverResourceOutput = {
29
29
  readonly items: readonly Unstable_TriggerItem[];
30
30
  readonly highlightedIndex: number;
31
31
  readonly isSearchMode: boolean;
32
+ /** Whether the adapter is currently resolving items (async sources). */
33
+ readonly isLoading: boolean;
32
34
  /** Stable ID prefix for generating accessible element IDs. */
33
35
  readonly popoverId: string;
34
36
  /** ID of the currently highlighted item (for aria-activedescendant). */
@@ -58,6 +60,7 @@ const useTriggerPopoverResource = ({
58
60
  behavior,
59
61
  aui,
60
62
  popoverId,
63
+ isLoading,
61
64
  }: {
62
65
  adapter: Unstable_TriggerAdapter | undefined;
63
66
  text: string;
@@ -66,6 +69,7 @@ const useTriggerPopoverResource = ({
66
69
  aui: AssistantClient;
67
70
  /** Stable ID for accessible element IDs (pass React's useId() from component layer). */
68
71
  popoverId: string;
72
+ isLoading: boolean;
69
73
  }): TriggerPopoverResourceOutput => {
70
74
  const detection = useResource(
71
75
  TriggerDetectionResource({ text, triggerChar }),
@@ -122,6 +126,7 @@ const useTriggerPopoverResource = ({
122
126
  items: navigation.items,
123
127
  highlightedIndex: keyboard.highlightedIndex,
124
128
  isSearchMode: navigation.isSearchMode,
129
+ isLoading,
125
130
  popoverId,
126
131
  highlightedItemId: keyboard.highlightedItemId,
127
132
  selectCategory: navigation.selectCategory,
@@ -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
  });