Sidebar chat foundation

Sidebar chat foundation

A working Svelte 5 sidebar chat: conversation history, per-chat drafts, streaming, stop, and local persistence. Includes a portable TypeScript controller, UI, tests, and reuse instructions. Replies are simulated by default; connect your own server transport.

SidebarChat

  • svelte
  • typescript
  • chat
  • sidebar
  • streaming
  • reference-implementation
Sign in to save

implementation.controller

src/lib/examples/sidebar-chat/chat.ts

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 + ' ';
	}
};

implementation.component

src/lib/examples/sidebar-chat/SidebarChat.svelte

svelte
<script lang="ts">
	import { onMount, tick } from 'svelte';
	import { createChat, demoTransport, type ChatState, type Transport } from './chat';
	let { transport = demoTransport, persist = true }: { transport?: Transport; persist?: boolean } =
		$props();
	let chat: ReturnType<typeof createChat> | undefined;
	let view = $state<ChatState | null>(null);
	let transcript: HTMLDivElement;
	let follow = true;
	let showConversations = $state(false);
	const active = $derived(view?.conversations.find((c) => c.id === view?.activeId));
	onMount(() => {
		let storage: Storage | undefined;
		try {
			if (persist) storage = window.localStorage;
		} catch {
			/* Browser may disable storage. */
		}
		chat = createChat(transport, storage);
		const unsubscribe = chat.subscribe((next) => {
			const changed = next.activeId !== view?.activeId;
			view = next;
			if (changed || follow)
				void tick().then(() => {
					if (transcript) transcript.scrollTop = transcript.scrollHeight;
				});
		});
		return () => {
			unsubscribe();
			chat?.dispose();
		};
	});
	function send() {
		follow = true;
		void chat?.send();
	}
</script>

<section class="chat-app" aria-label="Sidebar chat example">
	<aside class:open={showConversations} aria-label="Conversations">
		<div class="sidebar-heading">
			<strong>Workspace</strong><button
				class="mobile"
				onclick={() => (showConversations = false)}
				aria-label="Close conversations">Close</button
			>
		</div>
		<button
			class="new-chat"
			onclick={() => {
				chat?.newConversation();
				showConversations = false;
			}}>+ New conversation</button
		>
		<nav aria-label="Conversation history">
			{#each view?.conversations ?? [] as conversation (conversation.id)}
				<div class="conversation" class:active={conversation.id === view?.activeId}>
					<button
						class="select-chat"
						aria-current={conversation.id === view?.activeId ? 'true' : undefined}
						onclick={() => {
							chat?.select(conversation.id);
							showConversations = false;
						}}>{conversation.title}</button
					>
					<button
						class="delete"
						aria-label={`Delete ${conversation.title}`}
						onclick={() => chat?.remove(conversation.id)}>×</button
					>
				</div>
			{/each}
		</nav>
		<p class="storage-note">
			{persist ? 'History stays in this browser.' : 'Session-only history.'}
		</p>
	</aside>
	<div class="workspace">
		<header>
			<button
				class="mobile"
				onclick={() => (showConversations = !showConversations)}
				aria-expanded={showConversations}>Chats</button
			>
			<h2>{active?.title ?? 'Loading chat…'}</h2>
			<span>{transport === demoTransport ? 'Simulated replies' : 'Connected transport'}</span>
		</header>
		<div
			class="transcript"
			bind:this={transcript}
			onscroll={() =>
				(follow = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 60)}
		>
			{#if !active?.messages.length}<div class="empty">
					<h3>A place to work things out.</h3>
					<p>Start a conversation. Switch between chats without losing your draft.</p>
				</div>{/if}
			{#each active?.messages ?? [] as message (message.id)}
				<article
					class:user={message.role === 'user'}
					aria-label={message.role === 'user' ? 'You' : 'Assistant'}
				>
					<strong>{message.role === 'user' ? 'You' : 'Assistant'}</strong>
					<p>{message.text || (message.status === 'streaming' ? 'Thinking…' : 'No response.')}</p>
					{#if message.status === 'stopped' || message.status === 'error'}<small
							>{message.status === 'stopped' ? 'Stopped' : 'Response failed'}</small
						>{/if}
				</article>
			{/each}
		</div>
		<div class="composer-area">
			{#if view?.error}<p class="notice" role="alert">{view.error}</p>{/if}
			{#if view?.storageError}<p class="notice" role="status">{view.storageError}</p>{/if}
			<form
				onsubmit={(event) => {
					event.preventDefault();
					send();
				}}
			>
				<label class="sr-only" for="chat-message">Message</label>
				<textarea
					id="chat-message"
					rows="2"
					placeholder="Write a message…"
					value={active?.draft ?? ''}
					oninput={(event) => chat?.setDraft(event.currentTarget.value)}
					onkeydown={(event) => {
						if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
							event.preventDefault();
							send();
						}
					}}></textarea>
				{#if view?.busyId}<button type="button" onclick={() => chat?.stop()}>Stop</button
					>{:else}<button type="submit" disabled={!active?.draft.trim()}>Send</button>{/if}
			</form>
			<p class="hint" role="status">
				{view?.busyId
					? 'A response is streaming. You can switch chats or stop it.'
					: 'Enter to send · Shift + Enter for a new line'}
			</p>
		</div>
	</div>
</section>

<style>
	.chat-app {
		display: grid;
		grid-template-columns: 240px minmax(0, 1fr);
		height: 640px;
		max-height: 80dvh;
		min-height: 420px;
		border: 1px solid #303030;
		border-radius: 16px;
		overflow: hidden;
		background: #101010;
		color: #f4f4f4;
		font:
			14px/1.5 system-ui,
			sans-serif;
		position: relative;
	}
	button,
	textarea {
		font: inherit;
		color: inherit;
	}
	button {
		cursor: pointer;
		border: 1px solid #393939;
		border-radius: 8px;
		background: #232323;
		padding: 8px 12px;
	}
	button:hover {
		background: #303030;
	}
	button:focus-visible,
	textarea:focus-visible {
		outline: 2px solid #c3bbff;
		outline-offset: 2px;
	}
	button:disabled {
		opacity: 0.4;
		cursor: default;
	}
	aside {
		min-width: 0;
		display: flex;
		flex-direction: column;
		padding: 16px 12px;
		background: #181818;
		border-right: 1px solid #303030;
	}
	.sidebar-heading {
		display: flex;
		justify-content: space-between;
		align-items: center;
		padding: 0 8px 20px;
	}
	.new-chat {
		width: 100%;
		text-align: left;
		margin-bottom: 20px;
	}
	nav {
		flex: 1;
		overflow: auto;
	}
	.conversation {
		display: flex;
		border-radius: 8px;
		margin-bottom: 4px;
	}
	.conversation.active {
		background: #303030;
	}
	.select-chat {
		flex: 1;
		min-width: 0;
		text-align: left;
		white-space: nowrap;
		overflow: hidden;
		text-overflow: ellipsis;
	}
	.conversation button {
		background: transparent;
		border: 0;
	}
	.delete {
		color: #aaa;
	}
	.storage-note {
		font-size: 11px;
		color: #a3a3a3;
		margin: 20px 8px 0;
	}
	.workspace {
		min-width: 0;
		min-height: 0;
		display: flex;
		flex-direction: column;
	}
	header {
		display: flex;
		gap: 12px;
		align-items: center;
		border-bottom: 1px solid #292929;
		padding: 18px 24px;
	}
	h2 {
		font-size: 14px;
		font-weight: 500;
		margin: 0;
		overflow: hidden;
		text-overflow: ellipsis;
		white-space: nowrap;
	}
	header span {
		margin-left: auto;
		font-size: 11px;
		color: #aaa;
		white-space: nowrap;
	}
	.transcript {
		flex: 1;
		min-height: 0;
		overflow: auto;
		padding: 24px;
	}
	.empty {
		max-width: 360px;
		margin: 70px auto;
		text-align: center;
		color: #aaa;
	}
	h3 {
		color: #eee;
		font-weight: 500;
		font-size: 22px;
	}
	article {
		max-width: 620px;
		margin: 0 auto 24px;
	}
	article strong {
		font-size: 12px;
		color: #aaa;
	}
	article p {
		white-space: pre-wrap;
		overflow-wrap: anywhere;
		margin: 6px 0;
	}
	article.user p {
		background: #242424;
		border-radius: 12px;
		padding: 12px 16px;
	}
	small {
		color: #bbb;
	}
	.composer-area {
		padding: 12px 24px 16px;
	}
	form {
		display: flex;
		align-items: flex-end;
		gap: 12px;
		border: 1px solid #444;
		padding: 12px;
		border-radius: 12px;
	}
	textarea {
		flex: 1;
		min-width: 0;
		resize: none;
		max-height: 160px;
		border: 0;
		background: transparent;
		padding: 4px;
	}
	.hint {
		color: #999;
		font-size: 11px;
		margin: 8px 0 0;
	}
	.notice {
		color: #efc7a4;
		font-size: 12px;
	}
	.sr-only {
		position: absolute;
		width: 1px;
		height: 1px;
		overflow: hidden;
		clip-path: inset(50%);
	}
	.mobile {
		display: none;
	}
	@media (max-width: 640px) {
		.chat-app {
			grid-template-columns: 1fr;
		}
		aside {
			display: none;
		}
		aside.open {
			display: flex;
			position: absolute;
			inset: 0 auto 0 0;
			width: min(280px, 85%);
			z-index: 2;
			box-shadow: 12px 0 40px #0008;
		}
		.mobile {
			display: block;
		}
		header {
			padding: 12px;
		}
		header span {
			display: none;
		}
		.transcript {
			padding: 16px;
		}
		.composer-area {
			padding: 12px;
		}
	}
</style>

verification.tests

src/lib/examples/sidebar-chat/chat.spec.ts

typescript
import { describe, expect, it } from 'vitest';
import { createChat, type Transport, type StorageAdapter } from './chat';

const reply: Transport = async function* () {
	yield 'Hello ';
	yield 'there';
};
function storage(): StorageAdapter {
	const data = new Map<string, string>();
	return {
		getItem: (key) => data.get(key) ?? null,
		setItem: (key, value) => {
			data.set(key, value);
		}
	};
}

describe('sidebar chat', () => {
	it('ignores blank submissions and streams a complete reply', async () => {
		const chat = createChat(reply);
		chat.setDraft('  ');
		await chat.send();
		expect(chat.snapshot().conversations[0].messages).toHaveLength(0);
		chat.setDraft('Hello');
		await chat.send();
		const state = chat.snapshot();
		expect(state.conversations[0].messages.map((m) => m.text)).toEqual(['Hello', 'Hello there']);
		expect(state.busyId).toBeNull();
		expect(state.conversations[0].draft).toBe('');
	});
	it('preserves per-conversation drafts and history across reload', async () => {
		const disk = storage();
		const chat = createChat(reply, disk);
		const id = chat.snapshot().activeId;
		chat.setDraft('First');
		await chat.send();
		chat.setDraft('Unsent');
		chat.newConversation();
		chat.setDraft('Second draft');
		const restored = createChat(reply, disk);
		expect(restored.snapshot().conversations[0].draft).toBe('Second draft');
		restored.select(id);
		expect(restored.snapshot().conversations.find((c) => c.id === id)?.draft).toBe('Unsent');
		expect(restored.snapshot().conversations.find((c) => c.id === id)?.messages).toHaveLength(2);
	});
	it('keeps streaming bound to the originating chat and prevents duplicate sends', async () => {
		let release!: () => void;
		const wait = new Promise<void>((resolve) => (release = resolve));
		const chat = createChat(async function* () {
			yield 'A';
			await wait;
			yield 'B';
		});
		const id = chat.snapshot().activeId;
		chat.setDraft('Question');
		const pending = chat.send();
		await Promise.resolve();
		chat.newConversation();
		chat.setDraft('Other');
		await chat.send();
		release();
		await pending;
		expect(
			chat
				.snapshot()
				.conversations.find((c) => c.id === id)
				?.messages.at(-1)?.text
		).toBe('AB');
		expect(chat.snapshot().conversations[0].messages).toHaveLength(0);
		expect(chat.snapshot().conversations[0].draft).toBe('Other');
	});
	it('ignores late chunks after stop even if transport ignores abort', async () => {
		let release!: () => void;
		const wait = new Promise<void>((resolve) => (release = resolve));
		const chat = createChat(async function* () {
			yield 'First';
			await wait;
			yield 'Late';
		});
		chat.setDraft('Test');
		const pending = chat.send();
		await Promise.resolve();
		await Promise.resolve();
		chat.stop();
		release();
		await pending;
		expect(chat.snapshot().conversations[0].messages.at(-1)?.status).toBe('stopped');
		expect(chat.snapshot().conversations[0].messages.at(-1)?.text).not.toContain('Late');
		expect(chat.snapshot().busyId).toBeNull();
	});
	it('keeps partial output on transport failure and recovers for a subsequent send', async () => {
		let calls = 0;
		const chat = createChat(async function* () {
			yield 'Partial';
			if (!calls++) throw new Error('offline');
		});
		chat.setDraft('One');
		await chat.send();
		expect(chat.snapshot().error).toContain('Response failed');
		chat.setDraft('Two');
		await chat.send();
		expect(chat.snapshot().error).toBeNull();
		expect(chat.snapshot().conversations[0].messages).toHaveLength(4);
	});
	it('deleting the final chat leaves a usable empty conversation', () => {
		const chat = createChat(reply);
		const old = chat.snapshot().activeId;
		chat.remove(old);
		expect(chat.snapshot().conversations).toHaveLength(1);
		expect(chat.snapshot().activeId).not.toBe(old);
	});
	it('recovers from corrupt and unavailable persistence', async () => {
		const chat = createChat(reply, {
			getItem: () => '{broken',
			setItem: () => {
				throw new Error('quota');
			}
		});
		expect(chat.snapshot().storageError).toContain('could not be read');
		chat.setDraft('Still works');
		await chat.send();
		expect(chat.snapshot().conversations[0].messages).toHaveLength(2);
		expect(chat.snapshot().storageError).toContain('could not be saved');
	});
	it('marks an interrupted persisted stream as stopped on reload', async () => {
		const disk = storage();
		let release!: () => void;
		const wait = new Promise<void>((resolve) => (release = resolve));
		const chat = createChat(async function* () {
			yield 'Partial';
			await wait;
		}, disk);
		chat.setDraft('Test');
		const pending = chat.send();
		await Promise.resolve();
		await Promise.resolve();
		const restored = createChat(reply, disk);
		expect(restored.snapshot().conversations[0].messages.at(-1)?.status).toBe('stopped');
		expect(restored.snapshot().busyId).toBeNull();
		release();
		await pending;
	});
});

integration.preview

src/routes/examples/sidebar-chat/+page.svelte

svelte
<script lang="ts">
	import SidebarChat from '$lib/examples/sidebar-chat/SidebarChat.svelte';
</script>

<svelte:head
	><title>Sidebar chat — Anchor example</title><meta
		name="description"
		content="A working sidebar chat reference with local history and a replaceable streaming transport."
	/></svelte:head
>
<div class="example">
	<p class="eyebrow">ANCHOR / WORKING EXAMPLE</p>
	<h1>Sidebar chat</h1>
	<p class="intro">
		Conversations, drafts, and streaming in one reusable foundation. Try it below.
	</p>
	<SidebarChat />
	<p class="disclosure">
		Original reference implementation. Replies are simulated; no AI service is called. History is
		stored only in this browser. Avoid entering sensitive information on a shared device.
	</p>
</div>

<style>
	.example {
		max-width: 1080px;
		margin: 40px auto;
	}
	.eyebrow {
		color: #aaa;
		font-size: 11px;
		letter-spacing: 0.12em;
	}
	h1 {
		font-size: 36px;
		font-weight: 500;
		letter-spacing: -0.04em;
		margin: 12px 0;
	}
	.intro {
		color: #aaa;
		margin-bottom: 28px;
	}
	.disclosure {
		color: #999;
		font-size: 12px;
		line-height: 1.7;
		margin-top: 16px;
		max-width: 780px;
	}
</style>

integration.guide

src/lib/examples/sidebar-chat/README.md

Sidebar chat foundation

Original implementation created for the Anchor workspace. This is a working Svelte 5 UI and framework-independent TypeScript controller, not an integration with an AI provider. No external application source code was copied. The default response adapter simulates streaming and is labeled in the UI.

Reuse

Copy chat.ts and SidebarChat.svelte into the same directory in a Svelte 5 application. Render <SidebarChat /> to try the simulated responder. The component uses relative imports and scoped CSS, with no Anchor, database, icon, or CSS-framework dependencies. SvelteKit mounts safely in the browser; it does not read localStorage during server rendering.

To connect real responses, pass <SidebarChat transport={yourTransport} />. A Transport receives an array of { role, text } messages and an AbortSignal, and returns an async iterable of text chunks. Keep provider credentials on your server. The controller passes only complete messages, excluding failed or stopped assistant replies. Decode your server's streaming protocol in the adapter; each yielded string must contain only response text. Forward the signal to fetch and cancel any reader when aborted.

persist={false} keeps history in memory. The default versioned localStorage key is anchor.sidebar-chat.v1; change it when reusing this example in multiple independent workspaces on one origin. Stored data is plain text, not encrypted. There is no cross-device sync or multi-tab conflict resolution. Unavailable storage falls back to the current session; malformed history and write failures are handled without crashing the chat.

Behavior and decisions

  • Sidebar creates, selects, and deletes conversations; deleting the final conversation creates an empty replacement.
  • Drafts belong to conversations. First user message supplies a short title.
  • One response can stream at a time across the workspace. Switching chats does not redirect that response; the composer still allows drafting while generation is busy.
  • Stop aborts the adapter and ignores late chunks even from an adapter that ignores cancellation. Deleting the streaming conversation also aborts it. Partial stopped or failed responses remain visible.
  • Enter sends, Shift+Enter inserts a newline, and IME composition does not trigger sending.
  • Text is rendered as text, never inserted as HTML. There is no Markdown rendering or attachment handling.
  • Scroll follows new messages only while near the bottom, after sending, or after switching conversations.
  • On narrow screens the sidebar opens as a drawer with visible close and conversation controls.

Testing

Tested against Svelte 5.57.0, TypeScript 6.0.3, and Vitest 4.1.11 in the Anchor workspace. Run npm run check and npx vitest run --project server src/lib/examples/sidebar-chat/chat.spec.ts in this repository. For another repository, adapt the test command to its Vitest configuration; the tests themselves only depend on Vitest and the adjacent controller.

The behavior tests cover blank submissions, streaming completion, history and drafts after reload, switching during streaming, duplicate-send prevention, cancellation with late chunks, transport error recovery, final-conversation deletion, corrupt/full storage, and interrupted-stream recovery.

Scope

This is a small reference, not a production chat service. It has no live model backend, authentication, database, retry/regenerate UI, attachments, or virtualization. Persistence serializes history per update; larger applications should batch writes or use IndexedDB/a server. The controller limits conversation creation to 100; message history is otherwise unbounded and storage quota errors are surfaced.

The example route is /examples/sidebar-chat. The source files saved in the anchor are the portable implementation; the local route is only a preview wrapper. Source provenance records local paths and content hashes, rather than claiming these uncommitted files exist at a published GitHub revision.

verification.results

{
  "date": "2026-09-18",
  "unitTests": {
    "failed": 0,
    "passed": 8,
    "command": "npx vitest run --project server src/lib/examples/sidebar-chat/chat.spec.ts"
  },
  "limitations": [
    "No real model/backend tested",
    "No screenshot captured: collaborative preview snapshot failed"
  ],
  "staticChecks": [
    "svelte-check: 0 errors, 0 warnings",
    "ESLint: passed for example files"
  ],
  "browserChecks": [
    "simulated stream completion",
    "Stop preserves partial response",
    "draft isolation after switching",
    "draft and conversations survive reload",
    "390px mobile sidebar opens and closes; no horizontal overflow"
  ],
  "retrievalVerification": {
    "dependencies": "Shared installed dependencies via symlink; no fresh dependency install or second browser app.",
    "exactSourceMatch": true,
    "svelteCompilation": "passed, zero warnings",
    "isolatedFolderTests": 8
  }
}