- Load available commands from Claude at session start (control_initialized event) - Display commands in autocomplete dropdown with search in name and description - Group commands into "UI Commands" and "Claude Commands" sections - Shorten display names by removing common prefixes (taches-cc-resources:, claude-mem:) - Blacklist TUI-only commands (vim, terminal-setup, ide, etc.) - Add max-height with scrollbar for long command lists - Implement auto-scroll to keep selected command visible during keyboard navigation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1055 lines
34 KiB
JavaScript
1055 lines
34 KiB
JavaScript
import { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
|
|
|
// Build WebSocket URL from current location
|
|
function getWsUrl() {
|
|
if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL;
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
return `${protocol}//${window.location.host}/ws`;
|
|
}
|
|
|
|
const SESSIONS_STORAGE_KEY = 'claude-webui-sessions';
|
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
|
|
// Generate unique session ID
|
|
function generateSessionId() {
|
|
return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
// Create initial session state (messages are stored separately for performance)
|
|
function createSessionState(id, host = 'neko', project = '/home/sumdex/projects') {
|
|
return {
|
|
id,
|
|
name: null, // Auto-generated from context, or user-defined
|
|
host,
|
|
project,
|
|
connected: false,
|
|
active: false, // Claude session started
|
|
// messages: [], // Stored in separate sessionMessages state for performance
|
|
isProcessing: false,
|
|
error: null,
|
|
claudeSessionId: null,
|
|
stats: {
|
|
totalCost: 0,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheCreationTokens: 0,
|
|
numTurns: 0,
|
|
},
|
|
permissionMode: 'default',
|
|
controlInitialized: false,
|
|
availableCommands: [], // Slash commands from Claude
|
|
pendingPermission: null,
|
|
unreadCount: 0,
|
|
currentContext: null, // For dynamic tab naming
|
|
resumeOnStart: true,
|
|
};
|
|
}
|
|
|
|
const SessionContext = createContext(null);
|
|
|
|
export function SessionProvider({ children }) {
|
|
// All sessions keyed by ID (excludes messages for performance)
|
|
const [sessions, setSessions] = useState({});
|
|
|
|
// Messages stored separately to avoid re-rendering entire context on every message
|
|
const [sessionMessages, setSessionMessages] = useState({});
|
|
|
|
// Currently focused session ID
|
|
const [focusedSessionId, setFocusedSessionId] = useState(null);
|
|
|
|
// Sessions shown in split view (array of session IDs)
|
|
const [splitSessions, setSplitSessions] = useState([]);
|
|
|
|
// Tab order (array of session IDs)
|
|
const [tabOrder, setTabOrder] = useState([]);
|
|
|
|
// WebSocket refs keyed by session ID
|
|
const wsRefs = useRef({});
|
|
|
|
// Current assistant message refs keyed by session ID
|
|
const currentAssistantMessages = useRef({});
|
|
|
|
// Ref to current sessions state (for stable callbacks)
|
|
const sessionsRef = useRef(sessions);
|
|
sessionsRef.current = sessions;
|
|
|
|
// Track if initial load is done (for auto-connecting restored sessions)
|
|
const initialLoadDone = useRef(false);
|
|
const sessionsToConnect = useRef([]);
|
|
|
|
// Load sessions from localStorage on mount
|
|
useEffect(() => {
|
|
try {
|
|
const stored = localStorage.getItem(SESSIONS_STORAGE_KEY);
|
|
if (stored) {
|
|
const data = JSON.parse(stored);
|
|
// Restore sessions but reset connection state
|
|
const restored = {};
|
|
const toConnect = [];
|
|
Object.entries(data.sessions || {}).forEach(([id, session]) => {
|
|
restored[id] = {
|
|
...session,
|
|
connected: false,
|
|
active: false,
|
|
isProcessing: false,
|
|
pendingPermission: null,
|
|
controlInitialized: false,
|
|
};
|
|
toConnect.push(id);
|
|
});
|
|
setSessions(restored);
|
|
setTabOrder(data.tabOrder || []);
|
|
setSplitSessions(data.splitSessions || []);
|
|
setFocusedSessionId(data.focusedSessionId || null);
|
|
sessionsToConnect.current = toConnect;
|
|
}
|
|
initialLoadDone.current = true;
|
|
} catch (e) {
|
|
console.error('Failed to restore sessions:', e);
|
|
initialLoadDone.current = true;
|
|
}
|
|
}, []);
|
|
|
|
// Save sessions to localStorage on change (debounced, excluding messages)
|
|
const saveTimeoutRef = useRef(null);
|
|
useEffect(() => {
|
|
// Debounce saves to avoid performance issues during streaming
|
|
if (saveTimeoutRef.current) {
|
|
clearTimeout(saveTimeoutRef.current);
|
|
}
|
|
|
|
saveTimeoutRef.current = setTimeout(() => {
|
|
try {
|
|
// Only save essential config, not transient state
|
|
const sessionsToSave = {};
|
|
Object.entries(sessions).forEach(([id, session]) => {
|
|
sessionsToSave[id] = {
|
|
id: session.id,
|
|
name: session.name,
|
|
host: session.host,
|
|
project: session.project,
|
|
permissionMode: session.permissionMode,
|
|
resumeOnStart: session.resumeOnStart,
|
|
};
|
|
});
|
|
|
|
const data = {
|
|
sessions: sessionsToSave,
|
|
tabOrder,
|
|
splitSessions,
|
|
focusedSessionId,
|
|
};
|
|
localStorage.setItem(SESSIONS_STORAGE_KEY, JSON.stringify(data));
|
|
} catch (e) {
|
|
console.error('Failed to save sessions:', e);
|
|
}
|
|
}, 1000);
|
|
|
|
return () => {
|
|
if (saveTimeoutRef.current) {
|
|
clearTimeout(saveTimeoutRef.current);
|
|
}
|
|
};
|
|
}, [sessions, tabOrder, splitSessions, focusedSessionId]);
|
|
|
|
// Update a specific session
|
|
const updateSession = useCallback((sessionId, updates) => {
|
|
setSessions(prev => {
|
|
if (!prev[sessionId]) return prev;
|
|
const newUpdates = typeof updates === 'function' ? updates(prev[sessionId]) : updates;
|
|
return {
|
|
...prev,
|
|
[sessionId]: {
|
|
...prev[sessionId],
|
|
...newUpdates,
|
|
},
|
|
};
|
|
});
|
|
}, []);
|
|
|
|
// Add message to session (uses separate state for performance)
|
|
const addMessage = useCallback((sessionId, message) => {
|
|
setSessionMessages(prev => ({
|
|
...prev,
|
|
[sessionId]: [...(prev[sessionId] || []), { ...message, timestamp: Date.now() }],
|
|
}));
|
|
|
|
// Update unread count in session state only if not focused
|
|
const isFocused = sessionId === focusedSessionId || splitSessions.includes(sessionId);
|
|
if (!isFocused) {
|
|
setSessions(prev => {
|
|
if (!prev[sessionId]) return prev;
|
|
return {
|
|
...prev,
|
|
[sessionId]: {
|
|
...prev[sessionId],
|
|
unreadCount: (prev[sessionId].unreadCount || 0) + 1,
|
|
},
|
|
};
|
|
});
|
|
}
|
|
}, [focusedSessionId, splitSessions]);
|
|
|
|
// Throttled streaming update refs
|
|
const pendingStreamUpdates = useRef({});
|
|
const streamUpdateTimers = useRef({});
|
|
|
|
// Update last assistant message (for streaming) - throttled to reduce re-renders
|
|
const updateLastAssistantMessage = useCallback((sessionId, content) => {
|
|
// Store the latest content
|
|
pendingStreamUpdates.current[sessionId] = content;
|
|
|
|
// If no timer is running, start one
|
|
if (!streamUpdateTimers.current[sessionId]) {
|
|
streamUpdateTimers.current[sessionId] = setTimeout(() => {
|
|
const latestContent = pendingStreamUpdates.current[sessionId];
|
|
delete pendingStreamUpdates.current[sessionId];
|
|
delete streamUpdateTimers.current[sessionId];
|
|
|
|
// Update only sessionMessages, not sessions (for performance)
|
|
setSessionMessages(prev => {
|
|
const messages = [...(prev[sessionId] || [])];
|
|
|
|
// Find last assistant message
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
if (messages[i].type === 'assistant') {
|
|
messages[i] = { ...messages[i], content: latestContent };
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
[sessionId]: messages,
|
|
};
|
|
});
|
|
}, 50); // Update UI max every 50ms
|
|
}
|
|
}, []);
|
|
|
|
// Handle Claude events (wrapped in claude_event messages from backend)
|
|
const handleClaudeEvent = useCallback((sessionId, event) => {
|
|
if (!event) return;
|
|
|
|
const { type } = event;
|
|
|
|
switch (type) {
|
|
case 'assistant': {
|
|
// Assistant message with content blocks (text + tool_use)
|
|
const message = event.message;
|
|
if (!message?.content) break;
|
|
|
|
const textBlocks = [];
|
|
const toolUseBlocks = [];
|
|
|
|
for (const block of message.content) {
|
|
if (block.type === 'text') {
|
|
textBlocks.push(block.text);
|
|
} else if (block.type === 'tool_use') {
|
|
toolUseBlocks.push({
|
|
type: 'tool_use',
|
|
tool: block.name,
|
|
input: block.input,
|
|
toolUseId: block.id,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Add text content as assistant message
|
|
if (textBlocks.length > 0) {
|
|
// Check if we're streaming and should update existing message
|
|
if (currentAssistantMessages.current[sessionId] !== undefined) {
|
|
updateLastAssistantMessage(sessionId, textBlocks.join('\n'));
|
|
delete currentAssistantMessages.current[sessionId];
|
|
} else {
|
|
addMessage(sessionId, {
|
|
type: 'assistant',
|
|
content: textBlocks.join('\n'),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Add tool use blocks as separate messages
|
|
for (const toolMsg of toolUseBlocks) {
|
|
addMessage(sessionId, toolMsg);
|
|
}
|
|
|
|
// Extract usage stats from message if present
|
|
const usage = message.usage;
|
|
if (usage) {
|
|
const inputTokens = (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0);
|
|
const outputTokens = usage.output_tokens || 0;
|
|
const cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
|
|
|
updateSession(sessionId, (session) => ({
|
|
stats: {
|
|
...(session.stats || {}),
|
|
inputTokens: (session.stats?.inputTokens || 0) + inputTokens,
|
|
outputTokens: (session.stats?.outputTokens || 0) + outputTokens,
|
|
cacheReadTokens: (session.stats?.cacheReadTokens || 0) + cacheReadTokens,
|
|
cacheCreationTokens: (session.stats?.cacheCreationTokens || 0) + cacheCreationTokens,
|
|
numTurns: (session.stats?.numTurns || 0) + 1,
|
|
},
|
|
}));
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'user': {
|
|
// User message with tool results
|
|
const message = event.message;
|
|
if (!message?.content) break;
|
|
|
|
for (const block of message.content) {
|
|
if (block.type === 'tool_result') {
|
|
addMessage(sessionId, {
|
|
type: 'tool_result',
|
|
toolUseId: block.tool_use_id,
|
|
content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content),
|
|
isError: block.is_error || false,
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'content_block_delta':
|
|
case 'stream_event': {
|
|
// Streaming text delta
|
|
const delta = event.delta || event;
|
|
if (delta?.type === 'text_delta' || delta?.text) {
|
|
const text = delta.text || '';
|
|
|
|
if (currentAssistantMessages.current[sessionId] === undefined) {
|
|
// Start new streaming message
|
|
currentAssistantMessages.current[sessionId] = text;
|
|
addMessage(sessionId, { type: 'assistant', content: text });
|
|
} else {
|
|
// Append to existing
|
|
currentAssistantMessages.current[sessionId] += text;
|
|
updateLastAssistantMessage(sessionId, currentAssistantMessages.current[sessionId]);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'content_block_start': {
|
|
// Starting a new content block (text or tool_use)
|
|
const contentBlock = event.content_block;
|
|
if (contentBlock?.type === 'text') {
|
|
// Initialize streaming for text
|
|
currentAssistantMessages.current[sessionId] = '';
|
|
addMessage(sessionId, { type: 'assistant', content: '' });
|
|
} else if (contentBlock?.type === 'tool_use') {
|
|
// Tool use start - we'll get the full thing in assistant message
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'content_block_stop': {
|
|
// Content block finished
|
|
if (currentAssistantMessages.current[sessionId] !== undefined) {
|
|
// Clean up streaming state - message is already updated
|
|
delete currentAssistantMessages.current[sessionId];
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'message_start': {
|
|
// Message is starting
|
|
updateSession(sessionId, { isProcessing: true });
|
|
break;
|
|
}
|
|
|
|
case 'message_stop': {
|
|
// Message is complete
|
|
if (currentAssistantMessages.current[sessionId] !== undefined) {
|
|
delete currentAssistantMessages.current[sessionId];
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'result': {
|
|
// Final result with stats
|
|
console.log(`[${sessionId}] Result event:`, JSON.stringify(event, null, 2));
|
|
const defaultStats = { totalCost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, numTurns: 0 };
|
|
updateSession(sessionId, (session) => ({
|
|
isProcessing: false,
|
|
stats: {
|
|
...(session.stats || defaultStats),
|
|
totalCost: event.totalCost ?? session.stats?.totalCost ?? 0,
|
|
inputTokens: event.inputTokens ?? session.stats?.inputTokens ?? 0,
|
|
outputTokens: event.outputTokens ?? session.stats?.outputTokens ?? 0,
|
|
cacheReadTokens: event.cacheReadTokens ?? session.stats?.cacheReadTokens ?? 0,
|
|
cacheCreationTokens: event.cacheCreationTokens ?? session.stats?.cacheCreationTokens ?? 0,
|
|
numTurns: event.numTurns ?? session.stats?.numTurns ?? 0,
|
|
},
|
|
currentContext: event.currentContext || session.currentContext,
|
|
}));
|
|
break;
|
|
}
|
|
|
|
case 'system': {
|
|
// System events from Claude - log full event for debugging
|
|
console.log(`[${sessionId}] System event:`, event.subtype, JSON.stringify(event, null, 2));
|
|
|
|
// Check for context info in system init event
|
|
if (event.subtype === 'init') {
|
|
// Log all fields to find context info
|
|
console.log(`[${sessionId}] System init fields:`, Object.keys(event));
|
|
}
|
|
|
|
// Parse context message if present
|
|
if (event.message) {
|
|
const contextMatch = event.message.match(/Context left[^:]*:\s*(\d+)%/i);
|
|
if (contextMatch) {
|
|
const contextLeft = parseInt(contextMatch[1], 10);
|
|
console.log(`[${sessionId}] Context left:`, contextLeft + '%');
|
|
updateSession(sessionId, (session) => ({
|
|
stats: {
|
|
...(session.stats || {}),
|
|
contextLeftPercent: contextLeft,
|
|
},
|
|
}));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
default:
|
|
console.log(`[${sessionId}] Unhandled claude event:`, type, event);
|
|
}
|
|
}, [updateSession, addMessage, updateLastAssistantMessage]);
|
|
|
|
// Handle incoming WebSocket message for a session
|
|
const handleWsMessage = useCallback((sessionId, data) => {
|
|
const { type } = data;
|
|
|
|
switch (type) {
|
|
case 'session_started':
|
|
updateSession(sessionId, {
|
|
active: true,
|
|
claudeSessionId: data.sessionId,
|
|
error: null,
|
|
});
|
|
break;
|
|
|
|
case 'session_stopped':
|
|
updateSession(sessionId, {
|
|
active: false,
|
|
isProcessing: false,
|
|
});
|
|
break;
|
|
|
|
case 'control_initialized': {
|
|
updateSession(sessionId, {
|
|
controlInitialized: true,
|
|
availableCommands: data.commands || [],
|
|
});
|
|
// Restore saved permission mode after control is initialized
|
|
// Read from localStorage since sessions state may be stale in this callback
|
|
try {
|
|
const stored = localStorage.getItem(SESSIONS_STORAGE_KEY);
|
|
if (stored) {
|
|
const savedData = JSON.parse(stored);
|
|
const savedSession = savedData.sessions?.[sessionId];
|
|
if (savedSession?.permissionMode && savedSession.permissionMode !== 'default') {
|
|
const ws = wsRefs.current[sessionId];
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'set_permission_mode', mode: savedSession.permissionMode }));
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to restore permission mode:', e);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'permission_mode_changed':
|
|
updateSession(sessionId, {
|
|
permissionMode: data.mode,
|
|
});
|
|
break;
|
|
|
|
case 'permission_request':
|
|
updateSession(sessionId, {
|
|
pendingPermission: data,
|
|
});
|
|
break;
|
|
|
|
case 'permission_resolved':
|
|
updateSession(sessionId, {
|
|
pendingPermission: null,
|
|
});
|
|
break;
|
|
|
|
case 'assistant_message':
|
|
if (data.partial) {
|
|
// Streaming update
|
|
if (!currentAssistantMessages.current[sessionId]) {
|
|
currentAssistantMessages.current[sessionId] = '';
|
|
addMessage(sessionId, { type: 'assistant', content: '' });
|
|
}
|
|
currentAssistantMessages.current[sessionId] = data.content;
|
|
updateLastAssistantMessage(sessionId, data.content);
|
|
} else {
|
|
// Final message
|
|
if (currentAssistantMessages.current[sessionId] !== undefined) {
|
|
updateLastAssistantMessage(sessionId, data.content);
|
|
delete currentAssistantMessages.current[sessionId];
|
|
} else {
|
|
addMessage(sessionId, { type: 'assistant', content: data.content });
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'user_message':
|
|
addMessage(sessionId, {
|
|
type: 'user',
|
|
content: data.content,
|
|
attachments: data.attachments,
|
|
});
|
|
break;
|
|
|
|
case 'tool_use':
|
|
addMessage(sessionId, {
|
|
type: 'tool_use',
|
|
tool: data.tool,
|
|
input: data.input,
|
|
toolUseId: data.toolUseId,
|
|
});
|
|
break;
|
|
|
|
case 'tool_result':
|
|
addMessage(sessionId, {
|
|
type: 'tool_result',
|
|
content: data.content,
|
|
toolUseId: data.toolUseId,
|
|
isError: data.isError,
|
|
});
|
|
break;
|
|
|
|
case 'processing':
|
|
updateSession(sessionId, { isProcessing: data.isProcessing });
|
|
break;
|
|
|
|
case 'result': {
|
|
const defaultStats = { totalCost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, numTurns: 0 };
|
|
updateSession(sessionId, (session) => ({
|
|
isProcessing: false,
|
|
stats: {
|
|
...(session.stats || defaultStats),
|
|
totalCost: data.totalCost ?? session.stats?.totalCost ?? 0,
|
|
inputTokens: data.inputTokens ?? session.stats?.inputTokens ?? 0,
|
|
outputTokens: data.outputTokens ?? session.stats?.outputTokens ?? 0,
|
|
cacheReadTokens: data.cacheReadTokens ?? session.stats?.cacheReadTokens ?? 0,
|
|
cacheCreationTokens: data.cacheCreationTokens ?? session.stats?.cacheCreationTokens ?? 0,
|
|
numTurns: data.numTurns ?? session.stats?.numTurns ?? 0,
|
|
},
|
|
currentContext: data.currentContext || session.currentContext,
|
|
}));
|
|
break;
|
|
}
|
|
|
|
case 'error':
|
|
updateSession(sessionId, {
|
|
error: data.message,
|
|
isProcessing: false,
|
|
});
|
|
addMessage(sessionId, { type: 'error', content: data.message });
|
|
break;
|
|
|
|
case 'history':
|
|
if (data.messages && Array.isArray(data.messages)) {
|
|
setSessionMessages(prev => ({
|
|
...prev,
|
|
[sessionId]: data.messages,
|
|
}));
|
|
}
|
|
break;
|
|
|
|
case 'session_ended':
|
|
updateSession(sessionId, {
|
|
active: false,
|
|
isProcessing: false,
|
|
});
|
|
addMessage(sessionId, {
|
|
type: 'system',
|
|
content: `Session ended (code: ${data.code})`,
|
|
});
|
|
break;
|
|
|
|
case 'claude_event':
|
|
handleClaudeEvent(sessionId, data.event);
|
|
break;
|
|
|
|
case 'generation_stopped':
|
|
updateSession(sessionId, { isProcessing: false });
|
|
if (data.message) {
|
|
addMessage(sessionId, { type: 'system', content: data.message });
|
|
}
|
|
break;
|
|
|
|
case 'context_update':
|
|
// Live context window metrics from message_delta events
|
|
updateSession(sessionId, (session) => ({
|
|
stats: {
|
|
...(session.stats || {}),
|
|
tokensUsed: data.tokensUsed,
|
|
contextWindow: data.contextWindow,
|
|
contextLeftPercent: data.percentRemaining,
|
|
},
|
|
}));
|
|
break;
|
|
|
|
case 'compacting_started':
|
|
console.log(`[${sessionId}] Compacting started`);
|
|
updateSession(sessionId, (session) => ({
|
|
stats: {
|
|
...(session.stats || {}),
|
|
isCompacting: true,
|
|
},
|
|
}));
|
|
addMessage(sessionId, {
|
|
type: 'system',
|
|
content: '⏳ Context compacting in progress...',
|
|
});
|
|
break;
|
|
|
|
case 'compacting_finished':
|
|
console.log(`[${sessionId}] Compacting finished`);
|
|
updateSession(sessionId, (session) => ({
|
|
stats: {
|
|
...(session.stats || {}),
|
|
isCompacting: false,
|
|
},
|
|
}));
|
|
break;
|
|
|
|
case 'compact_boundary':
|
|
console.log(`[${sessionId}] Compact boundary:`, data);
|
|
// Context was compacted - reset the metrics
|
|
updateSession(sessionId, (session) => ({
|
|
stats: {
|
|
...(session.stats || {}),
|
|
isCompacting: false,
|
|
tokensUsed: 0,
|
|
contextLeftPercent: 100,
|
|
lastCompactTrigger: data.trigger,
|
|
lastCompactPreTokens: data.preTokens,
|
|
},
|
|
}));
|
|
addMessage(sessionId, {
|
|
type: 'system',
|
|
content: `✅ Context compacted (${data.trigger}). Was at ${data.percentUsedBeforeCompact}% usage.`,
|
|
});
|
|
break;
|
|
|
|
default:
|
|
console.log(`[${sessionId}] Unhandled message type:`, type, data);
|
|
}
|
|
}, [updateSession, addMessage, updateLastAssistantMessage, handleClaudeEvent]);
|
|
|
|
// Connect WebSocket for a session
|
|
const connectSession = useCallback((sessionId) => {
|
|
if (wsRefs.current[sessionId]?.readyState === WebSocket.OPEN) return;
|
|
|
|
const ws = new WebSocket(getWsUrl());
|
|
wsRefs.current[sessionId] = ws;
|
|
|
|
ws.onopen = () => {
|
|
console.log(`[${sessionId}] WebSocket connected`);
|
|
updateSession(sessionId, { connected: true, error: null });
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
console.log(`[${sessionId}] WebSocket disconnected`);
|
|
updateSession(sessionId, {
|
|
connected: false,
|
|
active: false,
|
|
isProcessing: false,
|
|
});
|
|
delete wsRefs.current[sessionId];
|
|
};
|
|
|
|
ws.onerror = (err) => {
|
|
console.error(`[${sessionId}] WebSocket error:`, err);
|
|
updateSession(sessionId, { error: 'Connection error' });
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
handleWsMessage(sessionId, data);
|
|
} catch (e) {
|
|
console.error(`[${sessionId}] Failed to parse message:`, e);
|
|
}
|
|
};
|
|
}, [updateSession, handleWsMessage]);
|
|
|
|
// Auto-connect restored sessions
|
|
useEffect(() => {
|
|
if (sessionsToConnect.current.length > 0) {
|
|
const toConnect = [...sessionsToConnect.current];
|
|
sessionsToConnect.current = [];
|
|
toConnect.forEach(sessionId => {
|
|
connectSession(sessionId);
|
|
});
|
|
}
|
|
}, [connectSession]);
|
|
|
|
// Disconnect WebSocket for a session
|
|
const disconnectSession = useCallback((sessionId) => {
|
|
const ws = wsRefs.current[sessionId];
|
|
if (ws) {
|
|
ws.close();
|
|
delete wsRefs.current[sessionId];
|
|
}
|
|
}, []);
|
|
|
|
// Create a new session
|
|
const createSession = useCallback((host = 'neko', project = '/home/sumdex/projects') => {
|
|
const id = generateSessionId();
|
|
const session = createSessionState(id, host, project);
|
|
|
|
setSessions(prev => ({ ...prev, [id]: session }));
|
|
setTabOrder(prev => [...prev, id]);
|
|
setFocusedSessionId(id);
|
|
|
|
// Auto-connect
|
|
setTimeout(() => connectSession(id), 0);
|
|
|
|
return id;
|
|
}, [connectSession]);
|
|
|
|
// Close a session (stops Claude but keeps in tabs unless removed)
|
|
const closeSession = useCallback((sessionId) => {
|
|
const ws = wsRefs.current[sessionId];
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'stop_session' }));
|
|
}
|
|
disconnectSession(sessionId);
|
|
}, [disconnectSession]);
|
|
|
|
// Remove session completely
|
|
const removeSession = useCallback((sessionId) => {
|
|
closeSession(sessionId);
|
|
|
|
setSessions(prev => {
|
|
const { [sessionId]: removed, ...rest } = prev;
|
|
return rest;
|
|
});
|
|
|
|
setTabOrder(prev => prev.filter(id => id !== sessionId));
|
|
setSplitSessions(prev => prev.filter(id => id !== sessionId));
|
|
|
|
// Focus another session if this was focused
|
|
setFocusedSessionId(prev => {
|
|
if (prev === sessionId) {
|
|
const remaining = tabOrder.filter(id => id !== sessionId);
|
|
return remaining[0] || null;
|
|
}
|
|
return prev;
|
|
});
|
|
}, [closeSession, tabOrder]);
|
|
|
|
// Start Claude session
|
|
const startClaudeSession = useCallback(async (sessionId) => {
|
|
const session = sessionsRef.current[sessionId];
|
|
if (!session) return;
|
|
|
|
const ws = wsRefs.current[sessionId];
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
connectSession(sessionId);
|
|
// Wait for connection
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
}
|
|
|
|
const currentWs = wsRefs.current[sessionId];
|
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
|
// Load history if resuming
|
|
if (session.resumeOnStart) {
|
|
try {
|
|
const res = await fetch(
|
|
`/api/history/${encodeURIComponent(session.project)}?host=${session.host}`,
|
|
{ credentials: 'include' }
|
|
);
|
|
const data = await res.json();
|
|
if (data.messages && Array.isArray(data.messages)) {
|
|
setSessionMessages(prev => ({
|
|
...prev,
|
|
[sessionId]: data.messages,
|
|
}));
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load history:', e);
|
|
}
|
|
}
|
|
|
|
currentWs.send(JSON.stringify({
|
|
type: 'start_session',
|
|
project: session.project,
|
|
resume: session.resumeOnStart,
|
|
host: session.host,
|
|
}));
|
|
}
|
|
}, [connectSession]);
|
|
|
|
// Stop Claude session
|
|
const stopClaudeSession = useCallback((sessionId) => {
|
|
const ws = wsRefs.current[sessionId];
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'stop_session' }));
|
|
}
|
|
updateSession(sessionId, { active: false, isProcessing: false });
|
|
}, [updateSession]);
|
|
|
|
// Send message to session
|
|
const sendMessage = useCallback(async (sessionId, message, attachments = []) => {
|
|
const session = sessionsRef.current[sessionId];
|
|
if (!session?.active) return;
|
|
|
|
const ws = wsRefs.current[sessionId];
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
|
|
// Handle file uploads if needed
|
|
let uploadedFiles = [];
|
|
if (attachments.length > 0) {
|
|
const formData = new FormData();
|
|
for (const file of attachments) {
|
|
formData.append('files', file.file);
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/upload/${session.claudeSessionId}`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
credentials: 'include',
|
|
});
|
|
const data = await res.json();
|
|
uploadedFiles = data.files || [];
|
|
} catch (e) {
|
|
console.error('Upload failed:', e);
|
|
updateSession(sessionId, { error: `Upload failed: ${e.message}` });
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Build message with file references
|
|
let finalMessage = message;
|
|
if (uploadedFiles.length > 0) {
|
|
const fileRefs = uploadedFiles.map(f =>
|
|
f.isImage ? `[Image: ${f.path}]` : `[File: ${f.path}]`
|
|
).join('\n');
|
|
finalMessage = `${fileRefs}\n\n${message}`;
|
|
}
|
|
|
|
// Add user message to local state immediately for instant feedback
|
|
addMessage(sessionId, {
|
|
type: 'user',
|
|
content: message,
|
|
attachments: uploadedFiles.length > 0 ? uploadedFiles : undefined,
|
|
});
|
|
|
|
updateSession(sessionId, { isProcessing: true });
|
|
|
|
ws.send(JSON.stringify({
|
|
type: 'user_message',
|
|
message: finalMessage,
|
|
}));
|
|
}, [updateSession, addMessage]);
|
|
|
|
// Stop generation
|
|
const stopGeneration = useCallback((sessionId) => {
|
|
const ws = wsRefs.current[sessionId];
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'stop_generation' }));
|
|
}
|
|
updateSession(sessionId, { isProcessing: false });
|
|
}, [updateSession]);
|
|
|
|
// Clear messages
|
|
const clearMessages = useCallback((sessionId) => {
|
|
setSessionMessages(prev => ({
|
|
...prev,
|
|
[sessionId]: [],
|
|
}));
|
|
updateSession(sessionId, { unreadCount: 0 });
|
|
}, [updateSession]);
|
|
|
|
// Set compacting state
|
|
const setCompacting = useCallback((sessionId, value) => {
|
|
updateSession(sessionId, {
|
|
stats: {
|
|
...(sessions[sessionId]?.stats || {}),
|
|
isCompacting: value,
|
|
},
|
|
});
|
|
}, [updateSession, sessions]);
|
|
|
|
// Change permission mode
|
|
const changePermissionMode = useCallback((sessionId, mode) => {
|
|
const ws = wsRefs.current[sessionId];
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'set_permission_mode', mode }));
|
|
}
|
|
}, []);
|
|
|
|
// Respond to permission request
|
|
const respondToPermission = useCallback((sessionId, requestId, allow) => {
|
|
const ws = wsRefs.current[sessionId];
|
|
if (ws?.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
type: 'permission_response',
|
|
requestId,
|
|
allow,
|
|
}));
|
|
}
|
|
updateSession(sessionId, { pendingPermission: null });
|
|
}, [updateSession]);
|
|
|
|
// Reorder tabs
|
|
const reorderTabs = useCallback((fromIndex, toIndex) => {
|
|
setTabOrder(prev => {
|
|
const result = [...prev];
|
|
const [removed] = result.splice(fromIndex, 1);
|
|
result.splice(toIndex, 0, removed);
|
|
return result;
|
|
});
|
|
}, []);
|
|
|
|
// Add session to split view
|
|
const addToSplit = useCallback((sessionId) => {
|
|
setSplitSessions(prev => {
|
|
if (prev.includes(sessionId)) return prev;
|
|
if (prev.length >= 4) return prev; // Max 4 panels
|
|
return [...prev, sessionId];
|
|
});
|
|
}, []);
|
|
|
|
// Remove session from split view
|
|
const removeFromSplit = useCallback((sessionId) => {
|
|
setSplitSessions(prev => prev.filter(id => id !== sessionId));
|
|
}, []);
|
|
|
|
// Clear split view
|
|
const clearSplit = useCallback(() => {
|
|
setSplitSessions([]);
|
|
}, []);
|
|
|
|
// Mark session as read
|
|
const markAsRead = useCallback((sessionId) => {
|
|
updateSession(sessionId, { unreadCount: 0 });
|
|
}, [updateSession]);
|
|
|
|
// Rename session
|
|
const renameSession = useCallback((sessionId, name) => {
|
|
updateSession(sessionId, { name });
|
|
}, [updateSession]);
|
|
|
|
// Update session config (host, project, resume)
|
|
const updateSessionConfig = useCallback((sessionId, config) => {
|
|
updateSession(sessionId, config);
|
|
}, [updateSession]);
|
|
|
|
// Memoize focused session to prevent unnecessary re-renders
|
|
const focusedSession = useMemo(() => {
|
|
return focusedSessionId ? sessions[focusedSessionId] : null;
|
|
}, [focusedSessionId, sessions]);
|
|
|
|
// Memoize the entire context value to prevent re-renders when nothing changed
|
|
const value = useMemo(() => ({
|
|
// State
|
|
sessions,
|
|
sessionMessages,
|
|
tabOrder,
|
|
splitSessions,
|
|
focusedSessionId,
|
|
focusedSession,
|
|
|
|
// Session management
|
|
createSession,
|
|
closeSession,
|
|
removeSession,
|
|
renameSession,
|
|
updateSessionConfig,
|
|
|
|
// Focus & view
|
|
setFocusedSessionId,
|
|
markAsRead,
|
|
reorderTabs,
|
|
addToSplit,
|
|
removeFromSplit,
|
|
clearSplit,
|
|
|
|
// Claude session control
|
|
connectSession,
|
|
disconnectSession,
|
|
startClaudeSession,
|
|
stopClaudeSession,
|
|
|
|
// Messaging
|
|
sendMessage,
|
|
stopGeneration,
|
|
clearMessages,
|
|
setCompacting,
|
|
|
|
// Permissions
|
|
changePermissionMode,
|
|
respondToPermission,
|
|
}), [
|
|
sessions, sessionMessages, tabOrder, splitSessions, focusedSessionId, focusedSession,
|
|
createSession, closeSession, removeSession, renameSession, updateSessionConfig,
|
|
setFocusedSessionId, markAsRead, reorderTabs, addToSplit, removeFromSplit, clearSplit,
|
|
connectSession, disconnectSession, startClaudeSession, stopClaudeSession,
|
|
sendMessage, stopGeneration, clearMessages, setCompacting, changePermissionMode, respondToPermission,
|
|
]);
|
|
|
|
return (
|
|
<SessionContext.Provider value={value}>
|
|
{children}
|
|
</SessionContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useSessionManager() {
|
|
const context = useContext(SessionContext);
|
|
if (!context) {
|
|
throw new Error('useSessionManager must be used within SessionProvider');
|
|
}
|
|
return context;
|
|
}
|
|
|
|
// Hook to get a specific session's data and actions
|
|
export function useSession(sessionId) {
|
|
const manager = useSessionManager();
|
|
const session = manager.sessions[sessionId];
|
|
const messages = manager.sessionMessages[sessionId] || [];
|
|
|
|
// Combine session data with messages for backward compatibility
|
|
const sessionWithMessages = session ? { ...session, messages } : null;
|
|
|
|
return {
|
|
session: sessionWithMessages,
|
|
messages, // Also expose separately for components that only need messages
|
|
start: () => manager.startClaudeSession(sessionId),
|
|
stop: () => manager.stopClaudeSession(sessionId),
|
|
send: (msg, attachments) => manager.sendMessage(sessionId, msg, attachments),
|
|
stopGeneration: () => manager.stopGeneration(sessionId),
|
|
clearMessages: () => manager.clearMessages(sessionId),
|
|
changePermissionMode: (mode) => manager.changePermissionMode(sessionId, mode),
|
|
respondToPermission: (reqId, allow) => manager.respondToPermission(sessionId, reqId, allow),
|
|
close: () => manager.closeSession(sessionId),
|
|
remove: () => manager.removeSession(sessionId),
|
|
rename: (name) => manager.renameSession(sessionId, name),
|
|
updateConfig: (config) => manager.updateSessionConfig(sessionId, config),
|
|
addToSplit: () => manager.addToSplit(sessionId),
|
|
removeFromSplit: () => manager.removeFromSplit(sessionId),
|
|
focus: () => manager.setFocusedSessionId(sessionId),
|
|
markAsRead: () => manager.markAsRead(sessionId),
|
|
};
|
|
}
|