Files
claude-web-ui/frontend/src/hooks/useClaudeSession.js
Nikolas Syring 38ab89932a feat: Add multi-host config and dynamic project scanning
Backend:
- Load hosts from config/hosts.json
- New /api/hosts endpoint listing available hosts
- Dynamic project scanning with configurable depth
- Support for local and SSH hosts (SSH execution coming next)

Frontend (by Web-UI Claude):
- Slash commands: /clear, /help, /export, /scroll, /new, /info
- Chat export as Markdown

Config:
- hosts.json defines hosts with connection info and base paths
- hosts.example.json as template (real config is gitignored)
- Each host has name, description, color, icon, basePaths

Next steps:
- SSH command execution for remote hosts
- Frontend host selector UI
- Multi-agent collaboration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 22:11:22 +01:00

302 lines
8.6 KiB
JavaScript

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';
export function useClaudeSession() {
const [connected, setConnected] = useState(false);
const [sessionActive, setSessionActive] = useState(false);
const [messages, setMessages] = useState([]);
const [currentProject, setCurrentProject] = useState(null);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState(null);
const wsRef = useRef(null);
const currentAssistantMessage = useRef(null);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(WS_URL);
wsRef.current = ws;
ws.onopen = () => {
console.log('WebSocket connected');
setConnected(true);
setError(null);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
setConnected(false);
setSessionActive(false);
setIsProcessing(false);
};
ws.onerror = (err) => {
console.error('WebSocket error:', err);
setError('Connection error');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleMessage(data);
} catch (e) {
console.error('Failed to parse message:', e);
}
};
}, []);
const handleMessage = useCallback((data) => {
console.log('Received:', data.type, data);
switch (data.type) {
case 'session_started':
setSessionActive(true);
setCurrentProject(data.project);
setMessages(prev => [...prev, {
type: 'system',
content: `Session started in ${data.project}`,
timestamp: data.timestamp
}]);
break;
case 'session_ended':
setSessionActive(false);
setIsProcessing(false);
setMessages(prev => [...prev, {
type: 'system',
content: `Session ended (code: ${data.code})`,
timestamp: data.timestamp
}]);
break;
case 'claude_event':
handleClaudeEvent(data.event);
break;
case 'raw_output':
console.log('Raw:', data.content);
break;
case 'stderr':
console.log('Stderr:', data.content);
break;
case 'error':
setError(data.message);
setIsProcessing(false);
break;
}
}, []);
const handleClaudeEvent = useCallback((event) => {
// Debug: log all event types to understand the structure
console.log('Claude Event:', event.type, event);
// Handle different Claude event types
if (event.type === 'assistant') {
setIsProcessing(true);
// Check if this is a content block
if (event.message?.content) {
const newMessages = [];
for (const block of event.message.content) {
if (block.type === 'text' && block.text) {
// Only add text if we don't have a streaming message with similar content
// (to avoid duplicates from stream_event + final assistant event)
newMessages.push({
type: 'assistant',
content: block.text,
timestamp: Date.now(),
final: true // Mark as final message
});
} else if (block.type === 'tool_use') {
// Tool use is embedded in assistant message content
newMessages.push({
type: 'tool_use',
tool: block.name,
input: block.input,
toolUseId: block.id,
timestamp: Date.now()
});
}
}
if (newMessages.length > 0) {
setMessages(prev => {
// 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) {
// Replace streaming message with final content, keep other new messages (tool_use)
const textMessages = newMessages.filter(m => m.type === 'assistant');
const otherMessages = newMessages.filter(m => m.type !== 'assistant');
if (textMessages.length > 0) {
return [
...prev.slice(0, -1),
{ ...textMessages[0], streaming: false },
...otherMessages
];
}
}
return [...prev, ...newMessages];
});
}
}
} 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 === 'content_block_delta') {
// Streaming delta (direct)
if (event.delta?.text) {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.type === 'assistant' && last.streaming) {
return [
...prev.slice(0, -1),
{ ...last, content: last.content + event.delta.text }
];
}
return [...prev, {
type: 'assistant',
content: event.delta.text,
streaming: true,
timestamp: Date.now()
}];
});
}
} else if (event.type === 'stream_event' && event.event?.type === 'content_block_delta') {
// Streaming delta (wrapped in stream_event)
const deltaText = event.event?.delta?.text;
if (deltaText) {
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.type === 'assistant' && last.streaming) {
return [
...prev.slice(0, -1),
{ ...last, content: last.content + deltaText }
];
}
return [...prev, {
type: 'assistant',
content: deltaText,
streaming: true,
timestamp: Date.now()
}];
});
}
} else if (event.type === 'result') {
// Final result - just stop processing
setIsProcessing(false);
} else if (event.type === 'system' && event.subtype === 'result') {
setIsProcessing(false);
}
}, []);
const loadHistory = useCallback(async (project) => {
try {
const encodedProject = encodeURIComponent(project);
const response = await fetch(`${API_URL}/api/history/${encodedProject}`);
if (response.ok) {
const data = await response.json();
if (data.messages && data.messages.length > 0) {
console.log(`Loaded ${data.messages.length} messages from history`);
setMessages(data.messages);
return data.sessionId;
}
}
} catch (err) {
console.error('Failed to load history:', err);
}
return null;
}, []);
const startSession = useCallback(async (project = '/projects', resume = true) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
setError('Not connected');
return;
}
// Load history before starting session if resuming
if (resume) {
await loadHistory(project);
}
wsRef.current.send(JSON.stringify({
type: 'start_session',
project,
resume
}));
}, [loadHistory]);
const sendMessage = useCallback((message) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
setError('Not connected');
return;
}
if (!sessionActive) {
setError('No active session');
return;
}
// Add user message to display
setMessages(prev => [...prev, {
type: 'user',
content: message,
timestamp: Date.now()
}]);
setIsProcessing(true);
wsRef.current.send(JSON.stringify({
type: 'user_message',
message
}));
}, [sessionActive]);
const stopSession = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'stop_session' }));
}
}, []);
const clearMessages = useCallback(() => {
setMessages([]);
}, []);
// Auto-connect on mount
useEffect(() => {
connect();
return () => {
wsRef.current?.close();
};
}, [connect]);
return {
connected,
sessionActive,
messages,
currentProject,
isProcessing,
error,
connect,
startSession,
sendMessage,
stopSession,
clearMessages,
setError,
setMessages
};
}