feat: Claude Web UI POC with streaming and tool visualization

Initial implementation of a web-based Claude Code interface with:

Backend (Node.js + Express + WebSocket):
- Claude CLI spawning with JSON stream mode
- Session management with resume support (--continue flag)
- Session history API endpoint
- Real-time WebSocket communication
- --include-partial-messages for live streaming

Frontend (React + Vite + Tailwind):
- Modern dark theme UI (Discord/Slack style)
- Live text streaming with content_block_delta handling
- Markdown rendering with react-markdown + remark-gfm
- Syntax highlighting with react-syntax-highlighter (One Dark)
- Collapsible high-tech tool cards with:
  - Tool-specific icons and colors
  - Compact summaries (Read, Glob, Bash, Edit, etc.)
  - Expandable JSON details
- Session history loading on resume
- Project directory selection
- Resume session toggle

Docker:
- Multi-container setup (backend + nginx frontend)
- Isolated Claude config directory
- Host network mode for backend

Built collaboratively by Neko (VPS Claude) and Web-UI Claude,
with Web-UI Claude implementing most frontend features while
running inside the interface itself (meta-programming!).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-14 17:32:52 +01:00
commit 52792268fa
23 changed files with 6004 additions and 0 deletions

98
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,98 @@
import { useState, useRef, useEffect } from 'react';
import { useClaudeSession } from './hooks/useClaudeSession';
import { MessageList } from './components/MessageList';
import { ChatInput } from './components/ChatInput';
import { Sidebar } from './components/Sidebar';
import { Header } from './components/Header';
function App() {
const {
connected,
sessionActive,
messages,
currentProject,
isProcessing,
error,
startSession,
sendMessage,
stopSession,
clearMessages,
setError
} = useClaudeSession();
const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui');
const [sidebarOpen, setSidebarOpen] = useState(true);
const [resumeSession, setResumeSession] = useState(true);
const handleStartSession = () => {
startSession(selectedProject, resumeSession);
};
const handleSendMessage = (message) => {
if (message.trim()) {
sendMessage(message);
}
};
return (
<div className="flex h-screen bg-dark-950">
{/* Sidebar */}
<Sidebar
open={sidebarOpen}
onToggle={() => setSidebarOpen(!sidebarOpen)}
selectedProject={selectedProject}
onSelectProject={setSelectedProject}
sessionActive={sessionActive}
onStartSession={handleStartSession}
onStopSession={stopSession}
onClearMessages={clearMessages}
resumeSession={resumeSession}
onToggleResume={() => setResumeSession(!resumeSession)}
/>
{/* Main Content */}
<div className="flex-1 flex flex-col min-w-0">
<Header
connected={connected}
sessionActive={sessionActive}
currentProject={currentProject}
isProcessing={isProcessing}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
/>
{/* Error Banner */}
{error && (
<div className="bg-red-900/50 border-b border-red-800 px-4 py-2 flex justify-between items-center">
<span className="text-red-200 text-sm">{error}</span>
<button
onClick={() => setError(null)}
className="text-red-400 hover:text-red-300"
>
×
</button>
</div>
)}
{/* Messages */}
<MessageList messages={messages} isProcessing={isProcessing} />
{/* Input */}
<ChatInput
onSend={handleSendMessage}
disabled={!sessionActive || isProcessing}
placeholder={
!connected
? 'Connecting...'
: !sessionActive
? 'Start a session to begin'
: isProcessing
? 'Claude is thinking...'
: 'Type your message...'
}
/>
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,81 @@
import { useState, useRef, useEffect } from 'react';
import { Send, Loader2 } from 'lucide-react';
export function ChatInput({ onSend, disabled, placeholder }) {
const [message, setMessage] = useState('');
const textareaRef = useRef(null);
// Auto-resize textarea
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
}
}, [message]);
const handleSubmit = (e) => {
e.preventDefault();
if (message.trim() && !disabled) {
onSend(message);
setMessage('');
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<form onSubmit={handleSubmit} className="p-4 border-t border-dark-800 bg-dark-900">
<div className="flex gap-3 items-end max-w-4xl mx-auto">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className={`
w-full bg-dark-800 border border-dark-700 rounded-xl
px-4 py-3 pr-12 text-dark-100 placeholder-dark-500
focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/20
resize-none transition-colors
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
/>
<div className="absolute right-2 bottom-2 text-xs text-dark-600">
Shift+Enter for newline
</div>
</div>
<button
type="submit"
disabled={disabled || !message.trim()}
className={`
p-3 rounded-xl transition-all
${disabled || !message.trim()
? 'bg-dark-800 text-dark-600 cursor-not-allowed'
: 'bg-orange-600 hover:bg-orange-500 text-white shadow-lg shadow-orange-600/20'
}
`}
>
{disabled ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
<div className="text-center mt-2 text-xs text-dark-600">
Messages are processed via Claude Code JSON streaming
</div>
</form>
);
}

View File

@@ -0,0 +1,56 @@
import { Menu, Wifi, WifiOff, Loader2, FolderOpen } from 'lucide-react';
export function Header({ connected, sessionActive, currentProject, isProcessing, onToggleSidebar }) {
return (
<header className="bg-dark-900 border-b border-dark-800 px-4 py-3 flex items-center gap-4">
<button
onClick={onToggleSidebar}
className="p-2 hover:bg-dark-800 rounded-lg transition-colors lg:hidden"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-orange-500 to-orange-600 flex items-center justify-center">
<span className="text-white font-bold text-sm">C</span>
</div>
<h1 className="font-semibold text-lg">Claude Web UI</h1>
</div>
<div className="flex-1" />
{/* Project indicator */}
{currentProject && (
<div className="hidden md:flex items-center gap-2 text-dark-400 text-sm">
<FolderOpen className="w-4 h-4" />
<span className="font-mono">{currentProject}</span>
</div>
)}
{/* Status indicators */}
<div className="flex items-center gap-3">
{isProcessing && (
<div className="flex items-center gap-2 text-orange-400">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm hidden sm:inline">Processing</span>
</div>
)}
<div className={`flex items-center gap-2 ${sessionActive ? 'text-green-400' : 'text-dark-500'}`}>
<div className={`w-2 h-2 rounded-full ${sessionActive ? 'bg-green-400' : 'bg-dark-600'}`} />
<span className="text-sm hidden sm:inline">
{sessionActive ? 'Session Active' : 'No Session'}
</span>
</div>
<div className={`flex items-center gap-2 ${connected ? 'text-blue-400' : 'text-red-400'}`}>
{connected ? (
<Wifi className="w-4 h-4" />
) : (
<WifiOff className="w-4 h-4" />
)}
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,398 @@
import { useEffect, useRef, useState } from 'react';
import {
User, Bot, Terminal, CheckCircle, AlertCircle, Info,
FileText, Search, FolderSearch, Pencil, FilePlus, Globe,
ChevronDown, ChevronRight, Play, Loader2
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
export function MessageList({ messages, isProcessing }) {
const endRef = useRef(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isProcessing]);
if (messages.length === 0) {
return (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center text-dark-500 max-w-md">
<Bot className="w-16 h-16 mx-auto mb-4 opacity-50" />
<h2 className="text-xl font-semibold mb-2">Welcome to Claude Web UI</h2>
<p className="text-sm">
Start a session and begin chatting with Claude.
All messages are streamed in real-time via JSON.
</p>
</div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message, index) => (
<Message key={index} message={message} />
))}
{isProcessing && (
<div className="flex items-center gap-3 text-dark-400">
<Bot className="w-6 h-6" />
<div className="flex gap-1">
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
<span className="w-2 h-2 bg-orange-400 rounded-full typing-dot" />
</div>
</div>
)}
<div ref={endRef} />
</div>
);
}
function Message({ message }) {
const { type, content, tool, input, timestamp } = message;
const renderContent = () => {
switch (type) {
case 'user':
return (
<div className="flex gap-3 message-enter">
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-dark-500 mb-1">You</div>
<div className="bg-dark-800 rounded-lg rounded-tl-none p-3 text-dark-100">
<pre className="whitespace-pre-wrap font-sans text-sm">{content}</pre>
</div>
</div>
</div>
);
case 'assistant':
return (
<div className="flex gap-3 message-enter">
<div className="w-8 h-8 rounded-lg bg-orange-600 flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-dark-500 mb-1">Claude</div>
<div className="bg-dark-850 rounded-lg rounded-tl-none p-3 text-dark-100 border border-dark-700 prose prose-invert prose-sm max-w-none
prose-headings:text-dark-100 prose-headings:font-semibold prose-headings:mt-4 prose-headings:mb-2
prose-p:text-dark-200 prose-p:my-2
prose-a:text-orange-400 prose-a:no-underline hover:prose-a:underline
prose-code:text-orange-300 prose-code:bg-dark-900 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-sm
prose-pre:bg-dark-900 prose-pre:border prose-pre:border-dark-700 prose-pre:rounded-lg
prose-ul:my-2 prose-ol:my-2 prose-li:text-dark-200
prose-strong:text-dark-100 prose-em:text-dark-200
prose-table:text-sm prose-th:text-dark-300 prose-td:text-dark-300">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
customStyle={{
margin: 0,
borderRadius: '0.5rem',
fontSize: '0.875rem',
}}
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
}
}}
>
{content}
</ReactMarkdown>
</div>
</div>
</div>
);
case 'tool_use':
return <ToolUseCard tool={tool} input={input} />;
case 'tool_result':
return <ToolResultCard content={content} isSuccess={!message.isError} />;
case 'system':
return (
<div className="flex items-center gap-2 justify-center message-enter">
<Info className="w-4 h-4 text-dark-500" />
<span className="text-xs text-dark-500">{content}</span>
</div>
);
case 'error':
return (
<div className="flex gap-3 message-enter">
<div className="w-8 h-8 rounded-lg bg-red-600/30 flex items-center justify-center flex-shrink-0">
<AlertCircle className="w-4 h-4 text-red-400" />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs text-red-400 mb-1">Error</div>
<div className="bg-red-900/20 rounded-lg p-3 text-red-300 border border-red-900/50 text-sm">
{content}
</div>
</div>
</div>
);
default:
return (
<div className="text-xs text-dark-500 ml-11">
Unknown message type: {type}
</div>
);
}
};
return <div>{renderContent()}</div>;
}
// Tool configuration with icons, colors, and display logic
const TOOL_CONFIG = {
Read: {
icon: FileText,
color: 'blue',
label: 'Reading file',
getSummary: (input) => input.file_path?.split('/').slice(-2).join('/') || 'file',
},
Glob: {
icon: FolderSearch,
color: 'cyan',
label: 'Searching files',
getSummary: (input) => input.pattern || 'pattern',
},
Grep: {
icon: Search,
color: 'yellow',
label: 'Searching content',
getSummary: (input) => `"${input.pattern?.substring(0, 30)}${input.pattern?.length > 30 ? '...' : ''}"` || 'pattern',
},
Edit: {
icon: Pencil,
color: 'orange',
label: 'Editing file',
getSummary: (input) => input.file_path?.split('/').slice(-2).join('/') || 'file',
},
Write: {
icon: FilePlus,
color: 'green',
label: 'Writing file',
getSummary: (input) => input.file_path?.split('/').slice(-2).join('/') || 'file',
},
Bash: {
icon: Terminal,
color: 'purple',
label: 'Running command',
getSummary: (input) => {
const cmd = input.command || '';
return cmd.length > 40 ? cmd.substring(0, 40) + '...' : cmd;
},
},
WebFetch: {
icon: Globe,
color: 'pink',
label: 'Fetching URL',
getSummary: (input) => {
try {
const url = new URL(input.url || '');
return url.hostname;
} catch {
return input.url?.substring(0, 30) || 'url';
}
},
},
Task: {
icon: Play,
color: 'indigo',
label: 'Running task',
getSummary: (input) => input.description || 'task',
},
};
const COLOR_CLASSES = {
blue: {
bg: 'bg-blue-500/20',
border: 'border-blue-500/30',
text: 'text-blue-400',
glow: 'shadow-blue-500/20',
},
cyan: {
bg: 'bg-cyan-500/20',
border: 'border-cyan-500/30',
text: 'text-cyan-400',
glow: 'shadow-cyan-500/20',
},
yellow: {
bg: 'bg-yellow-500/20',
border: 'border-yellow-500/30',
text: 'text-yellow-400',
glow: 'shadow-yellow-500/20',
},
orange: {
bg: 'bg-orange-500/20',
border: 'border-orange-500/30',
text: 'text-orange-400',
glow: 'shadow-orange-500/20',
},
green: {
bg: 'bg-green-500/20',
border: 'border-green-500/30',
text: 'text-green-400',
glow: 'shadow-green-500/20',
},
purple: {
bg: 'bg-purple-500/20',
border: 'border-purple-500/30',
text: 'text-purple-400',
glow: 'shadow-purple-500/20',
},
pink: {
bg: 'bg-pink-500/20',
border: 'border-pink-500/30',
text: 'text-pink-400',
glow: 'shadow-pink-500/20',
},
indigo: {
bg: 'bg-indigo-500/20',
border: 'border-indigo-500/30',
text: 'text-indigo-400',
glow: 'shadow-indigo-500/20',
},
};
function ToolUseCard({ tool, input }) {
const [expanded, setExpanded] = useState(false);
const config = TOOL_CONFIG[tool] || {
icon: Terminal,
color: 'purple',
label: tool,
getSummary: () => 'executing...',
};
const colors = COLOR_CLASSES[config.color];
const Icon = config.icon;
const summary = config.getSummary(input);
return (
<div className={`ml-11 message-enter rounded-lg border ${colors.border} ${colors.bg} overflow-hidden transition-all duration-200 hover:shadow-lg ${colors.glow}`}>
{/* Header - always visible */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-3 p-2.5 text-left hover:bg-white/5 transition-colors"
>
{/* Icon with pulse animation */}
<div className={`w-7 h-7 rounded-md ${colors.bg} border ${colors.border} flex items-center justify-center flex-shrink-0`}>
<Icon className={`w-4 h-4 ${colors.text}`} />
</div>
{/* Tool info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${colors.text}`}>{config.label}</span>
<span className="text-dark-600"></span>
<code className="text-xs text-dark-300 truncate">{summary}</code>
</div>
</div>
{/* Expand/collapse icon */}
<div className={`${colors.text} transition-transform duration-200`}>
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</div>
</button>
{/* Expandable details */}
{expanded && (
<div className="border-t border-dark-700/50 p-3 bg-dark-900/50">
<pre className="text-xs text-dark-400 overflow-x-auto whitespace-pre-wrap">
{typeof input === 'string' ? input : JSON.stringify(input, null, 2)}
</pre>
</div>
)}
</div>
);
}
function ToolResultCard({ content, isSuccess = true }) {
const [expanded, setExpanded] = useState(false);
// Defensive: handle undefined/null content
let contentStr = '';
if (content === undefined || content === null) {
contentStr = '(no content)';
} else if (typeof content === 'string') {
contentStr = content;
} else if (Array.isArray(content)) {
// Content might be an array of content blocks
contentStr = content.map(c => {
if (typeof c === 'string') return c;
if (c?.type === 'text') return c.text || '';
return JSON.stringify(c);
}).join('\n');
} else {
contentStr = JSON.stringify(content, null, 2);
}
const isLong = contentStr.length > 200;
const preview = contentStr.substring(0, 200);
const lines = contentStr.split('\n').length;
const chars = contentStr.length;
return (
<div className={`ml-11 message-enter rounded-lg border overflow-hidden transition-all duration-200
${isSuccess ? 'border-green-500/30 bg-green-500/10' : 'border-red-500/30 bg-red-500/10'}`}>
{/* Header */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-3 p-2.5 text-left hover:bg-white/5 transition-colors"
>
<div className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0
${isSuccess ? 'bg-green-500/20 border border-green-500/30' : 'bg-red-500/20 border border-red-500/30'}`}>
<CheckCircle className={`w-4 h-4 ${isSuccess ? 'text-green-400' : 'text-red-400'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${isSuccess ? 'text-green-400' : 'text-red-400'}`}>
{isSuccess ? 'Success' : 'Error'}
</span>
<span className="text-dark-600"></span>
<span className="text-xs text-dark-500">{lines} lines, {chars} chars</span>
</div>
{!expanded && isLong && (
<div className="text-xs text-dark-500 truncate mt-0.5">{preview}...</div>
)}
</div>
<div className={`transition-transform duration-200 ${isSuccess ? 'text-green-400' : 'text-red-400'}`}>
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</div>
</button>
{/* Expandable content */}
{expanded && (
<div className="border-t border-dark-700/50 p-3 bg-dark-900/50 max-h-96 overflow-y-auto">
<pre className="text-xs text-dark-400 whitespace-pre-wrap">{contentStr}</pre>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,180 @@
import { useState, useEffect } from 'react';
import { Play, Square, Trash2, FolderOpen, ChevronRight, Settings } from 'lucide-react';
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
export function Sidebar({
open,
onToggle,
selectedProject,
onSelectProject,
sessionActive,
onStartSession,
onStopSession,
onClearMessages,
resumeSession,
onToggleResume
}) {
const [projects, setProjects] = useState([]);
const [customPath, setCustomPath] = useState('');
useEffect(() => {
fetch(`${API_URL}/api/projects`)
.then(res => res.json())
.then(setProjects)
.catch(console.error);
}, []);
const handleCustomPath = () => {
if (customPath.trim()) {
onSelectProject(customPath.trim());
setCustomPath('');
}
};
return (
<aside
className={`
${open ? 'w-72' : 'w-0'}
bg-dark-900 border-r border-dark-800 flex flex-col
transition-all duration-300 overflow-hidden
lg:relative fixed inset-y-0 left-0 z-40
`}
>
<div className="p-4 border-b border-dark-800">
<h2 className="font-semibold text-dark-200 flex items-center gap-2">
<Settings className="w-4 h-4" />
Session Control
</h2>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Project Selection */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
Working Directory
</h3>
<div className="space-y-1">
{projects.map((project) => (
<button
key={project.path}
onClick={() => onSelectProject(project.path)}
className={`
w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left
transition-colors text-sm
${selectedProject === project.path
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'hover:bg-dark-800 text-dark-300'
}
`}
>
<FolderOpen className="w-4 h-4 flex-shrink-0" />
<div className="min-w-0">
<div className="font-medium truncate">{project.name}</div>
<div className="text-xs text-dark-500 truncate">{project.path}</div>
</div>
{selectedProject === project.path && (
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
)}
</button>
))}
</div>
{/* Custom path input */}
<div className="pt-2">
<div className="flex gap-2">
<input
type="text"
value={customPath}
onChange={(e) => setCustomPath(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCustomPath()}
placeholder="Custom path..."
className="flex-1 bg-dark-800 border border-dark-700 rounded-lg px-3 py-2
text-sm text-dark-200 placeholder-dark-500
focus:outline-none focus:border-orange-500/50"
/>
<button
onClick={handleCustomPath}
className="px-3 py-2 bg-dark-800 hover:bg-dark-700 rounded-lg
text-dark-400 hover:text-dark-200 transition-colors"
>
Set
</button>
</div>
</div>
{/* Resume toggle */}
<div className="pt-3">
<label className="flex items-center gap-3 cursor-pointer group">
<div
onClick={onToggleResume}
className={`
relative w-10 h-5 rounded-full transition-colors
${resumeSession ? 'bg-orange-600' : 'bg-dark-700'}
`}
>
<div
className={`
absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform
${resumeSession ? 'translate-x-5' : 'translate-x-0.5'}
`}
/>
</div>
<span className="text-sm text-dark-300 group-hover:text-dark-200">
Resume previous session
</span>
</label>
</div>
</div>
{/* Session Actions */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
Actions
</h3>
<div className="space-y-2">
{!sessionActive ? (
<button
onClick={onStartSession}
className="w-full flex items-center justify-center gap-2 px-4 py-3
bg-green-600 hover:bg-green-500 rounded-lg
font-medium transition-colors"
>
<Play className="w-4 h-4" />
Start Session
</button>
) : (
<button
onClick={onStopSession}
className="w-full flex items-center justify-center gap-2 px-4 py-3
bg-red-600 hover:bg-red-500 rounded-lg
font-medium transition-colors"
>
<Square className="w-4 h-4" />
Stop Session
</button>
)}
<button
onClick={onClearMessages}
className="w-full flex items-center justify-center gap-2 px-4 py-2
bg-dark-800 hover:bg-dark-700 rounded-lg
text-dark-300 hover:text-dark-100 transition-colors"
>
<Trash2 className="w-4 h-4" />
Clear Messages
</button>
</div>
</div>
</div>
{/* Footer */}
<div className="p-4 border-t border-dark-800 text-xs text-dark-500">
<div>Claude Code Web UI POC</div>
<div>JSON Stream Mode</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,4 @@
export { Header } from './Header';
export { Sidebar } from './Sidebar';
export { MessageList } from './MessageList';
export { ChatInput } from './ChatInput';

View File

@@ -0,0 +1,300 @@
import { useState, useRef, useCallback, useEffect } from 'react';
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://100.105.142.13:3001';
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
export function useClaudeSession() {
const [connected, setConnected] = useState(false);
const [sessionActive, setSessionActive] = useState(false);
const [messages, setMessages] = useState([]);
const [currentProject, setCurrentProject] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState(null);
const wsRef = useRef(null);
const currentAssistantMessage = useRef(null);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(WS_URL);
wsRef.current = ws;
ws.onopen = () => {
console.log('WebSocket connected');
setConnected(true);
setError(null);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
setConnected(false);
setSessionActive(false);
setIsProcessing(false);
};
ws.onerror = (err) => {
console.error('WebSocket error:', err);
setError('Connection error');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleMessage(data);
} catch (e) {
console.error('Failed to parse message:', e);
}
};
}, []);
const handleMessage = useCallback((data) => {
console.log('Received:', data.type, data);
switch (data.type) {
case 'session_started':
setSessionActive(true);
setCurrentProject(data.project);
setMessages(prev => [...prev, {
type: 'system',
content: `Session started in ${data.project}`,
timestamp: data.timestamp
}]);
break;
case 'session_ended':
setSessionActive(false);
setIsProcessing(false);
setMessages(prev => [...prev, {
type: 'system',
content: `Session ended (code: ${data.code})`,
timestamp: data.timestamp
}]);
break;
case 'claude_event':
handleClaudeEvent(data.event);
break;
case 'raw_output':
console.log('Raw:', data.content);
break;
case 'stderr':
console.log('Stderr:', data.content);
break;
case 'error':
setError(data.message);
setIsProcessing(false);
break;
}
}, []);
const handleClaudeEvent = useCallback((event) => {
// Debug: log all event types to understand the structure
console.log('Claude Event:', event.type, event);
// Handle different Claude event types
if (event.type === 'assistant') {
setIsProcessing(true);
// Check if this is a content block
if (event.message?.content) {
const newMessages = [];
for (const block of event.message.content) {
if (block.type === 'text' && block.text) {
// Only add text if we don't have a streaming message with similar content
// (to avoid duplicates from stream_event + final assistant event)
newMessages.push({
type: 'assistant',
content: block.text,
timestamp: Date.now(),
final: true // Mark as final message
});
} else if (block.type === 'tool_use') {
// Tool use is embedded in assistant message content
newMessages.push({
type: 'tool_use',
tool: block.name,
input: block.input,
toolUseId: block.id,
timestamp: Date.now()
});
}
}
if (newMessages.length > 0) {
setMessages(prev => {
// Check if last message is a streaming message - if so, replace it with final
const last = prev[prev.length - 1];
if (last?.type === 'assistant' && last.streaming) {
// Replace streaming message with final content, keep other new messages (tool_use)
const textMessages = newMessages.filter(m => m.type === 'assistant');
const otherMessages = newMessages.filter(m => m.type !== 'assistant');
if (textMessages.length > 0) {
return [
...prev.slice(0, -1),
{ ...textMessages[0], streaming: false },
...otherMessages
];
}
}
return [...prev, ...newMessages];
});
}
}
} else if (event.type === 'user' && event.tool_use_result) {
// Tool results come as 'user' events with tool_use_result
const result = event.tool_use_result;
setMessages(prev => [...prev, {
type: 'tool_result',
content: result.content,
toolUseId: result.tool_use_id,
isError: result.is_error || false,
timestamp: Date.now()
}]);
} else if (event.type === 'content_block_delta') {
// Streaming delta (direct)
if (event.delta?.text) {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.type === 'assistant' && last.streaming) {
return [
...prev.slice(0, -1),
{ ...last, content: last.content + event.delta.text }
];
}
return [...prev, {
type: 'assistant',
content: event.delta.text,
streaming: true,
timestamp: Date.now()
}];
});
}
} else if (event.type === 'stream_event' && event.event?.type === 'content_block_delta') {
// Streaming delta (wrapped in stream_event)
const deltaText = event.event?.delta?.text;
if (deltaText) {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.type === 'assistant' && last.streaming) {
return [
...prev.slice(0, -1),
{ ...last, content: last.content + deltaText }
];
}
return [...prev, {
type: 'assistant',
content: deltaText,
streaming: true,
timestamp: Date.now()
}];
});
}
} else if (event.type === 'result') {
// Final result - just stop processing
setIsProcessing(false);
} else if (event.type === 'system' && event.subtype === 'result') {
setIsProcessing(false);
}
}, []);
const loadHistory = useCallback(async (project) => {
try {
const encodedProject = encodeURIComponent(project);
const response = await fetch(`${API_URL}/api/history/${encodedProject}`);
if (response.ok) {
const data = await response.json();
if (data.messages && data.messages.length > 0) {
console.log(`Loaded ${data.messages.length} messages from history`);
setMessages(data.messages);
return data.sessionId;
}
}
} catch (err) {
console.error('Failed to load history:', err);
}
return null;
}, []);
const startSession = useCallback(async (project = '/projects', resume = true) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
setError('Not connected');
return;
}
// Load history before starting session if resuming
if (resume) {
await loadHistory(project);
}
wsRef.current.send(JSON.stringify({
type: 'start_session',
project,
resume
}));
}, [loadHistory]);
const sendMessage = useCallback((message) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
setError('Not connected');
return;
}
if (!sessionActive) {
setError('No active session');
return;
}
// Add user message to display
setMessages(prev => [...prev, {
type: 'user',
content: message,
timestamp: Date.now()
}]);
setIsProcessing(true);
wsRef.current.send(JSON.stringify({
type: 'user_message',
message
}));
}, [sessionActive]);
const stopSession = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'stop_session' }));
}
}, []);
const clearMessages = useCallback(() => {
setMessages([]);
}, []);
// Auto-connect on mount
useEffect(() => {
connect();
return () => {
wsRef.current?.close();
};
}, [connect]);
return {
connected,
sessionActive,
messages,
currentProject,
isProcessing,
error,
connect,
startSession,
sendMessage,
stopSession,
clearMessages,
setError
};
}

75
frontend/src/index.css Normal file
View File

@@ -0,0 +1,75 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1e293b;
}
::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
/* Code blocks */
pre {
background: #0f172a;
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
}
code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.875rem;
}
/* Message animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.message-enter {
animation: fadeIn 0.2s ease-out;
}
/* Typing indicator */
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.typing-dot {
animation: pulse 1.4s infinite;
}
.typing-dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-dot:nth-child(3) {
animation-delay: 0.4s;
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)