React persistence preview
Start a conversation, switch away, then load its saved messages.
Recorded in the real example with fixture responses paced for readability. No live model requests or narration. Actual-speed footage with reading pauses. Capture details
Read the walkthrough
- Send a message in the first conversation.
- Start a new conversation. The first remains in the picker.
- Select the first conversation to load its saved messages. The picker is local to this page; this recording does not demonstrate browser-refresh recovery.
Keep the Portland itinerary when the traveler opens another conversation. Persistence means the backend retains the conversation and the application knows which thread to load; a local picker alone does not provide both.
The native React APIs on this page are experimental, private source-build packages. Run the repository example to use them; they are not an npm installation recipe.
Identify, load, then render
A session belongs to one confirmed thread. Selecting a saved conversation creates a session for that identity and reads its history before enabling another submission. The picker stores IDs, not copies of the server transcript. The small lookup below illustrates the boundary: this read restores history without sending a new user message. The complete application below also handles cancellation, stale selections, and failures.
Application reference → one confirmed thread
Thread 1 → 0…many runs over its lifetime
Thread 1 → 0…many checkpoints as graph state is saved
Checkpoint → saved messages and application state
Read diagram description
The application stores a reference to a confirmed thread ID. One thread can have many runs and many checkpoints. A run advances graph execution and can produce multiple checkpoints. A checkpoint stores graph state, including messages, and may point to a parent checkpoint. Reloading with a confirmed thread ID restores its saved state. Saving that identifier in browser storage alone does not persist messages or guarantee recovery of an interrupted run.
import { createSession } from '@threadplane/langgraph';
export async function openConversation(apiUrl: string, confirmedThreadId: string) {
const session = createSession({ apiUrl, assistantId: 'persistence', threadId: confirmedThreadId });
if (!session.load) {
await session.dispose();
throw new Error('History loading is unavailable.');
}
try {
await session.load();
return session; // The application owns this session and its eventual disposal.
} catch (error) {
await session.dispose();
throw error;
}
}The React conversation walkthrough connects this capability to requests, tools, UI state, and saved history.
Create and revisit conversations
Choose Run and introduce a fictional name or preference. Wait for Response complete, choose New conversation, and send a different fictional fact. The Saved conversations picker lists the two confirmed conversations created on this page.
Select Conversation 1 to load its history from the server. The old transcript clears while history loads, and the composer and picker stay disabled until the read finishes. Ask the assistant to recall the first fictional fact, then select Conversation 2 to see its separate history. Switching conversations does not create a thread or start a model run.
The picker belongs to the current page. Reloading clears its list even though the server retains the conversations. This preview does not provide a server-wide conversation catalog, browser storage, history pagination, branching, deletion, or the Store API.
Ownership and rendering
A stable application owner records only confirmed thread IDs. Selecting a known conversation creates a fresh session bound to its ID and explicitly loads server history. Cached rows never authorize a selected session. React borrows snapshots with useAgent and renders the native MessageList and ChatInput alongside a semantic conversation picker.
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated consumer copies this internal example layout.
import { ExampleFooter, ExampleLayout } from '../../../../shared/react-example';
import { useEffect, useState } from 'react';
import { useAgent } from '@threadplane/react';
import { ChatInput, MessageList } from '@threadplane/react/chat';
import {
createConnectedApplication,
type persistenceConnection,
} from './connection';
export function PersistenceDemo({
connection,
onReady,
}: {
readonly connection: ReturnType<typeof persistenceConnection>;
readonly onReady: () => void;
}) {
const [application] = useState(() => createConnectedApplication(connection));
const snapshot = useAgent(application);
useEffect(() => {
onReady();
}, [onReady]);
useEffect(
() => () => {
void application.dispose();
},
[application]
);
const status = snapshot.busy
? snapshot.activity === 'loading'
? 'Loading saved conversation…'
: snapshot.activity === 'creating'
? 'Creating conversation…'
: snapshot.activity === 'replacing'
? 'Starting new conversation…'
: 'Receiving response…'
: snapshot.outcome === 'aborted'
? 'Stopped. Start a new conversation to continue.'
: !snapshot.canSubmit
? 'Start a new conversation to continue.'
: snapshot.outcome === 'success'
? 'Response complete.'
: 'Ready.';
return (
<ExampleLayout className="persistence-demo">
<header>
<div>
<h1>LangGraph persistence</h1>
</div>
<button
type="button"
disabled={snapshot.busy}
onClick={() => void application.newConversation()}
>
New conversation
</button>
</header>
<p>
Create two conversations, then switch between them to load their saved
history from the server.
</p>
<section aria-label="Saved conversations" className="saved-conversations">
<h2>Saved conversations</h2>
<p>
This list belongs to the current page. Reloading clears the list;
conversation history is saved on the server.
</p>
{snapshot.conversations.length ? (
<div className="conversation-picker">
{snapshot.conversations.map((entry) => (
<button
key={entry.id}
type="button"
aria-pressed={snapshot.selectedId === entry.id}
disabled={snapshot.busy || entry.availability === 'unavailable'}
onClick={() => void application.select(entry)}
>
{entry.label}
{entry.availability === 'unavailable' ? ' — unavailable' : ''}
</button>
))}
</div>
) : (
<p>No saved conversations yet.</p>
)}
</section>
<MessageList rows={snapshot.rows} label="Conversation" />
<ExampleFooter>
{snapshot.error && <p role="alert">{snapshot.error}</p>}
<ChatInput
key={snapshot.viewGeneration}
label="Message"
placeholder="Tell me a fictional fact…"
disabled={!snapshot.canSubmit}
busy={snapshot.busy}
onStop={snapshot.busy ? () => void application.stop() : undefined}
onSubmit={(text) => {
if (!application.getSnapshot().canSubmit) return false;
void application.submit(text);
}}
/>
<p role="status">{status}</p>
</ExampleFooter>
</ExampleLayout>
);
}Stop cancels active work. A stopped, failed, uncertain, or unexpectedly paused conversation becomes unavailable on this page. Choose New conversation or another available entry to continue. The owner blocks competing commands synchronously and fences late results after cancellation or disposal; a fulfilled history promise alone never grants command authority.
import type {
AgentSession,
AgentSnapshot,
CompleteOutcome,
} from '@threadplane/core';
import {
createMessageContent,
type MessageRow,
} from '@threadplane/content/messages';
export interface PersistenceSession extends Omit<AgentSession, 'getSnapshot'> {
getSnapshot(): AgentSnapshot & {
readonly interrupts: readonly { readonly value?: unknown }[];
};
load(options?: { readonly signal?: AbortSignal }): Promise<void>;
}
export interface ConversationEntry {
readonly id: string;
readonly label: string;
readonly availability: 'available' | 'unavailable';
}
export interface PersistenceSnapshot {
readonly selectedId: string | null;
readonly conversations: readonly ConversationEntry[];
readonly rows: readonly MessageRow[];
readonly activity: 'idle' | 'creating' | 'loading' | 'running' | 'replacing';
readonly busy: boolean;
readonly canSubmit: boolean;
readonly outcome: CompleteOutcome | null;
readonly error: string | null;
readonly viewGeneration: number;
}
/** Owns a page-local picker of confirmed threads and one selected session. */
export function createPersistenceApplication(options: {
readonly createThread: (signal: AbortSignal) => Promise<string>;
readonly sessionFactory: (id: string) => PersistenceSession;
}) {
type Lifetime = {
readonly session: PersistenceSession;
readonly content: ReturnType<typeof createMessageContent>;
release?: () => void;
closed: boolean;
};
const listeners = new Set<() => void>();
const cleanups = new Set<Promise<void>>();
let snapshot: PersistenceSnapshot = Object.freeze({
selectedId: null,
conversations: Object.freeze([]),
rows: Object.freeze([]),
activity: 'idle',
busy: false,
canSubmit: true,
outcome: null,
error: null,
viewGeneration: 0,
});
let lifetime: Lifetime | undefined;
let operation: AbortController | undefined;
let disposed = false;
let disposal: Promise<void> | undefined;
function publish(update: Partial<PersistenceSnapshot>) {
if (disposed) return;
snapshot = Object.freeze({ ...snapshot, ...update });
for (const notify of listeners) notify();
}
function unavailable(id = snapshot.selectedId) {
return Object.freeze(
snapshot.conversations.map((entry) =>
entry.id === id && entry.availability !== 'unavailable'
? Object.freeze({ ...entry, availability: 'unavailable' as const })
: entry
)
);
}
function unsafe(state: ReturnType<PersistenceSession['getSnapshot']>) {
return (
!!state.error ||
!!state.interrupts.length ||
!!state.toolCalls.length ||
state.messages.some((message) => !!message.toolCallIds?.length)
);
}
function close(selected: Lifetime): Promise<void> {
if (selected.closed) return Promise.resolve();
selected.closed = true;
selected.release?.();
selected.content.dispose();
const cleanup = Promise.resolve()
.then(() => selected.session.dispose())
.then(
() => undefined,
() => undefined
);
cleanups.add(cleanup);
void cleanup.then(() => cleanups.delete(cleanup));
return cleanup;
}
function detach(): Promise<void> {
const previous = lifetime;
lifetime = undefined;
return previous ? close(previous) : Promise.resolve();
}
function observe(selected: Lifetime) {
const current = () =>
!disposed &&
lifetime === selected &&
!selected.closed &&
!operation?.signal.aborted;
if (!current()) return;
const state = selected.session.getSnapshot();
if (!current()) return;
const blocked = unsafe(state);
if (!current()) return;
const rows = selected.content.project(state);
if (!current()) return;
publish({
rows,
...(blocked ? { canSubmit: false, conversations: unavailable() } : {}),
});
}
function install(id: string, current: () => boolean): Lifetime | undefined {
const session = options.sessionFactory(id);
const selected: Lifetime = {
session,
content: createMessageContent(),
closed: false,
};
if (!current()) {
void close(selected);
return;
}
lifetime = selected;
const release = session.subscribe(() => observe(selected));
selected.release = release;
if (selected.closed) release();
if (!current() || lifetime !== selected) {
if (!selected.closed) void close(selected);
return;
}
return selected;
}
function admit(activity: PersistenceSnapshot['activity']) {
const admitted = new AbortController();
operation = admitted;
const current = () =>
!disposed && operation === admitted && !admitted.signal.aborted;
publish({
busy: true,
canSubmit: false,
activity,
outcome: null,
error: null,
});
return { admitted, current };
}
function finish(admitted: AbortController) {
if (!disposed && operation === admitted) {
operation = undefined;
publish({ busy: false, activity: 'idle' });
}
}
function fail() {
publish({
canSubmit: false,
conversations: unavailable(),
outcome: 'error',
error: 'The LangGraph request failed.',
});
}
function block() {
publish({
canSubmit: false,
conversations: unavailable(),
outcome: 'paused',
});
}
async function submit(text: string) {
if (disposed || snapshot.busy || !snapshot.canSubmit || !text.trim())
return;
const previous = lifetime;
const initiallyBlocked = previous
? unsafe(previous.session.getSnapshot())
: false;
if (
disposed ||
snapshot.busy ||
!snapshot.canSubmit ||
lifetime !== previous ||
previous?.closed
)
return;
if (initiallyBlocked) {
block();
return;
}
const { admitted, current } = admit(lifetime ? 'running' : 'creating');
try {
if (!current()) return;
if (!lifetime) {
const id = await options.createThread(admitted.signal);
if (!current()) return;
const selected = install(id, current);
if (!selected || !current()) return;
const entry = Object.freeze({
id,
label: `Conversation ${snapshot.conversations.length + 1}`,
availability: 'available' as const,
});
publish({
selectedId: id,
conversations: Object.freeze([...snapshot.conversations, entry]),
activity: 'running',
});
}
if (!current() || !lifetime) return;
const selected = lifetime;
const beforeRun = selected.session.getSnapshot();
if (!current() || lifetime !== selected) return;
const blocked = unsafe(beforeRun);
if (!current() || lifetime !== selected) return;
if (blocked) {
block();
return;
}
const outcome = await selected.session.submit(text, {
signal: admitted.signal,
});
if (!current() || lifetime !== selected) return;
const state = selected.session.getSnapshot();
if (!current() || lifetime !== selected) return;
const safe =
outcome === 'success' &&
!unsafe(state) &&
snapshot.conversations.some(
(entry) =>
entry.id === snapshot.selectedId &&
entry.availability === 'available'
);
if (!current() || lifetime !== selected) return;
const rows = selected.content.project(state);
if (!current() || lifetime !== selected) return;
publish({
rows,
outcome,
canSubmit: safe,
...(safe ? {} : { conversations: unavailable() }),
error: outcome === 'error' ? 'The LangGraph request failed.' : null,
});
} catch {
if (current()) fail();
} finally {
finish(admitted);
}
}
async function select(entry: ConversationEntry) {
if (
disposed ||
snapshot.busy ||
!snapshot.conversations.includes(entry) ||
entry.id === snapshot.selectedId ||
entry.availability !== 'available'
)
return;
// Detach before publishing the new selection so old observers cannot mix rows.
const cleanup = detach();
const { admitted, current } = admit('loading');
try {
if (!current()) return;
publish({
selectedId: entry.id,
rows: Object.freeze([]),
viewGeneration: snapshot.viewGeneration + 1,
});
if (!current()) return;
await cleanup;
if (!current()) return;
const selected = install(entry.id, current);
if (!selected || !current()) return;
await selected.session.load({ signal: admitted.signal });
// Detached or aborted SDK reads may fulfill. Fulfillment grants no authority.
if (!current() || lifetime !== selected) return;
const state = selected.session.getSnapshot();
if (!current() || lifetime !== selected) return;
const blocked =
unsafe(state) ||
!snapshot.conversations.some(
(item) => item.id === entry.id && item.availability === 'available'
);
if (!current() || lifetime !== selected) return;
if (blocked) {
block();
return;
}
const rows = selected.content.project(state);
if (!current() || lifetime !== selected) return;
publish({ rows, canSubmit: true });
} catch {
if (current()) fail();
} finally {
finish(admitted);
}
}
async function newConversation() {
if (disposed || snapshot.busy) return;
const cleanup = detach();
const { admitted, current } = admit('replacing');
try {
if (!current()) return;
publish({
selectedId: null,
rows: Object.freeze([]),
viewGeneration: snapshot.viewGeneration + 1,
});
if (!current()) return;
await cleanup;
if (current()) publish({ canSubmit: true });
} finally {
finish(admitted);
}
}
async function stop() {
if (disposed || !operation) return;
operation.abort();
operation = undefined;
const selected = lifetime;
const cleanup = detach();
publish({
busy: false,
activity: 'idle',
canSubmit: false,
outcome: 'aborted',
conversations: unavailable(),
});
try {
await selected?.session.stop();
} catch {
/* No command reuse after uncertainty. */
}
await cleanup;
}
function dispose(): Promise<void> {
if (disposal) return disposal;
disposed = true;
operation?.abort();
operation = undefined;
void detach();
listeners.clear();
disposal = Promise.all([...cleanups]).then(() => undefined);
return disposal;
}
return {
getSnapshot: () => snapshot,
subscribe(notify: () => void) {
if (disposed) return () => undefined;
listeners.add(notify);
return () => {
listeners.delete(notify);
};
},
submit,
select,
newConversation,
stop,
dispose,
};
}The existing shared backend is used by default. A developer LangGraph target can be configured through runtime settings, with credentials passed as SDK headers. Creation and execution use zero SDK retries.
import { Client } from '@langchain/langgraph-sdk';
import { createSession } from '@threadplane/langgraph';
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated build copies this neutral internal bridge; it is not a published package.
import type { RuntimeBridgeConfiguration } from '../../../../../libs/cockpit-runtime-bridge/src/index';
import { createPersistenceApplication } from './application';
export function persistenceConnection(
configuration: RuntimeBridgeConfiguration
): {
apiUrl: string;
headers: Record<string, string>;
} {
if (
configuration.status === 'error' ||
(configuration.status === 'configured' &&
configuration.target.kind === 'ag-ui')
)
throw new Error('Runtime configuration failed.');
const target =
configuration.status === 'configured' &&
configuration.target.kind === 'langsmith'
? configuration.target
: null;
return {
apiUrl: target?.apiUrl ?? new URL('/api', window.location.origin).href,
headers: target ? { 'x-api-key': target.apiKey } : {},
};
}
export function createConnectedApplication(
connection: ReturnType<typeof persistenceConnection>
) {
const client = new Client({
apiUrl: connection.apiUrl,
apiKey: null,
defaultHeaders: connection.headers,
callerOptions: { maxRetries: 0 },
});
return createPersistenceApplication({
async createThread(signal) {
const threadId = crypto.randomUUID();
const created = await client.threads.create({
threadId,
ifExists: 'raise',
signal,
});
if (created.thread_id !== threadId)
throw new Error('Conversation creation was not confirmed.');
return threadId;
},
sessionFactory: (threadId) => {
const session = createSession({
assistantId: 'persistence',
threadId,
apiUrl: connection.apiUrl,
clientOptions: { maxRetries: 0, defaultHeaders: connection.headers },
});
if (typeof session.load !== 'function')
throw new Error('Conversation history is unavailable.');
return { ...session, load: session.load };
},
});
}Build from source
From a Threadplane checkout with dependencies installed:
npx nx build cockpit-langgraph-persistence-react
npx nx e2e cockpit-langgraph-persistence-reactThe isolated build installs local candidate packages and checks strict declarations and emitted browser modules. Browser tests use real SDK HTTP and SSE against a local fixture. Docs, Code, and Run select this same React implementation.