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

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