feat: Major UI improvements and SSH-only mode

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-17 10:33:25 +01:00
parent 9eb0ecfb57
commit 960f2e137d
16 changed files with 3108 additions and 278 deletions

129
PLAN-MODE-BUG-FIX.md Normal file
View File

@@ -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

View File

@@ -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"
}
}

View File

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

49
docker-compose.dev.yml Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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 . .

15
frontend/Dockerfile.dev Normal file
View File

@@ -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"]

View File

@@ -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 <term>)',
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 (
<div className="flex h-screen bg-dark-950">
{/* Sidebar */}
<Sidebar
open={sidebarOpen}
onToggle={() => 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() {
<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)}
onClick={handleClearError}
className="text-red-400 hover:text-red-300"
>
×
@@ -202,23 +265,44 @@ function App() {
)}
{/* Messages */}
<MessageList messages={messages} isProcessing={isProcessing} />
<MessageList
messages={messages}
isProcessing={isProcessing}
/>
{/* Status Bar */}
<StatusBar
sessionStats={sessionStats}
isProcessing={isProcessing}
connected={connected}
permissionMode={permissionMode}
controlInitialized={controlInitialized}
onChangeMode={changePermissionMode}
/>
{/* Input */}
<ChatInput
onSend={handleSendMessage}
disabled={!sessionActive || isProcessing}
onStop={stopGeneration}
disabled={!sessionActive}
isProcessing={isProcessing}
sessionId={sessionId}
placeholder={
!connected
? 'Connecting...'
: !sessionActive
? 'Start a session to begin'
: isProcessing
? 'Claude is thinking...'
: 'Type your message...'
}
/>
</div>
{/* Permission Dialog */}
<PermissionDialog
permission={pendingPermission}
onAllow={(requestId) => respondToPermission(requestId, true)}
onDeny={(requestId) => respondToPermission(requestId, false)}
/>
</div>
);
}

View File

@@ -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 <term>)' },
];
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 (
<form onSubmit={handleSubmit} className="p-4 border-t border-dark-800 bg-dark-900">
<form
onSubmit={handleSubmit}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`p-4 border-t border-dark-800 bg-dark-900 transition-colors ${isDragOver ? 'bg-orange-900/20 border-orange-500/50' : ''}`}
>
{/* Drag overlay */}
{isDragOver && (
<div className="absolute inset-0 bg-orange-900/30 border-2 border-dashed border-orange-500 rounded-lg flex items-center justify-center z-20 pointer-events-none">
<div className="text-orange-400 text-lg font-medium">Drop files here</div>
</div>
)}
{/* Upload error */}
{uploadError && (
<div className="max-w-4xl mx-auto mb-2 px-3 py-2 bg-red-900/30 border border-red-700 rounded-lg text-red-400 text-sm flex justify-between items-center">
<span>{uploadError}</span>
<button type="button" onClick={() => setUploadError(null)} className="text-red-500 hover:text-red-400">
<X className="w-4 h-4" />
</button>
</div>
)}
{/* Attached files preview */}
{attachedFiles.length > 0 && (
<div className="max-w-4xl mx-auto mb-3 flex gap-2 flex-wrap">
{attachedFiles.map((file) => (
<div
key={file.id}
className="relative group bg-dark-800 border border-dark-700 rounded-lg overflow-hidden"
>
{file.isImage ? (
<div className="w-16 h-16 relative">
<img
src={file.preview}
alt={file.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
type="button"
onClick={() => removeFile(file.id)}
className="p-1 bg-red-600 rounded-full text-white hover:bg-red-500"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
) : (
<div className="px-3 py-2 flex items-center gap-2 max-w-48">
<FileText className="w-4 h-4 text-dark-400 flex-shrink-0" />
<span className="text-dark-300 text-xs truncate">{file.name}</span>
<button
type="button"
onClick={() => removeFile(file.id)}
className="p-0.5 text-dark-500 hover:text-red-400 flex-shrink-0"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</div>
))}
{attachedFiles.length < MAX_FILES && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-16 h-16 border border-dashed border-dark-600 rounded-lg flex items-center justify-center text-dark-500 hover:text-dark-400 hover:border-dark-500 transition-colors"
title="Add more files"
>
<Paperclip className="w-5 h-5" />
</button>
)}
</div>
)}
<div className="flex gap-3 items-end max-w-4xl mx-auto">
{/* File input (hidden) */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ALLOWED_EXTENSIONS.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
{/* Attach button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || attachedFiles.length >= MAX_FILES}
className={`p-3 rounded-xl transition-all ${
disabled || attachedFiles.length >= MAX_FILES
? 'bg-dark-800 text-dark-600 cursor-not-allowed'
: 'bg-dark-800 text-dark-400 hover:text-orange-400 hover:bg-dark-700'
}`}
title={attachedFiles.length >= MAX_FILES ? `Max ${MAX_FILES} files` : 'Attach files (or paste/drag)'}
>
<Paperclip className="w-5 h-5" />
</button>
<div className="flex-1 relative">
{/* Command autocomplete dropdown */}
{showCommands && (
@@ -110,12 +465,37 @@ export function ChatInput({ onSend, disabled, placeholder }) {
</div>
)}
{/* History search results dropdown */}
{showHistorySearch && historySearchResults.length > 0 && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-dark-800 border border-dark-700 rounded-lg shadow-xl overflow-hidden z-10 max-h-64 overflow-y-auto">
<div className="px-3 py-2 text-xs text-dark-500 border-b border-dark-700 flex items-center gap-2">
<History className="w-3 h-3" />
History search results ({historySearchResults.length})
</div>
{historySearchResults.map((item, index) => (
<button
key={index}
type="button"
onClick={() => selectHistoryItem(item)}
className={`w-full px-4 py-2.5 text-left transition-colors text-sm truncate
${index === selectedIndex ? 'bg-orange-600/20 text-orange-400' : 'hover:bg-dark-700 text-dark-200'}`}
>
{item.length > 100 ? item.slice(0, 100) + '...' : item}
</button>
))}
</div>
)}
<textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onChange={(e) => {
setMessage(e.target.value);
setHistoryIndex(-1); // Reset history navigation on manual edit
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
onPaste={handlePaste}
placeholder={isProcessing ? 'Type to send a follow-up message...' : placeholder}
disabled={disabled}
rows={1}
className={`
@@ -127,32 +507,39 @@ export function ChatInput({ onSend, disabled, placeholder }) {
`}
/>
<div className="absolute right-2 bottom-2 text-xs text-dark-600">
{message.startsWith('/') ? 'Tab to complete' : 'Shift+Enter for newline'}
{isProcessing ? 'ESC to stop' : attachedFiles.length > 0 ? `${attachedFiles.length} file(s)` : message.startsWith('/') ? 'Tab to complete' : '↑↓ history'}
</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" />
) : (
{isProcessing ? (
<button
type="button"
onClick={onStop}
className="p-3 rounded-xl transition-all bg-red-600 hover:bg-red-500 text-white shadow-lg shadow-red-600/20"
title="Stop generation (ESC)"
>
<Square className="w-5 h-5" />
</button>
) : (
<button
type="submit"
disabled={disabled || (!message.trim() && attachedFiles.length === 0)}
className={`
p-3 rounded-xl transition-all
${disabled || (!message.trim() && attachedFiles.length === 0)
? 'bg-dark-800 text-dark-600 cursor-not-allowed'
: 'bg-orange-600 hover:bg-orange-500 text-white shadow-lg shadow-orange-600/20'
}
`}
>
<Send className="w-5 h-5" />
)}
</button>
</button>
)}
</div>
<div className="text-center mt-2 text-xs text-dark-600">
Messages are processed via Claude Code JSON streaming
{isProcessing ? 'Generating... Press Enter to send follow-up, ESC to stop' : 'Paste, drag & drop, or click clip to attach files'}
</div>
</form>
);
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,202 @@
import { useState } from 'react';
import { ShieldAlert, Check, X, ChevronDown, ChevronUp, ClipboardList, ThumbsUp, ThumbsDown, Play } from 'lucide-react';
// Tool risk levels and descriptions
const TOOL_INFO = {
Bash: { risk: 'high', description: 'Execute shell commands' },
Edit: { risk: 'medium', description: 'Modify file contents' },
Write: { risk: 'medium', description: 'Create or overwrite files' },
Read: { risk: 'low', description: 'Read file contents' },
Glob: { risk: 'low', description: 'Search for files by pattern' },
Grep: { risk: 'low', description: 'Search file contents' },
Task: { risk: 'low', description: 'Launch sub-agent' },
WebFetch: { risk: 'low', description: 'Fetch web content' },
WebSearch: { risk: 'low', description: 'Search the web' },
TodoWrite: { risk: 'low', description: 'Update task list' },
NotebookEdit: { risk: 'medium', description: 'Edit Jupyter notebook' },
ExitPlanMode: { risk: 'low', description: 'Exit plan mode and start implementation' },
default: { risk: 'medium', description: 'Execute tool' }
};
function getRiskColor(risk) {
switch (risk) {
case 'high': return 'text-red-400 bg-red-500/20 border-red-500/30';
case 'medium': return 'text-yellow-400 bg-yellow-500/20 border-yellow-500/30';
case 'low': return 'text-green-400 bg-green-500/20 border-green-500/30';
default: return 'text-dark-400 bg-dark-700 border-dark-600';
}
}
export function PermissionDialog({ permission, onAllow, onDeny }) {
const [showDetails, setShowDetails] = useState(false);
if (!permission) return null;
const { requestId, toolName, toolInput, blockedPath, isPlanApproval } = permission;
const toolInfo = TOOL_INFO[toolName] || TOOL_INFO.default;
const riskColor = getRiskColor(toolInfo.risk);
// Format tool input for display
const formatInput = (input) => {
if (!input) return null;
try {
return JSON.stringify(input, null, 2);
} catch {
return String(input);
}
};
const inputStr = formatInput(toolInput);
// Special UI for Plan Approval
if (isPlanApproval) {
const launchSwarm = toolInput?.launchSwarm;
const teammateCount = toolInput?.teammateCount;
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-gradient-to-br from-purple-900/50 to-dark-900 border-2 border-purple-500/50 rounded-xl shadow-2xl shadow-purple-500/20 max-w-lg w-full">
{/* Header */}
<div className="flex items-center gap-3 p-5 border-b border-purple-500/30 bg-purple-900/20">
<div className="w-12 h-12 rounded-lg bg-purple-500/30 border border-purple-400/50 flex items-center justify-center">
<ClipboardList className="w-6 h-6 text-purple-300" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-purple-200">Plan Review</h3>
<p className="text-sm text-purple-400/70">Claude has created an implementation plan for your approval</p>
</div>
</div>
{/* Swarm info if applicable */}
{launchSwarm && (
<div className="px-5 py-3 bg-indigo-900/20 border-b border-indigo-500/30 flex items-center gap-2">
<Play className="w-4 h-4 text-indigo-400" />
<span className="text-sm text-indigo-300">
This plan will launch a swarm{teammateCount ? ` with ${teammateCount} teammates` : ''}
</span>
</div>
)}
{/* Content */}
<div className="p-5">
<p className="text-sm text-dark-300 mb-4">
Review the plan in the conversation above. Once approved, Claude will begin implementing the changes.
</p>
{/* Show details toggle */}
{inputStr && (
<div>
<button
onClick={() => setShowDetails(!showDetails)}
className="flex items-center gap-1 text-xs text-purple-400 hover:text-purple-300"
>
{showDetails ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
{showDetails ? 'Hide' : 'Show'} tool parameters
</button>
{showDetails && (
<pre className="mt-2 text-xs bg-dark-800/50 rounded p-2 overflow-auto max-h-32 text-dark-400">
{inputStr}
</pre>
)}
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-3 p-5 border-t border-purple-500/30">
<button
onClick={() => onDeny(requestId)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-dark-800 hover:bg-dark-700 text-dark-200 rounded-lg font-medium transition-colors border border-dark-600"
>
<ThumbsDown className="w-4 h-4" />
Reject Plan
</button>
<button
onClick={() => onAllow(requestId)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-green-600 hover:bg-green-500 text-white rounded-lg font-medium transition-colors"
>
<ThumbsUp className="w-4 h-4" />
Approve Plan
</button>
</div>
</div>
</div>
);
}
// Standard permission dialog
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-dark-900 border border-dark-700 rounded-lg shadow-xl max-w-lg w-full">
{/* Header */}
<div className="flex items-center gap-3 p-4 border-b border-dark-700">
<div className={`p-2 rounded-lg ${riskColor}`}>
<ShieldAlert className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-medium text-dark-100">Permission Required</h3>
<p className="text-xs text-dark-400">Claude wants to use a tool</p>
</div>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Tool name and description */}
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-mono text-dark-200">{toolName}</span>
<p className="text-xs text-dark-500">{toolInfo.description}</p>
</div>
<span className={`text-xs px-2 py-0.5 rounded border ${riskColor}`}>
{toolInfo.risk} risk
</span>
</div>
{/* Blocked path if any */}
{blockedPath && (
<div className="text-xs text-dark-400 bg-dark-800 rounded p-2">
<span className="text-dark-500">Path: </span>
<code className="text-orange-400">{blockedPath}</code>
</div>
)}
{/* Tool input (collapsible) */}
{inputStr && (
<div>
<button
onClick={() => setShowDetails(!showDetails)}
className="flex items-center gap-1 text-xs text-dark-400 hover:text-dark-300"
>
{showDetails ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
{showDetails ? 'Hide' : 'Show'} details
</button>
{showDetails && (
<pre className="mt-2 text-xs bg-dark-800 rounded p-2 overflow-auto max-h-48 text-dark-300">
{inputStr}
</pre>
)}
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2 p-4 border-t border-dark-700">
<button
onClick={() => onDeny(requestId)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-dark-800 hover:bg-dark-700 text-dark-300 rounded-lg transition-colors"
>
<X className="w-4 h-4" />
Deny
</button>
<button
onClick={() => onAllow(requestId)}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg transition-colors"
>
<Check className="w-4 h-4" />
Allow
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,42 @@
import { useState, useEffect } from 'react';
import { Play, Square, Trash2, FolderOpen, ChevronRight, Settings, Server } from 'lucide-react';
import { useState, useEffect, useCallback } from 'react';
import { Play, Square, Trash2, FolderOpen, ChevronRight, ChevronDown, Settings, Server, Plus, X, Folder, ArrowUp, Loader2 } from 'lucide-react';
const API_URL = import.meta.env.VITE_API_URL || 'http://100.105.142.13:3001';
const RECENT_DIRS_KEY = 'claude-webui-recent-dirs';
const MAX_RECENT_DIRS = 10;
// Load recent directories from localStorage
function loadRecentDirs() {
try {
const stored = localStorage.getItem(RECENT_DIRS_KEY);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}
// Save recent directories to localStorage
function saveRecentDirs(dirs) {
try {
localStorage.setItem(RECENT_DIRS_KEY, JSON.stringify(dirs));
} catch (e) {
console.error('Failed to save recent dirs:', e);
}
}
// Add a directory to recent list for a host
function addRecentDir(hostId, path) {
const recent = loadRecentDirs();
const hostRecent = recent[hostId] || [];
// Remove if already exists, then add to front
const filtered = hostRecent.filter(p => p !== path);
const updated = [path, ...filtered].slice(0, MAX_RECENT_DIRS);
recent[hostId] = updated;
saveRecentDirs(recent);
return updated;
}
export function Sidebar({
open,
@@ -11,6 +46,7 @@ export function Sidebar({
selectedHost,
onSelectHost,
sessionActive,
activeHost,
onStartSession,
onStopSession,
onClearMessages,
@@ -18,8 +54,13 @@ export function Sidebar({
onToggleResume
}) {
const [hosts, setHosts] = useState([]);
const [projects, setProjects] = useState([]);
const [customPath, setCustomPath] = useState('');
const [recentDirs, setRecentDirs] = useState([]);
const [showBrowser, setShowBrowser] = useState(false);
const [browserPath, setBrowserPath] = useState('~');
const [browserDirs, setBrowserDirs] = useState([]);
const [browserLoading, setBrowserLoading] = useState(false);
const [browserError, setBrowserError] = useState(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
// Fetch hosts on mount
useEffect(() => {
@@ -34,27 +75,57 @@ export function Sidebar({
.catch(console.error);
}, []);
// Fetch projects when host changes
// Load recent directories when host changes
useEffect(() => {
if (!selectedHost) return;
fetch(`${API_URL}/api/projects?host=${selectedHost}`)
.then(res => res.json())
.then(data => {
const projectList = data.projects || data;
setProjects(projectList);
// Auto-select first project when host changes
if (projectList.length > 0) {
onSelectProject(projectList[0].path);
}
})
.catch(console.error);
const recent = loadRecentDirs();
setRecentDirs(recent[selectedHost] || []);
}, [selectedHost]);
// Handle selecting a directory (from dropdown or browser)
const handleSelectDir = useCallback((path) => {
onSelectProject(path);
const updated = addRecentDir(selectedHost, path);
setRecentDirs(updated);
setDropdownOpen(false);
setShowBrowser(false);
}, [selectedHost, onSelectProject]);
const handleCustomPath = () => {
if (customPath.trim()) {
onSelectProject(customPath.trim());
setCustomPath('');
// Browse directories on host
const browsePath = useCallback(async (path) => {
if (!selectedHost) return;
setBrowserLoading(true);
setBrowserError(null);
try {
const res = await fetch(`${API_URL}/api/browse?host=${selectedHost}&path=${encodeURIComponent(path)}`);
const data = await res.json();
if (data.error) {
setBrowserError(data.error);
return;
}
setBrowserPath(data.currentPath);
setBrowserDirs(data.directories || []);
} catch (err) {
setBrowserError(err.message);
} finally {
setBrowserLoading(false);
}
}, [selectedHost]);
// Open browser
const openBrowser = useCallback(() => {
setShowBrowser(true);
setDropdownOpen(false);
browsePath('~');
}, [browsePath]);
// Get display name for path
const getDisplayName = (path) => {
const parts = path.split('/');
return parts[parts.length - 1] || path;
};
return (
@@ -80,87 +151,106 @@ export function Sidebar({
Host
</h3>
<div className="flex gap-2 flex-wrap">
{hosts.map((host) => (
<button
key={host.id}
onClick={() => onSelectHost(host.id)}
className={`
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
transition-colors border
${selectedHost === host.id
? 'border-orange-500/50 text-white'
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
}
`}
style={{
backgroundColor: selectedHost === host.id ? `${host.color}30` : 'transparent'
}}
>
<Server className="w-3.5 h-3.5" style={{ color: host.color }} />
<span>{host.name}</span>
{!host.isLocal && <span className="text-xs text-dark-500">(SSH)</span>}
</button>
))}
{hosts.map((host) => {
const isActive = sessionActive && activeHost === host.id;
const isDisabled = sessionActive && activeHost && activeHost !== host.id;
return (
<button
key={host.id}
onClick={() => !isDisabled && onSelectHost(host.id)}
disabled={isDisabled}
className={`
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
transition-colors border relative
${isDisabled
? 'border-dark-800 text-dark-600 cursor-not-allowed opacity-50'
: selectedHost === host.id
? 'border-orange-500/50 text-white'
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
}
`}
style={{
backgroundColor: selectedHost === host.id ? `${host.color}30` : 'transparent'
}}
>
<Server className="w-3.5 h-3.5" style={{ color: isDisabled ? '#4a4a4a' : host.color }} />
<span>{host.name}</span>
{isActive && (
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" title="Active session" />
)}
</button>
);
})}
</div>
{sessionActive && (
<p className="text-xs text-dark-500">Stop session to switch hosts</p>
)}
</div>
{/* Project Selection */}
{/* Working Directory */}
<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">
{/* Directory selector with dropdown */}
<div className="relative">
<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"
/>
{/* Dropdown button */}
<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"
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex-1 flex items-center gap-2 px-3 py-2.5 bg-dark-800 border border-dark-700 rounded-lg text-left hover:border-dark-600 transition-colors"
>
Set
<FolderOpen className="w-4 h-4 text-orange-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm text-dark-200 truncate">{getDisplayName(selectedProject)}</div>
<div className="text-xs text-dark-500 truncate">{selectedProject}</div>
</div>
<ChevronDown className={`w-4 h-4 text-dark-500 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
</button>
{/* Browse button */}
<button
onClick={openBrowser}
className="px-3 py-2.5 bg-dark-800 hover:bg-dark-700 border border-dark-700 rounded-lg text-dark-400 hover:text-dark-200 transition-colors"
title="Browse directories"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Dropdown menu */}
{dropdownOpen && (
<div className="absolute top-full left-0 right-0 mt-1 bg-dark-800 border border-dark-700 rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto">
{recentDirs.length === 0 ? (
<div className="px-3 py-4 text-sm text-dark-500 text-center">
No recent directories
</div>
) : (
recentDirs.map((path) => (
<button
key={path}
onClick={() => handleSelectDir(path)}
className={`w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-dark-700 transition-colors
${selectedProject === path ? 'bg-orange-500/10 text-orange-400' : 'text-dark-300'}`}
>
<Folder className="w-4 h-4 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm truncate">{getDisplayName(path)}</div>
<div className="text-xs text-dark-500 truncate">{path}</div>
</div>
{selectedProject === path && (
<ChevronRight className="w-4 h-4 flex-shrink-0" />
)}
</button>
))
)}
</div>
)}
</div>
{/* Resume toggle */}
<div className="pt-3">
<div className="pt-2">
<label className="flex items-center gap-3 cursor-pointer group">
<div
onClick={onToggleResume}
@@ -230,6 +320,84 @@ export function Sidebar({
<div>Claude Code Web UI POC</div>
<div>JSON Stream Mode</div>
</div>
{/* Directory Browser Modal */}
{showBrowser && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-900 border border-dark-700 rounded-xl shadow-2xl w-full max-w-md max-h-[70vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<h3 className="font-semibold text-dark-200">Browse Directories</h3>
<button
onClick={() => setShowBrowser(false)}
className="p-1 hover:bg-dark-700 rounded transition-colors text-dark-400 hover:text-dark-200"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Current path */}
<div className="px-4 py-2 bg-dark-800/50 border-b border-dark-700 flex items-center gap-2">
<span className="text-xs text-dark-500">Path:</span>
<code className="text-xs text-orange-400 flex-1 truncate">{browserPath}</code>
<button
onClick={() => handleSelectDir(browserPath)}
className="px-2 py-1 text-xs bg-orange-600 hover:bg-orange-500 rounded transition-colors"
>
Select
</button>
</div>
{/* Directory list */}
<div className="flex-1 overflow-y-auto p-2">
{browserLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 text-orange-400 animate-spin" />
</div>
) : browserError ? (
<div className="text-red-400 text-sm text-center py-4">
{browserError}
</div>
) : browserDirs.length === 0 ? (
<div className="text-dark-500 text-sm text-center py-4">
No subdirectories found
</div>
) : (
<div className="space-y-1">
{browserDirs.map((dir) => (
<button
key={dir.path}
onClick={() => browsePath(dir.path)}
onDoubleClick={() => handleSelectDir(dir.path)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-dark-700 transition-colors text-left"
>
{dir.type === 'parent' ? (
<ArrowUp className="w-4 h-4 text-dark-500" />
) : (
<Folder className="w-4 h-4 text-orange-400" />
)}
<span className="text-sm text-dark-200">{dir.name}</span>
</button>
))}
</div>
)}
</div>
{/* Footer hint */}
<div className="px-4 py-2 border-t border-dark-700 text-xs text-dark-500">
Click to navigate, double-click to select
</div>
</div>
</div>
)}
{/* Click outside to close dropdown */}
{dropdownOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setDropdownOpen(false)}
/>
)}
</aside>
);
}

View File

@@ -0,0 +1,177 @@
import { useState } from 'react';
import {
Coins, MessageSquare, Database, Zap, Loader2,
Brain, ShieldCheck, FileEdit, ChevronDown
} from 'lucide-react';
// Permission mode definitions with display info
const PERMISSION_MODES = [
{ value: 'default', label: 'Default', icon: ShieldCheck, color: 'text-blue-400', description: 'Prompts for dangerous tools' },
{ value: 'acceptEdits', label: 'Accept Edits', icon: FileEdit, color: 'text-green-400', description: 'Auto-accept file edits' },
{ value: 'plan', label: 'Plan', icon: Brain, color: 'text-purple-400', description: 'Planning mode only' },
{ value: 'bypassPermissions', label: 'Bypass', icon: Zap, color: 'text-orange-400', description: 'Allow all tools (careful!)' },
];
export function StatusBar({ sessionStats, isProcessing, connected, permissionMode = 'default', controlInitialized, onChangeMode }) {
const [showModeMenu, setShowModeMenu] = useState(false);
const {
totalCost = 0,
inputTokens = 0,
outputTokens = 0,
cacheReadTokens = 0,
cacheCreationTokens = 0,
numTurns = 0,
isCompacting = false,
} = sessionStats || {};
// Get current mode info
const currentMode = PERMISSION_MODES.find(m => m.value === permissionMode) || PERMISSION_MODES[0];
const ModeIcon = currentMode.icon;
// Calculate total tokens and estimate context usage
const totalTokens = inputTokens + outputTokens;
// Claude has ~200k context, but we show relative usage
const contextPercent = Math.min(100, (inputTokens / 200000) * 100);
// Format cost
const formatCost = (cost) => {
if (cost < 0.01) return `$${cost.toFixed(4)}`;
if (cost < 1) return `$${cost.toFixed(3)}`;
return `$${cost.toFixed(2)}`;
};
// Format token count
const formatTokens = (tokens) => {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`;
return tokens.toString();
};
return (
<div className="bg-dark-900 border-t border-dark-700 px-4 py-2">
<div className="flex items-center justify-between text-xs">
{/* Left side: Stats */}
<div className="flex items-center gap-4">
{/* Connection status */}
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-dark-400">{connected ? 'Connected' : 'Disconnected'}</span>
</div>
{/* Processing indicator */}
{isProcessing && (
<div className="flex items-center gap-1.5 text-orange-400">
<Loader2 className="w-3 h-3 animate-spin" />
<span>Processing...</span>
</div>
)}
{/* Compacting indicator */}
{isCompacting && (
<div className="flex items-center gap-1.5 text-purple-400">
<Zap className="w-3 h-3 animate-pulse" />
<span>Compacting context...</span>
</div>
)}
{/* Turns */}
{numTurns > 0 && (
<div className="flex items-center gap-1.5 text-dark-400">
<MessageSquare className="w-3 h-3" />
<span>{numTurns} turns</span>
</div>
)}
{/* Cost */}
{totalCost > 0 && (
<div className="flex items-center gap-1.5 text-dark-400">
<Coins className="w-3 h-3" />
<span>{formatCost(totalCost)}</span>
</div>
)}
{/* Permission Mode Toggle - show always for debugging, just disabled when not initialized */}
{(
<div className="relative">
<button
onClick={() => setShowModeMenu(!showModeMenu)}
className={`flex items-center gap-1.5 px-2 py-0.5 rounded ${currentMode.color} hover:bg-dark-800 transition-colors`}
title={currentMode.description}
>
<ModeIcon className="w-3 h-3" />
<span>{currentMode.label}</span>
<ChevronDown className={`w-3 h-3 transition-transform ${showModeMenu ? 'rotate-180' : ''}`} />
</button>
{showModeMenu && (
<div className="absolute bottom-full left-0 mb-1 bg-dark-800 border border-dark-600 rounded shadow-lg py-1 min-w-[160px] z-50">
{PERMISSION_MODES.map((mode) => {
const Icon = mode.icon;
const isActive = mode.value === permissionMode;
return (
<button
key={mode.value}
onClick={() => {
onChangeMode(mode.value);
setShowModeMenu(false);
}}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-dark-700 transition-colors ${
isActive ? mode.color : 'text-dark-300'
}`}
>
<Icon className="w-3.5 h-3.5" />
<div>
<div className="text-xs font-medium">{mode.label}</div>
<div className="text-[10px] text-dark-500">{mode.description}</div>
</div>
</button>
);
})}
</div>
)}
</div>
)}
</div>
{/* Right side: Token usage */}
<div className="flex items-center gap-4">
{/* Token counts */}
{totalTokens > 0 && (
<div className="flex items-center gap-3 text-dark-400">
<span className="flex items-center gap-1">
<span className="text-dark-500">In:</span>
<span className="text-cyan-400">{formatTokens(inputTokens)}</span>
</span>
<span className="flex items-center gap-1">
<span className="text-dark-500">Out:</span>
<span className="text-green-400">{formatTokens(outputTokens)}</span>
</span>
{cacheReadTokens > 0 && (
<span className="flex items-center gap-1">
<Database className="w-3 h-3 text-purple-400" />
<span className="text-purple-400">{formatTokens(cacheReadTokens)}</span>
</span>
)}
</div>
)}
{/* Context status - simple text based on remaining context */}
<div className="flex items-center gap-2">
<span className="text-dark-500">Context:</span>
{inputTokens > 0 ? (
<span className={`${
contextPercent >= 95 ? 'text-red-400 font-medium' :
contextPercent >= 85 ? 'text-yellow-400' : 'text-green-400'
}`}>
{contextPercent >= 85 ? `${(100 - contextPercent).toFixed(0)}% left` : 'ok'}
</span>
) : (
<span className="text-green-400">ok</span>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -2,14 +2,46 @@ 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';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export function useClaudeSession() {
const [connected, setConnected] = useState(false);
const [sessionActive, setSessionActive] = useState(false);
const [messages, setMessages] = useState([]);
const [currentProject, setCurrentProject] = useState(null);
const [currentHost, setCurrentHost] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState(null);
const [sessionId, setSessionId] = useState(null);
// Session stats
const [sessionStats, setSessionStats] = useState({
totalCost: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
numTurns: 0,
isCompacting: false,
});
// Permission mode and control protocol state
// Load from localStorage, default to 'default'
const [permissionMode, setPermissionMode] = useState(() => {
try {
return localStorage.getItem('claude-permission-mode') || 'default';
} catch {
return 'default';
}
});
const [controlInitialized, setControlInitialized] = useState(false);
const [availableModels, setAvailableModels] = useState([]);
const [accountInfo, setAccountInfo] = useState(null);
// Pending permission request (tool approval dialog)
const [pendingPermission, setPendingPermission] = useState(null);
// Note: respondedPlanApprovals removed - plan approval now handled via control protocol permission flow
const wsRef = useRef(null);
const currentAssistantMessage = useRef(null);
@@ -55,6 +87,7 @@ export function useClaudeSession() {
case 'session_started':
setSessionActive(true);
setCurrentProject(data.project);
setSessionId(data.sessionId || `session-${Date.now()}`);
setMessages(prev => [...prev, {
type: 'system',
content: `Session started in ${data.project}`,
@@ -88,6 +121,78 @@ export function useClaudeSession() {
setError(data.message);
setIsProcessing(false);
break;
case 'system':
// Handle system messages from backend
if (data.message) {
setMessages(prev => [...prev, {
type: 'system',
content: data.message,
timestamp: data.timestamp || Date.now()
}]);
}
break;
case 'generation_stopped':
// User requested to stop - process killed and restarting
setIsProcessing(false);
if (data.message) {
setMessages(prev => [...prev, {
type: 'system',
content: data.message,
timestamp: data.timestamp || Date.now()
}]);
}
break;
case 'control_initialized':
setControlInitialized(true);
if (data.models) setAvailableModels(data.models);
if (data.account) setAccountInfo(data.account);
console.log('Control protocol initialized:', data);
// Apply saved permission mode after control protocol is ready
try {
const savedMode = localStorage.getItem('claude-permission-mode');
if (savedMode && savedMode !== 'default' && wsRef.current?.readyState === WebSocket.OPEN) {
console.log('Applying saved permission mode:', savedMode);
wsRef.current.send(JSON.stringify({
type: 'set_permission_mode',
mode: savedMode
}));
}
} catch {}
break;
case 'permission_mode_changed':
setPermissionMode(data.mode);
try { localStorage.setItem('claude-permission-mode', data.mode); } catch {}
setMessages(prev => [...prev, {
type: 'system',
content: `Mode changed to: ${data.mode}`,
timestamp: data.timestamp
}]);
break;
case 'permission_mode':
setPermissionMode(data.mode);
try { localStorage.setItem('claude-permission-mode', data.mode); } catch {}
break;
case 'control_error':
setError(`Control error: ${data.error}`);
break;
case 'permission_request':
console.log('Permission request:', data);
setPendingPermission({
requestId: data.requestId,
toolName: data.toolName,
toolInput: data.toolInput,
permissionSuggestions: data.permissionSuggestions,
blockedPath: data.blockedPath
});
break;
}
}, []);
@@ -127,6 +232,11 @@ export function useClaudeSession() {
if (newMessages.length > 0) {
setMessages(prev => {
// Collect existing toolUseIds for deduplication
const existingToolUseIds = new Set(
prev.filter(m => m.type === 'tool_use' && m.toolUseId).map(m => m.toolUseId)
);
// 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) {
@@ -134,28 +244,45 @@ export function useClaudeSession() {
const textMessages = newMessages.filter(m => m.type === 'assistant');
const otherMessages = newMessages.filter(m => m.type !== 'assistant');
// Deduplicate tool_use messages by toolUseId
const uniqueOtherMessages = otherMessages.filter(
m => m.type !== 'tool_use' || !existingToolUseIds.has(m.toolUseId)
);
if (textMessages.length > 0) {
return [
...prev.slice(0, -1),
{ ...textMessages[0], streaming: false },
...otherMessages
...uniqueOtherMessages
];
}
}
return [...prev, ...newMessages];
// Deduplicate tool_use messages by toolUseId
const uniqueNewMessages = newMessages.filter(
m => m.type !== 'tool_use' || !existingToolUseIds.has(m.toolUseId)
);
return [...prev, ...uniqueNewMessages];
});
}
}
} 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 === 'user') {
// Tool results come as 'user' events with message.content containing tool_result blocks
if (event.message?.content) {
for (const block of event.message.content) {
if (block.type === 'tool_result') {
console.log('Tool result found:', block);
setMessages(prev => [...prev, {
type: 'tool_result',
content: block.content,
toolUseId: block.tool_use_id,
isError: block.is_error || false,
timestamp: Date.now()
}]);
}
}
}
} else if (event.type === 'content_block_delta') {
// Streaming delta (direct)
if (event.delta?.text) {
@@ -196,10 +323,26 @@ export function useClaudeSession() {
});
}
} else if (event.type === 'result') {
// Final result - just stop processing
// Final result - extract stats and stop processing
setIsProcessing(false);
// Update session stats from result event
if (event.usage || event.total_cost_usd !== undefined) {
setSessionStats(prev => ({
totalCost: event.total_cost_usd || prev.totalCost,
inputTokens: event.usage?.input_tokens || prev.inputTokens,
outputTokens: event.usage?.output_tokens || prev.outputTokens,
cacheReadTokens: event.usage?.cache_read_input_tokens || prev.cacheReadTokens,
cacheCreationTokens: event.usage?.cache_creation_input_tokens || prev.cacheCreationTokens,
numTurns: event.num_turns || prev.numTurns,
isCompacting: false,
}));
}
} else if (event.type === 'system' && event.subtype === 'result') {
setIsProcessing(false);
} else if (event.type === 'system' && event.message?.includes?.('compact')) {
// Detect compacting
setSessionStats(prev => ({ ...prev, isCompacting: true }));
}
}, []);
@@ -228,6 +371,9 @@ export function useClaudeSession() {
return;
}
// Track which host we're connecting to
setCurrentHost(host);
// Load history before starting session if resuming
if (resume) {
await loadHistory(project, host);
@@ -241,7 +387,36 @@ export function useClaudeSession() {
}));
}, [loadHistory]);
const sendMessage = useCallback((message) => {
// Upload files to server
const uploadFiles = useCallback(async (files) => {
if (!files || files.length === 0) return [];
const formData = new FormData();
for (const fileData of files) {
formData.append('files', fileData.file);
}
try {
const response = await fetch(`${API_URL}/api/upload/${sessionId}`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Upload failed');
}
const data = await response.json();
return data.files;
} catch (err) {
console.error('Upload error:', err);
setError(`Upload failed: ${err.message}`);
return [];
}
}, [sessionId]);
const sendMessage = useCallback(async (message, attachedFiles = []) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
setError('Not connected');
return;
@@ -252,10 +427,39 @@ export function useClaudeSession() {
return;
}
// Add user message to display
let uploadedFiles = [];
let attachmentInfo = null;
// Upload files if any
if (attachedFiles.length > 0) {
setIsProcessing(true);
uploadedFiles = await uploadFiles(attachedFiles);
if (uploadedFiles.length > 0) {
// Create attachment info for the message
attachmentInfo = uploadedFiles.map(f => ({
path: f.path,
name: f.originalName,
type: f.mimeType,
isImage: f.isImage
}));
}
}
// Build the prompt with attachment info
let finalMessage = message;
if (uploadedFiles.length > 0) {
const attachmentPrefix = uploadedFiles
.map(f => `${f.path} (${f.mimeType})`)
.join(', ');
finalMessage = `[Attachments: ${attachmentPrefix}]\n\n${message}`;
}
// Add user message to display (with attachment badge info)
setMessages(prev => [...prev, {
type: 'user',
content: message,
attachments: attachmentInfo,
timestamp: Date.now()
}]);
@@ -263,20 +467,72 @@ export function useClaudeSession() {
wsRef.current.send(JSON.stringify({
type: 'user_message',
message
message: finalMessage
}));
}, [sessionActive]);
}, [sessionActive, uploadFiles]);
const stopSession = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'stop_session' }));
}
setCurrentHost(null);
}, []);
// Stop current generation (interrupt Claude)
const stopGeneration = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'stop_generation' }));
setIsProcessing(false);
}
}, []);
const clearMessages = useCallback(() => {
setMessages([]);
}, []);
// Respond to a permission request (allow/deny tool execution)
const respondToPermission = useCallback((requestId, allow, message = null) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
setError('Not connected');
return;
}
wsRef.current.send(JSON.stringify({
type: 'permission_response',
requestId,
allow,
message
}));
// Clear the pending permission
setPendingPermission(null);
}, []);
// Note: respondToPlanApproval removed - plan approval now handled via permission_response with isPlanApproval flag
// Change permission mode during session
const changePermissionMode = useCallback((mode) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
setError('Not connected');
return;
}
if (!sessionActive) {
setError('No active session');
return;
}
if (!controlInitialized) {
setError('Control protocol not initialized');
return;
}
wsRef.current.send(JSON.stringify({
type: 'set_permission_mode',
mode
}));
}, [sessionActive, controlInitialized]);
// Auto-connect on mount
useEffect(() => {
connect();
@@ -288,15 +544,26 @@ export function useClaudeSession() {
return {
connected,
sessionActive,
sessionId,
messages,
currentProject,
currentHost,
isProcessing,
error,
sessionStats,
permissionMode,
controlInitialized,
availableModels,
accountInfo,
pendingPermission,
connect,
startSession,
sendMessage,
stopSession,
stopGeneration,
clearMessages,
changePermissionMode,
respondToPermission,
setError,
setMessages
};

View File

@@ -73,3 +73,151 @@ code {
.typing-dot:nth-child(3) {
animation-delay: 0.4s;
}
/* Claude message markdown styling */
.claude-message {
color: #94a3b8; /* gray text */
font-size: 0.875rem; /* base text-sm */
}
div.claude-message h1 {
all: revert;
color: #fb923c !important;
font-weight: 700 !important;
font-size: 1.75rem !important;
margin-top: 1.25rem !important;
margin-bottom: 0.75rem !important;
line-height: 1.2 !important;
}
div.claude-message h2 {
all: revert;
color: #fb923c !important;
font-weight: 700 !important;
font-size: 1.375rem !important;
margin-top: 1rem !important;
margin-bottom: 0.5rem !important;
line-height: 1.2 !important;
}
div.claude-message h3 {
all: revert;
color: #fb923c !important;
font-weight: 600 !important;
font-size: 1.125rem !important;
margin-top: 0.875rem !important;
margin-bottom: 0.375rem !important;
line-height: 1.3 !important;
}
div.claude-message h4 {
all: revert;
color: #fb923c !important;
font-weight: 600 !important;
font-size: 1rem !important;
margin-top: 0.75rem !important;
margin-bottom: 0.25rem !important;
line-height: 1.3 !important;
}
.claude-message p {
margin: 0.5rem 0;
line-height: 1.6;
}
.claude-message strong {
color: #ffffff;
font-weight: 700;
}
.claude-message em {
color: #cbd5e1;
font-style: italic;
}
.claude-message a {
color: #22d3ee; /* cyan-400 */
text-decoration: none;
}
.claude-message a:hover {
text-decoration: underline;
}
.claude-message code {
color: #67e8f9; /* cyan-300 */
background: #0f172a;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
}
.claude-message pre {
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.5rem;
margin: 0.75rem 0;
padding: 0;
}
.claude-message pre code {
background: transparent;
padding: 0;
}
.claude-message ul {
margin: 0.5rem 0 !important;
padding-left: 1.5rem !important;
list-style-type: disc !important;
list-style-position: outside !important;
}
.claude-message ol {
margin: 0.5rem 0 !important;
padding-left: 1.5rem !important;
list-style-type: decimal !important;
list-style-position: outside !important;
}
.claude-message ul li,
.claude-message ol li {
display: list-item !important;
}
.claude-message li {
margin: 0.25rem 0;
}
.claude-message li::marker {
color: #f97316; /* orange-500 */
}
.claude-message blockquote {
border-left: 3px solid #f97316;
padding-left: 1rem;
margin: 0.75rem 0;
color: #64748b;
font-style: italic;
}
.claude-message table {
font-size: 0.75rem;
border-collapse: collapse;
width: 100%;
margin: 0.75rem 0;
}
.claude-message th {
color: #fb923c;
font-weight: 600;
text-align: left;
padding: 0.5rem;
border-bottom: 1px solid #334155;
}
.claude-message td {
color: #94a3b8;
padding: 0.5rem;
border-bottom: 1px solid #1e293b;
}

7
test-rendering.txt Normal file
View File

@@ -0,0 +1,7 @@
This is a test file to check if the Write tool renders correctly.
Line 1
Line 2
Line 3
The end.