@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.
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/internal.d.ts +3 -3
- package/dist/internal.js +2 -4
- package/dist/internal.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.d.ts +4 -2
- package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.js +125 -119
- package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts +3 -1
- package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverResource.js +22 -20
- package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
- package/dist/primitives/composer/trigger/index.d.ts +1 -0
- package/dist/unstable/useLiveCompletionAdapter.d.ts +47 -0
- package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -0
- package/dist/unstable/useLiveCompletionAdapter.js +116 -0
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -0
- package/dist/utils/smooth/useSmooth.d.ts +8 -0
- package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
- package/dist/utils/smooth/useSmooth.js +11 -2
- package/dist/utils/smooth/useSmooth.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +7 -0
- package/src/internal.ts +0 -2
- package/src/primitives/composer/trigger/TriggerPopover.tsx +11 -1
- package/src/primitives/composer/trigger/TriggerPopoverResource.ts +5 -0
- package/src/tests/ExternalStoreThreadRuntimeCore.test.ts +113 -0
- package/src/unstable/useLiveCompletionAdapter.test.tsx +176 -0
- package/src/unstable/useLiveCompletionAdapter.ts +145 -0
- package/src/utils/smooth/useSmooth.test.tsx +106 -2
- 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
|
-
|
|
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
|
-
|
|
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;
|