@assistant-ui/react 0.15.23 → 0.15.25

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 (212) hide show
  1. package/README.md +44 -23
  2. package/dist/assistant-transport.js.map +1 -1
  3. package/dist/cloud-renderer/CloudRendererHost.js.map +1 -1
  4. package/dist/context/providers/MessageProvider.js.map +1 -1
  5. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  6. package/dist/hooks/useToolCallElapsed.d.ts +3 -1
  7. package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
  8. package/dist/hooks/useToolCallElapsed.js +7 -8
  9. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  10. package/dist/index.d.ts +10 -4
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3 -3
  13. package/dist/legacy-runtime/AssistantRuntimeProvider.js.map +1 -1
  14. package/dist/mcp-apps/app-frame.js.map +1 -1
  15. package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
  16. package/dist/model-context/makeAssistantVisible.d.ts +1 -0
  17. package/dist/model-context/makeAssistantVisible.d.ts.map +1 -1
  18. package/dist/model-context/makeAssistantVisible.js +31 -12
  19. package/dist/model-context/makeAssistantVisible.js.map +1 -1
  20. package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
  21. package/dist/primitives/actionBar/ActionBarEdit.js.map +1 -1
  22. package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
  23. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js.map +1 -1
  24. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js.map +1 -1
  25. package/dist/primitives/actionBar/ActionBarReload.js.map +1 -1
  26. package/dist/primitives/actionBar/ActionBarRoot.js.map +1 -1
  27. package/dist/primitives/actionBar/ActionBarSpeak.js.map +1 -1
  28. package/dist/primitives/actionBar/ActionBarStopSpeaking.js.map +1 -1
  29. package/dist/primitives/actionBar/useActionBarFloatStatus.js.map +1 -1
  30. package/dist/primitives/actionBarMore/ActionBarMoreContent.js.map +1 -1
  31. package/dist/primitives/actionBarMore/ActionBarMoreItem.js.map +1 -1
  32. package/dist/primitives/actionBarMore/ActionBarMoreRoot.js.map +1 -1
  33. package/dist/primitives/actionBarMore/ActionBarMoreSeparator.js.map +1 -1
  34. package/dist/primitives/actionBarMore/ActionBarMoreTrigger.js.map +1 -1
  35. package/dist/primitives/actionBarMore/scope.js.map +1 -1
  36. package/dist/primitives/assistantModal/AssistantModalAnchor.js.map +1 -1
  37. package/dist/primitives/assistantModal/AssistantModalContent.js.map +1 -1
  38. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  39. package/dist/primitives/assistantModal/AssistantModalTrigger.js.map +1 -1
  40. package/dist/primitives/assistantModal/popoverRenderPrimitives.js.map +1 -1
  41. package/dist/primitives/assistantModal/scope.js.map +1 -1
  42. package/dist/primitives/attachment/AttachmentName.js.map +1 -1
  43. package/dist/primitives/attachment/AttachmentRemove.js.map +1 -1
  44. package/dist/primitives/attachment/AttachmentRoot.js.map +1 -1
  45. package/dist/primitives/attachment/AttachmentThumb.js.map +1 -1
  46. package/dist/primitives/branchPicker/BranchPickerCount.js.map +1 -1
  47. package/dist/primitives/branchPicker/BranchPickerNext.js.map +1 -1
  48. package/dist/primitives/branchPicker/BranchPickerNumber.js.map +1 -1
  49. package/dist/primitives/branchPicker/BranchPickerPrevious.js.map +1 -1
  50. package/dist/primitives/branchPicker/BranchPickerRoot.js.map +1 -1
  51. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js.map +1 -1
  52. package/dist/primitives/chainOfThought/ChainOfThoughtRoot.js.map +1 -1
  53. package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
  54. package/dist/primitives/composer/ComposerCancel.js.map +1 -1
  55. package/dist/primitives/composer/ComposerDictate.js.map +1 -1
  56. package/dist/primitives/composer/ComposerDictationTranscript.js.map +1 -1
  57. package/dist/primitives/composer/ComposerInput.d.ts +1 -1
  58. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  59. package/dist/primitives/composer/ComposerInput.js +4 -3
  60. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  61. package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
  62. package/dist/primitives/composer/ComposerQuote.js.map +1 -1
  63. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  64. package/dist/primitives/composer/ComposerSend.js.map +1 -1
  65. package/dist/primitives/composer/ComposerStopDictation.js.map +1 -1
  66. package/dist/primitives/composer/trigger/TriggerPopover.d.ts +11 -11
  67. package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
  68. package/dist/primitives/composer/trigger/TriggerPopover.js +62 -60
  69. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  70. package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts +8 -8
  71. package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts.map +1 -1
  72. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +6 -6
  73. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  74. package/dist/primitives/composer/trigger/TriggerPopoverBack.js.map +1 -1
  75. package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts +3 -3
  76. package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts.map +1 -1
  77. package/dist/primitives/composer/trigger/TriggerPopoverCategories.js.map +1 -1
  78. package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts +9 -9
  79. package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts.map +1 -1
  80. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +7 -7
  81. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  82. package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts +5 -5
  83. package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts.map +1 -1
  84. package/dist/primitives/composer/trigger/TriggerPopoverItems.js.map +1 -1
  85. package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
  86. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +8 -8
  87. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +8 -8
  88. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
  89. package/dist/primitives/composer/trigger/index.d.ts +1 -1
  90. package/dist/primitives/composer/trigger/index.js.map +1 -1
  91. package/dist/primitives/composer/trigger/triggerDetectionResource.js.map +1 -1
  92. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  93. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  94. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  95. package/dist/primitives/composer/useComposerInputState.js.map +1 -1
  96. package/dist/primitives/composer.d.ts +20 -1
  97. package/dist/primitives/composer.d.ts.map +1 -1
  98. package/dist/primitives/composer.js +8 -1
  99. package/dist/primitives/composer.js.map +1 -1
  100. package/dist/primitives/dropdownMenuRenderPrimitives.js.map +1 -1
  101. package/dist/primitives/error/ErrorMessage.js.map +1 -1
  102. package/dist/primitives/error/ErrorRoot.js.map +1 -1
  103. package/dist/primitives/message/MessageIf.js.map +1 -1
  104. package/dist/primitives/message/MessageParts.js.map +1 -1
  105. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  106. package/dist/primitives/message/MessagePartsGrouped.js +42 -17
  107. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  108. package/dist/primitives/message/MessageRoot.js.map +1 -1
  109. package/dist/primitives/messagePart/MessagePartImage.js.map +1 -1
  110. package/dist/primitives/queueItem/QueueItemRemove.js.map +1 -1
  111. package/dist/primitives/queueItem/QueueItemSteer.js.map +1 -1
  112. package/dist/primitives/queueItem/QueueItemText.js.map +1 -1
  113. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  114. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js.map +1 -1
  115. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  116. package/dist/primitives/suggestion/SuggestionDescription.js.map +1 -1
  117. package/dist/primitives/suggestion/SuggestionTitle.js.map +1 -1
  118. package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
  119. package/dist/primitives/thread/ThreadIf.js.map +1 -1
  120. package/dist/primitives/thread/ThreadLoadEarlier.d.ts +19 -0
  121. package/dist/primitives/thread/ThreadLoadEarlier.d.ts.map +1 -0
  122. package/dist/primitives/thread/ThreadLoadEarlier.js +36 -0
  123. package/dist/primitives/thread/ThreadLoadEarlier.js.map +1 -0
  124. package/dist/primitives/thread/ThreadMessages.d.ts +1 -1
  125. package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
  126. package/dist/primitives/thread/ThreadMessages.js +2 -2
  127. package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
  128. package/dist/primitives/thread/ThreadRoot.js +2 -0
  129. package/dist/primitives/thread/ThreadRoot.js.map +1 -1
  130. package/dist/primitives/thread/ThreadScrollToBottom.js.map +1 -1
  131. package/dist/primitives/thread/ThreadSuggestion.js.map +1 -1
  132. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  133. package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
  134. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
  135. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  136. package/dist/primitives/thread/useThreadViewportAutoScroll.js +111 -2
  137. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  138. package/dist/primitives/thread.d.ts +2 -0
  139. package/dist/primitives/thread.d.ts.map +1 -1
  140. package/dist/primitives/thread.js +5 -2
  141. package/dist/primitives/thread.js.map +1 -1
  142. package/dist/primitives/threadList/ThreadListLoadMore.js.map +1 -1
  143. package/dist/primitives/threadList/ThreadListNew.js.map +1 -1
  144. package/dist/primitives/threadList/ThreadListRoot.js.map +1 -1
  145. package/dist/primitives/threadListItem/ThreadListItemArchive.js.map +1 -1
  146. package/dist/primitives/threadListItem/ThreadListItemDelete.js.map +1 -1
  147. package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
  148. package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
  149. package/dist/primitives/threadListItem/ThreadListItemUnarchive.js.map +1 -1
  150. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
  151. package/dist/primitives/threadListItemMore/ThreadListItemMoreItem.js.map +1 -1
  152. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js.map +1 -1
  153. package/dist/primitives/threadListItemMore/ThreadListItemMoreSeparator.js.map +1 -1
  154. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js.map +1 -1
  155. package/dist/primitives/threadListItemMore/scope.js.map +1 -1
  156. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  157. package/dist/sandbox-host/SandboxHost.js +6 -1
  158. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  159. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  160. package/dist/unstable/useMessageStallDetection.js +10 -6
  161. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  162. package/dist/unstable/useModelContextSnapshot.js.map +1 -1
  163. package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -1
  164. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  165. package/dist/utils/Primitive.js.map +1 -1
  166. package/dist/utils/createActionButton.js.map +1 -1
  167. package/dist/utils/hooks/useManagedRef.js.map +1 -1
  168. package/dist/utils/hooks/useMediaQuery.js.map +1 -1
  169. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  170. package/dist/utils/hooks/useOnScrollToBottom.js.map +1 -1
  171. package/dist/utils/hooks/useSizeHandle.js.map +1 -1
  172. package/dist/utils/isCompositionKey.d.ts +2 -0
  173. package/dist/utils/isCompositionKey.d.ts.map +1 -0
  174. package/dist/utils/isCompositionKey.js +6 -0
  175. package/dist/utils/isCompositionKey.js.map +1 -0
  176. package/dist/utils/smooth/SmoothContext.js.map +1 -1
  177. package/dist/utils/smooth/useSmooth.js +6 -6
  178. package/dist/utils/smooth/useSmooth.js.map +1 -1
  179. package/package.json +12 -11
  180. package/src/hooks/useToolCallElapsed.test.tsx +35 -0
  181. package/src/hooks/useToolCallElapsed.ts +9 -7
  182. package/src/index.ts +28 -3
  183. package/src/model-context/makeAssistantVisible.test.tsx +315 -5
  184. package/src/model-context/makeAssistantVisible.tsx +43 -7
  185. package/src/primitives/composer/ComposerInput.test.tsx +33 -1
  186. package/src/primitives/composer/ComposerInput.tsx +4 -3
  187. package/src/primitives/composer/trigger/TriggerPopover.tsx +11 -11
  188. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +10 -13
  189. package/src/primitives/composer/trigger/TriggerPopoverCategories.tsx +2 -2
  190. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +11 -14
  191. package/src/primitives/composer/trigger/TriggerPopoverExports.test.ts +61 -0
  192. package/src/primitives/composer/trigger/TriggerPopoverItems.tsx +3 -3
  193. package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +8 -8
  194. package/src/primitives/composer.ts +21 -1
  195. package/src/primitives/message/MessagePartsGrouped.test.tsx +271 -1
  196. package/src/primitives/message/MessagePartsGrouped.tsx +14 -6
  197. package/src/primitives/message/MessageRoot.test.tsx +125 -2
  198. package/src/primitives/thread/ThreadLoadEarlier.tsx +35 -0
  199. package/src/primitives/thread/ThreadMessages.tsx +1 -0
  200. package/src/primitives/thread/ThreadRoot.test.tsx +33 -0
  201. package/src/primitives/thread/ThreadRoot.tsx +3 -0
  202. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +256 -5
  203. package/src/primitives/thread/useThreadViewportAutoScroll.ts +147 -1
  204. package/src/primitives/thread.ts +2 -0
  205. package/src/sandbox-host/SandboxHost.test.tsx +34 -0
  206. package/src/sandbox-host/SandboxHost.tsx +8 -4
  207. package/src/tests/toolCallTiming.test.tsx +19 -2
  208. package/src/unstable/useMessageStallDetection.test.tsx +40 -0
  209. package/src/unstable/useMessageStallDetection.ts +10 -6
  210. package/src/utils/isCompositionKey.ts +5 -0
  211. package/src/utils/smooth/useSmooth.test.tsx +16 -5
  212. package/src/utils/smooth/useSmooth.ts +7 -7
@@ -1 +1 @@
1
- {"version":3,"file":"useSmooth.js","names":["useEffect","useMemo","useRef","useState","useAui","useAuiState","MessagePartStatus","ReasoningMessagePart","TextMessagePart","MessagePartState","useCallbackRef","useMediaQuery","useSmoothStatusStore","writableStore","SmoothOptions","drainMs","maxCharIntervalMs","maxCharsPerFrame","minCommitMs","DEFAULT_DRAIN_MS","DEFAULT_MAX_CHAR_INTERVAL_MS","TextStreamAnimator","animationFrameId","lastUpdateTime","Date","now","lastCommitTime","targetText","Infinity","currentText","setText","newText","constructor","start","animate","stop","cancelAnimationFrame","currentTime","deltaTime","timeToConsume","remainingChars","length","baseTimePerChar","Math","min","frameLimit","charsToAdd","requestAnimationFrame","slice","isComplete","SMOOTH_STATUS","Object","freeze","type","positiveOr","value","fallback","undefined","useSmooth","state","smooth","text","reduceMotion","options","enabled","displayedText","setDisplayedText","status","aui","part","prevPart","setPrevPart","startsWith","smoothStatusStore","optional","target","setState","animatorRef","animatorPartRef","partChanged","current"],"sources":["../../../src/utils/smooth/useSmooth.ts"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useAui, useAuiState } from \"@assistant-ui/store\";\nimport type {\n MessagePartStatus,\n ReasoningMessagePart,\n TextMessagePart,\n MessagePartState,\n} from \"@assistant-ui/core\";\nimport { useCallbackRef } from \"radix-ui/internal\";\nimport { useMediaQuery } from \"../hooks/useMediaQuery\";\nimport { useSmoothStatusStore } from \"./SmoothContext\";\nimport { writableStore } from \"../../context/ReadonlyStore\";\n\n/**\n * Tuning options for the smooth text streaming animation.\n */\nexport type SmoothOptions = {\n /**\n * Target time in milliseconds to drain the backlog of unrevealed\n * characters. Larger values reveal long backlogs more gradually.\n * @default 250\n */\n drainMs?: number | undefined;\n /**\n * Maximum time in milliseconds between revealed characters, i.e. the\n * slowest reveal rate when the backlog is short.\n * @default 5\n */\n maxCharIntervalMs?: number | undefined;\n /**\n * Maximum number of characters revealed per animation frame.\n * @default Infinity\n */\n maxCharsPerFrame?: number | undefined;\n /**\n * Minimum time in milliseconds between committed updates. The reveal keeps\n * advancing every frame, but the visible text (and the downstream re-render\n * and markdown re-parse it triggers) is committed at most once per interval.\n * The final frame always commits. `0` commits every frame.\n * @default 0\n */\n minCommitMs?: number | undefined;\n};\n\nconst DEFAULT_DRAIN_MS = 250;\nconst DEFAULT_MAX_CHAR_INTERVAL_MS = 5;\n\nclass TextStreamAnimator {\n private animationFrameId: number | null = null;\n private lastUpdateTime: number = Date.now();\n public lastCommitTime: number = 0;\n\n public targetText: string = \"\";\n public drainMs: number = DEFAULT_DRAIN_MS;\n public maxCharIntervalMs: number = DEFAULT_MAX_CHAR_INTERVAL_MS;\n public maxCharsPerFrame: number = Infinity;\n public minCommitMs: number = 0;\n\n public currentText: string;\n private setText: (newText: string) => void;\n\n constructor(currentText: string, setText: (newText: string) => void) {\n this.currentText = currentText;\n this.setText = setText;\n }\n\n start() {\n if (this.animationFrameId !== null) return;\n this.lastUpdateTime = Date.now();\n this.animate();\n }\n\n stop() {\n if (this.animationFrameId !== null) {\n cancelAnimationFrame(this.animationFrameId);\n this.animationFrameId = null;\n }\n }\n\n private animate = () => {\n const currentTime = Date.now();\n const deltaTime = currentTime - this.lastUpdateTime;\n let timeToConsume = deltaTime;\n\n const remainingChars = this.targetText.length - this.currentText.length;\n const baseTimePerChar = Math.min(\n this.maxCharIntervalMs,\n this.drainMs / remainingChars,\n );\n\n const frameLimit = Math.min(remainingChars, this.maxCharsPerFrame);\n let charsToAdd = 0;\n while (timeToConsume >= baseTimePerChar && charsToAdd < frameLimit) {\n charsToAdd++;\n timeToConsume -= baseTimePerChar;\n }\n // A cap-limited frame must not bank its surplus time, or the next\n // frame would burst past the cap.\n if (charsToAdd === frameLimit && frameLimit === this.maxCharsPerFrame) {\n timeToConsume = 0;\n }\n\n if (charsToAdd !== remainingChars) {\n this.animationFrameId = requestAnimationFrame(this.animate);\n } else {\n this.animationFrameId = null;\n }\n if (charsToAdd === 0) return;\n\n this.currentText = this.targetText.slice(\n 0,\n this.currentText.length + charsToAdd,\n );\n this.lastUpdateTime = currentTime - timeToConsume;\n\n const isComplete = charsToAdd === remainingChars;\n if (isComplete || currentTime - this.lastCommitTime >= this.minCommitMs) {\n this.lastCommitTime = currentTime;\n this.setText(this.currentText);\n }\n };\n}\n\nconst SMOOTH_STATUS: MessagePartStatus = Object.freeze({\n type: \"running\",\n});\n\nconst positiveOr = (value: number | undefined, fallback: number): number =>\n value !== undefined && value > 0 ? value : fallback;\n\n/**\n * Animates streamed message part text with a typewriter-style reveal.\n *\n * Takes the current part state and a `smooth` argument: `false` disables,\n * `true` uses the default rate, and a {@link SmoothOptions} object tunes\n * the reveal. Returns the part state with `text` replaced by the revealed\n * prefix and `status` reporting `running` until the reveal catches up.\n * If the source settles before any character has been revealed, the text\n * is committed immediately so a missed animation frame cannot leave an\n * empty bubble.\n *\n * The reveal auto-disables under `prefers-reduced-motion: reduce`,\n * committing the full text immediately; this takes precedence over an\n * explicit `smooth={true}`.\n *\n * @example\n * ```tsx\n * const { text, status } = useSmooth(useMessagePartText(), {\n * drainMs: 500,\n * maxCharsPerFrame: 30,\n * });\n * ```\n */\nexport const useSmooth = (\n state: MessagePartState & (TextMessagePart | ReasoningMessagePart),\n smooth: boolean | SmoothOptions = false,\n): MessagePartState & (TextMessagePart | ReasoningMessagePart) => {\n const { text } = state;\n const reduceMotion = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n const options =\n typeof smooth === \"object\" && smooth !== null ? smooth : undefined;\n const enabled = smooth !== false && smooth !== null && !reduceMotion;\n const drainMs = positiveOr(options?.drainMs, DEFAULT_DRAIN_MS);\n const maxCharIntervalMs = positiveOr(\n options?.maxCharIntervalMs,\n DEFAULT_MAX_CHAR_INTERVAL_MS,\n );\n const maxCharsPerFrame = positiveOr(options?.maxCharsPerFrame, Infinity);\n const minCommitMs = positiveOr(options?.minCommitMs, 0);\n\n const [displayedText, setDisplayedText] = useState(\n state.status.type === \"running\" ? \"\" : text,\n );\n\n // Render-phase resync on part flip or text discontinuity, so the\n // first paint after a thread switch never shows the previous\n // part's text (#4051). `displayedText` is already a prefix of\n // `text` during normal streaming, so use it as the previous-text\n // reference instead of carrying separate state — avoids the\n // double render per streaming token. Read part identity through\n // `useAuiState` so we actually subscribe to its changes instead\n // of relying on a render-time proxy reference that may be stable\n // across thread swaps.\n const aui = useAui();\n const part = useAuiState(() => aui.part);\n const [prevPart, setPrevPart] = useState(part);\n if (part !== prevPart || !text.startsWith(displayedText)) {\n setPrevPart(part);\n setDisplayedText(state.status.type === \"running\" ? \"\" : text);\n }\n\n const smoothStatusStore = useSmoothStatusStore({ optional: true });\n const setText = useCallbackRef((text: string) => {\n setDisplayedText(text);\n if (smoothStatusStore) {\n const target =\n displayedText !== text || state.status.type === \"running\"\n ? SMOOTH_STATUS\n : state.status;\n writableStore(smoothStatusStore).setState(target, true);\n }\n });\n\n // TODO this is hacky\n useEffect(() => {\n if (smoothStatusStore) {\n const target =\n enabled && (displayedText !== text || state.status.type === \"running\")\n ? SMOOTH_STATUS\n : state.status;\n writableStore(smoothStatusStore).setState(target, true);\n }\n }, [smoothStatusStore, enabled, text, displayedText, state.status]);\n\n const [animatorRef] = useState<TextStreamAnimator>(\n new TextStreamAnimator(displayedText, setText),\n );\n\n useEffect(() => {\n animatorRef.drainMs = drainMs;\n animatorRef.maxCharIntervalMs = maxCharIntervalMs;\n animatorRef.maxCharsPerFrame = maxCharsPerFrame;\n animatorRef.minCommitMs = minCommitMs;\n }, [animatorRef, drainMs, maxCharIntervalMs, maxCharsPerFrame, minCommitMs]);\n\n const animatorPartRef = useRef(part);\n useEffect(() => {\n if (!enabled) {\n animatorRef.stop();\n return;\n }\n\n const partChanged = animatorPartRef.current !== part;\n animatorPartRef.current = part;\n // A new part whose text shares a prefix with the previous target would\n // keep the stale cursor and flicker without this reset.\n if (partChanged || !text.startsWith(animatorRef.targetText)) {\n if (state.status.type === \"running\") {\n animatorRef.currentText = \"\";\n animatorRef.targetText = text;\n animatorRef.lastCommitTime = 0;\n animatorRef.start();\n } else {\n animatorRef.currentText = text;\n animatorRef.targetText = text;\n animatorRef.stop();\n setText(text);\n }\n return;\n }\n\n animatorRef.targetText = text;\n if (state.status.type !== \"running\") {\n // No character has painted. A pending frame that never runs would\n // leave an empty bubble after settle.\n if (animatorRef.currentText === \"\") {\n animatorRef.currentText = text;\n animatorRef.stop();\n setText(text);\n return;\n }\n animatorRef.start();\n return;\n }\n\n animatorRef.start();\n }, [animatorRef, enabled, text, state.status.type, part, setText]);\n\n useEffect(() => {\n return () => {\n animatorRef.stop();\n };\n }, [animatorRef]);\n\n return useMemo(\n () =>\n enabled\n ? {\n ...state,\n text: displayedText,\n status: text === displayedText ? state.status : SMOOTH_STATUS,\n }\n : state,\n [enabled, displayedText, state, text],\n );\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"useSmooth.js","names":["useEffect","useMemo","useRef","useState","useAui","useAuiState","MessagePartStatus","ReasoningMessagePart","TextMessagePart","MessagePartState","useCallbackRef","useMediaQuery","useSmoothStatusStore","writableStore","SmoothOptions","drainMs","maxCharIntervalMs","maxCharsPerFrame","minCommitMs","DEFAULT_DRAIN_MS","DEFAULT_MAX_CHAR_INTERVAL_MS","TextStreamAnimator","animationFrameId","lastUpdateTime","lastCommitTime","Infinity","targetText","currentText","setText","newText","constructor","start","performance","now","animate","stop","cancelAnimationFrame","currentTime","deltaTime","timeToConsume","remainingChars","length","baseTimePerChar","Math","min","frameLimit","charsToAdd","requestAnimationFrame","slice","isComplete","SMOOTH_STATUS","Object","freeze","type","positiveOr","value","fallback","undefined","useSmooth","state","smooth","text","reduceMotion","options","enabled","displayedText","setDisplayedText","status","aui","part","prevPart","setPrevPart","startsWith","smoothStatusStore","optional","target","setState","animatorRef","animatorPartRef","partChanged","current"],"sources":["../../../src/utils/smooth/useSmooth.ts"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useAui, useAuiState } from \"@assistant-ui/store\";\nimport type {\n MessagePartStatus,\n ReasoningMessagePart,\n TextMessagePart,\n MessagePartState,\n} from \"@assistant-ui/core\";\nimport { useCallbackRef } from \"radix-ui/internal\";\nimport { useMediaQuery } from \"../hooks/useMediaQuery\";\nimport { useSmoothStatusStore } from \"./SmoothContext\";\nimport { writableStore } from \"../../context/ReadonlyStore\";\n\n/**\n * Tuning options for the smooth text streaming animation.\n */\nexport type SmoothOptions = {\n /**\n * Target time in milliseconds to drain the backlog of unrevealed\n * characters. Larger values reveal long backlogs more gradually.\n * @default 250\n */\n drainMs?: number | undefined;\n /**\n * Maximum time in milliseconds between revealed characters, i.e. the\n * slowest reveal rate when the backlog is short.\n * @default 5\n */\n maxCharIntervalMs?: number | undefined;\n /**\n * Maximum number of characters revealed per animation frame.\n * @default Infinity\n */\n maxCharsPerFrame?: number | undefined;\n /**\n * Minimum time in milliseconds between committed updates. The reveal keeps\n * advancing every frame, but the visible text (and the downstream re-render\n * and markdown re-parse it triggers) is committed at most once per interval.\n * The final frame always commits. `0` commits every frame.\n * @default 0\n */\n minCommitMs?: number | undefined;\n};\n\nconst DEFAULT_DRAIN_MS = 250;\nconst DEFAULT_MAX_CHAR_INTERVAL_MS = 5;\n\nclass TextStreamAnimator {\n private animationFrameId: number | null = null;\n private lastUpdateTime: number = 0;\n public lastCommitTime: number = -Infinity;\n\n public targetText: string = \"\";\n public drainMs: number = DEFAULT_DRAIN_MS;\n public maxCharIntervalMs: number = DEFAULT_MAX_CHAR_INTERVAL_MS;\n public maxCharsPerFrame: number = Infinity;\n public minCommitMs: number = 0;\n\n public currentText: string;\n private setText: (newText: string) => void;\n\n constructor(currentText: string, setText: (newText: string) => void) {\n this.currentText = currentText;\n this.setText = setText;\n }\n\n start() {\n if (this.animationFrameId !== null) return;\n this.lastUpdateTime = performance.now();\n this.animate();\n }\n\n stop() {\n if (this.animationFrameId !== null) {\n cancelAnimationFrame(this.animationFrameId);\n this.animationFrameId = null;\n }\n }\n\n private animate = () => {\n const currentTime = performance.now();\n const deltaTime = currentTime - this.lastUpdateTime;\n let timeToConsume = deltaTime;\n\n const remainingChars = this.targetText.length - this.currentText.length;\n const baseTimePerChar = Math.min(\n this.maxCharIntervalMs,\n this.drainMs / remainingChars,\n );\n\n const frameLimit = Math.min(remainingChars, this.maxCharsPerFrame);\n let charsToAdd = 0;\n while (timeToConsume >= baseTimePerChar && charsToAdd < frameLimit) {\n charsToAdd++;\n timeToConsume -= baseTimePerChar;\n }\n // A cap-limited frame must not bank its surplus time, or the next\n // frame would burst past the cap.\n if (charsToAdd === frameLimit && frameLimit === this.maxCharsPerFrame) {\n timeToConsume = 0;\n }\n\n if (charsToAdd !== remainingChars) {\n this.animationFrameId = requestAnimationFrame(this.animate);\n } else {\n this.animationFrameId = null;\n }\n if (charsToAdd === 0) return;\n\n this.currentText = this.targetText.slice(\n 0,\n this.currentText.length + charsToAdd,\n );\n this.lastUpdateTime = currentTime - timeToConsume;\n\n const isComplete = charsToAdd === remainingChars;\n if (isComplete || currentTime - this.lastCommitTime >= this.minCommitMs) {\n this.lastCommitTime = currentTime;\n this.setText(this.currentText);\n }\n };\n}\n\nconst SMOOTH_STATUS: MessagePartStatus = Object.freeze({\n type: \"running\",\n});\n\nconst positiveOr = (value: number | undefined, fallback: number): number =>\n value !== undefined && value > 0 ? value : fallback;\n\n/**\n * Animates streamed message part text with a typewriter-style reveal.\n *\n * Takes the current part state and a `smooth` argument: `false` disables,\n * `true` uses the default rate, and a {@link SmoothOptions} object tunes\n * the reveal. Returns the part state with `text` replaced by the revealed\n * prefix and `status` reporting `running` until the reveal catches up.\n * If the source settles before any character has been revealed, the text\n * is committed immediately so a missed animation frame cannot leave an\n * empty bubble.\n *\n * The reveal auto-disables under `prefers-reduced-motion: reduce`,\n * committing the full text immediately; this takes precedence over an\n * explicit `smooth={true}`.\n *\n * @example\n * ```tsx\n * const { text, status } = useSmooth(useMessagePartText(), {\n * drainMs: 500,\n * maxCharsPerFrame: 30,\n * });\n * ```\n */\nexport const useSmooth = (\n state: MessagePartState & (TextMessagePart | ReasoningMessagePart),\n smooth: boolean | SmoothOptions = false,\n): MessagePartState & (TextMessagePart | ReasoningMessagePart) => {\n const { text } = state;\n const reduceMotion = useMediaQuery(\"(prefers-reduced-motion: reduce)\");\n const options =\n typeof smooth === \"object\" && smooth !== null ? smooth : undefined;\n const enabled = smooth !== false && smooth !== null && !reduceMotion;\n const drainMs = positiveOr(options?.drainMs, DEFAULT_DRAIN_MS);\n const maxCharIntervalMs = positiveOr(\n options?.maxCharIntervalMs,\n DEFAULT_MAX_CHAR_INTERVAL_MS,\n );\n const maxCharsPerFrame = positiveOr(options?.maxCharsPerFrame, Infinity);\n const minCommitMs = positiveOr(options?.minCommitMs, 0);\n\n const [displayedText, setDisplayedText] = useState(\n state.status.type === \"running\" ? \"\" : text,\n );\n\n // Render-phase resync on part flip or text discontinuity, so the\n // first paint after a thread switch never shows the previous\n // part's text (#4051). `displayedText` is already a prefix of\n // `text` during normal streaming, so use it as the previous-text\n // reference instead of carrying separate state — avoids the\n // double render per streaming token. Read part identity through\n // `useAuiState` so we actually subscribe to its changes instead\n // of relying on a render-time proxy reference that may be stable\n // across thread swaps.\n const aui = useAui();\n const part = useAuiState(() => aui.part);\n const [prevPart, setPrevPart] = useState(part);\n if (part !== prevPart || !text.startsWith(displayedText)) {\n setPrevPart(part);\n setDisplayedText(state.status.type === \"running\" ? \"\" : text);\n }\n\n const smoothStatusStore = useSmoothStatusStore({ optional: true });\n const setText = useCallbackRef((text: string) => {\n setDisplayedText(text);\n if (smoothStatusStore) {\n const target =\n displayedText !== text || state.status.type === \"running\"\n ? SMOOTH_STATUS\n : state.status;\n writableStore(smoothStatusStore).setState(target, true);\n }\n });\n\n // TODO this is hacky\n useEffect(() => {\n if (smoothStatusStore) {\n const target =\n enabled && (displayedText !== text || state.status.type === \"running\")\n ? SMOOTH_STATUS\n : state.status;\n writableStore(smoothStatusStore).setState(target, true);\n }\n }, [smoothStatusStore, enabled, text, displayedText, state.status]);\n\n const [animatorRef] = useState(\n () => new TextStreamAnimator(displayedText, setText),\n );\n\n useEffect(() => {\n animatorRef.drainMs = drainMs;\n animatorRef.maxCharIntervalMs = maxCharIntervalMs;\n animatorRef.maxCharsPerFrame = maxCharsPerFrame;\n animatorRef.minCommitMs = minCommitMs;\n }, [animatorRef, drainMs, maxCharIntervalMs, maxCharsPerFrame, minCommitMs]);\n\n const animatorPartRef = useRef(part);\n useEffect(() => {\n if (!enabled) {\n animatorRef.stop();\n return;\n }\n\n const partChanged = animatorPartRef.current !== part;\n animatorPartRef.current = part;\n // A new part whose text shares a prefix with the previous target would\n // keep the stale cursor and flicker without this reset.\n if (partChanged || !text.startsWith(animatorRef.targetText)) {\n if (state.status.type === \"running\") {\n animatorRef.currentText = \"\";\n animatorRef.targetText = text;\n animatorRef.lastCommitTime = -Infinity;\n animatorRef.start();\n } else {\n animatorRef.currentText = text;\n animatorRef.targetText = text;\n animatorRef.stop();\n setText(text);\n }\n return;\n }\n\n animatorRef.targetText = text;\n if (state.status.type !== \"running\") {\n // No character has painted. A pending frame that never runs would\n // leave an empty bubble after settle.\n if (animatorRef.currentText === \"\") {\n animatorRef.currentText = text;\n animatorRef.stop();\n setText(text);\n return;\n }\n animatorRef.start();\n return;\n }\n\n animatorRef.start();\n }, [animatorRef, enabled, text, state.status.type, part, setText]);\n\n useEffect(() => {\n return () => {\n animatorRef.stop();\n };\n }, [animatorRef]);\n\n return useMemo(\n () =>\n enabled\n ? {\n ...state,\n text: displayedText,\n status: text === displayedText ? state.status : SMOOTH_STATUS,\n }\n : state,\n [enabled, displayedText, state, text],\n );\n};\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react",
3
- "version": "0.15.23",
3
+ "version": "0.15.25",
4
4
  "description": "Open-source TypeScript/React library for building production-grade AI chat experiences",
5
5
  "keywords": [
6
6
  "ai",
@@ -55,14 +55,14 @@
55
55
  ],
56
56
  "sideEffects": false,
57
57
  "dependencies": {
58
- "@assistant-ui/core": "^0.3.22",
59
- "@assistant-ui/store": "^0.3.16",
60
- "@assistant-ui/tap": "^0.9.20",
61
- "assistant-cloud": "^0.2.4",
62
- "assistant-stream": "^0.3.46",
58
+ "@assistant-ui/core": "^0.3.24",
59
+ "@assistant-ui/store": "^0.3.17",
60
+ "@assistant-ui/tap": "^0.9.21",
61
+ "assistant-cloud": "^0.2.5",
62
+ "assistant-stream": "^0.3.48",
63
63
  "radix-ui": "^1.6.7",
64
64
  "react-textarea-autosize": "^8.5.9",
65
- "safe-content-frame": "^0.0.31",
65
+ "safe-content-frame": "^0.0.33",
66
66
  "zod": "^4.6.5",
67
67
  "zustand": "^5.0.15"
68
68
  },
@@ -81,18 +81,18 @@
81
81
  }
82
82
  },
83
83
  "devDependencies": {
84
- "@assistant-ui/vite": "0.0.19",
85
- "@assistant-ui/x-buildutils": "0.0.29",
84
+ "@assistant-ui/vite": "0.0.20",
85
+ "@assistant-ui/x-buildutils": "0.0.30",
86
86
  "@assistant-ui/x-react18": "0.0.0",
87
87
  "@testing-library/react": "^16.3.3",
88
88
  "@types/json-schema": "^7.0.15",
89
- "@types/node": "^26.5.1",
89
+ "@types/node": "^26.6.3",
90
90
  "@types/react": "^19.3.0",
91
91
  "@types/react-dom": "^19.3.0",
92
92
  "jsdom": "^30.0.1",
93
93
  "react": "^19.3.0",
94
94
  "react-dom": "^19.3.0",
95
- "vitest": "^5.0.1"
95
+ "vitest": "^5.0.3"
96
96
  },
97
97
  "publishConfig": {
98
98
  "access": "public",
@@ -110,6 +110,7 @@
110
110
  "scripts": {
111
111
  "build": "aui-build",
112
112
  "test": "vitest run",
113
+ "test:coverage": "vitest run --coverage --coverage.reporter=text --coverage.reporter=html --coverage.reporter=json-summary",
113
114
  "typecheck": "tsc --noEmit",
114
115
  "test:peer-react18": "vitest run --config vitest.peer-react18.config.ts",
115
116
  "test:watch": "vitest",
@@ -0,0 +1,35 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { renderToString } from "react-dom/server";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+
6
+ const { part } = vi.hoisted(() => ({
7
+ part: {
8
+ type: "tool-call" as const,
9
+ status: { type: "running" as const },
10
+ timing: { startedAt: 9000 },
11
+ },
12
+ }));
13
+
14
+ vi.mock("@assistant-ui/store", async (importOriginal) => ({
15
+ ...(await importOriginal<typeof import("@assistant-ui/store")>()),
16
+ useAuiState: (
17
+ selector: (state: { optional: { part: typeof part } }) => unknown,
18
+ ) => selector({ optional: { part } }),
19
+ }));
20
+
21
+ import { useToolCallElapsed } from "./useToolCallElapsed";
22
+
23
+ describe("useToolCallElapsed", () => {
24
+ afterEach(() => {
25
+ vi.restoreAllMocks();
26
+ });
27
+
28
+ it("does not read the wall clock during server render", () => {
29
+ const nowSpy = vi.spyOn(Date, "now");
30
+ const Probe = () => <span>{useToolCallElapsed() ?? "none"}</span>;
31
+
32
+ expect(renderToString(<Probe />)).toBe("<span>none</span>");
33
+ expect(nowSpy).not.toHaveBeenCalled();
34
+ });
35
+ });
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
3
+ import { useLayoutEffect, useState } from "react";
4
4
  import { useAuiState } from "@assistant-ui/store";
5
5
 
6
6
  /**
@@ -10,7 +10,9 @@ import { useAuiState } from "@assistant-ui/store";
10
10
  * Reads `part.timing`. Returns `undefined` when the part is not a tool call,
11
11
  * carries no timing, ended without a recorded completion (the duration is
12
12
  * unknown), or when no message part scope is available (so kit components
13
- * stay renderable standalone, e.g. in docs previews).
13
+ * stay renderable standalone, e.g. in docs previews). A running call also
14
+ * returns `undefined` until the component mounts, so a server render never
15
+ * reads the clock.
14
16
  *
15
17
  * @example
16
18
  * ```tsx
@@ -32,12 +34,12 @@ export const useToolCallElapsed = (): number | undefined => {
32
34
  });
33
35
  const running =
34
36
  timing !== undefined && timing.completedAt === undefined && partRunning;
35
- const [now, setNow] = useState(() => Date.now());
37
+ const [now, setNow] = useState<number | undefined>(undefined);
36
38
 
37
- useEffect(() => {
39
+ useLayoutEffect(() => {
38
40
  if (!running) return undefined;
39
- // The clock is an external source; this catches the elapsed value up before
40
- // the interval takes over.
41
+ // The clock is read only after mount so a prerender never reads it; the
42
+ // layout effect still catches the elapsed value up before the first paint.
41
43
  // eslint-disable-next-line react-hooks/set-state-in-effect
42
44
  setNow(Date.now());
43
45
  const id = setInterval(() => setNow(Date.now()), 1000);
@@ -47,6 +49,6 @@ export const useToolCallElapsed = (): number | undefined => {
47
49
  if (timing === undefined) return undefined;
48
50
  if (timing.completedAt !== undefined)
49
51
  return Math.max(0, timing.completedAt - timing.startedAt);
50
- if (!running) return undefined;
52
+ if (!running || now === undefined) return undefined;
51
53
  return Math.max(0, now - timing.startedAt);
52
54
  };
package/src/index.ts CHANGED
@@ -333,6 +333,11 @@ export * as ThreadListItemMorePrimitive from "./primitives/threadListItemMore";
333
333
  export * as SelectionToolbarPrimitive from "./primitives/selectionToolbar";
334
334
 
335
335
  export { groupPartByType, type GroupByContext } from "@assistant-ui/core/react";
336
+ export {
337
+ createThreadRowsSelector,
338
+ type ThreadRow,
339
+ type ThreadRowsOptions,
340
+ } from "@assistant-ui/core/react";
336
341
  export { unstable_useThreadMessageIds } from "@assistant-ui/core/react";
337
342
  export { useMessagePartText } from "./primitives/messagePart/useMessagePartText";
338
343
  export { useMessagePartReasoning } from "./primitives/messagePart/useMessagePartReasoning";
@@ -375,8 +380,12 @@ export type {
375
380
  ToolApprovalDisplay,
376
381
  ToolApprovalOption,
377
382
  ToolApprovalOptionKind,
383
+ ToolApprovalAnswer,
384
+ ToolApprovalQuestion,
385
+ ToolApprovalQuestionOption,
378
386
  ToolApprovalResponse,
379
387
  ToolCallMessagePart,
388
+ MessagePartTiming,
380
389
  ToolCallTiming,
381
390
  ToolModelContentPart,
382
391
  MessageStatus,
@@ -494,16 +503,32 @@ export {
494
503
  useTriggerPopoverTriggers as unstable_useTriggerPopoverTriggers,
495
504
  useTriggerPopoverTriggersOptional as unstable_useTriggerPopoverTriggersOptional,
496
505
  type RegisteredTrigger as Unstable_RegisteredTrigger,
497
- type TriggerMatch as Unstable_TriggerMatch,
498
- type TriggerMatcher as Unstable_TriggerMatcher,
506
+ type TriggerMatch,
507
+ type TriggerMatcher,
499
508
  type TriggerBehavior as Unstable_TriggerBehavior,
500
509
  } from "./primitives/composer/trigger";
510
+ import type {
511
+ TriggerMatch,
512
+ TriggerMatcher,
513
+ } from "./primitives/composer/trigger";
514
+ /** @deprecated Use `TriggerMatch` instead. */
515
+ export type Unstable_TriggerMatch = TriggerMatch;
516
+ /** @deprecated Use `TriggerMatcher` instead. */
517
+ export type Unstable_TriggerMatcher = TriggerMatcher;
501
518
  export type {
519
+ DirectiveFormatter,
520
+ DirectiveSegment,
521
+ TriggerAdapter,
522
+ TriggerCategory,
523
+ TriggerItem,
502
524
  Unstable_DirectiveFormatter,
503
525
  Unstable_DirectiveSegment,
504
526
  Unstable_TriggerItem,
505
527
  } from "@assistant-ui/core";
506
- export { unstable_defaultDirectiveFormatter } from "@assistant-ui/core";
528
+ export {
529
+ defaultDirectiveFormatter,
530
+ unstable_defaultDirectiveFormatter,
531
+ } from "@assistant-ui/core";
507
532
 
508
533
  // Unstable - composer input history (terminal-style ArrowUp/ArrowDown recall)
509
534
  export {
@@ -8,6 +8,11 @@ import {
8
8
  type ChangeEvent,
9
9
  } from "react";
10
10
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+ import {
12
+ mergeModelContexts,
13
+ type ModelContextProvider,
14
+ } from "@assistant-ui/core";
15
+ import type { Tool } from "assistant-stream";
11
16
 
12
17
  const { registerMock, auiMock } = vi.hoisted(() => {
13
18
  const register = vi.fn((_provider: unknown) => () => {});
@@ -30,10 +35,15 @@ type EditTool = {
30
35
  execute: (args: { editId: string; value: string }) => Promise<unknown>;
31
36
  };
32
37
 
33
- type ModelContextProvider = {
38
+ type ClickTool = {
39
+ execute: (args: { clickId: string }) => Promise<unknown>;
40
+ };
41
+
42
+ type VisibleToolsProvider = {
34
43
  getModelContext: () => {
35
44
  tools: {
36
- edit: EditTool;
45
+ edit?: EditTool;
46
+ click?: ClickTool;
37
47
  };
38
48
  };
39
49
  };
@@ -79,6 +89,36 @@ const ControlledTextarea = forwardRef<
79
89
  );
80
90
  });
81
91
 
92
+ const Button = forwardRef<HTMLButtonElement, ComponentProps<"button">>(
93
+ (props, ref) => <button {...props} ref={ref} />,
94
+ );
95
+
96
+ const SplitRefButton = forwardRef<HTMLButtonElement, ComponentProps<"div">>(
97
+ ({ children, ...props }, ref) => (
98
+ <div {...props}>
99
+ <button ref={ref}>{children}</button>
100
+ </div>
101
+ ),
102
+ );
103
+
104
+ const getRegisteredTools = () => {
105
+ const provider = registerMock.mock.calls[0]![0] as VisibleToolsProvider;
106
+ return provider.getModelContext().tools;
107
+ };
108
+
109
+ const executeTool = (tool: Tool | undefined, args: Record<string, unknown>) => {
110
+ if (!tool?.execute) throw new Error("Missing executable tool");
111
+ return Promise.resolve(
112
+ tool.execute(args, {
113
+ toolCallId: "test-call",
114
+ abortSignal: new AbortController().signal,
115
+ human: async () => {
116
+ throw new Error("Unexpected human input request");
117
+ },
118
+ }),
119
+ );
120
+ };
121
+
82
122
  const runEditTool = async (
83
123
  element: HTMLInputElement | HTMLTextAreaElement,
84
124
  value: string,
@@ -86,8 +126,9 @@ const runEditTool = async (
86
126
  const editId = element.dataset.editId;
87
127
  if (editId === undefined) throw new Error("Missing edit id");
88
128
 
89
- const provider = registerMock.mock.calls[0]![0] as ModelContextProvider;
90
- const task = provider.getModelContext().tools.edit.execute({
129
+ const edit = getRegisteredTools().edit;
130
+ if (!edit) throw new Error("Missing edit tool");
131
+ const task = edit.execute({
91
132
  editId,
92
133
  value,
93
134
  });
@@ -95,7 +136,7 @@ const runEditTool = async (
95
136
  await task;
96
137
  };
97
138
 
98
- describe("makeAssistantVisible editable components", () => {
139
+ describe("makeAssistantVisible", () => {
99
140
  beforeEach(() => {
100
141
  registerMock.mockClear();
101
142
  vi.stubGlobal("CSS", { escape: (value: string) => value });
@@ -143,4 +184,273 @@ describe("makeAssistantVisible editable components", () => {
143
184
  expect(textarea.getAttribute("data-react-value")).toBe("updated");
144
185
  expect(onChange).toHaveBeenCalledOnce();
145
186
  });
187
+
188
+ it("preserves the default action settle delay", async () => {
189
+ const EditableInput = makeAssistantVisible(ControlledInput, {
190
+ editable: true,
191
+ });
192
+ const input = render(<EditableInput />).getByRole("textbox");
193
+ const editId = (input as HTMLInputElement).dataset.editId!;
194
+ const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
195
+
196
+ expect(
197
+ (input as HTMLInputElement).dataset.actionSettleDelay,
198
+ ).toBeUndefined();
199
+
200
+ const task = getRegisteredTools().edit!.execute({
201
+ editId,
202
+ value: "updated",
203
+ });
204
+
205
+ expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2000);
206
+ await vi.runAllTimersAsync();
207
+ await task;
208
+ setTimeoutSpy.mockRestore();
209
+ });
210
+
211
+ it("uses the default when the rendered settle delay is invalid", async () => {
212
+ const ClickableButton = makeAssistantVisible(Button, {
213
+ clickable: true,
214
+ settleDelayMs: 25,
215
+ });
216
+ const button = render(<ClickableButton>Run</ClickableButton>).getByRole(
217
+ "button",
218
+ );
219
+ const clickId = (button as HTMLButtonElement).dataset.clickId!;
220
+ (button as HTMLButtonElement).dataset.actionSettleDelay = "invalid";
221
+ const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
222
+
223
+ const task = getRegisteredTools().click!.execute({ clickId });
224
+
225
+ expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2000);
226
+ await vi.runAllTimersAsync();
227
+ await task;
228
+ setTimeoutSpy.mockRestore();
229
+ });
230
+
231
+ it.each(["", " "])(
232
+ "uses the default for a blank rendered settle delay of %j",
233
+ async (renderedDelay) => {
234
+ const VisibleInput = makeAssistantVisible(ControlledInput, {
235
+ clickable: true,
236
+ editable: true,
237
+ settleDelayMs: 25,
238
+ });
239
+ const input = render(<VisibleInput />).getByRole("textbox");
240
+ const clickId = (input as HTMLInputElement).dataset.clickId!;
241
+ const editId = (input as HTMLInputElement).dataset.editId!;
242
+ const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
243
+ input.dataset.actionSettleDelay = renderedDelay;
244
+
245
+ const clickTask = getRegisteredTools().click!.execute({ clickId });
246
+ expect(setTimeoutSpy).toHaveBeenLastCalledWith(
247
+ expect.any(Function),
248
+ 2000,
249
+ );
250
+ await vi.runAllTimersAsync();
251
+ await clickTask;
252
+
253
+ setTimeoutSpy.mockClear();
254
+ const editTask = getRegisteredTools().edit!.execute({
255
+ editId,
256
+ value: "updated",
257
+ });
258
+ expect(setTimeoutSpy).toHaveBeenLastCalledWith(
259
+ expect.any(Function),
260
+ 2000,
261
+ );
262
+ await vi.runAllTimersAsync();
263
+ await editTask;
264
+ setTimeoutSpy.mockRestore();
265
+ },
266
+ );
267
+
268
+ it("uses the configured settle delay for click actions", async () => {
269
+ const ClickableButton = makeAssistantVisible(Button, {
270
+ clickable: true,
271
+ settleDelayMs: 25,
272
+ });
273
+ const onClick = vi.fn();
274
+ const button = render(
275
+ <ClickableButton onClick={onClick}>Run</ClickableButton>,
276
+ ).getByRole("button");
277
+ const clickId = (button as HTMLButtonElement).dataset.clickId!;
278
+ const settled = vi.fn();
279
+
280
+ const task = getRegisteredTools().click!.execute({ clickId });
281
+ void task.then(settled);
282
+
283
+ expect(onClick).toHaveBeenCalledOnce();
284
+ await vi.advanceTimersByTimeAsync(24);
285
+ expect(settled).not.toHaveBeenCalled();
286
+
287
+ await vi.advanceTimersByTimeAsync(1);
288
+ await task;
289
+ expect(settled).toHaveBeenCalledOnce();
290
+ });
291
+
292
+ it("uses the action id when the forwarded ref targets a child", async () => {
293
+ const ClickableButton = makeAssistantVisible(SplitRefButton, {
294
+ clickable: true,
295
+ settleDelayMs: 25,
296
+ });
297
+ const onClick = vi.fn();
298
+ const view = render(
299
+ <ClickableButton data-testid="action" onClick={onClick}>
300
+ Run
301
+ </ClickableButton>,
302
+ );
303
+ const action = view.getByTestId("action");
304
+ const clickId = action.dataset.clickId!;
305
+ const settled = vi.fn();
306
+
307
+ const task = getRegisteredTools().click!.execute({ clickId });
308
+ void task.then(settled);
309
+
310
+ expect(onClick).toHaveBeenCalledOnce();
311
+ await vi.advanceTimersByTimeAsync(24);
312
+ expect(settled).not.toHaveBeenCalled();
313
+
314
+ await vi.advanceTimersByTimeAsync(1);
315
+ await task;
316
+ expect(settled).toHaveBeenCalledOnce();
317
+ });
318
+
319
+ it("shares tools across wrappers while keeping per-element delays", async () => {
320
+ const SlowButton = makeAssistantVisible(Button, {
321
+ clickable: true,
322
+ editable: true,
323
+ settleDelayMs: 25,
324
+ });
325
+ const ImmediateButton = makeAssistantVisible(Button, {
326
+ clickable: true,
327
+ editable: true,
328
+ settleDelayMs: 0,
329
+ });
330
+ const view = render(
331
+ <>
332
+ <SlowButton>Slow</SlowButton>
333
+ <ImmediateButton>Immediate</ImmediateButton>
334
+ </>,
335
+ );
336
+ const providers = registerMock.mock.calls.map(
337
+ ([provider]) => provider as ModelContextProvider,
338
+ );
339
+ const mergedContext = mergeModelContexts(new Set(providers));
340
+
341
+ expect(mergedContext.tools?.click).toBe(
342
+ providers[0]!.getModelContext().tools?.click,
343
+ );
344
+ expect(mergedContext.tools?.click).toBe(
345
+ providers[1]!.getModelContext().tools?.click,
346
+ );
347
+ expect(mergedContext.tools?.edit).toBe(
348
+ providers[0]!.getModelContext().tools?.edit,
349
+ );
350
+ expect(mergedContext.tools?.edit).toBe(
351
+ providers[1]!.getModelContext().tools?.edit,
352
+ );
353
+
354
+ const [slowButton, immediateButton] = view.getAllByRole("button");
355
+ const click = mergedContext.tools!.click!;
356
+ const slowTask = executeTool(click, {
357
+ clickId: (slowButton as HTMLButtonElement).dataset.clickId!,
358
+ });
359
+ const immediateTask = executeTool(click, {
360
+ clickId: (immediateButton as HTMLButtonElement).dataset.clickId!,
361
+ });
362
+
363
+ await immediateTask;
364
+ let slowSettled = false;
365
+ void slowTask.then(() => {
366
+ slowSettled = true;
367
+ });
368
+ await vi.advanceTimersByTimeAsync(24);
369
+ expect(slowSettled).toBe(false);
370
+ await vi.advanceTimersByTimeAsync(1);
371
+ await slowTask;
372
+ });
373
+
374
+ it("shares the edit tool across wrappers while keeping per-element delays", async () => {
375
+ const SlowInput = makeAssistantVisible(ControlledInput, {
376
+ editable: true,
377
+ settleDelayMs: 25,
378
+ });
379
+ const ImmediateInput = makeAssistantVisible(ControlledInput, {
380
+ editable: true,
381
+ settleDelayMs: 0,
382
+ });
383
+ const view = render(
384
+ <>
385
+ <SlowInput aria-label="Slow" />
386
+ <ImmediateInput aria-label="Immediate" />
387
+ </>,
388
+ );
389
+ const providers = registerMock.mock.calls.map(
390
+ ([provider]) => provider as ModelContextProvider,
391
+ );
392
+ const mergedContext = mergeModelContexts(new Set(providers));
393
+
394
+ expect(mergedContext.tools?.edit).toBe(
395
+ providers[0]!.getModelContext().tools?.edit,
396
+ );
397
+ expect(mergedContext.tools?.edit).toBe(
398
+ providers[1]!.getModelContext().tools?.edit,
399
+ );
400
+
401
+ const slowInput = view.getByRole("textbox", { name: "Slow" });
402
+ const immediateInput = view.getByRole("textbox", { name: "Immediate" });
403
+ const edit = mergedContext.tools!.edit!;
404
+ const slowTask = executeTool(edit, {
405
+ editId: (slowInput as HTMLInputElement).dataset.editId!,
406
+ value: "slow update",
407
+ });
408
+ const immediateTask = executeTool(edit, {
409
+ editId: (immediateInput as HTMLInputElement).dataset.editId!,
410
+ value: "immediate update",
411
+ });
412
+
413
+ await immediateTask;
414
+ expect(immediateInput).toHaveProperty("value", "immediate update");
415
+ let slowSettled = false;
416
+ void slowTask.then(() => {
417
+ slowSettled = true;
418
+ });
419
+ await vi.advanceTimersByTimeAsync(24);
420
+ expect(slowSettled).toBe(false);
421
+
422
+ await vi.advanceTimersByTimeAsync(1);
423
+ await slowTask;
424
+ expect(slowInput).toHaveProperty("value", "slow update");
425
+ });
426
+
427
+ it("allows the action settle delay to be disabled", async () => {
428
+ const EditableInput = makeAssistantVisible(ControlledInput, {
429
+ editable: true,
430
+ settleDelayMs: 0,
431
+ });
432
+ const input = render(<EditableInput />).getByRole("textbox");
433
+ const editId = (input as HTMLInputElement).dataset.editId!;
434
+ const timersBefore = vi.getTimerCount();
435
+
436
+ const task = getRegisteredTools().edit!.execute({
437
+ editId,
438
+ value: "updated",
439
+ });
440
+
441
+ expect(vi.getTimerCount()).toBe(timersBefore);
442
+ await task;
443
+ expect(input).toHaveProperty("value", "updated");
444
+ });
445
+
446
+ it.each([-1, Number.NaN, Number.POSITIVE_INFINITY, 2_147_483_648])(
447
+ "rejects an invalid action settle delay of %s",
448
+ (settleDelayMs) => {
449
+ expect(() =>
450
+ makeAssistantVisible(Button, { clickable: true, settleDelayMs }),
451
+ ).toThrow(
452
+ new RangeError("settleDelayMs must be between 0 and 2147483647"),
453
+ );
454
+ },
455
+ );
146
456
  });