♻️ 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:
3
web/.env.example
Normal file
3
web/.env.example
Normal 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
3
web/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
36
web/app/api/chat/route.ts
Normal file
36
web/app/api/chat/route.ts
Normal 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
78
web/app/globals.css
Normal 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
56
web/app/layout.tsx
Normal 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
5
web/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ChatLayout } from "@/components/chat/ChatLayout";
|
||||
|
||||
export default function Home() {
|
||||
return <ChatLayout />;
|
||||
}
|
||||
174
web/components/chat/ChatInput.tsx
Normal file
174
web/components/chat/ChatInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
web/components/chat/ChatLayout.tsx
Normal file
39
web/components/chat/ChatLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
web/components/chat/ChatWindow.tsx
Normal file
48
web/components/chat/ChatWindow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
web/components/chat/MarkdownContent.tsx
Normal file
32
web/components/chat/MarkdownContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
web/components/chat/MessageBubble.tsx
Normal file
73
web/components/chat/MessageBubble.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
web/components/layout/Header.tsx
Normal file
56
web/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
web/components/layout/Sidebar.tsx
Normal file
93
web/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
web/components/layout/ThemeProvider.tsx
Normal file
56
web/components/layout/ThemeProvider.tsx
Normal 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);
|
||||
48
web/components/ui/Button.tsx
Normal file
48
web/components/ui/Button.tsx
Normal 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 };
|
||||
66
web/components/ui/ToastProvider.tsx
Normal file
66
web/components/ui/ToastProvider.tsx
Normal 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);
|
||||
15
web/hooks/useConversation.ts
Normal file
15
web/hooks/useConversation.ts
Normal 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
1
web/hooks/useTheme.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useTheme } from "@/components/layout/ThemeProvider";
|
||||
85
web/lib/api.ts
Normal file
85
web/lib/api.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Message } from "./types";
|
||||
|
||||
const getApiUrl = () =>
|
||||
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
|
||||
|
||||
export interface StreamChunk {
|
||||
type: "text" | "tool_use" | "tool_result" | "done" | "error";
|
||||
content?: string;
|
||||
tool?: {
|
||||
id: string;
|
||||
name: string;
|
||||
input?: Record<string, unknown>;
|
||||
result?: string;
|
||||
is_error?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function* streamChat(
|
||||
messages: Pick<Message, "role" | "content">[],
|
||||
model: string,
|
||||
signal?: AbortSignal
|
||||
): AsyncGenerator<StreamChunk> {
|
||||
const response = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages, model, stream: true }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
yield { type: "error", error: err };
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
yield { type: "error", error: "No response body" };
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim();
|
||||
if (data === "[DONE]") {
|
||||
yield { type: "done" };
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const chunk = JSON.parse(data) as StreamChunk;
|
||||
yield chunk;
|
||||
} catch {
|
||||
// skip malformed chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
yield { type: "done" };
|
||||
}
|
||||
|
||||
export async function fetchHealth(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${getApiUrl()}/health`, { cache: "no-store" });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
16
web/lib/constants.ts
Normal file
16
web/lib/constants.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const MODELS = [
|
||||
{ id: "claude-opus-4-6", label: "Claude Opus 4.6", description: "Most capable" },
|
||||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", description: "Balanced" },
|
||||
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5", description: "Fastest" },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_MODEL = "claude-sonnet-4-6";
|
||||
|
||||
export const API_ROUTES = {
|
||||
chat: "/api/chat",
|
||||
stream: "/api/stream",
|
||||
} as const;
|
||||
|
||||
export const MAX_MESSAGE_LENGTH = 100_000;
|
||||
|
||||
export const STREAMING_CHUNK_SIZE = 64;
|
||||
123
web/lib/store.ts
Normal file
123
web/lib/store.ts
Normal 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
59
web/lib/types.ts
Normal 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
42
web/lib/utils.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDate(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function truncate(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) return str;
|
||||
return str.slice(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
export function extractTextContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((b): b is { type: "text"; text: string } => b?.type === "text")
|
||||
.map((b) => b.text)
|
||||
.join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
10
web/next.config.ts
Normal file
10
web/next.config.ts
Normal 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
46
web/package.json
Normal 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
6
web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
0
web/public/.gitkeep
Normal file
0
web/public/.gitkeep
Normal file
69
web/tailwind.config.ts
Normal file
69
web/tailwind.config.ts
Normal 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
27
web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user