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