From 960f2e137dce0c8b90ea3fec6a11ecafd76c968a Mon Sep 17 00:00:00 2001 From: Nikolas Syring Date: Wed, 17 Dec 2025 10:33:25 +0100 Subject: [PATCH] feat: Major UI improvements and SSH-only mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tool rendering: Unified tool_use/tool_result cards with collapsible results - Special rendering for WebSearch, WebFetch, Task, Write tools - File upload support with drag & drop - Permission dialog for tool approvals - Status bar with session stats and permission mode toggle - SSH-only mode: Removed local container execution - Host switching disabled during active session with visual indicator - Directory browser: Browse remote directories via SSH - Recent directories dropdown with localStorage persistence - Follow-up messages during generation - Improved scroll behavior with "back to bottom" button 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- PLAN-MODE-BUG-FIX.md | 129 +++ backend/package.json | 3 +- backend/server.js | 526 ++++++++++- docker-compose.dev.yml | 49 + docker-compose.yml | 9 + frontend/Dockerfile | 6 +- frontend/Dockerfile.dev | 15 + frontend/src/App.jsx | 128 ++- frontend/src/components/ChatInput.jsx | 445 ++++++++- frontend/src/components/MessageList.jsx | 903 ++++++++++++++++--- frontend/src/components/PermissionDialog.jsx | 202 +++++ frontend/src/components/Sidebar.jsx | 338 +++++-- frontend/src/components/StatusBar.jsx | 177 ++++ frontend/src/hooks/useClaudeSession.js | 301 ++++++- frontend/src/index.css | 148 +++ test-rendering.txt | 7 + 16 files changed, 3108 insertions(+), 278 deletions(-) create mode 100644 PLAN-MODE-BUG-FIX.md create mode 100644 docker-compose.dev.yml create mode 100644 frontend/Dockerfile.dev create mode 100644 frontend/src/components/PermissionDialog.jsx create mode 100644 frontend/src/components/StatusBar.jsx create mode 100644 test-rendering.txt diff --git a/PLAN-MODE-BUG-FIX.md b/PLAN-MODE-BUG-FIX.md new file mode 100644 index 0000000..14cbea1 --- /dev/null +++ b/PLAN-MODE-BUG-FIX.md @@ -0,0 +1,129 @@ +# Plan Mode Bug Fix - Claude WebUI + +**Datum:** 2025-12-16 +**Status:** DEPLOYED + +## Das Problem + +Beim Plan Mode Approval im WebUI kam ein API Error: +``` +API Error: 400 {"type":"error","error":{"type":"invalid_request_error", +"message":"messages.98.content.0: unexpected tool_use_id found in tool_result blocks: +toolu_01R6yjX7gqduKgMAuYuPzMeG. Each tool_result block must have a corresponding +tool_use block in the previous message."}} +``` + +### Ursache +- Wenn das WebUI ein Plan Approval sendet, muss die `tool_use_id` mit einem vorherigen `tool_use` Block matchen +- Bei **Message History Compaction** gehen die ursprünglichen `tool_use` Blöcke verloren +- Das WebUI sendete `tool_result` für Permissions, aber die zugehörigen `tool_use` IDs existierten nicht mehr + +## Die Lösung + +### 1. Scope-Problem gefixt +`setPermissionModeViaControl` wurde aus `startClaudeSession` in den äußeren WebSocket-Scope verschoben: + +**Datei:** `backend/server.js` (Zeile ~307) +```javascript +// Helper to set permission mode via control protocol (needs claudeProcess) +const setPermissionModeViaControl = (mode) => { + if (!claudeProcess) return; + const modeRequestId = generateRequestId(); + const modeRequest = { + type: 'control_request', + request_id: modeRequestId, + request: { + subtype: 'set_permission_mode', + mode: mode + } + }; + console.log(`[${sessionId}] Auto-switching permission mode to: ${mode}`); + claudeProcess.stdin.write(JSON.stringify(modeRequest) + '\n'); + pendingControlRequests.set(modeRequestId, { type: 'set_permission_mode', mode }); +}; +``` + +### 2. Permission Responses als `control_response` statt `tool_result` + +**Datei:** `backend/server.js` (Zeile ~641) +```javascript +// All permission responses (including ExitPlanMode/plan approval) use control_response +// This avoids tool_result issues when message history is compacted +const permResponse = { + type: 'control_response', + response: { + subtype: 'success', + request_id: data.requestId, + response: data.allow + ? { behavior: 'allow', updated_input: data.updatedInput || null } + : { behavior: 'deny', message: data.message || 'User denied permission' } + } +}; +``` + +### 3. Plan Mode State Tracking + +**Datei:** `backend/server.js` (Zeile ~302) +```javascript +let inPlanMode = false; // Track if we're in plan mode +``` + +Bei `ExitPlanMode` Permission Request (Zeile ~441): +```javascript +// Track plan mode state for ExitPlanMode +if (isPlanApproval && !inPlanMode) { + savedPermissionMode = currentPermissionMode; + inPlanMode = true; + console.log(`[${sessionId}] Entering plan mode, saved mode: ${savedPermissionMode}`); +} +``` + +## Deployment + +Nach Code-Änderungen muss das Backend-Image neu gebaut werden: + +```bash +cd /home/sumdex/projects/claude-web-ui +docker compose build backend +docker compose up -d backend +``` + +**Wichtig:** Der Backend-Code ist NICHT als Volume gemountet, sondern im Image gebaut! + +### Verifizieren dass der Fix deployed ist: +```bash +docker exec claude-webui-backend cat /app/server.js | grep "setPermissionModeViaControl" +``` + +## Relevante Dateien + +| Datei | Beschreibung | +|-------|--------------| +| `backend/server.js` | Haupt-Backend, WebSocket Handler, Permission Handling | +| `frontend/src/hooks/useClaudeSession.js` | Frontend Hook für Claude Session | +| `frontend/src/components/PermissionDialog.jsx` | Permission UI Dialog | +| `docker-compose.yml` | Container Konfiguration | + +## Session-Dump + +Falls weitere Analyse nötig: +``` +/home/sumdex/projects/webui-workspace/session-dump-20251216-164148.jsonl +``` + +## Technischer Hintergrund + +### Control Protocol vs Tool Results +- **tool_result:** Muss immer mit einem `tool_use` in der vorherigen Nachricht matchen +- **control_response:** Hat diese Einschränkung nicht, funktioniert unabhängig von Message History + +### Message Compaction +Claude Code kompaktiert die Message History um Context zu sparen. Dabei können `tool_use` Blöcke entfernt werden, was zu ID-Mismatches führt wenn später `tool_result` mit diesen IDs gesendet wird. + +## Test + +1. WebUI öffnen: http://100.105.142.13:3000 +2. Neue Session starten +3. Eine Anfrage stellen die Plan Mode triggert (z.B. "Implementiere Feature X") +4. Plan Mode Approval im UI bestätigen +5. Es sollte KEIN `unexpected tool_use_id` Error mehr kommen diff --git a/backend/package.json b/backend/package.json index 642d298..54ed5a2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ "express": "^4.18.2", "ws": "^8.14.2", "cors": "^2.8.5", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "multer": "^1.4.5-lts.1" } } diff --git a/backend/server.js b/backend/server.js index 6088fa5..3239894 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,8 +4,9 @@ import { createServer } from 'http'; import { spawn } from 'child_process'; import { v4 as uuidv4 } from 'uuid'; import cors from 'cors'; -import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; -import { join, basename } from 'path'; +import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs'; +import { join, basename, extname } from 'path'; +import multer from 'multer'; const app = express(); app.use(cors()); @@ -13,6 +14,68 @@ app.use(express.json()); const PORT = process.env.PORT || 3001; const HOST = process.env.HOST || '0.0.0.0'; +const DEBUG = process.env.DEBUG === 'true'; +// Upload to /projects/.claude-uploads so Claude can access them via mounted volume +const UPLOAD_DIR = process.env.UPLOAD_DIR || '/projects/.claude-uploads'; +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +// Allowed file types +const ALLOWED_TYPES = { + // Images + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/gif': '.gif', + 'image/webp': '.webp', + // Text/Code + 'text/plain': '.txt', + 'text/markdown': '.md', + 'text/csv': '.csv', + 'text/html': '.html', + 'text/css': '.css', + 'text/javascript': '.js', + 'application/json': '.json', + 'application/xml': '.xml', + 'text/xml': '.xml', + 'application/x-yaml': '.yaml', + 'text/yaml': '.yaml' +}; + +// Configure multer for file uploads +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const sessionId = req.params.sessionId || 'default'; + const sessionDir = join(UPLOAD_DIR, sessionId); + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + cb(null, sessionDir); + }, + filename: (req, file, cb) => { + // Preserve original filename with timestamp prefix to avoid collisions + const timestamp = Date.now(); + const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_'); + cb(null, `${timestamp}-${safeName}`); + } +}); + +const upload = multer({ + storage, + limits: { fileSize: MAX_FILE_SIZE }, + fileFilter: (req, file, cb) => { + if (ALLOWED_TYPES[file.mimetype]) { + cb(null, true); + } else { + // Also allow common code file extensions + const ext = extname(file.originalname).toLowerCase(); + const codeExtensions = ['.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.rs', '.java', '.c', '.cpp', '.h', '.sh', '.bash', '.zsh', '.yml', '.yaml', '.toml', '.ini', '.conf', '.md', '.txt', '.json', '.xml', '.html', '.css', '.scss', '.less', '.sql', '.rb', '.php', '.swift', '.kt', '.scala', '.r', '.lua', '.pl', '.pm']; + if (codeExtensions.includes(ext)) { + cb(null, true); + } else { + cb(new Error(`File type not allowed: ${file.mimetype} (${ext})`)); + } + } + } +}); // Load hosts configuration const CONFIG_PATH = process.env.CONFIG_PATH || '/app/config/hosts.json'; @@ -35,6 +98,12 @@ loadConfig(); // Store active Claude sessions const sessions = new Map(); +// Control request counter for unique IDs +let controlRequestCounter = 0; +function generateRequestId() { + return `req_${++controlRequestCounter}_${Date.now().toString(16)}`; +} + const server = createServer(app); const wss = new WebSocketServer({ server }); @@ -140,6 +209,144 @@ app.get('/api/health', (req, res) => { }); }); +// Browse directories on a host (for directory picker) +app.get('/api/browse', async (req, res) => { + const hostId = req.query.host || hostsConfig.defaults?.host || 'neko'; + const path = req.query.path || '~'; + const host = hostsConfig.hosts[hostId]; + + if (!host) { + return res.status(404).json({ error: `Host '${hostId}' not found` }); + } + + // For SSH hosts, execute ls command remotely + if (host.connection.type === 'ssh') { + const { host: sshHost, user, port = 22 } = host.connection; + // Expand ~ to home directory, list only directories, format as JSON-friendly output + const lsCmd = `cd ${path} 2>/dev/null && pwd && ls -1F 2>/dev/null | grep '/$' | sed 's/\\/$//'`; + const sshCmd = `ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${port} ${user}@${sshHost} "${lsCmd}"`; + + try { + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + + const { stdout, stderr } = await execAsync(sshCmd, { timeout: 10000 }); + const lines = stdout.trim().split('\n').filter(Boolean); + const currentPath = lines[0] || path; + const directories = lines.slice(1).map(name => ({ + name, + path: currentPath === '/' ? `/${name}` : `${currentPath}/${name}`, + type: 'directory' + })); + + // Add parent directory if not at root + if (currentPath !== '/') { + const parentPath = currentPath.split('/').slice(0, -1).join('/') || '/'; + directories.unshift({ + name: '..', + path: parentPath, + type: 'parent' + }); + } + + res.json({ + currentPath, + directories, + host: hostId + }); + } catch (err) { + console.error('Browse error:', err.message); + res.status(500).json({ error: `Failed to browse: ${err.message}` }); + } + } else { + // Local browsing + try { + const resolvedPath = path === '~' ? process.env.HOME || '/home' : path; + if (!existsSync(resolvedPath)) { + return res.status(404).json({ error: `Path not found: ${resolvedPath}` }); + } + + const entries = readdirSync(resolvedPath); + const directories = entries + .filter(name => { + try { + return statSync(join(resolvedPath, name)).isDirectory() && !name.startsWith('.'); + } catch { + return false; + } + }) + .map(name => ({ + name, + path: join(resolvedPath, name), + type: 'directory' + })); + + // Add parent directory + if (resolvedPath !== '/') { + const parentPath = join(resolvedPath, '..'); + directories.unshift({ + name: '..', + path: parentPath, + type: 'parent' + }); + } + + res.json({ + currentPath: resolvedPath, + directories, + host: hostId + }); + } catch (err) { + res.status(500).json({ error: `Failed to browse: ${err.message}` }); + } + } +}); + +// File upload endpoint +app.post('/api/upload/:sessionId', upload.array('files', 5), (req, res) => { + try { + if (!req.files || req.files.length === 0) { + return res.status(400).json({ error: 'No files uploaded' }); + } + + const uploadedFiles = req.files.map(file => { + // Convert container path to host path for Claude + // /projects/.claude-uploads/... -> /home/sumdex/projects/.claude-uploads/... + const hostPath = file.path.replace('/projects/', '/home/sumdex/projects/'); + return { + originalName: file.originalname, + savedName: file.filename, + path: hostPath, // Use host path so Claude can read it + containerPath: file.path, // Keep container path for reference + size: file.size, + mimeType: file.mimetype, + isImage: file.mimetype.startsWith('image/') + }; + }); + + console.log(`[Upload] Session ${req.params.sessionId}: ${uploadedFiles.length} files uploaded`); + res.json({ files: uploadedFiles }); + } catch (err) { + console.error('[Upload] Error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// Error handler for multer +app.use((err, req, res, next) => { + if (err instanceof multer.MulterError) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB` }); + } + return res.status(400).json({ error: err.message }); + } + if (err) { + return res.status(400).json({ error: err.message }); + } + next(); +}); + // Parse history content into messages function parseHistoryContent(content) { const lines = content.split('\n').filter(l => l.trim()); @@ -290,10 +497,54 @@ wss.on('connection', (ws, req) => { let claudeProcess = null; let currentProject = null; + let currentHostId = null; // Track current host for restart + let isInitialized = false; + let currentPermissionMode = 'default'; + let savedPermissionMode = 'default'; // Store mode before plan mode switch + let inPlanMode = false; // Track if we're in plan mode (to require approval for ExitPlanMode) + const pendingControlRequests = new Map(); + const pendingPermissionRequests = new Map(); // Track tool permission requests + + // Cleanup stale pending requests (TTL: 30 seconds) + const REQUEST_TTL = 30000; + const cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [id, req] of pendingControlRequests) { + if (req.createdAt && now - req.createdAt > REQUEST_TTL) { + pendingControlRequests.delete(id); + } + } + for (const [id, req] of pendingPermissionRequests) { + if (req.createdAt && now - req.createdAt > REQUEST_TTL) { + pendingPermissionRequests.delete(id); + } + } + }, 60000); + + // Helper to set permission mode via control protocol (needs claudeProcess) + const setPermissionModeViaControl = (mode) => { + if (!claudeProcess) return; + const modeRequestId = generateRequestId(); + const modeRequest = { + type: 'control_request', + request_id: modeRequestId, + request: { + subtype: 'set_permission_mode', + mode: mode + } + }; + console.log(`[${sessionId}] Auto-switching permission mode to: ${mode}`); + claudeProcess.stdin.write(JSON.stringify(modeRequest) + '\n'); + pendingControlRequests.set(modeRequestId, { type: 'set_permission_mode', mode, createdAt: Date.now() }); + }; const sendToClient = (type, data) => { if (ws.readyState === ws.OPEN) { - ws.send(JSON.stringify({ type, ...data, timestamp: Date.now() })); + try { + ws.send(JSON.stringify({ type, ...data, timestamp: Date.now() })); + } catch (err) { + console.error(`[${sessionId}] WebSocket send failed:`, err.message); + } } }; @@ -304,6 +555,7 @@ wss.on('connection', (ws, req) => { } currentProject = projectPath; + currentHostId = hostId; // Save for potential restart // Get host config const host = hostId ? hostsConfig.hosts[hostId] : null; @@ -316,8 +568,8 @@ wss.on('connection', (ws, req) => { '--output-format', 'stream-json', '--input-format', 'stream-json', '--include-partial-messages', - '--verbose', - '--dangerously-skip-permissions' + '--verbose' + // Note: No --dangerously-skip-permissions - we handle permissions via control protocol ]; // Add continue flag to resume most recent conversation @@ -362,26 +614,133 @@ wss.on('connection', (ws, req) => { project: projectPath }); + // Send control protocol initialization after process starts + const initializeControlProtocol = () => { + const requestId = generateRequestId(); + const initRequest = { + type: 'control_request', + request_id: requestId, + request: { + subtype: 'initialize', + hooks: null + } + }; + console.log(`[${sessionId}] Sending control initialization`); + claudeProcess.stdin.write(JSON.stringify(initRequest) + '\n'); + pendingControlRequests.set(requestId, { type: 'initialize', createdAt: Date.now() }); + }; + + // Small delay to ensure process is ready + setTimeout(initializeControlProtocol, 100); + // Handle stdout (JSON events) - let buffer = ''; + let pendingLine = ''; claudeProcess.stdout.on('data', (data) => { const chunk = data.toString(); - console.log(`[${sessionId}] stdout chunk:`, chunk.substring(0, 200)); + if (DEBUG) console.log(`[${sessionId}] stdout chunk:`, chunk.substring(0, 200)); - buffer += chunk; - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; + const parts = (pendingLine + chunk).split('\n'); + pendingLine = parts.pop() || ''; - for (const line of lines) { + for (const line of parts) { if (line.trim()) { - console.log(`[${sessionId}] Processing line:`, line.substring(0, 100)); + if (DEBUG) console.log(`[${sessionId}] Processing line:`, line.substring(0, 100)); try { const event = JSON.parse(line); - console.log(`[${sessionId}] Event type:`, event.type); + if (DEBUG) console.log(`[${sessionId}] Event type:`, event.type); + + // Handle control protocol requests (permission prompts from CLI) + if (event.type === 'control_request') { + const request = event.request; + const requestId = event.request_id; + + if (request?.subtype === 'can_use_tool') { + if (DEBUG) console.log(`[${sessionId}] Permission request for tool: ${request.tool_name}`); + + // Special handling for ExitPlanMode - always send to UI for approval + const isPlanApproval = request.tool_name === 'ExitPlanMode'; + + // Track plan mode state for ExitPlanMode + if (isPlanApproval && !inPlanMode) { + savedPermissionMode = currentPermissionMode; + inPlanMode = true; + if (DEBUG) console.log(`[${sessionId}] Entering plan mode, saved mode: ${savedPermissionMode}`); + } + + // Store the request so we know which tool is being approved/denied + pendingPermissionRequests.set(requestId, { + toolName: request.tool_name, + toolInput: request.input, + isPlanApproval, + createdAt: Date.now() + }); + + // Send to frontend for user approval + sendToClient('permission_request', { + requestId, + toolName: request.tool_name, + toolInput: request.input, + permissionSuggestions: request.permission_suggestions, + blockedPath: request.blocked_path, + isPlanApproval // Flag for special UI treatment + }); + } + continue; + } + + // Handle control protocol responses + if (event.type === 'control_response') { + const response = event.response; + const requestId = response?.request_id; + const pending = pendingControlRequests.get(requestId); + + if (pending) { + pendingControlRequests.delete(requestId); + + if (response.subtype === 'success') { + if (pending.type === 'initialize') { + isInitialized = true; + console.log(`[${sessionId}] Control protocol initialized`); + // Send available commands/models to frontend + sendToClient('control_initialized', { + commands: response.response?.commands, + models: response.response?.models, + account: response.response?.account + }); + } else if (pending.type === 'set_permission_mode') { + currentPermissionMode = pending.mode; + console.log(`[${sessionId}] Permission mode changed to: ${currentPermissionMode}`); + sendToClient('permission_mode_changed', { + mode: currentPermissionMode + }); + } + } else if (response.subtype === 'error') { + console.error(`[${sessionId}] Control request error:`, response.error); + sendToClient('control_error', { + requestId, + error: response.error + }); + } + } + // Don't forward control responses as regular events + continue; + } + + // Debug: log all tool_use blocks in assistant messages + if (event.type === 'assistant' && event.message?.content) { + for (const block of event.message.content) { + if (block.type === 'tool_use') { + if (DEBUG) console.log(`[${sessionId}] Tool use detected: ${block.name}`); + } + } + } + // Note: ExitPlanMode is now handled exclusively via Control Protocol (can_use_tool) + // to avoid tool_result/control_response confusion and message compaction issues + sendToClient('claude_event', { event }); } catch (e) { // Non-JSON output, send as raw - console.log(`[${sessionId}] Raw output:`, line.substring(0, 100)); + if (DEBUG) console.log(`[${sessionId}] Raw output:`, line.substring(0, 100)); sendToClient('raw_output', { content: line }); } } @@ -391,7 +750,7 @@ wss.on('connection', (ws, req) => { // Handle stderr claudeProcess.stderr.on('data', (data) => { const content = data.toString(); - console.log(`[${sessionId}] stderr:`, content); + if (DEBUG) console.log(`[${sessionId}] stderr:`, content); sendToClient('stderr', { content }); }); @@ -411,7 +770,7 @@ wss.on('connection', (ws, req) => { ws.on('message', (message) => { try { const data = JSON.parse(message.toString()); - console.log(`[${sessionId}] Received:`, data.type); + if (DEBUG) console.log(`[${sessionId}] Received:`, data.type); switch (data.type) { case 'start_session': @@ -432,7 +791,7 @@ wss.on('connection', (ws, req) => { } }; - console.log(`[${sessionId}] Sending to Claude:`, data.message.substring(0, 50) + '...'); + if (DEBUG) console.log(`[${sessionId}] Sending to Claude:`, data.message.substring(0, 50) + '...'); claudeProcess.stdin.write(JSON.stringify(payload) + '\n'); break; @@ -443,6 +802,139 @@ wss.on('connection', (ws, req) => { } break; + case 'stop_generation': + // Kill the process and restart with --continue to resume session + if (claudeProcess) { + console.log(`[${sessionId}] Stop generation: killing process and restarting`); + + // Save current state for restart + const restartProject = currentProject; + const restartHost = currentHostId; + const restartPermissionMode = currentPermissionMode; + + // Kill the process + claudeProcess.kill('SIGKILL'); + claudeProcess = null; + isInitialized = false; + + // Notify frontend + sendToClient('generation_stopped', { + message: 'Generation stopped, reconnecting...', + timestamp: Date.now() + }); + + // Restart after a short delay + setTimeout(() => { + console.log(`[${sessionId}] Restarting session with --continue`); + startClaudeSession(restartProject, true, restartHost); + + // Restore permission mode after initialization + savedPermissionMode = restartPermissionMode; + }, 500); + } else { + sendToClient('generation_stopped', { + message: 'No active process', + timestamp: Date.now() + }); + } + break; + + case 'set_permission_mode': + if (!claudeProcess) { + sendToClient('error', { message: 'No active Claude session' }); + return; + } + if (!isInitialized) { + sendToClient('error', { message: 'Control protocol not yet initialized' }); + return; + } + + // Don't allow mode change while in plan mode - plan mode takes priority + if (inPlanMode) { + console.log(`[${sessionId}] Ignoring set_permission_mode while in plan mode (saving ${data.mode} for later)`); + // Save what the user wanted so we can restore it after plan approval + savedPermissionMode = data.mode; + // Notify frontend that we're still in plan mode + sendToClient('permission_mode', { mode: 'plan', reason: 'plan_mode_active' }); + return; + } + + const mode = data.mode; + const validModes = ['default', 'acceptEdits', 'plan', 'bypassPermissions']; + if (!validModes.includes(mode)) { + sendToClient('error', { message: `Invalid permission mode: ${mode}. Valid: ${validModes.join(', ')}` }); + return; + } + + const modeRequestId = generateRequestId(); + const modeRequest = { + type: 'control_request', + request_id: modeRequestId, + request: { + subtype: 'set_permission_mode', + mode: mode + } + }; + + console.log(`[${sessionId}] Setting permission mode to: ${mode}`); + claudeProcess.stdin.write(JSON.stringify(modeRequest) + '\n'); + pendingControlRequests.set(modeRequestId, { type: 'set_permission_mode', mode, createdAt: Date.now() }); + break; + + case 'get_permission_mode': + sendToClient('permission_mode', { mode: currentPermissionMode }); + break; + + case 'permission_response': + if (!claudeProcess) { + sendToClient('error', { message: 'No active Claude session' }); + return; + } + + const pendingPerm = pendingPermissionRequests.get(data.requestId); + if (pendingPerm) { + pendingPermissionRequests.delete(data.requestId); + } + + // All permission responses (including ExitPlanMode/plan approval) use control_response + // This avoids tool_result issues when message history is compacted + const permResponse = { + type: 'control_response', + response: { + subtype: 'success', + request_id: data.requestId, + response: data.allow + ? { behavior: 'allow', updated_input: data.updatedInput || null } + : { behavior: 'deny', message: data.message || 'User denied permission' } + } + }; + + console.log(`[${sessionId}] Sending permission response: ${data.allow ? 'allow' : 'deny'} for ${data.requestId}`); + claudeProcess.stdin.write(JSON.stringify(permResponse) + '\n'); + + // Handle plan mode state updates for ExitPlanMode + if (pendingPerm?.isPlanApproval) { + if (data.allow) { + const modeToRestore = savedPermissionMode || 'default'; + console.log(`[${sessionId}] ExitPlanMode approved - restoring mode to '${modeToRestore}'`); + inPlanMode = false; + savedPermissionMode = null; + + // Restore previous permission mode after a small delay + setTimeout(() => { + setPermissionModeViaControl(modeToRestore); + }, 100); + + sendToClient('plan_mode_exited', { approved: true }); + } else { + console.log(`[${sessionId}] ExitPlanMode rejected`); + sendToClient('plan_mode_exited', { approved: false }); + } + } + break; + + // Note: plan_approval case removed - now handled via permission_response with isPlanApproval flag + default: console.log(`[${sessionId}] Unknown message type:`, data.type); } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..4c7c16e --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,49 @@ +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: claude-webui-backend + restart: unless-stopped + network_mode: host + volumes: + - /home/sumdex/.local/share/claude:/home/node/.local/share/claude:ro + - ./config/.claude:/home/node/.claude:rw + - ./config/.config/claude:/home/node/.config/claude:rw + - ./config/hosts.json:/app/config/hosts.json:ro + - /home/sumdex/.ssh/id_rsa:/home/node/.ssh/id_rsa:ro + - /home/sumdex/.ssh/known_hosts:/home/node/.ssh/known_hosts:ro + - /home/sumdex/projects:/projects:rw + - /home/sumdex/docker:/docker:rw + - /opt/stacks:/stacks:rw + environment: + - NODE_ENV=production + - HOST=100.105.142.13 + - PORT=3001 + - PATH=/home/node/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.dev + container_name: claude-webui-frontend-dev + restart: unless-stopped + ports: + - "100.105.142.13:3000:5173" + volumes: + - ./frontend/src:/app/src:ro + - ./frontend/index.html:/app/index.html:ro + - ./frontend/vite.config.js:/app/vite.config.js:ro + - ./frontend/tailwind.config.js:/app/tailwind.config.js:ro + - ./frontend/postcss.config.js:/app/postcss.config.js:ro + environment: + - VITE_WS_URL=ws://100.105.142.13:3001 + - VITE_API_URL=http://100.105.142.13:3001 + depends_on: + - backend + networks: + - claude-webui + +networks: + claude-webui: + name: claude-webui diff --git a/docker-compose.yml b/docker-compose.yml index 9539159..6f1e7ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,9 +3,17 @@ services: build: context: ./backend dockerfile: Dockerfile + network: host container_name: claude-webui-backend restart: unless-stopped network_mode: host + deploy: + resources: + limits: + memory: 2G + cpus: '2' + reservations: + memory: 512M volumes: # Claude CLI binary (read-only from host) - /home/sumdex/.local/share/claude:/home/node/.local/share/claude:ro @@ -31,6 +39,7 @@ services: build: context: ./frontend dockerfile: Dockerfile + network: host args: - VITE_WS_URL=ws://100.105.142.13:3001 - VITE_API_URL=http://100.105.142.13:3001 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 7c1eae6..3e01f2b 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1.4 # Build stage FROM node:20-slim AS builder @@ -14,8 +15,9 @@ ENV VITE_API_URL=$VITE_API_URL # Copy package files COPY package*.json ./ -# Install dependencies -RUN npm install +# Install dependencies with cache mount +RUN --mount=type=cache,target=/root/.npm \ + npm install # Copy source COPY . . diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..56889e9 --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM node:20-slim + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Expose Vite port +EXPOSE 5173 + +# Start Vite dev server +CMD ["npx", "vite", "--host", "0.0.0.0", "--port", "5173"] diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index baff6d1..017f508 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,6 +4,8 @@ import { MessageList } from './components/MessageList'; import { ChatInput } from './components/ChatInput'; import { Sidebar } from './components/Sidebar'; import { Header } from './components/Header'; +import { StatusBar } from './components/StatusBar'; +import { PermissionDialog } from './components/PermissionDialog'; // Slash command definitions const SLASH_COMMANDS = { @@ -81,6 +83,37 @@ const SLASH_COMMANDS = { ].join('\n'); addSystemMessage(info); } + }, + history: { + description: 'Search input history (/history )', + execute: ({ args, addSystemMessage }) => { + const HISTORY_KEY = 'claude-webui-input-history'; + try { + const stored = localStorage.getItem(HISTORY_KEY); + const history = stored ? JSON.parse(stored) : []; + const searchTerm = args.join(' ').toLowerCase(); + + if (!searchTerm) { + // Show recent history + const recent = history.slice(0, 10); + if (recent.length === 0) { + addSystemMessage('No input history found.'); + } else { + addSystemMessage(`Recent inputs:\n${recent.map((h, i) => `${i + 1}. ${h.length > 80 ? h.slice(0, 80) + '...' : h}`).join('\n')}`); + } + } else { + // Search history + const matches = history.filter(h => h.toLowerCase().includes(searchTerm)).slice(0, 10); + if (matches.length === 0) { + addSystemMessage(`No history entries matching "${searchTerm}"`); + } else { + addSystemMessage(`History matching "${searchTerm}":\n${matches.map((h, i) => `${i + 1}. ${h.length > 80 ? h.slice(0, 80) + '...' : h}`).join('\n')}`); + } + } + } catch { + addSystemMessage('Failed to read input history.'); + } + } } }; @@ -88,20 +121,29 @@ function App() { const { connected, sessionActive, + sessionId, messages, currentProject, + currentHost, isProcessing, error, + sessionStats, + permissionMode, + controlInitialized, + pendingPermission, startSession, sendMessage, stopSession, + stopGeneration, clearMessages, + changePermissionMode, + respondToPermission, setError, setMessages } = useClaudeSession(); - const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui'); - const [selectedHost, setSelectedHost] = useState('local'); + const [selectedProject, setSelectedProject] = useState('/home/sumdex/projects'); + const [selectedHost, setSelectedHost] = useState('neko'); const [sidebarOpen, setSidebarOpen] = useState(true); const [resumeSession, setResumeSession] = useState(true); @@ -114,9 +156,29 @@ function App() { }]); }, [setMessages]); - const handleStartSession = () => { + const handleStartSession = useCallback(() => { startSession(selectedProject, resumeSession, selectedHost); - }; + }, [startSession, selectedProject, resumeSession, selectedHost]); + + const handleSelectProject = useCallback((path) => { + setSelectedProject(path); + }, []); + + const handleSelectHost = useCallback((host) => { + setSelectedHost(host); + }, []); + + const handleToggleSidebar = useCallback(() => { + setSidebarOpen(prev => !prev); + }, []); + + const handleToggleResume = useCallback(() => { + setResumeSession(prev => !prev); + }, []); + + const handleClearError = useCallback(() => { + setError(null); + }, [setError]); // Handle slash commands const handleCommand = useCallback((command, args) => { @@ -139,11 +201,9 @@ function App() { return false; }, [clearMessages, addSystemMessage, messages, stopSession, startSession, selectedProject, connected, sessionActive, currentProject]); - const handleSendMessage = (message) => { - if (!message.trim()) return; - - // Check for slash command - if (message.startsWith('/')) { + const handleSendMessage = useCallback((message, attachedFiles = []) => { + // Check for slash command (only if no files attached) + if (message.startsWith('/') && attachedFiles.length === 0) { const parts = message.slice(1).split(' '); const command = parts[0]; const args = parts.slice(1); @@ -156,26 +216,29 @@ function App() { } } - // Regular message - sendMessage(message); - }; + // Regular message (with optional attachments) + if (message.trim() || attachedFiles.length > 0) { + sendMessage(message, attachedFiles); + } + }, [handleCommand, addSystemMessage, sendMessage]); return (
{/* Sidebar */} setSidebarOpen(!sidebarOpen)} + onToggle={handleToggleSidebar} selectedProject={selectedProject} - onSelectProject={setSelectedProject} + onSelectProject={handleSelectProject} selectedHost={selectedHost} - onSelectHost={setSelectedHost} + onSelectHost={handleSelectHost} sessionActive={sessionActive} + activeHost={currentHost} onStartSession={handleStartSession} onStopSession={stopSession} onClearMessages={clearMessages} resumeSession={resumeSession} - onToggleResume={() => setResumeSession(!resumeSession)} + onToggleResume={handleToggleResume} /> {/* Main Content */} @@ -185,7 +248,7 @@ function App() { sessionActive={sessionActive} currentProject={currentProject} isProcessing={isProcessing} - onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} + onToggleSidebar={handleToggleSidebar} /> {/* Error Banner */} @@ -193,7 +256,7 @@ function App() {
{error}
+ + {/* Permission Dialog */} + respondToPermission(requestId, true)} + onDeny={(requestId) => respondToPermission(requestId, false)} + />
); } diff --git a/frontend/src/components/ChatInput.jsx b/frontend/src/components/ChatInput.jsx index 5af2dc7..f9f09c0 100644 --- a/frontend/src/components/ChatInput.jsx +++ b/frontend/src/components/ChatInput.jsx @@ -1,5 +1,39 @@ -import { useState, useRef, useEffect } from 'react'; -import { Send, Loader2, Command } from 'lucide-react'; +import { useState, useRef, useEffect, memo, useCallback } from 'react'; +import { Send, Square, Command, History, Paperclip, X, Image, FileText } from 'lucide-react'; + +// LocalStorage key for input history +const HISTORY_KEY = 'claude-webui-input-history'; +const MAX_HISTORY = 100; +const MAX_FILES = 5; +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +// Allowed file types +const ALLOWED_EXTENSIONS = [ + // Images + '.png', '.jpg', '.jpeg', '.gif', '.webp', + // Text/Code + '.txt', '.md', '.csv', '.html', '.css', '.js', '.ts', '.jsx', '.tsx', + '.json', '.xml', '.yaml', '.yml', '.py', '.go', '.rs', '.java', '.c', + '.cpp', '.h', '.sh', '.bash', '.zsh', '.toml', '.ini', '.conf', '.sql', + '.rb', '.php', '.swift', '.kt', '.scala', '.r', '.lua', '.pl', '.pm' +]; + +// Load history from localStorage +function loadHistory() { + try { + const stored = localStorage.getItem(HISTORY_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +// Save history to localStorage +function saveHistory(history) { + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(0, MAX_HISTORY))); + } catch {} +} // Available slash commands for autocomplete const COMMANDS = [ @@ -9,14 +43,24 @@ const COMMANDS = [ { name: 'scroll', description: 'Scroll to top or bottom' }, { name: 'new', description: 'Start a new session' }, { name: 'info', description: 'Show session info' }, + { name: 'history', description: 'Search input history (/history )' }, ]; -export function ChatInput({ onSend, disabled, placeholder }) { +export const ChatInput = memo(function ChatInput({ onSend, onStop, disabled, isProcessing, placeholder, sessionId }) { const [message, setMessage] = useState(''); const [showCommands, setShowCommands] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); const [filteredCommands, setFilteredCommands] = useState(COMMANDS); + const [inputHistory, setInputHistory] = useState(() => loadHistory()); + const [historyIndex, setHistoryIndex] = useState(-1); + const [savedInput, setSavedInput] = useState(''); + const [showHistorySearch, setShowHistorySearch] = useState(false); + const [historySearchResults, setHistorySearchResults] = useState([]); + const [attachedFiles, setAttachedFiles] = useState([]); + const [isDragOver, setIsDragOver] = useState(false); + const [uploadError, setUploadError] = useState(null); const textareaRef = useRef(null); + const fileInputRef = useRef(null); // Auto-resize textarea useEffect(() => { @@ -42,12 +86,153 @@ export function ChatInput({ onSend, disabled, placeholder }) { } }, [message]); + // Add message to history + const addToHistory = useCallback((msg) => { + const trimmed = msg.trim(); + if (!trimmed) return; + + setInputHistory(prev => { + // Remove duplicate if exists + const filtered = prev.filter(h => h !== trimmed); + const newHistory = [trimmed, ...filtered].slice(0, MAX_HISTORY); + saveHistory(newHistory); + return newHistory; + }); + }, []); + + // Validate file + const validateFile = useCallback((file) => { + const ext = '.' + file.name.split('.').pop().toLowerCase(); + if (!ALLOWED_EXTENSIONS.includes(ext) && !file.type.startsWith('image/')) { + return `File type not allowed: ${ext}`; + } + if (file.size > MAX_FILE_SIZE) { + return `File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB (max 10MB)`; + } + return null; + }, []); + + // Process and add files + const processFiles = useCallback((files) => { + setUploadError(null); + const fileArray = Array.from(files); + const remaining = MAX_FILES - attachedFiles.length; + + if (fileArray.length > remaining) { + setUploadError(`Only ${remaining} more file(s) can be added (max ${MAX_FILES})`); + return; + } + + const newFiles = []; + for (const file of fileArray) { + const error = validateFile(file); + if (error) { + setUploadError(error); + return; + } + + // Create preview for images + const isImage = file.type.startsWith('image/'); + const fileData = { + file, + name: file.name, + size: file.size, + type: file.type, + isImage, + preview: isImage ? URL.createObjectURL(file) : null, + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + }; + newFiles.push(fileData); + } + + setAttachedFiles(prev => [...prev, ...newFiles]); + }, [attachedFiles.length, validateFile]); + + // Remove attached file + const removeFile = useCallback((id) => { + setAttachedFiles(prev => { + const file = prev.find(f => f.id === id); + if (file?.preview) { + URL.revokeObjectURL(file.preview); + } + return prev.filter(f => f.id !== id); + }); + }, []); + + // Handle paste event + const handlePaste = useCallback((e) => { + const items = e.clipboardData?.items; + if (!items) return; + + const files = []; + for (const item of items) { + if (item.kind === 'file') { + const file = item.getAsFile(); + if (file) files.push(file); + } + } + + if (files.length > 0) { + e.preventDefault(); + processFiles(files); + } + }, [processFiles]); + + // Handle drag and drop + const handleDragOver = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }, []); + + const handleDragLeave = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }, []); + + const handleDrop = useCallback((e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + processFiles(files); + } + }, [processFiles]); + + // Handle file input change + const handleFileSelect = useCallback((e) => { + const files = e.target.files; + if (files && files.length > 0) { + processFiles(files); + } + // Reset input so same file can be selected again + e.target.value = ''; + }, [processFiles]); + + // Cleanup previews on unmount + useEffect(() => { + return () => { + attachedFiles.forEach(f => { + if (f.preview) URL.revokeObjectURL(f.preview); + }); + }; + }, []); + const handleSubmit = (e) => { e.preventDefault(); - if (message.trim() && !disabled) { - onSend(message); + if (message.trim() || attachedFiles.length > 0) { + addToHistory(message); + // Pass both message and files to onSend + onSend(message, attachedFiles); setMessage(''); + setAttachedFiles([]); setShowCommands(false); + setHistoryIndex(-1); + setSavedInput(''); + setUploadError(null); } }; @@ -57,7 +242,51 @@ export function ChatInput({ onSend, disabled, placeholder }) { textareaRef.current?.focus(); }; + // Select from history search + const selectHistoryItem = (item) => { + setMessage(item); + setShowHistorySearch(false); + setHistorySearchResults([]); + textareaRef.current?.focus(); + }; + const handleKeyDown = (e) => { + // ESC to stop generation + if (e.key === 'Escape') { + if (isProcessing && onStop) { + e.preventDefault(); + onStop(); + return; + } + if (showCommands) { + setShowCommands(false); + return; + } + if (showHistorySearch) { + setShowHistorySearch(false); + return; + } + } + + // Handle history search results navigation + if (showHistorySearch && historySearchResults.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex(i => Math.min(i + 1, historySearchResults.length - 1)); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex(i => Math.max(i - 1, 0)); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + selectHistoryItem(historySearchResults[selectedIndex]); + return; + } + } + // Handle command selection if (showCommands) { if (e.key === 'ArrowDown') { @@ -75,8 +304,33 @@ export function ChatInput({ onSend, disabled, placeholder }) { selectCommand(filteredCommands[selectedIndex].name); return; } - if (e.key === 'Escape') { - setShowCommands(false); + } + + // Arrow up/down for history navigation (only when not in command/search mode) + if (!showCommands && !showHistorySearch && inputHistory.length > 0) { + if (e.key === 'ArrowUp') { + // Only navigate history if at start of input or input is empty + const textarea = textareaRef.current; + if (textarea && (textarea.selectionStart === 0 || message === '')) { + e.preventDefault(); + if (historyIndex === -1) { + setSavedInput(message); + } + const newIndex = Math.min(historyIndex + 1, inputHistory.length - 1); + setHistoryIndex(newIndex); + setMessage(inputHistory[newIndex]); + return; + } + } + if (e.key === 'ArrowDown' && historyIndex >= 0) { + e.preventDefault(); + const newIndex = historyIndex - 1; + setHistoryIndex(newIndex); + if (newIndex === -1) { + setMessage(savedInput); + } else { + setMessage(inputHistory[newIndex]); + } return; } } @@ -88,8 +342,109 @@ export function ChatInput({ onSend, disabled, placeholder }) { }; return ( -
+ + {/* Drag overlay */} + {isDragOver && ( +
+
Drop files here
+
+ )} + + {/* Upload error */} + {uploadError && ( +
+ {uploadError} + +
+ )} + + {/* Attached files preview */} + {attachedFiles.length > 0 && ( +
+ {attachedFiles.map((file) => ( +
+ {file.isImage ? ( +
+ {file.name} +
+ +
+
+ ) : ( +
+ + {file.name} + +
+ )} +
+ ))} + {attachedFiles.length < MAX_FILES && ( + + )} +
+ )} +
+ {/* File input (hidden) */} + + + {/* Attach button */} + +
{/* Command autocomplete dropdown */} {showCommands && ( @@ -110,12 +465,37 @@ export function ChatInput({ onSend, disabled, placeholder }) {
)} + {/* History search results dropdown */} + {showHistorySearch && historySearchResults.length > 0 && ( +
+
+ + History search results ({historySearchResults.length}) +
+ {historySearchResults.map((item, index) => ( + + ))} +
+ )} +