@assistant-ui/react 0.15.31 → 0.15.33

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 (228) hide show
  1. package/dist/_virtual/_rolldown/runtime.js +1 -2
  2. package/dist/adapters/GeminiLiveVoiceAdapter.d.ts +26 -0
  3. package/dist/adapters/GeminiLiveVoiceAdapter.d.ts.map +1 -0
  4. package/dist/adapters/GeminiLiveVoiceAdapter.js +346 -0
  5. package/dist/adapters/GeminiLiveVoiceAdapter.js.map +1 -0
  6. package/dist/adapters/OpenAIRealtimeVoiceAdapter.js +1 -2
  7. package/dist/adapters/gemini-live/audio.d.ts +27 -0
  8. package/dist/adapters/gemini-live/audio.d.ts.map +1 -0
  9. package/dist/adapters/gemini-live/audio.js +291 -0
  10. package/dist/adapters/gemini-live/audio.js.map +1 -0
  11. package/dist/assistant-transport.js +1 -2
  12. package/dist/cloud-renderer/CloudRendererHost.js +1 -2
  13. package/dist/context/ReadonlyStore.js +1 -2
  14. package/dist/context/providers/MessageProvider.js +1 -2
  15. package/dist/context/providers/ThreadViewportProvider.js +1 -2
  16. package/dist/context/react/ThreadViewportContext.js +1 -2
  17. package/dist/context/react/utils/createContextHook.js +1 -2
  18. package/dist/context/react/utils/createContextStoreHook.js +1 -2
  19. package/dist/context/stores/ThreadViewport.js +2 -3
  20. package/dist/devtools/DevToolsHooks.js +1 -2
  21. package/dist/hooks/useMessageQuote.js +1 -2
  22. package/dist/hooks/useMessageTiming.js +1 -2
  23. package/dist/hooks/useToolCallElapsed.js +1 -2
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -1
  27. package/dist/internal.js +2 -3
  28. package/dist/legacy-runtime/AssistantRuntimeProvider.js +1 -2
  29. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  30. package/dist/mcp-apps/McpAppRenderer.js +4 -4
  31. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  32. package/dist/mcp-apps/McpAppsRemoteHost.js +1 -2
  33. package/dist/mcp-apps/app-frame.js +2 -3
  34. package/dist/mcp-apps/bridge.js +1 -2
  35. package/dist/mcp-apps/csp.js +1 -2
  36. package/dist/mcp-apps/types.js +1 -2
  37. package/dist/mcp-apps/utils.js +1 -2
  38. package/dist/model-context/frame/useAssistantFrameHost.js +1 -2
  39. package/dist/model-context/makeAssistantVisible.js +1 -2
  40. package/dist/primitives/actionBar/ActionBarCopy.js +1 -2
  41. package/dist/primitives/actionBar/ActionBarEdit.js +1 -2
  42. package/dist/primitives/actionBar/ActionBarExportMarkdown.js +1 -2
  43. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +1 -2
  44. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +1 -2
  45. package/dist/primitives/actionBar/ActionBarInteractionContext.js +1 -2
  46. package/dist/primitives/actionBar/ActionBarReload.js +1 -2
  47. package/dist/primitives/actionBar/ActionBarRoot.js +1 -2
  48. package/dist/primitives/actionBar/ActionBarSpeak.js +1 -2
  49. package/dist/primitives/actionBar/ActionBarStopSpeaking.js +1 -2
  50. package/dist/primitives/actionBar/useActionBarFloatStatus.js +1 -2
  51. package/dist/primitives/actionBar.js +1 -2
  52. package/dist/primitives/actionBarMore/ActionBarMoreContent.js +1 -2
  53. package/dist/primitives/actionBarMore/ActionBarMoreItem.js +1 -2
  54. package/dist/primitives/actionBarMore/ActionBarMoreRoot.js +1 -2
  55. package/dist/primitives/actionBarMore/ActionBarMoreSeparator.js +1 -2
  56. package/dist/primitives/actionBarMore/ActionBarMoreTrigger.js +1 -2
  57. package/dist/primitives/actionBarMore/scope.js +1 -2
  58. package/dist/primitives/actionBarMore.js +1 -2
  59. package/dist/primitives/assistantModal/AssistantModalAnchor.js +1 -2
  60. package/dist/primitives/assistantModal/AssistantModalContent.js +1 -2
  61. package/dist/primitives/assistantModal/AssistantModalRoot.js +1 -2
  62. package/dist/primitives/assistantModal/AssistantModalTrigger.js +1 -2
  63. package/dist/primitives/assistantModal/popoverRenderPrimitives.js +1 -2
  64. package/dist/primitives/assistantModal/scope.js +1 -2
  65. package/dist/primitives/assistantModal.js +1 -2
  66. package/dist/primitives/attachment/AttachmentName.js +1 -2
  67. package/dist/primitives/attachment/AttachmentRemove.js +1 -2
  68. package/dist/primitives/attachment/AttachmentRoot.js +1 -2
  69. package/dist/primitives/attachment/AttachmentThumb.js +1 -2
  70. package/dist/primitives/attachment.js +1 -2
  71. package/dist/primitives/branchPicker/BranchPickerCount.js +1 -2
  72. package/dist/primitives/branchPicker/BranchPickerNext.js +1 -2
  73. package/dist/primitives/branchPicker/BranchPickerNumber.js +1 -2
  74. package/dist/primitives/branchPicker/BranchPickerPrevious.js +1 -2
  75. package/dist/primitives/branchPicker/BranchPickerRoot.js +1 -2
  76. package/dist/primitives/branchPicker.js +1 -2
  77. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.d.ts +5 -4
  78. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.d.ts.map +1 -1
  79. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js +57 -20
  80. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js.map +1 -1
  81. package/dist/primitives/chainOfThought/ChainOfThoughtRoot.js +1 -2
  82. package/dist/primitives/chainOfThought.js +1 -2
  83. package/dist/primitives/composer/ComposerAddAttachment.js +1 -2
  84. package/dist/primitives/composer/ComposerAttachmentDropzone.js +1 -2
  85. package/dist/primitives/composer/ComposerCancel.js +1 -2
  86. package/dist/primitives/composer/ComposerCompactContext.js +1 -2
  87. package/dist/primitives/composer/ComposerDictate.js +1 -2
  88. package/dist/primitives/composer/ComposerDictationTranscript.js +1 -2
  89. package/dist/primitives/composer/ComposerInput.js +1 -2
  90. package/dist/primitives/composer/ComposerInputPluginContext.js +1 -2
  91. package/dist/primitives/composer/ComposerQuote.js +1 -2
  92. package/dist/primitives/composer/ComposerResume.js +1 -2
  93. package/dist/primitives/composer/ComposerRoot.js +1 -2
  94. package/dist/primitives/composer/ComposerSend.js +1 -2
  95. package/dist/primitives/composer/ComposerStopDictation.js +1 -2
  96. package/dist/primitives/composer/composerInputSelector.js +1 -2
  97. package/dist/primitives/composer/trigger/TriggerPopover.js +1 -2
  98. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +1 -2
  99. package/dist/primitives/composer/trigger/TriggerPopoverBack.js +1 -2
  100. package/dist/primitives/composer/trigger/TriggerPopoverCategories.js +1 -2
  101. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +1 -2
  102. package/dist/primitives/composer/trigger/TriggerPopoverItems.js +1 -2
  103. package/dist/primitives/composer/trigger/TriggerPopoverResource.js +1 -2
  104. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +1 -2
  105. package/dist/primitives/composer/trigger/detectTrigger.js +1 -2
  106. package/dist/primitives/composer/trigger/index.js +1 -2
  107. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +1 -2
  108. package/dist/primitives/composer/trigger/triggerDetectionResource.js +1 -2
  109. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +1 -2
  110. package/dist/primitives/composer/trigger/triggerNavigationResource.js +1 -2
  111. package/dist/primitives/composer/trigger/triggerSelectionResource.js +1 -2
  112. package/dist/primitives/composer/useComposerCancelWithFocus.js +1 -2
  113. package/dist/primitives/composer/useComposerInputState.js +1 -2
  114. package/dist/primitives/composer.js +1 -2
  115. package/dist/primitives/dropdownMenuRenderPrimitives.js +1 -2
  116. package/dist/primitives/error/ErrorMessage.js +1 -2
  117. package/dist/primitives/error/ErrorRoot.js +1 -2
  118. package/dist/primitives/error.js +1 -2
  119. package/dist/primitives/message/MessageError.js +1 -2
  120. package/dist/primitives/message/MessageIf.js +1 -2
  121. package/dist/primitives/message/MessageParts.js +1 -2
  122. package/dist/primitives/message/MessagePartsGrouped.js +2 -3
  123. package/dist/primitives/message/MessageRoot.js +1 -2
  124. package/dist/primitives/message.js +1 -2
  125. package/dist/primitives/messagePart/MessagePartImage.js +1 -2
  126. package/dist/primitives/messagePart/MessagePartText.js +1 -2
  127. package/dist/primitives/messagePart/useMessagePartData.js +1 -2
  128. package/dist/primitives/messagePart/useMessagePartFile.js +1 -2
  129. package/dist/primitives/messagePart/useMessagePartImage.js +1 -2
  130. package/dist/primitives/messagePart/useMessagePartReasoning.js +1 -2
  131. package/dist/primitives/messagePart/useMessagePartSource.js +1 -2
  132. package/dist/primitives/messagePart/useMessagePartText.js +1 -2
  133. package/dist/primitives/messagePart.js +1 -2
  134. package/dist/primitives/queueItem/QueueItemRemove.js +1 -2
  135. package/dist/primitives/queueItem/QueueItemSteer.js +1 -2
  136. package/dist/primitives/queueItem/QueueItemText.js +1 -2
  137. package/dist/primitives/queueItem.js +1 -2
  138. package/dist/primitives/reasoning/useScrollLock.d.ts.map +1 -1
  139. package/dist/primitives/reasoning/useScrollLock.js +41 -25
  140. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  141. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js +1 -2
  142. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +1 -2
  143. package/dist/primitives/selectionToolbar.js +1 -2
  144. package/dist/primitives/suggestion/SuggestionDescription.js +1 -2
  145. package/dist/primitives/suggestion/SuggestionTitle.js +1 -2
  146. package/dist/primitives/suggestion/SuggestionTrigger.js +1 -2
  147. package/dist/primitives/suggestion.js +1 -2
  148. package/dist/primitives/thread/ThreadEmpty.js +1 -2
  149. package/dist/primitives/thread/ThreadIf.js +1 -2
  150. package/dist/primitives/thread/ThreadLoadEarlier.js +1 -2
  151. package/dist/primitives/thread/ThreadRoot.js +1 -2
  152. package/dist/primitives/thread/ThreadRootElementContext.js +1 -2
  153. package/dist/primitives/thread/ThreadScrollToBottom.js +1 -2
  154. package/dist/primitives/thread/ThreadSuggestion.js +1 -2
  155. package/dist/primitives/thread/ThreadViewport.js +1 -2
  156. package/dist/primitives/thread/ThreadViewportFooter.js +1 -2
  157. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js +1 -2
  158. package/dist/primitives/thread/topAnchor/createReserveObservers.js +1 -2
  159. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js +1 -2
  160. package/dist/primitives/thread/topAnchor/topAnchorTurn.js +1 -2
  161. package/dist/primitives/thread/topAnchor/topAnchorUtils.js +1 -2
  162. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +1 -2
  163. package/dist/primitives/thread/useThreadViewportAutoScroll.js +1 -2
  164. package/dist/primitives/thread.js +1 -2
  165. package/dist/primitives/threadList/ThreadListLoadMore.js +1 -2
  166. package/dist/primitives/threadList/ThreadListNew.js +1 -2
  167. package/dist/primitives/threadList/ThreadListRoot.js +1 -2
  168. package/dist/primitives/threadList.js +1 -2
  169. package/dist/primitives/threadListFocusGroup.js +1 -2
  170. package/dist/primitives/threadListItem/ThreadListItemArchive.js +1 -2
  171. package/dist/primitives/threadListItem/ThreadListItemDelete.js +1 -2
  172. package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts.map +1 -1
  173. package/dist/primitives/threadListItem/ThreadListItemRoot.js +2 -3
  174. package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
  175. package/dist/primitives/threadListItem/ThreadListItemTrigger.js +1 -2
  176. package/dist/primitives/threadListItem/ThreadListItemUnarchive.js +1 -2
  177. package/dist/primitives/threadListItem.js +1 -2
  178. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +1 -2
  179. package/dist/primitives/threadListItemMore/ThreadListItemMoreItem.js +1 -2
  180. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js +1 -2
  181. package/dist/primitives/threadListItemMore/ThreadListItemMoreSeparator.js +1 -2
  182. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +1 -2
  183. package/dist/primitives/threadListItemMore/scope.js +1 -2
  184. package/dist/primitives/threadListItemMore.js +1 -2
  185. package/dist/sandbox-host/SandboxHost.js +2 -3
  186. package/dist/unstable/useComposerInput.js +1 -2
  187. package/dist/unstable/useComposerInputHistory.js +2 -3
  188. package/dist/unstable/useLiveCompletionAdapter.js +1 -2
  189. package/dist/unstable/useMentionAdapter.js +1 -2
  190. package/dist/unstable/useMessageStallDetection.js +1 -2
  191. package/dist/unstable/useModelContextSnapshot.js +1 -2
  192. package/dist/unstable/useSlashCommandAdapter.js +1 -2
  193. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +1 -2
  194. package/dist/unstable/webmcp/convertTools.js +1 -2
  195. package/dist/unstable/webmcp/useWebMcpProvider.js +1 -2
  196. package/dist/unstable/webmcp/webmcp-host.js +1 -2
  197. package/dist/utils/Primitive.js +1 -2
  198. package/dist/utils/createActionButton.js +1 -2
  199. package/dist/utils/getSelectionMessageId.js +1 -2
  200. package/dist/utils/hooks/useManagedRef.js +1 -2
  201. package/dist/utils/hooks/useMediaQuery.js +1 -2
  202. package/dist/utils/hooks/useOnScrollToBottom.js +1 -2
  203. package/dist/utils/hooks/useSizeHandle.js +1 -2
  204. package/dist/utils/isCompositionKey.js +1 -2
  205. package/dist/utils/smooth/SmoothContext.js +1 -2
  206. package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
  207. package/dist/utils/smooth/useSmooth.js +11 -4
  208. package/dist/utils/smooth/useSmooth.js.map +1 -1
  209. package/dist/utils/useIsomorphicLayoutEffect.js +0 -1
  210. package/package.json +8 -8
  211. package/src/adapters/GeminiLiveVoiceAdapter.test.ts +992 -0
  212. package/src/adapters/GeminiLiveVoiceAdapter.ts +454 -0
  213. package/src/adapters/gemini-live/audio.test.ts +880 -0
  214. package/src/adapters/gemini-live/audio.ts +333 -0
  215. package/src/index.test.ts +34 -0
  216. package/src/index.ts +4 -0
  217. package/src/mcp-apps/McpAppRenderer.composer.test.tsx +111 -0
  218. package/src/mcp-apps/McpAppRenderer.test.tsx +24 -23
  219. package/src/mcp-apps/McpAppRenderer.tsx +9 -1
  220. package/src/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.test.tsx +91 -0
  221. package/src/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.tsx +54 -0
  222. package/src/primitives/reasoning/useScrollLock.test.tsx +71 -2
  223. package/src/primitives/reasoning/useScrollLock.ts +78 -51
  224. package/src/primitives/threadListItem/ThreadListItemRoot.tsx +4 -1
  225. package/src/primitives/threadListItem/ThreadListKeyboardNav.test.tsx +25 -0
  226. package/src/utils/smooth/useSmooth.test.tsx +113 -4
  227. package/src/utils/smooth/useSmooth.ts +16 -2
  228. package/src/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.ts +0 -49
@@ -0,0 +1,91 @@
1
+ // @vitest-environment jsdom
2
+ import { createRef, type MouseEvent, type Ref } from "react";
3
+ import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { AuiProvider, useAui, useAuiState } from "@assistant-ui/store";
6
+ import { ChainOfThoughtClient } from "@assistant-ui/core/store";
7
+ import { ChainOfThoughtPrimitiveAccordionTrigger } from "./ChainOfThoughtAccordionTrigger";
8
+
9
+ afterEach(cleanup);
10
+
11
+ const Content = () => {
12
+ const collapsed = useAuiState((s) => s.chainOfThought.collapsed);
13
+ return (
14
+ <div data-testid="reasoning" hidden={collapsed}>
15
+ Reasoning details
16
+ </div>
17
+ );
18
+ };
19
+
20
+ const App = ({
21
+ triggerRef,
22
+ ...props
23
+ }: ChainOfThoughtPrimitiveAccordionTrigger.Props & {
24
+ triggerRef?: Ref<HTMLButtonElement>;
25
+ }) => {
26
+ const aui = useAui({
27
+ chainOfThought: ChainOfThoughtClient({
28
+ parts: [
29
+ {
30
+ type: "reasoning",
31
+ text: "Reasoning details",
32
+ status: { type: "complete" },
33
+ },
34
+ ],
35
+ getMessagePart: () => {
36
+ throw new Error("Part lookup is not used by the toggle");
37
+ },
38
+ }),
39
+ });
40
+ return (
41
+ <AuiProvider value={aui}>
42
+ <ChainOfThoughtPrimitiveAccordionTrigger {...props} ref={triggerRef} />
43
+ <Content />
44
+ </AuiProvider>
45
+ );
46
+ };
47
+
48
+ describe("ChainOfThoughtPrimitiveAccordionTrigger", () => {
49
+ it.each(["button", "render", "asChild"] as const)(
50
+ "exposes expansion and forwards the ref with %s",
51
+ async (mode) => {
52
+ const ref = createRef<HTMLButtonElement>();
53
+ const { getByRole, getByTestId } = render(
54
+ <App
55
+ triggerRef={ref}
56
+ {...(mode === "render" ? { render: <button /> } : {})}
57
+ {...(mode === "asChild" ? { asChild: true } : {})}
58
+ >
59
+ {mode === "asChild" ? <button>Thinking</button> : "Thinking"}
60
+ </App>,
61
+ );
62
+ const button = getByRole("button", { name: "Thinking" });
63
+ const content = getByTestId("reasoning");
64
+ expect(ref.current).toBe(button);
65
+ expect(content.hidden).toBe(true);
66
+ expect(button.getAttribute("aria-expanded")).toBe("false");
67
+ fireEvent.click(button);
68
+ await waitFor(() => expect(content.hidden).toBe(false));
69
+ expect(button.getAttribute("aria-expanded")).toBe("true");
70
+ fireEvent.click(button);
71
+ await waitFor(() => expect(content.hidden).toBe(true));
72
+ expect(button.getAttribute("aria-expanded")).toBe("false");
73
+ },
74
+ );
75
+
76
+ it("lets the caller veto expansion", () => {
77
+ const onClick = vi.fn((event: MouseEvent) => event.preventDefault());
78
+ const { getByRole, getByTestId } = render(
79
+ <App onClick={onClick}>Thinking</App>,
80
+ );
81
+ fireEvent.click(getByRole("button"));
82
+ expect(onClick).toHaveBeenCalledOnce();
83
+ expect(getByTestId("reasoning").hidden).toBe(true);
84
+ });
85
+
86
+ it("does not expand a disabled trigger", () => {
87
+ const { getByRole, getByTestId } = render(<App disabled>Thinking</App>);
88
+ fireEvent.click(getByRole("button"));
89
+ expect(getByTestId("reasoning").hidden).toBe(true);
90
+ });
91
+ });
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import type { ActionButtonElement } from "../../utils/createActionButton";
4
+ import { type ComponentPropsWithoutRef, forwardRef, useCallback } from "react";
5
+ import { useAuiState, useAui } from "@assistant-ui/store";
6
+ import { composeEventHandlers } from "radix-ui/internal";
7
+ import { Primitive } from "../../utils/Primitive";
8
+
9
+ export namespace ChainOfThoughtPrimitiveAccordionTrigger {
10
+ export type Element = ActionButtonElement;
11
+ /**
12
+ * Props for the ChainOfThoughtPrimitive.AccordionTrigger component.
13
+ * Inherits all button element props and action button functionality.
14
+ */
15
+ export type Props = ComponentPropsWithoutRef<typeof Primitive.button>;
16
+ }
17
+
18
+ /**
19
+ * A button component that toggles the collapsed state of the chain of thought accordion.
20
+ *
21
+ * This component automatically handles the toggle functionality, expanding or collapsing
22
+ * the chain of thought parts when clicked.
23
+ * Exposes the current disclosure state through `aria-expanded`.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * <ChainOfThoughtPrimitive.AccordionTrigger>
28
+ * Toggle Reasoning
29
+ * </ChainOfThoughtPrimitive.AccordionTrigger>
30
+ * ```
31
+ */
32
+ export const ChainOfThoughtPrimitiveAccordionTrigger = forwardRef<
33
+ ChainOfThoughtPrimitiveAccordionTrigger.Element,
34
+ ChainOfThoughtPrimitiveAccordionTrigger.Props
35
+ >(({ onClick, ...props }, ref) => {
36
+ const aui = useAui();
37
+ const collapsed = useAuiState((s) => s.chainOfThought.collapsed);
38
+ const toggle = useCallback(() => {
39
+ aui.chainOfThought.setCollapsed(!collapsed);
40
+ }, [aui, collapsed]);
41
+
42
+ return (
43
+ <Primitive.button
44
+ type="button"
45
+ {...props}
46
+ ref={ref}
47
+ aria-expanded={!collapsed}
48
+ onClick={composeEventHandlers(onClick, toggle)}
49
+ />
50
+ );
51
+ });
52
+
53
+ ChainOfThoughtPrimitiveAccordionTrigger.displayName =
54
+ "ChainOfThoughtPrimitive.AccordionTrigger";
@@ -31,9 +31,29 @@ function stubWidths(
31
31
  function lockWithin(container: HTMLElement) {
32
32
  const animated = document.createElement("div");
33
33
  container.appendChild(animated);
34
+ return createLock(animated).lock;
35
+ }
36
+
37
+ function createLock(animated: HTMLElement) {
34
38
  const ref = { current: animated };
35
- const { result } = renderHook(() => useScrollLock(ref, 200));
36
- return result.current;
39
+ const { result, unmount } = renderHook(() => useScrollLock(ref, 200));
40
+ return { lock: result.current, dispose: unmount };
41
+ }
42
+
43
+ function createSharedScroller() {
44
+ const scroller = document.createElement("div");
45
+ scroller.style.overflowY = "auto";
46
+ scroller.style.borderWidth = "0px";
47
+ scroller.style.paddingRight = "4px";
48
+ document.body.appendChild(scroller);
49
+ Object.defineProperty(scroller, "offsetWidth", { value: 306 });
50
+ Object.defineProperty(scroller, "clientWidth", {
51
+ get: () => (scroller.style.scrollbarWidth === "none" ? 306 : 300),
52
+ });
53
+ const first = document.createElement("div");
54
+ const second = document.createElement("div");
55
+ scroller.append(first, second);
56
+ return { scroller, first: createLock(first), second: createLock(second) };
37
57
  }
38
58
 
39
59
  describe("useScrollLock", () => {
@@ -115,6 +135,55 @@ describe("useScrollLock", () => {
115
135
  expect(scroller.style.scrollbarWidth).toBe("");
116
136
  });
117
137
 
138
+ it("keeps overlapping animations locked until the last one finishes", () => {
139
+ vi.useFakeTimers();
140
+ const { scroller, first, second } = createSharedScroller();
141
+ scroller.scrollTop = 120;
142
+
143
+ first.lock();
144
+ vi.advanceTimersByTime(100);
145
+ second.lock();
146
+ expect(scroller.style.paddingRight).toBe("10px");
147
+
148
+ vi.advanceTimersByTime(100);
149
+ expect(scroller.style.scrollbarWidth).toBe("none");
150
+ expect(scroller.style.paddingRight).toBe("10px");
151
+ scroller.scrollTop = 90;
152
+ scroller.dispatchEvent(new Event("scroll"));
153
+ expect(scroller.scrollTop).toBe(120);
154
+
155
+ vi.advanceTimersByTime(100);
156
+ expect(scroller.style.scrollbarWidth).toBe("");
157
+ expect(scroller.style.paddingRight).toBe("4px");
158
+ scroller.scrollTop = 90;
159
+ scroller.dispatchEvent(new Event("scroll"));
160
+ expect(scroller.scrollTop).toBe(90);
161
+ });
162
+
163
+ it.each(["first", "second"] as const)(
164
+ "preserves a restarted shared lock when the %s owner is disposed first",
165
+ (disposedFirst) => {
166
+ vi.useFakeTimers();
167
+ const shared = createSharedScroller();
168
+ const { scroller, first, second } = shared;
169
+ first.lock();
170
+ second.lock();
171
+ vi.advanceTimersByTime(100);
172
+ first.lock();
173
+
174
+ shared[disposedFirst].dispose();
175
+ expect(scroller.style.scrollbarWidth).toBe("none");
176
+ expect(scroller.style.paddingRight).toBe("10px");
177
+
178
+ shared[disposedFirst === "first" ? "second" : "first"].dispose();
179
+ expect(scroller.style.scrollbarWidth).toBe("");
180
+ expect(scroller.style.paddingRight).toBe("4px");
181
+ scroller.style.scrollbarWidth = "thin";
182
+ vi.advanceTimersByTime(200);
183
+ expect(scroller.style.scrollbarWidth).toBe("thin");
184
+ },
185
+ );
186
+
118
187
  it("leaves the padding alone when no scrollbar takes space", () => {
119
188
  const scroller = document.createElement("div");
120
189
  scroller.style.overflowY = "auto";
@@ -12,6 +12,74 @@ const findScrollableAncestor = (element: HTMLElement | null) => {
12
12
  return null;
13
13
  };
14
14
 
15
+ const scrollLocks = new WeakMap<
16
+ HTMLElement,
17
+ { count: number; restore: () => void }
18
+ >();
19
+
20
+ const acquireScrollLock = (scrollContainer: HTMLElement) => {
21
+ const existing = scrollLocks.get(scrollContainer);
22
+ if (existing) {
23
+ existing.count++;
24
+ return existing;
25
+ }
26
+
27
+ const scrollPosition = scrollContainer.scrollTop;
28
+ const scrollbarWidth = scrollContainer.style.scrollbarWidth;
29
+
30
+ // Hiding the scrollbar collapses its gutter on classic scrollbars, which
31
+ // shifts centered content horizontally; compensate with padding on the
32
+ // side the scrollbar occupies (the left side in RTL).
33
+ const computed = getComputedStyle(scrollContainer);
34
+ const paddingSide =
35
+ computed.direction === "rtl" ? "paddingLeft" : "paddingRight";
36
+ const previousPadding = scrollContainer.style[paddingSide];
37
+ const elementScrollbarSize =
38
+ scrollContainer.offsetWidth -
39
+ scrollContainer.clientWidth -
40
+ parseFloat(computed.borderLeftWidth) -
41
+ parseFloat(computed.borderRightWidth);
42
+ // A root element's offsetWidth already excludes the viewport scrollbar, so
43
+ // the element formula reports zero once the scroll propagates to the
44
+ // viewport, and only then does the viewport measure apply. A body that
45
+ // scrolls in its own right is measured by the element formula like any
46
+ // other scroller, whether or not the viewport is scrolling too.
47
+ const ownerDocument = scrollContainer.ownerDocument;
48
+ const isRootScroller =
49
+ scrollContainer === ownerDocument.documentElement ||
50
+ scrollContainer === ownerDocument.body;
51
+ const scrollbarSize =
52
+ isRootScroller && elementScrollbarSize <= 0
53
+ ? (ownerDocument.defaultView?.innerWidth ?? 0) -
54
+ ownerDocument.documentElement.clientWidth
55
+ : elementScrollbarSize;
56
+
57
+ scrollContainer.style.scrollbarWidth = "none";
58
+ if (scrollbarSize > 0) {
59
+ scrollContainer.style[paddingSide] = `${
60
+ parseFloat(computed[paddingSide]) + scrollbarSize
61
+ }px`;
62
+ }
63
+
64
+ const restoreStyles = () => {
65
+ scrollContainer.style.scrollbarWidth = scrollbarWidth;
66
+ scrollContainer.style[paddingSide] = previousPadding;
67
+ };
68
+
69
+ const resetPosition = () => (scrollContainer.scrollTop = scrollPosition);
70
+ scrollContainer.addEventListener("scroll", resetPosition);
71
+
72
+ const lock = {
73
+ count: 1,
74
+ restore: () => {
75
+ scrollContainer.removeEventListener("scroll", resetPosition);
76
+ restoreStyles();
77
+ },
78
+ };
79
+ scrollLocks.set(scrollContainer, lock);
80
+ return lock;
81
+ };
82
+
15
83
  /**
16
84
  * Locks scroll position during collapsible/height animations and hides scrollbar.
17
85
  *
@@ -59,61 +127,20 @@ export const useScrollLock = <T extends HTMLElement = HTMLElement>(
59
127
  return;
60
128
  }
61
129
 
62
- const scrollPosition = scrollContainer.scrollTop;
63
- const scrollbarWidth = scrollContainer.style.scrollbarWidth;
64
-
65
- // Hiding the scrollbar collapses its gutter on classic scrollbars, which
66
- // shifts centered content horizontally; compensate with padding on the
67
- // side the scrollbar occupies (the left side in RTL).
68
- const computed = getComputedStyle(scrollContainer);
69
- const paddingSide =
70
- computed.direction === "rtl" ? "paddingLeft" : "paddingRight";
71
- const previousPadding = scrollContainer.style[paddingSide];
72
- const elementScrollbarSize =
73
- scrollContainer.offsetWidth -
74
- scrollContainer.clientWidth -
75
- parseFloat(computed.borderLeftWidth) -
76
- parseFloat(computed.borderRightWidth);
77
- // A root element's offsetWidth already excludes the viewport scrollbar, so
78
- // the element formula reports zero once the scroll propagates to the
79
- // viewport, and only then does the viewport measure apply. A body that
80
- // scrolls in its own right is measured by the element formula like any
81
- // other scroller, whether or not the viewport is scrolling too.
82
- const ownerDocument = scrollContainer.ownerDocument;
83
- const isRootScroller =
84
- scrollContainer === ownerDocument.documentElement ||
85
- scrollContainer === ownerDocument.body;
86
- const scrollbarSize =
87
- isRootScroller && elementScrollbarSize <= 0
88
- ? (ownerDocument.defaultView?.innerWidth ?? 0) -
89
- ownerDocument.documentElement.clientWidth
90
- : elementScrollbarSize;
91
-
92
- scrollContainer.style.scrollbarWidth = "none";
93
- if (scrollbarSize > 0) {
94
- scrollContainer.style[paddingSide] = `${
95
- parseFloat(computed[paddingSide]) + scrollbarSize
96
- }px`;
97
- }
98
-
99
- const restoreStyles = () => {
100
- scrollContainer.style.scrollbarWidth = scrollbarWidth;
101
- scrollContainer.style[paddingSide] = previousPadding;
102
- };
130
+ const lock = acquireScrollLock(scrollContainer);
103
131
 
104
- const resetPosition = () => (scrollContainer.scrollTop = scrollPosition);
105
- scrollContainer.addEventListener("scroll", resetPosition);
106
-
107
- const timeoutId = setTimeout(() => {
108
- scrollContainer.removeEventListener("scroll", resetPosition);
109
- restoreStyles();
110
- cleanupRef.current = null;
111
- }, animationDuration);
132
+ const timeoutId = setTimeout(
133
+ () => cleanupRef.current?.(),
134
+ animationDuration,
135
+ );
112
136
 
113
137
  cleanupRef.current = () => {
114
138
  clearTimeout(timeoutId);
115
- scrollContainer.removeEventListener("scroll", resetPosition);
116
- restoreStyles();
139
+ if (--lock.count === 0) {
140
+ lock.restore();
141
+ scrollLocks.delete(scrollContainer);
142
+ }
143
+ cleanupRef.current = null;
117
144
  };
118
145
  }, [animationDuration, animatedElementRef]);
119
146
 
@@ -51,7 +51,10 @@ export const ThreadListItemPrimitiveRoot = forwardRef<
51
51
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
52
52
  const triggers = getItems()
53
53
  .map((item) => item.ref.current)
54
- .filter((node): node is HTMLButtonElement => node !== null);
54
+ .filter(
55
+ (node): node is HTMLButtonElement =>
56
+ node !== null && !node.disabled,
57
+ );
55
58
  const next =
56
59
  triggers[
57
60
  triggers.indexOf(trigger) + (event.key === "ArrowDown" ? 1 : -1)
@@ -112,6 +112,31 @@ describe("thread list keyboard navigation", () => {
112
112
  expect(document.activeElement).toBe(first);
113
113
  });
114
114
 
115
+ it("skips disabled triggers in both directions without wrapping", () => {
116
+ const { container } = render(
117
+ <ThreadListPrimitiveRoot>
118
+ {[0, 1, 2].map((i) => (
119
+ <ThreadListItemPrimitiveRoot key={i}>
120
+ <ThreadListItemPrimitiveTrigger disabled={i === 1}>
121
+ item {i}
122
+ </ThreadListItemPrimitiveTrigger>
123
+ </ThreadListItemPrimitiveRoot>
124
+ ))}
125
+ </ThreadListPrimitiveRoot>,
126
+ );
127
+ const [first, , third] = triggers(container);
128
+
129
+ first!.focus();
130
+ expect(fireEvent.keyDown(first!, { key: "ArrowDown" })).toBe(false);
131
+ expect(document.activeElement).toBe(third);
132
+ expect(fireEvent.keyDown(third!, { key: "ArrowDown" })).toBe(true);
133
+ expect(document.activeElement).toBe(third);
134
+ expect(fireEvent.keyDown(third!, { key: "ArrowUp" })).toBe(false);
135
+ expect(document.activeElement).toBe(first);
136
+ expect(fireEvent.keyDown(first!, { key: "ArrowUp" })).toBe(true);
137
+ expect(document.activeElement).toBe(first);
138
+ });
139
+
115
140
  it("moves between an item and its More button with right/left arrows", () => {
116
141
  const { trigger, more } = renderItem();
117
142
 
@@ -8,12 +8,14 @@ import type {
8
8
  TextMessagePart,
9
9
  } from "@assistant-ui/core";
10
10
 
11
- const part = {};
12
- vi.mock("@assistant-ui/store", () => ({
13
- useAui: () => ({ part }),
11
+ const aui = vi.hoisted(() => ({ part: {} }));
12
+ vi.mock("@assistant-ui/store", async (importOriginal) => ({
13
+ ...(await importOriginal<typeof import("@assistant-ui/store")>()),
14
+ useAui: () => aui,
14
15
  useAuiState: (selector: () => unknown) => selector(),
15
16
  }));
16
- vi.mock("./SmoothContext", () => ({
17
+ vi.mock("./SmoothContext", async (importOriginal) => ({
18
+ ...(await importOriginal<typeof import("./SmoothContext")>()),
17
19
  useSmoothStatusStore: () => null,
18
20
  }));
19
21
 
@@ -163,6 +165,113 @@ describe("useSmooth", () => {
163
165
  expect(result.current.status.type).toBe("running");
164
166
  });
165
167
 
168
+ it("keeps completed text visible when smoothing is enabled", () => {
169
+ vi.spyOn(performance, "now").mockReturnValue(1000);
170
+ vi.spyOn(globalThis, "requestAnimationFrame").mockReturnValue(1);
171
+ vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => {});
172
+ const { result, rerender } = renderHook(
173
+ ({ state, smooth }) => useSmooth(state, smooth),
174
+ { initialProps: { state: textState("Hello"), smooth: false } },
175
+ );
176
+ rerender({ state: runningState("Hello world"), smooth: false });
177
+ rerender({ state: textState("Hello world"), smooth: false });
178
+ expect(result.current.text).toBe("Hello world");
179
+
180
+ rerender({ state: textState("Hello world"), smooth: true });
181
+ expect(result.current.text).toBe("Hello world");
182
+ expect(result.current.status.type).toBe("complete");
183
+ });
184
+
185
+ it("starts a new part from its own cursor when enabling smoothing in the same update", () => {
186
+ let now = 1000;
187
+ let queued: FrameRequestCallback | undefined;
188
+ vi.spyOn(performance, "now").mockImplementation(() => now);
189
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
190
+ queued = cb;
191
+ return 1;
192
+ });
193
+ vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => {
194
+ queued = undefined;
195
+ });
196
+ const { result, rerender } = renderHook(
197
+ ({ state, smooth }) => useSmooth(state, smooth),
198
+ {
199
+ initialProps: {
200
+ state: runningState("Hello"),
201
+ smooth: false as boolean | SmoothOptions,
202
+ },
203
+ },
204
+ );
205
+ expect(result.current.text).toBe("Hello");
206
+
207
+ aui.part = {};
208
+ rerender({
209
+ state: runningState("Hello world"),
210
+ smooth: { maxCharsPerFrame: 1 },
211
+ });
212
+ expect(result.current.text).toBe("");
213
+ act(() => {
214
+ now += 16;
215
+ const callback = queued;
216
+ queued = undefined;
217
+ callback?.(now);
218
+ });
219
+ expect(result.current.text).toBe("H");
220
+ });
221
+
222
+ it.each([false, true])(
223
+ "animates only new text after reduced motion is disabled, with a part switch: %s",
224
+ (switchPart) => {
225
+ let now = 1000;
226
+ let queued: FrameRequestCallback | undefined;
227
+ vi.spyOn(performance, "now").mockImplementation(() => now);
228
+ vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation((cb) => {
229
+ queued = cb;
230
+ return 1;
231
+ });
232
+ vi.spyOn(globalThis, "cancelAnimationFrame").mockImplementation(() => {
233
+ queued = undefined;
234
+ });
235
+ const listeners = new Set<() => void>();
236
+ const media = {
237
+ matches: true,
238
+ addEventListener: (_event: string, listener: () => void) =>
239
+ listeners.add(listener),
240
+ removeEventListener: (_event: string, listener: () => void) =>
241
+ listeners.delete(listener),
242
+ };
243
+ Object.defineProperty(window, "matchMedia", {
244
+ configurable: true,
245
+ value: () => media,
246
+ });
247
+ const { result, rerender } = renderHook(
248
+ (state) => useSmooth(state, { maxCharsPerFrame: 1 }),
249
+ { initialProps: runningState("Hello") },
250
+ );
251
+ if (switchPart) aui.part = {};
252
+ rerender(runningState("Hello world"));
253
+ expect(result.current.text).toBe("Hello world");
254
+
255
+ act(() => {
256
+ media.matches = false;
257
+ for (const listener of listeners) listener();
258
+ });
259
+ expect(result.current.text).toBe("Hello world");
260
+
261
+ rerender(runningState("Hello world!"));
262
+ expect(result.current.text).toBe("Hello world");
263
+ act(() => {
264
+ now += 16;
265
+ const callback = queued;
266
+ queued = undefined;
267
+ callback?.(now);
268
+ });
269
+ expect(result.current.text).toBe("Hello world!");
270
+ rerender(textState("Hello world!"));
271
+ expect(result.current.status.type).toBe("complete");
272
+ },
273
+ );
274
+
166
275
  it.each([
167
276
  { name: "negative drain time", options: { drainMs: -1 } },
168
277
  { name: "NaN character interval", options: { maxCharIntervalMs: NaN } },
@@ -217,6 +217,17 @@ export const useSmooth = (
217
217
  const [animatorRef] = useState(
218
218
  () => new TextStreamAnimator(displayedText, setText),
219
219
  );
220
+ const [prevEnabled, setPrevEnabled] = useState(enabled);
221
+ if (enabled !== prevEnabled) {
222
+ setPrevEnabled(enabled);
223
+ if (
224
+ enabled &&
225
+ part === prevPart &&
226
+ text.startsWith(animatorRef.currentText)
227
+ ) {
228
+ setDisplayedText(animatorRef.currentText);
229
+ }
230
+ }
220
231
 
221
232
  useEffect(() => {
222
233
  animatorRef.drainMs = drainMs;
@@ -227,13 +238,16 @@ export const useSmooth = (
227
238
 
228
239
  const animatorPartRef = useRef(part);
229
240
  useEffect(() => {
241
+ const partChanged = animatorPartRef.current !== part;
242
+ animatorPartRef.current = part;
230
243
  if (!enabled) {
231
244
  animatorRef.stop();
245
+ animatorRef.currentText = text;
246
+ animatorRef.targetText = text;
247
+ animatorRef.lastCommitTime = -Infinity;
232
248
  return;
233
249
  }
234
250
 
235
- const partChanged = animatorPartRef.current !== part;
236
- animatorPartRef.current = part;
237
251
  // A new part whose text shares a prefix with the previous target would
238
252
  // keep the stale cursor and flicker without this reset.
239
253
  if (partChanged || !text.startsWith(animatorRef.targetText)) {
@@ -1,49 +0,0 @@
1
- "use client";
2
-
3
- import {
4
- type ActionButtonElement,
5
- type ActionButtonProps,
6
- createActionButton,
7
- } from "../../utils/createActionButton";
8
- import { useCallback } from "react";
9
- import { useAuiState, useAui } from "@assistant-ui/store";
10
-
11
- const useChainOfThoughtAccordionTrigger = () => {
12
- const aui = useAui();
13
- const collapsed = useAuiState((s) => s.chainOfThought.collapsed);
14
-
15
- const callback = useCallback(() => {
16
- aui.chainOfThought.setCollapsed(!collapsed);
17
- }, [aui, collapsed]);
18
-
19
- return callback;
20
- };
21
-
22
- export namespace ChainOfThoughtPrimitiveAccordionTrigger {
23
- export type Element = ActionButtonElement;
24
- /**
25
- * Props for the ChainOfThoughtPrimitive.AccordionTrigger component.
26
- * Inherits all button element props and action button functionality.
27
- */
28
- export type Props = ActionButtonProps<
29
- typeof useChainOfThoughtAccordionTrigger
30
- >;
31
- }
32
-
33
- /**
34
- * A button component that toggles the collapsed state of the chain of thought accordion.
35
- *
36
- * This component automatically handles the toggle functionality, expanding or collapsing
37
- * the chain of thought parts when clicked.
38
- *
39
- * @example
40
- * ```tsx
41
- * <ChainOfThoughtPrimitive.AccordionTrigger>
42
- * Toggle Reasoning
43
- * </ChainOfThoughtPrimitive.AccordionTrigger>
44
- * ```
45
- */
46
- export const ChainOfThoughtPrimitiveAccordionTrigger = createActionButton(
47
- "ChainOfThoughtPrimitive.AccordionTrigger",
48
- useChainOfThoughtAccordionTrigger,
49
- );