@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.
- package/dist/context/react/utils/createContextStoreHook.d.ts.map +1 -1
- package/dist/context/react/utils/createContextStoreHook.js +3 -18
- package/dist/context/react/utils/createContextStoreHook.js.map +1 -1
- 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 +4 -4
- package/src/context/react/utils/createContextStoreHook.ts +4 -3
- 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
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
|
@@ -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
|
-
{
|
|
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
|
});
|