@assistant-ui/react 0.15.25 → 0.15.27

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 (159) hide show
  1. package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
  2. package/dist/hooks/useToolCallElapsed.js +3 -2
  3. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  4. package/dist/index.d.ts +31 -5
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  7. package/dist/mcp-apps/McpAppRenderer.js +3 -2
  8. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  9. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  10. package/dist/mcp-apps/app-frame.js +58 -48
  11. package/dist/mcp-apps/app-frame.js.map +1 -1
  12. package/dist/mcp-apps/bridge.d.ts.map +1 -1
  13. package/dist/mcp-apps/bridge.js +28 -3
  14. package/dist/mcp-apps/bridge.js.map +1 -1
  15. package/dist/mcp-apps/csp.d.ts +4 -0
  16. package/dist/mcp-apps/csp.d.ts.map +1 -0
  17. package/dist/mcp-apps/csp.js +93 -0
  18. package/dist/mcp-apps/csp.js.map +1 -0
  19. package/dist/mcp-apps/index.d.ts +1 -1
  20. package/dist/mcp-apps/index.d.ts.map +1 -1
  21. package/dist/mcp-apps/types.d.ts +19 -1
  22. package/dist/mcp-apps/types.d.ts.map +1 -1
  23. package/dist/mcp-apps/types.js.map +1 -1
  24. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts +1 -0
  25. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  26. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  27. package/dist/primitives/attachment.d.ts +3 -1
  28. package/dist/primitives/attachment.d.ts.map +1 -1
  29. package/dist/primitives/attachment.js.map +1 -1
  30. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  31. package/dist/primitives/composer/ComposerAttachmentDropzone.js +14 -12
  32. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  33. package/dist/primitives/composer/ComposerCancel.d.ts +5 -1
  34. package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
  35. package/dist/primitives/composer/ComposerCancel.js +14 -1
  36. package/dist/primitives/composer/ComposerCancel.js.map +1 -1
  37. package/dist/primitives/composer/ComposerInput.d.ts +14 -0
  38. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  39. package/dist/primitives/composer/ComposerInput.js +4 -3
  40. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  41. package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
  42. package/dist/primitives/composer/ComposerRoot.js +24 -19
  43. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  44. package/dist/primitives/composer/composerInputSelector.d.ts +3 -0
  45. package/dist/primitives/composer/composerInputSelector.d.ts.map +1 -0
  46. package/dist/primitives/composer/composerInputSelector.js +7 -0
  47. package/dist/primitives/composer/composerInputSelector.js.map +1 -0
  48. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts +2 -0
  49. package/dist/primitives/composer/useComposerCancelWithFocus.d.ts.map +1 -0
  50. package/dist/primitives/composer/useComposerCancelWithFocus.js +42 -0
  51. package/dist/primitives/composer/useComposerCancelWithFocus.js.map +1 -0
  52. package/dist/primitives/composer.d.ts +35 -14
  53. package/dist/primitives/composer.d.ts.map +1 -1
  54. package/dist/primitives/composer.js.map +1 -1
  55. package/dist/primitives/message/MessagePartsGrouped.d.ts +8 -1
  56. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  57. package/dist/primitives/message/MessagePartsGrouped.js +58 -278
  58. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  59. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
  60. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -2
  61. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  62. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  63. package/dist/primitives/thread/ThreadViewport.js +4 -3
  64. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  65. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +2 -2
  66. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
  67. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  68. package/dist/primitives/thread/useThreadViewportAutoScroll.js +90 -21
  69. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  70. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  71. package/dist/sandbox-host/SandboxHost.js +3 -2
  72. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  73. package/dist/unstable/useComposerInput.d.ts +9 -4
  74. package/dist/unstable/useComposerInput.d.ts.map +1 -1
  75. package/dist/unstable/useComposerInput.js +6 -4
  76. package/dist/unstable/useComposerInput.js.map +1 -1
  77. package/dist/unstable/useComposerInputHistory.d.ts +3 -2
  78. package/dist/unstable/useComposerInputHistory.d.ts.map +1 -1
  79. package/dist/unstable/useComposerInputHistory.js +2 -2
  80. package/dist/unstable/useComposerInputHistory.js.map +1 -1
  81. package/dist/unstable/useLiveCompletionAdapter.d.ts +3 -2
  82. package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
  83. package/dist/unstable/useLiveCompletionAdapter.js +8 -7
  84. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  85. package/dist/unstable/useMentionAdapter.d.ts +12 -3
  86. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  87. package/dist/unstable/useMentionAdapter.js +2 -2
  88. package/dist/unstable/useMentionAdapter.js.map +1 -1
  89. package/dist/unstable/useMessageStallDetection.d.ts +4 -2
  90. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  91. package/dist/unstable/useMessageStallDetection.js +2 -2
  92. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  93. package/dist/unstable/useSlashCommandAdapter.d.ts +5 -2
  94. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  95. package/dist/unstable/useSlashCommandAdapter.js +5 -4
  96. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  97. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +4 -0
  98. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  99. package/dist/unstable/webmcp/useWebMcpProvider.js +2 -0
  100. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  101. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  102. package/dist/utils/getSelectionMessageId.js +31 -8
  103. package/dist/utils/getSelectionMessageId.js.map +1 -1
  104. package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
  105. package/dist/utils/hooks/useOnResizeContent.js +2 -22
  106. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  107. package/dist/utils/useIsomorphicLayoutEffect.d.ts +3 -0
  108. package/dist/utils/useIsomorphicLayoutEffect.d.ts.map +1 -0
  109. package/dist/utils/useIsomorphicLayoutEffect.js +6 -0
  110. package/dist/utils/useIsomorphicLayoutEffect.js.map +1 -0
  111. package/package.json +10 -10
  112. package/src/hooks/useToolCallElapsed.test.tsx +4 -2
  113. package/src/hooks/useToolCallElapsed.ts +3 -2
  114. package/src/index.ts +20 -2
  115. package/src/mcp-apps/McpAppRenderer.test.tsx +7 -2
  116. package/src/mcp-apps/McpAppRenderer.tsx +3 -9
  117. package/src/mcp-apps/app-frame.test.tsx +102 -6
  118. package/src/mcp-apps/app-frame.tsx +7 -10
  119. package/src/mcp-apps/bridge.test.ts +154 -0
  120. package/src/mcp-apps/bridge.ts +51 -9
  121. package/src/mcp-apps/csp.test.ts +112 -0
  122. package/src/mcp-apps/csp.ts +124 -0
  123. package/src/mcp-apps/index.ts +1 -0
  124. package/src/mcp-apps/types.ts +16 -1
  125. package/src/model-context/frame/AssistantFrame.test.ts +45 -0
  126. package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
  127. package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
  128. package/src/primitives/attachment.ts +4 -1
  129. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +32 -1
  130. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +14 -12
  131. package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
  132. package/src/primitives/composer/ComposerCancel.ts +8 -1
  133. package/src/primitives/composer/ComposerDisabledThread.test.tsx +121 -0
  134. package/src/primitives/composer/ComposerInput.tsx +19 -1
  135. package/src/primitives/composer/ComposerRoot.tsx +6 -5
  136. package/src/primitives/composer/composerInputSelector.ts +4 -0
  137. package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
  138. package/src/primitives/composer.ts +56 -21
  139. package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
  140. package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
  141. package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
  142. package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
  143. package/src/primitives/thread/ThreadViewport.tsx +3 -3
  144. package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
  145. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +666 -63
  146. package/src/primitives/thread/useThreadViewportAutoScroll.ts +201 -73
  147. package/src/sandbox-host/SandboxHost.tsx +3 -8
  148. package/src/unstable/useComposerInput.ts +9 -4
  149. package/src/unstable/useComposerInputHistory.ts +3 -2
  150. package/src/unstable/useLiveCompletionAdapter.ts +9 -14
  151. package/src/unstable/useMentionAdapter.ts +12 -3
  152. package/src/unstable/useMessageStallDetection.ts +4 -2
  153. package/src/unstable/useSlashCommandAdapter.ts +8 -4
  154. package/src/unstable/webmcp/useWebMcpProvider.ts +4 -0
  155. package/src/utils/getSelectionMessageId.test.ts +231 -0
  156. package/src/utils/getSelectionMessageId.ts +45 -7
  157. package/src/utils/hooks/useOnResizeContent.ts +2 -29
  158. package/src/utils/useIsomorphicLayoutEffect.test.tsx +21 -0
  159. package/src/utils/useIsomorphicLayoutEffect.ts +9 -0
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
4
4
  import { useAuiState } from "@assistant-ui/store";
5
5
  import { useShallowSelector } from "@assistant-ui/store/internal";
6
6
 
7
+ /** @deprecated Experimental since 2026-06-13. Not scheduled for removal; the API may change in any release. */
7
8
  export type Unstable_MessageStallDetectionOptions = {
8
9
  /**
9
10
  * Milliseconds of unchanged message content before the message counts as
@@ -13,6 +14,7 @@ export type Unstable_MessageStallDetectionOptions = {
13
14
  thresholdMs?: number | undefined;
14
15
  };
15
16
 
17
+ /** @deprecated Experimental since 2026-06-13. Not scheduled for removal; the API may change in any release. */
16
18
  export type Unstable_MessageStallDetection = {
17
19
  /** True while the message is running and its content has not changed for at least `thresholdMs`. */
18
20
  stalled: boolean;
@@ -21,8 +23,6 @@ export type Unstable_MessageStallDetection = {
21
23
  };
22
24
 
23
25
  /**
24
- * @deprecated Under active development and might change without notice.
25
- *
26
26
  * Detects mid-run output stalls on the current message: while the message is
27
27
  * running, watches its text, reasoning, and tool-argument values plus tool-result
28
28
  * availability and reports a stall once they stop changing for `thresholdMs`.
@@ -30,6 +30,8 @@ export type Unstable_MessageStallDetection = {
30
30
  * provider stalls, after the first tokens have already streamed.
31
31
  *
32
32
  * Must be used inside a message scope.
33
+ *
34
+ * @deprecated Experimental since 2026-06-13. Not scheduled for removal; the API may change in any release.
33
35
  */
34
36
  export function unstable_useMessageStallDetection(
35
37
  options?: Unstable_MessageStallDetectionOptions,
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { useLayoutEffect, useMemo, useRef } from "react";
3
+ import { useMemo, useRef } from "react";
4
+ import { useIsomorphicLayoutEffect } from "../utils/useIsomorphicLayoutEffect";
4
5
  import type {
5
6
  Unstable_TriggerAdapter,
6
7
  Unstable_TriggerItem,
@@ -8,6 +9,7 @@ import type {
8
9
  import type { Unstable_IconComponent } from "./useMentionAdapter";
9
10
  import { matchesTriggerItemQuery } from "../primitives/composer/trigger/matchesTriggerItemQuery";
10
11
 
12
+ /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
11
13
  export type Unstable_SlashCommand = {
12
14
  readonly id: string;
13
15
  readonly label?: string | undefined;
@@ -16,6 +18,7 @@ export type Unstable_SlashCommand = {
16
18
  readonly execute: () => void;
17
19
  };
18
20
 
21
+ /** @deprecated Experimental since 2026-04-06. Not scheduled for removal; the API may change in any release. */
19
22
  export type Unstable_UseSlashCommandAdapterOptions = {
20
23
  readonly commands: readonly Unstable_SlashCommand[];
21
24
  /** Strip the trigger text from the composer after executing. @default false */
@@ -26,19 +29,20 @@ export type Unstable_UseSlashCommandAdapterOptions = {
26
29
  readonly fallbackIcon?: Unstable_IconComponent;
27
30
  };
28
31
 
32
+ /** @deprecated Experimental since 2026-04-15. Not scheduled for removal; the API may change in any release. */
29
33
  export type Unstable_SlashCommandAction = {
30
34
  readonly onExecute: (item: Unstable_TriggerItem) => void;
31
35
  readonly removeOnExecute?: boolean | undefined;
32
36
  };
33
37
 
34
38
  /**
35
- * @deprecated Under active development and may change without notice.
36
- *
37
39
  * Bundles slash command definitions (with inline `execute` callbacks) into
38
40
  * `{adapter, action}` that plug directly into `ComposerTriggerPopover`.
39
41
  * `execute` stays in the hook closure and is never attached to the returned
40
42
  * `TriggerItem`, keeping items serializable.
41
43
  *
44
+ * @deprecated Experimental since 2026-04-06. Not scheduled for removal; the API may change in any release.
45
+ *
42
46
  * @example
43
47
  * ```tsx
44
48
  * const slash = unstable_useSlashCommandAdapter({
@@ -71,7 +75,7 @@ export function unstable_useSlashCommandAdapter(
71
75
  : nextItems;
72
76
 
73
77
  // The adapter's callbacks must only observe commands from committed renders.
74
- useLayoutEffect(() => {
78
+ useIsomorphicLayoutEffect(() => {
75
79
  commandsRef.current = commands;
76
80
  committedItemsRef.current = items;
77
81
  }, [commands, items]);
@@ -20,10 +20,12 @@ import {
20
20
  type ModelContextSnapshotSource,
21
21
  } from "../useModelContextSnapshot";
22
22
 
23
+ /** @deprecated Experimental since 2026-08-29. Not scheduled for removal; the API may change in any release. */
23
24
  export type Unstable_WebMcpProviderOptions = {
24
25
  filter?: (name: string, tool: Tool<any, any>) => boolean;
25
26
  };
26
27
 
28
+ /** @deprecated Experimental since 2026-08-29. Not scheduled for removal; the API may change in any release. */
27
29
  export type Unstable_WebMcpProviderResult = {
28
30
  status: "unsupported" | "active";
29
31
  registeredToolNames: readonly string[];
@@ -128,6 +130,8 @@ const useWebMcpRegistry = ({
128
130
  * Returns `status: "unsupported"` when the page exposes no
129
131
  * `document.modelContext` (or `navigator.modelContext`), and the sorted names
130
132
  * of the tools currently registered with the host.
133
+ *
134
+ * @deprecated Experimental since 2026-08-29. Not scheduled for removal; the API may change in any release.
131
135
  */
132
136
  export const unstable_useWebMcpProvider = (
133
137
  options: Unstable_WebMcpProviderOptions = {},
@@ -30,6 +30,237 @@ afterEach(() => {
30
30
  });
31
31
 
32
32
  describe("getSelectionMessageId", () => {
33
+ it.each([false, true])(
34
+ "accepts an end at the next message's first text offset zero (backward: %s)",
35
+ (backward) => {
36
+ document.body.innerHTML =
37
+ '<div data-message-id="message-1"><p id="first" data-aui-quote-selectable>first text</p></div><div data-message-id="message-2"><p data-aui-quote-selectable><span id="second">second text</span></p></div>';
38
+ const first = textNode("#first");
39
+ const second = textNode("#second");
40
+ const selection = selectText(first);
41
+ if (backward) {
42
+ selection.setBaseAndExtent(second, 0, first, 1);
43
+ } else {
44
+ selection.setBaseAndExtent(first, 1, second, 0);
45
+ }
46
+ const endpoints = [
47
+ selection.anchorNode,
48
+ selection.anchorOffset,
49
+ selection.focusNode,
50
+ selection.focusOffset,
51
+ ];
52
+ const text = selection.toString();
53
+
54
+ expect(getSelectionMessageId(selection)).toBe("message-1");
55
+ expect(selection.toString()).toBe(text);
56
+ expect([
57
+ selection.anchorNode,
58
+ selection.anchorOffset,
59
+ selection.focusNode,
60
+ selection.focusOffset,
61
+ ]).toEqual(endpoints);
62
+ },
63
+ );
64
+
65
+ it("accepts an end at the next message element's offset zero", () => {
66
+ document.body.innerHTML =
67
+ '<div data-message-id="message-1"><p id="first" data-aui-quote-selectable>first text</p></div><div id="second" data-message-id="message-2"><p>second text</p></div>';
68
+ const selection = selectText(textNode("#first"));
69
+ const second = document.querySelector("#second");
70
+ assert(second);
71
+ selection.getRangeAt(0).setEnd(second, 0);
72
+
73
+ expect(getSelectionMessageId(selection)).toBe("message-1");
74
+ });
75
+
76
+ it.each([false, true])(
77
+ "accepts a zero-offset end inside an excluded next message (backward: %s)",
78
+ (backward) => {
79
+ document.body.innerHTML =
80
+ '<div data-message-id="message-1"><p id="first" data-aui-quote-selectable>first text</p></div><div data-message-id="message-2" data-aui-quote-selectable="false"><p><span id="second">second text</span></p></div>';
81
+ const first = textNode("#first");
82
+ const second = textNode("#second");
83
+ const selection = selectText(first);
84
+ selection.setBaseAndExtent(
85
+ backward ? second : first,
86
+ 0,
87
+ backward ? first : second,
88
+ 0,
89
+ );
90
+ const endpoints = [
91
+ selection.anchorNode,
92
+ selection.anchorOffset,
93
+ selection.focusNode,
94
+ selection.focusOffset,
95
+ ];
96
+ const text = selection.toString();
97
+
98
+ expect(getSelectionMessageId(selection)).toBe("message-1");
99
+ expect(selection.toString()).toBe(text);
100
+ expect([
101
+ selection.anchorNode,
102
+ selection.anchorOffset,
103
+ selection.focusNode,
104
+ selection.focusOffset,
105
+ ]).toEqual(endpoints);
106
+ },
107
+ );
108
+
109
+ it.each(["<span></span>", "<span> </span>", "<span>excluded text</span>"])(
110
+ "rejects selected content inheriting the next message's exclusion: %s",
111
+ (prefix) => {
112
+ document.body.innerHTML = `<div data-message-id="message-1"><p id="first" data-aui-quote-selectable>first text</p></div><div data-message-id="message-2" data-aui-quote-selectable="false">${prefix}<p id="second">second text</p></div>`;
113
+ const first = textNode("#first");
114
+ const second = textNode("#second");
115
+ const selection = selectText(first);
116
+ for (const backward of [false, true]) {
117
+ selection.setBaseAndExtent(
118
+ backward ? second : first,
119
+ 0,
120
+ backward ? first : second,
121
+ 0,
122
+ );
123
+ expect(getSelectionMessageId(selection)).toBeNull();
124
+ }
125
+ },
126
+ );
127
+
128
+ it.each([
129
+ { footer: "", separator: "", prefix: "<span></span>" },
130
+ { footer: "", separator: "\n ", prefix: "" },
131
+ {
132
+ footer: '<div class="footer"><button><svg></svg></button></div>',
133
+ separator: "",
134
+ prefix: "",
135
+ },
136
+ {
137
+ footer: "<div><span></span></div>",
138
+ separator: "\n",
139
+ prefix: "<span></span>\n",
140
+ },
141
+ ])(
142
+ "skips empty chrome and whitespace at the end: %j",
143
+ ({ footer, separator, prefix }) => {
144
+ document.body.innerHTML = `<div data-message-id="message-1"><p id="first" data-aui-quote-selectable>first text</p>${footer}</div>${separator}<div data-message-id="message-2">${prefix}<p id="second">second text</p></div>`;
145
+ const first = textNode("#first");
146
+ const second = textNode("#second");
147
+ const selection = selectText(first);
148
+ for (const backward of [false, true]) {
149
+ selection.setBaseAndExtent(
150
+ backward ? second : first,
151
+ backward ? 0 : 1,
152
+ backward ? first : second,
153
+ backward ? 1 : 0,
154
+ );
155
+ const endpoints = [
156
+ selection.anchorNode,
157
+ selection.anchorOffset,
158
+ selection.focusNode,
159
+ selection.focusOffset,
160
+ ];
161
+ const text = selection.toString();
162
+ expect(getSelectionMessageId(selection)).toBe("message-1");
163
+ expect(selection.toString()).toBe(text);
164
+ expect([
165
+ selection.anchorNode,
166
+ selection.anchorOffset,
167
+ selection.focusNode,
168
+ selection.focusOffset,
169
+ ]).toEqual(endpoints);
170
+ }
171
+ },
172
+ );
173
+
174
+ it.each([
175
+ { footer: "<footer>footer text</footer>", prefix: "" },
176
+ { footer: "", prefix: "<header>next message header</header>" },
177
+ {
178
+ footer: '<div data-aui-quote-selectable="false"><span></span>\n</div>',
179
+ prefix: "",
180
+ },
181
+ {
182
+ footer: "",
183
+ prefix: '<div data-aui-quote-selectable="false"><span></span>\n</div>',
184
+ },
185
+ ])(
186
+ "does not trim selected content or exclusions: %j",
187
+ ({ footer, prefix }) => {
188
+ document.body.innerHTML = `<div data-message-id="message-1"><p id="first" data-aui-quote-selectable>first text</p>${footer}</div>\n<div data-message-id="message-2">${prefix}<p id="second">second text</p></div>`;
189
+ const selection = selectText(textNode("#first"));
190
+ selection.getRangeAt(0).setEnd(textNode("#second"), 0);
191
+ expect(getSelectionMessageId(selection)).toBeNull();
192
+ },
193
+ );
194
+
195
+ it("does not normalize past the selection start", () => {
196
+ document.body.innerHTML =
197
+ '<div data-message-id="message-1"><p id="first">first text</p><span></span></div><div data-message-id="message-2"><p id="second">second text</p></div>';
198
+ const first = textNode("#first");
199
+ const selection = selectText(first);
200
+ selection.setBaseAndExtent(first, first.length, textNode("#second"), 0);
201
+ expect(getSelectionMessageId(selection)).toBeNull();
202
+ });
203
+
204
+ it.each(["<span>\n </span>", "<span></span>"])(
205
+ "preserves inherited exclusions after a nested opt-in: %s",
206
+ (suffix) => {
207
+ document.body.innerHTML = `<div data-message-id="message-1"><div data-aui-quote-selectable="false"><p id="first" data-aui-quote-selectable>first text</p>${suffix}</div></div><div data-message-id="message-2"><p id="second">second text</p></div>`;
208
+ const first = textNode("#first");
209
+ const second = textNode("#second");
210
+ const selection = selectText(first);
211
+ expect(getSelectionMessageId(selection)).toBe("message-1");
212
+ for (const backward of [false, true]) {
213
+ selection.setBaseAndExtent(
214
+ backward ? second : first,
215
+ 0,
216
+ backward ? first : second,
217
+ 0,
218
+ );
219
+ expect(getSelectionMessageId(selection)).toBeNull();
220
+ }
221
+ },
222
+ );
223
+
224
+ it("rejects even one selected character in the next message", () => {
225
+ document.body.innerHTML =
226
+ '<div data-message-id="message-1"><p id="first">first text</p></div><div data-message-id="message-2"><p id="second">second text</p></div>';
227
+ const selection = selectText(textNode("#first"));
228
+ selection.getRangeAt(0).setEnd(textNode("#second"), 1);
229
+
230
+ expect(getSelectionMessageId(selection)).toBeNull();
231
+ });
232
+
233
+ it.each(["", '<span aria-hidden="true"></span>'])(
234
+ "rejects an empty excluded subtree before a zero-offset end with suffix %s",
235
+ (suffix) => {
236
+ document.body.innerHTML = `<div data-message-id="message-1" data-aui-quote-selectable><p id="first">first text</p><span data-aui-quote-selectable="false"></span>${suffix}</div><div data-message-id="message-2"><p id="second">second text</p></div>`;
237
+ const selection = selectText(textNode("#first"));
238
+ selection.getRangeAt(0).setEnd(textNode("#second"), 0);
239
+
240
+ expect(getSelectionMessageId(selection)).toBeNull();
241
+ },
242
+ );
243
+
244
+ it("rejects an excluded subtree preceding the next message's text", () => {
245
+ document.body.innerHTML =
246
+ '<div data-message-id="message-1"><p id="first">first text</p></div><div data-message-id="message-2"><span data-aui-quote-selectable="false"></span><p id="second">second text</p></div>';
247
+ const selection = selectText(textNode("#first"));
248
+ selection.getRangeAt(0).setEnd(textNode("#second"), 0);
249
+
250
+ expect(getSelectionMessageId(selection)).toBeNull();
251
+ });
252
+
253
+ it("still checks thread ownership after adjusting a zero-offset end", () => {
254
+ document.body.innerHTML =
255
+ '<div id="first-thread"><div data-message-id="message-1"><p id="first">first text</p></div><div data-message-id="message-2"><p id="second">second text</p></div></div><div id="second-thread"></div>';
256
+ const selection = selectText(textNode("#first"));
257
+ selection.getRangeAt(0).setEnd(textNode("#second"), 0);
258
+ const root = document.querySelector("#second-thread");
259
+ assert(root);
260
+
261
+ expect(getSelectionMessageId(selection, root)).toBeNull();
262
+ });
263
+
33
264
  it("rejects a message outside the supplied thread root", () => {
34
265
  document.body.innerHTML = `
35
266
  <div id="first-thread">
@@ -43,10 +43,37 @@ const findQuoteMarker = (
43
43
  return marker;
44
44
  };
45
45
 
46
- const intersectsExcluded = (scope: Element, selection: Selection): boolean => {
47
- const ranges = Array.from({ length: selection.rangeCount }, (_, i) =>
48
- selection.getRangeAt(i),
46
+ const normalizeRangeEnd = (range: Range): Range => {
47
+ if (range.endOffset !== 0 || range.collapsed) return range;
48
+
49
+ const walker = range.endContainer.ownerDocument?.createTreeWalker(
50
+ range.commonAncestorContainer,
51
+ NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
49
52
  );
53
+ if (!walker) return range;
54
+ walker.currentNode = range.endContainer;
55
+
56
+ while (walker.previousNode()) {
57
+ const node = walker.currentNode;
58
+ const offset = node instanceof Text ? node.length : node.childNodes.length;
59
+ if (range.comparePoint(node, offset) < 0) return range;
60
+ const element = node instanceof Element ? node : node.parentElement;
61
+ const marker = element?.closest(QUOTE_SELECTABLE_SELECTOR);
62
+ if (marker && isExcluded(marker) && !node.contains(range.endContainer))
63
+ return range;
64
+ if (!(node instanceof Text) || !node.data.trim()) continue;
65
+
66
+ const normalized = range.cloneRange();
67
+ normalized.setEnd(node, offset);
68
+ return normalized.collapsed ? range : normalized;
69
+ }
70
+ return range;
71
+ };
72
+
73
+ const intersectsExcluded = (
74
+ scope: Element,
75
+ ranges: readonly Range[],
76
+ ): boolean => {
50
77
  for (const marker of scope.querySelectorAll(QUOTE_SELECTABLE_SELECTOR)) {
51
78
  if (!isExcluded(marker)) continue;
52
79
  if (ranges.some((range) => range.intersectsNode(marker))) return true;
@@ -58,9 +85,21 @@ export const getSelectionMessageId = (
58
85
  selection: Selection,
59
86
  root?: Element | null,
60
87
  ): string | null => {
61
- const { anchorNode, focusNode } = selection;
88
+ let { anchorNode, focusNode } = selection;
62
89
  if (!anchorNode || !focusNode) return null;
63
90
 
91
+ const ranges = Array.from({ length: selection.rangeCount }, (_, i) =>
92
+ selection.getRangeAt(i),
93
+ );
94
+ if (ranges.length === 1) {
95
+ const range = normalizeRangeEnd(ranges[0]!);
96
+ if (range !== ranges[0]) {
97
+ ranges[0] = range;
98
+ anchorNode = range.startContainer;
99
+ focusNode = range.endContainer;
100
+ }
101
+ }
102
+
64
103
  const anchorMessageElement = findMessageElement(anchorNode);
65
104
  const focusMessageElement = findMessageElement(focusNode);
66
105
 
@@ -86,10 +125,9 @@ export const getSelectionMessageId = (
86
125
 
87
126
  const scope = anchorMarker ?? anchorMessageElement;
88
127
 
89
- for (let i = 0; i < selection.rangeCount; i++) {
90
- const { commonAncestorContainer } = selection.getRangeAt(i);
128
+ for (const { commonAncestorContainer } of ranges) {
91
129
  if (!scope.contains(commonAncestorContainer)) return null;
92
130
  }
93
131
 
94
- return intersectsExcluded(scope, selection) ? null : messageId;
132
+ return intersectsExcluded(scope, ranges) ? null : messageId;
95
133
  };
@@ -1,3 +1,4 @@
1
+ import { observeContentResize } from "@assistant-ui/store/client";
1
2
  import { useCallbackRef } from "radix-ui/internal";
2
3
  import { useCallback } from "react";
3
4
  import { useManagedRef } from "./useManagedRef";
@@ -6,35 +7,7 @@ export const useOnResizeContent = (callback: () => void) => {
6
7
  const callbackRef = useCallbackRef(callback);
7
8
 
8
9
  const refCallback = useCallback(
9
- (el: HTMLElement) => {
10
- const resizeObserver = new ResizeObserver(() => {
11
- callbackRef();
12
- });
13
-
14
- const mutationObserver = new MutationObserver((mutations) => {
15
- // Filter out style-only attribute mutations to prevent feedback loops
16
- // with code paths that write styles in response to viewport changes.
17
- const hasRelevantMutation = mutations.some(
18
- (m) => m.type !== "attributes" || m.attributeName !== "style",
19
- );
20
- if (hasRelevantMutation) {
21
- callbackRef();
22
- }
23
- });
24
-
25
- resizeObserver.observe(el);
26
- mutationObserver.observe(el, {
27
- childList: true,
28
- subtree: true,
29
- attributes: true,
30
- characterData: true,
31
- });
32
-
33
- return () => {
34
- resizeObserver.disconnect();
35
- mutationObserver.disconnect();
36
- };
37
- },
10
+ (el: HTMLElement) => observeContentResize(el, callbackRef),
38
11
  [callbackRef],
39
12
  );
40
13
 
@@ -0,0 +1,21 @@
1
+ import { createElement } from "react";
2
+ import { renderToString } from "react-dom/server";
3
+ import { expect, it, vi } from "vitest";
4
+ import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
5
+
6
+ it("does not warn about useLayoutEffect during server rendering", () => {
7
+ const errors = vi.spyOn(console, "error").mockImplementation(() => {});
8
+ function Probe() {
9
+ useIsomorphicLayoutEffect(() => {});
10
+ return null;
11
+ }
12
+
13
+ try {
14
+ renderToString(createElement(Probe));
15
+ expect(errors.mock.calls.flat().join(" ")).not.toMatch(
16
+ /useLayoutEffect does nothing on the server/,
17
+ );
18
+ } finally {
19
+ errors.mockRestore();
20
+ }
21
+ });
@@ -0,0 +1,9 @@
1
+ import { useEffect, useLayoutEffect } from "react";
2
+
3
+ const hasWindow =
4
+ typeof (globalThis as typeof globalThis & { window?: unknown }).window !==
5
+ "undefined";
6
+
7
+ export const useIsomorphicLayoutEffect = hasWindow
8
+ ? useLayoutEffect
9
+ : useEffect;