@assistant-ui/react 0.12.27 → 0.14.0
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/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +0 -2
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/client/InMemoryThreadList.d.ts.map +1 -1
- package/dist/client/InMemoryThreadList.js +3 -0
- package/dist/client/InMemoryThreadList.js.map +1 -1
- package/dist/client/SingleThreadList.d.ts.map +1 -1
- package/dist/client/SingleThreadList.js +3 -0
- package/dist/client/SingleThreadList.js.map +1 -1
- package/dist/context/providers/ThreadViewportProvider.d.ts.map +1 -1
- package/dist/context/providers/ThreadViewportProvider.js +2 -10
- package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
- package/dist/context/stores/ThreadViewport.d.ts +46 -4
- package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
- package/dist/context/stores/ThreadViewport.js +51 -7
- package/dist/context/stores/ThreadViewport.js.map +1 -1
- package/dist/index.d.ts +2 -30
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -28
- package/dist/index.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +9 -4
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +6 -21
- package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js +6 -21
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/message/MessageRoot.d.ts +6 -30
- package/dist/primitives/message/MessageRoot.d.ts.map +1 -1
- package/dist/primitives/message/MessageRoot.js +68 -25
- package/dist/primitives/message/MessageRoot.js.map +1 -1
- package/dist/primitives/message.d.ts +1 -0
- package/dist/primitives/message.d.ts.map +1 -1
- package/dist/primitives/message.js +1 -0
- package/dist/primitives/message.js.map +1 -1
- package/dist/primitives/thread/ThreadViewport.d.ts +38 -0
- package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewport.js +53 -5
- package/dist/primitives/thread/ThreadViewport.js.map +1 -1
- package/dist/primitives/thread/ThreadViewportFooter.d.ts +2 -1
- package/dist/primitives/thread/ThreadViewportFooter.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewportFooter.js +2 -1
- package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
- package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.d.ts +22 -0
- package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js +53 -0
- package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js.map +1 -0
- package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts +5 -0
- package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/createReserveObservers.js +38 -0
- package/dist/primitives/thread/topAnchor/createReserveObservers.js.map +1 -0
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts +22 -0
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js +75 -0
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js.map +1 -0
- package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts +15 -0
- package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/topAnchorTurn.js +13 -0
- package/dist/primitives/thread/topAnchor/topAnchorTurn.js.map +1 -0
- package/dist/primitives/thread/topAnchor/topAnchorUtils.d.ts +15 -0
- package/dist/primitives/thread/topAnchor/topAnchorUtils.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/topAnchorUtils.js +51 -0
- package/dist/primitives/thread/topAnchor/topAnchorUtils.js.map +1 -0
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.d.ts +7 -0
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +18 -0
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -0
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +13 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/primitives/thread.d.ts +0 -1
- package/dist/primitives/thread.d.ts.map +1 -1
- package/dist/primitives/thread.js +0 -1
- package/dist/primitives/thread.js.map +1 -1
- package/dist/primitives/threadList/ThreadListLoadMore.d.ts +13 -0
- package/dist/primitives/threadList/ThreadListLoadMore.d.ts.map +1 -0
- package/dist/primitives/threadList/ThreadListLoadMore.js +11 -0
- package/dist/primitives/threadList/ThreadListLoadMore.js.map +1 -0
- package/dist/primitives/threadList.d.ts +1 -0
- package/dist/primitives/threadList.d.ts.map +1 -1
- package/dist/primitives/threadList.js +1 -0
- package/dist/primitives/threadList.js.map +1 -1
- package/dist/tests/remote-thread-list-test-helpers.d.ts +3 -0
- package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -0
- package/dist/tests/remote-thread-list-test-helpers.js +27 -0
- package/dist/tests/remote-thread-list-test-helpers.js.map +1 -0
- package/dist/utils/hooks/useManagedRef.d.ts.map +1 -1
- package/dist/utils/hooks/useManagedRef.js +1 -0
- package/dist/utils/hooks/useManagedRef.js.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.js +1 -2
- package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
- package/package.json +10 -10
- package/src/client/ExternalThread.ts +0 -2
- package/src/client/InMemoryThreadList.ts +3 -0
- package/src/client/SingleThreadList.ts +3 -0
- package/src/context/providers/ThreadViewportProvider.tsx +2 -12
- package/src/context/stores/ThreadViewport.ts +111 -11
- package/src/index.ts +2 -35
- package/src/legacy-runtime/runtime-cores/assistant-transport/utils.ts +1 -5
- package/src/primitives/composer/ComposerInput.test.tsx +232 -0
- package/src/primitives/composer/ComposerInput.tsx +9 -4
- package/src/primitives/message/MessagePartsGrouped.tsx +6 -21
- package/src/primitives/message/MessageRoot.tsx +135 -57
- package/src/primitives/message.ts +1 -0
- package/src/primitives/thread/ThreadViewport.tsx +95 -4
- package/src/primitives/thread/ThreadViewportFooter.tsx +2 -1
- package/src/primitives/thread/topAnchor/computeTopAnchorSlack.test.ts +131 -0
- package/src/primitives/thread/topAnchor/computeTopAnchorSlack.ts +94 -0
- package/src/primitives/thread/topAnchor/createReserveObservers.ts +50 -0
- package/src/primitives/thread/topAnchor/mountTopAnchorReserve.test.ts +131 -0
- package/src/primitives/thread/topAnchor/mountTopAnchorReserve.ts +127 -0
- package/src/primitives/thread/topAnchor/topAnchorTurn.test.ts +46 -0
- package/src/primitives/thread/topAnchor/topAnchorTurn.ts +30 -0
- package/src/primitives/thread/topAnchor/topAnchorUtils.ts +58 -0
- package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +19 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +15 -1
- package/src/primitives/thread.ts +0 -1
- package/src/primitives/threadList/ThreadListLoadMore.tsx +24 -0
- package/src/primitives/threadList.ts +1 -0
- package/src/tests/BaseComposerRuntimeCore.test.ts +9 -0
- package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +138 -0
- package/src/tests/RemoteThreadListRuntime.deferredProvider.test.tsx +29 -18
- package/src/tests/remote-thread-list-test-helpers.ts +33 -0
- package/src/utils/hooks/useManagedRef.ts +1 -0
- package/src/utils/hooks/useOnResizeContent.ts +1 -2
- package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.d.ts +0 -3
- package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.d.ts.map +0 -1
- package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js +0 -3
- package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js.map +0 -1
- package/dist/primitives/thread/ThreadViewportSlack.d.ts +0 -20
- package/dist/primitives/thread/ThreadViewportSlack.d.ts.map +0 -1
- package/dist/primitives/thread/ThreadViewportSlack.js +0 -80
- package/dist/primitives/thread/ThreadViewportSlack.js.map +0 -1
- package/src/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.ts +0 -6
- package/src/primitives/thread/ThreadViewportSlack.tsx +0 -116
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { act } from "react";
|
|
5
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { ComposerPrimitiveInput } from "./ComposerInput";
|
|
8
|
+
|
|
9
|
+
const setText = vi.fn<(text: string) => void>();
|
|
10
|
+
const setCursorPosition = vi.fn<(pos: number) => void>();
|
|
11
|
+
|
|
12
|
+
const composerState = {
|
|
13
|
+
isEditing: true,
|
|
14
|
+
text: "",
|
|
15
|
+
type: "thread" as const,
|
|
16
|
+
isEmpty: true,
|
|
17
|
+
canCancel: false,
|
|
18
|
+
dictation: undefined as undefined | { inputDisabled: boolean },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const threadState = {
|
|
22
|
+
isDisabled: false,
|
|
23
|
+
isRunning: false,
|
|
24
|
+
capabilities: { queue: false, attachments: false },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const plugin = {
|
|
28
|
+
handleKeyDown: () => false,
|
|
29
|
+
setCursorPosition,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let pluginRegistry: { getPlugins: () => (typeof plugin)[] } | null = null;
|
|
33
|
+
|
|
34
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
35
|
+
|
|
36
|
+
vi.mock("@assistant-ui/store", () => {
|
|
37
|
+
const aui = {
|
|
38
|
+
composer: () => ({
|
|
39
|
+
setText,
|
|
40
|
+
getState: () => composerState,
|
|
41
|
+
cancel: () => {},
|
|
42
|
+
send: () => {},
|
|
43
|
+
addAttachment: async () => {},
|
|
44
|
+
}),
|
|
45
|
+
thread: () => ({
|
|
46
|
+
getState: () => threadState,
|
|
47
|
+
}),
|
|
48
|
+
on: () => () => {},
|
|
49
|
+
};
|
|
50
|
+
type Selector<T> = (s: {
|
|
51
|
+
composer: typeof composerState;
|
|
52
|
+
thread: typeof threadState;
|
|
53
|
+
}) => T;
|
|
54
|
+
return {
|
|
55
|
+
useAui: () => aui,
|
|
56
|
+
useAuiState: <T,>(selector: Selector<T>) =>
|
|
57
|
+
selector({ composer: composerState, thread: threadState }),
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
vi.mock("@assistant-ui/tap", () => ({
|
|
62
|
+
flushResourcesSync: (fn: () => void) => fn(),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
vi.mock("./ComposerInputPluginContext", () => ({
|
|
66
|
+
useComposerInputPluginRegistryOptional: () => pluginRegistry,
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
vi.mock("@radix-ui/react-use-escape-keydown", () => ({
|
|
70
|
+
useEscapeKeydown: () => {},
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
vi.mock("../../utils/hooks/useOnScrollToBottom", () => ({
|
|
74
|
+
useOnScrollToBottom: () => {},
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
const setNativeValue = (textarea: HTMLTextAreaElement, value: string) => {
|
|
78
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
79
|
+
HTMLTextAreaElement.prototype,
|
|
80
|
+
"value",
|
|
81
|
+
)?.set;
|
|
82
|
+
setter?.call(textarea, value);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const fireInput = (
|
|
86
|
+
textarea: HTMLTextAreaElement,
|
|
87
|
+
value: string,
|
|
88
|
+
isComposing: boolean,
|
|
89
|
+
) => {
|
|
90
|
+
setNativeValue(textarea, value);
|
|
91
|
+
textarea.dispatchEvent(
|
|
92
|
+
new InputEvent("input", { bubbles: true, isComposing }),
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const fireCompositionStart = (textarea: HTMLTextAreaElement) => {
|
|
97
|
+
textarea.dispatchEvent(
|
|
98
|
+
new CompositionEvent("compositionstart", { bubbles: true }),
|
|
99
|
+
);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const fireCompositionEnd = (textarea: HTMLTextAreaElement, value: string) => {
|
|
103
|
+
setNativeValue(textarea, value);
|
|
104
|
+
textarea.dispatchEvent(
|
|
105
|
+
new CompositionEvent("compositionend", { bubbles: true }),
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
describe("ComposerPrimitiveInput", () => {
|
|
110
|
+
let container: HTMLDivElement;
|
|
111
|
+
let root: Root;
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
setText.mockReset();
|
|
115
|
+
setCursorPosition.mockReset();
|
|
116
|
+
composerState.isEditing = true;
|
|
117
|
+
composerState.text = "";
|
|
118
|
+
composerState.isEmpty = true;
|
|
119
|
+
composerState.dictation = undefined;
|
|
120
|
+
threadState.isDisabled = false;
|
|
121
|
+
threadState.isRunning = false;
|
|
122
|
+
threadState.capabilities = { queue: false, attachments: false };
|
|
123
|
+
pluginRegistry = null;
|
|
124
|
+
|
|
125
|
+
container = document.createElement("div");
|
|
126
|
+
document.body.appendChild(container);
|
|
127
|
+
root = createRoot(container);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
afterEach(async () => {
|
|
131
|
+
await act(async () => {
|
|
132
|
+
root.unmount();
|
|
133
|
+
});
|
|
134
|
+
container.remove();
|
|
135
|
+
vi.restoreAllMocks();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const mount = async () => {
|
|
139
|
+
await act(async () => {
|
|
140
|
+
root.render(<ComposerPrimitiveInput data-testid="input" />);
|
|
141
|
+
});
|
|
142
|
+
const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
|
|
143
|
+
expect(textarea).not.toBeNull();
|
|
144
|
+
return textarea;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
it("syncs setText during active composition so React 19 cannot reset the textarea", async () => {
|
|
148
|
+
const textarea = await mount();
|
|
149
|
+
|
|
150
|
+
await act(async () => {
|
|
151
|
+
fireCompositionStart(textarea);
|
|
152
|
+
fireInput(textarea, "ㄱ", true);
|
|
153
|
+
});
|
|
154
|
+
expect(setText).toHaveBeenCalledWith("ㄱ");
|
|
155
|
+
|
|
156
|
+
await act(async () => {
|
|
157
|
+
fireInput(textarea, "가", true);
|
|
158
|
+
});
|
|
159
|
+
expect(setText).toHaveBeenLastCalledWith("가");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("commits the final value on compositionend", async () => {
|
|
163
|
+
const textarea = await mount();
|
|
164
|
+
|
|
165
|
+
await act(async () => {
|
|
166
|
+
fireCompositionStart(textarea);
|
|
167
|
+
fireInput(textarea, "가", true);
|
|
168
|
+
});
|
|
169
|
+
expect(setText).toHaveBeenCalledTimes(1);
|
|
170
|
+
|
|
171
|
+
await act(async () => {
|
|
172
|
+
fireCompositionEnd(textarea, "가");
|
|
173
|
+
});
|
|
174
|
+
expect(setText).toHaveBeenCalledTimes(2);
|
|
175
|
+
expect(setText).toHaveBeenLastCalledWith("가");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("recovers when compositionend is dropped before the next input", async () => {
|
|
179
|
+
const textarea = await mount();
|
|
180
|
+
|
|
181
|
+
await act(async () => {
|
|
182
|
+
fireCompositionStart(textarea);
|
|
183
|
+
fireInput(textarea, "hello", false);
|
|
184
|
+
});
|
|
185
|
+
expect(setText).toHaveBeenCalledWith("hello");
|
|
186
|
+
|
|
187
|
+
await act(async () => {
|
|
188
|
+
fireInput(textarea, "hello!", false);
|
|
189
|
+
});
|
|
190
|
+
expect(setText).toHaveBeenLastCalledWith("hello!");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("skips plugin cursor tracking during composition but resumes after", async () => {
|
|
194
|
+
pluginRegistry = { getPlugins: () => [plugin] };
|
|
195
|
+
const textarea = await mount();
|
|
196
|
+
|
|
197
|
+
await act(async () => {
|
|
198
|
+
fireCompositionStart(textarea);
|
|
199
|
+
fireInput(textarea, "ㄱ", true);
|
|
200
|
+
});
|
|
201
|
+
expect(setText).toHaveBeenCalledWith("ㄱ");
|
|
202
|
+
expect(setCursorPosition).not.toHaveBeenCalled();
|
|
203
|
+
|
|
204
|
+
await act(async () => {
|
|
205
|
+
fireCompositionEnd(textarea, "가");
|
|
206
|
+
});
|
|
207
|
+
expect(setCursorPosition).toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("tracks plugin cursor for non-composition input", async () => {
|
|
211
|
+
pluginRegistry = { getPlugins: () => [plugin] };
|
|
212
|
+
const textarea = await mount();
|
|
213
|
+
|
|
214
|
+
await act(async () => {
|
|
215
|
+
fireInput(textarea, "abc", false);
|
|
216
|
+
});
|
|
217
|
+
expect(setText).toHaveBeenCalledWith("abc");
|
|
218
|
+
expect(setCursorPosition).toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("ignores input and compositionend when the composer is not editing", async () => {
|
|
222
|
+
composerState.isEditing = false;
|
|
223
|
+
const textarea = await mount();
|
|
224
|
+
|
|
225
|
+
await act(async () => {
|
|
226
|
+
fireInput(textarea, "abc", false);
|
|
227
|
+
fireCompositionStart(textarea);
|
|
228
|
+
fireCompositionEnd(textarea, "가");
|
|
229
|
+
});
|
|
230
|
+
expect(setText).not.toHaveBeenCalled();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -297,13 +297,18 @@ export const ComposerPrimitiveInput = forwardRef<
|
|
|
297
297
|
onChange,
|
|
298
298
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
299
299
|
if (!aui.composer().getState().isEditing) return;
|
|
300
|
-
const
|
|
301
|
-
(e.nativeEvent as { isComposing?: boolean }).isComposing === true
|
|
302
|
-
|
|
303
|
-
if (
|
|
300
|
+
const nativeIsComposing =
|
|
301
|
+
(e.nativeEvent as { isComposing?: boolean }).isComposing === true;
|
|
302
|
+
// recover stuck compositionRef when the browser drops compositionend
|
|
303
|
+
if (compositionRef.current && !nativeIsComposing) {
|
|
304
|
+
compositionRef.current = false;
|
|
305
|
+
}
|
|
306
|
+
const isComposing = nativeIsComposing || compositionRef.current;
|
|
307
|
+
// keep controlled value in sync mid-IME so react does not reset the textarea to a stale value
|
|
304
308
|
flushResourcesSync(() => {
|
|
305
309
|
aui.composer().setText(e.target.value);
|
|
306
310
|
});
|
|
311
|
+
if (isComposing) return;
|
|
307
312
|
const pos = e.target.selectionStart ?? e.target.value.length;
|
|
308
313
|
if (pluginRegistry) {
|
|
309
314
|
for (const plugin of pluginRegistry.getPlugins()) {
|
|
@@ -412,6 +412,12 @@ const EmptyParts = memo(
|
|
|
412
412
|
* The grouping function receives all message parts and returns an array of groups,
|
|
413
413
|
* where each group has a key and an array of part indices.
|
|
414
414
|
*
|
|
415
|
+
* @deprecated Prefer `<MessagePrimitive.GroupedParts>` for adjacent
|
|
416
|
+
* grouping — it dispatches all rendering through one `switch (part.type)`
|
|
417
|
+
* and supports nested group paths. Keep this primitive only for
|
|
418
|
+
* non-adjacent clustering (e.g., gathering parts with the same parent-id
|
|
419
|
+
* across the message).
|
|
420
|
+
*
|
|
415
421
|
* @example
|
|
416
422
|
* ```tsx
|
|
417
423
|
* // Group by parent ID (default behavior)
|
|
@@ -431,27 +437,6 @@ const EmptyParts = memo(
|
|
|
431
437
|
* }}
|
|
432
438
|
* />
|
|
433
439
|
* ```
|
|
434
|
-
*
|
|
435
|
-
* @example
|
|
436
|
-
* ```tsx
|
|
437
|
-
* // Group by tool name
|
|
438
|
-
* import { groupMessagePartsByToolName } from "@assistant-ui/react";
|
|
439
|
-
*
|
|
440
|
-
* <MessagePrimitive.Unstable_PartsGrouped
|
|
441
|
-
* groupingFunction={groupMessagePartsByToolName}
|
|
442
|
-
* components={{
|
|
443
|
-
* Group: ({ groupKey, indices, children }) => {
|
|
444
|
-
* if (!groupKey) return <>{children}</>;
|
|
445
|
-
* return (
|
|
446
|
-
* <div className="tool-group">
|
|
447
|
-
* <h4>Tool: {groupKey}</h4>
|
|
448
|
-
* {children}
|
|
449
|
-
* </div>
|
|
450
|
-
* );
|
|
451
|
-
* }
|
|
452
|
-
* }}
|
|
453
|
-
* />
|
|
454
|
-
* ```
|
|
455
440
|
*/
|
|
456
441
|
export const MessagePrimitiveUnstable_PartsGrouped: FC<
|
|
457
442
|
MessagePrimitiveUnstable_PartsGrouped.Props
|
|
@@ -5,14 +5,21 @@ import {
|
|
|
5
5
|
type ComponentRef,
|
|
6
6
|
forwardRef,
|
|
7
7
|
type ComponentPropsWithoutRef,
|
|
8
|
+
type ForwardedRef,
|
|
8
9
|
useCallback,
|
|
9
10
|
} from "react";
|
|
10
11
|
import { useAui, useAuiState } from "@assistant-ui/store";
|
|
11
12
|
import { useManagedRef } from "../../utils/hooks/useManagedRef";
|
|
12
|
-
import { useSizeHandle } from "../../utils/hooks/useSizeHandle";
|
|
13
13
|
import { useComposedRefs } from "@radix-ui/react-compose-refs";
|
|
14
|
-
import {
|
|
15
|
-
|
|
14
|
+
import {
|
|
15
|
+
useThreadViewport,
|
|
16
|
+
useThreadViewportStore,
|
|
17
|
+
} from "../../context/react/ThreadViewportContext";
|
|
18
|
+
import { parseCssLength } from "../thread/topAnchor/topAnchorUtils";
|
|
19
|
+
|
|
20
|
+
type ThreadViewportStore = NonNullable<
|
|
21
|
+
ReturnType<typeof useThreadViewportStore>
|
|
22
|
+
>;
|
|
16
23
|
|
|
17
24
|
const useIsHoveringRef = () => {
|
|
18
25
|
const aui = useAui();
|
|
@@ -47,54 +54,126 @@ const useIsHoveringRef = () => {
|
|
|
47
54
|
return useManagedRef(callbackRef);
|
|
48
55
|
};
|
|
49
56
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*/
|
|
54
|
-
const useMessageViewportRef = () => {
|
|
55
|
-
const turnAnchor = useThreadViewport((s) => s.turnAnchor);
|
|
56
|
-
const registerUserHeight = useThreadViewport(
|
|
57
|
-
(s) => s.registerUserMessageHeight,
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
// inset rules:
|
|
61
|
-
// - the previous user message before the last assistant message registers its full height
|
|
62
|
-
const shouldRegisterAsInset = useAuiState(
|
|
57
|
+
const useIsTopAnchorUser = () => {
|
|
58
|
+
const activeAnchorId = useThreadViewport((s) => s.topAnchorTurn?.anchorId);
|
|
59
|
+
return useAuiState(
|
|
63
60
|
(s) =>
|
|
64
|
-
turnAnchor === "top" &&
|
|
65
61
|
s.message.role === "user" &&
|
|
62
|
+
s.message.index > 0 &&
|
|
66
63
|
s.message.index === s.thread.messages.length - 2 &&
|
|
67
|
-
s.thread.messages.at(-1)?.role === "assistant"
|
|
64
|
+
s.thread.messages.at(-1)?.role === "assistant" &&
|
|
65
|
+
(s.message.id === activeAnchorId || s.thread.isRunning),
|
|
68
66
|
);
|
|
67
|
+
};
|
|
69
68
|
|
|
70
|
-
|
|
69
|
+
const useIsTopAnchorTarget = () => {
|
|
70
|
+
const activeTargetId = useThreadViewport((s) => s.topAnchorTurn?.targetId);
|
|
71
|
+
return useAuiState(
|
|
72
|
+
(s) =>
|
|
73
|
+
s.message.isLast &&
|
|
74
|
+
s.message.role === "assistant" &&
|
|
75
|
+
s.message.index >= 1 &&
|
|
76
|
+
s.thread.messages.at(s.message.index - 1)?.role === "user" &&
|
|
77
|
+
(s.message.id === activeTargetId || s.thread.isRunning),
|
|
78
|
+
);
|
|
79
|
+
};
|
|
71
80
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
const useTopAnchorUserRef = (
|
|
82
|
+
active: boolean,
|
|
83
|
+
threadViewportStore: ThreadViewportStore,
|
|
84
|
+
) => {
|
|
85
|
+
const callback = useCallback(
|
|
86
|
+
(el: HTMLElement) => {
|
|
87
|
+
if (!active) return;
|
|
88
|
+
return threadViewportStore.getState().registerAnchorElement(el);
|
|
89
|
+
},
|
|
90
|
+
[active, threadViewportStore],
|
|
75
91
|
);
|
|
92
|
+
|
|
93
|
+
return useManagedRef<HTMLElement>(callback);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const useTopAnchorTargetRef = ({
|
|
97
|
+
active,
|
|
98
|
+
threadViewportStore,
|
|
99
|
+
}: {
|
|
100
|
+
active: boolean;
|
|
101
|
+
threadViewportStore: ThreadViewportStore;
|
|
102
|
+
}) => {
|
|
103
|
+
const targetRefCallback = useCallback(
|
|
104
|
+
(el: HTMLElement) => {
|
|
105
|
+
if (!active) return;
|
|
106
|
+
const state = threadViewportStore.getState();
|
|
107
|
+
const clamp = state.topAnchorMessageClamp;
|
|
108
|
+
|
|
109
|
+
return state.registerAnchorTargetElement(el, {
|
|
110
|
+
tallerThan: parseCssLength(clamp.tallerThan, el),
|
|
111
|
+
visibleHeight: parseCssLength(clamp.visibleHeight, el),
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
[active, threadViewportStore],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return useManagedRef<HTMLElement>(targetRefCallback);
|
|
76
118
|
};
|
|
77
119
|
|
|
78
120
|
export namespace MessagePrimitiveRoot {
|
|
79
121
|
export type Element = ComponentRef<typeof Primitive.div>;
|
|
80
|
-
|
|
81
|
-
* Props for the MessagePrimitive.Root component.
|
|
82
|
-
* Accepts all standard div element props plus optional viewport slack tuning.
|
|
83
|
-
*/
|
|
84
|
-
export type Props = ComponentPropsWithoutRef<typeof Primitive.div> & {
|
|
85
|
-
/**
|
|
86
|
-
* Threshold at which the user message height clamps to the offset.
|
|
87
|
-
* @default "10em"
|
|
88
|
-
*/
|
|
89
|
-
fillClampThreshold?: string | undefined;
|
|
90
|
-
/**
|
|
91
|
-
* Offset used when clamping large user messages.
|
|
92
|
-
* @default "6em"
|
|
93
|
-
*/
|
|
94
|
-
fillClampOffset?: string | undefined;
|
|
95
|
-
};
|
|
122
|
+
export type Props = ComponentPropsWithoutRef<typeof Primitive.div>;
|
|
96
123
|
}
|
|
97
124
|
|
|
125
|
+
type MessagePrimitiveRootInternalProps = MessagePrimitiveRoot.Props & {
|
|
126
|
+
forwardedRef: ForwardedRef<MessagePrimitiveRoot.Element>;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const MessagePrimitiveRootDefault = ({
|
|
130
|
+
forwardedRef,
|
|
131
|
+
...props
|
|
132
|
+
}: MessagePrimitiveRootInternalProps) => {
|
|
133
|
+
const isHoveringRef = useIsHoveringRef();
|
|
134
|
+
const ref = useComposedRefs<HTMLDivElement>(forwardedRef, isHoveringRef);
|
|
135
|
+
const messageId = useAuiState((s) => s.message.id);
|
|
136
|
+
|
|
137
|
+
return <Primitive.div {...props} ref={ref} data-message-id={messageId} />;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const MessagePrimitiveRootTopAnchor = ({
|
|
141
|
+
forwardedRef,
|
|
142
|
+
threadViewportStore,
|
|
143
|
+
...props
|
|
144
|
+
}: MessagePrimitiveRootInternalProps & {
|
|
145
|
+
threadViewportStore: ThreadViewportStore;
|
|
146
|
+
}) => {
|
|
147
|
+
const isHoveringRef = useIsHoveringRef();
|
|
148
|
+
const isTopAnchorUser = useIsTopAnchorUser();
|
|
149
|
+
const isTopAnchorTarget = useIsTopAnchorTarget();
|
|
150
|
+
const topAnchorUserRef = useTopAnchorUserRef(
|
|
151
|
+
isTopAnchorUser,
|
|
152
|
+
threadViewportStore,
|
|
153
|
+
);
|
|
154
|
+
const topAnchorTargetRef = useTopAnchorTargetRef({
|
|
155
|
+
active: isTopAnchorTarget,
|
|
156
|
+
threadViewportStore,
|
|
157
|
+
});
|
|
158
|
+
const ref = useComposedRefs<HTMLDivElement>(
|
|
159
|
+
forwardedRef,
|
|
160
|
+
isHoveringRef,
|
|
161
|
+
topAnchorUserRef,
|
|
162
|
+
topAnchorTargetRef,
|
|
163
|
+
);
|
|
164
|
+
const messageId = useAuiState((s) => s.message.id);
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<Primitive.div
|
|
168
|
+
{...props}
|
|
169
|
+
ref={ref}
|
|
170
|
+
data-message-id={messageId}
|
|
171
|
+
data-aui-top-anchor-user={isTopAnchorUser ? "" : undefined}
|
|
172
|
+
data-aui-top-anchor-target={isTopAnchorTarget ? "" : undefined}
|
|
173
|
+
/>
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
|
|
98
177
|
/**
|
|
99
178
|
* The root container component for a message.
|
|
100
179
|
*
|
|
@@ -102,8 +181,10 @@ export namespace MessagePrimitiveRoot {
|
|
|
102
181
|
* hover state management for the message. It automatically tracks when the user
|
|
103
182
|
* is hovering over the message, which can be used by child components like action bars.
|
|
104
183
|
*
|
|
105
|
-
* When `turnAnchor="top"` is set on the viewport, this component
|
|
106
|
-
* registers itself as the
|
|
184
|
+
* When `turnAnchor="top"` is set on the viewport, this component automatically
|
|
185
|
+
* registers itself as the top-anchor user message (when it's the previous user
|
|
186
|
+
* message) or as the top-anchor target (when it's the streaming assistant
|
|
187
|
+
* response). No additional component is required.
|
|
107
188
|
*
|
|
108
189
|
* @example
|
|
109
190
|
* ```tsx
|
|
@@ -119,24 +200,21 @@ export namespace MessagePrimitiveRoot {
|
|
|
119
200
|
export const MessagePrimitiveRoot = forwardRef<
|
|
120
201
|
MessagePrimitiveRoot.Element,
|
|
121
202
|
MessagePrimitiveRoot.Props
|
|
122
|
-
>((
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
forwardRef,
|
|
127
|
-
isHoveringRef,
|
|
128
|
-
anchorUserMessageRef,
|
|
129
|
-
);
|
|
130
|
-
const messageId = useAuiState((s) => s.message.id);
|
|
203
|
+
>((props, forwardedRef) => {
|
|
204
|
+
const threadViewportStore = useThreadViewportStore();
|
|
205
|
+
// turnAnchor is initial-only viewport config (see ThreadViewportProvider).
|
|
206
|
+
const turnAnchor = threadViewportStore.getState().turnAnchor;
|
|
131
207
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
208
|
+
if (turnAnchor === "top") {
|
|
209
|
+
return (
|
|
210
|
+
<MessagePrimitiveRootTopAnchor
|
|
211
|
+
{...props}
|
|
212
|
+
forwardedRef={forwardedRef}
|
|
213
|
+
threadViewportStore={threadViewportStore}
|
|
214
|
+
/>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return <MessagePrimitiveRootDefault {...props} forwardedRef={forwardedRef} />;
|
|
140
218
|
});
|
|
141
219
|
|
|
142
220
|
MessagePrimitiveRoot.displayName = "MessagePrimitive.Root";
|
|
@@ -7,6 +7,7 @@ export { MessagePrimitiveAttachments as Attachments } from "./message/MessageAtt
|
|
|
7
7
|
export { MessagePrimitiveAttachmentByIndex as AttachmentByIndex } from "./message/MessageAttachments";
|
|
8
8
|
export { MessagePrimitiveQuote as Quote } from "@assistant-ui/core/react";
|
|
9
9
|
export { MessagePrimitiveError as Error } from "./message/MessageError";
|
|
10
|
+
export { MessagePrimitiveGroupedParts as GroupedParts } from "@assistant-ui/core/react";
|
|
10
11
|
export {
|
|
11
12
|
MessagePrimitiveUnstable_PartsGrouped as Unstable_PartsGrouped,
|
|
12
13
|
MessagePrimitiveUnstable_PartsGroupedByParentId as Unstable_PartsGroupedByParentId,
|
|
@@ -7,11 +7,23 @@ import {
|
|
|
7
7
|
forwardRef,
|
|
8
8
|
type ComponentPropsWithoutRef,
|
|
9
9
|
useCallback,
|
|
10
|
+
useLayoutEffect,
|
|
11
|
+
useMemo,
|
|
10
12
|
} from "react";
|
|
13
|
+
import { useAuiEvent, useAuiState } from "@assistant-ui/store";
|
|
14
|
+
import { useManagedRef } from "../../utils/hooks/useManagedRef";
|
|
11
15
|
import { useThreadViewportAutoScroll } from "./useThreadViewportAutoScroll";
|
|
12
16
|
import { ThreadPrimitiveViewportProvider } from "../../context/providers/ThreadViewportProvider";
|
|
13
17
|
import { useSizeHandle } from "../../utils/hooks/useSizeHandle";
|
|
14
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
useThreadViewport,
|
|
20
|
+
useThreadViewportStore,
|
|
21
|
+
} from "../../context/react/ThreadViewportContext";
|
|
22
|
+
import { useTopAnchorReserve } from "./topAnchor/useTopAnchorReserve";
|
|
23
|
+
import {
|
|
24
|
+
getActiveTopAnchorAnchorId,
|
|
25
|
+
getActiveTopAnchorTargetId,
|
|
26
|
+
} from "./topAnchor/topAnchorTurn";
|
|
15
27
|
|
|
16
28
|
export namespace ThreadPrimitiveViewport {
|
|
17
29
|
export type Element = ComponentRef<typeof Primitive.div>;
|
|
@@ -31,6 +43,26 @@ export namespace ThreadPrimitiveViewport {
|
|
|
31
43
|
*/
|
|
32
44
|
turnAnchor?: "top" | "bottom" | undefined;
|
|
33
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Clamps tall user messages so the assistant response stays in view.
|
|
48
|
+
*
|
|
49
|
+
* @default { tallerThan: "10em", visibleHeight: "6em" }
|
|
50
|
+
*/
|
|
51
|
+
topAnchorMessageClamp?: {
|
|
52
|
+
/**
|
|
53
|
+
* Clamp messages taller than this. Supports `px`, `em`, and `rem`.
|
|
54
|
+
*
|
|
55
|
+
* @default "10em"
|
|
56
|
+
*/
|
|
57
|
+
tallerThan?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Visible portion of clamped messages. Supports `px`, `em`, and `rem`.
|
|
60
|
+
*
|
|
61
|
+
* @default "6em"
|
|
62
|
+
*/
|
|
63
|
+
visibleHeight?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
34
66
|
/**
|
|
35
67
|
* Whether to scroll to bottom when a new run starts.
|
|
36
68
|
*
|
|
@@ -60,6 +92,52 @@ const useViewportSizeRef = () => {
|
|
|
60
92
|
return useSizeHandle(register, getHeight);
|
|
61
93
|
};
|
|
62
94
|
|
|
95
|
+
const useViewportElementRef = () => {
|
|
96
|
+
const registerViewportElement = useThreadViewport(
|
|
97
|
+
(s) => s.registerViewportElement,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return useManagedRef(registerViewportElement);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const useTopAnchorTurn = (enabled: boolean) => {
|
|
104
|
+
const threadViewportStore = useThreadViewportStore();
|
|
105
|
+
const activeAnchorId = useAuiState((s) => {
|
|
106
|
+
if (!enabled) return undefined;
|
|
107
|
+
return getActiveTopAnchorAnchorId(s.thread);
|
|
108
|
+
});
|
|
109
|
+
const activeTargetId = useAuiState((s) => {
|
|
110
|
+
if (!enabled) return undefined;
|
|
111
|
+
return getActiveTopAnchorTargetId(s.thread);
|
|
112
|
+
});
|
|
113
|
+
const activeTurn = useMemo(() => {
|
|
114
|
+
if (!activeAnchorId || !activeTargetId) return null;
|
|
115
|
+
return { anchorId: activeAnchorId, targetId: activeTargetId };
|
|
116
|
+
}, [activeAnchorId, activeTargetId]);
|
|
117
|
+
|
|
118
|
+
useLayoutEffect(() => {
|
|
119
|
+
if (!activeTurn) return;
|
|
120
|
+
|
|
121
|
+
const state = threadViewportStore.getState();
|
|
122
|
+
const current = state.topAnchorTurn;
|
|
123
|
+
if (
|
|
124
|
+
current?.anchorId === activeTurn.anchorId &&
|
|
125
|
+
current.targetId === activeTurn.targetId
|
|
126
|
+
) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
state.setTopAnchorTurn(activeTurn);
|
|
131
|
+
}, [activeTurn, threadViewportStore]);
|
|
132
|
+
|
|
133
|
+
const clearTopAnchorTurn = useCallback(() => {
|
|
134
|
+
threadViewportStore.getState().setTopAnchorTurn(null);
|
|
135
|
+
}, [threadViewportStore]);
|
|
136
|
+
|
|
137
|
+
useAuiEvent("thread.initialize", clearTopAnchorTurn);
|
|
138
|
+
useAuiEvent("threadListItem.switchedTo", clearTopAnchorTurn);
|
|
139
|
+
};
|
|
140
|
+
|
|
63
141
|
const ThreadPrimitiveViewportScrollable = forwardRef<
|
|
64
142
|
ThreadPrimitiveViewport.Element,
|
|
65
143
|
ThreadPrimitiveViewport.Props
|
|
@@ -82,7 +160,18 @@ const ThreadPrimitiveViewportScrollable = forwardRef<
|
|
|
82
160
|
scrollToBottomOnThreadSwitch,
|
|
83
161
|
});
|
|
84
162
|
const viewportSizeRef = useViewportSizeRef();
|
|
85
|
-
const
|
|
163
|
+
const viewportElementRef = useViewportElementRef();
|
|
164
|
+
const threadViewportStore = useThreadViewportStore();
|
|
165
|
+
const turnAnchor = threadViewportStore.getState().turnAnchor;
|
|
166
|
+
const topAnchorEnabled = turnAnchor === "top";
|
|
167
|
+
useTopAnchorTurn(topAnchorEnabled);
|
|
168
|
+
useTopAnchorReserve(topAnchorEnabled);
|
|
169
|
+
const ref = useComposedRefs(
|
|
170
|
+
forwardedRef,
|
|
171
|
+
autoScrollRef,
|
|
172
|
+
viewportSizeRef,
|
|
173
|
+
viewportElementRef,
|
|
174
|
+
);
|
|
86
175
|
|
|
87
176
|
return (
|
|
88
177
|
<Primitive.div {...rest} ref={ref}>
|
|
@@ -114,9 +203,11 @@ ThreadPrimitiveViewportScrollable.displayName =
|
|
|
114
203
|
export const ThreadPrimitiveViewport = forwardRef<
|
|
115
204
|
ThreadPrimitiveViewport.Element,
|
|
116
205
|
ThreadPrimitiveViewport.Props
|
|
117
|
-
>(({ turnAnchor, ...props }, ref) => {
|
|
206
|
+
>(({ turnAnchor, topAnchorMessageClamp, ...props }, ref) => {
|
|
118
207
|
return (
|
|
119
|
-
<ThreadPrimitiveViewportProvider
|
|
208
|
+
<ThreadPrimitiveViewportProvider
|
|
209
|
+
options={{ turnAnchor, topAnchorMessageClamp }}
|
|
210
|
+
>
|
|
120
211
|
<ThreadPrimitiveViewportScrollable {...props} ref={ref} />
|
|
121
212
|
</ThreadPrimitiveViewportProvider>
|
|
122
213
|
);
|
|
@@ -20,7 +20,8 @@ export namespace ThreadPrimitiveViewportFooter {
|
|
|
20
20
|
* A footer container that measures its height for scroll calculations.
|
|
21
21
|
*
|
|
22
22
|
* This component measures its height and provides it to the viewport context
|
|
23
|
-
*
|
|
23
|
+
* so the auto-scroll system can account for any sticky footer overlapping the
|
|
24
|
+
* message list.
|
|
24
25
|
*
|
|
25
26
|
* Multiple ViewportFooter components can be used - their heights are summed.
|
|
26
27
|
*
|