overview
A reusable sidebar-chat solution: conversation navigation, isolated drafts and history, a message thread, and a replaceable streaming transport. Reuse the state model and behavioral rules in your own framework and visual design. The optional TypeScript controller is a tested reference, not a required file to copy.
structure
{
"Views": {
"thread": "messages for activeId",
"sidebar": "create, select, delete conversations",
"composer": "edits that conversation's draft; send or stop"
},
"Message": {
"id": "stable unique string",
"role": "user | assistant",
"text": "plain text",
"status": "complete | streaming | stopped | error"
},
"Transport": "(complete message history, AbortSignal) -> async iterable of text chunks",
"Workspace": {
"error": "recoverable response error",
"busyId": "conversation receiving the one active response, or null",
"activeId": "visible conversation",
"storageError": "persistence warning",
"conversations": "ordered Conversation[]"
},
"Conversation": {
"id": "stable unique string",
"draft": "unsent text owned by this conversation",
"title": "first submitted message, shortened",
"messages": "ordered Message[]"
}
}behavior
[
"New conversation: allocate a stable ID, an empty draft and messages, then select it. Switching only changes activeId; drafts remain attached to their conversations.",
"Send: reject blank text or a second concurrent generation. Capture the conversation ID and a new request controller; append the user message, clear only its draft, and create a streaming assistant message.",
"Stream: append chunks to the captured reply, even if the user switches conversations. UI updates must not redirect output into the newly selected chat.",
"Stop: abort the request and mark its reply stopped. Check the aborted flag before appending every chunk, because an adapter may ignore cancellation. A stale request's finalizer must not clear a newer request.",
"Failure: preserve user messages and partial output, show a recoverable error, and release the generation lock. This reference allows another send, not automatic retry.",
"Delete: cancel generation if it belongs to the deleted conversation. Choose a remaining conversation, or create an empty one when the last is deleted.",
"Persistence: save versioned conversations, drafts and selection. Validate restored data, turn interrupted streaming messages into stopped messages, and recover to a usable session if storage fails."
]
decisions
[
"One active response per workspace keeps cancellation and send controls simple. Parallel per-conversation generation needs a request map instead of one controller.",
"Only complete messages enter subsequent model history. Stopped and failed assistant text remains visible but is excluded from the transport's context.",
"Keep model credentials on a server. The transport adapter translates the server protocol into text chunks and forwards cancellation.",
"Render responses as text by default. Markdown needs a deliberate renderer and safe HTML policy.",
"Enter sends, Shift+Enter inserts a newline, and IME composition must not submit.",
"Follow scroll only near the bottom, after sending, or after switching chats. Use a collapsible conversation drawer on narrow screens.",
"Local storage is a small-example choice: plain text, one browser, no synchronization. Namespace it per workspace and move to batched IndexedDB or server persistence as histories grow."
]
reuse
Read structure and behavior first. Map the entities into the destination app's state system, implement the sidebar/thread/composer using its UI conventions, then connect its server transport. Consult reference.controller only when useful for cancellation, persistence, and request ownership. Validate: drafts survive switching; streaming stays with the originating chat; late chunks after Stop are ignored; deleting the active chat is safe; reload restores history; failures leave the UI usable. No particular page layout, CSS, or complete component file is required.
reference.controller
Optional original TypeScript reference. Includes a clearly simulated demo adapter; no live AI backend.
typescript export type Message = {
id: string;
role: 'user' | 'assistant';
text: string;
status: 'complete' | 'streaming' | 'stopped' | 'error';
};
export type Conversation = { id: string; title: string; draft: string; messages: Message[] };
export type ChatState = {
conversations: Conversation[];
activeId: string;
busyId: string | null;
error: string | null;
storageError: string | null;
};
export type Transport = (
messages: ReadonlyArray<Pick<Message, 'role' | 'text'>>,
signal: AbortSignal
) => AsyncIterable<string>;
export type StorageAdapter = Pick<Storage, 'getItem' | 'setItem'>;
const key = 'anchor.sidebar-chat.v1';
export function createChat(transport: Transport, storage?: StorageAdapter) {
const fresh = (): Conversation => ({
id: crypto.randomUUID(),
title: 'New conversation',
draft: '',
messages: []
});
let first = fresh();
const state: ChatState = {
conversations: [first],
activeId: first.id,
busyId: null,
error: null,
storageError: null
};
let controller: AbortController | null = null;
const listeners = new Set<(state: ChatState) => void>();
try {
const raw = storage?.getItem(key);
if (raw) {
const saved = JSON.parse(raw);
const ids = new Set<string>();
if (
saved.version !== 1 ||
!Array.isArray(saved.conversations) ||
!saved.conversations.length ||
saved.conversations.length > 100
)
throw new Error('Invalid history');
for (const c of saved.conversations) {
if (
typeof c.id !== 'string' ||
ids.has(c.id) ||
typeof c.title !== 'string' ||
typeof c.draft !== 'string' ||
!Array.isArray(c.messages)
)
throw new Error('Invalid conversation');
ids.add(c.id);
for (const m of c.messages) {
if (
typeof m.id !== 'string' ||
!['user', 'assistant'].includes(m.role) ||
typeof m.text !== 'string' ||
!['complete', 'streaming', 'stopped', 'error'].includes(m.status)
)
throw new Error('Invalid message');
if (m.status === 'streaming') m.status = 'stopped';
}
}
state.conversations = saved.conversations;
state.activeId = ids.has(saved.activeId) ? saved.activeId : saved.conversations[0].id;
}
} catch {
state.storageError = 'Saved history could not be read. Starting a new conversation.';
}
const snapshot = () => structuredClone(state);
function publish() {
try {
storage?.setItem(
key,
JSON.stringify({ version: 1, conversations: state.conversations, activeId: state.activeId })
);
} catch {
state.storageError = 'History could not be saved on this device. This session still works.';
}
for (const listener of listeners) listener(snapshot());
}
function stop() {
if (!controller) return;
controller.abort();
controller = null;
for (const c of state.conversations)
for (const m of c.messages) if (m.status === 'streaming') m.status = 'stopped';
state.busyId = null;
publish();
}
return {
snapshot,
subscribe(listener: (state: ChatState) => void) {
listeners.add(listener);
listener(snapshot());
return () => {
listeners.delete(listener);
};
},
select(id: string) {
if (state.conversations.some((c) => c.id === id)) {
state.activeId = id;
state.error = null;
publish();
}
},
newConversation() {
if (state.conversations.length >= 100) {
state.error = 'Delete a conversation before adding another.';
publish();
return;
}
first = fresh();
state.conversations.unshift(first);
state.activeId = first.id;
state.error = null;
publish();
},
setDraft(text: string) {
state.conversations.find((c) => c.id === state.activeId)!.draft = text;
publish();
},
remove(id: string) {
if (state.busyId === id) stop();
state.conversations = state.conversations.filter((c) => c.id !== id);
if (!state.conversations.length) state.conversations.push(fresh());
if (!state.conversations.some((c) => c.id === state.activeId))
state.activeId = state.conversations[0].id;
publish();
},
stop,
async send() {
const conversation = state.conversations.find((c) => c.id === state.activeId)!;
const text = conversation.draft.trim();
if (!text || state.busyId) return;
const request = new AbortController();
controller = request;
if (!conversation.messages.length) conversation.title = text.slice(0, 48);
conversation.draft = '';
conversation.messages.push({
id: crypto.randomUUID(),
role: 'user',
text,
status: 'complete'
});
const history = conversation.messages
.filter((m) => m.status === 'complete')
.map(({ role, text }) => ({ role, text }));
const reply: Message = {
id: crypto.randomUUID(),
role: 'assistant',
text: '',
status: 'streaming'
};
conversation.messages.push(reply);
state.busyId = conversation.id;
state.error = null;
publish();
try {
for await (const chunk of transport(history, request.signal)) {
if (request.signal.aborted) break;
reply.text += chunk;
publish();
}
if (!request.signal.aborted) reply.status = 'complete';
} catch {
if (!request.signal.aborted) {
reply.status = 'error';
state.error = 'Response failed. Your messages are kept; try sending again.';
}
} finally {
if (controller === request) {
controller = null;
state.busyId = null;
publish();
}
}
},
dispose() {
stop();
listeners.clear();
}
};
}
// Demonstration only: replace this adapter with a server-backed stream.
export const demoTransport: Transport = async function* (messages, signal) {
const prompt = messages.at(-1)?.text ?? '';
const response = `This is a simulated streaming reply to “${prompt}”. The conversation list, drafts, history, and Stop button are working locally. Connect your own server transport to generate real responses.`;
for (const word of response.split(' ')) {
if (signal.aborted) return;
await new Promise((resolve) => setTimeout(resolve, 35));
if (signal.aborted) return;
yield word + ' ';
}
};
verification
{
"basis": "Original sidebar-chat implementation built in the user's Anchor workspace, then distilled into this anchor. Summaries describe design intent; the optional controller is the exact tested source.",
"tests": {
"passed": 8,
"coverage": [
"blank submission and streaming completion",
"draft/history persistence",
"switching during streaming and duplicate-send prevention",
"late chunks after cancellation",
"error recovery",
"deleting the final conversation",
"corrupt or full storage",
"interrupted stream restoration"
]
},
"limits": [
"Simulated transport only; no live model integration tested",
"These implementation checks do not guarantee arbitrary adaptations work",
"No authentication, cross-device sync, attachments, or automatic retries included"
],
"browserChecks": [
"send and stop",
"draft switching",
"history reload",
"mobile drawer and no horizontal overflow at 390px"
],
"retrievalCheck": "The previous anchor's identical controller and tests were retrieved into an isolated folder; all 8 tests passed. Its UI compiled without warnings using shared installed dependencies."
}