♻️ feat: implement session management for PTY sessions in the server

- Add SessionManager class to handle PTY sessions with WebSocket connections.
- Implement methods for creating, retrieving, and destroying sessions.
- Handle PTY output and WebSocket messages for terminal interaction.
- Ensure graceful session destruction and cleanup.

feat: initialize web application with Next.js and Tailwind CSS

- Create initial Next.js application structure with TypeScript support.
- Set up Tailwind CSS for styling with custom theme configurations.
- Add ESLint configuration for code quality and consistency.

feat: implement chat API and UI components

- Create chat API route to handle chat requests and responses.
- Develop chat layout with sidebar, header, chat window, and input components.
- Integrate Zustand for state management of conversations and messages.
- Add utility functions for formatting dates and managing class names.

chore: add environment variables and configuration files

- Create .env.example for environment variable setup.
- Add configuration files for PostCSS, Tailwind CSS, and TypeScript.
- Set up package.json with necessary dependencies and scripts for development.
This commit is contained in:
nirholas
2026-03-31 12:35:31 +00:00
parent d31c2bec03
commit 38648ae5f4
53 changed files with 4177 additions and 4 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 });
}
}

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

@@ -0,0 +1,78 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--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: 210 20% 98%;
--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%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 20% 98%;
--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%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-surface-300 dark:bg-surface-700 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-surface-400 dark:bg-surface-600;
}

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

@@ -0,0 +1,56 @@
import type { Metadata } 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/ui/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,174 @@
"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"
title="Attach file"
>
<Paperclip className="w-4 h-4" />
</button>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => {
setInput(e.target.value.slice(0, MAX_MESSAGE_LENGTH));
adjustHeight();
}}
onKeyDown={handleKeyDown}
placeholder="Message Claude Code..."
rows={1}
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}
className="p-1.5 rounded-lg bg-surface-700 text-surface-300 hover:bg-surface-600 transition-colors flex-shrink-0"
title="Stop generation"
>
<Square className="w-4 h-4" />
</button>
) : (
<button
onClick={handleSubmit}
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"
)}
title="Send message"
>
<Send className="w-4 h-4" />
</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,39 @@
"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";
export function ChatLayout() {
const { conversations, createConversation, activeConversationId } = useChatStore();
useEffect(() => {
if (conversations.length === 0) {
createConversation();
}
}, []);
return (
<div className="flex h-screen bg-surface-950 text-surface-100">
<Sidebar />
<div className="flex flex-col flex-1 min-w-0">
<Header />
<main 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>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { useEffect, useRef } 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 ?? [];
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages.length]);
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">
<Bot className="w-6 h-6 text-brand-400" />
</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">
<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} />
</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,73 @@
"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 (
<div
className={cn(
"flex gap-3 animate-fade-in",
isUser && "flex-row-reverse"
)}
>
{/* Avatar */}
<div
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" />
) : isError ? (
<AlertCircle className="w-4 h-4" />
) : (
<Bot className="w-4 h-4" />
)}
</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 className="inline-block w-1.5 h-4 bg-current ml-0.5 animate-pulse-soft" />
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
"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";
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 */}
<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>
{/* Theme toggle */}
<button
onClick={() => setTheme(nextTheme)}
className="p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors"
title={`Switch to ${nextTheme} theme`}
>
<ThemeIcon className="w-4 h-4" />
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { Plus, MessageSquare, Trash2, Settings } from "lucide-react";
import { useChatStore } from "@/lib/store";
import { cn, formatDate, truncate } from "@/lib/utils";
interface SidebarProps {
className?: string;
}
export function Sidebar({ className }: SidebarProps) {
const {
conversations,
activeConversationId,
createConversation,
setActiveConversation,
deleteConversation,
} = useChatStore();
const [hoveredId, setHoveredId] = useState<string | null>(null);
return (
<aside
className={cn(
"flex flex-col h-full bg-surface-900 border-r border-surface-800 w-64",
className
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-surface-800">
<span className="text-sm font-semibold text-surface-100">Claude Code</span>
<button
onClick={createConversation}
className="p-1.5 rounded-md text-surface-400 hover:text-surface-100 hover:bg-surface-800 transition-colors"
title="New conversation"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Conversation list */}
<nav className="flex-1 overflow-y-auto py-2">
{conversations.length === 0 ? (
<div className="px-4 py-8 text-center text-surface-500 text-sm">
No conversations yet
</div>
) : (
conversations.map((conv) => (
<div
key={conv.id}
className={cn(
"group relative flex items-center px-3 py-2 mx-2 rounded-md cursor-pointer",
"hover:bg-surface-800 transition-colors",
activeConversationId === conv.id && "bg-surface-800"
)}
onClick={() => setActiveConversation(conv.id)}
onMouseEnter={() => setHoveredId(conv.id)}
onMouseLeave={() => setHoveredId(null)}
>
<MessageSquare className="w-3.5 h-3.5 text-surface-500 flex-shrink-0 mr-2" />
<div className="flex-1 min-w-0">
<p className="text-sm text-surface-200 truncate">
{truncate(conv.title, 30)}
</p>
<p className="text-xs text-surface-500">{formatDate(conv.updatedAt)}</p>
</div>
{hoveredId === conv.id && (
<button
onClick={(e) => {
e.stopPropagation();
deleteConversation(conv.id);
}}
className="p-1 rounded text-surface-500 hover:text-red-400 hover:bg-surface-700 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
))
)}
</nav>
{/* Footer */}
<div className="px-4 py-3 border-t border-surface-800">
<button className="flex items-center gap-2 text-sm text-surface-400 hover:text-surface-100 transition-colors w-full">
<Settings className="w-4 h-4" />
<span>Settings</span>
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,56 @@
"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);
document.documentElement.classList.toggle("dark", resolved === "dark");
};
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,48 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
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",
secondary: "bg-surface-800 text-surface-100 hover:bg-surface-700",
ghost: "hover:bg-surface-800 hover:text-surface-100 text-surface-400",
link: "text-brand-400 underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,66 @@
"use client";
import * as Toast from "@radix-ui/react-toast";
import { createContext, useContext, useState, useCallback } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
interface ToastMessage {
id: string;
title: string;
description?: string;
variant?: "default" | "destructive";
}
interface ToastContextValue {
toast: (message: Omit<ToastMessage, "id">) => void;
}
const ToastContext = createContext<ToastContextValue>({ toast: () => {} });
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const toast = useCallback((message: Omit<ToastMessage, "id">) => {
const id = Math.random().toString(36).slice(2);
setToasts((prev) => [...prev, { ...message, id }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 5000);
}, []);
return (
<ToastContext.Provider value={{ toast }}>
<Toast.Provider swipeDirection="right">
{children}
{toasts.map((t) => (
<Toast.Root
key={t.id}
className={cn(
"flex items-start gap-3 p-4 rounded-lg shadow-lg border",
"bg-surface-800 border-surface-700 text-surface-100",
"data-[state=open]:animate-slide-up",
t.variant === "destructive" && "border-red-800 bg-red-950"
)}
open
>
<div className="flex-1">
<Toast.Title className="text-sm font-medium">{t.title}</Toast.Title>
{t.description && (
<Toast.Description className="text-xs text-surface-400 mt-0.5">
{t.description}
</Toast.Description>
)}
</div>
<Toast.Close className="text-surface-500 hover:text-surface-100">
<X className="w-4 h-4" />
</Toast.Close>
</Toast.Root>
))}
<Toast.Viewport className="fixed bottom-4 right-4 flex flex-col gap-2 w-80 z-50" />
</Toast.Provider>
</ToastContext.Provider>
);
}
export const useToast = () => useContext(ToastContext);

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),
};
}

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

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

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

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

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

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

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

@@ -0,0 +1,123 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { nanoid } from "nanoid";
import type { Conversation, Message, AppSettings } from "./types";
import { DEFAULT_MODEL } from "./constants";
interface ChatState {
conversations: Conversation[];
activeConversationId: string | null;
settings: AppSettings;
// Actions
createConversation: () => string;
setActiveConversation: (id: string) => void;
deleteConversation: (id: string) => void;
addMessage: (conversationId: string, message: Omit<Message, "id" | "createdAt">) => string;
updateMessage: (conversationId: string, messageId: string, updates: Partial<Message>) => void;
updateSettings: (settings: Partial<AppSettings>) => void;
getActiveConversation: () => Conversation | null;
}
export const useChatStore = create<ChatState>()(
persist(
(set, get) => ({
conversations: [],
activeConversationId: null,
settings: {
theme: "dark",
model: DEFAULT_MODEL,
apiUrl: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001",
streamingEnabled: true,
},
createConversation: () => {
const id = nanoid();
const now = Date.now();
const conversation: Conversation = {
id,
title: "New conversation",
messages: [],
createdAt: now,
updatedAt: now,
model: get().settings.model,
};
set((state) => ({
conversations: [conversation, ...state.conversations],
activeConversationId: id,
}));
return id;
},
setActiveConversation: (id) => {
set({ activeConversationId: id });
},
deleteConversation: (id) => {
set((state) => {
const remaining = state.conversations.filter((c) => c.id !== id);
const nextActive =
state.activeConversationId === id
? (remaining[0]?.id ?? null)
: state.activeConversationId;
return { conversations: remaining, activeConversationId: nextActive };
});
},
addMessage: (conversationId, message) => {
const id = nanoid();
const now = Date.now();
set((state) => ({
conversations: state.conversations.map((c) =>
c.id === conversationId
? {
...c,
messages: [...c.messages, { ...message, id, createdAt: now }],
updatedAt: now,
}
: c
),
}));
return id;
},
updateMessage: (conversationId, messageId, updates) => {
set((state) => ({
conversations: state.conversations.map((c) =>
c.id === conversationId
? {
...c,
messages: c.messages.map((m) =>
m.id === messageId ? { ...m, ...updates } : m
),
updatedAt: Date.now(),
}
: c
),
}));
},
updateSettings: (settings) => {
set((state) => ({
settings: { ...state.settings, ...settings },
}));
},
getActiveConversation: () => {
const state = get();
return (
state.conversations.find((c) => c.id === state.activeConversationId) ??
null
);
},
}),
{
name: "claude-code-chat",
partialize: (state) => ({
conversations: state.conversations,
activeConversationId: state.activeConversationId,
settings: state.settings,
}),
}
)
);

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

@@ -0,0 +1,59 @@
export type MessageRole = "user" | "assistant" | "system" | "tool";
export type MessageStatus = "pending" | "streaming" | "complete" | "error";
export interface TextContent {
type: "text";
text: string;
}
export interface ToolUseContent {
type: "tool_use";
id: string;
name: string;
input: Record<string, unknown>;
}
export interface ToolResultContent {
type: "tool_result";
tool_use_id: string;
content: string | ContentBlock[];
is_error?: boolean;
}
export type ContentBlock = TextContent | ToolUseContent | ToolResultContent;
export interface Message {
id: string;
role: MessageRole;
content: ContentBlock[] | string;
status: MessageStatus;
createdAt: number;
model?: string;
usage?: {
input_tokens: number;
output_tokens: number;
};
}
export interface Conversation {
id: string;
title: string;
messages: Message[];
createdAt: number;
updatedAt: number;
model?: string;
}
export interface ToolDefinition {
name: string;
description: string;
input_schema: Record<string, unknown>;
}
export interface AppSettings {
theme: "light" | "dark" | "system";
model: string;
apiUrl: string;
streamingEnabled: boolean;
}

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

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

10
web/next.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
experimental: {
typedRoutes: true,
},
};
export default nextConfig;

46
web/package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "claude-code-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"zustand": "^4.5.0",
"swr": "^2.2.0",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.0",
"framer-motion": "^11.0.0",
"lucide-react": "^0.400.0",
"shiki": "^1.10.0",
"react-markdown": "^9.0.0",
"remark-gfm": "^4.0.0",
"nanoid": "^5.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.3.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "^14.2.0",
"postcss": "^8",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10",
"typescript": "^5"
}
}

6
web/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

0
web/public/.gitkeep Normal file
View File

69
web/tailwind.config.ts Normal file
View File

@@ -0,0 +1,69 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
brand: {
50: "#f5f3ff",
100: "#ede9fe",
200: "#ddd6fe",
300: "#c4b5fd",
400: "#a78bfa",
500: "#8b5cf6",
600: "#7c3aed",
700: "#6d28d9",
800: "#5b21b6",
900: "#4c1d95",
950: "#2e1065",
},
surface: {
50: "#fafafa",
100: "#f4f4f5",
200: "#e4e4e7",
300: "#d4d4d8",
400: "#a1a1aa",
500: "#71717a",
600: "#52525b",
700: "#3f3f46",
800: "#27272a",
850: "#1f1f23",
900: "#18181b",
950: "#09090b",
},
},
fontFamily: {
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
mono: ["var(--font-jetbrains-mono)", "ui-monospace", "monospace"],
},
animation: {
"fade-in": "fadeIn 0.2s ease-in-out",
"slide-up": "slideUp 0.3s ease-out",
"pulse-soft": "pulseSoft 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
slideUp: {
"0%": { transform: "translateY(8px)", opacity: "0" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
pulseSoft: {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.5" },
},
},
},
},
plugins: [],
};
export default config;

27
web/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}