From 47d6d897ed0e60e0eeb8e47f0ec872ef1c6440ff Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:46:29 +1000 Subject: [PATCH 01/18] Reframe TanStack AI messaging around who owns what Restructure the AI landing page to walk through the ownership split before the technical details: what TanStack AI handles (agent loop, providers, durability, interrupts, sandboxes, tools) and what you own (server, persistence, UI, deploy), each with real code from the docs. Replace the tagline, description, and feature highlights with the same framing. Honor the claim boundaries from the homepage plan: no lock-in language, no counts in copy, no decorative numbering, and a closing set of starting-point doc links. Claude-Session: https://claude.ai/code/session_01YFZ1i6q5qEeQL63B7zQXBX --- src/components/landing/AiLanding.tsx | 738 ++++++++++++++++++++------- src/libraries/ai.tsx | 36 +- src/libraries/libraries.ts | 4 +- 3 files changed, 563 insertions(+), 215 deletions(-) diff --git a/src/components/landing/AiLanding.tsx b/src/components/landing/AiLanding.tsx index ea08d0d5b..833871953 100644 --- a/src/components/landing/AiLanding.tsx +++ b/src/components/landing/AiLanding.tsx @@ -11,16 +11,19 @@ import { CubeIcon, DatabaseIcon, HardDrivesIcon, + LayoutIcon, MicrophoneIcon, PlugIcon, RadioIcon, RobotIcon, + ScalesIcon, TerminalIcon, WaveformIcon, type Icon, } from '@phosphor-icons/react' import { getLibrary } from '~/libraries' +import { CodeBlock } from '~/components/markdown/CodeBlock' import { LandingSection, LandingSectionIntro, @@ -185,8 +188,8 @@ export default function AiLanding() { return ( } prompt={aiPrompt} promptLabel="Copy AI prompt" @@ -194,33 +197,68 @@ export default function AiLanding() { + + +
+
+
+ + + + + +
+
+
+ + +
+ +
@@ -230,21 +268,16 @@ export default function AiLanding() {
@@ -291,183 +324,469 @@ export default function AiLanding() {
+ + +
) } -function CodeLine({ - children, - indent = 0, +const codeWindowClass = + 'm-0 min-w-0 rounded-none border-0 [&>div:first-child]:rounded-none [&_pre]:max-h-[26rem] [&_pre]:overflow-auto [&_pre]:rounded-none [&_pre]:text-[11px] [&_pre]:leading-5 sm:[&_pre]:text-xs' + +function CodeTabs({ + label, + samples, }: { - children?: React.ReactNode - indent?: number + label: string + samples: Array<{ code: string; file: string; name: string }> }) { - return

{children || ' '}

-} - -function Kw({ children }: { children: React.ReactNode }) { - return {children} -} + const [activeIndex, setActiveIndex] = React.useState(0) + const sample = samples[activeIndex] ?? samples[0] -// ponytail: the code surface is always dark, so these use fixed token colors. -// --landing-accent-bright resolves to a dark terracotta in light mode and is -// unreadable here. -function Fn({ children }: { children: React.ReactNode }) { - return {children} + return ( + +
+ {samples.map((item, index) => ( + + ))} +
+ + {sample.code} + +
+ ) } -function Str({ children }: { children: React.ReactNode }) { - return {children} +const ownership = { + handled: [ + { + label: 'Agent loop', + body: 'Tool calls, stop conditions, and every model round-trip. Easy to start, wrong in a hundred small ways.', + }, + { + label: 'Providers', + body: 'Every major provider behind one call, each model typed down to its options and modalities.', + }, + { + label: 'Durability', + body: 'A dropped socket, a reload, or a restart replays from a log. The model is never re-run.', + }, + { + label: 'Interrupts', + body: 'A run pauses for a human, then resumes at the exact step with their edits applied.', + }, + { + label: 'Sandboxes', + body: 'Coding agents and Code Mode run in an isolate or a container. Their activity is ordinary events.', + }, + { + label: 'Tools', + body: 'One schema, typed on both ends, executed on the server or the client.', + }, + ], + owned: [ + { + label: 'Server', + body: 'Any route, any runtime. Your auth check sits next to the call, not behind a config flag.', + }, + { + label: 'Persistence', + body: 'Your database and your schema. Two store functions are the whole contract.', + }, + { + label: 'UI', + body: 'Typed messages, parts, and states. You render them.', + }, + { + label: 'Deploy', + body: 'Your requests, credentials, and data never pass through TanStack.', + }, + ], } -function Cmt({ children }: { children: React.ReactNode }) { - return {children} +function OwnershipMap() { + return ( +
+ + +
+ ) } -function CodeSurface({ children }: { children: React.ReactNode }) { +function OwnershipColumn({ + eyebrow, + items, + note, +}: { + eyebrow: string + items: Array<{ body: string; label: string }> + note: string +}) { return ( -
- {children} +
+

+ {eyebrow} +

+ +

+ {note} +

) } -function QuickStart() { +const serverRoutes = [ + { + name: 'TanStack Start', + file: 'routes/api.chat.ts', + code: `import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openRouterText } from '@tanstack/ai-openrouter' +import { createFileRoute } from '@tanstack/react-router' +import { lookupInvoice } from './tools' + +export const Route = createFileRoute('/api/chat')({ + server: { + handlers: { + POST: async ({ request }) => { + const { messages } = await request.json() + + const stream = chat({ + adapter: openRouterText('anthropic/claude-sonnet-4.5'), + messages, + tools: [lookupInvoice], + }) + + return toServerSentEventsResponse(stream) + }, + }, + }, +})`, + }, + { + name: 'Next.js', + file: 'app/api/chat/route.ts', + code: `import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openRouterText } from '@tanstack/ai-openrouter' +import { lookupInvoice } from './tools' + +export async function POST(request: Request) { + const { messages } = await request.json() + + const stream = chat({ + adapter: openRouterText('anthropic/claude-sonnet-4.5'), + messages, + tools: [lookupInvoice], + }) + + return toServerSentEventsResponse(stream) +}`, + }, + { + name: 'Hono', + file: 'server.ts', + code: `import { Hono } from 'hono' +import { chat, toServerSentEventsResponse } from '@tanstack/ai' +import { openRouterText } from '@tanstack/ai-openrouter' +import { lookupInvoice } from './tools' + +const app = new Hono() + +app.post('/api/chat', async (c) => { + const { messages } = await c.req.json() + + const stream = chat({ + adapter: openRouterText('anthropic/claude-sonnet-4.5'), + messages, + tools: [lookupInvoice], + }) + + return toServerSentEventsResponse(stream) +})`, + }, +] + +function ServerRoutes() { + return +} + +const persistenceContract = `import { defineAIPersistence, defineMessageStore } from '@tanstack/ai-persistence' +import { db } from './db' + +// The whole contract. Your tables, your columns, your types. +export const persistence = defineAIPersistence({ + stores: { + messages: defineMessageStore({ + loadThread: (threadId) => db.threads.messages(threadId), + saveThread: (threadId, messages) => db.threads.save(threadId, messages), + }), + }, +}) + +// chat({ ..., middleware: [withPersistence(persistence)] })` + +const persistenceStores = [ + 'Postgres', + 'MySQL', + 'SQLite', + 'MongoDB', + 'Cloudflare D1', + 'Redis', + 'Drizzle', + 'Prisma', + 'localStorage', + 'IndexedDB', +] + +function PersistenceContract() { return ( -
- +
    - - - import {'{ chat, toServerSentEventsResponse }'}{' '} - from '@tanstack/ai' - - - import {'{ openRouterText }'} from{' '} - '@tanstack/ai-openrouter' - - - import {'{ createFileRoute }'} from{' '} - '@tanstack/react-router' - - - - export const Route = createFileRoute( - '/api/chat')({'{'} - - server: {'{'} - handlers: {'{'} - - POST: async ({'{ request }'}) => {'{'} - - - const {'{ messages }'} = await request. - json() - - - - const stream = chat({'{'} - - - adapter: openRouterText( - 'anthropic/claude-sonnet-4.5'), - - messages, - tools: [lookupInvoice], - {'})'} - - - // your route, your auth, your deploy target - - - return toServerSentEventsResponse(stream) - - {'},'} - {'},'} - {'},'} - {'})'} - - - - - - - import {'{ useChat, fetchServerSentEvents }'} from{' '} - '@tanstack/ai-react' - - - - export function Chat() {'{'} - - - const {'{ messages, sendMessage, interrupts }'} ={' '} - useChat({'{'} - - - connection: fetchServerSentEvents('/api/chat'), - - {'})'} - - - // typed state and events. no components, no styles. - - - return ( - - <> - - {'{'}messages.map((message) => ( - - - <Bubble key={'{'}message.id{'}'} {'{'}...message{'}'}{' '} - /> - - )){'}'} - - - - {'{/* the loop paused. you decide when it continues. */}'} - - - - {'{'}interrupts.map((interrupt) => ( - - - <button key={'{'}interrupt.id{'}'} - - - onClick={'{'}() => interrupt.resolveInterrupt( - true){'}'}> - - - Approve {'{'}interrupt.toolName{'}'} - - - </button> - - )){'}'} - </> - ) - {'}'} - + {persistenceStores.map((store) => ( +
  • + {store} +
  • + ))} +
+ + + {persistenceContract} + - -

- Swap React for any other framework and keep your server-side code - identical. +

+ Add a runs store to rejoin a run after a reload and an interrupts store + to hold an approval for days. Start with memoryPersistence() on the + server or localStoragePersistence() in the browser, and swap it out + without touching the route.

) } +const durabilityTiers = [ + { + name: 'In memory', + file: 'routes/api.chat.ts', + code: `import { memoryStream, toServerSentEventsResponse } from '@tanstack/ai' + +// Development and single-process apps. Zero setup. +export async function POST(request: Request) { + const stream = chat({ /* ... */ }) + + return toServerSentEventsResponse(stream, { + durability: { adapter: memoryStream(request) }, + }) +}`, + }, + { + name: 'Hosted log', + file: 'routes/api.chat.ts', + code: `import { toServerSentEventsResponse } from '@tanstack/ai' +import { durableStream } from '@tanstack/ai-durable-stream' + +// Many processes, many regions. The route does not change. +export async function POST(request: Request) { + const stream = chat({ /* ... */ }) + + return toServerSentEventsResponse(stream, { + durability: { + adapter: durableStream(request, { server: process.env.DURABLE_STREAMS_URL }), + }, + }) +}`, + }, + { + name: 'Your store', + file: 'redis-stream.ts', + code: `import type { StreamDurability } from '@tanstack/ai' + +// Five methods against anything: Redis, Postgres, a queue. +// Offsets are opaque strings. Core never reads your store. +export function redisStream(request: Request): StreamDurability { + const key = runKey(request) + + return { + resumeFrom: () => resumeOffset(request), + append: (chunks) => appendAll(key, chunks), + read: (offset, signal) => readAfter(key, offset, signal), + snapshot: () => readAll(key), + close: () => markDone(key), + } +}`, + }, +] + +function DurabilityTiers() { + return +} + +const toolCallStates = [ + 'awaiting-input', + 'input-streaming', + 'input-complete', + 'approval-requested', + 'approval-responded', + 'complete', +] as const + +function MessageParts() { + const [stateIndex, setStateIndex] = React.useState(toolCallStates.length - 1) + + React.useEffect(() => { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + return + } + + const intervalId = window.setInterval(() => { + setStateIndex((current) => (current + 1) % toolCallStates.length) + }, 1400) + + return () => window.clearInterval(intervalId) + }, []) + + const toolState = toolCallStates[stateIndex] ?? 'complete' + const parts = [ + { + type: 'thinking', + detail: 'Checking the invoice before answering.', + state: 'complete', + }, + { + type: 'tool-call', + detail: 'lookup_invoice({ id: "inv_2231" })', + state: toolState, + }, + // The result and the reply only exist once the call is complete. + ...(toolState === 'complete' + ? [ + { + type: 'tool-result', + detail: '{ total: 1240, status: "paid" }', + state: 'complete', + }, + { + type: 'text', + detail: 'Invoice 2231 was paid in full on', + state: 'streaming', + }, + ] + : []), + ] + + return ( + +
    + {parts.map((part) => ( +
  • + + {part.type} + + + {part.detail} + {part.state === 'streaming' ? ( + + ) : null} + + + {part.state} + +
  • + ))} +
+
+

+ tool-call lifecycle +

+
    + {toolCallStates.map((state, index) => ( +
  1. + {state} +
  2. + ))} +
  3. + error +
  4. +
+
+
+ ) +} + function AiGraphChatHero() { const [activeClient, setActiveClient] = React.useState(0) const [activeServer, setActiveServer] = React.useState(0) @@ -1365,13 +1684,13 @@ function DevtoolsPanel() { function FeatureRail({ items }: { items: Array }) { return (
- {items.map((item, index) => { + {items.map((item) => { const Icon = item.icon return (
- - 0{index + 1} -
) })}
) } + +const startingPoints = [ + { label: 'Build streaming chat', to: 'getting-started/quick-start' }, + { + label: 'Start from a server route', + to: 'getting-started/quick-start-server', + }, + { label: 'Add persistence', to: 'persistence/overview' }, + { label: 'Compare with Vercel AI SDK', to: 'comparison/vercel-ai-sdk' }, +] + +function StartingPoints() { + const { version } = useParams({ strict: false }) + const library = getLibrary('ai') + + return ( + + ) +} diff --git a/src/libraries/ai.tsx b/src/libraries/ai.tsx index f6da8795c..f14a3ccbb 100644 --- a/src/libraries/ai.tsx +++ b/src/libraries/ai.tsx @@ -7,47 +7,43 @@ const textStyles = `text-category-data` export const aiProject = { ...ai, - description: `TanStack AI is a pluggable AI ecosystem that makes it easy for you to build AI features into your apps. Provide tools to LLMs, interrupt chat for user approval, run agents in sandboxes, build headless chat UI, stream from your server to your client, and connect to any AG-UI compatible server or client. Bring your own infrastructure. We offer the pluggable APIs to build on top of.`, + description: `TanStack AI gives you composable building blocks for everything you should not write yourself: the agent loop, provider adapters, durability, interrupts, sandboxes, and tools. It leaves you everything a one-size-fits-all framework gets wrong past the prototype: your server, your database, your UI. Typed end to end, AG-UI native, and no TanStack service in the request path.`, latestBranch: 'main', defaultDocs: 'getting-started/overview', featureHighlights: [ { - title: 'A Real Agent Loop', + title: "We Build What You Shouldn't", icon: , description: (
- chat() drives the loop and you control every part of it: - isomorphic tools you place on the client or the server, composable{' '} - {`(state) => boolean`} stop strategies, and interrupts - that pause a run for human approval and resume exactly where it - stopped, with no database required. + chat() drives the agent loop: typed tools that run on the + server or the client, stop strategies, interrupts that pause a run for + a human and resume at the exact step, and a durability log that + survives a dropped socket or a reload without re-running the model.
), }, { - title: 'Bring Your Own Everything', + title: 'You Own What Outgrows a Framework', icon: , description: (
- Your provider, server, transport, auth, and deploy target. Official - adapters for OpenRouter, OpenAI, Anthropic, Gemini, Vertex, Bedrock, - Mistral, Groq, Grok, Ollama, Cohere, Perplexity, BytePlus, ElevenLabs, - fal.ai, Lovable, LLM Gateway, and Vercel AI Gateway, plus{' '} - openaiCompatible for anything else. Import only what you - use: every activity is a separate, tree-shakeable module. + Your route, your database, your UI. Persistence is two store functions + against your own schema. The server is one call and a Response in any + framework. Your requests, credentials, and data never pass through + TanStack.
), }, { - title: 'Headless, Not Opinionated', + title: 'One Mental Model, Typed End to End', icon: , description: (
- A framework-free core with React, Vue, Solid, Svelte, Preact, Angular, - and React Native bindings on top, plus official Octane bindings from - the Octane team. All of them speak native AG-UI over SSE, HTTP - streams, XHR, RPC, or your own transport. No components to fight, no - styles to override. + Every major provider behind one call, each model typed down to its + options and modalities. A framework-free core with React, Vue, Solid, + Svelte, Preact, Angular, React Native, and Octane bindings, all + speaking native AG-UI over the transport you choose.
), }, diff --git a/src/libraries/libraries.ts b/src/libraries/libraries.ts index 3b0f50475..3510d3430 100644 --- a/src/libraries/libraries.ts +++ b/src/libraries/libraries.ts @@ -691,9 +691,9 @@ export const ai: LibrarySlim = { ...categoryStyles.data, name: 'TanStack AI', to: '/ai/latest', - tagline: 'The headless agent framework for TypeScript. Bring your own stack', + tagline: 'Composable AI building blocks. Your server, database, and UI', description: - 'TanStack AI is a pluggable AI ecosystem that makes it easy for you to build AI features into your apps. Provide tools to LLMs, interrupt chat for user approval, run agents in sandboxes, build headless chat UI, stream from your server to your client, and connect to any AG-UI compatible server or client. Bring your own infrastructure. We offer the pluggable APIs to build on top of.', + 'TanStack AI gives you composable building blocks for everything you should not write yourself: the agent loop, provider adapters, durability, interrupts, sandboxes, and tools. It leaves you everything a one-size-fits-all framework gets wrong past the prototype: your server, your database, your UI. Typed end to end, AG-UI native, and no TanStack service in the request path.', badge: 'RC', repo: 'tanstack/ai', frameworks: [ From 79ae06fd114c172955b1790950eb912fcc8f604b Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:10:59 +1000 Subject: [PATCH 02/18] Replace AI hero graphic with a wireframe placeholder Drop the client graph and chat animation. The headline now answers what it is and why in one line. Claude-Session: https://claude.ai/code/session_01YFZ1i6q5qEeQL63B7zQXBX --- src/components/landing/AiLanding.tsx | 603 ++------------------------- 1 file changed, 30 insertions(+), 573 deletions(-) diff --git a/src/components/landing/AiLanding.tsx b/src/components/landing/AiLanding.tsx index 833871953..9f1b818e9 100644 --- a/src/components/landing/AiLanding.tsx +++ b/src/components/landing/AiLanding.tsx @@ -66,93 +66,6 @@ const providers = [ }, ] -type AiHeroServer = { - detail?: string - dotted?: boolean - kind?: 'tanstack' - label: string -} - -type GraphNodePosition = { - height: number - label: string - width: number - x: number - y: number -} - -type GraphPoint = { - x: number - y: number -} - -const aiHeroClients = [ - 'Vanilla', - 'React', - 'Vue', - 'Solid', - 'Svelte', - 'Preact', - 'Angular', - 'Octane', -] -const aiHeroServers: Array = [ - { label: 'TanStack AI', detail: 'Server', kind: 'tanstack' }, - { label: 'Python', dotted: true }, - { label: 'Go', dotted: true }, - { label: 'PHP', dotted: true }, -] -const aiHeroProviders = ['OpenRouter', 'OpenAI', 'Anthropic', 'Gemini'] -// ponytail: 8 clients on a fixed 4x2 grid; recompute the columns if the list changes length -const graphClientNodes = aiHeroClients.map((label, index) => ({ - label, - x: [10, 112, 214, 316][index % 4] ?? 112, - y: index < 4 ? 36 : 84, - width: 94, - height: 36, -})) -const graphAgUiNode: GraphNodePosition & { - kind: 'tanstack' -} = { - label: 'TanStack AI Client', - kind: 'tanstack', - x: 142, - y: 138, - width: 136, - height: 58, -} -const graphServerNodes = aiHeroServers.map((server, index) => ({ - ...server, - x: [38, 178, 254, 326][index] ?? 178, - y: index === 0 ? 254 : 260, - width: index === 0 ? 124 : 56, - height: index === 0 ? 54 : 42, -})) -const graphProviderNodes = aiHeroProviders.map((label, index) => ({ - label, - x: 18 + index * 98, - y: 352, - width: 78, - height: 34, -})) -const aiHeroMessages = [ - { - user: 'Build the invoice agent on our stack, not yours.', - assistant: - 'Done. Headless client in your app, the agent loop on your server, AG-UI between them. No gateway, no hosted state.', - }, - { - user: 'It should ask before it charges a card.', - assistant: - 'chargeCard is marked needsApproval, so the run ends as an interrupt. Resolve it and the loop continues from that exact step.', - }, - { - user: 'And if we move off this provider?', - assistant: - 'Swap the adapter. Your tools, events, and UI never learn the difference.', - }, -] - // ponytail: the shared --landing-accent-ink is pure black, which reads badly on the // orange accent fill. Darken the fill instead and use white text on it. const accentFillClass = @@ -177,20 +90,13 @@ function AdapterDocsLink() { ) } -type AiHeroChatMessage = { - assistant: string - id: string - isStreaming: boolean - user: string -} - export default function AiLanding() { return ( } + hero={} prompt={aiPrompt} promptLabel="Copy AI prompt" > @@ -787,483 +693,6 @@ function MessageParts() { ) } -function AiGraphChatHero() { - const [activeClient, setActiveClient] = React.useState(0) - const [activeServer, setActiveServer] = React.useState(0) - const [activeProvider, setActiveProvider] = React.useState(0) - const [chatMessages, setChatMessages] = React.useState< - Array - >([]) - const [typingUserMessage, setTypingUserMessage] = React.useState('') - const activeServerNode = graphServerNodes[activeServer] ?? graphServerNodes[0] - const chatScrollRef = React.useRef(null) - const chatLockedToBottomRef = React.useRef(true) - - React.useEffect(() => { - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { - return - } - - const clientIntervalId = window.setInterval(() => { - setActiveClient((current) => (current + 1) % aiHeroClients.length) - }, 2300) - const serverIntervalId = window.setInterval(() => { - setActiveServer((current) => (current + 1) % aiHeroServers.length) - }, 3300) - const providerIntervalId = window.setInterval(() => { - setActiveProvider((current) => (current + 1) % aiHeroProviders.length) - }, 4100) - - return () => { - window.clearInterval(clientIntervalId) - window.clearInterval(serverIntervalId) - window.clearInterval(providerIntervalId) - } - }, []) - - React.useEffect(() => { - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { - const message = aiHeroMessages[0] - - setChatMessages([ - { - ...message, - id: 'reduced-motion-example', - isStreaming: false, - }, - ]) - return - } - - let cancelled = false - const timeouts: Array = [] - - const addTimeout = (callback: () => void, delay: number) => { - const timeoutId = window.setTimeout(callback, delay) - timeouts.push(timeoutId) - } - - const streamAssistantResponse = ( - id: string, - response: string, - onComplete: () => void, - ) => { - let currentIndex = 0 - - const streamChunk = () => { - if (cancelled) { - return - } - - if (currentIndex < response.length) { - const chunkSize = 2 + Math.floor(Math.random() * 7) - const nextIndex = Math.min(currentIndex + chunkSize, response.length) - const nextText = response.slice(0, nextIndex) - - setChatMessages((currentMessages) => - currentMessages.map((message) => - message.id === id - ? { ...message, assistant: nextText, isStreaming: true } - : message, - ), - ) - - currentIndex = nextIndex - addTimeout(streamChunk, 22 + Math.floor(Math.random() * 58)) - return - } - - setChatMessages((currentMessages) => - currentMessages.map((message) => - message.id === id ? { ...message, isStreaming: false } : message, - ), - ) - addTimeout(onComplete, 1600) - } - - addTimeout(streamChunk, 450) - } - - const typeUserMessage = ( - messageIndex: number, - onComplete: (id: string) => void, - ) => { - const message = aiHeroMessages[messageIndex] - let currentIndex = 0 - - setTypingUserMessage('') - - const typeChar = () => { - if (cancelled) { - return - } - - if (currentIndex < message.user.length) { - currentIndex += 1 - setTypingUserMessage(message.user.slice(0, currentIndex)) - addTimeout(typeChar, 30 + Math.floor(Math.random() * 40)) - return - } - - addTimeout(() => { - const id = `${messageIndex}-${Date.now()}` - - setTypingUserMessage('') - setChatMessages((currentMessages) => [ - ...currentMessages.slice(-1), - { - assistant: '', - id, - isStreaming: true, - user: message.user, - }, - ]) - onComplete(id) - }, 320) - } - - typeChar() - } - - const playMessage = (messageIndex: number) => { - if (cancelled) { - return - } - - const nextMessageIndex = messageIndex % aiHeroMessages.length - const message = aiHeroMessages[nextMessageIndex] - - typeUserMessage(nextMessageIndex, (id) => { - streamAssistantResponse(id, message.assistant, () => { - playMessage(nextMessageIndex + 1) - }) - }) - } - - addTimeout(() => playMessage(0), 700) - - return () => { - cancelled = true - timeouts.forEach((timeoutId) => window.clearTimeout(timeoutId)) - } - }, []) - - React.useEffect(() => { - const element = chatScrollRef.current - if (!element) { - return - } - - const handleScroll = () => { - const distanceFromBottom = - element.scrollHeight - element.scrollTop - element.clientHeight - - chatLockedToBottomRef.current = distanceFromBottom < 72 - } - - element.addEventListener('scroll', handleScroll, { passive: true }) - return () => element.removeEventListener('scroll', handleScroll) - }, []) - - React.useEffect(() => { - const frameId = window.requestAnimationFrame(() => { - const element = chatScrollRef.current - - if (element && chatLockedToBottomRef.current) { - element.scrollTop = element.scrollHeight - } - }) - - return () => window.cancelAnimationFrame(frameId) - }, [chatMessages]) - - return ( -
- - A client graph shows eight UI adapters converging on the TanStack AI - Client over AG-UI, then reaching an agent runtime in TypeScript, Python, - Go, or PHP, and interchangeable model providers. - - - - - ) -} - -function topAnchor(node: GraphNodePosition): GraphPoint { - return { - x: node.x + node.width / 2, - y: node.y, - } -} - -function bottomAnchor(node: GraphNodePosition): GraphPoint { - return { - x: node.x + node.width / 2, - y: node.y + node.height, - } -} - -function curveBetween(start: GraphPoint, end: GraphPoint, bend = 0.5): string { - if (Math.abs(end.y - start.y) > Math.abs(end.x - start.x)) { - const controlY = start.y + (end.y - start.y) * bend - - return `M ${start.x} ${start.y} C ${start.x} ${controlY}, ${end.x} ${controlY}, ${end.x} ${end.y}` - } - - const controlX = start.x + (end.x - start.x) * bend - return `M ${start.x} ${start.y} C ${controlX} ${start.y}, ${controlX} ${end.y}, ${end.x} ${end.y}` -} - -function graphStyle(node: GraphNodePosition): React.CSSProperties { - return { - height: `${(node.height / 420) * 100}%`, - left: `${(node.x / 420) * 100}%`, - top: `${(node.y / 420) * 100}%`, - width: `${(node.width / 420) * 100}%`, - } -} - -function GraphLine({ active, d }: { active?: boolean; d: string }) { - return ( - - ) -} - -function GraphLabel({ - children, - x, - y, -}: { - children: React.ReactNode - x: number - y: number -}) { - return ( -
- {children} -
- ) -} - -function GraphNode({ - active, - detail, - dotted, - kind, - label, - node, -}: { - active?: boolean - detail?: string - dotted?: boolean - kind?: 'tanstack' - label: string - node: GraphNodePosition -}) { - const isTanStack = kind === 'tanstack' - const className = isTanStack - ? active - ? `absolute z-20 flex flex-col items-center justify-center rounded-lg border-2 border-[var(--landing-accent)] px-2 text-center font-ds-mono text-ds-mono-2xs shadow-[0_12px_28px_rgb(var(--landing-glow)/0.28)] ring-2 ring-[color:rgb(var(--landing-glow)/0.24)] transition-all duration-500 motion-reduce:transition-none ${accentFillClass}` - : 'absolute z-20 flex flex-col items-center justify-center rounded-lg border-2 border-[var(--landing-accent)] bg-[color:rgb(var(--landing-glow)/0.15)] px-2 text-center font-ds-mono text-ds-mono-2xs text-[var(--landing-accent-bright)] transition-all duration-500 motion-reduce:transition-none' - : active - ? 'absolute z-20 flex flex-col items-center justify-center rounded-lg border border-text-primary bg-text-primary px-2 text-center font-ds-mono text-ds-mono-2xs text-background-default shadow-sm transition-all duration-500 motion-reduce:transition-none' - : dotted - ? 'absolute z-20 flex flex-col items-center justify-center rounded-lg border border-dashed border-text-primary/25 bg-background-subtle/80 px-2 text-center font-ds-mono text-ds-mono-2xs text-text-primary/30 transition-all duration-500 motion-reduce:transition-none' - : 'absolute z-20 flex flex-col items-center justify-center rounded-lg border border-border-default bg-background-subtle/90 px-2 text-center font-ds-mono text-ds-mono-2xs text-text-primary/40 transition-all duration-500 motion-reduce:transition-none' - - return ( -
- {label} - {detail ? ( - - {detail} - - ) : null} -
- ) -} - function ToolBoundary() { const [boundary, setBoundary] = React.useState<'client' | 'server'>('server') const boundaries: Array<'client' | 'server'> = ['client', 'server'] @@ -1746,3 +1175,31 @@ function StartingPoints() { ) } + +// ponytail: placeholder until the hero visual is designed. Replace the whole component. +function HeroWireframe() { + return ( +