@assistant-ui/react 0.15.24 → 0.15.25
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 +3 -1
- package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
- package/dist/hooks/useToolCallElapsed.js +7 -8
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/model-context/makeAssistantVisible.d.ts +1 -0
- package/dist/model-context/makeAssistantVisible.d.ts.map +1 -1
- package/dist/model-context/makeAssistantVisible.js +31 -12
- package/dist/model-context/makeAssistantVisible.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts +1 -1
- package/dist/primitives/composer/ComposerInput.js +1 -1
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.d.ts +11 -11
- package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.js +62 -60
- package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts +8 -8
- package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js +6 -6
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts +3 -3
- 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/TriggerPopoverDirective.d.ts +9 -9
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +7 -7
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts +5 -5
- 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/TriggerPopoverRootContext.d.ts +8 -8
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +8 -8
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
- package/dist/primitives/composer/trigger/index.d.ts +1 -1
- package/dist/primitives/composer.d.ts +20 -1
- package/dist/primitives/composer.d.ts.map +1 -1
- package/dist/primitives/composer.js +8 -1
- package/dist/primitives/composer.js.map +1 -1
- package/dist/primitives/thread/ThreadLoadEarlier.d.ts +19 -0
- package/dist/primitives/thread/ThreadLoadEarlier.d.ts.map +1 -0
- package/dist/primitives/thread/ThreadLoadEarlier.js +36 -0
- package/dist/primitives/thread/ThreadLoadEarlier.js.map +1 -0
- package/dist/primitives/thread/ThreadMessages.d.ts +1 -1
- package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadMessages.js +2 -2
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +111 -2
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/primitives/thread.d.ts +2 -0
- package/dist/primitives/thread.d.ts.map +1 -1
- package/dist/primitives/thread.js +5 -2
- package/dist/primitives/thread.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +6 -1
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
- package/dist/unstable/useMessageStallDetection.js +10 -6
- package/dist/unstable/useMessageStallDetection.js.map +1 -1
- package/dist/utils/smooth/useSmooth.js +6 -6
- package/dist/utils/smooth/useSmooth.js.map +1 -1
- package/package.json +5 -4
- package/src/hooks/useToolCallElapsed.test.tsx +35 -0
- package/src/hooks/useToolCallElapsed.ts +9 -7
- package/src/index.ts +28 -3
- package/src/model-context/makeAssistantVisible.test.tsx +315 -5
- package/src/model-context/makeAssistantVisible.tsx +43 -7
- package/src/primitives/composer/ComposerInput.tsx +1 -1
- package/src/primitives/composer/trigger/TriggerPopover.tsx +11 -11
- package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +10 -13
- package/src/primitives/composer/trigger/TriggerPopoverCategories.tsx +2 -2
- package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +11 -14
- package/src/primitives/composer/trigger/TriggerPopoverExports.test.ts +61 -0
- package/src/primitives/composer/trigger/TriggerPopoverItems.tsx +3 -3
- package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +8 -8
- package/src/primitives/composer.ts +21 -1
- package/src/primitives/message/MessageRoot.test.tsx +125 -2
- package/src/primitives/thread/ThreadLoadEarlier.tsx +35 -0
- package/src/primitives/thread/ThreadMessages.tsx +1 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +256 -5
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +147 -1
- package/src/primitives/thread.ts +2 -0
- package/src/sandbox-host/SandboxHost.test.tsx +34 -0
- package/src/sandbox-host/SandboxHost.tsx +8 -4
- package/src/tests/toolCallTiming.test.tsx +19 -2
- package/src/unstable/useMessageStallDetection.test.tsx +40 -0
- package/src/unstable/useMessageStallDetection.ts +10 -6
- package/src/utils/smooth/useSmooth.test.tsx +16 -5
- package/src/utils/smooth/useSmooth.ts +7 -7
|
@@ -28,11 +28,13 @@ import { ThreadPrimitiveScrollToBottom } from "./ThreadScrollToBottom";
|
|
|
28
28
|
import { ThreadPrimitiveViewport } from "./ThreadViewport";
|
|
29
29
|
import {
|
|
30
30
|
ExportedMessageRepository,
|
|
31
|
+
useExternalStoreRuntime,
|
|
31
32
|
useLocalRuntime,
|
|
32
33
|
type ChatModelAdapter,
|
|
33
34
|
type ThreadHistoryAdapter,
|
|
34
35
|
type ThreadMessageLike,
|
|
35
36
|
} from "../../index";
|
|
37
|
+
import { ThreadPrimitiveLoadEarlier } from "./ThreadLoadEarlier";
|
|
36
38
|
|
|
37
39
|
const adapter: ChatModelAdapter = {
|
|
38
40
|
async *run() {},
|
|
@@ -50,8 +52,19 @@ const getMaxScrollTop = (element: Element) =>
|
|
|
50
52
|
|
|
51
53
|
let forceShortViewportMeasurement = false;
|
|
52
54
|
let viewportMeasurementOffset = 0;
|
|
55
|
+
const messageHeights = new Map<string, number>();
|
|
53
56
|
const resizeObserverCallbacks = new Set<ResizeObserverCallback>();
|
|
54
57
|
|
|
58
|
+
const messageRows = () => [
|
|
59
|
+
...document.querySelectorAll<HTMLElement>('[data-testid="thread-message"]'),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const rowHeight = (row: Element) =>
|
|
63
|
+
messageHeights.get(row.getAttribute("data-message-id") ?? "") ?? 80;
|
|
64
|
+
|
|
65
|
+
const rowsHeight = (rows: readonly Element[]) =>
|
|
66
|
+
rows.reduce((height, row) => height + rowHeight(row), 0);
|
|
67
|
+
|
|
55
68
|
class TestResizeObserver {
|
|
56
69
|
private callback: ResizeObserverCallback;
|
|
57
70
|
|
|
@@ -121,11 +134,32 @@ beforeAll(() => {
|
|
|
121
134
|
get() {
|
|
122
135
|
if (this.getAttribute("data-testid") !== "viewport") return 0;
|
|
123
136
|
if (forceShortViewportMeasurement) return this.clientHeight;
|
|
124
|
-
return (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
return rowsHeight(messageRows()) + viewportMeasurementOffset;
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", {
|
|
142
|
+
configurable: true,
|
|
143
|
+
value(this: HTMLElement) {
|
|
144
|
+
const rows = messageRows();
|
|
145
|
+
const index = rows.indexOf(this);
|
|
146
|
+
const viewport = this.closest<HTMLElement>('[data-testid="viewport"]');
|
|
147
|
+
const top =
|
|
148
|
+
index === -1
|
|
149
|
+
? 0
|
|
150
|
+
: rowsHeight(rows.slice(0, index)) - (viewport?.scrollTop ?? 0);
|
|
151
|
+
const height = index === -1 ? 0 : rowHeight(this);
|
|
152
|
+
return {
|
|
153
|
+
x: 0,
|
|
154
|
+
y: top,
|
|
155
|
+
top,
|
|
156
|
+
bottom: top + height,
|
|
157
|
+
left: 0,
|
|
158
|
+
right: 0,
|
|
159
|
+
width: 0,
|
|
160
|
+
height,
|
|
161
|
+
toJSON: () => ({}),
|
|
162
|
+
} satisfies DOMRect;
|
|
129
163
|
},
|
|
130
164
|
});
|
|
131
165
|
|
|
@@ -141,6 +175,7 @@ beforeAll(() => {
|
|
|
141
175
|
afterEach(() => {
|
|
142
176
|
forceShortViewportMeasurement = false;
|
|
143
177
|
viewportMeasurementOffset = 0;
|
|
178
|
+
messageHeights.clear();
|
|
144
179
|
resizeObserverCallbacks.clear();
|
|
145
180
|
cleanup();
|
|
146
181
|
});
|
|
@@ -169,9 +204,11 @@ const AtBottom: FC = () => {
|
|
|
169
204
|
const Thread = ({
|
|
170
205
|
autoScroll,
|
|
171
206
|
scrollToBottomOnInitialize,
|
|
207
|
+
scrollToBottomOnThreadSwitch,
|
|
172
208
|
}: {
|
|
173
209
|
autoScroll?: boolean | undefined;
|
|
174
210
|
scrollToBottomOnInitialize?: boolean | undefined;
|
|
211
|
+
scrollToBottomOnThreadSwitch?: boolean | undefined;
|
|
175
212
|
}) => (
|
|
176
213
|
<ThreadPrimitiveRoot>
|
|
177
214
|
<ThreadPrimitiveViewport
|
|
@@ -179,6 +216,7 @@ const Thread = ({
|
|
|
179
216
|
data-testid="viewport"
|
|
180
217
|
turnAnchor="top"
|
|
181
218
|
scrollToBottomOnInitialize={scrollToBottomOnInitialize}
|
|
219
|
+
scrollToBottomOnThreadSwitch={scrollToBottomOnThreadSwitch}
|
|
182
220
|
>
|
|
183
221
|
<ThreadPrimitiveMessages components={{ Message }} />
|
|
184
222
|
<AtBottom />
|
|
@@ -908,4 +946,217 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
908
946
|
act(notifyResizeObservers);
|
|
909
947
|
expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
|
|
910
948
|
});
|
|
949
|
+
|
|
950
|
+
describe("earlier messages", () => {
|
|
951
|
+
const pagedMessages: ThreadMessageLike[] = messages.map(
|
|
952
|
+
(message, index) => ({
|
|
953
|
+
...message,
|
|
954
|
+
id: `message-${index}`,
|
|
955
|
+
}),
|
|
956
|
+
);
|
|
957
|
+
|
|
958
|
+
type PagedThreadProps = {
|
|
959
|
+
follow?: boolean;
|
|
960
|
+
running?: boolean;
|
|
961
|
+
/** Runs in the same update that prepends the page. */
|
|
962
|
+
onPrepend?: () => void;
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
const PagedThread = ({
|
|
966
|
+
follow = false,
|
|
967
|
+
running = false,
|
|
968
|
+
onPrepend,
|
|
969
|
+
}: PagedThreadProps) => {
|
|
970
|
+
const [loaded, setLoaded] = useState(pagedMessages.slice(4));
|
|
971
|
+
const runtime = useExternalStoreRuntime<ThreadMessageLike>({
|
|
972
|
+
messages: loaded,
|
|
973
|
+
convertMessage: (message) => message,
|
|
974
|
+
onNew: async () => {},
|
|
975
|
+
isRunning: running,
|
|
976
|
+
hasEarlier: loaded.length < pagedMessages.length,
|
|
977
|
+
onLoadEarlier: async () => {
|
|
978
|
+
onPrepend?.();
|
|
979
|
+
setLoaded(pagedMessages);
|
|
980
|
+
},
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
return (
|
|
984
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
985
|
+
{follow ? <BottomAnchorThread /> : <Thread />}
|
|
986
|
+
<ThreadPrimitiveLoadEarlier data-testid="load-earlier" />
|
|
987
|
+
</AssistantRuntimeProvider>
|
|
988
|
+
);
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
const renderAt = async (
|
|
992
|
+
scrollTop: number,
|
|
993
|
+
{ follow = false, onPrepend }: PagedThreadProps = {},
|
|
994
|
+
) => {
|
|
995
|
+
render(<PagedThread follow={follow} {...(onPrepend && { onPrepend })} />);
|
|
996
|
+
await waitFor(() =>
|
|
997
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(4),
|
|
998
|
+
);
|
|
999
|
+
act(() => {
|
|
1000
|
+
getViewport().scrollTop = scrollTop;
|
|
1001
|
+
fireEvent.scroll(getViewport());
|
|
1002
|
+
});
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
const loadEarlier = async () => {
|
|
1006
|
+
await act(async () => {
|
|
1007
|
+
fireEvent.click(screen.getByTestId("load-earlier"));
|
|
1008
|
+
});
|
|
1009
|
+
await waitFor(() =>
|
|
1010
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(8),
|
|
1011
|
+
);
|
|
1012
|
+
act(notifyResizeObservers);
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
it("keeps the rows a reader scrolled to in place when an earlier page is prepended", async () => {
|
|
1016
|
+
await renderAt(0);
|
|
1017
|
+
|
|
1018
|
+
await loadEarlier();
|
|
1019
|
+
|
|
1020
|
+
expect(getViewport().scrollTop).toBe(4 * 80);
|
|
1021
|
+
expect(
|
|
1022
|
+
(screen.getByTestId("load-earlier") as HTMLButtonElement).disabled,
|
|
1023
|
+
).toBe(true);
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it.each([false, true])(
|
|
1027
|
+
"keeps a reader at the bottom at the bottom (auto-follow %s)",
|
|
1028
|
+
async (follow) => {
|
|
1029
|
+
await renderAt(4 * 80 - 100, { follow });
|
|
1030
|
+
|
|
1031
|
+
await loadEarlier();
|
|
1032
|
+
|
|
1033
|
+
expect(getViewport().scrollTop).toBe(8 * 80 - 100);
|
|
1034
|
+
},
|
|
1035
|
+
);
|
|
1036
|
+
|
|
1037
|
+
it("does not count growth below the reader in the same update as part of the page", async () => {
|
|
1038
|
+
await renderAt(100, {
|
|
1039
|
+
onPrepend: () => {
|
|
1040
|
+
viewportMeasurementOffset = 40;
|
|
1041
|
+
},
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
await loadEarlier();
|
|
1045
|
+
|
|
1046
|
+
expect(getViewport().scrollTop).toBe(4 * 80 + 100);
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it("holds the rows in place while the page above settles, until the reader's next gesture", async () => {
|
|
1050
|
+
await renderAt(0);
|
|
1051
|
+
await loadEarlier();
|
|
1052
|
+
expect(getViewport().scrollTop).toBe(4 * 80);
|
|
1053
|
+
|
|
1054
|
+
messageHeights.set("message-3", 120);
|
|
1055
|
+
act(notifyResizeObservers);
|
|
1056
|
+
expect(getViewport().scrollTop).toBe(4 * 80 + 40);
|
|
1057
|
+
|
|
1058
|
+
fireEvent.wheel(getViewport());
|
|
1059
|
+
messageHeights.set("message-2", 120);
|
|
1060
|
+
act(notifyResizeObservers);
|
|
1061
|
+
expect(getViewport().scrollTop).toBe(4 * 80 + 40);
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
it("lets go of the held rows when a run starts, so the new turn can take the viewport", async () => {
|
|
1065
|
+
const { rerender } = render(<PagedThread />);
|
|
1066
|
+
await waitFor(() =>
|
|
1067
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(4),
|
|
1068
|
+
);
|
|
1069
|
+
act(() => {
|
|
1070
|
+
getViewport().scrollTop = 0;
|
|
1071
|
+
fireEvent.scroll(getViewport());
|
|
1072
|
+
});
|
|
1073
|
+
await loadEarlier();
|
|
1074
|
+
|
|
1075
|
+
await act(async () => {
|
|
1076
|
+
rerender(<PagedThread running />);
|
|
1077
|
+
});
|
|
1078
|
+
messageHeights.set("message-3", 120);
|
|
1079
|
+
act(notifyResizeObservers);
|
|
1080
|
+
|
|
1081
|
+
expect(getViewport().scrollTop).toBe(4 * 80);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it("lets go of the held rows once something else scrolls the viewport", async () => {
|
|
1085
|
+
await renderAt(0);
|
|
1086
|
+
await loadEarlier();
|
|
1087
|
+
|
|
1088
|
+
act(() => {
|
|
1089
|
+
getViewport().scrollTo({ top: 100 });
|
|
1090
|
+
});
|
|
1091
|
+
messageHeights.set("message-3", 120);
|
|
1092
|
+
act(notifyResizeObservers);
|
|
1093
|
+
|
|
1094
|
+
expect(getViewport().scrollTop).toBe(100);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
it("holds the row the reader sees, not the old first message, while content between them settles", async () => {
|
|
1098
|
+
await renderAt(100);
|
|
1099
|
+
await loadEarlier();
|
|
1100
|
+
expect(getViewport().scrollTop).toBe(4 * 80 + 100);
|
|
1101
|
+
|
|
1102
|
+
messageHeights.set("message-4", 120);
|
|
1103
|
+
act(notifyResizeObservers);
|
|
1104
|
+
expect(getViewport().scrollTop).toBe(4 * 80 + 100 + 40);
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
it("does not treat a thread switch as a page, even when the new thread repeats the old first message", async () => {
|
|
1108
|
+
const SwitchingThread = () => {
|
|
1109
|
+
const [thread, setThread] = useState({
|
|
1110
|
+
id: "a",
|
|
1111
|
+
messages: pagedMessages.slice(4),
|
|
1112
|
+
});
|
|
1113
|
+
const runtime = useExternalStoreRuntime<ThreadMessageLike>({
|
|
1114
|
+
messages: thread.messages,
|
|
1115
|
+
convertMessage: (message) => message,
|
|
1116
|
+
onNew: async () => {},
|
|
1117
|
+
adapters: {
|
|
1118
|
+
threadList: {
|
|
1119
|
+
threadId: thread.id,
|
|
1120
|
+
threads: [
|
|
1121
|
+
{ id: "a", status: "regular" },
|
|
1122
|
+
{ id: "b", status: "regular" },
|
|
1123
|
+
],
|
|
1124
|
+
},
|
|
1125
|
+
},
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
return (
|
|
1129
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
1130
|
+
<Thread scrollToBottomOnThreadSwitch={false} />
|
|
1131
|
+
<button
|
|
1132
|
+
type="button"
|
|
1133
|
+
data-testid="switch"
|
|
1134
|
+
onClick={() =>
|
|
1135
|
+
setThread({ id: "b", messages: pagedMessages.slice(3) })
|
|
1136
|
+
}
|
|
1137
|
+
/>
|
|
1138
|
+
</AssistantRuntimeProvider>
|
|
1139
|
+
);
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
render(<SwitchingThread />);
|
|
1143
|
+
await waitFor(() =>
|
|
1144
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(4),
|
|
1145
|
+
);
|
|
1146
|
+
act(() => {
|
|
1147
|
+
getViewport().scrollTop = 100;
|
|
1148
|
+
fireEvent.scroll(getViewport());
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
await act(async () => {
|
|
1152
|
+
fireEvent.click(screen.getByTestId("switch"));
|
|
1153
|
+
});
|
|
1154
|
+
await waitFor(() =>
|
|
1155
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(5),
|
|
1156
|
+
);
|
|
1157
|
+
act(notifyResizeObservers);
|
|
1158
|
+
|
|
1159
|
+
expect(getViewport().scrollTop).toBe(100);
|
|
1160
|
+
});
|
|
1161
|
+
});
|
|
911
1162
|
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useComposedRefs } from "radix-ui/internal";
|
|
4
4
|
import { useCallback, useLayoutEffect, useRef, type RefCallback } from "react";
|
|
5
|
-
import { useAuiEvent, useAuiState } from "@assistant-ui/store";
|
|
5
|
+
import { useAui, useAuiEvent, useAuiState } from "@assistant-ui/store";
|
|
6
6
|
import {
|
|
7
7
|
isUserScrollUp,
|
|
8
8
|
isViewportAtBottom,
|
|
@@ -33,6 +33,69 @@ const TEXT_ENTRY_SELECTOR = [
|
|
|
33
33
|
":not([type='range']):not([type='file']):not([type='color'])",
|
|
34
34
|
].join(", ");
|
|
35
35
|
|
|
36
|
+
const MESSAGE_SELECTOR = "[data-message-id]";
|
|
37
|
+
|
|
38
|
+
type MessageOffset = { readonly id: string; readonly offset: number };
|
|
39
|
+
|
|
40
|
+
const offsetInViewport = (element: Element, viewport: Element) =>
|
|
41
|
+
element.getBoundingClientRect().top - viewport.getBoundingClientRect().top;
|
|
42
|
+
|
|
43
|
+
const measureFirstMessage = (viewport: HTMLElement): MessageOffset | null => {
|
|
44
|
+
const element = viewport.querySelector<HTMLElement>(MESSAGE_SELECTOR);
|
|
45
|
+
const id = element?.dataset["messageId"];
|
|
46
|
+
return element && id
|
|
47
|
+
? { id, offset: offsetInViewport(element, viewport) }
|
|
48
|
+
: null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The first message that starts in view, else the one spanning its top edge. */
|
|
52
|
+
const measureReaderMessage = (viewport: HTMLElement): MessageOffset | null => {
|
|
53
|
+
const { top, bottom } = viewport.getBoundingClientRect();
|
|
54
|
+
let spanning: MessageOffset | null = null;
|
|
55
|
+
for (const element of viewport.querySelectorAll<HTMLElement>(
|
|
56
|
+
MESSAGE_SELECTOR,
|
|
57
|
+
)) {
|
|
58
|
+
const rect = element.getBoundingClientRect();
|
|
59
|
+
if (rect.bottom <= top) continue;
|
|
60
|
+
if (rect.top >= bottom) break;
|
|
61
|
+
const id = element.dataset["messageId"];
|
|
62
|
+
if (!id) continue;
|
|
63
|
+
if (rect.top >= top) return { id, offset: rect.top - top };
|
|
64
|
+
spanning ??= { id, offset: rect.top - top };
|
|
65
|
+
}
|
|
66
|
+
return spanning;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const findMessage = (viewport: HTMLElement, id: string) => {
|
|
70
|
+
for (const element of viewport.querySelectorAll<HTMLElement>(
|
|
71
|
+
MESSAGE_SELECTOR,
|
|
72
|
+
)) {
|
|
73
|
+
if (element.dataset["messageId"] === id) return element;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Scrolls so the message sits at `anchor.offset` again, recording the
|
|
80
|
+
* position it scrolls to in `heldScrollTop` before the scroll event can
|
|
81
|
+
* report it; false when the message is gone.
|
|
82
|
+
*/
|
|
83
|
+
const keepMessageAt = (
|
|
84
|
+
viewport: HTMLElement,
|
|
85
|
+
anchor: MessageOffset,
|
|
86
|
+
heldScrollTop: { current: number },
|
|
87
|
+
) => {
|
|
88
|
+
const element = findMessage(viewport, anchor.id);
|
|
89
|
+
if (!element) return false;
|
|
90
|
+
const delta = offsetInViewport(element, viewport) - anchor.offset;
|
|
91
|
+
heldScrollTop.current = viewport.scrollTop + delta;
|
|
92
|
+
if (Math.abs(delta) >= 1) {
|
|
93
|
+
viewport.scrollTo({ top: heldScrollTop.current, behavior: "instant" });
|
|
94
|
+
}
|
|
95
|
+
heldScrollTop.current = viewport.scrollTop;
|
|
96
|
+
return true;
|
|
97
|
+
};
|
|
98
|
+
|
|
36
99
|
export namespace useThreadViewportAutoScroll {
|
|
37
100
|
export type Options = {
|
|
38
101
|
/**
|
|
@@ -72,8 +135,11 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
72
135
|
scrollToBottomOnInitialize = true,
|
|
73
136
|
scrollToBottomOnThreadSwitch = true,
|
|
74
137
|
}: useThreadViewportAutoScroll.Options): RefCallback<TElement> => {
|
|
138
|
+
const aui = useAui();
|
|
75
139
|
const divRef = useRef<TElement>(null);
|
|
76
140
|
const hasMessages = useAuiState((s) => s.thread.messages.length > 0);
|
|
141
|
+
const firstMessageId = useAuiState((s) => s.thread.messages[0]?.id);
|
|
142
|
+
const threadId = useAuiState((s) => s.threadListItem.id);
|
|
77
143
|
const isRunning = useAuiState((s) => s.thread.isRunning);
|
|
78
144
|
const initializeScrollRequestedRef = useRef(false);
|
|
79
145
|
const scheduledFrameRef = useRef<number | null>(null);
|
|
@@ -87,6 +153,13 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
87
153
|
const lastScrollHeight = useRef<number>(0);
|
|
88
154
|
const lastObservedScrollHeight = useRef<number>(0);
|
|
89
155
|
const lastObservedClientHeight = useRef<number>(0);
|
|
156
|
+
const firstMessageRef = useRef<MessageOffset | null>(null);
|
|
157
|
+
// The row the reader sees when earlier messages land, held until a gesture
|
|
158
|
+
// or a scroll this hook did not make, so content above it that settles
|
|
159
|
+
// afterwards (lazy layout, late media) does not move it on engines without
|
|
160
|
+
// native scroll anchoring.
|
|
161
|
+
const prependAnchorRef = useRef<MessageOffset | null>(null);
|
|
162
|
+
const heldScrollTopRef = useRef(0);
|
|
90
163
|
|
|
91
164
|
// Pending bottom-scroll intent. Planted by initialize/run-start/switch/button
|
|
92
165
|
// triggers, cleared when handleScroll confirms we reached bottom, or when the
|
|
@@ -110,6 +183,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
110
183
|
|
|
111
184
|
followBottomRef.current = true;
|
|
112
185
|
scrollingToBottomBehaviorRef.current = behavior;
|
|
186
|
+
prependAnchorRef.current = null;
|
|
113
187
|
div.scrollTo({ top: div.scrollHeight, behavior });
|
|
114
188
|
}, []);
|
|
115
189
|
|
|
@@ -146,6 +220,13 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
146
220
|
const div = divRef.current;
|
|
147
221
|
if (!div) return;
|
|
148
222
|
|
|
223
|
+
if (
|
|
224
|
+
prependAnchorRef.current &&
|
|
225
|
+
Math.abs(div.scrollTop - heldScrollTopRef.current) >= 1
|
|
226
|
+
) {
|
|
227
|
+
prependAnchorRef.current = null;
|
|
228
|
+
}
|
|
229
|
+
|
|
149
230
|
const isAtBottom = threadViewportStore.getState().isAtBottom;
|
|
150
231
|
const newIsAtBottom = isViewportAtBottom(div);
|
|
151
232
|
|
|
@@ -188,6 +269,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
188
269
|
|
|
189
270
|
lastScrollTop.current = div.scrollTop;
|
|
190
271
|
lastScrollHeight.current = div.scrollHeight;
|
|
272
|
+
firstMessageRef.current = measureFirstMessage(div);
|
|
191
273
|
};
|
|
192
274
|
|
|
193
275
|
const resizeRef = useOnResizeContent(() => {
|
|
@@ -216,6 +298,11 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
216
298
|
followBottomRef.current
|
|
217
299
|
) {
|
|
218
300
|
scrollToBottom("instant");
|
|
301
|
+
} else if (
|
|
302
|
+
prependAnchorRef.current &&
|
|
303
|
+
!keepMessageAt(div, prependAnchorRef.current, heldScrollTopRef)
|
|
304
|
+
) {
|
|
305
|
+
prependAnchorRef.current = null;
|
|
219
306
|
}
|
|
220
307
|
|
|
221
308
|
handleScroll();
|
|
@@ -242,16 +329,74 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
242
329
|
if (target?.closest?.(TEXT_ENTRY_SELECTOR)) return;
|
|
243
330
|
cancelPendingScrollToBottom();
|
|
244
331
|
};
|
|
332
|
+
const releasePrependAnchor = () => {
|
|
333
|
+
prependAnchorRef.current = null;
|
|
334
|
+
};
|
|
335
|
+
const gestures = ["pointerdown", "wheel", "touchstart", "keydown"] as const;
|
|
245
336
|
el.addEventListener("scroll", handleScroll);
|
|
246
337
|
el.addEventListener("pointerdown", cancelPendingScrollToBottom);
|
|
247
338
|
el.addEventListener("keydown", cancelOnKeyDown);
|
|
339
|
+
for (const gesture of gestures) {
|
|
340
|
+
el.addEventListener(gesture, releasePrependAnchor, { passive: true });
|
|
341
|
+
}
|
|
248
342
|
return () => {
|
|
249
343
|
el.removeEventListener("scroll", handleScroll);
|
|
250
344
|
el.removeEventListener("pointerdown", cancelPendingScrollToBottom);
|
|
251
345
|
el.removeEventListener("keydown", cancelOnKeyDown);
|
|
346
|
+
for (const gesture of gestures) {
|
|
347
|
+
el.removeEventListener(gesture, releasePrependAnchor);
|
|
348
|
+
}
|
|
252
349
|
};
|
|
253
350
|
});
|
|
254
351
|
|
|
352
|
+
// Earlier messages prepended above the reader put the old first message back
|
|
353
|
+
// where the reader saw it, so growth below it in the same commit (a
|
|
354
|
+
// streaming reply) is not mistaken for the page; where the browser already
|
|
355
|
+
// anchored the scroll, this is a no-op.
|
|
356
|
+
const previousRef = useRef({ threadId, firstMessageId });
|
|
357
|
+
useLayoutEffect(() => {
|
|
358
|
+
const previous = previousRef.current;
|
|
359
|
+
previousRef.current = { threadId, firstMessageId };
|
|
360
|
+
if (previous.threadId !== threadId) {
|
|
361
|
+
prependAnchorRef.current = null;
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const div = divRef.current;
|
|
365
|
+
const previousFirstMessageId = previous.firstMessageId;
|
|
366
|
+
if (
|
|
367
|
+
!div ||
|
|
368
|
+
previousFirstMessageId === undefined ||
|
|
369
|
+
previousFirstMessageId === firstMessageId ||
|
|
370
|
+
(autoScroll && followBottomRef.current) ||
|
|
371
|
+
scrollingToBottomBehaviorRef.current !== null
|
|
372
|
+
)
|
|
373
|
+
return;
|
|
374
|
+
const prepended = aui.thread
|
|
375
|
+
.getState()
|
|
376
|
+
.messages.some(
|
|
377
|
+
(message, index) => index > 0 && message.id === previousFirstMessageId,
|
|
378
|
+
);
|
|
379
|
+
if (!prepended) return;
|
|
380
|
+
|
|
381
|
+
const anchor = firstMessageRef.current;
|
|
382
|
+
if (
|
|
383
|
+
anchor?.id === previousFirstMessageId &&
|
|
384
|
+
keepMessageAt(div, anchor, heldScrollTopRef)
|
|
385
|
+
) {
|
|
386
|
+
prependAnchorRef.current = measureReaderMessage(div) ?? anchor;
|
|
387
|
+
} else {
|
|
388
|
+
// Message roots that render no `data-message-id` leave only the total
|
|
389
|
+
// growth to go on, which counts any growth below the reader too.
|
|
390
|
+
const top =
|
|
391
|
+
div.scrollHeight - (lastScrollHeight.current - lastScrollTop.current);
|
|
392
|
+
if (Math.abs(div.scrollTop - top) >= 1) {
|
|
393
|
+
div.scrollTo({ top, behavior: "instant" });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
lastScrollTop.current = div.scrollTop;
|
|
397
|
+
lastScrollHeight.current = div.scrollHeight;
|
|
398
|
+
}, [aui, autoScroll, firstMessageId, threadId]);
|
|
399
|
+
|
|
255
400
|
useLayoutEffect(() => {
|
|
256
401
|
if (!scrollToBottomOnInitialize) return;
|
|
257
402
|
if (!hasMessages) {
|
|
@@ -272,6 +417,7 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
272
417
|
});
|
|
273
418
|
|
|
274
419
|
useAuiEvent("thread.runStart", () => {
|
|
420
|
+
prependAnchorRef.current = null;
|
|
275
421
|
if (!scrollToBottomOnRunStart) return;
|
|
276
422
|
if (threadViewportStore.getState().turnAnchor === "top") return;
|
|
277
423
|
scheduleScrollToBottom("auto");
|
package/src/primitives/thread.ts
CHANGED
|
@@ -7,7 +7,9 @@ export { ThreadPrimitiveViewportFooter as ViewportFooter } from "./thread/Thread
|
|
|
7
7
|
export { ThreadPrimitiveMessages as Messages } from "./thread/ThreadMessages";
|
|
8
8
|
export { ThreadPrimitiveMessageByIndex as MessageByIndex } from "./thread/ThreadMessages";
|
|
9
9
|
export { ThreadPrimitiveUnstable_MessageById as Unstable_MessageById } from "./thread/ThreadMessages";
|
|
10
|
+
export { ThreadPrimitiveRow as Row } from "./thread/ThreadMessages";
|
|
10
11
|
export { ThreadPrimitiveScrollToBottom as ScrollToBottom } from "./thread/ThreadScrollToBottom";
|
|
12
|
+
export { ThreadPrimitiveLoadEarlier as LoadEarlier } from "./thread/ThreadLoadEarlier";
|
|
11
13
|
export { ThreadPrimitiveSuggestion as Suggestion } from "./thread/ThreadSuggestion";
|
|
12
14
|
export {
|
|
13
15
|
ThreadPrimitiveSuggestions as Suggestions,
|
|
@@ -416,6 +416,40 @@ describe("SandboxHost", () => {
|
|
|
416
416
|
expect(onError).not.toHaveBeenCalled();
|
|
417
417
|
});
|
|
418
418
|
|
|
419
|
+
it("cancels pending renders on replacement and unmount", async () => {
|
|
420
|
+
renderHtmlMock.mockImplementation(() => new Promise(() => {}));
|
|
421
|
+
|
|
422
|
+
await act(async () => {
|
|
423
|
+
root.render(
|
|
424
|
+
<SandboxHost
|
|
425
|
+
content={{ html: "first" }}
|
|
426
|
+
contentKey="first"
|
|
427
|
+
createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
|
|
428
|
+
/>,
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
const firstSignal = renderHtmlMock.mock.calls[0]![2].signal as AbortSignal;
|
|
432
|
+
|
|
433
|
+
await act(async () => {
|
|
434
|
+
root.render(
|
|
435
|
+
<SandboxHost
|
|
436
|
+
content={{ html: "second" }}
|
|
437
|
+
contentKey="second"
|
|
438
|
+
createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
|
|
439
|
+
/>,
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
const secondSignal = renderHtmlMock.mock.calls[1]![2].signal as AbortSignal;
|
|
443
|
+
|
|
444
|
+
expect(firstSignal.aborted).toBe(true);
|
|
445
|
+
expect(secondSignal.aborted).toBe(false);
|
|
446
|
+
|
|
447
|
+
await act(async () => {
|
|
448
|
+
root.unmount();
|
|
449
|
+
});
|
|
450
|
+
expect(secondSignal.aborted).toBe(true);
|
|
451
|
+
});
|
|
452
|
+
|
|
419
453
|
it("contains failures thrown by onError", async () => {
|
|
420
454
|
const renderError = new Error("render failed");
|
|
421
455
|
const callbackError = new Error("error callback failed");
|
|
@@ -107,6 +107,7 @@ export function SandboxHost({
|
|
|
107
107
|
let frame: RenderedFrame | null = null;
|
|
108
108
|
let bridge: SandboxBridge | null = null;
|
|
109
109
|
let onMessage: ((event: MessageEvent) => void) | null = null;
|
|
110
|
+
const renderController = new AbortController();
|
|
110
111
|
|
|
111
112
|
const { content: liveContent, sandbox: sb } = liveRef.current;
|
|
112
113
|
|
|
@@ -129,10 +130,12 @@ export function SandboxHost({
|
|
|
129
130
|
...(sb?.salt !== undefined && { salt: sb.salt }),
|
|
130
131
|
});
|
|
131
132
|
|
|
132
|
-
const renderOpts =
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
:
|
|
133
|
+
const renderOpts = {
|
|
134
|
+
signal: renderController.signal,
|
|
135
|
+
...(sb?.unsafeDocumentWrite !== undefined && {
|
|
136
|
+
unsafeDocumentWrite: sb.unsafeDocumentWrite,
|
|
137
|
+
}),
|
|
138
|
+
};
|
|
136
139
|
|
|
137
140
|
scf
|
|
138
141
|
.renderHtml(liveContent.html, container, renderOpts)
|
|
@@ -216,6 +219,7 @@ export function SandboxHost({
|
|
|
216
219
|
const bridgeToDispose = bridge;
|
|
217
220
|
bridge = null;
|
|
218
221
|
if (bridgeToDispose) runCleanup(() => bridgeToDispose.dispose());
|
|
222
|
+
runCleanup(() => renderController.abort());
|
|
219
223
|
const frameToDispose = frame;
|
|
220
224
|
frame = null;
|
|
221
225
|
if (frameToDispose) runCleanup(() => frameToDispose.dispose());
|
|
@@ -47,8 +47,15 @@ const ElapsedProbe: FC = () => {
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
const StallProbe: FC = () => {
|
|
50
|
-
const { stalled } = unstable_useMessageStallDetection({
|
|
51
|
-
|
|
50
|
+
const { stalled, stalledForMs } = unstable_useMessageStallDetection({
|
|
51
|
+
thresholdMs: 2000,
|
|
52
|
+
});
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
<span data-testid="stalled">{String(stalled)}</span>
|
|
56
|
+
<span data-testid="stalled-for">{stalledForMs}</span>
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
52
59
|
};
|
|
53
60
|
|
|
54
61
|
const RuntimeProvider: FC<
|
|
@@ -224,6 +231,16 @@ describe("unstable_useMessageStallDetection", () => {
|
|
|
224
231
|
});
|
|
225
232
|
|
|
226
233
|
expect(screen.getByTestId("stalled").textContent).toBe("true");
|
|
234
|
+
const stalledFor = () =>
|
|
235
|
+
Number(screen.getByTestId("stalled-for").textContent);
|
|
236
|
+
const atStall = stalledFor();
|
|
237
|
+
expect(atStall).toBeGreaterThanOrEqual(2000);
|
|
238
|
+
|
|
239
|
+
await act(async () => {
|
|
240
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(stalledFor()).toBeGreaterThan(atStall);
|
|
227
244
|
});
|
|
228
245
|
|
|
229
246
|
it("never stalls on settled messages", async () => {
|