@assistant-ui/react 0.12.28 → 0.14.2

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 (187) hide show
  1. package/README.md +58 -42
  2. package/dist/client/ExternalThread.d.ts +7 -0
  3. package/dist/client/ExternalThread.d.ts.map +1 -1
  4. package/dist/client/ExternalThread.js +24 -18
  5. package/dist/client/ExternalThread.js.map +1 -1
  6. package/dist/client/InMemoryThreadList.d.ts.map +1 -1
  7. package/dist/client/InMemoryThreadList.js +3 -0
  8. package/dist/client/InMemoryThreadList.js.map +1 -1
  9. package/dist/client/SingleThreadList.d.ts.map +1 -1
  10. package/dist/client/SingleThreadList.js +3 -0
  11. package/dist/client/SingleThreadList.js.map +1 -1
  12. package/dist/context/providers/ThreadViewportProvider.d.ts.map +1 -1
  13. package/dist/context/providers/ThreadViewportProvider.js +2 -10
  14. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  15. package/dist/context/stores/ThreadViewport.d.ts +46 -4
  16. package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
  17. package/dist/context/stores/ThreadViewport.js +51 -7
  18. package/dist/context/stores/ThreadViewport.js.map +1 -1
  19. package/dist/index.d.ts +5 -30
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3 -28
  22. package/dist/index.js.map +1 -1
  23. package/dist/legacy-runtime/cloud/auiV0.d.ts +10 -1
  24. package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
  25. package/dist/legacy-runtime/cloud/auiV0.js +21 -3
  26. package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
  27. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts +1 -1
  28. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts.map +1 -1
  29. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js +1 -1
  30. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js.map +1 -1
  31. package/dist/mcp-apps/McpAppRenderer.d.ts +28 -0
  32. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -0
  33. package/dist/mcp-apps/McpAppRenderer.js +115 -0
  34. package/dist/mcp-apps/McpAppRenderer.js.map +1 -0
  35. package/dist/mcp-apps/McpAppsRemoteHost.d.ts +3 -0
  36. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -0
  37. package/dist/mcp-apps/McpAppsRemoteHost.js +27 -0
  38. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -0
  39. package/dist/mcp-apps/app-frame.d.ts +3 -0
  40. package/dist/mcp-apps/app-frame.d.ts.map +1 -0
  41. package/dist/mcp-apps/app-frame.js +203 -0
  42. package/dist/mcp-apps/app-frame.js.map +1 -0
  43. package/dist/mcp-apps/bridge.d.ts +18 -0
  44. package/dist/mcp-apps/bridge.d.ts.map +1 -0
  45. package/dist/mcp-apps/bridge.js +290 -0
  46. package/dist/mcp-apps/bridge.js.map +1 -0
  47. package/dist/mcp-apps/index.d.ts +4 -0
  48. package/dist/mcp-apps/index.d.ts.map +1 -0
  49. package/dist/mcp-apps/index.js +3 -0
  50. package/dist/mcp-apps/index.js.map +1 -0
  51. package/dist/mcp-apps/types.d.ts +144 -0
  52. package/dist/mcp-apps/types.d.ts.map +1 -0
  53. package/dist/mcp-apps/types.js +3 -0
  54. package/dist/mcp-apps/types.js.map +1 -0
  55. package/dist/mcp-apps/utils.d.ts +5 -0
  56. package/dist/mcp-apps/utils.d.ts.map +1 -0
  57. package/dist/mcp-apps/utils.js +10 -0
  58. package/dist/mcp-apps/utils.js.map +1 -0
  59. package/dist/primitives/composer/ComposerInput.d.ts +6 -0
  60. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  61. package/dist/primitives/composer/ComposerInput.js +28 -6
  62. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  63. package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
  64. package/dist/primitives/composer/trigger/TriggerPopover.js +17 -1
  65. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  66. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +33 -0
  67. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts.map +1 -1
  68. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +80 -11
  69. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
  70. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  71. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +2 -1
  72. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  73. package/dist/primitives/message/MessageRoot.d.ts +6 -30
  74. package/dist/primitives/message/MessageRoot.d.ts.map +1 -1
  75. package/dist/primitives/message/MessageRoot.js +68 -25
  76. package/dist/primitives/message/MessageRoot.js.map +1 -1
  77. package/dist/primitives/messagePart/useMessagePartSource.d.ts +22 -3
  78. package/dist/primitives/messagePart/useMessagePartSource.d.ts.map +1 -1
  79. package/dist/primitives/thread/ThreadViewport.d.ts +38 -0
  80. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  81. package/dist/primitives/thread/ThreadViewport.js +53 -5
  82. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  83. package/dist/primitives/thread/ThreadViewportFooter.d.ts +2 -1
  84. package/dist/primitives/thread/ThreadViewportFooter.d.ts.map +1 -1
  85. package/dist/primitives/thread/ThreadViewportFooter.js +2 -1
  86. package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
  87. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.d.ts +22 -0
  88. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.d.ts.map +1 -0
  89. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js +53 -0
  90. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js.map +1 -0
  91. package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts +5 -0
  92. package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts.map +1 -0
  93. package/dist/primitives/thread/topAnchor/createReserveObservers.js +38 -0
  94. package/dist/primitives/thread/topAnchor/createReserveObservers.js.map +1 -0
  95. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts +22 -0
  96. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts.map +1 -0
  97. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js +75 -0
  98. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js.map +1 -0
  99. package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts +15 -0
  100. package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts.map +1 -0
  101. package/dist/primitives/thread/topAnchor/topAnchorTurn.js +13 -0
  102. package/dist/primitives/thread/topAnchor/topAnchorTurn.js.map +1 -0
  103. package/dist/primitives/thread/topAnchor/topAnchorUtils.d.ts +15 -0
  104. package/dist/primitives/thread/topAnchor/topAnchorUtils.d.ts.map +1 -0
  105. package/dist/primitives/thread/topAnchor/topAnchorUtils.js +51 -0
  106. package/dist/primitives/thread/topAnchor/topAnchorUtils.js.map +1 -0
  107. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.d.ts +7 -0
  108. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.d.ts.map +1 -0
  109. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +18 -0
  110. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -0
  111. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  112. package/dist/primitives/thread/useThreadViewportAutoScroll.js +13 -1
  113. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  114. package/dist/primitives/thread.d.ts +0 -1
  115. package/dist/primitives/thread.d.ts.map +1 -1
  116. package/dist/primitives/thread.js +0 -1
  117. package/dist/primitives/thread.js.map +1 -1
  118. package/dist/primitives/threadList/ThreadListLoadMore.d.ts +13 -0
  119. package/dist/primitives/threadList/ThreadListLoadMore.d.ts.map +1 -0
  120. package/dist/primitives/threadList/ThreadListLoadMore.js +11 -0
  121. package/dist/primitives/threadList/ThreadListLoadMore.js.map +1 -0
  122. package/dist/primitives/threadList.d.ts +1 -0
  123. package/dist/primitives/threadList.d.ts.map +1 -1
  124. package/dist/primitives/threadList.js +1 -0
  125. package/dist/primitives/threadList.js.map +1 -1
  126. package/dist/utils/hooks/useManagedRef.d.ts.map +1 -1
  127. package/dist/utils/hooks/useManagedRef.js +1 -0
  128. package/dist/utils/hooks/useManagedRef.js.map +1 -1
  129. package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
  130. package/dist/utils/hooks/useOnResizeContent.js +1 -2
  131. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  132. package/package.json +13 -13
  133. package/src/client/ExternalThread.ts +32 -19
  134. package/src/client/InMemoryThreadList.ts +3 -0
  135. package/src/client/SingleThreadList.ts +3 -0
  136. package/src/context/providers/ThreadViewportProvider.tsx +2 -12
  137. package/src/context/stores/ThreadViewport.ts +111 -11
  138. package/src/index.ts +20 -34
  139. package/src/legacy-runtime/cloud/auiV0.ts +37 -4
  140. package/src/legacy-runtime/runtime-cores/assistant-transport/utils.ts +1 -5
  141. package/src/mcp-apps/McpAppRenderer.tsx +215 -0
  142. package/src/mcp-apps/McpAppsRemoteHost.ts +52 -0
  143. package/src/mcp-apps/app-frame.tsx +280 -0
  144. package/src/mcp-apps/bridge.test.ts +391 -0
  145. package/src/mcp-apps/bridge.ts +435 -0
  146. package/src/mcp-apps/index.ts +16 -0
  147. package/src/mcp-apps/types.ts +158 -0
  148. package/src/mcp-apps/utils.ts +16 -0
  149. package/src/primitives/composer/ComposerInput.test.tsx +280 -0
  150. package/src/primitives/composer/ComposerInput.tsx +29 -6
  151. package/src/primitives/composer/trigger/TriggerPopover.tsx +21 -1
  152. package/src/primitives/composer/trigger/TriggerPopoverRootContext.test.tsx +152 -0
  153. package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +134 -17
  154. package/src/primitives/composer/trigger/triggerKeyboardResource.test.ts +236 -0
  155. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +2 -1
  156. package/src/primitives/message/MessageRoot.tsx +135 -57
  157. package/src/primitives/thread/ThreadViewport.tsx +95 -4
  158. package/src/primitives/thread/ThreadViewportFooter.tsx +2 -1
  159. package/src/primitives/thread/topAnchor/computeTopAnchorSlack.test.ts +131 -0
  160. package/src/primitives/thread/topAnchor/computeTopAnchorSlack.ts +94 -0
  161. package/src/primitives/thread/topAnchor/createReserveObservers.ts +50 -0
  162. package/src/primitives/thread/topAnchor/mountTopAnchorReserve.test.ts +131 -0
  163. package/src/primitives/thread/topAnchor/mountTopAnchorReserve.ts +127 -0
  164. package/src/primitives/thread/topAnchor/topAnchorTurn.test.ts +46 -0
  165. package/src/primitives/thread/topAnchor/topAnchorTurn.ts +30 -0
  166. package/src/primitives/thread/topAnchor/topAnchorUtils.ts +58 -0
  167. package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +19 -0
  168. package/src/primitives/thread/useThreadViewportAutoScroll.ts +15 -1
  169. package/src/primitives/thread.ts +0 -1
  170. package/src/primitives/threadList/ThreadListLoadMore.tsx +24 -0
  171. package/src/primitives/threadList.ts +1 -0
  172. package/src/tests/BaseComposerRuntimeCore.test.ts +4 -0
  173. package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +138 -0
  174. package/src/tests/RemoteThreadListRuntime.deferredProvider.test.tsx +28 -17
  175. package/src/tests/auiV0Encode.test.ts +55 -0
  176. package/src/utils/hooks/useManagedRef.ts +1 -0
  177. package/src/utils/hooks/useOnResizeContent.ts +1 -2
  178. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.d.ts +0 -3
  179. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.d.ts.map +0 -1
  180. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js +0 -3
  181. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js.map +0 -1
  182. package/dist/primitives/thread/ThreadViewportSlack.d.ts +0 -20
  183. package/dist/primitives/thread/ThreadViewportSlack.d.ts.map +0 -1
  184. package/dist/primitives/thread/ThreadViewportSlack.js +0 -80
  185. package/dist/primitives/thread/ThreadViewportSlack.js.map +0 -1
  186. package/src/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.ts +0 -6
  187. package/src/primitives/thread/ThreadViewportSlack.tsx +0 -116
@@ -29,17 +29,43 @@ export type TriggerPopoverLifecycleListener = {
29
29
  removed(char: string): void;
30
30
  };
31
31
 
32
+ /**
33
+ * ARIA descriptor of the popover that is currently open. Consumed by the
34
+ * focused element (typically the composer textarea) so it can advertise the
35
+ * combobox relationship per the WAI-ARIA editable combobox pattern.
36
+ */
37
+ export type TriggerPopoverActiveAria = {
38
+ popoverId: string;
39
+ highlightedItemId: string | undefined;
40
+ };
41
+
32
42
  export type TriggerPopoverRootContextValue = {
33
43
  register(trigger: RegisteredTrigger): () => void;
34
44
  getTriggers(): ReadonlyMap<string, RegisteredTrigger>;
35
45
  subscribe(listener: () => void): () => void;
36
46
  /** Subscribe to per-trigger add/remove events. */
37
47
  subscribeLifecycle(listener: TriggerPopoverLifecycleListener): () => void;
48
+ /** ARIA descriptor of the open popover, or null if none is open. */
49
+ getActiveAria(): TriggerPopoverActiveAria | null;
50
+ /** Subscribe to changes in the active ARIA descriptor. */
51
+ subscribeAria(listener: () => void): () => void;
52
+ };
53
+
54
+ /**
55
+ * Write-side of the ARIA descriptor, scoped to `TriggerPopover` children of a
56
+ * `TriggerPopoverRoot`. Intentionally not exposed on the public root context
57
+ * value: external consumers can read ARIA state but cannot publish or clear it.
58
+ */
59
+ type TriggerPopoverAriaPublish = {
60
+ setActiveAria(char: string, aria: TriggerPopoverActiveAria | null): void;
38
61
  };
39
62
 
40
63
  const TriggerPopoverRootContext =
41
64
  createContext<TriggerPopoverRootContextValue | null>(null);
42
65
 
66
+ const TriggerPopoverAriaPublishContext =
67
+ createContext<TriggerPopoverAriaPublish | null>(null);
68
+
43
69
  export const useTriggerPopoverRootContext = () => {
44
70
  const ctx = useContext(TriggerPopoverRootContext);
45
71
  if (!ctx)
@@ -52,6 +78,19 @@ export const useTriggerPopoverRootContext = () => {
52
78
  export const useTriggerPopoverRootContextOptional = () =>
53
79
  useContext(TriggerPopoverRootContext);
54
80
 
81
+ /**
82
+ * Internal hook used by `TriggerPopover` children to publish their open and
83
+ * highlight state. Not exported from the trigger module.
84
+ */
85
+ export const useTriggerPopoverAriaPublish = (): TriggerPopoverAriaPublish => {
86
+ const ctx = useContext(TriggerPopoverAriaPublishContext);
87
+ if (!ctx)
88
+ throw new Error(
89
+ "useTriggerPopoverAriaPublish must be used within ComposerPrimitive.TriggerPopoverRoot",
90
+ );
91
+ return ctx;
92
+ };
93
+
55
94
  /**
56
95
  * Live map of registered triggers, re-rendering on change. Prefer
57
96
  * `subscribeLifecycle` for incremental add/remove handling.
@@ -75,24 +114,57 @@ export const useTriggerPopoverTriggersOptional = () => {
75
114
  );
76
115
  };
77
116
 
117
+ const getNullAria = () => null;
118
+
119
+ /**
120
+ * Returns the ARIA descriptor of the currently open trigger popover, or
121
+ * `null` if none is open or the consumer is rendered outside a
122
+ * `TriggerPopoverRoot`.
123
+ */
124
+ export const useTriggerPopoverActiveAriaOptional =
125
+ (): TriggerPopoverActiveAria | null => {
126
+ const ctx = useTriggerPopoverRootContextOptional();
127
+ return useSyncExternalStore(
128
+ ctx ? ctx.subscribeAria : noopSubscribe,
129
+ ctx ? ctx.getActiveAria : getNullAria,
130
+ ctx ? ctx.getActiveAria : getNullAria,
131
+ );
132
+ };
133
+
78
134
  export namespace ComposerPrimitiveTriggerPopoverRoot {
79
135
  export type Props = {
80
136
  children: ReactNode;
81
137
  };
82
138
  }
83
139
 
140
+ /**
141
+ * Local helper for the simple "notify-all listeners" subscribable pattern.
142
+ * Used twice in this file (trigger registry, active ARIA); kept inline to
143
+ * avoid pulling a single-use abstraction into the wider tree.
144
+ */
145
+ function useSimpleSubscribable() {
146
+ const listenersRef = useRef<Set<() => void>>(new Set());
147
+ const notify = useCallback(() => {
148
+ for (const listener of listenersRef.current) listener();
149
+ }, []);
150
+ const subscribe = useCallback((listener: () => void) => {
151
+ listenersRef.current.add(listener);
152
+ return () => {
153
+ listenersRef.current.delete(listener);
154
+ };
155
+ }, []);
156
+ return { notify, subscribe };
157
+ }
158
+
84
159
  const TriggerPopoverRootInner: FC<
85
160
  ComposerPrimitiveTriggerPopoverRoot.Props
86
161
  > = ({ children }) => {
87
162
  const triggersRef = useRef<ReadonlyMap<string, RegisteredTrigger>>(new Map());
88
- const listenersRef = useRef<Set<() => void>>(new Set());
89
163
  const lifecycleListenersRef = useRef<Set<TriggerPopoverLifecycleListener>>(
90
164
  new Set(),
91
165
  );
92
166
 
93
- const notify = useCallback(() => {
94
- for (const listener of listenersRef.current) listener();
95
- }, []);
167
+ const { notify, subscribe } = useSimpleSubscribable();
96
168
 
97
169
  const register = useCallback<TriggerPopoverRootContextValue["register"]>(
98
170
  (trigger) => {
@@ -137,16 +209,6 @@ const TriggerPopoverRootInner: FC<
137
209
  TriggerPopoverRootContextValue["getTriggers"]
138
210
  >(() => triggersRef.current, []);
139
211
 
140
- const subscribe = useCallback<TriggerPopoverRootContextValue["subscribe"]>(
141
- (listener) => {
142
- listenersRef.current.add(listener);
143
- return () => {
144
- listenersRef.current.delete(listener);
145
- };
146
- },
147
- [],
148
- );
149
-
150
212
  const subscribeLifecycle = useCallback<
151
213
  TriggerPopoverRootContextValue["subscribeLifecycle"]
152
214
  >((listener) => {
@@ -156,14 +218,69 @@ const TriggerPopoverRootInner: FC<
156
218
  };
157
219
  }, []);
158
220
 
221
+ const activeAriaRef = useRef<TriggerPopoverActiveAria | null>(null);
222
+ const activeAriaCharRef = useRef<string | null>(null);
223
+ const { notify: notifyAria, subscribe: subscribeAria } =
224
+ useSimpleSubscribable();
225
+
226
+ const setActiveAria = useCallback<TriggerPopoverAriaPublish["setActiveAria"]>(
227
+ (char, aria) => {
228
+ if (aria === null) {
229
+ if (activeAriaCharRef.current !== char) return;
230
+ activeAriaRef.current = null;
231
+ activeAriaCharRef.current = null;
232
+ notifyAria();
233
+ return;
234
+ }
235
+ const prev = activeAriaRef.current;
236
+ if (
237
+ activeAriaCharRef.current === char &&
238
+ prev !== null &&
239
+ prev.popoverId === aria.popoverId &&
240
+ prev.highlightedItemId === aria.highlightedItemId
241
+ ) {
242
+ return;
243
+ }
244
+ activeAriaRef.current = aria;
245
+ activeAriaCharRef.current = char;
246
+ notifyAria();
247
+ },
248
+ [notifyAria],
249
+ );
250
+
251
+ const getActiveAria = useCallback<
252
+ TriggerPopoverRootContextValue["getActiveAria"]
253
+ >(() => activeAriaRef.current, []);
254
+
159
255
  const value = useMemo<TriggerPopoverRootContextValue>(
160
- () => ({ register, getTriggers, subscribe, subscribeLifecycle }),
161
- [register, getTriggers, subscribe, subscribeLifecycle],
256
+ () => ({
257
+ register,
258
+ getTriggers,
259
+ subscribe,
260
+ subscribeLifecycle,
261
+ getActiveAria,
262
+ subscribeAria,
263
+ }),
264
+ [
265
+ register,
266
+ getTriggers,
267
+ subscribe,
268
+ subscribeLifecycle,
269
+ getActiveAria,
270
+ subscribeAria,
271
+ ],
272
+ );
273
+
274
+ const ariaPublishValue = useMemo<TriggerPopoverAriaPublish>(
275
+ () => ({ setActiveAria }),
276
+ [setActiveAria],
162
277
  );
163
278
 
164
279
  return (
165
280
  <TriggerPopoverRootContext.Provider value={value}>
166
- {children}
281
+ <TriggerPopoverAriaPublishContext.Provider value={ariaPublishValue}>
282
+ {children}
283
+ </TriggerPopoverAriaPublishContext.Provider>
167
284
  </TriggerPopoverRootContext.Provider>
168
285
  );
169
286
  };
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createResourceRoot } from "@assistant-ui/tap";
3
+ import type {
4
+ Unstable_TriggerCategory,
5
+ Unstable_TriggerItem,
6
+ } from "@assistant-ui/core";
7
+ import { TriggerKeyboardResource } from "./triggerKeyboardResource";
8
+
9
+ const item = (id: string): Unstable_TriggerItem => ({
10
+ id,
11
+ type: "command",
12
+ label: id,
13
+ });
14
+
15
+ const category = (id: string): Unstable_TriggerCategory => ({
16
+ id,
17
+ label: id,
18
+ });
19
+
20
+ const makeKeyEvent = (key: string, shiftKey = false) => ({
21
+ key,
22
+ shiftKey,
23
+ preventDefault: vi.fn(),
24
+ });
25
+
26
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
27
+
28
+ const render = (
29
+ overrides: Partial<Parameters<typeof TriggerKeyboardResource>[0]> = {},
30
+ ) => {
31
+ const props = {
32
+ navigableList: [item("a"), item("b"), item("c")],
33
+ isSearchMode: false,
34
+ activeCategoryId: null as string | null,
35
+ query: "",
36
+ popoverId: "popover",
37
+ open: true,
38
+ selectItem: vi.fn(),
39
+ selectCategory: vi.fn(),
40
+ goBack: vi.fn(),
41
+ close: vi.fn(),
42
+ ...overrides,
43
+ };
44
+ const root = createResourceRoot();
45
+ const sub = root.render(TriggerKeyboardResource(props));
46
+ return { sub, props };
47
+ };
48
+
49
+ describe("TriggerKeyboardResource", () => {
50
+ it("selects highlighted item on Tab", () => {
51
+ const { sub, props } = render();
52
+ const e = makeKeyEvent("Tab");
53
+
54
+ const consumed = sub.getValue().handleKeyDown(e);
55
+
56
+ expect(consumed).toBe(true);
57
+ expect(e.preventDefault).toHaveBeenCalled();
58
+ expect(props.selectItem).toHaveBeenCalledWith(props.navigableList[0]);
59
+ expect(props.selectCategory).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it("selects category on Tab when entry is a category", () => {
63
+ const { sub, props } = render({
64
+ navigableList: [category("cat-1"), item("b")],
65
+ });
66
+
67
+ const consumed = sub.getValue().handleKeyDown(makeKeyEvent("Tab"));
68
+
69
+ expect(consumed).toBe(true);
70
+ expect(props.selectCategory).toHaveBeenCalledWith("cat-1");
71
+ expect(props.selectItem).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("lets Shift+Tab pass through for native focus traversal", () => {
75
+ const { sub, props } = render();
76
+ const e = makeKeyEvent("Tab", true);
77
+
78
+ const consumed = sub.getValue().handleKeyDown(e);
79
+
80
+ expect(consumed).toBe(false);
81
+ expect(e.preventDefault).not.toHaveBeenCalled();
82
+ expect(props.selectItem).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it("swallows Tab when the navigable list is empty", () => {
86
+ const { sub, props } = render({ navigableList: [] });
87
+ const e = makeKeyEvent("Tab");
88
+
89
+ const consumed = sub.getValue().handleKeyDown(e);
90
+
91
+ expect(consumed).toBe(true);
92
+ expect(e.preventDefault).toHaveBeenCalled();
93
+ expect(props.selectItem).not.toHaveBeenCalled();
94
+ expect(props.selectCategory).not.toHaveBeenCalled();
95
+ });
96
+
97
+ it("does nothing when the popover is closed", () => {
98
+ const { sub, props } = render({ open: false });
99
+ const e = makeKeyEvent("Tab");
100
+
101
+ const consumed = sub.getValue().handleKeyDown(e);
102
+
103
+ expect(consumed).toBe(false);
104
+ expect(e.preventDefault).not.toHaveBeenCalled();
105
+ expect(props.selectItem).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it("moves the highlight forward on ArrowDown", async () => {
109
+ const { sub } = render();
110
+
111
+ const consumed = sub.getValue().handleKeyDown(makeKeyEvent("ArrowDown"));
112
+ await tick();
113
+
114
+ expect(consumed).toBe(true);
115
+ expect(sub.getValue().highlightedIndex).toBe(1);
116
+ });
117
+
118
+ it("wraps the highlight to the top on ArrowDown past the last entry", async () => {
119
+ const { sub } = render();
120
+ const handle = sub.getValue().handleKeyDown;
121
+
122
+ handle(makeKeyEvent("ArrowDown"));
123
+ handle(makeKeyEvent("ArrowDown"));
124
+ handle(makeKeyEvent("ArrowDown"));
125
+ await tick();
126
+
127
+ expect(sub.getValue().highlightedIndex).toBe(0);
128
+ });
129
+
130
+ it("moves the highlight backward on ArrowUp", async () => {
131
+ const { sub } = render();
132
+ const handle = sub.getValue().handleKeyDown;
133
+
134
+ handle(makeKeyEvent("ArrowDown"));
135
+ await tick();
136
+ handle(makeKeyEvent("ArrowUp"));
137
+ await tick();
138
+
139
+ expect(sub.getValue().highlightedIndex).toBe(0);
140
+ });
141
+
142
+ it("wraps the highlight to the bottom on ArrowUp from the first entry", async () => {
143
+ const { sub } = render();
144
+
145
+ sub.getValue().handleKeyDown(makeKeyEvent("ArrowUp"));
146
+ await tick();
147
+
148
+ expect(sub.getValue().highlightedIndex).toBe(2);
149
+ });
150
+
151
+ it("keeps the highlight at 0 on ArrowDown when navigableList is empty", async () => {
152
+ const { sub } = render({ navigableList: [] });
153
+ const e = makeKeyEvent("ArrowDown");
154
+
155
+ const consumed = sub.getValue().handleKeyDown(e);
156
+ await tick();
157
+
158
+ expect(consumed).toBe(true);
159
+ expect(e.preventDefault).toHaveBeenCalled();
160
+ expect(sub.getValue().highlightedIndex).toBe(0);
161
+ });
162
+
163
+ it("selects the highlighted item on Enter", () => {
164
+ const { sub, props } = render();
165
+ const e = makeKeyEvent("Enter");
166
+
167
+ const consumed = sub.getValue().handleKeyDown(e);
168
+
169
+ expect(consumed).toBe(true);
170
+ expect(e.preventDefault).toHaveBeenCalled();
171
+ expect(props.selectItem).toHaveBeenCalledWith(props.navigableList[0]);
172
+ });
173
+
174
+ it("lets Shift+Enter pass through for newline insertion", () => {
175
+ const { sub, props } = render();
176
+ const e = makeKeyEvent("Enter", true);
177
+
178
+ const consumed = sub.getValue().handleKeyDown(e);
179
+
180
+ expect(consumed).toBe(false);
181
+ expect(e.preventDefault).not.toHaveBeenCalled();
182
+ expect(props.selectItem).not.toHaveBeenCalled();
183
+ });
184
+
185
+ it("closes the popover on Escape", () => {
186
+ const { sub, props } = render();
187
+ const e = makeKeyEvent("Escape");
188
+
189
+ const consumed = sub.getValue().handleKeyDown(e);
190
+
191
+ expect(consumed).toBe(true);
192
+ expect(e.preventDefault).toHaveBeenCalled();
193
+ expect(props.close).toHaveBeenCalled();
194
+ });
195
+
196
+ it("drills back on Backspace when a category is active and the query is empty", () => {
197
+ const { sub, props } = render({
198
+ activeCategoryId: "cat-1",
199
+ query: "",
200
+ });
201
+ const e = makeKeyEvent("Backspace");
202
+
203
+ const consumed = sub.getValue().handleKeyDown(e);
204
+
205
+ expect(consumed).toBe(true);
206
+ expect(e.preventDefault).toHaveBeenCalled();
207
+ expect(props.goBack).toHaveBeenCalled();
208
+ });
209
+
210
+ it("lets Backspace pass through when the query is non-empty", () => {
211
+ const { sub, props } = render({
212
+ activeCategoryId: "cat-1",
213
+ query: "foo",
214
+ });
215
+ const e = makeKeyEvent("Backspace");
216
+
217
+ const consumed = sub.getValue().handleKeyDown(e);
218
+
219
+ expect(consumed).toBe(false);
220
+ expect(e.preventDefault).not.toHaveBeenCalled();
221
+ expect(props.goBack).not.toHaveBeenCalled();
222
+ });
223
+
224
+ it("lets Backspace pass through when no category is active", () => {
225
+ const { sub, props } = render({
226
+ activeCategoryId: null,
227
+ query: "",
228
+ });
229
+ const e = makeKeyEvent("Backspace");
230
+
231
+ const consumed = sub.getValue().handleKeyDown(e);
232
+
233
+ expect(consumed).toBe(false);
234
+ expect(props.goBack).not.toHaveBeenCalled();
235
+ });
236
+ });
@@ -103,7 +103,8 @@ export const TriggerKeyboardResource = resource(
103
103
  });
104
104
  return true;
105
105
  }
106
- case "Enter": {
106
+ case "Enter":
107
+ case "Tab": {
107
108
  if (e.shiftKey) return false;
108
109
  e.preventDefault();
109
110
  const item = navigableList[highlightedIndex];
@@ -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 { useThreadViewport } from "../../context/react/ThreadViewportContext";
15
- import { ThreadPrimitiveViewportSlack } from "../thread/ThreadViewportSlack";
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
- * Hook that registers the anchor user message as a content inset.
52
- * Only registers if: user message, at index messages.length-2, and last message is assistant.
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
- const getHeight = useCallback((el: HTMLElement) => el.offsetHeight, []);
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
- return useSizeHandle(
73
- shouldRegisterAsInset ? registerUserHeight : null,
74
- getHeight,
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 scroll anchor if it's the last user message.
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
- >(({ fillClampThreshold, fillClampOffset, ...props }, forwardRef) => {
123
- const isHoveringRef = useIsHoveringRef();
124
- const anchorUserMessageRef = useMessageViewportRef();
125
- const ref = useComposedRefs<HTMLDivElement>(
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
- return (
133
- <ThreadPrimitiveViewportSlack
134
- fillClampThreshold={fillClampThreshold}
135
- fillClampOffset={fillClampOffset}
136
- >
137
- <Primitive.div {...props} ref={ref} data-message-id={messageId} />
138
- </ThreadPrimitiveViewportSlack>
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";