The chat UI
The backend streams and persists turns; now it needs a face. In this chapter you copy the
fresh-ui ai component collection into your app, seed the first paint from
resolveChatSnapshot, and hydrate a chat island that drives createNetScriptChatConnection
— subscribe, send, dispose. The transcript renders complete on load and updates as turns
settle.
Step 1 — Copy the AI component collection
The chat primitives are copy-source: the CLI copies them into your app, where you own
them. The ai collection installs the whole chat surface in one command — message,
prompt-input, markdown, the chat-render block parser, and the tool-call and citation
components you use in chapter 4:
netscript ui:add ai
This copies component files into apps/dashboard/components/ui/, the chat-render parser
into apps/dashboard/lib/chat/parse-blocks.ts, and their CSS into apps/dashboard/assets/ui/,
then wires the styles and merges any required imports. After the copy, that code is yours to
edit — see Customize Fresh UI for the ownership model.
| Name | Type | Description |
|---|---|---|
message |
chat message |
Author + time, inline markup (bold / code / [n] citation chips), tool-call + chart/code blocks, follow-up chips, typing indicator. Exports Message, renderInline, TypingIndicator. |
prompt-input |
composer |
Auto-grow textarea + toolbar. Presentational |
markdown |
renderer |
Sanitized streaming-markdown component + pipeline for assistant prose. |
chat-render |
block parser (lib) |
parseBlocks(input): RenderPart[] — turns assistant markdown into typed rich blocks. Never throws. |
tool-call-card |
tool disclosure |
Tool invocation + result as a with a status badge (used in chapter 4). |
citation-chip |
citation |
Inline [n] source marker that pairs with a sources list (used in chapter 4). |
Step 2 — Seed the first paint
In the page that hosts the chat, materialize the transcript so far and pass it into the
island. resolveChatSnapshot returns { messages, renderParts, offset }; the offset
seeds the live subscription so seed and live read one continuous log:
// In your chat page route (server side)
import { resolveChatSnapshot } from '@netscript/fresh/ai';
const sessionId = 'demo-1'; // from the route params / the signed-in user's session
const seed = await resolveChatSnapshot({ target: { sessionId } });
// Render <Chat sessionId={sessionId} seed={seed} /> as an island.
Wire seed into your page with the scaffold's page builder the same way the dashboard
passes data to a view — see Customize Fresh UI for the
definePage pattern. The important part is that the island receives seed as a prop.
Step 3 — The chat island
The island is where the browser hydrates. It holds the snapshot in a signal, opens a durable
connection pointed at the proxy from chapter 2, and on submit: appends the user message with
connection.send, fires the model turn, then re-materializes the settled transcript.
// apps/dashboard/islands/Chat.tsx
import { useSignal } from '@preact/signals';
import { createNetScriptChatConnection, resolveChatSnapshot } from '@netscript/fresh/ai';
import type { NetScriptChatMessage, NetScriptChatSnapshot } from '@netscript/fresh/ai';
import { Message, type MessageData } from '@app/components/ui/message.tsx';
import { PromptInput } from '@app/components/ui/prompt-input.tsx';
const toMessageData = (m: NetScriptChatMessage): MessageData => ({
role: m.role === 'assistant' ? 'assistant' : 'user',
author: { name: m.role === 'assistant' ? 'Assistant' : 'You', agent: m.role === 'assistant' },
body: m.content,
});
interface ChatProps {
sessionId: string;
seed: NetScriptChatSnapshot;
}
const Chat = ({ sessionId, seed }: ChatProps) => {
const snapshot = useSignal(seed);
const pending = useSignal(false);
const base = `${location.origin}/api/chat-stream`;
const connection = createNetScriptChatConnection({
target: { sessionId, baseUrl: base },
initialOffset: seed.offset ?? undefined,
authorize: () => true, // browser half; the server route re-checks ownership
});
const onSubmit = async (text: string) => {
pending.value = true;
// 1. Append the user message to the durable session.
await connection.send([{ id: crypto.randomUUID(), role: 'user', content: text }]);
// 2. Fire the model turn (chapter 2's route streams + persists the reply).
await fetch(`/api/chat/${sessionId}`, { method: 'POST' });
// 3. Re-materialize the settled transcript through the same projection.
snapshot.value = await resolveChatSnapshot({ target: { sessionId, baseUrl: base } });
pending.value = false;
};
return (
<div class='ns-stack'>
<div class='ns-stack'>
{snapshot.value.messages.map((m) => (
<Message key={m.id} message={toMessageData(m)} />
))}
{pending.value
? <Message message={ { role: 'assistant', author: { name: 'Assistant', agent: true }, pending: true } } />
: null}
</div>
<PromptInput onSubmit={onSubmit} placeholder='Ask anything…' />
</div>
);
};
export default Chat;
A few rules keep the island cheap and correct: keep it leaf-shaped, pass plain serializable
props in (the seed snapshot is serializable), and declare handlers as arrow functions. The
connection's close / stop / dispose are one idempotent teardown — call
connection.dispose() when the island unmounts so no subscription leaks.
Step 4 — Rich blocks with chat-render
Assistant replies are markdown, and they can embed fenced data blocks (chart, donut,
table, stats, line). parseBlocks from the copied chat-render parser turns that
markdown into typed presentation parts you render with the copied primitives:
import { parseBlocks, type RenderPart } from '@app/lib/chat/parse-blocks.ts';
import { Markdown } from '@app/components/ui/markdown.tsx';
import { ChartBlock } from '@app/components/ui/chart-block.tsx';
const renderPart = (part: RenderPart) => {
switch (part.kind) {
case 'text':
return <Markdown>{part.text}</Markdown>;
case 'chart':
return <ChartBlock title={part.title} data={part.data} unit={part.unit} />;
// donut / table / stats / line follow the same shape → their primitive.
default:
return null;
}
};
// Render an assistant message body as rich blocks:
// {parseBlocks(message.content).map(renderPart)}
parseBlocks never throws — an unrecognized fence falls back to a verbatim text part — and
its inverse blockToText guarantees a parsed message survives a reload without drift.
Verify your progress
With aspire start running, open the app in the browser:
- The chat renders your existing transcript on load — no loading flash.
- Type a message and send it; the assistant reply appears once the turn settles.
- Reload the page. The full transcript is still there — that is the durable session, not browser state.
- Type-check the app:
deno task --cwd apps/dashboard check
- [ ]
netscript ui:add aicopied the chat components intocomponents/ui/and the parser intolib/chat/. - [ ] The chat renders the seeded transcript on first paint.
- [ ] Sending a message produces a persisted assistant reply.
- [ ] A reload replays the full transcript.
- [ ]
deno task --cwd apps/dashboard checkis clean.
What you built
A working chat UI: copied, app-owned components; an SSR-seeded island that sends through the
durable connection and re-renders on settle; and chat-render turning assistant markdown
into rich blocks. Next you give the model a tool to call — and render its result as a
tool-call card with citation chips.