diff --git a/.gitignore b/.gitignore
index acab2d4c5..089f3adf5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@ node_modules
package-lock.json
yarn.lock
convex/generated
+assets
.tanstack
.DS_Store
diff --git a/src/components/AILibraryHero.tsx b/src/components/AILibraryHero.tsx
new file mode 100644
index 000000000..758ec47de
--- /dev/null
+++ b/src/components/AILibraryHero.tsx
@@ -0,0 +1,1131 @@
+import * as React from 'react'
+import { twMerge } from 'tailwind-merge'
+import { Link, LinkProps } from '@tanstack/react-router'
+import type { Library } from '~/libraries'
+import { useIsDark } from '~/hooks/useIsDark'
+import { ChatPanel } from './ChatPanel'
+import {
+ useAILibraryHeroAnimationStore,
+ AnimationPhase,
+} from '~/stores/aiLibraryHeroAnimation'
+import { AILibraryHeroCard } from './AILibraryHeroCard'
+import { AILibraryHeroBox } from './AILibraryHeroBox'
+import { AILibraryHeroServiceCard } from './AILibraryHeroServiceCard'
+import tsLogo from '~/images/ts-logo.svg'
+import reactLogo from '~/images/react-logo.svg'
+import solidLogo from '~/images/solid-logo.svg'
+import pythonLogo from '~/images/python.svg'
+import phpLightLogo from '~/images/php-light.svg'
+import phpDarkLogo from '~/images/php-dark.svg'
+import ollamaLightLogo from '~/images/ollama-light.svg'
+import ollamaDarkLogo from '~/images/ollama-dark.svg'
+import openaiLightLogo from '~/images/openai-light.svg'
+import openaiDarkLogo from '~/images/openai-dark.svg'
+import anthropicLightLogo from '~/images/anthropic-light.svg'
+import anthropicDarkLogo from '~/images/anthropic-dark.svg'
+import geminiLogo from '~/images/gemini.svg'
+
+import {
+ SVG_WIDTH,
+ SVG_HEIGHT,
+ BOX_FONT_SIZE,
+ BOX_FONT_WEIGHT,
+ SERVICE_WIDTH,
+ SERVICE_GUTTER,
+ SERVICE_LOCATIONS,
+ SERVICE_Y_OFFSET,
+ SERVICE_Y_CENTER,
+ SERVICE_HEIGHT,
+ LIBRARY_CARD_WIDTH,
+ LIBRARY_CARD_HEIGHT,
+ LIBRARY_CARD_LOCATIONS,
+ SERVER_CARD_Y_OFFSET,
+ SERVER_CARD_LOCATIONS,
+ SERVER_CARD_WIDTH,
+ SERVER_CARD_HEIGHT,
+} from '~/stores/aiLibraryHeroAnimation'
+
+// Get the store instance for accessing getState in closures
+const getStoreState = () => useAILibraryHeroAnimationStore.getState()
+
+type AILibraryHeroProps = {
+ project: Library
+ cta?: {
+ linkProps: LinkProps
+ label: string
+ className?: string
+ }
+ actions?: React.ReactNode
+}
+
+const FRAMEWORKS = ['vanilla', 'react', 'solid', '?'] as const
+const SERVICES = ['ollama', 'openai', 'anthropic', 'gemini'] as const
+const SERVERS = ['typescript', 'php', 'python', '?'] as const
+
+const MESSAGES = [
+ {
+ user: 'What makes TanStack AI different?',
+ assistant:
+ 'TanStack AI is completely agnostic - server agnostic, client agnostic, and service agnostic. Use any backend (TypeScript, PHP, Python), any client (vanilla JS, React, Solid), and any AI service (OpenAI, Anthropic, Gemini, Ollama). We provide the libraries and standards, you choose your stack.',
+ },
+ {
+ user: 'Do you support tools?',
+ assistant:
+ 'Yes! We have full support for both client and server tooling, including tool approvals. You can execute tools on either side with complete type safety and control.',
+ },
+ {
+ user: 'What about thinking models?',
+ assistant:
+ "We fully support thinking and reasoning models. All thinking and reasoning tokens are sent to the client, giving you complete visibility into the model's reasoning process.",
+ },
+ {
+ user: 'How type-safe is it?',
+ assistant:
+ 'We have total type safety across providers, models, and model options. Every interaction is fully typed from end to end, catching errors at compile time.',
+ },
+ {
+ user: 'What about developer experience?',
+ assistant:
+ 'We have next-generation dev tools that show you everything happening with your AI connection in real-time. Debug, inspect, and optimize with complete visibility.',
+ },
+ {
+ user: 'Is this a service I have to pay for?',
+ assistant:
+ "No! TanStack AI is pure open source software. We don't have a service to promote or charge for. This is an ecosystem of libraries and standards connecting you with the services you choose - completely community supported.",
+ },
+]
+
+export function AILibraryHero({ project, cta, actions }: AILibraryHeroProps) {
+ const isDark = useIsDark()
+ const strokeColor = isDark ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.6)'
+ const textColor = isDark ? '#ffffff' : '#000000'
+
+ const {
+ phase,
+ selectedFramework,
+ selectedService,
+ selectedServer,
+ rotatingFramework,
+ rotatingServer,
+ rotatingService,
+ serviceOffset,
+ messages,
+ typingUserMessage,
+ connectionPulseDirection,
+ setPhase,
+ setSelectedFramework,
+ setSelectedService,
+ setSelectedServer,
+ setRotatingFramework,
+ setRotatingServer,
+ setRotatingService,
+ setServiceOffset,
+ addMessage,
+ updateCurrentAssistantMessage,
+ setCurrentMessageStreaming,
+ clearMessages,
+ setTypingUserMessage,
+ clearTypingUserMessage,
+ setConnectionPulseDirection,
+ addTimeout,
+ clearTimeouts,
+ } = useAILibraryHeroAnimationStore()
+
+ React.useEffect(() => {
+ const addTimeoutHelper = (fn: () => void, delay: number) => {
+ const timeout = setTimeout(fn, delay)
+ addTimeout(timeout)
+ return timeout
+ }
+
+ const getRandomIndex = (length: number, exclude?: number) => {
+ let index
+ do {
+ index = Math.floor(Math.random() * length)
+ } while (exclude !== undefined && index === exclude)
+ return index
+ }
+
+ const selectFrameworkServiceServer = (onComplete: () => void) => {
+ // Phase 2: DESELECTING
+ setPhase(AnimationPhase.DESELECTING)
+ addTimeoutHelper(() => {
+ // Phase 3: SELECTING_FRAMEWORK
+ setPhase(AnimationPhase.SELECTING_FRAMEWORK)
+ const targetFramework = getRandomIndex(FRAMEWORKS.length)
+ let currentIndex = Math.floor(Math.random() * FRAMEWORKS.length)
+ const rotationCount = 8 + Math.floor(Math.random() * 4) // 8-11 rotations
+
+ const rotateFramework = (iteration: number) => {
+ if (iteration < rotationCount - 1) {
+ setRotatingFramework(currentIndex)
+ currentIndex = (currentIndex + 1) % FRAMEWORKS.length
+ const delay =
+ iteration < rotationCount - 4
+ ? 100
+ : 150 + (iteration - (rotationCount - 4)) * 50
+ addTimeoutHelper(() => rotateFramework(iteration + 1), delay)
+ } else {
+ // Final iteration - ensure we land on target
+ setRotatingFramework(targetFramework)
+ addTimeoutHelper(() => {
+ setSelectedFramework(targetFramework)
+ setRotatingFramework(null)
+ addTimeoutHelper(() => {
+ // Phase 4: SELECTING_SERVICE
+ setPhase(AnimationPhase.SELECTING_SERVICE)
+ // Always pick a different service so it has to scroll
+ const currentSelectedService = getStoreState().selectedService
+ const targetService = getRandomIndex(
+ SERVICES.length,
+ currentSelectedService ?? undefined
+ )
+ let currentServiceIndex = Math.floor(
+ Math.random() * SERVICES.length
+ )
+ const serviceRotationCount = 6 + Math.floor(Math.random() * 3)
+
+ const rotateService = (iteration: number) => {
+ if (iteration < serviceRotationCount - 1) {
+ setRotatingService(currentServiceIndex)
+ currentServiceIndex =
+ (currentServiceIndex + 1) % SERVICES.length
+ const delay =
+ iteration < serviceRotationCount - 3
+ ? 120
+ : 180 + (iteration - (serviceRotationCount - 3)) * 60
+ addTimeoutHelper(() => rotateService(iteration + 1), delay)
+ } else {
+ // Final iteration - ensure we land on target
+ setRotatingService(targetService)
+ addTimeoutHelper(() => {
+ setSelectedService(targetService)
+ setRotatingService(null)
+ const targetX =
+ 0 -
+ SERVICE_WIDTH / 2 -
+ SERVICE_GUTTER / 2 -
+ targetService * (SERVICE_WIDTH + SERVICE_GUTTER)
+ setServiceOffset(targetX)
+
+ addTimeoutHelper(() => {
+ // Phase 5: SELECTING_SERVER
+ setPhase(AnimationPhase.SELECTING_SERVER)
+ const targetServer = getRandomIndex(SERVERS.length)
+ let currentServerIndex = Math.floor(
+ Math.random() * SERVERS.length
+ )
+ const serverRotationCount =
+ 8 + Math.floor(Math.random() * 4)
+
+ const rotateServer = (iteration: number) => {
+ if (iteration < serverRotationCount - 1) {
+ setRotatingServer(currentServerIndex)
+ currentServerIndex =
+ (currentServerIndex + 1) % SERVERS.length
+ const delay =
+ iteration < serverRotationCount - 4
+ ? 100
+ : 150 +
+ (iteration - (serverRotationCount - 4)) * 50
+ addTimeoutHelper(
+ () => rotateServer(iteration + 1),
+ delay
+ )
+ } else {
+ // Final iteration - ensure we land on target
+ setRotatingServer(targetServer)
+ addTimeoutHelper(() => {
+ setSelectedServer(targetServer)
+ setRotatingServer(null)
+ addTimeoutHelper(() => {
+ // Selection complete, call callback
+ onComplete()
+ }, 800)
+ }, 1000)
+ }
+ }
+ rotateServer(0)
+ }, 1000)
+ }, 800)
+ }
+ }
+ rotateService(0)
+ }, 800)
+ }, 500)
+ }
+ }
+ rotateFramework(0)
+ }, 500)
+ }
+
+ const processNextMessage = (messageIndex: number) => {
+ if (messageIndex >= MESSAGES.length) {
+ // All messages shown, clear and restart
+ clearMessages()
+ addTimeoutHelper(() => {
+ // Phase 1: STARTING (initial state)
+ setPhase(AnimationPhase.STARTING)
+ addTimeoutHelper(() => {
+ // Start first message with selection
+ selectFrameworkServiceServer(() => {
+ processNextMessage(0)
+ })
+ }, 1000)
+ }, 1000)
+ return
+ }
+
+ const message = MESSAGES[messageIndex]
+
+ // Phase 6: SHOWING_CHAT - Type user message in input field first
+ setPhase(AnimationPhase.SHOWING_CHAT)
+ clearTypingUserMessage()
+
+ // Type the user message character by character in the input field
+ let typingIndex = 0
+ const typeUserMessage = () => {
+ if (typingIndex < message.user.length) {
+ setTypingUserMessage(message.user.slice(0, typingIndex + 1))
+ typingIndex++
+ const delay = 30 + Math.floor(Math.random() * 40) // 30-70ms per character
+ addTimeoutHelper(typeUserMessage, delay)
+ } else {
+ // Typing complete, wait a moment then clear input and show as bubble
+ addTimeoutHelper(() => {
+ clearTypingUserMessage()
+ addMessage(message.user)
+ addTimeoutHelper(() => {
+ // Phase 7: PULSING_CONNECTIONS
+ setPhase(AnimationPhase.PULSING_CONNECTIONS)
+ setConnectionPulseDirection('down')
+ addTimeoutHelper(() => {
+ // Phase 8: STREAMING_RESPONSE
+ setPhase(AnimationPhase.STREAMING_RESPONSE)
+ setConnectionPulseDirection('up')
+ const fullMessage = message.assistant
+ setCurrentMessageStreaming(true)
+ let currentIndex = 0
+
+ const streamChunk = () => {
+ if (currentIndex < fullMessage.length) {
+ // Random chunk size between 2 and 8 characters
+ const chunkSize = 2 + Math.floor(Math.random() * 7)
+ const nextIndex = Math.min(
+ currentIndex + chunkSize,
+ fullMessage.length
+ )
+ updateCurrentAssistantMessage(
+ fullMessage.slice(0, nextIndex)
+ )
+ currentIndex = nextIndex
+ // Random delay between 20ms and 80ms
+ const delay = 20 + Math.floor(Math.random() * 60)
+ addTimeoutHelper(streamChunk, delay)
+ } else {
+ setCurrentMessageStreaming(false)
+ addTimeoutHelper(() => {
+ // Phase 9: HOLDING - brief pause before next message
+ setPhase(AnimationPhase.HOLDING)
+ addTimeoutHelper(() => {
+ // Select new combination for next message
+ selectFrameworkServiceServer(() => {
+ // Move to next message
+ processNextMessage(messageIndex + 1)
+ })
+ }, 2000)
+ }, 500)
+ }
+ }
+ streamChunk()
+ }, 2000)
+ }, 500)
+ }, 300)
+ }
+ }
+ typeUserMessage()
+ }
+
+ const startAnimationSequence = () => {
+ // Phase 1: STARTING (initial state)
+ setPhase(AnimationPhase.STARTING)
+ addTimeoutHelper(() => {
+ // Start first message with selection
+ selectFrameworkServiceServer(() => {
+ processNextMessage(0)
+ })
+ }, 1000)
+ }
+
+ startAnimationSequence()
+
+ return () => {
+ clearTimeouts()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ const getOpacity = (
+ index: number,
+ selectedIndex: number | null,
+ rotatingIndex: number | null
+ ) => {
+ if (rotatingIndex !== null && rotatingIndex === index) {
+ return 1.0
+ }
+ if (selectedIndex !== null && selectedIndex === index) {
+ return 1.0
+ }
+ return 0.3
+ }
+
+ const getServiceOpacity = (index: number) => {
+ if (rotatingService !== null && rotatingService === index) {
+ return 1.0
+ }
+ if (selectedService !== null && selectedService === index) {
+ return 1.0
+ }
+ return 0.3
+ }
+
+ const getConnectionOpacity = (
+ frameworkIndex: number,
+ serverIndex: number
+ ) => {
+ const isFrameworkSelected =
+ selectedFramework !== null && selectedFramework === frameworkIndex
+ const isServerSelected =
+ selectedServer !== null && selectedServer === serverIndex
+ const isHighlighting =
+ phase === AnimationPhase.SHOWING_CHAT ||
+ phase === AnimationPhase.PULSING_CONNECTIONS ||
+ phase === AnimationPhase.STREAMING_RESPONSE
+
+ // Active path: selected framework -> client -> ai -> selected server
+ if (isHighlighting && isFrameworkSelected && isServerSelected) {
+ return 1.0
+ }
+ // Unused lines should be low opacity
+ return 0.3
+ }
+
+ const getConnectionStrokeColor = (
+ frameworkIndex: number,
+ serverIndex: number
+ ) => {
+ // If no selections, ALWAYS return original stroke color (highest priority check)
+ if (selectedFramework === null || selectedServer === null) {
+ return strokeColor
+ }
+
+ // Only highlight during specific phases
+ const isHighlighting =
+ phase === AnimationPhase.SHOWING_CHAT ||
+ phase === AnimationPhase.PULSING_CONNECTIONS ||
+ phase === AnimationPhase.STREAMING_RESPONSE
+
+ // If not in a highlighting phase, always return original stroke color
+ if (!isHighlighting) {
+ return strokeColor
+ }
+
+ // Now check if this is the active path
+ const isFrameworkSelected = selectedFramework === frameworkIndex
+ const isServerSelected = selectedServer === serverIndex
+
+ // Active path: selected framework -> client -> ai -> selected server
+ // Only return off-white if we're in a highlighting phase AND this is the active path
+ if (isFrameworkSelected && isServerSelected) {
+ // Off-white color when active
+ return isDark ? 'rgba(255, 255, 240, 0.95)' : 'rgba(255, 255, 240, 0.95)'
+ }
+
+ // Not the active path, return original color
+ return strokeColor
+ }
+
+ const getConnectionPulse = () => {
+ if (
+ phase === AnimationPhase.PULSING_CONNECTIONS ||
+ phase === AnimationPhase.STREAMING_RESPONSE
+ ) {
+ return connectionPulseDirection === 'down' ? 'down' : 'up'
+ }
+ return null
+ }
+
+ const getScaleTransform = (
+ index: number,
+ selectedIndex: number | null,
+ centerX: number,
+ centerY: number
+ ) => {
+ if (selectedIndex === index) {
+ return `translate(${centerX}, ${centerY}) scale(1.1) translate(-${centerX}, -${centerY})`
+ }
+ return ''
+ }
+
+ return (
+ <>
+
+
+ {/* Diagram and Chat Panel Container */}
+
+ {/* SVG Diagram */}
+
+
+
+ {/* Glass effect filter with blur and opacity */}
+
+
+
+
+
+
+ {/* Subtle glow for lines */}
+
+
+
+
+
+
+
+
+ {/* Glass gradient */}
+
+
+
+
+
+ {/* Glass gradient for larger boxes */}
+
+
+
+
+
+
+ {/* Lines from frameworks to ai-client */}
+
+
+
+
+
+ {/* Lines from TanStack AI to servers */}
+
+
+
+
+
+ {/* Top layer: Frameworks */}
+
+
+
+
+
+
+
+
+ {/* @tanstack/ai-client box */}
+
+
+ {/* Large TanStack AI container box */}
+
+
+ {/* Line from ai-client to @tanstack/ai - drawn after boxes to be on top */}
+
+
+ {/* Provider layer */}
+
+
+
+
+
+
+
+
+
+
+ {/* Server layer */}
+
+
+
+
+
+
+
+
+
+
+ {/* Chat Panel */}
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/components/AILibraryHeroBox.tsx b/src/components/AILibraryHeroBox.tsx
new file mode 100644
index 000000000..610a86f4f
--- /dev/null
+++ b/src/components/AILibraryHeroBox.tsx
@@ -0,0 +1,83 @@
+import * as React from 'react'
+
+type AILibraryHeroBoxProps = {
+ x: number
+ y: number
+ width: number
+ height: number
+ label?: string
+ textColor: string
+ strokeColor: string
+ fontSize?: number
+ fontWeight?: number
+ rx?: number
+ opacity?: number
+ strokeWidth?: number
+ fill?: string
+ showLogo?: boolean
+ logoSize?: number
+ centerText?: boolean
+}
+
+export function AILibraryHeroBox({
+ x,
+ y,
+ width,
+ height,
+ label,
+ textColor,
+ strokeColor,
+ fontSize = 25,
+ fontWeight = 900,
+ rx = 9,
+ opacity = 0.9,
+ strokeWidth = 3,
+ fill = 'url(#glassGradientLarge)',
+ logoSize = 40,
+}: AILibraryHeroBoxProps) {
+ // For centerText, align logo and text higher up; otherwise use normal center
+ const textX = 25 + logoSize
+ const textY = 15 + fontSize
+
+ // Position logo to the right of the text, centered vertically
+ const logoX = 15
+ const logoY = 15
+
+ return (
+
+
+ {label && (
+
+ {label}
+
+ )}
+
+
+ )
+}
diff --git a/src/components/AILibraryHeroCard.tsx b/src/components/AILibraryHeroCard.tsx
new file mode 100644
index 000000000..43ef6c868
--- /dev/null
+++ b/src/components/AILibraryHeroCard.tsx
@@ -0,0 +1,99 @@
+import * as React from 'react'
+import { useIsDark } from '~/hooks/useIsDark'
+
+type AILibraryHeroCardProps = {
+ x: number
+ y: number
+ width: number
+ height: number
+ label: string
+ opacity: number
+ textColor: string
+ strokeColor: string
+ fontSize?: number
+ fontWeight?: number
+ rx?: number
+ isDashed?: boolean
+ logoLight?: string
+ logoDark?: string
+ logo?: string
+ logoSize?: number
+ transform?: string
+ fill?: string
+}
+
+export function AILibraryHeroCard({
+ x,
+ y,
+ width,
+ height,
+ label,
+ opacity,
+ textColor,
+ strokeColor,
+ fontSize = 18,
+ fontWeight = 700,
+ rx = 9,
+ isDashed = false,
+ logoLight,
+ logoDark,
+ logo,
+ logoSize = 20,
+ transform,
+ fill = 'url(#glassGradient)',
+}: AILibraryHeroCardProps) {
+ const isDark = useIsDark()
+
+ // Determine which logo to use
+ const logoToUse =
+ logo || (isDark && logoDark ? logoDark : logoLight) || logoLight || logoDark
+
+ const centerY = y + height / 2
+ // Position logo to the left of text, both centered vertically
+ const logoX = logoToUse ? x + 12 : 0
+ const logoY = centerY - logoSize / 2
+ const textX = logoToUse ? x + logoSize + 20 : x + width / 2
+ // Better vertical alignment: text baseline should align with logo center
+ const textY = centerY + fontSize * 0.35
+
+ return (
+
+
+ {logoToUse && (
+
+ )}
+
+ {label}
+
+
+ )
+}
diff --git a/src/components/AILibraryHeroServiceCard.tsx b/src/components/AILibraryHeroServiceCard.tsx
new file mode 100644
index 000000000..ef717117a
--- /dev/null
+++ b/src/components/AILibraryHeroServiceCard.tsx
@@ -0,0 +1,97 @@
+import * as React from 'react'
+import { useIsDark } from '~/hooks/useIsDark'
+
+type AILibraryHeroServiceCardProps = {
+ x: number
+ y: number
+ width: number
+ height: number
+ label: string
+ opacity: number
+ textColor: string
+ strokeColor: string
+ fontSize?: number
+ fontWeight?: number
+ rx?: number
+ logoLight?: string
+ logoDark?: string
+ logo?: string
+ logoSize?: number
+ transform?: string
+ fill?: string
+}
+
+export function AILibraryHeroServiceCard({
+ x,
+ y,
+ width,
+ height,
+ label,
+ opacity,
+ textColor,
+ strokeColor,
+ fontSize = 18,
+ fontWeight = 700,
+ rx = 6,
+ logoLight,
+ logoDark,
+ logo,
+ logoSize = 20,
+ transform,
+ fill = 'url(#glassGradient)',
+}: AILibraryHeroServiceCardProps) {
+ const isDark = useIsDark()
+
+ // Determine which logo to use
+ const logoToUse =
+ logo || (isDark && logoDark ? logoDark : logoLight) || logoLight || logoDark
+
+ const centerX = x + width / 2
+ const centerY = y + height / 2
+ // Position logo to the left of text, both centered vertically
+ const logoX = logoToUse ? x + 12 : 0
+ const logoY = centerY - logoSize / 2
+ const textX = logoToUse ? x + logoSize + 20 : centerX
+ // Better vertical alignment: text baseline should align with logo center
+ const textY = centerY + fontSize * 0.35
+
+ return (
+
+
+ {logoToUse && (
+
+ )}
+
+ {label}
+
+
+ )
+}
diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx
new file mode 100644
index 000000000..56987904f
--- /dev/null
+++ b/src/components/ChatPanel.tsx
@@ -0,0 +1,119 @@
+import * as React from 'react'
+import { useIsDark } from '~/hooks/useIsDark'
+import type { ChatMessage } from '~/stores/aiLibraryHeroAnimation'
+
+type ChatPanelProps = {
+ messages: ChatMessage[]
+ typingUserMessage: string
+}
+
+export function ChatPanel({ messages, typingUserMessage }: ChatPanelProps) {
+ const isDark = useIsDark()
+
+ return (
+
+ {/* Header */}
+
+
+ Chat Panel
+
+
+
+ {/* Messages area */}
+
+
+ {messages.map((message) => (
+
+ {/* User message - right aligned */}
+
+ {/* Assistant message - left aligned, appears below user message */}
+ {message.assistant && (
+
+
+
+ {message.assistant}
+ {message.isStreaming && (
+
+ )}
+
+
+
+ )}
+
+ ))}
+
+
+
+ {/* Input field at bottom */}
+
+
+
+ {typingUserMessage || 'Type a message...'}
+
+
+
+
+ )
+}
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index c7b94b080..4d1e2364c 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -243,6 +243,7 @@ export function Navbar({ children }: { children: React.ReactNode }) {
'table',
'form',
'db',
+ 'ai',
'virtual',
'pacer',
'store',
diff --git a/src/images/anthropic-dark.svg b/src/images/anthropic-dark.svg
new file mode 100644
index 000000000..09d2f13f6
--- /dev/null
+++ b/src/images/anthropic-dark.svg
@@ -0,0 +1 @@
+Anthropic
\ No newline at end of file
diff --git a/src/images/anthropic-light.svg b/src/images/anthropic-light.svg
new file mode 100644
index 000000000..e19a838ad
--- /dev/null
+++ b/src/images/anthropic-light.svg
@@ -0,0 +1 @@
+Anthropic
\ No newline at end of file
diff --git a/src/images/gemini.svg b/src/images/gemini.svg
new file mode 100644
index 000000000..87cce066a
--- /dev/null
+++ b/src/images/gemini.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/images/ollama-dark.svg b/src/images/ollama-dark.svg
new file mode 100644
index 000000000..fa8a61281
--- /dev/null
+++ b/src/images/ollama-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/images/ollama-light.svg b/src/images/ollama-light.svg
new file mode 100644
index 000000000..833defd51
--- /dev/null
+++ b/src/images/ollama-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/images/openai-dark.svg b/src/images/openai-dark.svg
new file mode 100644
index 000000000..b6d542d09
--- /dev/null
+++ b/src/images/openai-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/images/openai-light.svg b/src/images/openai-light.svg
new file mode 100644
index 000000000..2ebab679f
--- /dev/null
+++ b/src/images/openai-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/images/php-dark.svg b/src/images/php-dark.svg
new file mode 100644
index 000000000..873cb5aa2
--- /dev/null
+++ b/src/images/php-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/images/php-light.svg b/src/images/php-light.svg
new file mode 100644
index 000000000..a430c98b0
--- /dev/null
+++ b/src/images/php-light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/images/python.svg b/src/images/python.svg
new file mode 100644
index 000000000..3856cda09
--- /dev/null
+++ b/src/images/python.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/images/ts-logo.svg b/src/images/ts-logo.svg
new file mode 100644
index 000000000..a2688f33b
--- /dev/null
+++ b/src/images/ts-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/libraries/ai.tsx b/src/libraries/ai.tsx
new file mode 100644
index 000000000..d837fdde9
--- /dev/null
+++ b/src/libraries/ai.tsx
@@ -0,0 +1,81 @@
+import { VscPreview } from 'react-icons/vsc'
+import { Library } from '.'
+import { FaGithub, FaBolt, FaCogs } from 'react-icons/fa'
+import { BiBookAlt } from 'react-icons/bi'
+import { twMerge } from 'tailwind-merge'
+import { FaPlug } from 'react-icons/fa6'
+
+const repo = 'tanstack/ai'
+
+const textStyles = `text-pink-600 dark:text-pink-500`
+
+export const aiProject = {
+ id: 'ai',
+ name: 'TanStack AI',
+ cardStyles: `shadow-xl shadow-pink-700/20 dark:shadow-lg dark:shadow-pink-500/20 text-pink-500 dark:text-pink-400 border-2 border-transparent hover:border-current`,
+ to: '/ai',
+ tagline: `A powerful, open-source AI SDK with a unified interface across multiple providers`,
+ description: `A powerful, open-source AI SDK with a unified interface across multiple providers. No vendor lock-in, no proprietary formats, just clean TypeScript and honest open source.`,
+ ogImage: 'https://github.com/tanstack/ai/raw/main/media/repo-header.png',
+ badge: 'alpha',
+ bgStyle: `bg-pink-700`,
+ textStyle: `text-pink-500`,
+ repo,
+ latestBranch: 'main',
+ latestVersion: 'v0',
+ availableVersions: ['v0'],
+ bgRadial: 'from-pink-500 via-pink-700/50 to-transparent',
+ colorFrom: `from-pink-500`,
+ colorTo: `to-pink-700`,
+ textColor: `text-pink-700`,
+ frameworks: ['react', 'solid', 'vanilla'],
+ scarfId: undefined,
+ defaultDocs: 'overview',
+ menu: [
+ {
+ icon: ,
+ label: 'Docs',
+ to: '/ai/latest/docs',
+ },
+ {
+ icon: ,
+ label: 'Github',
+ to: `https://github.com/${repo}`,
+ },
+ ],
+ featureHighlights: [
+ {
+ title: 'Multi-Provider Support',
+ icon: ,
+ description: (
+
+ Support for OpenAI, Anthropic, Ollama, and Google Gemini. Switch
+ providers at runtime without code changes. No vendor lock-in, just
+ clean TypeScript.
+
+ ),
+ },
+ {
+ title: 'Unified API',
+ icon: ,
+ description: (
+
+ Same interface across all providers. Standalone functions with
+ automatic type inference from adapters. Framework-agnostic client for
+ any JavaScript environment.
+
+ ),
+ },
+ {
+ title: 'Tool/Function Calling',
+ icon: ,
+ description: (
+
+ Automatic execution loop with no manual tool management needed.
+ Type-safe tool definitions with structured outputs and streaming
+ support.
+
+ ),
+ },
+ ],
+} satisfies Library
diff --git a/src/libraries/index.tsx b/src/libraries/index.tsx
index 58ed6c67a..adb7f66cd 100644
--- a/src/libraries/index.tsx
+++ b/src/libraries/index.tsx
@@ -18,6 +18,7 @@ import { rangerProject } from './ranger'
import { storeProject } from './store'
import { pacerProject } from './pacer'
import { dbProject } from './db'
+import { aiProject } from './ai'
import { devtoolsProject } from './devtools'
export const frameworkOptions = [
@@ -56,6 +57,7 @@ export type Library = {
| 'store'
| 'pacer'
| 'db'
+ | 'ai'
| 'config'
| 'devtools'
| 'react-charts'
@@ -112,6 +114,7 @@ export const libraries = [
tableProject,
formProject,
dbProject,
+ aiProject,
virtualProject,
pacerProject,
storeProject,
@@ -137,16 +140,18 @@ export const librariesByGroup = {
queryProject,
dbProject,
storeProject,
- pacerProject,
+ aiProject,
],
- headlessUI: [tableProject, formProject, virtualProject],
- other: [devtoolsProject, configProject],
+ headlessUI: [tableProject, formProject],
+ performance: [virtualProject, pacerProject],
+ tooling: [devtoolsProject, configProject],
}
export const librariesGroupNamesMap = {
state: 'Data and State Management',
headlessUI: 'Headless UI',
- other: 'Other',
+ performance: 'Performance',
+ tooling: 'Tooling',
}
export function getLibrary(id: LibraryId): Library {
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index d5f8e6d57..5cd78a96d 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -56,6 +56,7 @@ import { Route as LibrariesFormVersionIndexRouteImport } from './routes/_librari
import { Route as LibrariesDevtoolsVersionIndexRouteImport } from './routes/_libraries/devtools.$version.index'
import { Route as LibrariesDbVersionIndexRouteImport } from './routes/_libraries/db.$version.index'
import { Route as LibrariesConfigVersionIndexRouteImport } from './routes/_libraries/config.$version.index'
+import { Route as LibrariesAiVersionIndexRouteImport } from './routes/_libraries/ai.$version.index'
import { Route as LibraryIdVersionDocsIndexRouteImport } from './routes/$libraryId/$version.docs.index'
import { Route as LibraryIdVersionDocsChar123Char125DotmdRouteImport } from './routes/$libraryId/$version.docs.{$}[.]md'
import { Route as LibraryIdVersionDocsContributorsRouteImport } from './routes/$libraryId/$version.docs.contributors'
@@ -312,6 +313,11 @@ const LibrariesConfigVersionIndexRoute =
path: '/config/$version/',
getParentRoute: () => LibrariesRouteRoute,
} as any)
+const LibrariesAiVersionIndexRoute = LibrariesAiVersionIndexRouteImport.update({
+ id: '/ai/$version/',
+ path: '/ai/$version/',
+ getParentRoute: () => LibrariesRouteRoute,
+} as any)
const LibraryIdVersionDocsIndexRoute =
LibraryIdVersionDocsIndexRouteImport.update({
id: '/',
@@ -413,6 +419,7 @@ export interface FileRoutesByFullPath {
'/$libraryId/$version/docs/contributors': typeof LibraryIdVersionDocsContributorsRoute
'/$libraryId/$version/docs/{$}.md': typeof LibraryIdVersionDocsChar123Char125DotmdRoute
'/$libraryId/$version/docs/': typeof LibraryIdVersionDocsIndexRoute
+ '/ai/$version': typeof LibrariesAiVersionIndexRoute
'/config/$version': typeof LibrariesConfigVersionIndexRoute
'/db/$version': typeof LibrariesDbVersionIndexRoute
'/devtools/$version': typeof LibrariesDevtoolsVersionIndexRoute
@@ -467,6 +474,7 @@ export interface FileRoutesByTo {
'/$libraryId/$version/docs/contributors': typeof LibraryIdVersionDocsContributorsRoute
'/$libraryId/$version/docs/{$}.md': typeof LibraryIdVersionDocsChar123Char125DotmdRoute
'/$libraryId/$version/docs': typeof LibraryIdVersionDocsIndexRoute
+ '/ai/$version': typeof LibrariesAiVersionIndexRoute
'/config/$version': typeof LibrariesConfigVersionIndexRoute
'/db/$version': typeof LibrariesDbVersionIndexRoute
'/devtools/$version': typeof LibrariesDevtoolsVersionIndexRoute
@@ -527,6 +535,7 @@ export interface FileRoutesById {
'/$libraryId/$version/docs/contributors': typeof LibraryIdVersionDocsContributorsRoute
'/$libraryId/$version/docs/{$}.md': typeof LibraryIdVersionDocsChar123Char125DotmdRoute
'/$libraryId/$version/docs/': typeof LibraryIdVersionDocsIndexRoute
+ '/_libraries/ai/$version/': typeof LibrariesAiVersionIndexRoute
'/_libraries/config/$version/': typeof LibrariesConfigVersionIndexRoute
'/_libraries/db/$version/': typeof LibrariesDbVersionIndexRoute
'/_libraries/devtools/$version/': typeof LibrariesDevtoolsVersionIndexRoute
@@ -587,6 +596,7 @@ export interface FileRouteTypes {
| '/$libraryId/$version/docs/contributors'
| '/$libraryId/$version/docs/{$}.md'
| '/$libraryId/$version/docs/'
+ | '/ai/$version'
| '/config/$version'
| '/db/$version'
| '/devtools/$version'
@@ -641,6 +651,7 @@ export interface FileRouteTypes {
| '/$libraryId/$version/docs/contributors'
| '/$libraryId/$version/docs/{$}.md'
| '/$libraryId/$version/docs'
+ | '/ai/$version'
| '/config/$version'
| '/db/$version'
| '/devtools/$version'
@@ -700,6 +711,7 @@ export interface FileRouteTypes {
| '/$libraryId/$version/docs/contributors'
| '/$libraryId/$version/docs/{$}.md'
| '/$libraryId/$version/docs/'
+ | '/_libraries/ai/$version/'
| '/_libraries/config/$version/'
| '/_libraries/db/$version/'
| '/_libraries/devtools/$version/'
@@ -1064,6 +1076,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LibrariesConfigVersionIndexRouteImport
parentRoute: typeof LibrariesRouteRoute
}
+ '/_libraries/ai/$version/': {
+ id: '/_libraries/ai/$version/'
+ path: '/ai/$version'
+ fullPath: '/ai/$version'
+ preLoaderRoute: typeof LibrariesAiVersionIndexRouteImport
+ parentRoute: typeof LibrariesRouteRoute
+ }
'/$libraryId/$version/docs/': {
id: '/$libraryId/$version/docs/'
path: '/'
@@ -1230,6 +1249,7 @@ interface LibrariesRouteRouteChildren {
LibrariesTermsRoute: typeof LibrariesTermsRoute
LibrariesWorkshopsRoute: typeof LibrariesWorkshopsRoute
LibrariesIndexRoute: typeof LibrariesIndexRoute
+ LibrariesAiVersionIndexRoute: typeof LibrariesAiVersionIndexRoute
LibrariesConfigVersionIndexRoute: typeof LibrariesConfigVersionIndexRoute
LibrariesDbVersionIndexRoute: typeof LibrariesDbVersionIndexRoute
LibrariesDevtoolsVersionIndexRoute: typeof LibrariesDevtoolsVersionIndexRoute
@@ -1262,6 +1282,7 @@ const LibrariesRouteRouteChildren: LibrariesRouteRouteChildren = {
LibrariesTermsRoute: LibrariesTermsRoute,
LibrariesWorkshopsRoute: LibrariesWorkshopsRoute,
LibrariesIndexRoute: LibrariesIndexRoute,
+ LibrariesAiVersionIndexRoute: LibrariesAiVersionIndexRoute,
LibrariesConfigVersionIndexRoute: LibrariesConfigVersionIndexRoute,
LibrariesDbVersionIndexRoute: LibrariesDbVersionIndexRoute,
LibrariesDevtoolsVersionIndexRoute: LibrariesDevtoolsVersionIndexRoute,
diff --git a/src/routes/_libraries/ai.$version.index.tsx b/src/routes/_libraries/ai.$version.index.tsx
new file mode 100644
index 000000000..ec5b04560
--- /dev/null
+++ b/src/routes/_libraries/ai.$version.index.tsx
@@ -0,0 +1,152 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { Footer } from '~/components/Footer'
+import { LazySponsorSection } from '~/components/LazySponsorSection'
+import { PartnersSection } from '~/components/PartnersSection'
+import { BottomCTA } from '~/components/BottomCTA'
+import { aiProject } from '~/libraries/ai'
+import { seo } from '~/utils/seo'
+import { AILibraryHero } from '~/components/AILibraryHero'
+import { getLibrary } from '~/libraries'
+import { LibraryFeatureHighlights } from '~/components/LibraryFeatureHighlights'
+import LandingPageGad from '~/components/LandingPageGad'
+import OpenSourceStats, { ossStatsQuery } from '~/components/OpenSourceStats'
+import { LibraryHero } from '~/components/LibraryHero'
+
+const library = getLibrary('ai')
+
+export const Route = createFileRoute('/_libraries/ai/$version/')({
+ component: AIVersionIndex,
+ head: () => ({
+ meta: seo({
+ title: aiProject.name,
+ description: aiProject.description,
+ }),
+ }),
+ loader: async ({ context: { queryClient } }) => {
+ await queryClient.ensureQueryData(ossStatsQuery({ library }))
+ },
+})
+
+function AIVersionIndex() {
+ // sponsorsPromise no longer needed - using lazy loading
+ const { version } = Route.useParams()
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ A complete AI ecosystem, not a vendor platform
+
+
+ TanStack AI is a pure open-source ecosystem of libraries and
+ standardsโnot a service. We connect you directly to the AI
+ providers you choose, with no middleman, no service fees, and no
+ vendor lock-in. Just powerful, type-safe tools built by and for
+ the community.
+
+
+
+
+
๐ฅ๏ธ Server Agnostic
+
+ Use any backend server you want. Well-documented protocol with
+ libraries for TypeScript, PHP, Python, and more.
+
+
+
+
๐ฑ Client Agnostic
+
+ Vanilla client library (@tanstack/ai-client) or framework
+ integrations for React, Solid, and more coming soon.
+
+
+
+
๐ Service Agnostic
+
+ Connect to OpenAI, Anthropic, Gemini, and Ollama out of the box.
+ Create custom adapters for any provider.
+
+
+
+
๐ ๏ธ Full Tooling Support
+
+ Complete support for client and server tools, including tool
+ approvals and execution control.
+
+
+
+
๐ง Thinking & Reasoning
+
+ Full support for thinking and reasoning models with
+ thinking/reasoning tokens streamed to clients.
+
+
+
+
๐ฏ Fully Type-Safe
+
+ Complete type safety across providers, models, and model options
+ from end to end.
+
+
+
+
๐ Next-Gen DevTools
+
+ Amazing developer tools that show you everything happening with
+ your AI connections in real-time.
+
+
+
+
๐ Pure Open Source
+
+ No hidden service, no fees, no upsells. Community-supported
+ software connecting you directly to your chosen providers.
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/routes/_libraries/partners.tsx b/src/routes/_libraries/partners.tsx
index 3dde5322d..bd97cdcb8 100644
--- a/src/routes/_libraries/partners.tsx
+++ b/src/routes/_libraries/partners.tsx
@@ -13,6 +13,7 @@ import { queryProject } from '~/libraries/query'
import { tableProject } from '~/libraries/table'
import { configProject } from '~/libraries/config'
import { dbProject } from '~/libraries/db'
+import { aiProject } from '~/libraries/ai'
import { formProject } from '~/libraries/form'
import { pacerProject } from '~/libraries/pacer'
import { rangerProject } from '~/libraries/ranger'
@@ -30,6 +31,7 @@ const availableLibraries = [
storeProject,
pacerProject,
dbProject,
+ aiProject,
configProject,
]
@@ -44,6 +46,7 @@ const librarySchema = z.enum([
'store',
'pacer',
'db',
+ 'ai',
'config',
'react-charts',
'devtools',
diff --git a/src/stores/aiLibraryHeroAnimation.ts b/src/stores/aiLibraryHeroAnimation.ts
new file mode 100644
index 000000000..6179e3557
--- /dev/null
+++ b/src/stores/aiLibraryHeroAnimation.ts
@@ -0,0 +1,194 @@
+import { create } from 'zustand'
+
+export enum AnimationPhase {
+ STARTING = 'STARTING',
+ DESELECTING = 'DESELECTING',
+ SELECTING_FRAMEWORK = 'SELECTING_FRAMEWORK',
+ SELECTING_SERVICE = 'SELECTING_SERVICE',
+ SELECTING_SERVER = 'SELECTING_SERVER',
+ SHOWING_CHAT = 'SHOWING_CHAT',
+ PULSING_CONNECTIONS = 'PULSING_CONNECTIONS',
+ STREAMING_RESPONSE = 'STREAMING_RESPONSE',
+ HOLDING = 'HOLDING',
+}
+
+export const SVG_WIDTH = 632
+export const SVG_HEIGHT = 432
+export const BOX_FONT_SIZE = 18
+export const BOX_FONT_WEIGHT = 700
+export const SERVICE_WIDTH = 160
+export const SERVICE_GUTTER = 20
+export const SERVICE_LOCATIONS = [0, 1, 2, 3].map(
+ (index) =>
+ SERVICE_WIDTH * 2 +
+ index * (SERVICE_WIDTH + SERVICE_GUTTER) +
+ SERVICE_GUTTER / 2
+)
+export const SERVICE_Y_OFFSET = 265
+export const SERVICE_HEIGHT = 40
+export const SERVICE_Y_CENTER = SERVICE_Y_OFFSET + SERVICE_HEIGHT / 2
+
+export const LIBRARY_CARD_WIDTH = 140
+export const LIBRARY_CARD_HEIGHT = 60
+export const LIBRARY_CARD_GUTTER = 20
+const LIBRARY_CARD_START_X = -20
+export const LIBRARY_CARD_LOCATIONS = [0, 1, 2, 3].map(
+ (index) =>
+ LIBRARY_CARD_START_X +
+ index * (LIBRARY_CARD_WIDTH + LIBRARY_CARD_GUTTER) +
+ LIBRARY_CARD_GUTTER / 2
+)
+
+export const SERVER_CARD_WIDTH = 140
+export const SERVER_CARD_HEIGHT = 60
+export const SERVER_CARD_GUTTER = 20
+const SERVER_CARD_START_X = -20
+export const SERVER_CARD_LOCATIONS = [0, 1, 2, 3].map(
+ (index) =>
+ SERVER_CARD_START_X +
+ index * (SERVER_CARD_WIDTH + SERVER_CARD_GUTTER) +
+ SERVER_CARD_GUTTER / 2
+)
+export const SERVER_CARD_Y_OFFSET = 370
+
+export type ChatMessage = {
+ id: string
+ user: string
+ assistant: string | null
+ isStreaming: boolean
+}
+
+type AILibraryHeroAnimationState = {
+ phase: AnimationPhase
+ selectedFramework: number | null
+ selectedService: number | null
+ selectedServer: number | null
+ rotatingFramework: number | null
+ rotatingServer: number | null
+ rotatingService: number | null
+ serviceOffset: number
+ messages: ChatMessage[]
+ currentMessageIndex: number
+ typingUserMessage: string
+ connectionPulseDirection: 'down' | 'up'
+ timeoutRefs: NodeJS.Timeout[]
+}
+
+type AILibraryHeroAnimationActions = {
+ setPhase: (phase: AnimationPhase) => void
+ setSelectedFramework: (framework: number | null) => void
+ setSelectedService: (service: number | null) => void
+ setSelectedServer: (server: number | null) => void
+ setRotatingFramework: (framework: number | null) => void
+ setRotatingServer: (server: number | null) => void
+ setRotatingService: (service: number | null) => void
+ setServiceOffset: (offset: number) => void
+ addMessage: (user: string) => string
+ updateCurrentAssistantMessage: (text: string) => void
+ setCurrentMessageStreaming: (streaming: boolean) => void
+ setCurrentMessageIndex: (index: number) => void
+ clearMessages: () => void
+ setTypingUserMessage: (message: string) => void
+ clearTypingUserMessage: () => void
+ setConnectionPulseDirection: (direction: 'down' | 'up') => void
+ addTimeout: (timeout: NodeJS.Timeout) => void
+ clearTimeouts: () => void
+ reset: () => void
+}
+
+export const useAILibraryHeroAnimationStore = create<
+ AILibraryHeroAnimationState & AILibraryHeroAnimationActions
+>()((set, get) => ({
+ // State
+ phase: AnimationPhase.STARTING,
+ selectedFramework: null,
+ selectedService: null,
+ selectedServer: null,
+ rotatingFramework: null,
+ rotatingServer: null,
+ rotatingService: null,
+ serviceOffset: 0 - SERVICE_WIDTH / 2 - SERVICE_GUTTER / 2,
+ messages: [],
+ currentMessageIndex: -1,
+ typingUserMessage: '',
+ connectionPulseDirection: 'down',
+ timeoutRefs: [],
+
+ // Actions
+ setPhase: (phase) => set({ phase }),
+ setSelectedFramework: (framework) => set({ selectedFramework: framework }),
+ setSelectedService: (service) => set({ selectedService: service }),
+ setSelectedServer: (server) => set({ selectedServer: server }),
+ setRotatingFramework: (framework) => set({ rotatingFramework: framework }),
+ setRotatingServer: (server) => set({ rotatingServer: server }),
+ setRotatingService: (service) => set({ rotatingService: service }),
+ setServiceOffset: (offset) => set({ serviceOffset: offset }),
+ addMessage: (user) => {
+ const id = `${Date.now()}-${Math.random()}`
+ set((state) => ({
+ messages: [
+ ...state.messages,
+ { id, user, assistant: null, isStreaming: false },
+ ],
+ currentMessageIndex: state.messages.length,
+ }))
+ return id
+ },
+ updateCurrentAssistantMessage: (text) =>
+ set((state) => {
+ if (
+ state.currentMessageIndex >= 0 &&
+ state.currentMessageIndex < state.messages.length
+ ) {
+ const messages = [...state.messages]
+ messages[state.currentMessageIndex] = {
+ ...messages[state.currentMessageIndex],
+ assistant: text,
+ }
+ return { messages }
+ }
+ return {}
+ }),
+ setCurrentMessageStreaming: (streaming) =>
+ set((state) => {
+ if (
+ state.currentMessageIndex >= 0 &&
+ state.currentMessageIndex < state.messages.length
+ ) {
+ const messages = [...state.messages]
+ messages[state.currentMessageIndex] = {
+ ...messages[state.currentMessageIndex],
+ isStreaming: streaming,
+ }
+ return { messages }
+ }
+ return {}
+ }),
+ setCurrentMessageIndex: (index) => set({ currentMessageIndex: index }),
+ clearMessages: () =>
+ set({ messages: [], currentMessageIndex: -1, typingUserMessage: '' }),
+ setTypingUserMessage: (message) => set({ typingUserMessage: message }),
+ clearTypingUserMessage: () => set({ typingUserMessage: '' }),
+ setConnectionPulseDirection: (direction) =>
+ set({ connectionPulseDirection: direction }),
+ addTimeout: (timeout) =>
+ set((state) => ({ timeoutRefs: [...state.timeoutRefs, timeout] })),
+ clearTimeouts: () => {
+ const { timeoutRefs } = get()
+ timeoutRefs.forEach(clearTimeout)
+ set({ timeoutRefs: [] })
+ },
+ reset: () =>
+ set({
+ phase: AnimationPhase.STARTING,
+ selectedFramework: null,
+ selectedService: null,
+ selectedServer: null,
+ rotatingFramework: null,
+ rotatingServer: null,
+ rotatingService: null,
+ connectionPulseDirection: 'down',
+ // Don't reset serviceOffset - keep service in place until new selection
+ // Don't reset messages - they'll be cleared separately when all are shown
+ }),
+}))