• Blog
  • Docs
  • Pricing
  • We’re hiring!
Log inSign up
drewmcdonald

drewmcdonald

promptCompare

Public
Like
promptCompare
Home
Code
11
.claude
3
.playwright-mcp
1
backend
3
docs
5
frontend
4
shared
1
.gitignore
.mcp.json
.vtignore
CLAUDE.md
deno.json
Environment variables
2
Branches
1
Pull requests
Remixes
History
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
docs
/
plans
/
2026-02-19-promptcompare-implementation.md
Code
/
docs
/
plans
/
2026-02-19-promptcompare-implementation.md
Search
…
Viewing readonly version of main branch: v176
View latest version
2026-02-19-promptcompare-implementation.md

PromptCompare Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build a Val Town app that lets users compare LLM outputs side-by-side across multiple models with multi-turn conversation support.

Architecture: Hono backend serves a React 18 frontend as static files. SQLite stores conversations/messages/responses. Backend proxies chat requests to OpenCode Zen, multiplexing multiple model responses into a single SSE stream. Simple password auth with session cookies.

Tech Stack: Deno (Val Town), Hono, React 18.2.0 (esm.sh), Tailwind (twind CDN), Val Town SQLite, OpenCode Zen API


Task 1: Shared Types

Files:

  • Create: shared/types.ts

Step 1: Create shared type definitions

Create val
// shared/types.ts export interface ModelInfo { id: string; name: string; family: string; reasoning: boolean; cost: { input: number; output: number }; limit: { context: number; output: number }; } export interface Conversation { id: string; title: string; model_ids: string[]; created_at: string; updated_at: string; } export interface Message { id: string; conversation_id: string; role: "user"; content: string; created_at: string; } export interface Response { id: string; message_id: string; model_id: string; content: string; created_at: string; } export interface ConversationDetail extends Conversation { messages: (Message & { responses: Response[] })[]; } export interface SSEChunk { model_id: string; content: string; } export interface SSEDone { model_id: string; } export interface SSEError { model_id: string; error: string; }

Step 2: Commit

git add shared/types.ts git commit -m "feat: add shared type definitions"

Task 2: Database Schema & Migrations

Files:

  • Create: backend/database/migrations.ts

Context: Use https://esm.town/v/std/sqlite/main.ts for val-scoped SQLite. See docs/vt-sqlite.md for API reference. The sqlite object is an initialized @libsql/client instance supporting execute and batch.

Step 1: Create migrations file

Create val
// backend/database/migrations.ts import { sqlite } from "https://esm.town/v/std/sqlite/main.ts"; export async function runMigrations() { await sqlite.batch([ `CREATE TABLE IF NOT EXISTS conversations ( id TEXT PRIMARY KEY, title TEXT, model_ids TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) )`, `CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS responses ( id TEXT PRIMARY KEY, message_id TEXT NOT NULL, model_id TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE )`, ]); }

Step 2: Commit

git add backend/database/migrations.ts git commit -m "feat: add database migrations"

Task 3: Database Query Functions

Files:

  • Create: backend/database/queries.ts

Step 1: Create typed query functions

Create val
// backend/database/queries.ts import { sqlite } from "https://esm.town/v/std/sqlite/main.ts"; import type { Conversation, ConversationDetail, Message, Response, } from "../../shared/types.ts"; export async function listConversations(): Promise<Conversation[]> { const result = await sqlite.execute( "SELECT id, title, model_ids, created_at, updated_at FROM conversations ORDER BY updated_at DESC", ); return (result.rows as any[]).map((r) => ({ ...r, model_ids: JSON.parse(r.model_ids), })); } export async function getConversation( id: string, ): Promise<ConversationDetail | null> { const convResult = await sqlite.execute({ sql: "SELECT * FROM conversations WHERE id = ?", args: [id], }); if (convResult.rows.length === 0) return null; const conv = convResult.rows[0] as any; const msgsResult = await sqlite.execute({ sql: "SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at ASC", args: [id], }); const messages = []; for (const msg of msgsResult.rows as any[]) { const respResult = await sqlite.execute({ sql: "SELECT * FROM responses WHERE message_id = ? ORDER BY model_id ASC", args: [msg.id], }); messages.push({ ...msg, responses: respResult.rows as Response[], }); } return { ...conv, model_ids: JSON.parse(conv.model_ids), messages, }; } export async function createConversation( id: string, title: string, modelIds: string[], ): Promise<void> { await sqlite.execute({ sql: "INSERT INTO conversations (id, title, model_ids) VALUES (?, ?, ?)", args: [id, title, JSON.stringify(modelIds)], }); } export async function deleteConversation(id: string): Promise<void> { // Delete in order due to foreign keys (SQLite may not enforce CASCADE) await sqlite.batch([ { sql: `DELETE FROM responses WHERE message_id IN (SELECT id FROM messages WHERE conversation_id = ?)`, args: [id], }, { sql: "DELETE FROM messages WHERE conversation_id = ?", args: [id] }, { sql: "DELETE FROM conversations WHERE id = ?", args: [id] }, ]); } export async function updateConversationTitle( id: string, title: string, ): Promise<void> { await sqlite.execute({ sql: "UPDATE conversations SET title = ?, updated_at = datetime('now') WHERE id = ?", args: [title, id], }); } export async function createMessage( id: string, conversationId: string, content: string, ): Promise<void> { await sqlite.execute({ sql: "INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, 'user', ?)", args: [id, conversationId, content], }); await sqlite.execute({ sql: "UPDATE conversations SET updated_at = datetime('now') WHERE id = ?", args: [conversationId], }); } export async function createResponse( id: string, messageId: string, modelId: string, ): Promise<void> { await sqlite.execute({ sql: "INSERT INTO responses (id, message_id, model_id) VALUES (?, ?, ?)", args: [id, messageId, modelId], }); } export async function updateResponseContent( id: string, content: string, ): Promise<void> { await sqlite.execute({ sql: "UPDATE responses SET content = ? WHERE id = ?", args: [content, id], }); } export async function getConversationHistory( conversationId: string, modelId: string, ): Promise<{ role: string; content: string }[]> { const result = await sqlite.execute({ sql: `SELECT m.role, m.content AS user_content, r.content AS assistant_content FROM messages m LEFT JOIN responses r ON r.message_id = m.id AND r.model_id = ? WHERE m.conversation_id = ? ORDER BY m.created_at ASC`, args: [modelId, conversationId], }); const history: { role: string; content: string }[] = []; for (const row of result.rows as any[]) { history.push({ role: "user", content: row.user_content }); if (row.assistant_content) { history.push({ role: "assistant", content: row.assistant_content }); } } return history; }

Step 2: Commit

git add backend/database/queries.ts git commit -m "feat: add database query functions"

Task 4: Auth Routes

Files:

  • Create: backend/routes/auth.ts

Context: Use Deno.env.get("PROMPTCOMPARE_PASSWORD") for the secret. Simple approach: hash the password with a known salt using the Web Crypto API, store the hash in a cookie. Middleware checks the cookie on protected routes.

Step 1: Create auth routes and middleware

Create val
// backend/routes/auth.ts import { Hono } from "https://esm.sh/hono@4.7.4"; import { deleteCookie, getCookie, setCookie, } from "https://esm.sh/hono@4.7.4/cookie"; const COOKIE_NAME = "promptcompare_session"; const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 days async function hashPassword(password: string): Promise<string> { const encoder = new TextEncoder(); const data = encoder.encode(password + "_promptcompare_salt"); const hash = await crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(hash)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } export const authRoutes = new Hono(); authRoutes.post("/", async (c) => { const { password } = await c.req.json(); const expected = Deno.env.get("PROMPTCOMPARE_PASSWORD"); if (!expected || password !== expected) { return c.json({ error: "Invalid password" }, 401); } const token = await hashPassword(expected); setCookie(c, COOKIE_NAME, token, { httpOnly: true, secure: true, sameSite: "Lax", maxAge: SESSION_MAX_AGE, path: "/", }); return c.json({ ok: true }); }); authRoutes.get("/check", async (c) => { const token = getCookie(c, COOKIE_NAME); const expected = Deno.env.get("PROMPTCOMPARE_PASSWORD"); if (!token || !expected) return c.json({ authenticated: false }); const expectedToken = await hashPassword(expected); return c.json({ authenticated: token === expectedToken }); }); authRoutes.post("/logout", (c) => { deleteCookie(c, COOKIE_NAME, { path: "/" }); return c.json({ ok: true }); }); export async function requireAuth(c: any, next: any) { const token = getCookie(c, COOKIE_NAME); const expected = Deno.env.get("PROMPTCOMPARE_PASSWORD"); if (!token || !expected) return c.json({ error: "Unauthorized" }, 401); const expectedToken = await hashPassword(expected); if (token !== expectedToken) return c.json({ error: "Unauthorized" }, 401); await next(); }

Step 2: Commit

git add backend/routes/auth.ts git commit -m "feat: add auth routes and middleware"

Task 5: API Routes (Conversations + Models)

Files:

  • Create: backend/routes/api.ts

Context: The models list comes from docs/models.json but at runtime we need to read it via Val Town's readFile utility. For the streaming chat endpoint, we need to proxy to OpenCode Zen endpoints. Each model has a different endpoint based on its provider — see the endpoint table in docs/zen.md.

Step 1: Create API routes

Create val
// backend/routes/api.ts import { Hono } from "https://esm.sh/hono@4.7.4"; import { streamSSE } from "https://esm.sh/hono@4.7.4/streaming"; import { readFile } from "https://esm.town/v/std/utils@85-main/index.ts"; import * as db from "../database/queries.ts"; const api = new Hono(); // --- Models --- let cachedModels: any = null; async function getModels() { if (cachedModels) return cachedModels; const raw = await readFile("/docs/models.json", import.meta.url); const data = JSON.parse(raw); const models = Object.values(data.models).filter((m: any) => m.status !== "deprecated" ); cachedModels = models; return models; } api.get("/models", async (c) => { const models = await getModels(); return c.json(models); }); // --- Conversations --- api.get("/conversations", async (c) => { const conversations = await db.listConversations(); return c.json(conversations); }); api.get("/conversations/:id", async (c) => { const conv = await db.getConversation(c.req.param("id")); if (!conv) return c.json({ error: "Not found" }, 404); return c.json(conv); }); api.post("/conversations", async (c) => { const { title, model_ids } = await c.req.json(); const id = crypto.randomUUID(); await db.createConversation(id, title || "New Conversation", model_ids); return c.json({ id }); }); api.delete("/conversations/:id", async (c) => { await db.deleteConversation(c.req.param("id")); return c.json({ ok: true }); }); // --- Chat (SSE streaming) --- function getZenEndpoint(model: any): string { const npm = model.provider?.npm || ""; if (npm === "@ai-sdk/anthropic") return "https://opencode.ai/zen/v1/messages"; if (npm === "@ai-sdk/openai") return "https://opencode.ai/zen/v1/responses"; if (npm === "@ai-sdk/google") { return `https://opencode.ai/zen/v1/models/${model.id}`; } // Default: openai-compatible chat/completions return "https://opencode.ai/zen/v1/chat/completions"; } function buildRequestBody( model: any, messages: { role: string; content: string }[], ): { endpoint: string; body: any; headers: Record<string, string> } { const apiKey = Deno.env.get("OPENCODE_API_KEY") || ""; const npm = model.provider?.npm || ""; const endpoint = getZenEndpoint(model); const headers: Record<string, string> = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }; if (npm === "@ai-sdk/anthropic") { return { endpoint, headers: { ...headers, "anthropic-version": "2023-06-01" }, body: { model: model.id, messages: messages.map((m) => ({ role: m.role, content: m.content })), max_tokens: Math.min(4096, model.limit.output), stream: true, }, }; } if (npm === "@ai-sdk/openai") { return { endpoint, headers, body: { model: model.id, input: messages.map((m) => ({ role: m.role, content: m.content })), stream: true, }, }; } // Default: OpenAI-compatible chat/completions (GLM, Kimi, MiniMax, etc.) return { endpoint, headers, body: { model: model.id, messages: messages.map((m) => ({ role: m.role, content: m.content })), max_tokens: Math.min(4096, model.limit.output), stream: true, }, }; } async function* streamModel( model: any, messages: { role: string; content: string }[], ): AsyncGenerator<{ type: "chunk" | "done" | "error"; data: any }> { const { endpoint, body, headers } = buildRequestBody(model, messages); const npm = model.provider?.npm || ""; try { const resp = await fetch(endpoint, { method: "POST", headers, body: JSON.stringify(body), }); if (!resp.ok) { const errText = await resp.text(); yield { type: "error", data: { model_id: model.id, error: `${resp.status}: ${errText}` }, }; return; } const reader = resp.body?.getReader(); if (!reader) { yield { type: "error", data: { model_id: model.id, error: "No response body" }, }; return; } const decoder = new TextDecoder(); let buffer = ""; let fullContent = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.startsWith("data: ")) continue; const data = line.slice(6).trim(); if (data === "[DONE]") continue; try { const parsed = JSON.parse(data); let text = ""; if (npm === "@ai-sdk/anthropic") { // Anthropic streaming: content_block_delta if (parsed.type === "content_block_delta" && parsed.delta?.text) { text = parsed.delta.text; } } else if (npm === "@ai-sdk/openai") { // OpenAI Responses API streaming if (parsed.type === "response.output_text.delta" && parsed.delta) { text = parsed.delta; } } else { // OpenAI-compatible chat/completions text = parsed.choices?.[0]?.delta?.content || ""; } if (text) { fullContent += text; yield { type: "chunk", data: { model_id: model.id, content: text }, }; } } catch { // Skip unparseable lines } } } yield { type: "done", data: { model_id: model.id, full_content: fullContent }, }; } catch (err: any) { yield { type: "error", data: { model_id: model.id, error: err.message } }; } } api.post("/chat", async (c) => { const { conversation_id, content } = await c.req.json(); const conv = await db.getConversation(conversation_id); if (!conv) return c.json({ error: "Conversation not found" }, 404); // Create the user message const messageId = crypto.randomUUID(); await db.createMessage(messageId, conversation_id, content); // Create response placeholders for each model const allModels = await getModels(); const selectedModels = allModels.filter((m: any) => conv.model_ids.includes(m.id) ); const responseIds: Record<string, string> = {}; for (const model of selectedModels) { const responseId = crypto.randomUUID(); responseIds[model.id] = responseId; await db.createResponse(responseId, messageId, model.id); } return streamSSE(c, async (stream) => { // Send message_id so frontend knows it await stream.writeSSE({ event: "message", data: JSON.stringify({ message_id: messageId }), }); // Build per-model conversation histories and stream in parallel const streamPromises = selectedModels.map(async (model: any) => { const history = await db.getConversationHistory( conversation_id, model.id, ); // Add the current message history.push({ role: "user", content }); let fullContent = ""; for await (const event of streamModel(model, history)) { if (event.type === "chunk") { fullContent += event.data.content; await stream.writeSSE({ event: "chunk", data: JSON.stringify(event.data), }); } else if (event.type === "done") { await db.updateResponseContent( responseIds[model.id], event.data.full_content, ); await stream.writeSSE({ event: "done", data: JSON.stringify({ model_id: model.id }), }); } else if (event.type === "error") { await stream.writeSSE({ event: "error", data: JSON.stringify(event.data), }); } } }); await Promise.all(streamPromises); }); }); export { api };

Step 2: Commit

git add backend/routes/api.ts git commit -m "feat: add API routes for conversations, models, and streaming chat"

Task 6: Backend Entry Point

Files:

  • Create: backend/index.ts

Step 1: Create Hono app entry point

Create val
// backend/index.ts import { Hono } from "https://esm.sh/hono@4.7.4"; import { readFile, serveFile, } from "https://esm.town/v/std/utils@85-main/index.ts"; import { runMigrations } from "./database/migrations.ts"; import { authRoutes, requireAuth } from "./routes/auth.ts"; import { api } from "./routes/api.ts"; const app = new Hono(); // Unwrap Hono errors to see original error details app.onError((err, c) => { throw err; }); // Run migrations on startup await runMigrations(); // Auth routes (public) app.route("/api/auth", authRoutes); // Protected API routes app.use("/api/*", requireAuth); app.route("/api", api); // Serve frontend static files app.get("/frontend/*", (c) => serveFile(c.req.path, import.meta.url)); app.get("/shared/*", (c) => serveFile(c.req.path, import.meta.url)); // Serve index.html for all other routes app.get("*", async (c) => { const html = await readFile("/frontend/index.html", import.meta.url); return c.html(html); }); export default app.fetch;

Step 2: Commit

git add backend/index.ts git commit -m "feat: add backend entry point"

Task 7: Frontend HTML Shell

Files:

  • Create: frontend/index.html
  • Create: frontend/style.css

Step 1: Create HTML shell with React and Tailwind CDN

<!-- frontend/index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>PromptCompare</title> <script src="https://cdn.twind.style" crossorigin></script> <script src="https://esm.town/v/std/catch"></script> <link rel="stylesheet" href="/frontend/style.css" /> </head> <body> <div id="root"></div> <script type="module" src="/frontend/index.tsx"></script> </body> </html>
/* frontend/style.css */ * { margin: 0; padding: 0; box-sizing: border-box; } html, body, #root { height: 100%; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } /* Markdown rendered in responses */ .prose pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; } .prose code { font-size: 0.875rem; } .prose p { margin-bottom: 0.5rem; } .prose ul, .prose ol { margin-left: 1.5rem; margin-bottom: 0.5rem; }

Step 2: Commit

git add frontend/index.html frontend/style.css git commit -m "feat: add frontend HTML shell and base styles"

Task 8: Frontend App Entry & Login

Files:

  • Create: frontend/index.tsx
  • Create: frontend/components/Login.tsx

Step 1: Create app entry with auth state management

Create val
/** @jsxImportSource https://esm.sh/react@18.2.0 */ // frontend/index.tsx import React, { useEffect, useState } from "https://esm.sh/react@18.2.0"; import { createRoot } from "https://esm.sh/react-dom@18.2.0/client"; import { Login } from "./components/Login.tsx"; import { ChatApp } from "./components/ChatApp.tsx"; function App() { const [authenticated, setAuthenticated] = useState<boolean | null>(null); useEffect(() => { fetch("/api/auth/check") .then((r) => r.json()) .then((d) => setAuthenticated(d.authenticated)) .catch(() => setAuthenticated(false)); }, []); if (authenticated === null) { return ( <div className="flex items-center justify-center h-screen text-gray-500"> Loading... </div> ); } if (!authenticated) { return <Login onSuccess={() => setAuthenticated(true)} />; } return <ChatApp onLogout={() => setAuthenticated(false)} />; } createRoot(document.getElementById("root")!).render(<App />);

Step 2: Create Login component

Create val
/** @jsxImportSource https://esm.sh/react@18.2.0 */ // frontend/components/Login.tsx import React, { useState } from "https://esm.sh/react@18.2.0"; export function Login({ onSuccess }: { onSuccess: () => void }) { const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(""); try { const res = await fetch("/api/auth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }), }); if (res.ok) { onSuccess(); } else { setError("Invalid password"); } } catch { setError("Connection error"); } finally { setLoading(false); } }; return ( <div className="flex items-center justify-center h-screen bg-gray-50"> <form onSubmit={handleSubmit} className="bg-white p-8 rounded-lg shadow-md w-80" > <h1 className="text-xl font-semibold mb-6 text-center"> PromptCompare </h1> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" className="w-full px-3 py-2 border rounded-md mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500" autoFocus /> {error && <p className="text-red-500 text-sm mb-4">{error}</p>} <button type="submit" disabled={loading} className="w-full bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 disabled:opacity-50" > {loading ? "..." : "Sign In"} </button> </form> </div> ); }

Step 3: Commit

git add frontend/index.tsx frontend/components/Login.tsx git commit -m "feat: add frontend app entry and login component"

Task 9: ChatApp Shell (Sidebar + Layout)

Files:

  • Create: frontend/components/ChatApp.tsx
  • Create: frontend/components/Sidebar.tsx

Step 1: Create ChatApp layout component

Create val
/** @jsxImportSource https://esm.sh/react@18.2.0 */ // frontend/components/ChatApp.tsx import React, { useCallback, useEffect, useState, } from "https://esm.sh/react@18.2.0"; import type { Conversation, ModelInfo } from "../../shared/types.ts"; import { Sidebar } from "./Sidebar.tsx"; import { ModelPicker } from "./ModelPicker.tsx"; import { ConversationView } from "./ConversationView.tsx"; export function ChatApp({ onLogout }: { onLogout: () => void }) { const [conversations, setConversations] = useState<Conversation[]>([]); const [activeConvId, setActiveConvId] = useState<string | null>(null); const [models, setModels] = useState<ModelInfo[]>([]); const [showModelPicker, setShowModelPicker] = useState(false); const loadConversations = useCallback(async () => { const res = await fetch("/api/conversations"); const data = await res.json(); setConversations(data); }, []); useEffect(() => { loadConversations(); fetch("/api/models").then((r) => r.json()).then(setModels); }, []); const handleNewChat = () => { setShowModelPicker(true); setActiveConvId(null); }; const handleCreateConversation = async (selectedModelIds: string[]) => { const res = await fetch("/api/conversations", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "New Conversation", model_ids: selectedModelIds, }), }); const { id } = await res.json(); setShowModelPicker(false); setActiveConvId(id); await loadConversations(); }; const handleDeleteConversation = async (id: string) => { await fetch(`/api/conversations/${id}`, { method: "DELETE" }); if (activeConvId === id) setActiveConvId(null); await loadConversations(); }; return ( <div className="flex h-screen bg-gray-50"> <Sidebar conversations={conversations} activeId={activeConvId} onSelect={setActiveConvId} onNewChat={handleNewChat} onDelete={handleDeleteConversation} onLogout={onLogout} /> <main className="flex-1 flex flex-col"> {showModelPicker ? ( <ModelPicker models={models} onConfirm={handleCreateConversation} onCancel={() => setShowModelPicker(false)} /> ) : activeConvId ? ( <ConversationView conversationId={activeConvId} models={models} onTitleUpdate={loadConversations} /> ) : ( <div className="flex items-center justify-center h-full text-gray-400"> Select a conversation or start a new one </div> )} </main> </div> ); }

Step 2: Create Sidebar component

Create val
/** @jsxImportSource https://esm.sh/react@18.2.0 */ // frontend/components/Sidebar.tsx import React from "https://esm.sh/react@18.2.0"; import type { Conversation } from "../../shared/types.ts"; interface SidebarProps { conversations: Conversation[]; activeId: string | null; onSelect: (id: string) => void; onNewChat: () => void; onDelete: (id: string) => void; onLogout: () => void; } export function Sidebar( { conversations, activeId, onSelect, onNewChat, onDelete, onLogout }: SidebarProps, ) { return ( <div className="w-64 bg-gray-900 text-white flex flex-col h-full"> <div className="p-4"> <button onClick={onNewChat} className="w-full py-2 px-4 border border-gray-600 rounded-md hover:bg-gray-800 text-sm" > + New Comparison </button> </div> <div className="flex-1 overflow-y-auto px-2"> {conversations.map((conv) => ( <div key={conv.id} className={`group flex items-center px-3 py-2 rounded-md cursor-pointer text-sm mb-1 ${ conv.id === activeId ? "bg-gray-700" : "hover:bg-gray-800" }`} onClick={() => onSelect(conv.id)} > <span className="flex-1 truncate">{conv.title}</span> <button onClick={(e) => { e.stopPropagation(); onDelete(conv.id); }} className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-400 ml-2" > x </button> </div> ))} </div> <div className="p-4 border-t border-gray-700"> <button onClick={async () => { await fetch("/api/auth/logout", { method: "POST" }); onLogout(); }} className="text-sm text-gray-400 hover:text-white" > Sign out </button> </div> </div> ); }

Step 3: Commit

git add frontend/components/ChatApp.tsx frontend/components/Sidebar.tsx git commit -m "feat: add ChatApp layout and Sidebar"

Task 10: Model Picker Component

Files:

  • Create: frontend/components/ModelPicker.tsx

Step 1: Create model picker with checkboxes

Create val
/** @jsxImportSource https://esm.sh/react@18.2.0 */ // frontend/components/ModelPicker.tsx import React, { useState } from "https://esm.sh/react@18.2.0"; import type { ModelInfo } from "../../shared/types.ts"; interface ModelPickerProps { models: ModelInfo[]; onConfirm: (modelIds: string[]) => void; onCancel: () => void; } export function ModelPicker({ models, onConfirm, onCancel }: ModelPickerProps) { const [selected, setSelected] = useState<Set<string>>(new Set()); const toggle = (id: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else if (next.size < 4) { next.add(id); } return next; }); }; // Group by family const families = new Map<string, ModelInfo[]>(); for (const m of models) { const fam = m.family; if (!families.has(fam)) families.set(fam, []); families.get(fam)!.push(m); } return ( <div className="flex-1 flex items-center justify-center p-8"> <div className="bg-white rounded-lg shadow-lg p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto"> <h2 className="text-lg font-semibold mb-1">Select Models</h2> <p className="text-sm text-gray-500 mb-4"> Choose up to 4 models to compare ({selected.size}/4) </p> {Array.from(families.entries()).map(([family, familyModels]) => ( <div key={family} className="mb-4"> <h3 className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2"> {family} </h3> <div className="grid grid-cols-1 gap-1"> {familyModels.map((model) => ( <label key={model.id} className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer border ${ selected.has(model.id) ? "border-blue-500 bg-blue-50" : "border-transparent hover:bg-gray-50" } ${ !selected.has(model.id) && selected.size >= 4 ? "opacity-50 cursor-not-allowed" : "" }`} > <input type="checkbox" checked={selected.has(model.id)} onChange={() => toggle(model.id)} disabled={!selected.has(model.id) && selected.size >= 4} className="rounded" /> <div className="flex-1"> <span className="text-sm font-medium">{model.name}</span> </div> <span className="text-xs text-gray-400"> ${model.cost.input}/{model.cost.output} per 1M </span> </label> ))} </div> </div> ))} <div className="flex gap-3 mt-6"> <button onClick={onCancel} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800" > Cancel </button> <button onClick={() => onConfirm(Array.from(selected))} disabled={selected.size === 0} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50" > Start Comparison </button> </div> </div> </div> ); }

Step 2: Commit

git add frontend/components/ModelPicker.tsx git commit -m "feat: add ModelPicker component"

Task 11: ConversationView (Messages + Streaming)

Files:

  • Create: frontend/components/ConversationView.tsx
  • Create: frontend/components/MessageRow.tsx
  • Create: frontend/components/PromptInput.tsx

This is the core component. It loads conversation history, displays messages with side-by-side model responses, handles streaming, and renders markdown.

Step 1: Create PromptInput component

Create val
/** @jsxImportSource https://esm.sh/react@18.2.0 */ // frontend/components/PromptInput.tsx import React, { useRef, useState } from "https://esm.sh/react@18.2.0"; interface PromptInputProps { onSend: (content: string) => void; disabled: boolean; } export function PromptInput({ onSend, disabled }: PromptInputProps) { const [value, setValue] = useState(""); const textareaRef = useRef<HTMLTextAreaElement>(null); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const trimmed = value.trim(); if (!trimmed || disabled) return; onSend(trimmed); setValue(""); if (textareaRef.current) textareaRef.current.style.height = "auto"; }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(e); } }; const handleInput = () => { const el = textareaRef.current; if (el) { el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 200) + "px"; } }; return ( <form onSubmit={handleSubmit} className="border-t bg-white p-4"> <div className="flex gap-2 max-w-4xl mx-auto"> <textarea ref={textareaRef} value={value} onChange={(e) => setValue(e.target.value)} onKeyDown={handleKeyDown} onInput={handleInput} placeholder="Type a message..." disabled={disabled} rows={1} className="flex-1 resize-none border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm" /> <button type="submit" disabled={disabled || !value.trim()} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm" > Send </button> </div> </form> ); }

Step 2: Create MessageRow component

Create val
/** @jsxImportSource https://esm.sh/react@18.2.0 */ // frontend/components/MessageRow.tsx import React from "https://esm.sh/react@18.2.0"; import { marked } from "https://esm.sh/marked@15.0.6?deps=react@18.2.0,react-dom@18.2.0"; interface MessageRowProps { userContent: string; responses: { model_id: string; content: string }[]; modelNames: Record<string, string>; streaming?: boolean; } export function MessageRow( { userContent, responses, modelNames, streaming }: MessageRowProps, ) { return ( <div className="mb-6"> {/* User message */} <div className="mb-3 flex justify-end"> <div className="bg-blue-600 text-white px-4 py-2 rounded-2xl rounded-br-md max-w-2xl text-sm whitespace-pre-wrap"> {userContent} </div> </div> {/* Model responses side by side */} <div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${responses.length}, 1fr)` }} > {responses.map((resp) => ( <div key={resp.model_id} className="border rounded-lg overflow-hidden bg-white" > <div className="px-3 py-1.5 bg-gray-50 border-b text-xs font-medium text-gray-600"> {modelNames[resp.model_id] || resp.model_id} </div> <div className="px-3 py-2 text-sm prose max-w-none" dangerouslySetInnerHTML={{ __html: resp.content ? marked.parse(resp.content, { async: false }) as string : streaming ? '<span class="animate-pulse text-gray-400">...</span>' : '<span class="text-gray-400">No response</span>', }} /> </div> ))} </div> </div> ); }

Step 3: Create ConversationView component

Create val
/** @jsxImportSource https://esm.sh/react@18.2.0 */ // frontend/components/ConversationView.tsx import React, { useCallback, useEffect, useRef, useState, } from "https://esm.sh/react@18.2.0"; import type { ConversationDetail, ModelInfo } from "../../shared/types.ts"; import { MessageRow } from "./MessageRow.tsx"; import { PromptInput } from "./PromptInput.tsx"; interface ConversationViewProps { conversationId: string; models: ModelInfo[]; onTitleUpdate: () => void; } interface StreamingState { [modelId: string]: string; } export function ConversationView( { conversationId, models, onTitleUpdate }: ConversationViewProps, ) { const [conversation, setConversation] = useState<ConversationDetail | null>( null, ); const [streaming, setStreaming] = useState(false); const [streamingResponses, setStreamingResponses] = useState<StreamingState>( {}, ); const [streamingMessageContent, setStreamingMessageContent] = useState< string | null >(null); const messagesEndRef = useRef<HTMLDivElement>(null); const modelNames = Object.fromEntries(models.map((m) => [m.id, m.name])); const loadConversation = useCallback(async () => { const res = await fetch(`/api/conversations/${conversationId}`); const data = await res.json(); setConversation(data); }, [conversationId]); useEffect(() => { loadConversation(); setStreamingResponses({}); setStreamingMessageContent(null); }, [conversationId]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [conversation, streamingResponses]); const handleSend = async (content: string) => { if (!conversation) return; setStreaming(true); setStreamingMessageContent(content); // Initialize streaming state for each model const initialState: StreamingState = {}; for (const modelId of conversation.model_ids) { initialState[modelId] = ""; } setStreamingResponses(initialState); try { const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ conversation_id: conversationId, content }), }); const reader = response.body?.getReader(); if (!reader) return; const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (line.startsWith("data: ")) { const eventLine = lines[lines.indexOf(line) - 1]; const eventType = eventLine?.startsWith("event: ") ? eventLine.slice(7) : ""; try { const data = JSON.parse(line.slice(6)); if (eventType === "chunk" || (!eventType && data.content)) { setStreamingResponses((prev) => ({ ...prev, [data.model_id]: (prev[data.model_id] || "") + data.content, })); } } catch { // Skip } } } } } catch (err) { console.error("Stream error:", err); } finally { setStreaming(false); setStreamingMessageContent(null); setStreamingResponses({}); await loadConversation(); onTitleUpdate(); } }; if (!conversation) { return ( <div className="flex-1 flex items-center justify-center text-gray-400"> Loading... </div> ); } return ( <div className="flex-1 flex flex-col h-full"> {/* Header */} <div className="border-b bg-white px-4 py-3 flex items-center gap-2"> <h2 className="text-sm font-medium">{conversation.title}</h2> <div className="flex gap-1 ml-auto"> {conversation.model_ids.map((id) => ( <span key={id} className="text-xs bg-gray-100 px-2 py-0.5 rounded-full text-gray-600" > {modelNames[id] || id} </span> ))} </div> </div> {/* Messages */} <div className="flex-1 overflow-y-auto p-4"> {conversation.messages.map((msg) => ( <MessageRow key={msg.id} userContent={msg.content} responses={conversation.model_ids.map((modelId) => { const resp = msg.responses.find((r) => r.model_id === modelId); return { model_id: modelId, content: resp?.content || "" }; })} modelNames={modelNames} /> ))} {/* Currently streaming message */} {streamingMessageContent && ( <MessageRow userContent={streamingMessageContent} responses={conversation.model_ids.map((modelId) => ({ model_id: modelId, content: streamingResponses[modelId] || "", }))} modelNames={modelNames} streaming /> )} <div ref={messagesEndRef} /> </div> <PromptInput onSend={handleSend} disabled={streaming} /> </div> ); }

Step 4: Commit

git add frontend/components/ConversationView.tsx frontend/components/MessageRow.tsx frontend/components/PromptInput.tsx git commit -m "feat: add ConversationView, MessageRow, and PromptInput components"

Task 12: SSE Parsing Fix & Manual Verification

Context: The SSE parsing in ConversationView needs to handle the event: + data: line pairs correctly. The current implementation has a bug — it tries to look up the event type by finding the previous line in the already-split array, which is fragile. Fix the parser to properly track event types.

Step 1: Update the SSE parser in ConversationView.tsx

Replace the reader while-loop body in handleSend with a proper SSE parser:

Create val
// Replace the while(true) loop content in handleSend: let currentEvent = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (line.startsWith("event: ")) { currentEvent = line.slice(7).trim(); } else if (line.startsWith("data: ")) { try { const data = JSON.parse(line.slice(6)); if (currentEvent === "chunk" && data.content && data.model_id) { setStreamingResponses((prev) => ({ ...prev, [data.model_id]: (prev[data.model_id] || "") + data.content, })); } } catch { // Skip unparseable } currentEvent = ""; } else if (line.trim() === "") { currentEvent = ""; } } }

Step 2: Verify by deploying to Val Town and testing

  • Deploy via vt push
  • Open the val URL in browser
  • Log in with password
  • Create a new comparison with 2 cheap/free models (e.g. glm-5-free, gpt-5-nano)
  • Send a simple prompt like "Say hello in one sentence"
  • Verify responses stream in side-by-side

Step 3: Commit

git add frontend/components/ConversationView.tsx git commit -m "fix: proper SSE event parsing in ConversationView"

Task 13: Auto-title Conversations

Context: After the first message, use the user's prompt to set a meaningful conversation title instead of "New Conversation".

Step 1: Update the chat endpoint in backend/routes/api.ts

After creating the message, check if this is the first message and auto-set the title:

Create val
// Add after createMessage call in the /chat handler: const existingMessages = conv.messages || []; if (existingMessages.length === 0) { // First message — set title from content (truncated) const title = content.length > 60 ? content.slice(0, 57) + "..." : content; await db.updateConversationTitle(conversation_id, title); }

Step 2: Commit

git add backend/routes/api.ts git commit -m "feat: auto-title conversations from first message"
FeaturesVersion controlCode intelligenceCLIMCP
Use cases
TeamsAI agentsSlackGTM
DocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
PricingNewsletterBlogAboutCareers
We’re hiring!
Brandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Open Source Pledge
Terms of usePrivacy policyAbuse contact
© 2026 Val Town, Inc.