@assistant-ui/mcp-docs-server 0.1.33 → 0.1.35
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/.docs/organized/code-examples/waterfall.md +7 -7
- package/.docs/organized/code-examples/with-a2a.md +8 -8
- package/.docs/organized/code-examples/with-ag-ui.md +12 -12
- package/.docs/organized/code-examples/with-ai-sdk-v6.md +10 -10
- package/.docs/organized/code-examples/with-artifacts.md +40 -34
- package/.docs/organized/code-examples/with-assistant-transport.md +11 -11
- package/.docs/organized/code-examples/with-browser-extension.md +9 -9
- package/.docs/organized/code-examples/with-chain-of-thought.md +72 -51
- package/.docs/organized/code-examples/with-cloud-standalone.md +10 -10
- package/.docs/organized/code-examples/with-cloud.md +10 -10
- package/.docs/organized/code-examples/with-custom-thread-list.md +10 -10
- package/.docs/organized/code-examples/with-elevenlabs-conversational.md +12 -12
- package/.docs/organized/code-examples/with-elevenlabs-scribe.md +12 -12
- package/.docs/organized/code-examples/with-expo.md +66 -31
- package/.docs/organized/code-examples/with-external-store.md +8 -8
- package/.docs/organized/code-examples/with-ffmpeg.md +13 -13
- package/.docs/organized/code-examples/with-generative-ui.md +98 -368
- package/.docs/organized/code-examples/with-google-adk.md +9 -9
- package/.docs/organized/code-examples/with-heat-graph.md +7 -7
- package/.docs/organized/code-examples/with-image-generation.md +10 -10
- package/.docs/organized/code-examples/with-interactables.md +10 -10
- package/.docs/organized/code-examples/with-langchain.md +10 -10
- package/.docs/organized/code-examples/with-langgraph.md +33 -29
- package/.docs/organized/code-examples/with-livekit.md +12 -12
- package/.docs/organized/code-examples/with-mcp.md +11 -11
- package/.docs/organized/code-examples/with-opencode.md +109 -583
- package/.docs/organized/code-examples/with-pi.md +2044 -0
- package/.docs/organized/code-examples/with-react-hook-form.md +11 -11
- package/.docs/organized/code-examples/with-react-ink-web.md +691 -0
- package/.docs/organized/code-examples/with-react-ink.md +309 -102
- package/.docs/organized/code-examples/with-react-router.md +14 -14
- package/.docs/organized/code-examples/with-resumable-stream.md +11 -11
- package/.docs/organized/code-examples/with-store.md +70 -66
- package/.docs/organized/code-examples/with-tanstack.md +25 -11
- package/.docs/organized/code-examples/with-tap-runtime.md +8 -8
- package/.docs/organized/code-examples/with-virtualized-thread.md +656 -0
- package/.docs/raw/docs/(docs)/architecture.mdx +65 -53
- package/.docs/raw/docs/(reference)/api-reference/external-store/message-conversion.mdx +1 -1
- package/.docs/raw/docs/(reference)/api-reference/external-store/runtime.mdx +3 -0
- package/.docs/raw/docs/(reference)/api-reference/hooks/primitives.mdx +44 -1
- package/.docs/raw/docs/(reference)/api-reference/integrations/react-ai-sdk.mdx +14 -3
- package/.docs/raw/docs/(reference)/api-reference/model-context/context.mdx +1 -1
- package/.docs/raw/docs/(reference)/api-reference/tools/rendering.mdx +2 -23
- package/.docs/raw/docs/(reference)/api-reference/tools/status.mdx +22 -0
- package/.docs/raw/docs/(reference)/api-reference/tools/toolkits.mdx +93 -9
- package/.docs/raw/docs/(reference)/api-reference/utilities/miscellaneous.mdx +21 -86
- package/.docs/raw/docs/guides/chain-of-thought.mdx +1 -1
- package/.docs/raw/docs/guides/index.mdx +3 -0
- package/.docs/raw/docs/guides/input-history.mdx +55 -0
- package/.docs/raw/docs/guides/virtualization.mdx +63 -0
- package/.docs/raw/docs/ink/adapters.mdx +23 -1
- package/.docs/raw/docs/ink/hooks.mdx +22 -19
- package/.docs/raw/docs/migrations/toolkit-tools.mdx +14 -8
- package/.docs/raw/docs/react-native/hooks.mdx +26 -18
- package/.docs/raw/docs/runtimes/ag-ui/runtime-options.mdx +39 -0
- package/.docs/raw/docs/runtimes/ai-sdk/v6.mdx +3 -3
- package/.docs/raw/docs/runtimes/concepts/architecture.mdx +46 -4
- package/.docs/raw/docs/runtimes/concepts/stability.mdx +1 -1
- package/.docs/raw/docs/runtimes/custom/assistant-transport.mdx +54 -12
- package/.docs/raw/docs/runtimes/custom/data-stream.mdx +1 -1
- package/.docs/raw/docs/runtimes/custom/external-store.mdx +62 -4
- package/.docs/raw/docs/runtimes/custom/local-runtime.mdx +120 -4
- package/.docs/raw/docs/runtimes/google-adk/hooks.mdx +9 -9
- package/.docs/raw/docs/runtimes/langgraph/streaming.mdx +13 -0
- package/.docs/raw/docs/runtimes/langgraph/tutorial/part-2.mdx +5 -5
- package/.docs/raw/docs/runtimes/langgraph/tutorial/part-3.mdx +3 -3
- package/.docs/raw/docs/runtimes/pick-a-runtime.mdx +6 -6
- package/.docs/raw/docs/tools/backend.mdx +19 -11
- package/.docs/raw/docs/tools/defining-tools.mdx +177 -52
- package/.docs/raw/docs/tools/index.mdx +7 -12
- package/.docs/raw/docs/tools/mcp.mdx +83 -15
- package/.docs/raw/docs/tools/multi-agent.mdx +5 -5
- package/.docs/raw/docs/tools/tool-ui.mdx +64 -28
- package/.docs/raw/docs/tools/user-managed-mcp.mdx +4 -4
- package/.docs/raw/docs/ui/dot-matrix.mdx +133 -0
- package/.docs/raw/docs/ui/mermaid.mdx +16 -9
- package/.docs/raw/docs/ui/model-selector.mdx +219 -52
- package/.docs/raw/docs/ui/number-roll.mdx +154 -0
- package/.docs/raw/docs/ui/part-grouping.mdx +2 -2
- package/.docs/raw/docs/ui/reasoning.mdx +3 -3
- package/.docs/raw/docs/ui/streamdown.mdx +2 -0
- package/.docs/raw/docs/ui/syntax-highlighting.mdx +1 -1
- package/.docs/raw/docs/ui/thread.mdx +52 -0
- package/.docs/raw/docs/ui/tool-fallback.mdx +16 -0
- package/.docs/raw/docs/utilities/react-o11y.mdx +2 -2
- package/dist/constants.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/prepare-docs/code-examples.js.map +1 -1
- package/dist/prepare-docs/copy-raw.js.map +1 -1
- package/dist/prepare-docs/prepare.js.map +1 -1
- package/dist/stdio.js.map +1 -1
- package/dist/tools/docs.js.map +1 -1
- package/dist/tools/examples.js.map +1 -1
- package/dist/tools/tests/test-setup.js.map +1 -1
- package/dist/utils/mdx.js.map +1 -1
- package/dist/utils/paths.js.map +1 -1
- package/package.json +4 -4
- /package/.docs/raw/docs/{(docs)/copilots → copilots}/assistant-frame.mdx +0 -0
- /package/.docs/raw/docs/{(docs)/copilots → copilots}/make-assistant-visible.mdx +0 -0
- /package/.docs/raw/docs/{(docs)/copilots → copilots}/model-context.mdx +0 -0
- /package/.docs/raw/docs/{(docs)/copilots → copilots}/motivation.mdx +0 -0
- /package/.docs/raw/docs/{(docs)/copilots → copilots}/use-assistant-instructions.mdx +0 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
# Example: with-virtualized-thread
|
|
2
|
+
|
|
3
|
+
## app/globals.css
|
|
4
|
+
|
|
5
|
+
```css
|
|
6
|
+
@import "tailwindcss";
|
|
7
|
+
@import "tw-animate-css";
|
|
8
|
+
|
|
9
|
+
@source "../../../packages/ui/src";
|
|
10
|
+
|
|
11
|
+
@custom-variant dark (&:is(.dark *));
|
|
12
|
+
|
|
13
|
+
@theme inline {
|
|
14
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
15
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
16
|
+
--radius-lg: var(--radius);
|
|
17
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
18
|
+
--color-background: var(--background);
|
|
19
|
+
--color-foreground: var(--foreground);
|
|
20
|
+
--color-card: var(--card);
|
|
21
|
+
--color-card-foreground: var(--card-foreground);
|
|
22
|
+
--color-popover: var(--popover);
|
|
23
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
24
|
+
--color-primary: var(--primary);
|
|
25
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
26
|
+
--color-secondary: var(--secondary);
|
|
27
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
28
|
+
--color-muted: var(--muted);
|
|
29
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
30
|
+
--color-accent: var(--accent);
|
|
31
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
32
|
+
--color-destructive: var(--destructive);
|
|
33
|
+
--color-border: var(--border);
|
|
34
|
+
--color-input: var(--input);
|
|
35
|
+
--color-ring: var(--ring);
|
|
36
|
+
--color-chart-1: var(--chart-1);
|
|
37
|
+
--color-chart-2: var(--chart-2);
|
|
38
|
+
--color-chart-3: var(--chart-3);
|
|
39
|
+
--color-chart-4: var(--chart-4);
|
|
40
|
+
--color-chart-5: var(--chart-5);
|
|
41
|
+
--color-sidebar: var(--sidebar);
|
|
42
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
43
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
44
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
45
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
46
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
47
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
48
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
:root {
|
|
52
|
+
--radius: 0.625rem;
|
|
53
|
+
--background: oklch(1 0 0);
|
|
54
|
+
--foreground: oklch(0.141 0.005 285.823);
|
|
55
|
+
--card: oklch(1 0 0);
|
|
56
|
+
--card-foreground: oklch(0.141 0.005 285.823);
|
|
57
|
+
--popover: oklch(1 0 0);
|
|
58
|
+
--popover-foreground: oklch(0.141 0.005 285.823);
|
|
59
|
+
--primary: oklch(0.21 0.006 285.885);
|
|
60
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
61
|
+
--secondary: oklch(0.967 0.001 286.375);
|
|
62
|
+
--secondary-foreground: oklch(0.21 0.006 285.885);
|
|
63
|
+
--muted: oklch(0.967 0.001 286.375);
|
|
64
|
+
--muted-foreground: oklch(0.552 0.016 285.938);
|
|
65
|
+
--accent: oklch(0.967 0.001 286.375);
|
|
66
|
+
--accent-foreground: oklch(0.21 0.006 285.885);
|
|
67
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
68
|
+
--border: oklch(0.92 0.004 286.32);
|
|
69
|
+
--input: oklch(0.92 0.004 286.32);
|
|
70
|
+
--ring: oklch(0.705 0.015 286.067);
|
|
71
|
+
--chart-1: oklch(0.646 0.222 41.116);
|
|
72
|
+
--chart-2: oklch(0.6 0.118 184.704);
|
|
73
|
+
--chart-3: oklch(0.398 0.07 227.392);
|
|
74
|
+
--chart-4: oklch(0.828 0.189 84.429);
|
|
75
|
+
--chart-5: oklch(0.769 0.188 70.08);
|
|
76
|
+
--sidebar: oklch(0.985 0 0);
|
|
77
|
+
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
|
78
|
+
--sidebar-primary: oklch(0.21 0.006 285.885);
|
|
79
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
80
|
+
--sidebar-accent: oklch(0.967 0.001 286.375);
|
|
81
|
+
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
|
82
|
+
--sidebar-border: oklch(0.92 0.004 286.32);
|
|
83
|
+
--sidebar-ring: oklch(0.705 0.015 286.067);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.dark {
|
|
87
|
+
--background: oklch(0.141 0.005 285.823);
|
|
88
|
+
--foreground: oklch(0.985 0 0);
|
|
89
|
+
--card: oklch(0.21 0.006 285.885);
|
|
90
|
+
--card-foreground: oklch(0.985 0 0);
|
|
91
|
+
--popover: oklch(0.21 0.006 285.885);
|
|
92
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
93
|
+
--primary: oklch(0.92 0.004 286.32);
|
|
94
|
+
--primary-foreground: oklch(0.21 0.006 285.885);
|
|
95
|
+
--secondary: oklch(0.274 0.006 286.033);
|
|
96
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
97
|
+
--muted: oklch(0.274 0.006 286.033);
|
|
98
|
+
--muted-foreground: oklch(0.705 0.015 286.067);
|
|
99
|
+
--accent: oklch(0.274 0.006 286.033);
|
|
100
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
101
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
102
|
+
--border: oklch(1 0 0 / 10%);
|
|
103
|
+
--input: oklch(1 0 0 / 15%);
|
|
104
|
+
--ring: oklch(0.552 0.016 285.938);
|
|
105
|
+
--chart-1: oklch(0.488 0.243 264.376);
|
|
106
|
+
--chart-2: oklch(0.696 0.17 162.48);
|
|
107
|
+
--chart-3: oklch(0.769 0.188 70.08);
|
|
108
|
+
--chart-4: oklch(0.627 0.265 303.9);
|
|
109
|
+
--chart-5: oklch(0.645 0.246 16.439);
|
|
110
|
+
--sidebar: oklch(0.21 0.006 285.885);
|
|
111
|
+
--sidebar-foreground: oklch(0.985 0 0);
|
|
112
|
+
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
113
|
+
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
114
|
+
--sidebar-accent: oklch(0.274 0.006 286.033);
|
|
115
|
+
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
116
|
+
--sidebar-border: oklch(1 0 0 / 10%);
|
|
117
|
+
--sidebar-ring: oklch(0.552 0.016 285.938);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@layer base {
|
|
121
|
+
* {
|
|
122
|
+
@apply border-border outline-ring/50;
|
|
123
|
+
}
|
|
124
|
+
body {
|
|
125
|
+
@apply bg-background text-foreground;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## app/layout.tsx
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
import type { Metadata } from "next";
|
|
135
|
+
import { MyRuntimeProvider } from "@/app/MyRuntimeProvider";
|
|
136
|
+
|
|
137
|
+
import "./globals.css";
|
|
138
|
+
|
|
139
|
+
export const metadata: Metadata = {
|
|
140
|
+
title: "assistant-ui virtualized thread",
|
|
141
|
+
description: "Long threads with @tanstack/react-virtual",
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export default function RootLayout({
|
|
145
|
+
children,
|
|
146
|
+
}: Readonly<{
|
|
147
|
+
children: React.ReactNode;
|
|
148
|
+
}>) {
|
|
149
|
+
return (
|
|
150
|
+
<MyRuntimeProvider>
|
|
151
|
+
<html lang="en" className="h-dvh">
|
|
152
|
+
<body className="h-dvh font-sans">{children}</body>
|
|
153
|
+
</html>
|
|
154
|
+
</MyRuntimeProvider>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## app/MyRuntimeProvider.tsx
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
"use client";
|
|
164
|
+
|
|
165
|
+
import {
|
|
166
|
+
AssistantRuntimeProvider,
|
|
167
|
+
type AppendMessage,
|
|
168
|
+
type ThreadMessageLike,
|
|
169
|
+
useExternalStoreRuntime,
|
|
170
|
+
} from "@assistant-ui/react";
|
|
171
|
+
import { useRef, useState, type ReactNode } from "react";
|
|
172
|
+
import { generateSeedMessages, REPLY_CHUNKS } from "./seed-messages";
|
|
173
|
+
|
|
174
|
+
const convertMessage = (message: ThreadMessageLike) => message;
|
|
175
|
+
|
|
176
|
+
export function MyRuntimeProvider({ children }: { children: ReactNode }) {
|
|
177
|
+
const [messages, setMessages] =
|
|
178
|
+
useState<readonly ThreadMessageLike[]>(generateSeedMessages);
|
|
179
|
+
const [isRunning, setIsRunning] = useState(false);
|
|
180
|
+
const nextIdRef = useRef(0);
|
|
181
|
+
|
|
182
|
+
const onNew = async (message: AppendMessage) => {
|
|
183
|
+
if (message.content[0]?.type !== "text")
|
|
184
|
+
throw new Error("Only text content is supported");
|
|
185
|
+
const userText = message.content[0].text;
|
|
186
|
+
const id = `reply-${nextIdRef.current++}`;
|
|
187
|
+
|
|
188
|
+
setMessages((current) => [
|
|
189
|
+
...current,
|
|
190
|
+
{ id: `${id}-user`, role: "user", content: userText },
|
|
191
|
+
]);
|
|
192
|
+
setIsRunning(true);
|
|
193
|
+
try {
|
|
194
|
+
for (let i = 1; i <= REPLY_CHUNKS.length; i++) {
|
|
195
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
196
|
+
const content = REPLY_CHUNKS.slice(0, i).join("");
|
|
197
|
+
setMessages((current) =>
|
|
198
|
+
current.at(-1)?.id === id
|
|
199
|
+
? [...current.slice(0, -1), { id, role: "assistant", content }]
|
|
200
|
+
: [...current, { id, role: "assistant", content }],
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
setIsRunning(false);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const runtime = useExternalStoreRuntime<ThreadMessageLike>({
|
|
209
|
+
messages,
|
|
210
|
+
setMessages,
|
|
211
|
+
isRunning,
|
|
212
|
+
onNew,
|
|
213
|
+
convertMessage,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
218
|
+
{children}
|
|
219
|
+
</AssistantRuntimeProvider>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## app/page.tsx
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
"use client";
|
|
229
|
+
|
|
230
|
+
import { VirtualizedThread } from "@/app/VirtualizedThread";
|
|
231
|
+
|
|
232
|
+
export default function Home() {
|
|
233
|
+
return (
|
|
234
|
+
<main className="h-dvh">
|
|
235
|
+
<VirtualizedThread />
|
|
236
|
+
</main>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## app/seed-messages.ts
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
import type { ThreadMessageLike } from "@assistant-ui/react";
|
|
246
|
+
|
|
247
|
+
const SEED_COUNT = 500;
|
|
248
|
+
|
|
249
|
+
const SNIPPETS = [
|
|
250
|
+
"Virtualization keeps the DOM small by only mounting the messages near the viewport. Everything else is represented by empty space, so scrolling through thousands of messages stays cheap.",
|
|
251
|
+
"Here is a list of things to keep in mind:\n\n- only mount what is visible\n- measure real heights after mount\n- keep React keys stable across index shifts",
|
|
252
|
+
"```ts\nconst virtualizer = useVirtualizer({\n count: turns.length,\n estimateSize: () => 200,\n getScrollElement: () => scrollerRef.current,\n});\n```",
|
|
253
|
+
"Padding spacers keep the mounted items in normal document flow, which means CSS like `position: sticky` and the regular kit styling keep working inside each message.",
|
|
254
|
+
"Auto-follow works by observing the content size with a ResizeObserver and re-pinning the scroller to the bottom while the user has not scrolled away.",
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
export const generateSeedMessages = (): ThreadMessageLike[] =>
|
|
258
|
+
Array.from({ length: SEED_COUNT }, (_, i) => ({
|
|
259
|
+
id: `seed-${i}`,
|
|
260
|
+
role: i % 2 === 0 ? ("user" as const) : ("assistant" as const),
|
|
261
|
+
content:
|
|
262
|
+
i % 2 === 0
|
|
263
|
+
? `Question ${i / 2 + 1}: tell me more about long threads.`
|
|
264
|
+
: SNIPPETS.slice(0, (i % 3) + 1).join("\n\n"),
|
|
265
|
+
}));
|
|
266
|
+
|
|
267
|
+
export const REPLY_CHUNKS =
|
|
268
|
+
"Sure! This reply is streamed chunk by chunk so you can watch the thread follow the newest tokens. Scroll up at any point and the view stops following until you return to the bottom or press the scroll to bottom button. The reply is intentionally long enough to grow past a single viewport so the auto-follow behavior is easy to observe while it streams in."
|
|
269
|
+
.split(" ")
|
|
270
|
+
.map((word) => `${word} `);
|
|
271
|
+
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## app/VirtualizedThread.tsx
|
|
275
|
+
|
|
276
|
+
```tsx
|
|
277
|
+
"use client";
|
|
278
|
+
|
|
279
|
+
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
|
280
|
+
import {
|
|
281
|
+
ComposerPrimitive,
|
|
282
|
+
MessagePrimitive,
|
|
283
|
+
ThreadPrimitive,
|
|
284
|
+
useAuiState,
|
|
285
|
+
} from "@assistant-ui/react";
|
|
286
|
+
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
287
|
+
import { ArrowDownIcon, SendHorizontalIcon } from "lucide-react";
|
|
288
|
+
import {
|
|
289
|
+
type ComponentProps,
|
|
290
|
+
type FC,
|
|
291
|
+
useCallback,
|
|
292
|
+
useEffect,
|
|
293
|
+
useLayoutEffect,
|
|
294
|
+
useMemo,
|
|
295
|
+
useRef,
|
|
296
|
+
useState,
|
|
297
|
+
} from "react";
|
|
298
|
+
|
|
299
|
+
const ESTIMATED_TURN_HEIGHT = 200;
|
|
300
|
+
const AT_BOTTOM_THRESHOLD = 4;
|
|
301
|
+
|
|
302
|
+
type MessageComponents = ComponentProps<
|
|
303
|
+
typeof ThreadPrimitive.MessageByIndex
|
|
304
|
+
>["components"];
|
|
305
|
+
|
|
306
|
+
type Turn = { id: string; indices: number[] };
|
|
307
|
+
|
|
308
|
+
const buildTurns = (signature: string): Turn[] => {
|
|
309
|
+
if (!signature) return [];
|
|
310
|
+
const turns: Turn[] = [];
|
|
311
|
+
for (const row of signature.split("\n")) {
|
|
312
|
+
const firstColon = row.indexOf(":");
|
|
313
|
+
const secondColon = row.indexOf(":", firstColon + 1);
|
|
314
|
+
const index = Number(row.slice(0, firstColon));
|
|
315
|
+
const role = row.slice(firstColon + 1, secondColon);
|
|
316
|
+
const id = row.slice(secondColon + 1);
|
|
317
|
+
const last = turns.at(-1);
|
|
318
|
+
if (role === "user" || !last) turns.push({ id, indices: [index] });
|
|
319
|
+
else last.indices.push(index);
|
|
320
|
+
}
|
|
321
|
+
return turns;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const UserMessage: FC = () => (
|
|
325
|
+
<MessagePrimitive.Root
|
|
326
|
+
data-role="user"
|
|
327
|
+
className="bg-muted ml-auto w-fit max-w-[80%] rounded-xl px-4 py-2"
|
|
328
|
+
>
|
|
329
|
+
<MessagePrimitive.Parts />
|
|
330
|
+
</MessagePrimitive.Root>
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const AssistantMessage: FC = () => (
|
|
334
|
+
<MessagePrimitive.Root
|
|
335
|
+
data-role="assistant"
|
|
336
|
+
className="text-foreground leading-relaxed"
|
|
337
|
+
>
|
|
338
|
+
<MessagePrimitive.Parts components={{ Text: MarkdownText }} />
|
|
339
|
+
</MessagePrimitive.Root>
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const MESSAGE_COMPONENTS: MessageComponents = { UserMessage, AssistantMessage };
|
|
343
|
+
|
|
344
|
+
const Composer: FC = () => (
|
|
345
|
+
<ComposerPrimitive.Root className="border-border bg-background focus-within:ring-ring flex items-end rounded-xl border shadow-sm focus-within:ring-1">
|
|
346
|
+
<ComposerPrimitive.Input
|
|
347
|
+
placeholder="Send a message to watch the thread follow the stream"
|
|
348
|
+
className="placeholder:text-muted-foreground max-h-40 flex-1 resize-none bg-transparent px-4 py-3 text-sm outline-none"
|
|
349
|
+
rows={1}
|
|
350
|
+
autoFocus
|
|
351
|
+
/>
|
|
352
|
+
<ComposerPrimitive.Send
|
|
353
|
+
aria-label="Send"
|
|
354
|
+
className="text-muted-foreground hover:text-foreground m-2 rounded-lg p-2 transition-colors disabled:opacity-40"
|
|
355
|
+
>
|
|
356
|
+
<SendHorizontalIcon className="size-4" />
|
|
357
|
+
</ComposerPrimitive.Send>
|
|
358
|
+
</ComposerPrimitive.Root>
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
export const VirtualizedThread: FC = () => {
|
|
362
|
+
const signature = useAuiState((s) =>
|
|
363
|
+
s.thread.messages.map((m, i) => `${i}:${m.role}:${m.id}`).join("\n"),
|
|
364
|
+
);
|
|
365
|
+
const isRunning = useAuiState((s) => s.thread.isRunning);
|
|
366
|
+
const turns = useMemo(() => buildTurns(signature), [signature]);
|
|
367
|
+
|
|
368
|
+
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
369
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
370
|
+
const stickyRef = useRef(true);
|
|
371
|
+
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
372
|
+
|
|
373
|
+
const virtualizer = useVirtualizer({
|
|
374
|
+
count: turns.length,
|
|
375
|
+
estimateSize: () => ESTIMATED_TURN_HEIGHT,
|
|
376
|
+
getItemKey: (index) => turns[index]!.id,
|
|
377
|
+
getScrollElement: () => scrollerRef.current,
|
|
378
|
+
initialRect: { height: 800, width: 800 },
|
|
379
|
+
overscan: 4,
|
|
380
|
+
scrollToFn: (offset, _options, instance) => {
|
|
381
|
+
const el = instance.scrollElement;
|
|
382
|
+
if (!el) return;
|
|
383
|
+
if (stickyRef.current) {
|
|
384
|
+
const maxScroll = el.scrollHeight - el.clientHeight;
|
|
385
|
+
if (
|
|
386
|
+
maxScroll - el.scrollTop <= AT_BOTTOM_THRESHOLD &&
|
|
387
|
+
offset < maxScroll
|
|
388
|
+
)
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
el.scrollTo(0, offset);
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const jumpToBottom = useCallback(() => {
|
|
396
|
+
stickyRef.current = true;
|
|
397
|
+
if (turns.length > 0)
|
|
398
|
+
virtualizer.scrollToIndex(turns.length - 1, { align: "end" });
|
|
399
|
+
requestAnimationFrame(() => {
|
|
400
|
+
const el = scrollerRef.current;
|
|
401
|
+
if (el && stickyRef.current) el.scrollTop = el.scrollHeight;
|
|
402
|
+
});
|
|
403
|
+
}, [turns.length, virtualizer]);
|
|
404
|
+
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
const el = scrollerRef.current;
|
|
407
|
+
if (!el) return;
|
|
408
|
+
let lastScrollTop = el.scrollTop;
|
|
409
|
+
let lastScrollHeight = el.scrollHeight;
|
|
410
|
+
let lastClientHeight = el.clientHeight;
|
|
411
|
+
const onScroll = () => {
|
|
412
|
+
const atBottom =
|
|
413
|
+
el.scrollHeight - el.scrollTop - el.clientHeight <= AT_BOTTOM_THRESHOLD;
|
|
414
|
+
if (atBottom) {
|
|
415
|
+
stickyRef.current = true;
|
|
416
|
+
} else if (
|
|
417
|
+
el.scrollTop < lastScrollTop &&
|
|
418
|
+
el.scrollHeight === lastScrollHeight &&
|
|
419
|
+
Math.abs(el.clientHeight - lastClientHeight) <= 1
|
|
420
|
+
) {
|
|
421
|
+
stickyRef.current = false;
|
|
422
|
+
}
|
|
423
|
+
lastScrollTop = el.scrollTop;
|
|
424
|
+
lastScrollHeight = el.scrollHeight;
|
|
425
|
+
lastClientHeight = el.clientHeight;
|
|
426
|
+
setIsAtBottom(atBottom);
|
|
427
|
+
};
|
|
428
|
+
const onWheel = (e: WheelEvent) => {
|
|
429
|
+
if (e.deltaY < 0) stickyRef.current = false;
|
|
430
|
+
};
|
|
431
|
+
const disarm = () => {
|
|
432
|
+
stickyRef.current = false;
|
|
433
|
+
};
|
|
434
|
+
el.addEventListener("scroll", onScroll, { passive: true });
|
|
435
|
+
el.addEventListener("wheel", onWheel, { passive: true });
|
|
436
|
+
el.addEventListener("touchmove", disarm, { passive: true });
|
|
437
|
+
return () => {
|
|
438
|
+
el.removeEventListener("scroll", onScroll);
|
|
439
|
+
el.removeEventListener("wheel", onWheel);
|
|
440
|
+
el.removeEventListener("touchmove", disarm);
|
|
441
|
+
};
|
|
442
|
+
}, []);
|
|
443
|
+
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
const el = scrollerRef.current;
|
|
446
|
+
const content = contentRef.current;
|
|
447
|
+
if (!el || !content) return;
|
|
448
|
+
const observer = new ResizeObserver(() => {
|
|
449
|
+
if (stickyRef.current) el.scrollTop = el.scrollHeight;
|
|
450
|
+
});
|
|
451
|
+
observer.observe(content);
|
|
452
|
+
return () => observer.disconnect();
|
|
453
|
+
}, []);
|
|
454
|
+
|
|
455
|
+
const prevIsRunningRef = useRef(false);
|
|
456
|
+
useLayoutEffect(() => {
|
|
457
|
+
if (isRunning && !prevIsRunningRef.current) jumpToBottom();
|
|
458
|
+
prevIsRunningRef.current = isRunning;
|
|
459
|
+
}, [isRunning, jumpToBottom]);
|
|
460
|
+
|
|
461
|
+
const didInitialJumpRef = useRef(false);
|
|
462
|
+
useLayoutEffect(() => {
|
|
463
|
+
if (didInitialJumpRef.current || turns.length === 0) return;
|
|
464
|
+
didInitialJumpRef.current = true;
|
|
465
|
+
jumpToBottom();
|
|
466
|
+
}, [turns.length, jumpToBottom]);
|
|
467
|
+
|
|
468
|
+
const items = virtualizer.getVirtualItems();
|
|
469
|
+
const paddingTop = items[0]?.start ?? 0;
|
|
470
|
+
const paddingBottom = Math.max(
|
|
471
|
+
0,
|
|
472
|
+
virtualizer.getTotalSize() - (items.at(-1)?.end ?? 0),
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
return (
|
|
476
|
+
<ThreadPrimitive.Root className="bg-background flex h-full flex-col">
|
|
477
|
+
<div
|
|
478
|
+
ref={scrollerRef}
|
|
479
|
+
className="flex-1 overflow-y-auto overscroll-contain"
|
|
480
|
+
>
|
|
481
|
+
<div ref={contentRef} className="mx-auto w-full max-w-3xl px-4 pt-4">
|
|
482
|
+
<div style={{ paddingTop, paddingBottom }}>
|
|
483
|
+
{items.map((item) => (
|
|
484
|
+
<div
|
|
485
|
+
key={item.key}
|
|
486
|
+
data-index={item.index}
|
|
487
|
+
ref={virtualizer.measureElement}
|
|
488
|
+
className="flex flex-col gap-4 py-3"
|
|
489
|
+
>
|
|
490
|
+
{turns[item.index]!.indices.map((index) => (
|
|
491
|
+
<ThreadPrimitive.MessageByIndex
|
|
492
|
+
key={index}
|
|
493
|
+
index={index}
|
|
494
|
+
components={MESSAGE_COMPONENTS}
|
|
495
|
+
/>
|
|
496
|
+
))}
|
|
497
|
+
</div>
|
|
498
|
+
))}
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
<div className="relative mx-auto w-full max-w-3xl px-4 pb-4">
|
|
503
|
+
{!isAtBottom && (
|
|
504
|
+
<button
|
|
505
|
+
type="button"
|
|
506
|
+
onClick={jumpToBottom}
|
|
507
|
+
aria-label="Scroll to bottom"
|
|
508
|
+
className="border-border bg-background hover:bg-muted absolute -top-12 left-1/2 -translate-x-1/2 rounded-full border p-2 shadow-sm transition-colors"
|
|
509
|
+
>
|
|
510
|
+
<ArrowDownIcon className="size-4" />
|
|
511
|
+
</button>
|
|
512
|
+
)}
|
|
513
|
+
<Composer />
|
|
514
|
+
</div>
|
|
515
|
+
</ThreadPrimitive.Root>
|
|
516
|
+
);
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
## components.json
|
|
522
|
+
|
|
523
|
+
```json
|
|
524
|
+
{
|
|
525
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
526
|
+
"style": "new-york",
|
|
527
|
+
"rsc": true,
|
|
528
|
+
"tsx": true,
|
|
529
|
+
"tailwind": {
|
|
530
|
+
"config": "",
|
|
531
|
+
"css": "app/globals.css",
|
|
532
|
+
"baseColor": "zinc",
|
|
533
|
+
"cssVariables": true,
|
|
534
|
+
"prefix": ""
|
|
535
|
+
},
|
|
536
|
+
"aliases": {
|
|
537
|
+
"components": "@/components",
|
|
538
|
+
"utils": "@/lib/utils",
|
|
539
|
+
"ui": "@/components/ui",
|
|
540
|
+
"lib": "@/lib",
|
|
541
|
+
"hooks": "@/hooks"
|
|
542
|
+
},
|
|
543
|
+
"iconLibrary": "lucide",
|
|
544
|
+
"registries": {
|
|
545
|
+
"@assistant-ui": "https://r.assistant-ui.com/{name}.json"
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
## next.config.ts
|
|
552
|
+
|
|
553
|
+
```typescript
|
|
554
|
+
import { withAui } from "@assistant-ui/next";
|
|
555
|
+
import type { NextConfig } from "next";
|
|
556
|
+
|
|
557
|
+
const nextConfig: NextConfig = {
|
|
558
|
+
/* config options here */
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
export default withAui(nextConfig);
|
|
562
|
+
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
## package.json
|
|
566
|
+
|
|
567
|
+
```json
|
|
568
|
+
{
|
|
569
|
+
"name": "with-virtualized-thread",
|
|
570
|
+
"version": "0.0.0",
|
|
571
|
+
"private": true,
|
|
572
|
+
"type": "module",
|
|
573
|
+
"scripts": {
|
|
574
|
+
"dev": "next dev",
|
|
575
|
+
"build": "next build",
|
|
576
|
+
"start": "next start"
|
|
577
|
+
},
|
|
578
|
+
"dependencies": {
|
|
579
|
+
"@assistant-ui/react": "workspace:*",
|
|
580
|
+
"@assistant-ui/react-markdown": "workspace:*",
|
|
581
|
+
"@assistant-ui/ui": "workspace:*",
|
|
582
|
+
"@tanstack/react-virtual": "^3.14.2",
|
|
583
|
+
"class-variance-authority": "^0.7.1",
|
|
584
|
+
"clsx": "^2.1.1",
|
|
585
|
+
"lucide-react": "^1.18.0",
|
|
586
|
+
"next": "^16.2.9",
|
|
587
|
+
"react": "^19.2.7",
|
|
588
|
+
"react-dom": "^19.2.7",
|
|
589
|
+
"tailwind-merge": "^3.6.0"
|
|
590
|
+
},
|
|
591
|
+
"devDependencies": {
|
|
592
|
+
"@assistant-ui/next": "workspace:*",
|
|
593
|
+
"@assistant-ui/x-buildutils": "workspace:*",
|
|
594
|
+
"@tailwindcss/postcss": "^4.3.1",
|
|
595
|
+
"@types/node": "^25.9.3",
|
|
596
|
+
"@types/react": "^19.2.17",
|
|
597
|
+
"@types/react-dom": "^19.2.3",
|
|
598
|
+
"postcss": "^8.5.15",
|
|
599
|
+
"tailwindcss": "^4.3.1",
|
|
600
|
+
"tw-animate-css": "^1.4.0",
|
|
601
|
+
"typescript": "^6.0.3"
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
## README.md
|
|
608
|
+
|
|
609
|
+
```markdown
|
|
610
|
+
# Virtualized thread example
|
|
611
|
+
|
|
612
|
+
Renders a 500-message thread with `@tanstack/react-virtual` so only the turns near the viewport are mounted. The runtime is a self-contained external store with a fake streaming reply, so the example runs fully offline with no environment variables.
|
|
613
|
+
|
|
614
|
+
## Quick start
|
|
615
|
+
|
|
616
|
+
```sh
|
|
617
|
+
pnpm install
|
|
618
|
+
pnpm --filter with-virtualized-thread dev
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
## What the example demonstrates
|
|
622
|
+
|
|
623
|
+
| Concern | File |
|
|
624
|
+
| --- | --- |
|
|
625
|
+
| Virtualizer over user turns, padding spacers, `measureElement` | `app/VirtualizedThread.tsx` |
|
|
626
|
+
| Per-message rendering through `ThreadPrimitive.MessageByIndex` | `app/VirtualizedThread.tsx` |
|
|
627
|
+
| Sticky-bottom auto-follow with a user-scroll disarm guard | `app/VirtualizedThread.tsx` |
|
|
628
|
+
| External store runtime with seeded messages and a streaming tail | `app/MyRuntimeProvider.tsx` |
|
|
629
|
+
| Deterministic synthetic thread content | `app/seed-messages.ts` |
|
|
630
|
+
|
|
631
|
+
See the [thread virtualization guide](https://assistant-ui.com/docs/guides/virtualization) for the full walkthrough.
|
|
632
|
+
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
## tsconfig.json
|
|
636
|
+
|
|
637
|
+
```json
|
|
638
|
+
{
|
|
639
|
+
"extends": "@assistant-ui/x-buildutils/ts/next",
|
|
640
|
+
"compilerOptions": {
|
|
641
|
+
"paths": {
|
|
642
|
+
"@/*": ["./*"],
|
|
643
|
+
"@/components/assistant-ui/*": [
|
|
644
|
+
"../../packages/ui/src/components/assistant-ui/*"
|
|
645
|
+
],
|
|
646
|
+
"@/components/ui/*": ["../../packages/ui/src/components/ui/*"],
|
|
647
|
+
"@/lib/utils": ["../../packages/ui/src/lib/utils"],
|
|
648
|
+
"@assistant-ui/ui/*": ["../../packages/ui/src/*"]
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
"include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
652
|
+
"exclude": ["node_modules"]
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
```
|
|
656
|
+
|