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