Skip to content

Commit 876f75d

Browse files
authored
feat(core): add interactable state persistence (#3711)
1 parent dffb6b4 commit 876f75d

File tree

9 files changed

+396
-50
lines changed

9 files changed

+396
-50
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@assistant-ui/core": patch
3+
"@assistant-ui/react": patch
4+
---
5+
6+
feat: add interactable state persistence
7+
8+
Add persistence API to interactables with exportState/importState, debounced setPersistenceAdapter, per-id isPending/error tracking, flush() for immediate sync, and auto-flush on component unregister.

apps/docs/content/docs/(docs)/guides/interactables.mdx

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ const id = useAssistantInteractable(name, config);
235235
Reads and writes the state of a registered interactable.
236236

237237
```tsx
238-
const [state, { setState, setSelected }] = useInteractableState<TState>(id, fallback?);
238+
const [state, { setState, setSelected, isPending, error, flush }] = useInteractableState<TState>(id, fallback?);
239239
```
240240
241241
**Parameters:**
@@ -245,13 +245,16 @@ const [state, { setState, setSelected }] = useInteractableState<TState>(id, fall
245245
| `id` | `string` | The interactable instance id (from `useAssistantInteractable`) |
246246
| `fallback` | `TState?` | Fallback value before the interactable is registered |
247247
248-
**Returns:** `[state, { setState, setSelected }]`
248+
**Returns:** `[state, methods]`
249249
250250
| Return | Type | Description |
251251
| --- | --- | --- |
252252
| `state` | `TState` | Current state |
253253
| `setState` | `(updater: TState \| (prev: TState) => TState) => void` | State setter (like `useState`) |
254254
| `setSelected` | `(selected: boolean) => void` | Mark this interactable as selected |
255+
| `isPending` | `boolean` | Whether a persistence save is in-flight |
256+
| `error` | `unknown` | Error from the last failed save |
257+
| `flush` | `() => Promise<void>` | Force an immediate persistence save |
255258
256259
### `Interactables`
257260
@@ -274,6 +277,62 @@ When you call `useAssistantInteractable("taskBoard", config)`:
274277
5. **Partial merge** — only the fields the AI sends are updated; the rest are preserved.
275278
6. **Bidirectional updates** — when the AI calls the tool, the state updates and React re-renders. When the user updates state via `setState`, the model context is notified so the AI sees the latest state on the next turn.
276279
280+
## Persistence
281+
282+
By default, interactable state is in-memory and lost on page refresh. You can add persistence by providing a save callback:
283+
284+
```tsx
285+
import { useEffect } from "react";
286+
import { useAui, Interactables } from "@assistant-ui/react";
287+
288+
function MyRuntimeProvider({ children }) {
289+
const aui = useAui({ interactables: Interactables() });
290+
291+
useEffect(() => {
292+
// Set up persistence adapter
293+
aui.interactables().setPersistenceAdapter({
294+
save: async (state) => {
295+
localStorage.setItem("interactables", JSON.stringify(state));
296+
},
297+
});
298+
299+
// Restore saved state on mount
300+
const saved = localStorage.getItem("interactables");
301+
if (saved) {
302+
aui.interactables().importState(JSON.parse(saved));
303+
}
304+
}, [aui]);
305+
306+
return /* ... */;
307+
}
308+
```
309+
310+
### Sync Status
311+
312+
When a persistence adapter is set, `useInteractableState` exposes sync metadata:
313+
314+
```tsx
315+
const [state, { setState, isPending, error, flush }] = useInteractableState(id, fallback);
316+
317+
// isPending — true while a save is in-flight
318+
// error — the error from the last failed save, if any
319+
// flush() — force an immediate save (useful before navigation)
320+
```
321+
322+
State changes are automatically debounced (500ms) before saving. When a component unregisters, any pending save is flushed immediately.
323+
324+
### Export / Import
325+
326+
For custom persistence strategies, use `exportState` and `importState` directly:
327+
328+
```tsx
329+
const snapshot = aui.interactables().exportState();
330+
// => { "note-1": { name: "note", state: { title: "Hello" } }, ... }
331+
332+
aui.interactables().importState(snapshot);
333+
// Imported state is picked up when components next register
334+
```
335+
277336
## Combining with Tools
278337
279338
You can use `Interactables` alongside `Tools`:
@@ -284,3 +343,12 @@ const aui = useAui({
284343
interactables: Interactables(),
285344
});
286345
```
346+
347+
## Full Example
348+
349+
See the complete [with-interactables example](https://github.com/assistant-ui/assistant-ui/tree/main/examples/with-interactables) for a working implementation featuring:
350+
351+
- **Task Board** — single-instance interactable with a custom `manage_tasks` tool
352+
- **Sticky Notes** — multi-instance interactables with selection and partial updates
353+
- **localStorage persistence** — state survives page refresh via `setPersistenceAdapter`
354+
- **Sync indicator** — spinning icon while a save is in-flight (`isPending`)

apps/docs/lib/examples.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ const INTERNAL_EXAMPLES: ExampleItem[] = [
106106
githubLink:
107107
"https://github.com/assistant-ui/assistant-ui/tree/main/examples/with-expo",
108108
},
109+
{
110+
title: "Interactables",
111+
image: "/screenshot/examples/interactables.png",
112+
description:
113+
"Task board and sticky notes with AI-driven state updates and localStorage persistence.",
114+
link: "https://github.com/assistant-ui/assistant-ui/tree/main/examples/with-interactables",
115+
external: true,
116+
},
109117
];
110118

111119
const COMMUNITY_EXAMPLES: ExampleItem[] = [
203 KB
Loading

examples/with-interactables/app/page.tsx

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useRef, useState, useCallback } from "react";
3+
import { useRef, useState, useCallback, useEffect } from "react";
44
import { Thread } from "@/components/assistant-ui/thread";
55
import {
66
AssistantRuntimeProvider,
@@ -18,6 +18,7 @@ import {
1818
CheckCircle2Icon,
1919
CircleIcon,
2020
ListTodoIcon,
21+
Loader2Icon,
2122
StickyNoteIcon,
2223
Trash2Icon,
2324
PlusIcon,
@@ -51,7 +52,7 @@ function TaskBoard() {
5152
stateSchema: taskBoardSchema,
5253
initialState: taskBoardInitialState,
5354
});
54-
const [state, { setState }] = useInteractableState<TaskBoardState>(
55+
const [state, { setState, isPending }] = useInteractableState<TaskBoardState>(
5556
id,
5657
taskBoardInitialState,
5758
);
@@ -114,6 +115,9 @@ function TaskBoard() {
114115
<div className="flex items-center gap-2 border-b px-4 py-3">
115116
<ListTodoIcon className="size-4 text-muted-foreground" />
116117
<span className="font-semibold text-sm">Task Board</span>
118+
{isPending && (
119+
<Loader2Icon className="size-3 animate-spin text-muted-foreground" />
120+
)}
117121
{state.tasks.length > 0 && (
118122
<span className="ml-auto rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary text-xs">
119123
{doneCount}/{state.tasks.length}
@@ -266,9 +270,31 @@ function NoteCard({
266270
);
267271
}
268272

273+
const NOTE_IDS_KEY = "interactables-example-note-ids";
274+
275+
function loadNoteIds(): string[] {
276+
try {
277+
const saved = localStorage.getItem(NOTE_IDS_KEY);
278+
return saved ? JSON.parse(saved) : [];
279+
} catch {
280+
return [];
281+
}
282+
}
283+
269284
function NotesPanel() {
270285
const [noteIds, setNoteIds] = useState<string[]>([]);
271286
const [selectedId, setSelectedId] = useState<string | null>(null);
287+
const hydratedRef = useRef(false);
288+
289+
useEffect(() => {
290+
if (!hydratedRef.current) {
291+
hydratedRef.current = true;
292+
const saved = loadNoteIds();
293+
if (saved.length > 0) setNoteIds(saved);
294+
return;
295+
}
296+
localStorage.setItem(NOTE_IDS_KEY, JSON.stringify(noteIds));
297+
}, [noteIds]);
272298

273299
const noteIdsRef = useRef(noteIds);
274300
noteIdsRef.current = noteIds;
@@ -367,6 +393,27 @@ function NotesPanel() {
367393
// App
368394
// ===========================================================================
369395

396+
const STORAGE_KEY = "interactables-example";
397+
398+
function useInteractablePersistence(aui: ReturnType<typeof useAui>) {
399+
useEffect(() => {
400+
const saved = localStorage.getItem(STORAGE_KEY);
401+
if (saved) {
402+
try {
403+
aui.interactables().importState(JSON.parse(saved));
404+
} catch {
405+
// ignore malformed data
406+
}
407+
}
408+
409+
aui.interactables().setPersistenceAdapter({
410+
save: (state) => {
411+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
412+
},
413+
});
414+
}, [aui]);
415+
}
416+
370417
export default function Home() {
371418
const runtime = useChatRuntime({
372419
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
@@ -394,6 +441,8 @@ export default function Home() {
394441
]),
395442
});
396443

444+
useInteractablePersistence(aui);
445+
397446
return (
398447
<AssistantRuntimeProvider aui={aui} runtime={runtime}>
399448
<main className="flex h-full">

0 commit comments

Comments
 (0)