@assistant-ui/react 0.14.23 → 0.14.26
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 +4 -3
- package/dist/index.js +4 -3
- package/dist/legacy-runtime/hooks/AttachmentContext.d.ts +12 -12
- package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts +3 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js +2 -2
- package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +6 -2
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +5 -14
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/useComposerInputState.d.ts +13 -0
- package/dist/primitives/composer/useComposerInputState.d.ts.map +1 -0
- package/dist/primitives/composer/useComposerInputState.js +47 -0
- package/dist/primitives/composer/useComposerInputState.js.map +1 -0
- package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
- package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
- package/dist/primitives/thread/ThreadMessages.d.ts +2 -2
- package/dist/primitives/thread/ThreadMessages.js +2 -2
- package/dist/primitives/thread.d.ts +3 -3
- package/dist/primitives/thread.js +3 -2
- package/dist/primitives/thread.js.map +1 -1
- package/dist/primitives/threadList/ThreadListRoot.d.ts.map +1 -1
- package/dist/primitives/threadList/ThreadListRoot.js +10 -3
- package/dist/primitives/threadList/ThreadListRoot.js.map +1 -1
- package/dist/primitives/threadListFocusGroup.d.ts +26 -0
- package/dist/primitives/threadListFocusGroup.d.ts.map +1 -0
- package/dist/primitives/threadListFocusGroup.js +15 -0
- package/dist/primitives/threadListFocusGroup.js.map +1 -0
- package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemRoot.js +45 -27
- package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemTrigger.d.ts.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemTrigger.js +26 -2
- package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +63 -38
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.d.ts +20 -3
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js +95 -14
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +38 -21
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js.map +1 -1
- package/dist/unstable/useComposerInput.d.ts +84 -0
- package/dist/unstable/useComposerInput.d.ts.map +1 -0
- package/dist/unstable/useComposerInput.js +78 -0
- package/dist/unstable/useComposerInput.js.map +1 -0
- package/dist/utils/hooks/useMediaQuery.js +2 -2
- package/dist/utils/hooks/useMediaQuery.js.map +1 -1
- package/dist/utils/smooth/useSmooth.d.ts +4 -0
- package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
- package/dist/utils/smooth/useSmooth.js +7 -1
- package/dist/utils/smooth/useSmooth.js.map +1 -1
- package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
- package/dist/utils/useToolArgsFieldStatus.d.ts.map +1 -1
- package/package.json +16 -14
- package/src/index.ts +39 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/commandQueue.ts +5 -2
- package/src/legacy-runtime/runtime-cores/assistant-transport/transport-scheduling.test.ts +22 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +12 -2
- package/src/primitives/composer/ComposerInput.tsx +9 -20
- package/src/primitives/composer/trigger/triggerKeyboardResource.test.ts +18 -22
- package/src/primitives/composer/useComposerInputState.ts +34 -0
- package/src/primitives/messagePart/MessagePartText.tsx +1 -0
- package/src/primitives/thread/ThreadMessages.tsx +1 -0
- package/src/primitives/thread.ts +1 -0
- package/src/primitives/threadList/ThreadListRoot.tsx +8 -1
- package/src/primitives/threadListFocusGroup.ts +24 -0
- package/src/primitives/threadListItem/ThreadListItemRoot.tsx +53 -5
- package/src/primitives/threadListItem/ThreadListItemTrigger.tsx +45 -0
- package/src/primitives/threadListItem/ThreadListKeyboardNav.test.tsx +141 -0
- package/src/primitives/threadListItemMore/ThreadListItemMoreContent.tsx +22 -1
- package/src/primitives/threadListItemMore/ThreadListItemMoreRoot.tsx +64 -3
- package/src/primitives/threadListItemMore/ThreadListItemMoreTrigger.tsx +29 -2
- package/src/tests/generative-ui.test.tsx +21 -1
- package/src/tests/threadMessageById.test.tsx +224 -0
- package/src/unstable/useComposerInput.test.tsx +187 -0
- package/src/unstable/useComposerInput.ts +126 -0
- package/src/utils/hooks/useMediaQuery.ts +3 -2
- package/src/utils/smooth/useSmooth.test.tsx +26 -0
- package/src/utils/smooth/useSmooth.ts +7 -1
- package/src/primitives/threadListItem/ThreadListItemTrigger.ts +0 -23
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
type ComposerState = { isEditing: boolean; text: string };
|
|
6
|
+
|
|
7
|
+
const fixture = {
|
|
8
|
+
composer: { isEditing: true, text: "hello" } as ComposerState,
|
|
9
|
+
threadDisabled: false,
|
|
10
|
+
dictationInputDisabled: undefined as boolean | undefined,
|
|
11
|
+
sendDisabled: false,
|
|
12
|
+
activeAria: null as { popoverId: string; highlightedItemId?: string } | null,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const composerSetText = vi.fn();
|
|
16
|
+
const composerSend = vi.fn();
|
|
17
|
+
const flushTapSync = vi.fn((fn: () => void) => fn());
|
|
18
|
+
|
|
19
|
+
vi.mock("@assistant-ui/store", () => ({
|
|
20
|
+
useAui: () => ({
|
|
21
|
+
composer: () => ({
|
|
22
|
+
getState: () => ({ isEditing: fixture.composer.isEditing }),
|
|
23
|
+
setText: composerSetText,
|
|
24
|
+
send: composerSend,
|
|
25
|
+
}),
|
|
26
|
+
}),
|
|
27
|
+
useAuiState: (selector: (s: unknown) => unknown) =>
|
|
28
|
+
selector({
|
|
29
|
+
composer: {
|
|
30
|
+
isEditing: fixture.composer.isEditing,
|
|
31
|
+
text: fixture.composer.text,
|
|
32
|
+
dictation: fixture.dictationInputDisabled
|
|
33
|
+
? { inputDisabled: fixture.dictationInputDisabled }
|
|
34
|
+
: undefined,
|
|
35
|
+
},
|
|
36
|
+
thread: { isDisabled: fixture.threadDisabled },
|
|
37
|
+
}),
|
|
38
|
+
}));
|
|
39
|
+
vi.mock("@assistant-ui/tap", () => ({
|
|
40
|
+
flushTapSync: (fn: () => void) => flushTapSync(fn),
|
|
41
|
+
}));
|
|
42
|
+
vi.mock("@assistant-ui/core/react", () => ({
|
|
43
|
+
useComposerSend: () => ({
|
|
44
|
+
send: composerSend,
|
|
45
|
+
disabled: fixture.sendDisabled,
|
|
46
|
+
}),
|
|
47
|
+
}));
|
|
48
|
+
vi.mock("../primitives/composer/trigger/TriggerPopoverRootContext", () => ({
|
|
49
|
+
useTriggerPopoverActiveAriaOptional: () => fixture.activeAria,
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
unstable_useComposerInput,
|
|
54
|
+
unstable_useTriggerPopoverAriaProps,
|
|
55
|
+
type Unstable_ComposerInput,
|
|
56
|
+
type Unstable_TriggerPopoverAriaProps,
|
|
57
|
+
} from "./useComposerInput";
|
|
58
|
+
|
|
59
|
+
const renderInput = (options?: { disabled?: boolean }) => {
|
|
60
|
+
let result!: Unstable_ComposerInput;
|
|
61
|
+
function Harness() {
|
|
62
|
+
result = unstable_useComposerInput(options);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
render(<Harness />);
|
|
66
|
+
return () => result;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const renderAria = () => {
|
|
70
|
+
let result!: Unstable_TriggerPopoverAriaProps;
|
|
71
|
+
function Harness() {
|
|
72
|
+
result = unstable_useTriggerPopoverAriaProps();
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
render(<Harness />);
|
|
76
|
+
return () => result;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
fixture.composer = { isEditing: true, text: "hello" };
|
|
81
|
+
fixture.threadDisabled = false;
|
|
82
|
+
fixture.dictationInputDisabled = undefined;
|
|
83
|
+
fixture.sendDisabled = false;
|
|
84
|
+
fixture.activeAria = null;
|
|
85
|
+
composerSetText.mockClear();
|
|
86
|
+
composerSend.mockClear();
|
|
87
|
+
flushTapSync.mockClear();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("unstable_useComposerInput", () => {
|
|
91
|
+
it("exposes the composer text while editing", () => {
|
|
92
|
+
expect(renderInput()().value).toBe("hello");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("reports an empty value when the composer is not editing", () => {
|
|
96
|
+
fixture.composer = { isEditing: false, text: "stale" };
|
|
97
|
+
expect(renderInput()().value).toBe("");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("writes text via flushTapSync when editing", () => {
|
|
101
|
+
renderInput()().setText("world");
|
|
102
|
+
expect(flushTapSync).toHaveBeenCalledTimes(1);
|
|
103
|
+
expect(composerSetText).toHaveBeenCalledWith("world");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("ignores setText when the composer is not editing (editing guard)", () => {
|
|
107
|
+
fixture.composer = { isEditing: false, text: "" };
|
|
108
|
+
renderInput()().setText("world");
|
|
109
|
+
expect(composerSetText).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("combines the disabled option with composer disabled sources", () => {
|
|
113
|
+
expect(renderInput()().isDisabled).toBe(false);
|
|
114
|
+
expect(renderInput({ disabled: true })().isDisabled).toBe(true);
|
|
115
|
+
|
|
116
|
+
fixture.threadDisabled = true;
|
|
117
|
+
expect(renderInput()().isDisabled).toBe(true);
|
|
118
|
+
|
|
119
|
+
fixture.threadDisabled = false;
|
|
120
|
+
fixture.dictationInputDisabled = true;
|
|
121
|
+
expect(renderInput()().isDisabled).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("derives canSend from send gating and disabled state", () => {
|
|
125
|
+
expect(renderInput()().canSend).toBe(true);
|
|
126
|
+
|
|
127
|
+
fixture.sendDisabled = true;
|
|
128
|
+
expect(renderInput()().canSend).toBe(false);
|
|
129
|
+
|
|
130
|
+
fixture.sendDisabled = false;
|
|
131
|
+
expect(renderInput({ disabled: true })().canSend).toBe(false);
|
|
132
|
+
|
|
133
|
+
fixture.threadDisabled = true;
|
|
134
|
+
expect(renderInput()().canSend).toBe(false);
|
|
135
|
+
|
|
136
|
+
fixture.threadDisabled = false;
|
|
137
|
+
fixture.dictationInputDisabled = true;
|
|
138
|
+
expect(renderInput()().canSend).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("forwards send options to the composer action", () => {
|
|
142
|
+
renderInput()().send({ steer: true });
|
|
143
|
+
expect(composerSend).toHaveBeenCalledWith({ steer: true });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("does not send while send is unavailable or the input is disabled", () => {
|
|
147
|
+
fixture.sendDisabled = true;
|
|
148
|
+
renderInput()().send();
|
|
149
|
+
expect(composerSend).not.toHaveBeenCalled();
|
|
150
|
+
|
|
151
|
+
fixture.sendDisabled = false;
|
|
152
|
+
renderInput({ disabled: true })().send();
|
|
153
|
+
expect(composerSend).not.toHaveBeenCalled();
|
|
154
|
+
|
|
155
|
+
fixture.threadDisabled = true;
|
|
156
|
+
renderInput()().send();
|
|
157
|
+
expect(composerSend).not.toHaveBeenCalled();
|
|
158
|
+
|
|
159
|
+
fixture.threadDisabled = false;
|
|
160
|
+
fixture.dictationInputDisabled = true;
|
|
161
|
+
renderInput()().send();
|
|
162
|
+
expect(composerSend).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("unstable_useTriggerPopoverAriaProps", () => {
|
|
167
|
+
it("is empty when no popover is active", () => {
|
|
168
|
+
expect(renderAria()()).toEqual({});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("describes the active popover combobox relationship", () => {
|
|
172
|
+
fixture.activeAria = { popoverId: "pop-1", highlightedItemId: "item-2" };
|
|
173
|
+
expect(renderAria()()).toEqual({
|
|
174
|
+
"aria-controls": "pop-1",
|
|
175
|
+
"aria-expanded": true,
|
|
176
|
+
"aria-haspopup": "listbox",
|
|
177
|
+
"aria-activedescendant": "item-2",
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("emits an undefined aria-activedescendant when nothing is highlighted", () => {
|
|
182
|
+
fixture.activeAria = { popoverId: "pop-1" };
|
|
183
|
+
const props = renderAria()();
|
|
184
|
+
expect(props).toHaveProperty("aria-activedescendant", undefined);
|
|
185
|
+
expect(props["aria-controls"]).toBe("pop-1");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import { useAui } from "@assistant-ui/store";
|
|
5
|
+
import { flushTapSync } from "@assistant-ui/tap";
|
|
6
|
+
import { useComposerSend } from "@assistant-ui/core/react";
|
|
7
|
+
import type { ComposerSendOptions } from "@assistant-ui/core/store";
|
|
8
|
+
import {
|
|
9
|
+
type TriggerPopoverAriaProps,
|
|
10
|
+
useComposerInputDisabled,
|
|
11
|
+
useComposerInputValue,
|
|
12
|
+
useTriggerPopoverAriaProps,
|
|
13
|
+
} from "../primitives/composer/useComposerInputState";
|
|
14
|
+
|
|
15
|
+
export type Unstable_UseComposerInputOptions = {
|
|
16
|
+
/**
|
|
17
|
+
* Disables the input in addition to the composer's own disabled sources
|
|
18
|
+
* (thread disabled, active dictation). When disabled, `isDisabled` is `true`
|
|
19
|
+
* and `canSend` is `false`.
|
|
20
|
+
*/
|
|
21
|
+
disabled?: boolean | undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type Unstable_ComposerInput = {
|
|
25
|
+
/** Current composer text, or `""` when the composer is not editing. */
|
|
26
|
+
value: string;
|
|
27
|
+
/**
|
|
28
|
+
* Writes `text` into the composer, mirroring `ComposerPrimitive.Input`:
|
|
29
|
+
* a no-op unless the composer is editing, committed via `flushTapSync` so the
|
|
30
|
+
* controlled value stays in sync within the same tick.
|
|
31
|
+
*/
|
|
32
|
+
setText(text: string): void;
|
|
33
|
+
/**
|
|
34
|
+
* Sends the current message when `canSend` is `true`; otherwise a no-op.
|
|
35
|
+
* Accepts the same options as the composer send action.
|
|
36
|
+
*/
|
|
37
|
+
send(options?: ComposerSendOptions): void;
|
|
38
|
+
/**
|
|
39
|
+
* Whether the input is disabled, combining the `disabled` option with the
|
|
40
|
+
* composer's own disabled sources (thread disabled, active dictation).
|
|
41
|
+
*/
|
|
42
|
+
isDisabled: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Whether a send is currently available. Matches `ComposerPrimitive.Send`
|
|
45
|
+
* gating (non-empty editing composer, not running without queue support) and
|
|
46
|
+
* is additionally `false` while `isDisabled`.
|
|
47
|
+
*/
|
|
48
|
+
canSend: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @deprecated Under active development and might change without notice.
|
|
53
|
+
*
|
|
54
|
+
* Headless bridge to the composer's text value and send action, for building a
|
|
55
|
+
* custom composer input without `ComposerPrimitive.Input`. It is a thin bridge,
|
|
56
|
+
* not a second input: it does not own keyboard behavior, autosize, IME or
|
|
57
|
+
* contentEditable sync, paste/drop attachments, focus management, or rich-text
|
|
58
|
+
* state. Spread `unstable_useTriggerPopoverAriaProps()` onto your element for
|
|
59
|
+
* trigger-popover combobox semantics.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```tsx
|
|
63
|
+
* const { value, setText, send, isDisabled, canSend } = unstable_useComposerInput();
|
|
64
|
+
* <textarea
|
|
65
|
+
* value={value}
|
|
66
|
+
* disabled={isDisabled}
|
|
67
|
+
* onChange={(e) => setText(e.target.value)}
|
|
68
|
+
* onKeyDown={(e) => {
|
|
69
|
+
* if (e.key === "Enter" && !e.shiftKey && canSend) {
|
|
70
|
+
* e.preventDefault();
|
|
71
|
+
* send();
|
|
72
|
+
* }
|
|
73
|
+
* }}
|
|
74
|
+
* />
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export function unstable_useComposerInput(
|
|
78
|
+
options?: Unstable_UseComposerInputOptions,
|
|
79
|
+
): Unstable_ComposerInput {
|
|
80
|
+
const aui = useAui();
|
|
81
|
+
const value = useComposerInputValue();
|
|
82
|
+
const isDisabled = useComposerInputDisabled(options?.disabled);
|
|
83
|
+
|
|
84
|
+
const setText = useCallback(
|
|
85
|
+
(text: string) => {
|
|
86
|
+
if (!aui.composer().getState().isEditing) return;
|
|
87
|
+
flushTapSync(() => {
|
|
88
|
+
aui.composer().setText(text);
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
[aui],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const { send: rawSend, disabled: sendDisabled } = useComposerSend();
|
|
95
|
+
const canSend = !sendDisabled && !isDisabled;
|
|
96
|
+
const send = useCallback(
|
|
97
|
+
(sendOptions?: ComposerSendOptions) => {
|
|
98
|
+
if (!canSend) return;
|
|
99
|
+
rawSend(sendOptions);
|
|
100
|
+
},
|
|
101
|
+
[canSend, rawSend],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
return { value, setText, send, isDisabled, canSend };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type Unstable_TriggerPopoverAriaProps = TriggerPopoverAriaProps;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @deprecated Under active development and might change without notice.
|
|
111
|
+
*
|
|
112
|
+
* ARIA combobox attributes for the focused element (typically the composer
|
|
113
|
+
* input) describing the open trigger popover, per the WAI-ARIA editable
|
|
114
|
+
* combobox pattern. Returns an empty object outside a `TriggerPopoverRoot` or
|
|
115
|
+
* when no popover is open. Spread these last so they take precedence over any
|
|
116
|
+
* matching ARIA props you set yourself, mirroring `ComposerPrimitive.Input`.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```tsx
|
|
120
|
+
* const aria = unstable_useTriggerPopoverAriaProps();
|
|
121
|
+
* <textarea {...aria} />
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
export function unstable_useTriggerPopoverAriaProps(): Unstable_TriggerPopoverAriaProps {
|
|
125
|
+
return useTriggerPopoverAriaProps();
|
|
126
|
+
}
|
|
@@ -8,7 +8,7 @@ const noopUnsubscribe = () => {};
|
|
|
8
8
|
export const useMediaQuery = (query: string | null): boolean => {
|
|
9
9
|
const subscribe = useCallback(
|
|
10
10
|
(callback: () => void) => {
|
|
11
|
-
if (typeof window === "undefined" || query === null)
|
|
11
|
+
if (typeof window === "undefined" || query === null || !window.matchMedia)
|
|
12
12
|
return noopUnsubscribe;
|
|
13
13
|
const mql = window.matchMedia(query);
|
|
14
14
|
mql.addEventListener("change", callback);
|
|
@@ -18,7 +18,8 @@ export const useMediaQuery = (query: string | null): boolean => {
|
|
|
18
18
|
);
|
|
19
19
|
|
|
20
20
|
const getSnapshot = useCallback(() => {
|
|
21
|
-
if (typeof window === "undefined" || query === null
|
|
21
|
+
if (typeof window === "undefined" || query === null || !window.matchMedia)
|
|
22
|
+
return false;
|
|
22
23
|
return window.matchMedia(query).matches;
|
|
23
24
|
}, [query]);
|
|
24
25
|
|
|
@@ -39,6 +39,23 @@ const runningState = (text: string) =>
|
|
|
39
39
|
status: { type: "running" },
|
|
40
40
|
}) as MessagePartState & TextMessagePart;
|
|
41
41
|
|
|
42
|
+
const setReducedMotion = (matches: boolean) => {
|
|
43
|
+
Object.defineProperty(window, "matchMedia", {
|
|
44
|
+
writable: true,
|
|
45
|
+
configurable: true,
|
|
46
|
+
value: (query: string) => ({
|
|
47
|
+
matches,
|
|
48
|
+
media: query,
|
|
49
|
+
onchange: null,
|
|
50
|
+
addEventListener: () => {},
|
|
51
|
+
removeEventListener: () => {},
|
|
52
|
+
addListener: () => {},
|
|
53
|
+
removeListener: () => {},
|
|
54
|
+
dispatchEvent: () => false,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
42
59
|
const driveAndCount = (minCommitMs: number) => {
|
|
43
60
|
const raf: FrameRequestCallback[] = [];
|
|
44
61
|
const rafSpy = vi
|
|
@@ -85,6 +102,7 @@ const driveAndCount = (minCommitMs: number) => {
|
|
|
85
102
|
describe("useSmooth", () => {
|
|
86
103
|
afterEach(() => {
|
|
87
104
|
vi.restoreAllMocks();
|
|
105
|
+
Reflect.deleteProperty(window, "matchMedia");
|
|
88
106
|
});
|
|
89
107
|
|
|
90
108
|
it("returns the input state unchanged when disabled", () => {
|
|
@@ -115,6 +133,14 @@ describe("useSmooth", () => {
|
|
|
115
133
|
expect(result.current).toBe(state);
|
|
116
134
|
});
|
|
117
135
|
|
|
136
|
+
it("disables the reveal under prefers-reduced-motion", () => {
|
|
137
|
+
setReducedMotion(true);
|
|
138
|
+
const state = runningState("streaming");
|
|
139
|
+
const { result } = renderHook(() => useSmooth(state, true));
|
|
140
|
+
expect(result.current).toBe(state);
|
|
141
|
+
expect(result.current.text).toBe("streaming");
|
|
142
|
+
});
|
|
143
|
+
|
|
118
144
|
it("starts from an empty reveal for running parts when enabled", () => {
|
|
119
145
|
const state = {
|
|
120
146
|
type: "text",
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
MessagePartState,
|
|
10
10
|
} from "@assistant-ui/core";
|
|
11
11
|
import { useCallbackRef } from "@radix-ui/react-use-callback-ref";
|
|
12
|
+
import { useMediaQuery } from "../hooks/useMediaQuery";
|
|
12
13
|
import { useSmoothStatusStore } from "./SmoothContext";
|
|
13
14
|
import { writableStore } from "../../context/ReadonlyStore";
|
|
14
15
|
|
|
@@ -134,6 +135,10 @@ const positiveOr = (value: number | undefined, fallback: number): number =>
|
|
|
134
135
|
* the reveal. Returns the part state with `text` replaced by the revealed
|
|
135
136
|
* prefix and `status` reporting `running` until the reveal catches up.
|
|
136
137
|
*
|
|
138
|
+
* The reveal auto-disables under `prefers-reduced-motion: reduce`,
|
|
139
|
+
* committing the full text immediately; this takes precedence over an
|
|
140
|
+
* explicit `smooth={true}`.
|
|
141
|
+
*
|
|
137
142
|
* @example
|
|
138
143
|
* ```tsx
|
|
139
144
|
* const { text, status } = useSmooth(useMessagePartText(), {
|
|
@@ -147,9 +152,10 @@ export const useSmooth = (
|
|
|
147
152
|
smooth: boolean | SmoothOptions = false,
|
|
148
153
|
): MessagePartState & (TextMessagePart | ReasoningMessagePart) => {
|
|
149
154
|
const { text } = state;
|
|
155
|
+
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
|
|
150
156
|
const options =
|
|
151
157
|
typeof smooth === "object" && smooth !== null ? smooth : undefined;
|
|
152
|
-
const enabled = smooth !== false && smooth !== null;
|
|
158
|
+
const enabled = smooth !== false && smooth !== null && !reduceMotion;
|
|
153
159
|
const drainMs = positiveOr(options?.drainMs, DEFAULT_DRAIN_MS);
|
|
154
160
|
const maxCharIntervalMs = positiveOr(
|
|
155
161
|
options?.maxCharIntervalMs,
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
type ActionButtonElement,
|
|
5
|
-
type ActionButtonProps,
|
|
6
|
-
createActionButton,
|
|
7
|
-
} from "../../utils/createActionButton";
|
|
8
|
-
import { useThreadListItemTrigger as useThreadListItemTriggerBehavior } from "@assistant-ui/core/react";
|
|
9
|
-
|
|
10
|
-
const useThreadListItemTrigger = () => {
|
|
11
|
-
const { switchTo } = useThreadListItemTriggerBehavior();
|
|
12
|
-
return switchTo;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export namespace ThreadListItemPrimitiveTrigger {
|
|
16
|
-
export type Element = ActionButtonElement;
|
|
17
|
-
export type Props = ActionButtonProps<typeof useThreadListItemTrigger>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export const ThreadListItemPrimitiveTrigger = createActionButton(
|
|
21
|
-
"ThreadListItemPrimitive.Trigger",
|
|
22
|
-
useThreadListItemTrigger,
|
|
23
|
-
);
|