Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/ai-client-store-snapshot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@tanstack/ai-client': minor
'@tanstack/ai-react': patch
'@tanstack/ai-solid': patch
'@tanstack/ai-vue': patch
'@tanstack/ai-svelte': patch
'@tanstack/ai-angular': patch
'@tanstack/ai-preact': patch
'@tanstack/ai-octane': patch
---

Hold client UI state in a TanStack Store atom. Framework hooks read `getSnapshot()` instead of copying fields through change callbacks. React `useChat()` first commits `initialMessages` and `initialResumeSnapshot`, then applies browser persistence after attach. This timing applies to client-only rendering and keeps the server snapshot stable during SSR hydration. The `useChat()` return shape stays the same.

`ChatClient` adds `subscribeSnapshot` / `getSnapshot`. The live connection method stays `subscribe()`. Generation, video, realtime, audio, and BYOK clients expose the same snapshot pair (`subscribe` / `getSnapshot`) where the name does not collide.
35 changes: 35 additions & 0 deletions docs/api/ai-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,41 @@ Stop tailing and drop the connection. Keeps messages, the run pointer and the ru
id, so a later `attach()` continues where it left off. See
[Lifecycle](#lifecycle-attach-and-detach).

#### `getSnapshot()`

Returns the current UI snapshot: messages, status, loading, error, queue, run
id, subscription, connection, session generating, and interrupt state.

`subscribeSnapshot(listener)` calls `listener` after that snapshot changes. It
does not fire with the current value. Read `getSnapshot()` first. This is not
`subscribe()`, which starts the live connection loop.

```typescript
import { ChatClient, fetchServerSentEvents } from "@tanstack/ai-client";

const client = new ChatClient({
connection: fetchServerSentEvents("/api/chat"),
});

function logSnapshot() {
const snapshot = client.getSnapshot();
console.log(snapshot.status, snapshot.messages.length);
}

logSnapshot();

const stop = client.subscribeSnapshot(() => {
logSnapshot();
});

client.attach();
client.sendMessage("Hello");
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

`GenerationClient`, `VideoGenerationClient`, `RealtimeClient`, `AudioRecorder`,
and `ByokClient` use `subscribe(listener)` / `getSnapshot()` for the same job.
Their `subscribe` name does not collide with a live connection method.

#### `stop()`

Stops the current response generation. It aborts the in-flight request.
Expand Down
11 changes: 7 additions & 4 deletions docs/persistence/client-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,13 @@ to pick one and how it differs from the `runId` the hook reports.
The client stores one record per `threadId`, the transcript plus a small resume
pointer. On the next load `useChat` reads it and:

- **Repaints the transcript** from storage with no network. Sync adapters
(`localStorage` / `sessionStorage`) hydrate during construction; IndexedDB
hydrates asynchronously after the database opens (so the first paint may be
empty for a tick).
- **Repaints the transcript** from storage with no network. In React, the first
committed render uses `initialMessages` and `initialResumeSnapshot`. After the
hook attaches, it applies browser persistence from either a synchronous or an
asynchronous adapter. This timing also prevents SSR hydration mismatches. A
direct `ChatClient` and non-React framework wrappers still apply synchronous
stores during construction. IndexedDB always hydrates asynchronously after
the database opens.
- **Rehydrates a pending interrupt**, so an approval prompt comes back exactly as
it was.
- **Rejoins an in-flight run**, if a reply was still streaming when the page
Expand Down
6 changes: 3 additions & 3 deletions examples/sandbox-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
"@tanstack/ai-sandbox": "workspace:*",
"@tanstack/ai-sandbox-cloudflare": "workspace:*",
"@tanstack/ai-sandbox-local-process": "workspace:*",
"@tanstack/react-router": "^1.158.4",
"@tanstack/react-start": "^1.159.0",
"@tanstack/react-router": "^1.170.32",
"@tanstack/react-start": "^1.168.49",
"lucide-react": "^0.561.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
Expand All @@ -37,7 +37,7 @@
"devDependencies": {
"@cloudflare/vite-plugin": "^1.42.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/router-plugin": "^1.158.4",
"@tanstack/router-plugin": "^1.168.35",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
Expand Down
6 changes: 3 additions & 3 deletions examples/sandbox-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
"@tanstack/ai-react": "workspace:*",
"@tanstack/ai-sandbox": "workspace:*",
"@tanstack/ai-sandbox-docker": "workspace:*",
"@tanstack/react-router": "^1.158.4",
"@tanstack/react-start": "^1.159.0",
"@tanstack/react-router": "^1.170.32",
"@tanstack/react-start": "^1.168.49",
"lucide-react": "^0.561.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
Expand All @@ -31,7 +31,7 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@tanstack/router-plugin": "^1.158.4",
"@tanstack/router-plugin": "^1.168.35",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
Expand Down
26 changes: 13 additions & 13 deletions examples/sandbox-web/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiRunRouteImport } from './routes/api.run'
import { Route as ApiRunCancelRouteImport } from './routes/api.run.cancel'
import { Route as ApiRunActiveRouteImport } from './routes/api.run.active'
import { Route as ApiRunCancelRouteImport } from './routes/api.run.cancel'

const IndexRoute = IndexRouteImport.update({
id: '/',
Expand All @@ -24,16 +24,16 @@ const ApiRunRoute = ApiRunRouteImport.update({
path: '/api/run',
getParentRoute: () => rootRouteImport,
} as any)
const ApiRunCancelRoute = ApiRunCancelRouteImport.update({
id: '/cancel',
path: '/cancel',
getParentRoute: () => ApiRunRoute,
} as any)
const ApiRunActiveRoute = ApiRunActiveRouteImport.update({
id: '/active',
path: '/active',
getParentRoute: () => ApiRunRoute,
} as any)
const ApiRunCancelRoute = ApiRunCancelRouteImport.update({
id: '/cancel',
path: '/cancel',
getParentRoute: () => ApiRunRoute,
} as any)

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
Expand Down Expand Up @@ -83,20 +83,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiRunRouteImport
parentRoute: typeof rootRouteImport
}
'/api/run/cancel': {
id: '/api/run/cancel'
path: '/cancel'
fullPath: '/api/run/cancel'
preLoaderRoute: typeof ApiRunCancelRouteImport
parentRoute: typeof ApiRunRoute
}
'/api/run/active': {
id: '/api/run/active'
path: '/active'
fullPath: '/api/run/active'
preLoaderRoute: typeof ApiRunActiveRouteImport
parentRoute: typeof ApiRunRoute
}
'/api/run/cancel': {
id: '/api/run/cancel'
path: '/cancel'
fullPath: '/api/run/cancel'
preLoaderRoute: typeof ApiRunCancelRouteImport
parentRoute: typeof ApiRunRoute
}
}
}

Expand Down
8 changes: 4 additions & 4 deletions examples/ts-code-mode-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@
"@tanstack/ai-ollama": "workspace:*",
"@tanstack/ai-openai": "workspace:*",
"@tanstack/ai-react": "workspace:*",
"@tanstack/react-router": "^1.158.4",
"@tanstack/react-start": "^1.159.0",
"@tanstack/router-plugin": "^1.158.4",
"@tanstack/store": "^0.8.0",
"@tanstack/react-router": "^1.170.32",
"@tanstack/react-start": "^1.168.49",
"@tanstack/router-plugin": "^1.168.35",
"@tanstack/store": "^0.11.1",
"framer-motion": "^11.15.0",
"highlight.js": "^11.11.1",
"isolated-vm": "^6.0.2",
Expand Down
Loading
Loading