@assistant-ui/react 0.14.27 → 0.14.28
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/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +2 -2
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/context/react/utils/createContextStoreHook.d.ts.map +1 -1
- package/dist/context/react/utils/createStateHookForRuntime.d.ts.map +1 -1
- package/dist/legacy-runtime/AssistantRuntimeProvider.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/model-context/makeAssistantVisible.d.ts.map +1 -1
- package/dist/model-context/makeAssistantVisible.js +7 -1
- package/dist/model-context/makeAssistantVisible.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarCopy.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarEdit.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarReload.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarRoot.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarRoot.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarSpeak.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarStopSpeaking.d.ts.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreContent.d.ts.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreContent.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreItem.d.ts.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreSeparator.d.ts.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreTrigger.d.ts.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalAnchor.d.ts.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalContent.d.ts.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalContent.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalTrigger.d.ts.map +1 -1
- package/dist/primitives/assistantModal/popoverRenderPrimitives.d.ts.map +1 -1
- package/dist/primitives/attachment/AttachmentRemove.d.ts.map +1 -1
- package/dist/primitives/attachment/AttachmentRoot.d.ts.map +1 -1
- package/dist/primitives/attachment/AttachmentThumb.d.ts.map +1 -1
- package/dist/primitives/attachment/AttachmentThumb.js.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerNext.d.ts.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerPrevious.d.ts.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerRoot.d.ts.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerRoot.js.map +1 -1
- package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.d.ts.map +1 -1
- package/dist/primitives/chainOfThought/ChainOfThoughtRoot.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAddAttachment.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerDictate.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerDictationTranscript.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerDictationTranscript.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerQuote.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerQuote.js.map +1 -1
- package/dist/primitives/composer/ComposerRoot.d.ts +5 -0
- package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerRoot.js +86 -40
- package/dist/primitives/composer/ComposerRoot.js.map +1 -1
- package/dist/primitives/composer/ComposerSend.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerStopDictation.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverBack.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverItems.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
- package/dist/primitives/composer/trigger/detectTrigger.d.ts +1 -1
- package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
- package/dist/primitives/dropdownMenuRenderPrimitives.d.ts.map +1 -1
- package/dist/primitives/error/ErrorMessage.d.ts.map +1 -1
- package/dist/primitives/error/ErrorMessage.js.map +1 -1
- package/dist/primitives/error/ErrorRoot.d.ts.map +1 -1
- package/dist/primitives/message/MessageParts.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/message/MessageRoot.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartImage.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
- package/dist/primitives/messagePart/useMessagePartData.d.ts +3 -2
- package/dist/primitives/messagePart/useMessagePartData.d.ts.map +1 -1
- package/dist/primitives/messagePart/useMessagePartData.js +3 -2
- package/dist/primitives/messagePart/useMessagePartData.js.map +1 -1
- package/dist/primitives/messagePart/useMessagePartFile.d.ts +5 -4
- package/dist/primitives/messagePart/useMessagePartFile.d.ts.map +1 -1
- package/dist/primitives/messagePart/useMessagePartFile.js +11 -3
- package/dist/primitives/messagePart/useMessagePartFile.js.map +1 -1
- package/dist/primitives/messagePart/useMessagePartImage.d.ts +5 -4
- package/dist/primitives/messagePart/useMessagePartImage.d.ts.map +1 -1
- package/dist/primitives/messagePart/useMessagePartImage.js +10 -3
- package/dist/primitives/messagePart/useMessagePartImage.js.map +1 -1
- package/dist/primitives/messagePart/useMessagePartReasoning.d.ts +5 -4
- package/dist/primitives/messagePart/useMessagePartReasoning.d.ts.map +1 -1
- package/dist/primitives/messagePart/useMessagePartReasoning.js +10 -3
- package/dist/primitives/messagePart/useMessagePartReasoning.js.map +1 -1
- package/dist/primitives/messagePart/useMessagePartSource.d.ts +6 -4
- package/dist/primitives/messagePart/useMessagePartSource.d.ts.map +1 -1
- package/dist/primitives/messagePart/useMessagePartSource.js +12 -3
- package/dist/primitives/messagePart/useMessagePartSource.js.map +1 -1
- package/dist/primitives/messagePart/useMessagePartText.d.ts +6 -5
- package/dist/primitives/messagePart/useMessagePartText.d.ts.map +1 -1
- package/dist/primitives/messagePart/useMessagePartText.js +10 -3
- package/dist/primitives/messagePart/useMessagePartText.js.map +1 -1
- package/dist/primitives/queueItem/QueueItemRemove.d.ts.map +1 -1
- package/dist/primitives/queueItem/QueueItemSteer.d.ts.map +1 -1
- package/dist/primitives/queueItem/QueueItemText.d.ts.map +1 -1
- package/dist/primitives/queueItem/QueueItemText.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarQuote.d.ts.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
- package/dist/primitives/suggestion/SuggestionDescription.d.ts.map +1 -1
- package/dist/primitives/suggestion/SuggestionDescription.js.map +1 -1
- package/dist/primitives/suggestion/SuggestionTitle.d.ts.map +1 -1
- package/dist/primitives/suggestion/SuggestionTitle.js.map +1 -1
- package/dist/primitives/suggestion/SuggestionTrigger.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadScrollToBottom.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadSuggestion.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewport.js +53 -19
- package/dist/primitives/thread/ThreadViewport.js.map +1 -1
- package/dist/primitives/thread/ThreadViewportFooter.d.ts.map +1 -1
- package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts.map +1 -1
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts +4 -0
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts.map +1 -1
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js +14 -1
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js.map +1 -1
- package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts +13 -1
- package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts.map +1 -1
- package/dist/primitives/thread/topAnchor/topAnchorTurn.js +14 -1
- package/dist/primitives/thread/topAnchor/topAnchorTurn.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +2 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/primitives/threadList/ThreadListLoadMore.d.ts.map +1 -1
- package/dist/primitives/threadList/ThreadListNew.d.ts.map +1 -1
- package/dist/primitives/threadList/ThreadListRoot.d.ts.map +1 -1
- package/dist/primitives/threadList/ThreadListRoot.js.map +1 -1
- package/dist/primitives/threadListFocusGroup.d.ts.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemArchive.d.ts.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemDelete.d.ts.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemTrigger.d.ts.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemUnarchive.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreItem.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreSeparator.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.d.ts.map +1 -1
- package/dist/tests/setup.d.ts +1 -1
- package/dist/utils/Primitive.d.ts.map +1 -1
- package/dist/utils/Primitive.js.map +1 -1
- package/dist/utils/createActionButton.d.ts.map +1 -1
- package/package.json +20 -20
- package/src/client/ExternalThread.ts +2 -1
- package/src/model-context/makeAssistantVisible.test.tsx +146 -0
- package/src/model-context/makeAssistantVisible.tsx +18 -1
- package/src/primitives/composer/ComposerRoot.test.tsx +226 -5
- package/src/primitives/composer/ComposerRoot.tsx +50 -1
- package/src/primitives/messagePart/useMessagePartData.ts +3 -2
- package/src/primitives/messagePart/useMessagePartFile.ts +19 -7
- package/src/primitives/messagePart/useMessagePartImage.ts +18 -7
- package/src/primitives/messagePart/useMessagePartReasoning.ts +15 -6
- package/src/primitives/messagePart/useMessagePartSource.ts +20 -7
- package/src/primitives/messagePart/useMessagePartText.ts +15 -5
- package/src/primitives/thread/ThreadViewport.tsx +15 -0
- package/src/primitives/thread/topAnchor/mountTopAnchorReserve.test.ts +243 -0
- package/src/primitives/thread/topAnchor/mountTopAnchorReserve.ts +28 -7
- package/src/primitives/thread/topAnchor/topAnchorTurn.test.ts +69 -0
- package/src/primitives/thread/topAnchor/topAnchorTurn.ts +29 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +86 -1
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +6 -1
- package/src/tests/BaseComposerRuntimeCore.test.ts +12 -3
- package/src/tests/external-thread-attachments.test.tsx +81 -0
- package/src/tests/messagePartSwitchRace.test.tsx +158 -0
- package/src/tests/messagePartTypeMismatch.test.tsx +146 -0
|
@@ -8,12 +8,36 @@ import {
|
|
|
8
8
|
type FormEvent,
|
|
9
9
|
forwardRef,
|
|
10
10
|
type ComponentPropsWithoutRef,
|
|
11
|
+
type MouseEvent,
|
|
11
12
|
useMemo,
|
|
12
13
|
useState,
|
|
13
14
|
} from "react";
|
|
14
15
|
import { useComposerSend } from "./ComposerSend";
|
|
15
16
|
import { ComposerCompactContext } from "./ComposerCompactContext";
|
|
16
17
|
|
|
18
|
+
const CONTENT_EDITABLE_SELECTOR =
|
|
19
|
+
"[contenteditable]:not([contenteditable='false'])";
|
|
20
|
+
|
|
21
|
+
const COMPOSER_INPUT_SELECTOR = `textarea:not(:disabled), ${CONTENT_EDITABLE_SELECTOR}`;
|
|
22
|
+
|
|
23
|
+
// Keeps tabindex="-1": roving-tabindex widgets click-focus items at -1.
|
|
24
|
+
const INTERACTIVE_ELEMENT_SELECTOR = [
|
|
25
|
+
"a",
|
|
26
|
+
"audio[controls]",
|
|
27
|
+
"button",
|
|
28
|
+
"details",
|
|
29
|
+
"embed",
|
|
30
|
+
"iframe",
|
|
31
|
+
"input",
|
|
32
|
+
"label",
|
|
33
|
+
"select",
|
|
34
|
+
"summary",
|
|
35
|
+
"textarea",
|
|
36
|
+
"video[controls]",
|
|
37
|
+
CONTENT_EDITABLE_SELECTOR,
|
|
38
|
+
"[tabindex]",
|
|
39
|
+
].join(", ");
|
|
40
|
+
|
|
17
41
|
export namespace ComposerPrimitiveRoot {
|
|
18
42
|
export type Element = ComponentRef<typeof Primitive.form>;
|
|
19
43
|
/**
|
|
@@ -40,6 +64,11 @@ export namespace ComposerPrimitiveRoot {
|
|
|
40
64
|
* is submitted (e.g., via Enter key or submit button). It automatically prevents the
|
|
41
65
|
* default form submission and triggers the composer's send functionality.
|
|
42
66
|
*
|
|
67
|
+
* Clicking blank form space focuses the composer input (the first textarea or
|
|
68
|
+
* contenteditable inside the form). Text selection cannot start from blank
|
|
69
|
+
* areas; descendants that need native mousedown defaults can call
|
|
70
|
+
* `stopPropagation()` in their own `onMouseDown`.
|
|
71
|
+
*
|
|
43
72
|
* @example
|
|
44
73
|
* ```tsx
|
|
45
74
|
* <ComposerPrimitive.Root>
|
|
@@ -51,7 +80,7 @@ export namespace ComposerPrimitiveRoot {
|
|
|
51
80
|
export const ComposerPrimitiveRoot = forwardRef<
|
|
52
81
|
ComposerPrimitiveRoot.Element,
|
|
53
82
|
ComposerPrimitiveRoot.Props
|
|
54
|
-
>(({ onSubmit, compact, ...rest }, forwardedRef) => {
|
|
83
|
+
>(({ onSubmit, onMouseDown, compact, ...rest }, forwardedRef) => {
|
|
55
84
|
const send = useComposerSend();
|
|
56
85
|
|
|
57
86
|
const [multiline, setMultiline] = useState(false);
|
|
@@ -74,6 +103,25 @@ export const ComposerPrimitiveRoot = forwardRef<
|
|
|
74
103
|
send();
|
|
75
104
|
};
|
|
76
105
|
|
|
106
|
+
const handleMouseDown = (e: MouseEvent<HTMLFormElement>) => {
|
|
107
|
+
if (e.button !== 0) return;
|
|
108
|
+
const target = e.target;
|
|
109
|
+
// Portaled descendants (e.g. a dialog overlay) propagate mousedown through
|
|
110
|
+
// the React tree while their DOM nodes live outside the form.
|
|
111
|
+
if (!(target instanceof Element) || !e.currentTarget.contains(target))
|
|
112
|
+
return;
|
|
113
|
+
// Bound the ancestor walk to the form: hosts like AssistantModal render
|
|
114
|
+
// the composer inside a wrapper carrying tabindex="-1" (Radix FocusScope).
|
|
115
|
+
const interactive = target.closest(INTERACTIVE_ELEMENT_SELECTOR);
|
|
116
|
+
if (interactive && e.currentTarget.contains(interactive)) return;
|
|
117
|
+
const input = e.currentTarget.querySelector<HTMLElement>(
|
|
118
|
+
COMPOSER_INPUT_SELECTOR,
|
|
119
|
+
);
|
|
120
|
+
if (!input) return;
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
input.focus();
|
|
123
|
+
};
|
|
124
|
+
|
|
77
125
|
return (
|
|
78
126
|
<ComposerCompactContext.Provider value={compact ? compactContext : null}>
|
|
79
127
|
<Primitive.form
|
|
@@ -81,6 +129,7 @@ export const ComposerPrimitiveRoot = forwardRef<
|
|
|
81
129
|
data-compact={isCompact ? "" : undefined}
|
|
82
130
|
ref={forwardedRef}
|
|
83
131
|
onSubmit={composeEventHandlers(onSubmit, handleSubmit)}
|
|
132
|
+
onMouseDown={composeEventHandlers(onMouseDown, handleMouseDown)}
|
|
84
133
|
/>
|
|
85
134
|
</ComposerCompactContext.Provider>
|
|
86
135
|
);
|
|
@@ -5,8 +5,9 @@ import { useAuiState } from "@assistant-ui/store";
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* @deprecated Use {@link useAuiState} to select and narrow `s.part`.
|
|
8
|
-
* Return `null` for optional rendering
|
|
9
|
-
*
|
|
8
|
+
* Return `null` for optional rendering. Do not throw inside the selector:
|
|
9
|
+
* selectors run inside `useSyncExternalStore`'s `getSnapshot`, so a transient
|
|
10
|
+
* part mismatch during thread switches can unmount the React root.
|
|
10
11
|
*
|
|
11
12
|
* @example
|
|
12
13
|
* ```tsx
|
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
FileMessagePart,
|
|
5
|
+
MessagePartState,
|
|
6
|
+
MessagePartStatus,
|
|
7
|
+
} from "@assistant-ui/core";
|
|
4
8
|
import { useAuiState } from "@assistant-ui/store";
|
|
5
9
|
|
|
10
|
+
const COMPLETE_STATUS: MessagePartStatus = Object.freeze({ type: "complete" });
|
|
11
|
+
|
|
12
|
+
const EMPTY_FILE_PART: MessagePartState & FileMessagePart = Object.freeze({
|
|
13
|
+
type: "file",
|
|
14
|
+
data: "",
|
|
15
|
+
mimeType: "",
|
|
16
|
+
status: COMPLETE_STATUS,
|
|
17
|
+
});
|
|
18
|
+
|
|
6
19
|
/**
|
|
7
20
|
* @deprecated Use {@link useAuiState} to select and narrow `s.part`.
|
|
8
|
-
* Return `null` for optional rendering
|
|
9
|
-
*
|
|
21
|
+
* Return `null` for optional rendering. Do not throw inside the selector:
|
|
22
|
+
* selectors run inside `useSyncExternalStore`'s `getSnapshot`, so a transient
|
|
23
|
+
* part mismatch during thread switches can unmount the React root.
|
|
10
24
|
*
|
|
11
25
|
* @example
|
|
12
26
|
* ```tsx
|
|
@@ -19,11 +33,9 @@ import { useAuiState } from "@assistant-ui/store";
|
|
|
19
33
|
* See the {@link https://assistant-ui.com/docs/migrations/v0-12 migration guide}.
|
|
20
34
|
*/
|
|
21
35
|
export const useMessagePartFile = () => {
|
|
36
|
+
// Sentinel instead of throw: see useMessagePartText for the invariant.
|
|
22
37
|
const file = useAuiState((s) => {
|
|
23
|
-
if (s.part.type !== "file")
|
|
24
|
-
throw new Error(
|
|
25
|
-
"MessagePartFile can only be used inside file message parts.",
|
|
26
|
-
);
|
|
38
|
+
if (s.part.type !== "file") return EMPTY_FILE_PART;
|
|
27
39
|
|
|
28
40
|
return s.part as MessagePartState & FileMessagePart;
|
|
29
41
|
});
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
ImageMessagePart,
|
|
5
|
+
MessagePartState,
|
|
6
|
+
MessagePartStatus,
|
|
7
|
+
} from "@assistant-ui/core";
|
|
4
8
|
import { useAuiState } from "@assistant-ui/store";
|
|
5
9
|
|
|
10
|
+
const COMPLETE_STATUS: MessagePartStatus = Object.freeze({ type: "complete" });
|
|
11
|
+
|
|
12
|
+
const EMPTY_IMAGE_PART: MessagePartState & ImageMessagePart = Object.freeze({
|
|
13
|
+
type: "image",
|
|
14
|
+
image: "",
|
|
15
|
+
status: COMPLETE_STATUS,
|
|
16
|
+
});
|
|
17
|
+
|
|
6
18
|
/**
|
|
7
19
|
* @deprecated Use {@link useAuiState} to select and narrow `s.part`.
|
|
8
|
-
* Return `null` for optional rendering
|
|
9
|
-
*
|
|
20
|
+
* Return `null` for optional rendering. Do not throw inside the selector:
|
|
21
|
+
* selectors run inside `useSyncExternalStore`'s `getSnapshot`, so a transient
|
|
22
|
+
* part mismatch during thread switches can unmount the React root.
|
|
10
23
|
*
|
|
11
24
|
* @example
|
|
12
25
|
* ```tsx
|
|
@@ -19,11 +32,9 @@ import { useAuiState } from "@assistant-ui/store";
|
|
|
19
32
|
* See the {@link https://assistant-ui.com/docs/migrations/v0-12 migration guide}.
|
|
20
33
|
*/
|
|
21
34
|
export const useMessagePartImage = () => {
|
|
35
|
+
// Sentinel instead of throw: see useMessagePartText for the invariant.
|
|
22
36
|
const image = useAuiState((s) => {
|
|
23
|
-
if (s.part.type !== "image")
|
|
24
|
-
throw new Error(
|
|
25
|
-
"MessagePartImage can only be used inside image message parts.",
|
|
26
|
-
);
|
|
37
|
+
if (s.part.type !== "image") return EMPTY_IMAGE_PART;
|
|
27
38
|
|
|
28
39
|
return s.part as MessagePartState & ImageMessagePart;
|
|
29
40
|
});
|
|
@@ -3,13 +3,24 @@
|
|
|
3
3
|
import type {
|
|
4
4
|
ReasoningMessagePart,
|
|
5
5
|
MessagePartState,
|
|
6
|
+
MessagePartStatus,
|
|
6
7
|
} from "@assistant-ui/core";
|
|
7
8
|
import { useAuiState } from "@assistant-ui/store";
|
|
8
9
|
|
|
10
|
+
const COMPLETE_STATUS: MessagePartStatus = Object.freeze({ type: "complete" });
|
|
11
|
+
|
|
12
|
+
const EMPTY_REASONING_PART: MessagePartState & ReasoningMessagePart =
|
|
13
|
+
Object.freeze({
|
|
14
|
+
type: "reasoning",
|
|
15
|
+
text: "",
|
|
16
|
+
status: COMPLETE_STATUS,
|
|
17
|
+
});
|
|
18
|
+
|
|
9
19
|
/**
|
|
10
20
|
* @deprecated Use {@link useAuiState} to select and narrow `s.part`.
|
|
11
|
-
* Return `null` for optional rendering
|
|
12
|
-
*
|
|
21
|
+
* Return `null` for optional rendering. Do not throw inside the selector:
|
|
22
|
+
* selectors run inside `useSyncExternalStore`'s `getSnapshot`, so a transient
|
|
23
|
+
* part mismatch during thread switches can unmount the React root.
|
|
13
24
|
*
|
|
14
25
|
* @example
|
|
15
26
|
* ```tsx
|
|
@@ -22,11 +33,9 @@ import { useAuiState } from "@assistant-ui/store";
|
|
|
22
33
|
* See the {@link https://assistant-ui.com/docs/migrations/v0-12 migration guide}.
|
|
23
34
|
*/
|
|
24
35
|
export const useMessagePartReasoning = () => {
|
|
36
|
+
// Sentinel instead of throw: see useMessagePartText for the invariant.
|
|
25
37
|
const text = useAuiState((s) => {
|
|
26
|
-
if (s.part.type !== "reasoning")
|
|
27
|
-
throw new Error(
|
|
28
|
-
"MessagePartReasoning can only be used inside reasoning message parts.",
|
|
29
|
-
);
|
|
38
|
+
if (s.part.type !== "reasoning") return EMPTY_REASONING_PART;
|
|
30
39
|
|
|
31
40
|
return s.part as MessagePartState & ReasoningMessagePart;
|
|
32
41
|
});
|
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
SourceMessagePart,
|
|
5
|
+
MessagePartState,
|
|
6
|
+
MessagePartStatus,
|
|
7
|
+
} from "@assistant-ui/core";
|
|
4
8
|
import { useAuiState } from "@assistant-ui/store";
|
|
5
9
|
|
|
10
|
+
const COMPLETE_STATUS: MessagePartStatus = Object.freeze({ type: "complete" });
|
|
11
|
+
|
|
12
|
+
const EMPTY_SOURCE_PART: MessagePartState & SourceMessagePart = Object.freeze({
|
|
13
|
+
type: "source",
|
|
14
|
+
sourceType: "url",
|
|
15
|
+
id: "",
|
|
16
|
+
url: "",
|
|
17
|
+
status: COMPLETE_STATUS,
|
|
18
|
+
});
|
|
19
|
+
|
|
6
20
|
/**
|
|
7
21
|
* @deprecated Use {@link useAuiState} to select and narrow `s.part`.
|
|
8
|
-
* Return `null` for optional rendering
|
|
9
|
-
*
|
|
22
|
+
* Return `null` for optional rendering. Do not throw inside the selector:
|
|
23
|
+
* selectors run inside `useSyncExternalStore`'s `getSnapshot`, so a transient
|
|
24
|
+
* part mismatch during thread switches can unmount the React root.
|
|
10
25
|
*
|
|
11
26
|
* @example
|
|
12
27
|
* ```tsx
|
|
@@ -19,11 +34,9 @@ import { useAuiState } from "@assistant-ui/store";
|
|
|
19
34
|
* See the {@link https://assistant-ui.com/docs/migrations/v0-12 migration guide}.
|
|
20
35
|
*/
|
|
21
36
|
export const useMessagePartSource = () => {
|
|
37
|
+
// Sentinel instead of throw: see useMessagePartText for the invariant.
|
|
22
38
|
const source = useAuiState((s) => {
|
|
23
|
-
if (s.part.type !== "source")
|
|
24
|
-
throw new Error(
|
|
25
|
-
"MessagePartSource can only be used inside source message parts.",
|
|
26
|
-
);
|
|
39
|
+
if (s.part.type !== "source") return EMPTY_SOURCE_PART;
|
|
27
40
|
|
|
28
41
|
return s.part as MessagePartState & SourceMessagePart;
|
|
29
42
|
});
|
|
@@ -4,13 +4,23 @@ import type {
|
|
|
4
4
|
TextMessagePart,
|
|
5
5
|
ReasoningMessagePart,
|
|
6
6
|
MessagePartState,
|
|
7
|
+
MessagePartStatus,
|
|
7
8
|
} from "@assistant-ui/core";
|
|
8
9
|
import { useAuiState } from "@assistant-ui/store";
|
|
9
10
|
|
|
11
|
+
const COMPLETE_STATUS: MessagePartStatus = Object.freeze({ type: "complete" });
|
|
12
|
+
|
|
13
|
+
const EMPTY_TEXT_PART: MessagePartState & TextMessagePart = Object.freeze({
|
|
14
|
+
type: "text",
|
|
15
|
+
text: "",
|
|
16
|
+
status: COMPLETE_STATUS,
|
|
17
|
+
});
|
|
18
|
+
|
|
10
19
|
/**
|
|
11
20
|
* @deprecated Use {@link useAuiState} to select and narrow `s.part`.
|
|
12
|
-
* Return `null` for optional rendering
|
|
13
|
-
*
|
|
21
|
+
* Return `null` for optional rendering. Do not throw inside the selector:
|
|
22
|
+
* selectors run inside `useSyncExternalStore`'s `getSnapshot`, so a transient
|
|
23
|
+
* part mismatch during thread switches can unmount the React root.
|
|
14
24
|
*
|
|
15
25
|
* @example
|
|
16
26
|
* ```tsx
|
|
@@ -23,11 +33,11 @@ import { useAuiState } from "@assistant-ui/store";
|
|
|
23
33
|
* See the {@link https://assistant-ui.com/docs/migrations/v0-12 migration guide}.
|
|
24
34
|
*/
|
|
25
35
|
export const useMessagePartText = () => {
|
|
36
|
+
// Runs inside useSyncExternalStore's getSnapshot, where a throw tears down
|
|
37
|
+
// the React root; the module-level frozen sentinel keeps snapshots stable.
|
|
26
38
|
const text = useAuiState((s) => {
|
|
27
39
|
if (s.part.type !== "text" && s.part.type !== "reasoning")
|
|
28
|
-
|
|
29
|
-
"MessagePartText can only be used inside text or reasoning message parts.",
|
|
30
|
-
);
|
|
40
|
+
return EMPTY_TEXT_PART;
|
|
31
41
|
|
|
32
42
|
return s.part as MessagePartState &
|
|
33
43
|
(TextMessagePart | ReasoningMessagePart);
|
|
@@ -23,6 +23,7 @@ import { useTopAnchorReserve } from "./topAnchor/useTopAnchorReserve";
|
|
|
23
23
|
import {
|
|
24
24
|
getActiveTopAnchorAnchorId,
|
|
25
25
|
getActiveTopAnchorTargetId,
|
|
26
|
+
isTopAnchorTurnValid,
|
|
26
27
|
} from "./topAnchor/topAnchorTurn";
|
|
27
28
|
|
|
28
29
|
export namespace ThreadPrimitiveViewport {
|
|
@@ -110,11 +111,25 @@ const useTopAnchorTurn = (enabled: boolean) => {
|
|
|
110
111
|
if (!enabled) return undefined;
|
|
111
112
|
return getActiveTopAnchorTargetId(s.thread);
|
|
112
113
|
});
|
|
114
|
+
const topAnchorTurn = useThreadViewport((s) => s.topAnchorTurn);
|
|
113
115
|
const activeTurn = useMemo(() => {
|
|
114
116
|
if (!activeAnchorId || !activeTargetId) return null;
|
|
115
117
|
return { anchorId: activeAnchorId, targetId: activeTargetId };
|
|
116
118
|
}, [activeAnchorId, activeTargetId]);
|
|
117
119
|
|
|
120
|
+
const topAnchorTurnIsValid = useAuiState(
|
|
121
|
+
(s) =>
|
|
122
|
+
enabled &&
|
|
123
|
+
!!topAnchorTurn &&
|
|
124
|
+
isTopAnchorTurnValid(topAnchorTurn, s.thread.messages),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
useLayoutEffect(() => {
|
|
128
|
+
if (!topAnchorTurn || topAnchorTurnIsValid) return;
|
|
129
|
+
|
|
130
|
+
threadViewportStore.getState().setTopAnchorTurn(null);
|
|
131
|
+
}, [threadViewportStore, topAnchorTurn, topAnchorTurnIsValid]);
|
|
132
|
+
|
|
118
133
|
useLayoutEffect(() => {
|
|
119
134
|
if (!activeTurn) return;
|
|
120
135
|
|
|
@@ -43,6 +43,7 @@ const makeStore = (state: ReturnType<TopAnchorStore["getState"]>) => {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
const numericClamp = { tallerThan: 160, visibleHeight: 96 };
|
|
46
|
+
const activeTopAnchorTurn = { anchorId: "user-1", targetId: "assistant-1" };
|
|
46
47
|
|
|
47
48
|
describe("mountTopAnchorReserve", () => {
|
|
48
49
|
beforeEach(() => {
|
|
@@ -76,6 +77,7 @@ describe("mountTopAnchorReserve", () => {
|
|
|
76
77
|
turnAnchor: "top",
|
|
77
78
|
element: { viewport, anchor, target },
|
|
78
79
|
targetConfig: numericClamp,
|
|
80
|
+
topAnchorTurn: activeTopAnchorTurn,
|
|
79
81
|
});
|
|
80
82
|
|
|
81
83
|
mountTopAnchorReserve(store);
|
|
@@ -90,6 +92,245 @@ describe("mountTopAnchorReserve", () => {
|
|
|
90
92
|
expect(reserve.style.height).toBe("60px");
|
|
91
93
|
});
|
|
92
94
|
|
|
95
|
+
it("preserves the reserve across a transient between-turns anchor gap", () => {
|
|
96
|
+
const viewport = document.createElement("div");
|
|
97
|
+
const anchor = document.createElement("div");
|
|
98
|
+
const target = document.createElement("div");
|
|
99
|
+
const reserveHost = document.createElement("div");
|
|
100
|
+
reserveHost.append(target);
|
|
101
|
+
document.body.append(reserveHost);
|
|
102
|
+
|
|
103
|
+
defineReadonlyNumber(viewport, "offsetTop", 0);
|
|
104
|
+
defineReadonlyNumber(viewport, "clientHeight", 400);
|
|
105
|
+
defineReadonlyNumber(viewport, "scrollHeight", 560);
|
|
106
|
+
defineReadonlyNumber(anchor, "offsetTop", 220);
|
|
107
|
+
defineReadonlyNumber(anchor, "offsetHeight", 64);
|
|
108
|
+
viewport.scrollTo = vi.fn();
|
|
109
|
+
|
|
110
|
+
const { store, setState } = makeStore({
|
|
111
|
+
turnAnchor: "top",
|
|
112
|
+
element: { viewport, anchor, target },
|
|
113
|
+
targetConfig: numericClamp,
|
|
114
|
+
topAnchorTurn: activeTopAnchorTurn,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const unmount = mountTopAnchorReserve(store);
|
|
118
|
+
vi.runOnlyPendingTimers();
|
|
119
|
+
|
|
120
|
+
const reserve = reserveHost.querySelector(
|
|
121
|
+
"[data-aui-top-anchor-reserve]",
|
|
122
|
+
) as HTMLElement;
|
|
123
|
+
const optimisticUserMessage = document.createElement("div");
|
|
124
|
+
reserveHost.append(optimisticUserMessage);
|
|
125
|
+
const appendSpy = vi.spyOn(reserveHost, "append");
|
|
126
|
+
|
|
127
|
+
setState({
|
|
128
|
+
turnAnchor: "top",
|
|
129
|
+
element: { viewport, anchor: null, target: null },
|
|
130
|
+
targetConfig: null,
|
|
131
|
+
topAnchorTurn: activeTopAnchorTurn,
|
|
132
|
+
});
|
|
133
|
+
vi.runOnlyPendingTimers();
|
|
134
|
+
|
|
135
|
+
expect(reserve.isConnected).toBe(true);
|
|
136
|
+
expect(reserve.style.height).toBe("60px");
|
|
137
|
+
expect(reserve.previousElementSibling).toBe(optimisticUserMessage);
|
|
138
|
+
expect(appendSpy).toHaveBeenCalledTimes(1);
|
|
139
|
+
|
|
140
|
+
setState({
|
|
141
|
+
turnAnchor: "top",
|
|
142
|
+
element: { viewport, anchor: null, target: null },
|
|
143
|
+
targetConfig: null,
|
|
144
|
+
topAnchorTurn: activeTopAnchorTurn,
|
|
145
|
+
});
|
|
146
|
+
vi.runOnlyPendingTimers();
|
|
147
|
+
|
|
148
|
+
expect(appendSpy).toHaveBeenCalledTimes(1);
|
|
149
|
+
|
|
150
|
+
const nextAnchor = document.createElement("div");
|
|
151
|
+
const nextTarget = document.createElement("div");
|
|
152
|
+
reserveHost.append(nextTarget);
|
|
153
|
+
defineReadonlyNumber(nextAnchor, "offsetTop", 236);
|
|
154
|
+
defineReadonlyNumber(nextAnchor, "offsetHeight", 64);
|
|
155
|
+
|
|
156
|
+
setState({
|
|
157
|
+
turnAnchor: "top",
|
|
158
|
+
element: { viewport, anchor: nextAnchor, target: nextTarget },
|
|
159
|
+
targetConfig: numericClamp,
|
|
160
|
+
topAnchorTurn: activeTopAnchorTurn,
|
|
161
|
+
});
|
|
162
|
+
vi.runOnlyPendingTimers();
|
|
163
|
+
|
|
164
|
+
expect(reserve.previousElementSibling).toBe(nextTarget);
|
|
165
|
+
|
|
166
|
+
unmount();
|
|
167
|
+
expect(reserve.isConnected).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("removes the reserve when the active turn is no longer valid", () => {
|
|
171
|
+
const viewport = document.createElement("div");
|
|
172
|
+
const anchor = document.createElement("div");
|
|
173
|
+
const target = document.createElement("div");
|
|
174
|
+
const reserveHost = document.createElement("div");
|
|
175
|
+
reserveHost.append(target);
|
|
176
|
+
document.body.append(reserveHost);
|
|
177
|
+
|
|
178
|
+
defineReadonlyNumber(viewport, "offsetTop", 0);
|
|
179
|
+
defineReadonlyNumber(viewport, "clientHeight", 400);
|
|
180
|
+
defineReadonlyNumber(viewport, "scrollHeight", 560);
|
|
181
|
+
defineReadonlyNumber(anchor, "offsetTop", 220);
|
|
182
|
+
defineReadonlyNumber(anchor, "offsetHeight", 64);
|
|
183
|
+
viewport.scrollTo = vi.fn();
|
|
184
|
+
|
|
185
|
+
const { store, setState } = makeStore({
|
|
186
|
+
turnAnchor: "top",
|
|
187
|
+
element: { viewport, anchor, target },
|
|
188
|
+
targetConfig: numericClamp,
|
|
189
|
+
topAnchorTurn: activeTopAnchorTurn,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
mountTopAnchorReserve(store);
|
|
193
|
+
vi.runOnlyPendingTimers();
|
|
194
|
+
|
|
195
|
+
const reserve = reserveHost.querySelector(
|
|
196
|
+
"[data-aui-top-anchor-reserve]",
|
|
197
|
+
) as HTMLElement;
|
|
198
|
+
|
|
199
|
+
setState({
|
|
200
|
+
turnAnchor: "top",
|
|
201
|
+
element: { viewport, anchor: null, target: null },
|
|
202
|
+
targetConfig: null,
|
|
203
|
+
topAnchorTurn: null,
|
|
204
|
+
});
|
|
205
|
+
vi.runOnlyPendingTimers();
|
|
206
|
+
|
|
207
|
+
expect(reserve.isConnected).toBe(false);
|
|
208
|
+
expect(reserve.style.height).toBe("0px");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("removes the reserve when only the registered anchor unmounts", () => {
|
|
212
|
+
const viewport = document.createElement("div");
|
|
213
|
+
const anchor = document.createElement("div");
|
|
214
|
+
const target = document.createElement("div");
|
|
215
|
+
const reserveHost = document.createElement("div");
|
|
216
|
+
reserveHost.append(target);
|
|
217
|
+
document.body.append(reserveHost);
|
|
218
|
+
|
|
219
|
+
defineReadonlyNumber(viewport, "offsetTop", 0);
|
|
220
|
+
defineReadonlyNumber(viewport, "clientHeight", 400);
|
|
221
|
+
defineReadonlyNumber(viewport, "scrollHeight", 560);
|
|
222
|
+
defineReadonlyNumber(anchor, "offsetTop", 220);
|
|
223
|
+
defineReadonlyNumber(anchor, "offsetHeight", 64);
|
|
224
|
+
viewport.scrollTo = vi.fn();
|
|
225
|
+
|
|
226
|
+
const { store, setState } = makeStore({
|
|
227
|
+
turnAnchor: "top",
|
|
228
|
+
element: { viewport, anchor, target },
|
|
229
|
+
targetConfig: numericClamp,
|
|
230
|
+
topAnchorTurn: activeTopAnchorTurn,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
mountTopAnchorReserve(store);
|
|
234
|
+
vi.runOnlyPendingTimers();
|
|
235
|
+
|
|
236
|
+
const reserve = reserveHost.querySelector(
|
|
237
|
+
"[data-aui-top-anchor-reserve]",
|
|
238
|
+
) as HTMLElement;
|
|
239
|
+
|
|
240
|
+
setState({
|
|
241
|
+
turnAnchor: "top",
|
|
242
|
+
element: { viewport, anchor: null, target },
|
|
243
|
+
targetConfig: numericClamp,
|
|
244
|
+
topAnchorTurn: activeTopAnchorTurn,
|
|
245
|
+
});
|
|
246
|
+
vi.runOnlyPendingTimers();
|
|
247
|
+
|
|
248
|
+
expect(reserve.isConnected).toBe(false);
|
|
249
|
+
expect(reserve.style.height).toBe("0px");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("removes the reserve when the viewport is unavailable", () => {
|
|
253
|
+
const viewport = document.createElement("div");
|
|
254
|
+
const anchor = document.createElement("div");
|
|
255
|
+
const target = document.createElement("div");
|
|
256
|
+
const reserveHost = document.createElement("div");
|
|
257
|
+
reserveHost.append(target);
|
|
258
|
+
document.body.append(reserveHost);
|
|
259
|
+
|
|
260
|
+
defineReadonlyNumber(viewport, "offsetTop", 0);
|
|
261
|
+
defineReadonlyNumber(viewport, "clientHeight", 400);
|
|
262
|
+
defineReadonlyNumber(viewport, "scrollHeight", 560);
|
|
263
|
+
defineReadonlyNumber(anchor, "offsetTop", 220);
|
|
264
|
+
defineReadonlyNumber(anchor, "offsetHeight", 64);
|
|
265
|
+
viewport.scrollTo = vi.fn();
|
|
266
|
+
|
|
267
|
+
const { store, setState } = makeStore({
|
|
268
|
+
turnAnchor: "top",
|
|
269
|
+
element: { viewport, anchor, target },
|
|
270
|
+
targetConfig: numericClamp,
|
|
271
|
+
topAnchorTurn: activeTopAnchorTurn,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
mountTopAnchorReserve(store);
|
|
275
|
+
vi.runOnlyPendingTimers();
|
|
276
|
+
|
|
277
|
+
const reserve = reserveHost.querySelector(
|
|
278
|
+
"[data-aui-top-anchor-reserve]",
|
|
279
|
+
) as HTMLElement;
|
|
280
|
+
|
|
281
|
+
setState({
|
|
282
|
+
turnAnchor: "top",
|
|
283
|
+
element: { viewport: null, anchor: null, target: null },
|
|
284
|
+
targetConfig: null,
|
|
285
|
+
topAnchorTurn: null,
|
|
286
|
+
});
|
|
287
|
+
vi.runOnlyPendingTimers();
|
|
288
|
+
|
|
289
|
+
expect(reserve.isConnected).toBe(false);
|
|
290
|
+
expect(reserve.style.height).toBe("0px");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("removes the reserve when top anchoring is disabled", () => {
|
|
294
|
+
const viewport = document.createElement("div");
|
|
295
|
+
const anchor = document.createElement("div");
|
|
296
|
+
const target = document.createElement("div");
|
|
297
|
+
const reserveHost = document.createElement("div");
|
|
298
|
+
reserveHost.append(target);
|
|
299
|
+
document.body.append(reserveHost);
|
|
300
|
+
|
|
301
|
+
defineReadonlyNumber(viewport, "offsetTop", 0);
|
|
302
|
+
defineReadonlyNumber(viewport, "clientHeight", 400);
|
|
303
|
+
defineReadonlyNumber(viewport, "scrollHeight", 560);
|
|
304
|
+
defineReadonlyNumber(anchor, "offsetTop", 220);
|
|
305
|
+
defineReadonlyNumber(anchor, "offsetHeight", 64);
|
|
306
|
+
viewport.scrollTo = vi.fn();
|
|
307
|
+
|
|
308
|
+
const { store, setState } = makeStore({
|
|
309
|
+
turnAnchor: "top",
|
|
310
|
+
element: { viewport, anchor, target },
|
|
311
|
+
targetConfig: numericClamp,
|
|
312
|
+
topAnchorTurn: activeTopAnchorTurn,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
mountTopAnchorReserve(store);
|
|
316
|
+
vi.runOnlyPendingTimers();
|
|
317
|
+
|
|
318
|
+
const reserve = reserveHost.querySelector(
|
|
319
|
+
"[data-aui-top-anchor-reserve]",
|
|
320
|
+
) as HTMLElement;
|
|
321
|
+
|
|
322
|
+
setState({
|
|
323
|
+
turnAnchor: "bottom",
|
|
324
|
+
element: { viewport, anchor: null, target: null },
|
|
325
|
+
targetConfig: null,
|
|
326
|
+
topAnchorTurn: null,
|
|
327
|
+
});
|
|
328
|
+
vi.runOnlyPendingTimers();
|
|
329
|
+
|
|
330
|
+
expect(reserve.isConnected).toBe(false);
|
|
331
|
+
expect(reserve.style.height).toBe("0px");
|
|
332
|
+
});
|
|
333
|
+
|
|
93
334
|
it("does not repeat the smooth top-anchor scroll for the same message", () => {
|
|
94
335
|
const viewport = document.createElement("div");
|
|
95
336
|
const anchor = document.createElement("div");
|
|
@@ -108,6 +349,7 @@ describe("mountTopAnchorReserve", () => {
|
|
|
108
349
|
turnAnchor: "top",
|
|
109
350
|
element: { viewport, anchor, target },
|
|
110
351
|
targetConfig: numericClamp,
|
|
352
|
+
topAnchorTurn: activeTopAnchorTurn,
|
|
111
353
|
});
|
|
112
354
|
|
|
113
355
|
mountTopAnchorReserve(store);
|
|
@@ -123,6 +365,7 @@ describe("mountTopAnchorReserve", () => {
|
|
|
123
365
|
turnAnchor: "top",
|
|
124
366
|
element: { viewport, anchor, target },
|
|
125
367
|
targetConfig: numericClamp,
|
|
368
|
+
topAnchorTurn: activeTopAnchorTurn,
|
|
126
369
|
});
|
|
127
370
|
vi.runOnlyPendingTimers();
|
|
128
371
|
|
|
@@ -29,6 +29,10 @@ export type TopAnchorStore = {
|
|
|
29
29
|
tallerThan: number;
|
|
30
30
|
visibleHeight: number;
|
|
31
31
|
} | null;
|
|
32
|
+
topAnchorTurn: {
|
|
33
|
+
readonly anchorId: string;
|
|
34
|
+
readonly targetId: string;
|
|
35
|
+
} | null;
|
|
32
36
|
};
|
|
33
37
|
subscribe(fn: () => void): () => void;
|
|
34
38
|
};
|
|
@@ -62,13 +66,30 @@ export const mountTopAnchorReserve = (store: TopAnchorStore) => {
|
|
|
62
66
|
const { viewport, anchor, target } = state.element;
|
|
63
67
|
const clamp = state.targetConfig;
|
|
64
68
|
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
if (state.turnAnchor !== "top" || !viewport) {
|
|
70
|
+
observers.disconnect();
|
|
71
|
+
if (reserve) {
|
|
72
|
+
setReserveHeight(reserve, 0);
|
|
73
|
+
reserve.remove();
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!anchor && !target && !clamp && state.topAnchorTurn) {
|
|
79
|
+
// ThreadViewport clears this state once the stored pair stops being the
|
|
80
|
+
// trailing turn (followed at most by pending user messages), so reaching
|
|
81
|
+
// here means the anchor gap is transient and the next run is imminent.
|
|
82
|
+
observers.disconnect();
|
|
83
|
+
if (
|
|
84
|
+
reserve?.parentElement &&
|
|
85
|
+
reserve.parentElement.lastElementChild !== reserve
|
|
86
|
+
) {
|
|
87
|
+
reserve.parentElement.append(reserve);
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!anchor || !target || !clamp) {
|
|
72
93
|
observers.disconnect();
|
|
73
94
|
if (reserve) {
|
|
74
95
|
setReserveHeight(reserve, 0);
|