@assistant-ui/react 0.14.24 → 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 (50) hide show
  1. package/dist/legacy-runtime/hooks/AttachmentContext.d.ts +12 -12
  2. package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
  3. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  4. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  5. package/dist/primitives/threadList/ThreadListRoot.d.ts.map +1 -1
  6. package/dist/primitives/threadList/ThreadListRoot.js +10 -3
  7. package/dist/primitives/threadList/ThreadListRoot.js.map +1 -1
  8. package/dist/primitives/threadListFocusGroup.d.ts +26 -0
  9. package/dist/primitives/threadListFocusGroup.d.ts.map +1 -0
  10. package/dist/primitives/threadListFocusGroup.js +15 -0
  11. package/dist/primitives/threadListFocusGroup.js.map +1 -0
  12. package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts.map +1 -1
  13. package/dist/primitives/threadListItem/ThreadListItemRoot.js +45 -27
  14. package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
  15. package/dist/primitives/threadListItem/ThreadListItemTrigger.d.ts.map +1 -1
  16. package/dist/primitives/threadListItem/ThreadListItemTrigger.js +26 -2
  17. package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
  18. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.d.ts.map +1 -1
  19. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +63 -38
  20. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
  21. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.d.ts +20 -3
  22. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.d.ts.map +1 -1
  23. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js +95 -14
  24. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js.map +1 -1
  25. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.d.ts.map +1 -1
  26. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +38 -21
  27. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js.map +1 -1
  28. package/dist/utils/hooks/useMediaQuery.js +2 -2
  29. package/dist/utils/hooks/useMediaQuery.js.map +1 -1
  30. package/dist/utils/smooth/useSmooth.d.ts +4 -0
  31. package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
  32. package/dist/utils/smooth/useSmooth.js +7 -1
  33. package/dist/utils/smooth/useSmooth.js.map +1 -1
  34. package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
  35. package/dist/utils/useToolArgsFieldStatus.d.ts.map +1 -1
  36. package/package.json +8 -6
  37. package/src/primitives/messagePart/MessagePartText.tsx +1 -0
  38. package/src/primitives/threadList/ThreadListRoot.tsx +8 -1
  39. package/src/primitives/threadListFocusGroup.ts +24 -0
  40. package/src/primitives/threadListItem/ThreadListItemRoot.tsx +53 -5
  41. package/src/primitives/threadListItem/ThreadListItemTrigger.tsx +45 -0
  42. package/src/primitives/threadListItem/ThreadListKeyboardNav.test.tsx +141 -0
  43. package/src/primitives/threadListItemMore/ThreadListItemMoreContent.tsx +22 -1
  44. package/src/primitives/threadListItemMore/ThreadListItemMoreRoot.tsx +64 -3
  45. package/src/primitives/threadListItemMore/ThreadListItemMoreTrigger.tsx +29 -2
  46. package/src/tests/generative-ui.test.tsx +21 -1
  47. package/src/utils/hooks/useMediaQuery.ts +3 -2
  48. package/src/utils/smooth/useSmooth.test.tsx +26 -0
  49. package/src/utils/smooth/useSmooth.ts +7 -1
  50. package/src/primitives/threadListItem/ThreadListItemTrigger.ts +0 -23
@@ -0,0 +1,45 @@
1
+ "use client";
2
+
3
+ import { forwardRef } from "react";
4
+ import { useComposedRefs } from "@radix-ui/react-compose-refs";
5
+ import {
6
+ type ActionButtonElement,
7
+ type ActionButtonProps,
8
+ createActionButton,
9
+ } from "../../utils/createActionButton";
10
+ import { useThreadListItemTrigger as useThreadListItemTriggerBehavior } from "@assistant-ui/core/react";
11
+ import {
12
+ ThreadListCollection,
13
+ useThreadListItemFocus,
14
+ } from "../threadListFocusGroup";
15
+
16
+ const useThreadListItemTrigger = () => {
17
+ const { switchTo } = useThreadListItemTriggerBehavior();
18
+ return switchTo;
19
+ };
20
+
21
+ export namespace ThreadListItemPrimitiveTrigger {
22
+ export type Element = ActionButtonElement;
23
+ export type Props = ActionButtonProps<typeof useThreadListItemTrigger>;
24
+ }
25
+
26
+ const ThreadListItemTriggerButton = createActionButton(
27
+ "ThreadListItemPrimitive.Trigger",
28
+ useThreadListItemTrigger,
29
+ );
30
+
31
+ export const ThreadListItemPrimitiveTrigger = forwardRef<
32
+ ThreadListItemPrimitiveTrigger.Element,
33
+ ThreadListItemPrimitiveTrigger.Props
34
+ >((props, forwardedRef) => {
35
+ const focus = useThreadListItemFocus();
36
+ const ref = useComposedRefs(forwardedRef, focus?.triggerRef);
37
+
38
+ return (
39
+ <ThreadListCollection.ItemSlot scope={undefined}>
40
+ <ThreadListItemTriggerButton {...props} ref={ref} />
41
+ </ThreadListCollection.ItemSlot>
42
+ );
43
+ });
44
+
45
+ ThreadListItemPrimitiveTrigger.displayName = "ThreadListItemPrimitive.Trigger";
@@ -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: [
@@ -8,7 +8,7 @@ const noopUnsubscribe = () => {};
8
8
  export const useMediaQuery = (query: string | null): boolean => {
9
9
  const subscribe = useCallback(
10
10
  (callback: () => void) => {
11
- if (typeof window === "undefined" || query === null)
11
+ if (typeof window === "undefined" || query === null || !window.matchMedia)
12
12
  return noopUnsubscribe;
13
13
  const mql = window.matchMedia(query);
14
14
  mql.addEventListener("change", callback);
@@ -18,7 +18,8 @@ export const useMediaQuery = (query: string | null): boolean => {
18
18
  );
19
19
 
20
20
  const getSnapshot = useCallback(() => {
21
- if (typeof window === "undefined" || query === null) return false;
21
+ if (typeof window === "undefined" || query === null || !window.matchMedia)
22
+ return false;
22
23
  return window.matchMedia(query).matches;
23
24
  }, [query]);
24
25
 
@@ -39,6 +39,23 @@ const runningState = (text: string) =>
39
39
  status: { type: "running" },
40
40
  }) as MessagePartState & TextMessagePart;
41
41
 
42
+ const setReducedMotion = (matches: boolean) => {
43
+ Object.defineProperty(window, "matchMedia", {
44
+ writable: true,
45
+ configurable: true,
46
+ value: (query: string) => ({
47
+ matches,
48
+ media: query,
49
+ onchange: null,
50
+ addEventListener: () => {},
51
+ removeEventListener: () => {},
52
+ addListener: () => {},
53
+ removeListener: () => {},
54
+ dispatchEvent: () => false,
55
+ }),
56
+ });
57
+ };
58
+
42
59
  const driveAndCount = (minCommitMs: number) => {
43
60
  const raf: FrameRequestCallback[] = [];
44
61
  const rafSpy = vi
@@ -85,6 +102,7 @@ const driveAndCount = (minCommitMs: number) => {
85
102
  describe("useSmooth", () => {
86
103
  afterEach(() => {
87
104
  vi.restoreAllMocks();
105
+ Reflect.deleteProperty(window, "matchMedia");
88
106
  });
89
107
 
90
108
  it("returns the input state unchanged when disabled", () => {
@@ -115,6 +133,14 @@ describe("useSmooth", () => {
115
133
  expect(result.current).toBe(state);
116
134
  });
117
135
 
136
+ it("disables the reveal under prefers-reduced-motion", () => {
137
+ setReducedMotion(true);
138
+ const state = runningState("streaming");
139
+ const { result } = renderHook(() => useSmooth(state, true));
140
+ expect(result.current).toBe(state);
141
+ expect(result.current.text).toBe("streaming");
142
+ });
143
+
118
144
  it("starts from an empty reveal for running parts when enabled", () => {
119
145
  const state = {
120
146
  type: "text",
@@ -9,6 +9,7 @@ import type {
9
9
  MessagePartState,
10
10
  } from "@assistant-ui/core";
11
11
  import { useCallbackRef } from "@radix-ui/react-use-callback-ref";
12
+ import { useMediaQuery } from "../hooks/useMediaQuery";
12
13
  import { useSmoothStatusStore } from "./SmoothContext";
13
14
  import { writableStore } from "../../context/ReadonlyStore";
14
15
 
@@ -134,6 +135,10 @@ const positiveOr = (value: number | undefined, fallback: number): number =>
134
135
  * the reveal. Returns the part state with `text` replaced by the revealed
135
136
  * prefix and `status` reporting `running` until the reveal catches up.
136
137
  *
138
+ * The reveal auto-disables under `prefers-reduced-motion: reduce`,
139
+ * committing the full text immediately; this takes precedence over an
140
+ * explicit `smooth={true}`.
141
+ *
137
142
  * @example
138
143
  * ```tsx
139
144
  * const { text, status } = useSmooth(useMessagePartText(), {
@@ -147,9 +152,10 @@ export const useSmooth = (
147
152
  smooth: boolean | SmoothOptions = false,
148
153
  ): MessagePartState & (TextMessagePart | ReasoningMessagePart) => {
149
154
  const { text } = state;
155
+ const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
150
156
  const options =
151
157
  typeof smooth === "object" && smooth !== null ? smooth : undefined;
152
- const enabled = smooth !== false && smooth !== null;
158
+ const enabled = smooth !== false && smooth !== null && !reduceMotion;
153
159
  const drainMs = positiveOr(options?.drainMs, DEFAULT_DRAIN_MS);
154
160
  const maxCharIntervalMs = positiveOr(
155
161
  options?.maxCharIntervalMs,
@@ -1,23 +0,0 @@
1
- "use client";
2
-
3
- import {
4
- type ActionButtonElement,
5
- type ActionButtonProps,
6
- createActionButton,
7
- } from "../../utils/createActionButton";
8
- import { useThreadListItemTrigger as useThreadListItemTriggerBehavior } from "@assistant-ui/core/react";
9
-
10
- const useThreadListItemTrigger = () => {
11
- const { switchTo } = useThreadListItemTriggerBehavior();
12
- return switchTo;
13
- };
14
-
15
- export namespace ThreadListItemPrimitiveTrigger {
16
- export type Element = ActionButtonElement;
17
- export type Props = ActionButtonProps<typeof useThreadListItemTrigger>;
18
- }
19
-
20
- export const ThreadListItemPrimitiveTrigger = createActionButton(
21
- "ThreadListItemPrimitive.Trigger",
22
- useThreadListItemTrigger,
23
- );