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