♻️ 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

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