@assistant-ui/react 0.14.15 → 0.14.18

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 (84) hide show
  1. package/dist/client/ExternalThread.d.ts +4 -3
  2. package/dist/client/ExternalThread.d.ts.map +1 -1
  3. package/dist/client/ExternalThread.js +46 -21
  4. package/dist/client/ExternalThread.js.map +1 -1
  5. package/dist/client/InMemoryThreadList.d.ts +1 -1
  6. package/dist/client/InMemoryThreadList.d.ts.map +1 -1
  7. package/dist/client/InMemoryThreadList.js +7 -5
  8. package/dist/client/InMemoryThreadList.js.map +1 -1
  9. package/dist/client/SingleThreadList.d.ts +1 -6
  10. package/dist/client/SingleThreadList.d.ts.map +1 -1
  11. package/dist/client/SingleThreadList.js +6 -4
  12. package/dist/client/SingleThreadList.js.map +1 -1
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.js +3 -1
  15. package/dist/mcp-apps/McpAppRenderer.d.ts +2 -10
  16. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  17. package/dist/mcp-apps/McpAppRenderer.js +3 -2
  18. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  19. package/dist/mcp-apps/McpAppsRemoteHost.d.ts +1 -8
  20. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
  21. package/dist/mcp-apps/McpAppsRemoteHost.js +3 -2
  22. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
  23. package/dist/primitives/composer/ComposerInput.js +3 -3
  24. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  25. package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts +2 -10
  26. package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts.map +1 -1
  27. package/dist/primitives/composer/trigger/TriggerPopoverResource.js +3 -2
  28. package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
  29. package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts +2 -6
  30. package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts.map +1 -1
  31. package/dist/primitives/composer/trigger/triggerDetectionResource.js +3 -2
  32. package/dist/primitives/composer/trigger/triggerDetectionResource.js.map +1 -1
  33. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts +2 -17
  34. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  35. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +3 -2
  36. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  37. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts +2 -10
  38. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  39. package/dist/primitives/composer/trigger/triggerNavigationResource.js +3 -2
  40. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  41. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts +2 -10
  42. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
  43. package/dist/primitives/composer/trigger/triggerSelectionResource.js +3 -2
  44. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  45. package/dist/primitives/messagePart/MessagePartText.d.ts +5 -2
  46. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  47. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  48. package/dist/primitives/reasoning/useScrollLock.js +11 -2
  49. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  50. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  51. package/dist/primitives/thread/useThreadViewportAutoScroll.js +5 -0
  52. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  53. package/dist/unstable/useComposerInputHistory.d.ts +30 -0
  54. package/dist/unstable/useComposerInputHistory.d.ts.map +1 -0
  55. package/dist/unstable/useComposerInputHistory.js +117 -0
  56. package/dist/unstable/useComposerInputHistory.js.map +1 -0
  57. package/dist/utils/smooth/useSmooth.d.ts +40 -2
  58. package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
  59. package/dist/utils/smooth/useSmooth.js +48 -9
  60. package/dist/utils/smooth/useSmooth.js.map +1 -1
  61. package/package.json +31 -24
  62. package/src/client/ExternalThread.ts +70 -27
  63. package/src/client/InMemoryThreadList.ts +11 -7
  64. package/src/client/SingleThreadList.ts +29 -27
  65. package/src/index.ts +8 -0
  66. package/src/mcp-apps/McpAppRenderer.tsx +5 -3
  67. package/src/mcp-apps/McpAppsRemoteHost.ts +5 -3
  68. package/src/primitives/composer/ComposerInput.test.tsx +1 -1
  69. package/src/primitives/composer/ComposerInput.tsx +3 -3
  70. package/src/primitives/composer/trigger/TriggerPopoverResource.ts +5 -3
  71. package/src/primitives/composer/trigger/triggerDetectionResource.ts +21 -21
  72. package/src/primitives/composer/trigger/triggerKeyboardResource.test.ts +5 -4
  73. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +99 -101
  74. package/src/primitives/composer/trigger/triggerNavigationResource.ts +92 -98
  75. package/src/primitives/composer/trigger/triggerSelectionResource.ts +76 -76
  76. package/src/primitives/messagePart/MessagePartText.tsx +3 -2
  77. package/src/primitives/reasoning/useScrollLock.ts +25 -2
  78. package/src/primitives/thread/useThreadViewportAutoScroll.ts +8 -0
  79. package/src/tests/external-thread-branches.test.tsx +160 -0
  80. package/src/tests/shouldContinue.test.ts +33 -0
  81. package/src/unstable/useComposerInputHistory.test.tsx +201 -0
  82. package/src/unstable/useComposerInputHistory.ts +160 -0
  83. package/src/utils/smooth/useSmooth.test.tsx +95 -0
  84. package/src/utils/smooth/useSmooth.ts +82 -10
@@ -0,0 +1,160 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ type KeyboardEvent,
9
+ type KeyboardEventHandler,
10
+ } from "react";
11
+ import { useAui } from "@assistant-ui/store";
12
+ import { flushTapSync } from "@assistant-ui/tap";
13
+ import type { ThreadMessage } from "@assistant-ui/core";
14
+ import { getThreadMessageText } from "@assistant-ui/core/internal";
15
+ import { useTriggerPopoverRootContextOptional } from "../primitives/composer/trigger/TriggerPopoverRootContext";
16
+
17
+ export type Unstable_ComposerInputHistory = {
18
+ /** Keydown handler to spread onto `ComposerPrimitive.Input`. */
19
+ onKeyDown: KeyboardEventHandler<HTMLTextAreaElement>;
20
+ };
21
+
22
+ type BrowseState = {
23
+ cursor: number;
24
+ draftSnapshot: string;
25
+ lastRecalledText: string;
26
+ };
27
+
28
+ const deriveHistory = (messages: readonly ThreadMessage[]): string[] => {
29
+ const entries: string[] = [];
30
+ for (let i = messages.length - 1; i >= 0; i--) {
31
+ const message = messages[i]!;
32
+ if (message.role !== "user") continue;
33
+ const text = getThreadMessageText(message).trim();
34
+ if (!text) continue;
35
+ if (entries[entries.length - 1] === text) continue;
36
+ entries.push(text);
37
+ }
38
+ return entries;
39
+ };
40
+
41
+ const isOnFirstLine = (value: string, caret: number): boolean =>
42
+ !value.slice(0, caret).includes("\n");
43
+
44
+ const isOnLastLine = (value: string, caret: number): boolean =>
45
+ !value.slice(caret).includes("\n");
46
+
47
+ /**
48
+ * @deprecated Under active development and might change without notice.
49
+ *
50
+ * Terminal-style input history for the thread composer: ArrowUp on an
51
+ * empty draft recalls previously sent user messages (newest first),
52
+ * ArrowDown steps back toward the newest and finally restores the draft
53
+ * that was being typed when browsing started.
54
+ *
55
+ * Recall only triggers when the caret is on the first/last line with no
56
+ * selection, so multi-line editing keeps native arrow behavior. The
57
+ * handler yields to an open mention/slash popover, to IME composition,
58
+ * to modifier keys, and to consumer handlers that already called
59
+ * `preventDefault`. It is inert on edit composers.
60
+ *
61
+ * @example
62
+ * ```tsx
63
+ * const history = unstable_useComposerInputHistory();
64
+ * <ComposerPrimitive.Input {...history} />
65
+ * ```
66
+ */
67
+ export function unstable_useComposerInputHistory(): Unstable_ComposerInputHistory {
68
+ const aui = useAui();
69
+ const popoverCtx = useTriggerPopoverRootContextOptional();
70
+ const browseRef = useRef<BrowseState | null>(null);
71
+
72
+ useEffect(() => {
73
+ if (aui.composer().getState().type !== "thread") return undefined;
74
+
75
+ return aui.on("threadListItem.switchedTo", () => {
76
+ browseRef.current = null;
77
+ });
78
+ }, [aui]);
79
+
80
+ const onKeyDown = useCallback(
81
+ (e: KeyboardEvent<HTMLTextAreaElement>) => {
82
+ if (e.defaultPrevented) return;
83
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
84
+ if (e.nativeEvent.isComposing) return;
85
+ if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
86
+ if (popoverCtx && popoverCtx.getActiveAria() !== null) return;
87
+ if (aui.composer().getState().type !== "thread") return;
88
+
89
+ const textarea = e.currentTarget;
90
+ const { selectionStart, selectionEnd, value } = textarea;
91
+ if (selectionStart !== selectionEnd) return;
92
+
93
+ if (browseRef.current && value !== browseRef.current.lastRecalledText) {
94
+ browseRef.current = null;
95
+ }
96
+ const browse = browseRef.current;
97
+
98
+ const commitText = (text: string): void => {
99
+ flushTapSync(() => aui.composer().setText(text));
100
+ // React's controlled-value commit restores the pre-recall caret;
101
+ // reposition after the commit, before paint.
102
+ requestAnimationFrame(() => {
103
+ textarea.setSelectionRange(text.length, text.length);
104
+ });
105
+ e.preventDefault();
106
+ };
107
+
108
+ const recall = (
109
+ history: readonly string[],
110
+ cursor: number,
111
+ draftSnapshot: string,
112
+ ): void => {
113
+ const entry = history[cursor];
114
+ if (entry === undefined) {
115
+ e.preventDefault();
116
+ return;
117
+ }
118
+ browseRef.current = { cursor, draftSnapshot, lastRecalledText: entry };
119
+ commitText(entry);
120
+ };
121
+
122
+ if (e.key === "ArrowUp") {
123
+ if (!isOnFirstLine(value, selectionStart)) return;
124
+
125
+ if (!browse) {
126
+ if (value.trim() !== "") return;
127
+ const history = deriveHistory(aui.thread().getState().messages);
128
+ if (history.length === 0) return;
129
+ recall(history, 0, value);
130
+ return;
131
+ }
132
+
133
+ const history = deriveHistory(aui.thread().getState().messages);
134
+ const next = browse.cursor + 1;
135
+ if (next >= history.length) {
136
+ e.preventDefault();
137
+ return;
138
+ }
139
+ recall(history, next, browse.draftSnapshot);
140
+ return;
141
+ }
142
+
143
+ if (!browse) return;
144
+ if (!isOnLastLine(value, selectionEnd)) return;
145
+
146
+ const next = browse.cursor - 1;
147
+ if (next < 0) {
148
+ browseRef.current = null;
149
+ commitText(browse.draftSnapshot);
150
+ return;
151
+ }
152
+
153
+ const history = deriveHistory(aui.thread().getState().messages);
154
+ recall(history, next, browse.draftSnapshot);
155
+ },
156
+ [aui, popoverCtx],
157
+ );
158
+
159
+ return useMemo(() => ({ onKeyDown }), [onKeyDown]);
160
+ }
@@ -0,0 +1,95 @@
1
+ /** @vitest-environment jsdom */
2
+ import { renderHook } from "@testing-library/react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import type {
5
+ MessagePartState,
6
+ ReasoningMessagePart,
7
+ TextMessagePart,
8
+ } from "@assistant-ui/core";
9
+
10
+ const part = {};
11
+ vi.mock("@assistant-ui/store", () => ({
12
+ useAui: () => ({ part: () => part }),
13
+ useAuiState: (selector: () => unknown) => selector(),
14
+ }));
15
+ vi.mock("./SmoothContext", () => ({
16
+ useSmoothStatusStore: () => null,
17
+ }));
18
+
19
+ import { useSmooth, type SmoothOptions } from "./useSmooth";
20
+
21
+ const textState = (text: string) =>
22
+ ({
23
+ type: "text",
24
+ text,
25
+ status: { type: "complete", reason: "stop" },
26
+ }) as MessagePartState & TextMessagePart;
27
+
28
+ const reasoningState = (text: string) =>
29
+ ({
30
+ type: "reasoning",
31
+ text,
32
+ status: { type: "complete", reason: "stop" },
33
+ }) as MessagePartState & ReasoningMessagePart;
34
+
35
+ describe("useSmooth", () => {
36
+ it("returns the input state unchanged when disabled", () => {
37
+ const state = textState("hello");
38
+ const { result } = renderHook(() => useSmooth(state, false));
39
+ expect(result.current).toBe(state);
40
+ });
41
+
42
+ it("returns the full text immediately for settled parts when enabled", () => {
43
+ const state = textState("hello");
44
+ const { result } = renderHook(() => useSmooth(state, true));
45
+ expect(result.current.text).toBe("hello");
46
+ expect(result.current.status).toBe(state.status);
47
+ });
48
+
49
+ it("preserves the part type for reasoning parts", () => {
50
+ const state = reasoningState("thinking...");
51
+ const { result } = renderHook(() => useSmooth(state, true));
52
+ expect(result.current.type).toBe("reasoning");
53
+ expect(result.current.text).toBe("thinking...");
54
+ });
55
+
56
+ it("tolerates null as disabled", () => {
57
+ const state = textState("hello");
58
+ const { result } = renderHook(() =>
59
+ useSmooth(state, null as unknown as boolean),
60
+ );
61
+ expect(result.current).toBe(state);
62
+ });
63
+
64
+ it("starts from an empty reveal for running parts when enabled", () => {
65
+ const state = {
66
+ type: "text",
67
+ text: "streaming",
68
+ status: { type: "running" },
69
+ } as MessagePartState & TextMessagePart;
70
+ const { result } = renderHook(() => useSmooth(state, true));
71
+ expect(result.current.text).toBe("");
72
+ expect(result.current.status.type).toBe("running");
73
+ });
74
+
75
+ it("falls back to defaults for non-positive or NaN options", () => {
76
+ const state = textState("hello");
77
+ const { result } = renderHook(() =>
78
+ useSmooth(state, {
79
+ drainMs: -1,
80
+ maxCharIntervalMs: NaN,
81
+ maxCharsPerFrame: 0,
82
+ }),
83
+ );
84
+ expect(result.current.text).toBe("hello");
85
+ expect(result.current.status).toBe(state.status);
86
+ });
87
+
88
+ it("accepts a SmoothOptions object as the enabled form", () => {
89
+ const options: SmoothOptions = { drainMs: 500, maxCharsPerFrame: 30 };
90
+ const state = textState("hello");
91
+ const { result } = renderHook(() => useSmooth(state, options));
92
+ expect(result.current.text).toBe("hello");
93
+ expect(result.current.status).toBe(state.status);
94
+ });
95
+ });
@@ -12,11 +12,40 @@ import { useCallbackRef } from "@radix-ui/react-use-callback-ref";
12
12
  import { useSmoothStatusStore } from "./SmoothContext";
13
13
  import { writableStore } from "../../context/ReadonlyStore";
14
14
 
15
+ /**
16
+ * Tuning options for the smooth text streaming animation.
17
+ */
18
+ export type SmoothOptions = {
19
+ /**
20
+ * Target time in milliseconds to drain the backlog of unrevealed
21
+ * characters. Larger values reveal long backlogs more gradually.
22
+ * @default 250
23
+ */
24
+ drainMs?: number | undefined;
25
+ /**
26
+ * Maximum time in milliseconds between revealed characters, i.e. the
27
+ * slowest reveal rate when the backlog is short.
28
+ * @default 5
29
+ */
30
+ maxCharIntervalMs?: number | undefined;
31
+ /**
32
+ * Maximum number of characters revealed per animation frame.
33
+ * @default Infinity
34
+ */
35
+ maxCharsPerFrame?: number | undefined;
36
+ };
37
+
38
+ const DEFAULT_DRAIN_MS = 250;
39
+ const DEFAULT_MAX_CHAR_INTERVAL_MS = 5;
40
+
15
41
  class TextStreamAnimator {
16
42
  private animationFrameId: number | null = null;
17
43
  private lastUpdateTime: number = Date.now();
18
44
 
19
45
  public targetText: string = "";
46
+ public drainMs: number = DEFAULT_DRAIN_MS;
47
+ public maxCharIntervalMs: number = DEFAULT_MAX_CHAR_INTERVAL_MS;
48
+ public maxCharsPerFrame: number = Infinity;
20
49
 
21
50
  constructor(
22
51
  public currentText: string,
@@ -42,13 +71,22 @@ class TextStreamAnimator {
42
71
  let timeToConsume = deltaTime;
43
72
 
44
73
  const remainingChars = this.targetText.length - this.currentText.length;
45
- const baseTimePerChar = Math.min(5, 250 / remainingChars);
74
+ const baseTimePerChar = Math.min(
75
+ this.maxCharIntervalMs,
76
+ this.drainMs / remainingChars,
77
+ );
46
78
 
79
+ const frameLimit = Math.min(remainingChars, this.maxCharsPerFrame);
47
80
  let charsToAdd = 0;
48
- while (timeToConsume >= baseTimePerChar && charsToAdd < remainingChars) {
81
+ while (timeToConsume >= baseTimePerChar && charsToAdd < frameLimit) {
49
82
  charsToAdd++;
50
83
  timeToConsume -= baseTimePerChar;
51
84
  }
85
+ // A cap-limited frame must not bank its surplus time, or the next
86
+ // frame would burst past the cap.
87
+ if (charsToAdd === frameLimit && frameLimit === this.maxCharsPerFrame) {
88
+ timeToConsume = 0;
89
+ }
52
90
 
53
91
  if (charsToAdd !== remainingChars) {
54
92
  this.animationFrameId = requestAnimationFrame(this.animate);
@@ -70,11 +108,39 @@ const SMOOTH_STATUS: MessagePartStatus = Object.freeze({
70
108
  type: "running",
71
109
  });
72
110
 
111
+ const positiveOr = (value: number | undefined, fallback: number): number =>
112
+ value !== undefined && value > 0 ? value : fallback;
113
+
114
+ /**
115
+ * Animates streamed message part text with a typewriter-style reveal.
116
+ *
117
+ * Takes the current part state and a `smooth` argument: `false` disables,
118
+ * `true` uses the default rate, and a {@link SmoothOptions} object tunes
119
+ * the reveal. Returns the part state with `text` replaced by the revealed
120
+ * prefix and `status` reporting `running` until the reveal catches up.
121
+ *
122
+ * @example
123
+ * ```tsx
124
+ * const { text, status } = useSmooth(useMessagePartText(), {
125
+ * drainMs: 500,
126
+ * maxCharsPerFrame: 30,
127
+ * });
128
+ * ```
129
+ */
73
130
  export const useSmooth = (
74
131
  state: MessagePartState & (TextMessagePart | ReasoningMessagePart),
75
- smooth: boolean = false,
132
+ smooth: boolean | SmoothOptions = false,
76
133
  ): MessagePartState & (TextMessagePart | ReasoningMessagePart) => {
77
134
  const { text } = state;
135
+ const options =
136
+ typeof smooth === "object" && smooth !== null ? smooth : undefined;
137
+ const enabled = smooth !== false && smooth !== null;
138
+ const drainMs = positiveOr(options?.drainMs, DEFAULT_DRAIN_MS);
139
+ const maxCharIntervalMs = positiveOr(
140
+ options?.maxCharIntervalMs,
141
+ DEFAULT_MAX_CHAR_INTERVAL_MS,
142
+ );
143
+ const maxCharsPerFrame = positiveOr(options?.maxCharsPerFrame, Infinity);
78
144
 
79
145
  const [displayedText, setDisplayedText] = useState(
80
146
  state.status.type === "running" ? "" : text,
@@ -113,20 +179,26 @@ export const useSmooth = (
113
179
  useEffect(() => {
114
180
  if (smoothStatusStore) {
115
181
  const target =
116
- smooth && (displayedText !== text || state.status.type === "running")
182
+ enabled && (displayedText !== text || state.status.type === "running")
117
183
  ? SMOOTH_STATUS
118
184
  : state.status;
119
185
  writableStore(smoothStatusStore).setState(target, true);
120
186
  }
121
- }, [smoothStatusStore, smooth, text, displayedText, state.status]);
187
+ }, [smoothStatusStore, enabled, text, displayedText, state.status]);
122
188
 
123
189
  const [animatorRef] = useState<TextStreamAnimator>(
124
190
  new TextStreamAnimator(displayedText, setText),
125
191
  );
126
192
 
193
+ useEffect(() => {
194
+ animatorRef.drainMs = drainMs;
195
+ animatorRef.maxCharIntervalMs = maxCharIntervalMs;
196
+ animatorRef.maxCharsPerFrame = maxCharsPerFrame;
197
+ }, [animatorRef, drainMs, maxCharIntervalMs, maxCharsPerFrame]);
198
+
127
199
  const animatorPartRef = useRef(part);
128
200
  useEffect(() => {
129
- if (!smooth) {
201
+ if (!enabled) {
130
202
  animatorRef.stop();
131
203
  return;
132
204
  }
@@ -153,7 +225,7 @@ export const useSmooth = (
153
225
 
154
226
  animatorRef.targetText = text;
155
227
  animatorRef.start();
156
- }, [animatorRef, smooth, text, state.status.type, part]);
228
+ }, [animatorRef, enabled, text, state.status.type, part]);
157
229
 
158
230
  useEffect(() => {
159
231
  return () => {
@@ -163,13 +235,13 @@ export const useSmooth = (
163
235
 
164
236
  return useMemo(
165
237
  () =>
166
- smooth
238
+ enabled
167
239
  ? {
168
- type: "text",
240
+ ...state,
169
241
  text: displayedText,
170
242
  status: text === displayedText ? state.status : SMOOTH_STATUS,
171
243
  }
172
244
  : state,
173
- [smooth, displayedText, state, text],
245
+ [enabled, displayedText, state, text],
174
246
  );
175
247
  };