@assistant-ui/react 0.14.23 → 0.14.26
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/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/legacy-runtime/hooks/AttachmentContext.d.ts +12 -12
- package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts +3 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js +2 -2
- package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +6 -2
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +5 -14
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/useComposerInputState.d.ts +13 -0
- package/dist/primitives/composer/useComposerInputState.d.ts.map +1 -0
- package/dist/primitives/composer/useComposerInputState.js +47 -0
- package/dist/primitives/composer/useComposerInputState.js.map +1 -0
- package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
- package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
- package/dist/primitives/thread/ThreadMessages.d.ts +2 -2
- package/dist/primitives/thread/ThreadMessages.js +2 -2
- package/dist/primitives/thread.d.ts +3 -3
- package/dist/primitives/thread.js +3 -2
- package/dist/primitives/thread.js.map +1 -1
- package/dist/primitives/threadList/ThreadListRoot.d.ts.map +1 -1
- package/dist/primitives/threadList/ThreadListRoot.js +10 -3
- package/dist/primitives/threadList/ThreadListRoot.js.map +1 -1
- package/dist/primitives/threadListFocusGroup.d.ts +26 -0
- package/dist/primitives/threadListFocusGroup.d.ts.map +1 -0
- package/dist/primitives/threadListFocusGroup.js +15 -0
- package/dist/primitives/threadListFocusGroup.js.map +1 -0
- package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemRoot.js +45 -27
- 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 +26 -2
- package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +63 -38
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.d.ts +20 -3
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js +95 -14
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.d.ts.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +38 -21
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js.map +1 -1
- package/dist/unstable/useComposerInput.d.ts +84 -0
- package/dist/unstable/useComposerInput.d.ts.map +1 -0
- package/dist/unstable/useComposerInput.js +78 -0
- package/dist/unstable/useComposerInput.js.map +1 -0
- package/dist/utils/hooks/useMediaQuery.js +2 -2
- package/dist/utils/hooks/useMediaQuery.js.map +1 -1
- package/dist/utils/smooth/useSmooth.d.ts +4 -0
- package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
- package/dist/utils/smooth/useSmooth.js +7 -1
- package/dist/utils/smooth/useSmooth.js.map +1 -1
- package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
- package/dist/utils/useToolArgsFieldStatus.d.ts.map +1 -1
- package/package.json +16 -14
- package/src/index.ts +39 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/commandQueue.ts +5 -2
- package/src/legacy-runtime/runtime-cores/assistant-transport/transport-scheduling.test.ts +22 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +12 -2
- package/src/primitives/composer/ComposerInput.tsx +9 -20
- package/src/primitives/composer/trigger/triggerKeyboardResource.test.ts +18 -22
- package/src/primitives/composer/useComposerInputState.ts +34 -0
- package/src/primitives/messagePart/MessagePartText.tsx +1 -0
- package/src/primitives/thread/ThreadMessages.tsx +1 -0
- package/src/primitives/thread.ts +1 -0
- package/src/primitives/threadList/ThreadListRoot.tsx +8 -1
- package/src/primitives/threadListFocusGroup.ts +24 -0
- package/src/primitives/threadListItem/ThreadListItemRoot.tsx +53 -5
- package/src/primitives/threadListItem/ThreadListItemTrigger.tsx +45 -0
- package/src/primitives/threadListItem/ThreadListKeyboardNav.test.tsx +141 -0
- package/src/primitives/threadListItemMore/ThreadListItemMoreContent.tsx +22 -1
- package/src/primitives/threadListItemMore/ThreadListItemMoreRoot.tsx +64 -3
- package/src/primitives/threadListItemMore/ThreadListItemMoreTrigger.tsx +29 -2
- package/src/tests/generative-ui.test.tsx +21 -1
- package/src/tests/threadMessageById.test.tsx +224 -0
- package/src/unstable/useComposerInput.test.tsx +187 -0
- package/src/unstable/useComposerInput.ts +126 -0
- package/src/utils/hooks/useMediaQuery.ts +3 -2
- package/src/utils/smooth/useSmooth.test.tsx +26 -0
- package/src/utils/smooth/useSmooth.ts +7 -1
- package/src/primitives/threadListItem/ThreadListItemTrigger.ts +0 -23
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
import { fireEvent, render } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { ThreadListPrimitiveRoot } from "../threadList/ThreadListRoot";
|
|
5
|
+
import { ThreadListItemPrimitiveRoot } from "./ThreadListItemRoot";
|
|
6
|
+
import { ThreadListItemPrimitiveTrigger } from "./ThreadListItemTrigger";
|
|
7
|
+
import { ThreadListItemMorePrimitiveRoot } from "../threadListItemMore/ThreadListItemMoreRoot";
|
|
8
|
+
import { ThreadListItemMorePrimitiveTrigger } from "../threadListItemMore/ThreadListItemMoreTrigger";
|
|
9
|
+
import { ThreadListItemMorePrimitiveContent } from "../threadListItemMore/ThreadListItemMoreContent";
|
|
10
|
+
import { ThreadListItemMorePrimitiveItem } from "../threadListItemMore/ThreadListItemMoreItem";
|
|
11
|
+
|
|
12
|
+
vi.mock("@assistant-ui/store", async (importOriginal) => {
|
|
13
|
+
const actual = await importOriginal<typeof import("@assistant-ui/store")>();
|
|
14
|
+
return { ...actual, useAuiState: () => false };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
vi.mock("@assistant-ui/core/react", async (importOriginal) => {
|
|
18
|
+
const actual =
|
|
19
|
+
await importOriginal<typeof import("@assistant-ui/core/react")>();
|
|
20
|
+
return {
|
|
21
|
+
...actual,
|
|
22
|
+
useThreadListItemTrigger: () => ({ switchTo: () => {} }),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const triggers = (root: HTMLElement) =>
|
|
27
|
+
Array.from(
|
|
28
|
+
root.querySelectorAll<HTMLButtonElement>("[data-radix-collection-item]"),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const moreOf = (root: HTMLElement) =>
|
|
32
|
+
root.querySelector<HTMLButtonElement>('[aria-haspopup="menu"]')!;
|
|
33
|
+
|
|
34
|
+
const renderItem = (rootProps?: { defaultOpen?: boolean }) => {
|
|
35
|
+
const result = render(
|
|
36
|
+
<ThreadListPrimitiveRoot>
|
|
37
|
+
<ThreadListItemPrimitiveRoot>
|
|
38
|
+
<ThreadListItemPrimitiveTrigger>item</ThreadListItemPrimitiveTrigger>
|
|
39
|
+
<ThreadListItemMorePrimitiveRoot sharedFocusGroup {...rootProps}>
|
|
40
|
+
<ThreadListItemMorePrimitiveTrigger>
|
|
41
|
+
more
|
|
42
|
+
</ThreadListItemMorePrimitiveTrigger>
|
|
43
|
+
<ThreadListItemMorePrimitiveContent>
|
|
44
|
+
<ThreadListItemMorePrimitiveItem>
|
|
45
|
+
Archive
|
|
46
|
+
</ThreadListItemMorePrimitiveItem>
|
|
47
|
+
</ThreadListItemMorePrimitiveContent>
|
|
48
|
+
</ThreadListItemMorePrimitiveRoot>
|
|
49
|
+
</ThreadListItemPrimitiveRoot>
|
|
50
|
+
</ThreadListPrimitiveRoot>,
|
|
51
|
+
);
|
|
52
|
+
return {
|
|
53
|
+
...result,
|
|
54
|
+
trigger: triggers(result.container)[0]!,
|
|
55
|
+
more: moreOf(result.container),
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const menu = (): HTMLElement | null =>
|
|
60
|
+
document.querySelector<HTMLElement>('[role="menu"]');
|
|
61
|
+
|
|
62
|
+
describe("thread list keyboard navigation", () => {
|
|
63
|
+
it("moves focus between items with the up/down arrows", () => {
|
|
64
|
+
const { container } = render(
|
|
65
|
+
<ThreadListPrimitiveRoot>
|
|
66
|
+
{[0, 1, 2].map((i) => (
|
|
67
|
+
<ThreadListItemPrimitiveRoot key={i}>
|
|
68
|
+
<ThreadListItemPrimitiveTrigger>
|
|
69
|
+
item {i}
|
|
70
|
+
</ThreadListItemPrimitiveTrigger>
|
|
71
|
+
</ThreadListItemPrimitiveRoot>
|
|
72
|
+
))}
|
|
73
|
+
</ThreadListPrimitiveRoot>,
|
|
74
|
+
);
|
|
75
|
+
const [first, second] = triggers(container);
|
|
76
|
+
|
|
77
|
+
first!.focus();
|
|
78
|
+
fireEvent.keyDown(first!, { key: "ArrowDown" });
|
|
79
|
+
expect(document.activeElement).toBe(second);
|
|
80
|
+
|
|
81
|
+
fireEvent.keyDown(second!, { key: "ArrowUp" });
|
|
82
|
+
expect(document.activeElement).toBe(first);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("moves between an item and its More button with right/left arrows", () => {
|
|
86
|
+
const { trigger, more } = renderItem();
|
|
87
|
+
|
|
88
|
+
trigger.focus();
|
|
89
|
+
fireEvent.keyDown(trigger, { key: "ArrowRight" });
|
|
90
|
+
expect(document.activeElement).toBe(more);
|
|
91
|
+
|
|
92
|
+
fireEvent.keyDown(more, { key: "ArrowLeft" });
|
|
93
|
+
expect(document.activeElement).toBe(trigger);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("opens the More menu on ArrowRight", () => {
|
|
97
|
+
const { more } = renderItem();
|
|
98
|
+
|
|
99
|
+
more.focus();
|
|
100
|
+
fireEvent.keyDown(more, { key: "ArrowRight" });
|
|
101
|
+
|
|
102
|
+
expect(menu()).not.toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it.each(["ArrowLeft", "Escape"])(
|
|
106
|
+
"closes the menu on %s and returns focus to the More button",
|
|
107
|
+
(key) => {
|
|
108
|
+
const { more } = renderItem({ defaultOpen: true });
|
|
109
|
+
|
|
110
|
+
fireEvent.keyDown(menu() as HTMLElement, { key });
|
|
111
|
+
|
|
112
|
+
expect(menu()).toBeNull();
|
|
113
|
+
expect(document.activeElement).toBe(more);
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
it("leaves the More menu's arrow keys inert without sharedFocusGroup (the default)", () => {
|
|
118
|
+
const result = render(
|
|
119
|
+
<ThreadListPrimitiveRoot>
|
|
120
|
+
<ThreadListItemPrimitiveRoot>
|
|
121
|
+
<ThreadListItemPrimitiveTrigger>item</ThreadListItemPrimitiveTrigger>
|
|
122
|
+
<ThreadListItemMorePrimitiveRoot>
|
|
123
|
+
<ThreadListItemMorePrimitiveTrigger>
|
|
124
|
+
more
|
|
125
|
+
</ThreadListItemMorePrimitiveTrigger>
|
|
126
|
+
<ThreadListItemMorePrimitiveContent>
|
|
127
|
+
<ThreadListItemMorePrimitiveItem>
|
|
128
|
+
Archive
|
|
129
|
+
</ThreadListItemMorePrimitiveItem>
|
|
130
|
+
</ThreadListItemMorePrimitiveContent>
|
|
131
|
+
</ThreadListItemMorePrimitiveRoot>
|
|
132
|
+
</ThreadListItemPrimitiveRoot>
|
|
133
|
+
</ThreadListPrimitiveRoot>,
|
|
134
|
+
);
|
|
135
|
+
const more = moreOf(result.container);
|
|
136
|
+
|
|
137
|
+
more.focus();
|
|
138
|
+
fireEvent.keyDown(more, { key: "ArrowRight" });
|
|
139
|
+
expect(menu()).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -5,9 +5,15 @@ import {
|
|
|
5
5
|
type ComponentRef,
|
|
6
6
|
forwardRef,
|
|
7
7
|
} from "react";
|
|
8
|
-
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
|
8
|
+
import { Direction, DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
|
9
|
+
import { composeEventHandlers } from "@radix-ui/primitive";
|
|
9
10
|
import type { WithRenderPropProps } from "../../utils/Primitive";
|
|
10
11
|
import { DropdownMenuRenderContent } from "../dropdownMenuRenderPrimitives";
|
|
12
|
+
import { useThreadListItemFocus } from "../threadListFocusGroup";
|
|
13
|
+
import {
|
|
14
|
+
useThreadListItemMoreSharedFocusGroup,
|
|
15
|
+
useThreadListItemMoreSetOpen,
|
|
16
|
+
} from "./ThreadListItemMoreRoot";
|
|
11
17
|
import { type ScopedProps, useDropdownMenuScope } from "./scope";
|
|
12
18
|
|
|
13
19
|
export namespace ThreadListItemMorePrimitiveContent {
|
|
@@ -35,12 +41,27 @@ export const ThreadListItemMorePrimitiveContent = forwardRef<
|
|
|
35
41
|
forwardedRef,
|
|
36
42
|
) => {
|
|
37
43
|
const scope = useDropdownMenuScope(__scopeThreadListItemMore);
|
|
44
|
+
const setOpen = useThreadListItemMoreSetOpen();
|
|
45
|
+
const focus = useThreadListItemFocus();
|
|
46
|
+
const sharedFocusGroup = useThreadListItemMoreSharedFocusGroup();
|
|
47
|
+
const direction = Direction.useDirection();
|
|
48
|
+
const closeKey = direction === "rtl" ? "ArrowRight" : "ArrowLeft";
|
|
38
49
|
|
|
39
50
|
return (
|
|
40
51
|
<DropdownMenuPrimitive.Portal {...scope} {...portalProps}>
|
|
41
52
|
<DropdownMenuRenderContent
|
|
42
53
|
{...scope}
|
|
43
54
|
{...props}
|
|
55
|
+
onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
|
|
56
|
+
if (!sharedFocusGroup || event.key !== closeKey) return;
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
setOpen(false);
|
|
59
|
+
focus?.moreRef.current?.focus();
|
|
60
|
+
})}
|
|
61
|
+
onEscapeKeyDown={composeEventHandlers(props.onEscapeKeyDown, () => {
|
|
62
|
+
if (!sharedFocusGroup) return;
|
|
63
|
+
focus?.moreRef.current?.focus();
|
|
64
|
+
})}
|
|
44
65
|
ref={forwardedRef}
|
|
45
66
|
sideOffset={sideOffset}
|
|
46
67
|
/>
|
|
@@ -1,22 +1,83 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
type Dispatch,
|
|
6
|
+
type FC,
|
|
7
|
+
type SetStateAction,
|
|
8
|
+
useContext,
|
|
9
|
+
} from "react";
|
|
4
10
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
|
11
|
+
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
|
5
12
|
import { type ScopedProps, useDropdownMenuScope } from "./scope";
|
|
6
13
|
|
|
14
|
+
const ThreadListItemMoreSetOpenContext = createContext<
|
|
15
|
+
Dispatch<SetStateAction<boolean>>
|
|
16
|
+
>(() => {});
|
|
17
|
+
|
|
18
|
+
export const useThreadListItemMoreSetOpen = (): Dispatch<
|
|
19
|
+
SetStateAction<boolean>
|
|
20
|
+
> => useContext(ThreadListItemMoreSetOpenContext);
|
|
21
|
+
|
|
22
|
+
const ThreadListItemMoreSharedFocusGroupContext = createContext(false);
|
|
23
|
+
|
|
24
|
+
export const useThreadListItemMoreSharedFocusGroup = (): boolean =>
|
|
25
|
+
useContext(ThreadListItemMoreSharedFocusGroupContext);
|
|
26
|
+
|
|
7
27
|
export namespace ThreadListItemMorePrimitiveRoot {
|
|
8
|
-
export type Props = DropdownMenuPrimitive.DropdownMenuProps
|
|
28
|
+
export type Props = DropdownMenuPrimitive.DropdownMenuProps & {
|
|
29
|
+
/**
|
|
30
|
+
* Join the menu to the thread list's focus group: Right opens it, Left/Escape
|
|
31
|
+
* close it and return focus to the trigger (mirrored in RTL). Forces the menu
|
|
32
|
+
* non-modal, since a focus trap can't let focus cross the trigger/menu
|
|
33
|
+
* boundary. Defaults to a standalone modal dropdown.
|
|
34
|
+
*/
|
|
35
|
+
sharedFocusGroup?: boolean | undefined;
|
|
36
|
+
};
|
|
9
37
|
}
|
|
10
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Root container for the overflow menu, built on Radix DropdownMenu.
|
|
41
|
+
*
|
|
42
|
+
* Defaults to a standard, self-contained modal dropdown. Pass
|
|
43
|
+
* {@link ThreadListItemMorePrimitiveRoot.Props.sharedFocusGroup} to fold it into
|
|
44
|
+
* the thread list's keyboard navigation instead.
|
|
45
|
+
*/
|
|
11
46
|
export const ThreadListItemMorePrimitiveRoot: FC<
|
|
12
47
|
ThreadListItemMorePrimitiveRoot.Props
|
|
13
48
|
> = ({
|
|
14
49
|
__scopeThreadListItemMore,
|
|
50
|
+
open: openProp,
|
|
51
|
+
defaultOpen,
|
|
52
|
+
onOpenChange,
|
|
53
|
+
sharedFocusGroup = false,
|
|
54
|
+
modal: modalProp,
|
|
15
55
|
...rest
|
|
16
56
|
}: ScopedProps<ThreadListItemMorePrimitiveRoot.Props>) => {
|
|
57
|
+
const modal = sharedFocusGroup ? false : (modalProp ?? true);
|
|
17
58
|
const scope = useDropdownMenuScope(__scopeThreadListItemMore);
|
|
59
|
+
const [open, setOpen] = useControllableState({
|
|
60
|
+
prop: openProp,
|
|
61
|
+
defaultProp: defaultOpen ?? false,
|
|
62
|
+
caller: "ThreadListItemMorePrimitive.Root",
|
|
63
|
+
...(onOpenChange ? { onChange: onOpenChange } : {}),
|
|
64
|
+
});
|
|
18
65
|
|
|
19
|
-
return
|
|
66
|
+
return (
|
|
67
|
+
<ThreadListItemMoreSharedFocusGroupContext.Provider
|
|
68
|
+
value={sharedFocusGroup}
|
|
69
|
+
>
|
|
70
|
+
<ThreadListItemMoreSetOpenContext.Provider value={setOpen}>
|
|
71
|
+
<DropdownMenuPrimitive.Root
|
|
72
|
+
{...scope}
|
|
73
|
+
{...rest}
|
|
74
|
+
modal={modal}
|
|
75
|
+
open={open}
|
|
76
|
+
onOpenChange={setOpen}
|
|
77
|
+
/>
|
|
78
|
+
</ThreadListItemMoreSetOpenContext.Provider>
|
|
79
|
+
</ThreadListItemMoreSharedFocusGroupContext.Provider>
|
|
80
|
+
);
|
|
20
81
|
};
|
|
21
82
|
|
|
22
83
|
ThreadListItemMorePrimitiveRoot.displayName =
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { type ComponentRef, forwardRef } from "react";
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
Direction,
|
|
6
|
+
type DropdownMenu as DropdownMenuPrimitive,
|
|
7
|
+
} from "radix-ui";
|
|
8
|
+
import { useComposedRefs } from "@radix-ui/react-compose-refs";
|
|
9
|
+
import { composeEventHandlers } from "@radix-ui/primitive";
|
|
5
10
|
import type { WithRenderPropProps } from "../../utils/Primitive";
|
|
6
11
|
import { DropdownMenuRenderTrigger } from "../dropdownMenuRenderPrimitives";
|
|
12
|
+
import { useThreadListItemFocus } from "../threadListFocusGroup";
|
|
13
|
+
import {
|
|
14
|
+
useThreadListItemMoreSharedFocusGroup,
|
|
15
|
+
useThreadListItemMoreSetOpen,
|
|
16
|
+
} from "./ThreadListItemMoreRoot";
|
|
7
17
|
import { type ScopedProps, useDropdownMenuScope } from "./scope";
|
|
8
18
|
|
|
9
19
|
export namespace ThreadListItemMorePrimitiveTrigger {
|
|
@@ -23,8 +33,25 @@ export const ThreadListItemMorePrimitiveTrigger = forwardRef<
|
|
|
23
33
|
ref,
|
|
24
34
|
) => {
|
|
25
35
|
const scope = useDropdownMenuScope(__scopeThreadListItemMore);
|
|
36
|
+
const focus = useThreadListItemFocus();
|
|
37
|
+
const setOpen = useThreadListItemMoreSetOpen();
|
|
38
|
+
const sharedFocusGroup = useThreadListItemMoreSharedFocusGroup();
|
|
39
|
+
const composedRef = useComposedRefs(ref, focus?.moreRef);
|
|
40
|
+
const direction = Direction.useDirection();
|
|
41
|
+
const openKey = direction === "rtl" ? "ArrowLeft" : "ArrowRight";
|
|
26
42
|
|
|
27
|
-
return
|
|
43
|
+
return (
|
|
44
|
+
<DropdownMenuRenderTrigger
|
|
45
|
+
{...scope}
|
|
46
|
+
{...rest}
|
|
47
|
+
onKeyDown={composeEventHandlers(rest.onKeyDown, (event) => {
|
|
48
|
+
if (!sharedFocusGroup || event.key !== openKey) return;
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
setOpen(true);
|
|
51
|
+
})}
|
|
52
|
+
ref={composedRef}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
28
55
|
},
|
|
29
56
|
);
|
|
30
57
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
3
|
import {
|
|
4
4
|
GenerativeUIRender,
|
|
@@ -113,6 +113,26 @@ describe("MessagePrimitive.GenerativeUI (same-realm renderer)", () => {
|
|
|
113
113
|
expect(out2).toContain("<h3>loading</h3>");
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
it("skips malformed nodes with non-string component names", () => {
|
|
117
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
118
|
+
const malformedNode = { component: 123, props: { title: "bad" } };
|
|
119
|
+
const spec = { root: malformedNode } as unknown as GenerativeUISpec;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const out = renderToStaticMarkup(
|
|
123
|
+
<GenerativeUIRender spec={spec} components={{ Card }} />,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(out).toBe("");
|
|
127
|
+
expect(warn).toHaveBeenCalledWith(
|
|
128
|
+
"[generative-ui] Skipping malformed node at 0:",
|
|
129
|
+
malformedNode,
|
|
130
|
+
);
|
|
131
|
+
} finally {
|
|
132
|
+
warn.mockRestore();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
116
136
|
it("respects user-provided keys", () => {
|
|
117
137
|
const spec: GenerativeUISpec = {
|
|
118
138
|
root: [
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, render, screen } from "@testing-library/react";
|
|
4
|
+
import { useState, type FC, type PropsWithChildren } from "react";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
AssistantRuntimeProvider,
|
|
8
|
+
useExternalStoreRuntime,
|
|
9
|
+
unstable_useThreadMessageIds,
|
|
10
|
+
type ThreadMessageLike,
|
|
11
|
+
} from "../index";
|
|
12
|
+
import * as ThreadPrimitive from "../primitives/thread";
|
|
13
|
+
import * as MessagePrimitive from "../primitives/message";
|
|
14
|
+
import * as MessagePartPrimitive from "../primitives/messagePart";
|
|
15
|
+
|
|
16
|
+
type Msg = { id: string; role: "user" | "assistant"; text: string };
|
|
17
|
+
|
|
18
|
+
const convertMessage = (m: Msg): ThreadMessageLike => ({
|
|
19
|
+
id: m.id,
|
|
20
|
+
role: m.role,
|
|
21
|
+
content: [{ type: "text", text: m.text }],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const TextPart: FC = () => <MessagePartPrimitive.Text />;
|
|
25
|
+
const Message: FC = () => (
|
|
26
|
+
<MessagePrimitive.Parts components={{ Text: TextPart }} />
|
|
27
|
+
);
|
|
28
|
+
const COMPONENTS = { Message };
|
|
29
|
+
|
|
30
|
+
let setMessages: (updater: (prev: Msg[]) => Msg[]) => void;
|
|
31
|
+
|
|
32
|
+
const Provider: FC<PropsWithChildren<{ initial: Msg[] }>> = ({
|
|
33
|
+
initial,
|
|
34
|
+
children,
|
|
35
|
+
}) => {
|
|
36
|
+
const [messages, setState] = useState(initial);
|
|
37
|
+
setMessages = setState;
|
|
38
|
+
const runtime = useExternalStoreRuntime({
|
|
39
|
+
messages,
|
|
40
|
+
convertMessage,
|
|
41
|
+
onNew: async () => {},
|
|
42
|
+
});
|
|
43
|
+
return (
|
|
44
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
45
|
+
{children}
|
|
46
|
+
</AssistantRuntimeProvider>
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let lastIds: readonly string[] | undefined;
|
|
51
|
+
const idIdentities: (readonly string[])[] = [];
|
|
52
|
+
|
|
53
|
+
const List: FC = () => {
|
|
54
|
+
const ids = unstable_useThreadMessageIds();
|
|
55
|
+
lastIds = ids;
|
|
56
|
+
if (idIdentities[idIdentities.length - 1] !== ids) idIdentities.push(ids);
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
{ids.map((id) => (
|
|
60
|
+
<div data-testid={`by-id-${id}`} key={id}>
|
|
61
|
+
<ThreadPrimitive.Unstable_MessageById
|
|
62
|
+
messageId={id}
|
|
63
|
+
components={COMPONENTS}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
))}
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const initial: Msg[] = [
|
|
72
|
+
{ id: "m1", role: "user", text: "first" },
|
|
73
|
+
{ id: "m2", role: "assistant", text: "second" },
|
|
74
|
+
{ id: "m3", role: "user", text: "third" },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const reset = () => {
|
|
78
|
+
lastIds = undefined;
|
|
79
|
+
idIdentities.length = 0;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
describe("unstable_useThreadMessageIds", () => {
|
|
83
|
+
it("returns the message ids in thread order", () => {
|
|
84
|
+
reset();
|
|
85
|
+
render(
|
|
86
|
+
<Provider initial={initial}>
|
|
87
|
+
<List />
|
|
88
|
+
</Provider>,
|
|
89
|
+
);
|
|
90
|
+
expect(lastIds).toEqual(["m1", "m2", "m3"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("keeps a stable array identity across content-only updates", async () => {
|
|
94
|
+
reset();
|
|
95
|
+
render(
|
|
96
|
+
<Provider initial={initial}>
|
|
97
|
+
<List />
|
|
98
|
+
</Provider>,
|
|
99
|
+
);
|
|
100
|
+
const before = lastIds;
|
|
101
|
+
|
|
102
|
+
await act(async () => {
|
|
103
|
+
setMessages((prev) =>
|
|
104
|
+
prev.map((m) => (m.id === "m2" ? { ...m, text: "second-edited" } : m)),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(lastIds).toBe(before);
|
|
109
|
+
expect(idIdentities).toHaveLength(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("changes array identity when the id sequence changes", async () => {
|
|
113
|
+
reset();
|
|
114
|
+
render(
|
|
115
|
+
<Provider initial={initial}>
|
|
116
|
+
<List />
|
|
117
|
+
</Provider>,
|
|
118
|
+
);
|
|
119
|
+
const before = lastIds;
|
|
120
|
+
|
|
121
|
+
await act(async () => {
|
|
122
|
+
setMessages((prev) => [
|
|
123
|
+
...prev,
|
|
124
|
+
{ id: "m4", role: "user", text: "fourth" },
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(lastIds).not.toBe(before);
|
|
129
|
+
expect(lastIds).toEqual(["m1", "m2", "m3", "m4"]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("ThreadPrimitive.Unstable_MessageById", () => {
|
|
134
|
+
it("renders the same content as MessageByIndex for a known id", () => {
|
|
135
|
+
reset();
|
|
136
|
+
render(
|
|
137
|
+
<Provider initial={initial}>
|
|
138
|
+
<ThreadPrimitive.MessageByIndex index={1} components={COMPONENTS} />
|
|
139
|
+
<div data-testid="by-id">
|
|
140
|
+
<ThreadPrimitive.Unstable_MessageById
|
|
141
|
+
messageId="m2"
|
|
142
|
+
components={COMPONENTS}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
</Provider>,
|
|
146
|
+
);
|
|
147
|
+
expect(screen.getByTestId("by-id").textContent).toBe("second");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("renders null for an unknown id without throwing", () => {
|
|
151
|
+
reset();
|
|
152
|
+
expect(() =>
|
|
153
|
+
render(
|
|
154
|
+
<Provider initial={initial}>
|
|
155
|
+
<div data-testid="by-id">
|
|
156
|
+
<ThreadPrimitive.Unstable_MessageById
|
|
157
|
+
messageId="does-not-exist"
|
|
158
|
+
components={COMPONENTS}
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
</Provider>,
|
|
162
|
+
),
|
|
163
|
+
).not.toThrow();
|
|
164
|
+
expect(screen.getByTestId("by-id").textContent).toBe("");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("renders null when messageId changes from known to unknown", () => {
|
|
168
|
+
reset();
|
|
169
|
+
const { rerender } = render(
|
|
170
|
+
<Provider initial={initial}>
|
|
171
|
+
<div data-testid="by-id">
|
|
172
|
+
<ThreadPrimitive.Unstable_MessageById
|
|
173
|
+
messageId="m3"
|
|
174
|
+
components={COMPONENTS}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
</Provider>,
|
|
178
|
+
);
|
|
179
|
+
expect(screen.getByTestId("by-id").textContent).toBe("third");
|
|
180
|
+
|
|
181
|
+
expect(() =>
|
|
182
|
+
rerender(
|
|
183
|
+
<Provider initial={initial}>
|
|
184
|
+
<div data-testid="by-id">
|
|
185
|
+
<ThreadPrimitive.Unstable_MessageById
|
|
186
|
+
messageId="does-not-exist"
|
|
187
|
+
components={COMPONENTS}
|
|
188
|
+
/>
|
|
189
|
+
</div>
|
|
190
|
+
</Provider>,
|
|
191
|
+
),
|
|
192
|
+
).not.toThrow();
|
|
193
|
+
|
|
194
|
+
expect(screen.getByTestId("by-id").textContent).toBe("");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("stays attached to the same message across reordering", async () => {
|
|
198
|
+
reset();
|
|
199
|
+
render(
|
|
200
|
+
<Provider initial={initial}>
|
|
201
|
+
<div data-testid="by-id">
|
|
202
|
+
<ThreadPrimitive.Unstable_MessageById
|
|
203
|
+
messageId="m1"
|
|
204
|
+
components={COMPONENTS}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
</Provider>,
|
|
208
|
+
);
|
|
209
|
+
expect(screen.getByTestId("by-id").textContent).toBe("first");
|
|
210
|
+
|
|
211
|
+
await act(async () => {
|
|
212
|
+
setMessages((prev) => [...prev].reverse());
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(screen.getByTestId("by-id").textContent).toBe("first");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("exports", () => {
|
|
220
|
+
it("exposes the unstable id-keyed surface", () => {
|
|
221
|
+
expect(typeof unstable_useThreadMessageIds).toBe("function");
|
|
222
|
+
expect(ThreadPrimitive.Unstable_MessageById).toBeDefined();
|
|
223
|
+
});
|
|
224
|
+
});
|