claude-code

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

3
web/.env.example Normal file
View File

@@ -0,0 +1,3 @@
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_WS_URL=ws://localhost:3001
ANTHROPIC_API_KEY=

3
web/.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}

36
web/app/api/chat/route.ts Normal file
View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
const response = await fetch(`${apiUrl}/api/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(process.env.ANTHROPIC_API_KEY
? { Authorization: `Bearer ${process.env.ANTHROPIC_API_KEY}` }
: {}),
},
body: JSON.stringify(body),
});
if (!response.ok) {
return NextResponse.json(
{ error: "Backend request failed" },
{ status: response.status }
);
}
// Stream the response through
return new NextResponse(response.body, {
headers: {
"Content-Type": response.headers.get("Content-Type") ?? "application/json",
},
});
} catch (error) {
console.error("Chat API error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import type { Conversation, ExportOptions } from "@/lib/types";
import { toMarkdown } from "@/lib/export/markdown";
import { toJSON } from "@/lib/export/json";
import { toHTML } from "@/lib/export/html";
import { toPlainText } from "@/lib/export/plaintext";
interface ExportRequest {
conversation: Conversation;
options: ExportOptions;
}
const MIME: Record<string, string> = {
markdown: "text/markdown; charset=utf-8",
json: "application/json",
html: "text/html; charset=utf-8",
plaintext: "text/plain; charset=utf-8",
};
const EXT: Record<string, string> = {
markdown: "md",
json: "json",
html: "html",
plaintext: "txt",
};
export async function POST(req: NextRequest) {
let body: ExportRequest;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { conversation, options } = body;
if (!conversation || !options) {
return NextResponse.json(
{ error: "Missing conversation or options" },
{ status: 400 }
);
}
const { format } = options;
if (format === "pdf") {
// PDF is handled client-side via window.print()
return NextResponse.json(
{ error: "PDF export is handled client-side" },
{ status: 400 }
);
}
let content: string;
switch (format) {
case "markdown":
content = toMarkdown(conversation, options);
break;
case "json":
content = toJSON(conversation, options);
break;
case "html":
content = toHTML(conversation, options);
break;
case "plaintext":
content = toPlainText(conversation, options);
break;
default:
return NextResponse.json({ error: `Unknown format: ${format}` }, { status: 400 });
}
const slug = conversation.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 50);
const filename = `${slug || "conversation"}.${EXT[format]}`;
return new NextResponse(content, {
status: 200,
headers: {
"Content-Type": MIME[format],
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
const IMAGE_MIME: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
};
export async function GET(request: NextRequest) {
const filePath = request.nextUrl.searchParams.get("path");
if (!filePath) {
return NextResponse.json({ error: "path parameter required" }, { status: 400 });
}
const resolvedPath = path.resolve(filePath);
try {
const stats = await fs.stat(resolvedPath);
if (stats.isDirectory()) {
return NextResponse.json({ error: "path is a directory" }, { status: 400 });
}
const ext = resolvedPath.split(".").pop()?.toLowerCase() ?? "";
// Binary images: return base64 data URL
if (ext in IMAGE_MIME) {
const buffer = await fs.readFile(resolvedPath);
const base64 = buffer.toString("base64");
return NextResponse.json({
content: `data:${IMAGE_MIME[ext]};base64,${base64}`,
isImage: true,
size: stats.size,
modified: stats.mtime.toISOString(),
});
}
// Text (including SVG)
const content = await fs.readFile(resolvedPath, "utf-8");
return NextResponse.json({
content,
isImage: ext === "svg",
size: stats.size,
modified: stats.mtime.toISOString(),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 404 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
export async function POST(request: NextRequest) {
let body: { path?: string; content?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
}
const { path: filePath, content } = body;
if (!filePath || content === undefined) {
return NextResponse.json(
{ error: "path and content are required" },
{ status: 400 }
);
}
const resolvedPath = path.resolve(filePath);
try {
await fs.writeFile(resolvedPath, content, "utf-8");
const stats = await fs.stat(resolvedPath);
return NextResponse.json({ success: true, size: stats.size });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { getShare, revokeShare, verifySharePassword } from "@/lib/share-store";
interface RouteContext {
params: { shareId: string };
}
export async function GET(req: NextRequest, { params }: RouteContext) {
const { shareId } = params;
const share = getShare(shareId);
if (!share) {
return NextResponse.json({ error: "Share not found or expired" }, { status: 404 });
}
if (share.visibility === "password") {
const pw = req.headers.get("x-share-password") ?? req.nextUrl.searchParams.get("password");
if (!pw || !verifySharePassword(shareId, pw)) {
return NextResponse.json({ error: "Password required", requiresPassword: true }, { status: 401 });
}
}
return NextResponse.json({
id: share.id,
title: share.conversation.title,
messages: share.conversation.messages,
model: share.conversation.model,
createdAt: share.conversation.createdAt,
shareCreatedAt: share.createdAt,
});
}
export async function DELETE(_req: NextRequest, { params }: RouteContext) {
const { shareId } = params;
const deleted = revokeShare(shareId);
if (!deleted) {
return NextResponse.json({ error: "Share not found" }, { status: 404 });
}
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { nanoid } from "nanoid";
import type { Conversation } from "@/lib/types";
import type { ShareVisibility, ShareExpiry } from "@/lib/share-store";
import { createShare } from "@/lib/share-store";
interface CreateShareRequest {
conversation: Conversation;
visibility: ShareVisibility;
password?: string;
expiry: ShareExpiry;
}
export async function POST(req: NextRequest) {
let body: CreateShareRequest;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { conversation, visibility, password, expiry } = body;
if (!conversation || !visibility || !expiry) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
if (visibility === "password" && !password) {
return NextResponse.json(
{ error: "Password required for password-protected shares" },
{ status: 400 }
);
}
const shareId = nanoid(12);
const share = createShare(shareId, { conversation, visibility, password, expiry });
const origin = req.headers.get("origin") ?? "";
const url = `${origin}/share/${shareId}`;
return NextResponse.json({
id: share.id,
conversationId: share.conversationId,
visibility: share.visibility,
hasPassword: !!share.passwordHash,
expiry: share.expiry,
expiresAt: share.expiresAt,
createdAt: share.createdAt,
url,
});
}

227
web/app/globals.css Normal file
View File

@@ -0,0 +1,227 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* =====================================================
DESIGN TOKENS — CSS Custom Properties
Dark theme is default; add .light to <html> for light
===================================================== */
@layer base {
:root {
/* Backgrounds */
--color-bg-primary: #09090b;
--color-bg-secondary: #18181b;
--color-bg-elevated: #27272a;
/* Text */
--color-text-primary: #fafafa;
--color-text-secondary: #a1a1aa;
--color-text-muted: #52525b;
/* Accent (brand purple) */
--color-accent: #8b5cf6;
--color-accent-hover: #7c3aed;
--color-accent-active: #6d28d9;
--color-accent-foreground: #ffffff;
/* Borders */
--color-border: #27272a;
--color-border-hover: #3f3f46;
/* Status */
--color-success: #22c55e;
--color-success-bg: rgba(34, 197, 94, 0.12);
--color-warning: #f59e0b;
--color-warning-bg: rgba(245, 158, 11, 0.12);
--color-error: #ef4444;
--color-error-bg: rgba(239, 68, 68, 0.12);
--color-info: #3b82f6;
--color-info-bg: rgba(59, 130, 246, 0.12);
/* Code */
--color-code-bg: #1f1f23;
--color-code-text: #a78bfa;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
/* Border radius */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius: 0.5rem;
/* Animation tokens */
--transition-fast: 100ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
/* Tailwind / shadcn compat (HSL channel values) */
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 3.7% 10%;
--card-foreground: 0 0% 98%;
--popover: 240 3.7% 15.9%;
--popover-foreground: 0 0% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 0 0% 100%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 263.4 70% 50.4%;
}
/* Light theme override */
.light {
--color-bg-primary: #fafafa;
--color-bg-secondary: #f4f4f5;
--color-bg-elevated: #ffffff;
--color-text-primary: #09090b;
--color-text-secondary: #52525b;
--color-text-muted: #a1a1aa;
--color-accent: #7c3aed;
--color-accent-hover: #6d28d9;
--color-accent-active: #5b21b6;
--color-accent-foreground: #ffffff;
--color-border: #e4e4e7;
--color-border-hover: #d4d4d8;
--color-success: #16a34a;
--color-success-bg: rgba(22, 163, 74, 0.1);
--color-warning: #d97706;
--color-warning-bg: rgba(217, 119, 6, 0.1);
--color-error: #dc2626;
--color-error-bg: rgba(220, 38, 38, 0.1);
--color-info: #2563eb;
--color-info-bg: rgba(37, 99, 235, 0.1);
--color-code-bg: #f4f4f5;
--color-code-text: #7c3aed;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.05);
--background: 0 0% 98%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 262.1 83.3% 57.8%;
--primary-foreground: 0 0% 100%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 262.1 83.3% 57.8%;
}
*, *::before, *::after {
box-sizing: border-box;
border-color: var(--color-border);
}
html {
color-scheme: dark;
}
html.light {
color-scheme: light;
}
body {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-feature-settings: "rlig" 1, "calt" 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.5;
}
/* Focus visible ring */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
/* =====================================================
ANIMATIONS
===================================================== */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes slideUp {
from { transform: translateY(8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideDown {
from { transform: translateY(-8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes slideDownOut {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(8px); opacity: 0; }
}
@keyframes scaleIn {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes scaleOut {
from { transform: scale(1); opacity: 1; }
to { transform: scale(0.95); opacity: 0; }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes progress {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
/* =====================================================
SCROLLBAR
===================================================== */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-border-hover);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}

56
web/app/layout.tsx Normal file
View File

@@ -0,0 +1,56 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { ToastProvider } from "@/components/notifications/ToastProvider";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
});
const jetbrainsMono = localFont({
src: [
{
path: "../public/fonts/JetBrainsMono-Regular.woff2",
weight: "400",
style: "normal",
},
{
path: "../public/fonts/JetBrainsMono-Medium.woff2",
weight: "500",
style: "normal",
},
],
variable: "--font-jetbrains-mono",
display: "swap",
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Monaco", "monospace"],
});
export const metadata: Metadata = {
title: "Claude Code",
description: "Claude Code — AI-powered development assistant",
icons: {
icon: "/favicon.ico",
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}>
<ThemeProvider>
<ToastProvider>
{children}
</ToastProvider>
</ThemeProvider>
</body>
</html>
);
}

5
web/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { ChatLayout } from "@/components/chat/ChatLayout";
export default function Home() {
return <ChatLayout />;
}

View File

@@ -0,0 +1,68 @@
"use client";
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
interface AnnouncerContextValue {
announce: (message: string, politeness?: "polite" | "assertive") => void;
}
const AnnouncerContext = createContext<AnnouncerContextValue | null>(null);
/**
* Provides a programmatic screen-reader announcement API via context.
* Place <AnnouncerProvider> near the root of the app, then call `useAnnouncer()`
* anywhere to imperatively announce status changes.
*
* @example
* const { announce } = useAnnouncer();
* announce("File uploaded successfully");
* announce("Error: request failed", "assertive");
*/
export function AnnouncerProvider({ children }: { children: ReactNode }) {
const [politeMsg, setPoliteMsg] = useState("");
const [assertiveMsg, setAssertiveMsg] = useState("");
const politeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const assertiveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const announce = useCallback((message: string, politeness: "polite" | "assertive" = "polite") => {
if (politeness === "assertive") {
setAssertiveMsg("");
if (assertiveTimer.current) clearTimeout(assertiveTimer.current);
assertiveTimer.current = setTimeout(() => setAssertiveMsg(message), 50);
} else {
setPoliteMsg("");
if (politeTimer.current) clearTimeout(politeTimer.current);
politeTimer.current = setTimeout(() => setPoliteMsg(message), 50);
}
}, []);
const srStyle: React.CSSProperties = {
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0,0,0,0)",
whiteSpace: "nowrap",
borderWidth: 0,
};
return (
<AnnouncerContext.Provider value={{ announce }}>
{children}
<div aria-live="polite" aria-atomic="true" style={srStyle}>
{politeMsg}
</div>
<div aria-live="assertive" aria-atomic="true" style={srStyle}>
{assertiveMsg}
</div>
</AnnouncerContext.Provider>
);
}
export function useAnnouncer() {
const ctx = useContext(AnnouncerContext);
if (!ctx) throw new Error("useAnnouncer must be used within <AnnouncerProvider>");
return ctx;
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
interface FocusTrapProps {
children: ReactNode;
/** When false, the trap is inactive (e.g. when the panel is hidden) */
active?: boolean;
}
const FOCUSABLE_SELECTORS = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(", ");
/**
* Traps keyboard focus within its children when `active` is true.
* Use for modals, drawers, and other overlay patterns.
* Note: Radix Dialog handles focus trapping natively — use this only for
* custom overlay components that don't use Radix primitives.
*/
export function FocusTrap({ children, active = true }: FocusTrapProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const focusable = () =>
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)).filter(
(el) => !el.closest("[aria-hidden='true']")
);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const els = focusable();
if (els.length === 0) return;
const first = els[0];
const last = els[els.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
// Move focus into the trap on mount
const els = focusable();
if (els.length > 0 && !container.contains(document.activeElement)) {
els[0].focus();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [active]);
return <div ref={containerRef}>{children}</div>;
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface LiveRegionProps {
/** The message to announce. Changing this value triggers an announcement. */
message: string;
/**
* "polite" — waits for user to be idle (new chat messages, status updates)
* "assertive" — interrupts immediately (errors, critical alerts)
*/
politeness?: "polite" | "assertive";
}
/**
* Managed aria-live region that announces dynamic content to screen readers.
* Clears after 500 ms to ensure repeated identical messages are re-announced.
*/
export function LiveRegion({ message, politeness = "polite" }: LiveRegionProps) {
const [announced, setAnnounced] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!message) return;
// Clear first to force re-announcement of identical messages
setAnnounced("");
timerRef.current = setTimeout(() => setAnnounced(message), 50);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [message]);
return (
<div
role="status"
aria-live={politeness}
aria-atomic="true"
style={{
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
borderWidth: 0,
}}
>
{announced}
</div>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
export function SkipToContent() {
return (
<a
href="#main-content"
className={[
"sr-only focus:not-sr-only",
"focus:fixed focus:top-4 focus:left-4 focus:z-50",
"focus:px-4 focus:py-2 focus:rounded-md",
"focus:bg-brand-600 focus:text-white focus:font-medium focus:text-sm",
"focus:outline-none focus:ring-2 focus:ring-brand-300 focus:ring-offset-2",
].join(" ")}
>
Skip to main content
</a>
);
}

View File

@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
interface VisuallyHiddenProps {
children: ReactNode;
/** When true, renders as a span inline; defaults to span */
as?: "span" | "div" | "p";
}
/**
* Visually hides content while keeping it accessible to screen readers.
* Use for icon-only buttons, supplemental context, etc.
*/
export function VisuallyHidden({ children, as: Tag = "span" }: VisuallyHiddenProps) {
return (
<Tag
style={{
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
borderWidth: 0,
}}
>
{children}
</Tag>
);
}

View File

@@ -0,0 +1,170 @@
"use client";
/**
* Web-adapted Markdown renderer.
*
* The terminal Markdown (src/components/Markdown.tsx) uses marked + Ink's
* <Ansi> / <Box> to render tokenised markdown as coloured ANSI output.
* This web version uses react-markdown + remark-gfm + rehype-highlight, which
* are already present in the web package, to render proper HTML with Tailwind
* prose styles.
*
* Props are intentionally compatible with the terminal version so callers can
* swap between them via the platform conditional.
*/
import * as React from "react";
import { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface MarkdownProps {
/** Markdown source string — matches the terminal component's children prop. */
children: string;
/** When true, render all text as visually dimmed (muted colour). */
dimColor?: boolean;
/** Extra class names applied to the prose wrapper. */
className?: string;
}
// ─── Inline code / pre renderers ─────────────────────────────────────────────
function InlineCode({ children }: { children?: React.ReactNode }) {
return (
<code className="px-1 py-0.5 rounded text-xs font-mono bg-surface-850 text-brand-300 border border-surface-700">
{children}
</code>
);
}
interface PreProps {
children?: React.ReactNode;
}
function Pre({ children }: PreProps) {
return (
<pre className="overflow-x-auto rounded-md bg-surface-900 border border-surface-700 p-3 my-2 text-xs font-mono leading-relaxed">
{children}
</pre>
);
}
// ─── Component ────────────────────────────────────────────────────────────────
export function Markdown({ children, dimColor = false, className }: MarkdownProps) {
// Memoised to avoid re-parsing on every parent render.
const content = useMemo(() => children, [children]);
return (
<div
className={cn(
"markdown-body text-sm leading-relaxed font-mono",
dimColor ? "text-surface-500" : "text-surface-100",
// Headings
"[&_h1]:text-base [&_h1]:font-bold [&_h1]:mb-2 [&_h1]:mt-3 [&_h1]:text-surface-50",
"[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mb-1.5 [&_h2]:mt-2.5 [&_h2]:text-surface-100",
"[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:mb-1 [&_h3]:mt-2 [&_h3]:text-surface-200",
// Paragraphs
"[&_p]:my-1 [&_p]:leading-relaxed",
// Lists
"[&_ul]:my-1 [&_ul]:pl-4 [&_ul]:list-disc",
"[&_ol]:my-1 [&_ol]:pl-4 [&_ol]:list-decimal",
"[&_li]:my-0.5",
// Blockquote
"[&_blockquote]:border-l-2 [&_blockquote]:border-brand-500 [&_blockquote]:pl-3",
"[&_blockquote]:my-2 [&_blockquote]:text-surface-400 [&_blockquote]:italic",
// Horizontal rule
"[&_hr]:border-surface-700 [&_hr]:my-3",
// Tables (GFM)
"[&_table]:w-full [&_table]:text-xs [&_table]:border-collapse [&_table]:my-2",
"[&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:border [&_th]:border-surface-700 [&_th]:bg-surface-800 [&_th]:font-semibold",
"[&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-surface-700",
"[&_tr:nth-child(even)_td]:bg-surface-900/40",
// Links
"[&_a]:text-brand-400 [&_a]:no-underline [&_a:hover]:underline",
// Strong / em
"[&_strong]:font-bold [&_strong]:text-surface-50",
"[&_em]:italic",
className
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className: cls, children: codeChildren, ...rest }) {
const isBlock = /language-/.test(cls ?? "");
if (isBlock) {
return (
<code className={cn("block text-surface-200", cls)} {...rest}>
{codeChildren}
</code>
);
}
return <InlineCode {...rest}>{codeChildren}</InlineCode>;
},
pre: ({ children: preChildren }) => <Pre>{preChildren}</Pre>,
}}
>
{content}
</ReactMarkdown>
</div>
);
}
// ─── Table component (matches MarkdownTable.tsx surface) ─────────────────────
export interface MarkdownTableProps {
headers: string[];
rows: string[][];
className?: string;
}
export function MarkdownTable({ headers, rows, className }: MarkdownTableProps) {
return (
<div className={cn("overflow-x-auto my-2", className)}>
<table className="w-full text-xs border-collapse font-mono">
<thead>
<tr>
{headers.map((h, i) => (
<th
key={i}
className="px-2 py-1 text-left border border-surface-700 bg-surface-800 font-semibold text-surface-200"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td
key={ci}
className={cn(
"px-2 py-1 border border-surface-700 text-surface-300",
ri % 2 === 1 && "bg-surface-900/40"
)}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
/**
* Web-adapted Spinner.
*
* The terminal Spinner (src/components/Spinner.tsx) drives animation via
* useAnimationFrame and renders Unicode braille/block characters with ANSI
* colour via Ink's <Text>. In the browser we replace that with a pure-CSS
* spinning ring, preserving the same optional `tip` text and `mode` prop
* surface so callers can swap in this component without changing props.
*/
import * as React from "react";
import { cn } from "@/lib/utils";
// ─── Types ────────────────────────────────────────────────────────────────────
/** Mirrors the SpinnerMode type from src/components/Spinner/index.ts */
export type SpinnerMode =
| "queued"
| "loading"
| "thinking"
| "auto"
| "disabled";
export interface SpinnerProps {
/** Visual mode — controls colour/appearance. */
mode?: SpinnerMode;
/** Optional tip text shown next to the spinner. */
spinnerTip?: string;
/** Override message replaces the default verb. */
overrideMessage?: string | null;
/** Additional suffix appended after the main label. */
spinnerSuffix?: string | null;
/** When true the spinner renders inline instead of as a block row. */
inline?: boolean;
/** Extra class names for the wrapper element. */
className?: string;
}
// ─── Colour map ───────────────────────────────────────────────────────────────
const MODE_RING_CLASS: Record<SpinnerMode, string> = {
queued: "border-surface-500",
loading: "border-brand-400",
thinking: "border-brand-500",
auto: "border-brand-400",
disabled: "border-surface-600",
};
const MODE_TEXT_CLASS: Record<SpinnerMode, string> = {
queued: "text-surface-400",
loading: "text-brand-300",
thinking: "text-brand-300",
auto: "text-brand-300",
disabled: "text-surface-500",
};
const MODE_LABEL: Record<SpinnerMode, string> = {
queued: "Queued…",
loading: "Loading…",
thinking: "Thinking…",
auto: "Working…",
disabled: "",
};
// ─── Component ────────────────────────────────────────────────────────────────
export function Spinner({
mode = "loading",
spinnerTip,
overrideMessage,
spinnerSuffix,
inline = false,
className,
}: SpinnerProps) {
if (mode === "disabled") return null;
const label =
overrideMessage ??
spinnerTip ??
MODE_LABEL[mode];
const ringClass = MODE_RING_CLASS[mode];
const textClass = MODE_TEXT_CLASS[mode];
return (
<span
role="status"
aria-label={label || "Loading"}
className={cn(
"flex items-center gap-2",
inline ? "inline-flex" : "flex",
className
)}
>
{/* CSS spinning ring */}
<span
className={cn(
"block w-3.5 h-3.5 rounded-full border-2 border-transparent animate-spin flex-shrink-0",
ringClass,
// Top border only — creates the "gap" in the ring for the spinning effect
"[border-top-color:currentColor]"
)}
style={{ borderTopColor: undefined }}
aria-hidden
>
{/* Inner ring for the visible arc — achieved via box-shadow trick */}
</span>
{(label || spinnerSuffix) && (
<span className={cn("text-sm font-mono", textClass)}>
{label}
{spinnerSuffix && (
<span className="text-surface-500 ml-1">{spinnerSuffix}</span>
)}
</span>
)}
</span>
);
}
// ─── Shimmer / glimmer variant ────────────────────────────────────────────────
/** Pulsing shimmer bar — web replacement for GlimmerMessage / ShimmerChar. */
export function ShimmerBar({ className }: { className?: string }) {
return (
<div
className={cn(
"h-2 rounded-full bg-gradient-to-r from-surface-700 via-surface-500 to-surface-700",
"bg-[length:200%_100%] animate-shimmer",
className
)}
aria-hidden
/>
);
}
/** Inline flashing cursor dot — web replacement for FlashingChar. */
export function FlashingCursor({ className }: { className?: string }) {
return (
<span
className={cn(
"inline-block w-1.5 h-4 bg-current align-text-bottom ml-0.5",
"animate-pulse-soft",
className
)}
aria-hidden
/>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { Send, Square, Paperclip } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { streamChat } from "@/lib/api";
import { cn } from "@/lib/utils";
import { MAX_MESSAGE_LENGTH } from "@/lib/constants";
interface ChatInputProps {
conversationId: string;
}
export function ChatInput({ conversationId }: ChatInputProps) {
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const { conversations, settings, addMessage, updateMessage } = useChatStore();
const conversation = conversations.find((c) => c.id === conversationId);
const handleSubmit = useCallback(async () => {
const text = input.trim();
if (!text || isStreaming) return;
setInput("");
setIsStreaming(true);
// Add user message
addMessage(conversationId, {
role: "user",
content: text,
status: "complete",
});
// Add placeholder assistant message
const assistantId = addMessage(conversationId, {
role: "assistant",
content: "",
status: "streaming",
});
const controller = new AbortController();
abortRef.current = controller;
const messages = [
...(conversation?.messages ?? []).map((m) => ({
role: m.role,
content: m.content,
})),
{ role: "user" as const, content: text },
];
let fullText = "";
try {
for await (const chunk of streamChat(messages, settings.model, controller.signal)) {
if (chunk.type === "text" && chunk.content) {
fullText += chunk.content;
updateMessage(conversationId, assistantId, {
content: fullText,
status: "streaming",
});
} else if (chunk.type === "done") {
break;
} else if (chunk.type === "error") {
updateMessage(conversationId, assistantId, {
content: chunk.error ?? "An error occurred",
status: "error",
});
return;
}
}
updateMessage(conversationId, assistantId, { status: "complete" });
} catch (err) {
if ((err as Error).name !== "AbortError") {
updateMessage(conversationId, assistantId, {
content: "Request failed. Please try again.",
status: "error",
});
} else {
updateMessage(conversationId, assistantId, { status: "complete" });
}
} finally {
setIsStreaming(false);
abortRef.current = null;
}
}, [input, isStreaming, conversationId, conversation, settings.model, addMessage, updateMessage]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleStop = () => {
abortRef.current?.abort();
};
const adjustHeight = () => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
};
return (
<div className="border-t border-surface-800 bg-surface-900/50 backdrop-blur-sm px-4 py-3">
<div className="max-w-3xl mx-auto">
<div
className={cn(
"flex items-end gap-2 rounded-xl border bg-surface-800 px-3 py-2",
"border-surface-700 focus-within:border-brand-500 transition-colors"
)}
>
<button
className="p-1 text-surface-500 hover:text-surface-300 transition-colors flex-shrink-0 mb-0.5"
aria-label="Attach file"
>
<Paperclip className="w-4 h-4" aria-hidden="true" />
</button>
<label htmlFor="chat-input" className="sr-only">
Message
</label>
<textarea
id="chat-input"
ref={textareaRef}
value={input}
onChange={(e) => {
setInput(e.target.value.slice(0, MAX_MESSAGE_LENGTH));
adjustHeight();
}}
onKeyDown={handleKeyDown}
placeholder="Message Claude Code..."
rows={1}
aria-label="Message"
className={cn(
"flex-1 resize-none bg-transparent text-sm text-surface-100",
"placeholder:text-surface-500 focus:outline-none",
"min-h-[24px] max-h-[200px] py-0.5"
)}
/>
{isStreaming ? (
<button
onClick={handleStop}
aria-label="Stop generation"
className="p-1.5 rounded-lg bg-surface-700 text-surface-300 hover:bg-surface-600 transition-colors flex-shrink-0"
>
<Square className="w-4 h-4" aria-hidden="true" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={!input.trim()}
aria-label="Send message"
aria-disabled={!input.trim()}
className={cn(
"p-1.5 rounded-lg transition-colors flex-shrink-0",
input.trim()
? "bg-brand-600 text-white hover:bg-brand-700"
: "bg-surface-700 text-surface-500 cursor-not-allowed"
)}
>
<Send className="w-4 h-4" aria-hidden="true" />
</button>
)}
</div>
<p className="text-xs text-surface-600 text-center mt-2">
Claude can make mistakes. Verify important information.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { useEffect } from "react";
import { useChatStore } from "@/lib/store";
import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
import { ChatWindow } from "./ChatWindow";
import { ChatInput } from "./ChatInput";
import { SkipToContent } from "@/components/a11y/SkipToContent";
import { AnnouncerProvider } from "@/components/a11y/Announcer";
export function ChatLayout() {
const { conversations, createConversation, activeConversationId } = useChatStore();
useEffect(() => {
if (conversations.length === 0) {
createConversation();
}
}, []);
return (
<AnnouncerProvider>
<SkipToContent />
<div className="flex h-screen bg-surface-950 text-surface-100">
<Sidebar />
<div className="flex flex-col flex-1 min-w-0">
<Header />
<main
id="main-content"
aria-label="Chat"
className="flex flex-col flex-1 min-h-0"
>
{activeConversationId ? (
<>
<ChatWindow conversationId={activeConversationId} />
<ChatInput conversationId={activeConversationId} />
</>
) : (
<div className="flex-1 flex items-center justify-center text-surface-500">
Select or create a conversation
</div>
)}
</main>
</div>
</div>
</AnnouncerProvider>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useChatStore } from "@/lib/store";
import { MessageBubble } from "./MessageBubble";
import { Bot } from "lucide-react";
interface ChatWindowProps {
conversationId: string;
}
export function ChatWindow({ conversationId }: ChatWindowProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const { conversations } = useChatStore();
const conversation = conversations.find((c) => c.id === conversationId);
const messages = conversation?.messages ?? [];
const isStreaming = messages.some((m) => m.status === "streaming");
// Announce the last completed assistant message to screen readers
const [announcement, setAnnouncement] = useState("");
const prevLengthRef = useRef(messages.length);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
const lastMsg = messages[messages.length - 1];
if (
messages.length > prevLengthRef.current &&
lastMsg?.role === "assistant" &&
lastMsg.status === "complete"
) {
// Announce a short preview so screen reader users know a reply arrived
const preview = lastMsg.content.slice(0, 100);
setAnnouncement("");
setTimeout(() => setAnnouncement(`Claude replied: ${preview}`), 50);
}
prevLengthRef.current = messages.length;
}, [messages.length, messages]);
if (messages.length === 0) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-center px-6">
<div
className="w-12 h-12 rounded-full bg-brand-600/20 flex items-center justify-center"
aria-hidden="true"
>
<Bot className="w-6 h-6 text-brand-400" aria-hidden="true" />
</div>
<div>
<h2 className="text-lg font-semibold text-surface-100">How can I help?</h2>
<p className="text-sm text-surface-400 mt-1">
Start a conversation with Claude Code
</p>
</div>
</div>
);
}
return (
<div
className="flex-1 overflow-y-auto"
aria-busy={isStreaming}
aria-label="Conversation"
>
{/* Polite live region — announces when Claude finishes a reply */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0,0,0,0)",
whiteSpace: "nowrap",
borderWidth: 0,
}}
>
{announcement}
</div>
<div className="max-w-3xl mx-auto py-6 px-4 space-y-6">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
<div ref={bottomRef} aria-hidden="true" />
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
interface MarkdownContentProps {
content: string;
className?: string;
}
export function MarkdownContent({ content, className }: MarkdownContentProps) {
return (
<div
className={cn(
"prose prose-sm prose-invert max-w-none",
"prose-p:leading-relaxed prose-p:my-1",
"prose-pre:bg-surface-900 prose-pre:border prose-pre:border-surface-700",
"prose-code:text-brand-300 prose-code:bg-surface-900 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs",
"prose-pre:code:bg-transparent prose-pre:code:text-surface-100 prose-pre:code:p-0",
"prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5",
"prose-headings:text-surface-100 prose-headings:font-semibold",
"prose-a:text-brand-400 prose-a:no-underline hover:prose-a:underline",
"prose-blockquote:border-brand-500 prose-blockquote:text-surface-300",
"prose-hr:border-surface-700",
className
)}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { User, Bot, AlertCircle } from "lucide-react";
import { cn, extractTextContent } from "@/lib/utils";
import type { Message } from "@/lib/types";
import { MarkdownContent } from "./MarkdownContent";
interface MessageBubbleProps {
message: Message;
}
export function MessageBubble({ message }: MessageBubbleProps) {
const isUser = message.role === "user";
const isError = message.status === "error";
const text = extractTextContent(message.content);
return (
<article
className={cn(
"flex gap-3 animate-fade-in",
isUser && "flex-row-reverse"
)}
aria-label={isUser ? "You" : isError ? "Error from Claude" : "Claude"}
>
{/* Avatar — purely decorative, role conveyed by article label */}
<div
aria-hidden="true"
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5",
isUser
? "bg-brand-600 text-white"
: isError
? "bg-red-900 text-red-300"
: "bg-surface-700 text-surface-300"
)}
>
{isUser ? (
<User className="w-4 h-4" aria-hidden="true" />
) : isError ? (
<AlertCircle className="w-4 h-4" aria-hidden="true" />
) : (
<Bot className="w-4 h-4" aria-hidden="true" />
)}
</div>
{/* Content */}
<div
className={cn(
"flex-1 min-w-0 max-w-2xl",
isUser && "flex justify-end"
)}
>
<div
className={cn(
"rounded-2xl px-4 py-3 text-sm",
isUser
? "bg-brand-600 text-white rounded-tr-sm"
: isError
? "bg-red-950 border border-red-800 text-red-200 rounded-tl-sm"
: "bg-surface-800 text-surface-100 rounded-tl-sm"
)}
>
{isUser ? (
<p className="whitespace-pre-wrap break-words">{text}</p>
) : (
<MarkdownContent content={text} />
)}
{message.status === "streaming" && (
<span
aria-hidden="true"
className="inline-block w-1.5 h-4 bg-current ml-0.5 animate-pulse-soft"
/>
)}
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useRef, useEffect, useCallback } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { Message } from "@/lib/types";
import { MessageBubble } from "./MessageBubble";
/**
* Estimated heights used for initial layout. The virtualizer measures actual
* heights after render and updates scroll positions accordingly.
*/
const ESTIMATED_HEIGHT = {
short: 80, // typical user message
medium: 160, // short assistant reply
tall: 320, // code blocks / long replies
};
function estimateMessageHeight(message: Message): number {
const text =
typeof message.content === "string"
? message.content
: message.content
.filter((b): b is { type: "text"; text: string } => b.type === "text")
.map((b) => b.text)
.join("");
if (text.length < 100) return ESTIMATED_HEIGHT.short;
if (text.length < 500 || text.includes("```")) return ESTIMATED_HEIGHT.medium;
return ESTIMATED_HEIGHT.tall;
}
interface VirtualMessageListProps {
messages: Message[];
/** Whether streaming is in progress — suppresses smooth-scroll so the
* autoscroll keeps up with incoming tokens. */
isStreaming: boolean;
}
export function VirtualMessageList({ messages, isStreaming }: VirtualMessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => scrollRef.current,
estimateSize: (index) => estimateMessageHeight(messages[index]),
overscan: 5,
});
// Track whether the user has scrolled away from the bottom
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
isAtBottomRef.current = distanceFromBottom < 80;
}, []);
// Auto-scroll to bottom when new messages arrive (if already at bottom)
useEffect(() => {
if (!isAtBottomRef.current) return;
const el = scrollRef.current;
if (!el) return;
if (isStreaming) {
// Instant scroll during streaming to keep up with tokens
el.scrollTop = el.scrollHeight;
} else {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}
}, [messages.length, isStreaming]);
// Also scroll when the last streaming message content changes
useEffect(() => {
if (!isStreaming || !isAtBottomRef.current) return;
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
});
const items = virtualizer.getVirtualItems();
return (
<div
ref={scrollRef}
className="flex-1 overflow-y-auto"
onScroll={handleScroll}
>
{/* Spacer that gives the virtualizer its total height */}
<div
style={{ height: virtualizer.getTotalSize(), position: "relative" }}
className="max-w-3xl mx-auto px-4 py-6"
>
{items.map((virtualItem) => {
const message = messages[virtualItem.index];
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
transform: `translateY(${virtualItem.start}px)`,
}}
className="pb-6"
>
<MessageBubble message={message} />
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { MessageSquare } from "lucide-react";
import { cn } from "@/lib/utils";
import { AnnotationThread } from "./AnnotationThread";
import { useCollaborationContextOptional } from "./CollaborationProvider";
interface AnnotationBadgeProps {
messageId: string;
}
export function AnnotationBadge({ messageId }: AnnotationBadgeProps) {
const ctx = useCollaborationContextOptional();
const [open, setOpen] = useState(false);
if (!ctx) return null;
const annotations = ctx.annotations[messageId] ?? [];
const unresolved = annotations.filter((a) => !a.resolved);
if (annotations.length === 0) return null;
return (
<div className="relative inline-block">
<button
onClick={() => setOpen((v) => !v)}
className={cn(
"flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium",
"transition-colors border",
unresolved.length > 0
? "bg-amber-900/30 border-amber-700/50 text-amber-300 hover:bg-amber-900/50"
: "bg-surface-800 border-surface-700 text-surface-400 hover:bg-surface-700"
)}
title={`${annotations.length} comment${annotations.length !== 1 ? "s" : ""}`}
>
<MessageSquare className="w-3 h-3" />
{unresolved.length > 0 ? unresolved.length : annotations.length}
</button>
{open && (
<div
className="absolute right-0 top-full mt-1 z-40 w-80"
onKeyDown={(e) => e.key === "Escape" && setOpen(false)}
>
<AnnotationThread messageId={messageId} onClose={() => setOpen(false)} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { createContext, useContext, useRef, useMemo } from "react";
import { useCollaboration } from "@/hooks/useCollaboration";
import { usePresence } from "@/hooks/usePresence";
import { CollabSocket } from "@/lib/collaboration/socket";
import type { CollabUser, CollabRole } from "@/lib/collaboration/socket";
import type { CollabAnnotation, PendingToolUse } from "@/lib/collaboration/types";
import type { PresenceState } from "@/lib/collaboration/presence";
import type { LinkExpiry, ShareLink } from "@/lib/collaboration/types";
import { createShareLink } from "@/lib/collaboration/permissions";
// ─── Context Shape ────────────────────────────────────────────────────────────
interface CollaborationContextValue {
// Connection
isConnected: boolean;
sessionId: string;
currentUser: CollabUser;
// Roles & policy
myRole: CollabRole | null;
toolApprovalPolicy: "owner-only" | "any-collaborator";
// Presence
presence: PresenceState;
otherUsers: CollabUser[];
typingUsers: CollabUser[];
// Tool approvals
pendingToolUses: PendingToolUse[];
approveTool: (toolUseId: string) => void;
denyTool: (toolUseId: string) => void;
// Annotations
annotations: Record<string, CollabAnnotation[]>;
addAnnotation: (messageId: string, text: string) => void;
resolveAnnotation: (annotationId: string, resolved: boolean) => void;
replyAnnotation: (annotationId: string, text: string) => void;
// Presence actions
sendCursorUpdate: (pos: number, start?: number, end?: number) => void;
notifyTyping: () => void;
stopTyping: () => void;
// Session management
generateShareLink: (role: CollabRole, expiry: LinkExpiry) => ShareLink;
revokeAccess: (userId: string) => void;
changeRole: (userId: string, role: CollabRole) => void;
transferOwnership: (userId: string) => void;
}
const CollaborationContext = createContext<CollaborationContextValue | null>(null);
// ─── Provider ─────────────────────────────────────────────────────────────────
interface CollaborationProviderProps {
sessionId: string;
currentUser: CollabUser;
wsUrl?: string;
children: React.ReactNode;
}
export function CollaborationProvider({
sessionId,
currentUser,
wsUrl,
children,
}: CollaborationProviderProps) {
const socketRef = useRef<CollabSocket | null>(null);
const collab = useCollaboration({ sessionId, currentUser, wsUrl });
// Access the socket from the ref (set by the hook internally)
// Since useCollaboration creates the socket internally, we expose a proxy
// via the presence hook's socket param by reaching into the hook return
const presence = usePresence({
socket: socketRef.current,
sessionId,
currentUser,
});
const generateShareLink = useMemo(
() => (role: CollabRole, expiry: LinkExpiry) =>
createShareLink(sessionId, role, expiry, currentUser.id),
[sessionId, currentUser.id]
);
const value: CollaborationContextValue = {
isConnected: collab.isConnected,
sessionId,
currentUser,
myRole: collab.myRole,
toolApprovalPolicy: collab.toolApprovalPolicy,
presence: presence.presence,
otherUsers: presence.otherUsers,
typingUsers: presence.typingUsers,
pendingToolUses: collab.pendingToolUses,
approveTool: collab.approveTool,
denyTool: collab.denyTool,
annotations: collab.annotations,
addAnnotation: collab.addAnnotation,
resolveAnnotation: collab.resolveAnnotation,
replyAnnotation: collab.replyAnnotation,
sendCursorUpdate: presence.sendCursorUpdate,
notifyTyping: presence.notifyTyping,
stopTyping: presence.stopTyping,
generateShareLink,
revokeAccess: collab.revokeAccess,
changeRole: collab.changeRole,
transferOwnership: collab.transferOwnership,
};
return (
<CollaborationContext.Provider value={value}>
{children}
</CollaborationContext.Provider>
);
}
// ─── Consumer Hook ────────────────────────────────────────────────────────────
export function useCollaborationContext(): CollaborationContextValue {
const ctx = useContext(CollaborationContext);
if (!ctx) {
throw new Error(
"useCollaborationContext must be used inside <CollaborationProvider>"
);
}
return ctx;
}
/**
* Returns null when there is no active collaboration session.
* Use this in components that render outside a CollaborationProvider.
*/
export function useCollaborationContextOptional(): CollaborationContextValue | null {
return useContext(CollaborationContext);
}

View File

@@ -0,0 +1,122 @@
"use client";
import { useRef, useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useCollaborationContextOptional } from "./CollaborationProvider";
import type { CursorState } from "@/lib/collaboration/presence";
import type { CollabUser } from "@/lib/collaboration/socket";
// ─── Types ────────────────────────────────────────────────────────────────────
interface CursorGhostProps {
/** The textarea ref to measure cursor positions against */
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
}
interface RenderedCursor {
user: CollabUser;
cursor: CursorState;
top: number;
left: number;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Approximates pixel position of a text offset inside a textarea.
* Uses a hidden mirror div that matches the textarea's styling.
*/
function measureCursorPosition(
textarea: HTMLTextAreaElement,
offset: number
): { top: number; left: number } {
const mirror = document.createElement("div");
const computed = window.getComputedStyle(textarea);
mirror.style.position = "absolute";
mirror.style.visibility = "hidden";
mirror.style.whiteSpace = "pre-wrap";
mirror.style.wordWrap = "break-word";
mirror.style.width = computed.width;
mirror.style.font = computed.font;
mirror.style.lineHeight = computed.lineHeight;
mirror.style.padding = computed.padding;
mirror.style.border = computed.border;
mirror.style.boxSizing = computed.boxSizing;
const text = textarea.value.slice(0, offset);
mirror.textContent = text;
const span = document.createElement("span");
span.textContent = "\u200b"; // zero-width space
mirror.appendChild(span);
document.body.appendChild(mirror);
const rect = textarea.getBoundingClientRect();
const spanRect = span.getBoundingClientRect();
document.body.removeChild(mirror);
return {
top: spanRect.top - rect.top + textarea.scrollTop,
left: spanRect.left - rect.left,
};
}
// ─── CursorGhost ─────────────────────────────────────────────────────────────
export function CursorGhost({ textareaRef }: CursorGhostProps) {
const ctx = useCollaborationContextOptional();
const [rendered, setRendered] = useState<RenderedCursor[]>([]);
useEffect(() => {
if (!ctx || !textareaRef.current) return;
const textarea = textareaRef.current;
const { presence, otherUsers } = ctx;
const next: RenderedCursor[] = [];
for (const user of otherUsers) {
const cursor = presence.cursors.get(user.id);
if (!cursor) continue;
try {
const pos = measureCursorPosition(textarea, cursor.position);
next.push({ user, cursor, ...pos });
} catch {
// ignore measurement errors
}
}
setRendered(next);
});
if (!ctx || rendered.length === 0) return null;
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<AnimatePresence>
{rendered.map(({ user, top, left }) => (
<motion.div
key={user.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute flex flex-col items-start"
style={{ top, left }}
>
{/* Cursor caret */}
<div
className="w-0.5 h-4"
style={{ backgroundColor: user.color }}
/>
{/* Name tag */}
<div
className="px-1 py-0.5 rounded text-[9px] font-semibold text-white whitespace-nowrap"
style={{ backgroundColor: user.color }}
>
{user.name}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,136 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import * as Tooltip from "@radix-ui/react-tooltip";
import { Wifi, WifiOff } from "lucide-react";
import { getInitials } from "@/lib/collaboration/presence";
import { labelForRole } from "@/lib/collaboration/permissions";
import { useCollaborationContextOptional } from "./CollaborationProvider";
import { cn } from "@/lib/utils";
// ─── Single Avatar ────────────────────────────────────────────────────────────
interface AvatarProps {
name: string;
color: string;
avatar?: string;
role: import("@/lib/collaboration/socket").CollabRole;
isActive?: boolean;
}
function UserAvatar({ name, color, avatar, role, isActive = true }: AvatarProps) {
return (
<Tooltip.Provider delayDuration={300}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div
className="relative w-7 h-7 rounded-full flex-shrink-0 cursor-default select-none"
style={{ boxShadow: `0 0 0 2px ${color}` }}
>
{avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatar}
alt={name}
className="w-full h-full rounded-full object-cover"
/>
) : (
<div
className="w-full h-full rounded-full flex items-center justify-center text-[10px] font-semibold text-white"
style={{ backgroundColor: color }}
>
{getInitials(name)}
</div>
)}
{/* Online indicator dot */}
{isActive && (
<span className="absolute bottom-0 right-0 w-2 h-2 rounded-full bg-green-400 border border-surface-900" />
)}
</div>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="bottom"
sideOffset={6}
className={cn(
"z-50 rounded-md px-2.5 py-1.5 text-xs shadow-md",
"bg-surface-800 border border-surface-700 text-surface-100"
)}
>
<p className="font-medium">{name}</p>
<p className="text-surface-400">{labelForRole(role)}</p>
<Tooltip.Arrow className="fill-surface-800" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}
// ─── PresenceAvatars ──────────────────────────────────────────────────────────
export function PresenceAvatars() {
const ctx = useCollaborationContextOptional();
if (!ctx) return null;
const { isConnected, otherUsers, currentUser } = ctx;
// Show at most 4 avatars + overflow badge
const MAX_VISIBLE = 4;
const allUsers = [currentUser, ...otherUsers];
const visible = allUsers.slice(0, MAX_VISIBLE);
const overflow = allUsers.length - MAX_VISIBLE;
return (
<div className="flex items-center gap-2">
{/* Connection indicator */}
<div className="flex items-center gap-1.5">
{isConnected ? (
<Wifi className="w-3.5 h-3.5 text-green-400" />
) : (
<WifiOff className="w-3.5 h-3.5 text-surface-500 animate-pulse" />
)}
<span className="text-xs text-surface-500 hidden sm:inline">
{isConnected
? `${allUsers.length} online`
: "Reconnecting…"}
</span>
</div>
{/* Stacked avatars */}
<div className="flex items-center">
<AnimatePresence>
{visible.map((user, i) => (
<motion.div
key={user.id}
initial={{ opacity: 0, scale: 0.5, x: -8 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.2, delay: i * 0.04 }}
style={{ zIndex: visible.length - i, marginLeft: i === 0 ? 0 : -8 }}
>
<UserAvatar
name={user.id === currentUser.id ? `${user.name} (you)` : user.name}
color={user.color}
avatar={user.avatar}
role={user.role}
/>
</motion.div>
))}
</AnimatePresence>
{overflow > 0 && (
<div
className={cn(
"w-7 h-7 rounded-full -ml-2 z-0 flex items-center justify-center",
"bg-surface-700 border-2 border-surface-900 text-[10px] font-medium text-surface-300"
)}
>
+{overflow}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useCollaborationContextOptional } from "./CollaborationProvider";
// ─── Animated dots ────────────────────────────────────────────────────────────
function Dots() {
return (
<span className="inline-flex items-end gap-0.5 h-3">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="w-1 h-1 rounded-full bg-surface-400 inline-block"
animate={{ y: [0, -3, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: i * 0.15,
ease: "easeInOut",
}}
/>
))}
</span>
);
}
// ─── TypingIndicator ──────────────────────────────────────────────────────────
export function TypingIndicator() {
const ctx = useCollaborationContextOptional();
if (!ctx) return null;
const { typingUsers } = ctx;
if (typingUsers.length === 0) return null;
let label: string;
if (typingUsers.length === 1) {
label = `${typingUsers[0].name} is typing`;
} else if (typingUsers.length === 2) {
label = `${typingUsers[0].name} and ${typingUsers[1].name} are typing`;
} else {
label = `${typingUsers.length} people are typing`;
}
return (
<AnimatePresence>
<motion.div
key="typing-indicator"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.15 }}
className="flex items-center gap-1.5 px-4 pb-1 text-xs text-surface-400"
>
{/* Colored dots for each typing user */}
<span className="flex -space-x-1">
{typingUsers.slice(0, 3).map((u) => (
<span
key={u.id}
className="w-4 h-4 rounded-full border border-surface-900 flex items-center justify-center text-[8px] font-bold text-white"
style={{ backgroundColor: u.color }}
>
{u.name[0].toUpperCase()}
</span>
))}
</span>
<span>{label}</span>
<Dots />
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,249 @@
"use client";
import { useEffect, useRef, useState, useMemo } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { Search, Clock } from "lucide-react";
import { useCommandRegistry } from "@/hooks/useCommandRegistry";
import { CommandPaletteItem } from "./CommandPaletteItem";
import { SHORTCUT_CATEGORIES } from "@/lib/shortcuts";
import type { Command, ShortcutCategory } from "@/lib/shortcuts";
import { cn } from "@/lib/utils";
/** Fuzzy match: every character of query must appear in order in target */
function fuzzyMatch(target: string, query: string): boolean {
if (!query) return true;
const t = target.toLowerCase();
const q = query.toLowerCase();
let qi = 0;
for (let i = 0; i < t.length && qi < q.length; i++) {
if (t[i] === q[qi]) qi++;
}
return qi === q.length;
}
/** Score a command against a search query (higher = better) */
function score(cmd: Command, query: string): number {
const q = query.toLowerCase();
const label = cmd.label.toLowerCase();
if (label === q) return 100;
if (label.startsWith(q)) return 80;
if (label.includes(q)) return 60;
if (cmd.description.toLowerCase().includes(q)) return 40;
if (fuzzyMatch(label, q)) return 20;
return 0;
}
interface GroupedResults {
label: string;
commands: Command[];
}
export function CommandPalette() {
const {
paletteOpen,
closePalette,
commands,
runCommand,
recentCommandIds,
openHelp,
} = useCommandRegistry();
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Reset state when palette opens
useEffect(() => {
if (paletteOpen) {
setQuery("");
setActiveIndex(0);
// Small delay to let the dialog animate in before focusing
setTimeout(() => inputRef.current?.focus(), 10);
}
}, [paletteOpen]);
const filteredGroups = useMemo<GroupedResults[]>(() => {
if (!query.trim()) {
// Show recents first, then all categories
const recentCmds = recentCommandIds
.map((id) => commands.find((c) => c.id === id))
.filter((c): c is Command => !!c);
const groups: GroupedResults[] = [];
if (recentCmds.length > 0) {
groups.push({ label: "Recent", commands: recentCmds });
}
for (const cat of SHORTCUT_CATEGORIES) {
const catCmds = commands.filter((c) => c.category === cat);
if (catCmds.length > 0) {
groups.push({ label: cat, commands: catCmds });
}
}
return groups;
}
// Search mode: flat scored list, re-grouped by category
const scored = commands
.map((cmd) => ({ cmd, s: score(cmd, query) }))
.filter(({ s }) => s > 0)
.sort((a, b) => b.s - a.s)
.map(({ cmd }) => cmd);
if (scored.length === 0) return [];
const byCategory: Partial<Record<ShortcutCategory, Command[]>> = {};
for (const cmd of scored) {
if (!byCategory[cmd.category]) byCategory[cmd.category] = [];
byCategory[cmd.category]!.push(cmd);
}
return SHORTCUT_CATEGORIES.filter((c) => byCategory[c]?.length).map(
(c) => ({ label: c, commands: byCategory[c]! })
);
}, [query, commands, recentCommandIds]);
const flatResults = useMemo(
() => filteredGroups.flatMap((g) => g.commands),
[filteredGroups]
);
// Clamp activeIndex when results change
useEffect(() => {
setActiveIndex((i) => Math.min(i, Math.max(flatResults.length - 1, 0)));
}, [flatResults.length]);
// Scroll active item into view
useEffect(() => {
const el = listRef.current?.querySelector(`[data-index="${activeIndex}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, flatResults.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const cmd = flatResults[activeIndex];
if (cmd) {
closePalette();
runCommand(cmd.id);
}
}
};
const handleSelect = (cmd: Command) => {
closePalette();
runCommand(cmd.id);
};
let flatIdx = 0;
return (
<Dialog.Root open={paletteOpen} onOpenChange={(open) => !open && closePalette()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content
className={cn(
"fixed left-1/2 top-[20%] -translate-x-1/2 z-50",
"w-full max-w-xl",
"bg-surface-900 border border-surface-700 rounded-xl shadow-2xl",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"data-[state=closed]:slide-out-to-left-1/2 data-[state=open]:slide-in-from-left-1/2",
"data-[state=closed]:slide-out-to-top-[18%] data-[state=open]:slide-in-from-top-[18%]"
)}
onKeyDown={handleKeyDown}
aria-label="Command palette"
>
{/* Search input */}
<div className="flex items-center gap-2 px-3 py-3 border-b border-surface-800">
<Search className="w-4 h-4 text-surface-500 flex-shrink-0" />
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActiveIndex(0);
}}
placeholder="Search commands..."
className={cn(
"flex-1 bg-transparent text-sm text-surface-100",
"placeholder:text-surface-500 focus:outline-none"
)}
/>
<kbd className="hidden sm:inline-flex items-center h-5 px-1.5 rounded text-[10px] font-mono bg-surface-800 border border-surface-700 text-surface-500">
Esc
</kbd>
</div>
{/* Results */}
<div
ref={listRef}
role="listbox"
className="overflow-y-auto max-h-[360px] py-1"
>
{filteredGroups.length === 0 ? (
<div className="py-10 text-center text-sm text-surface-500">
No commands found
</div>
) : (
filteredGroups.map((group) => (
<div key={group.label}>
<div className="flex items-center gap-2 px-3 py-1.5">
{group.label === "Recent" && (
<Clock className="w-3 h-3 text-surface-600" />
)}
<span className="text-[10px] font-semibold uppercase tracking-wider text-surface-600">
{group.label}
</span>
</div>
{group.commands.map((cmd) => {
const idx = flatIdx++;
return (
<div key={cmd.id} data-index={idx}>
<CommandPaletteItem
command={cmd}
isActive={idx === activeIndex}
onSelect={() => handleSelect(cmd)}
onHighlight={() => setActiveIndex(idx)}
/>
</div>
);
})}
</div>
))
)}
</div>
{/* Footer */}
<div className="flex items-center gap-4 px-3 py-2 border-t border-surface-800 text-[10px] text-surface-600">
<span className="flex items-center gap-1">
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono"></kbd>
navigate
</span>
<span className="flex items-center gap-1">
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono"></kbd>
select
</span>
<span className="flex items-center gap-1">
<kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 font-mono">Esc</kbd>
close
</span>
<button
onClick={() => { closePalette(); openHelp(); }}
className="ml-auto hover:text-surface-300 transition-colors"
>
? View all shortcuts
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import {
MessageSquarePlus,
Trash2,
Settings,
Sun,
Search,
HelpCircle,
PanelLeftClose,
ChevronRight,
Zap,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { ShortcutBadge } from "@/components/shortcuts/ShortcutBadge";
import type { Command } from "@/lib/shortcuts";
const ICON_MAP: Record<string, LucideIcon> = {
MessageSquarePlus,
Trash2,
Settings,
Sun,
Search,
HelpCircle,
PanelLeftClose,
ChevronRight,
Zap,
};
interface CommandPaletteItemProps {
command: Command;
isActive: boolean;
onSelect: () => void;
onHighlight: () => void;
}
export function CommandPaletteItem({
command,
isActive,
onSelect,
onHighlight,
}: CommandPaletteItemProps) {
const Icon = command.icon ? (ICON_MAP[command.icon] ?? ChevronRight) : ChevronRight;
return (
<div
role="option"
aria-selected={isActive}
onClick={onSelect}
onMouseEnter={onHighlight}
className={cn(
"flex items-center gap-3 px-3 py-2.5 cursor-pointer select-none",
"transition-colors",
isActive ? "bg-brand-600/20 text-surface-100" : "text-surface-300 hover:bg-surface-800"
)}
>
<span
className={cn(
"flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center",
isActive ? "bg-brand-600/30 text-brand-400" : "bg-surface-800 text-surface-500"
)}
>
<Icon className="w-3.5 h-3.5" />
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{command.label}</p>
{command.description && (
<p className="text-xs text-surface-500 truncate">{command.description}</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span
className={cn(
"text-[10px] px-1.5 py-0.5 rounded border font-medium",
isActive
? "border-brand-600/40 text-brand-400 bg-brand-600/10"
: "border-surface-700 text-surface-600 bg-surface-800"
)}
>
{command.category}
</span>
{command.keys.length > 0 && <ShortcutBadge keys={command.keys} />}
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import * as Switch from "@radix-ui/react-switch";
import type { ExportOptions, ExportFormat } from "@/lib/types";
import { cn } from "@/lib/utils";
interface OptionRowProps {
id: string;
label: string;
description?: string;
checked: boolean;
onCheckedChange: (v: boolean) => void;
disabled?: boolean;
}
function OptionRow({ id, label, description, checked, onCheckedChange, disabled }: OptionRowProps) {
return (
<div className={cn("flex items-center justify-between gap-4 py-2", disabled && "opacity-40")}>
<label htmlFor={id} className={cn("flex flex-col gap-0.5", !disabled && "cursor-pointer")}>
<span className="text-sm text-surface-200">{label}</span>
{description && (
<span className="text-xs text-surface-500">{description}</span>
)}
</label>
<Switch.Root
id={id}
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
className={cn(
"w-9 h-5 rounded-full transition-colors outline-none cursor-pointer",
"data-[state=checked]:bg-brand-600 data-[state=unchecked]:bg-surface-700",
"disabled:cursor-not-allowed"
)}
>
<Switch.Thumb className="block w-4 h-4 bg-white rounded-full shadow transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5" />
</Switch.Root>
</div>
);
}
interface ExportOptionsProps {
options: ExportOptions;
onChange: (opts: Partial<ExportOptions>) => void;
}
export function ExportOptionsPanel({ options, onChange }: ExportOptionsProps) {
const isJson = options.format === "json";
return (
<div className="divide-y divide-surface-800">
<OptionRow
id="opt-tool-use"
label="Include tool use"
description="Show tool calls and results in the export"
checked={options.includeToolUse}
onCheckedChange={(v) => onChange({ includeToolUse: v })}
/>
<OptionRow
id="opt-thinking"
label="Include thinking blocks"
description="Show extended thinking when present"
checked={options.includeThinking}
onCheckedChange={(v) => onChange({ includeThinking: v })}
disabled={isJson}
/>
<OptionRow
id="opt-timestamps"
label="Include timestamps"
description="Add date/time to messages and metadata"
checked={options.includeTimestamps}
onCheckedChange={(v) => onChange({ includeTimestamps: v })}
/>
<OptionRow
id="opt-file-contents"
label="Include full file contents"
description="Show complete tool result output (may be large)"
checked={options.includeFileContents}
onCheckedChange={(v) => onChange({ includeFileContents: v })}
/>
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { FileText, Braces, Globe, FileDown, AlignLeft } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ExportFormat } from "@/lib/types";
interface FormatOption {
value: ExportFormat;
label: string;
description: string;
icon: React.ReactNode;
}
const FORMATS: FormatOption[] = [
{
value: "markdown",
label: "Markdown",
description: "Clean .md with code blocks and metadata",
icon: <FileText className="w-4 h-4" />,
},
{
value: "json",
label: "JSON",
description: "Full conversation data with tool use",
icon: <Braces className="w-4 h-4" />,
},
{
value: "html",
label: "HTML",
description: "Self-contained file with embedded styles",
icon: <Globe className="w-4 h-4" />,
},
{
value: "pdf",
label: "PDF",
description: "Print-to-PDF via browser dialog",
icon: <FileDown className="w-4 h-4" />,
},
{
value: "plaintext",
label: "Plain Text",
description: "Stripped of all formatting",
icon: <AlignLeft className="w-4 h-4" />,
},
];
interface FormatSelectorProps {
value: ExportFormat;
onChange: (format: ExportFormat) => void;
}
export function FormatSelector({ value, onChange }: FormatSelectorProps) {
return (
<div className="grid grid-cols-5 gap-2">
{FORMATS.map((fmt) => (
<button
key={fmt.value}
onClick={() => onChange(fmt.value)}
className={cn(
"flex flex-col items-center gap-1.5 px-2 py-3 rounded-lg border text-center transition-colors",
value === fmt.value
? "border-brand-500 bg-brand-500/10 text-brand-300"
: "border-surface-700 bg-surface-800 text-surface-400 hover:border-surface-600 hover:text-surface-200"
)}
title={fmt.description}
>
{fmt.icon}
<span className="text-xs font-medium leading-none">{fmt.label}</span>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { Copy, Check, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
interface FileBreadcrumbProps {
path: string;
className?: string;
}
export function FileBreadcrumb({ path, className }: FileBreadcrumbProps) {
const [copied, setCopied] = useState(false);
const segments = path.split("/").filter(Boolean);
const isAbsolute = path.startsWith("/");
const handleCopy = async () => {
await navigator.clipboard.writeText(path);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const openInVSCode = () => {
window.open(`vscode://file${isAbsolute ? path : `/${path}`}`);
};
return (
<div
className={cn(
"flex items-center gap-1 px-3 py-1.5 border-b border-surface-800 bg-surface-900/80",
"text-xs text-surface-400 min-w-0",
className
)}
>
{/* Segments */}
<div className="flex items-center gap-0.5 flex-1 min-w-0 overflow-hidden">
{isAbsolute && (
<span className="text-surface-600 flex-shrink-0">/</span>
)}
{segments.map((seg, i) => (
<span key={i} className="flex items-center gap-0.5 flex-shrink-0">
{i > 0 && <span className="text-surface-700 mx-0.5">/</span>}
<span
className={cn(
"truncate max-w-[120px]",
i === segments.length - 1
? "text-surface-200 font-medium"
: "text-surface-500 hover:text-surface-300 cursor-pointer transition-colors"
)}
title={seg}
>
{seg}
</span>
</span>
))}
</div>
{/* Actions */}
<div className="flex items-center gap-0.5 flex-shrink-0">
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-surface-800 transition-colors"
title="Copy path"
>
{copied ? (
<Check className="w-3 h-3 text-green-400" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
<button
onClick={openInVSCode}
className="p-1 rounded hover:bg-surface-800 transition-colors"
title="Open in VS Code"
>
<ExternalLink className="w-3 h-3" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { Eye, Edit3, GitCompare } from "lucide-react";
import { useFileViewerStore, type FileTab, type FileViewMode } from "@/lib/fileViewerStore";
import { cn } from "@/lib/utils";
interface FileInfoBarProps {
tab: FileTab;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const LANGUAGE_LABELS: Record<string, string> = {
typescript: "TypeScript",
tsx: "TSX",
javascript: "JavaScript",
jsx: "JSX",
python: "Python",
rust: "Rust",
go: "Go",
css: "CSS",
scss: "SCSS",
html: "HTML",
json: "JSON",
markdown: "Markdown",
bash: "Bash",
yaml: "YAML",
toml: "TOML",
sql: "SQL",
graphql: "GraphQL",
ruby: "Ruby",
java: "Java",
c: "C",
cpp: "C++",
csharp: "C#",
php: "PHP",
swift: "Swift",
kotlin: "Kotlin",
dockerfile: "Dockerfile",
makefile: "Makefile",
text: "Plain Text",
};
const VIEW_MODES: { mode: FileViewMode; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ mode: "view", label: "View", icon: Eye },
{ mode: "edit", label: "Edit", icon: Edit3 },
{ mode: "diff", label: "Diff", icon: GitCompare },
];
export function FileInfoBar({ tab }: FileInfoBarProps) {
const { setMode } = useFileViewerStore();
const lineCount = tab.content.split("\n").length;
const byteSize = new TextEncoder().encode(tab.content).length;
const langLabel = LANGUAGE_LABELS[tab.language] ?? tab.language;
return (
<div className="flex items-center justify-between px-3 py-1 border-t border-surface-800 bg-surface-950 text-xs text-surface-500">
{/* Left: file stats */}
<div className="flex items-center gap-3">
<span className="text-surface-400">{langLabel}</span>
<span>UTF-8</span>
<span>{lineCount.toLocaleString()} lines</span>
<span>{formatBytes(byteSize)}</span>
{tab.isDirty && (
<span className="text-yellow-500"> Unsaved changes</span>
)}
</div>
{/* Right: mode switcher */}
{!tab.isImage && (
<div className="flex items-center gap-0.5 bg-surface-900 rounded px-1 py-0.5">
{VIEW_MODES.map(({ mode, label, icon: Icon }) => (
<button
key={mode}
onClick={() => setMode(tab.id, mode)}
className={cn(
"flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] transition-colors",
tab.mode === mode
? "bg-surface-700 text-surface-100"
: "text-surface-500 hover:text-surface-300"
)}
disabled={mode === "diff" && !tab.diff}
title={mode === "diff" && !tab.diff ? "No diff available" : label}
>
<Icon className="w-3 h-3" />
{label}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { useState, useRef } from "react";
import { ZoomIn, ZoomOut, Maximize2, Image as ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface ImageViewerProps {
src: string;
path: string;
}
export function ImageViewer({ src, path }: ImageViewerProps) {
const [zoom, setZoom] = useState(1);
const [fitMode, setFitMode] = useState<"fit" | "actual">("fit");
const [error, setError] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const handleZoomIn = () => setZoom((z) => Math.min(z * 1.25, 8));
const handleZoomOut = () => setZoom((z) => Math.max(z / 1.25, 0.1));
const handleFitToggle = () => {
setFitMode((m) => (m === "fit" ? "actual" : "fit"));
setZoom(1);
};
const isSvg = path.endsWith(".svg");
const hasTransparency = path.endsWith(".png") || path.endsWith(".gif") ||
path.endsWith(".webp") || path.endsWith(".svg");
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-surface-500">
<ImageIcon className="w-10 h-10" />
<p className="text-sm">Failed to load image</p>
<p className="text-xs text-surface-600">{path}</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center gap-1 px-2 py-1 border-b border-surface-800 bg-surface-900/50">
<button
onClick={handleZoomOut}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
title="Zoom out"
>
<ZoomOut className="w-3.5 h-3.5" />
</button>
<span className="text-xs text-surface-400 w-12 text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
title="Zoom in"
>
<ZoomIn className="w-3.5 h-3.5" />
</button>
<div className="w-px h-4 bg-surface-800 mx-1" />
<button
onClick={handleFitToggle}
className={cn(
"flex items-center gap-1 px-2 py-0.5 rounded text-xs transition-colors",
fitMode === "fit"
? "text-brand-400 bg-brand-900/30"
: "text-surface-500 hover:text-surface-200 hover:bg-surface-800"
)}
title={fitMode === "fit" ? "Switch to actual size" : "Switch to fit width"}
>
<Maximize2 className="w-3 h-3" />
{fitMode === "fit" ? "Fit" : "Actual"}
</button>
</div>
{/* Image container */}
<div
ref={containerRef}
className="flex-1 overflow-auto flex items-center justify-center p-4"
style={{
backgroundImage: hasTransparency
? "linear-gradient(45deg, #3f3f46 25%, transparent 25%), linear-gradient(-45deg, #3f3f46 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #3f3f46 75%), linear-gradient(-45deg, transparent 75%, #3f3f46 75%)"
: undefined,
backgroundSize: hasTransparency ? "16px 16px" : undefined,
backgroundPosition: hasTransparency ? "0 0, 0 8px, 8px -8px, -8px 0px" : undefined,
backgroundColor: hasTransparency ? "#27272a" : "#1a1a1e",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={path.split("/").pop()}
onError={() => setError(true)}
style={{
transform: `scale(${zoom})`,
transformOrigin: "center center",
maxWidth: fitMode === "fit" ? "100%" : "none",
maxHeight: fitMode === "fit" ? "100%" : "none",
imageRendering: zoom > 2 ? "pixelated" : "auto",
}}
className="transition-transform duration-150 shadow-lg"
draggable={false}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,250 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { X, ChevronUp, ChevronDown, Regex, CaseSensitive } from "lucide-react";
import { cn } from "@/lib/utils";
interface SearchBarProps {
content: string;
containerRef: React.RefObject<HTMLDivElement>;
onClose: () => void;
}
export function SearchBar({ content, containerRef, onClose }: SearchBarProps) {
const [query, setQuery] = useState("");
const [isRegex, setIsRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
const [currentMatch, setCurrentMatch] = useState(0);
const [totalMatches, setTotalMatches] = useState(0);
const [hasError, setHasError] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
// Compute matches
useEffect(() => {
if (!query) {
setTotalMatches(0);
setCurrentMatch(0);
clearHighlights();
return;
}
try {
const flags = caseSensitive ? "g" : "gi";
const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags);
const matches = Array.from(content.matchAll(pattern));
setTotalMatches(matches.length);
setCurrentMatch(matches.length > 0 ? 1 : 0);
setHasError(false);
} catch {
setHasError(true);
setTotalMatches(0);
setCurrentMatch(0);
}
}, [query, isRegex, caseSensitive, content]);
// Apply DOM highlights
useEffect(() => {
if (!containerRef.current) return;
clearHighlights();
if (!query || hasError || totalMatches === 0) return;
try {
const flags = caseSensitive ? "g" : "gi";
const pattern = isRegex ? new RegExp(query, flags) : new RegExp(escapeRegex(query), flags);
const walker = document.createTreeWalker(
containerRef.current,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
// Skip nodes inside already-marked elements
if ((node.parentElement as HTMLElement)?.tagName === "MARK") {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
}
);
const textNodes: Text[] = [];
let node: Text | null;
while ((node = walker.nextNode() as Text | null)) {
textNodes.push(node);
}
let matchIdx = 0;
// Process in reverse order to avoid position shifting
const replacements: { node: Text; ranges: { start: number; end: number; idx: number }[] }[] = [];
for (const textNode of textNodes) {
const text = textNode.textContent ?? "";
pattern.lastIndex = 0;
const nodeRanges: { start: number; end: number; idx: number }[] = [];
let m: RegExpExecArray | null;
while ((m = pattern.exec(text)) !== null) {
nodeRanges.push({ start: m.index, end: m.index + m[0].length, idx: matchIdx++ });
if (m[0].length === 0) break; // prevent infinite loop on zero-width matches
}
if (nodeRanges.length > 0) {
replacements.push({ node: textNode, ranges: nodeRanges });
}
}
// Apply replacements in document order but process ranges in reverse
for (const { node: textNode, ranges } of replacements) {
const text = textNode.textContent ?? "";
const fragment = document.createDocumentFragment();
let lastEnd = 0;
for (const { start, end, idx } of ranges) {
if (start > lastEnd) {
fragment.appendChild(document.createTextNode(text.slice(lastEnd, start)));
}
const mark = document.createElement("mark");
mark.className = cn(
"search-highlight",
idx === currentMatch - 1 ? "search-highlight-current" : ""
);
mark.textContent = text.slice(start, end);
fragment.appendChild(mark);
lastEnd = end;
}
if (lastEnd < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastEnd)));
}
textNode.parentNode?.replaceChild(fragment, textNode);
}
// Scroll current match into view
const currentEl = containerRef.current?.querySelector(".search-highlight-current");
currentEl?.scrollIntoView({ block: "center", behavior: "smooth" });
} catch {
// Ignore DOM errors
}
return () => {
if (containerRef.current) clearHighlights();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, isRegex, caseSensitive, currentMatch, totalMatches]);
function clearHighlights() {
if (!containerRef.current) return;
const marks = containerRef.current.querySelectorAll("mark.search-highlight");
marks.forEach((mark) => {
mark.replaceWith(mark.textContent ?? "");
});
// Normalize text nodes
containerRef.current.normalize();
}
const goNext = () => {
setCurrentMatch((c) => (c >= totalMatches ? 1 : c + 1));
};
const goPrev = () => {
setCurrentMatch((c) => (c <= 1 ? totalMatches : c - 1));
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.shiftKey ? goPrev() : goNext();
}
if (e.key === "Escape") {
clearHighlights();
onClose();
}
};
return (
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-surface-800 bg-surface-900/90 backdrop-blur-sm">
<div
className={cn(
"flex items-center gap-1 flex-1 bg-surface-800 rounded px-2 py-1",
hasError && "ring-1 ring-red-500/50"
)}
>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search..."
className="flex-1 bg-transparent text-xs text-surface-100 placeholder-surface-500 outline-none min-w-0"
/>
{/* Match count */}
{query && (
<span className={cn(
"text-xs flex-shrink-0",
hasError ? "text-red-400" : totalMatches === 0 ? "text-red-400" : "text-surface-400"
)}>
{hasError ? "Invalid regex" : totalMatches === 0 ? "No results" : `${currentMatch}/${totalMatches}`}
</span>
)}
{/* Toggles */}
<button
onClick={() => setCaseSensitive((v) => !v)}
className={cn(
"p-0.5 rounded transition-colors flex-shrink-0",
caseSensitive
? "text-brand-400 bg-brand-900/40"
: "text-surface-500 hover:text-surface-300"
)}
title="Case sensitive"
>
<CaseSensitive className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setIsRegex((v) => !v)}
className={cn(
"p-0.5 rounded transition-colors flex-shrink-0",
isRegex
? "text-brand-400 bg-brand-900/40"
: "text-surface-500 hover:text-surface-300"
)}
title="Use regular expression"
>
<Regex className="w-3.5 h-3.5" />
</button>
</div>
{/* Navigation */}
<button
onClick={goPrev}
disabled={totalMatches === 0}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 disabled:opacity-30 transition-colors"
title="Previous match (Shift+Enter)"
>
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button
onClick={goNext}
disabled={totalMatches === 0}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 disabled:opacity-30 transition-colors"
title="Next match (Enter)"
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
<button
onClick={() => { clearHighlights(); onClose(); }}
className="p-1 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
title="Close (Escape)"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,64 @@
"use client";
import { Sun, Moon, Monitor } from "lucide-react";
import { useTheme } from "./ThemeProvider";
import { useChatStore } from "@/lib/store";
import { MODELS } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { NotificationCenter } from "@/components/notifications/NotificationCenter";
export function Header() {
const { theme, setTheme } = useTheme();
const { settings, updateSettings } = useChatStore();
const themeIcons = {
light: Sun,
dark: Moon,
system: Monitor,
} as const;
const ThemeIcon = themeIcons[theme];
const nextTheme = theme === "dark" ? "light" : theme === "light" ? "system" : "dark";
return (
<header className="flex items-center justify-between px-4 py-2.5 border-b border-surface-800 bg-surface-900/50 backdrop-blur-sm">
<div className="flex items-center gap-3">
<h1 className="text-sm font-medium text-surface-100">Chat</h1>
</div>
<div className="flex items-center gap-2">
{/* Model selector */}
<label htmlFor="model-select" className="sr-only">
Model
</label>
<select
id="model-select"
value={settings.model}
onChange={(e) => updateSettings({ model: e.target.value })}
className={cn(
"text-xs bg-surface-800 border border-surface-700 rounded-md px-2 py-1",
"text-surface-300 focus:outline-none focus:ring-1 focus:ring-brand-500"
)}
>
{MODELS.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
</select>
{/* Notification center */}
<NotificationCenter />
{/* Theme toggle */}
<button
onClick={() => setTheme(nextTheme)}
aria-label={`Switch to ${nextTheme} theme`}
className="p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
>
<ThemeIcon className="w-4 h-4" aria-hidden="true" />
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { MessageSquare, FolderOpen, Settings, ChevronLeft, ChevronRight } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { cn } from "@/lib/utils";
import { ChatHistory } from "./ChatHistory";
import { FileExplorer } from "./FileExplorer";
import { QuickActions } from "./QuickActions";
const MIN_WIDTH = 200;
const MAX_WIDTH = 480;
const COLLAPSED_WIDTH = 60;
type SidebarTab = "chats" | "history" | "files" | "settings";
const TABS: Array<{ id: SidebarTab; icon: React.ElementType; label: string }> = [
{ id: "chats", icon: MessageSquare, label: "Chats" },
{ id: "files", icon: FolderOpen, label: "Files" },
{ id: "settings", icon: Settings, label: "Settings" },
];
export function Sidebar() {
const {
sidebarOpen,
sidebarWidth,
sidebarTab,
toggleSidebar,
setSidebarWidth,
setSidebarTab,
openSettings,
} = useChatStore();
const [isResizing, setIsResizing] = useState(false);
const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
const startResize = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
resizeRef.current = { startX: e.clientX, startWidth: sidebarWidth };
setIsResizing(true);
},
[sidebarWidth]
);
useEffect(() => {
if (!isResizing) return;
const onMove = (e: MouseEvent) => {
if (!resizeRef.current) return;
const delta = e.clientX - resizeRef.current.startX;
const next = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, resizeRef.current.startWidth + delta));
setSidebarWidth(next);
};
const onUp = () => setIsResizing(false);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [isResizing, setSidebarWidth]);
// Global keyboard shortcut: Cmd/Ctrl+B
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "b") {
e.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [toggleSidebar]);
const handleTabClick = (id: SidebarTab) => {
if (id === "settings") {
openSettings();
return;
}
if (!sidebarOpen) toggleSidebar();
setSidebarTab(id);
};
return (
<motion.aside
className={cn(
"hidden md:flex flex-col h-full bg-surface-900 border-r border-surface-800",
"relative flex-shrink-0 z-20",
isResizing && "select-none"
)}
animate={{ width: sidebarOpen ? sidebarWidth : COLLAPSED_WIDTH }}
transition={{ duration: 0.2, ease: "easeInOut" }}
aria-label="Navigation sidebar"
>
{/* Top bar: app name + tabs + collapse toggle */}
<div
className={cn(
"flex border-b border-surface-800 flex-shrink-0",
sidebarOpen ? "flex-row items-center" : "flex-col items-center py-2 gap-1"
)}
>
{sidebarOpen && (
<span className="flex-1 text-sm font-semibold text-surface-100 px-4 py-3 truncate">
Claude Code
</span>
)}
<div
className={cn(
"flex",
sidebarOpen
? "flex-row items-center gap-0.5 pr-1 py-1.5"
: "flex-col w-full px-1.5 gap-0.5"
)}
>
{TABS.map(({ id, icon: Icon, label }) => (
<button
key={id}
onClick={() => handleTabClick(id)}
title={label}
aria-label={label}
className={cn(
"flex items-center gap-2 rounded-md text-xs font-medium transition-colors",
sidebarOpen ? "px-2.5 py-1.5" : "w-full justify-center px-0 py-2",
sidebarOpen && sidebarTab === id && id !== "settings"
? "bg-surface-800 text-surface-100"
: "text-surface-500 hover:text-surface-300 hover:bg-surface-800/60"
)}
>
<Icon className="w-4 h-4 flex-shrink-0" aria-hidden="true" />
{sidebarOpen && <span>{label}</span>}
</button>
))}
</div>
<button
onClick={toggleSidebar}
title={sidebarOpen ? "Collapse sidebar (⌘B)" : "Expand sidebar (⌘B)"}
aria-label={sidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
className={cn(
"p-2 rounded-md text-surface-500 hover:text-surface-300 hover:bg-surface-800/60 transition-colors",
sidebarOpen ? "mr-1" : "my-0.5"
)}
>
{sidebarOpen ? (
<ChevronLeft className="w-4 h-4" aria-hidden="true" />
) : (
<ChevronRight className="w-4 h-4" aria-hidden="true" />
)}
</button>
</div>
{/* Tab content */}
<AnimatePresence mode="wait">
{sidebarOpen && (
<motion.div
key={sidebarTab}
className="flex-1 flex flex-col min-h-0 overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
>
{(sidebarTab === "chats" || sidebarTab === "history") && <ChatHistory />}
{sidebarTab === "files" && <FileExplorer />}
</motion.div>
)}
</AnimatePresence>
{sidebarOpen && <QuickActions />}
{/* Drag-to-resize handle */}
{sidebarOpen && (
<div
onMouseDown={startResize}
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
className={cn(
"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-10 transition-colors",
"hover:bg-brand-500/40",
isResizing && "bg-brand-500/60"
)}
/>
)}
</motion.aside>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { PanelLeft } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { cn } from "@/lib/utils";
interface SidebarToggleProps {
className?: string;
}
export function SidebarToggle({ className }: SidebarToggleProps) {
const { sidebarOpen, toggleSidebar } = useChatStore();
return (
<button
onClick={toggleSidebar}
title={sidebarOpen ? "Close sidebar (⌘B)" : "Open sidebar (⌘B)"}
aria-label={sidebarOpen ? "Close sidebar" : "Open sidebar"}
aria-expanded={sidebarOpen}
className={cn(
"p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500",
className
)}
>
<PanelLeft className="w-4 h-4" aria-hidden="true" />
</button>
);
}

View File

@@ -0,0 +1,57 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { useChatStore } from "@/lib/store";
type Theme = "light" | "dark" | "system";
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: "light" | "dark";
}
const ThemeContext = createContext<ThemeContextValue>({
theme: "dark",
setTheme: () => {},
resolvedTheme: "dark",
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const { settings, updateSettings } = useChatStore();
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("dark");
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const resolve = () => {
if (settings.theme === "system") {
return mediaQuery.matches ? "dark" : "light";
}
return settings.theme;
};
const apply = () => {
const resolved = resolve();
setResolvedTheme(resolved);
// Dark is the default; add `.light` class for light theme
document.documentElement.classList.toggle("light", resolved === "light");
};
apply();
mediaQuery.addEventListener("change", apply);
return () => mediaQuery.removeEventListener("change", apply);
}, [settings.theme]);
const setTheme = (theme: Theme) => {
updateSettings({ theme });
};
return (
<ThemeContext.Provider value={{ theme: settings.theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);

View File

@@ -0,0 +1,100 @@
"use client";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { useTouchGesture } from "@/hooks/useTouchGesture";
interface BottomSheetProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
className?: string;
}
/**
* iOS-style bottom sheet.
* - Slides up from the bottom of the screen
* - Swipe down on the drag handle or sheet body to close
* - Tap backdrop to close
* - Locks body scroll while open
*/
export function BottomSheet({ isOpen, onClose, title, children, className }: BottomSheetProps) {
const sheetRef = useRef<HTMLDivElement>(null);
const swipeHandlers = useTouchGesture({ onSwipeDown: onClose });
// Lock body scroll while open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isOpen, onClose]);
return (
<>
{/* Backdrop */}
<div
className={cn(
"fixed inset-0 z-40 bg-black/60",
"transition-opacity duration-300",
isOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
)}
onClick={onClose}
aria-hidden="true"
/>
{/* Sheet */}
<div
ref={sheetRef}
role="dialog"
aria-modal="true"
aria-label={title ?? "Options"}
className={cn(
"fixed bottom-0 left-0 right-0 z-50",
"bg-surface-900 border-t border-surface-800 rounded-t-2xl",
"max-h-[85dvh] flex flex-col",
"transition-transform duration-300 ease-out",
isOpen ? "translate-y-0" : "translate-y-full",
className
)}
{...swipeHandlers}
>
{/* Drag handle */}
<div className="flex justify-center pt-3 pb-1 flex-shrink-0 cursor-grab active:cursor-grabbing">
<div className="w-10 h-1 bg-surface-600 rounded-full" />
</div>
{/* Optional title */}
{title && (
<div className="px-4 pb-2 flex-shrink-0">
<h2 className="text-sm font-semibold text-surface-100 text-center">{title}</h2>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto overscroll-contain">
{children}
</div>
{/* iOS safe area bottom padding */}
<div className="pb-safe flex-shrink-0" style={{ paddingBottom: "env(safe-area-inset-bottom)" }} />
</div>
</>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { useEffect } from "react";
import { X, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTouchGesture } from "@/hooks/useTouchGesture";
interface MobileFileViewerProps {
isOpen: boolean;
onClose: () => void;
fileName?: string;
children?: React.ReactNode;
className?: string;
}
/**
* Full-screen file viewer overlay for mobile.
* - Slides up from the bottom on open
* - Swipe down to close
* - Back/close button in header
* - Content area is scrollable with pinch-to-zoom enabled on images
*/
export function MobileFileViewer({
isOpen,
onClose,
fileName,
children,
className,
}: MobileFileViewerProps) {
const swipeHandlers = useTouchGesture({ onSwipeDown: onClose, threshold: 80 });
// Lock body scroll and close on Escape
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isOpen, onClose]);
return (
<div
className={cn(
"fixed inset-0 z-50 bg-surface-950 flex flex-col",
"transition-transform duration-300 ease-out",
isOpen ? "translate-y-0" : "translate-y-full",
className
)}
role="dialog"
aria-modal="true"
aria-label={fileName ?? "File viewer"}
>
{/* Header — swipe-down handle zone */}
<div
className="flex items-center gap-2 px-2 border-b border-surface-800 bg-surface-900/80 backdrop-blur-sm h-[52px] flex-shrink-0"
{...swipeHandlers}
>
{/* Drag handle */}
<div className="absolute left-1/2 -translate-x-1/2 top-2 w-10 h-1 bg-surface-600 rounded-full" />
<button
onClick={onClose}
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
aria-label="Close file viewer"
>
<X className="w-5 h-5" />
</button>
<span className="flex-1 text-sm font-medium text-surface-100 truncate">{fileName}</span>
<button
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
aria-label="Download file"
>
<Download className="w-5 h-5" />
</button>
</div>
{/* File content — pinch-to-zoom enabled via touch-action */}
<div
className="flex-1 overflow-auto overscroll-contain"
style={{ touchAction: "pan-x pan-y pinch-zoom" }}
>
{children ?? (
<div className="flex items-center justify-center h-full text-surface-500 text-sm">
No file selected
</div>
)}
</div>
{/* iOS safe area inset */}
<div style={{ paddingBottom: "env(safe-area-inset-bottom)" }} className="flex-shrink-0" />
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { Menu, ChevronLeft } from "lucide-react";
import { cn } from "@/lib/utils";
interface MobileHeaderProps {
title?: string;
onMenuOpen: () => void;
onBack?: () => void;
right?: React.ReactNode;
className?: string;
}
/**
* Compact top bar for mobile: hamburger (or back) on the left, title in centre, optional actions on the right.
* Tap targets are at least 44×44 px per WCAG / Apple HIG guidelines.
*/
export function MobileHeader({
title = "Chat",
onMenuOpen,
onBack,
right,
className,
}: MobileHeaderProps) {
return (
<header
className={cn(
"flex items-center gap-2 px-2 border-b border-surface-800 bg-surface-900/80 backdrop-blur-sm",
"h-[52px] flex-shrink-0",
className
)}
>
{/* Left action — back or hamburger */}
{onBack ? (
<button
onClick={onBack}
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
aria-label="Go back"
>
<ChevronLeft className="w-5 h-5" />
</button>
) : (
<button
onClick={onMenuOpen}
className="min-w-[44px] min-h-[44px] flex items-center justify-center rounded-md text-surface-400 hover:text-surface-100 active:bg-surface-800 transition-colors"
aria-label="Open sidebar"
>
<Menu className="w-5 h-5" />
</button>
)}
{/* Title */}
<h1 className="flex-1 text-sm font-medium text-surface-100 truncate">{title}</h1>
{/* Right actions */}
{right && <div className="flex items-center">{right}</div>}
</header>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { ChatInput } from "@/components/chat/ChatInput";
interface MobileInputProps {
conversationId: string;
/** Height of the software keyboard in px — shifts input above it */
keyboardHeight: number;
}
/**
* Mobile-optimised chat input wrapper.
* Uses a paddingBottom equal to the keyboard height so the input floats
* above the virtual keyboard without relying on position:fixed (which
* breaks on iOS Safari when the keyboard is open).
*/
export function MobileInput({ conversationId, keyboardHeight }: MobileInputProps) {
return (
<div
style={{ paddingBottom: keyboardHeight }}
className="transition-[padding] duration-100"
>
<ChatInput conversationId={conversationId} />
</div>
);
}

View File

@@ -0,0 +1,75 @@
"use client";
import { useEffect } from "react";
import { cn } from "@/lib/utils";
import { Sidebar } from "@/components/layout/Sidebar";
import { useTouchGesture } from "@/hooks/useTouchGesture";
interface MobileSidebarProps {
isOpen: boolean;
onClose: () => void;
}
/**
* Slide-in drawer sidebar for mobile / tablet.
* - Opens from the left as an overlay
* - Swipe left or tap backdrop to close
* - Traps focus while open and restores on close
* - Locks body scroll while open
*/
export function MobileSidebar({ isOpen, onClose }: MobileSidebarProps) {
// Swipe left on the drawer to close
const swipeHandlers = useTouchGesture({ onSwipeLeft: onClose });
// Lock body scroll while drawer is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isOpen, onClose]);
return (
<>
{/* Backdrop */}
<div
className={cn(
"fixed inset-0 z-40 bg-black/60 lg:hidden",
"transition-opacity duration-300",
isOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
)}
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer */}
<div
className={cn(
"fixed top-0 left-0 bottom-0 z-50 w-72 lg:hidden",
"transition-transform duration-300 ease-in-out",
isOpen ? "translate-x-0" : "-translate-x-full"
)}
role="dialog"
aria-modal="true"
aria-label="Navigation"
{...swipeHandlers}
>
<Sidebar onNavigate={onClose} />
</div>
</>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { useRef, useState, useCallback } from "react";
import { cn } from "@/lib/utils";
interface SwipeAction {
label: string;
icon?: React.ReactNode;
onClick: () => void;
className?: string;
}
interface SwipeableRowProps {
children: React.ReactNode;
leftActions?: SwipeAction[];
rightActions?: SwipeAction[];
className?: string;
/** Width of each action button in px (default 72) */
actionWidth?: number;
}
/**
* Row that reveals swipe actions when the user drags left (right-actions)
* or right (left-actions). Used in the sidebar conversation list for
* one-swipe delete.
*/
export function SwipeableRow({
children,
leftActions = [],
rightActions = [],
className,
actionWidth = 72,
}: SwipeableRowProps) {
const [translateX, setTranslateX] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const startXRef = useRef<number | null>(null);
const currentXRef = useRef(0);
const maxLeft = leftActions.length * actionWidth;
const maxRight = rightActions.length * actionWidth;
const handleTouchStart = useCallback((e: React.TouchEvent) => {
startXRef.current = e.touches[0].clientX;
setIsDragging(true);
}, []);
const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
if (startXRef.current === null) return;
const dx = e.touches[0].clientX - startXRef.current + currentXRef.current;
const clamped = Math.max(-maxRight, Math.min(maxLeft, dx));
setTranslateX(clamped);
},
[maxLeft, maxRight]
);
const handleTouchEnd = useCallback(() => {
setIsDragging(false);
startXRef.current = null;
// Snap: if dragged > half an action width, show actions; otherwise reset
if (translateX < -(actionWidth / 2) && maxRight > 0) {
const snapped = -maxRight;
setTranslateX(snapped);
currentXRef.current = snapped;
} else if (translateX > actionWidth / 2 && maxLeft > 0) {
const snapped = maxLeft;
setTranslateX(snapped);
currentXRef.current = snapped;
} else {
setTranslateX(0);
currentXRef.current = 0;
}
}, [translateX, actionWidth, maxLeft, maxRight]);
const resetPosition = useCallback(() => {
setTranslateX(0);
currentXRef.current = 0;
}, []);
return (
<div className={cn("relative overflow-hidden", className)}>
{/* Left action buttons (revealed on swipe-right) */}
{leftActions.length > 0 && (
<div
className="absolute inset-y-0 left-0 flex"
style={{ width: maxLeft }}
>
{leftActions.map((action) => (
<button
key={action.label}
onClick={() => {
action.onClick();
resetPosition();
}}
className={cn(
"flex flex-col items-center justify-center gap-1 text-xs font-medium min-w-[44px]",
"bg-brand-600 text-white",
action.className
)}
style={{ width: actionWidth }}
>
{action.icon}
{action.label}
</button>
))}
</div>
)}
{/* Right action buttons (revealed on swipe-left) */}
{rightActions.length > 0 && (
<div
className="absolute inset-y-0 right-0 flex"
style={{ width: maxRight }}
>
{rightActions.map((action) => (
<button
key={action.label}
onClick={() => {
action.onClick();
resetPosition();
}}
className={cn(
"flex flex-col items-center justify-center gap-1 text-xs font-medium min-w-[44px]",
"bg-red-600 text-white",
action.className
)}
style={{ width: actionWidth }}
>
{action.icon}
{action.label}
</button>
))}
</div>
)}
{/* Content row */}
<div
className={cn(
"relative z-10 bg-surface-900",
!isDragging && "transition-transform duration-200"
)}
style={{ transform: `translateX(${translateX}px)` }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { cn } from "@/lib/utils";
interface NotificationBadgeProps {
count: number;
className?: string;
}
export function NotificationBadge({ count, className }: NotificationBadgeProps) {
if (count <= 0) return null;
return (
<span
className={cn(
"absolute -top-1 -right-1 flex items-center justify-center",
"min-w-[16px] h-4 px-1 rounded-full",
"bg-brand-500 text-white text-[10px] font-bold leading-none",
className
)}
>
{count > 99 ? "99+" : count}
</span>
);
}

View File

@@ -0,0 +1,185 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Bell, CheckCheck, Trash2 } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { useNotifications } from "@/hooks/useNotifications";
import { NotificationBadge } from "./NotificationBadge";
import { NotificationItem } from "./NotificationItem";
import type { NotificationCategory } from "@/lib/notifications";
type FilterCategory = "all" | NotificationCategory;
const FILTER_TABS: { key: FilterCategory; label: string }[] = [
{ key: "all", label: "All" },
{ key: "error", label: "Errors" },
{ key: "activity", label: "Activity" },
{ key: "system", label: "System" },
];
export function NotificationCenter() {
const [isOpen, setIsOpen] = useState(false);
const [activeFilter, setActiveFilter] = useState<FilterCategory>("all");
const containerRef = useRef<HTMLDivElement>(null);
const { notifications, unreadCount, markRead, markAllRead, clearHistory } =
useNotifications();
// Close on click outside
useEffect(() => {
if (!isOpen) return;
const handler = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false);
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [isOpen]);
const filtered =
activeFilter === "all"
? notifications
: notifications.filter((n) => n.category === activeFilter);
return (
<div ref={containerRef} className="relative">
{/* Bell button */}
<button
onClick={() => setIsOpen((o) => !o)}
className={cn(
"relative p-1.5 rounded-md transition-colors",
"text-surface-400 hover:text-surface-100 hover:bg-surface-800",
isOpen && "bg-surface-800 text-surface-100"
)}
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ""}`}
>
<Bell className="w-4 h-4" />
<NotificationBadge count={unreadCount} />
</button>
{/* Panel */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.96 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className={cn(
"absolute right-0 top-full mt-2 z-50",
"w-80 rounded-lg border border-surface-700 shadow-2xl",
"bg-surface-900 overflow-hidden",
"flex flex-col"
)}
style={{ maxHeight: "480px" }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-800">
<h3 className="text-sm font-semibold text-surface-100">Notifications</h3>
<div className="flex items-center gap-1">
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="p-1.5 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
title="Mark all as read"
>
<CheckCheck className="w-3.5 h-3.5" />
</button>
)}
{notifications.length > 0 && (
<button
onClick={clearHistory}
className="p-1.5 rounded text-surface-500 hover:text-surface-200 hover:bg-surface-800 transition-colors"
title="Clear all"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Filter tabs */}
<div className="flex border-b border-surface-800 px-4">
{FILTER_TABS.map((tab) => {
const count =
tab.key === "all"
? notifications.length
: notifications.filter((n) => n.category === tab.key).length;
return (
<button
key={tab.key}
onClick={() => setActiveFilter(tab.key)}
className={cn(
"relative px-2 py-2.5 text-xs font-medium transition-colors mr-1",
activeFilter === tab.key
? "text-surface-100"
: "text-surface-500 hover:text-surface-300"
)}
>
{tab.label}
{count > 0 && (
<span
className={cn(
"ml-1 text-[10px] px-1 py-0.5 rounded-full",
activeFilter === tab.key
? "bg-brand-600 text-white"
: "bg-surface-700 text-surface-400"
)}
>
{count}
</span>
)}
{activeFilter === tab.key && (
<motion.div
layoutId="notification-tab-indicator"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-brand-500"
/>
)}
</button>
);
})}
</div>
{/* Notification list */}
<div className="overflow-y-auto flex-1">
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Bell className="w-8 h-8 text-surface-700" />
<p className="text-sm text-surface-500">No notifications</p>
</div>
) : (
<div className="divide-y divide-surface-800/60">
{filtered.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onMarkRead={markRead}
/>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import { XCircle, Zap, Settings, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatDate } from "@/lib/utils";
import type { NotificationItem as NotificationItemType } from "@/lib/notifications";
interface NotificationItemProps {
notification: NotificationItemType;
onMarkRead: (id: string) => void;
}
const CATEGORY_CONFIG = {
error: {
icon: XCircle,
iconColor: "text-red-400",
bgColor: "bg-red-500/10",
},
activity: {
icon: Zap,
iconColor: "text-brand-400",
bgColor: "bg-brand-500/10",
},
system: {
icon: Settings,
iconColor: "text-surface-400",
bgColor: "bg-surface-700/40",
},
} as const;
export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) {
const config = CATEGORY_CONFIG[notification.category];
const Icon = config.icon;
const handleClick = () => {
if (!notification.read) {
onMarkRead(notification.id);
}
};
const content = (
<div
className={cn(
"flex items-start gap-3 px-4 py-3 transition-colors cursor-pointer",
"hover:bg-surface-800/60",
!notification.read && "bg-surface-800/30"
)}
onClick={handleClick}
>
{/* Icon */}
<div
className={cn(
"mt-0.5 shrink-0 w-7 h-7 rounded-full flex items-center justify-center",
config.bgColor
)}
>
<Icon className={cn("w-3.5 h-3.5", config.iconColor)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p
className={cn(
"text-sm leading-snug",
notification.read ? "text-surface-300" : "text-surface-100 font-medium"
)}
>
{notification.title}
</p>
<div className="flex items-center gap-1.5 shrink-0">
{!notification.read && (
<span className="w-1.5 h-1.5 rounded-full bg-brand-500 mt-1" />
)}
{notification.link && (
<ExternalLink className="w-3 h-3 text-surface-500" />
)}
</div>
</div>
<p className="text-xs text-surface-500 mt-0.5 leading-relaxed line-clamp-2">
{notification.description}
</p>
<p className="text-xs text-surface-600 mt-1">
{formatDate(notification.createdAt)}
</p>
</div>
</div>
);
if (notification.link) {
return (
<a href={notification.link} className="block no-underline">
{content}
</a>
);
}
return content;
}

View File

@@ -0,0 +1,182 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import {
X,
CheckCircle2,
XCircle,
AlertTriangle,
Info,
Loader2,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { ToastItem } from "@/lib/notifications";
interface ToastProps {
toast: ToastItem;
onDismiss: (id: string) => void;
}
const VARIANT_CONFIG = {
success: {
border: "border-green-800",
bg: "bg-green-950/90",
icon: CheckCircle2,
iconColor: "text-green-400",
progress: "bg-green-500",
},
error: {
border: "border-red-800",
bg: "bg-red-950/90",
icon: XCircle,
iconColor: "text-red-400",
progress: "bg-red-500",
},
warning: {
border: "border-yellow-800",
bg: "bg-yellow-950/90",
icon: AlertTriangle,
iconColor: "text-yellow-400",
progress: "bg-yellow-500",
},
info: {
border: "border-blue-800",
bg: "bg-blue-950/90",
icon: Info,
iconColor: "text-blue-400",
progress: "bg-blue-500",
},
loading: {
border: "border-surface-700",
bg: "bg-surface-800",
icon: Loader2,
iconColor: "text-brand-400",
progress: "bg-brand-500",
},
} as const;
export function Toast({ toast, onDismiss }: ToastProps) {
const [paused, setPaused] = useState(false);
const [expanded, setExpanded] = useState(false);
const [progress, setProgress] = useState(100);
// Track remaining time across pause/resume cycles
const remainingRef = useRef(toast.duration);
const dismiss = useCallback(() => onDismiss(toast.id), [onDismiss, toast.id]);
useEffect(() => {
if (toast.duration === 0) return; // loading: never auto-dismiss
if (paused) return;
const snapRemaining = remainingRef.current;
const start = Date.now();
const interval = setInterval(() => {
const elapsed = Date.now() - start;
const newRemaining = Math.max(0, snapRemaining - elapsed);
remainingRef.current = newRemaining;
setProgress((newRemaining / toast.duration) * 100);
if (newRemaining === 0) {
clearInterval(interval);
dismiss();
}
}, 50);
return () => clearInterval(interval);
}, [paused, toast.duration, dismiss]);
const config = VARIANT_CONFIG[toast.variant];
const Icon = config.icon;
return (
<div
className={cn(
"relative flex flex-col rounded-lg shadow-xl border overflow-hidden",
"w-80 pointer-events-auto backdrop-blur-sm",
config.border,
config.bg
)}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div className="flex items-start gap-3 p-3.5 pb-5">
{/* Icon */}
<div className={cn("mt-0.5 shrink-0", config.iconColor)}>
<Icon
className={cn("w-4 h-4", toast.variant === "loading" && "animate-spin")}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-surface-100 leading-snug">
{toast.title}
</p>
{toast.description && (
<p className="text-xs text-surface-400 mt-0.5 leading-relaxed">
{toast.description}
</p>
)}
{/* Action button */}
{toast.action && (
<button
onClick={() => {
toast.action!.onClick();
dismiss();
}}
className="mt-2 text-xs font-medium text-brand-400 hover:text-brand-300 transition-colors"
>
{toast.action.label}
</button>
)}
{/* Expandable details */}
{toast.details && (
<div className="mt-1.5">
<button
onClick={() => setExpanded((e) => !e)}
className="flex items-center gap-1 text-xs text-surface-500 hover:text-surface-300 transition-colors"
>
<ChevronDown
className={cn(
"w-3 h-3 transition-transform duration-150",
expanded && "rotate-180"
)}
/>
{expanded ? "Hide details" : "Show details"}
</button>
{expanded && (
<pre className="mt-1.5 text-xs text-surface-400 bg-surface-900/80 rounded p-2 overflow-auto max-h-24 font-mono whitespace-pre-wrap break-all">
{toast.details}
</pre>
)}
</div>
)}
</div>
{/* Dismiss button */}
<button
onClick={dismiss}
className="shrink-0 text-surface-600 hover:text-surface-200 transition-colors"
aria-label="Dismiss"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
{/* Progress bar */}
{toast.duration > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-surface-700/50">
<div
className={cn("h-full transition-none", config.progress)}
style={{ width: `${progress}%` }}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
import { ToastStack } from "./ToastStack";
export function ToastProvider({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<ToastStack />
</>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Toast } from "./Toast";
import { useNotificationStore } from "@/lib/notifications";
export function ToastStack() {
const toasts = useNotificationStore((s) => s.toasts);
const dismissToast = useNotificationStore((s) => s.dismissToast);
return (
<div
aria-live="polite"
aria-label="Notifications"
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"
>
<AnimatePresence mode="popLayout" initial={false}>
{toasts.map((toast) => (
<motion.div
key={toast.id}
layout
initial={{ opacity: 0, x: 80, scale: 0.92 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 80, scale: 0.92, transition: { duration: 0.15 } }}
transition={{ type: "spring", stiffness: 380, damping: 28 }}
>
<Toast toast={toast} onDismiss={dismissToast} />
</motion.div>
))}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { Eye, EyeOff, CheckCircle, XCircle, Loader2 } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { SettingRow, SectionHeader, Toggle } from "./SettingRow";
import { cn } from "@/lib/utils";
type ConnectionStatus = "idle" | "checking" | "ok" | "error";
export function ApiSettings() {
const { settings, updateSettings, resetSettings } = useChatStore();
const [showKey, setShowKey] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("idle");
const [latencyMs, setLatencyMs] = useState<number | null>(null);
async function checkConnection() {
setConnectionStatus("checking");
setLatencyMs(null);
const start = Date.now();
try {
const res = await fetch(`${settings.apiUrl}/health`, { signal: AbortSignal.timeout(5000) });
const ms = Date.now() - start;
setLatencyMs(ms);
setConnectionStatus(res.ok ? "ok" : "error");
} catch {
setConnectionStatus("error");
}
}
const statusIcon = {
idle: null,
checking: <Loader2 className="w-4 h-4 animate-spin text-surface-400" />,
ok: <CheckCircle className="w-4 h-4 text-green-400" />,
error: <XCircle className="w-4 h-4 text-red-400" />,
}[connectionStatus];
const statusText = {
idle: "Not checked",
checking: "Checking...",
ok: latencyMs !== null ? `Connected — ${latencyMs}ms` : "Connected",
error: "Connection failed",
}[connectionStatus];
return (
<div>
<SectionHeader title="API & Authentication" onReset={() => resetSettings("api")} />
<SettingRow
label="API key"
description="Your Anthropic API key. Stored locally and never sent to third parties."
stack
>
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showKey ? "text" : "password"}
value={settings.apiKey}
onChange={(e) => updateSettings({ apiKey: e.target.value })}
placeholder="sk-ant-..."
className={cn(
"w-full bg-surface-800 border border-surface-700 rounded-md px-3 py-1.5 pr-10 text-sm",
"text-surface-200 placeholder-surface-600 focus:outline-none focus:ring-1 focus:ring-brand-500 font-mono"
)}
/>
<button
onClick={() => setShowKey((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-surface-500 hover:text-surface-300 transition-colors"
title={showKey ? "Hide key" : "Show key"}
>
{showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{settings.apiKey && (
<p className="text-xs text-surface-500 mt-1">
Key ending in{" "}
<span className="font-mono text-surface-400">
...{settings.apiKey.slice(-4)}
</span>
</p>
)}
</SettingRow>
<SettingRow
label="API base URL"
description="Custom endpoint for enterprise or proxy setups. Leave as default for direct Anthropic access."
stack
>
<input
type="url"
value={settings.apiUrl}
onChange={(e) => updateSettings({ apiUrl: e.target.value })}
placeholder="http://localhost:3001"
className={cn(
"w-full bg-surface-800 border border-surface-700 rounded-md px-3 py-1.5 text-sm",
"text-surface-200 placeholder-surface-600 focus:outline-none focus:ring-1 focus:ring-brand-500 font-mono"
)}
/>
</SettingRow>
<SettingRow
label="Connection status"
description="Verify that the API endpoint is reachable."
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
{statusIcon}
<span
className={cn(
"text-xs",
connectionStatus === "ok" && "text-green-400",
connectionStatus === "error" && "text-red-400",
connectionStatus === "idle" && "text-surface-500",
connectionStatus === "checking" && "text-surface-400"
)}
>
{statusText}
</span>
</div>
<button
onClick={checkConnection}
disabled={connectionStatus === "checking"}
className={cn(
"px-3 py-1 text-xs rounded-md border border-surface-700 transition-colors",
"text-surface-300 hover:text-surface-100 hover:bg-surface-800",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
Check
</button>
</div>
</SettingRow>
<SettingRow
label="Streaming"
description="Stream responses token by token as they are generated."
>
<Toggle
checked={settings.streamingEnabled}
onChange={(v) => updateSettings({ streamingEnabled: v })}
/>
</SettingRow>
</div>
);
}

View File

@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import { Download, Trash2, AlertTriangle } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { SettingRow, SectionHeader, Toggle } from "./SettingRow";
import { cn } from "@/lib/utils";
export function DataSettings() {
const { settings, updateSettings, conversations, deleteConversation } = useChatStore();
const [showClearConfirm, setShowClearConfirm] = useState(false);
function exportConversations(format: "json" | "markdown") {
let content: string;
let filename: string;
const ts = new Date().toISOString().split("T")[0];
if (format === "json") {
content = JSON.stringify(conversations, null, 2);
filename = `claude-code-conversations-${ts}.json`;
} else {
content = conversations
.map((conv) => {
const messages = conv.messages
.map((m) => {
const role = m.role === "user" ? "**You**" : "**Claude**";
const text = typeof m.content === "string"
? m.content
: m.content
.filter((b) => b.type === "text")
.map((b) => (b as { type: "text"; text: string }).text)
.join("\n");
return `${role}\n\n${text}`;
})
.join("\n\n---\n\n");
return `# ${conv.title}\n\n${messages}`;
})
.join("\n\n====\n\n");
filename = `claude-code-conversations-${ts}.md`;
}
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function clearAllConversations() {
const ids = conversations.map((c) => c.id);
ids.forEach((id) => deleteConversation(id));
setShowClearConfirm(false);
}
const totalMessages = conversations.reduce((sum, c) => sum + c.messages.length, 0);
return (
<div>
<SectionHeader title="Data & Privacy" />
<SettingRow
label="Export conversations"
description={`Export all ${conversations.length} conversations (${totalMessages} messages).`}
>
<div className="flex gap-2">
<button
onClick={() => exportConversations("json")}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs border",
"border-surface-700 text-surface-300 hover:text-surface-100 hover:bg-surface-800 transition-colors"
)}
>
<Download className="w-3.5 h-3.5" />
JSON
</button>
<button
onClick={() => exportConversations("markdown")}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs border",
"border-surface-700 text-surface-300 hover:text-surface-100 hover:bg-surface-800 transition-colors"
)}
>
<Download className="w-3.5 h-3.5" />
Markdown
</button>
</div>
</SettingRow>
<SettingRow
label="Clear conversation history"
description="Permanently delete all conversations. This cannot be undone."
>
{showClearConfirm ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-400 flex items-center gap-1">
<AlertTriangle className="w-3.5 h-3.5" />
Are you sure?
</span>
<button
onClick={clearAllConversations}
className="px-3 py-1.5 text-xs rounded-md bg-red-600 text-white hover:bg-red-700 transition-colors"
>
Delete all
</button>
<button
onClick={() => setShowClearConfirm(false)}
className="px-3 py-1.5 text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setShowClearConfirm(true)}
disabled={conversations.length === 0}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs border",
"border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors",
"disabled:opacity-40 disabled:cursor-not-allowed"
)}
>
<Trash2 className="w-3.5 h-3.5" />
Clear all
</button>
)}
</SettingRow>
<SettingRow
label="Anonymous telemetry"
description="Help improve Claude Code by sharing anonymous usage data. No conversation content is ever sent."
>
<Toggle
checked={settings.telemetryEnabled}
onChange={(v) => updateSettings({ telemetryEnabled: v })}
/>
</SettingRow>
<div className="mt-6 pt-4 border-t border-surface-800">
<p className="text-xs text-surface-500">
All data is stored locally in your browser. Claude Code does not send conversation data
to any server unless explicitly configured.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { Sun, Moon, Monitor } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { useTheme } from "@/components/layout/ThemeProvider";
import { SettingRow, SectionHeader, Toggle, Slider } from "./SettingRow";
import { cn } from "@/lib/utils";
export function GeneralSettings() {
const { settings, updateSettings, resetSettings } = useChatStore();
const { setTheme } = useTheme();
const themes = [
{ id: "light" as const, label: "Light", icon: Sun },
{ id: "dark" as const, label: "Dark", icon: Moon },
{ id: "system" as const, label: "System", icon: Monitor },
];
function handleThemeChange(t: "light" | "dark" | "system") {
updateSettings({ theme: t });
setTheme(t);
}
return (
<div>
<SectionHeader title="General" onReset={() => resetSettings("general")} />
<SettingRow
label="Theme"
description="Choose the color scheme for the interface."
>
<div className="flex gap-1.5">
{themes.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => handleThemeChange(id)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
settings.theme === id
? "bg-brand-600 text-white"
: "bg-surface-800 text-surface-400 hover:text-surface-200"
)}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
</SettingRow>
<SettingRow
label="Chat font size"
description="Font size for messages in the chat window."
stack
>
<Slider
value={settings.fontSize.chat}
min={12}
max={20}
onChange={(v) =>
updateSettings({ fontSize: { ...settings.fontSize, chat: v } })
}
unit="px"
/>
</SettingRow>
<SettingRow
label="Code font size"
description="Font size for code blocks and inline code."
stack
>
<Slider
value={settings.fontSize.code}
min={10}
max={18}
onChange={(v) =>
updateSettings({ fontSize: { ...settings.fontSize, code: v } })
}
unit="px"
/>
</SettingRow>
<SettingRow
label="Send on Enter"
description="Press Enter to send messages. When off, use Cmd+Enter or Ctrl+Enter."
>
<Toggle
checked={settings.sendOnEnter}
onChange={(v) => updateSettings({ sendOnEnter: v })}
/>
</SettingRow>
<SettingRow
label="Show timestamps"
description="Display the time each message was sent."
>
<Toggle
checked={settings.showTimestamps}
onChange={(v) => updateSettings({ showTimestamps: v })}
/>
</SettingRow>
<SettingRow
label="Compact mode"
description="Reduce spacing between messages for higher information density."
>
<Toggle
checked={settings.compactMode}
onChange={(v) => updateSettings({ compactMode: v })}
/>
</SettingRow>
</div>
);
}

View File

@@ -0,0 +1,161 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { RotateCcw } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { SectionHeader } from "./SettingRow";
import { cn } from "@/lib/utils";
const DEFAULT_SHORTCUTS: Record<string, string> = {
"new-conversation": "Ctrl+Shift+N",
"send-message": "Enter",
"focus-input": "Ctrl+L",
"toggle-sidebar": "Ctrl+B",
"open-settings": "Ctrl+,",
"command-palette": "Ctrl+K",
};
const SHORTCUT_LABELS: Record<string, { label: string; description: string }> = {
"new-conversation": { label: "New conversation", description: "Start a fresh conversation" },
"send-message": { label: "Send message", description: "Submit the current message" },
"focus-input": { label: "Focus input", description: "Jump to the message input" },
"toggle-sidebar": { label: "Toggle sidebar", description: "Show or hide the sidebar" },
"open-settings": { label: "Open settings", description: "Open this settings panel" },
"command-palette": { label: "Command palette", description: "Open the command palette" },
};
function captureKeyCombo(e: KeyboardEvent): string {
e.preventDefault();
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push("Ctrl");
if (e.altKey) parts.push("Alt");
if (e.shiftKey) parts.push("Shift");
if (e.key && !["Control", "Alt", "Shift", "Meta"].includes(e.key)) {
parts.push(e.key === " " ? "Space" : e.key);
}
return parts.join("+");
}
function ShortcutRow({
id,
binding,
isDefault,
isConflict,
onRebind,
onReset,
}: {
id: string;
binding: string;
isDefault: boolean;
isConflict: boolean;
onRebind: (combo: string) => void;
onReset: () => void;
}) {
const [listening, setListening] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
const info = SHORTCUT_LABELS[id];
useEffect(() => {
if (!listening) return;
function handler(e: KeyboardEvent) {
if (e.key === "Escape") {
setListening(false);
return;
}
const combo = captureKeyCombo(e);
if (combo) {
onRebind(combo);
setListening(false);
}
}
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [listening, onRebind]);
return (
<div
className={cn(
"flex items-center justify-between py-3 border-b border-surface-800 last:border-0",
isConflict && "bg-red-500/5"
)}
>
<div className="flex-1">
<p className="text-sm text-surface-200">{info?.label ?? id}</p>
<p className="text-xs text-surface-500">{info?.description}</p>
{isConflict && (
<p className="text-xs text-red-400 mt-0.5">Conflict with another shortcut</p>
)}
</div>
<div className="flex items-center gap-2">
<button
ref={ref}
onClick={() => setListening(true)}
className={cn(
"px-3 py-1 rounded-md text-xs font-mono transition-colors border",
listening
? "bg-brand-600/20 border-brand-500 text-brand-300 animate-pulse"
: isConflict
? "bg-red-500/10 border-red-500/30 text-red-300"
: "bg-surface-800 border-surface-700 text-surface-300 hover:border-surface-600"
)}
>
{listening ? "Press keys..." : binding}
</button>
{!isDefault && (
<button
onClick={onReset}
title="Reset to default"
className="text-surface-500 hover:text-surface-300 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
}
export function KeyboardSettings() {
const { settings, updateSettings, resetSettings } = useChatStore();
const keybindings = settings.keybindings;
// Find conflicts
const bindingValues = Object.values(keybindings);
const conflicts = new Set(
bindingValues.filter((v, i) => bindingValues.indexOf(v) !== i)
);
function rebind(id: string, combo: string) {
updateSettings({ keybindings: { ...keybindings, [id]: combo } });
}
function resetOne(id: string) {
updateSettings({
keybindings: { ...keybindings, [id]: DEFAULT_SHORTCUTS[id] },
});
}
return (
<div>
<SectionHeader title="Keyboard Shortcuts" onReset={() => resetSettings("keybindings")} />
<p className="text-xs text-surface-400 mb-4">
Click a shortcut to rebind it. Press Escape to cancel.
</p>
<div>
{Object.entries(keybindings).map(([id, binding]) => (
<ShortcutRow
key={id}
id={id}
binding={binding}
isDefault={binding === DEFAULT_SHORTCUTS[id]}
isConflict={conflicts.has(binding)}
onRebind={(combo) => rebind(id, combo)}
onReset={() => resetOne(id)}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,256 @@
"use client";
import { useState } from "react";
import {
Plus,
Trash2,
CheckCircle,
XCircle,
Loader2,
ChevronDown,
Circle,
} from "lucide-react";
import { nanoid } from "nanoid";
import { useChatStore } from "@/lib/store";
import type { MCPServerConfig } from "@/lib/types";
import { SectionHeader, Toggle } from "./SettingRow";
import { cn } from "@/lib/utils";
type TestStatus = "idle" | "testing" | "ok" | "error";
function ServerRow({
server,
onUpdate,
onDelete,
}: {
server: MCPServerConfig;
onUpdate: (updated: MCPServerConfig) => void;
onDelete: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const [testStatus, setTestStatus] = useState<TestStatus>("idle");
async function testConnection() {
setTestStatus("testing");
// Simulate connection test — in real impl this would call an API
await new Promise((r) => setTimeout(r, 800));
setTestStatus(Math.random() > 0.3 ? "ok" : "error");
}
const statusDot = {
idle: <Circle className="w-2 h-2 text-surface-600" />,
testing: <Loader2 className="w-3 h-3 animate-spin text-surface-400" />,
ok: <CheckCircle className="w-3 h-3 text-green-400" />,
error: <XCircle className="w-3 h-3 text-red-400" />,
}[testStatus];
return (
<div className="border border-surface-800 rounded-lg overflow-hidden">
{/* Header row */}
<div className="flex items-center gap-3 px-3 py-2.5 bg-surface-800/40">
<Toggle
checked={server.enabled}
onChange={(v) => onUpdate({ ...server, enabled: v })}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-surface-200 truncate">{server.name}</p>
<p className="text-xs text-surface-500 font-mono truncate">{server.command}</p>
</div>
<div className="flex items-center gap-2">
{statusDot}
<button
onClick={testConnection}
disabled={testStatus === "testing"}
className="text-xs text-surface-400 hover:text-surface-200 transition-colors disabled:opacity-50"
>
Test
</button>
<button
onClick={() => setExpanded((v) => !v)}
className="text-surface-500 hover:text-surface-300 transition-colors"
>
<ChevronDown
className={cn("w-4 h-4 transition-transform", expanded && "rotate-180")}
/>
</button>
<button
onClick={onDelete}
className="text-surface-500 hover:text-red-400 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Expanded edit form */}
{expanded && (
<div className="px-3 py-3 space-y-2 border-t border-surface-800">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-surface-400 mb-1">Name</label>
<input
value={server.name}
onChange={(e) => onUpdate({ ...server, name: e.target.value })}
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-xs text-surface-400 mb-1">Command</label>
<input
value={server.command}
onChange={(e) => onUpdate({ ...server, command: e.target.value })}
placeholder="npx, node, python..."
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
</div>
<div>
<label className="block text-xs text-surface-400 mb-1">
Arguments (space-separated)
</label>
<input
value={server.args.join(" ")}
onChange={(e) =>
onUpdate({
...server,
args: e.target.value.split(" ").filter(Boolean),
})
}
placeholder="-y @modelcontextprotocol/server-filesystem /path"
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
</div>
)}
</div>
);
}
export function McpSettings() {
const { settings, updateSettings } = useChatStore();
const [showAddForm, setShowAddForm] = useState(false);
const [newServer, setNewServer] = useState<Omit<MCPServerConfig, "id">>({
name: "",
command: "",
args: [],
env: {},
enabled: true,
});
function updateServer(id: string, updated: MCPServerConfig) {
updateSettings({
mcpServers: settings.mcpServers.map((s) => (s.id === id ? updated : s)),
});
}
function deleteServer(id: string) {
updateSettings({
mcpServers: settings.mcpServers.filter((s) => s.id !== id),
});
}
function addServer() {
if (!newServer.name.trim() || !newServer.command.trim()) return;
updateSettings({
mcpServers: [...settings.mcpServers, { ...newServer, id: nanoid() }],
});
setNewServer({ name: "", command: "", args: [], env: {}, enabled: true });
setShowAddForm(false);
}
return (
<div>
<SectionHeader title="MCP Servers" />
<p className="text-xs text-surface-400 mb-4">
Model Context Protocol servers extend Claude with external tools and data sources.
</p>
<div className="space-y-2 mb-4">
{settings.mcpServers.length === 0 ? (
<div className="text-center py-8 text-surface-500 text-sm">
No MCP servers configured
</div>
) : (
settings.mcpServers.map((server) => (
<ServerRow
key={server.id}
server={server}
onUpdate={(updated) => updateServer(server.id, updated)}
onDelete={() => deleteServer(server.id)}
/>
))
)}
</div>
{showAddForm ? (
<div className="border border-surface-700 rounded-lg p-3 space-y-2">
<p className="text-xs font-medium text-surface-300 mb-2">Add MCP server</p>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-surface-400 mb-1">Name *</label>
<input
value={newServer.name}
onChange={(e) => setNewServer((s) => ({ ...s, name: e.target.value }))}
placeholder="filesystem"
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-xs text-surface-400 mb-1">Command *</label>
<input
value={newServer.command}
onChange={(e) => setNewServer((s) => ({ ...s, command: e.target.value }))}
placeholder="npx"
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
</div>
<div>
<label className="block text-xs text-surface-400 mb-1">
Arguments (space-separated)
</label>
<input
value={newServer.args.join(" ")}
onChange={(e) =>
setNewServer((s) => ({
...s,
args: e.target.value.split(" ").filter(Boolean),
}))
}
placeholder="-y @modelcontextprotocol/server-filesystem /path"
className="w-full bg-surface-800 border border-surface-700 rounded px-2 py-1 text-xs text-surface-200 font-mono focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<button
onClick={() => setShowAddForm(false)}
className="px-3 py-1.5 text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
Cancel
</button>
<button
onClick={addServer}
disabled={!newServer.name.trim() || !newServer.command.trim()}
className={cn(
"px-3 py-1.5 text-xs rounded-md bg-brand-600 text-white",
"hover:bg-brand-700 transition-colors",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
Add server
</button>
</div>
</div>
) : (
<button
onClick={() => setShowAddForm(true)}
className="flex items-center gap-2 text-sm text-surface-400 hover:text-surface-200 transition-colors"
>
<Plus className="w-4 h-4" />
Add server
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { MODELS } from "@/lib/constants";
import { SettingRow, SectionHeader, Slider } from "./SettingRow";
import { cn } from "@/lib/utils";
export function ModelSettings() {
const { settings, updateSettings, resetSettings } = useChatStore();
const [showAdvanced, setShowAdvanced] = useState(false);
const selectedModel = MODELS.find((m) => m.id === settings.model);
return (
<div>
<SectionHeader title="Model" onReset={() => resetSettings("model")} />
<SettingRow
label="Default model"
description="The AI model used for new conversations."
>
<select
value={settings.model}
onChange={(e) => updateSettings({ model: e.target.value })}
className={cn(
"bg-surface-800 border border-surface-700 rounded-md px-3 py-1.5 text-sm",
"text-surface-200 focus:outline-none focus:ring-1 focus:ring-brand-500"
)}
>
{MODELS.map((m) => (
<option key={m.id} value={m.id}>
{m.label} {m.description}
</option>
))}
</select>
</SettingRow>
{selectedModel && (
<div className="mb-4 px-3 py-2 rounded-md bg-surface-800/50 border border-surface-800 text-xs text-surface-400">
<span className="font-medium text-surface-300">{selectedModel.label}</span>
{" — "}{selectedModel.description}
</div>
)}
<SettingRow
label="Max tokens"
description="Maximum number of tokens in the model's response."
stack
>
<div className="flex items-center gap-3">
<Slider
value={settings.maxTokens}
min={1000}
max={200000}
step={1000}
onChange={(v) => updateSettings({ maxTokens: v })}
showValue={false}
className="flex-1"
/>
<input
type="number"
value={settings.maxTokens}
min={1000}
max={200000}
step={1000}
onChange={(e) => updateSettings({ maxTokens: Number(e.target.value) })}
className={cn(
"w-24 bg-surface-800 border border-surface-700 rounded-md px-2 py-1 text-sm text-right",
"text-surface-200 focus:outline-none focus:ring-1 focus:ring-brand-500 font-mono"
)}
/>
</div>
</SettingRow>
<SettingRow
label="System prompt"
description="Custom instructions prepended to every conversation."
stack
>
<textarea
value={settings.systemPrompt}
onChange={(e) => updateSettings({ systemPrompt: e.target.value })}
placeholder="You are a helpful assistant..."
rows={4}
className={cn(
"w-full bg-surface-800 border border-surface-700 rounded-md px-3 py-2 text-sm",
"text-surface-200 placeholder-surface-600 focus:outline-none focus:ring-1 focus:ring-brand-500",
"resize-none font-mono"
)}
/>
</SettingRow>
{/* Advanced toggle */}
<button
onClick={() => setShowAdvanced((v) => !v)}
className="flex items-center gap-1.5 text-xs text-surface-400 hover:text-surface-200 transition-colors mt-2 mb-1"
>
<ChevronDown
className={cn("w-3.5 h-3.5 transition-transform", showAdvanced && "rotate-180")}
/>
Advanced settings
</button>
{showAdvanced && (
<SettingRow
label="Temperature"
description="Controls response randomness. Higher values produce more varied output."
stack
>
<Slider
value={settings.temperature}
min={0}
max={1}
step={0.05}
onChange={(v) => updateSettings({ temperature: v })}
/>
</SettingRow>
)}
</div>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { Plus, X } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { SettingRow, SectionHeader, Toggle } from "./SettingRow";
import { cn } from "@/lib/utils";
const TOOL_LABELS: Record<string, { label: string; description: string }> = {
file_read: {
label: "File read",
description: "Read files from the filesystem",
},
file_write: {
label: "File write",
description: "Create or modify files",
},
bash: {
label: "Bash commands",
description: "Execute shell commands",
},
web_search: {
label: "Web search",
description: "Search the internet",
},
};
export function PermissionSettings() {
const { settings, updateSettings, resetSettings } = useChatStore();
const [newDir, setNewDir] = useState("");
function toggleAutoApprove(tool: string, value: boolean) {
updateSettings({
permissions: {
...settings.permissions,
autoApprove: { ...settings.permissions.autoApprove, [tool]: value },
},
});
}
function addRestrictedDir() {
const dir = newDir.trim();
if (!dir || settings.permissions.restrictedDirs.includes(dir)) return;
updateSettings({
permissions: {
...settings.permissions,
restrictedDirs: [...settings.permissions.restrictedDirs, dir],
},
});
setNewDir("");
}
function removeRestrictedDir(dir: string) {
updateSettings({
permissions: {
...settings.permissions,
restrictedDirs: settings.permissions.restrictedDirs.filter((d) => d !== dir),
},
});
}
return (
<div>
<SectionHeader title="Permissions & Safety" onReset={() => resetSettings("permissions")} />
<div className="mb-4 p-3 rounded-md bg-amber-500/10 border border-amber-500/20 text-xs text-amber-300">
Auto-approving tools means Claude can perform these actions without asking for confirmation.
Use with caution.
</div>
{Object.entries(TOOL_LABELS).map(([tool, { label, description }]) => (
<SettingRow key={tool} label={label} description={description}>
<Toggle
checked={!!settings.permissions.autoApprove[tool]}
onChange={(v) => toggleAutoApprove(tool, v)}
/>
</SettingRow>
))}
<SettingRow
label="Restricted directories"
description="Limit file operations to specific directories. Leave empty for no restriction."
stack
>
<div className="space-y-2">
{settings.permissions.restrictedDirs.map((dir) => (
<div
key={dir}
className="flex items-center justify-between gap-2 px-2 py-1 rounded bg-surface-800 border border-surface-700"
>
<span className="text-xs font-mono text-surface-300 truncate">{dir}</span>
<button
onClick={() => removeRestrictedDir(dir)}
className="text-surface-500 hover:text-red-400 transition-colors flex-shrink-0"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
<div className="flex gap-2">
<input
type="text"
value={newDir}
onChange={(e) => setNewDir(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addRestrictedDir()}
placeholder="/path/to/directory"
className={cn(
"flex-1 bg-surface-800 border border-surface-700 rounded-md px-3 py-1.5 text-xs",
"text-surface-200 placeholder-surface-600 focus:outline-none focus:ring-1 focus:ring-brand-500 font-mono"
)}
/>
<button
onClick={addRestrictedDir}
className={cn(
"flex items-center gap-1 px-2.5 py-1.5 rounded-md text-xs",
"bg-surface-800 border border-surface-700 text-surface-300",
"hover:text-surface-100 hover:bg-surface-700 transition-colors"
)}
>
<Plus className="w-3.5 h-3.5" />
Add
</button>
</div>
</div>
</SettingRow>
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { cn } from "@/lib/utils";
interface SettingRowProps {
label: string;
description?: string;
children: React.ReactNode;
className?: string;
stack?: boolean;
}
export function SettingRow({ label, description, children, className, stack = false }: SettingRowProps) {
return (
<div
className={cn(
"py-4 border-b border-surface-800 last:border-0",
stack ? "flex flex-col gap-3" : "flex items-start justify-between gap-6",
className
)}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-surface-100">{label}</p>
{description && (
<p className="text-xs text-surface-400 mt-0.5 leading-relaxed">{description}</p>
)}
</div>
<div className={cn("flex-shrink-0", stack && "w-full")}>{children}</div>
</div>
);
}
interface SectionHeaderProps {
title: string;
onReset?: () => void;
}
export function SectionHeader({ title, onReset }: SectionHeaderProps) {
return (
<div className="flex items-center justify-between mb-2">
<h2 className="text-base font-semibold text-surface-100">{title}</h2>
{onReset && (
<button
onClick={onReset}
className="text-xs text-surface-400 hover:text-surface-200 transition-colors"
>
Reset to defaults
</button>
)}
</div>
);
}
interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export function Toggle({ checked, onChange, disabled = false }: ToggleProps) {
return (
<button
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent",
"transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-900",
"disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-brand-600" : "bg-surface-700"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow",
"transition duration-200 ease-in-out",
checked ? "translate-x-4" : "translate-x-0"
)}
/>
</button>
);
}
interface SliderProps {
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
showValue?: boolean;
unit?: string;
className?: string;
}
export function Slider({ value, min, max, step = 1, onChange, showValue = true, unit = "", className }: SliderProps) {
return (
<div className={cn("flex items-center gap-3", className)}>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="flex-1 h-1.5 bg-surface-700 rounded-full appearance-none cursor-pointer accent-brand-500"
/>
{showValue && (
<span className="text-xs text-surface-300 w-12 text-right font-mono">
{value}{unit}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import {
Settings,
Cpu,
Key,
Shield,
Server,
Keyboard,
Database,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type SettingsSection =
| "general"
| "model"
| "api"
| "permissions"
| "mcp"
| "keyboard"
| "data";
interface NavItem {
id: SettingsSection;
label: string;
icon: React.ElementType;
}
const NAV_ITEMS: NavItem[] = [
{ id: "general", label: "General", icon: Settings },
{ id: "model", label: "Model", icon: Cpu },
{ id: "api", label: "API & Auth", icon: Key },
{ id: "permissions", label: "Permissions", icon: Shield },
{ id: "mcp", label: "MCP Servers", icon: Server },
{ id: "keyboard", label: "Keyboard", icon: Keyboard },
{ id: "data", label: "Data & Privacy", icon: Database },
];
interface SettingsNavProps {
active: SettingsSection;
onChange: (section: SettingsSection) => void;
searchQuery: string;
}
export function SettingsNav({ active, onChange, searchQuery }: SettingsNavProps) {
const filtered = searchQuery
? NAV_ITEMS.filter((item) =>
item.label.toLowerCase().includes(searchQuery.toLowerCase())
)
: NAV_ITEMS;
return (
<nav className="w-48 flex-shrink-0 py-2">
{filtered.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => onChange(item.id)}
className={cn(
"w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors text-left",
active === item.id
? "bg-surface-800 text-surface-100"
: "text-surface-400 hover:text-surface-200 hover:bg-surface-800/50"
)}
>
<Icon className="w-4 h-4 flex-shrink-0" />
{item.label}
</button>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { formatKeyCombo } from "@/lib/keyParser";
import { cn } from "@/lib/utils";
interface ShortcutBadgeProps {
keys: string[];
className?: string;
}
/**
* Renders the first key combo for a command as a series of <kbd> elements.
* E.g. "mod+shift+k" → [⌘] [⇧] [K]
*/
export function ShortcutBadge({ keys, className }: ShortcutBadgeProps) {
if (keys.length === 0) return null;
const parts = formatKeyCombo(keys[0]);
return (
<span className={cn("flex items-center gap-0.5", className)}>
{parts.map((part, i) => (
<kbd
key={i}
className={cn(
"inline-flex items-center justify-center",
"min-w-[1.375rem] h-5 px-1 rounded text-[10px] font-medium",
"bg-surface-800 border border-surface-600 text-surface-400",
"font-mono leading-none"
)}
>
{part}
</kbd>
))}
</span>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import { useState, useMemo } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { X, Search } from "lucide-react";
import { useCommandRegistry } from "@/hooks/useCommandRegistry";
import { ShortcutBadge } from "./ShortcutBadge";
import { SHORTCUT_CATEGORIES } from "@/lib/shortcuts";
import { cn } from "@/lib/utils";
export function ShortcutsHelp() {
const { helpOpen, closeHelp, commands } = useCommandRegistry();
const [filter, setFilter] = useState("");
const groups = useMemo(() => {
const q = filter.toLowerCase();
return SHORTCUT_CATEGORIES.map((cat) => ({
category: cat,
commands: commands.filter(
(c) =>
c.category === cat &&
c.keys.length > 0 &&
(!q ||
c.label.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q))
),
})).filter((g) => g.commands.length > 0);
}, [commands, filter]);
return (
<Dialog.Root open={helpOpen} onOpenChange={(open) => !open && closeHelp()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content
className={cn(
"fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50",
"w-full max-w-2xl max-h-[80vh] flex flex-col",
"bg-surface-900 border border-surface-700 rounded-xl shadow-2xl",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-surface-800 flex-shrink-0">
<div>
<Dialog.Title className="text-sm font-semibold text-surface-100">
Keyboard Shortcuts
</Dialog.Title>
<Dialog.Description className="text-xs text-surface-500 mt-0.5">
Press <kbd className="inline-flex items-center h-4 px-1 rounded bg-surface-800 border border-surface-700 text-[10px] font-mono">?</kbd> anytime to open this panel
</Dialog.Description>
</div>
<Dialog.Close className="p-1.5 rounded-md text-surface-500 hover:text-surface-100 hover:bg-surface-800 transition-colors">
<X className="w-4 h-4" />
</Dialog.Close>
</div>
{/* Search */}
<div className="px-4 py-2.5 border-b border-surface-800 flex-shrink-0">
<div className="flex items-center gap-2 bg-surface-800 rounded-lg px-3 py-1.5">
<Search className="w-3.5 h-3.5 text-surface-500 flex-shrink-0" />
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter shortcuts..."
className="flex-1 bg-transparent text-sm text-surface-100 placeholder:text-surface-500 focus:outline-none"
autoFocus
/>
</div>
</div>
{/* Shortcut groups */}
<div className="overflow-y-auto flex-1 px-4 py-3 space-y-5">
{groups.length === 0 ? (
<p className="text-center text-sm text-surface-500 py-8">
No shortcuts found
</p>
) : (
groups.map(({ category, commands: cmds }) => (
<div key={category}>
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-surface-600 mb-2">
{category}
</h3>
<div className="rounded-lg border border-surface-800 overflow-hidden divide-y divide-surface-800">
{cmds.map((cmd) => (
<div
key={cmd.id}
className="flex items-center justify-between px-3 py-2 hover:bg-surface-800/50 transition-colors"
>
<div className="min-w-0 flex-1">
<p className="text-sm text-surface-200">{cmd.label}</p>
{cmd.description && (
<p className="text-xs text-surface-500 mt-0.5">{cmd.description}</p>
)}
</div>
<div className="flex flex-col items-end gap-1 ml-4 flex-shrink-0">
{cmd.keys.map((k) => (
<ShortcutBadge key={k} keys={[k]} />
))}
</div>
</div>
))}
</div>
</div>
))
)}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,223 @@
"use client";
import React from "react";
// 16-color ANSI palette (matches common terminal defaults)
const FG_COLORS: 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_COLORS: 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",
};
// 256-color palette
function get256Color(n: number): string {
if (n < 16) {
const fg = FG_COLORS[n + 30] ?? FG_COLORS[n + 82]; // handle 0-7 and 8-15
if (fg) return fg;
}
if (n < 232) {
// 6×6×6 color cube
const i = n - 16;
const b = i % 6;
const g = Math.floor(i / 6) % 6;
const r = Math.floor(i / 36);
const toHex = (v: number) =>
(v === 0 ? 0 : 55 + v * 40).toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
// Grayscale ramp
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;
}
interface Segment {
text: string;
style: AnsiStyle;
}
function parseAnsi(input: string): Segment[] {
const segments: Segment[] = [];
let current: AnsiStyle = {};
let pos = 0;
let textStart = 0;
const pushSegment = (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;
pushSegment(esc);
// Find the end of the escape sequence (letter terminator)
let seqEnd = esc + 2;
while (seqEnd < input.length && !/[A-Za-z]/.test(input[seqEnd])) {
seqEnd++;
}
const terminator = input[seqEnd];
const params = input.slice(esc + 2, seqEnd).split(";").map(Number);
if (terminator === "m") {
// SGR sequence
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_COLORS[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 + 2] !== undefined &&
params[i + 3] !== undefined &&
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_COLORS[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 + 2] !== undefined &&
params[i + 3] !== undefined &&
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_COLORS[p];
} else if (p >= 100 && p <= 107) {
current.background = BG_COLORS[p];
}
i++;
}
}
pos = seqEnd + 1;
textStart = pos;
}
pushSegment(input.length);
return segments;
}
function segmentToStyle(style: AnsiStyle): React.CSSProperties {
return {
color: style.color,
backgroundColor: style.background,
fontWeight: style.bold ? "bold" : undefined,
opacity: style.dim ? 0.7 : undefined,
fontStyle: style.italic ? "italic" : undefined,
textDecoration: [
style.underline ? "underline" : "",
style.strikethrough ? "line-through" : "",
]
.filter(Boolean)
.join(" ") || undefined,
};
}
interface AnsiRendererProps {
text: string;
className?: string;
}
export function AnsiRenderer({ text, className }: AnsiRendererProps) {
const lines = text.split("\n");
return (
<span className={className}>
{lines.map((line, lineIdx) => (
<span key={lineIdx}>
{lineIdx > 0 && "\n"}
{parseAnsi(line).map((seg, segIdx) => (
<span key={segIdx} style={segmentToStyle(seg.style)}>
{seg.text}
</span>
))}
</span>
))}
</span>
);
}

View File

@@ -0,0 +1,397 @@
"use client";
import { useState, useMemo } from "react";
import { Columns2, AlignLeft, Copy, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { useHighlightedCode } from "./SyntaxHighlight";
// ─── Diff algorithm ──────────────────────────────────────────────────────────
type DiffLineType = "equal" | "add" | "remove";
interface DiffLine {
type: DiffLineType;
content: string;
oldLineNo?: number;
newLineNo?: number;
}
function computeDiff(oldStr: string, newStr: string): DiffLine[] {
const oldLines = oldStr.split("\n");
const newLines = newStr.split("\n");
const m = oldLines.length;
const n = newLines.length;
// Build LCS table
const dp: Uint32Array[] = Array.from(
{ length: m + 1 },
() => new Uint32Array(n + 1)
);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// Backtrack to build diff
const result: DiffLine[] = [];
let i = m;
let j = n;
let oldLineNo = m;
let newLineNo = n;
while (i > 0 || j > 0) {
if (
i > 0 &&
j > 0 &&
oldLines[i - 1] === newLines[j - 1]
) {
result.unshift({
type: "equal",
content: oldLines[i - 1],
oldLineNo: oldLineNo--,
newLineNo: newLineNo--,
});
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.unshift({
type: "add",
content: newLines[j - 1],
newLineNo: newLineNo--,
});
j--;
} else {
result.unshift({
type: "remove",
content: oldLines[i - 1],
oldLineNo: oldLineNo--,
});
i--;
}
}
return result;
}
// ─── Copy button ─────────────────────────────────────────────────────────────
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<button
onClick={handleCopy}
className="p-1 rounded text-surface-400 hover:text-surface-200 hover:bg-surface-700 transition-colors"
title="Copy"
>
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
</button>
);
}
// ─── Unified diff view ───────────────────────────────────────────────────────
const CONTEXT_LINES = 3;
interface UnifiedDiffProps {
lines: DiffLine[];
lang: string;
}
function UnifiedDiff({ lines, lang: _lang }: UnifiedDiffProps) {
const [expandedHunks, setExpandedHunks] = useState<Set<number>>(new Set());
// Identify collapsed regions (equal lines away from changes)
const visible = useMemo(() => {
const changed = new Set<number>();
lines.forEach((l, i) => {
if (l.type !== "equal") {
for (
let k = Math.max(0, i - CONTEXT_LINES);
k <= Math.min(lines.length - 1, i + CONTEXT_LINES);
k++
) {
changed.add(k);
}
}
});
return changed;
}, [lines]);
const items: Array<
| { kind: "line"; line: DiffLine; idx: number }
| { kind: "hunk"; start: number; end: number; count: number }
> = useMemo(() => {
const result: typeof items = [];
let i = 0;
while (i < lines.length) {
if (visible.has(i) || expandedHunks.has(i)) {
result.push({ kind: "line", line: lines[i], idx: i });
i++;
} else {
// Find the extent of the collapsed hunk
let end = i;
while (end < lines.length && !visible.has(end) && !expandedHunks.has(end)) {
end++;
}
result.push({ kind: "hunk", start: i, end, count: end - i });
i = end;
}
}
return result;
}, [lines, visible, expandedHunks]);
return (
<div className="font-mono text-xs leading-5 overflow-x-auto">
<table className="w-full border-collapse">
<tbody>
{items.map((item, idx) => {
if (item.kind === "hunk") {
return (
<tr key={`hunk-${idx}`}>
<td colSpan={3} className="bg-surface-800/50 text-center py-0.5">
<button
onClick={() => {
setExpandedHunks((prev) => {
const next = new Set(prev);
for (let k = item.start; k < item.end; k++) next.add(k);
return next;
});
}}
className="text-surface-400 hover:text-surface-200 text-xs px-2 py-0.5"
>
{item.count} unchanged line{item.count !== 1 ? "s" : ""}
</button>
</td>
</tr>
);
}
const { line } = item;
const bgClass =
line.type === "add"
? "bg-green-950/50 hover:bg-green-950/70"
: line.type === "remove"
? "bg-red-950/50 hover:bg-red-950/70"
: "hover:bg-surface-800/30";
const prefixClass =
line.type === "add"
? "text-green-400"
: line.type === "remove"
? "text-red-400"
: "text-surface-600";
const prefix =
line.type === "add" ? "+" : line.type === "remove" ? "" : " ";
return (
<tr key={`line-${idx}`} className={bgClass}>
{/* Old line number */}
<td className="select-none text-right text-surface-600 pr-2 pl-3 w-10 border-r border-surface-700/50">
{line.type !== "add" ? line.oldLineNo : ""}
</td>
{/* New line number */}
<td className="select-none text-right text-surface-600 pr-2 pl-2 w-10 border-r border-surface-700/50">
{line.type !== "remove" ? line.newLineNo : ""}
</td>
{/* Content */}
<td className="pl-3 pr-4 whitespace-pre">
<span className={cn("mr-2", prefixClass)}>{prefix}</span>
<span className="text-surface-100">{line.content}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ─── Side-by-side diff ───────────────────────────────────────────────────────
interface SideBySideDiffProps {
lines: DiffLine[];
}
function SideBySideDiff({ lines }: SideBySideDiffProps) {
// Build paired columns: match adds to removes
const pairs: Array<{
left: DiffLine | null;
right: DiffLine | null;
}> = useMemo(() => {
const result: Array<{ left: DiffLine | null; right: DiffLine | null }> = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.type === "equal") {
result.push({ left: line, right: line });
i++;
} else if (line.type === "remove") {
// Pair with next add if exists
const next = lines[i + 1];
if (next?.type === "add") {
result.push({ left: line, right: next });
i += 2;
} else {
result.push({ left: line, right: null });
i++;
}
} else {
result.push({ left: null, right: line });
i++;
}
}
return result;
}, [lines]);
return (
<div className="font-mono text-xs leading-5 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="text-surface-500 border-b border-surface-700">
<th colSpan={2} className="text-left pl-3 py-1 font-normal">
Before
</th>
<th colSpan={2} className="text-left pl-3 py-1 font-normal border-l border-surface-700">
After
</th>
</tr>
</thead>
<tbody>
{pairs.map((pair, idx) => (
<tr key={idx}>
{/* Left column */}
<td
className={cn(
"select-none text-right text-surface-600 pr-2 pl-3 w-10 border-r border-surface-700/50",
pair.left?.type === "remove" && "bg-red-950/50"
)}
>
{pair.left?.oldLineNo ?? ""}
</td>
<td
className={cn(
"pl-2 pr-3 whitespace-pre border-r border-surface-700",
pair.left?.type === "remove"
? "bg-red-950/50 text-red-200"
: "text-surface-300"
)}
>
{pair.left?.content ?? ""}
</td>
{/* Right column */}
<td
className={cn(
"select-none text-right text-surface-600 pr-2 pl-3 w-10 border-r border-surface-700/50",
pair.right?.type === "add" && "bg-green-950/50"
)}
>
{pair.right?.newLineNo ?? ""}
</td>
<td
className={cn(
"pl-2 pr-3 whitespace-pre",
pair.right?.type === "add"
? "bg-green-950/50 text-green-200"
: "text-surface-300"
)}
>
{pair.right?.content ?? ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ─── Public component ────────────────────────────────────────────────────────
interface DiffViewProps {
oldContent: string;
newContent: string;
lang?: string;
defaultMode?: "unified" | "side-by-side";
className?: string;
}
export function DiffView({
oldContent,
newContent,
lang = "text",
defaultMode = "unified",
className,
}: DiffViewProps) {
const [mode, setMode] = useState<"unified" | "side-by-side">(defaultMode);
const lines = useMemo(
() => computeDiff(oldContent, newContent),
[oldContent, newContent]
);
const addCount = lines.filter((l) => l.type === "add").length;
const removeCount = lines.filter((l) => l.type === "remove").length;
return (
<div className={cn("rounded-lg overflow-hidden border border-surface-700", className)}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 bg-surface-800 border-b border-surface-700">
<div className="flex items-center gap-2 text-xs">
<span className="text-green-400 font-mono">+{addCount}</span>
<span className="text-red-400 font-mono">{removeCount}</span>
</div>
<div className="flex items-center gap-1">
<CopyButton text={newContent} />
<div className="flex items-center rounded overflow-hidden border border-surface-700">
<button
onClick={() => setMode("unified")}
className={cn(
"px-2 py-1 text-xs flex items-center gap-1 transition-colors",
mode === "unified"
? "bg-brand-600 text-white"
: "text-surface-400 hover:text-surface-200 hover:bg-surface-700"
)}
>
<AlignLeft className="w-3 h-3" />
Unified
</button>
<button
onClick={() => setMode("side-by-side")}
className={cn(
"px-2 py-1 text-xs flex items-center gap-1 transition-colors",
mode === "side-by-side"
? "bg-brand-600 text-white"
: "text-surface-400 hover:text-surface-200 hover:bg-surface-700"
)}
>
<Columns2 className="w-3 h-3" />
Side by side
</button>
</div>
</div>
</div>
{/* Diff content */}
<div className="bg-surface-900 overflow-auto max-h-[480px]">
{mode === "unified" ? (
<UnifiedDiff lines={lines} lang={lang} />
) : (
<SideBySideDiff lines={lines} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import {
FileText,
FileCode,
FileJson,
FileImage,
File,
Database,
Settings,
Package,
Globe,
BookOpen,
type LucideIcon,
} from "lucide-react";
const EXT_MAP: Record<string, LucideIcon> = {
// JavaScript / TypeScript
js: FileCode,
jsx: FileCode,
ts: FileCode,
tsx: FileCode,
mjs: FileCode,
cjs: FileCode,
// Web
html: Globe,
htm: Globe,
css: FileCode,
scss: FileCode,
sass: FileCode,
less: FileCode,
// Data
json: FileJson,
jsonc: FileJson,
yaml: FileJson,
yml: FileJson,
toml: FileJson,
xml: FileJson,
csv: Database,
// Config
env: Settings,
gitignore: Settings,
eslintrc: Settings,
prettierrc: Settings,
editorconfig: Settings,
// Docs
md: BookOpen,
mdx: BookOpen,
txt: FileText,
rst: FileText,
// Images
png: FileImage,
jpg: FileImage,
jpeg: FileImage,
gif: FileImage,
svg: FileImage,
ico: FileImage,
webp: FileImage,
// Package
lock: Package,
// Python
py: FileCode,
pyc: FileCode,
// Ruby
rb: FileCode,
// Go
go: FileCode,
// Rust
rs: FileCode,
// Java / Kotlin
java: FileCode,
kt: FileCode,
// C / C++
c: FileCode,
cpp: FileCode,
h: FileCode,
hpp: FileCode,
// Shell
sh: FileCode,
bash: FileCode,
zsh: FileCode,
fish: FileCode,
// SQL
sql: Database,
};
function getExtension(filePath: string): string {
const parts = filePath.split(".");
if (parts.length < 2) return "";
return parts[parts.length - 1].toLowerCase();
}
export function getFileIcon(filePath: string): LucideIcon {
const ext = getExtension(filePath);
return EXT_MAP[ext] ?? File;
}
interface FileIconProps {
filePath: string;
className?: string;
}
export function FileIcon({ filePath, className }: FileIconProps) {
const Icon = getFileIcon(filePath);
return <Icon className={className} />;
}

View File

@@ -0,0 +1,161 @@
"use client";
import { useEffect, useState, useRef } from "react";
import type { Highlighter } from "shiki";
// Singleton highlighter promise so we only init once
let highlighterPromise: Promise<Highlighter> | null = null;
async function getHighlighter(): Promise<Highlighter> {
if (!highlighterPromise) {
highlighterPromise = import("shiki").then((shiki) =>
shiki.createHighlighter({
themes: ["github-dark", "github-light"],
langs: [
"typescript",
"javascript",
"tsx",
"jsx",
"python",
"rust",
"go",
"java",
"c",
"cpp",
"ruby",
"shell",
"bash",
"json",
"yaml",
"toml",
"css",
"html",
"markdown",
"sql",
"dockerfile",
"kotlin",
"swift",
"php",
"xml",
],
})
);
}
return highlighterPromise;
}
// Map file extension to shiki language
const EXT_TO_LANG: Record<string, string> = {
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
mjs: "javascript",
cjs: "javascript",
py: "python",
rs: "rust",
go: "go",
java: "java",
c: "c",
cpp: "cpp",
h: "c",
hpp: "cpp",
rb: "ruby",
sh: "bash",
bash: "bash",
zsh: "bash",
json: "json",
jsonc: "json",
yaml: "yaml",
yml: "yaml",
toml: "toml",
css: "css",
scss: "css",
html: "html",
htm: "html",
md: "markdown",
mdx: "markdown",
sql: "sql",
kt: "kotlin",
swift: "swift",
php: "php",
xml: "xml",
dockerfile: "dockerfile",
};
export function getLanguageFromPath(filePath: string): string {
const name = filePath.split("/").pop() ?? "";
if (name.toLowerCase() === "dockerfile") return "dockerfile";
const ext = name.split(".").pop()?.toLowerCase() ?? "";
return EXT_TO_LANG[ext] ?? "text";
}
interface UseHighlightedCodeOptions {
code: string;
lang: string;
theme?: "github-dark" | "github-light";
}
export function useHighlightedCode({
code,
lang,
theme = "github-dark",
}: UseHighlightedCodeOptions): string | null {
const [html, setHtml] = useState<string | null>(null);
const lastKey = useRef<string>("");
const key = `${lang}:${theme}:${code}`;
useEffect(() => {
if (lastKey.current === key) return;
lastKey.current = key;
let cancelled = false;
getHighlighter().then((hl) => {
if (cancelled) return;
try {
const highlighted = hl.codeToHtml(code, { lang, theme });
if (!cancelled) setHtml(highlighted);
} catch {
// Language not supported — fall back to plain
if (!cancelled) setHtml(null);
}
});
return () => {
cancelled = true;
};
}, [key, code, lang, theme]);
return html;
}
interface SyntaxHighlightProps {
code: string;
lang: string;
theme?: "github-dark" | "github-light";
className?: string;
}
export function SyntaxHighlight({
code,
lang,
theme = "github-dark",
className,
}: SyntaxHighlightProps) {
const html = useHighlightedCode({ code, lang, theme });
if (html) {
return (
<div
className={className}
// shiki wraps output in <pre><code> already
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
return (
<pre className={className}>
<code>{code}</code>
</pre>
);
}

View File

@@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { Copy, Check, Clock } from "lucide-react";
import { cn } from "@/lib/utils";
import { AnsiRenderer } from "./AnsiRenderer";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolBashProps {
input: {
command: string;
timeout?: number;
description?: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
className="p-1 rounded text-surface-400 hover:text-surface-200 hover:bg-surface-700 transition-colors"
title="Copy command"
>
{copied ? (
<Check className="w-3.5 h-3.5 text-green-400" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
);
}
// Parse exit code from result — bash tool often appends it
function parseExitCode(result: string): number | null {
const match = result.match(/\nExit code: (\d+)\s*$/);
if (match) return parseInt(match[1], 10);
return null;
}
function stripExitCode(result: string): string {
return result.replace(/\nExit code: \d+\s*$/, "");
}
const MAX_OUTPUT_LINES = 200;
export function ToolBash({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolBashProps) {
const [showAll, setShowAll] = useState(false);
const exitCode = result ? parseExitCode(result) : null;
const outputText = result ? stripExitCode(result) : "";
const outputLines = outputText.split("\n");
const isTruncated = !showAll && outputLines.length > MAX_OUTPUT_LINES;
const displayOutput = isTruncated
? outputLines.slice(0, MAX_OUTPUT_LINES).join("\n")
: outputText;
// Determine if it's an error (non-zero exit code or isError prop)
const hasError = isError || (exitCode !== null && exitCode !== 0);
return (
<ToolUseBlock
toolName="bash"
toolInput={input}
toolResult={result}
isError={hasError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* Command display */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
<span className="text-brand-400 font-mono text-xs select-none">$</span>
<code className="font-mono text-xs text-surface-100 flex-1 break-all">
{input.command}
</code>
<div className="flex items-center gap-1 flex-shrink-0">
{input.timeout && (
<span
className="flex items-center gap-1 text-xs text-surface-500"
title={`Timeout: ${input.timeout}ms`}
>
<Clock className="w-3 h-3" />
{(input.timeout / 1000).toFixed(0)}s
</span>
)}
<CopyButton text={input.command} />
</div>
</div>
{/* Output */}
{isRunning ? (
<div className="px-3 py-3 font-mono text-xs text-brand-400 animate-pulse-soft">
Running
</div>
) : outputText ? (
<div>
<div
className={cn(
"overflow-auto max-h-[400px] bg-[#0d0d0d] px-3 py-3",
"font-mono text-xs leading-5 whitespace-pre"
)}
>
<AnsiRenderer text={displayOutput} />
{isTruncated && (
<button
onClick={() => setShowAll(true)}
className="mt-2 block text-brand-400 hover:text-brand-300 text-xs"
>
Show {outputLines.length - MAX_OUTPUT_LINES} more lines
</button>
)}
</div>
{/* Footer: exit code */}
{exitCode !== null && (
<div
className={cn(
"flex items-center gap-2 px-3 py-1.5 border-t border-surface-700/50",
exitCode === 0 ? "bg-surface-850" : "bg-red-950/20"
)}
>
<span className="text-xs text-surface-500">Exit code</span>
<span
className={cn(
"font-mono text-xs px-1.5 py-0.5 rounded",
exitCode === 0
? "bg-green-900/40 text-green-400"
: "bg-red-900/40 text-red-400"
)}
>
{exitCode}
</span>
</div>
)}
</div>
) : null}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { ChevronRight } from "lucide-react";
import { FileIcon } from "./FileIcon";
import { DiffView } from "./DiffView";
import { getLanguageFromPath } from "./SyntaxHighlight";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolFileEditProps {
input: {
file_path: string;
old_string: string;
new_string: string;
replace_all?: boolean;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function FileBreadcrumb({ filePath }: { filePath: string }) {
const parts = filePath.replace(/^\//, "").split("/");
return (
<div className="flex items-center gap-1 font-mono text-xs text-surface-400 flex-wrap">
{parts.map((part, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="w-3 h-3 text-surface-600" />}
<span
className={i === parts.length - 1 ? "text-surface-200 font-medium" : ""}
>
{part}
</span>
</span>
))}
</div>
);
}
export function ToolFileEdit({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolFileEditProps) {
const lang = getLanguageFromPath(input.file_path);
return (
<ToolUseBlock
toolName="edit"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* File path header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-surface-700/50 bg-surface-850">
<FileIcon
filePath={input.file_path}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<FileBreadcrumb filePath={input.file_path} />
{input.replace_all && (
<span className="ml-auto text-xs px-1.5 py-0.5 rounded bg-surface-700 text-surface-300">
replace all
</span>
)}
</div>
{/* Content */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Editing
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono whitespace-pre-wrap">
{result}
</div>
) : (
<div className="p-3">
<DiffView
oldContent={input.old_string}
newContent={input.new_string}
lang={lang}
/>
</div>
)}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { ChevronRight, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { FileIcon } from "./FileIcon";
import { SyntaxHighlight, getLanguageFromPath } from "./SyntaxHighlight";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolFileReadProps {
input: {
file_path: string;
offset?: number;
limit?: number;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function FileBreadcrumb({ filePath }: { filePath: string }) {
const parts = filePath.replace(/^\//, "").split("/");
return (
<div className="flex items-center gap-1 font-mono text-xs text-surface-400 flex-wrap">
{parts.map((part, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="w-3 h-3 text-surface-600" />}
<span
className={
i === parts.length - 1 ? "text-surface-200 font-medium" : ""
}
>
{part}
</span>
</span>
))}
</div>
);
}
const MAX_LINES_COLLAPSED = 40;
export function ToolFileRead({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolFileReadProps) {
const [showAll, setShowAll] = useState(false);
const lang = getLanguageFromPath(input.file_path);
const lines = result?.split("\n") ?? [];
const isTruncated = !showAll && lines.length > MAX_LINES_COLLAPSED;
const displayContent = isTruncated
? lines.slice(0, MAX_LINES_COLLAPSED).join("\n")
: (result ?? "");
return (
<ToolUseBlock
toolName="read"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* File path header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-surface-700/50 bg-surface-850">
<FileIcon
filePath={input.file_path}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<FileBreadcrumb filePath={input.file_path} />
{(input.offset !== undefined || input.limit !== undefined) && (
<span className="ml-auto text-xs text-surface-500 flex-shrink-0">
{input.offset !== undefined && `offset: ${input.offset}`}
{input.offset !== undefined && input.limit !== undefined && " · "}
{input.limit !== undefined && `limit: ${input.limit}`}
</span>
)}
</div>
{/* Content */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Reading
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : result ? (
<div className="relative">
<div className="overflow-auto max-h-[480px] [&_pre]:!bg-transparent [&_pre]:!m-0 [&_.shiki]:!bg-transparent">
<SyntaxHighlight
code={displayContent}
lang={lang}
className="text-xs [&_pre]:p-3 [&_pre]:leading-5"
/>
</div>
{isTruncated && (
<div className="flex justify-center py-2 border-t border-surface-700/50 bg-surface-850">
<button
onClick={() => setShowAll(true)}
className="text-xs text-brand-400 hover:text-brand-300 flex items-center gap-1"
>
<ChevronDown className="w-3.5 h-3.5" />
Show {lines.length - MAX_LINES_COLLAPSED} more lines
</button>
</div>
)}
</div>
) : null}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { ChevronRight } from "lucide-react";
import { FileIcon } from "./FileIcon";
import { SyntaxHighlight, getLanguageFromPath } from "./SyntaxHighlight";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolFileWriteProps {
input: {
file_path: string;
content: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function FileBreadcrumb({ filePath }: { filePath: string }) {
const parts = filePath.replace(/^\//, "").split("/");
return (
<div className="flex items-center gap-1 font-mono text-xs text-surface-400 flex-wrap">
{parts.map((part, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="w-3 h-3 text-surface-600" />}
<span
className={i === parts.length - 1 ? "text-surface-200 font-medium" : ""}
>
{part}
</span>
</span>
))}
</div>
);
}
function isNewFile(result?: string): boolean {
if (!result) return false;
return /creat|new/i.test(result);
}
export function ToolFileWrite({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolFileWriteProps) {
const lang = getLanguageFromPath(input.file_path);
const lineCount = input.content.split("\n").length;
return (
<ToolUseBlock
toolName="write"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* File path header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-surface-700/50 bg-surface-850">
<FileIcon
filePath={input.file_path}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<FileBreadcrumb filePath={input.file_path} />
<div className="ml-auto flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-surface-500">{lineCount} lines</span>
<span
className={
isNewFile(result)
? "text-xs px-1.5 py-0.5 rounded bg-green-900/40 text-green-400 border border-green-800/50"
: "text-xs px-1.5 py-0.5 rounded bg-yellow-900/30 text-yellow-400 border border-yellow-800/40"
}
>
{isNewFile(result) ? "New file" : "Overwrite"}
</span>
</div>
</div>
{/* Content */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Writing
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : (
<div className="overflow-auto max-h-[480px] [&_pre]:!bg-transparent [&_pre]:!m-0 [&_.shiki]:!bg-transparent">
<SyntaxHighlight
code={input.content}
lang={lang}
className="text-xs [&_pre]:p-3 [&_pre]:leading-5"
/>
</div>
)}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { FileIcon } from "./FileIcon";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolGlobProps {
input: {
pattern: string;
path?: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function parseFilePaths(result: string): string[] {
return result
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
}
export function ToolGlob({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolGlobProps) {
const files = result ? parseFilePaths(result) : [];
const fileCount = files.length;
return (
<ToolUseBlock
toolName="glob"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* Pattern header */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
<code className="font-mono text-xs text-brand-300">{input.pattern}</code>
{input.path && (
<span className="text-xs text-surface-500">in {input.path}</span>
)}
{!isRunning && fileCount > 0 && (
<span className="ml-auto text-xs text-surface-500">
{fileCount} match{fileCount !== 1 ? "es" : ""}
</span>
)}
</div>
{/* File list */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Searching
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : files.length === 0 ? (
<div className="px-3 py-3 text-surface-500 text-xs">No matches found.</div>
) : (
<div className="overflow-auto max-h-[320px] py-1">
{files.map((filePath, i) => (
<div
key={i}
className="flex items-center gap-2 px-3 py-1 hover:bg-surface-800/50 transition-colors"
>
<FileIcon
filePath={filePath}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<span className="font-mono text-xs text-surface-200 truncate">
{filePath}
</span>
</div>
))}
</div>
)}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,182 @@
"use client";
import { FileIcon } from "./FileIcon";
import { cn } from "@/lib/utils";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolGrepProps {
input: {
pattern: string;
path?: string;
glob?: string;
type?: string;
output_mode?: string;
"-i"?: boolean;
"-n"?: boolean;
context?: number;
"-A"?: number;
"-B"?: number;
"-C"?: number;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
interface GrepMatch {
file: string;
lineNo?: number;
content: string;
isContext?: boolean;
}
interface GrepGroup {
file: string;
matches: GrepMatch[];
}
function parseGrepOutput(result: string): GrepGroup[] {
const lines = result.split("\n").filter(Boolean);
const groups: Map<string, GrepMatch[]> = new Map();
for (const line of lines) {
// Format: "file:lineNo:content" or "file:content" or just "file"
const colonMatch = line.match(/^([^:]+):(\d+):(.*)$/);
if (colonMatch) {
const [, file, lineNo, content] = colonMatch;
if (!groups.has(file)) groups.set(file, []);
groups.get(file)!.push({ file, lineNo: parseInt(lineNo, 10), content });
} else if (line.match(/^[^:]+$/)) {
// Files-only mode
if (!groups.has(line)) groups.set(line, []);
} else {
// fallback: treat entire line as content with unknown file
if (!groups.has("")) groups.set("", []);
groups.get("")!.push({ file: "", content: line });
}
}
return Array.from(groups.entries()).map(([file, matches]) => ({
file,
matches,
}));
}
function highlightPattern(text: string, pattern: string): React.ReactNode {
try {
const re = new RegExp(`(${pattern})`, "gi");
const parts = text.split(re);
return parts.map((part, i) =>
re.test(part) ? (
<mark key={i} className="bg-yellow-500/30 text-yellow-200 rounded-sm">
{part}
</mark>
) : (
part
)
);
} catch {
return text;
}
}
export function ToolGrep({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolGrepProps) {
const groups = result ? parseGrepOutput(result) : [];
const totalMatches = groups.reduce((sum, g) => sum + g.matches.length, 0);
const flags = [
input["-i"] && "-i",
input["-n"] !== false && "-n",
input.glob && `--glob ${input.glob}`,
input.type && `--type ${input.type}`,
input.context && `-C ${input.context}`,
]
.filter(Boolean)
.join(" ");
return (
<ToolUseBlock
toolName="grep"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* Search header */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50 flex-wrap">
<code className="font-mono text-xs text-yellow-300">{input.pattern}</code>
{flags && <span className="text-xs text-surface-500 font-mono">{flags}</span>}
{input.path && (
<span className="text-xs text-surface-500">in {input.path}</span>
)}
{!isRunning && totalMatches > 0 && (
<span className="ml-auto text-xs text-surface-500">
{totalMatches} match{totalMatches !== 1 ? "es" : ""}
</span>
)}
</div>
{/* Results */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Searching
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : groups.length === 0 ? (
<div className="px-3 py-3 text-surface-500 text-xs">No matches found.</div>
) : (
<div className="overflow-auto max-h-[400px]">
{groups.map((group, gi) => (
<div key={gi} className="border-b border-surface-700/40 last:border-0">
{/* File header */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface-800/40 sticky top-0">
<FileIcon
filePath={group.file}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<span className="font-mono text-xs text-surface-300 truncate">
{group.file || "(unknown)"}
</span>
<span className="ml-auto text-xs text-surface-500 flex-shrink-0">
{group.matches.length} match{group.matches.length !== 1 ? "es" : ""}
</span>
</div>
{/* Match lines */}
{group.matches.map((match, mi) => (
<div
key={mi}
className={cn(
"flex font-mono text-xs leading-5 hover:bg-surface-800/30",
match.isContext ? "text-surface-500" : "text-surface-200"
)}
>
{match.lineNo !== undefined && (
<span className="select-none text-right text-surface-600 pr-2 pl-3 w-12 border-r border-surface-700/50 flex-shrink-0">
{match.lineNo}
</span>
)}
<span className="pl-3 pr-4 py-0.5 whitespace-pre">
{highlightPattern(match.content, input.pattern)}
</span>
</div>
))}
</div>
))}
</div>
)}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,247 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
ChevronDown,
ChevronRight,
CheckCircle2,
XCircle,
Loader2,
Terminal,
FileText,
FileEdit,
FileSearch,
Search,
Globe,
BookOpen,
ClipboardList,
Bot,
Wrench,
} from "lucide-react";
import { cn } from "@/lib/utils";
// ─── Tool icon mapping ────────────────────────────────────────────────────────
const TOOL_ICONS: Record<string, React.ElementType> = {
bash: Terminal,
read: FileText,
write: FileText,
edit: FileEdit,
glob: FileSearch,
grep: Search,
webfetch: Globe,
websearch: Globe,
notebookedit: BookOpen,
todowrite: ClipboardList,
agent: Bot,
};
function getToolIcon(name: string): React.ElementType {
return TOOL_ICONS[name.toLowerCase()] ?? Wrench;
}
const TOOL_LABELS: Record<string, string> = {
bash: "Bash",
read: "Read File",
write: "Write File",
edit: "Edit File",
glob: "Glob",
grep: "Grep",
webfetch: "Web Fetch",
websearch: "Web Search",
notebookedit: "Notebook Edit",
todowrite: "Todo",
agent: "Agent",
};
function getToolLabel(name: string): string {
return TOOL_LABELS[name.toLowerCase()] ?? name;
}
// ─── Elapsed timer ────────────────────────────────────────────────────────────
function ElapsedTimer({ startMs }: { startMs: number }) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setElapsed(Date.now() - startMs);
}, 100);
return () => clearInterval(interval);
}, [startMs]);
if (elapsed < 1000) return <span>{elapsed}ms</span>;
return <span>{(elapsed / 1000).toFixed(1)}s</span>;
}
// ─── Status badge ─────────────────────────────────────────────────────────────
interface StatusBadgeProps {
isRunning: boolean;
isError: boolean;
startedAt: number;
completedAt?: number;
}
function StatusBadge({
isRunning,
isError,
startedAt,
completedAt,
}: StatusBadgeProps) {
if (isRunning) {
return (
<span className="flex items-center gap-1.5 text-xs text-brand-400">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<ElapsedTimer startMs={startedAt} />
</span>
);
}
const duration = completedAt ? completedAt - startedAt : null;
const durationStr = duration
? duration < 1000
? `${duration}ms`
: `${(duration / 1000).toFixed(1)}s`
: null;
if (isError) {
return (
<span className="flex items-center gap-1.5 text-xs text-red-400">
<XCircle className="w-3.5 h-3.5" />
{durationStr && <span>{durationStr}</span>}
<span>Error</span>
</span>
);
}
return (
<span className="flex items-center gap-1.5 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
{durationStr && <span>{durationStr}</span>}
</span>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export interface ToolUseBlockProps {
toolName: string;
toolInput: Record<string, unknown>;
toolResult?: string | null;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
children?: React.ReactNode;
defaultExpanded?: boolean;
}
export function ToolUseBlock({
toolName,
toolInput: _toolInput,
toolResult: _toolResult,
isError = false,
isRunning = false,
startedAt,
completedAt,
children,
defaultExpanded = false,
}: ToolUseBlockProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded || isRunning);
const startRef = useRef(startedAt ?? Date.now());
// Auto-expand while running, retain state after completion
useEffect(() => {
if (isRunning) setIsExpanded(true);
}, [isRunning]);
const Icon = getToolIcon(toolName);
const label = getToolLabel(toolName);
const borderColor = isRunning
? "border-brand-600/40"
: isError
? "border-red-800/50"
: "border-surface-700";
const headerBg = isRunning
? "bg-brand-950/30"
: isError
? "bg-red-950/20"
: "bg-surface-850";
return (
<div
className={cn(
"rounded-lg border overflow-hidden text-sm",
borderColor
)}
>
{/* Header row */}
<button
onClick={() => setIsExpanded((v) => !v)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2.5 text-left transition-colors",
headerBg,
"hover:bg-surface-800"
)}
>
{/* Expand icon */}
<span className="text-surface-500 flex-shrink-0">
{isExpanded ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)}
</span>
{/* Tool icon */}
<span
className={cn(
"flex-shrink-0",
isRunning
? "text-brand-400"
: isError
? "text-red-400"
: "text-surface-400"
)}
>
<Icon className="w-4 h-4" />
</span>
{/* Tool name */}
<span className="text-surface-200 font-medium flex-1 truncate">
{label}
</span>
{/* Status */}
<StatusBadge
isRunning={isRunning}
isError={isError}
startedAt={startRef.current}
completedAt={completedAt}
/>
</button>
{/* Expandable body */}
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
key="body"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: "easeInOut" }}
style={{ overflow: "hidden" }}
>
<div className="border-t border-surface-700 bg-surface-900">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import { ExternalLink, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolWebFetchProps {
input: {
url: string;
prompt?: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
// Very rough HTTP status parsing from result text
function parseStatus(result: string): number | null {
const m = result.match(/^(?:HTTP[^\n]*\s)?(\d{3})\b/m);
if (m) return parseInt(m[1], 10);
return null;
}
function StatusBadge({ code }: { code: number | null }) {
if (!code) return null;
const isOk = code >= 200 && code < 300;
return (
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded font-mono",
isOk
? "bg-green-900/40 text-green-400 border border-green-800/40"
: "bg-red-900/40 text-red-400 border border-red-800/40"
)}
>
{code}
</span>
);
}
const MAX_VISIBLE = 80;
export function ToolWebFetch({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolWebFetchProps) {
const [showFull, setShowFull] = useState(false);
const status = result ? parseStatus(result) : null;
const isTruncated = !showFull && result && result.length > MAX_VISIBLE * 10;
return (
<ToolUseBlock
toolName="webfetch"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* URL header */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
<a
href={input.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="font-mono text-xs text-brand-400 hover:text-brand-300 hover:underline truncate flex-1 flex items-center gap-1"
>
{input.url}
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
{status && <StatusBadge code={status} />}
</div>
{/* Prompt if any */}
{input.prompt && (
<div className="px-3 py-2 border-b border-surface-700/50 text-xs text-surface-400 italic">
Prompt: {input.prompt}
</div>
)}
{/* Response body */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Fetching
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : result ? (
<div>
<div className="overflow-auto max-h-[400px] px-3 py-3 text-xs text-surface-300 leading-relaxed whitespace-pre-wrap font-mono">
{isTruncated ? result.slice(0, MAX_VISIBLE * 10) : result}
</div>
{isTruncated && (
<button
onClick={() => setShowFull(true)}
className="flex items-center gap-1 mx-3 mb-2 text-xs text-brand-400 hover:text-brand-300"
>
<ChevronDown className="w-3.5 h-3.5" />
Show full response ({Math.round(result.length / 1024)}KB)
</button>
)}
{showFull && (
<button
onClick={() => setShowFull(false)}
className="flex items-center gap-1 mx-3 mb-2 text-xs text-surface-400 hover:text-surface-200"
>
<ChevronUp className="w-3.5 h-3.5" />
Collapse
</button>
)}
</div>
) : null}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { ExternalLink, Search } from "lucide-react";
import { ToolUseBlock } from "./ToolUseBlock";
interface SearchResult {
title: string;
url: string;
snippet: string;
}
interface ToolWebSearchProps {
input: {
query: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function parseSearchResults(result: string): SearchResult[] {
// Try JSON first
try {
const data = JSON.parse(result);
if (Array.isArray(data)) {
return data.map((item) => ({
title: item.title ?? item.name ?? "(no title)",
url: item.url ?? item.link ?? "",
snippet: item.snippet ?? item.description ?? item.content ?? "",
}));
}
if (data.results) return parseSearchResults(JSON.stringify(data.results));
} catch {
// not JSON
}
// Fallback: treat raw text as a single result
return [{ title: "Search Result", url: "", snippet: result }];
}
export function ToolWebSearch({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolWebSearchProps) {
const results = result && !isError ? parseSearchResults(result) : [];
return (
<ToolUseBlock
toolName="websearch"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* Query header */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
<Search className="w-3.5 h-3.5 text-surface-500 flex-shrink-0" />
<span className="text-sm text-surface-200 flex-1">{input.query}</span>
{!isRunning && results.length > 0 && (
<span className="text-xs text-surface-500">
{results.length} result{results.length !== 1 ? "s" : ""}
</span>
)}
</div>
{/* Results */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Searching
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : results.length === 0 ? (
<div className="px-3 py-3 text-surface-500 text-xs">No results.</div>
) : (
<div className="overflow-auto max-h-[480px] divide-y divide-surface-700/40">
{results.map((r, i) => (
<div key={i} className="px-3 py-3 hover:bg-surface-800/30 transition-colors">
{r.url ? (
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="group flex flex-col gap-1"
>
<div className="flex items-center gap-1">
<span className="text-sm font-medium text-brand-400 group-hover:text-brand-300 group-hover:underline">
{r.title}
</span>
<ExternalLink className="w-3 h-3 text-surface-500 flex-shrink-0" />
</div>
<span className="text-xs text-surface-500 truncate">{r.url}</span>
</a>
) : (
<span className="text-sm font-medium text-surface-200">{r.title}</span>
)}
{r.snippet && (
<p className="mt-1 text-xs text-surface-400 leading-relaxed line-clamp-3">
{r.snippet}
</p>
)}
</div>
))}
</div>
)}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,6 @@
"use client";
// Re-exports for backwards compatibility.
// The notification system lives in web/components/notifications/ and web/lib/notifications.ts.
export { ToastProvider } from "@/components/notifications/ToastProvider";
export { useToast } from "@/hooks/useToast";

View File

@@ -0,0 +1,82 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const avatarVariants = cva(
'relative inline-flex items-center justify-center rounded-full overflow-hidden font-medium select-none flex-shrink-0',
{
variants: {
size: {
xs: 'h-6 w-6 text-[10px]',
sm: 'h-8 w-8 text-xs',
md: 'h-10 w-10 text-sm',
lg: 'h-12 w-12 text-base',
xl: 'h-16 w-16 text-lg',
},
},
defaultVariants: { size: 'md' },
}
)
export interface AvatarProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof avatarVariants> {
src?: string
alt?: string
name?: string
}
function getInitials(name: string): string {
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((n) => n[0].toUpperCase())
.join('')
}
// Deterministic colour from name
function getAvatarColor(name: string): string {
const colours = [
'bg-brand-700 text-brand-200',
'bg-violet-800 text-violet-200',
'bg-indigo-800 text-indigo-200',
'bg-blue-800 text-blue-200',
'bg-cyan-800 text-cyan-200',
'bg-teal-800 text-teal-200',
'bg-emerald-800 text-emerald-200',
'bg-amber-800 text-amber-200',
'bg-rose-800 text-rose-200',
]
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) & 0xffffffff
}
return colours[Math.abs(hash) % colours.length]
}
function Avatar({ className, size, src, alt, name, ...props }: AvatarProps) {
const [imgError, setImgError] = React.useState(false)
const showImage = src && !imgError
const initials = name ? getInitials(name) : '?'
const colorClass = name ? getAvatarColor(name) : 'bg-surface-700 text-surface-300'
return (
<span className={cn(avatarVariants({ size, className }))} {...props}>
{showImage ? (
<img
src={src}
alt={alt ?? name ?? 'Avatar'}
className="h-full w-full object-cover"
onError={() => setImgError(true)}
/>
) : (
<span className={cn('flex h-full w-full items-center justify-center', colorClass)} aria-label={name}>
{initials}
</span>
)}
</span>
)
}
export { Avatar, avatarVariants }

View File

@@ -0,0 +1,56 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
{
variants: {
variant: {
default: 'bg-surface-800 text-surface-300 border border-surface-700',
success: 'bg-success-bg text-success border border-success/20',
error: 'bg-error-bg text-error border border-error/20',
warning: 'bg-warning-bg text-warning border border-warning/20',
info: 'bg-info-bg text-info border border-info/20',
brand: 'bg-brand-500/15 text-brand-300 border border-brand-500/25',
outline: 'border border-surface-600 text-surface-400',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {
dot?: boolean
}
function Badge({ className, variant, dot = false, children, ...props }: BadgeProps) {
const dotColors: Record<string, string> = {
default: 'bg-surface-400',
success: 'bg-success',
error: 'bg-error',
warning: 'bg-warning',
info: 'bg-info',
brand: 'bg-brand-400',
outline: 'bg-surface-500',
}
const dotColor = dotColors[variant ?? 'default'] ?? dotColors.default
return (
<span className={cn(badgeVariants({ variant, className }))} {...props}>
{dot && (
<span
className={cn('h-1.5 w-1.5 rounded-full flex-shrink-0', dotColor)}
aria-hidden="true"
/>
)}
{children}
</span>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,73 @@
'use client'
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors duration-[var(--transition-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 select-none',
{
variants: {
variant: {
primary: 'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800',
secondary: 'bg-surface-800 text-surface-100 border border-surface-700 hover:bg-surface-700',
ghost: 'text-surface-400 hover:bg-surface-800 hover:text-surface-100',
danger: 'bg-red-600 text-white hover:bg-red-700 active:bg-red-800',
// Legacy aliases
default: 'bg-brand-600 text-white hover:bg-brand-700',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-surface-700 bg-transparent hover:bg-surface-800 text-surface-200',
link: 'text-brand-400 underline-offset-4 hover:underline',
},
size: {
sm: 'h-7 px-3 text-xs rounded',
md: 'h-9 px-4 text-sm',
lg: 'h-11 px-6 text-base',
// Legacy aliases
default: 'h-9 px-4 py-2',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
loading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading = false, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
const spinnerSize = size === 'sm' ? 12 : size === 'lg' ? 18 : 14
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...props}
>
{loading && (
<Loader2
className="animate-spin flex-shrink-0"
size={spinnerSize}
aria-hidden="true"
/>
)}
{children}
</Comp>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,131 @@
'use client'
import * as React from 'react'
import * as RadixDialog from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const dialogContentVariants = cva(
[
'relative z-50 grid w-full gap-4 rounded-lg border border-surface-700',
'bg-surface-900 p-6 shadow-lg',
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
].join(' '),
{
variants: {
size: {
sm: 'max-w-sm',
md: 'max-w-lg',
lg: 'max-w-2xl',
full: 'max-w-[calc(100vw-2rem)] h-[calc(100vh-4rem)]',
},
},
defaultVariants: {
size: 'md',
},
}
)
const Dialog = RadixDialog.Root
const DialogTrigger = RadixDialog.Trigger
const DialogPortal = RadixDialog.Portal
const DialogClose = RadixDialog.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof RadixDialog.Overlay>,
React.ComponentPropsWithoutRef<typeof RadixDialog.Overlay>
>(({ className, ...props }, ref) => (
<RadixDialog.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out',
className
)}
{...props}
/>
))
DialogOverlay.displayName = RadixDialog.Overlay.displayName
interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof RadixDialog.Content>,
VariantProps<typeof dialogContentVariants> {
showClose?: boolean
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof RadixDialog.Content>,
DialogContentProps
>(({ className, children, size, showClose = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<RadixDialog.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
dialogContentVariants({ size, className })
)}
{...props}
>
{children}
{showClose && (
<DialogClose className="absolute right-4 top-4 rounded-sm text-surface-500 hover:text-surface-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<X className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Close</span>
</DialogClose>
)}
</RadixDialog.Content>
</DialogPortal>
))
DialogContent.displayName = RadixDialog.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col gap-1.5', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof RadixDialog.Title>,
React.ComponentPropsWithoutRef<typeof RadixDialog.Title>
>(({ className, ...props }, ref) => (
<RadixDialog.Title
ref={ref}
className={cn('text-lg font-semibold text-surface-50', className)}
{...props}
/>
))
DialogTitle.displayName = RadixDialog.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof RadixDialog.Description>,
React.ComponentPropsWithoutRef<typeof RadixDialog.Description>
>(({ className, ...props }, ref) => (
<RadixDialog.Description
ref={ref}
className={cn('text-sm text-surface-400', className)}
{...props}
/>
))
DialogDescription.displayName = RadixDialog.Description.displayName
export {
Dialog,
DialogTrigger,
DialogPortal,
DialogOverlay,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,191 @@
'use client'
import * as React from 'react'
import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu'
import { Check, ChevronRight, Circle } from 'lucide-react'
import { cn } from '@/lib/utils'
const DropdownMenu = RadixDropdownMenu.Root
const DropdownMenuTrigger = RadixDropdownMenu.Trigger
const DropdownMenuGroup = RadixDropdownMenu.Group
const DropdownMenuPortal = RadixDropdownMenu.Portal
const DropdownMenuSub = RadixDropdownMenu.Sub
const DropdownMenuRadioGroup = RadixDropdownMenu.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.SubTrigger>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.SubTrigger> & { inset?: boolean }
>(({ className, inset, children, ...props }, ref) => (
<RadixDropdownMenu.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm',
'text-surface-200 outline-none focus:bg-surface-800 data-[state=open]:bg-surface-800',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4 text-surface-500" aria-hidden="true" />
</RadixDropdownMenu.SubTrigger>
))
DropdownMenuSubTrigger.displayName = RadixDropdownMenu.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.SubContent>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.SubContent>
>(({ className, ...props }, ref) => (
<RadixDropdownMenu.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
'bg-surface-850 p-1 text-surface-100 shadow-lg',
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName = RadixDropdownMenu.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.Content>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<RadixDropdownMenu.Portal>
<RadixDropdownMenu.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
'bg-surface-850 p-1 text-surface-100 shadow-lg',
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
className
)}
{...props}
/>
</RadixDropdownMenu.Portal>
))
DropdownMenuContent.displayName = RadixDropdownMenu.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.Item>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Item> & { inset?: boolean; destructive?: boolean }
>(({ className, inset, destructive, ...props }, ref) => (
<RadixDropdownMenu.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm',
'outline-none transition-colors focus:bg-surface-800',
destructive
? 'text-red-400 focus:text-red-300'
: 'text-surface-200 focus:text-surface-50',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = RadixDropdownMenu.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<RadixDropdownMenu.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
'text-surface-200 outline-none transition-colors focus:bg-surface-800 focus:text-surface-50',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<RadixDropdownMenu.ItemIndicator>
<Check className="h-4 w-4 text-brand-400" aria-hidden="true" />
</RadixDropdownMenu.ItemIndicator>
</span>
{children}
</RadixDropdownMenu.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName = RadixDropdownMenu.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.RadioItem>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.RadioItem>
>(({ className, children, ...props }, ref) => (
<RadixDropdownMenu.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
'text-surface-200 outline-none transition-colors focus:bg-surface-800 focus:text-surface-50',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<RadixDropdownMenu.ItemIndicator>
<Circle className="h-2 w-2 fill-brand-400 text-brand-400" aria-hidden="true" />
</RadixDropdownMenu.ItemIndicator>
</span>
{children}
</RadixDropdownMenu.RadioItem>
))
DropdownMenuRadioItem.displayName = RadixDropdownMenu.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.Label>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<RadixDropdownMenu.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-xs font-semibold text-surface-500 uppercase tracking-wider',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = RadixDropdownMenu.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof RadixDropdownMenu.Separator>,
React.ComponentPropsWithoutRef<typeof RadixDropdownMenu.Separator>
>(({ className, ...props }, ref) => (
<RadixDropdownMenu.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-surface-700', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = RadixDropdownMenu.Separator.displayName
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span className={cn('ml-auto text-xs tracking-widest text-surface-500', className)} {...props} />
)
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,79 @@
'use client'
import * as React from 'react'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
helper?: string
variant?: 'default' | 'search'
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, helper, variant = 'default', id, ...props }, ref) => {
const inputId = id ?? React.useId()
const errorId = `${inputId}-error`
const helperId = `${inputId}-helper`
const describedBy = [
error ? errorId : null,
helper ? helperId : null,
]
.filter(Boolean)
.join(' ')
return (
<div className="flex flex-col gap-1.5">
{label && (
<label
htmlFor={inputId}
className="text-sm font-medium text-surface-200"
>
{label}
</label>
)}
<div className="relative">
{variant === 'search' && (
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-surface-500 pointer-events-none"
size={15}
aria-hidden="true"
/>
)}
<input
id={inputId}
ref={ref}
aria-describedby={describedBy || undefined}
aria-invalid={error ? true : undefined}
className={cn(
'flex h-9 w-full rounded-md border bg-surface-900 px-3 py-1 text-sm text-surface-100',
'border-surface-700 placeholder:text-surface-500',
'transition-colors duration-[var(--transition-fast)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
'disabled:cursor-not-allowed disabled:opacity-50',
variant === 'search' && 'pl-9',
error && 'border-red-500 focus-visible:ring-red-500',
className
)}
{...props}
/>
</div>
{error && (
<p id={errorId} className="text-xs text-red-400" role="alert">
{error}
</p>
)}
{helper && !error && (
<p id={helperId} className="text-xs text-surface-500">
{helper}
</p>
)}
</div>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,161 @@
'use client'
import * as React from 'react'
import * as RadixSelect from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = RadixSelect.Root
const SelectGroup = RadixSelect.Group
const SelectValue = RadixSelect.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof RadixSelect.Trigger>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Trigger>
>(({ className, children, ...props }, ref) => (
<RadixSelect.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between rounded-md border border-surface-700',
'bg-surface-900 px-3 py-2 text-sm text-surface-100',
'placeholder:text-surface-500',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
'disabled:cursor-not-allowed disabled:opacity-50',
'[&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<RadixSelect.Icon asChild>
<ChevronDown className="h-4 w-4 text-surface-500 flex-shrink-0" aria-hidden="true" />
</RadixSelect.Icon>
</RadixSelect.Trigger>
))
SelectTrigger.displayName = RadixSelect.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof RadixSelect.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof RadixSelect.ScrollUpButton>
>(({ className, ...props }, ref) => (
<RadixSelect.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4 text-surface-500" aria-hidden="true" />
</RadixSelect.ScrollUpButton>
))
SelectScrollUpButton.displayName = RadixSelect.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof RadixSelect.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof RadixSelect.ScrollDownButton>
>(({ className, ...props }, ref) => (
<RadixSelect.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4 text-surface-500" aria-hidden="true" />
</RadixSelect.ScrollDownButton>
))
SelectScrollDownButton.displayName = RadixSelect.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof RadixSelect.Content>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<RadixSelect.Portal>
<RadixSelect.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-surface-700',
'bg-surface-850 text-surface-100 shadow-lg',
'data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out',
position === 'popper' && [
'data-[side=bottom]:translate-y-1',
'data-[side=top]:-translate-y-1',
],
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<RadixSelect.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</RadixSelect.Viewport>
<SelectScrollDownButton />
</RadixSelect.Content>
</RadixSelect.Portal>
))
SelectContent.displayName = RadixSelect.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof RadixSelect.Label>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Label>
>(({ className, ...props }, ref) => (
<RadixSelect.Label
ref={ref}
className={cn('px-2 py-1.5 text-xs font-semibold text-surface-500 uppercase tracking-wider', className)}
{...props}
/>
))
SelectLabel.displayName = RadixSelect.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof RadixSelect.Item>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Item>
>(({ className, children, ...props }, ref) => (
<RadixSelect.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm',
'text-surface-200 outline-none',
'focus:bg-surface-800 focus:text-surface-50',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<RadixSelect.ItemIndicator>
<Check className="h-4 w-4 text-brand-400" aria-hidden="true" />
</RadixSelect.ItemIndicator>
</span>
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
</RadixSelect.Item>
))
SelectItem.displayName = RadixSelect.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof RadixSelect.Separator>,
React.ComponentPropsWithoutRef<typeof RadixSelect.Separator>
>(({ className, ...props }, ref) => (
<RadixSelect.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-surface-700', className)}
{...props}
/>
))
SelectSeparator.displayName = RadixSelect.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,64 @@
'use client'
import * as React from 'react'
import * as RadixTabs from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = RadixTabs.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof RadixTabs.List>,
React.ComponentPropsWithoutRef<typeof RadixTabs.List>
>(({ className, ...props }, ref) => (
<RadixTabs.List
ref={ref}
className={cn(
'relative inline-flex items-center border-b border-surface-800 w-full',
className
)}
{...props}
/>
))
TabsList.displayName = RadixTabs.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof RadixTabs.Trigger>,
React.ComponentPropsWithoutRef<typeof RadixTabs.Trigger>
>(({ className, ...props }, ref) => (
<RadixTabs.Trigger
ref={ref}
className={cn(
'relative inline-flex items-center justify-center gap-1.5 whitespace-nowrap',
'px-4 py-2.5 text-sm font-medium',
'text-surface-500 transition-colors duration-[var(--transition-fast)]',
'hover:text-surface-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-inset',
'disabled:pointer-events-none disabled:opacity-50',
// Animated underline via pseudo-element
'after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full',
'after:scale-x-0 after:bg-brand-500 after:transition-transform after:duration-[var(--transition-normal)]',
'data-[state=active]:text-surface-50 data-[state=active]:after:scale-x-100',
className
)}
{...props}
/>
))
TabsTrigger.displayName = RadixTabs.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof RadixTabs.Content>,
React.ComponentPropsWithoutRef<typeof RadixTabs.Content>
>(({ className, ...props }, ref) => (
<RadixTabs.Content
ref={ref}
className={cn(
'mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'data-[state=active]:animate-fade-in',
className
)}
{...props}
/>
))
TabsContent.displayName = RadixTabs.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,105 @@
'use client'
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
helper?: string
maxCount?: number
autoGrow?: boolean
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, label, error, helper, maxCount, autoGrow = false, id, onChange, value, ...props }, ref) => {
const textareaId = id ?? React.useId()
const errorId = `${textareaId}-error`
const helperId = `${textareaId}-helper`
const internalRef = React.useRef<HTMLTextAreaElement>(null)
const resolvedRef = (ref as React.RefObject<HTMLTextAreaElement>) ?? internalRef
const [charCount, setCharCount] = React.useState(
typeof value === 'string' ? value.length : 0
)
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setCharCount(e.target.value.length)
if (autoGrow && resolvedRef.current) {
resolvedRef.current.style.height = 'auto'
resolvedRef.current.style.height = `${resolvedRef.current.scrollHeight}px`
}
onChange?.(e)
}
React.useEffect(() => {
if (autoGrow && resolvedRef.current) {
resolvedRef.current.style.height = 'auto'
resolvedRef.current.style.height = `${resolvedRef.current.scrollHeight}px`
}
}, [value, autoGrow, resolvedRef])
const describedBy = [error ? errorId : null, helper ? helperId : null]
.filter(Boolean)
.join(' ')
const isOverLimit = maxCount !== undefined && charCount > maxCount
return (
<div className="flex flex-col gap-1.5">
{label && (
<label htmlFor={textareaId} className="text-sm font-medium text-surface-200">
{label}
</label>
)}
<textarea
id={textareaId}
ref={resolvedRef}
value={value}
onChange={handleChange}
aria-describedby={describedBy || undefined}
aria-invalid={error ? true : undefined}
className={cn(
'flex min-h-[80px] w-full rounded-md border bg-surface-900 px-3 py-2 text-sm text-surface-100',
'border-surface-700 placeholder:text-surface-500',
'transition-colors duration-[var(--transition-fast)] resize-none',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:border-transparent',
'disabled:cursor-not-allowed disabled:opacity-50',
autoGrow && 'overflow-hidden',
error && 'border-red-500 focus-visible:ring-red-500',
className
)}
{...props}
/>
<div className="flex items-start justify-between gap-2">
<div>
{error && (
<p id={errorId} className="text-xs text-red-400" role="alert">
{error}
</p>
)}
{helper && !error && (
<p id={helperId} className="text-xs text-surface-500">
{helper}
</p>
)}
</div>
{maxCount !== undefined && (
<span
className={cn(
'text-xs tabular-nums ml-auto flex-shrink-0',
isOverLimit ? 'text-red-400' : 'text-surface-500'
)}
aria-live="polite"
>
{charCount}/{maxCount}
</span>
)}
</div>
</div>
)
}
)
Textarea.displayName = 'Textarea'
export { Textarea }

168
web/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,168 @@
'use client'
import * as React from 'react'
import * as RadixToast from '@radix-ui/react-toast'
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
// ── Types ────────────────────────────────────────────────────────────────────
export type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info'
export interface ToastData {
id: string
title: string
description?: string
variant?: ToastVariant
duration?: number
}
// ── Store (singleton for imperative toasts) ───────────────────────────────────
type Listener = (toasts: ToastData[]) => void
let toastList: ToastData[] = []
const listeners = new Set<Listener>()
function emit() {
listeners.forEach((fn) => fn([...toastList]))
}
export const toast = {
show(data: Omit<ToastData, 'id'>) {
const id = Math.random().toString(36).slice(2, 9)
toastList = [...toastList, { id, ...data }]
emit()
return id
},
success(title: string, description?: string) {
return this.show({ title, description, variant: 'success' })
},
error(title: string, description?: string) {
return this.show({ title, description, variant: 'error' })
},
warning(title: string, description?: string) {
return this.show({ title, description, variant: 'warning' })
},
info(title: string, description?: string) {
return this.show({ title, description, variant: 'info' })
},
dismiss(id: string) {
toastList = toastList.filter((t) => t.id !== id)
emit()
},
}
function useToastStore() {
const [toasts, setToasts] = React.useState<ToastData[]>([])
React.useEffect(() => {
setToasts([...toastList])
listeners.add(setToasts)
return () => { listeners.delete(setToasts) }
}, [])
return toasts
}
// ── Style variants ────────────────────────────────────────────────────────────
const toastVariants = cva(
[
'group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden',
'rounded-lg border p-4 shadow-lg transition-all',
'data-[state=open]:animate-slide-up data-[state=closed]:animate-slide-down-out',
'data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)]',
'data-[swipe=cancel]:translate-x-0',
'data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=end]:animate-fade-out',
].join(' '),
{
variants: {
variant: {
default: 'bg-surface-800 border-surface-700 text-surface-100',
success: 'bg-surface-800 border-green-800/60 text-surface-100',
error: 'bg-surface-800 border-red-800/60 text-surface-100',
warning: 'bg-surface-800 border-yellow-800/60 text-surface-100',
info: 'bg-surface-800 border-blue-800/60 text-surface-100',
},
},
defaultVariants: { variant: 'default' },
}
)
const variantIcons: Record<ToastVariant, React.ReactNode> = {
default: null,
success: <CheckCircle2 className="h-4 w-4 text-green-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
error: <AlertCircle className="h-4 w-4 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
warning: <AlertTriangle className="h-4 w-4 text-yellow-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
info: <Info className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" aria-hidden="true" />,
}
// ── Single toast item ─────────────────────────────────────────────────────────
interface ToastItemProps extends VariantProps<typeof toastVariants> {
id: string
title: string
description?: string
duration?: number
}
function ToastItem({ id, title, description, variant = 'default', duration = 5000 }: ToastItemProps) {
const [open, setOpen] = React.useState(true)
const icon = variantIcons[variant ?? 'default']
return (
<RadixToast.Root
open={open}
onOpenChange={(o) => {
setOpen(o)
if (!o) setTimeout(() => toast.dismiss(id), 300)
}}
duration={duration}
className={cn(toastVariants({ variant }))}
>
{icon}
<div className="flex-1 min-w-0">
<RadixToast.Title className="text-sm font-medium leading-snug">{title}</RadixToast.Title>
{description && (
<RadixToast.Description className="mt-0.5 text-xs text-surface-400 leading-relaxed">
{description}
</RadixToast.Description>
)}
</div>
<RadixToast.Close
className="flex-shrink-0 text-surface-500 hover:text-surface-200 transition-colors rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Dismiss notification"
>
<X className="h-4 w-4" aria-hidden="true" />
</RadixToast.Close>
{/* Progress bar */}
<div
className="absolute bottom-0 left-0 h-0.5 w-full origin-left bg-current opacity-20"
style={{ animation: `progress ${duration}ms linear forwards` }}
aria-hidden="true"
/>
</RadixToast.Root>
)
}
// ── Provider (mount once in layout) ──────────────────────────────────────────
export function ToastProvider({ children }: { children: React.ReactNode }) {
const toasts = useToastStore()
return (
<RadixToast.Provider swipeDirection="right">
{children}
{toasts.map((t) => (
<ToastItem key={t.id} {...t} />
))}
<RadixToast.Viewport className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-80 focus:outline-none" />
</RadixToast.Provider>
)
}
// ── Hook (alternative to imperative API) ─────────────────────────────────────
export function useToast() {
return toast
}

View File

@@ -0,0 +1,57 @@
'use client'
import * as React from 'react'
import * as RadixTooltip from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = RadixTooltip.Provider
const Tooltip = RadixTooltip.Root
const TooltipTrigger = RadixTooltip.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof RadixTooltip.Content>,
React.ComponentPropsWithoutRef<typeof RadixTooltip.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<RadixTooltip.Portal>
<RadixTooltip.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md border border-surface-700',
'bg-surface-800 px-3 py-1.5 text-xs text-surface-100 shadow-md',
'animate-scale-in data-[state=closed]:animate-scale-out',
className
)}
{...props}
/>
</RadixTooltip.Portal>
))
TooltipContent.displayName = RadixTooltip.Content.displayName
// Convenience wrapper
interface SimpleTooltipProps {
content: React.ReactNode
children: React.ReactNode
side?: 'top' | 'right' | 'bottom' | 'left'
delayDuration?: number
asChild?: boolean
}
function SimpleTooltip({
content,
children,
side = 'top',
delayDuration = 400,
asChild = false,
}: SimpleTooltipProps) {
return (
<TooltipProvider delayDuration={delayDuration}>
<Tooltip>
<TooltipTrigger asChild={asChild}>{children}</TooltipTrigger>
<TooltipContent side={side}>{content}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, SimpleTooltip }

65
web/hooks/useAriaLive.ts Normal file
View File

@@ -0,0 +1,65 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
interface UseAriaLiveOptions {
politeness?: "polite" | "assertive";
/** Delay in ms before the message is injected — allows the region to reset first */
delay?: number;
}
interface UseAriaLiveReturn {
/** The current announcement string — render this inside an aria-live region */
announcement: string;
/** Call this to trigger a new announcement */
announce: (message: string) => void;
/** Props to spread onto your aria-live container element */
liveRegionProps: {
role: "status";
"aria-live": "polite" | "assertive";
"aria-atomic": true;
};
}
/**
* Hook-based aria-live region manager.
* Returns an `announcement` string to render inside a visually-hidden container
* and an `announce` function to update it.
*
* @example
* const { announcement, announce, liveRegionProps } = useAriaLive();
*
* // Trigger
* announce("Message sent");
*
* // Render (visually hidden)
* <div {...liveRegionProps} className="sr-only">{announcement}</div>
*/
export function useAriaLive({
politeness = "polite",
delay = 50,
}: UseAriaLiveOptions = {}): UseAriaLiveReturn {
const [announcement, setAnnouncement] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const announce = useCallback(
(message: string) => {
setAnnouncement("");
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setAnnouncement(message), delay);
},
[delay]
);
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
return {
announcement,
announce,
liveRegionProps: {
role: "status",
"aria-live": politeness,
"aria-atomic": true,
},
};
}

View File

@@ -0,0 +1,348 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { CollabSocket } from "@/lib/collaboration/socket";
import type {
CollabUser,
CollabRole,
ToolUsePendingEvent,
AnnotationAddedEvent,
AnnotationReplyEvent,
} from "@/lib/collaboration/socket";
import type { CollabAnnotation, PendingToolUse } from "@/lib/collaboration/types";
// ─── Options ──────────────────────────────────────────────────────────────────
export interface UseCollaborationOptions {
sessionId: string;
currentUser: CollabUser;
wsUrl?: string;
}
// ─── State ────────────────────────────────────────────────────────────────────
export interface CollaborationState {
isConnected: boolean;
myRole: CollabRole | null;
pendingToolUses: PendingToolUse[];
annotations: Record<string, CollabAnnotation[]>; // messageId → annotations
toolApprovalPolicy: "owner-only" | "any-collaborator";
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useCollaboration({
sessionId,
currentUser,
wsUrl,
}: UseCollaborationOptions) {
const socketRef = useRef<CollabSocket | null>(null);
const [state, setState] = useState<CollaborationState>({
isConnected: false,
myRole: null,
pendingToolUses: [],
annotations: {},
toolApprovalPolicy: "any-collaborator",
});
const effectiveWsUrl =
wsUrl ??
(typeof process !== "undefined"
? process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:3001"
: "ws://localhost:3001");
useEffect(() => {
const socket = new CollabSocket(sessionId, currentUser.id);
socketRef.current = socket;
socket.onConnectionChange = (connected) => {
setState((s) => ({ ...s, isConnected: connected }));
};
const cleanup: Array<() => void> = [];
cleanup.push(
socket.on("session_state", (e) => {
const me = e.users.find((u) => u.id === currentUser.id);
setState((s) => ({
...s,
myRole: me?.role ?? null,
toolApprovalPolicy: e.toolApprovalPolicy,
}));
})
);
cleanup.push(
socket.on("tool_use_pending", (e: ToolUsePendingEvent) => {
const entry: PendingToolUse = {
id: e.toolUseId,
name: e.toolName,
input: e.toolInput,
messageId: e.messageId,
requestedAt: e.timestamp,
};
setState((s) => ({
...s,
pendingToolUses: [...s.pendingToolUses, entry],
}));
})
);
cleanup.push(
socket.on("tool_use_approved", (e) => {
setState((s) => ({
...s,
pendingToolUses: s.pendingToolUses.filter((t) => t.id !== e.toolUseId),
}));
})
);
cleanup.push(
socket.on("tool_use_denied", (e) => {
setState((s) => ({
...s,
pendingToolUses: s.pendingToolUses.filter((t) => t.id !== e.toolUseId),
}));
})
);
cleanup.push(
socket.on("role_changed", (e) => {
if (e.targetUserId === currentUser.id) {
setState((s) => ({ ...s, myRole: e.newRole }));
}
})
);
cleanup.push(
socket.on("access_revoked", (e) => {
if (e.targetUserId === currentUser.id) {
socket.disconnect();
setState((s) => ({ ...s, isConnected: false, myRole: null }));
}
})
);
cleanup.push(
socket.on("ownership_transferred", (e) => {
if (e.newOwnerId === currentUser.id) {
setState((s) => ({ ...s, myRole: "owner" }));
} else if (e.previousOwnerId === currentUser.id) {
setState((s) => ({ ...s, myRole: "collaborator" }));
}
})
);
cleanup.push(
socket.on("annotation_added", (e: AnnotationAddedEvent) => {
const ann: CollabAnnotation = { ...e.annotation };
setState((s) => ({
...s,
annotations: {
...s.annotations,
[ann.messageId]: [...(s.annotations[ann.messageId] ?? []), ann],
},
}));
})
);
cleanup.push(
socket.on("annotation_resolved", (e) => {
setState((s) => {
const next: Record<string, CollabAnnotation[]> = {};
for (const [msgId, anns] of Object.entries(s.annotations)) {
next[msgId] = anns.map((a) =>
a.id === e.annotationId ? { ...a, resolved: e.resolved } : a
);
}
return { ...s, annotations: next };
});
})
);
cleanup.push(
socket.on("annotation_reply", (e: AnnotationReplyEvent) => {
setState((s) => {
const next: Record<string, CollabAnnotation[]> = {};
for (const [msgId, anns] of Object.entries(s.annotations)) {
next[msgId] = anns.map((a) =>
a.id === e.annotationId
? { ...a, replies: [...a.replies, e.reply] }
: a
);
}
return { ...s, annotations: next };
});
})
);
socket.connect(`${effectiveWsUrl}/collab`);
return () => {
cleanup.forEach((off) => off());
socket.disconnect();
};
}, [sessionId, currentUser.id, effectiveWsUrl]);
// ─── Actions ───────────────────────────────────────────────────────────────
const approveTool = useCallback(
(toolUseId: string) => {
socketRef.current?.send({
type: "tool_use_approved",
sessionId,
userId: currentUser.id,
toolUseId,
approvedBy: currentUser,
});
},
[sessionId, currentUser]
);
const denyTool = useCallback(
(toolUseId: string) => {
socketRef.current?.send({
type: "tool_use_denied",
sessionId,
userId: currentUser.id,
toolUseId,
deniedBy: currentUser,
});
},
[sessionId, currentUser]
);
const addAnnotation = useCallback(
(messageId: string, text: string, parentId?: string) => {
const annotation: CollabAnnotation = {
id: crypto.randomUUID(),
messageId,
parentId,
text,
author: currentUser,
createdAt: Date.now(),
resolved: false,
replies: [],
};
// Optimistic update
setState((s) => ({
...s,
annotations: {
...s.annotations,
[messageId]: [...(s.annotations[messageId] ?? []), annotation],
},
}));
socketRef.current?.send({
type: "annotation_added",
sessionId,
userId: currentUser.id,
annotation,
});
},
[sessionId, currentUser]
);
const resolveAnnotation = useCallback(
(annotationId: string, resolved: boolean) => {
setState((s) => {
const next: Record<string, CollabAnnotation[]> = {};
for (const [msgId, anns] of Object.entries(s.annotations)) {
next[msgId] = anns.map((a) =>
a.id === annotationId ? { ...a, resolved } : a
);
}
return { ...s, annotations: next };
});
socketRef.current?.send({
type: "annotation_resolved",
sessionId,
userId: currentUser.id,
annotationId,
resolved,
resolvedBy: currentUser,
});
},
[sessionId, currentUser]
);
const replyAnnotation = useCallback(
(annotationId: string, text: string) => {
const reply = {
id: crypto.randomUUID(),
text,
author: currentUser,
createdAt: Date.now(),
};
// Optimistic update
setState((s) => {
const next: Record<string, CollabAnnotation[]> = {};
for (const [msgId, anns] of Object.entries(s.annotations)) {
next[msgId] = anns.map((a) =>
a.id === annotationId
? { ...a, replies: [...a.replies, reply] }
: a
);
}
return { ...s, annotations: next };
});
socketRef.current?.send({
type: "annotation_reply",
sessionId,
userId: currentUser.id,
annotationId,
reply,
});
},
[sessionId, currentUser]
);
const revokeAccess = useCallback(
(targetUserId: string) => {
socketRef.current?.send({
type: "access_revoked",
sessionId,
userId: currentUser.id,
targetUserId,
});
},
[sessionId, currentUser.id]
);
const changeRole = useCallback(
(targetUserId: string, newRole: CollabRole) => {
socketRef.current?.send({
type: "role_changed",
sessionId,
userId: currentUser.id,
targetUserId,
newRole,
});
},
[sessionId, currentUser.id]
);
const transferOwnership = useCallback(
(newOwnerId: string) => {
socketRef.current?.send({
type: "ownership_transferred",
sessionId,
userId: currentUser.id,
newOwnerId,
previousOwnerId: currentUser.id,
});
},
[sessionId, currentUser.id]
);
return {
...state,
approveTool,
denyTool,
addAnnotation,
resolveAnnotation,
replyAnnotation,
revokeAccess,
changeRole,
transferOwnership,
};
}

View File

@@ -0,0 +1,137 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import type { Command } from "@/lib/shortcuts";
const RECENT_MAX = 5;
const RECENT_KEY = "claude-code-recent-commands";
interface CommandRegistryContextValue {
/** Live list of all registered commands for UI rendering */
commands: Command[];
/** Ref always pointing to the latest commands list — use in event handlers */
commandsRef: React.MutableRefObject<Command[]>;
registerCommand: (cmd: Command) => () => void;
/** Run a command by id and record it as recently used */
runCommand: (id: string) => void;
paletteOpen: boolean;
openPalette: () => void;
closePalette: () => void;
helpOpen: boolean;
openHelp: () => void;
closeHelp: () => void;
recentCommandIds: string[];
}
const CommandRegistryContext = createContext<CommandRegistryContextValue>({
commands: [],
commandsRef: { current: [] },
registerCommand: () => () => {},
runCommand: () => {},
paletteOpen: false,
openPalette: () => {},
closePalette: () => {},
helpOpen: false,
openHelp: () => {},
closeHelp: () => {},
recentCommandIds: [],
});
export function CommandRegistryProvider({
children,
}: {
children: React.ReactNode;
}) {
const [commands, setCommands] = useState<Command[]>([]);
const commandsRef = useRef<Command[]>([]);
const [paletteOpen, setPaletteOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [recentCommandIds, setRecentCommandIds] = useState<string[]>(() => {
if (typeof window === "undefined") return [];
try {
return JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]");
} catch {
return [];
}
});
const registerCommand = useCallback((cmd: Command) => {
setCommands((prev) => {
const next = [...prev.filter((c) => c.id !== cmd.id), cmd];
commandsRef.current = next;
return next;
});
return () => {
setCommands((prev) => {
const next = prev.filter((c) => c.id !== cmd.id);
commandsRef.current = next;
return next;
});
};
}, []);
const addToRecent = useCallback((id: string) => {
setRecentCommandIds((prev) => {
const next = [id, ...prev.filter((r) => r !== id)].slice(0, RECENT_MAX);
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
} catch {}
return next;
});
}, []);
const runCommand = useCallback(
(id: string) => {
const cmd = commandsRef.current.find((c) => c.id === id);
if (!cmd) return;
if (cmd.when && !cmd.when()) return;
addToRecent(id);
cmd.action();
},
[addToRecent]
);
const openPalette = useCallback(() => setPaletteOpen(true), []);
const closePalette = useCallback(() => setPaletteOpen(false), []);
const openHelp = useCallback(() => setHelpOpen(true), []);
const closeHelp = useCallback(() => setHelpOpen(false), []);
// Keep commandsRef in sync when state updates
useEffect(() => {
commandsRef.current = commands;
}, [commands]);
return (
<CommandRegistryContext.Provider
value={{
commands,
commandsRef,
registerCommand,
runCommand,
paletteOpen,
openPalette,
closePalette,
helpOpen,
openHelp,
closeHelp,
recentCommandIds,
}}
>
{children}
</CommandRegistryContext.Provider>
);
}
export function useCommandRegistry() {
return useContext(CommandRegistryContext);
}

View File

@@ -0,0 +1,15 @@
import { useChatStore } from "@/lib/store";
export function useConversation(id: string) {
const { conversations, addMessage, updateMessage, deleteConversation } = useChatStore();
const conversation = conversations.find((c) => c.id === id) ?? null;
return {
conversation,
messages: conversation?.messages ?? [],
addMessage: (msg: Parameters<typeof addMessage>[1]) => addMessage(id, msg),
updateMessage: (msgId: string, updates: Parameters<typeof updateMessage>[2]) =>
updateMessage(id, msgId, updates),
deleteConversation: () => deleteConversation(id),
};
}

View File

@@ -0,0 +1,40 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
/**
* Saves the currently focused element and returns a function to restore focus to it.
* Use when opening modals/dialogs to return focus to the trigger on close.
*
* @example
* const returnFocus = useFocusReturn();
*
* const openDialog = () => {
* returnFocus.save(); // call before showing the dialog
* setOpen(true);
* };
*
* const closeDialog = () => {
* setOpen(false);
* returnFocus.restore(); // call after hiding the dialog
* };
*/
export function useFocusReturn() {
const savedRef = useRef<HTMLElement | null>(null);
const save = useCallback(() => {
savedRef.current = document.activeElement as HTMLElement | null;
}, []);
const restore = useCallback(() => {
if (savedRef.current && typeof savedRef.current.focus === "function") {
savedRef.current.focus();
savedRef.current = null;
}
}, []);
// Safety cleanup on unmount
useEffect(() => () => { savedRef.current = null; }, []);
return { save, restore };
}

View File

@@ -0,0 +1,101 @@
"use client";
import { useEffect, useRef } from "react";
import { parseKey, matchesEvent } from "@/lib/keyParser";
import { useCommandRegistry } from "./useCommandRegistry";
const SEQUENCE_TIMEOUT_MS = 1000;
/** Tags whose focus should suppress non-global shortcuts */
const INPUT_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
function isTypingTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
if (INPUT_TAGS.has(el.tagName)) return true;
if (el.isContentEditable) return true;
return false;
}
/**
* Attaches a global keydown listener that fires registered commands.
* Supports single combos ("mod+k") and two-key sequences ("g d").
* Must be used inside a CommandRegistryProvider.
*/
export function useKeyboardShortcuts() {
const { commandsRef } = useCommandRegistry();
const pendingSequenceRef = useRef<string | null>(null);
const sequenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const clearSequence = () => {
pendingSequenceRef.current = null;
if (sequenceTimerRef.current) {
clearTimeout(sequenceTimerRef.current);
sequenceTimerRef.current = null;
}
};
const handler = (e: KeyboardEvent) => {
// Ignore bare modifier keypresses
if (["Meta", "Control", "Shift", "Alt"].includes(e.key)) return;
const inInput = isTypingTarget(e.target);
const commands = commandsRef.current;
// --- Sequence matching (e.g. "g" then "d") ---
if (pendingSequenceRef.current) {
const seq = `${pendingSequenceRef.current} ${e.key.toLowerCase()}`;
const match = commands.find(
(cmd) =>
(!inInput || cmd.global) &&
(!cmd.when || cmd.when()) &&
cmd.keys.includes(seq)
);
clearSequence();
if (match) {
e.preventDefault();
match.action();
return;
}
}
// --- Single combo matching ---
const singleMatch = commands.find((cmd) => {
if (inInput && !cmd.global) return false;
if (cmd.when && !cmd.when()) return false;
return cmd.keys.some((k) => {
// Sequence keys contain a space; skip them in the single pass
if (k.includes(" ")) return false;
return matchesEvent(parseKey(k), e);
});
});
if (singleMatch) {
e.preventDefault();
singleMatch.action();
return;
}
// --- Start-of-sequence detection (single bare key that starts a sequence) ---
// Only when not in an input and no modifier held
if (!inInput && !e.metaKey && !e.ctrlKey && !e.altKey) {
const keyLower = e.key.toLowerCase();
const startsSequence = commands.some((cmd) =>
cmd.keys.some((k) => k.includes(" ") && k.startsWith(keyLower + " "))
);
if (startsSequence) {
e.preventDefault();
clearSequence();
pendingSequenceRef.current = keyLower;
sequenceTimerRef.current = setTimeout(clearSequence, SEQUENCE_TIMEOUT_MS);
}
}
};
document.addEventListener("keydown", handler);
return () => {
document.removeEventListener("keydown", handler);
clearSequence();
};
}, [commandsRef]);
}

View File

@@ -0,0 +1,32 @@
"use client";
import { useState, useEffect } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mq = window.matchMedia(query);
setMatches(mq.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [query]);
return matches;
}
/** < 768px */
export function useIsMobile(): boolean {
return useMediaQuery("(max-width: 767px)");
}
/** 768px 1023px */
export function useIsTablet(): boolean {
return useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
}
/** >= 1024px */
export function useIsDesktop(): boolean {
return useMediaQuery("(min-width: 1024px)");
}

View File

@@ -0,0 +1,54 @@
"use client";
import { useCallback } from "react";
import { useNotificationStore, type NotificationCategory } from "@/lib/notifications";
import { browserNotifications } from "@/lib/browser-notifications";
export interface NotifyOptions {
title: string;
description: string;
category: NotificationCategory;
link?: string;
browserNotification?: boolean;
}
export function useNotifications() {
const notifications = useNotificationStore((s) => s.notifications);
const browserNotificationsEnabled = useNotificationStore(
(s) => s.browserNotificationsEnabled
);
const addNotification = useNotificationStore((s) => s.addNotification);
const markRead = useNotificationStore((s) => s.markRead);
const markAllRead = useNotificationStore((s) => s.markAllRead);
const clearHistory = useNotificationStore((s) => s.clearHistory);
const notify = useCallback(
async (options: NotifyOptions) => {
addNotification({
title: options.title,
description: options.description,
category: options.category,
link: options.link,
});
if (options.browserNotification && browserNotificationsEnabled) {
await browserNotifications.send({
title: options.title,
body: options.description,
});
}
},
[addNotification, browserNotificationsEnabled]
);
const unreadCount = notifications.filter((n) => !n.read).length;
return {
notifications,
unreadCount,
notify,
markRead,
markAllRead,
clearHistory,
};
}

154
web/hooks/usePresence.ts Normal file
View File

@@ -0,0 +1,154 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
createPresenceState,
presenceAddUser,
presenceRemoveUser,
presenceSyncUsers,
presenceUpdateCursor,
presenceSetTyping,
} from "@/lib/collaboration/presence";
import type { PresenceState } from "@/lib/collaboration/presence";
import type { CollabSocket } from "@/lib/collaboration/socket";
import type { CollabUser } from "@/lib/collaboration/socket";
// ─── Options ──────────────────────────────────────────────────────────────────
export interface UsePresenceOptions {
socket: CollabSocket | null;
sessionId: string;
currentUser: CollabUser;
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function usePresence({ socket, sessionId, currentUser }: UsePresenceOptions) {
const [presence, setPresence] = useState<PresenceState>(createPresenceState);
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isTypingRef = useRef(false);
useEffect(() => {
if (!socket) return;
const cleanup: Array<() => void> = [];
cleanup.push(
socket.on("presence_sync", (e) => {
setPresence((s) => presenceSyncUsers(s, e.users));
})
);
cleanup.push(
socket.on("session_state", (e) => {
setPresence((s) => presenceSyncUsers(s, e.users));
})
);
cleanup.push(
socket.on("user_joined", (e) => {
setPresence((s) => presenceAddUser(s, e.user));
})
);
cleanup.push(
socket.on("user_left", (e) => {
setPresence((s) => presenceRemoveUser(s, e.user.id));
})
);
cleanup.push(
socket.on("cursor_update", (e) => {
if (e.userId === currentUser.id) return; // skip own cursor
setPresence((s) =>
presenceUpdateCursor(s, e.userId, e.position, e.selectionStart, e.selectionEnd)
);
})
);
cleanup.push(
socket.on("typing_start", (e) => {
if (e.userId === currentUser.id) return;
setPresence((s) => presenceSetTyping(s, e.user.id, true));
})
);
cleanup.push(
socket.on("typing_stop", (e) => {
if (e.userId === currentUser.id) return;
setPresence((s) => presenceSetTyping(s, e.user.id, false));
})
);
return () => cleanup.forEach((off) => off());
}, [socket, currentUser.id]);
// ─── Actions ─────────────────────────────────────────────────────────────
const sendCursorUpdate = useCallback(
(position: number, selectionStart?: number, selectionEnd?: number) => {
socket?.send({
type: "cursor_update",
sessionId,
userId: currentUser.id,
position,
selectionStart,
selectionEnd,
});
},
[socket, sessionId, currentUser.id]
);
// Call this whenever the user types — auto-sends typing_start + debounced typing_stop
const notifyTyping = useCallback(() => {
if (!isTypingRef.current) {
isTypingRef.current = true;
socket?.send({
type: "typing_start",
sessionId,
userId: currentUser.id,
user: currentUser,
});
}
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
typingTimerRef.current = setTimeout(() => {
isTypingRef.current = false;
socket?.send({
type: "typing_stop",
sessionId,
userId: currentUser.id,
user: currentUser,
});
}, 2_000);
}, [socket, sessionId, currentUser]);
const stopTyping = useCallback(() => {
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
if (isTypingRef.current) {
isTypingRef.current = false;
socket?.send({
type: "typing_stop",
sessionId,
userId: currentUser.id,
user: currentUser,
});
}
}, [socket, sessionId, currentUser]);
// Derived helpers
const otherUsers = Array.from(presence.users.values()).filter(
(u) => u.id !== currentUser.id
);
const typingUsers = Array.from(presence.typing)
.filter((id) => id !== currentUser.id)
.map((id) => presence.users.get(id))
.filter((u): u is CollabUser => u !== undefined);
return {
presence,
otherUsers,
typingUsers,
sendCursorUpdate,
notifyTyping,
stopTyping,
};
}

View File

@@ -0,0 +1,27 @@
"use client";
import { useEffect, useState } from "react";
/**
* Returns true when the user has requested reduced motion via OS settings.
* Use this to disable or simplify animations for users who need it.
*
* @example
* const reducedMotion = useReducedMotion();
* <div className={reducedMotion ? "" : "animate-fade-in"}>...</div>
*/
export function useReducedMotion(): boolean {
const [reducedMotion, setReducedMotion] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
});
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return reducedMotion;
}

1
web/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1 @@
export { useTheme } from "@/components/layout/ThemeProvider";

Some files were not shown because too many files have changed in this diff Show More