@assistant-ui/react 0.14.22 → 0.14.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/index.d.ts +4 -3
  2. package/dist/index.js +4 -3
  3. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts +3 -1
  4. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts.map +1 -1
  5. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js +2 -2
  6. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js.map +1 -1
  7. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  8. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +6 -2
  9. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  10. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  11. package/dist/primitives/composer/ComposerInput.js +5 -14
  12. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  13. package/dist/primitives/composer/useComposerInputState.d.ts +13 -0
  14. package/dist/primitives/composer/useComposerInputState.d.ts.map +1 -0
  15. package/dist/primitives/composer/useComposerInputState.js +47 -0
  16. package/dist/primitives/composer/useComposerInputState.js.map +1 -0
  17. package/dist/primitives/thread/ThreadMessages.d.ts +2 -2
  18. package/dist/primitives/thread/ThreadMessages.js +2 -2
  19. package/dist/primitives/thread.d.ts +3 -3
  20. package/dist/primitives/thread.js +3 -2
  21. package/dist/primitives/thread.js.map +1 -1
  22. package/dist/unstable/useComposerInput.d.ts +84 -0
  23. package/dist/unstable/useComposerInput.d.ts.map +1 -0
  24. package/dist/unstable/useComposerInput.js +78 -0
  25. package/dist/unstable/useComposerInput.js.map +1 -0
  26. package/package.json +13 -13
  27. package/src/index.ts +39 -0
  28. package/src/legacy-runtime/runtime-cores/assistant-transport/commandQueue.ts +5 -2
  29. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-scheduling.test.ts +22 -0
  30. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +12 -2
  31. package/src/primitives/composer/ComposerInput.tsx +9 -20
  32. package/src/primitives/composer/trigger/triggerKeyboardResource.test.ts +18 -22
  33. package/src/primitives/composer/useComposerInputState.ts +34 -0
  34. package/src/primitives/thread/ThreadMessages.tsx +1 -0
  35. package/src/primitives/thread.ts +1 -0
  36. package/src/tests/threadMessageById.test.tsx +224 -0
  37. package/src/unstable/useComposerInput.test.tsx +187 -0
  38. package/src/unstable/useComposerInput.ts +126 -0
@@ -0,0 +1,224 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, render, screen } from "@testing-library/react";
4
+ import { useState, type FC, type PropsWithChildren } from "react";
5
+ import { describe, expect, it } from "vitest";
6
+ import {
7
+ AssistantRuntimeProvider,
8
+ useExternalStoreRuntime,
9
+ unstable_useThreadMessageIds,
10
+ type ThreadMessageLike,
11
+ } from "../index";
12
+ import * as ThreadPrimitive from "../primitives/thread";
13
+ import * as MessagePrimitive from "../primitives/message";
14
+ import * as MessagePartPrimitive from "../primitives/messagePart";
15
+
16
+ type Msg = { id: string; role: "user" | "assistant"; text: string };
17
+
18
+ const convertMessage = (m: Msg): ThreadMessageLike => ({
19
+ id: m.id,
20
+ role: m.role,
21
+ content: [{ type: "text", text: m.text }],
22
+ });
23
+
24
+ const TextPart: FC = () => <MessagePartPrimitive.Text />;
25
+ const Message: FC = () => (
26
+ <MessagePrimitive.Parts components={{ Text: TextPart }} />
27
+ );
28
+ const COMPONENTS = { Message };
29
+
30
+ let setMessages: (updater: (prev: Msg[]) => Msg[]) => void;
31
+
32
+ const Provider: FC<PropsWithChildren<{ initial: Msg[] }>> = ({
33
+ initial,
34
+ children,
35
+ }) => {
36
+ const [messages, setState] = useState(initial);
37
+ setMessages = setState;
38
+ const runtime = useExternalStoreRuntime({
39
+ messages,
40
+ convertMessage,
41
+ onNew: async () => {},
42
+ });
43
+ return (
44
+ <AssistantRuntimeProvider runtime={runtime}>
45
+ {children}
46
+ </AssistantRuntimeProvider>
47
+ );
48
+ };
49
+
50
+ let lastIds: readonly string[] | undefined;
51
+ const idIdentities: (readonly string[])[] = [];
52
+
53
+ const List: FC = () => {
54
+ const ids = unstable_useThreadMessageIds();
55
+ lastIds = ids;
56
+ if (idIdentities[idIdentities.length - 1] !== ids) idIdentities.push(ids);
57
+ return (
58
+ <>
59
+ {ids.map((id) => (
60
+ <div data-testid={`by-id-${id}`} key={id}>
61
+ <ThreadPrimitive.Unstable_MessageById
62
+ messageId={id}
63
+ components={COMPONENTS}
64
+ />
65
+ </div>
66
+ ))}
67
+ </>
68
+ );
69
+ };
70
+
71
+ const initial: Msg[] = [
72
+ { id: "m1", role: "user", text: "first" },
73
+ { id: "m2", role: "assistant", text: "second" },
74
+ { id: "m3", role: "user", text: "third" },
75
+ ];
76
+
77
+ const reset = () => {
78
+ lastIds = undefined;
79
+ idIdentities.length = 0;
80
+ };
81
+
82
+ describe("unstable_useThreadMessageIds", () => {
83
+ it("returns the message ids in thread order", () => {
84
+ reset();
85
+ render(
86
+ <Provider initial={initial}>
87
+ <List />
88
+ </Provider>,
89
+ );
90
+ expect(lastIds).toEqual(["m1", "m2", "m3"]);
91
+ });
92
+
93
+ it("keeps a stable array identity across content-only updates", async () => {
94
+ reset();
95
+ render(
96
+ <Provider initial={initial}>
97
+ <List />
98
+ </Provider>,
99
+ );
100
+ const before = lastIds;
101
+
102
+ await act(async () => {
103
+ setMessages((prev) =>
104
+ prev.map((m) => (m.id === "m2" ? { ...m, text: "second-edited" } : m)),
105
+ );
106
+ });
107
+
108
+ expect(lastIds).toBe(before);
109
+ expect(idIdentities).toHaveLength(1);
110
+ });
111
+
112
+ it("changes array identity when the id sequence changes", async () => {
113
+ reset();
114
+ render(
115
+ <Provider initial={initial}>
116
+ <List />
117
+ </Provider>,
118
+ );
119
+ const before = lastIds;
120
+
121
+ await act(async () => {
122
+ setMessages((prev) => [
123
+ ...prev,
124
+ { id: "m4", role: "user", text: "fourth" },
125
+ ]);
126
+ });
127
+
128
+ expect(lastIds).not.toBe(before);
129
+ expect(lastIds).toEqual(["m1", "m2", "m3", "m4"]);
130
+ });
131
+ });
132
+
133
+ describe("ThreadPrimitive.Unstable_MessageById", () => {
134
+ it("renders the same content as MessageByIndex for a known id", () => {
135
+ reset();
136
+ render(
137
+ <Provider initial={initial}>
138
+ <ThreadPrimitive.MessageByIndex index={1} components={COMPONENTS} />
139
+ <div data-testid="by-id">
140
+ <ThreadPrimitive.Unstable_MessageById
141
+ messageId="m2"
142
+ components={COMPONENTS}
143
+ />
144
+ </div>
145
+ </Provider>,
146
+ );
147
+ expect(screen.getByTestId("by-id").textContent).toBe("second");
148
+ });
149
+
150
+ it("renders null for an unknown id without throwing", () => {
151
+ reset();
152
+ expect(() =>
153
+ render(
154
+ <Provider initial={initial}>
155
+ <div data-testid="by-id">
156
+ <ThreadPrimitive.Unstable_MessageById
157
+ messageId="does-not-exist"
158
+ components={COMPONENTS}
159
+ />
160
+ </div>
161
+ </Provider>,
162
+ ),
163
+ ).not.toThrow();
164
+ expect(screen.getByTestId("by-id").textContent).toBe("");
165
+ });
166
+
167
+ it("renders null when messageId changes from known to unknown", () => {
168
+ reset();
169
+ const { rerender } = render(
170
+ <Provider initial={initial}>
171
+ <div data-testid="by-id">
172
+ <ThreadPrimitive.Unstable_MessageById
173
+ messageId="m3"
174
+ components={COMPONENTS}
175
+ />
176
+ </div>
177
+ </Provider>,
178
+ );
179
+ expect(screen.getByTestId("by-id").textContent).toBe("third");
180
+
181
+ expect(() =>
182
+ rerender(
183
+ <Provider initial={initial}>
184
+ <div data-testid="by-id">
185
+ <ThreadPrimitive.Unstable_MessageById
186
+ messageId="does-not-exist"
187
+ components={COMPONENTS}
188
+ />
189
+ </div>
190
+ </Provider>,
191
+ ),
192
+ ).not.toThrow();
193
+
194
+ expect(screen.getByTestId("by-id").textContent).toBe("");
195
+ });
196
+
197
+ it("stays attached to the same message across reordering", async () => {
198
+ reset();
199
+ render(
200
+ <Provider initial={initial}>
201
+ <div data-testid="by-id">
202
+ <ThreadPrimitive.Unstable_MessageById
203
+ messageId="m1"
204
+ components={COMPONENTS}
205
+ />
206
+ </div>
207
+ </Provider>,
208
+ );
209
+ expect(screen.getByTestId("by-id").textContent).toBe("first");
210
+
211
+ await act(async () => {
212
+ setMessages((prev) => [...prev].reverse());
213
+ });
214
+
215
+ expect(screen.getByTestId("by-id").textContent).toBe("first");
216
+ });
217
+ });
218
+
219
+ describe("exports", () => {
220
+ it("exposes the unstable id-keyed surface", () => {
221
+ expect(typeof unstable_useThreadMessageIds).toBe("function");
222
+ expect(ThreadPrimitive.Unstable_MessageById).toBeDefined();
223
+ });
224
+ });
@@ -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
+ }