@assistant-ui/react 0.15.15 → 0.15.16
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/devtools/DevToolsHooks.d.ts +0 -2
- package/dist/devtools/DevToolsHooks.d.ts.map +1 -1
- package/dist/devtools/DevToolsHooks.js +10 -12
- package/dist/devtools/DevToolsHooks.js.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +1 -1
- package/dist/mcp-apps/bridge.d.ts.map +1 -1
- package/dist/mcp-apps/bridge.js +2 -8
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js +4 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerRoot.d.ts.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerRoot.js +20 -15
- package/dist/primitives/branchPicker/BranchPickerRoot.js.map +1 -1
- package/dist/primitives/composer/ComposerDictationTranscript.d.ts +2 -2
- package/dist/primitives/composer/ComposerDictationTranscript.js +2 -2
- package/dist/primitives/composer/ComposerDictationTranscript.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js +24 -20
- package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
- package/dist/primitives/message.js +1 -1
- package/dist/primitives/thread/ThreadRoot.d.ts +3 -0
- package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadRoot.js +3 -0
- package/dist/primitives/thread/ThreadRoot.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +4 -1
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useComposerInputHistory.js +1 -1
- package/dist/utils/invokeCallbackSafely.d.ts +5 -0
- package/dist/utils/invokeCallbackSafely.d.ts.map +1 -0
- package/dist/utils/invokeCallbackSafely.js +15 -0
- package/dist/utils/invokeCallbackSafely.js.map +1 -0
- package/dist/utils/smooth/SmoothContext.d.ts +6 -6
- package/dist/utils/smooth/useSmooth.d.ts +3 -0
- package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
- package/dist/utils/smooth/useSmooth.js +16 -1
- package/dist/utils/smooth/useSmooth.js.map +1 -1
- package/package.json +5 -5
- package/src/devtools/DevToolsHooks.test.ts +40 -0
- package/src/devtools/DevToolsHooks.ts +10 -15
- package/src/mcp-apps/bridge.test.ts +21 -0
- package/src/mcp-apps/bridge.ts +2 -14
- package/src/model-context/frame/SPEC_AssistantFrame.md +1 -1
- package/src/primitives/actionBar/ActionBarExportMarkdown.test.tsx +67 -0
- package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +3 -1
- package/src/primitives/branchPicker/BranchPickerRoot.tsx +5 -3
- package/src/primitives/composer/ComposerDictationTranscript.tsx +2 -2
- package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +135 -0
- package/src/primitives/composer/trigger/triggerSelectionResource.ts +2 -0
- package/src/primitives/thread/ThreadRoot.tsx +3 -0
- package/src/sandbox-host/SandboxHost.test.tsx +61 -0
- package/src/sandbox-host/SandboxHost.tsx +6 -2
- package/src/unstable/useLiveCompletionAdapter.test.tsx +57 -0
- package/src/utils/invokeCallbackSafely.ts +11 -0
- package/src/utils/smooth/useSmooth.test.tsx +110 -0
- package/src/utils/smooth/useSmooth.ts +20 -6
- package/dist/context/react/utils/ensureBinding.d.ts +0 -5
- package/dist/context/react/utils/ensureBinding.d.ts.map +0 -1
- package/dist/context/react/utils/ensureBinding.js +0 -24
- package/dist/context/react/utils/ensureBinding.js.map +0 -1
- package/dist/context/react/utils/useRuntimeState.d.ts +0 -13
- package/dist/context/react/utils/useRuntimeState.d.ts.map +0 -1
- package/dist/context/react/utils/useRuntimeState.js +0 -33
- package/dist/context/react/utils/useRuntimeState.js.map +0 -1
- package/src/context/react/utils/ensureBinding.ts +0 -46
- package/src/context/react/utils/useRuntimeState.ts +0 -46
|
@@ -61,7 +61,9 @@ export const ActionBarPrimitiveExportMarkdown = forwardRef<
|
|
|
61
61
|
ref={forwardedRef}
|
|
62
62
|
disabled={disabled || !callback}
|
|
63
63
|
onClick={composeEventHandlers(onClick, () => {
|
|
64
|
-
callback?.()
|
|
64
|
+
void callback?.().catch((error: unknown) => {
|
|
65
|
+
console.error("[assistant-ui] markdown export failed:", error);
|
|
66
|
+
});
|
|
65
67
|
})}
|
|
66
68
|
/>
|
|
67
69
|
);
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
forwardRef,
|
|
7
7
|
type ComponentPropsWithoutRef,
|
|
8
8
|
} from "react";
|
|
9
|
-
import {
|
|
9
|
+
import { AuiIf } from "@assistant-ui/store";
|
|
10
10
|
|
|
11
11
|
export namespace BranchPickerPrimitiveRoot {
|
|
12
12
|
export type Element = ComponentRef<typeof Primitive.div>;
|
|
@@ -42,9 +42,11 @@ export const BranchPickerPrimitiveRoot = forwardRef<
|
|
|
42
42
|
BranchPickerPrimitiveRoot.Props
|
|
43
43
|
>(({ hideWhenSingleBranch, ...rest }, ref) => {
|
|
44
44
|
return (
|
|
45
|
-
<
|
|
45
|
+
<AuiIf
|
|
46
|
+
condition={(s) => !hideWhenSingleBranch || s.message.branchCount >= 2}
|
|
47
|
+
>
|
|
46
48
|
<Primitive.div {...rest} ref={ref} />
|
|
47
|
-
</
|
|
49
|
+
</AuiIf>
|
|
48
50
|
);
|
|
49
51
|
});
|
|
50
52
|
|
|
@@ -21,11 +21,11 @@ export namespace ComposerPrimitiveDictationTranscript {
|
|
|
21
21
|
*
|
|
22
22
|
* @example
|
|
23
23
|
* ```tsx
|
|
24
|
-
* <
|
|
24
|
+
* <AuiIf condition={(s) => s.composer.dictation != null}>
|
|
25
25
|
* <div className="dictation-preview">
|
|
26
26
|
* <ComposerPrimitive.DictationTranscript />
|
|
27
27
|
* </div>
|
|
28
|
-
* </
|
|
28
|
+
* </AuiIf>
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
31
|
export const ComposerPrimitiveDictationTranscript = forwardRef<
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
|
|
3
|
+
import type {
|
|
4
|
+
Unstable_DirectiveFormatter,
|
|
5
|
+
Unstable_TriggerItem,
|
|
6
|
+
} from "@assistant-ui/core";
|
|
7
|
+
import type { AssistantClient } from "@assistant-ui/store";
|
|
8
|
+
import { TriggerDetectionResource } from "./triggerDetectionResource";
|
|
9
|
+
import {
|
|
10
|
+
TriggerSelectionResource,
|
|
11
|
+
type TriggerBehavior,
|
|
12
|
+
} from "./triggerSelectionResource";
|
|
13
|
+
|
|
14
|
+
const item: Unstable_TriggerItem = {
|
|
15
|
+
id: "file",
|
|
16
|
+
type: "file",
|
|
17
|
+
label: "file.txt",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const formatter: Unstable_DirectiveFormatter = {
|
|
21
|
+
serialize: () => "@file.txt",
|
|
22
|
+
parse: () => [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const makeAui = (textRef: { value: string }) => {
|
|
26
|
+
const setText = vi.fn((value: string) => {
|
|
27
|
+
textRef.value = value;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
aui: {
|
|
32
|
+
composer: {
|
|
33
|
+
getState: () => ({ text: textRef.value }),
|
|
34
|
+
setText,
|
|
35
|
+
},
|
|
36
|
+
} as unknown as AssistantClient,
|
|
37
|
+
setText,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const render = ({
|
|
42
|
+
behavior,
|
|
43
|
+
text,
|
|
44
|
+
trigger,
|
|
45
|
+
}: {
|
|
46
|
+
behavior: TriggerBehavior;
|
|
47
|
+
text: string;
|
|
48
|
+
trigger: { offset: number; query: string };
|
|
49
|
+
}) => {
|
|
50
|
+
const textRef = { value: text };
|
|
51
|
+
const { aui, setText } = makeAui(textRef);
|
|
52
|
+
const setCursorPosition = vi.fn();
|
|
53
|
+
const onSelected = vi.fn();
|
|
54
|
+
const root = createTapRoot(function Root() {
|
|
55
|
+
return useResource(
|
|
56
|
+
TriggerSelectionResource({
|
|
57
|
+
behavior,
|
|
58
|
+
trigger,
|
|
59
|
+
aui,
|
|
60
|
+
triggerChar: "@",
|
|
61
|
+
setCursorPosition,
|
|
62
|
+
onSelected,
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return { root, setText, setCursorPosition, onSelected };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe("TriggerSelectionResource", () => {
|
|
71
|
+
it("resynchronizes the cursor after inserting a directive", () => {
|
|
72
|
+
const { root, setText, setCursorPosition, onSelected } = render({
|
|
73
|
+
behavior: { kind: "directive", formatter },
|
|
74
|
+
text: "@file tail",
|
|
75
|
+
trigger: { offset: 0, query: "file" },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
root.getValue().selectItem(item);
|
|
79
|
+
|
|
80
|
+
expect(setText).toHaveBeenCalledWith("@file.txt tail");
|
|
81
|
+
expect(setCursorPosition).toHaveBeenCalledWith("@file.txt".length + 1);
|
|
82
|
+
expect(onSelected).toHaveBeenCalledOnce();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("resynchronizes the cursor after removing an action", () => {
|
|
86
|
+
const { root, setText, setCursorPosition } = render({
|
|
87
|
+
behavior: {
|
|
88
|
+
kind: "action",
|
|
89
|
+
formatter,
|
|
90
|
+
onExecute: vi.fn(),
|
|
91
|
+
removeOnExecute: true,
|
|
92
|
+
},
|
|
93
|
+
text: "before @file tail",
|
|
94
|
+
trigger: { offset: 7, query: "file" },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
root.getValue().selectItem(item);
|
|
98
|
+
|
|
99
|
+
expect(setText).toHaveBeenCalledWith("before tail");
|
|
100
|
+
expect(setCursorPosition).toHaveBeenCalledWith(7);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("does not rewrite composer text on a second select after insertion", () => {
|
|
104
|
+
const textRef = { value: "@fil" };
|
|
105
|
+
const { aui, setText } = makeAui(textRef);
|
|
106
|
+
const onSelected = vi.fn();
|
|
107
|
+
const root = createTapRoot(function Root() {
|
|
108
|
+
const detection = useResource(
|
|
109
|
+
TriggerDetectionResource({
|
|
110
|
+
text: textRef.value,
|
|
111
|
+
triggerChar: "@",
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
return useResource(
|
|
115
|
+
TriggerSelectionResource({
|
|
116
|
+
behavior: { kind: "directive", formatter },
|
|
117
|
+
trigger: detection.trigger,
|
|
118
|
+
aui,
|
|
119
|
+
triggerChar: "@",
|
|
120
|
+
setCursorPosition: detection.setCursorPosition,
|
|
121
|
+
onSelected,
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
flushTapSync(() => {
|
|
127
|
+
root.getValue().selectItem(item);
|
|
128
|
+
});
|
|
129
|
+
root.getValue().selectItem(item);
|
|
130
|
+
|
|
131
|
+
expect(setText).toHaveBeenCalledOnce();
|
|
132
|
+
expect(setText).toHaveBeenCalledWith("@file.txt ");
|
|
133
|
+
expect(textRef.value).toBe("@file.txt ");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -83,6 +83,7 @@ const useTriggerSelectionResource = ({
|
|
|
83
83
|
aui.composer.setText(
|
|
84
84
|
before + directive + (after.startsWith(" ") ? after : ` ${after}`),
|
|
85
85
|
);
|
|
86
|
+
setCursorPosition(before.length + directive.length + 1);
|
|
86
87
|
};
|
|
87
88
|
|
|
88
89
|
if (behavior.kind === "directive") {
|
|
@@ -93,6 +94,7 @@ const useTriggerSelectionResource = ({
|
|
|
93
94
|
aui.composer.setText(
|
|
94
95
|
before + (after.startsWith(" ") ? after.slice(1) : after),
|
|
95
96
|
);
|
|
97
|
+
setCursorPosition(before.length);
|
|
96
98
|
} else {
|
|
97
99
|
// Leave directive chip in the composer as an audit trail
|
|
98
100
|
insertDirective();
|
|
@@ -24,6 +24,9 @@ export namespace ThreadPrimitiveRoot {
|
|
|
24
24
|
* This component serves as the foundational wrapper for all thread-related components.
|
|
25
25
|
* It provides the basic structure and context needed for thread functionality.
|
|
26
26
|
*
|
|
27
|
+
* While this component is mounted, an unhandled Escape keydown stops active speech, even if
|
|
28
|
+
* the action bar that started it is no longer mounted.
|
|
29
|
+
*
|
|
27
30
|
* @example
|
|
28
31
|
* ```tsx
|
|
29
32
|
* <ThreadPrimitive.Root>
|
|
@@ -229,6 +229,67 @@ describe("SandboxHost", () => {
|
|
|
229
229
|
expect(onError.mock.calls[0]![0].message).toBe("boom");
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
it("does not report render failures after unmount", async () => {
|
|
233
|
+
let rejectRender!: (error: Error) => void;
|
|
234
|
+
renderHtmlMock.mockReturnValue(
|
|
235
|
+
new Promise((_, reject) => {
|
|
236
|
+
rejectRender = reject;
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
const onError = vi.fn();
|
|
240
|
+
|
|
241
|
+
await act(async () => {
|
|
242
|
+
root.render(
|
|
243
|
+
<SandboxHost
|
|
244
|
+
content={{ html: "" }}
|
|
245
|
+
contentKey="k"
|
|
246
|
+
createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
|
|
247
|
+
onError={onError}
|
|
248
|
+
/>,
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await act(async () => {
|
|
253
|
+
root.unmount();
|
|
254
|
+
});
|
|
255
|
+
rejectRender(new Error("late failure"));
|
|
256
|
+
await flush();
|
|
257
|
+
|
|
258
|
+
expect(onError).not.toHaveBeenCalled();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("contains failures thrown by onError", async () => {
|
|
262
|
+
const renderError = new Error("render failed");
|
|
263
|
+
const callbackError = new Error("error callback failed");
|
|
264
|
+
renderHtmlMock.mockRejectedValue(renderError);
|
|
265
|
+
const consoleError = vi
|
|
266
|
+
.spyOn(console, "error")
|
|
267
|
+
.mockImplementation(() => {});
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
await act(async () => {
|
|
271
|
+
root.render(
|
|
272
|
+
<SandboxHost
|
|
273
|
+
content={{ html: "" }}
|
|
274
|
+
contentKey="k"
|
|
275
|
+
createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
|
|
276
|
+
onError={() => {
|
|
277
|
+
throw callbackError;
|
|
278
|
+
}}
|
|
279
|
+
/>,
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
await flush();
|
|
283
|
+
|
|
284
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
285
|
+
"[assistant-ui] SandboxHost onError callback threw an error",
|
|
286
|
+
callbackError,
|
|
287
|
+
);
|
|
288
|
+
} finally {
|
|
289
|
+
consoleError.mockRestore();
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
232
293
|
it("disposes the rendered frame when bridge creation fails", async () => {
|
|
233
294
|
const rendered = fakeRendered();
|
|
234
295
|
renderHtmlMock.mockResolvedValue(rendered);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
SafeContentFrame,
|
|
7
7
|
type SandboxOption,
|
|
8
8
|
} from "safe-content-frame";
|
|
9
|
+
import { invokeCallbackSafely } from "../utils/invokeCallbackSafely";
|
|
9
10
|
|
|
10
11
|
const DEFAULT_PRODUCT = "assistant-ui-sandbox";
|
|
11
12
|
const DEFAULT_MAX_HEIGHT = 800;
|
|
@@ -147,10 +148,13 @@ export function SandboxHost({
|
|
|
147
148
|
window.addEventListener("message", onMessage);
|
|
148
149
|
})
|
|
149
150
|
.catch((err) => {
|
|
151
|
+
if (cancelled) return;
|
|
150
152
|
frame?.dispose();
|
|
151
153
|
frame = null;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
155
|
+
invokeCallbackSafely(
|
|
156
|
+
() => liveRef.current.onError?.(error),
|
|
157
|
+
"SandboxHost onError",
|
|
154
158
|
);
|
|
155
159
|
});
|
|
156
160
|
|
|
@@ -203,6 +203,63 @@ describe("unstable_useLiveCompletionAdapter", () => {
|
|
|
203
203
|
]);
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
it("keeps cached results when only the fetcher identity changes", async () => {
|
|
207
|
+
const first = vi.fn(async () => [item("alice")]);
|
|
208
|
+
const second = vi.fn(async () => [item("bob")]);
|
|
209
|
+
const { result, rerender } = renderHook(
|
|
210
|
+
({ fetcher }) =>
|
|
211
|
+
unstable_useLiveCompletionAdapter({
|
|
212
|
+
fetcher,
|
|
213
|
+
cacheKey: "workspace-a",
|
|
214
|
+
debounceMs: 0,
|
|
215
|
+
}),
|
|
216
|
+
{ initialProps: { fetcher: first } },
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
await act(async () => {
|
|
220
|
+
result.current.adapter.search!("alice");
|
|
221
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
222
|
+
});
|
|
223
|
+
expect(result.current.adapter.search!("alice")).toEqual([item("alice")]);
|
|
224
|
+
|
|
225
|
+
await act(async () => {
|
|
226
|
+
rerender({ fetcher: second });
|
|
227
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
228
|
+
});
|
|
229
|
+
expect(second).not.toHaveBeenCalled();
|
|
230
|
+
expect(result.current.adapter.search!("alice")).toEqual([item("alice")]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("drops a pending result when only the cache key changes", async () => {
|
|
234
|
+
let resolveFirst!: (items: readonly Unstable_TriggerItem[]) => void;
|
|
235
|
+
const first = new Promise<readonly Unstable_TriggerItem[]>((resolve) => {
|
|
236
|
+
resolveFirst = resolve;
|
|
237
|
+
});
|
|
238
|
+
const fetcher = vi.fn(() => first);
|
|
239
|
+
const { result, rerender } = renderHook(
|
|
240
|
+
({ cacheKey }) =>
|
|
241
|
+
unstable_useLiveCompletionAdapter({ fetcher, cacheKey, debounceMs: 0 }),
|
|
242
|
+
{ initialProps: { cacheKey: "workspace-a" } },
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
await act(async () => {
|
|
246
|
+
result.current.adapter.search!("alice");
|
|
247
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
248
|
+
});
|
|
249
|
+
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
250
|
+
|
|
251
|
+
await act(async () => {
|
|
252
|
+
rerender({ cacheKey: "workspace-b" });
|
|
253
|
+
});
|
|
254
|
+
await act(async () => {
|
|
255
|
+
resolveFirst([item("workspace-a")]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(result.current.isLoading).toBe(false);
|
|
259
|
+
expect(fetcher).toHaveBeenCalledTimes(1);
|
|
260
|
+
expect(result.current.adapter.search!("alice")).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
|
|
206
263
|
it("allows a failed query to be retried", async () => {
|
|
207
264
|
const fetcher = vi
|
|
208
265
|
.fn<(query: string) => Promise<readonly Unstable_TriggerItem[]>>()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const invokeCallbackSafely = (invoke: () => unknown, name: string) => {
|
|
2
|
+
const reportFailure = (error: unknown) => {
|
|
3
|
+
console.error(`[assistant-ui] ${name} callback threw an error`, error);
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
void Promise.resolve(invoke()).catch(reportFailure);
|
|
8
|
+
} catch (error) {
|
|
9
|
+
reportFailure(error);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
@@ -222,4 +222,114 @@ describe("useSmooth", () => {
|
|
|
222
222
|
frame();
|
|
223
223
|
expect(result.current.text).toBe("z");
|
|
224
224
|
});
|
|
225
|
+
|
|
226
|
+
it("drains completed text when a queued frame never runs", () => {
|
|
227
|
+
vi.spyOn(globalThis, "requestAnimationFrame").mockReturnValue(1);
|
|
228
|
+
vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => {});
|
|
229
|
+
|
|
230
|
+
const { result, rerender } = renderHook((state) => useSmooth(state, true), {
|
|
231
|
+
initialProps: runningState(""),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
rerender(runningState("hello"));
|
|
235
|
+
rerender(textState("hello"));
|
|
236
|
+
|
|
237
|
+
expect(result.current.text).toBe("hello");
|
|
238
|
+
expect(result.current.status.type).toBe("complete");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("keeps draining after the source settles when frames are flowing", () => {
|
|
242
|
+
const raf: FrameRequestCallback[] = [];
|
|
243
|
+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
244
|
+
raf.push(cb);
|
|
245
|
+
return raf.length;
|
|
246
|
+
});
|
|
247
|
+
vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => {});
|
|
248
|
+
let now = 1000;
|
|
249
|
+
vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
250
|
+
|
|
251
|
+
const frame = () => {
|
|
252
|
+
if (raf.length === 0) return;
|
|
253
|
+
const cb = raf.shift()!;
|
|
254
|
+
now += 16;
|
|
255
|
+
act(() => {
|
|
256
|
+
cb(now);
|
|
257
|
+
});
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const { result, rerender } = renderHook(
|
|
261
|
+
(state) =>
|
|
262
|
+
useSmooth(state, {
|
|
263
|
+
maxCharsPerFrame: 1,
|
|
264
|
+
maxCharIntervalMs: 1,
|
|
265
|
+
drainMs: 250,
|
|
266
|
+
}),
|
|
267
|
+
{ initialProps: runningState("hello") },
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
frame();
|
|
271
|
+
const mid = result.current.text;
|
|
272
|
+
expect(mid.length).toBeGreaterThan(0);
|
|
273
|
+
expect(mid.length).toBeLessThan("hello".length);
|
|
274
|
+
expect(result.current.status.type).toBe("running");
|
|
275
|
+
|
|
276
|
+
rerender(textState("hello"));
|
|
277
|
+
expect(result.current.text).toBe(mid);
|
|
278
|
+
expect(result.current.status.type).toBe("running");
|
|
279
|
+
|
|
280
|
+
frame();
|
|
281
|
+
expect(result.current.text.length).toBeGreaterThan(mid.length);
|
|
282
|
+
|
|
283
|
+
let guard = 0;
|
|
284
|
+
while (result.current.text !== "hello" && guard < 20) {
|
|
285
|
+
guard++;
|
|
286
|
+
frame();
|
|
287
|
+
}
|
|
288
|
+
expect(result.current.text).toBe("hello");
|
|
289
|
+
expect(result.current.status.type).toBe("complete");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("snaps when a settled part replaces the text instead of looping the animator", () => {
|
|
293
|
+
const raf: FrameRequestCallback[] = [];
|
|
294
|
+
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
|
|
295
|
+
raf.push(cb);
|
|
296
|
+
return raf.length;
|
|
297
|
+
});
|
|
298
|
+
vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => {});
|
|
299
|
+
let now = 1000;
|
|
300
|
+
vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
301
|
+
|
|
302
|
+
const frame = () => {
|
|
303
|
+
if (raf.length === 0) return;
|
|
304
|
+
const cb = raf.shift()!;
|
|
305
|
+
now += 16;
|
|
306
|
+
act(() => {
|
|
307
|
+
cb(now);
|
|
308
|
+
});
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const { result, rerender } = renderHook(
|
|
312
|
+
(state) =>
|
|
313
|
+
useSmooth(state, {
|
|
314
|
+
maxCharsPerFrame: 1,
|
|
315
|
+
maxCharIntervalMs: 1,
|
|
316
|
+
drainMs: 250,
|
|
317
|
+
}),
|
|
318
|
+
{ initialProps: runningState("hello world") },
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
frame();
|
|
322
|
+
expect(result.current.text.length).toBeGreaterThan(0);
|
|
323
|
+
const queued = raf.length;
|
|
324
|
+
|
|
325
|
+
rerender(textState("hi"));
|
|
326
|
+
expect(result.current.text).toBe("hi");
|
|
327
|
+
expect(result.current.status.type).toBe("complete");
|
|
328
|
+
expect(raf.length).toBeLessThanOrEqual(queued);
|
|
329
|
+
|
|
330
|
+
const after = raf.length;
|
|
331
|
+
frame();
|
|
332
|
+
expect(result.current.text).toBe("hi");
|
|
333
|
+
expect(raf.length).toBeLessThanOrEqual(after);
|
|
334
|
+
});
|
|
225
335
|
});
|
|
@@ -137,6 +137,9 @@ const positiveOr = (value: number | undefined, fallback: number): number =>
|
|
|
137
137
|
* `true` uses the default rate, and a {@link SmoothOptions} object tunes
|
|
138
138
|
* the reveal. Returns the part state with `text` replaced by the revealed
|
|
139
139
|
* prefix and `status` reporting `running` until the reveal catches up.
|
|
140
|
+
* If the source settles before any character has been revealed, the text
|
|
141
|
+
* is committed immediately so a missed animation frame cannot leave an
|
|
142
|
+
* empty bubble.
|
|
140
143
|
*
|
|
141
144
|
* The reveal auto-disables under `prefers-reduced-motion: reduce`,
|
|
142
145
|
* committing the full text immediately; this takes precedence over an
|
|
@@ -229,13 +232,10 @@ export const useSmooth = (
|
|
|
229
232
|
return;
|
|
230
233
|
}
|
|
231
234
|
|
|
232
|
-
// Discontinuity: part flipped, or new text breaks continuation
|
|
233
|
-
// of the animator's current target. Either case requires
|
|
234
|
-
// resetting the cursor — without the part check, a new part
|
|
235
|
-
// whose text happens to share a prefix with the previous target
|
|
236
|
-
// would keep the stale cursor and flicker.
|
|
237
235
|
const partChanged = animatorPartRef.current !== part;
|
|
238
236
|
animatorPartRef.current = part;
|
|
237
|
+
// A new part whose text shares a prefix with the previous target would
|
|
238
|
+
// keep the stale cursor and flicker without this reset.
|
|
239
239
|
if (partChanged || !text.startsWith(animatorRef.targetText)) {
|
|
240
240
|
if (state.status.type === "running") {
|
|
241
241
|
animatorRef.currentText = "";
|
|
@@ -246,13 +246,27 @@ export const useSmooth = (
|
|
|
246
246
|
animatorRef.currentText = text;
|
|
247
247
|
animatorRef.targetText = text;
|
|
248
248
|
animatorRef.stop();
|
|
249
|
+
setText(text);
|
|
249
250
|
}
|
|
250
251
|
return;
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
animatorRef.targetText = text;
|
|
255
|
+
if (state.status.type !== "running") {
|
|
256
|
+
// No character has painted. A pending frame that never runs would
|
|
257
|
+
// leave an empty bubble after settle.
|
|
258
|
+
if (animatorRef.currentText === "") {
|
|
259
|
+
animatorRef.currentText = text;
|
|
260
|
+
animatorRef.stop();
|
|
261
|
+
setText(text);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
animatorRef.start();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
254
268
|
animatorRef.start();
|
|
255
|
-
}, [animatorRef, enabled, text, state.status.type, part]);
|
|
269
|
+
}, [animatorRef, enabled, text, state.status.type, part, setText]);
|
|
256
270
|
|
|
257
271
|
useEffect(() => {
|
|
258
272
|
return () => {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ensureBinding.d.ts","names":[],"sources":["../../../../src/context/react/utils/ensureBinding.ts"],"mappings":";cAmCa,gBAAiB"}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
//#region src/context/react/utils/ensureBinding.ts
|
|
2
|
-
const debugVerifyPrototype = (runtime, prototype) => {
|
|
3
|
-
const unboundMethods = Object.getOwnPropertyNames(prototype).filter((methodStr) => {
|
|
4
|
-
const descriptor = Object.getOwnPropertyDescriptor(prototype, methodStr);
|
|
5
|
-
const isMethod = descriptor && typeof descriptor.value === "function";
|
|
6
|
-
if (!isMethod) return false;
|
|
7
|
-
const methodName = methodStr;
|
|
8
|
-
return isMethod && !methodName.startsWith("_") && methodName !== "constructor" && prototype[methodName] === runtime[methodName];
|
|
9
|
-
});
|
|
10
|
-
if (unboundMethods.length > 0) throw new Error(`The following methods are not bound: ${JSON.stringify(unboundMethods)}`);
|
|
11
|
-
const prototypePrototype = Object.getPrototypeOf(prototype);
|
|
12
|
-
if (prototypePrototype && prototypePrototype !== Object.prototype) debugVerifyPrototype(runtime, prototypePrototype);
|
|
13
|
-
};
|
|
14
|
-
const ensureBinding = (r) => {
|
|
15
|
-
const runtime = r;
|
|
16
|
-
if (runtime.__isBound) return;
|
|
17
|
-
runtime.__internal_bindMethods?.();
|
|
18
|
-
runtime.__isBound = true;
|
|
19
|
-
if (process.env.NODE_ENV !== "production") debugVerifyPrototype(runtime, Object.getPrototypeOf(runtime));
|
|
20
|
-
};
|
|
21
|
-
//#endregion
|
|
22
|
-
export { ensureBinding };
|
|
23
|
-
|
|
24
|
-
//# sourceMappingURL=ensureBinding.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ensureBinding.js","names":["Bindable","__internal_bindMethods","__isBound","debugVerifyPrototype","runtime","Record","prototype","unboundMethods","Object","getOwnPropertyNames","filter","methodStr","descriptor","getOwnPropertyDescriptor","isMethod","value","methodName","startsWith","length","Error","JSON","stringify","prototypePrototype","getPrototypeOf","ensureBinding","r","process","env","NODE_ENV"],"sources":["../../../../src/context/react/utils/ensureBinding.ts"],"sourcesContent":["type Bindable = {\n __internal_bindMethods?: () => void;\n __isBound?: boolean;\n};\nconst debugVerifyPrototype = (\n runtime: Record<string, unknown>,\n prototype: any,\n) => {\n const unboundMethods = Object.getOwnPropertyNames(prototype).filter(\n (methodStr) => {\n const descriptor = Object.getOwnPropertyDescriptor(prototype, methodStr);\n const isMethod = descriptor && typeof descriptor.value === \"function\";\n if (!isMethod) return false;\n\n const methodName = methodStr as keyof typeof runtime | \"constructor\";\n return (\n isMethod &&\n !methodName.startsWith(\"_\") &&\n methodName !== \"constructor\" &&\n prototype[methodName] === runtime[methodName]\n );\n },\n );\n\n if (unboundMethods.length > 0) {\n throw new Error(\n `The following methods are not bound: ${JSON.stringify(unboundMethods)}`,\n );\n }\n\n const prototypePrototype = Object.getPrototypeOf(prototype);\n if (prototypePrototype && prototypePrototype !== Object.prototype) {\n debugVerifyPrototype(runtime, prototypePrototype);\n }\n};\nexport const ensureBinding = (r: unknown) => {\n const runtime = r as Bindable;\n if (runtime.__isBound) return;\n\n runtime.__internal_bindMethods?.();\n runtime.__isBound = true;\n\n if (process.env.NODE_ENV !== \"production\") {\n debugVerifyPrototype(runtime, Object.getPrototypeOf(runtime));\n }\n};\n"],"mappings":";AAIA,MAAMG,wBACJC,SACAE,cACG;CACH,MAAMC,iBAAiBC,OAAOC,oBAAoBH,SAAS,CAAC,CAACI,QAC1DC,cAAc;EACb,MAAMC,aAAaJ,OAAOK,yBAAyBP,WAAWK,SAAS;EACvE,MAAMG,WAAWF,cAAc,OAAOA,WAAWG,UAAU;EAC3D,IAAI,CAACD,UAAU,OAAO;EAEtB,MAAME,aAAaL;EACnB,OACEG,YACA,CAACE,WAAWC,WAAW,GAAG,KAC1BD,eAAe,iBACfV,UAAUU,gBAAgBZ,QAAQY;CAEtC,CACF;CAEA,IAAIT,eAAeW,SAAS,GAC1B,MAAM,IAAIC,MACR,wCAAwCC,KAAKC,UAAUd,cAAc,GACvE;CAGF,MAAMe,qBAAqBd,OAAOe,eAAejB,SAAS;CAC1D,IAAIgB,sBAAsBA,uBAAuBd,OAAOF,WACtDH,qBAAqBC,SAASkB,kBAAkB;AAEpD;AACA,MAAaE,iBAAiBC,MAAe;CAC3C,MAAMrB,UAAUqB;CAChB,IAAIrB,QAAQF,WAAW;CAEvBE,QAAQH,yBAAyB;CACjCG,QAAQF,YAAY;CAEpB,IAAIwB,QAAQC,IAAIC,aAAa,cAC3BzB,qBAAqBC,SAASI,OAAOe,eAAenB,OAAO,CAAC;AAEhE"}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { Unsubscribe } from "@assistant-ui/core";
|
|
2
|
-
//#region src/context/react/utils/useRuntimeState.d.ts
|
|
3
|
-
type SubscribableRuntime<TState> = {
|
|
4
|
-
getState: () => TState;
|
|
5
|
-
subscribe: (callback: () => void) => Unsubscribe;
|
|
6
|
-
};
|
|
7
|
-
declare function useRuntimeStateInternal<TState, TSelected>(runtime: SubscribableRuntime<TState>, selector?: ((state: TState) => TSelected | TState) | undefined): TSelected | TState;
|
|
8
|
-
declare function useRuntimeState<TState>(runtime: SubscribableRuntime<TState>): TState;
|
|
9
|
-
declare function useRuntimeState<TState, TSelected>(runtime: SubscribableRuntime<TState>, selector: (state: TState) => TSelected): TSelected;
|
|
10
|
-
declare function useRuntimeState<TState, TSelected>(runtime: SubscribableRuntime<TState>, selector: ((state: TState) => TSelected) | undefined): TSelected | TState;
|
|
11
|
-
//#endregion
|
|
12
|
-
export { SubscribableRuntime, useRuntimeState, useRuntimeStateInternal };
|
|
13
|
-
//# sourceMappingURL=useRuntimeState.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"useRuntimeState.d.ts","names":[],"sources":["../../../../src/context/react/utils/useRuntimeState.ts"],"mappings":";;KAIY,oBAAoB;EAC9B,gBAAgB;EAChB,YAAY,yBAAyB;;iBAGvB,wBAAwB,QAAQ,WAC9C,SAAS,oBAAoB,SAC7B,aAAY,OAAO,WAAW,YAAY,sBACzC,YAAY;iBAcC,gBAAgB,QAC9B,SAAS,oBAAoB,UAC5B;iBACa,gBAAgB,QAAQ,WACtC,SAAS,oBAAoB,SAC7B,WAAW,OAAO,WAAW,YAC5B;iBACa,gBAAgB,QAAQ,WACtC,SAAS,oBAAoB,SAC7B,YAAY,OAAO,WAAW,yBAC7B,YAAY"}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { ensureBinding } from "./ensureBinding.js";
|
|
2
|
-
import { c } from "@assistant-ui/tap/react-shim/compiler-runtime";
|
|
3
|
-
import { useDebugValue, useSyncExternalStore } from "@assistant-ui/tap/react-shim";
|
|
4
|
-
//#region src/context/react/utils/useRuntimeState.ts
|
|
5
|
-
function useRuntimeStateInternal(runtime, t0) {
|
|
6
|
-
const $ = c(4);
|
|
7
|
-
const selector = t0 === void 0 ? identity : t0;
|
|
8
|
-
ensureBinding(runtime);
|
|
9
|
-
let t1;
|
|
10
|
-
let t2;
|
|
11
|
-
if ($[0] !== runtime || $[1] !== selector) {
|
|
12
|
-
t1 = () => selector(runtime.getState());
|
|
13
|
-
t2 = () => selector(runtime.getState());
|
|
14
|
-
$[0] = runtime;
|
|
15
|
-
$[1] = selector;
|
|
16
|
-
$[2] = t1;
|
|
17
|
-
$[3] = t2;
|
|
18
|
-
} else {
|
|
19
|
-
t1 = $[2];
|
|
20
|
-
t2 = $[3];
|
|
21
|
-
}
|
|
22
|
-
const slice = useSyncExternalStore(runtime.subscribe, t1, t2);
|
|
23
|
-
useDebugValue(slice);
|
|
24
|
-
return slice;
|
|
25
|
-
}
|
|
26
|
-
const identity = (arg) => arg;
|
|
27
|
-
function useRuntimeState(runtime, selector) {
|
|
28
|
-
return useRuntimeStateInternal(runtime, selector);
|
|
29
|
-
}
|
|
30
|
-
//#endregion
|
|
31
|
-
export { useRuntimeState, useRuntimeStateInternal };
|
|
32
|
-
|
|
33
|
-
//# sourceMappingURL=useRuntimeState.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"useRuntimeState.js","names":["Unsubscribe","useDebugValue","useSyncExternalStore","ensureBinding","SubscribableRuntime","getState","TState","subscribe","callback","useRuntimeStateInternal","runtime","t0","$","_c","selector","undefined","identity","t1","t2","slice","arg","T","useRuntimeState","state","TSelected"],"sources":["../../../../src/context/react/utils/useRuntimeState.ts"],"sourcesContent":["import type { Unsubscribe } from \"@assistant-ui/core\";\nimport { useDebugValue, useSyncExternalStore } from \"react\";\nimport { ensureBinding } from \"./ensureBinding\";\n\nexport type SubscribableRuntime<TState> = {\n getState: () => TState;\n subscribe: (callback: () => void) => Unsubscribe;\n};\n\nexport function useRuntimeStateInternal<TState, TSelected>(\n runtime: SubscribableRuntime<TState>,\n selector: ((state: TState) => TSelected | TState) | undefined = identity,\n): TSelected | TState {\n // TODO move to useRuntimeState\n ensureBinding(runtime);\n\n const slice = useSyncExternalStore(\n runtime.subscribe,\n () => selector(runtime.getState()),\n () => selector(runtime.getState()),\n );\n useDebugValue(slice);\n return slice;\n}\n\nconst identity = <T>(arg: T): T => arg;\nexport function useRuntimeState<TState>(\n runtime: SubscribableRuntime<TState>,\n): TState;\nexport function useRuntimeState<TState, TSelected>(\n runtime: SubscribableRuntime<TState>,\n selector: (state: TState) => TSelected,\n): TSelected;\nexport function useRuntimeState<TState, TSelected>(\n runtime: SubscribableRuntime<TState>,\n selector: ((state: TState) => TSelected) | undefined,\n): TSelected | TState;\nexport function useRuntimeState<TState, TSelected>(\n runtime: SubscribableRuntime<TState>,\n selector?: ((state: TState) => TSelected) | undefined,\n): TSelected | TState {\n // ensure that the runtime is bound\n // ensureBinding(runtime);\n\n return useRuntimeStateInternal(runtime, selector);\n}\n"],"mappings":";;;;AASA,SAAOS,wBAAAC,SAAAC,IAAA;CAAA,MAAAC,IAAAC,EAAA,CAAA;CAEL,MAAAC,WAAAH,OAAAI,KAAAA,IAAAC,WAAAL;CAGAR,cAAcO,OAAO;CAAC,IAAAO;CAAA,IAAAC;CAAA,IAAAN,EAAA,OAAAF,WAAAE,EAAA,OAAAE,UAAA;EAIpBG,WAAMH,SAASJ,QAAOL,SAAU,CAAC;EACjCa,WAAMJ,SAASJ,QAAOL,SAAU,CAAC;EAACO,EAAA,KAAAF;EAAAE,EAAA,KAAAE;EAAAF,EAAA,KAAAK;EAAAL,EAAA,KAAAM;CAAA,OAAA;EAAAD,KAAAL,EAAA;EAAAM,KAAAN,EAAA;CAAA;CAHpC,MAAAO,QAAcjB,qBACZQ,QAAOH,WACPU,IACAC,EACF;CACAjB,cAAckB,KAAK;CAAC,OACbA;AAAK;AAGd,MAAMH,YAAeI,QAAcA;AAYnC,SAAOE,gBAAAZ,SAAAI,UAAA;CAAA,OAOEL,wBAAwBC,SAASI,QAAQ;AAAC"}
|