React chat theming preview
Give the same conversation a different visual theme without replacing its session. Colors, typography, and surfaces belong to presentation state, so changing a palette should preserve the draft and answer.
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.
Scope the theme to the conversation root
The example applies one of four classes around the native chat components. Those classes supply CSS tokens while MessageList, Markdown, and ChatInput continue reading the same owner. Keep the theme selection outside the session creation path; the code below makes a palette change a normal local React update.
import { useState, type ReactNode } from 'react';
export function ThemeScope({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState('dark');
return <section className={`trip-theme-${theme}`}>
<select aria-label="Theme" value={theme} onChange={(event) => setTheme(event.target.value)}>
<option value="dark">Dark</option><option value="light">Light</option>
</select>
{children}
</section>;
}The React conversation walkthrough connects this capability to requests, tools, UI state, and saved history.
Four local palettes
Choose Run and select Dark, Light, Ocean or Forest. The palette changes a class on this chat root. It preserves the same application owner, exact unsent draft, messages and active conversation during streaming and saved-history confirmation. Palette changes make no runtime requests, cancel no work and do not change the document theme. A second chat root can choose independently; reload restores Dark.
The eight React chat CSS tokens are --tp-chat-text, --tp-chat-muted, --tp-chat-border, --tp-chat-surface, --tp-chat-accent, --tp-chat-on-accent, --tp-chat-danger and --tp-chat-gap. These local presets demonstrate this composed React surface. The Angular guide retains its framework-specific token API.
body {
margin: 0;
background: var(--ds-bg, light-dark(#ffffff, #111111));
}
:root:has(.theming-demo--light) {
color-scheme: light;
}
:root:has(.theming-demo--dark, .theming-demo--ocean, .theming-demo--forest) {
color-scheme: dark;
}
.tp-chat.theming-demo.theming-demo--dark {
--tp-chat-text: #e0e0e0;
--tp-chat-muted: #a3a3a3;
--tp-chat-border: #767676;
--tp-chat-surface: #1c1c1c;
--tp-chat-accent: #3b82f6;
--tp-chat-on-accent: #07111f;
--tp-chat-danger: #ff8a80;
--tp-chat-gap: 1rem;
color-scheme: dark;
}
.tp-chat.theming-demo.theming-demo--light {
--tp-chat-text: #1a1a1a;
--tp-chat-muted: #6b7280;
--tp-chat-border: #858a94;
--tp-chat-surface: #ffffff;
--tp-chat-accent: #2563eb;
--tp-chat-on-accent: #ffffff;
--tp-chat-danger: #b42318;
--tp-chat-gap: 1rem;
color-scheme: light;
}
.tp-chat.theming-demo.theming-demo--ocean {
--tp-chat-text: #c8d6e5;
--tp-chat-muted: #a3b8cf;
--tp-chat-border: #607b9c;
--tp-chat-surface: #111b31;
--tp-chat-accent: #0abde3;
--tp-chat-on-accent: #07111f;
--tp-chat-danger: #ff8a80;
--tp-chat-gap: 1rem;
color-scheme: dark;
}
.tp-chat.theming-demo.theming-demo--forest {
--tp-chat-text: #d4e6d4;
--tp-chat-muted: #a7c0a7;
--tp-chat-border: #769076;
--tp-chat-surface: #203420;
--tp-chat-accent: #4ade80;
--tp-chat-on-accent: #102410;
--tp-chat-danger: #ff8a80;
--tp-chat-gap: 1rem;
color-scheme: dark;
}
.tp-chat.theming-demo {
--tp-chat-bg: var(--tp-chat-surface);
--tp-chat-surface-alt: color-mix(
in srgb,
var(--tp-chat-text) 7%,
var(--tp-chat-surface)
);
box-sizing: border-box;
width: 100%;
max-width: 56rem;
min-width: 0;
min-height: 100svh;
margin-inline: auto;
padding: clamp(1rem, 4vw, 2rem);
background: var(--tp-chat-surface);
color: var(--tp-chat-text);
font-family: system-ui, sans-serif;
line-height: 1.5;
overflow-wrap: anywhere;
}
.theming-demo .theming-demo__message-body {
--ds-text-primary: var(--tp-chat-text);
}
.theming-demo .theming-demo__header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--tp-chat-gap);
}
.theming-demo > h1,
.theming-demo > p {
margin-block: 0;
}
.theming-demo .theming-demo__eyebrow,
.theming-demo .theming-demo__status,
.theming-demo .theming-demo__tokens {
color: var(--tp-chat-muted);
}
.theming-demo .theming-demo__palettes {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.theming-demo button:where(:not(.tp-chat-input button)) {
padding: 0.6rem 1rem;
border: 1px solid var(--tp-chat-border);
border-radius: 0.5rem;
background: var(--tp-chat-surface);
color: var(--tp-chat-text);
font: inherit;
cursor: pointer;
}
.theming-demo button[aria-pressed='true'],
.theming-demo .tp-chat-input__send {
background: var(--tp-chat-accent);
border-color: var(--tp-chat-accent);
color: var(--tp-chat-on-accent);
}
.theming-demo button[aria-pressed='true'] {
font-weight: 700;
text-decoration: underline;
text-underline-offset: 0.25em;
}
.theming-demo button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.theming-demo
:is(button, textarea, summary, a):focus-visible:where(
:not(.tp-chat-input__textarea)
) {
outline: 2px solid var(--tp-chat-accent);
outline-offset: 3px;
}
.theming-demo ::selection {
background: var(--tp-chat-accent);
color: var(--tp-chat-on-accent);
}
.theming-demo .tp-chat-list {
min-height: 10rem;
}
.theming-demo .tp-chat-list,
.theming-demo .tp-chat-message,
.theming-demo .theming-demo__message-body {
min-width: 0;
max-width: 100%;
}
.theming-demo pre:where(:not(.tp-markdown *)) {
max-width: 100%;
overflow-x: auto;
}
.theming-demo .theming-demo__tokens {
border-top: 1px solid var(--tp-chat-border);
padding-top: 1rem;
}
.theming-demo summary {
cursor: pointer;
}
.theming-demo .theming-demo__tokens ul {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
gap: 0.5rem;
padding-inline-start: 1.25rem;
}
@media (max-width: 36rem) {
.theming-demo .theming-demo__header,
.theming-demo .theming-demo__palettes {
flex-direction: column;
align-items: stretch;
}
}Markdown uses its design-system text variable. The demo scopes a --ds-text-primary alias to --tp-chat-text around message and reasoning bodies; this is a demo-local bridge, not a ninth React chat token or a global override.
// 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,
Reasoning,
Citations,
} from '@threadplane/react/chat';
import { Markdown } from '@threadplane/react/markdown';
import type { MessageRow } from '@threadplane/content/messages';
import {
createConnectedApplication,
type themingConnection,
} from './connection';
import { themes, chatTokens, type Theme } from './themes';
/** Stable renderer: palettes keep the application's Markdown lifetimes. */
export function renderMessage(row: MessageRow) {
if (row.role === 'tool') return null;
const label =
row.role === 'user'
? 'You'
: row.role === 'assistant'
? 'Assistant'
: 'System';
return (
<article
aria-label={label}
className={`tp-chat-message tp-chat-message--${row.role}`}
>
<p className="tp-chat-message__role">{label}</p>
<div className="theming-demo__message-body">
{row.role === 'assistant' && row.reasoning && (
<Reasoning snapshot={row.reasoning} />
)}
<Markdown snapshot={row.markdown} />
</div>
{row.role === 'assistant' && row.message.citations && (
<Citations citations={row.message.citations} />
)}
</article>
);
}
export function ThemingDemo({
connection,
onReady,
applicationFactory = createConnectedApplication,
}: {
readonly connection: ReturnType<typeof themingConnection>;
readonly onReady: () => void;
readonly applicationFactory?: typeof createConnectedApplication;
}) {
const [application] = useState(() => applicationFactory(connection));
const [theme, setTheme] = useState<Theme>('dark');
const snapshot = useAgent(application);
useEffect(() => {
onReady();
}, [onReady]);
useEffect(
() => () => {
void application.dispose();
},
[application]
);
const status =
snapshot.activity === 'cleanup-failed'
? 'Reload the preview to continue.'
: snapshot.busy
? snapshot.activity === 'creating'
? 'Creating conversation…'
: snapshot.activity === 'confirming'
? 'Confirming saved response…'
: snapshot.activity === 'replacing'
? 'Starting a new conversation…'
: snapshot.rows.at(-1)?.role === 'assistant'
? 'Receiving response…'
: 'Waiting for the first response…'
: snapshot.outcome === 'success'
? 'Response saved.'
: snapshot.outcome === 'aborted'
? 'Stopped. Start a new conversation to continue.'
: !snapshot.canSubmit
? 'Start a new conversation to continue.'
: 'Ready.';
return (
<ExampleLayout
className={`tp-chat theming-demo theming-demo--${theme}`}
data-theme={theme}
>
<header className="theming-demo__header">
<div>
<h1>Chat theming</h1>
</div>
<button
type="button"
disabled={snapshot.busy || snapshot.activity === 'cleanup-failed'}
onClick={() => void application.newConversation()}
>
New conversation
</button>
</header>
<p>
Ask an aviation question and watch the answer arrive. Switch palettes at
any time; your conversation and draft stay in place. Continue after the
response is saved.
</p>
<div
className="theming-demo__palettes"
role="group"
aria-label="Chat palette"
>
{themes.map(({ id, label }) => (
<button
key={id}
type="button"
aria-pressed={theme === id}
onClick={() => setTheme(id)}
>
{label}
</button>
))}
</div>
<MessageList
rows={snapshot.rows}
renderMessage={renderMessage}
label="Conversation"
/>
<ExampleFooter>
{snapshot.error && (
<p role="alert" className="tp-chat__error">
{snapshot.error}
</p>
)}
<ChatInput
key={snapshot.viewGeneration}
label="Message"
placeholder="Ask an aviation question…"
busy={snapshot.busy}
disabled={
snapshot.activity === 'cleanup-failed' ||
(!snapshot.canSubmit && !snapshot.busy)
}
onStop={
snapshot.busy && snapshot.activity !== 'cleanup-failed'
? () => void application.stop()
: undefined
}
onSubmit={(text) => {
const current = application.getSnapshot();
// A retained callback belongs to its rendered conversation generation.
if (
current.viewGeneration !== snapshot.viewGeneration ||
!current.canSubmit ||
current.busy ||
!text.trim()
)
return false;
void application.submit(text, snapshot.viewGeneration);
return true;
}}
/>
<p role="status" className="theming-demo__status">
{status}
</p>
</ExampleFooter>
<details className="theming-demo__tokens" aria-label="Chat CSS variables">
<summary>Eight chat CSS tokens</summary>
<p>
Each palette sets these properties on this chat. Try a different
palette while keeping your draft.
</p>
<ul>
{chatTokens.map((token) => (
<li key={token}>
<code>{token}</code>
</li>
))}
</ul>
</details>
</ExampleLayout>
);
}Ask and continue
Only Send creates a confirmed server thread and starts the existing aviation graph. Ask an aviation question, then wait for Response saved before following up in the same conversation. Saved history must retain the exact confirmed prefix and current question/answer identities; streamed text alone cannot authorize continuation. Theme selection remains available while the owner is busy.
New conversation clears local state and creates nothing until Send. Stop revokes active delivery; stopped, failed or uncertain work requires a new conversation without automatic retry or replay. Cleanup failure permanently disables authoring and requires reload. These browser ownership checks do not claim a durable external store or backend cancellation acknowledgment.
import type {
AgentSession,
AgentSnapshot,
CompleteOutcome,
Message,
} from '@threadplane/core';
import {
createMessageContent,
type MessageRow,
} from '@threadplane/content/messages';
export interface ThemingSession extends Omit<AgentSession, 'getSnapshot'> {
getSnapshot(): AgentSnapshot & {
readonly interrupts: readonly unknown[];
readonly subgraphs: readonly unknown[];
readonly history:
| readonly {
readonly next: readonly string[];
readonly checkpoint: {
readonly thread_id: string;
readonly checkpoint_ns: string;
};
}[]
| undefined;
};
load(options?: { readonly signal?: AbortSignal }): Promise<void>;
}
export interface ThemingSnapshot {
readonly threadId: string | null;
readonly rows: readonly MessageRow[];
readonly activity:
| 'idle'
| 'creating'
| 'running'
| 'confirming'
| 'replacing'
| 'cleanup-failed';
readonly busy: boolean;
readonly canSubmit: boolean;
readonly outcome: CompleteOutcome | null;
readonly error: string | null;
readonly viewGeneration: number;
}
const equal = (a: Message, b: Message) =>
!!b &&
a.id === b.id &&
a.role === b.role &&
a.content === b.content &&
a.reasoning === b.reasoning &&
a.name === b.name &&
JSON.stringify(a.citations) === JSON.stringify(b.citations);
const complete = (m: Message) =>
m.delivery.phase === 'complete' && m.delivery.outcome === 'success';
type Methods = Pick<
ThemingSession,
'getSnapshot' | 'subscribe' | 'submit' | 'load' | 'stop' | 'dispose'
>;
type Owner = {
threadId: string;
session: ThemingSession;
methods: Methods;
content: ReturnType<typeof createMessageContent>;
release?: () => void;
closed: boolean;
unsafe: boolean;
confirmed: readonly Message[];
generations: Set<string>;
loading?: boolean;
turn?: { text: string; human?: Message; answer?: Message };
};
// The application owns its frontend aggregate separately from the native session.
// Copy evidence before exposing rows so caller-owned mutation cannot rewrite history.
function ownSnapshot(state: ReturnType<ThemingSession['getSnapshot']>) {
return Object.freeze({
status: state.status,
error: state.error,
messages: Object.freeze(
state.messages.map((m) =>
Object.freeze({
...m,
delivery: Object.freeze({ ...m.delivery }),
...(m.toolCallIds
? { toolCallIds: Object.freeze([...m.toolCallIds]) }
: {}),
...(m.citations
? {
citations: Object.freeze(
m.citations.map((c) => Object.freeze(structuredClone(c)))
),
}
: {}),
})
)
),
toolCalls: Object.freeze([...state.toolCalls]),
interrupts: Object.freeze([...state.interrupts]),
subgraphs: Object.freeze([...state.subgraphs]),
history:
state.history === undefined
? undefined
: Object.freeze(
state.history.map((entry) =>
Object.freeze({
next: Object.freeze([...entry.next]),
checkpoint: Object.freeze({ ...entry.checkpoint }),
})
)
),
});
}
/** One local conversation owner; only a saved, terminal transcript grants continuation. */
export function createThemingApplication(options: {
readonly createThread: (signal: AbortSignal) => Promise<string>;
readonly sessionFactory: (
id: string
) => ThemingSession | Promise<ThemingSession>;
}) {
let owner: Owner | undefined, operation: AbortController | undefined;
let disposed = false,
revoking = false,
cleanupFailed = false,
disposal: Promise<void> | undefined;
const listeners = new Set<() => void>(),
cleanups = new Set<Promise<void>>();
let snapshot: ThemingSnapshot = Object.freeze({
threadId: null,
rows: Object.freeze([]),
activity: 'idle',
busy: false,
canSubmit: true,
outcome: null,
error: null,
viewGeneration: 0,
});
function publish(update: Partial<ThemingSnapshot>) {
if (disposed) return;
snapshot = Object.freeze({
...snapshot,
...update,
...(cleanupFailed
? {
canSubmit: false,
activity: 'cleanup-failed' as const,
outcome: 'error' as const,
error:
'The conversation could not close safely. Reload the preview to continue.',
}
: {}),
});
for (const notify of [...listeners]) {
try {
notify();
} catch {
/* Observers cannot prevent teardown. */
}
}
}
function failCleanup() {
cleanupFailed = true;
publish({ canSubmit: false });
}
function track(promise: Promise<unknown>) {
const done = promise.then(
() => undefined,
() => undefined
);
cleanups.add(done);
void done.then(() => cleanups.delete(done));
return done;
}
async function drain() {
while (cleanups.size) await Promise.all([...cleanups]);
}
// Register settlement before calling potentially reentrant user/native code.
function owned<T>(work: () => T | Promise<T>): Promise<T> {
let resolve!: (value: T | PromiseLike<T>) => void,
reject!: (error: unknown) => void;
const pending = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
track(pending);
try {
resolve(work());
} catch (error) {
reject(error);
}
return pending;
}
function cleanupCandidate(
candidate: unknown,
methods: Partial<Methods> = {},
release?: () => void,
content?: Owner['content'],
stopping = false
) {
const done = track(
Promise.resolve()
.then(async () => {
let dispose = methods.dispose,
stop = methods.stop;
try {
dispose ??= (candidate as ThemingSession)?.dispose;
} catch {
failCleanup();
}
try {
stop ??= (candidate as ThemingSession)?.stop;
} catch {
failCleanup();
}
if (stopping && typeof stop === 'function') {
try {
await stop.call(candidate);
} catch {
failCleanup();
}
}
if (typeof dispose === 'function') {
try {
await dispose.call(candidate);
return;
} catch {
failCleanup();
}
}
if (typeof stop === 'function') {
try {
await stop.call(candidate);
} catch {
failCleanup();
}
} else failCleanup();
})
.catch(() => {
failCleanup();
})
);
try {
release?.();
} catch {
failCleanup();
}
try {
content?.dispose();
} catch {
failCleanup();
}
return done;
}
function close(selected: Owner, stopping = false) {
selected.closed = true;
const release = selected.release;
selected.release = undefined;
return cleanupCandidate(
selected.session,
selected.methods,
release,
selected.content,
stopping
);
}
async function install(signal: AbortSignal, valid: () => boolean) {
const createThread = options.createThread;
if (!valid()) return;
const id = await createThread(signal);
if (!valid()) return;
if (typeof id !== 'string' || !id || id.length > 500)
throw Error('Unconfirmed conversation');
const factory = options.sessionFactory;
if (!valid()) return;
const candidate = await factory(id);
if (!valid()) {
await cleanupCandidate(candidate);
return;
}
const methods: Partial<Methods> = {};
let selected: Owner | undefined, release: (() => void) | undefined;
try {
for (const name of [
'dispose',
'stop',
'getSnapshot',
'subscribe',
'submit',
'load',
] as const) {
let method;
try {
method = candidate[name];
} catch (error) {
if (name === 'dispose' || name === 'stop') failCleanup();
throw error;
}
if (typeof method !== 'function')
throw Error('Runtime session is unavailable.');
Object.defineProperty(methods, name, {
value: method,
enumerable: true,
});
if (!valid()) {
await cleanupCandidate(candidate, methods);
return;
}
}
const native = (methods as Methods).getSnapshot.call(candidate);
if (!valid()) {
await cleanupCandidate(candidate, methods);
return;
}
const initial = ownSnapshot(native);
if (!valid()) {
await cleanupCandidate(candidate, methods);
return;
}
if (
unsafe(initial, id) ||
initial.status !== 'idle' ||
initial.messages.length
)
throw Error('Runtime session is unavailable.');
selected = {
threadId: id,
session: candidate,
methods: methods as Methods,
content: createMessageContent(),
closed: false,
unsafe: false,
confirmed: [],
generations: new Set(),
};
const p = selected;
const subscribed = p.methods.subscribe.call(candidate, () => {
const current = () =>
!disposed && !cleanupFailed && owner === p && !p.closed;
if (!current()) return;
try {
read(p, current);
} catch {
if (current()) {
p.unsafe = true;
blocked();
}
}
});
if (typeof subscribed !== 'function')
throw Error('A callable release is required.');
release = subscribed;
if (!valid()) {
selected.closed = true;
await cleanupCandidate(candidate, methods, release, selected.content);
return;
}
selected.release = release;
owner = selected;
publish({ threadId: id, activity: 'running' });
return selected;
} catch (error) {
if (selected) selected.closed = true;
await cleanupCandidate(candidate, methods, release, selected?.content);
throw error;
}
}
function unsafe(
state: ReturnType<ThemingSession['getSnapshot']>,
threadId: string,
historyRequired = false
) {
return (
!state ||
!Array.isArray(state.messages) ||
!Array.isArray(state.toolCalls) ||
!Array.isArray(state.interrupts) ||
!Array.isArray(state.subgraphs) ||
!!state.error ||
state.status === 'error' ||
!!state.toolCalls.length ||
!!state.interrupts.length ||
!!state.subgraphs.length ||
(historyRequired && !state.history?.length) ||
(!!state.history?.length &&
(state.history[0].checkpoint?.thread_id !== threadId ||
state.history[0].checkpoint?.checkpoint_ns !== '' ||
state.history[0].next.length !== 0)) ||
new Set(state.messages.map((m) => m.id)).size !== state.messages.length ||
state.messages.some(
(m) =>
m.role === 'tool' ||
m.toolCallId !== undefined ||
!!m.toolCallIds?.length
)
);
}
function capture(
selected: Owner,
state: ReturnType<ThemingSession['getSnapshot']>
) {
const prefix = selected.confirmed,
messages = state.messages;
if (
messages.length < prefix.length ||
prefix.some(
(m, i) =>
!equal(m, messages[i]) ||
!complete(messages[i]) ||
m.delivery.generation !== messages[i].delivery.generation
)
)
selected.unsafe = true;
const turn = selected.turn;
if (!turn) {
// Without an admitted turn, only the exact confirmed transcript at idle is owned.
selected.unsafe ||=
state.status !== 'idle' || messages.length !== prefix.length;
return;
}
if (selected.unsafe) return;
const expected = [turn.human, turn.answer];
const suffix = messages.slice(prefix.length);
// Observed members retain their positions and terminal success; content may be revised.
if (
suffix.some(
(message) => message.delivery.phase === 'complete' && !complete(message)
) ||
expected.some(
(message, index) =>
message &&
(suffix[index]?.id !== message.id ||
(message.delivery.phase === 'complete' && !complete(suffix[index])))
)
) {
selected.unsafe = true;
return;
}
if (snapshot.activity === 'confirming') {
if (
suffix.length !== 2 ||
expected.some(
(message, index) =>
!message ||
!equal(message, suffix[index]) ||
!complete(suffix[index]) ||
(suffix[index].delivery.generation !==
message.delivery.generation &&
(!selected.loading ||
suffix[index].delivery.generation !== suffix[index].id))
)
)
selected.unsafe = true;
return;
}
if (snapshot.activity !== 'running') return;
if (!suffix.length) return;
const human = suffix[0];
if (
human.role !== 'user' ||
human.content !== turn.text ||
!complete(human) ||
selected.generations.has(human.delivery.generation) ||
prefix.some((m) => m.id === human.id) ||
(turn.human &&
(!equal(turn.human, human) ||
turn.human.delivery.generation !== human.delivery.generation))
) {
selected.unsafe = true;
return;
}
turn.human ??= human;
if (suffix.length > 2) {
selected.unsafe = true;
return;
}
const answer = suffix[1];
if (!answer) return;
if (
answer.role !== 'assistant' ||
answer.id === human.id ||
prefix.some((m) => m.id === answer.id) ||
answer.delivery.generation !== human.delivery.generation ||
(turn.answer && turn.answer.id !== answer.id)
) {
selected.unsafe = true;
return;
}
turn.answer = answer;
}
function read(selected: Owner, valid: () => boolean) {
if (!valid()) return;
const native = selected.methods.getSnapshot.call(selected.session);
if (!valid()) return;
const state = ownSnapshot(native);
if (!valid()) return;
// The native session retains its last explicit history page during a run.
// Once confirmed, losing terminal history revokes this owner's continuation.
selected.unsafe ||= unsafe(
state,
selected.threadId,
selected.confirmed.length > 0
);
if (!selected.unsafe) capture(selected, state);
if (!valid()) return;
if (Array.isArray(state?.messages) && Array.isArray(state?.toolCalls)) {
const rows = selected.content.project(state);
if (!valid()) return;
publish({ rows, ...(selected.unsafe ? { canSubmit: false } : {}) });
}
if (!valid()) return;
const latest = selected.methods.getSnapshot.call(selected.session);
if (!valid()) return;
// Subscribers may replace the observation while rows are being published.
const changed =
latest !== native ||
JSON.stringify(ownSnapshot(latest)) !== JSON.stringify(state);
if (!valid()) return;
if (changed) {
selected.unsafe = true;
publish({ canSubmit: false });
return;
}
return state;
}
function blocked() {
publish({
canSubmit: false,
outcome: 'paused',
error: 'Start a new conversation to continue.',
});
}
async function submit(text: string, generation = snapshot.viewGeneration) {
if (
disposed ||
cleanupFailed ||
revoking ||
snapshot.busy ||
!snapshot.canSubmit ||
generation !== snapshot.viewGeneration ||
typeof text !== 'string' ||
!text.trim()
)
return;
const admitted = new AbortController();
operation = admitted;
const current = () =>
!disposed &&
!cleanupFailed &&
operation === admitted &&
!admitted.signal.aborted;
publish({
busy: true,
canSubmit: false,
activity: owner ? 'running' : 'creating',
outcome: null,
error: null,
});
try {
if (!current()) return;
const selected =
owner ?? (await owned(() => install(admitted.signal, current)));
if (!selected || !current()) return;
const valid = () => current() && owner === selected && !selected.closed;
const before = read(selected, valid);
if (!valid()) return;
if (
!before ||
before.status !== 'idle' ||
selected.unsafe ||
before.messages.length !== selected.confirmed.length
) {
blocked();
return;
}
selected.turn = { text };
const result = await selected.methods.submit.call(
selected.session,
text,
{ signal: admitted.signal }
);
if (!valid()) return;
const live = read(selected, valid),
turn = selected.turn;
if (!valid()) return;
if (
result !== 'success' ||
!live ||
live.status !== 'idle' ||
selected.unsafe ||
!turn?.human ||
!turn.answer ||
!complete(turn.answer) ||
live.messages.length !== selected.confirmed.length + 2
) {
blocked();
return;
}
const expected = Object.freeze([
...selected.confirmed,
turn.human,
turn.answer,
]);
publish({ activity: 'confirming' });
if (!valid()) return;
if (!read(selected, valid) || selected.unsafe) {
if (valid()) blocked();
return;
}
selected.loading = true;
await selected.methods.load.call(selected.session, {
signal: admitted.signal,
});
if (!valid()) return;
const saved = read(selected, valid);
if (!valid()) return;
if (
!saved ||
selected.unsafe ||
saved.status !== 'idle' ||
!saved.history?.length ||
!Array.isArray(saved.history[0].next) ||
saved.history[0].next.length ||
saved.messages.length !== expected.length ||
expected.some(
(m, i) =>
!equal(m, saved.messages[i]) ||
!complete(saved.messages[i]) ||
saved.messages[i].delivery.generation !== saved.messages[i].id
)
) {
blocked();
return;
}
selected.confirmed = Object.freeze([...saved.messages]);
selected.generations.add(turn.human.delivery.generation);
selected.loading = false;
selected.turn = undefined;
publish({ outcome: 'success', canSubmit: true });
} catch {
if (current())
publish({
canSubmit: false,
outcome: 'error',
error:
'The conversation could not be confirmed. Start a new conversation.',
});
} finally {
if (!disposed && operation === admitted) {
operation = undefined;
publish({ busy: false, activity: 'idle' });
}
}
}
async function revoke(replacing: boolean) {
if (disposed || cleanupFailed || revoking) return;
revoking = true;
const previous = operation,
selected = owner;
operation = undefined;
owner = undefined;
if (selected) selected.closed = true;
// Keep reentrant disposal pending until cleanup has been registered.
let prepared!: () => void;
track(
new Promise<void>((resolve) => {
prepared = resolve;
})
);
const admitted = new AbortController();
operation = admitted;
publish({
viewGeneration: snapshot.viewGeneration + 1,
busy: true,
canSubmit: false,
activity: replacing ? 'replacing' : 'idle',
outcome: replacing ? null : 'aborted',
error: null,
...(replacing ? { threadId: null, rows: Object.freeze([]) } : {}),
});
try {
previous?.abort();
if (selected) void close(selected, !replacing);
} finally {
prepared();
}
try {
await drain();
if (!disposed && operation === admitted)
publish({ canSubmit: replacing && !cleanupFailed });
} finally {
revoking = false;
if (!disposed && operation === admitted) {
operation = undefined;
publish({ busy: false, activity: 'idle' });
}
}
}
function dispose(): Promise<void> {
if (disposal) return disposal;
let settled!: () => void;
disposal = new Promise<void>((resolve) => {
settled = resolve;
});
disposed = true;
const previous = operation,
selected = owner;
operation = undefined;
owner = undefined;
if (selected) selected.closed = true;
snapshot = Object.freeze({
...snapshot,
viewGeneration: snapshot.viewGeneration + 1,
busy: false,
canSubmit: false,
});
listeners.clear();
previous?.abort();
if (selected) void close(selected);
void drain().then(settled);
return disposal;
}
return {
getSnapshot: () => snapshot,
subscribe(notify: () => void) {
if (disposed) return () => undefined;
listeners.add(notify);
return () => {
listeners.delete(notify);
};
},
submit,
stop: () => revoke(false),
newConversation: () => revoke(true),
dispose,
};
}One configured LangGraph target serves thread creation, streaming and saved history. The shared backend reuses c-theming. The title model carries TAG_NOSTREAM, so title generation updates thread metadata without entering the visible answer stream.
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 { createThemingApplication, type ThemingSession } from './application';
export function themingConnection(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 themingConnection>
) {
const apiUrl = connection.apiUrl;
const headers = Object.freeze({ ...connection.headers });
const client = new Client({
apiUrl,
apiKey: null,
defaultHeaders: headers,
callerOptions: { maxRetries: 0 },
});
return createThemingApplication({
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: 'c-theming',
threadId,
apiUrl,
clientOptions: { maxRetries: 0, defaultHeaders: headers },
});
return session as ThemingSession;
},
});
}"""
Chat Theming Graph
A simple conversational agent. The focus of this capability is on
frontend theming with CSS custom properties, not graph complexity.
"""
import os
from pathlib import Path
from langgraph.graph import StateGraph, MessagesState, END
from langgraph.constants import TAG_NOSTREAM
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from langgraph_sdk import get_client
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
# ── generate_title node (inline; matches Pattern D from spec
# 2026-05-19-llm-generated-labels-design.md) ──────────────────────────────
_TITLE_PROMPT = (
"In 3-5 words, summarize what the user is asking about. "
"Output ONLY the title — no quotes, no period, no prefix."
)
_TITLE_MODEL = "gpt-5-mini"
async def generate_title(state: MessagesState, config) -> dict:
"""Background title generation: on the first turn, summarize the user's
intent into 3-5 words and persist to LangGraph thread metadata.
Idempotent — skips when metadata.title already exists. Errors are
swallowed because the title is a UX nicety, never a blocker.
"""
thread_id = (config.get("configurable") or {}).get("thread_id")
if not thread_id:
return {}
sdk_url = os.environ.get("LANGGRAPH_API_URL")
try:
client = get_client(url=sdk_url)
thread = await client.threads.get(thread_id)
if (thread.get("metadata") or {}).get("title"):
return {}
first_user = next(
(m for m in state["messages"] if getattr(m, "type", None) == "human"),
None,
)
if not first_user or not isinstance(first_user.content, str):
return {}
if first_user.content.lstrip().startswith("{"):
return {}
llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0, tags=[TAG_NOSTREAM])
response = await llm.ainvoke([
SystemMessage(content=_TITLE_PROMPT),
HumanMessage(content=first_user.content),
])
title = (response.content or "").strip().strip('"').strip("'")[:80]
if title:
await client.threads.update(thread_id, metadata={"title": title})
except Exception as e: # noqa: BLE001 — title is a UX nicety; never block
print(
f"[generate_title] failed for thread {thread_id}: "
f"{type(e).__name__}: {e}",
flush=True,
)
return {}
# region theming-graph
def build_theming_graph():
"""
Constructs a simple conversational agent for demonstrating
chat theming and CSS custom property customization.
"""
llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
async def generate(state: MessagesState) -> dict:
system_prompt = (PROMPTS_DIR / "theming.md").read_text()
messages = [SystemMessage(content=system_prompt)] + state["messages"]
response = await llm.ainvoke(messages)
return {"messages": [response]}
graph = StateGraph(MessagesState)
graph.add_node("generate", generate)
graph.add_node("generate_title", generate_title)
graph.set_entry_point("generate")
graph.add_edge("generate", "generate_title")
graph.add_edge("generate_title", END)
return graph.compile()
# endregion
graph = build_theming_graph()Build from source
npx nx build cockpit-chat-theming-react
npx nx e2e cockpit-chat-theming-reactThe isolated build consumes installed candidate packages. Local browser tests exercise actual SDK HTTP and SSE from the compiled Python graph with deterministic providers; they do not prove live provider execution. Docs, Code and Run use the same React implementation and preserve the mounted iframe and draft across mode navigation. Angular remains the default, with its own components and tokens.