@assistant-ui/react 0.15.18 → 0.15.19

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 (100) hide show
  1. package/README.md +7 -1
  2. package/dist/context/providers/ThreadViewportProvider.js +2 -2
  3. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  4. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +2 -2
  7. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  8. package/dist/mcp-apps/app-frame.js +17 -12
  9. package/dist/mcp-apps/app-frame.js.map +1 -1
  10. package/dist/mcp-apps/bridge.js +7 -1
  11. package/dist/mcp-apps/bridge.js.map +1 -1
  12. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  13. package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
  14. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  15. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  16. package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
  17. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  18. package/dist/primitives/composer/ComposerInput.js +3 -8
  19. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  20. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  21. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
  22. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  23. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  24. package/dist/primitives/composer/trigger/triggerNavigationResource.js +127 -137
  25. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  26. package/dist/primitives/message/MessageParts.js +2 -14
  27. package/dist/primitives/message/MessageParts.js.map +1 -1
  28. package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
  29. package/dist/primitives/message/MessagePartsGrouped.js +2 -2
  30. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  31. package/dist/primitives/messagePart/MessagePartText.d.ts +3 -1
  32. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  33. package/dist/primitives/messagePart/MessagePartText.js +13 -36
  34. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  35. package/dist/primitives/reasoning/useScrollLock.js +3 -1
  36. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  37. package/dist/sandbox-host/SandboxHost.js +22 -4
  38. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  39. package/dist/unstable/useLiveCompletionAdapter.js +24 -2
  40. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  41. package/dist/unstable/useMentionAdapter.js +2 -2
  42. package/dist/unstable/useMentionAdapter.js.map +1 -1
  43. package/dist/unstable/useMessageStallDetection.d.ts +4 -5
  44. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  45. package/dist/unstable/useMessageStallDetection.js +16 -15
  46. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  47. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -1
  48. package/dist/unstable/webmcp/convertTools.js +54 -3
  49. package/dist/unstable/webmcp/convertTools.js.map +1 -1
  50. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  51. package/dist/unstable/webmcp/useWebMcpProvider.js +2 -1
  52. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  53. package/dist/utils/Primitive.d.ts +14 -6
  54. package/dist/utils/Primitive.d.ts.map +1 -1
  55. package/dist/utils/Primitive.js +25 -6
  56. package/dist/utils/Primitive.js.map +1 -1
  57. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  58. package/dist/utils/getSelectionMessageId.js +17 -3
  59. package/dist/utils/getSelectionMessageId.js.map +1 -1
  60. package/package.json +11 -11
  61. package/src/context/providers/ThreadViewportProvider.tsx +2 -2
  62. package/src/hooks/useToolCallElapsed.ts +3 -0
  63. package/src/index.ts +1 -1
  64. package/src/mcp-apps/app-frame.test.tsx +189 -0
  65. package/src/mcp-apps/app-frame.tsx +31 -22
  66. package/src/mcp-apps/bridge.test.ts +98 -0
  67. package/src/mcp-apps/bridge.ts +7 -1
  68. package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
  69. package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
  70. package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
  71. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
  72. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
  73. package/src/primitives/composer/ComposerInput.test.tsx +22 -0
  74. package/src/primitives/composer/ComposerInput.tsx +5 -9
  75. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
  76. package/src/primitives/composer/trigger/triggerNavigationResource.ts +5 -3
  77. package/src/primitives/message/MessageParts.tsx +1 -21
  78. package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
  79. package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
  80. package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
  81. package/src/primitives/messagePart/MessagePartText.tsx +22 -9
  82. package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
  83. package/src/primitives/reasoning/useScrollLock.ts +15 -1
  84. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
  85. package/src/sandbox-host/SandboxHost.test.tsx +40 -0
  86. package/src/sandbox-host/SandboxHost.tsx +24 -4
  87. package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
  88. package/src/tests/toolCallTiming.test.tsx +55 -1
  89. package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
  90. package/src/unstable/useLiveCompletionAdapter.ts +38 -8
  91. package/src/unstable/useMentionAdapter.test.tsx +47 -1
  92. package/src/unstable/useMentionAdapter.ts +2 -2
  93. package/src/unstable/useMessageStallDetection.ts +26 -19
  94. package/src/unstable/webmcp/convertTools.test.ts +181 -21
  95. package/src/unstable/webmcp/convertTools.ts +87 -7
  96. package/src/unstable/webmcp/useWebMcpProvider.ts +2 -4
  97. package/src/utils/Primitive.test.tsx +46 -1
  98. package/src/utils/Primitive.tsx +37 -7
  99. package/src/utils/getSelectionMessageId.test.ts +176 -1
  100. package/src/utils/getSelectionMessageId.ts +21 -3
@@ -0,0 +1,129 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { renderHook } from "@testing-library/react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { useScrollLock } from "./useScrollLock";
6
+
7
+ afterEach(() => {
8
+ vi.useRealTimers();
9
+ vi.unstubAllGlobals();
10
+ document.documentElement.removeAttribute("style");
11
+ document.body.removeAttribute("style");
12
+ document.body.replaceChildren();
13
+ });
14
+
15
+ /** jsdom reports zero for every box, so the widths a real browser would measure
16
+ * are stubbed on the element under test. */
17
+ function stubWidths(
18
+ element: HTMLElement,
19
+ { offsetWidth, clientWidth }: { offsetWidth: number; clientWidth: number },
20
+ ) {
21
+ Object.defineProperty(element, "offsetWidth", {
22
+ configurable: true,
23
+ value: offsetWidth,
24
+ });
25
+ Object.defineProperty(element, "clientWidth", {
26
+ configurable: true,
27
+ value: clientWidth,
28
+ });
29
+ }
30
+
31
+ function lockWithin(container: HTMLElement) {
32
+ const animated = document.createElement("div");
33
+ container.appendChild(animated);
34
+ const ref = { current: animated };
35
+ const { result } = renderHook(() => useScrollLock(ref, 200));
36
+ return result.current;
37
+ }
38
+
39
+ describe("useScrollLock", () => {
40
+ it("compensates for the scrollbar it hides on an element scroller", () => {
41
+ const scroller = document.createElement("div");
42
+ scroller.style.overflowY = "auto";
43
+ scroller.style.borderWidth = "0px";
44
+ scroller.style.paddingRight = "4px";
45
+ document.body.appendChild(scroller);
46
+ stubWidths(scroller, { offsetWidth: 306, clientWidth: 300 });
47
+
48
+ lockWithin(scroller)();
49
+
50
+ expect(scroller.style.scrollbarWidth).toBe("none");
51
+ expect(scroller.style.paddingRight).toBe("10px");
52
+ });
53
+
54
+ // The root element's offsetWidth already excludes the viewport scrollbar, so
55
+ // the element formula reports zero for it; without this the page's centered
56
+ // content shifts sideways for the length of the animation.
57
+ it("compensates when the scroller is the root element", () => {
58
+ const root = document.documentElement;
59
+ root.style.overflowY = "auto";
60
+ stubWidths(root, { offsetWidth: 1599, clientWidth: 1599 });
61
+ vi.stubGlobal("innerWidth", 1605);
62
+
63
+ lockWithin(document.body)();
64
+
65
+ expect(root.style.scrollbarWidth).toBe("none");
66
+ expect(root.style.paddingRight).toBe("6px");
67
+ });
68
+
69
+ // With `html { overflow: hidden }` the body scrolls on its own, so the
70
+ // viewport measure reports nothing and only the element formula sees the bar.
71
+ it("compensates when the body is the scroller rather than the viewport", () => {
72
+ const root = document.documentElement;
73
+ stubWidths(root, { offsetWidth: 1600, clientWidth: 1600 });
74
+ vi.stubGlobal("innerWidth", 1600);
75
+ const body = document.body;
76
+ body.style.overflowY = "auto";
77
+ body.style.borderWidth = "0px";
78
+ stubWidths(body, { offsetWidth: 1606, clientWidth: 1600 });
79
+
80
+ lockWithin(body)();
81
+
82
+ expect(body.style.paddingRight).toBe("6px");
83
+ });
84
+
85
+ // Both can scroll at once, and the padding lands on the body, so it is the
86
+ // body's own gutter that has to be replaced rather than the viewport's.
87
+ it("pads a scrolling body by its own gutter, not the viewport's", () => {
88
+ const root = document.documentElement;
89
+ stubWidths(root, { offsetWidth: 1585, clientWidth: 1585 });
90
+ vi.stubGlobal("innerWidth", 1600);
91
+ const body = document.body;
92
+ body.style.overflowY = "auto";
93
+ body.style.borderWidth = "0px";
94
+ stubWidths(body, { offsetWidth: 1591, clientWidth: 1585 });
95
+
96
+ lockWithin(body)();
97
+
98
+ expect(body.style.paddingRight).toBe("6px");
99
+ });
100
+
101
+ it("restores the padding it added once the animation is over", () => {
102
+ vi.useFakeTimers();
103
+ const scroller = document.createElement("div");
104
+ scroller.style.overflowY = "auto";
105
+ scroller.style.borderWidth = "0px";
106
+ document.body.appendChild(scroller);
107
+ stubWidths(scroller, { offsetWidth: 306, clientWidth: 300 });
108
+
109
+ lockWithin(scroller)();
110
+ expect(scroller.style.paddingRight).toBe("6px");
111
+
112
+ vi.advanceTimersByTime(200);
113
+
114
+ expect(scroller.style.paddingRight).toBe("");
115
+ expect(scroller.style.scrollbarWidth).toBe("");
116
+ });
117
+
118
+ it("leaves the padding alone when no scrollbar takes space", () => {
119
+ const scroller = document.createElement("div");
120
+ scroller.style.overflowY = "auto";
121
+ scroller.style.borderWidth = "0px";
122
+ document.body.appendChild(scroller);
123
+ stubWidths(scroller, { offsetWidth: 300, clientWidth: 300 });
124
+
125
+ lockWithin(scroller)();
126
+
127
+ expect(scroller.style.paddingRight).toBe("");
128
+ });
129
+ });
@@ -71,11 +71,25 @@ export const useScrollLock = <T extends HTMLElement = HTMLElement>(
71
71
  const paddingSide =
72
72
  computed.direction === "rtl" ? "paddingLeft" : "paddingRight";
73
73
  const previousPadding = scrollContainer.style[paddingSide];
74
- const scrollbarSize =
74
+ const elementScrollbarSize =
75
75
  scrollContainer.offsetWidth -
76
76
  scrollContainer.clientWidth -
77
77
  parseFloat(computed.borderLeftWidth) -
78
78
  parseFloat(computed.borderRightWidth);
79
+ // A root element's offsetWidth already excludes the viewport scrollbar, so
80
+ // the element formula reports zero once the scroll propagates to the
81
+ // viewport, and only then does the viewport measure apply. A body that
82
+ // scrolls in its own right is measured by the element formula like any
83
+ // other scroller, whether or not the viewport is scrolling too.
84
+ const ownerDocument = scrollContainer.ownerDocument;
85
+ const isRootScroller =
86
+ scrollContainer === ownerDocument.documentElement ||
87
+ scrollContainer === ownerDocument.body;
88
+ const scrollbarSize =
89
+ isRootScroller && elementScrollbarSize <= 0
90
+ ? (ownerDocument.defaultView?.innerWidth ?? 0) -
91
+ ownerDocument.documentElement.clientWidth
92
+ : elementScrollbarSize;
79
93
 
80
94
  scrollContainer.style.scrollbarWidth = "none";
81
95
  if (scrollbarSize > 0) {
@@ -1,6 +1,13 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
- import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
3
+ import {
4
+ act,
5
+ cleanup,
6
+ fireEvent,
7
+ render,
8
+ screen,
9
+ waitFor,
10
+ } from "@testing-library/react";
4
11
  import {
5
12
  afterAll,
6
13
  afterEach,
@@ -17,6 +24,7 @@ import { useThreadViewport } from "../../context/react/ThreadViewportContext";
17
24
  import * as MessagePrimitive from "../message";
18
25
  import { ThreadPrimitiveMessages } from "./ThreadMessages";
19
26
  import { ThreadPrimitiveRoot } from "./ThreadRoot";
27
+ import { ThreadPrimitiveScrollToBottom } from "./ThreadScrollToBottom";
20
28
  import { ThreadPrimitiveViewport } from "./ThreadViewport";
21
29
  import {
22
30
  ExportedMessageRepository,
@@ -242,6 +250,35 @@ const DelayedThread = ({
242
250
  };
243
251
 
244
252
  describe("useThreadViewportAutoScroll", () => {
253
+ it("preserves smooth scrolling from a control outside the viewport", async () => {
254
+ render(
255
+ <SyncRuntimeProvider>
256
+ <Thread autoScroll={false} scrollToBottomOnInitialize={false} />
257
+ <ThreadPrimitiveScrollToBottom behavior="smooth">
258
+ Scroll to bottom
259
+ </ThreadPrimitiveScrollToBottom>
260
+ </SyncRuntimeProvider>,
261
+ );
262
+
263
+ const viewport = getViewport();
264
+ act(() => {
265
+ viewport.dispatchEvent(new Event("scroll"));
266
+ });
267
+ const button = screen.getByRole("button", { name: "Scroll to bottom" });
268
+ await waitFor(() => expect(button.hasAttribute("disabled")).toBe(false));
269
+
270
+ const scrollToSpy = vi.spyOn(viewport, "scrollTo");
271
+ try {
272
+ fireEvent.click(button);
273
+ expect(scrollToSpy).toHaveBeenCalledWith({
274
+ top: viewport.scrollHeight,
275
+ behavior: "smooth",
276
+ });
277
+ } finally {
278
+ scrollToSpy.mockRestore();
279
+ }
280
+ });
281
+
245
282
  it("scrolls sync initialMessages to the bottom when the viewport mounts after initialization", async () => {
246
283
  render(
247
284
  <SyncRuntimeProvider>
@@ -327,6 +327,46 @@ describe("SandboxHost", () => {
327
327
  expect(onMessage).not.toHaveBeenCalled();
328
328
  });
329
329
 
330
+ it("disposes the frame when bridge cleanup throws", async () => {
331
+ const rendered = fakeRendered();
332
+ renderHtmlMock.mockResolvedValue(rendered);
333
+ const cleanupError = new Error("bridge cleanup failed");
334
+ const onMessage = vi.fn();
335
+ const bridge: SandboxBridge = {
336
+ onMessage,
337
+ dispose: vi.fn(() => {
338
+ throw cleanupError;
339
+ }),
340
+ };
341
+
342
+ await act(async () => {
343
+ root.render(
344
+ <SandboxHost
345
+ content={{ html: "" }}
346
+ contentKey="k"
347
+ createBridge={() => bridge}
348
+ />,
349
+ );
350
+ });
351
+ await flush();
352
+
353
+ expect(() => {
354
+ act(() => root.unmount());
355
+ }).toThrow(cleanupError);
356
+
357
+ expect(bridge.dispose).toHaveBeenCalledOnce();
358
+ expect(rendered.dispose).toHaveBeenCalledOnce();
359
+
360
+ window.dispatchEvent(
361
+ new MessageEvent("message", {
362
+ data: validData,
363
+ origin: rendered.origin,
364
+ source: rendered.iframe.contentWindow,
365
+ }),
366
+ );
367
+ expect(onMessage).not.toHaveBeenCalled();
368
+ });
369
+
330
370
  it("calls onError when rendering rejects", async () => {
331
371
  renderHtmlMock.mockRejectedValue(new Error("boom"));
332
372
  const onError = vi.fn();
@@ -193,15 +193,35 @@ export function SandboxHost({
193
193
 
194
194
  return () => {
195
195
  cancelled = true;
196
+ let cleanupFailed = false;
197
+ let cleanupError: unknown;
198
+ const runCleanup = (cleanup: () => void) => {
199
+ try {
200
+ cleanup();
201
+ } catch (error) {
202
+ if (cleanupFailed) {
203
+ console.error(error);
204
+ } else {
205
+ cleanupFailed = true;
206
+ cleanupError = error;
207
+ }
208
+ }
209
+ };
210
+
196
211
  if (onMessage) {
197
- window.removeEventListener("message", onMessage);
212
+ const listener = onMessage;
198
213
  onMessage = null;
214
+ runCleanup(() => window.removeEventListener("message", listener));
199
215
  }
200
- bridge?.dispose();
216
+ const bridgeToDispose = bridge;
201
217
  bridge = null;
202
- frame?.dispose();
218
+ if (bridgeToDispose) runCleanup(() => bridgeToDispose.dispose());
219
+ const frameToDispose = frame;
203
220
  frame = null;
204
- setContentHeight(undefined);
221
+ if (frameToDispose) runCleanup(() => frameToDispose.dispose());
222
+ runCleanup(() => setContentHeight(undefined));
223
+
224
+ if (cleanupFailed) throw cleanupError;
205
225
  };
206
226
  // oxlint-disable-next-line react/exhaustive-deps -- re-init only on contentKey change; live values flow through liveRef
207
227
  }, [contentKey]);
@@ -0,0 +1,117 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import {
7
+ AssistantRuntimeProvider,
8
+ MessageByIndexProvider,
9
+ useExternalStoreRuntime,
10
+ } from "@assistant-ui/core/react";
11
+ import type { ThreadMessageLike } from "@assistant-ui/core";
12
+ import { MessagePrimitiveParts } from "../primitives/message/MessageParts";
13
+
14
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ const messages: ThreadMessageLike[] = [
17
+ {
18
+ role: "assistant",
19
+ content: [
20
+ { type: "text", text: "Answer" },
21
+ { type: "reasoning", text: "Thinking" },
22
+ {
23
+ type: "generative-ui",
24
+ spec: {
25
+ root: [
26
+ { component: "Card", props: { title: "Result" } },
27
+ { component: "Unknown" },
28
+ ],
29
+ },
30
+ },
31
+ { type: "data", name: "chart", data: 42 },
32
+ { type: "data", name: "other", data: 7 },
33
+ ],
34
+ },
35
+ ];
36
+
37
+ const components = {
38
+ generativeUI: {
39
+ components: {
40
+ Card: ({ title }: { title: string }) => <span>{title}</span>,
41
+ },
42
+ Fallback: ({ component }: { component: string }) => (
43
+ <span>Unavailable: {component}</span>
44
+ ),
45
+ },
46
+ data: {
47
+ by_name: {
48
+ chart: ({ data }: { data: unknown }) => (
49
+ <span>Chart: {String(data)}</span>
50
+ ),
51
+ },
52
+ Fallback: ({ data }: { data: unknown }) => (
53
+ <span>Data: {String(data)}</span>
54
+ ),
55
+ },
56
+ };
57
+
58
+ const App = (props: MessagePrimitiveParts.Props) => {
59
+ const runtime = useExternalStoreRuntime({
60
+ messages,
61
+ convertMessage: (message) => message,
62
+ onNew: async () => {
63
+ throw new Error("This thread is read-only");
64
+ },
65
+ });
66
+ return (
67
+ <AssistantRuntimeProvider runtime={runtime}>
68
+ <MessageByIndexProvider index={0}>
69
+ <MessagePrimitiveParts {...props} />
70
+ </MessageByIndexProvider>
71
+ </AssistantRuntimeProvider>
72
+ );
73
+ };
74
+
75
+ const ChainOfThought = () => <span>Thought group</span>;
76
+
77
+ describe("MessagePrimitiveParts", () => {
78
+ let container: HTMLDivElement;
79
+ let root: Root;
80
+
81
+ beforeEach(() => {
82
+ container = document.createElement("div");
83
+ document.body.appendChild(container);
84
+ root = createRoot(container);
85
+ });
86
+
87
+ afterEach(async () => {
88
+ await act(async () => root.unmount());
89
+ container.remove();
90
+ });
91
+
92
+ it("renders generative UI and its fallback beside web text", async () => {
93
+ await act(async () => root.render(<App components={components} />));
94
+ expect(container.textContent).toBe(
95
+ "AnswerResultUnavailable: UnknownChart: 42Data: 7",
96
+ );
97
+ });
98
+
99
+ it("keeps data and generative UI when chain-of-thought grouping changes", async () => {
100
+ await act(async () => root.render(<App components={components} />));
101
+ expect(container.textContent).toBe(
102
+ "AnswerResultUnavailable: UnknownChart: 42Data: 7",
103
+ );
104
+
105
+ await act(async () =>
106
+ root.render(<App components={{ ...components, ChainOfThought }} />),
107
+ );
108
+ expect(container.textContent).toBe(
109
+ "AnswerThought groupResultUnavailable: UnknownChart: 42Data: 7",
110
+ );
111
+
112
+ await act(async () => root.render(<App components={components} />));
113
+ expect(container.textContent).toBe(
114
+ "AnswerResultUnavailable: UnknownChart: 42Data: 7",
115
+ );
116
+ });
117
+ });
@@ -7,8 +7,10 @@ import { AssistantRuntimeProvider } from "../context";
7
7
  import * as MessagePrimitive from "../primitives/message";
8
8
  import * as ThreadPrimitive from "../primitives/thread";
9
9
  import { useLocalRuntime } from "../legacy-runtime/runtime-cores/local/useLocalRuntime";
10
- import type { ChatModelAdapter, ThreadMessageLike } from "../index";
11
10
  import {
11
+ type ChatModelAdapter,
12
+ type ThreadMessageLike,
13
+ useExternalStoreRuntime,
12
14
  useToolCallElapsed,
13
15
  unstable_useMessageStallDetection,
14
16
  } from "../index";
@@ -60,6 +62,22 @@ const RuntimeProvider: FC<
60
62
  );
61
63
  };
62
64
 
65
+ const ExternalRuntimeProvider: FC<
66
+ PropsWithChildren<{ messages: ThreadMessageLike[] }>
67
+ > = ({ messages, children }) => {
68
+ const runtime = useExternalStoreRuntime({
69
+ messages,
70
+ isRunning: true,
71
+ convertMessage: (message) => message,
72
+ onNew: async () => {},
73
+ });
74
+ return (
75
+ <AssistantRuntimeProvider runtime={runtime}>
76
+ {children}
77
+ </AssistantRuntimeProvider>
78
+ );
79
+ };
80
+
63
81
  const renderHarness = async (messages: ThreadMessageLike[], probe: FC) => {
64
82
  render(<Harness messages={messages} probe={probe} />);
65
83
  // Flush the runtime's deferred initialization, which fake timers hold back.
@@ -83,6 +101,19 @@ const Harness: FC<{ messages: ThreadMessageLike[]; probe: FC }> = ({
83
101
  </RuntimeProvider>
84
102
  );
85
103
 
104
+ const ExternalHarness: FC<{
105
+ messages: ThreadMessageLike[];
106
+ probe: FC;
107
+ }> = ({ messages, probe: Probe }) => (
108
+ <ExternalRuntimeProvider messages={messages}>
109
+ <ThreadPrimitive.Messages
110
+ components={{
111
+ Message: () => <MessagePrimitive.Parts components={{ Text: Probe }} />,
112
+ }}
113
+ />
114
+ </ExternalRuntimeProvider>
115
+ );
116
+
86
117
  describe("useToolCallElapsed", () => {
87
118
  beforeEach(() => {
88
119
  vi.useFakeTimers();
@@ -218,4 +249,27 @@ describe("unstable_useMessageStallDetection", () => {
218
249
 
219
250
  expect(screen.getByTestId("stalled").textContent).toBe("false");
220
251
  });
252
+
253
+ it("resets the timer for equal-length content changes", async () => {
254
+ const message = (text: string): ThreadMessageLike => ({
255
+ role: "assistant",
256
+ content: [{ type: "text", text }],
257
+ status: { type: "running" },
258
+ });
259
+ const view = render(
260
+ <ExternalHarness messages={[message("first")]} probe={StallProbe} />,
261
+ );
262
+ await act(async () => vi.advanceTimersByTimeAsync(0));
263
+
264
+ await act(async () => vi.advanceTimersByTimeAsync(1500));
265
+ view.rerender(
266
+ <ExternalHarness messages={[message("other")]} probe={StallProbe} />,
267
+ );
268
+ await act(async () => vi.advanceTimersByTimeAsync(1000));
269
+
270
+ expect(screen.getByTestId("stalled").textContent).toBe("false");
271
+
272
+ await act(async () => vi.advanceTimersByTimeAsync(1000));
273
+ expect(screen.getByTestId("stalled").textContent).toBe("true");
274
+ });
221
275
  });