claude-code

This commit is contained in:
ashutoshpythoncs@gmail.com
2026-03-31 18:58:05 +05:30
parent a2a44a5841
commit b564857c0b
2148 changed files with 564518 additions and 2 deletions

166
web/lib/ansi-to-html.ts Normal file
View File

@@ -0,0 +1,166 @@
/**
* ANSI escape code → HTML/CSS utilities.
* Re-exports the full AnsiRenderer parser and adds text-stripping helpers.
*/
// ─── Strip ANSI ───────────────────────────────────────────────────────────────
/** Regex matching ANSI/VT100 escape sequences. */
const ANSI_RE = /\x1b\[[0-9;]*[mGKHF]|\x1b\][^\x07]*\x07|\x1b[()][AB012]/g;
/** Remove all ANSI escape sequences from a string. */
export function stripAnsi(text: string): string {
return text.replace(ANSI_RE, "");
}
/** Returns true if the string contains any ANSI escape sequences. */
export function hasAnsi(text: string): boolean {
ANSI_RE.lastIndex = 0;
return ANSI_RE.test(text);
}
// ─── ANSI → inline HTML spans ─────────────────────────────────────────────────
// 16-color ANSI foreground palette (matches common terminal defaults / xterm256)
const FG: Record<number, string> = {
30: "#3d3d3d", 31: "#cc0000", 32: "#4e9a06", 33: "#c4a000",
34: "#3465a4", 35: "#75507b", 36: "#06989a", 37: "#d3d7cf",
90: "#555753", 91: "#ef2929", 92: "#8ae234", 93: "#fce94f",
94: "#729fcf", 95: "#ad7fa8", 96: "#34e2e2", 97: "#eeeeec",
};
const BG: Record<number, string> = {
40: "#3d3d3d", 41: "#cc0000", 42: "#4e9a06", 43: "#c4a000",
44: "#3465a4", 45: "#75507b", 46: "#06989a", 47: "#d3d7cf",
100: "#555753", 101: "#ef2929", 102: "#8ae234", 103: "#fce94f",
104: "#729fcf", 105: "#ad7fa8", 106: "#34e2e2", 107: "#eeeeec",
};
function get256Color(n: number): string {
if (n < 16) return FG[n + 30] ?? FG[n + 82] ?? "#ffffff";
if (n < 232) {
const i = n - 16;
const b = i % 6, g = Math.floor(i / 6) % 6, r = Math.floor(i / 36);
const h = (v: number) => (v === 0 ? 0 : 55 + v * 40).toString(16).padStart(2, "0");
return `#${h(r)}${h(g)}${h(b)}`;
}
const gray = (n - 232) * 10 + 8;
const hex = gray.toString(16).padStart(2, "0");
return `#${hex}${hex}${hex}`;
}
interface AnsiStyle {
color?: string;
background?: string;
bold?: boolean;
dim?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
}
export interface AnsiSegment {
text: string;
style: AnsiStyle;
}
/**
* Parse an ANSI-encoded string into styled text segments.
* This is the core parser used by AnsiRenderer and can be used directly
* when you need to process segments programmatically.
*/
export function parseAnsiSegments(input: string): AnsiSegment[] {
const segments: AnsiSegment[] = [];
let current: AnsiStyle = {};
let pos = 0;
let textStart = 0;
const push = (end: number) => {
const text = input.slice(textStart, end);
if (text) segments.push({ text, style: { ...current } });
};
while (pos < input.length) {
const esc = input.indexOf("\x1b[", pos);
if (esc === -1) break;
push(esc);
let seqEnd = esc + 2;
while (seqEnd < input.length && !/[A-Za-z]/.test(input[seqEnd])) seqEnd++;
const term = input[seqEnd];
const params = input.slice(esc + 2, seqEnd).split(";").map(Number);
if (term === "m") {
let i = 0;
while (i < params.length) {
const p = params[i];
if (p === 0 || isNaN(p)) { current = {}; }
else if (p === 1) { current.bold = true; }
else if (p === 2) { current.dim = true; }
else if (p === 3) { current.italic = true; }
else if (p === 4) { current.underline = true; }
else if (p === 9) { current.strikethrough = true; }
else if (p === 22) { current.bold = false; current.dim = false; }
else if (p === 23) { current.italic = false; }
else if (p === 24) { current.underline = false; }
else if (p === 29) { current.strikethrough = false; }
else if (p >= 30 && p <= 37) { current.color = FG[p]; }
else if (p === 38) {
if (params[i+1] === 5 && params[i+2] !== undefined) {
current.color = get256Color(params[i+2]); i += 2;
} else if (params[i+1] === 2 && params[i+4] !== undefined) {
current.color = `rgb(${params[i+2]},${params[i+3]},${params[i+4]})`; i += 4;
}
}
else if (p === 39) { delete current.color; }
else if (p >= 40 && p <= 47) { current.background = BG[p]; }
else if (p === 48) {
if (params[i+1] === 5 && params[i+2] !== undefined) {
current.background = get256Color(params[i+2]); i += 2;
} else if (params[i+1] === 2 && params[i+4] !== undefined) {
current.background = `rgb(${params[i+2]},${params[i+3]},${params[i+4]})`; i += 4;
}
}
else if (p === 49) { delete current.background; }
else if (p >= 90 && p <= 97) { current.color = FG[p]; }
else if (p >= 100 && p <= 107) { current.background = BG[p]; }
i++;
}
}
pos = seqEnd + 1;
textStart = pos;
}
push(input.length);
return segments;
}
/**
* Convert an ANSI string to a plain HTML string with inline styles.
* Each styled run is wrapped in a `<span style="...">`.
* Suitable for dangerouslySetInnerHTML when the source is trusted server output.
*/
export function ansiToHtml(text: string): string {
const segments = parseAnsiSegments(text);
return segments
.map(({ text: t, style }) => {
const parts: string[] = [];
if (style.color) parts.push(`color:${style.color}`);
if (style.background) parts.push(`background:${style.background}`);
if (style.bold) parts.push("font-weight:bold");
if (style.dim) parts.push("opacity:0.7");
if (style.italic) parts.push("font-style:italic");
const deco = [style.underline && "underline", style.strikethrough && "line-through"]
.filter(Boolean).join(" ");
if (deco) parts.push(`text-decoration:${deco}`);
const escaped = t
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
return parts.length ? `<span style="${parts.join(";")}">${escaped}</span>` : escaped;
})
.join("");
}

85
web/lib/api.ts Normal file
View File

@@ -0,0 +1,85 @@
import type { Message } from "./types";
const getApiUrl = () =>
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
export interface StreamChunk {
type: "text" | "tool_use" | "tool_result" | "done" | "error";
content?: string;
tool?: {
id: string;
name: string;
input?: Record<string, unknown>;
result?: string;
is_error?: boolean;
};
error?: string;
}
export async function* streamChat(
messages: Pick<Message, "role" | "content">[],
model: string,
signal?: AbortSignal
): AsyncGenerator<StreamChunk> {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages, model, stream: true }),
signal,
});
if (!response.ok) {
const err = await response.text();
yield { type: "error", error: err };
return;
}
const reader = response.body?.getReader();
if (!reader) {
yield { type: "error", error: "No response body" };
return;
}
const decoder = new TextDecoder();
let buffer = "";
try {
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 data = line.slice(6).trim();
if (data === "[DONE]") {
yield { type: "done" };
return;
}
try {
const chunk = JSON.parse(data) as StreamChunk;
yield chunk;
} catch {
// skip malformed chunks
}
}
}
}
} finally {
reader.releaseLock();
}
yield { type: "done" };
}
export async function fetchHealth(): Promise<boolean> {
try {
const res = await fetch(`${getApiUrl()}/health`, { cache: "no-store" });
return res.ok;
} catch {
return false;
}
}

271
web/lib/api/client.ts Normal file
View File

@@ -0,0 +1,271 @@
import { ApiError } from "./types";
import type { RequestOptions } from "./types";
const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_RETRY_ATTEMPTS = 3;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getBaseUrl(): string {
return (
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) ||
"http://localhost:3001"
);
}
function getApiKey(): string | undefined {
return typeof process !== "undefined"
? process.env.NEXT_PUBLIC_API_KEY
: undefined;
}
function isAbortError(err: unknown): boolean {
return (
err instanceof Error &&
(err.name === "AbortError" || err.message.toLowerCase().includes("aborted"))
);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function backoffMs(attempt: number): number {
return Math.min(500 * Math.pow(2, attempt), 8_000);
}
/** Combine multiple AbortSignals into one. Aborts if any source aborts. */
function combineSignals(...signals: (AbortSignal | undefined)[]): {
signal: AbortSignal;
cleanup: () => void;
} {
const controller = new AbortController();
const listeners: Array<() => void> = [];
for (const sig of signals) {
if (!sig) continue;
if (sig.aborted) {
controller.abort(sig.reason);
break;
}
const listener = () => controller.abort(sig.reason);
sig.addEventListener("abort", listener, { once: true });
listeners.push(() => sig.removeEventListener("abort", listener));
}
return {
signal: controller.signal,
cleanup: () => listeners.forEach((fn) => fn()),
};
}
async function toApiError(res: Response): Promise<ApiError> {
let message = `Request failed with status ${res.status}`;
try {
const body = (await res.json()) as { error?: string; message?: string };
message = body.error ?? body.message ?? message;
} catch {
// ignore parse errors
}
const type =
res.status === 401
? "auth"
: res.status === 404
? "not_found"
: res.status === 429
? "rate_limit"
: res.status >= 500
? "server"
: "server";
const retryAfterMs =
res.status === 429
? (parseInt(res.headers.get("Retry-After") ?? "60", 10) || 60) * 1_000
: undefined;
return new ApiError(res.status, message, type, retryAfterMs);
}
// ---------------------------------------------------------------------------
// ApiClient
// ---------------------------------------------------------------------------
class ApiClient {
readonly baseUrl: string;
private readonly apiKey: string | undefined;
/** In-flight GET requests keyed by path — for deduplication. */
private readonly inflight = new Map<string, Promise<Response>>();
constructor() {
this.baseUrl = getBaseUrl();
this.apiKey = getApiKey();
}
private buildHeaders(extra?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...extra,
};
if (this.apiKey) {
headers["Authorization"] = `Bearer ${this.apiKey}`;
}
return headers;
}
private async fetchWithRetry(
url: string,
init: RequestInit,
attempt = 0
): Promise<Response> {
try {
if (attempt > 0) {
console.debug(`[api] retry ${attempt}/${MAX_RETRY_ATTEMPTS - 1} ${init.method ?? "GET"} ${url}`);
} else {
console.debug(`[api] → ${init.method ?? "GET"} ${url}`);
}
const res = await fetch(url, init);
console.debug(`[api] ← ${res.status} ${url}`);
if (res.status >= 500 && attempt < MAX_RETRY_ATTEMPTS - 1 && !init.signal?.aborted) {
await sleep(backoffMs(attempt));
return this.fetchWithRetry(url, init, attempt + 1);
}
return res;
} catch (err) {
if (!isAbortError(err) && attempt < MAX_RETRY_ATTEMPTS - 1) {
await sleep(backoffMs(attempt));
return this.fetchWithRetry(url, init, attempt + 1);
}
throw err;
}
}
/**
* Core fetch method. Applies auth headers, timeout, and retry logic.
* Pass `timeout: 0` to disable timeout (e.g. for streaming responses).
*/
async request(
path: string,
init: RequestInit & { timeout?: number; extraHeaders?: Record<string, string> } = {}
): Promise<Response> {
const {
timeout = DEFAULT_TIMEOUT_MS,
signal: userSignal,
extraHeaders,
...rest
} = init;
const url = `${this.baseUrl}${path}`;
const headers = this.buildHeaders(extraHeaders);
const timeoutSignals: (AbortSignal | undefined)[] = [userSignal];
let timeoutController: AbortController | undefined;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (timeout > 0) {
timeoutController = new AbortController();
timeoutId = setTimeout(() => timeoutController!.abort(), timeout);
timeoutSignals.push(timeoutController.signal);
}
const { signal, cleanup } = combineSignals(...timeoutSignals);
try {
return await this.fetchWithRetry(url, { ...rest, headers, signal }, 0);
} catch (err) {
if (isAbortError(err)) {
const didTimeout = timeoutController?.signal.aborted ?? false;
throw new ApiError(
408,
didTimeout ? "Request timed out" : "Request cancelled",
didTimeout ? "timeout" : "abort"
);
}
throw new ApiError(
0,
err instanceof Error ? err.message : "Network error",
"network"
);
} finally {
cleanup();
if (timeoutId) clearTimeout(timeoutId);
}
}
// ---------------------------------------------------------------------------
// Convenience methods
// ---------------------------------------------------------------------------
/** GET with request deduplication. */
async get<T>(path: string, opts?: RequestOptions): Promise<T> {
const key = path;
const existing = this.inflight.get(key);
if (existing) {
const res = await existing;
if (!res.ok) throw await toApiError(res.clone());
return res.clone().json() as Promise<T>;
}
const promise = this.request(path, { method: "GET", signal: opts?.signal });
this.inflight.set(key, promise);
promise.finally(() => this.inflight.delete(key));
const res = await promise;
if (!res.ok) throw await toApiError(res);
return res.json() as Promise<T>;
}
async post<T>(path: string, body: unknown, opts?: RequestOptions): Promise<T> {
const res = await this.request(path, {
method: "POST",
body: JSON.stringify(body),
signal: opts?.signal,
extraHeaders: opts?.headers,
});
if (!res.ok) throw await toApiError(res);
return res.json() as Promise<T>;
}
async patch<T>(path: string, body: unknown, opts?: RequestOptions): Promise<T> {
const res = await this.request(path, {
method: "PATCH",
body: JSON.stringify(body),
signal: opts?.signal,
});
if (!res.ok) throw await toApiError(res);
return res.json() as Promise<T>;
}
async delete(path: string, opts?: RequestOptions): Promise<void> {
const res = await this.request(path, {
method: "DELETE",
signal: opts?.signal,
});
if (!res.ok) throw await toApiError(res);
}
/**
* POST that returns the raw Response (for streaming). Timeout is disabled
* automatically.
*/
async postStream(
path: string,
body: unknown,
opts?: RequestOptions
): Promise<Response> {
const res = await this.request(path, {
method: "POST",
body: JSON.stringify(body),
signal: opts?.signal,
timeout: 0,
extraHeaders: opts?.headers,
});
if (!res.ok) throw await toApiError(res);
return res;
}
}
export const apiClient = new ApiClient();

View File

@@ -0,0 +1,109 @@
import { ApiError } from "./types";
import { extractTextContent } from "../utils";
import type { Conversation } from "../types";
// Lazy import to avoid circular deps at module init time
function getStore() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require("../store").useChatStore as import("../store").UseChatStore;
}
// ---------------------------------------------------------------------------
// ConversationAPI — backed by the Zustand client-side store.
// ---------------------------------------------------------------------------
//
// Conversations are not persisted on the backend; they live in localStorage
// via Zustand persist middleware. This API provides a consistent async
// interface so callers don't need to know about the store directly.
export interface ListOptions {
limit?: number;
offset?: number;
}
export interface CreateOptions {
title?: string;
model?: string;
}
export interface ConversationAPI {
list(options?: ListOptions): Promise<Conversation[]>;
get(id: string): Promise<Conversation>;
create(options?: CreateOptions): Promise<Conversation>;
update(id: string, updates: Partial<Pick<Conversation, "title" | "model">>): Promise<Conversation>;
delete(id: string): Promise<void>;
export(id: string, format: "json" | "markdown"): Promise<Blob>;
}
function findConversation(id: string): Conversation {
const store = getStore();
const conv = store.getState().conversations.find((c) => c.id === id);
if (!conv) throw new ApiError(404, `Conversation ${id} not found`, "not_found");
return conv;
}
export const conversationAPI: ConversationAPI = {
async list(opts) {
const { limit = 20, offset = 0 } = opts ?? {};
const { conversations } = getStore().getState();
return conversations.slice(offset, offset + limit);
},
async get(id) {
return findConversation(id);
},
async create(opts) {
const store = getStore();
const id = store.getState().createConversation();
if (opts?.title || opts?.model) {
store.getState().updateConversation(id, {
...(opts.title ? { title: opts.title } : {}),
...(opts.model ? { model: opts.model } : {}),
});
}
return findConversation(id);
},
async update(id, updates) {
findConversation(id); // throws 404 if missing
getStore().getState().updateConversation(id, updates);
return findConversation(id);
},
async delete(id) {
findConversation(id); // throws 404 if missing
getStore().getState().deleteConversation(id);
},
async export(id, format) {
const conv = findConversation(id);
if (format === "json") {
return new Blob([JSON.stringify(conv, null, 2)], {
type: "application/json",
});
}
// Markdown export
const lines: string[] = [`# ${conv.title}`, ""];
const created = new Date(conv.createdAt).toISOString();
lines.push(`> Exported from Claude Code · ${created}`, "");
for (const msg of conv.messages) {
const heading =
msg.role === "user"
? "**You**"
: msg.role === "assistant"
? "**Claude**"
: `**${msg.role}**`;
lines.push(heading, "");
lines.push(extractTextContent(msg.content), "");
lines.push("---", "");
}
return new Blob([lines.join("\n")], { type: "text/markdown" });
},
};

249
web/lib/api/files.ts Normal file
View File

@@ -0,0 +1,249 @@
import { nanoid } from "nanoid";
import { ApiError } from "./types";
import type {
FileEntry,
SearchResult,
UploadResult,
McpRequest,
McpResponse,
McpToolResult,
} from "./types";
// ---------------------------------------------------------------------------
// MCP JSON-RPC client for the file system tools
// ---------------------------------------------------------------------------
//
// The MCP server exposes tools at POST /mcp (Streamable HTTP transport).
// We maintain a single session per page load and reinitialize if it expires.
function getBaseUrl(): string {
return (
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) ||
"http://localhost:3001"
);
}
function getApiKey(): string | undefined {
return typeof process !== "undefined"
? process.env.NEXT_PUBLIC_API_KEY
: undefined;
}
class McpClient {
private sessionId: string | null = null;
private initPromise: Promise<void> | null = null;
private buildHeaders(extra?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...extra,
};
const key = getApiKey();
if (key) headers["Authorization"] = `Bearer ${key}`;
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
return headers;
}
private async doInitialize(): Promise<void> {
const res = await fetch(`${getBaseUrl()}/mcp`, {
method: "POST",
headers: this.buildHeaders(),
body: JSON.stringify({
jsonrpc: "2.0",
id: "init",
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "claude-code-web", version: "1.0.0" },
},
} satisfies McpRequest),
});
if (!res.ok) {
this.initPromise = null;
throw new ApiError(res.status, "MCP session initialization failed");
}
this.sessionId = res.headers.get("mcp-session-id");
// Send "initialized" notification (fire-and-forget)
if (this.sessionId) {
fetch(`${getBaseUrl()}/mcp`, {
method: "POST",
headers: this.buildHeaders(),
body: JSON.stringify({
jsonrpc: "2.0",
method: "notifications/initialized",
}),
}).catch(() => {
// non-critical
});
}
}
private initialize(): Promise<void> {
if (!this.initPromise) {
this.initPromise = this.doInitialize().catch((err) => {
this.initPromise = null;
throw err;
});
}
return this.initPromise;
}
/** Parse an SSE-streamed MCP response and return the first result text. */
private async parseSseResponse(res: Response): Promise<string> {
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
let result = "";
try {
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;
try {
const json = JSON.parse(line.slice(6)) as McpResponse<McpToolResult>;
if (json.result?.content?.[0]?.text != null) {
result = json.result.content[0].text;
}
} catch {
// skip
}
}
}
} finally {
reader.releaseLock();
}
return result;
}
async callTool(name: string, args: Record<string, unknown>): Promise<string> {
await this.initialize();
const res = await fetch(`${getBaseUrl()}/mcp`, {
method: "POST",
headers: this.buildHeaders(),
body: JSON.stringify({
jsonrpc: "2.0",
id: nanoid(),
method: "tools/call",
params: { name, arguments: args },
} satisfies McpRequest),
});
// Session expired — reinitialize once and retry
if (res.status === 400 || res.status === 404) {
this.sessionId = null;
this.initPromise = null;
await this.initialize();
return this.callTool(name, args);
}
if (!res.ok) {
throw new ApiError(res.status, `MCP tool "${name}" failed`);
}
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("text/event-stream")) {
return this.parseSseResponse(res);
}
const json = (await res.json()) as McpResponse<McpToolResult>;
if (json.error) {
throw new ApiError(500, json.error.message);
}
const toolResult = json.result;
if (toolResult?.isError) {
throw new ApiError(500, toolResult.content[0]?.text ?? "Tool error");
}
return toolResult?.content?.[0]?.text ?? "";
}
}
const mcpClient = new McpClient();
// ---------------------------------------------------------------------------
// Parsers
// ---------------------------------------------------------------------------
function parseDirectoryListing(basePath: string, text: string): FileEntry[] {
const entries: FileEntry[] = [];
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const isDir = trimmed.endsWith("/");
const name = isDir ? trimmed.slice(0, -1) : trimmed;
const joinedPath = `${basePath.replace(/\/$/, "")}/${name}`;
entries.push({ name, path: joinedPath, type: isDir ? "directory" : "file" });
}
return entries;
}
// Matches: path/to/file.ts:42: some content
const GREP_LINE_RE = /^([^:]+):(\d+):(.*)$/;
function parseSearchResults(text: string): SearchResult[] {
const results: SearchResult[] = [];
for (const line of text.split("\n")) {
const match = GREP_LINE_RE.exec(line.trim());
if (!match) continue;
const [, path, lineStr, content] = match;
results.push({ path, line: parseInt(lineStr, 10), content: content.trim() });
}
return results;
}
// ---------------------------------------------------------------------------
// FileAPI
// ---------------------------------------------------------------------------
export interface ReadOptions {
offset?: number;
limit?: number;
}
export interface SearchOptions {
glob?: string;
}
export interface FileAPI {
list(path: string): Promise<FileEntry[]>;
read(path: string, options?: ReadOptions): Promise<string>;
search(query: string, options?: SearchOptions): Promise<SearchResult[]>;
upload(file: File): Promise<UploadResult>;
}
export const fileAPI: FileAPI = {
async list(path) {
const text = await mcpClient.callTool("list_directory", { path });
return parseDirectoryListing(path, text);
},
async read(path, opts) {
const args: Record<string, unknown> = { path };
if (opts?.offset != null) args.offset = opts.offset;
if (opts?.limit != null) args.limit = opts.limit;
return mcpClient.callTool("read_source_file", args);
},
async search(query, opts) {
const args: Record<string, unknown> = { pattern: query };
if (opts?.glob) args.glob = opts.glob;
const text = await mcpClient.callTool("search_source", args);
return parseSearchResults(text);
},
async upload(_file) {
// The MCP server does not expose a file upload endpoint.
throw new ApiError(501, "File upload is not supported", "server");
},
};

156
web/lib/api/messages.ts Normal file
View File

@@ -0,0 +1,156 @@
import { apiClient } from "./client";
import { parseStream } from "./stream";
import { ApiError } from "./types";
import type { StreamEvent, StreamProgress } from "./types";
import type { Message } from "../types";
import { DEFAULT_MODEL } from "../constants";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Combine any number of AbortSignals into one. */
function combineSignals(...signals: (AbortSignal | undefined)[]): AbortSignal {
const controller = new AbortController();
for (const sig of signals) {
if (!sig) continue;
if (sig.aborted) {
controller.abort(sig.reason);
return controller.signal;
}
sig.addEventListener("abort", () => controller.abort(sig.reason), { once: true });
}
return controller.signal;
}
function toApiMessages(
messages: Message[]
): Array<{ role: string; content: unknown }> {
return messages
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => ({ role: m.role, content: m.content }));
}
// ---------------------------------------------------------------------------
// Per-conversation abort controllers (for stop())
// ---------------------------------------------------------------------------
const activeControllers = new Map<string, AbortController>();
// ---------------------------------------------------------------------------
// MessageAPI
// ---------------------------------------------------------------------------
export interface SendOptions {
model?: string;
maxTokens?: number;
files?: File[];
signal?: AbortSignal;
onProgress?: (progress: StreamProgress) => void;
}
export interface MessageAPI {
/**
* Send a user message in a conversation and stream the assistant response.
* The caller is responsible for reading the conversation history from the
* store and providing it in `history`.
*/
send(
conversationId: string,
content: string,
history: Message[],
opts?: SendOptions
): AsyncGenerator<StreamEvent>;
/**
* Retry an assistant message. Sends everything up to and including the
* user message that preceded `messageId`.
*/
retry(
conversationId: string,
messagesUpToAssistant: Message[],
opts?: SendOptions
): AsyncGenerator<StreamEvent>;
/**
* Edit a user message and regenerate the assistant response.
* `historyBefore` should contain only the messages before the edited one.
*/
edit(
conversationId: string,
newContent: string,
historyBefore: Message[],
opts?: SendOptions
): AsyncGenerator<StreamEvent>;
/** Cancel any in-progress stream for this conversation. */
stop(conversationId: string): Promise<void>;
}
async function* streamRequest(
conversationId: string,
body: Record<string, unknown>,
opts?: SendOptions
): AsyncGenerator<StreamEvent> {
// Cancel any existing stream for this conversation
activeControllers.get(conversationId)?.abort();
const controller = new AbortController();
activeControllers.set(conversationId, controller);
const signal = combineSignals(opts?.signal, controller.signal);
try {
const res = await apiClient.postStream("/api/chat", body, { signal });
yield* parseStream(res, { signal, onProgress: opts?.onProgress });
} catch (err) {
if (err instanceof ApiError && err.type === "abort") return; // stop() was called
throw err;
} finally {
if (activeControllers.get(conversationId) === controller) {
activeControllers.delete(conversationId);
}
}
}
export const messageAPI: MessageAPI = {
async *send(conversationId, content, history, opts) {
const model = opts?.model ?? DEFAULT_MODEL;
const messages = [
...toApiMessages(history),
{ role: "user", content },
];
yield* streamRequest(
conversationId,
{ messages, model, stream: true, max_tokens: opts?.maxTokens },
opts
);
},
async *retry(conversationId, messagesUpToAssistant, opts) {
const model = opts?.model ?? DEFAULT_MODEL;
const messages = toApiMessages(messagesUpToAssistant);
yield* streamRequest(
conversationId,
{ messages, model, stream: true },
opts
);
},
async *edit(conversationId, newContent, historyBefore, opts) {
const model = opts?.model ?? DEFAULT_MODEL;
const messages = [
...toApiMessages(historyBefore),
{ role: "user", content: newContent },
];
yield* streamRequest(
conversationId,
{ messages, model, stream: true },
opts
);
},
async stop(conversationId) {
activeControllers.get(conversationId)?.abort();
activeControllers.delete(conversationId);
},
};

228
web/lib/api/stream.ts Normal file
View File

@@ -0,0 +1,228 @@
import { ApiError } from "./types";
import type { StreamEvent, StreamProgress } from "./types";
// ---------------------------------------------------------------------------
// Parse a fetch streaming response body as SSE events
// ---------------------------------------------------------------------------
export interface ParseStreamOptions {
signal?: AbortSignal;
onProgress?: (progress: StreamProgress) => void;
}
/**
* Parses a streaming HTTP response body formatted as server-sent events.
* Yields typed StreamEvent objects as they arrive. Handles burst buffering —
* events are never dropped regardless of how fast they arrive.
*/
export async function* parseStream(
response: Response,
opts?: ParseStreamOptions
): AsyncGenerator<StreamEvent> {
const reader = response.body?.getReader();
if (!reader) throw new ApiError(0, "No response body", "network");
const decoder = new TextDecoder();
let buffer = "";
const startTime = Date.now();
let tokensReceived = 0;
try {
while (true) {
if (opts?.signal?.aborted) break;
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process all complete lines. The last (potentially incomplete) line
// stays in the buffer.
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue; // blank line = SSE event separator
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") return;
try {
const event = JSON.parse(data) as StreamEvent;
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
tokensReceived += event.delta.text.length;
}
opts?.onProgress?.({
tokensReceived,
elapsedMs: Date.now() - startTime,
isComplete: false,
});
yield event;
} catch {
// skip malformed JSON — keep going
}
}
}
} finally {
reader.releaseLock();
opts?.onProgress?.({
tokensReceived,
elapsedMs: Date.now() - startTime,
isComplete: true,
});
}
}
// ---------------------------------------------------------------------------
// Long-lived SSE connection with automatic reconnection
// ---------------------------------------------------------------------------
export interface SSEConnectionOptions {
onEvent: (event: StreamEvent) => void;
onError: (error: ApiError) => void;
onConnect?: () => void;
onDisconnect?: () => void;
/** Max reconnect attempts before giving up (default: 5) */
maxReconnects?: number;
/** Extra request headers (not supported by EventSource — use fetchSSE instead) */
headers?: Record<string, string>;
}
/**
* Manages a long-lived SSE connection to a URL with automatic exponential
* backoff reconnection. Uses the native EventSource API.
*
* For endpoints that require custom headers, use FetchSSEConnection instead.
*/
export class SSEConnection {
private eventSource: EventSource | null = null;
private reconnectAttempts = 0;
private readonly maxReconnects: number;
private closed = false;
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
constructor(
private readonly url: string,
private readonly opts: SSEConnectionOptions
) {
this.maxReconnects = opts.maxReconnects ?? 5;
}
connect(): void {
if (this.closed) return;
this.eventSource = new EventSource(this.url);
this.eventSource.onopen = () => {
this.reconnectAttempts = 0;
this.opts.onConnect?.();
};
this.eventSource.onmessage = (e: MessageEvent) => {
if (typeof e.data !== "string") return;
if (e.data === "[DONE]") return;
try {
const event = JSON.parse(e.data as string) as StreamEvent;
this.opts.onEvent(event);
} catch {
// skip malformed events
}
};
this.eventSource.onerror = () => {
this.eventSource?.close();
this.eventSource = null;
this.opts.onDisconnect?.();
this.scheduleReconnect();
};
}
disconnect(): void {
this.closed = true;
clearTimeout(this.reconnectTimer);
this.eventSource?.close();
this.eventSource = null;
}
private scheduleReconnect(): void {
if (this.closed || this.reconnectAttempts >= this.maxReconnects) {
if (!this.closed) {
this.opts.onError(
new ApiError(0, "SSE connection permanently lost", "network")
);
}
return;
}
const delay = Math.min(1_000 * Math.pow(2, this.reconnectAttempts), 30_000);
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(() => this.connect(), delay);
}
}
/**
* SSE connection backed by fetch (supports custom headers and auth).
* Streams an async generator of StreamEvents with reconnection.
*/
export async function* fetchSSE(
url: string,
opts?: {
signal?: AbortSignal;
headers?: Record<string, string>;
maxReconnects?: number;
onProgress?: (progress: StreamProgress) => void;
}
): AsyncGenerator<StreamEvent> {
const maxReconnects = opts?.maxReconnects ?? 5;
let attempt = 0;
while (true) {
if (opts?.signal?.aborted) break;
try {
const res = await fetch(url, {
headers: {
Accept: "text/event-stream",
"Cache-Control": "no-cache",
...opts?.headers,
},
signal: opts?.signal,
});
if (!res.ok) {
throw new ApiError(res.status, `SSE connection failed: ${res.status}`);
}
// Reset attempt counter on successful connection
attempt = 0;
yield* parseStream(res, { signal: opts?.signal, onProgress: opts?.onProgress });
// Clean disconnect — don't reconnect
break;
} catch (err) {
if (err instanceof ApiError && err.type === "abort") break;
if (opts?.signal?.aborted) break;
if (attempt >= maxReconnects) throw err;
const delay = Math.min(1_000 * Math.pow(2, attempt), 30_000);
attempt++;
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, delay);
opts?.signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(new ApiError(0, "Cancelled", "abort"));
},
{ once: true }
);
});
}
}
}

124
web/lib/api/types.ts Normal file
View File

@@ -0,0 +1,124 @@
import type { ContentBlock } from "../types";
// ---------------------------------------------------------------------------
// SSE stream events (Anthropic message streaming protocol)
// ---------------------------------------------------------------------------
export type ContentDelta =
| { type: "text_delta"; text: string }
| { type: "input_json_delta"; partial_json: string };
export type StreamEvent =
| {
type: "message_start";
message: {
id: string;
role: string;
model: string;
usage: { input_tokens: number; output_tokens: number };
};
}
| { type: "content_block_start"; index: number; content_block: ContentBlock }
| { type: "content_block_delta"; index: number; delta: ContentDelta }
| { type: "content_block_stop"; index: number }
| {
type: "message_delta";
delta: { stop_reason: string; stop_sequence: string | null };
usage: { output_tokens: number };
}
| { type: "message_stop" }
| { type: "error"; error: { type: string; message: string } }
| { type: "ping" };
// ---------------------------------------------------------------------------
// API errors
// ---------------------------------------------------------------------------
export type ApiErrorType =
| "network"
| "auth"
| "rate_limit"
| "server"
| "timeout"
| "abort"
| "not_found";
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly type: ApiErrorType = "server",
/** Retry-After seconds for rate limit errors */
public readonly retryAfterMs?: number
) {
super(message);
this.name = "ApiError";
}
get isRetryable(): boolean {
return this.type === "network" || this.type === "server" || this.status >= 500;
}
}
// ---------------------------------------------------------------------------
// Shared request / response types
// ---------------------------------------------------------------------------
export interface RequestOptions {
signal?: AbortSignal;
timeout?: number;
headers?: Record<string, string>;
}
export interface StreamProgress {
tokensReceived: number;
elapsedMs: number;
isComplete: boolean;
}
// ---------------------------------------------------------------------------
// File API types
// ---------------------------------------------------------------------------
export interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
size?: number;
modifiedAt?: number;
}
export interface SearchResult {
path: string;
line: number;
content: string;
context?: string[];
}
export interface UploadResult {
path: string;
size: number;
}
// ---------------------------------------------------------------------------
// MCP JSON-RPC types
// ---------------------------------------------------------------------------
export interface McpRequest {
jsonrpc: "2.0";
id?: string | number;
method: string;
params?: Record<string, unknown>;
}
export interface McpResponse<T = unknown> {
jsonrpc: "2.0";
id?: string | number;
result?: T;
error?: { code: number; message: string; data?: unknown };
}
export interface McpToolResult {
content: Array<{ type: "text"; text: string }>;
isError?: boolean;
}

View File

@@ -0,0 +1,50 @@
export interface BrowserNotificationOptions {
title: string;
body?: string;
onClick?: () => void;
}
class BrowserNotificationService {
async requestPermission(): Promise<boolean> {
if (typeof window === "undefined") return false;
if (!("Notification" in window)) return false;
if (Notification.permission === "granted") return true;
if (Notification.permission === "denied") return false;
const result = await Notification.requestPermission();
return result === "granted";
}
async send(options: BrowserNotificationOptions): Promise<void> {
if (typeof window === "undefined") return;
if (!("Notification" in window)) return;
// Only notify when the tab is in the background
if (!document.hidden) return;
const granted = await this.requestPermission();
if (!granted) return;
const n = new Notification(options.title, {
body: options.body,
icon: "/favicon.ico",
});
n.onclick = () => {
window.focus();
options.onClick?.();
n.close();
};
}
getPermission(): NotificationPermission {
if (typeof window === "undefined") return "default";
if (!("Notification" in window)) return "denied";
return Notification.permission;
}
isSupported(): boolean {
return typeof window !== "undefined" && "Notification" in window;
}
}
export const browserNotifications = new BrowserNotificationService();

View File

@@ -0,0 +1,100 @@
import type { CollabRole } from "./socket";
import type { LinkExpiry, ShareLink } from "./types";
import { nanoid } from "nanoid";
// ─── Role Hierarchy ───────────────────────────────────────────────────────────
const ROLE_RANK: Record<CollabRole, number> = {
owner: 3,
collaborator: 2,
viewer: 1,
};
export function hasPermission(role: CollabRole, required: CollabRole): boolean {
return ROLE_RANK[role] >= ROLE_RANK[required];
}
export function canSendMessages(role: CollabRole): boolean {
return hasPermission(role, "collaborator");
}
export function canApproveTools(
role: CollabRole,
policy: "owner-only" | "any-collaborator",
isOwner: boolean
): boolean {
if (policy === "owner-only") return isOwner;
return hasPermission(role, "collaborator");
}
export function canManageAccess(role: CollabRole): boolean {
return role === "owner";
}
export function canTransferOwnership(role: CollabRole): boolean {
return role === "owner";
}
export function canAddAnnotations(role: CollabRole): boolean {
return hasPermission(role, "collaborator");
}
export function canChangeRole(
actorRole: CollabRole,
targetRole: CollabRole
): boolean {
// Owner can change any role; collaborators cannot manage access at all
return actorRole === "owner" && targetRole !== "owner";
}
// ─── Share Links ──────────────────────────────────────────────────────────────
const EXPIRY_MS: Record<LinkExpiry, number | null> = {
"1h": 60 * 60 * 1_000,
"24h": 24 * 60 * 60 * 1_000,
"7d": 7 * 24 * 60 * 60 * 1_000,
never: null,
};
export function createShareLink(
sessionId: string,
role: CollabRole,
expiry: LinkExpiry,
createdBy: string
): ShareLink {
const expiryMs = EXPIRY_MS[expiry];
return {
id: nanoid(12),
sessionId,
role,
createdAt: Date.now(),
expiresAt: expiryMs !== null ? Date.now() + expiryMs : null,
createdBy,
};
}
export function isLinkExpired(link: ShareLink): boolean {
return link.expiresAt !== null && Date.now() > link.expiresAt;
}
export function buildShareUrl(linkId: string, role: CollabRole): string {
const base =
typeof window !== "undefined"
? window.location.origin
: "http://localhost:3000";
return `${base}/join/${linkId}?role=${role}`;
}
export function labelForRole(role: CollabRole): string {
return { owner: "Owner", collaborator: "Collaborator", viewer: "Viewer" }[
role
];
}
export function descriptionForRole(role: CollabRole): string {
return {
owner: "Full control — can send messages, approve tools, and manage access",
collaborator: "Can send messages and approve tool use",
viewer: "Read-only — can watch the session in real-time",
}[role];
}

View File

@@ -0,0 +1,115 @@
import type { CollabUser } from "./socket";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface CursorState {
userId: string;
position: number;
selectionStart?: number;
selectionEnd?: number;
updatedAt: number;
}
export interface PresenceState {
users: Map<string, CollabUser>;
cursors: Map<string, CursorState>;
typing: Set<string>; // user IDs currently typing
}
// ─── Factory ──────────────────────────────────────────────────────────────────
export function createPresenceState(): PresenceState {
return {
users: new Map(),
cursors: new Map(),
typing: new Set(),
};
}
// ─── Updaters (all return new state, immutable) ───────────────────────────────
export function presenceAddUser(
state: PresenceState,
user: CollabUser
): PresenceState {
return { ...state, users: new Map(state.users).set(user.id, user) };
}
export function presenceRemoveUser(
state: PresenceState,
userId: string
): PresenceState {
const users = new Map(state.users);
users.delete(userId);
const cursors = new Map(state.cursors);
cursors.delete(userId);
const typing = new Set(state.typing);
typing.delete(userId);
return { users, cursors, typing };
}
export function presenceSyncUsers(
state: PresenceState,
users: CollabUser[]
): PresenceState {
const map = new Map<string, CollabUser>();
users.forEach((u) => map.set(u.id, u));
return { ...state, users: map };
}
export function presenceUpdateCursor(
state: PresenceState,
userId: string,
position: number,
selectionStart?: number,
selectionEnd?: number
): PresenceState {
const cursor: CursorState = {
userId,
position,
selectionStart,
selectionEnd,
updatedAt: Date.now(),
};
return { ...state, cursors: new Map(state.cursors).set(userId, cursor) };
}
export function presenceSetTyping(
state: PresenceState,
userId: string,
isTyping: boolean
): PresenceState {
const typing = new Set(state.typing);
if (isTyping) {
typing.add(userId);
} else {
typing.delete(userId);
}
return { ...state, typing };
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const USER_COLORS = [
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#06b6d4",
"#3b82f6",
"#8b5cf6",
"#ec4899",
];
export function getUserColor(index: number): string {
return USER_COLORS[index % USER_COLORS.length];
}
export function getInitials(name: string): string {
return name
.split(" ")
.map((n) => n[0])
.slice(0, 2)
.join("")
.toUpperCase();
}

View File

@@ -0,0 +1,321 @@
"use client";
// ─── Role & User ────────────────────────────────────────────────────────────
export type CollabRole = "owner" | "collaborator" | "viewer";
export interface CollabUser {
id: string;
name: string;
email: string;
avatar?: string;
color: string; // hex color assigned per session
role: CollabRole;
}
// ─── Event Types ─────────────────────────────────────────────────────────────
export type CollabEventType =
| "message_added"
| "message_streaming"
| "tool_use_pending"
| "tool_use_approved"
| "tool_use_denied"
| "user_joined"
| "user_left"
| "cursor_update"
| "typing_start"
| "typing_stop"
| "presence_sync"
| "annotation_added"
| "annotation_resolved"
| "annotation_reply"
| "role_changed"
| "access_revoked"
| "ownership_transferred"
| "session_state"
| "error";
interface BaseCollabEvent {
type: CollabEventType;
sessionId: string;
userId: string;
timestamp: number;
}
export interface MessageAddedEvent extends BaseCollabEvent {
type: "message_added";
message: {
id: string;
role: string;
content: unknown;
status: string;
createdAt: number;
};
}
export interface MessageStreamingEvent extends BaseCollabEvent {
type: "message_streaming";
messageId: string;
delta: string;
done: boolean;
}
export interface ToolUsePendingEvent extends BaseCollabEvent {
type: "tool_use_pending";
toolUseId: string;
toolName: string;
toolInput: Record<string, unknown>;
messageId: string;
}
export interface ToolUseApprovedEvent extends BaseCollabEvent {
type: "tool_use_approved";
toolUseId: string;
approvedBy: CollabUser;
}
export interface ToolUseDeniedEvent extends BaseCollabEvent {
type: "tool_use_denied";
toolUseId: string;
deniedBy: CollabUser;
}
export interface UserJoinedEvent extends BaseCollabEvent {
type: "user_joined";
user: CollabUser;
}
export interface UserLeftEvent extends BaseCollabEvent {
type: "user_left";
user: CollabUser;
}
export interface CursorUpdateEvent extends BaseCollabEvent {
type: "cursor_update";
position: number;
selectionStart?: number;
selectionEnd?: number;
}
export interface TypingStartEvent extends BaseCollabEvent {
type: "typing_start";
user: CollabUser;
}
export interface TypingStopEvent extends BaseCollabEvent {
type: "typing_stop";
user: CollabUser;
}
export interface PresenceSyncEvent extends BaseCollabEvent {
type: "presence_sync";
users: CollabUser[];
}
export interface AnnotationAddedEvent extends BaseCollabEvent {
type: "annotation_added";
annotation: {
id: string;
messageId: string;
parentId?: string;
text: string;
author: CollabUser;
createdAt: number;
resolved: boolean;
replies: AnnotationReply[];
};
}
export interface AnnotationResolvedEvent extends BaseCollabEvent {
type: "annotation_resolved";
annotationId: string;
resolved: boolean;
resolvedBy: CollabUser;
}
export interface AnnotationReplyEvent extends BaseCollabEvent {
type: "annotation_reply";
annotationId: string;
reply: AnnotationReply;
}
export interface AnnotationReply {
id: string;
text: string;
author: CollabUser;
createdAt: number;
}
export interface RoleChangedEvent extends BaseCollabEvent {
type: "role_changed";
targetUserId: string;
newRole: CollabRole;
}
export interface AccessRevokedEvent extends BaseCollabEvent {
type: "access_revoked";
targetUserId: string;
}
export interface OwnershipTransferredEvent extends BaseCollabEvent {
type: "ownership_transferred";
newOwnerId: string;
previousOwnerId: string;
}
export interface SessionStateEvent extends BaseCollabEvent {
type: "session_state";
users: CollabUser[];
pendingToolUseIds: string[];
toolApprovalPolicy: "owner-only" | "any-collaborator";
}
export interface ErrorEvent extends BaseCollabEvent {
type: "error";
code: string;
message: string;
}
export type CollabEvent =
| MessageAddedEvent
| MessageStreamingEvent
| ToolUsePendingEvent
| ToolUseApprovedEvent
| ToolUseDeniedEvent
| UserJoinedEvent
| UserLeftEvent
| CursorUpdateEvent
| TypingStartEvent
| TypingStopEvent
| PresenceSyncEvent
| AnnotationAddedEvent
| AnnotationResolvedEvent
| AnnotationReplyEvent
| RoleChangedEvent
| AccessRevokedEvent
| OwnershipTransferredEvent
| SessionStateEvent
| ErrorEvent;
// Outgoing-only events (client → server) that don't need timestamp/sessionId
export type OutgoingEvent = Omit<CollabEvent, "timestamp">;
type EventHandler<T extends CollabEvent = CollabEvent> = (event: T) => void;
// ─── CollabSocket ─────────────────────────────────────────────────────────────
export class CollabSocket {
private ws: WebSocket | null = null;
private handlers = new Map<CollabEventType, Set<EventHandler>>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
private readonly baseDelay = 1000;
private pingInterval: ReturnType<typeof setInterval> | null = null;
private wsUrl = "";
readonly sessionId: string;
readonly token: string;
isConnected = false;
onConnectionChange?: (connected: boolean) => void;
constructor(sessionId: string, token: string) {
this.sessionId = sessionId;
this.token = token;
}
connect(wsUrl: string): void {
this.wsUrl = wsUrl;
if (this.ws?.readyState === WebSocket.OPEN) return;
const url = new URL(wsUrl);
url.searchParams.set("sessionId", this.sessionId);
url.searchParams.set("token", this.token);
this.ws = new WebSocket(url.toString());
this.ws.onopen = () => {
this.isConnected = true;
this.reconnectAttempts = 0;
this.onConnectionChange?.(true);
this.startPing();
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data as string) as CollabEvent;
this.dispatch(data);
} catch {
// ignore malformed frames
}
};
this.ws.onclose = () => {
this.isConnected = false;
this.onConnectionChange?.(false);
this.stopPing();
this.scheduleReconnect();
};
this.ws.onerror = () => {
this.ws?.close();
};
}
disconnect(): void {
// Set max attempts to prevent reconnect
this.reconnectAttempts = this.maxReconnectAttempts;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.stopPing();
this.ws?.close();
this.ws = null;
}
send(event: OutgoingEvent): void {
if (this.ws?.readyState !== WebSocket.OPEN) return;
this.ws.send(JSON.stringify({ ...event, timestamp: Date.now() }));
}
on<T extends CollabEvent>(
type: T["type"],
handler: EventHandler<T>
): () => void {
if (!this.handlers.has(type)) {
this.handlers.set(type, new Set());
}
this.handlers.get(type)!.add(handler as EventHandler);
return () => this.off(type, handler);
}
off<T extends CollabEvent>(type: T["type"], handler: EventHandler<T>): void {
this.handlers.get(type)?.delete(handler as EventHandler);
}
private dispatch(event: CollabEvent): void {
this.handlers.get(event.type)?.forEach((h) => h(event));
}
private startPing(): void {
this.pingInterval = setInterval(() => {
this.ws?.send(JSON.stringify({ type: "ping" }));
}, 30_000);
}
private stopPing(): void {
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = null;
}
}
private scheduleReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
const delay = this.baseDelay * Math.pow(2, this.reconnectAttempts);
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(() => this.connect(this.wsUrl), delay);
}
}

View File

@@ -0,0 +1,37 @@
import type { CollabUser, AnnotationReply } from "./socket";
// ─── Annotation ───────────────────────────────────────────────────────────────
export interface CollabAnnotation {
id: string;
messageId: string;
parentId?: string; // for threaded top-level grouping (unused in v1)
text: string;
author: CollabUser;
createdAt: number;
resolved: boolean;
replies: AnnotationReply[];
}
// ─── Pending Tool Use ─────────────────────────────────────────────────────────
export interface PendingToolUse {
id: string; // toolUseId
name: string;
input: Record<string, unknown>;
messageId: string;
requestedAt: number;
}
// ─── Share Link ───────────────────────────────────────────────────────────────
export type LinkExpiry = "1h" | "24h" | "7d" | "never";
export interface ShareLink {
id: string;
sessionId: string;
role: import("./socket").CollabRole;
createdAt: number;
expiresAt: number | null; // null = never expires
createdBy: string;
}

16
web/lib/constants.ts Normal file
View File

@@ -0,0 +1,16 @@
export const MODELS = [
{ id: "claude-opus-4-6", label: "Claude Opus 4.6", description: "Most capable" },
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", description: "Balanced" },
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5", description: "Fastest" },
] as const;
export const DEFAULT_MODEL = "claude-sonnet-4-6";
export const API_ROUTES = {
chat: "/api/chat",
stream: "/api/stream",
} as const;
export const MAX_MESSAGE_LENGTH = 100_000;
export const STREAMING_CHUNK_SIZE = 64;

154
web/lib/export/html.ts Normal file
View File

@@ -0,0 +1,154 @@
import type { Conversation, Message, ExportOptions } from "../types";
import { extractTextContent } from "../utils";
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function renderMessageHtml(msg: Message, options: ExportOptions): string {
const isUser = msg.role === "user";
const isError = msg.status === "error";
const roleClass = isUser ? "user" : isError ? "error" : "assistant";
const roleLabel =
msg.role.charAt(0).toUpperCase() + msg.role.slice(1);
let contentHtml = "";
if (typeof msg.content === "string") {
contentHtml = `<p class="message-text">${escapeHtml(msg.content)}</p>`;
} else {
const parts: string[] = [];
for (const block of msg.content) {
if (block.type === "text") {
parts.push(`<p class="message-text">${escapeHtml(block.text)}</p>`);
} else if (block.type === "tool_use" && options.includeToolUse) {
parts.push(`
<details class="tool-block">
<summary class="tool-summary">Tool: <code>${escapeHtml(block.name)}</code></summary>
<pre class="tool-code">${escapeHtml(JSON.stringify(block.input, null, 2))}</pre>
</details>`);
} else if (block.type === "tool_result" && options.includeToolUse) {
const raw =
typeof block.content === "string"
? block.content
: extractTextContent(block.content);
const text =
!options.includeFileContents && raw.length > 500
? raw.slice(0, 500) + "\n…[truncated]"
: raw;
parts.push(`
<details class="tool-block${block.is_error ? " tool-error" : ""}">
<summary class="tool-summary">Tool Result${block.is_error ? " (error)" : ""}</summary>
<pre class="tool-code">${escapeHtml(text)}</pre>
</details>`);
}
}
contentHtml = parts.join("\n");
}
const timestampHtml = options.includeTimestamps
? `<span class="message-time">${new Date(msg.createdAt).toLocaleString()}</span>`
: "";
return `
<div class="message message--${roleClass}">
<div class="message-header">
<span class="message-role">${escapeHtml(roleLabel)}</span>
${timestampHtml}
</div>
<div class="message-content">
${contentHtml}
</div>
</div>`;
}
const CSS = `
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 15px; line-height: 1.6;
background: #0f1117; color: #e2e8f0;
padding: 2rem; max-width: 900px; margin: 0 auto;
}
h1 { font-size: 1.5rem; font-weight: 700; color: #f1f5f9; margin-bottom: 0.5rem; }
.meta { font-size: 0.8rem; color: #94a3b8; margin-bottom: 2rem; display: flex; gap: 1rem; flex-wrap: wrap; }
.meta span::before { content: ""; }
.messages { display: flex; flex-direction: column; gap: 1.5rem; }
.message { border-radius: 12px; overflow: hidden; }
.message--user .message-header { background: #2563eb; }
.message--assistant .message-header { background: #1e293b; }
.message--error .message-header { background: #7f1d1d; }
.message-header {
display: flex; align-items: center; justify-content: space-between;
padding: 0.5rem 1rem; gap: 1rem;
}
.message-role { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.message-time { font-size: 0.7rem; color: rgba(255,255,255,0.6); }
.message--user { background: #1e3a5f; }
.message--assistant { background: #1e293b; }
.message--error { background: #1c0e0e; border: 1px solid #7f1d1d; }
.message-content { padding: 1rem; }
.message-text { white-space: pre-wrap; word-break: break-word; color: #e2e8f0; }
.message-text + .message-text { margin-top: 0.75rem; }
.tool-block { margin-top: 0.75rem; border: 1px solid #334155; border-radius: 8px; overflow: hidden; }
.tool-error { border-color: #7f1d1d; }
.tool-summary {
padding: 0.4rem 0.75rem; font-size: 0.75rem; font-weight: 500;
background: #0f172a; cursor: pointer; color: #94a3b8;
list-style: none; display: flex; align-items: center; gap: 0.5rem;
}
.tool-summary code { font-family: monospace; color: #7dd3fc; }
.tool-code {
padding: 0.75rem; font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.8rem; overflow-x: auto; white-space: pre;
background: #0a0f1a; color: #94a3b8;
}
.footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #1e293b; text-align: center; }
.footer a { color: #60a5fa; text-decoration: none; font-size: 0.8rem; }
.footer a:hover { text-decoration: underline; }
`;
export function toHTML(conv: Conversation, options: ExportOptions): string {
let messages = conv.messages;
if (options.dateRange) {
const { start, end } = options.dateRange;
messages = messages.filter((m) => m.createdAt >= start && m.createdAt <= end);
}
const metaParts = [
...(conv.model ? [`<span>Model: ${escapeHtml(conv.model)}</span>`] : []),
`<span>${messages.length} messages</span>`,
...(options.includeTimestamps
? [`<span>Created: ${new Date(conv.createdAt).toLocaleString()}</span>`]
: []),
`<span>Exported: ${new Date().toLocaleString()}</span>`,
];
const messagesHtml = messages
.map((m) => renderMessageHtml(m, options))
.join("\n");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(conv.title)} — Claude Code</title>
<style>${CSS}</style>
</head>
<body>
<h1>${escapeHtml(conv.title)}</h1>
<div class="meta">${metaParts.join("")}</div>
<div class="messages">${messagesHtml}</div>
<div class="footer">
<a href="https://claude.ai/code">Powered by Claude Code</a>
</div>
</body>
</html>`;
}

45
web/lib/export/json.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { Conversation, Message, ExportOptions, ContentBlock } from "../types";
function filterContent(
content: ContentBlock[] | string,
options: ExportOptions
): ContentBlock[] | string {
if (typeof content === "string") return content;
return content.filter((block) => {
if (block.type === "tool_use" || block.type === "tool_result") {
return options.includeToolUse;
}
return true;
});
}
function filterMessage(msg: Message, options: ExportOptions): Message {
return {
...msg,
content: filterContent(msg.content, options),
createdAt: options.includeTimestamps ? msg.createdAt : 0,
};
}
export function toJSON(conv: Conversation, options: ExportOptions): string {
let messages = conv.messages;
if (options.dateRange) {
const { start, end } = options.dateRange;
messages = messages.filter((m) => m.createdAt >= start && m.createdAt <= end);
}
const output = {
id: conv.id,
title: conv.title,
model: conv.model,
createdAt: options.includeTimestamps ? conv.createdAt : undefined,
updatedAt: options.includeTimestamps ? conv.updatedAt : undefined,
messageCount: messages.length,
messages: messages.map((m) => filterMessage(m, options)),
exportedAt: new Date().toISOString(),
};
return JSON.stringify(output, null, 2);
}

View File

@@ -0,0 +1,78 @@
import type { Conversation, Message, ExportOptions } from "../types";
import { extractTextContent } from "../utils";
function renderMessage(msg: Message, options: ExportOptions): string {
const parts: string[] = [];
const roleLabel =
msg.role === "user"
? "**User**"
: msg.role === "assistant"
? "**Assistant**"
: `**${msg.role}**`;
parts.push(`### ${roleLabel}`);
if (options.includeTimestamps) {
parts.push(`_${new Date(msg.createdAt).toLocaleString()}_\n`);
}
if (typeof msg.content === "string") {
parts.push(msg.content);
} else {
for (const block of msg.content) {
if (block.type === "text") {
parts.push(block.text);
} else if (block.type === "tool_use" && options.includeToolUse) {
parts.push(
`\`\`\`tool-use\n// Tool: ${block.name}\n${JSON.stringify(block.input, null, 2)}\n\`\`\``
);
} else if (block.type === "tool_result" && options.includeToolUse) {
const raw =
typeof block.content === "string"
? block.content
: extractTextContent(block.content);
const truncated =
!options.includeFileContents && raw.length > 500
? raw.slice(0, 500) + "\n…[truncated]"
: raw;
parts.push(
`\`\`\`tool-result${block.is_error ? " error" : ""}\n${truncated}\n\`\`\``
);
}
}
}
return parts.join("\n\n");
}
export function toMarkdown(conv: Conversation, options: ExportOptions): string {
const lines: string[] = [
`# ${conv.title}`,
"",
"---",
...(options.includeTimestamps
? [`**Created:** ${new Date(conv.createdAt).toISOString()}`]
: []),
...(conv.model ? [`**Model:** ${conv.model}`] : []),
`**Messages:** ${conv.messages.length}`,
"---",
"",
];
let messages = conv.messages;
if (options.dateRange) {
const { start, end } = options.dateRange;
messages = messages.filter((m) => m.createdAt >= start && m.createdAt <= end);
}
for (const msg of messages) {
lines.push(renderMessage(msg, options));
lines.push("");
}
lines.push("---");
lines.push("*Exported from Claude Code*");
return lines.join("\n");
}

View File

@@ -0,0 +1,58 @@
import type { Conversation, ExportOptions } from "../types";
import { extractTextContent } from "../utils";
export function toPlainText(conv: Conversation, options: ExportOptions): string {
const lines: string[] = [
conv.title,
"=".repeat(conv.title.length),
"",
];
if (conv.model) lines.push(`Model: ${conv.model}`);
if (options.includeTimestamps) {
lines.push(`Created: ${new Date(conv.createdAt).toLocaleString()}`);
}
lines.push("");
let messages = conv.messages;
if (options.dateRange) {
const { start, end } = options.dateRange;
messages = messages.filter((m) => m.createdAt >= start && m.createdAt <= end);
}
for (const msg of messages) {
const role = msg.role.charAt(0).toUpperCase() + msg.role.slice(1);
lines.push(`[${role}]${options.includeTimestamps ? ` (${new Date(msg.createdAt).toLocaleString()})` : ""}`);
if (typeof msg.content === "string") {
lines.push(msg.content);
} else {
const parts: string[] = [];
for (const block of msg.content) {
if (block.type === "text") {
parts.push(block.text);
} else if (block.type === "tool_use" && options.includeToolUse) {
parts.push(`[Tool: ${block.name}]\nInput: ${JSON.stringify(block.input)}`);
} else if (block.type === "tool_result" && options.includeToolUse) {
const raw =
typeof block.content === "string"
? block.content
: extractTextContent(block.content);
const text =
!options.includeFileContents && raw.length > 500
? raw.slice(0, 500) + " …[truncated]"
: raw;
parts.push(`[Tool Result${block.is_error ? " (error)" : ""}]\n${text}`);
}
}
lines.push(parts.join("\n\n"));
}
lines.push("");
}
lines.push("---");
lines.push("Exported from Claude Code");
return lines.join("\n");
}

210
web/lib/fileViewerStore.ts Normal file
View File

@@ -0,0 +1,210 @@
import { create } from "zustand";
import { nanoid } from "nanoid";
export type FileViewMode = "view" | "edit" | "diff";
export interface DiffData {
oldContent: string;
newContent: string;
oldPath?: string;
}
export interface FileTab {
id: string;
path: string;
filename: string;
content: string;
originalContent: string;
isDirty: boolean;
language: string;
mode: FileViewMode;
diff?: DiffData;
isImage: boolean;
}
interface FileViewerState {
isOpen: boolean;
tabs: FileTab[];
activeTabId: string | null;
panelWidth: number;
openFile: (path: string, content: string) => void;
openDiff: (path: string, oldContent: string, newContent: string) => void;
loadAndOpen: (path: string) => Promise<void>;
closeTab: (tabId: string) => void;
closeAllTabs: () => void;
setActiveTab: (tabId: string) => void;
updateContent: (tabId: string, content: string) => void;
setMode: (tabId: string, mode: FileViewMode) => void;
markSaved: (tabId: string) => void;
setPanelWidth: (width: number) => void;
setOpen: (open: boolean) => void;
}
const IMAGE_EXTENSIONS = new Set([
"png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "ico",
]);
export function isImageFile(filePath: string): boolean {
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
return IMAGE_EXTENSIONS.has(ext);
}
export function detectLanguage(filePath: string): string {
const filename = filePath.split("/").pop()?.toLowerCase() ?? "";
if (filename === "dockerfile") return "dockerfile";
if (filename === "makefile" || filename === "gnumakefile") return "makefile";
const ext = filename.split(".").pop() ?? "";
const map: Record<string, string> = {
ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
mjs: "javascript", cjs: "javascript",
py: "python", rs: "rust", go: "go",
css: "css", scss: "scss", less: "less",
html: "html", htm: "html",
json: "json", jsonc: "json",
md: "markdown", mdx: "markdown",
sh: "bash", bash: "bash", zsh: "bash",
yml: "yaml", yaml: "yaml",
toml: "toml", sql: "sql",
graphql: "graphql", gql: "graphql",
rb: "ruby", java: "java",
c: "c", cpp: "cpp", cc: "cpp", cxx: "cpp",
h: "c", hpp: "cpp",
cs: "csharp", php: "php",
swift: "swift", kt: "kotlin",
r: "r", scala: "scala",
ex: "elixir", exs: "elixir",
vue: "vue", svelte: "svelte",
xml: "xml",
};
return map[ext] ?? "text";
}
function getFilename(filePath: string): string {
return filePath.split("/").pop() ?? filePath;
}
export const useFileViewerStore = create<FileViewerState>()((set, get) => ({
isOpen: false,
tabs: [],
activeTabId: null,
panelWidth: 520,
openFile: (path, content) => {
const { tabs } = get();
const existing = tabs.find((t) => t.path === path && !t.diff);
if (existing) {
set({ activeTabId: existing.id, isOpen: true });
return;
}
const id = nanoid();
const tab: FileTab = {
id,
path,
filename: getFilename(path),
content,
originalContent: content,
isDirty: false,
language: detectLanguage(path),
mode: "view",
isImage: isImageFile(path),
};
set((state) => ({
tabs: [...state.tabs, tab],
activeTabId: id,
isOpen: true,
}));
},
openDiff: (path, oldContent, newContent) => {
const id = nanoid();
const tab: FileTab = {
id,
path,
filename: getFilename(path),
content: newContent,
originalContent: oldContent,
isDirty: false,
language: detectLanguage(path),
mode: "diff",
diff: { oldContent, newContent },
isImage: false,
};
set((state) => ({
tabs: [...state.tabs, tab],
activeTabId: id,
isOpen: true,
}));
},
loadAndOpen: async (path) => {
const { tabs } = get();
const existing = tabs.find((t) => t.path === path && !t.diff);
if (existing) {
set({ activeTabId: existing.id, isOpen: true });
return;
}
try {
const res = await fetch(`/api/files/read?path=${encodeURIComponent(path)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
get().openFile(path, data.content ?? "");
} catch (err) {
console.error("Failed to load file:", path, err);
}
},
closeTab: (tabId) => {
set((state) => {
const tabs = state.tabs.filter((t) => t.id !== tabId);
const activeTabId =
state.activeTabId === tabId
? (tabs[tabs.length - 1]?.id ?? null)
: state.activeTabId;
return { tabs, activeTabId, isOpen: tabs.length > 0 };
});
},
closeAllTabs: () => {
set({ tabs: [], activeTabId: null, isOpen: false });
},
setActiveTab: (tabId) => {
set({ activeTabId: tabId });
},
updateContent: (tabId, content) => {
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId
? { ...t, content, isDirty: content !== t.originalContent }
: t
),
}));
},
setMode: (tabId, mode) => {
set((state) => ({
tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, mode } : t)),
}));
},
markSaved: (tabId) => {
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId
? { ...t, isDirty: false, originalContent: t.content }
: t
),
}));
},
setPanelWidth: (width) => {
set({ panelWidth: Math.max(300, Math.min(width, 1400)) });
},
setOpen: (open) => {
set({ isOpen: open });
},
}));

151
web/lib/ink-compat/Box.tsx Normal file
View File

@@ -0,0 +1,151 @@
import React, { type CSSProperties, type MouseEvent, type KeyboardEvent as ReactKeyboardEvent, type PropsWithChildren, type Ref } from 'react'
import { inkBoxPropsToCSS, type InkStyleProps } from './prop-mapping'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Web-compat event shims that mirror the Ink event interface.
* Components that only inspect `.stopImmediatePropagation()` or basic
* event info will work without changes.
*/
export type WebClickEvent = {
x: number
y: number
button: 'left' | 'right' | 'middle'
stopImmediatePropagation(): void
}
export type WebFocusEvent = {
stopImmediatePropagation(): void
}
export type WebKeyboardEvent = {
key: string
ctrl: boolean
shift: boolean
meta: boolean
stopImmediatePropagation(): void
}
export type BoxProps = PropsWithChildren<
InkStyleProps & {
ref?: Ref<HTMLDivElement>
tabIndex?: number
autoFocus?: boolean
/** onClick receives a shim that mirrors the Ink ClickEvent interface. */
onClick?: (event: WebClickEvent) => void
onFocus?: (event: WebFocusEvent) => void
onFocusCapture?: (event: WebFocusEvent) => void
onBlur?: (event: WebFocusEvent) => void
onBlurCapture?: (event: WebFocusEvent) => void
onKeyDown?: (event: WebKeyboardEvent) => void
onKeyDownCapture?: (event: WebKeyboardEvent) => void
onMouseEnter?: () => void
onMouseLeave?: () => void
/** Pass-through className for web-specific styling. */
className?: string
/** Pass-through inline style overrides applied on top of Ink-mapped styles. */
style?: CSSProperties
}
>
// ---------------------------------------------------------------------------
// Adapters
// ---------------------------------------------------------------------------
function adaptClickEvent(e: MouseEvent<HTMLDivElement>): WebClickEvent {
let stopped = false
const shim: WebClickEvent = {
x: e.clientX,
y: e.clientY,
button: e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left',
stopImmediatePropagation() {
if (!stopped) {
stopped = true
e.stopPropagation()
}
},
}
return shim
}
function adaptFocusEvent(e: React.FocusEvent<HTMLDivElement>): WebFocusEvent {
return {
stopImmediatePropagation() {
e.stopPropagation()
},
}
}
function adaptKeyboardEvent(e: ReactKeyboardEvent<HTMLDivElement>): WebKeyboardEvent {
return {
key: e.key,
ctrl: e.ctrlKey,
shift: e.shiftKey,
meta: e.metaKey || e.altKey,
stopImmediatePropagation() {
e.stopPropagation()
},
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Web-compat `<Box>` — renders as a `<div>` with `display: flex` and maps
* all Ink layout props to CSS. Drop-in replacement for Ink's `<Box>`.
*/
export const Box = React.forwardRef<HTMLDivElement, BoxProps>(function Box(
{
children,
className,
style: styleProp,
tabIndex,
autoFocus,
onClick,
onFocus,
onFocusCapture,
onBlur,
onBlurCapture,
onKeyDown,
onKeyDownCapture,
onMouseEnter,
onMouseLeave,
// Ink style props — everything else
...inkProps
},
ref,
) {
const inkCSS = inkBoxPropsToCSS(inkProps)
const mergedStyle: CSSProperties = { ...inkCSS, ...styleProp }
return (
<div
ref={ref}
className={className}
style={mergedStyle}
tabIndex={tabIndex}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
onClick={onClick ? (e) => onClick(adaptClickEvent(e)) : undefined}
onFocus={onFocus ? (e) => onFocus(adaptFocusEvent(e)) : undefined}
onFocusCapture={onFocusCapture ? (e) => onFocusCapture(adaptFocusEvent(e)) : undefined}
onBlur={onBlur ? (e) => onBlur(adaptFocusEvent(e)) : undefined}
onBlurCapture={onBlurCapture ? (e) => onBlurCapture(adaptFocusEvent(e)) : undefined}
onKeyDown={onKeyDown ? (e) => onKeyDown(adaptKeyboardEvent(e)) : undefined}
onKeyDownCapture={onKeyDownCapture ? (e) => onKeyDownCapture(adaptKeyboardEvent(e)) : undefined}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{children}
</div>
)
})
Box.displayName = 'Box'
export default Box

View File

@@ -0,0 +1,93 @@
/**
* Maps Ink/ANSI color values to CSS color strings.
*
* Ink Color types:
* RGBColor = `rgb(${number},${number},${number})`
* HexColor = `#${string}`
* Ansi256Color = `ansi256(${number})`
* AnsiColor = `ansi:black` | `ansi:red` | ...
*/
// Standard ANSI 16-color palette mapped to CSS hex values
const ANSI_COLORS: Record<string, string> = {
black: '#000000',
red: '#cc0000',
green: '#4e9a06',
yellow: '#c4a000',
blue: '#3465a4',
magenta: '#75507b',
cyan: '#06989a',
white: '#d3d7cf',
blackBright: '#555753',
redBright: '#ef2929',
greenBright: '#8ae234',
yellowBright: '#fce94f',
blueBright: '#729fcf',
magentaBright: '#ad7fa8',
cyanBright: '#34e2e2',
whiteBright: '#eeeeec',
}
/**
* Convert an index in the xterm 256-color palette to a CSS hex color string.
*/
function ansi256ToHex(index: number): string {
// 015: standard palette
if (index < 16) {
const names = [
'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
'blackBright', 'redBright', 'greenBright', 'yellowBright',
'blueBright', 'magentaBright', 'cyanBright', 'whiteBright',
]
return ANSI_COLORS[names[index]!] ?? '#000000'
}
// 232255: grayscale ramp
if (index >= 232) {
const level = (index - 232) * 10 + 8
const hex = level.toString(16).padStart(2, '0')
return `#${hex}${hex}${hex}`
}
// 16231: 6×6×6 color cube
const i = index - 16
const b = i % 6
const g = Math.floor(i / 6) % 6
const r = Math.floor(i / 36)
const toChannel = (v: number) => (v === 0 ? 0 : v * 40 + 55)
const rh = toChannel(r).toString(16).padStart(2, '0')
const gh = toChannel(g).toString(16).padStart(2, '0')
const bh = toChannel(b).toString(16).padStart(2, '0')
return `#${rh}${gh}${bh}`
}
/**
* Convert an Ink Color string (or theme-resolved raw color) to a CSS color string.
* Returns `undefined` if `color` is undefined/empty.
*/
export function inkColorToCSS(color: string | undefined): string | undefined {
if (!color) return undefined
// Pass through hex colors
if (color.startsWith('#')) return color
// Normalise `rgb(r,g,b)` → `rgb(r, g, b)` (browsers accept both, but clean)
if (color.startsWith('rgb(')) {
return color.replace(/\s+/g, '')
}
// ansi256(N)
const ansi256Match = color.match(/^ansi256\((\d+)\)$/)
if (ansi256Match) {
return ansi256ToHex(parseInt(ansi256Match[1]!, 10))
}
// ansi:name
if (color.startsWith('ansi:')) {
const name = color.slice(5)
return ANSI_COLORS[name] ?? color
}
// Unknown format — return as-is (browser may understand it)
return color
}

View File

@@ -0,0 +1,328 @@
import type { CSSProperties } from 'react'
import { inkColorToCSS } from './color-mapping'
// ---------------------------------------------------------------------------
// Dimension helpers
// ---------------------------------------------------------------------------
/**
* Convert an Ink dimension (number = character cells, or `"50%"` percent string)
* to a CSS value. We use `ch` for character-width units so the layout
* approximates the terminal in a monospace font.
*/
function toCSSSize(value: number | string | undefined): string | undefined {
if (value === undefined) return undefined
if (typeof value === 'string') return value // already "50%" etc.
return `${value}ch`
}
// ---------------------------------------------------------------------------
// Border style mapping
// ---------------------------------------------------------------------------
// Maps Ink/cli-boxes border style names to CSS border-style values.
const BORDER_STYLE_MAP: Record<string, CSSProperties['borderStyle']> = {
single: 'solid',
double: 'double',
round: 'solid', // approximated; borderRadius added below
bold: 'solid',
singleDouble: 'solid',
doubleSingle: 'solid',
classic: 'solid',
arrow: 'solid',
ascii: 'solid',
dashed: 'dashed',
// cli-boxes names
none: 'none',
}
const BORDER_BOLD_STYLES = new Set(['bold'])
const BORDER_ROUND_STYLES = new Set(['round'])
// ---------------------------------------------------------------------------
// Main mapping function
// ---------------------------------------------------------------------------
export type InkStyleProps = {
// Position
position?: 'absolute' | 'relative'
top?: number | string
bottom?: number | string
left?: number | string
right?: number | string
// Margin
margin?: number
marginX?: number
marginY?: number
marginTop?: number
marginBottom?: number
marginLeft?: number
marginRight?: number
// Padding
padding?: number
paddingX?: number
paddingY?: number
paddingTop?: number
paddingBottom?: number
paddingLeft?: number
paddingRight?: number
// Flex
flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse'
flexGrow?: number
flexShrink?: number
flexBasis?: number | string
flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse'
alignItems?: 'flex-start' | 'center' | 'flex-end' | 'stretch'
alignSelf?: 'flex-start' | 'center' | 'flex-end' | 'auto'
justifyContent?: 'flex-start' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly' | 'center'
// Gap
gap?: number
columnGap?: number
rowGap?: number
// Sizing
width?: number | string
height?: number | string
minWidth?: number | string
minHeight?: number | string
maxWidth?: number | string
maxHeight?: number | string
// Display
display?: 'flex' | 'none'
// Overflow (Ink only has 'hidden')
overflow?: 'hidden' | 'visible'
overflowX?: 'hidden' | 'visible'
overflowY?: 'hidden' | 'visible'
// Border
borderStyle?: string | { top: string; bottom: string; left: string; right: string; topLeft: string; topRight: string; bottomLeft: string; bottomRight: string }
borderTop?: boolean
borderBottom?: boolean
borderLeft?: boolean
borderRight?: boolean
borderColor?: string
borderTopColor?: string
borderBottomColor?: string
borderLeftColor?: string
borderRightColor?: string
borderDimColor?: boolean
borderTopDimColor?: boolean
borderBottomDimColor?: boolean
borderLeftDimColor?: boolean
borderRightDimColor?: boolean
}
export type InkTextStyleProps = {
color?: string
backgroundColor?: string
bold?: boolean
dim?: boolean
italic?: boolean
underline?: boolean
strikethrough?: boolean
inverse?: boolean
wrap?: string
}
/**
* Convert Ink Box layout props to a React CSSProperties object.
*/
export function inkBoxPropsToCSS(props: InkStyleProps): CSSProperties {
const css: CSSProperties = {
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
}
// Display
if (props.display === 'none') {
css.display = 'none'
return css
}
// Position
if (props.position) css.position = props.position
if (props.top !== undefined) css.top = toCSSSize(props.top)
if (props.bottom !== undefined) css.bottom = toCSSSize(props.bottom)
if (props.left !== undefined) css.left = toCSSSize(props.left)
if (props.right !== undefined) css.right = toCSSSize(props.right)
// Flex
if (props.flexDirection) css.flexDirection = props.flexDirection
if (props.flexGrow !== undefined) css.flexGrow = props.flexGrow
if (props.flexShrink !== undefined) css.flexShrink = props.flexShrink
if (props.flexBasis !== undefined) css.flexBasis = toCSSSize(props.flexBasis)
if (props.flexWrap) css.flexWrap = props.flexWrap
if (props.alignItems) css.alignItems = props.alignItems
if (props.alignSelf) css.alignSelf = props.alignSelf
if (props.justifyContent) css.justifyContent = props.justifyContent
// Gap
if (props.gap !== undefined) css.gap = `${props.gap}ch`
if (props.columnGap !== undefined) css.columnGap = `${props.columnGap}ch`
if (props.rowGap !== undefined) css.rowGap = `${props.rowGap}ch`
// Sizing
if (props.width !== undefined) css.width = toCSSSize(props.width)
if (props.height !== undefined) css.height = toCSSSize(props.height)
if (props.minWidth !== undefined) css.minWidth = toCSSSize(props.minWidth)
if (props.minHeight !== undefined) css.minHeight = toCSSSize(props.minHeight)
if (props.maxWidth !== undefined) css.maxWidth = toCSSSize(props.maxWidth)
if (props.maxHeight !== undefined) css.maxHeight = toCSSSize(props.maxHeight)
// Margin (shorthand resolution: margin → marginX/Y → individual sides)
const mt = props.marginTop ?? props.marginY ?? props.margin
const mb = props.marginBottom ?? props.marginY ?? props.margin
const ml = props.marginLeft ?? props.marginX ?? props.margin
const mr = props.marginRight ?? props.marginX ?? props.margin
if (mt !== undefined) css.marginTop = toCSSSize(mt)
if (mb !== undefined) css.marginBottom = toCSSSize(mb)
if (ml !== undefined) css.marginLeft = toCSSSize(ml)
if (mr !== undefined) css.marginRight = toCSSSize(mr)
// Padding
const pt = props.paddingTop ?? props.paddingY ?? props.padding
const pb = props.paddingBottom ?? props.paddingY ?? props.padding
const pl = props.paddingLeft ?? props.paddingX ?? props.padding
const pr = props.paddingRight ?? props.paddingX ?? props.padding
if (pt !== undefined) css.paddingTop = toCSSSize(pt)
if (pb !== undefined) css.paddingBottom = toCSSSize(pb)
if (pl !== undefined) css.paddingLeft = toCSSSize(pl)
if (pr !== undefined) css.paddingRight = toCSSSize(pr)
// Overflow
if (props.overflow) css.overflow = props.overflow
if (props.overflowX) css.overflowX = props.overflowX
if (props.overflowY) css.overflowY = props.overflowY
// Border
if (props.borderStyle) {
const styleName = typeof props.borderStyle === 'string' ? props.borderStyle : 'single'
const cssBorderStyle = BORDER_STYLE_MAP[styleName] ?? 'solid'
const isBold = BORDER_BOLD_STYLES.has(styleName)
const borderWidth = isBold ? '2px' : '1px'
const showTop = props.borderTop !== false
const showBottom = props.borderBottom !== false
const showLeft = props.borderLeft !== false
const showRight = props.borderRight !== false
const resolveColor = (side: string | undefined, fallback: string | undefined, dim: boolean | undefined) => {
const raw = side ?? fallback
const cssColor = inkColorToCSS(raw) ?? 'currentColor'
return dim ? `color-mix(in srgb, ${cssColor} 60%, transparent)` : cssColor
}
const dimAll = props.borderDimColor
if (showTop) {
css.borderTopStyle = cssBorderStyle
css.borderTopWidth = borderWidth
css.borderTopColor = resolveColor(
props.borderTopColor ?? props.borderColor,
props.borderColor,
props.borderTopDimColor ?? dimAll,
)
}
if (showBottom) {
css.borderBottomStyle = cssBorderStyle
css.borderBottomWidth = borderWidth
css.borderBottomColor = resolveColor(
props.borderBottomColor ?? props.borderColor,
props.borderColor,
props.borderBottomDimColor ?? dimAll,
)
}
if (showLeft) {
css.borderLeftStyle = cssBorderStyle
css.borderLeftWidth = borderWidth
css.borderLeftColor = resolveColor(
props.borderLeftColor ?? props.borderColor,
props.borderColor,
props.borderLeftDimColor ?? dimAll,
)
}
if (showRight) {
css.borderRightStyle = cssBorderStyle
css.borderRightWidth = borderWidth
css.borderRightColor = resolveColor(
props.borderRightColor ?? props.borderColor,
props.borderColor,
props.borderRightDimColor ?? dimAll,
)
}
if (BORDER_ROUND_STYLES.has(styleName)) {
css.borderRadius = '4px'
}
}
return css
}
/**
* Convert Ink Text style props to a React CSSProperties object.
*/
export function inkTextPropsToCSS(props: InkTextStyleProps): CSSProperties {
const css: CSSProperties = {}
const fg = inkColorToCSS(props.color)
const bg = inkColorToCSS(props.backgroundColor)
if (props.inverse) {
// Swap foreground and background
if (fg) css.backgroundColor = fg
if (bg) css.color = bg
if (!fg) css.filter = 'invert(1)'
} else {
if (fg) css.color = fg
if (bg) css.backgroundColor = bg
}
if (props.bold) css.fontWeight = 'bold'
if (props.dim) css.opacity = 0.6
if (props.italic) css.fontStyle = 'italic'
const decorations: string[] = []
if (props.underline) decorations.push('underline')
if (props.strikethrough) decorations.push('line-through')
if (decorations.length > 0) css.textDecoration = decorations.join(' ')
if (props.wrap) {
switch (props.wrap) {
case 'wrap':
case 'wrap-trim':
css.whiteSpace = 'pre-wrap'
css.overflowWrap = 'anywhere'
break
case 'truncate':
case 'truncate-end':
case 'end':
css.overflow = 'hidden'
css.textOverflow = 'ellipsis'
css.whiteSpace = 'nowrap'
break
case 'truncate-middle':
case 'middle':
// CSS can't do mid-truncation; use ellipsis as fallback
css.overflow = 'hidden'
css.textOverflow = 'ellipsis'
css.whiteSpace = 'nowrap'
break
case 'truncate-start':
css.overflow = 'hidden'
css.direction = 'rtl'
css.textOverflow = 'ellipsis'
css.whiteSpace = 'nowrap'
break
}
}
return css
}

87
web/lib/keyParser.ts Normal file
View File

@@ -0,0 +1,87 @@
// Detects Mac at module load time (safe to call on client, returns false on server)
export const isMac =
typeof navigator !== "undefined" && /Mac|iPhone|iPad/i.test(navigator.platform);
export interface ParsedKey {
mod: boolean;
ctrl: boolean;
shift: boolean;
alt: boolean;
key: string;
}
/**
* Parse a shortcut string like "mod+shift+k" into a structured object.
* Supports modifiers: mod, ctrl, shift, alt
* `mod` = Cmd on Mac, Ctrl on Win/Linux
*/
export function parseKey(keyString: string): ParsedKey {
const parts = keyString.toLowerCase().split("+");
const key = parts[parts.length - 1];
return {
mod: parts.includes("mod"),
ctrl: parts.includes("ctrl"),
shift: parts.includes("shift"),
alt: parts.includes("alt"),
key,
};
}
/**
* Test whether a KeyboardEvent matches a parsed key definition.
*/
export function matchesEvent(parsed: ParsedKey, e: KeyboardEvent): boolean {
// On Mac, `mod` maps to metaKey; on Win/Linux it maps to ctrlKey
const expectedMeta = isMac ? parsed.mod : false;
const expectedCtrl = isMac ? parsed.ctrl : parsed.mod || parsed.ctrl;
if (e.metaKey !== expectedMeta) return false;
if (e.ctrlKey !== expectedCtrl) return false;
if (e.altKey !== parsed.alt) return false;
// For alphanumeric keys, enforce the shift modifier check explicitly.
// For symbol characters (?, /, comma, etc.) shift is implicit in the key value,
// so we skip the shift check and just match on e.key.
const keyIsAlphanumeric = /^[a-z0-9]$/.test(parsed.key);
if (keyIsAlphanumeric && e.shiftKey !== parsed.shift) return false;
return e.key.toLowerCase() === parsed.key;
}
/**
* Format a key combo string for display.
* Returns an array of display segments, e.g. ["⌘", "K"] or ["Ctrl", "K"].
*/
export function formatKeyCombo(keyString: string): string[] {
const parts = keyString.split("+");
return parts.map((part) => {
switch (part.toLowerCase()) {
case "mod":
return isMac ? "⌘" : "Ctrl";
case "shift":
return isMac ? "⇧" : "Shift";
case "alt":
return isMac ? "⌥" : "Alt";
case "ctrl":
return isMac ? "⌃" : "Ctrl";
case "enter":
return "↵";
case "escape":
return "Esc";
case "backspace":
return "⌫";
case "tab":
return "Tab";
case "arrowup":
return "↑";
case "arrowdown":
return "↓";
case "arrowleft":
return "←";
case "arrowright":
return "→";
default:
return part.toUpperCase();
}
});
}

149
web/lib/notifications.ts Normal file
View File

@@ -0,0 +1,149 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { nanoid } from "nanoid";
export type ToastVariant = "success" | "error" | "warning" | "info" | "loading";
export type NotificationCategory = "error" | "activity" | "system";
export interface ToastItem {
id: string;
variant: ToastVariant;
title: string;
description?: string;
duration: number; // ms, 0 = no auto-dismiss
action?: { label: string; onClick: () => void };
details?: string;
createdAt: number;
}
export interface NotificationItem {
id: string;
title: string;
description: string;
category: NotificationCategory;
link?: string;
read: boolean;
createdAt: number;
}
const MAX_VISIBLE_TOASTS = 5;
const MAX_NOTIFICATIONS = 100;
export const DEFAULT_DURATIONS: Record<ToastVariant, number> = {
success: 5000,
info: 5000,
warning: 7000,
error: 10000,
loading: 0,
};
interface NotificationStore {
// Toast state
toasts: ToastItem[];
toastQueue: ToastItem[];
// Notification center state
notifications: NotificationItem[];
// Preferences
browserNotificationsEnabled: boolean;
soundEnabled: boolean;
// Toast actions
addToast: (options: Omit<ToastItem, "id" | "createdAt">) => string;
dismissToast: (id: string) => void;
dismissAllToasts: () => void;
// Notification actions
addNotification: (options: Omit<NotificationItem, "id" | "read" | "createdAt">) => void;
markRead: (id: string) => void;
markAllRead: () => void;
clearHistory: () => void;
// Preference actions
setBrowserNotificationsEnabled: (enabled: boolean) => void;
setSoundEnabled: (enabled: boolean) => void;
}
export const useNotificationStore = create<NotificationStore>()(
persist(
(set) => ({
toasts: [],
toastQueue: [],
notifications: [],
browserNotificationsEnabled: true,
soundEnabled: false,
addToast: (options) => {
const id = nanoid();
const toast: ToastItem = { ...options, id, createdAt: Date.now() };
set((state) => {
if (state.toasts.length < MAX_VISIBLE_TOASTS) {
return { toasts: [...state.toasts, toast] };
}
return { toastQueue: [...state.toastQueue, toast] };
});
return id;
},
dismissToast: (id) => {
set((state) => {
const toasts = state.toasts.filter((t) => t.id !== id);
const [next, ...queue] = state.toastQueue;
if (next) {
return { toasts: [...toasts, next], toastQueue: queue };
}
return { toasts };
});
},
dismissAllToasts: () => {
set({ toasts: [], toastQueue: [] });
},
addNotification: (options) => {
const notification: NotificationItem = {
...options,
id: nanoid(),
read: false,
createdAt: Date.now(),
};
set((state) => ({
notifications: [notification, ...state.notifications].slice(0, MAX_NOTIFICATIONS),
}));
},
markRead: (id) => {
set((state) => ({
notifications: state.notifications.map((n) =>
n.id === id ? { ...n, read: true } : n
),
}));
},
markAllRead: () => {
set((state) => ({
notifications: state.notifications.map((n) => ({ ...n, read: true })),
}));
},
clearHistory: () => {
set({ notifications: [] });
},
setBrowserNotificationsEnabled: (enabled) => {
set({ browserNotificationsEnabled: enabled });
},
setSoundEnabled: (enabled) => {
set({ soundEnabled: enabled });
},
}),
{
name: "claude-code-notifications",
partialize: (state) => ({
notifications: state.notifications,
browserNotificationsEnabled: state.browserNotificationsEnabled,
soundEnabled: state.soundEnabled,
}),
}
)
);

View File

@@ -0,0 +1,176 @@
/**
* Core Web Vitals + custom chat performance metrics.
*
* Observed metrics are forwarded to an analytics sink (no-op by default;
* swap in your analytics provider via `setMetricSink`).
*/
export interface PerformanceMetric {
name: string;
value: number;
rating?: "good" | "needs-improvement" | "poor";
/** Additional context (e.g. conversationId, messageCount) */
meta?: Record<string, unknown>;
}
type MetricSink = (metric: PerformanceMetric) => void;
let sink: MetricSink = () => {};
/** Register a custom analytics sink (e.g. PostHog, Datadog, console). */
export function setMetricSink(fn: MetricSink): void {
sink = fn;
}
function report(metric: PerformanceMetric): void {
if (process.env.NODE_ENV === "development") {
// eslint-disable-next-line no-console
console.debug("[perf]", metric.name, metric.value.toFixed(1), metric.rating ?? "");
}
sink(metric);
}
// ─── Core Web Vitals ────────────────────────────────────────────────────────
function rateVital(name: string, value: number): PerformanceMetric["rating"] {
const thresholds: Record<string, [number, number]> = {
LCP: [2500, 4000],
FID: [100, 300],
CLS: [0.1, 0.25],
INP: [200, 500],
TTFB: [800, 1800],
FCP: [1800, 3000],
};
const [good, poor] = thresholds[name] ?? [0, Infinity];
if (value <= good) return "good";
if (value <= poor) return "needs-improvement";
return "poor";
}
export function observeWebVitals(): void {
if (typeof window === "undefined" || !("PerformanceObserver" in window)) return;
// LCP
try {
const lcpObs = new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries[entries.length - 1] as PerformancePaintTiming;
const value = last.startTime;
report({ name: "LCP", value, rating: rateVital("LCP", value) });
});
lcpObs.observe({ type: "largest-contentful-paint", buffered: true });
} catch {}
// FID / INP
try {
const fidObs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const e = entry as PerformanceEventTiming;
const value = e.processingStart - e.startTime;
report({ name: "FID", value, rating: rateVital("FID", value) });
}
});
fidObs.observe({ type: "first-input", buffered: true });
} catch {}
// CLS
try {
let clsValue = 0;
let clsSessionGap = 0;
let clsSessionValue = 0;
const clsObs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const e = entry as LayoutShift;
if (!e.hadRecentInput) {
const now = e.startTime;
if (now - clsSessionGap > 1000 || clsValue === 0) {
clsSessionValue = e.value;
} else {
clsSessionValue += e.value;
}
clsSessionGap = now;
clsValue = Math.max(clsValue, clsSessionValue);
report({ name: "CLS", value: clsValue, rating: rateVital("CLS", clsValue) });
}
}
});
clsObs.observe({ type: "layout-shift", buffered: true });
} catch {}
// TTFB
try {
const navObs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const nav = entry as PerformanceNavigationTiming;
const value = nav.responseStart - nav.requestStart;
report({ name: "TTFB", value, rating: rateVital("TTFB", value) });
}
});
navObs.observe({ type: "navigation", buffered: true });
} catch {}
}
// ─── Custom Chat Metrics ─────────────────────────────────────────────────────
/** Call when the chat input becomes interactive. */
export function markTimeToInteractive(): void {
if (typeof performance === "undefined") return;
const value = performance.now();
report({ name: "time_to_interactive", value });
}
/** Call when the first message bubble finishes rendering. */
export function markFirstMessageRender(): void {
if (typeof performance === "undefined") return;
const value = performance.now();
report({ name: "first_message_render", value });
}
/**
* Measures streaming token latency: time from when the server sends the
* first chunk to when it appears in the DOM.
*
* Usage:
* const end = startStreamingLatencyMeasurement();
* // … after DOM update …
* end();
*/
export function startStreamingLatencyMeasurement(): () => void {
const start = performance.now();
return () => {
const value = performance.now() - start;
report({ name: "streaming_token_latency_ms", value });
};
}
/** Monitor scroll FPS during user scrolling. Returns a cleanup fn. */
export function monitorScrollFps(element: HTMLElement): () => void {
let frameCount = 0;
let lastTime = performance.now();
let rafId: number;
let scrolling = false;
const onScroll = () => { scrolling = true; };
const loop = () => {
rafId = requestAnimationFrame(loop);
if (!scrolling) return;
frameCount++;
const now = performance.now();
if (now - lastTime >= 1000) {
const fps = (frameCount / (now - lastTime)) * 1000;
report({ name: "scroll_fps", value: fps, rating: fps >= 55 ? "good" : fps >= 30 ? "needs-improvement" : "poor" });
frameCount = 0;
lastTime = now;
scrolling = false;
}
};
element.addEventListener("scroll", onScroll, { passive: true });
rafId = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(rafId);
element.removeEventListener("scroll", onScroll);
};
}

View File

@@ -0,0 +1,66 @@
/**
* Batches streaming token updates via requestAnimationFrame to avoid
* per-token re-renders. Flushes accumulated text on each animation frame
* rather than on every chunk, keeping the UI smooth at 60fps during streaming.
*/
export class StreamingOptimizer {
private buffer = "";
private rafId: number | null = null;
private onFlush: (text: string) => void;
private lastFlushTime = 0;
/** Max ms to wait before forcing a flush regardless of rAF timing */
private readonly maxDelay: number;
constructor(onFlush: (text: string) => void, maxDelay = 50) {
this.onFlush = onFlush;
this.maxDelay = maxDelay;
}
push(chunk: string): void {
this.buffer += chunk;
if (this.rafId !== null) return; // rAF already scheduled
const now = performance.now();
const timeSinceLast = now - this.lastFlushTime;
if (timeSinceLast >= this.maxDelay) {
// Flush is overdue — do it synchronously to avoid latency buildup
this.flush();
} else {
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
this.flush();
});
}
}
flush(): void {
if (!this.buffer) return;
const text = this.buffer;
this.buffer = "";
this.lastFlushTime = performance.now();
this.onFlush(text);
}
destroy(): void {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
// Flush any remaining buffered text
this.flush();
}
}
/**
* Hook-friendly factory that returns a stable optimizer instance
* tied to a callback ref so the callback can change without recreating
* the optimizer (avoids stale closure issues).
*/
export function createStreamingOptimizer(
onFlush: (accumulated: string) => void,
maxDelay = 50
): StreamingOptimizer {
return new StreamingOptimizer(onFlush, maxDelay);
}

View File

@@ -0,0 +1,115 @@
/**
* Lightweight web worker pool. Keeps a fixed number of workers alive
* and queues tasks when all workers are busy.
*/
interface WorkerTask<T> {
payload: unknown;
resolve: (value: T) => void;
reject: (reason: unknown) => void;
transferables?: Transferable[];
}
interface WorkerSlot {
worker: Worker;
busy: boolean;
}
export class WorkerPool<T = unknown> {
private slots: WorkerSlot[] = [];
private queue: WorkerTask<T>[] = [];
private readonly size: number;
private readonly workerFactory: () => Worker;
constructor(workerFactory: () => Worker, size = navigator.hardwareConcurrency ?? 2) {
this.workerFactory = workerFactory;
this.size = Math.min(size, 4); // Cap at 4 to avoid too many threads
}
private createSlot(): WorkerSlot {
const worker = this.workerFactory();
const slot: WorkerSlot = { worker, busy: false };
return slot;
}
private getFreeSlot(): WorkerSlot | null {
return this.slots.find((s) => !s.busy) ?? null;
}
private ensureSlots(): void {
while (this.slots.length < this.size) {
this.slots.push(this.createSlot());
}
}
run(payload: unknown, transferables?: Transferable[]): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.ensureSlots();
const task: WorkerTask<T> = { payload, resolve, reject, transferables };
const slot = this.getFreeSlot();
if (slot) {
this.dispatch(slot, task);
} else {
this.queue.push(task);
}
});
}
private dispatch(slot: WorkerSlot, task: WorkerTask<T>): void {
slot.busy = true;
const handleMessage = (e: MessageEvent) => {
slot.worker.removeEventListener("message", handleMessage);
slot.worker.removeEventListener("error", handleError);
slot.busy = false;
task.resolve(e.data as T);
this.dequeue();
};
const handleError = (e: ErrorEvent) => {
slot.worker.removeEventListener("message", handleMessage);
slot.worker.removeEventListener("error", handleError);
slot.busy = false;
task.reject(new Error(e.message));
this.dequeue();
};
slot.worker.addEventListener("message", handleMessage);
slot.worker.addEventListener("error", handleError);
if (task.transferables?.length) {
slot.worker.postMessage(task.payload, task.transferables);
} else {
slot.worker.postMessage(task.payload);
}
}
private dequeue(): void {
if (!this.queue.length) return;
const task = this.queue.shift()!;
const slot = this.getFreeSlot();
if (slot) this.dispatch(slot, task);
}
terminate(): void {
for (const slot of this.slots) {
slot.worker.terminate();
}
this.slots = [];
this.queue = [];
}
}
/**
* Singleton pools lazily initialized per worker module URL.
* Avoids spawning duplicate workers when multiple components
* import the same pool.
*/
const pools = new Map<string, WorkerPool>();
export function getWorkerPool<T>(key: string, factory: () => Worker): WorkerPool<T> {
if (!pools.has(key)) {
pools.set(key, new WorkerPool<T>(factory));
}
return pools.get(key) as WorkerPool<T>;
}

37
web/lib/platform.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* Platform detection utilities.
* Determines whether the code is running in a web browser or terminal environment.
*/
/** True when running in a browser (Next.js client-side or any DOM environment). */
export const isWeb: boolean =
typeof window !== "undefined" && typeof document !== "undefined";
/** True when running in a Node.js / terminal environment (no DOM). */
export const isTerminal: boolean = !isWeb;
/**
* Returns a value based on the current platform.
* Useful for conditional rendering where both branches must be valid React.
*/
export function platform<T>(options: { web: T; terminal: T }): T {
return isWeb ? options.web : options.terminal;
}
/**
* Window/terminal dimensions hook substitute.
* In the terminal this maps to process.stdout.columns/rows.
* In the browser it uses window.innerWidth/innerHeight.
*/
export function getWindowDimensions(): { width: number; height: number } {
if (!isWeb) {
return {
width: (typeof process !== "undefined" && process.stdout?.columns) || 80,
height: (typeof process !== "undefined" && process.stdout?.rows) || 24,
};
}
return {
width: window.innerWidth,
height: window.innerHeight,
};
}

View File

@@ -0,0 +1,193 @@
import type { Conversation, SearchFilters, SearchResult, SearchResultMatch } from "@/lib/types";
import { extractTextContent } from "@/lib/utils";
import { tokenize, excerpt, highlight } from "./highlighter";
/**
* Score a text against a set of query tokens.
* Returns 0 if no tokens match, otherwise a positive score.
*/
function scoreText(text: string, tokens: string[]): number {
if (!text || tokens.length === 0) return 0;
const lower = text.toLowerCase();
let score = 0;
for (const token of tokens) {
const idx = lower.indexOf(token);
if (idx === -1) continue;
// Base score per token
score += 1;
// Bonus for word boundary match
const before = idx === 0 || /\W/.test(lower[idx - 1]);
const after = idx + token.length >= lower.length || /\W/.test(lower[idx + token.length]);
if (before && after) score += 0.5;
// Bonus for more occurrences (capped)
const count = (lower.match(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) ?? []).length;
score += Math.min(count - 1, 3) * 0.2;
}
// Penalty if not all tokens match
const matchedTokens = tokens.filter((t) => lower.includes(t));
if (matchedTokens.length < tokens.length) {
score *= matchedTokens.length / tokens.length;
}
return score;
}
/**
* Extract plain-text content from a message for indexing.
*/
function messageText(content: Conversation["messages"][number]["content"]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((block) => {
if (block.type === "text") return block.text;
if (block.type === "tool_use") return `${block.name} ${JSON.stringify(block.input)}`;
if (block.type === "tool_result") {
return typeof block.content === "string"
? block.content
: extractTextContent(block.content);
}
return "";
})
.join(" ");
}
/**
* Run a client-side full-text search over an array of conversations.
* Returns results sorted by relevance (highest score first).
*/
export function clientSearch(
conversations: Conversation[],
query: string,
filters: SearchFilters = {}
): SearchResult[] {
const tokens = tokenize(query);
if (tokens.length === 0 && !hasActiveFilters(filters)) return [];
const results: SearchResult[] = [];
const now = Date.now();
for (const conv of conversations) {
// --- Date filter ---
if (filters.dateFrom && conv.updatedAt < filters.dateFrom) continue;
if (filters.dateTo && conv.updatedAt > filters.dateTo + 86_400_000) continue;
// --- Conversation filter ---
if (filters.conversationId && conv.id !== filters.conversationId) continue;
// --- Model filter ---
if (filters.model && conv.model !== filters.model) continue;
// --- Tag filter ---
if (filters.tagIds && filters.tagIds.length > 0) {
const convTags = new Set(conv.tags ?? []);
if (!filters.tagIds.some((tid) => convTags.has(tid))) continue;
}
const matches: SearchResultMatch[] = [];
let titleScore = 0;
// Score the conversation title
if (tokens.length > 0) {
titleScore = scoreText(conv.title, tokens) * 1.5; // title matches weight more
}
for (const msg of conv.messages) {
// --- Role filter ---
if (filters.role && msg.role !== filters.role) continue;
// --- Content type filter ---
if (filters.contentType) {
const hasType = matchesContentType(msg.content, filters.contentType);
if (!hasType) continue;
}
const text = messageText(msg.content);
if (!text) continue;
let msgScore = tokens.length > 0 ? scoreText(text, tokens) : 1;
if (msgScore === 0) continue;
const ex = excerpt(text, query);
const hl = highlight(ex, query);
matches.push({
messageId: msg.id,
role: msg.role,
excerpt: ex,
highlighted: hl,
score: msgScore,
});
}
if (tokens.length === 0) {
// Filter-only mode: include conversation with a synthetic match on the title
results.push({
conversationId: conv.id,
conversationTitle: conv.title,
conversationDate: conv.updatedAt,
conversationModel: conv.model,
matches: matches.length > 0 ? matches.slice(0, 5) : [],
totalScore: 1,
});
continue;
}
if (matches.length === 0 && titleScore === 0) continue;
const totalScore =
titleScore + matches.reduce((sum, m) => sum + m.score, 0);
// Sort matches by score descending, keep top 5
matches.sort((a, b) => b.score - a.score);
results.push({
conversationId: conv.id,
conversationTitle: conv.title,
conversationDate: conv.updatedAt,
conversationModel: conv.model,
matches: matches.slice(0, 5),
totalScore,
});
}
// Sort results by total score descending
results.sort((a, b) => b.totalScore - a.totalScore);
return results;
}
function hasActiveFilters(filters: SearchFilters): boolean {
return !!(
filters.dateFrom ||
filters.dateTo ||
filters.role ||
filters.conversationId ||
filters.contentType ||
filters.model ||
(filters.tagIds && filters.tagIds.length > 0)
);
}
function matchesContentType(
content: Conversation["messages"][number]["content"],
type: NonNullable<SearchFilters["contentType"]>
): boolean {
if (typeof content === "string") return type === "text";
if (!Array.isArray(content)) return false;
return content.some((block) => {
if (type === "text" && block.type === "text") return true;
if (type === "tool_use" && block.type === "tool_use") return true;
if (type === "file" && block.type === "tool_use" && block.name?.includes("file")) return true;
if (type === "code" && block.type === "text") {
return block.text.includes("```") || block.text.includes(" ");
}
return false;
});
}

View File

@@ -0,0 +1,70 @@
/**
* Highlights occurrences of search terms in text by wrapping them in <mark> tags.
* Returns an HTML string safe for use with dangerouslySetInnerHTML.
*/
export function highlight(text: string, query: string): string {
if (!query.trim()) return escapeHtml(text);
const terms = tokenize(query);
if (terms.length === 0) return escapeHtml(text);
// Build a regex that matches any of the terms (case-insensitive)
const pattern = terms
.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("|");
const regex = new RegExp(`(${pattern})`, "gi");
return escapeHtml(text).replace(
// Re-run on escaped HTML — we need to match original terms
// So instead: split on matches then reassemble
regex,
(match) => `<mark class="search-highlight">${match}</mark>`
);
}
/**
* Returns a short excerpt (up to maxLength chars) centred around the first match.
*/
export function excerpt(text: string, query: string, maxLength = 160): string {
if (!query.trim()) return text.slice(0, maxLength);
const terms = tokenize(query);
if (terms.length === 0) return text.slice(0, maxLength);
const lowerText = text.toLowerCase();
let matchIndex = -1;
for (const term of terms) {
const idx = lowerText.indexOf(term.toLowerCase());
if (idx !== -1) {
matchIndex = idx;
break;
}
}
if (matchIndex === -1) return text.slice(0, maxLength);
const half = Math.floor(maxLength / 2);
const start = Math.max(0, matchIndex - half);
const end = Math.min(text.length, start + maxLength);
const slice = text.slice(start, end);
return (start > 0 ? "…" : "") + slice + (end < text.length ? "…" : "");
}
/** Tokenise a query string into non-empty lowercase words. */
export function tokenize(query: string): string[] {
return query
.trim()
.split(/\s+/)
.filter((t) => t.length > 0);
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

View File

@@ -0,0 +1,36 @@
/**
* Server-side search API client.
*
* Currently a stub — the app uses client-side search via client-search.ts.
* When a backend search endpoint is available, swap clientSearch calls
* in GlobalSearch.tsx for apiSearch.
*/
import type { SearchFilters, SearchResult } from "@/lib/types";
export interface SearchApiResponse {
results: SearchResult[];
total: number;
took: number; // ms
}
export async function apiSearch(
query: string,
filters: SearchFilters = {},
page = 0,
pageSize = 20,
apiUrl = ""
): Promise<SearchApiResponse> {
const params = new URLSearchParams({ q: query, page: String(page), pageSize: String(pageSize) });
if (filters.dateFrom) params.set("dateFrom", String(filters.dateFrom));
if (filters.dateTo) params.set("dateTo", String(filters.dateTo));
if (filters.role) params.set("role", filters.role);
if (filters.conversationId) params.set("conversationId", filters.conversationId);
if (filters.contentType) params.set("contentType", filters.contentType);
if (filters.model) params.set("model", filters.model);
if (filters.tagIds?.length) params.set("tagIds", filters.tagIds.join(","));
const res = await fetch(`${apiUrl}/api/search?${params.toString()}`);
if (!res.ok) throw new Error(`Search API error: ${res.status}`);
return res.json() as Promise<SearchApiResponse>;
}

88
web/lib/share-store.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* Server-side in-memory share store.
* In production, replace with a database (e.g. Redis, Postgres).
* Module-level singleton persists for the duration of the Node.js process.
*/
import type { Conversation } from "./types";
export type ShareVisibility = "public" | "unlisted" | "password";
export type ShareExpiry = "1h" | "24h" | "7d" | "30d" | "never";
export interface StoredShare {
id: string;
conversationId: string;
conversation: Conversation;
visibility: ShareVisibility;
passwordHash?: string; // bcrypt-style hash; plain comparison used here for simplicity
expiry: ShareExpiry;
expiresAt?: number;
createdAt: number;
}
const EXPIRY_MS: Record<ShareExpiry, number | null> = {
"1h": 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
never: null,
};
// Module-level singleton
const store = new Map<string, StoredShare>();
export function createShare(
shareId: string,
params: {
conversation: Conversation;
visibility: ShareVisibility;
password?: string;
expiry: ShareExpiry;
}
): StoredShare {
const expiryMs = EXPIRY_MS[params.expiry];
const now = Date.now();
const entry: StoredShare = {
id: shareId,
conversationId: params.conversation.id,
conversation: params.conversation,
visibility: params.visibility,
passwordHash: params.password ?? undefined,
expiry: params.expiry,
expiresAt: expiryMs !== null ? now + expiryMs : undefined,
createdAt: now,
};
store.set(shareId, entry);
return entry;
}
export function getShare(shareId: string): StoredShare | null {
const entry = store.get(shareId);
if (!entry) return null;
// Check expiry
if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) {
store.delete(shareId);
return null;
}
return entry;
}
export function verifySharePassword(shareId: string, password: string): boolean {
const entry = store.get(shareId);
if (!entry || entry.visibility !== "password") return false;
return entry.passwordHash === password;
}
export function revokeShare(shareId: string): boolean {
return store.delete(shareId);
}
export function getSharesByConversation(conversationId: string): StoredShare[] {
return Array.from(store.values()).filter(
(s) => s.conversationId === conversationId
);
}

51
web/lib/shortcuts.ts Normal file
View File

@@ -0,0 +1,51 @@
export interface Command {
id: string;
/** One or more key combo strings, e.g. ["mod+k", "mod+shift+p"] */
keys: string[];
label: string;
description: string;
category: ShortcutCategory;
action: () => void;
/** Return false to disable this command contextually */
when?: () => boolean;
/** If true, fires even when an input/textarea is focused */
global?: boolean;
/** Icon name from lucide-react, optional */
icon?: string;
}
export type ShortcutCategory =
| "Chat"
| "Navigation"
| "Model"
| "Theme"
| "View"
| "Help";
export const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
"Chat",
"Navigation",
"Model",
"Theme",
"View",
"Help",
];
/** IDs for all default commands — used to register/look up */
export const CMD = {
OPEN_PALETTE: "open-palette",
NEW_CONVERSATION: "new-conversation",
TOGGLE_SIDEBAR: "toggle-sidebar",
OPEN_SETTINGS: "open-settings",
SEND_MESSAGE: "send-message",
STOP_GENERATION: "stop-generation",
CLEAR_CONVERSATION: "clear-conversation",
TOGGLE_THEME: "toggle-theme",
PREV_CONVERSATION: "prev-conversation",
NEXT_CONVERSATION: "next-conversation",
FOCUS_CHAT: "focus-chat",
SHOW_HELP: "show-help",
GLOBAL_SEARCH: "global-search",
} as const;
export type CommandId = (typeof CMD)[keyof typeof CMD];

397
web/lib/store.ts Normal file
View File

@@ -0,0 +1,397 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { nanoid } from "nanoid";
import type { Conversation, Message, AppSettings, ConversationTag } from "./types";
import { DEFAULT_MODEL } from "./constants";
const DEFAULT_SETTINGS: AppSettings = {
// General
theme: "dark",
fontSize: { chat: 14, code: 13 },
sendOnEnter: true,
showTimestamps: false,
compactMode: false,
// Model
model: DEFAULT_MODEL,
maxTokens: 8096,
temperature: 1.0,
systemPrompt: "",
// API
apiUrl: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001",
apiKey: "",
streamingEnabled: true,
// Permissions
permissions: {
autoApprove: {
file_read: false,
file_write: false,
bash: false,
web_search: false,
},
restrictedDirs: [],
},
// MCP
mcpServers: [],
// Keybindings
keybindings: {
"new-conversation": "Ctrl+Shift+N",
"send-message": "Enter",
"focus-input": "Ctrl+L",
"toggle-sidebar": "Ctrl+B",
"open-settings": "Ctrl+,",
"command-palette": "Ctrl+K",
},
// Privacy
telemetryEnabled: false,
};
interface ChatState {
conversations: Conversation[];
activeConversationId: string | null;
settings: AppSettings;
settingsOpen: boolean;
// Sidebar state
sidebarOpen: boolean;
sidebarWidth: number;
sidebarTab: "chats" | "history" | "files" | "settings";
pinnedIds: string[];
searchQuery: string;
// Search & selection state (not persisted)
isSearchOpen: boolean;
selectedConversationIds: string[];
// Persisted search/tag state
recentSearches: string[];
tags: ConversationTag[];
// Conversation actions
createConversation: () => string;
setActiveConversation: (id: string) => void;
deleteConversation: (id: string) => void;
renameConversation: (id: string, title: string) => void;
updateConversation: (id: string, updates: Partial<Pick<Conversation, "title" | "model">>) => void;
addMessage: (conversationId: string, message: Omit<Message, "id" | "createdAt">) => string;
updateMessage: (conversationId: string, messageId: string, updates: Partial<Message>) => void;
/** Keep only the first `keepCount` messages in the conversation. */
truncateMessages: (conversationId: string, keepCount: number) => void;
getActiveConversation: () => Conversation | null;
// Bulk actions
toggleSelectConversation: (id: string) => void;
clearSelection: () => void;
bulkDeleteConversations: (ids: string[]) => void;
// Tag actions
createTag: (label: string, color: string) => string;
deleteTag: (id: string) => void;
updateTag: (id: string, updates: Partial<Pick<ConversationTag, "label" | "color">>) => void;
tagConversation: (conversationId: string, tagId: string) => void;
untagConversation: (conversationId: string, tagId: string) => void;
// Search actions
openSearch: () => void;
closeSearch: () => void;
addRecentSearch: (query: string) => void;
clearRecentSearches: () => void;
// Settings actions
updateSettings: (settings: Partial<AppSettings>) => void;
resetSettings: (section?: string) => void;
openSettings: () => void;
closeSettings: () => void;
// Sidebar actions
toggleSidebar: () => void;
setSidebarWidth: (w: number) => void;
setSidebarTab: (tab: "chats" | "history" | "files" | "settings") => void;
pinConversation: (id: string) => void;
setSearchQuery: (q: string) => void;
}
export const useChatStore = create<ChatState>()(
persist(
(set, get) => ({
conversations: [],
activeConversationId: null,
settings: DEFAULT_SETTINGS,
settingsOpen: false,
sidebarOpen: true,
sidebarWidth: 280,
sidebarTab: "chats",
pinnedIds: [],
searchQuery: "",
isSearchOpen: false,
selectedConversationIds: [],
recentSearches: [],
tags: [],
createConversation: () => {
const id = nanoid();
const now = Date.now();
const conversation: Conversation = {
id,
title: "New conversation",
messages: [],
createdAt: now,
updatedAt: now,
model: get().settings.model,
tags: [],
};
set((state) => ({
conversations: [conversation, ...state.conversations],
activeConversationId: id,
sidebarTab: "chats",
}));
return id;
},
setActiveConversation: (id) => {
set({ activeConversationId: id, sidebarTab: "chats" });
},
deleteConversation: (id) => {
set((state) => {
const remaining = state.conversations.filter((c) => c.id !== id);
const nextActive =
state.activeConversationId === id
? (remaining[0]?.id ?? null)
: state.activeConversationId;
return {
conversations: remaining,
activeConversationId: nextActive,
pinnedIds: state.pinnedIds.filter((pid) => pid !== id),
};
});
},
renameConversation: (id, title) => {
set((state) => ({
conversations: state.conversations.map((c) =>
c.id === id ? { ...c, title, updatedAt: Date.now() } : c
),
}));
},
updateConversation: (id, updates) => {
set((state) => ({
conversations: state.conversations.map((c) =>
c.id === id ? { ...c, ...updates, updatedAt: Date.now() } : c
),
}));
},
addMessage: (conversationId, message) => {
const id = nanoid();
const now = Date.now();
set((state) => ({
conversations: state.conversations.map((c) =>
c.id === conversationId
? {
...c,
messages: [...c.messages, { ...message, id, createdAt: now }],
updatedAt: now,
}
: c
),
}));
return id;
},
updateMessage: (conversationId, messageId, updates) => {
set((state) => ({
conversations: state.conversations.map((c) =>
c.id === conversationId
? {
...c,
messages: c.messages.map((m) =>
m.id === messageId ? { ...m, ...updates } : m
),
updatedAt: Date.now(),
}
: c
),
}));
},
toggleSelectConversation: (id) => {
set((state) => ({
selectedConversationIds: state.selectedConversationIds.includes(id)
? state.selectedConversationIds.filter((sid) => sid !== id)
: [...state.selectedConversationIds, id],
}));
},
clearSelection: () => set({ selectedConversationIds: [] }),
bulkDeleteConversations: (ids) => {
set((state) => {
const idSet = new Set(ids);
const remaining = state.conversations.filter((c) => !idSet.has(c.id));
const nextActive =
state.activeConversationId && idSet.has(state.activeConversationId)
? (remaining[0]?.id ?? null)
: state.activeConversationId;
return {
conversations: remaining,
activeConversationId: nextActive,
pinnedIds: state.pinnedIds.filter((pid) => !idSet.has(pid)),
selectedConversationIds: [],
};
});
},
createTag: (label, color) => {
const id = nanoid();
set((state) => ({ tags: [...state.tags, { id, label, color }] }));
return id;
},
deleteTag: (id) => {
set((state) => ({
tags: state.tags.filter((t) => t.id !== id),
conversations: state.conversations.map((c) => ({
...c,
tags: c.tags?.filter((tid) => tid !== id),
})),
}));
},
updateTag: (id, updates) => {
set((state) => ({
tags: state.tags.map((t) => (t.id === id ? { ...t, ...updates } : t)),
}));
},
tagConversation: (conversationId, tagId) => {
set((state) => ({
conversations: state.conversations.map((c) =>
c.id === conversationId
? { ...c, tags: [...new Set([...(c.tags ?? []), tagId])] }
: c
),
}));
},
untagConversation: (conversationId, tagId) => {
set((state) => ({
conversations: state.conversations.map((c) =>
c.id === conversationId
? { ...c, tags: c.tags?.filter((tid) => tid !== tagId) }
: c
),
}));
},
openSearch: () => set({ isSearchOpen: true }),
closeSearch: () => set({ isSearchOpen: false }),
addRecentSearch: (query) => {
if (!query.trim()) return;
set((state) => ({
recentSearches: [
query,
...state.recentSearches.filter((s) => s !== query),
].slice(0, 10),
}));
},
clearRecentSearches: () => set({ recentSearches: [] }),
updateSettings: (settings) => {
set((state) => ({
settings: { ...state.settings, ...settings },
}));
},
resetSettings: (section) => {
if (!section) {
set({ settings: DEFAULT_SETTINGS });
return;
}
const sectionDefaults: Record<string, Partial<AppSettings>> = {
general: {
theme: DEFAULT_SETTINGS.theme,
fontSize: DEFAULT_SETTINGS.fontSize,
sendOnEnter: DEFAULT_SETTINGS.sendOnEnter,
showTimestamps: DEFAULT_SETTINGS.showTimestamps,
compactMode: DEFAULT_SETTINGS.compactMode,
},
model: {
model: DEFAULT_SETTINGS.model,
maxTokens: DEFAULT_SETTINGS.maxTokens,
temperature: DEFAULT_SETTINGS.temperature,
systemPrompt: DEFAULT_SETTINGS.systemPrompt,
},
api: {
apiUrl: DEFAULT_SETTINGS.apiUrl,
streamingEnabled: DEFAULT_SETTINGS.streamingEnabled,
},
permissions: { permissions: DEFAULT_SETTINGS.permissions },
keybindings: { keybindings: DEFAULT_SETTINGS.keybindings },
data: { telemetryEnabled: DEFAULT_SETTINGS.telemetryEnabled },
};
const defaults = sectionDefaults[section];
if (defaults) {
set((state) => ({ settings: { ...state.settings, ...defaults } }));
}
},
openSettings: () => set({ settingsOpen: true }),
closeSettings: () => set({ settingsOpen: false }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarWidth: (w) => set({ sidebarWidth: w }),
setSidebarTab: (tab) => set({ sidebarTab: tab }),
pinConversation: (id) =>
set((state) => ({
pinnedIds: state.pinnedIds.includes(id)
? state.pinnedIds.filter((pid) => pid !== id)
: [id, ...state.pinnedIds],
})),
setSearchQuery: (q) => set({ searchQuery: q }),
getActiveConversation: () => {
const state = get();
return (
state.conversations.find((c) => c.id === state.activeConversationId) ??
null
);
},
}),
{
name: "claude-code-chat",
partialize: (state) => ({
conversations: state.conversations,
activeConversationId: state.activeConversationId,
settings: state.settings,
pinnedIds: state.pinnedIds,
recentSearches: state.recentSearches,
tags: state.tags,
}),
merge: (persisted, current) => ({
...current,
...(persisted as object),
settings: {
...DEFAULT_SETTINGS,
...((persisted as { settings?: Partial<AppSettings> }).settings ?? {}),
},
// Never persist UI state
settingsOpen: false,
isSearchOpen: false,
sidebarTab: "chats",
selectedConversationIds: [],
}),
}
)
);

View File

@@ -0,0 +1,95 @@
/**
* Stubs for terminal-only APIs used in src/components/.
* Import these in web-compatible code instead of the real Node.js APIs.
*
* These are no-ops / sensible defaults so components that conditionally use
* terminal features don't crash in the browser.
*/
import { isWeb } from "./platform";
// ─── process.stdout stubs ────────────────────────────────────────────────────
/** Terminal column count (characters wide). Falls back to window character width. */
export function getColumns(): number {
if (isWeb) {
// Approximate character columns: window width / ~8px per monospace char
if (typeof window !== "undefined") {
return Math.floor(window.innerWidth / 8);
}
return 120;
}
return (typeof process !== "undefined" && process.stdout?.columns) || 80;
}
/** Terminal row count. Falls back to window character height. */
export function getRows(): number {
if (isWeb) {
if (typeof window !== "undefined") {
return Math.floor(window.innerHeight / 16);
}
return 40;
}
return (typeof process !== "undefined" && process.stdout?.rows) || 24;
}
// ─── process.exit stub ───────────────────────────────────────────────────────
/**
* process.exit() replacement.
* In the terminal this exits the process; in the browser it navigates to "/".
*/
export function exit(code?: number): never {
if (isWeb) {
if (typeof window !== "undefined") {
window.location.href = "/";
}
// Keep the never return type satisfied in TS
throw new Error(`exit(${code ?? 0})`);
}
process.exit(code ?? 0);
}
// ─── stdin/stdout stubs ───────────────────────────────────────────────────────
/** No-op write stub for web environments. */
export function writeStdout(data: string): boolean {
if (!isWeb) {
return process.stdout.write(data);
}
// In browser, send to console so output isn't silently dropped
console.log("[stdout]", data);
return true;
}
// ─── useStdout / useTerminalSize stubs ────────────────────────────────────────
/**
* Minimal useStdout-compatible object for web environments.
* The real hook is from Ink and returns { stdout: NodeJS.WriteStream }.
*/
export const stdoutStub = {
write: writeStdout,
columns: getColumns(),
rows: getRows(),
};
// ─── useInput stub ────────────────────────────────────────────────────────────
/**
* No-op shim for Ink's useInput hook.
* Components that use useInput for keyboard shortcuts should use native
* keydown listeners in web mode instead.
*/
export function useInputStub(_handler: unknown, _options?: unknown): void {
// Intentional no-op for web
}
// ─── useApp stub ─────────────────────────────────────────────────────────────
/**
* Stub for Ink's useApp hook (provides exit()).
*/
export function useAppStub() {
return { exit: () => exit(0) };
}

74
web/lib/theme.tsx Normal file
View File

@@ -0,0 +1,74 @@
'use client'
import React, { createContext, useContext, useEffect, useState } from 'react'
export type Theme = 'dark' | 'light' | 'system'
export type ResolvedTheme = 'dark' | 'light'
interface ThemeContextValue {
theme: Theme
resolvedTheme: ResolvedTheme
setTheme: (theme: Theme) => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({
children,
defaultTheme = 'dark',
storageKey = 'ui-theme',
}: {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}) {
const [theme, setThemeState] = useState<Theme>(defaultTheme)
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>('dark')
useEffect(() => {
const stored = localStorage.getItem(storageKey) as Theme | null
if (stored && ['dark', 'light', 'system'].includes(stored)) {
setThemeState(stored)
}
}, [storageKey])
useEffect(() => {
const root = document.documentElement
const apply = (resolved: ResolvedTheme) => {
setResolvedTheme(resolved)
if (resolved === 'light') {
root.classList.add('light')
} else {
root.classList.remove('light')
}
}
if (theme === 'system') {
const mq = window.matchMedia('(prefers-color-scheme: light)')
apply(mq.matches ? 'light' : 'dark')
const handler = (e: MediaQueryListEvent) => apply(e.matches ? 'light' : 'dark')
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
} else {
apply(theme)
}
}, [theme])
const setTheme = (t: Theme) => {
setThemeState(t)
localStorage.setItem(storageKey, t)
}
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within a ThemeProvider')
return ctx
}

198
web/lib/types.ts Normal file
View File

@@ -0,0 +1,198 @@
export type MessageRole = "user" | "assistant" | "system" | "tool";
export type MessageStatus = "pending" | "streaming" | "complete" | "error";
export interface TextContent {
type: "text";
text: string;
}
export interface ToolUseContent {
type: "tool_use";
id: string;
name: string;
input: Record<string, unknown>;
}
export interface ToolResultContent {
type: "tool_result";
tool_use_id: string;
content: string | ContentBlock[];
is_error?: boolean;
}
export type ContentBlock = TextContent | ToolUseContent | ToolResultContent;
export interface Message {
id: string;
role: MessageRole;
content: ContentBlock[] | string;
status: MessageStatus;
createdAt: number;
model?: string;
usage?: {
input_tokens: number;
output_tokens: number;
};
}
export interface Conversation {
id: string;
title: string;
messages: Message[];
createdAt: number;
updatedAt: number;
model?: string;
tags?: string[]; // ConversationTag IDs
isPinned?: boolean;
}
// ─── Export ──────────────────────────────────────────────────────────────────
export type ExportFormat = "markdown" | "json" | "html" | "pdf" | "plaintext";
export interface ExportOptions {
format: ExportFormat;
includeToolUse: boolean;
includeThinking: boolean;
includeTimestamps: boolean;
includeFileContents: boolean;
dateRange?: { start: number; end: number };
}
// ─── Share ────────────────────────────────────────────────────────────────────
export type ShareVisibility = "public" | "unlisted" | "password";
export type ShareExpiry = "1h" | "24h" | "7d" | "30d" | "never";
export interface ShareOptions {
visibility: ShareVisibility;
password?: string;
expiry: ShareExpiry;
}
export interface ShareLink {
id: string;
conversationId: string;
visibility: ShareVisibility;
hasPassword: boolean;
expiry: ShareExpiry;
expiresAt?: number;
createdAt: number;
url: string;
}
export interface SharedConversation {
id: string;
title: string;
messages: Message[];
model?: string;
createdAt: number;
shareCreatedAt: number;
}
// ─────────────────────────────────────────────────────────────────────────────
export interface ToolDefinition {
name: string;
description: string;
input_schema: Record<string, unknown>;
}
export interface MCPServerConfig {
id: string;
name: string;
command: string;
args: string[];
env: Record<string, string>;
enabled: boolean;
}
export interface AppSettings {
// General
theme: "light" | "dark" | "system";
fontSize: { chat: number; code: number };
sendOnEnter: boolean;
showTimestamps: boolean;
compactMode: boolean;
// Model
model: string;
maxTokens: number;
temperature: number;
systemPrompt: string;
// API
apiUrl: string;
apiKey: string;
streamingEnabled: boolean;
// Permissions
permissions: {
autoApprove: Record<string, boolean>;
restrictedDirs: string[];
};
// MCP
mcpServers: MCPServerConfig[];
// Keybindings
keybindings: Record<string, string>;
// Privacy
telemetryEnabled: boolean;
}
export interface ConversationSummary {
id: string;
title: string;
preview: string;
updatedAt: number;
createdAt: number;
model?: string;
isPinned: boolean;
hasActiveTools: boolean;
}
export interface ConversationTag {
id: string;
label: string;
color: string; // "blue" | "green" | "red" | "yellow" | "purple" | "pink" | "orange" | "teal" | "cyan" | "indigo"
}
export interface SearchFilters {
dateFrom?: number;
dateTo?: number;
role?: MessageRole | null;
conversationId?: string | null;
contentType?: "text" | "code" | "tool_use" | "file" | null;
model?: string | null;
tagIds?: string[];
}
export interface SearchResultMatch {
messageId: string;
role: MessageRole;
excerpt: string; // plain text excerpt around the match
highlighted: string; // HTML string with <mark> tags
score: number;
}
export interface SearchResult {
conversationId: string;
conversationTitle: string;
conversationDate: number;
conversationModel?: string;
matches: SearchResultMatch[];
totalScore: number;
}
export type GitFileStatus = "M" | "A" | "?" | "D" | "R";
export interface FileNode {
name: string;
path: string;
type: "file" | "directory";
children?: FileNode[];
gitStatus?: GitFileStatus | null;
}

42
web/lib/utils.ts Normal file
View File

@@ -0,0 +1,42 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + "...";
}
export function extractTextContent(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.filter((b): b is { type: "text"; text: string } => b?.type === "text")
.map((b) => b.text)
.join("");
}
return "";
}

View File

@@ -0,0 +1,76 @@
/**
* Syntax highlighting worker.
* Loads Shiki lazily on first use so the main thread never blocks on it.
*
* Message in: { id: string; code: string; lang: string; theme?: string }
* Message out: { id: string; html: string; plainText: string }
*/
import type { Highlighter } from "shiki";
interface InMessage {
id: string;
code: string;
lang: string;
theme?: string;
}
interface OutMessage {
id: string;
html: string;
plainText: string;
}
let highlighterPromise: Promise<Highlighter> | null = null;
function getHighlighter(): Promise<Highlighter> {
if (!highlighterPromise) {
highlighterPromise = import("shiki").then(({ createHighlighter }) =>
createHighlighter({
themes: ["github-dark", "github-light"],
langs: [
"typescript",
"javascript",
"tsx",
"jsx",
"python",
"bash",
"shell",
"json",
"yaml",
"markdown",
"css",
"html",
"rust",
"go",
"java",
"c",
"cpp",
"sql",
"dockerfile",
],
})
);
}
return highlighterPromise;
}
async function highlight(msg: InMessage): Promise<OutMessage> {
const highlighter = await getHighlighter();
const theme = msg.theme ?? "github-dark";
let html: string;
try {
html = highlighter.codeToHtml(msg.code, { lang: msg.lang, theme });
} catch {
// Unknown language — fall back to plain text rendering
html = highlighter.codeToHtml(msg.code, { lang: "text", theme });
}
return { id: msg.id, html, plainText: msg.code };
}
self.addEventListener("message", async (e: MessageEvent<InMessage>) => {
const result = await highlight(e.data);
self.postMessage(result);
});

View File

@@ -0,0 +1,66 @@
/**
* Markdown parsing worker.
* Receives raw markdown strings and returns parsed token arrays
* so the main thread can skip heavy parsing during rendering.
*
* Message in: { id: string; markdown: string }
* Message out: { id: string; html: string }
*
* NOTE: This worker intentionally avoids importing the full remark
* pipeline to keep its bundle small. It does lightweight pre-processing
* (sanitise, extract headings/code-fence metadata) that would otherwise
* block the main thread on large documents.
*/
interface InMessage {
id: string;
markdown: string;
}
interface OutMessage {
id: string;
/** Line-by-line token classification for incremental rendering */
tokens: TokenLine[];
/** Top-level headings extracted for a mini table-of-contents */
headings: { level: number; text: string }[];
/** Number of code blocks found */
codeBlockCount: number;
}
interface TokenLine {
type: "text" | "heading" | "code-fence" | "list-item" | "blockquote" | "hr" | "blank";
content: string;
level?: number; // heading level
lang?: string; // code fence language
}
function classifyLine(line: string): TokenLine {
if (line.trim() === "") return { type: "blank", content: "" };
if (/^#{1,6}\s/.test(line)) {
const level = (line.match(/^(#{1,6})\s/)![1].length) as number;
return { type: "heading", content: line.replace(/^#{1,6}\s+/, ""), level };
}
if (/^```/.test(line)) {
const lang = line.slice(3).trim() || undefined;
return { type: "code-fence", content: line, lang };
}
if (/^[-*+]\s|^\d+\.\s/.test(line)) return { type: "list-item", content: line };
if (/^>\s/.test(line)) return { type: "blockquote", content: line.slice(2) };
if (/^[-*_]{3,}$/.test(line.trim())) return { type: "hr", content: line };
return { type: "text", content: line };
}
function process(msg: InMessage): OutMessage {
const lines = msg.markdown.split("\n");
const tokens: TokenLine[] = lines.map(classifyLine);
const headings = tokens
.filter((t): t is TokenLine & { type: "heading" } => t.type === "heading")
.map((t) => ({ level: t.level!, text: t.content }));
const codeBlockCount = tokens.filter((t) => t.type === "code-fence").length;
return { id: msg.id, tokens, headings, codeBlockCount };
}
self.addEventListener("message", (e: MessageEvent<InMessage>) => {
self.postMessage(process(e.data));
});

View File

@@ -0,0 +1,132 @@
/**
* Full-text search worker.
* Maintains an in-memory index of conversation messages so search queries
* never block the main thread.
*
* Messages in:
* { type: "index"; id: string; entries: SearchEntry[] }
* { type: "query"; id: string; query: string; limit?: number }
* { type: "remove"; id: string; conversationId: string }
*
* Messages out:
* { id: string; results: SearchResult[] }
*/
export interface SearchEntry {
conversationId: string;
messageId: string;
text: string;
role: "user" | "assistant";
createdAt: number;
}
export interface SearchResult {
conversationId: string;
messageId: string;
snippet: string;
score: number;
createdAt: number;
}
type InMessage =
| { type: "index"; id: string; entries: SearchEntry[] }
| { type: "query"; id: string; query: string; limit?: number }
| { type: "remove"; id: string; conversationId: string };
// Simple inverted index: term → Set of entry indices
const index = new Map<string, Set<number>>();
const entries: SearchEntry[] = [];
function tokenize(text: string): string[] {
return text
.toLowerCase()
.split(/\W+/)
.filter((t) => t.length >= 2);
}
function addEntry(entry: SearchEntry): void {
const idx = entries.length;
entries.push(entry);
for (const token of tokenize(entry.text)) {
if (!index.has(token)) index.set(token, new Set());
index.get(token)!.add(idx);
}
}
function removeConversation(conversationId: string): void {
// Mark entries as removed (nullish id) — we rebuild if fragmentation grows
for (const entry of entries) {
if (entry.conversationId === conversationId) {
(entry as { conversationId: string }).conversationId = "__removed__";
}
}
// Prune index entries that only point to removed items
for (const [token, set] of index) {
for (const idx of set) {
if (entries[idx].conversationId === "__removed__") set.delete(idx);
}
if (set.size === 0) index.delete(token);
}
}
function extractSnippet(text: string, query: string): string {
const lower = text.toLowerCase();
const pos = lower.indexOf(query.toLowerCase().split(/\s+/)[0]);
if (pos < 0) return text.slice(0, 120) + (text.length > 120 ? "…" : "");
const start = Math.max(0, pos - 40);
const end = Math.min(text.length, pos + 80);
return (start > 0 ? "…" : "") + text.slice(start, end) + (end < text.length ? "…" : "");
}
function query(
q: string,
limit = 20
): SearchResult[] {
const tokens = tokenize(q);
if (!tokens.length) return [];
// Score by how many query tokens appear
const scores = new Map<number, number>();
for (const token of tokens) {
for (const [term, set] of index) {
if (term.includes(token)) {
const boost = term === token ? 2 : 1; // exact > partial
for (const idx of set) {
scores.set(idx, (scores.get(idx) ?? 0) + boost);
}
}
}
}
return Array.from(scores.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([idx, score]) => {
const entry = entries[idx];
return {
conversationId: entry.conversationId,
messageId: entry.messageId,
snippet: extractSnippet(entry.text, q),
score,
createdAt: entry.createdAt,
};
})
.filter((r) => r.conversationId !== "__removed__");
}
self.addEventListener("message", (e: MessageEvent<InMessage>) => {
const msg = e.data;
switch (msg.type) {
case "index":
for (const entry of msg.entries) addEntry(entry);
self.postMessage({ id: msg.id, results: [] });
break;
case "query":
self.postMessage({ id: msg.id, results: query(msg.query, msg.limit) });
break;
case "remove":
removeConversation(msg.conversationId);
self.postMessage({ id: msg.id, results: [] });
break;
}
});