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",
|
"express": "^4.18.2",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"cors": "^2.8.5",
|
"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 { spawn } from 'child_process';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'fs';
|
||||||
import { join, basename } from 'path';
|
import { join, basename, extname } from 'path';
|
||||||
|
import multer from 'multer';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -13,6 +14,68 @@ app.use(express.json());
|
|||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
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
|
// Load hosts configuration
|
||||||
const CONFIG_PATH = process.env.CONFIG_PATH || '/app/config/hosts.json';
|
const CONFIG_PATH = process.env.CONFIG_PATH || '/app/config/hosts.json';
|
||||||
@@ -35,6 +98,12 @@ loadConfig();
|
|||||||
// Store active Claude sessions
|
// Store active Claude sessions
|
||||||
const sessions = new Map();
|
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 server = createServer(app);
|
||||||
const wss = new WebSocketServer({ server });
|
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
|
// Parse history content into messages
|
||||||
function parseHistoryContent(content) {
|
function parseHistoryContent(content) {
|
||||||
const lines = content.split('\n').filter(l => l.trim());
|
const lines = content.split('\n').filter(l => l.trim());
|
||||||
@@ -290,10 +497,54 @@ wss.on('connection', (ws, req) => {
|
|||||||
|
|
||||||
let claudeProcess = null;
|
let claudeProcess = null;
|
||||||
let currentProject = 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) => {
|
const sendToClient = (type, data) => {
|
||||||
if (ws.readyState === ws.OPEN) {
|
if (ws.readyState === ws.OPEN) {
|
||||||
ws.send(JSON.stringify({ type, ...data, timestamp: Date.now() }));
|
try {
|
||||||
|
ws.send(JSON.stringify({ type, ...data, timestamp: Date.now() }));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[${sessionId}] WebSocket send failed:`, err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -304,6 +555,7 @@ wss.on('connection', (ws, req) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentProject = projectPath;
|
currentProject = projectPath;
|
||||||
|
currentHostId = hostId; // Save for potential restart
|
||||||
|
|
||||||
// Get host config
|
// Get host config
|
||||||
const host = hostId ? hostsConfig.hosts[hostId] : null;
|
const host = hostId ? hostsConfig.hosts[hostId] : null;
|
||||||
@@ -316,8 +568,8 @@ wss.on('connection', (ws, req) => {
|
|||||||
'--output-format', 'stream-json',
|
'--output-format', 'stream-json',
|
||||||
'--input-format', 'stream-json',
|
'--input-format', 'stream-json',
|
||||||
'--include-partial-messages',
|
'--include-partial-messages',
|
||||||
'--verbose',
|
'--verbose'
|
||||||
'--dangerously-skip-permissions'
|
// Note: No --dangerously-skip-permissions - we handle permissions via control protocol
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add continue flag to resume most recent conversation
|
// Add continue flag to resume most recent conversation
|
||||||
@@ -362,26 +614,133 @@ wss.on('connection', (ws, req) => {
|
|||||||
project: projectPath
|
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)
|
// Handle stdout (JSON events)
|
||||||
let buffer = '';
|
let pendingLine = '';
|
||||||
claudeProcess.stdout.on('data', (data) => {
|
claudeProcess.stdout.on('data', (data) => {
|
||||||
const chunk = data.toString();
|
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 parts = (pendingLine + chunk).split('\n');
|
||||||
const lines = buffer.split('\n');
|
pendingLine = parts.pop() || '';
|
||||||
buffer = lines.pop() || '';
|
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of parts) {
|
||||||
if (line.trim()) {
|
if (line.trim()) {
|
||||||
console.log(`[${sessionId}] Processing line:`, line.substring(0, 100));
|
if (DEBUG) console.log(`[${sessionId}] Processing line:`, line.substring(0, 100));
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(line);
|
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 });
|
sendToClient('claude_event', { event });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Non-JSON output, send as raw
|
// 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 });
|
sendToClient('raw_output', { content: line });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,7 +750,7 @@ wss.on('connection', (ws, req) => {
|
|||||||
// Handle stderr
|
// Handle stderr
|
||||||
claudeProcess.stderr.on('data', (data) => {
|
claudeProcess.stderr.on('data', (data) => {
|
||||||
const content = data.toString();
|
const content = data.toString();
|
||||||
console.log(`[${sessionId}] stderr:`, content);
|
if (DEBUG) console.log(`[${sessionId}] stderr:`, content);
|
||||||
sendToClient('stderr', { content });
|
sendToClient('stderr', { content });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -411,7 +770,7 @@ wss.on('connection', (ws, req) => {
|
|||||||
ws.on('message', (message) => {
|
ws.on('message', (message) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(message.toString());
|
const data = JSON.parse(message.toString());
|
||||||
console.log(`[${sessionId}] Received:`, data.type);
|
if (DEBUG) console.log(`[${sessionId}] Received:`, data.type);
|
||||||
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'start_session':
|
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');
|
claudeProcess.stdin.write(JSON.stringify(payload) + '\n');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -443,6 +802,139 @@ wss.on('connection', (ws, req) => {
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
console.log(`[${sessionId}] Unknown message type:`, data.type);
|
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:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
network: host
|
||||||
container_name: claude-webui-backend
|
container_name: claude-webui-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 2G
|
||||||
|
cpus: '2'
|
||||||
|
reservations:
|
||||||
|
memory: 512M
|
||||||
volumes:
|
volumes:
|
||||||
# Claude CLI binary (read-only from host)
|
# Claude CLI binary (read-only from host)
|
||||||
- /home/sumdex/.local/share/claude:/home/node/.local/share/claude:ro
|
- /home/sumdex/.local/share/claude:/home/node/.local/share/claude:ro
|
||||||
@@ -31,6 +39,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
network: host
|
||||||
args:
|
args:
|
||||||
- VITE_WS_URL=ws://100.105.142.13:3001
|
- VITE_WS_URL=ws://100.105.142.13:3001
|
||||||
- VITE_API_URL=http://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
|
# Build stage
|
||||||
FROM node:20-slim AS builder
|
FROM node:20-slim AS builder
|
||||||
|
|
||||||
@@ -14,8 +15,9 @@ ENV VITE_API_URL=$VITE_API_URL
|
|||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies with cache mount
|
||||||
RUN npm install
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm install
|
||||||
|
|
||||||
# Copy source
|
# Copy source
|
||||||
COPY . .
|
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 { ChatInput } from './components/ChatInput';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
|
import { StatusBar } from './components/StatusBar';
|
||||||
|
import { PermissionDialog } from './components/PermissionDialog';
|
||||||
|
|
||||||
// Slash command definitions
|
// Slash command definitions
|
||||||
const SLASH_COMMANDS = {
|
const SLASH_COMMANDS = {
|
||||||
@@ -81,6 +83,37 @@ const SLASH_COMMANDS = {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
addSystemMessage(info);
|
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 {
|
const {
|
||||||
connected,
|
connected,
|
||||||
sessionActive,
|
sessionActive,
|
||||||
|
sessionId,
|
||||||
messages,
|
messages,
|
||||||
currentProject,
|
currentProject,
|
||||||
|
currentHost,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
error,
|
error,
|
||||||
|
sessionStats,
|
||||||
|
permissionMode,
|
||||||
|
controlInitialized,
|
||||||
|
pendingPermission,
|
||||||
startSession,
|
startSession,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
stopSession,
|
stopSession,
|
||||||
|
stopGeneration,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
|
changePermissionMode,
|
||||||
|
respondToPermission,
|
||||||
setError,
|
setError,
|
||||||
setMessages
|
setMessages
|
||||||
} = useClaudeSession();
|
} = useClaudeSession();
|
||||||
|
|
||||||
const [selectedProject, setSelectedProject] = useState('/projects/claude-web-ui');
|
const [selectedProject, setSelectedProject] = useState('/home/sumdex/projects');
|
||||||
const [selectedHost, setSelectedHost] = useState('local');
|
const [selectedHost, setSelectedHost] = useState('neko');
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [resumeSession, setResumeSession] = useState(true);
|
const [resumeSession, setResumeSession] = useState(true);
|
||||||
|
|
||||||
@@ -114,9 +156,29 @@ function App() {
|
|||||||
}]);
|
}]);
|
||||||
}, [setMessages]);
|
}, [setMessages]);
|
||||||
|
|
||||||
const handleStartSession = () => {
|
const handleStartSession = useCallback(() => {
|
||||||
startSession(selectedProject, resumeSession, selectedHost);
|
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
|
// Handle slash commands
|
||||||
const handleCommand = useCallback((command, args) => {
|
const handleCommand = useCallback((command, args) => {
|
||||||
@@ -139,11 +201,9 @@ function App() {
|
|||||||
return false;
|
return false;
|
||||||
}, [clearMessages, addSystemMessage, messages, stopSession, startSession, selectedProject, connected, sessionActive, currentProject]);
|
}, [clearMessages, addSystemMessage, messages, stopSession, startSession, selectedProject, connected, sessionActive, currentProject]);
|
||||||
|
|
||||||
const handleSendMessage = (message) => {
|
const handleSendMessage = useCallback((message, attachedFiles = []) => {
|
||||||
if (!message.trim()) return;
|
// Check for slash command (only if no files attached)
|
||||||
|
if (message.startsWith('/') && attachedFiles.length === 0) {
|
||||||
// Check for slash command
|
|
||||||
if (message.startsWith('/')) {
|
|
||||||
const parts = message.slice(1).split(' ');
|
const parts = message.slice(1).split(' ');
|
||||||
const command = parts[0];
|
const command = parts[0];
|
||||||
const args = parts.slice(1);
|
const args = parts.slice(1);
|
||||||
@@ -156,26 +216,29 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular message
|
// Regular message (with optional attachments)
|
||||||
sendMessage(message);
|
if (message.trim() || attachedFiles.length > 0) {
|
||||||
};
|
sendMessage(message, attachedFiles);
|
||||||
|
}
|
||||||
|
}, [handleCommand, addSystemMessage, sendMessage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-dark-950">
|
<div className="flex h-screen bg-dark-950">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<Sidebar
|
<Sidebar
|
||||||
open={sidebarOpen}
|
open={sidebarOpen}
|
||||||
onToggle={() => setSidebarOpen(!sidebarOpen)}
|
onToggle={handleToggleSidebar}
|
||||||
selectedProject={selectedProject}
|
selectedProject={selectedProject}
|
||||||
onSelectProject={setSelectedProject}
|
onSelectProject={handleSelectProject}
|
||||||
selectedHost={selectedHost}
|
selectedHost={selectedHost}
|
||||||
onSelectHost={setSelectedHost}
|
onSelectHost={handleSelectHost}
|
||||||
sessionActive={sessionActive}
|
sessionActive={sessionActive}
|
||||||
|
activeHost={currentHost}
|
||||||
onStartSession={handleStartSession}
|
onStartSession={handleStartSession}
|
||||||
onStopSession={stopSession}
|
onStopSession={stopSession}
|
||||||
onClearMessages={clearMessages}
|
onClearMessages={clearMessages}
|
||||||
resumeSession={resumeSession}
|
resumeSession={resumeSession}
|
||||||
onToggleResume={() => setResumeSession(!resumeSession)}
|
onToggleResume={handleToggleResume}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
@@ -185,7 +248,7 @@ function App() {
|
|||||||
sessionActive={sessionActive}
|
sessionActive={sessionActive}
|
||||||
currentProject={currentProject}
|
currentProject={currentProject}
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
|
onToggleSidebar={handleToggleSidebar}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Error Banner */}
|
{/* 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">
|
<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>
|
<span className="text-red-200 text-sm">{error}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setError(null)}
|
onClick={handleClearError}
|
||||||
className="text-red-400 hover:text-red-300"
|
className="text-red-400 hover:text-red-300"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -202,23 +265,44 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Messages */}
|
{/* 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 */}
|
{/* Input */}
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={handleSendMessage}
|
onSend={handleSendMessage}
|
||||||
disabled={!sessionActive || isProcessing}
|
onStop={stopGeneration}
|
||||||
|
disabled={!sessionActive}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
sessionId={sessionId}
|
||||||
placeholder={
|
placeholder={
|
||||||
!connected
|
!connected
|
||||||
? 'Connecting...'
|
? 'Connecting...'
|
||||||
: !sessionActive
|
: !sessionActive
|
||||||
? 'Start a session to begin'
|
? 'Start a session to begin'
|
||||||
: isProcessing
|
|
||||||
? 'Claude is thinking...'
|
|
||||||
: 'Type your message...'
|
: 'Type your message...'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Permission Dialog */}
|
||||||
|
<PermissionDialog
|
||||||
|
permission={pendingPermission}
|
||||||
|
onAllow={(requestId) => respondToPermission(requestId, true)}
|
||||||
|
onDeny={(requestId) => respondToPermission(requestId, false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,39 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, memo, useCallback } from 'react';
|
||||||
import { Send, Loader2, Command } from 'lucide-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
|
// Available slash commands for autocomplete
|
||||||
const COMMANDS = [
|
const COMMANDS = [
|
||||||
@@ -9,14 +43,24 @@ const COMMANDS = [
|
|||||||
{ name: 'scroll', description: 'Scroll to top or bottom' },
|
{ name: 'scroll', description: 'Scroll to top or bottom' },
|
||||||
{ name: 'new', description: 'Start a new session' },
|
{ name: 'new', description: 'Start a new session' },
|
||||||
{ name: 'info', description: 'Show session info' },
|
{ 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 [message, setMessage] = useState('');
|
||||||
const [showCommands, setShowCommands] = useState(false);
|
const [showCommands, setShowCommands] = useState(false);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [filteredCommands, setFilteredCommands] = useState(COMMANDS);
|
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 textareaRef = useRef(null);
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
// Auto-resize textarea
|
// Auto-resize textarea
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -42,12 +86,153 @@ export function ChatInput({ onSend, disabled, placeholder }) {
|
|||||||
}
|
}
|
||||||
}, [message]);
|
}, [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) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (message.trim() && !disabled) {
|
if (message.trim() || attachedFiles.length > 0) {
|
||||||
onSend(message);
|
addToHistory(message);
|
||||||
|
// Pass both message and files to onSend
|
||||||
|
onSend(message, attachedFiles);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
|
setAttachedFiles([]);
|
||||||
setShowCommands(false);
|
setShowCommands(false);
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
setSavedInput('');
|
||||||
|
setUploadError(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,7 +242,51 @@ export function ChatInput({ onSend, disabled, placeholder }) {
|
|||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Select from history search
|
||||||
|
const selectHistoryItem = (item) => {
|
||||||
|
setMessage(item);
|
||||||
|
setShowHistorySearch(false);
|
||||||
|
setHistorySearchResults([]);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
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
|
// Handle command selection
|
||||||
if (showCommands) {
|
if (showCommands) {
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
@@ -75,8 +304,33 @@ export function ChatInput({ onSend, disabled, placeholder }) {
|
|||||||
selectCommand(filteredCommands[selectedIndex].name);
|
selectCommand(filteredCommands[selectedIndex].name);
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,8 +342,109 @@ export function ChatInput({ onSend, disabled, placeholder }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<div className="flex-1 relative">
|
||||||
{/* Command autocomplete dropdown */}
|
{/* Command autocomplete dropdown */}
|
||||||
{showCommands && (
|
{showCommands && (
|
||||||
@@ -110,12 +465,37 @@ export function ChatInput({ onSend, disabled, placeholder }) {
|
|||||||
</div>
|
</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
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setMessage(e.target.value);
|
||||||
|
setHistoryIndex(-1); // Reset history navigation on manual edit
|
||||||
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder}
|
onPaste={handlePaste}
|
||||||
|
placeholder={isProcessing ? 'Type to send a follow-up message...' : placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
rows={1}
|
rows={1}
|
||||||
className={`
|
className={`
|
||||||
@@ -127,32 +507,39 @@ export function ChatInput({ onSend, disabled, placeholder }) {
|
|||||||
`}
|
`}
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-2 bottom-2 text-xs text-dark-600">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{isProcessing ? (
|
||||||
type="submit"
|
<button
|
||||||
disabled={disabled || !message.trim()}
|
type="button"
|
||||||
className={`
|
onClick={onStop}
|
||||||
p-3 rounded-xl transition-all
|
className="p-3 rounded-xl transition-all bg-red-600 hover:bg-red-500 text-white shadow-lg shadow-red-600/20"
|
||||||
${disabled || !message.trim()
|
title="Stop generation (ESC)"
|
||||||
? 'bg-dark-800 text-dark-600 cursor-not-allowed'
|
>
|
||||||
: 'bg-orange-600 hover:bg-orange-500 text-white shadow-lg shadow-orange-600/20'
|
<Square className="w-5 h-5" />
|
||||||
}
|
</button>
|
||||||
`}
|
) : (
|
||||||
>
|
<button
|
||||||
{disabled ? (
|
type="submit"
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
disabled={disabled || (!message.trim() && attachedFiles.length === 0)}
|
||||||
) : (
|
className={`
|
||||||
|
p-3 rounded-xl transition-all
|
||||||
|
${disabled || (!message.trim() && attachedFiles.length === 0)
|
||||||
|
? 'bg-dark-800 text-dark-600 cursor-not-allowed'
|
||||||
|
: 'bg-orange-600 hover:bg-orange-500 text-white shadow-lg shadow-orange-600/20'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
<Send className="w-5 h-5" />
|
<Send className="w-5 h-5" />
|
||||||
)}
|
</button>
|
||||||
</button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center mt-2 text-xs text-dark-600">
|
<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>
|
</div>
|
||||||
</form>
|
</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 { useState, useEffect, useCallback } from 'react';
|
||||||
import { Play, Square, Trash2, FolderOpen, ChevronRight, Settings, Server } from 'lucide-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 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({
|
export function Sidebar({
|
||||||
open,
|
open,
|
||||||
@@ -11,6 +46,7 @@ export function Sidebar({
|
|||||||
selectedHost,
|
selectedHost,
|
||||||
onSelectHost,
|
onSelectHost,
|
||||||
sessionActive,
|
sessionActive,
|
||||||
|
activeHost,
|
||||||
onStartSession,
|
onStartSession,
|
||||||
onStopSession,
|
onStopSession,
|
||||||
onClearMessages,
|
onClearMessages,
|
||||||
@@ -18,8 +54,13 @@ export function Sidebar({
|
|||||||
onToggleResume
|
onToggleResume
|
||||||
}) {
|
}) {
|
||||||
const [hosts, setHosts] = useState([]);
|
const [hosts, setHosts] = useState([]);
|
||||||
const [projects, setProjects] = useState([]);
|
const [recentDirs, setRecentDirs] = useState([]);
|
||||||
const [customPath, setCustomPath] = 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
|
// Fetch hosts on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -34,27 +75,57 @@ export function Sidebar({
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch projects when host changes
|
// Load recent directories when host changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedHost) return;
|
if (!selectedHost) return;
|
||||||
fetch(`${API_URL}/api/projects?host=${selectedHost}`)
|
const recent = loadRecentDirs();
|
||||||
.then(res => res.json())
|
setRecentDirs(recent[selectedHost] || []);
|
||||||
.then(data => {
|
}, [selectedHost]);
|
||||||
const projectList = data.projects || data;
|
|
||||||
setProjects(projectList);
|
// Handle selecting a directory (from dropdown or browser)
|
||||||
// Auto-select first project when host changes
|
const handleSelectDir = useCallback((path) => {
|
||||||
if (projectList.length > 0) {
|
onSelectProject(path);
|
||||||
onSelectProject(projectList[0].path);
|
const updated = addRecentDir(selectedHost, path);
|
||||||
}
|
setRecentDirs(updated);
|
||||||
})
|
setDropdownOpen(false);
|
||||||
.catch(console.error);
|
setShowBrowser(false);
|
||||||
}, [selectedHost, onSelectProject]);
|
}, [selectedHost, onSelectProject]);
|
||||||
|
|
||||||
const handleCustomPath = () => {
|
// Browse directories on host
|
||||||
if (customPath.trim()) {
|
const browsePath = useCallback(async (path) => {
|
||||||
onSelectProject(customPath.trim());
|
if (!selectedHost) return;
|
||||||
setCustomPath('');
|
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 (
|
return (
|
||||||
@@ -80,87 +151,106 @@ export function Sidebar({
|
|||||||
Host
|
Host
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{hosts.map((host) => (
|
{hosts.map((host) => {
|
||||||
<button
|
const isActive = sessionActive && activeHost === host.id;
|
||||||
key={host.id}
|
const isDisabled = sessionActive && activeHost && activeHost !== host.id;
|
||||||
onClick={() => onSelectHost(host.id)}
|
return (
|
||||||
className={`
|
<button
|
||||||
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
key={host.id}
|
||||||
transition-colors border
|
onClick={() => !isDisabled && onSelectHost(host.id)}
|
||||||
${selectedHost === host.id
|
disabled={isDisabled}
|
||||||
? 'border-orange-500/50 text-white'
|
className={`
|
||||||
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
|
flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||||
}
|
transition-colors border relative
|
||||||
`}
|
${isDisabled
|
||||||
style={{
|
? 'border-dark-800 text-dark-600 cursor-not-allowed opacity-50'
|
||||||
backgroundColor: selectedHost === host.id ? `${host.color}30` : 'transparent'
|
: selectedHost === host.id
|
||||||
}}
|
? 'border-orange-500/50 text-white'
|
||||||
>
|
: 'border-dark-700 text-dark-400 hover:border-dark-600 hover:text-dark-300'
|
||||||
<Server className="w-3.5 h-3.5" style={{ color: host.color }} />
|
}
|
||||||
<span>{host.name}</span>
|
`}
|
||||||
{!host.isLocal && <span className="text-xs text-dark-500">(SSH)</span>}
|
style={{
|
||||||
</button>
|
backgroundColor: selectedHost === host.id ? `${host.color}30` : 'transparent'
|
||||||
))}
|
}}
|
||||||
|
>
|
||||||
|
<Server className="w-3.5 h-3.5" style={{ color: isDisabled ? '#4a4a4a' : host.color }} />
|
||||||
|
<span>{host.name}</span>
|
||||||
|
{isActive && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" title="Active session" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{sessionActive && (
|
||||||
|
<p className="text-xs text-dark-500">Stop session to switch hosts</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project Selection */}
|
{/* Working Directory */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
|
<h3 className="text-sm font-medium text-dark-400 uppercase tracking-wide">
|
||||||
Working Directory
|
Working Directory
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-1">
|
{/* Directory selector with dropdown */}
|
||||||
{projects.map((project) => (
|
<div className="relative">
|
||||||
<button
|
|
||||||
key={project.path}
|
|
||||||
onClick={() => onSelectProject(project.path)}
|
|
||||||
className={`
|
|
||||||
w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left
|
|
||||||
transition-colors text-sm
|
|
||||||
${selectedProject === project.path
|
|
||||||
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
|
|
||||||
: 'hover:bg-dark-800 text-dark-300'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<FolderOpen className="w-4 h-4 flex-shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="font-medium truncate">{project.name}</div>
|
|
||||||
<div className="text-xs text-dark-500 truncate">{project.path}</div>
|
|
||||||
</div>
|
|
||||||
{selectedProject === project.path && (
|
|
||||||
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Custom path input */}
|
|
||||||
<div className="pt-2">
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
{/* Dropdown button */}
|
||||||
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
|
<button
|
||||||
onClick={handleCustomPath}
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
className="px-3 py-2 bg-dark-800 hover:bg-dark-700 rounded-lg
|
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"
|
||||||
text-dark-400 hover:text-dark-200 transition-colors"
|
|
||||||
>
|
>
|
||||||
Set
|
<FolderOpen className="w-4 h-4 text-orange-400 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-dark-200 truncate">{getDisplayName(selectedProject)}</div>
|
||||||
|
<div className="text-xs text-dark-500 truncate">{selectedProject}</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={`w-4 h-4 text-dark-500 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Browse button */}
|
||||||
|
<button
|
||||||
|
onClick={openBrowser}
|
||||||
|
className="px-3 py-2.5 bg-dark-800 hover:bg-dark-700 border border-dark-700 rounded-lg text-dark-400 hover:text-dark-200 transition-colors"
|
||||||
|
title="Browse directories"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown menu */}
|
||||||
|
{dropdownOpen && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-dark-800 border border-dark-700 rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto">
|
||||||
|
{recentDirs.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-sm text-dark-500 text-center">
|
||||||
|
No recent directories
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
recentDirs.map((path) => (
|
||||||
|
<button
|
||||||
|
key={path}
|
||||||
|
onClick={() => handleSelectDir(path)}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-dark-700 transition-colors
|
||||||
|
${selectedProject === path ? 'bg-orange-500/10 text-orange-400' : 'text-dark-300'}`}
|
||||||
|
>
|
||||||
|
<Folder className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm truncate">{getDisplayName(path)}</div>
|
||||||
|
<div className="text-xs text-dark-500 truncate">{path}</div>
|
||||||
|
</div>
|
||||||
|
{selectedProject === path && (
|
||||||
|
<ChevronRight className="w-4 h-4 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resume toggle */}
|
{/* Resume toggle */}
|
||||||
<div className="pt-3">
|
<div className="pt-2">
|
||||||
<label className="flex items-center gap-3 cursor-pointer group">
|
<label className="flex items-center gap-3 cursor-pointer group">
|
||||||
<div
|
<div
|
||||||
onClick={onToggleResume}
|
onClick={onToggleResume}
|
||||||
@@ -230,6 +320,84 @@ export function Sidebar({
|
|||||||
<div>Claude Code Web UI POC</div>
|
<div>Claude Code Web UI POC</div>
|
||||||
<div>JSON Stream Mode</div>
|
<div>JSON Stream Mode</div>
|
||||||
</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>
|
</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 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 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() {
|
export function useClaudeSession() {
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [sessionActive, setSessionActive] = useState(false);
|
const [sessionActive, setSessionActive] = useState(false);
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
const [currentProject, setCurrentProject] = useState(null);
|
const [currentProject, setCurrentProject] = useState(null);
|
||||||
|
const [currentHost, setCurrentHost] = useState(null);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
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 wsRef = useRef(null);
|
||||||
const currentAssistantMessage = useRef(null);
|
const currentAssistantMessage = useRef(null);
|
||||||
@@ -55,6 +87,7 @@ export function useClaudeSession() {
|
|||||||
case 'session_started':
|
case 'session_started':
|
||||||
setSessionActive(true);
|
setSessionActive(true);
|
||||||
setCurrentProject(data.project);
|
setCurrentProject(data.project);
|
||||||
|
setSessionId(data.sessionId || `session-${Date.now()}`);
|
||||||
setMessages(prev => [...prev, {
|
setMessages(prev => [...prev, {
|
||||||
type: 'system',
|
type: 'system',
|
||||||
content: `Session started in ${data.project}`,
|
content: `Session started in ${data.project}`,
|
||||||
@@ -88,6 +121,78 @@ export function useClaudeSession() {
|
|||||||
setError(data.message);
|
setError(data.message);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
break;
|
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) {
|
if (newMessages.length > 0) {
|
||||||
setMessages(prev => {
|
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
|
// Check if last message is a streaming message - if so, replace it with final
|
||||||
const last = prev[prev.length - 1];
|
const last = prev[prev.length - 1];
|
||||||
if (last?.type === 'assistant' && last.streaming) {
|
if (last?.type === 'assistant' && last.streaming) {
|
||||||
@@ -134,28 +244,45 @@ export function useClaudeSession() {
|
|||||||
const textMessages = newMessages.filter(m => m.type === 'assistant');
|
const textMessages = newMessages.filter(m => m.type === 'assistant');
|
||||||
const otherMessages = 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) {
|
if (textMessages.length > 0) {
|
||||||
return [
|
return [
|
||||||
...prev.slice(0, -1),
|
...prev.slice(0, -1),
|
||||||
{ ...textMessages[0], streaming: false },
|
{ ...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) {
|
} else if (event.type === 'user') {
|
||||||
// Tool results come as 'user' events with tool_use_result
|
// Tool results come as 'user' events with message.content containing tool_result blocks
|
||||||
const result = event.tool_use_result;
|
if (event.message?.content) {
|
||||||
setMessages(prev => [...prev, {
|
for (const block of event.message.content) {
|
||||||
type: 'tool_result',
|
if (block.type === 'tool_result') {
|
||||||
content: result.content,
|
console.log('Tool result found:', block);
|
||||||
toolUseId: result.tool_use_id,
|
setMessages(prev => [...prev, {
|
||||||
isError: result.is_error || false,
|
type: 'tool_result',
|
||||||
timestamp: Date.now()
|
content: block.content,
|
||||||
}]);
|
toolUseId: block.tool_use_id,
|
||||||
|
isError: block.is_error || false,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (event.type === 'content_block_delta') {
|
} else if (event.type === 'content_block_delta') {
|
||||||
// Streaming delta (direct)
|
// Streaming delta (direct)
|
||||||
if (event.delta?.text) {
|
if (event.delta?.text) {
|
||||||
@@ -196,10 +323,26 @@ export function useClaudeSession() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (event.type === 'result') {
|
} else if (event.type === 'result') {
|
||||||
// Final result - just stop processing
|
// Final result - extract stats and stop processing
|
||||||
setIsProcessing(false);
|
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') {
|
} else if (event.type === 'system' && event.subtype === 'result') {
|
||||||
setIsProcessing(false);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track which host we're connecting to
|
||||||
|
setCurrentHost(host);
|
||||||
|
|
||||||
// Load history before starting session if resuming
|
// Load history before starting session if resuming
|
||||||
if (resume) {
|
if (resume) {
|
||||||
await loadHistory(project, host);
|
await loadHistory(project, host);
|
||||||
@@ -241,7 +387,36 @@ export function useClaudeSession() {
|
|||||||
}));
|
}));
|
||||||
}, [loadHistory]);
|
}, [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) {
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||||
setError('Not connected');
|
setError('Not connected');
|
||||||
return;
|
return;
|
||||||
@@ -252,10 +427,39 @@ export function useClaudeSession() {
|
|||||||
return;
|
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, {
|
setMessages(prev => [...prev, {
|
||||||
type: 'user',
|
type: 'user',
|
||||||
content: message,
|
content: message,
|
||||||
|
attachments: attachmentInfo,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
@@ -263,20 +467,72 @@ export function useClaudeSession() {
|
|||||||
|
|
||||||
wsRef.current.send(JSON.stringify({
|
wsRef.current.send(JSON.stringify({
|
||||||
type: 'user_message',
|
type: 'user_message',
|
||||||
message
|
message: finalMessage
|
||||||
}));
|
}));
|
||||||
}, [sessionActive]);
|
}, [sessionActive, uploadFiles]);
|
||||||
|
|
||||||
const stopSession = useCallback(() => {
|
const stopSession = useCallback(() => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
wsRef.current.send(JSON.stringify({ type: 'stop_session' }));
|
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(() => {
|
const clearMessages = useCallback(() => {
|
||||||
setMessages([]);
|
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
|
// Auto-connect on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connect();
|
connect();
|
||||||
@@ -288,15 +544,26 @@ export function useClaudeSession() {
|
|||||||
return {
|
return {
|
||||||
connected,
|
connected,
|
||||||
sessionActive,
|
sessionActive,
|
||||||
|
sessionId,
|
||||||
messages,
|
messages,
|
||||||
currentProject,
|
currentProject,
|
||||||
|
currentHost,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
error,
|
error,
|
||||||
|
sessionStats,
|
||||||
|
permissionMode,
|
||||||
|
controlInitialized,
|
||||||
|
availableModels,
|
||||||
|
accountInfo,
|
||||||
|
pendingPermission,
|
||||||
connect,
|
connect,
|
||||||
startSession,
|
startSession,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
stopSession,
|
stopSession,
|
||||||
|
stopGeneration,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
|
changePermissionMode,
|
||||||
|
respondToPermission,
|
||||||
setError,
|
setError,
|
||||||
setMessages
|
setMessages
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,3 +73,151 @@ code {
|
|||||||
.typing-dot:nth-child(3) {
|
.typing-dot:nth-child(3) {
|
||||||
animation-delay: 0.4s;
|
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