mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-08 22:28:48 +03:00
claude-code
This commit is contained in:
166
web/lib/ansi-to-html.ts
Normal file
166
web/lib/ansi-to-html.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
return parts.length ? `<span style="${parts.join(";")}">${escaped}</span>` : escaped;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
85
web/lib/api.ts
Normal file
85
web/lib/api.ts
Normal 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
271
web/lib/api/client.ts
Normal 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();
|
||||
109
web/lib/api/conversations.ts
Normal file
109
web/lib/api/conversations.ts
Normal 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
249
web/lib/api/files.ts
Normal 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
156
web/lib/api/messages.ts
Normal 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
228
web/lib/api/stream.ts
Normal 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
124
web/lib/api/types.ts
Normal 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;
|
||||
}
|
||||
50
web/lib/browser-notifications.ts
Normal file
50
web/lib/browser-notifications.ts
Normal 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();
|
||||
100
web/lib/collaboration/permissions.ts
Normal file
100
web/lib/collaboration/permissions.ts
Normal 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];
|
||||
}
|
||||
115
web/lib/collaboration/presence.ts
Normal file
115
web/lib/collaboration/presence.ts
Normal 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();
|
||||
}
|
||||
321
web/lib/collaboration/socket.ts
Normal file
321
web/lib/collaboration/socket.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
37
web/lib/collaboration/types.ts
Normal file
37
web/lib/collaboration/types.ts
Normal 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
16
web/lib/constants.ts
Normal 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
154
web/lib/export/html.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
45
web/lib/export/json.ts
Normal 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);
|
||||
}
|
||||
78
web/lib/export/markdown.ts
Normal file
78
web/lib/export/markdown.ts
Normal 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");
|
||||
}
|
||||
58
web/lib/export/plaintext.ts
Normal file
58
web/lib/export/plaintext.ts
Normal 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
210
web/lib/fileViewerStore.ts
Normal 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
151
web/lib/ink-compat/Box.tsx
Normal 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
|
||||
93
web/lib/ink-compat/color-mapping.ts
Normal file
93
web/lib/ink-compat/color-mapping.ts
Normal 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 {
|
||||
// 0–15: 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'
|
||||
}
|
||||
|
||||
// 232–255: grayscale ramp
|
||||
if (index >= 232) {
|
||||
const level = (index - 232) * 10 + 8
|
||||
const hex = level.toString(16).padStart(2, '0')
|
||||
return `#${hex}${hex}${hex}`
|
||||
}
|
||||
|
||||
// 16–231: 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
|
||||
}
|
||||
328
web/lib/ink-compat/prop-mapping.ts
Normal file
328
web/lib/ink-compat/prop-mapping.ts
Normal 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
87
web/lib/keyParser.ts
Normal 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
149
web/lib/notifications.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
176
web/lib/performance/metrics.ts
Normal file
176
web/lib/performance/metrics.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
66
web/lib/performance/streaming-optimizer.ts
Normal file
66
web/lib/performance/streaming-optimizer.ts
Normal 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);
|
||||
}
|
||||
115
web/lib/performance/worker-pool.ts
Normal file
115
web/lib/performance/worker-pool.ts
Normal 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
37
web/lib/platform.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
193
web/lib/search/client-search.ts
Normal file
193
web/lib/search/client-search.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
70
web/lib/search/highlighter.ts
Normal file
70
web/lib/search/highlighter.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
36
web/lib/search/search-api.ts
Normal file
36
web/lib/search/search-api.ts
Normal 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
88
web/lib/share-store.ts
Normal 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
51
web/lib/shortcuts.ts
Normal 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
397
web/lib/store.ts
Normal 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: [],
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
95
web/lib/terminal-compat.ts
Normal file
95
web/lib/terminal-compat.ts
Normal 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
74
web/lib/theme.tsx
Normal 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
198
web/lib/types.ts
Normal 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
42
web/lib/utils.ts
Normal 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 "";
|
||||
}
|
||||
76
web/lib/workers/highlight.worker.ts
Normal file
76
web/lib/workers/highlight.worker.ts
Normal 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);
|
||||
});
|
||||
66
web/lib/workers/markdown.worker.ts
Normal file
66
web/lib/workers/markdown.worker.ts
Normal 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));
|
||||
});
|
||||
132
web/lib/workers/search.worker.ts
Normal file
132
web/lib/workers/search.worker.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user