@assistant-ui/react 0.12.28 → 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 +1 -29
- 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/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/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/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 +0 -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/MessageRoot.tsx +135 -57
- 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/RemoteThreadListRuntime.adapterProvider.test.tsx +138 -0
- package/src/tests/RemoteThreadListRuntime.deferredProvider.test.tsx +28 -17
- 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
|
@@ -60,28 +60,80 @@ export type ThreadViewportState = {
|
|
|
60
60
|
/** Controls scroll anchoring: "top" anchors user messages at top, "bottom" is classic behavior */
|
|
61
61
|
readonly turnAnchor: "top" | "bottom";
|
|
62
62
|
|
|
63
|
+
/** Clamps tall user messages so the assistant response stays in view. */
|
|
64
|
+
readonly topAnchorMessageClamp: {
|
|
65
|
+
readonly tallerThan: string;
|
|
66
|
+
readonly visibleHeight: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
63
69
|
/** Raw height values from registered elements */
|
|
64
70
|
readonly height: {
|
|
65
71
|
/** Total viewport height */
|
|
66
72
|
readonly viewport: number;
|
|
67
73
|
/** Total content inset height (footer, anchor message, etc.) */
|
|
68
74
|
readonly inset: number;
|
|
69
|
-
/** Height of the anchor user message (full height) */
|
|
70
|
-
readonly userMessage: number;
|
|
71
75
|
};
|
|
72
76
|
|
|
77
|
+
/** Current DOM elements used for geometry-based top anchoring */
|
|
78
|
+
readonly element: {
|
|
79
|
+
readonly viewport: HTMLElement | null;
|
|
80
|
+
readonly anchor: HTMLElement | null;
|
|
81
|
+
readonly target: HTMLElement | null;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Numeric clamp configuration for the active top-anchor target message */
|
|
85
|
+
readonly targetConfig: {
|
|
86
|
+
readonly tallerThan: number;
|
|
87
|
+
readonly visibleHeight: number;
|
|
88
|
+
} | null;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The current top-anchor turn activated in this viewport session.
|
|
92
|
+
* History-loaded messages do not populate this; it is set when a run creates
|
|
93
|
+
* a live user/assistant pair and remains after the run completes.
|
|
94
|
+
*/
|
|
95
|
+
readonly topAnchorTurn: {
|
|
96
|
+
readonly anchorId: string;
|
|
97
|
+
readonly targetId: string;
|
|
98
|
+
} | null;
|
|
99
|
+
|
|
73
100
|
/** Register a viewport and get a handle to update its height */
|
|
74
101
|
readonly registerViewport: () => SizeHandle;
|
|
75
102
|
|
|
76
103
|
/** Register a content inset (footer, anchor message, etc.) and get a handle to update its height */
|
|
77
104
|
readonly registerContentInset: () => SizeHandle;
|
|
78
105
|
|
|
79
|
-
/** Register the
|
|
80
|
-
readonly
|
|
106
|
+
/** Register the scroll viewport element */
|
|
107
|
+
readonly registerViewportElement: (
|
|
108
|
+
element: HTMLElement | null,
|
|
109
|
+
) => Unsubscribe;
|
|
110
|
+
|
|
111
|
+
/** Register the current anchor user message element */
|
|
112
|
+
readonly registerAnchorElement: (element: HTMLElement | null) => Unsubscribe;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Register the current top-anchor target (last assistant response) element
|
|
116
|
+
* along with its numeric clamp configuration. When unregistered, both
|
|
117
|
+
* `element.target` and `targetConfig` clear together.
|
|
118
|
+
*/
|
|
119
|
+
readonly registerAnchorTargetElement: (
|
|
120
|
+
element: HTMLElement | null,
|
|
121
|
+
config?: { readonly tallerThan: number; readonly visibleHeight: number },
|
|
122
|
+
) => Unsubscribe;
|
|
123
|
+
|
|
124
|
+
readonly setTopAnchorTurn: (
|
|
125
|
+
turn: { readonly anchorId: string; readonly targetId: string } | null,
|
|
126
|
+
) => void;
|
|
81
127
|
};
|
|
82
128
|
|
|
83
129
|
export type ThreadViewportStoreOptions = {
|
|
84
130
|
turnAnchor?: "top" | "bottom" | undefined;
|
|
131
|
+
topAnchorMessageClamp?:
|
|
132
|
+
| {
|
|
133
|
+
tallerThan?: string | undefined;
|
|
134
|
+
visibleHeight?: string | undefined;
|
|
135
|
+
}
|
|
136
|
+
| undefined;
|
|
85
137
|
};
|
|
86
138
|
|
|
87
139
|
export const makeThreadViewportStore = (
|
|
@@ -107,14 +159,27 @@ export const makeThreadViewportStore = (
|
|
|
107
159
|
},
|
|
108
160
|
});
|
|
109
161
|
});
|
|
110
|
-
const
|
|
162
|
+
const registerElementSlot = (
|
|
163
|
+
key: "viewport" | "anchor",
|
|
164
|
+
element: HTMLElement | null,
|
|
165
|
+
) => {
|
|
111
166
|
store.setState({
|
|
112
|
-
|
|
113
|
-
...store.getState().
|
|
114
|
-
|
|
167
|
+
element: {
|
|
168
|
+
...store.getState().element,
|
|
169
|
+
[key]: element,
|
|
115
170
|
},
|
|
116
171
|
});
|
|
117
|
-
|
|
172
|
+
|
|
173
|
+
return () => {
|
|
174
|
+
if (store.getState().element[key] !== element) return;
|
|
175
|
+
store.setState({
|
|
176
|
+
element: {
|
|
177
|
+
...store.getState().element,
|
|
178
|
+
[key]: null,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
};
|
|
118
183
|
|
|
119
184
|
const store = create<ThreadViewportState>(() => ({
|
|
120
185
|
isAtBottom: true,
|
|
@@ -131,16 +196,51 @@ export const makeThreadViewportStore = (
|
|
|
131
196
|
},
|
|
132
197
|
|
|
133
198
|
turnAnchor: options.turnAnchor ?? "bottom",
|
|
199
|
+
topAnchorMessageClamp: {
|
|
200
|
+
tallerThan: options.topAnchorMessageClamp?.tallerThan ?? "10em",
|
|
201
|
+
visibleHeight: options.topAnchorMessageClamp?.visibleHeight ?? "6em",
|
|
202
|
+
},
|
|
134
203
|
|
|
135
204
|
height: {
|
|
136
205
|
viewport: 0,
|
|
137
206
|
inset: 0,
|
|
138
|
-
userMessage: 0,
|
|
139
207
|
},
|
|
208
|
+
element: {
|
|
209
|
+
viewport: null,
|
|
210
|
+
anchor: null,
|
|
211
|
+
target: null,
|
|
212
|
+
},
|
|
213
|
+
targetConfig: null,
|
|
214
|
+
topAnchorTurn: null,
|
|
140
215
|
|
|
141
216
|
registerViewport: viewportRegistry.register,
|
|
142
217
|
registerContentInset: insetRegistry.register,
|
|
143
|
-
|
|
218
|
+
registerViewportElement: (element) =>
|
|
219
|
+
registerElementSlot("viewport", element),
|
|
220
|
+
registerAnchorElement: (element) => registerElementSlot("anchor", element),
|
|
221
|
+
registerAnchorTargetElement: (element, config) => {
|
|
222
|
+
store.setState({
|
|
223
|
+
element: {
|
|
224
|
+
...store.getState().element,
|
|
225
|
+
target: element,
|
|
226
|
+
},
|
|
227
|
+
targetConfig: element && config ? config : null,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return () => {
|
|
231
|
+
if (store.getState().element.target !== element) return;
|
|
232
|
+
store.setState({
|
|
233
|
+
element: {
|
|
234
|
+
...store.getState().element,
|
|
235
|
+
target: null,
|
|
236
|
+
},
|
|
237
|
+
targetConfig: null,
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
setTopAnchorTurn: (topAnchorTurn) => {
|
|
242
|
+
store.setState({ topAnchorTurn });
|
|
243
|
+
},
|
|
144
244
|
}));
|
|
145
245
|
|
|
146
246
|
return store;
|
package/src/index.ts
CHANGED
|
@@ -125,7 +125,6 @@ export type {
|
|
|
125
125
|
// --- external-store ---
|
|
126
126
|
export type { ThreadMessageLike } from "@assistant-ui/core";
|
|
127
127
|
export {
|
|
128
|
-
getExternalStoreMessage,
|
|
129
128
|
getExternalStoreMessages,
|
|
130
129
|
bindExternalStoreMessage,
|
|
131
130
|
} from "@assistant-ui/core";
|
|
@@ -151,25 +150,13 @@ export type {
|
|
|
151
150
|
LocalRuntimeOptionsBase,
|
|
152
151
|
} from "@assistant-ui/core";
|
|
153
152
|
export { useLocalRuntime } from "./legacy-runtime/runtime-cores/local/useLocalRuntime";
|
|
154
|
-
/**
|
|
155
|
-
* @deprecated Use `useLocalRuntime` instead.
|
|
156
|
-
*/
|
|
157
|
-
export { useLocalRuntime as useLocalThreadRuntime } from "./legacy-runtime/runtime-cores/local/useLocalRuntime";
|
|
158
153
|
export type { LocalRuntimeOptions } from "./legacy-runtime/runtime-cores/local/LocalRuntimeOptions";
|
|
159
154
|
|
|
160
155
|
// --- remote-thread-list ---
|
|
161
156
|
export { useRemoteThreadListRuntime } from "./legacy-runtime/runtime-cores/remote-thread-list/useRemoteThreadListRuntime";
|
|
162
|
-
/** @deprecated Use `useRemoteThreadListRuntime` instead. */
|
|
163
|
-
export { useRemoteThreadListRuntime as unstable_useRemoteThreadListRuntime } from "./legacy-runtime/runtime-cores/remote-thread-list/useRemoteThreadListRuntime";
|
|
164
157
|
export { useCloudThreadListAdapter } from "./legacy-runtime/runtime-cores/remote-thread-list/adapter/cloud";
|
|
165
|
-
/** @deprecated Use `useCloudThreadListAdapter` instead. */
|
|
166
|
-
export { useCloudThreadListAdapter as unstable_useCloudThreadListAdapter } from "./legacy-runtime/runtime-cores/remote-thread-list/adapter/cloud";
|
|
167
158
|
export type { RemoteThreadListAdapter } from "@assistant-ui/core";
|
|
168
|
-
/** @deprecated Use `RemoteThreadListAdapter` instead. */
|
|
169
|
-
export type { RemoteThreadListAdapter as unstable_RemoteThreadListAdapter } from "@assistant-ui/core";
|
|
170
159
|
export { InMemoryThreadListAdapter } from "@assistant-ui/core";
|
|
171
|
-
/** @deprecated Use `InMemoryThreadListAdapter` instead. */
|
|
172
|
-
export { InMemoryThreadListAdapter as unstable_InMemoryThreadListAdapter } from "@assistant-ui/core";
|
|
173
160
|
|
|
174
161
|
// Re-export from @assistant-ui/core (runtime-cores root)
|
|
175
162
|
export type { ExportedMessageRepositoryItem } from "@assistant-ui/core";
|
|
@@ -391,25 +378,3 @@ export {
|
|
|
391
378
|
} from "./primitives/composer/trigger";
|
|
392
379
|
|
|
393
380
|
export type { Assistant } from "./augmentations";
|
|
394
|
-
|
|
395
|
-
// Backwards compatibility — deprecated exports, to be removed in v0.13
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* @deprecated Use `useAui` instead. This alias will be removed in v0.13.
|
|
399
|
-
*/
|
|
400
|
-
export { useAui as useAssistantApi } from "@assistant-ui/store";
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* @deprecated Use `useAuiState` instead. This alias will be removed in v0.13.
|
|
404
|
-
*/
|
|
405
|
-
export { useAuiState as useAssistantState } from "@assistant-ui/store";
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* @deprecated Use `useAuiEvent` instead. This alias will be removed in v0.13.
|
|
409
|
-
*/
|
|
410
|
-
export { useAuiEvent as useAssistantEvent } from "@assistant-ui/store";
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* @deprecated Use `AuiIf` instead. This alias will be removed in v0.13.
|
|
414
|
-
*/
|
|
415
|
-
export { AuiIf as AssistantIf } from "@assistant-ui/store";
|
|
@@ -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()) {
|