@assistant-ui/react 0.14.16 → 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.
- package/dist/client/ExternalThread.d.ts +4 -3
- package/dist/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +46 -21
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/client/InMemoryThreadList.d.ts +1 -1
- package/dist/client/InMemoryThreadList.d.ts.map +1 -1
- package/dist/client/InMemoryThreadList.js +7 -5
- package/dist/client/InMemoryThreadList.js.map +1 -1
- package/dist/client/SingleThreadList.d.ts +1 -6
- package/dist/client/SingleThreadList.d.ts.map +1 -1
- package/dist/client/SingleThreadList.js +6 -4
- package/dist/client/SingleThreadList.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts +2 -10
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +3 -2
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.d.ts +1 -8
- package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.js +3 -2
- package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +3 -3
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts +2 -10
- package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverResource.js +3 -2
- package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts +2 -6
- package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerDetectionResource.js +3 -2
- package/dist/primitives/composer/trigger/triggerDetectionResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts +2 -17
- package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js +3 -2
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts +2 -10
- package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.js +3 -2
- package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts +2 -10
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js +3 -2
- package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts +5 -2
- package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
- package/dist/primitives/reasoning/useScrollLock.js +11 -2
- package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +5 -0
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/unstable/useComposerInputHistory.d.ts +30 -0
- package/dist/unstable/useComposerInputHistory.d.ts.map +1 -0
- package/dist/unstable/useComposerInputHistory.js +117 -0
- package/dist/unstable/useComposerInputHistory.js.map +1 -0
- package/dist/utils/smooth/useSmooth.d.ts +40 -2
- package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
- package/dist/utils/smooth/useSmooth.js +48 -9
- package/dist/utils/smooth/useSmooth.js.map +1 -1
- package/package.json +4 -4
- package/src/client/ExternalThread.ts +70 -27
- package/src/client/InMemoryThreadList.ts +11 -7
- package/src/client/SingleThreadList.ts +29 -27
- package/src/index.ts +8 -0
- package/src/mcp-apps/McpAppRenderer.tsx +5 -3
- package/src/mcp-apps/McpAppsRemoteHost.ts +5 -3
- package/src/primitives/composer/ComposerInput.test.tsx +1 -1
- package/src/primitives/composer/ComposerInput.tsx +3 -3
- package/src/primitives/composer/trigger/TriggerPopoverResource.ts +5 -3
- package/src/primitives/composer/trigger/triggerDetectionResource.ts +21 -21
- package/src/primitives/composer/trigger/triggerKeyboardResource.test.ts +5 -4
- package/src/primitives/composer/trigger/triggerKeyboardResource.ts +99 -101
- package/src/primitives/composer/trigger/triggerNavigationResource.ts +92 -98
- package/src/primitives/composer/trigger/triggerSelectionResource.ts +76 -76
- package/src/primitives/messagePart/MessagePartText.tsx +3 -2
- package/src/primitives/reasoning/useScrollLock.ts +25 -2
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +8 -0
- package/src/tests/external-thread-branches.test.tsx +160 -0
- package/src/tests/shouldContinue.test.ts +33 -0
- package/src/unstable/useComposerInputHistory.test.tsx +201 -0
- package/src/unstable/useComposerInputHistory.ts +160 -0
- package/src/utils/smooth/useSmooth.test.tsx +95 -0
- 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(
|
|
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 <
|
|
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
|
-
|
|
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,
|
|
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 (!
|
|
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,
|
|
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
|
-
|
|
238
|
+
enabled
|
|
167
239
|
? {
|
|
168
|
-
|
|
240
|
+
...state,
|
|
169
241
|
text: displayedText,
|
|
170
242
|
status: text === displayedText ? state.status : SMOOTH_STATUS,
|
|
171
243
|
}
|
|
172
244
|
: state,
|
|
173
|
-
[
|
|
245
|
+
[enabled, displayedText, state, text],
|
|
174
246
|
);
|
|
175
247
|
};
|