@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.
- package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
- package/dist/hooks/useToolCallElapsed.js +3 -2
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +31 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +3 -2
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +58 -48
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.d.ts.map +1 -1
- package/dist/mcp-apps/bridge.js +28 -3
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/mcp-apps/csp.d.ts +4 -0
- package/dist/mcp-apps/csp.d.ts.map +1 -0
- package/dist/mcp-apps/csp.js +93 -0
- package/dist/mcp-apps/csp.js.map +1 -0
- package/dist/mcp-apps/index.d.ts +1 -1
- package/dist/mcp-apps/index.d.ts.map +1 -1
- package/dist/mcp-apps/types.d.ts +19 -1
- package/dist/mcp-apps/types.d.ts.map +1 -1
- package/dist/mcp-apps/types.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts +1 -0
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
- package/dist/primitives/attachment.d.ts +3 -1
- package/dist/primitives/attachment.d.ts.map +1 -1
- package/dist/primitives/attachment.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +14 -12
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerCancel.d.ts +5 -1
- package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerCancel.js +14 -1
- package/dist/primitives/composer/ComposerCancel.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts +14 -0
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +4 -3
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerRoot.js +24 -19
- package/dist/primitives/composer/ComposerRoot.js.map +1 -1
- package/dist/primitives/composer/composerInputSelector.d.ts +3 -0
- package/dist/primitives/composer/composerInputSelector.d.ts.map +1 -0
- package/dist/primitives/composer/composerInputSelector.js +7 -0
- package/dist/primitives/composer/composerInputSelector.js.map +1 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.d.ts +2 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.d.ts.map +1 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.js +42 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.js.map +1 -0
- package/dist/primitives/composer.d.ts +35 -14
- package/dist/primitives/composer.d.ts.map +1 -1
- package/dist/primitives/composer.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +8 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js +58 -278
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -2
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
- package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewport.js +4 -3
- package/dist/primitives/thread/ThreadViewport.js.map +1 -1
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +2 -2
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +90 -21
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +3 -2
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useComposerInput.d.ts +9 -4
- package/dist/unstable/useComposerInput.d.ts.map +1 -1
- package/dist/unstable/useComposerInput.js +6 -4
- package/dist/unstable/useComposerInput.js.map +1 -1
- package/dist/unstable/useComposerInputHistory.d.ts +3 -2
- package/dist/unstable/useComposerInputHistory.d.ts.map +1 -1
- package/dist/unstable/useComposerInputHistory.js +2 -2
- package/dist/unstable/useComposerInputHistory.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.d.ts +3 -2
- package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +8 -7
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/unstable/useMentionAdapter.d.ts +12 -3
- package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
- package/dist/unstable/useMentionAdapter.js +2 -2
- package/dist/unstable/useMentionAdapter.js.map +1 -1
- package/dist/unstable/useMessageStallDetection.d.ts +4 -2
- package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
- package/dist/unstable/useMessageStallDetection.js +2 -2
- package/dist/unstable/useMessageStallDetection.js.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.d.ts +5 -2
- package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.js +5 -4
- package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts +4 -0
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js +2 -0
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
- package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
- package/dist/utils/getSelectionMessageId.js +31 -8
- package/dist/utils/getSelectionMessageId.js.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.js +2 -22
- package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
- package/dist/utils/useIsomorphicLayoutEffect.d.ts +3 -0
- package/dist/utils/useIsomorphicLayoutEffect.d.ts.map +1 -0
- package/dist/utils/useIsomorphicLayoutEffect.js +6 -0
- package/dist/utils/useIsomorphicLayoutEffect.js.map +1 -0
- package/package.json +10 -10
- package/src/hooks/useToolCallElapsed.test.tsx +4 -2
- package/src/hooks/useToolCallElapsed.ts +3 -2
- package/src/index.ts +20 -2
- package/src/mcp-apps/McpAppRenderer.test.tsx +7 -2
- package/src/mcp-apps/McpAppRenderer.tsx +3 -9
- package/src/mcp-apps/app-frame.test.tsx +102 -6
- package/src/mcp-apps/app-frame.tsx +7 -10
- package/src/mcp-apps/bridge.test.ts +154 -0
- package/src/mcp-apps/bridge.ts +51 -9
- package/src/mcp-apps/csp.test.ts +112 -0
- package/src/mcp-apps/csp.ts +124 -0
- package/src/mcp-apps/index.ts +1 -0
- package/src/mcp-apps/types.ts +16 -1
- package/src/model-context/frame/AssistantFrame.test.ts +45 -0
- package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
- package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
- package/src/primitives/attachment.ts +4 -1
- package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +32 -1
- package/src/primitives/composer/ComposerAttachmentDropzone.tsx +14 -12
- package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
- package/src/primitives/composer/ComposerCancel.ts +8 -1
- package/src/primitives/composer/ComposerDisabledThread.test.tsx +121 -0
- package/src/primitives/composer/ComposerInput.tsx +19 -1
- package/src/primitives/composer/ComposerRoot.tsx +6 -5
- package/src/primitives/composer/composerInputSelector.ts +4 -0
- package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
- package/src/primitives/composer.ts +56 -21
- package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
- package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
- package/src/primitives/thread/ThreadViewport.tsx +3 -3
- package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +666 -63
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +201 -73
- package/src/sandbox-host/SandboxHost.tsx +3 -8
- package/src/unstable/useComposerInput.ts +9 -4
- package/src/unstable/useComposerInputHistory.ts +3 -2
- package/src/unstable/useLiveCompletionAdapter.ts +9 -14
- package/src/unstable/useMentionAdapter.ts +12 -3
- package/src/unstable/useMessageStallDetection.ts +4 -2
- package/src/unstable/useSlashCommandAdapter.ts +8 -4
- package/src/unstable/webmcp/useWebMcpProvider.ts +4 -0
- package/src/utils/getSelectionMessageId.test.ts +231 -0
- package/src/utils/getSelectionMessageId.ts +45 -7
- package/src/utils/hooks/useOnResizeContent.ts +2 -29
- package/src/utils/useIsomorphicLayoutEffect.test.tsx +21 -0
- 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 {
|
|
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
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
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
|
+
});
|