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:
129
PLAN-MODE-BUG-FIX.md
Normal file
129
PLAN-MODE-BUG-FIX.md
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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
49
docker-compose.dev.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
15
frontend/Dockerfile.dev
Normal 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"]
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
{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()}
|
||||
disabled={disabled || (!message.trim() && attachedFiles.length === 0)}
|
||||
className={`
|
||||
p-3 rounded-xl transition-all
|
||||
${disabled || !message.trim()
|
||||
${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'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{disabled ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-2 text-xs text-dark-600">
|
||||
Messages are processed via Claude Code JSON streaming
|
||||
{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
202
frontend/src/components/PermissionDialog.jsx
Normal file
202
frontend/src/components/PermissionDialog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,14 +151,20 @@ export function Sidebar({
|
||||
Host
|
||||
</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{hosts.map((host) => (
|
||||
{hosts.map((host) => {
|
||||
const isActive = sessionActive && activeHost === host.id;
|
||||
const isDisabled = sessionActive && activeHost && activeHost !== host.id;
|
||||
return (
|
||||
<button
|
||||
key={host.id}
|
||||
onClick={() => onSelectHost(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
|
||||
${selectedHost === host.id
|
||||
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'
|
||||
}
|
||||
@@ -96,71 +173,84 @@ export function Sidebar({
|
||||
backgroundColor: selectedHost === host.id ? `${host.color}30` : 'transparent'
|
||||
}}
|
||||
>
|
||||
<Server className="w-3.5 h-3.5" style={{ color: host.color }} />
|
||||
<Server className="w-3.5 h-3.5" style={{ color: isDisabled ? '#4a4a4a' : host.color }} />
|
||||
<span>{host.name}</span>
|
||||
{!host.isLocal && <span className="text-xs text-dark-500">(SSH)</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) => (
|
||||
{/* Directory selector with dropdown */}
|
||||
<div className="relative">
|
||||
<div className="flex gap-2">
|
||||
{/* Dropdown button */}
|
||||
<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'
|
||||
}
|
||||
`}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
{selectedProject === project.path && (
|
||||
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* Custom path input */}
|
||||
<div className="pt-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customPath}
|
||||
onChange={(e) => setCustomPath(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCustomPath()}
|
||||
placeholder="Custom path..."
|
||||
className="flex-1 bg-dark-800 border border-dark-700 rounded-lg px-3 py-2
|
||||
text-sm text-dark-200 placeholder-dark-500
|
||||
focus:outline-none focus:border-orange-500/50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCustomPath}
|
||||
className="px-3 py-2 bg-dark-800 hover:bg-dark-700 rounded-lg
|
||||
text-dark-400 hover:text-dark-200 transition-colors"
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
177
frontend/src/components/StatusBar.jsx
Normal file
177
frontend/src/components/StatusBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
} 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: result.content,
|
||||
toolUseId: result.tool_use_id,
|
||||
isError: result.is_error || false,
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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
7
test-rendering.txt
Normal 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.
|
||||
Reference in New Issue
Block a user