@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.
Files changed (87) hide show
  1. package/dist/index.d.ts +4 -3
  2. package/dist/index.js +4 -3
  3. package/dist/legacy-runtime/hooks/AttachmentContext.d.ts +12 -12
  4. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts +3 -1
  5. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.d.ts.map +1 -1
  6. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js +2 -2
  7. package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js.map +1 -1
  8. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  9. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +6 -2
  10. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  11. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  12. package/dist/primitives/composer/ComposerInput.js +5 -14
  13. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  14. package/dist/primitives/composer/useComposerInputState.d.ts +13 -0
  15. package/dist/primitives/composer/useComposerInputState.d.ts.map +1 -0
  16. package/dist/primitives/composer/useComposerInputState.js +47 -0
  17. package/dist/primitives/composer/useComposerInputState.js.map +1 -0
  18. package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
  19. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  20. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  21. package/dist/primitives/thread/ThreadMessages.d.ts +2 -2
  22. package/dist/primitives/thread/ThreadMessages.js +2 -2
  23. package/dist/primitives/thread.d.ts +3 -3
  24. package/dist/primitives/thread.js +3 -2
  25. package/dist/primitives/thread.js.map +1 -1
  26. package/dist/primitives/threadList/ThreadListRoot.d.ts.map +1 -1
  27. package/dist/primitives/threadList/ThreadListRoot.js +10 -3
  28. package/dist/primitives/threadList/ThreadListRoot.js.map +1 -1
  29. package/dist/primitives/threadListFocusGroup.d.ts +26 -0
  30. package/dist/primitives/threadListFocusGroup.d.ts.map +1 -0
  31. package/dist/primitives/threadListFocusGroup.js +15 -0
  32. package/dist/primitives/threadListFocusGroup.js.map +1 -0
  33. package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts.map +1 -1
  34. package/dist/primitives/threadListItem/ThreadListItemRoot.js +45 -27
  35. package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
  36. package/dist/primitives/threadListItem/ThreadListItemTrigger.d.ts.map +1 -1
  37. package/dist/primitives/threadListItem/ThreadListItemTrigger.js +26 -2
  38. package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
  39. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.d.ts.map +1 -1
  40. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +63 -38
  41. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
  42. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.d.ts +20 -3
  43. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.d.ts.map +1 -1
  44. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js +95 -14
  45. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js.map +1 -1
  46. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.d.ts.map +1 -1
  47. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +38 -21
  48. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js.map +1 -1
  49. package/dist/unstable/useComposerInput.d.ts +84 -0
  50. package/dist/unstable/useComposerInput.d.ts.map +1 -0
  51. package/dist/unstable/useComposerInput.js +78 -0
  52. package/dist/unstable/useComposerInput.js.map +1 -0
  53. package/dist/utils/hooks/useMediaQuery.js +2 -2
  54. package/dist/utils/hooks/useMediaQuery.js.map +1 -1
  55. package/dist/utils/smooth/useSmooth.d.ts +4 -0
  56. package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
  57. package/dist/utils/smooth/useSmooth.js +7 -1
  58. package/dist/utils/smooth/useSmooth.js.map +1 -1
  59. package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
  60. package/dist/utils/useToolArgsFieldStatus.d.ts.map +1 -1
  61. package/package.json +16 -14
  62. package/src/index.ts +39 -0
  63. package/src/legacy-runtime/runtime-cores/assistant-transport/commandQueue.ts +5 -2
  64. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-scheduling.test.ts +22 -0
  65. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +12 -2
  66. package/src/primitives/composer/ComposerInput.tsx +9 -20
  67. package/src/primitives/composer/trigger/triggerKeyboardResource.test.ts +18 -22
  68. package/src/primitives/composer/useComposerInputState.ts +34 -0
  69. package/src/primitives/messagePart/MessagePartText.tsx +1 -0
  70. package/src/primitives/thread/ThreadMessages.tsx +1 -0
  71. package/src/primitives/thread.ts +1 -0
  72. package/src/primitives/threadList/ThreadListRoot.tsx +8 -1
  73. package/src/primitives/threadListFocusGroup.ts +24 -0
  74. package/src/primitives/threadListItem/ThreadListItemRoot.tsx +53 -5
  75. package/src/primitives/threadListItem/ThreadListItemTrigger.tsx +45 -0
  76. package/src/primitives/threadListItem/ThreadListKeyboardNav.test.tsx +141 -0
  77. package/src/primitives/threadListItemMore/ThreadListItemMoreContent.tsx +22 -1
  78. package/src/primitives/threadListItemMore/ThreadListItemMoreRoot.tsx +64 -3
  79. package/src/primitives/threadListItemMore/ThreadListItemMoreTrigger.tsx +29 -2
  80. package/src/tests/generative-ui.test.tsx +21 -1
  81. package/src/tests/threadMessageById.test.tsx +224 -0
  82. package/src/unstable/useComposerInput.test.tsx +187 -0
  83. package/src/unstable/useComposerInput.ts +126 -0
  84. package/src/utils/hooks/useMediaQuery.ts +3 -2
  85. package/src/utils/smooth/useSmooth.test.tsx +26 -0
  86. package/src/utils/smooth/useSmooth.ts +7 -1
  87. 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 type { FC } from "react";
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 <DropdownMenuPrimitive.Root {...scope} {...rest} />;
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 type { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
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 <DropdownMenuRenderTrigger {...scope} {...rest} ref={ref} />;
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
+ });