@assistant-ui/react 0.15.28 → 0.15.30

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 (71) hide show
  1. package/dist/adapters/OpenAIRealtimeVoiceAdapter.d.ts +22 -0
  2. package/dist/adapters/OpenAIRealtimeVoiceAdapter.d.ts.map +1 -0
  3. package/dist/adapters/OpenAIRealtimeVoiceAdapter.js +297 -0
  4. package/dist/adapters/OpenAIRealtimeVoiceAdapter.js.map +1 -0
  5. package/dist/context/stores/ThreadViewport.d.ts +8 -0
  6. package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
  7. package/dist/context/stores/ThreadViewport.js +24 -8
  8. package/dist/context/stores/ThreadViewport.js.map +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -2
  12. package/dist/legacy-runtime/runtime-cores/utils/MessageRepository.js +1 -1
  13. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  14. package/dist/mcp-apps/McpAppRenderer.js +5 -1
  15. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  16. package/dist/model-context/frame/useAssistantFrameHost.d.ts.map +1 -1
  17. package/dist/model-context/frame/useAssistantFrameHost.js +67 -46
  18. package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
  19. package/dist/model-context/makeAssistantVisible.js +1 -1
  20. package/dist/primitives/composer/ComposerAddAttachment.d.ts.map +1 -1
  21. package/dist/primitives/composer/ComposerAddAttachment.js +28 -10
  22. package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
  23. package/dist/primitives/composer/ComposerInput.d.ts +4 -1
  24. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  25. package/dist/primitives/composer/ComposerInput.js +15 -2
  26. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  27. package/dist/primitives/composer/ComposerResume.d.ts +23 -0
  28. package/dist/primitives/composer/ComposerResume.d.ts.map +1 -0
  29. package/dist/primitives/composer/ComposerResume.js +34 -0
  30. package/dist/primitives/composer/ComposerResume.js.map +1 -0
  31. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +1 -1
  32. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +1 -1
  33. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts +6 -2
  34. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
  35. package/dist/primitives/composer/trigger/triggerSelectionResource.js +4 -1
  36. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  37. package/dist/primitives/composer.d.ts +1 -0
  38. package/dist/primitives/composer.d.ts.map +1 -1
  39. package/dist/primitives/composer.js +3 -1
  40. package/dist/primitives/composer.js.map +1 -1
  41. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  42. package/dist/primitives/thread/useThreadViewportAutoScroll.js +32 -292
  43. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  44. package/dist/unstable/useMentionAdapter.js +1 -1
  45. package/dist/utils/Primitive.d.ts +14 -14
  46. package/dist/utils/hooks/useOnResizeContent.js +1 -1
  47. package/package.json +5 -5
  48. package/src/adapters/OpenAIRealtimeVoiceAdapter.test.ts +748 -0
  49. package/src/adapters/OpenAIRealtimeVoiceAdapter.ts +388 -0
  50. package/src/context/stores/ThreadViewport.ts +36 -8
  51. package/src/index.ts +6 -0
  52. package/src/mcp-apps/McpAppRenderer.test.tsx +65 -0
  53. package/src/mcp-apps/McpAppRenderer.tsx +13 -2
  54. package/src/model-context/frame/useAssistantFrameHost.detach.test.tsx +145 -0
  55. package/src/model-context/frame/useAssistantFrameHost.reload.test.tsx +106 -0
  56. package/src/model-context/frame/useAssistantFrameHost.test.tsx +3 -1
  57. package/src/model-context/frame/useAssistantFrameHost.ts +72 -24
  58. package/src/model-context/makeAssistantVisible.test.tsx +25 -37
  59. package/src/primitives/composer/ComposerAddAttachment.test.tsx +1 -0
  60. package/src/primitives/composer/ComposerAddAttachment.thread-scope.test.tsx +202 -0
  61. package/src/primitives/composer/ComposerAddAttachment.ts +11 -1
  62. package/src/primitives/composer/ComposerInput.focus-on-run-start.test.tsx +130 -0
  63. package/src/primitives/composer/ComposerInput.tsx +30 -4
  64. package/src/primitives/composer/ComposerResume.ts +36 -0
  65. package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +24 -0
  66. package/src/primitives/composer/trigger/triggerSelectionResource.ts +14 -3
  67. package/src/primitives/composer/useAuiState.composer.test.tsx +84 -0
  68. package/src/primitives/composer.ts +1 -0
  69. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +90 -0
  70. package/src/primitives/thread/useThreadViewportAutoScroll.ts +48 -486
  71. package/src/model-context/frame/AssistantFrame.test.ts +0 -400
@@ -5,7 +5,7 @@ import {
5
5
  type ActionButtonProps,
6
6
  createActionButton,
7
7
  } from "../../utils/createActionButton";
8
- import { useCallback } from "react";
8
+ import { useCallback, useInsertionEffect, useRef } from "react";
9
9
  import { useAui } from "@assistant-ui/store";
10
10
  import { useComposerAddAttachment as useComposerAddAttachmentBehavior } from "@assistant-ui/core/react";
11
11
 
@@ -17,8 +17,17 @@ const useComposerAddAttachment = ({
17
17
  } = {}) => {
18
18
  const { disabled, addAttachment } = useComposerAddAttachmentBehavior();
19
19
  const aui = useAui();
20
+ const scopeGenerationRef = useRef(0);
21
+
22
+ useInsertionEffect(
23
+ () => () => {
24
+ scopeGenerationRef.current += 1;
25
+ },
26
+ [aui.composer, aui.optional.threadListItem],
27
+ );
20
28
 
21
29
  const callback = useCallback(() => {
30
+ const scopeGeneration = scopeGenerationRef.current;
22
31
  const input = document.createElement("input");
23
32
  input.type = "file";
24
33
  input.multiple = multiple;
@@ -37,6 +46,7 @@ const useComposerAddAttachment = ({
37
46
 
38
47
  const attachmentPromises = Array.from(fileList, async (file) => {
39
48
  try {
49
+ if (scopeGenerationRef.current !== scopeGeneration) return;
40
50
  await addAttachment(file);
41
51
  } catch {
42
52
  // The composer runtime emits composer.attachmentAddError before rejecting.
@@ -0,0 +1,130 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, fireEvent, render, screen } from "@testing-library/react";
4
+ import { describe, expect, it } from "vitest";
5
+ import { AssistantRuntimeProvider } from "../../context";
6
+ import { useLocalRuntime } from "../../legacy-runtime/runtime-cores/local/useLocalRuntime";
7
+ import { ThreadPrimitiveRoot } from "../thread/ThreadRoot";
8
+ import { ComposerPrimitiveInput } from "./ComposerInput";
9
+ import { ComposerPrimitiveRoot } from "./ComposerRoot";
10
+
11
+ const adapter = { async *run() {} };
12
+
13
+ type Runtime = ReturnType<typeof useLocalRuntime>;
14
+
15
+ const setup = () => {
16
+ let runtime: Runtime | null = null;
17
+ const App = () => {
18
+ const currentRuntime = useLocalRuntime(adapter);
19
+ runtime = currentRuntime;
20
+ return (
21
+ <AssistantRuntimeProvider runtime={currentRuntime}>
22
+ <input aria-label="Outside thread" />
23
+ <ThreadPrimitiveRoot>
24
+ <button type="button">Inside thread</button>
25
+ <ComposerPrimitiveInput aria-label="Composer" autoFocus />
26
+ <ThreadPrimitiveRoot>
27
+ <button type="button">Nested thread</button>
28
+ </ThreadPrimitiveRoot>
29
+ </ThreadPrimitiveRoot>
30
+ </AssistantRuntimeProvider>
31
+ );
32
+ };
33
+
34
+ render(<App />);
35
+ const composer = screen.getByRole("textbox", {
36
+ name: "Composer",
37
+ }) as HTMLTextAreaElement;
38
+ const startRun = async () => {
39
+ await act(async () => {
40
+ await runtime!.thread.append("Programmatic message");
41
+ });
42
+ };
43
+ return { composer, startRun };
44
+ };
45
+
46
+ describe("ComposerPrimitiveInput run-start focus", () => {
47
+ it("focuses the composer when its button is focused without a thread root", async () => {
48
+ let runtime: Runtime | null = null;
49
+ const App = () => {
50
+ const currentRuntime = useLocalRuntime(adapter);
51
+ runtime = currentRuntime;
52
+ return (
53
+ <AssistantRuntimeProvider runtime={currentRuntime}>
54
+ <ComposerPrimitiveRoot>
55
+ <ComposerPrimitiveInput aria-label="Floating composer" autoFocus />
56
+ <button type="button">Send</button>
57
+ </ComposerPrimitiveRoot>
58
+ </AssistantRuntimeProvider>
59
+ );
60
+ };
61
+
62
+ render(<App />);
63
+ const composer = screen.getByRole("textbox", {
64
+ name: "Floating composer",
65
+ });
66
+ const send = screen.getByRole("button", { name: "Send" });
67
+ act(() => send.focus());
68
+
69
+ await act(async () => {
70
+ await runtime!.thread.append("Programmatic message");
71
+ });
72
+
73
+ expect(document.activeElement).toBe(composer);
74
+ });
75
+
76
+ it("keeps focus in an input outside the thread", async () => {
77
+ const { startRun } = setup();
78
+ const outside = screen.getByRole("textbox", { name: "Outside thread" });
79
+ act(() => outside.focus());
80
+
81
+ await startRun();
82
+
83
+ expect(document.activeElement).toBe(outside);
84
+ });
85
+
86
+ it("focuses the composer when nothing is focused", async () => {
87
+ const { composer, startRun } = setup();
88
+ act(() => composer.blur());
89
+ expect(document.activeElement).toBe(document.body);
90
+
91
+ await startRun();
92
+
93
+ expect(document.activeElement).toBe(composer);
94
+ });
95
+
96
+ it("focuses the composer when another element in its thread is focused", async () => {
97
+ const { composer, startRun } = setup();
98
+ const inside = screen.getByRole("button", { name: "Inside thread" });
99
+ act(() => inside.focus());
100
+
101
+ await startRun();
102
+
103
+ expect(document.activeElement).toBe(composer);
104
+ });
105
+
106
+ it("keeps focus inside a nested thread root", async () => {
107
+ const { startRun } = setup();
108
+ const nested = screen.getByRole("button", { name: "Nested thread" });
109
+ act(() => nested.focus());
110
+
111
+ await startRun();
112
+
113
+ expect(document.activeElement).toBe(nested);
114
+ });
115
+
116
+ it("preserves the focused composer's selection", async () => {
117
+ const { composer, startRun } = setup();
118
+ fireEvent.change(composer, { target: { value: "hello world" } });
119
+ act(() => {
120
+ composer.focus();
121
+ composer.setSelectionRange(2, 5);
122
+ });
123
+
124
+ await startRun();
125
+
126
+ expect(document.activeElement).toBe(composer);
127
+ expect(composer.selectionStart).toBe(2);
128
+ expect(composer.selectionEnd).toBe(5);
129
+ });
130
+ });
@@ -28,6 +28,7 @@ import { flushTapSync } from "@assistant-ui/tap";
28
28
  import { useComposerInputPluginRegistryOptional } from "./ComposerInputPluginContext";
29
29
  import { useComposerCompactContextOptional } from "./ComposerCompactContext";
30
30
  import { useComposerCancelWithFocus } from "./useComposerCancelWithFocus";
31
+ import { useThreadRootElementRef } from "../thread/ThreadRootElementContext";
31
32
  import {
32
33
  useComposerInputDisabled,
33
34
  useComposerInputValue,
@@ -57,7 +58,10 @@ export namespace ComposerPrimitiveInput {
57
58
  */
58
59
  cancelOnEscape?: boolean | undefined;
59
60
  /**
60
- * Whether to automatically focus the input when a new run starts.
61
+ * Whether to focus the input when a new run starts if nothing is focused
62
+ * or focus is already inside the same thread or composer. Keeps the current
63
+ * selection when the input is already focused. Applies when `autoFocus` is
64
+ * enabled.
61
65
  *
62
66
  * @deprecated Experimental since 2024-10-12. Not scheduled for removal; the API may change in any release.
63
67
  *
@@ -189,6 +193,7 @@ export const ComposerPrimitiveInput = forwardRef<
189
193
  forwardedRef,
190
194
  ) => {
191
195
  const aui = useAui();
196
+ const threadRootRef = useThreadRootElementRef();
192
197
  const pluginRegistry = useComposerInputPluginRegistryOptional();
193
198
  const compactContext = useComposerCompactContextOptional();
194
199
 
@@ -343,6 +348,27 @@ export const ComposerPrimitiveInput = forwardRef<
343
348
  textarea.setSelectionRange(textarea.value.length, textarea.value.length);
344
349
  }, [autoFocusEnabled]);
345
350
 
351
+ const focusOnRunStart = useCallback(() => {
352
+ const textarea = textareaRef.current;
353
+ if (!textarea) return;
354
+ const doc = textarea.ownerDocument;
355
+ const active = doc.activeElement;
356
+ if (active === textarea) return;
357
+
358
+ const root = threadRootRef?.current;
359
+ const composerRoot = textarea.closest("[data-aui-composer-type]");
360
+ if (
361
+ active &&
362
+ active !== doc.body &&
363
+ !composerRoot?.contains(active) &&
364
+ (!root?.contains(active) ||
365
+ active.closest("[data-aui-thread-root]") !== root)
366
+ )
367
+ return;
368
+
369
+ focus();
370
+ }, [focus, threadRootRef]);
371
+
346
372
  useEffect(() => focus(), [focus]);
347
373
 
348
374
  useOnScrollToBottom(() => {
@@ -361,8 +387,8 @@ export const ComposerPrimitiveInput = forwardRef<
361
387
  )
362
388
  return undefined;
363
389
 
364
- return aui.on("thread.runStart", focus);
365
- }, [unstable_focusOnRunStart, focus, aui]);
390
+ return aui.on("thread.runStart", focusOnRunStart);
391
+ }, [unstable_focusOnRunStart, focusOnRunStart, aui]);
366
392
 
367
393
  useEffect(() => {
368
394
  if (
@@ -398,7 +424,7 @@ export const ComposerPrimitiveInput = forwardRef<
398
424
  compositionRef.current = false;
399
425
  }
400
426
  const isComposing = nativeIsComposing || compositionRef.current;
401
- // keep controlled value in sync mid-IME so react does not reset the textarea to a stale value
427
+ // Standalone tap roots need a synchronous flush while the input is composing.
402
428
  flushTapSync(() => {
403
429
  aui.composer.setText(e.target.value);
404
430
  });
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import { useComposerResume as useComposerResumeBehavior } from "@assistant-ui/core/react";
5
+ import {
6
+ createActionButton,
7
+ type ActionButtonElement,
8
+ type ActionButtonProps,
9
+ } from "../../utils/createActionButton";
10
+
11
+ const useComposerResume = () => {
12
+ const { disabled, resume } = useComposerResumeBehavior();
13
+ const callback = useCallback(() => resume(), [resume]);
14
+ if (disabled) return null;
15
+ return callback;
16
+ };
17
+
18
+ export namespace ComposerPrimitiveResume {
19
+ export type Element = ActionButtonElement;
20
+ export type Props = ActionButtonProps<typeof useComposerResume>;
21
+ }
22
+
23
+ /**
24
+ * Resumes an interrupted run when its adapter explicitly reports a checkpoint.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * <ComposerPrimitive.Resume>
29
+ * Resume
30
+ * </ComposerPrimitive.Resume>
31
+ * ```
32
+ */
33
+ export const ComposerPrimitiveResume = createActionButton(
34
+ "ComposerPrimitive.Resume",
35
+ useComposerResume,
36
+ );
@@ -82,6 +82,30 @@ describe("TriggerSelectionResource", () => {
82
82
  expect(onSelected).toHaveBeenCalledOnce();
83
83
  });
84
84
 
85
+ it("reports an inserted directive once a select override finishes the insertion", () => {
86
+ const onInserted = vi.fn();
87
+ const { root, setText, onSelected } = render({
88
+ behavior: { kind: "directive", formatter, onInserted },
89
+ text: "@file tail",
90
+ trigger: { offset: 0, query: "file", endOffset: 5 },
91
+ });
92
+ let finishInsertion: (() => void) | undefined;
93
+ root.getValue().registerSelectItemOverride((_, onOverrideInserted) => {
94
+ finishInsertion = onOverrideInserted;
95
+ return true;
96
+ });
97
+
98
+ root.getValue().selectItem(item);
99
+
100
+ expect(onInserted).not.toHaveBeenCalled();
101
+ expect(setText).not.toHaveBeenCalled();
102
+ expect(onSelected).toHaveBeenCalledOnce();
103
+
104
+ finishInsertion?.();
105
+
106
+ expect(onInserted).toHaveBeenCalledExactlyOnceWith(item);
107
+ });
108
+
85
109
  it("resynchronizes the cursor after removing an action", () => {
86
110
  const { root, setText, setCursorPosition } = render({
87
111
  behavior: {
@@ -7,8 +7,15 @@ import type {
7
7
  import type { AssistantClient } from "@assistant-ui/store";
8
8
  import type { DetectedTrigger } from "./triggerDetectionResource";
9
9
 
10
- /** External override for selection (used by Lexical's DirectivePlugin). */
11
- export type SelectItemOverride = (item: Unstable_TriggerItem) => boolean;
10
+ /**
11
+ * External override for selection (used by Lexical's DirectivePlugin).
12
+ * Returns whether it handled the item, and calls `onInserted` once the
13
+ * insertion has reached the composer.
14
+ */
15
+ export type SelectItemOverride = (
16
+ item: Unstable_TriggerItem,
17
+ onInserted: () => void,
18
+ ) => boolean;
12
19
 
13
20
  export type TriggerBehavior =
14
21
  | {
@@ -66,7 +73,11 @@ const useTriggerSelectionResource = ({
66
73
  const selectItem = useEffectEvent((item: Unstable_TriggerItem) => {
67
74
  if (!trigger || !behavior) return;
68
75
 
69
- if (selectItemOverrideRef.current?.(item)) {
76
+ const reportInserted = () => {
77
+ if (behavior.kind === "directive") behavior.onInserted?.(item);
78
+ };
79
+
80
+ if (selectItemOverrideRef.current?.(item, reportInserted)) {
70
81
  onSelected();
71
82
  return;
72
83
  }
@@ -0,0 +1,84 @@
1
+ /** @vitest-environment jsdom */
2
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import { useState } from "react";
4
+ import { describe, expect, it } from "vitest";
5
+ import {
6
+ AssistantRuntimeProvider,
7
+ useExternalStoreRuntime,
8
+ } from "@assistant-ui/core/react";
9
+ import type { ThreadMessage } from "@assistant-ui/core";
10
+ import { useAui, useAuiState } from "@assistant-ui/store";
11
+
12
+ function Input({
13
+ value,
14
+ onChange,
15
+ withOwnState,
16
+ }: {
17
+ value: string;
18
+ onChange: (value: string) => void;
19
+ withOwnState: boolean;
20
+ }) {
21
+ const [, setChangeCount] = useState(0);
22
+ return (
23
+ <textarea
24
+ aria-label="composer"
25
+ value={value}
26
+ onChange={(event) => {
27
+ if (withOwnState) setChangeCount((count) => count + 1);
28
+ onChange(event.target.value);
29
+ }}
30
+ />
31
+ );
32
+ }
33
+
34
+ function BoundInput({ withOwnState }: { withOwnState: boolean }) {
35
+ const aui = useAui();
36
+ const text = useAuiState((s) => s.composer.text);
37
+ return (
38
+ <>
39
+ <button onClick={() => aui.composer.setText("hello world")}>seed</button>
40
+ <Input
41
+ value={text}
42
+ onChange={(value) => aui.composer.setText(value)}
43
+ withOwnState={withOwnState}
44
+ />
45
+ </>
46
+ );
47
+ }
48
+
49
+ function App({ withOwnState }: { withOwnState: boolean }) {
50
+ const runtime = useExternalStoreRuntime<ThreadMessage>({
51
+ messages: [],
52
+ convertMessage: (message) => message,
53
+ onNew: async () => {},
54
+ });
55
+ return (
56
+ <AssistantRuntimeProvider runtime={runtime}>
57
+ <BoundInput withOwnState={withOwnState} />
58
+ </AssistantRuntimeProvider>
59
+ );
60
+ }
61
+
62
+ describe("composer state in controlled inputs", () => {
63
+ it.each([
64
+ ["mentions-like", true],
65
+ ["plain textarea", false],
66
+ ])(
67
+ "keeps the caret after typing in the middle: %s",
68
+ async (_, withOwnState) => {
69
+ render(<App withOwnState={withOwnState} />);
70
+ fireEvent.click(screen.getByRole("button", { name: "seed" }));
71
+ const input = screen.getByRole("textbox", {
72
+ name: "composer",
73
+ }) as HTMLTextAreaElement;
74
+ await waitFor(() => expect(input.value).toBe("hello world"));
75
+
76
+ input.setSelectionRange(5, 5);
77
+ fireEvent.change(input, {
78
+ target: { value: "helloX world", selectionStart: 6, selectionEnd: 6 },
79
+ });
80
+ await waitFor(() => expect(input.value).toBe("helloX world"));
81
+ expect(input.selectionStart).toBe(6);
82
+ },
83
+ );
84
+ });
@@ -3,6 +3,7 @@ import type { TriggerMatch, TriggerMatcher } from "./composer/trigger";
3
3
  export { ComposerPrimitiveRoot as Root } from "./composer/ComposerRoot";
4
4
  export { ComposerPrimitiveInput as Input } from "./composer/ComposerInput";
5
5
  export { ComposerPrimitiveSend as Send } from "./composer/ComposerSend";
6
+ export { ComposerPrimitiveResume as Resume } from "./composer/ComposerResume";
6
7
  export { ComposerPrimitiveCancel as Cancel } from "./composer/ComposerCancel";
7
8
  export { ComposerPrimitiveAddAttachment as AddAttachment } from "./composer/ComposerAddAttachment";
8
9
  export { ComposerPrimitiveAttachments as Attachments } from "./composer/ComposerAttachments";
@@ -29,6 +29,7 @@ import { ThreadPrimitiveMessages } from "./ThreadMessages";
29
29
  import { ThreadPrimitiveRoot } from "./ThreadRoot";
30
30
  import { ThreadPrimitiveScrollToBottom } from "./ThreadScrollToBottom";
31
31
  import { ThreadPrimitiveViewport } from "./ThreadViewport";
32
+ import { ThreadPrimitiveViewportFooter } from "./ThreadViewportFooter";
32
33
  import {
33
34
  ExportedMessageRepository,
34
35
  useExternalStoreRuntime,
@@ -325,6 +326,95 @@ const DelayedThread = ({
325
326
  );
326
327
  };
327
328
 
329
+ const measureFooter = (element: HTMLDivElement | null) => {
330
+ if (!element) return;
331
+ Object.defineProperty(element, "offsetHeight", {
332
+ configurable: true,
333
+ get: () => Number.parseFloat(element.style.height),
334
+ });
335
+ };
336
+
337
+ const FooterInsetState = () => {
338
+ const inset = useThreadViewport((s) => s.height.inset);
339
+ return <output data-testid="footer-inset">{inset}</output>;
340
+ };
341
+
342
+ const FooterThread = ({
343
+ footerHeight,
344
+ autoScroll = false,
345
+ }: {
346
+ footerHeight: number | null;
347
+ autoScroll?: boolean;
348
+ }) => (
349
+ <SyncRuntimeProvider>
350
+ <ThreadPrimitiveViewport
351
+ data-testid="viewport"
352
+ autoScroll={autoScroll}
353
+ scrollToBottomOnInitialize={false}
354
+ >
355
+ {messages.map((_, index) => (
356
+ <div key={index} data-testid="thread-message" />
357
+ ))}
358
+ {footerHeight !== null && (
359
+ <ThreadPrimitiveViewportFooter
360
+ ref={measureFooter}
361
+ data-testid="footer"
362
+ style={{ height: footerHeight }}
363
+ />
364
+ )}
365
+ <AtBottom />
366
+ <FooterInsetState />
367
+ </ThreadPrimitiveViewport>
368
+ </SyncRuntimeProvider>
369
+ );
370
+
371
+ describe("viewport footer insets", () => {
372
+ it("counts a footer registered before viewport attachment toward at-bottom", () => {
373
+ render(<FooterThread footerHeight={40} />);
374
+
375
+ act(() => {
376
+ getViewport().scrollTop = 500;
377
+ fireEvent.scroll(getViewport());
378
+ });
379
+
380
+ expect(screen.getByTestId("footer-inset").textContent).toBe("40");
381
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
382
+ });
383
+
384
+ it("follows a pinned viewport when its footer grows without a content resize", () => {
385
+ const view = render(<FooterThread footerHeight={40} autoScroll />);
386
+ act(notifyResizeObservers);
387
+
388
+ act(() => {
389
+ getViewport().scrollTop = 500;
390
+ fireEvent.scroll(getViewport());
391
+ });
392
+
393
+ view.rerender(<FooterThread footerHeight={80} autoScroll />);
394
+ act(() => notifyResizeOf(screen.getByTestId("footer")));
395
+
396
+ expect(getViewport().scrollTop).toBe(540);
397
+ expect(screen.getByTestId("footer-inset").textContent).toBe("80");
398
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
399
+ });
400
+
401
+ it("unregisters an unmounted footer and recomputes at-bottom", () => {
402
+ const view = render(<FooterThread footerHeight={40} />);
403
+
404
+ act(() => {
405
+ getViewport().scrollTop = 500;
406
+ fireEvent.scroll(getViewport());
407
+ });
408
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
409
+
410
+ view.rerender(<FooterThread footerHeight={null} />);
411
+
412
+ expect(screen.getByTestId("footer-inset").textContent).toBe("0");
413
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
414
+ expect(getViewport().scrollTop).toBe(500);
415
+ });
416
+ });
417
+
328
418
  describe("useThreadViewportAutoScroll", () => {
329
419
  it("keeps viewport listeners and observers across rerenders", () => {
330
420
  const view = render(