diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 2bc80b0..918a7bd 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -24,6 +24,8 @@ import { notifyRunStopped, notifyUserIfEnabled } from './services/notification-orchestrator.js'; +import { claudeAdapter } from './providers/claude/adapter.js'; +import { createNormalizedMessage } from './providers/types.js'; const activeSessions = new Map(); const pendingToolApprovals = new Map(); @@ -142,7 +144,7 @@ function matchesToolPermission(entry, toolName, input) { * @returns {Object} SDK-compatible options */ function mapCliOptionsToSDK(options = {}) { - const { sessionId, cwd, toolsSettings, permissionMode, images } = options; + const { sessionId, cwd, toolsSettings, permissionMode } = options; const sdkOptions = {}; @@ -193,7 +195,7 @@ function mapCliOptionsToSDK(options = {}) { // Map model (default to sonnet) // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m] sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT; - console.log(`Using model: ${sdkOptions.model}`); + // Model logged at query start below // Map system prompt configuration sdkOptions.systemPrompt = { @@ -304,7 +306,7 @@ function extractTokenBudget(resultMessage) { // This is the user's budget limit, not the model's context window const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000; - console.log(`Token calculation: input=${inputTokens}, output=${outputTokens}, cache=${cacheReadTokens + cacheCreationTokens}, total=${totalUsed}/${contextWindow}`); + // Token calc logged via token-budget WS event return { used: totalUsed, @@ -360,7 +362,7 @@ async function handleImages(command, images, cwd) { modifiedCommand = command + imageNote; } - console.log(`Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`); + // Images processed return { modifiedCommand, tempImagePaths, tempDir }; } catch (error) { console.error('Error processing images for SDK:', error); @@ -393,7 +395,7 @@ async function cleanupTempFiles(tempImagePaths, tempDir) { ); } - console.log(`Cleaned up ${tempImagePaths.length} temp image files`); + // Temp files cleaned } catch (error) { console.error('Error during temp file cleanup:', error); } @@ -413,7 +415,7 @@ async function loadMcpConfig(cwd) { await fs.access(claudeConfigPath); } catch (error) { // File doesn't exist, return null - console.log('No ~/.claude.json found, proceeding without MCP servers'); + // No config file return null; } @@ -433,7 +435,7 @@ async function loadMcpConfig(cwd) { // Add global MCP servers if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') { mcpServers = { ...claudeConfig.mcpServers }; - console.log(`Loaded ${Object.keys(mcpServers).length} global MCP servers`); + // Global MCP servers loaded } // Add/override with project-specific MCP servers @@ -441,17 +443,14 @@ async function loadMcpConfig(cwd) { const projectConfig = claudeConfig.claudeProjects[cwd]; if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') { mcpServers = { ...mcpServers, ...projectConfig.mcpServers }; - console.log(`Loaded ${Object.keys(projectConfig.mcpServers).length} project-specific MCP servers`); + // Project MCP servers merged } } // Return null if no servers found if (Object.keys(mcpServers).length === 0) { - console.log('No MCP servers configured'); return null; } - - console.log(`Total MCP servers loaded: ${Object.keys(mcpServers).length}`); return mcpServers; } catch (error) { console.error('Error loading MCP config:', error.message); @@ -541,13 +540,7 @@ async function queryClaudeSDK(command, options = {}, ws) { } const requestId = createRequestId(); - ws.send({ - type: 'claude-permission-request', - requestId, - toolName, - input, - sessionId: capturedSessionId || sessionId || null - }); + ws.send(createNormalizedMessage({ kind: 'permission_request', requestId, toolName, input, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); emitNotification(createNotificationEvent({ provider: 'claude', sessionId: capturedSessionId || sessionId || null, @@ -569,12 +562,7 @@ async function queryClaudeSDK(command, options = {}, ws) { _receivedAt: new Date(), }, onCancel: (reason) => { - ws.send({ - type: 'claude-permission-cancelled', - requestId, - reason, - sessionId: capturedSessionId || sessionId || null - }); + ws.send(createNormalizedMessage({ kind: 'permission_cancelled', requestId, reason, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); } }); if (!decision) { @@ -650,39 +638,35 @@ async function queryClaudeSDK(command, options = {}, ws) { // Send session-created event only once for new sessions if (!sessionId && !sessionCreatedSent) { sessionCreatedSent = true; - ws.send({ - type: 'session-created', - sessionId: capturedSessionId - }); - } else { - console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent); + ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'claude' })); } } else { - console.log('No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId); + // session_id already captured } - // Transform and send message to WebSocket + // Transform and normalize message via adapter const transformedMessage = transformMessage(message); - ws.send({ - type: 'claude-response', - data: transformedMessage, - sessionId: capturedSessionId || sessionId || null - }); + const sid = capturedSessionId || sessionId || null; + + // Use adapter to normalize SDK events into NormalizedMessage[] + const normalized = claudeAdapter.normalizeMessage(transformedMessage, sid); + for (const msg of normalized) { + // Preserve parentToolUseId from SDK wrapper for subagent tool grouping + if (transformedMessage.parentToolUseId && !msg.parentToolUseId) { + msg.parentToolUseId = transformedMessage.parentToolUseId; + } + ws.send(msg); + } // Extract and send token budget updates from result messages if (message.type === 'result') { const models = Object.keys(message.modelUsage || {}); if (models.length > 0) { - console.log("---> Model was sent using:", models); + // Model info available in result message } - const tokenBudget = extractTokenBudget(message); - if (tokenBudget) { - console.log('Token budget from modelUsage:', tokenBudget); - ws.send({ - type: 'token-budget', - data: tokenBudget, - sessionId: capturedSessionId || sessionId || null - }); + const tokenBudgetData = extractTokenBudget(message); + if (tokenBudgetData) { + ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); } } } @@ -696,13 +680,7 @@ async function queryClaudeSDK(command, options = {}, ws) { await cleanupTempFiles(tempImagePaths, tempDir); // Send completion event - console.log('Streaming complete, sending claude-complete event'); - ws.send({ - type: 'claude-complete', - sessionId: capturedSessionId, - exitCode: 0, - isNewSession: !sessionId && !!command - }); + ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' })); notifyRunStopped({ userId: ws?.userId || null, provider: 'claude', @@ -710,7 +688,7 @@ async function queryClaudeSDK(command, options = {}, ws) { sessionName: sessionSummary, stopReason: 'completed' }); - console.log('claude-complete event sent'); + // Complete } catch (error) { console.error('SDK query error:', error); @@ -724,11 +702,7 @@ async function queryClaudeSDK(command, options = {}, ws) { await cleanupTempFiles(tempImagePaths, tempDir); // Send error to WebSocket - ws.send({ - type: 'claude-error', - error: error.message, - sessionId: capturedSessionId || sessionId || null - }); + ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'claude' })); notifyRunFailed({ userId: ws?.userId || null, provider: 'claude', diff --git a/server/cursor-cli.js b/server/cursor-cli.js index d354723..aedd7e0 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -1,6 +1,8 @@ import { spawn } from 'child_process'; import crossSpawn from 'cross-spawn'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; +import { cursorAdapter } from './providers/cursor/adapter.js'; +import { createNormalizedMessage } from './providers/types.js'; // Use cross-spawn on Windows for better command execution const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; @@ -172,75 +174,42 @@ async function spawnCursor(command, options = {}, ws) { // Send session-created event only once for new sessions if (!sessionId && !sessionCreatedSent) { sessionCreatedSent = true; - ws.send({ - type: 'session-created', - sessionId: capturedSessionId, - model: response.model, - cwd: response.cwd - }); + ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, model: response.model, cwd: response.cwd, sessionId: capturedSessionId, provider: 'cursor' })); } } - // Send system info to frontend - ws.send({ - type: 'cursor-system', - data: response, - sessionId: capturedSessionId || sessionId || null - }); + // System info — no longer needed by the frontend (session-lifecycle 'created' handles nav). } break; case 'user': - // Forward user message - ws.send({ - type: 'cursor-user', - data: response, - sessionId: capturedSessionId || sessionId || null - }); + // User messages are not displayed in the UI — skip. break; case 'assistant': // Accumulate assistant message chunks if (response.message && response.message.content && response.message.content.length > 0) { - const textContent = response.message.content[0].text; - - // Send as Claude-compatible format for frontend - ws.send({ - type: 'claude-response', - data: { - type: 'content_block_delta', - delta: { - type: 'text_delta', - text: textContent - } - }, - sessionId: capturedSessionId || sessionId || null - }); + const normalized = cursorAdapter.normalizeMessage(response, capturedSessionId || sessionId || null); + for (const msg of normalized) ws.send(msg); } break; - case 'result': - // Session complete + case 'result': { + // Session complete — send stream end + lifecycle complete with result payload console.log('Cursor session result:', response); - - // Do not emit an extra content_block_stop here. - // The UI already finalizes the streaming message in cursor-result handling, - // and emitting both can produce duplicate assistant messages. - ws.send({ - type: 'cursor-result', - sessionId: capturedSessionId || sessionId, - data: response, - success: response.subtype === 'success' - }); + const resultText = typeof response.result === 'string' ? response.result : ''; + ws.send(createNormalizedMessage({ + kind: 'complete', + exitCode: response.subtype === 'success' ? 0 : 1, + resultText, + isError: response.subtype !== 'success', + sessionId: capturedSessionId || sessionId, provider: 'cursor', + })); break; + } default: - // Forward any other message types - ws.send({ - type: 'cursor-response', - data: response, - sessionId: capturedSessionId || sessionId || null - }); + // Unknown message types — ignore. } } catch (parseError) { console.log('Non-JSON response:', line); @@ -249,12 +218,9 @@ async function spawnCursor(command, options = {}, ws) { return; } - // If not JSON, send as raw text - ws.send({ - type: 'cursor-output', - data: line, - sessionId: capturedSessionId || sessionId || null - }); + // If not JSON, send as stream delta via adapter + const normalized = cursorAdapter.normalizeMessage(line, capturedSessionId || sessionId || null); + for (const msg of normalized) ws.send(msg); } }; @@ -282,12 +248,7 @@ async function spawnCursor(command, options = {}, ws) { return; } - ws.send({ - type: 'cursor-error', - error: stderrText, - sessionId: capturedSessionId || sessionId || null, - provider: 'cursor' - }); + ws.send(createNormalizedMessage({ kind: 'error', content: stderrText, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' })); }); // Handle process completion @@ -314,13 +275,7 @@ async function spawnCursor(command, options = {}, ws) { return; } - ws.send({ - type: 'claude-complete', - sessionId: finalSessionId, - exitCode: code, - provider: 'cursor', - isNewSession: !sessionId && !!command // Flag to indicate this was a new session - }); + ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' })); if (code === 0) { notifyTerminalState({ code }); @@ -339,12 +294,7 @@ async function spawnCursor(command, options = {}, ws) { const finalSessionId = capturedSessionId || sessionId || processKey; activeCursorProcesses.delete(finalSessionId); - ws.send({ - type: 'cursor-error', - error: error.message, - sessionId: capturedSessionId || sessionId || null, - provider: 'cursor' - }); + ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' })); notifyTerminalState({ error }); settleOnce(() => reject(error)); diff --git a/server/gemini-cli.js b/server/gemini-cli.js index 3a2c968..8647270 100644 --- a/server/gemini-cli.js +++ b/server/gemini-cli.js @@ -6,15 +6,15 @@ const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; -import { getSessions, getSessionMessages } from './projects.js'; import sessionManager from './sessionManager.js'; import GeminiResponseHandler from './gemini-response-handler.js'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; +import { createNormalizedMessage } from './providers/types.js'; let activeGeminiProcesses = new Map(); // Track active processes by session ID async function spawnGemini(command, options = {}, ws) { - const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images, sessionSummary } = options; + const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options; let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event let assistantBlocks = []; // Accumulate the full response blocks including tools @@ -219,7 +219,6 @@ async function spawnGemini(command, options = {}, ws) { geminiProcess.stdin.end(); // Add timeout handler - let hasReceivedOutput = false; const timeoutMs = 120000; // 120 seconds for slower models let timeout; @@ -228,12 +227,7 @@ async function spawnGemini(command, options = {}, ws) { timeout = setTimeout(() => { const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey); terminalFailureReason = `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`; - ws.send({ - type: 'gemini-error', - sessionId: socketSessionId, - error: terminalFailureReason, - provider: 'gemini' - }); + ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' })); try { geminiProcess.kill('SIGTERM'); } catch (e) { } @@ -295,7 +289,6 @@ async function spawnGemini(command, options = {}, ws) { // Handle stdout geminiProcess.stdout.on('data', (data) => { const rawOutput = data.toString(); - hasReceivedOutput = true; startTimeout(); // Re-arm the timeout // For new sessions, create a session ID FIRST @@ -319,21 +312,7 @@ async function spawnGemini(command, options = {}, ws) { ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId); - ws.send({ - type: 'session-created', - sessionId: capturedSessionId - }); - - // Emit fake system init so the frontend immediately navigates and saves the session - ws.send({ - type: 'claude-response', - sessionId: capturedSessionId, - data: { - type: 'system', - subtype: 'init', - session_id: capturedSessionId - } - }); + ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' })); } if (responseHandler) { @@ -346,14 +325,7 @@ async function spawnGemini(command, options = {}, ws) { assistantBlocks.push({ type: 'text', text: rawOutput }); } const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId); - ws.send({ - type: 'gemini-response', - sessionId: socketSessionId, - data: { - type: 'message', - content: rawOutput - } - }); + ws.send(createNormalizedMessage({ kind: 'stream_delta', content: rawOutput, sessionId: socketSessionId, provider: 'gemini' })); } }); @@ -370,12 +342,7 @@ async function spawnGemini(command, options = {}, ws) { } const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId); - ws.send({ - type: 'gemini-error', - sessionId: socketSessionId, - error: errorMsg, - provider: 'gemini' - }); + ws.send(createNormalizedMessage({ kind: 'error', content: errorMsg, sessionId: socketSessionId, provider: 'gemini' })); }); // Handle process completion @@ -397,13 +364,7 @@ async function spawnGemini(command, options = {}, ws) { sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks); } - ws.send({ - type: 'claude-complete', // Use claude-complete for compatibility with UI - sessionId: finalSessionId, - exitCode: code, - provider: 'gemini', - isNewSession: !sessionId && !!command // Flag to indicate this was a new session - }); + ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' })); // Clean up temporary image files if any if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) { @@ -434,12 +395,7 @@ async function spawnGemini(command, options = {}, ws) { activeGeminiProcesses.delete(finalSessionId); const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId; - ws.send({ - type: 'gemini-error', - sessionId: errorSessionId, - error: error.message, - provider: 'gemini' - }); + ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: errorSessionId, provider: 'gemini' })); notifyTerminalState({ error }); reject(error); diff --git a/server/gemini-response-handler.js b/server/gemini-response-handler.js index e655d9a..9da1f5c 100644 --- a/server/gemini-response-handler.js +++ b/server/gemini-response-handler.js @@ -1,4 +1,6 @@ // Gemini Response Handler - JSON Stream processing +import { geminiAdapter } from './providers/gemini/adapter.js'; + class GeminiResponseHandler { constructor(ws, options = {}) { this.ws = ws; @@ -27,13 +29,12 @@ class GeminiResponseHandler { this.handleEvent(event); } catch (err) { // Not a JSON line, probably debug output or CLI warnings - // console.error('[Gemini Handler] Non-JSON line ignored:', line); } } } handleEvent(event) { - const socketSessionId = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null; + const sid = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null; if (event.type === 'init') { if (this.onInit) { @@ -42,88 +43,26 @@ class GeminiResponseHandler { return; } + // Invoke per-type callbacks for session tracking if (event.type === 'message' && event.role === 'assistant') { const content = event.content || ''; - - // Notify the parent CLI handler of accumulated text if (this.onContentFragment && content) { this.onContentFragment(content); } + } else if (event.type === 'tool_use' && this.onToolUse) { + this.onToolUse(event); + } else if (event.type === 'tool_result' && this.onToolResult) { + this.onToolResult(event); + } - let payload = { - type: 'gemini-response', - data: { - type: 'message', - content: content, - isPartial: event.delta === true - } - }; - if (socketSessionId) payload.sessionId = socketSessionId; - this.ws.send(payload); - } - else if (event.type === 'tool_use') { - if (this.onToolUse) { - this.onToolUse(event); - } - let payload = { - type: 'gemini-tool-use', - toolName: event.tool_name, - toolId: event.tool_id, - parameters: event.parameters || {} - }; - if (socketSessionId) payload.sessionId = socketSessionId; - this.ws.send(payload); - } - else if (event.type === 'tool_result') { - if (this.onToolResult) { - this.onToolResult(event); - } - let payload = { - type: 'gemini-tool-result', - toolId: event.tool_id, - status: event.status, - output: event.output || '' - }; - if (socketSessionId) payload.sessionId = socketSessionId; - this.ws.send(payload); - } - else if (event.type === 'result') { - // Send a finalize message string - let payload = { - type: 'gemini-response', - data: { - type: 'message', - content: '', - isPartial: false - } - }; - if (socketSessionId) payload.sessionId = socketSessionId; - this.ws.send(payload); - - if (event.stats && event.stats.total_tokens) { - let statsPayload = { - type: 'claude-status', - data: { - status: 'Complete', - tokens: event.stats.total_tokens - } - }; - if (socketSessionId) statsPayload.sessionId = socketSessionId; - this.ws.send(statsPayload); - } - } - else if (event.type === 'error') { - let payload = { - type: 'gemini-error', - error: event.error || event.message || 'Unknown Gemini streaming error' - }; - if (socketSessionId) payload.sessionId = socketSessionId; - this.ws.send(payload); + // Normalize via adapter and send all resulting messages + const normalized = geminiAdapter.normalizeMessage(event, sid); + for (const msg of normalized) { + this.ws.send(msg); } } forceFlush() { - // If the buffer has content, try to parse it one last time if (this.buffer.trim()) { try { const event = JSON.parse(this.buffer); diff --git a/server/index.js b/server/index.js index 4a6f8f3..5318fb3 100755 --- a/server/index.js +++ b/server/index.js @@ -44,7 +44,7 @@ import pty from 'node-pty'; import fetch from 'node-fetch'; import mime from 'mime-types'; -import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js'; +import { getProjects, getSessions, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js'; import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js'; @@ -65,6 +65,8 @@ import userRoutes from './routes/user.js'; import codexRoutes from './routes/codex.js'; import geminiRoutes from './routes/gemini.js'; import pluginsRoutes from './routes/plugins.js'; +import messagesRoutes from './routes/messages.js'; +import { createNormalizedMessage } from './providers/types.js'; import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js'; import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js'; import { configureWebPush } from './services/vapid-keys.js'; @@ -396,6 +398,9 @@ app.use('/api/gemini', authenticateToken, geminiRoutes); // Plugins API Routes (protected) app.use('/api/plugins', authenticateToken, pluginsRoutes); +// Unified session messages route (protected) +app.use('/api/sessions', authenticateToken, messagesRoutes); + // Agent API Routes (uses API key authentication) app.use('/api/agent', agentRoutes); @@ -509,31 +514,6 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, re } }); -// Get messages for a specific session -app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, async (req, res) => { - try { - const { projectName, sessionId } = req.params; - const { limit, offset } = req.query; - - // Parse limit and offset if provided - const parsedLimit = limit ? parseInt(limit, 10) : null; - const parsedOffset = offset ? parseInt(offset, 10) : 0; - - const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset); - - // Handle both old and new response formats - if (Array.isArray(result)) { - // Backward compatibility: no pagination parameters were provided - res.json({ messages: result }); - } else { - // New format with pagination info - res.json(result); - } - } catch (error) { - res.status(500).json({ error: error.message }); - } -}); - // Rename project endpoint app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => { try { @@ -958,7 +938,6 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) } const files = await getFileTree(actualPath, 10, 0, true); - const hiddenFiles = files.filter(f => f.name.startsWith('.')); res.json(files); } catch (error) { console.error('[ERROR] File tree error:', error.message); @@ -1463,6 +1442,10 @@ wss.on('connection', (ws, request) => { /** * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface + * + * Provider files use `createNormalizedMessage()` from `providers/types.js` and + * adapter `normalizeMessage()` to produce unified NormalizedMessage events. + * The writer simply serialises and sends. */ class WebSocketWriter { constructor(ws, userId = null) { @@ -1474,7 +1457,6 @@ class WebSocketWriter { send(data) { if (this.ws.readyState === 1) { // WebSocket.OPEN - // Providers send raw objects, we stringify for WebSocket this.ws.send(JSON.stringify(data)); } } @@ -1555,12 +1537,7 @@ function handleChatConnection(ws, request) { success = await abortClaudeSDKSession(data.sessionId); } - writer.send({ - type: 'session-aborted', - sessionId: data.sessionId, - provider, - success - }); + writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider })); } else if (data.type === 'claude-permission-response') { // Relay UI approval decisions back into the SDK control flow. // This does not persist permissions; it only resolves the in-flight request, @@ -1576,12 +1553,7 @@ function handleChatConnection(ws, request) { } else if (data.type === 'cursor-abort') { console.log('[DEBUG] Abort Cursor session:', data.sessionId); const success = abortCursorSession(data.sessionId); - writer.send({ - type: 'session-aborted', - sessionId: data.sessionId, - provider: 'cursor', - success - }); + writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider: 'cursor' })); } else if (data.type === 'check-session-status') { // Check if a specific session is currently processing const provider = data.provider || 'claude'; diff --git a/server/openai-codex.js b/server/openai-codex.js index a12f7e0..0169a3b 100644 --- a/server/openai-codex.js +++ b/server/openai-codex.js @@ -15,6 +15,8 @@ import { Codex } from '@openai/codex-sdk'; import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; +import { codexAdapter } from './providers/codex/adapter.js'; +import { createNormalizedMessage } from './providers/types.js'; // Track active sessions const activeCodexSessions = new Map(); @@ -241,11 +243,7 @@ export async function queryCodex(command, options = {}, ws) { }); // Send session created event - sendMessage(ws, { - type: 'session-created', - sessionId: currentSessionId, - provider: 'codex' - }); + sendMessage(ws, createNormalizedMessage({ kind: 'session_created', newSessionId: currentSessionId, sessionId: currentSessionId, provider: 'codex' })); // Execute with streaming const streamedTurn = await thread.runStreamed(command, { @@ -265,11 +263,11 @@ export async function queryCodex(command, options = {}, ws) { const transformed = transformCodexEvent(event); - sendMessage(ws, { - type: 'codex-response', - data: transformed, - sessionId: currentSessionId - }); + // Normalize the transformed event into NormalizedMessage(s) via adapter + const normalizedMsgs = codexAdapter.normalizeMessage(transformed, currentSessionId); + for (const msg of normalizedMsgs) { + sendMessage(ws, msg); + } if (event.type === 'turn.failed' && !terminalFailure) { terminalFailure = event.error || new Error('Turn failed'); @@ -285,25 +283,13 @@ export async function queryCodex(command, options = {}, ws) { // Extract and send token usage if available (normalized to match Claude format) if (event.type === 'turn.completed' && event.usage) { const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0); - sendMessage(ws, { - type: 'token-budget', - data: { - used: totalTokens, - total: 200000 // Default context window for Codex models - }, - sessionId: currentSessionId - }); + sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: currentSessionId, provider: 'codex' })); } } // Send completion event if (!terminalFailure) { - sendMessage(ws, { - type: 'codex-complete', - sessionId: currentSessionId, - actualSessionId: thread.id, - provider: 'codex' - }); + sendMessage(ws, createNormalizedMessage({ kind: 'complete', actualSessionId: thread.id, sessionId: currentSessionId, provider: 'codex' })); notifyRunStopped({ userId: ws?.userId || null, provider: 'codex', @@ -322,12 +308,7 @@ export async function queryCodex(command, options = {}, ws) { if (!wasAborted) { console.error('[Codex] Error:', error); - sendMessage(ws, { - type: 'codex-error', - error: error.message, - sessionId: currentSessionId, - provider: 'codex' - }); + sendMessage(ws, createNormalizedMessage({ kind: 'error', content: error.message, sessionId: currentSessionId, provider: 'codex' })); if (!terminalFailure) { notifyRunFailed({ userId: ws?.userId || null, diff --git a/server/projects.js b/server/projects.js index 875ca2c..d8ccaeb 100755 --- a/server/projects.js +++ b/server/projects.js @@ -1014,7 +1014,7 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset = messages.push(entry); } } catch (parseError) { - console.warn('Error parsing line:', parseError.message); + // Silently skip malformed JSONL lines (common with concurrent writes) } } } diff --git a/server/providers/claude/adapter.js b/server/providers/claude/adapter.js new file mode 100644 index 0000000..d5f850b --- /dev/null +++ b/server/providers/claude/adapter.js @@ -0,0 +1,278 @@ +/** + * Claude provider adapter. + * + * Normalizes Claude SDK session history into NormalizedMessage format. + * @module adapters/claude + */ + +import { getSessionMessages } from '../../projects.js'; +import { createNormalizedMessage, generateMessageId } from '../types.js'; +import { isInternalContent } from '../utils.js'; + +const PROVIDER = 'claude'; + +/** + * Normalize a raw JSONL message or realtime SDK event into NormalizedMessage(s). + * Handles both history entries (JSONL `{ message: { role, content } }`) and + * realtime streaming events (`content_block_delta`, `content_block_stop`, etc.). + * @param {object} raw - A single entry from JSONL or a live SDK event + * @param {string} sessionId + * @returns {import('../types.js').NormalizedMessage[]} + */ +export function normalizeMessage(raw, sessionId) { + // ── Streaming events (realtime) ────────────────────────────────────────── + if (raw.type === 'content_block_delta' && raw.delta?.text) { + return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })]; + } + if (raw.type === 'content_block_stop') { + return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })]; + } + + // ── History / full-message events ──────────────────────────────────────── + const messages = []; + const ts = raw.timestamp || new Date().toISOString(); + const baseId = raw.uuid || generateMessageId('claude'); + + // User message + if (raw.message?.role === 'user' && raw.message?.content) { + if (Array.isArray(raw.message.content)) { + // Handle tool_result parts + for (const part of raw.message.content) { + if (part.type === 'tool_result') { + messages.push(createNormalizedMessage({ + id: `${baseId}_tr_${part.tool_use_id}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_result', + toolId: part.tool_use_id, + content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content), + isError: Boolean(part.is_error), + subagentTools: raw.subagentTools, + toolUseResult: raw.toolUseResult, + })); + } else if (part.type === 'text') { + // Regular text parts from user + const text = part.text || ''; + if (text && !isInternalContent(text)) { + messages.push(createNormalizedMessage({ + id: `${baseId}_text`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'user', + content: text, + })); + } + } + } + + // If no text parts were found, check if it's a pure user message + if (messages.length === 0) { + const textParts = raw.message.content + .filter(p => p.type === 'text') + .map(p => p.text) + .filter(Boolean) + .join('\n'); + if (textParts && !isInternalContent(textParts)) { + messages.push(createNormalizedMessage({ + id: `${baseId}_text`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'user', + content: textParts, + })); + } + } + } else if (typeof raw.message.content === 'string') { + const text = raw.message.content; + if (text && !isInternalContent(text)) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'user', + content: text, + })); + } + } + return messages; + } + + // Thinking message + if (raw.type === 'thinking' && raw.message?.content) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'thinking', + content: raw.message.content, + })); + return messages; + } + + // Tool use result (codex-style in Claude) + if (raw.type === 'tool_use' && raw.toolName) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: raw.toolName, + toolInput: raw.toolInput, + toolId: raw.toolCallId || baseId, + })); + return messages; + } + + if (raw.type === 'tool_result') { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_result', + toolId: raw.toolCallId || '', + content: raw.output || '', + isError: false, + })); + return messages; + } + + // Assistant message + if (raw.message?.role === 'assistant' && raw.message?.content) { + if (Array.isArray(raw.message.content)) { + let partIndex = 0; + for (const part of raw.message.content) { + if (part.type === 'text' && part.text) { + messages.push(createNormalizedMessage({ + id: `${baseId}_${partIndex}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: part.text, + })); + } else if (part.type === 'tool_use') { + messages.push(createNormalizedMessage({ + id: `${baseId}_${partIndex}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: part.name, + toolInput: part.input, + toolId: part.id, + })); + } else if (part.type === 'thinking' && part.thinking) { + messages.push(createNormalizedMessage({ + id: `${baseId}_${partIndex}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'thinking', + content: part.thinking, + })); + } + partIndex++; + } + } else if (typeof raw.message.content === 'string') { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: raw.message.content, + })); + } + return messages; + } + + return messages; +} + +/** + * @type {import('../types.js').ProviderAdapter} + */ +export const claudeAdapter = { + normalizeMessage, + + /** + * Fetch session history from JSONL files, returning normalized messages. + */ + async fetchHistory(sessionId, opts = {}) { + const { projectName, limit = null, offset = 0 } = opts; + if (!projectName) { + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } + + let result; + try { + result = await getSessionMessages(projectName, sessionId, limit, offset); + } catch (error) { + console.warn(`[ClaudeAdapter] Failed to load session ${sessionId}:`, error.message); + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } + + // getSessionMessages returns either an array (no limit) or { messages, total, hasMore } + const rawMessages = Array.isArray(result) ? result : (result.messages || []); + const total = Array.isArray(result) ? rawMessages.length : (result.total || 0); + const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore); + + // First pass: collect tool results for attachment to tool_use messages + const toolResultMap = new Map(); + for (const raw of rawMessages) { + if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) { + for (const part of raw.message.content) { + if (part.type === 'tool_result') { + toolResultMap.set(part.tool_use_id, { + content: part.content, + isError: Boolean(part.is_error), + timestamp: raw.timestamp, + subagentTools: raw.subagentTools, + toolUseResult: raw.toolUseResult, + }); + } + } + } + } + + // Second pass: normalize all messages + const normalized = []; + for (const raw of rawMessages) { + const entries = normalizeMessage(raw, sessionId); + normalized.push(...entries); + } + + // Attach tool results to their corresponding tool_use messages + for (const msg of normalized) { + if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) { + const tr = toolResultMap.get(msg.toolId); + msg.toolResult = { + content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content), + isError: tr.isError, + toolUseResult: tr.toolUseResult, + }; + msg.subagentTools = tr.subagentTools; + } + } + + return { + messages: normalized, + total, + hasMore, + offset, + limit, + }; + }, +}; diff --git a/server/providers/codex/adapter.js b/server/providers/codex/adapter.js new file mode 100644 index 0000000..c9cae00 --- /dev/null +++ b/server/providers/codex/adapter.js @@ -0,0 +1,248 @@ +/** + * Codex (OpenAI) provider adapter. + * + * Normalizes Codex SDK session history into NormalizedMessage format. + * @module adapters/codex + */ + +import { getCodexSessionMessages } from '../../projects.js'; +import { createNormalizedMessage, generateMessageId } from '../types.js'; + +const PROVIDER = 'codex'; + +/** + * Normalize a raw Codex JSONL message into NormalizedMessage(s). + * @param {object} raw - A single parsed message from Codex JSONL + * @param {string} sessionId + * @returns {import('../types.js').NormalizedMessage[]} + */ +function normalizeCodexHistoryEntry(raw, sessionId) { + const ts = raw.timestamp || new Date().toISOString(); + const baseId = raw.uuid || generateMessageId('codex'); + + // User message + if (raw.message?.role === 'user') { + const content = typeof raw.message.content === 'string' + ? raw.message.content + : Array.isArray(raw.message.content) + ? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n') + : String(raw.message.content || ''); + if (!content.trim()) return []; + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'user', + content, + })]; + } + + // Assistant message + if (raw.message?.role === 'assistant') { + const content = typeof raw.message.content === 'string' + ? raw.message.content + : Array.isArray(raw.message.content) + ? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n') + : ''; + if (!content.trim()) return []; + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content, + })]; + } + + // Thinking/reasoning + if (raw.type === 'thinking' || raw.isReasoning) { + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'thinking', + content: raw.message?.content || '', + })]; + } + + // Tool use + if (raw.type === 'tool_use' || raw.toolName) { + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: raw.toolName || 'Unknown', + toolInput: raw.toolInput, + toolId: raw.toolCallId || baseId, + })]; + } + + // Tool result + if (raw.type === 'tool_result') { + return [createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_result', + toolId: raw.toolCallId || '', + content: raw.output || '', + isError: Boolean(raw.isError), + })]; + } + + return []; +} + +/** + * Normalize a raw Codex event (history JSONL or transformed SDK event) into NormalizedMessage(s). + * @param {object} raw - A history entry (has raw.message.role) or transformed SDK event (has raw.type) + * @param {string} sessionId + * @returns {import('../types.js').NormalizedMessage[]} + */ +export function normalizeMessage(raw, sessionId) { + // History format: has message.role + if (raw.message?.role) { + return normalizeCodexHistoryEntry(raw, sessionId); + } + + const ts = raw.timestamp || new Date().toISOString(); + const baseId = raw.uuid || generateMessageId('codex'); + + // SDK event format (output of transformCodexEvent) + if (raw.type === 'item') { + switch (raw.itemType) { + case 'agent_message': + return [createNormalizedMessage({ + id: baseId, sessionId, timestamp: ts, provider: PROVIDER, + kind: 'text', role: 'assistant', content: raw.message?.content || '', + })]; + case 'reasoning': + return [createNormalizedMessage({ + id: baseId, sessionId, timestamp: ts, provider: PROVIDER, + kind: 'thinking', content: raw.message?.content || '', + })]; + case 'command_execution': + return [createNormalizedMessage({ + id: baseId, sessionId, timestamp: ts, provider: PROVIDER, + kind: 'tool_use', toolName: 'Bash', toolInput: { command: raw.command }, + toolId: baseId, + output: raw.output, exitCode: raw.exitCode, status: raw.status, + })]; + case 'file_change': + return [createNormalizedMessage({ + id: baseId, sessionId, timestamp: ts, provider: PROVIDER, + kind: 'tool_use', toolName: 'FileChanges', toolInput: raw.changes, + toolId: baseId, status: raw.status, + })]; + case 'mcp_tool_call': + return [createNormalizedMessage({ + id: baseId, sessionId, timestamp: ts, provider: PROVIDER, + kind: 'tool_use', toolName: raw.tool || 'MCP', toolInput: raw.arguments, + toolId: baseId, server: raw.server, result: raw.result, + error: raw.error, status: raw.status, + })]; + case 'web_search': + return [createNormalizedMessage({ + id: baseId, sessionId, timestamp: ts, provider: PROVIDER, + kind: 'tool_use', toolName: 'WebSearch', toolInput: { query: raw.query }, + toolId: baseId, + })]; + case 'todo_list': + return [createNormalizedMessage({ + id: baseId, sessionId, timestamp: ts, provider: PROVIDER, + kind: 'tool_use', toolName: 'TodoList', toolInput: { items: raw.items }, + toolId: baseId, + })]; + case 'error': + return [createNormalizedMessage({ + id: baseId, sessionId, timestamp: ts, provider: PROVIDER, + kind: 'error', content: raw.message?.content || 'Unknown error', + })]; + default: + // Unknown item type — pass through as generic tool_use + return [createNormalizedMessage({ + id: baseId, sessionId, timestamp: ts, provider: PROVIDER, + kind: 'tool_use', toolName: raw.itemType || 'Unknown', + toolInput: raw.item || raw, toolId: baseId, + })]; + } + } + + if (raw.type === 'turn_complete') { + return [createNormalizedMessage({ + id: baseId, sessionId, timestamp: ts, provider: PROVIDER, + kind: 'complete', + })]; + } + if (raw.type === 'turn_failed') { + return [createNormalizedMessage({ + id: baseId, sessionId, timestamp: ts, provider: PROVIDER, + kind: 'error', content: raw.error?.message || 'Turn failed', + })]; + } + + return []; +} + +/** + * @type {import('../types.js').ProviderAdapter} + */ +export const codexAdapter = { + normalizeMessage, + /** + * Fetch session history from Codex JSONL files. + */ + async fetchHistory(sessionId, opts = {}) { + const { limit = null, offset = 0 } = opts; + + let result; + try { + result = await getCodexSessionMessages(sessionId, limit, offset); + } catch (error) { + console.warn(`[CodexAdapter] Failed to load session ${sessionId}:`, error.message); + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } + + const rawMessages = Array.isArray(result) ? result : (result.messages || []); + const total = Array.isArray(result) ? rawMessages.length : (result.total || 0); + const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore); + const tokenUsage = result.tokenUsage || null; + + const normalized = []; + for (const raw of rawMessages) { + const entries = normalizeCodexHistoryEntry(raw, sessionId); + normalized.push(...entries); + } + + // Attach tool results to tool_use messages + const toolResultMap = new Map(); + for (const msg of normalized) { + if (msg.kind === 'tool_result' && msg.toolId) { + toolResultMap.set(msg.toolId, msg); + } + } + for (const msg of normalized) { + if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) { + const tr = toolResultMap.get(msg.toolId); + msg.toolResult = { content: tr.content, isError: tr.isError }; + } + } + + return { + messages: normalized, + total, + hasMore, + offset, + limit, + tokenUsage, + }; + }, +}; diff --git a/server/providers/cursor/adapter.js b/server/providers/cursor/adapter.js new file mode 100644 index 0000000..c86215f --- /dev/null +++ b/server/providers/cursor/adapter.js @@ -0,0 +1,353 @@ +/** + * Cursor provider adapter. + * + * Normalizes Cursor CLI session history into NormalizedMessage format. + * @module adapters/cursor + */ + +import path from 'path'; +import os from 'os'; +import crypto from 'crypto'; +import { createNormalizedMessage, generateMessageId } from '../types.js'; + +const PROVIDER = 'cursor'; + +/** + * Load raw blobs from Cursor's SQLite store.db, parse the DAG structure, + * and return sorted message blobs in chronological order. + * @param {string} sessionId + * @param {string} projectPath - Absolute project path (used to compute cwdId hash) + * @returns {Promise>} + */ +async function loadCursorBlobs(sessionId, projectPath) { + // Lazy-import sqlite so the module doesn't fail if sqlite3 is unavailable + const { default: sqlite3 } = await import('sqlite3'); + const { open } = await import('sqlite'); + + const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex'); + const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db'); + + const db = await open({ + filename: storeDbPath, + driver: sqlite3.Database, + mode: sqlite3.OPEN_READONLY, + }); + + try { + const allBlobs = await db.all('SELECT rowid, id, data FROM blobs'); + + const blobMap = new Map(); + const parentRefs = new Map(); + const childRefs = new Map(); + const jsonBlobs = []; + + for (const blob of allBlobs) { + blobMap.set(blob.id, blob); + + if (blob.data && blob.data[0] === 0x7B) { + try { + const parsed = JSON.parse(blob.data.toString('utf8')); + jsonBlobs.push({ ...blob, parsed }); + } catch { + // skip unparseable blobs + } + } else if (blob.data) { + const parents = []; + let i = 0; + while (i < blob.data.length - 33) { + if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) { + const parentHash = blob.data.slice(i + 2, i + 34).toString('hex'); + if (blobMap.has(parentHash)) { + parents.push(parentHash); + } + i += 34; + } else { + i++; + } + } + if (parents.length > 0) { + parentRefs.set(blob.id, parents); + for (const parentId of parents) { + if (!childRefs.has(parentId)) childRefs.set(parentId, []); + childRefs.get(parentId).push(blob.id); + } + } + } + } + + // Topological sort (DFS) + const visited = new Set(); + const sorted = []; + function visit(nodeId) { + if (visited.has(nodeId)) return; + visited.add(nodeId); + for (const pid of (parentRefs.get(nodeId) || [])) visit(pid); + const b = blobMap.get(nodeId); + if (b) sorted.push(b); + } + for (const blob of allBlobs) { + if (!parentRefs.has(blob.id)) visit(blob.id); + } + for (const blob of allBlobs) visit(blob.id); + + // Order JSON blobs by DAG appearance + const messageOrder = new Map(); + let orderIndex = 0; + for (const blob of sorted) { + if (blob.data && blob.data[0] !== 0x7B) { + for (const jb of jsonBlobs) { + try { + const idBytes = Buffer.from(jb.id, 'hex'); + if (blob.data.includes(idBytes) && !messageOrder.has(jb.id)) { + messageOrder.set(jb.id, orderIndex++); + } + } catch { /* skip */ } + } + } + } + + const sortedJsonBlobs = jsonBlobs.sort((a, b) => { + const oa = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; + const ob = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; + return oa !== ob ? oa - ob : a.rowid - b.rowid; + }); + + const messages = []; + for (let idx = 0; idx < sortedJsonBlobs.length; idx++) { + const blob = sortedJsonBlobs[idx]; + const parsed = blob.parsed; + if (!parsed) continue; + const role = parsed?.role || parsed?.message?.role; + if (role === 'system') continue; + messages.push({ + id: blob.id, + sequence: idx + 1, + rowid: blob.rowid, + content: parsed, + }); + } + + return messages; + } finally { + await db.close(); + } +} + +/** + * Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s). + * History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON. + * @param {object|string} raw - A parsed NDJSON event or a raw text line + * @param {string} sessionId + * @returns {import('../types.js').NormalizedMessage[]} + */ +export function normalizeMessage(raw, sessionId) { + // Structured assistant message with content array + if (raw && typeof raw === 'object' && raw.type === 'assistant' && raw.message?.content?.[0]?.text) { + return [createNormalizedMessage({ kind: 'stream_delta', content: raw.message.content[0].text, sessionId, provider: PROVIDER })]; + } + // Plain string line (non-JSON output) + if (typeof raw === 'string' && raw.trim()) { + return [createNormalizedMessage({ kind: 'stream_delta', content: raw, sessionId, provider: PROVIDER })]; + } + return []; +} + +/** + * @type {import('../types.js').ProviderAdapter} + */ +export const cursorAdapter = { + normalizeMessage, + /** + * Fetch session history for Cursor from SQLite store.db. + */ + async fetchHistory(sessionId, opts = {}) { + const { projectPath = '', limit = null, offset = 0 } = opts; + + try { + const blobs = await loadCursorBlobs(sessionId, projectPath); + const allNormalized = cursorAdapter.normalizeCursorBlobs(blobs, sessionId); + + // Apply pagination + if (limit !== null && limit > 0) { + const start = offset; + const page = allNormalized.slice(start, start + limit); + return { + messages: page, + total: allNormalized.length, + hasMore: start + limit < allNormalized.length, + offset, + limit, + }; + } + + return { + messages: allNormalized, + total: allNormalized.length, + hasMore: false, + offset: 0, + limit: null, + }; + } catch (error) { + // DB doesn't exist or is unreadable — return empty + console.warn(`[CursorAdapter] Failed to load session ${sessionId}:`, error.message); + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } + }, + + /** + * Normalize raw Cursor blob messages into NormalizedMessage[]. + * @param {any[]} blobs - Raw cursor blobs from store.db ({id, sequence, rowid, content}) + * @param {string} sessionId + * @returns {import('../types.js').NormalizedMessage[]} + */ + normalizeCursorBlobs(blobs, sessionId) { + const messages = []; + const toolUseMap = new Map(); + + // Use a fixed base timestamp so messages have stable, monotonically-increasing + // timestamps based on their sequence number rather than wall-clock time. + const baseTime = Date.now(); + + for (let i = 0; i < blobs.length; i++) { + const blob = blobs[i]; + const content = blob.content; + const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString(); + const baseId = blob.id || generateMessageId('cursor'); + + try { + if (!content?.role || !content?.content) { + // Try nested message format + if (content?.message?.role && content?.message?.content) { + if (content.message.role === 'system') continue; + const role = content.message.role === 'user' ? 'user' : 'assistant'; + let text = ''; + if (Array.isArray(content.message.content)) { + text = content.message.content + .map(p => typeof p === 'string' ? p : p?.text || '') + .filter(Boolean) + .join('\n'); + } else if (typeof content.message.content === 'string') { + text = content.message.content; + } + if (text?.trim()) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role, + content: text, + sequence: blob.sequence, + rowid: blob.rowid, + })); + } + } + continue; + } + + if (content.role === 'system') continue; + + // Tool results + if (content.role === 'tool') { + const toolItems = Array.isArray(content.content) ? content.content : []; + for (const item of toolItems) { + if (item?.type !== 'tool-result') continue; + const toolCallId = item.toolCallId || content.id; + messages.push(createNormalizedMessage({ + id: `${baseId}_tr`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_result', + toolId: toolCallId, + content: item.result || '', + isError: false, + })); + } + continue; + } + + const role = content.role === 'user' ? 'user' : 'assistant'; + + if (Array.isArray(content.content)) { + for (let partIdx = 0; partIdx < content.content.length; partIdx++) { + const part = content.content[partIdx]; + + if (part?.type === 'text' && part?.text) { + messages.push(createNormalizedMessage({ + id: `${baseId}_${partIdx}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role, + content: part.text, + sequence: blob.sequence, + rowid: blob.rowid, + })); + } else if (part?.type === 'reasoning' && part?.text) { + messages.push(createNormalizedMessage({ + id: `${baseId}_${partIdx}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'thinking', + content: part.text, + })); + } else if (part?.type === 'tool-call' || part?.type === 'tool_use') { + const toolName = (part.toolName || part.name || 'Unknown Tool') === 'ApplyPatch' + ? 'Edit' : (part.toolName || part.name || 'Unknown Tool'); + const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`; + messages.push(createNormalizedMessage({ + id: `${baseId}_${partIdx}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName, + toolInput: part.args || part.input, + toolId, + })); + toolUseMap.set(toolId, messages[messages.length - 1]); + } + } + } else if (typeof content.content === 'string' && content.content.trim()) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role, + content: content.content, + sequence: blob.sequence, + rowid: blob.rowid, + })); + } + } catch (error) { + console.warn('Error normalizing cursor blob:', error); + } + } + + // Attach tool results to tool_use messages + for (const msg of messages) { + if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) { + const toolUse = toolUseMap.get(msg.toolId); + toolUse.toolResult = { + content: msg.content, + isError: msg.isError, + }; + } + } + + // Sort by sequence/rowid + messages.sort((a, b) => { + if (a.sequence !== undefined && b.sequence !== undefined) return a.sequence - b.sequence; + if (a.rowid !== undefined && b.rowid !== undefined) return a.rowid - b.rowid; + return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); + }); + + return messages; + }, +}; diff --git a/server/providers/gemini/adapter.js b/server/providers/gemini/adapter.js new file mode 100644 index 0000000..df303c3 --- /dev/null +++ b/server/providers/gemini/adapter.js @@ -0,0 +1,186 @@ +/** + * Gemini provider adapter. + * + * Normalizes Gemini CLI session history into NormalizedMessage format. + * @module adapters/gemini + */ + +import sessionManager from '../../sessionManager.js'; +import { getGeminiCliSessionMessages } from '../../projects.js'; +import { createNormalizedMessage, generateMessageId } from '../types.js'; + +const PROVIDER = 'gemini'; + +/** + * Normalize a realtime NDJSON event from Gemini CLI into NormalizedMessage(s). + * Handles: message (delta/final), tool_use, tool_result, result, error. + * @param {object} raw - A parsed NDJSON event + * @param {string} sessionId + * @returns {import('../types.js').NormalizedMessage[]} + */ +export function normalizeMessage(raw, sessionId) { + const ts = raw.timestamp || new Date().toISOString(); + const baseId = raw.uuid || generateMessageId('gemini'); + + if (raw.type === 'message' && raw.role === 'assistant') { + const content = raw.content || ''; + const msgs = []; + if (content) { + msgs.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_delta', content })); + } + // If not a delta, also send stream_end + if (raw.delta !== true) { + msgs.push(createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })); + } + return msgs; + } + + if (raw.type === 'tool_use') { + return [createNormalizedMessage({ + id: baseId, sessionId, timestamp: ts, provider: PROVIDER, + kind: 'tool_use', toolName: raw.tool_name, toolInput: raw.parameters || {}, + toolId: raw.tool_id || baseId, + })]; + } + + if (raw.type === 'tool_result') { + return [createNormalizedMessage({ + id: baseId, sessionId, timestamp: ts, provider: PROVIDER, + kind: 'tool_result', toolId: raw.tool_id || '', + content: raw.output === undefined ? '' : String(raw.output), + isError: raw.status === 'error', + })]; + } + + if (raw.type === 'result') { + const msgs = [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })]; + if (raw.stats?.total_tokens) { + msgs.push(createNormalizedMessage({ + sessionId, timestamp: ts, provider: PROVIDER, + kind: 'status', text: 'Complete', tokens: raw.stats.total_tokens, canInterrupt: false, + })); + } + return msgs; + } + + if (raw.type === 'error') { + return [createNormalizedMessage({ + id: baseId, sessionId, timestamp: ts, provider: PROVIDER, + kind: 'error', content: raw.error || raw.message || 'Unknown Gemini streaming error', + })]; + } + + return []; +} + +/** + * @type {import('../types.js').ProviderAdapter} + */ +export const geminiAdapter = { + normalizeMessage, + /** + * Fetch session history for Gemini. + * First tries in-memory session manager, then falls back to CLI sessions on disk. + */ + async fetchHistory(sessionId, opts = {}) { + let rawMessages; + try { + rawMessages = sessionManager.getSessionMessages(sessionId); + + // Fallback to Gemini CLI sessions on disk + if (rawMessages.length === 0) { + rawMessages = await getGeminiCliSessionMessages(sessionId); + } + } catch (error) { + console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message); + return { messages: [], total: 0, hasMore: false, offset: 0, limit: null }; + } + + const normalized = []; + for (let i = 0; i < rawMessages.length; i++) { + const raw = rawMessages[i]; + const ts = raw.timestamp || new Date().toISOString(); + const baseId = raw.uuid || generateMessageId('gemini'); + + // sessionManager format: { type: 'message', message: { role, content }, timestamp } + // CLI format: { role: 'user'|'gemini'|'assistant', content: string|array } + const role = raw.message?.role || raw.role; + const content = raw.message?.content || raw.content; + + if (!role || !content) continue; + + const normalizedRole = (role === 'user') ? 'user' : 'assistant'; + + if (Array.isArray(content)) { + for (let partIdx = 0; partIdx < content.length; partIdx++) { + const part = content[partIdx]; + if (part.type === 'text' && part.text) { + normalized.push(createNormalizedMessage({ + id: `${baseId}_${partIdx}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: normalizedRole, + content: part.text, + })); + } else if (part.type === 'tool_use') { + normalized.push(createNormalizedMessage({ + id: `${baseId}_${partIdx}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_use', + toolName: part.name, + toolInput: part.input, + toolId: part.id || generateMessageId('gemini_tool'), + })); + } else if (part.type === 'tool_result') { + normalized.push(createNormalizedMessage({ + id: `${baseId}_${partIdx}`, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'tool_result', + toolId: part.tool_use_id || '', + content: part.content === undefined ? '' : String(part.content), + isError: Boolean(part.is_error), + })); + } + } + } else if (typeof content === 'string' && content.trim()) { + normalized.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: normalizedRole, + content, + })); + } + } + + // Attach tool results to tool_use messages + const toolResultMap = new Map(); + for (const msg of normalized) { + if (msg.kind === 'tool_result' && msg.toolId) { + toolResultMap.set(msg.toolId, msg); + } + } + for (const msg of normalized) { + if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) { + const tr = toolResultMap.get(msg.toolId); + msg.toolResult = { content: tr.content, isError: tr.isError }; + } + } + + return { + messages: normalized, + total: normalized.length, + hasMore: false, + offset: 0, + limit: null, + }; + }, +}; diff --git a/server/providers/registry.js b/server/providers/registry.js new file mode 100644 index 0000000..236c909 --- /dev/null +++ b/server/providers/registry.js @@ -0,0 +1,44 @@ +/** + * Provider Registry + * + * Centralizes provider adapter lookup. All code that needs a provider adapter + * should go through this registry instead of importing individual adapters directly. + * + * @module providers/registry + */ + +import { claudeAdapter } from './claude/adapter.js'; +import { cursorAdapter } from './cursor/adapter.js'; +import { codexAdapter } from './codex/adapter.js'; +import { geminiAdapter } from './gemini/adapter.js'; + +/** + * @typedef {import('./types.js').ProviderAdapter} ProviderAdapter + * @typedef {import('./types.js').SessionProvider} SessionProvider + */ + +/** @type {Map} */ +const providers = new Map(); + +// Register built-in providers +providers.set('claude', claudeAdapter); +providers.set('cursor', cursorAdapter); +providers.set('codex', codexAdapter); +providers.set('gemini', geminiAdapter); + +/** + * Get a provider adapter by name. + * @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini') + * @returns {ProviderAdapter | undefined} + */ +export function getProvider(name) { + return providers.get(name); +} + +/** + * Get all registered provider names. + * @returns {string[]} + */ +export function getAllProviders() { + return Array.from(providers.keys()); +} diff --git a/server/providers/types.js b/server/providers/types.js new file mode 100644 index 0000000..5541525 --- /dev/null +++ b/server/providers/types.js @@ -0,0 +1,119 @@ +/** + * Provider Types & Interface + * + * Defines the normalized message format and the provider adapter interface. + * All providers normalize their native formats into NormalizedMessage + * before sending over REST or WebSocket. + * + * @module providers/types + */ + +// ─── Session Provider ──────────────────────────────────────────────────────── + +/** + * @typedef {'claude' | 'cursor' | 'codex' | 'gemini'} SessionProvider + */ + +// ─── Message Kind ──────────────────────────────────────────────────────────── + +/** + * @typedef {'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stream_delta' | 'stream_end' + * | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled' + * | 'session_created' | 'interactive_prompt' | 'task_notification'} MessageKind + */ + +// ─── NormalizedMessage ─────────────────────────────────────────────────────── + +/** + * @typedef {Object} NormalizedMessage + * @property {string} id - Unique message id (for dedup between server + realtime) + * @property {string} sessionId + * @property {string} timestamp - ISO 8601 + * @property {SessionProvider} provider + * @property {MessageKind} kind + * + * Additional fields depending on kind: + * - text: role ('user'|'assistant'), content, images? + * - tool_use: toolName, toolInput, toolId + * - tool_result: toolId, content, isError + * - thinking: content + * - stream_delta: content + * - stream_end: (no extra fields) + * - error: content + * - complete: (no extra fields) + * - status: text, tokens?, canInterrupt? + * - permission_request: requestId, toolName, input, context? + * - permission_cancelled: requestId + * - session_created: newSessionId + * - interactive_prompt: content + * - task_notification: status, summary + */ + +// ─── Fetch History ─────────────────────────────────────────────────────────── + +/** + * @typedef {Object} FetchHistoryOptions + * @property {string} [projectName] - Project name (required for Claude) + * @property {string} [projectPath] - Absolute project path (required for Cursor cwdId hash) + * @property {number|null} [limit] - Page size (null = all messages) + * @property {number} [offset] - Pagination offset (default: 0) + */ + +/** + * @typedef {Object} FetchHistoryResult + * @property {NormalizedMessage[]} messages - Normalized messages + * @property {number} total - Total number of messages in the session + * @property {boolean} hasMore - Whether more messages exist before the current page + * @property {number} offset - Current offset + * @property {number|null} limit - Page size used + * @property {object} [tokenUsage] - Token usage data (provider-specific) + */ + +// ─── Provider Adapter Interface ────────────────────────────────────────────── + +/** + * Every provider adapter MUST implement this interface. + * + * @typedef {Object} ProviderAdapter + * + * @property {(sessionId: string, opts?: FetchHistoryOptions) => Promise} fetchHistory + * Read persisted session messages from disk/database and return them as NormalizedMessage[]. + * The backend calls this from the unified GET /api/sessions/:id/messages endpoint. + * + * Provider implementations: + * - Claude: reads ~/.claude/projects/{projectName}/*.jsonl + * - Cursor: reads from SQLite store.db (via normalizeCursorBlobs helper) + * - Codex: reads ~/.codex/sessions/*.jsonl + * - Gemini: reads from in-memory sessionManager or ~/.gemini/tmp/ JSON files + * + * @property {(raw: any, sessionId: string) => NormalizedMessage[]} normalizeMessage + * Normalize a provider-specific event (JSONL entry or live SDK event) into NormalizedMessage[]. + * Used by provider files to convert both history and realtime events. + */ + +// ─── Runtime Helpers ───────────────────────────────────────────────────────── + +/** + * Generate a unique message ID. + * Uses crypto.randomUUID() to avoid collisions across server restarts and workers. + * @param {string} [prefix='msg'] - Optional prefix + * @returns {string} + */ +export function generateMessageId(prefix = 'msg') { + return `${prefix}_${crypto.randomUUID()}`; +} + +/** + * Create a NormalizedMessage with common fields pre-filled. + * @param {Partial & {kind: MessageKind, provider: SessionProvider}} fields + * @returns {NormalizedMessage} + */ +export function createNormalizedMessage(fields) { + return { + ...fields, + id: fields.id || generateMessageId(fields.kind), + sessionId: fields.sessionId || '', + timestamp: fields.timestamp || new Date().toISOString(), + provider: fields.provider, + }; +} diff --git a/server/providers/utils.js b/server/providers/utils.js new file mode 100644 index 0000000..1ec1382 --- /dev/null +++ b/server/providers/utils.js @@ -0,0 +1,29 @@ +/** + * Shared provider utilities. + * + * @module providers/utils + */ + +/** + * Prefixes that indicate internal/system content which should be hidden from the UI. + * @type {readonly string[]} + */ +export const INTERNAL_CONTENT_PREFIXES = Object.freeze([ + '', + '', + '', + '', + '', + 'Caveat:', + 'This session is being continued from a previous', + '[Request interrupted', +]); + +/** + * Check if user text content is internal/system that should be skipped. + * @param {string} content + * @returns {boolean} + */ +export function isInternalContent(content) { + return INTERNAL_CONTENT_PREFIXES.some(prefix => content.startsWith(prefix)); +} diff --git a/server/routes/codex.js b/server/routes/codex.js index 5ed5291..3855548 100644 --- a/server/routes/codex.js +++ b/server/routes/codex.js @@ -4,7 +4,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import os from 'os'; import TOML from '@iarna/toml'; -import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '../projects.js'; +import { getCodexSessions, deleteCodexSession } from '../projects.js'; import { applyCustomSessionNames, sessionNamesDb } from '../database/db.js'; const router = express.Router(); @@ -68,24 +68,6 @@ router.get('/sessions', async (req, res) => { } }); -router.get('/sessions/:sessionId/messages', async (req, res) => { - try { - const { sessionId } = req.params; - const { limit, offset } = req.query; - - const result = await getCodexSessionMessages( - sessionId, - limit ? parseInt(limit, 10) : null, - offset ? parseInt(offset, 10) : 0 - ); - - res.json({ success: true, ...result }); - } catch (error) { - console.error('Error fetching Codex session messages:', error); - res.status(500).json({ success: false, error: error.message }); - } -}); - router.delete('/sessions/:sessionId', async (req, res) => { try { const { sessionId } = req.params; diff --git a/server/routes/gemini.js b/server/routes/gemini.js index 594a468..ff7f366 100644 --- a/server/routes/gemini.js +++ b/server/routes/gemini.js @@ -1,39 +1,9 @@ import express from 'express'; import sessionManager from '../sessionManager.js'; import { sessionNamesDb } from '../database/db.js'; -import { getGeminiCliSessionMessages } from '../projects.js'; const router = express.Router(); -router.get('/sessions/:sessionId/messages', async (req, res) => { - try { - const { sessionId } = req.params; - - if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) { - return res.status(400).json({ success: false, error: 'Invalid session ID format' }); - } - - let messages = sessionManager.getSessionMessages(sessionId); - - // Fallback to Gemini CLI sessions on disk - if (messages.length === 0) { - messages = await getGeminiCliSessionMessages(sessionId); - } - - res.json({ - success: true, - messages: messages, - total: messages.length, - hasMore: false, - offset: 0, - limit: messages.length - }); - } catch (error) { - console.error('Error fetching Gemini session messages:', error); - res.status(500).json({ success: false, error: error.message }); - } -}); - router.delete('/sessions/:sessionId', async (req, res) => { try { const { sessionId } = req.params; diff --git a/server/routes/messages.js b/server/routes/messages.js new file mode 100644 index 0000000..8eb14b3 --- /dev/null +++ b/server/routes/messages.js @@ -0,0 +1,61 @@ +/** + * Unified messages endpoint. + * + * GET /api/sessions/:sessionId/messages?provider=claude&projectName=foo&limit=50&offset=0 + * + * Replaces the four provider-specific session message endpoints with a single route + * that delegates to the appropriate adapter via the provider registry. + * + * @module routes/messages + */ + +import express from 'express'; +import { getProvider, getAllProviders } from '../providers/registry.js'; + +const router = express.Router(); + +/** + * GET /api/sessions/:sessionId/messages + * + * Auth: authenticateToken applied at mount level in index.js + * + * Query params: + * provider - 'claude' | 'cursor' | 'codex' | 'gemini' (default: 'claude') + * projectName - required for claude provider + * projectPath - required for cursor provider (absolute path used for cwdId hash) + * limit - page size (omit or null for all) + * offset - pagination offset (default: 0) + */ +router.get('/:sessionId/messages', async (req, res) => { + try { + const { sessionId } = req.params; + const provider = req.query.provider || 'claude'; + const projectName = req.query.projectName || ''; + const projectPath = req.query.projectPath || ''; + const limitParam = req.query.limit; + const limit = limitParam !== undefined && limitParam !== null && limitParam !== '' + ? parseInt(limitParam, 10) + : null; + const offset = parseInt(req.query.offset || '0', 10); + + const adapter = getProvider(provider); + if (!adapter) { + const available = getAllProviders().join(', '); + return res.status(400).json({ error: `Unknown provider: ${provider}. Available: ${available}` }); + } + + const result = await adapter.fetchHistory(sessionId, { + projectName, + projectPath, + limit, + offset, + }); + + return res.json(result); + } catch (error) { + console.error('Error fetching unified messages:', error); + return res.status(500).json({ error: 'Failed to fetch messages' }); + } +}); + +export default router; diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 6aab9ee..6e84982 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -52,8 +52,9 @@ interface UseChatComposerStateArgs { onShowSettings?: () => void; pendingViewSessionRef: { current: PendingViewSession | null }; scrollToBottom: () => void; - setChatMessages: Dispatch>; - setSessionMessages?: Dispatch>; + addMessage: (msg: ChatMessage) => void; + clearMessages: () => void; + rewindMessages: (count: number) => void; setIsLoading: (loading: boolean) => void; setCanAbortSession: (canAbort: boolean) => void; setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void; @@ -123,8 +124,9 @@ export function useChatComposerState({ onShowSettings, pendingViewSessionRef, scrollToBottom, - setChatMessages, - setSessionMessages, + addMessage, + clearMessages, + rewindMessages, setIsLoading, setCanAbortSession, setClaudeStatus, @@ -155,69 +157,50 @@ export function useChatComposerState({ const { action, data } = result; switch (action) { case 'clear': - setChatMessages([]); - setSessionMessages?.([]); + clearMessages(); break; case 'help': - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: data.content, - timestamp: Date.now(), - }, - ]); + addMessage({ + type: 'assistant', + content: data.content, + timestamp: Date.now(), + }); break; case 'model': - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`, - timestamp: Date.now(), - }, - ]); + addMessage({ + type: 'assistant', + content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`, + timestamp: Date.now(), + }); break; case 'cost': { const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`; - setChatMessages((previous) => [ - ...previous, - { type: 'assistant', content: costMessage, timestamp: Date.now() }, - ]); + addMessage({ type: 'assistant', content: costMessage, timestamp: Date.now() }); break; } case 'status': { const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`; - setChatMessages((previous) => [ - ...previous, - { type: 'assistant', content: statusMessage, timestamp: Date.now() }, - ]); + addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() }); break; } case 'memory': if (data.error) { - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: `⚠️ ${data.message}`, - timestamp: Date.now(), - }, - ]); + addMessage({ + type: 'assistant', + content: `Warning: ${data.message}`, + timestamp: Date.now(), + }); } else { - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: `📝 ${data.message}\n\nPath: \`${data.path}\``, - timestamp: Date.now(), - }, - ]); + addMessage({ + type: 'assistant', + content: `${data.message}\n\nPath: \`${data.path}\``, + timestamp: Date.now(), + }); if (data.exists && onFileOpen) { onFileOpen(data.path); } @@ -230,24 +213,18 @@ export function useChatComposerState({ case 'rewind': if (data.error) { - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: `⚠️ ${data.message}`, - timestamp: Date.now(), - }, - ]); + addMessage({ + type: 'assistant', + content: `Warning: ${data.message}`, + timestamp: Date.now(), + }); } else { - setChatMessages((previous) => previous.slice(0, -data.steps * 2)); - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: `⏪ ${data.message}`, - timestamp: Date.now(), - }, - ]); + rewindMessages(data.steps * 2); + addMessage({ + type: 'assistant', + content: `Rewound ${data.steps} step(s). ${data.message}`, + timestamp: Date.now(), + }); } break; @@ -255,7 +232,7 @@ export function useChatComposerState({ console.warn('Unknown built-in command action:', action); } }, - [onFileOpen, onShowSettings, setChatMessages, setSessionMessages], + [onFileOpen, onShowSettings, addMessage, clearMessages, rewindMessages], ); const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => { @@ -266,14 +243,11 @@ export function useChatComposerState({ 'This command contains bash commands that will be executed. Do you want to proceed?', ); if (!confirmed) { - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: '❌ Command execution cancelled', - timestamp: Date.now(), - }, - ]); + addMessage({ + type: 'assistant', + content: 'Command execution cancelled', + timestamp: Date.now(), + }); return; } } @@ -288,7 +262,7 @@ export function useChatComposerState({ handleSubmitRef.current(createFakeSubmitEvent()); } }, 0); - }, [setChatMessages]); + }, [addMessage]); const executeCommand = useCallback( async (command: SlashCommand, rawInput?: string) => { @@ -346,14 +320,11 @@ export function useChatComposerState({ } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Error executing command:', error); - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: `Error executing command: ${message}`, - timestamp: Date.now(), - }, - ]); + addMessage({ + type: 'assistant', + content: `Error executing command: ${message}`, + timestamp: Date.now(), + }); } }, [ @@ -367,7 +338,7 @@ export function useChatComposerState({ input, provider, selectedProject, - setChatMessages, + addMessage, tokenBudget, ], ); @@ -547,18 +518,19 @@ export function useChatComposerState({ } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Image upload failed:', error); - setChatMessages((previous) => [ - ...previous, - { - type: 'error', - content: `Failed to upload images: ${message}`, - timestamp: new Date(), - }, - ]); + addMessage({ + type: 'error', + content: `Failed to upload images: ${message}`, + timestamp: new Date(), + }); return; } } + const effectiveSessionId = + currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId'); + const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`; + const userMessage: ChatMessage = { type: 'user', content: currentInput, @@ -566,7 +538,7 @@ export function useChatComposerState({ timestamp: new Date(), }; - setChatMessages((previous) => [...previous, userMessage]); + addMessage(userMessage); setIsLoading(true); // Processing banner starts setCanAbortSession(true); setClaudeStatus({ @@ -578,10 +550,6 @@ export function useChatComposerState({ setIsUserScrolledUp(false); setTimeout(() => scrollToBottom(), 100); - const effectiveSessionId = - currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId'); - const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`; - if (!effectiveSessionId && !selectedSession?.id) { if (typeof window !== 'undefined') { // Reset stale pending IDs from previous interrupted runs before creating a new one. @@ -723,7 +691,7 @@ export function useChatComposerState({ selectedProject, sendMessage, setCanAbortSession, - setChatMessages, + addMessage, setClaudeStatus, setIsLoading, setIsUserScrolledUp, diff --git a/src/components/chat/hooks/useChatMessages.ts b/src/components/chat/hooks/useChatMessages.ts new file mode 100644 index 0000000..039b406 --- /dev/null +++ b/src/components/chat/hooks/useChatMessages.ts @@ -0,0 +1,183 @@ +/** + * Message normalization utilities. + * Converts NormalizedMessage[] from the session store into ChatMessage[] for the UI. + */ + +import type { NormalizedMessage } from '../../../stores/useSessionStore'; +import type { ChatMessage, SubagentChildTool } from '../types/types'; +import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting'; + +/** + * Convert NormalizedMessage[] from the session store into ChatMessage[] + * that the existing UI components expect. + * + * Internal/system content (e.g. , ) is already + * filtered server-side by the Claude adapter (server/providers/utils.js). + */ +export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMessage[] { + const converted: ChatMessage[] = []; + + // First pass: collect tool results for attachment + const toolResultMap = new Map(); + for (const msg of messages) { + if (msg.kind === 'tool_result' && msg.toolId) { + toolResultMap.set(msg.toolId, msg); + } + } + + for (const msg of messages) { + switch (msg.kind) { + case 'text': { + const content = msg.content || ''; + if (!content.trim()) continue; + + if (msg.role === 'user') { + // Parse task notifications + const taskNotifRegex = /\s*[^<]*<\/task-id>\s*[^<]*<\/output-file>\s*([^<]*)<\/status>\s*([^<]*)<\/summary>\s*<\/task-notification>/g; + const taskNotifMatch = taskNotifRegex.exec(content); + if (taskNotifMatch) { + converted.push({ + type: 'assistant', + content: taskNotifMatch[2]?.trim() || 'Background task finished', + timestamp: msg.timestamp, + isTaskNotification: true, + taskStatus: taskNotifMatch[1]?.trim() || 'completed', + }); + } else { + converted.push({ + type: 'user', + content: unescapeWithMathProtection(decodeHtmlEntities(content)), + timestamp: msg.timestamp, + }); + } + } else { + let text = decodeHtmlEntities(content); + text = unescapeWithMathProtection(text); + text = formatUsageLimitText(text); + converted.push({ + type: 'assistant', + content: text, + timestamp: msg.timestamp, + }); + } + break; + } + + case 'tool_use': { + const tr = msg.toolResult || (msg.toolId ? toolResultMap.get(msg.toolId) : null); + const isSubagentContainer = msg.toolName === 'Task'; + + // Build child tools from subagentTools + const childTools: SubagentChildTool[] = []; + if (isSubagentContainer && msg.subagentTools && Array.isArray(msg.subagentTools)) { + for (const tool of msg.subagentTools as any[]) { + childTools.push({ + toolId: tool.toolId, + toolName: tool.toolName, + toolInput: tool.toolInput, + toolResult: tool.toolResult || null, + timestamp: new Date(tool.timestamp || Date.now()), + }); + } + } + + const toolResult = tr + ? { + content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content), + isError: Boolean(tr.isError), + toolUseResult: (tr as any).toolUseResult, + } + : null; + + converted.push({ + type: 'assistant', + content: '', + timestamp: msg.timestamp, + isToolUse: true, + toolName: msg.toolName, + toolInput: typeof msg.toolInput === 'string' ? msg.toolInput : JSON.stringify(msg.toolInput ?? '', null, 2), + toolId: msg.toolId, + toolResult, + isSubagentContainer, + subagentState: isSubagentContainer + ? { + childTools, + currentToolIndex: childTools.length > 0 ? childTools.length - 1 : -1, + isComplete: Boolean(toolResult), + } + : undefined, + }); + break; + } + + case 'thinking': + if (msg.content?.trim()) { + converted.push({ + type: 'assistant', + content: unescapeWithMathProtection(msg.content), + timestamp: msg.timestamp, + isThinking: true, + }); + } + break; + + case 'error': + converted.push({ + type: 'error', + content: msg.content || 'Unknown error', + timestamp: msg.timestamp, + }); + break; + + case 'interactive_prompt': + converted.push({ + type: 'assistant', + content: msg.content || '', + timestamp: msg.timestamp, + isInteractivePrompt: true, + }); + break; + + case 'task_notification': + converted.push({ + type: 'assistant', + content: msg.summary || 'Background task update', + timestamp: msg.timestamp, + isTaskNotification: true, + taskStatus: msg.status || 'completed', + }); + break; + + case 'stream_delta': + if (msg.content) { + converted.push({ + type: 'assistant', + content: msg.content, + timestamp: msg.timestamp, + isStreaming: true, + }); + } + break; + + // stream_end, complete, status, permission_*, session_created + // are control events — not rendered as messages + case 'stream_end': + case 'complete': + case 'status': + case 'permission_request': + case 'permission_cancelled': + case 'session_created': + // Skip — these are handled by useChatRealtimeHandlers + break; + + // tool_result is handled via attachment to tool_use above + case 'tool_result': + break; + + default: + break; + } + } + + return converted; +} diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts index ff957af..6d73473 100644 --- a/src/components/chat/hooks/useChatRealtimeHandlers.ts +++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts @@ -1,9 +1,8 @@ import { useEffect, useRef } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; -import { decodeHtmlEntities, formatUsageLimitText } from '../utils/chatFormatting'; -import { safeLocalStorage } from '../utils/chatStorage'; -import type { ChatMessage, PendingPermissionRequest } from '../types/types'; +import type { PendingPermissionRequest } from '../types/types'; import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; +import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; type PendingViewSession = { sessionId: string | null; @@ -12,17 +11,38 @@ type PendingViewSession = { type LatestChatMessage = { type?: string; + kind?: string; data?: any; + message?: any; + delta?: string; sessionId?: string; + session_id?: string; requestId?: string; toolName?: string; input?: unknown; context?: unknown; error?: string; - tool?: string; + tool?: any; + toolId?: string; + result?: any; exitCode?: number; isProcessing?: boolean; actualSessionId?: string; + event?: string; + status?: any; + isNewSession?: boolean; + resultText?: string; + isError?: boolean; + success?: boolean; + reason?: string; + provider?: string; + content?: string; + text?: string; + tokens?: number; + canInterrupt?: boolean; + tokenBudget?: unknown; + newSessionId?: string; + aborted?: boolean; [key: string]: any; }; @@ -33,64 +53,27 @@ interface UseChatRealtimeHandlersArgs { selectedSession: ProjectSession | null; currentSessionId: string | null; setCurrentSessionId: (sessionId: string | null) => void; - setChatMessages: Dispatch>; setIsLoading: (loading: boolean) => void; setCanAbortSession: (canAbort: boolean) => void; setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void; setTokenBudget: (budget: Record | null) => void; - setIsSystemSessionChange: (isSystemSessionChange: boolean) => void; setPendingPermissionRequests: Dispatch>; pendingViewSessionRef: MutableRefObject; streamBufferRef: MutableRefObject; streamTimerRef: MutableRefObject; + accumulatedStreamRef: MutableRefObject; onSessionInactive?: (sessionId?: string | null) => void; onSessionProcessing?: (sessionId?: string | null) => void; onSessionNotProcessing?: (sessionId?: string | null) => void; onReplaceTemporarySession?: (sessionId?: string | null) => void; onNavigateToSession?: (sessionId: string) => void; onWebSocketReconnect?: () => void; + sessionStore: SessionStore; } -const appendStreamingChunk = ( - setChatMessages: Dispatch>, - chunk: string, - newline = false, -) => { - if (!chunk) { - return; - } - - setChatMessages((previous) => { - const updated = [...previous]; - const lastIndex = updated.length - 1; - const last = updated[lastIndex]; - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - const nextContent = newline - ? last.content - ? `${last.content}\n${chunk}` - : chunk - : `${last.content || ''}${chunk}`; - // Clone the message instead of mutating in place so React can reliably detect state updates. - updated[lastIndex] = { ...last, content: nextContent }; - } else { - updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true }); - } - return updated; - }); -}; - -const finalizeStreamingMessage = (setChatMessages: Dispatch>) => { - setChatMessages((previous) => { - const updated = [...previous]; - const lastIndex = updated.length - 1; - const last = updated[lastIndex]; - if (last && last.type === 'assistant' && last.isStreaming) { - // Clone the message instead of mutating in place so React can reliably detect state updates. - updated[lastIndex] = { ...last, isStreaming: false }; - } - return updated; - }); -}; +/* ------------------------------------------------------------------ */ +/* Hook */ +/* ------------------------------------------------------------------ */ export function useChatRealtimeHandlers({ latestMessage, @@ -99,643 +82,203 @@ export function useChatRealtimeHandlers({ selectedSession, currentSessionId, setCurrentSessionId, - setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setTokenBudget, - setIsSystemSessionChange, setPendingPermissionRequests, pendingViewSessionRef, streamBufferRef, streamTimerRef, + accumulatedStreamRef, onSessionInactive, onSessionProcessing, onSessionNotProcessing, onReplaceTemporarySession, onNavigateToSession, onWebSocketReconnect, + sessionStore, }: UseChatRealtimeHandlersArgs) { const lastProcessedMessageRef = useRef(null); useEffect(() => { - if (!latestMessage) { - return; - } - - // Guard against duplicate processing when dependency updates occur without a new message object. - if (lastProcessedMessageRef.current === latestMessage) { - return; - } + if (!latestMessage) return; + if (lastProcessedMessageRef.current === latestMessage) return; lastProcessedMessageRef.current = latestMessage; - const messageData = latestMessage.data?.message || latestMessage.data; - const structuredMessageData = - messageData && typeof messageData === 'object' ? (messageData as Record) : null; - const rawStructuredData = - latestMessage.data && typeof latestMessage.data === 'object' - ? (latestMessage.data as Record) - : null; - const messageType = String(latestMessage.type); - - const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'websocket-reconnected']; - const isGlobalMessage = globalMessageTypes.includes(messageType); - const lifecycleMessageTypes = new Set([ - 'claude-complete', - 'codex-complete', - 'cursor-result', - 'session-aborted', - 'claude-error', - 'cursor-error', - 'codex-error', - 'gemini-error', - 'error', - ]); - - const isClaudeSystemInit = - latestMessage.type === 'claude-response' && - structuredMessageData && - structuredMessageData.type === 'system' && - structuredMessageData.subtype === 'init'; - - const isCursorSystemInit = - latestMessage.type === 'cursor-system' && - rawStructuredData && - rawStructuredData.type === 'system' && - rawStructuredData.subtype === 'init'; - - const systemInitSessionId = isClaudeSystemInit - ? structuredMessageData?.session_id - : isCursorSystemInit - ? rawStructuredData?.session_id - : null; - const activeViewSessionId = selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null; - const hasPendingUnboundSession = - Boolean(pendingViewSessionRef.current) && !pendingViewSessionRef.current?.sessionId; - const isSystemInitForView = - systemInitSessionId && (!activeViewSessionId || systemInitSessionId === activeViewSessionId); - const shouldBypassSessionFilter = isGlobalMessage || Boolean(isSystemInitForView); - const isLifecycleMessage = lifecycleMessageTypes.has(messageType); - const isUnscopedError = - !latestMessage.sessionId && - pendingViewSessionRef.current && - !pendingViewSessionRef.current.sessionId && - (latestMessage.type === 'claude-error' || - latestMessage.type === 'cursor-error' || - latestMessage.type === 'codex-error' || - latestMessage.type === 'gemini-error'); - const handleBackgroundLifecycle = (sessionId?: string) => { - if (!sessionId) { - return; + /* ---------------------------------------------------------------- */ + /* Legacy messages (no `kind` field) — handle and return */ + /* ---------------------------------------------------------------- */ + + const msg = latestMessage as any; + + if (!msg.kind) { + const messageType = String(msg.type || ''); + + switch (messageType) { + case 'websocket-reconnected': + onWebSocketReconnect?.(); + return; + + case 'pending-permissions-response': { + const permSessionId = msg.sessionId; + const isCurrentPermSession = + permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id); + if (permSessionId && !isCurrentPermSession) return; + setPendingPermissionRequests(msg.data || []); + return; + } + + case 'session-status': { + const statusSessionId = msg.sessionId; + if (!statusSessionId) return; + + const status = msg.status; + if (status) { + const statusInfo = { + text: status.text || 'Working...', + tokens: status.tokens || 0, + can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true, + }; + setClaudeStatus(statusInfo); + setIsLoading(true); + setCanAbortSession(statusInfo.can_interrupt); + return; + } + + // Legacy isProcessing format from check-session-status + const isCurrentSession = + statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id); + + if (msg.isProcessing) { + onSessionProcessing?.(statusSessionId); + if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); } + return; + } + onSessionInactive?.(statusSessionId); + onSessionNotProcessing?.(statusSessionId); + if (isCurrentSession) { + setIsLoading(false); + setCanAbortSession(false); + setClaudeStatus(null); + } + return; + } + + default: + // Unknown legacy message type — ignore + return; } - onSessionInactive?.(sessionId); - onSessionNotProcessing?.(sessionId); - }; + } - const collectSessionIds = (...sessionIds: Array) => - Array.from( - new Set( - sessionIds.filter((sessionId): sessionId is string => typeof sessionId === 'string' && sessionId.length > 0), - ), - ); + /* ---------------------------------------------------------------- */ + /* NormalizedMessage handling (has `kind` field) */ + /* ---------------------------------------------------------------- */ - const clearLoadingIndicators = () => { - setIsLoading(false); - setCanAbortSession(false); - setClaudeStatus(null); - }; + const sid = msg.sessionId || activeViewSessionId; - const clearPendingViewSession = (resolvedSessionId?: string | null) => { - const pendingSession = pendingViewSessionRef.current; - if (!pendingSession) { - return; + // --- Streaming: buffer for performance --- + if (msg.kind === 'stream_delta') { + const text = msg.content || ''; + if (!text) return; + streamBufferRef.current += text; + accumulatedStreamRef.current += text; + if (!streamTimerRef.current) { + streamTimerRef.current = window.setTimeout(() => { + streamTimerRef.current = null; + if (sid) { + sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); + } + }, 100); } - - // If the in-view request never received a concrete session ID (or this terminal event - // resolves the same pending session), clear it to avoid stale "in-flight" UI state. - if (!pendingSession.sessionId || !resolvedSessionId || pendingSession.sessionId === resolvedSessionId) { - pendingViewSessionRef.current = null; + // Also route to store for non-active sessions + if (sid && sid !== activeViewSessionId) { + sessionStore.appendRealtime(sid, msg as NormalizedMessage); } - }; + return; + } - const flushStreamingState = () => { + if (msg.kind === 'stream_end') { if (streamTimerRef.current) { clearTimeout(streamTimerRef.current); streamTimerRef.current = null; } - const pendingChunk = streamBufferRef.current; + if (sid) { + if (accumulatedStreamRef.current) { + sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); + } + sessionStore.finalizeStreaming(sid); + } + accumulatedStreamRef.current = ''; streamBufferRef.current = ''; - appendStreamingChunk(setChatMessages, pendingChunk, false); - finalizeStreamingMessage(setChatMessages); - }; - - const markSessionsAsCompleted = (...sessionIds: Array) => { - const normalizedSessionIds = collectSessionIds(...sessionIds); - normalizedSessionIds.forEach((sessionId) => { - onSessionInactive?.(sessionId); - onSessionNotProcessing?.(sessionId); - }); - }; - - const finalizeLifecycleForCurrentView = (...sessionIds: Array) => { - const pendingSessionId = typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; - const resolvedSessionIds = collectSessionIds(...sessionIds, pendingSessionId, pendingViewSessionRef.current?.sessionId); - const resolvedPrimarySessionId = resolvedSessionIds[0] || null; - - flushStreamingState(); - clearLoadingIndicators(); - markSessionsAsCompleted(...resolvedSessionIds); - setPendingPermissionRequests([]); - clearPendingViewSession(resolvedPrimarySessionId); - }; - - if (!shouldBypassSessionFilter) { - if (!activeViewSessionId) { - if (latestMessage.sessionId && isLifecycleMessage && !hasPendingUnboundSession) { - handleBackgroundLifecycle(latestMessage.sessionId); - return; - } - if (!isUnscopedError && !hasPendingUnboundSession) { - return; - } - } - - if (!latestMessage.sessionId && !isUnscopedError && !hasPendingUnboundSession) { - return; - } - - if (latestMessage.sessionId !== activeViewSessionId) { - const shouldTreatAsPendingViewLifecycle = - !activeViewSessionId && - hasPendingUnboundSession && - latestMessage.sessionId && - isLifecycleMessage; - - if (!shouldTreatAsPendingViewLifecycle) { - if (latestMessage.sessionId && isLifecycleMessage) { - handleBackgroundLifecycle(latestMessage.sessionId); - } - return; - } - } + return; } - switch (latestMessage.type) { - case 'session-created': - if (latestMessage.sessionId && !currentSessionId) { - sessionStorage.setItem('pendingSessionId', latestMessage.sessionId); + // --- All other messages: route to store --- + if (sid) { + sessionStore.appendRealtime(sid, msg as NormalizedMessage); + } + + // --- UI side effects for specific kinds --- + switch (msg.kind) { + case 'session_created': { + const newSessionId = msg.newSessionId; + if (!newSessionId) break; + + if (!currentSessionId || currentSessionId.startsWith('new-session-')) { + sessionStorage.setItem('pendingSessionId', newSessionId); if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) { - pendingViewSessionRef.current.sessionId = latestMessage.sessionId; + pendingViewSessionRef.current.sessionId = newSessionId; } - - setIsSystemSessionChange(true); - onReplaceTemporarySession?.(latestMessage.sessionId); - - setPendingPermissionRequests((previous) => - previous.map((request) => - request.sessionId ? request : { ...request, sessionId: latestMessage.sessionId }, - ), + setCurrentSessionId(newSessionId); + onReplaceTemporarySession?.(newSessionId); + setPendingPermissionRequests((prev) => + prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })), ); } - break; - - case 'websocket-reconnected': - // WebSocket dropped and reconnected — re-fetch session history to catch up on missed messages - onWebSocketReconnect?.(); - break; - - case 'token-budget': - if (latestMessage.data) { - setTokenBudget(latestMessage.data); - } - break; - - case 'claude-response': { - if (messageData && typeof messageData === 'object' && messageData.type) { - if (messageData.type === 'content_block_delta' && messageData.delta?.text) { - const decodedText = decodeHtmlEntities(messageData.delta.text); - streamBufferRef.current += decodedText; - if (!streamTimerRef.current) { - streamTimerRef.current = window.setTimeout(() => { - const chunk = streamBufferRef.current; - streamBufferRef.current = ''; - streamTimerRef.current = null; - appendStreamingChunk(setChatMessages, chunk, false); - }, 100); - } - return; - } - - if (messageData.type === 'content_block_stop') { - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current); - streamTimerRef.current = null; - } - const chunk = streamBufferRef.current; - streamBufferRef.current = ''; - appendStreamingChunk(setChatMessages, chunk, false); - finalizeStreamingMessage(setChatMessages); - return; - } - } - - if ( - structuredMessageData?.type === 'system' && - structuredMessageData.subtype === 'init' && - structuredMessageData.session_id && - currentSessionId && - structuredMessageData.session_id !== currentSessionId && - isSystemInitForView - ) { - setIsSystemSessionChange(true); - onNavigateToSession?.(structuredMessageData.session_id); - return; - } - - if ( - structuredMessageData?.type === 'system' && - structuredMessageData.subtype === 'init' && - structuredMessageData.session_id && - !currentSessionId && - isSystemInitForView - ) { - setIsSystemSessionChange(true); - onNavigateToSession?.(structuredMessageData.session_id); - return; - } - - if ( - structuredMessageData?.type === 'system' && - structuredMessageData.subtype === 'init' && - structuredMessageData.session_id && - currentSessionId && - structuredMessageData.session_id === currentSessionId && - isSystemInitForView - ) { - return; - } - - if (structuredMessageData && Array.isArray(structuredMessageData.content)) { - const parentToolUseId = rawStructuredData?.parentToolUseId; - - structuredMessageData.content.forEach((part: any) => { - if (part.type === 'tool_use') { - const toolInput = part.input ? JSON.stringify(part.input, null, 2) : ''; - - // Check if this is a child tool from a subagent - if (parentToolUseId) { - setChatMessages((previous) => - previous.map((message) => { - if (message.toolId === parentToolUseId && message.isSubagentContainer) { - const childTool = { - toolId: part.id, - toolName: part.name, - toolInput: part.input, - toolResult: null, - timestamp: new Date(), - }; - const existingChildren = message.subagentState?.childTools || []; - return { - ...message, - subagentState: { - childTools: [...existingChildren, childTool], - currentToolIndex: existingChildren.length, - isComplete: false, - }, - }; - } - return message; - }), - ); - return; - } - - // Check if this is a Task tool (subagent container) - const isSubagentContainer = part.name === 'Task'; - - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: '', - timestamp: new Date(), - isToolUse: true, - toolName: part.name, - toolInput, - toolId: part.id, - toolResult: null, - isSubagentContainer, - subagentState: isSubagentContainer - ? { childTools: [], currentToolIndex: -1, isComplete: false } - : undefined, - }, - ]); - return; - } - - if (part.type === 'text' && part.text?.trim()) { - let content = decodeHtmlEntities(part.text); - content = formatUsageLimitText(content); - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content, - timestamp: new Date(), - }, - ]); - } - }); - } else if (structuredMessageData && typeof structuredMessageData.content === 'string' && structuredMessageData.content.trim()) { - let content = decodeHtmlEntities(structuredMessageData.content); - content = formatUsageLimitText(content); - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content, - timestamp: new Date(), - }, - ]); - } - - if (structuredMessageData?.role === 'user' && Array.isArray(structuredMessageData.content)) { - const parentToolUseId = rawStructuredData?.parentToolUseId; - - structuredMessageData.content.forEach((part: any) => { - if (part.type !== 'tool_result') { - return; - } - - setChatMessages((previous) => - previous.map((message) => { - // Handle child tool results (route to parent's subagentState) - if (parentToolUseId && message.toolId === parentToolUseId && message.isSubagentContainer) { - return { - ...message, - subagentState: { - ...message.subagentState!, - childTools: message.subagentState!.childTools.map((child) => { - if (child.toolId === part.tool_use_id) { - return { - ...child, - toolResult: { - content: part.content, - isError: part.is_error, - timestamp: new Date(), - }, - }; - } - return child; - }), - }, - }; - } - - // Handle normal tool results (including parent Task tool completion) - if (message.isToolUse && message.toolId === part.tool_use_id) { - const result = { - ...message, - toolResult: { - content: part.content, - isError: part.is_error, - timestamp: new Date(), - }, - }; - // Mark subagent as complete when parent Task receives its result - if (message.isSubagentContainer && message.subagentState) { - result.subagentState = { - ...message.subagentState, - isComplete: true, - }; - } - return result; - } - return message; - }), - ); - }); - } + onNavigateToSession?.(newSessionId); break; } - case 'claude-output': { - const cleaned = String(latestMessage.data || ''); - if (cleaned.trim()) { - streamBufferRef.current += streamBufferRef.current ? `\n${cleaned}` : cleaned; - if (!streamTimerRef.current) { - streamTimerRef.current = window.setTimeout(() => { - const chunk = streamBufferRef.current; - streamBufferRef.current = ''; - streamTimerRef.current = null; - appendStreamingChunk(setChatMessages, chunk, true); - }, 100); - } + case 'complete': { + // Flush any remaining streaming state + if (streamTimerRef.current) { + clearTimeout(streamTimerRef.current); + streamTimerRef.current = null; } - break; - } - - case 'claude-interactive-prompt': - // Interactive prompts are parsed/rendered as text in the UI. - // Normalize to string to keep ChatMessage.content shape consistent. - { - const interactiveContent = - typeof latestMessage.data === 'string' - ? latestMessage.data - : JSON.stringify(latestMessage.data ?? '', null, 2); - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: interactiveContent, - timestamp: new Date(), - isInteractivePrompt: true, - }, - ]); + if (sid && accumulatedStreamRef.current) { + sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider); + sessionStore.finalizeStreaming(sid); } - break; + accumulatedStreamRef.current = ''; + streamBufferRef.current = ''; - case 'claude-permission-request': - if (provider !== 'claude' || !latestMessage.requestId) { + setIsLoading(false); + setCanAbortSession(false); + setClaudeStatus(null); + setPendingPermissionRequests([]); + onSessionInactive?.(sid); + onSessionNotProcessing?.(sid); + + // Handle aborted case + if (msg.aborted) { + // Abort was requested — the complete event confirms it + // No special UI action needed beyond clearing loading state above + // The backend already sent any abort-related messages break; } - { - const requestId = latestMessage.requestId; - setPendingPermissionRequests((previous) => { - if (previous.some((request) => request.requestId === requestId)) { - return previous; - } - return [ - ...previous, - { - requestId, - toolName: latestMessage.toolName || 'UnknownTool', - input: latestMessage.input, - context: latestMessage.context, - sessionId: latestMessage.sessionId || null, - receivedAt: new Date(), - }, - ]; - }); - } - - setIsLoading(true); - setCanAbortSession(true); - setClaudeStatus({ - text: 'Waiting for permission', - tokens: 0, - can_interrupt: true, - }); - break; - - case 'claude-permission-cancelled': - if (!latestMessage.requestId) { - break; - } - setPendingPermissionRequests((previous) => - previous.filter((request) => request.requestId !== latestMessage.requestId), - ); - break; - - case 'claude-error': - finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id); - setChatMessages((previous) => [ - ...previous, - { - type: 'error', - content: `Error: ${latestMessage.error}`, - timestamp: new Date(), - }, - ]); - break; - - case 'cursor-system': - try { - const cursorData = latestMessage.data; - if ( - cursorData && - cursorData.type === 'system' && - cursorData.subtype === 'init' && - cursorData.session_id - ) { - if (!isSystemInitForView) { - return; - } - - if (currentSessionId && cursorData.session_id !== currentSessionId) { - setIsSystemSessionChange(true); - onNavigateToSession?.(cursorData.session_id); - return; - } - - if (!currentSessionId) { - setIsSystemSessionChange(true); - onNavigateToSession?.(cursorData.session_id); - return; - } + // Clear pending session + const pendingSessionId = sessionStorage.getItem('pendingSessionId'); + if (pendingSessionId && !currentSessionId && msg.exitCode === 0) { + const actualId = msg.actualSessionId || pendingSessionId; + setCurrentSessionId(actualId); + if (msg.actualSessionId) { + onNavigateToSession?.(actualId); } - } catch (error) { - console.warn('Error handling cursor-system message:', error); - } - break; - - case 'cursor-user': - break; - - case 'cursor-tool-use': - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : '' - }`, - timestamp: new Date(), - isToolUse: true, - toolName: latestMessage.tool, - toolInput: latestMessage.input, - }, - ]); - break; - - case 'cursor-error': - finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id); - setChatMessages((previous) => [ - ...previous, - { - type: 'error', - content: `Cursor error: ${latestMessage.error || 'Unknown error'}`, - timestamp: new Date(), - }, - ]); - break; - - case 'cursor-result': { - const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId; - const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId'); - - finalizeLifecycleForCurrentView( - cursorCompletedSessionId, - currentSessionId, - selectedSession?.id, - pendingCursorSessionId, - ); - - try { - const resultData = latestMessage.data || {}; - const textResult = typeof resultData.result === 'string' ? resultData.result : ''; - - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current); - streamTimerRef.current = null; - } - const pendingChunk = streamBufferRef.current; - streamBufferRef.current = ''; - - setChatMessages((previous) => { - const updated = [...previous]; - const lastIndex = updated.length - 1; - const last = updated[lastIndex]; - const normalizedTextResult = textResult.trim(); - - if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) { - const finalContent = - normalizedTextResult - ? textResult - : `${last.content || ''}${pendingChunk || ''}`; - // Clone the message instead of mutating in place so React can reliably detect state updates. - updated[lastIndex] = { ...last, content: finalContent, isStreaming: false }; - } else if (normalizedTextResult) { - const lastAssistantText = - last && last.type === 'assistant' && !last.isToolUse - ? String(last.content || '').trim() - : ''; - - // Cursor can emit the same final text through both streaming and result payloads. - // Skip adding a second assistant bubble when the final text is unchanged. - const isDuplicateFinalText = lastAssistantText === normalizedTextResult; - if (isDuplicateFinalText) { - return updated; - } - - updated.push({ - type: resultData.is_error ? 'error' : 'assistant', - content: textResult, - timestamp: new Date(), - isStreaming: false, - }); - } - return updated; - }); - } catch (error) { - console.warn('Error handling cursor-result message:', error); - } - - if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) { - setCurrentSessionId(cursorCompletedSessionId); sessionStorage.removeItem('pendingSessionId'); if (window.refreshProjects) { setTimeout(() => window.refreshProjects?.(), 500); @@ -744,424 +287,58 @@ export function useChatRealtimeHandlers({ break; } - case 'cursor-output': - try { - const raw = String(latestMessage.data ?? ''); - const cleaned = raw - .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '') - .trim(); - - if (cleaned) { - streamBufferRef.current += streamBufferRef.current ? `\n${cleaned}` : cleaned; - if (!streamTimerRef.current) { - streamTimerRef.current = window.setTimeout(() => { - const chunk = streamBufferRef.current; - streamBufferRef.current = ''; - streamTimerRef.current = null; - appendStreamingChunk(setChatMessages, chunk, true); - }, 100); - } - } - } catch (error) { - console.warn('Error handling cursor-output message:', error); - } - break; - - case 'claude-complete': { - const pendingSessionId = sessionStorage.getItem('pendingSessionId'); - const completedSessionId = - latestMessage.sessionId || currentSessionId || pendingSessionId; - - finalizeLifecycleForCurrentView( - completedSessionId, - currentSessionId, - selectedSession?.id, - pendingSessionId, - ); - - if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) { - setCurrentSessionId(pendingSessionId); - sessionStorage.removeItem('pendingSessionId'); - console.log('New session complete, ID set to:', pendingSessionId); - } - - if (selectedProject && latestMessage.exitCode === 0) { - safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); - } + case 'error': { + setIsLoading(false); + setCanAbortSession(false); + setClaudeStatus(null); + onSessionInactive?.(sid); + onSessionNotProcessing?.(sid); break; } - case 'codex-response': { - const codexData = latestMessage.data; - if (!codexData) { - break; - } - - if (codexData.type === 'item') { - switch (codexData.itemType) { - case 'agent_message': - if (codexData.message?.content?.trim()) { - const content = decodeHtmlEntities(codexData.message.content); - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content, - timestamp: new Date(), - }, - ]); - } - break; - - case 'reasoning': - if (codexData.message?.content?.trim()) { - const content = decodeHtmlEntities(codexData.message.content); - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content, - timestamp: new Date(), - isThinking: true, - }, - ]); - } - break; - - case 'command_execution': - if (codexData.command) { - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: '', - timestamp: new Date(), - isToolUse: true, - toolName: 'Bash', - toolInput: codexData.command, - toolResult: codexData.output || null, - exitCode: codexData.exitCode, - }, - ]); - } - break; - - case 'file_change': - if (codexData.changes?.length > 0) { - const changesList = codexData.changes - .map((change: { kind: string; path: string }) => `${change.kind}: ${change.path}`) - .join('\n'); - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: '', - timestamp: new Date(), - isToolUse: true, - toolName: 'FileChanges', - toolInput: changesList, - toolResult: { - content: `Status: ${codexData.status}`, - isError: false, - }, - }, - ]); - } - break; - - case 'mcp_tool_call': - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: '', - timestamp: new Date(), - isToolUse: true, - toolName: `${codexData.server}:${codexData.tool}`, - toolInput: JSON.stringify(codexData.arguments, null, 2), - toolResult: codexData.result - ? JSON.stringify(codexData.result, null, 2) - : codexData.error?.message || null, - }, - ]); - break; - - case 'error': - if (codexData.message?.content) { - setChatMessages((previous) => [ - ...previous, - { - type: 'error', - content: codexData.message.content, - timestamp: new Date(), - }, - ]); - } - break; - - default: - console.log('[Codex] Unhandled item type:', codexData.itemType, codexData); - } - } - - if (codexData.type === 'turn_complete') { - finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id); - } - - if (codexData.type === 'turn_failed') { - finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id); - setChatMessages((previous) => [ - ...previous, - { - type: 'error', - content: codexData.error?.message || 'Turn failed', - timestamp: new Date(), - }, - ]); - } - break; - } - - case 'codex-complete': { - const codexPendingSessionId = sessionStorage.getItem('pendingSessionId'); - const codexActualSessionId = latestMessage.actualSessionId || codexPendingSessionId; - const codexCompletedSessionId = - latestMessage.sessionId || currentSessionId || codexPendingSessionId; - - finalizeLifecycleForCurrentView( - codexCompletedSessionId, - codexActualSessionId, - currentSessionId, - selectedSession?.id, - codexPendingSessionId, - ); - - if (codexPendingSessionId && !currentSessionId) { - setCurrentSessionId(codexActualSessionId); - setIsSystemSessionChange(true); - if (codexActualSessionId) { - onNavigateToSession?.(codexActualSessionId); - } - sessionStorage.removeItem('pendingSessionId'); - } - - if (selectedProject) { - safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); - } - break; - } - - case 'codex-error': - finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id); - setChatMessages((previous) => [ - ...previous, - { - type: 'error', - content: latestMessage.error || 'An error occurred with Codex', - timestamp: new Date(), - }, - ]); - break; - - case 'gemini-response': { - const geminiData = latestMessage.data; - - if (geminiData && geminiData.type === 'message' && typeof geminiData.content === 'string') { - const content = decodeHtmlEntities(geminiData.content); - - if (content) { - streamBufferRef.current += streamBufferRef.current ? `\n${content}` : content; - } - - if (!geminiData.isPartial) { - // Immediate flush and finalization for the last chunk - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current); - streamTimerRef.current = null; - } - const chunk = streamBufferRef.current; - streamBufferRef.current = ''; - - if (chunk) { - appendStreamingChunk(setChatMessages, chunk, true); - } - finalizeStreamingMessage(setChatMessages); - } else if (!streamTimerRef.current && streamBufferRef.current) { - streamTimerRef.current = window.setTimeout(() => { - const chunk = streamBufferRef.current; - streamBufferRef.current = ''; - streamTimerRef.current = null; - - if (chunk) { - appendStreamingChunk(setChatMessages, chunk, true); - } - }, 100); - } - } - break; - } - - case 'gemini-error': - finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id); - setChatMessages((previous) => [ - ...previous, - { - type: 'error', - content: latestMessage.error || 'An error occurred with Gemini', - timestamp: new Date(), - }, - ]); - break; - - case 'gemini-tool-use': - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: '', - timestamp: new Date(), - isToolUse: true, - toolName: latestMessage.toolName, - toolInput: latestMessage.parameters ? JSON.stringify(latestMessage.parameters, null, 2) : '', - toolId: latestMessage.toolId, - toolResult: null, - } - ]); - break; - - case 'gemini-tool-result': - setChatMessages((previous) => - previous.map((message) => { - if (message.isToolUse && message.toolId === latestMessage.toolId) { - return { - ...message, - toolResult: { - content: latestMessage.output || `Status: ${latestMessage.status}`, - isError: latestMessage.status === 'error', - timestamp: new Date(), - }, - }; - } - return message; - }), - ); - break; - - case 'session-aborted': { - const pendingSessionId = - typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null; - const abortedSessionId = latestMessage.sessionId || currentSessionId; - const abortSucceeded = latestMessage.success !== false; - - if (abortSucceeded) { - finalizeLifecycleForCurrentView(abortedSessionId, currentSessionId, selectedSession?.id, pendingSessionId); - if (pendingSessionId && (!abortedSessionId || pendingSessionId === abortedSessionId)) { - sessionStorage.removeItem('pendingSessionId'); - } - - setChatMessages((previous) => [ - ...previous, - { - type: 'assistant', - content: 'Session interrupted by user.', - timestamp: new Date(), - }, - ]); - } else { - setChatMessages((previous) => [ - ...previous, - { - type: 'error', - content: 'Stop request failed. The session is still running.', - timestamp: new Date(), - }, - ]); - } - break; - } - - case 'session-status': { - const statusSessionId = latestMessage.sessionId; - if (!statusSessionId) { - break; - } - - const isCurrentSession = - statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id); - - if (latestMessage.isProcessing) { - onSessionProcessing?.(statusSessionId); - if (isCurrentSession) { - setIsLoading(true); - setCanAbortSession(true); - } - break; - } - - onSessionInactive?.(statusSessionId); - onSessionNotProcessing?.(statusSessionId); - if (isCurrentSession) { - clearLoadingIndicators(); - } - break; - } - - case 'claude-status': { - const statusData = latestMessage.data; - if (!statusData) { - break; - } - - const statusInfo: { text: string; tokens: number; can_interrupt: boolean } = { - text: 'Working...', - tokens: 0, - can_interrupt: true, - }; - - if (statusData.message) { - statusInfo.text = statusData.message; - } else if (statusData.status) { - statusInfo.text = statusData.status; - } else if (typeof statusData === 'string') { - statusInfo.text = statusData; - } - - if (statusData.tokens) { - statusInfo.tokens = statusData.tokens; - } else if (statusData.token_count) { - statusInfo.tokens = statusData.token_count; - } - - if (statusData.can_interrupt !== undefined) { - statusInfo.can_interrupt = statusData.can_interrupt; - } - - setClaudeStatus(statusInfo); + case 'permission_request': { + if (!msg.requestId) break; + setPendingPermissionRequests((prev) => { + if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev; + return [...prev, { + requestId: msg.requestId, + toolName: msg.toolName || 'UnknownTool', + input: msg.input, + context: msg.context, + sessionId: sid || null, + receivedAt: new Date(), + }]; + }); setIsLoading(true); - setCanAbortSession(statusInfo.can_interrupt); + setCanAbortSession(true); + setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true }); break; } - case 'pending-permissions-response': { - // Server returned pending permissions for this session - const permSessionId = latestMessage.sessionId; - const isCurrentPermSession = - permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id); - if (permSessionId && !isCurrentPermSession) { - break; + case 'permission_cancelled': { + if (msg.requestId) { + setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId)); } - const serverRequests = latestMessage.data || []; - setPendingPermissionRequests(serverRequests); break; } - case 'error': - // Generic backend failure (e.g., provider process failed before a provider-specific - // completion event was emitted). Treat it as terminal for current view lifecycle. - finalizeLifecycleForCurrentView(latestMessage.sessionId, currentSessionId, selectedSession?.id); + case 'status': { + if (msg.text === 'token_budget' && msg.tokenBudget) { + setTokenBudget(msg.tokenBudget as Record); + } else if (msg.text) { + setClaudeStatus({ + text: msg.text, + tokens: msg.tokens || 0, + can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true, + }); + setIsLoading(true); + setCanAbortSession(msg.canInterrupt !== false); + } break; + } + // text, tool_use, tool_result, thinking, interactive_prompt, task_notification + // → already routed to store above, no UI side effects needed default: break; } @@ -1172,17 +349,21 @@ export function useChatRealtimeHandlers({ selectedSession, currentSessionId, setCurrentSessionId, - setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setTokenBudget, - setIsSystemSessionChange, setPendingPermissionRequests, + pendingViewSessionRef, + streamBufferRef, + streamTimerRef, + accumulatedStreamRef, onSessionInactive, onSessionProcessing, onSessionNotProcessing, onReplaceTemporarySession, onNavigateToSession, + onWebSocketReconnect, + sessionStore, ]); } diff --git a/src/components/chat/hooks/useChatSessionState.ts b/src/components/chat/hooks/useChatSessionState.ts index fc9dd50..e952ee1 100644 --- a/src/components/chat/hooks/useChatSessionState.ts +++ b/src/components/chat/hooks/useChatSessionState.ts @@ -1,15 +1,11 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { MutableRefObject } from 'react'; -import { api, authenticatedFetch } from '../../../utils/api'; +import { authenticatedFetch } from '../../../utils/api'; import type { ChatMessage, Provider } from '../types/types'; -import type { Project, ProjectSession } from '../../../types/app'; -import { safeLocalStorage } from '../utils/chatStorage'; -import { - convertCursorSessionMessages, - convertSessionMessages, - createCachedDiffCalculator, - type DiffCalculator, -} from '../utils/messageTransforms'; +import type { Project, ProjectSession, SessionProvider } from '../../../types/app'; +import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms'; +import { normalizedToChatMessages } from './useChatMessages'; +import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore'; const MESSAGES_PER_PAGE = 20; const INITIAL_VISIBLE_MESSAGES = 100; @@ -29,6 +25,7 @@ interface UseChatSessionStateArgs { processingSessions?: Set; resetStreamingState: () => void; pendingViewSessionRef: MutableRefObject; + sessionStore: SessionStore; } interface ScrollRestoreState { @@ -36,6 +33,61 @@ interface ScrollRestoreState { top: number; } +/* ------------------------------------------------------------------ */ +/* Helper: Convert a ChatMessage to a NormalizedMessage for the store */ +/* ------------------------------------------------------------------ */ + +function chatMessageToNormalized( + msg: ChatMessage, + sessionId: string, + provider: SessionProvider, +): NormalizedMessage | null { + const id = `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const ts = msg.timestamp instanceof Date + ? msg.timestamp.toISOString() + : typeof msg.timestamp === 'number' + ? new Date(msg.timestamp).toISOString() + : String(msg.timestamp); + const base = { id, sessionId, timestamp: ts, provider }; + + if (msg.isToolUse) { + return { + ...base, + kind: 'tool_use', + toolName: msg.toolName, + toolInput: msg.toolInput, + toolId: msg.toolId || id, + } as NormalizedMessage; + } + if (msg.isThinking) { + return { ...base, kind: 'thinking', content: msg.content || '' } as NormalizedMessage; + } + if (msg.isInteractivePrompt) { + return { ...base, kind: 'interactive_prompt', content: msg.content || '' } as NormalizedMessage; + } + if ((msg as any).isTaskNotification) { + return { + ...base, + kind: 'task_notification', + status: (msg as any).taskStatus || 'completed', + summary: msg.content || '', + } as NormalizedMessage; + } + if (msg.type === 'error') { + return { ...base, kind: 'error', content: msg.content || '' } as NormalizedMessage; + } + return { + ...base, + kind: 'text', + role: msg.type === 'user' ? 'user' : 'assistant', + content: msg.content || '', + } as NormalizedMessage; +} + +/* ------------------------------------------------------------------ */ +/* Hook */ +/* ------------------------------------------------------------------ */ + export function useChatSessionState({ selectedProject, selectedSession, @@ -46,31 +98,14 @@ export function useChatSessionState({ processingSessions, resetStreamingState, pendingViewSessionRef, + sessionStore, }: UseChatSessionStateArgs) { - const [chatMessages, setChatMessages] = useState(() => { - if (typeof window !== 'undefined' && selectedProject) { - const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`); - if (saved) { - try { - return JSON.parse(saved) as ChatMessage[]; - } catch { - console.error('Failed to parse saved chat messages, resetting'); - safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`); - return []; - } - } - return []; - } - return []; - }); const [isLoading, setIsLoading] = useState(false); const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null); - const [sessionMessages, setSessionMessages] = useState([]); const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false); const [hasMoreMessages, setHasMoreMessages] = useState(false); const [totalMessages, setTotalMessages] = useState(0); - const [isSystemSessionChange, setIsSystemSessionChange] = useState(false); const [canAbortSession, setCanAbortSession] = useState(false); const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); const [tokenBudget, setTokenBudget] = useState | null>(null); @@ -80,6 +115,7 @@ export function useChatSessionState({ const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false); const [loadAllJustFinished, setLoadAllJustFinished] = useState(false); const [showLoadAllOverlay, setShowLoadAllOverlay] = useState(false); + const [viewHiddenCount, setViewHiddenCount] = useState(0); const scrollContainerRef = useRef(null); const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null); @@ -98,97 +134,78 @@ export function useChatSessionState({ const createDiff = useMemo(() => createCachedDiffCalculator(), []); - const loadSessionMessages = useCallback( - async (projectName: string, sessionId: string, loadMore = false, provider: Provider | string = 'claude') => { - if (!projectName || !sessionId) { - return [] as any[]; - } + /* ---------------------------------------------------------------- */ + /* Derive chatMessages from the store */ + /* ---------------------------------------------------------------- */ - const isInitialLoad = !loadMore; - if (isInitialLoad) { - setIsLoadingSessionMessages(true); - } else { - setIsLoadingMoreMessages(true); - } + const activeSessionId = selectedSession?.id || currentSessionId || null; + const [pendingUserMessage, setPendingUserMessage] = useState(null); - try { - const currentOffset = loadMore ? messagesOffsetRef.current : 0; - const response = await (api.sessionMessages as any)( - projectName, - sessionId, - MESSAGES_PER_PAGE, - currentOffset, - provider, - ); - if (!response.ok) { - throw new Error('Failed to load session messages'); - } + // Tell the store which session we're viewing so it only re-renders for this one + const prevActiveForStoreRef = useRef(null); + if (activeSessionId !== prevActiveForStoreRef.current) { + prevActiveForStoreRef.current = activeSessionId; + sessionStore.setActiveSession(activeSessionId); + } - const data = await response.json(); - if (isInitialLoad && data.tokenUsage) { - setTokenBudget(data.tokenUsage); - } - - if (data.hasMore !== undefined) { - const loadedCount = data.messages?.length || 0; - setHasMoreMessages(Boolean(data.hasMore)); - setTotalMessages(Number(data.total || 0)); - messagesOffsetRef.current = currentOffset + loadedCount; - return data.messages || []; - } - - const messages = data.messages || []; - setHasMoreMessages(false); - setTotalMessages(messages.length); - messagesOffsetRef.current = messages.length; - return messages; - } catch (error) { - console.error('Error loading session messages:', error); - return []; - } finally { - if (isInitialLoad) { - setIsLoadingSessionMessages(false); - } else { - setIsLoadingMoreMessages(false); - } - } - }, - [], - ); - - const loadCursorSessionMessages = useCallback(async (projectPath: string, sessionId: string) => { - if (!projectPath || !sessionId) { - return [] as ChatMessage[]; + // When a real session ID arrives and we have a pending user message, flush it to the store + const prevActiveSessionRef = useRef(null); + if (activeSessionId && activeSessionId !== prevActiveSessionRef.current && pendingUserMessage) { + const prov = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude'; + const normalized = chatMessageToNormalized(pendingUserMessage, activeSessionId, prov); + if (normalized) { + sessionStore.appendRealtime(activeSessionId, normalized); } + setPendingUserMessage(null); + } + prevActiveSessionRef.current = activeSessionId; - setIsLoadingSessionMessages(true); - try { - const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`; - const response = await authenticatedFetch(url); - if (!response.ok) { - return []; - } + const storeMessages = activeSessionId ? sessionStore.getMessages(activeSessionId) : []; - const data = await response.json(); - const blobs = (data?.session?.messages || []) as any[]; - return convertCursorSessionMessages(blobs, projectPath); - } catch (error) { - console.error('Error loading Cursor session messages:', error); - return []; - } finally { - setIsLoadingSessionMessages(false); + // Reset viewHiddenCount when store messages change + const prevStoreLenRef = useRef(0); + if (storeMessages.length !== prevStoreLenRef.current) { + prevStoreLenRef.current = storeMessages.length; + if (viewHiddenCount > 0) setViewHiddenCount(0); + } + + const chatMessages = useMemo(() => { + const all = normalizedToChatMessages(storeMessages); + // Show pending user message when no session data exists yet (new session, pre-backend-response) + if (pendingUserMessage && all.length === 0) { + return [pendingUserMessage]; } - }, []); + if (viewHiddenCount > 0 && viewHiddenCount < all.length) return all.slice(0, -viewHiddenCount); + return all; + }, [storeMessages, viewHiddenCount, pendingUserMessage]); - const convertedMessages = useMemo(() => { - return convertSessionMessages(sessionMessages); - }, [sessionMessages]); + /* ---------------------------------------------------------------- */ + /* addMessage / clearMessages / rewindMessages */ + /* ---------------------------------------------------------------- */ + + const addMessage = useCallback((msg: ChatMessage) => { + if (!activeSessionId) { + // No session yet — show as pending until the backend creates one + setPendingUserMessage(msg); + return; + } + const prov = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude'; + const normalized = chatMessageToNormalized(msg, activeSessionId, prov); + if (normalized) { + sessionStore.appendRealtime(activeSessionId, normalized); + } + }, [activeSessionId, sessionStore]); + + const clearMessages = useCallback(() => { + if (!activeSessionId) return; + sessionStore.clearRealtime(activeSessionId); + }, [activeSessionId, sessionStore]); + + const rewindMessages = useCallback((count: number) => setViewHiddenCount(count), []); const scrollToBottom = useCallback(() => { const container = scrollContainerRef.current; - if (!container) { - return; - } + if (!container) return; container.scrollTop = container.scrollHeight; }, []); @@ -203,105 +220,74 @@ export function useChatSessionState({ const isNearBottom = useCallback(() => { const container = scrollContainerRef.current; - if (!container) { - return false; - } + if (!container) return false; const { scrollTop, scrollHeight, clientHeight } = container; return scrollHeight - scrollTop - clientHeight < 50; }, []); const loadOlderMessages = useCallback( async (container: HTMLDivElement) => { - if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) { - return false; - } + if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) return false; if (allMessagesLoadedRef.current) return false; - if (!hasMoreMessages || !selectedSession || !selectedProject) { - return false; - } + if (!hasMoreMessages || !selectedSession || !selectedProject) return false; const sessionProvider = selectedSession.__provider || 'claude'; - if (sessionProvider === 'cursor') { - return false; - } + if (sessionProvider === 'cursor') return false; isLoadingMoreRef.current = true; const previousScrollHeight = container.scrollHeight; const previousScrollTop = container.scrollTop; try { - const moreMessages = await loadSessionMessages( - selectedProject.name, - selectedSession.id, - true, - sessionProvider, - ); + const slot = await sessionStore.fetchMore(selectedSession.id, { + provider: sessionProvider as SessionProvider, + projectName: selectedProject.name, + projectPath: selectedProject.fullPath || selectedProject.path || '', + limit: MESSAGES_PER_PAGE, + }); + if (!slot || slot.serverMessages.length === 0) return false; - if (moreMessages.length === 0) { - return false; - } - - pendingScrollRestoreRef.current = { - height: previousScrollHeight, - top: previousScrollTop, - }; - setSessionMessages((previous) => [...moreMessages, ...previous]); - // Keep the rendered window in sync with top-pagination so newly loaded history becomes visible. - setVisibleMessageCount((previousCount) => previousCount + moreMessages.length); + pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop }; + setHasMoreMessages(slot.hasMore); + setTotalMessages(slot.total); + setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE); return true; } finally { isLoadingMoreRef.current = false; } }, - [hasMoreMessages, isLoadingMoreMessages, loadSessionMessages, selectedProject, selectedSession], + [hasMoreMessages, isLoadingMoreMessages, selectedProject, selectedSession, sessionStore], ); const handleScroll = useCallback(async () => { const container = scrollContainerRef.current; - if (!container) { - return; - } + if (!container) return; const nearBottom = isNearBottom(); setIsUserScrolledUp(!nearBottom); if (!allMessagesLoadedRef.current) { const scrolledNearTop = container.scrollTop < 100; - if (!scrolledNearTop) { - topLoadLockRef.current = false; - return; - } - + if (!scrolledNearTop) { topLoadLockRef.current = false; return; } if (topLoadLockRef.current) { - if (container.scrollTop > 20) { - topLoadLockRef.current = false; - } + if (container.scrollTop > 20) topLoadLockRef.current = false; return; } - const didLoad = await loadOlderMessages(container); - if (didLoad) { - topLoadLockRef.current = true; - } + if (didLoad) topLoadLockRef.current = true; } }, [isNearBottom, loadOlderMessages]); useLayoutEffect(() => { - if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) { - return; - } - + if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return; const { height, top } = pendingScrollRestoreRef.current; const container = scrollContainerRef.current; const newScrollHeight = container.scrollHeight; - const scrollDiff = newScrollHeight - height; - container.scrollTop = top + Math.max(scrollDiff, 0); + container.scrollTop = top + Math.max(newScrollHeight - height, 0); pendingScrollRestoreRef.current = null; }, [chatMessages.length]); - const prevSessionMessagesLengthRef = useRef(0); - const isInitialLoadRef = useRef(true); - + // Reset scroll/pagination state on session change useEffect(() => { if (!searchScrollActiveRef.current) { pendingInitialScrollRef.current = true; @@ -309,187 +295,129 @@ export function useChatSessionState({ } topLoadLockRef.current = false; pendingScrollRestoreRef.current = null; - prevSessionMessagesLengthRef.current = 0; - isInitialLoadRef.current = true; setIsUserScrolledUp(false); }, [selectedProject?.name, selectedSession?.id]); + // Initial scroll to bottom useEffect(() => { - if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) { - return; - } - - if (chatMessages.length === 0) { - pendingInitialScrollRef.current = false; - return; - } - + if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return; + if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; } pendingInitialScrollRef.current = false; - if (!searchScrollActiveRef.current) { - setTimeout(() => { - scrollToBottom(); - }, 200); - } + if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200); }, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]); + // Main session loading effect — store-based useEffect(() => { - const loadMessages = async () => { - if (selectedSession && selectedProject) { - const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude'; - isLoadingSessionRef.current = true; + if (!selectedSession || !selectedProject) { + resetStreamingState(); + pendingViewSessionRef.current = null; + setClaudeStatus(null); + setCanAbortSession(false); + setIsLoading(false); + setCurrentSessionId(null); + sessionStorage.removeItem('cursorSessionId'); + messagesOffsetRef.current = 0; + setHasMoreMessages(false); + setTotalMessages(0); + setTokenBudget(null); + lastLoadedSessionKeyRef.current = null; + return; + } - const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; - if (sessionChanged) { - if (!isSystemSessionChange) { - resetStreamingState(); - pendingViewSessionRef.current = null; - setChatMessages([]); - setSessionMessages([]); - setClaudeStatus(null); - setCanAbortSession(false); - } + const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude'; + const sessionKey = `${selectedSession.id}:${selectedProject.name}:${provider}`; - messagesOffsetRef.current = 0; - setHasMoreMessages(false); - setTotalMessages(0); - setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); - setAllMessagesLoaded(false); - allMessagesLoadedRef.current = false; - setIsLoadingAllMessages(false); - setLoadAllJustFinished(false); - setShowLoadAllOverlay(false); - if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); - if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); - setTokenBudget(null); - setIsLoading(false); + // Skip if already loaded and fresh + if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) { + return; + } - if (ws) { - sendMessage({ - type: 'check-session-status', - sessionId: selectedSession.id, - provider, - }); - } - } else if (currentSessionId === null) { - messagesOffsetRef.current = 0; - setHasMoreMessages(false); - setTotalMessages(0); + const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id; + if (sessionChanged) { + resetStreamingState(); + pendingViewSessionRef.current = null; + setClaudeStatus(null); + setCanAbortSession(false); + } - if (ws) { - sendMessage({ - type: 'check-session-status', - sessionId: selectedSession.id, - provider, - }); - } - } + // Reset pagination/scroll state + messagesOffsetRef.current = 0; + setHasMoreMessages(false); + setTotalMessages(0); + setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES); + setAllMessagesLoaded(false); + allMessagesLoadedRef.current = false; + setIsLoadingAllMessages(false); + setLoadAllJustFinished(false); + setShowLoadAllOverlay(false); + setViewHiddenCount(0); + if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); + if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); - // Skip loading if session+project+provider hasn't changed - const sessionKey = `${selectedSession.id}:${selectedProject.name}:${provider}`; - if (lastLoadedSessionKeyRef.current === sessionKey) { - setTimeout(() => { - isLoadingSessionRef.current = false; - }, 250); - return; - } + if (sessionChanged) { + setTokenBudget(null); + setIsLoading(false); + } - if (provider === 'cursor') { - setCurrentSessionId(selectedSession.id); - sessionStorage.setItem('cursorSessionId', selectedSession.id); + setCurrentSessionId(selectedSession.id); + if (provider === 'cursor') { + sessionStorage.setItem('cursorSessionId', selectedSession.id); + } - if (!isSystemSessionChange) { - const projectPath = selectedProject.fullPath || selectedProject.path || ''; - const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); - setSessionMessages([]); - setChatMessages(converted); - } else { - setIsSystemSessionChange(false); - } - } else { - setCurrentSessionId(selectedSession.id); + // Check session status + if (ws) { + sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider }); + } - if (!isSystemSessionChange) { - const messages = await loadSessionMessages( - selectedProject.name, - selectedSession.id, - false, - selectedSession.__provider || 'claude', - ); - setSessionMessages(messages); - } else { - setIsSystemSessionChange(false); - } - } + lastLoadedSessionKeyRef.current = sessionKey; - // Update the last loaded session key - lastLoadedSessionKeyRef.current = sessionKey; - } else { - if (!isSystemSessionChange) { - resetStreamingState(); - pendingViewSessionRef.current = null; - setChatMessages([]); - setSessionMessages([]); - setClaudeStatus(null); - setCanAbortSession(false); - setIsLoading(false); - } - - setCurrentSessionId(null); - sessionStorage.removeItem('cursorSessionId'); - messagesOffsetRef.current = 0; - setHasMoreMessages(false); - setTotalMessages(0); - setTokenBudget(null); - lastLoadedSessionKeyRef.current = null; + // Fetch from server → store updates → chatMessages re-derives automatically + setIsLoadingSessionMessages(true); + sessionStore.fetchFromServer(selectedSession.id, { + provider: (selectedSession.__provider || provider) as SessionProvider, + projectName: selectedProject.name, + projectPath: selectedProject.fullPath || selectedProject.path || '', + limit: MESSAGES_PER_PAGE, + offset: 0, + }).then(slot => { + if (slot) { + setHasMoreMessages(slot.hasMore); + setTotalMessages(slot.total); + if (slot.tokenUsage) setTokenBudget(slot.tokenUsage as Record); } - - setTimeout(() => { - isLoadingSessionRef.current = false; - }, 250); - }; - - loadMessages(); + setIsLoadingSessionMessages(false); + }).catch(() => { + setIsLoadingSessionMessages(false); + }); }, [ - // Intentionally exclude currentSessionId: this effect sets it and should not retrigger another full load. - isSystemSessionChange, - loadCursorSessionMessages, - loadSessionMessages, pendingViewSessionRef, resetStreamingState, selectedProject, - selectedSession?.id, // Only depend on session ID, not the entire object + selectedSession?.id, sendMessage, ws, + sessionStore, ]); + // External message update (e.g. WebSocket reconnect, background refresh) useEffect(() => { - if (!externalMessageUpdate || !selectedSession || !selectedProject) { - return; - } + if (!externalMessageUpdate || !selectedSession || !selectedProject) return; const reloadExternalMessages = async () => { try { const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude'; - if (provider === 'cursor') { - const projectPath = selectedProject.fullPath || selectedProject.path || ''; - const converted = await loadCursorSessionMessages(projectPath, selectedSession.id); - setSessionMessages([]); - setChatMessages(converted); - return; - } + // Skip store refresh during active streaming + if (!isLoading) { + await sessionStore.refreshFromServer(selectedSession.id, { + provider: (selectedSession.__provider || provider) as SessionProvider, + projectName: selectedProject.name, + projectPath: selectedProject.fullPath || selectedProject.path || '', + }); - const messages = await loadSessionMessages( - selectedProject.name, - selectedSession.id, - false, - selectedSession.__provider || 'claude', - ); - setSessionMessages(messages); - - const shouldAutoScroll = Boolean(autoScrollToBottom) && isNearBottom(); - if (shouldAutoScroll) { - setTimeout(() => scrollToBottom(), 200); + if (Boolean(autoScrollToBottom) && isNearBottom()) { + setTimeout(() => scrollToBottom(), 200); + } } } catch (error) { console.error('Error reloading messages from external update:', error); @@ -501,16 +429,14 @@ export function useChatSessionState({ autoScrollToBottom, externalMessageUpdate, isNearBottom, - loadCursorSessionMessages, - loadSessionMessages, scrollToBottom, selectedProject, selectedSession, + sessionStore, + isLoading, ]); - // Detect search navigation target from selectedSession object reference change - // This must be a separate effect because the loading effect depends on selectedSession?.id - // which doesn't change when clicking a search result for the already-loaded session + // Search navigation target useEffect(() => { const session = selectedSession as Record | null; const targetSnippet = session?.__searchTargetSnippet; @@ -525,68 +451,36 @@ export function useChatSessionState({ }, [selectedSession]); useEffect(() => { - if (selectedSession?.id) { - pendingViewSessionRef.current = null; - } + if (selectedSession?.id) pendingViewSessionRef.current = null; }, [pendingViewSessionRef, selectedSession?.id]); - useEffect(() => { - // Only sync sessionMessages to chatMessages when: - // 1. Not currently loading (to avoid overwriting user's just-sent message) - // 2. SessionMessages actually changed (including from non-empty to empty) - // 3. Either it's initial load OR sessionMessages increased (new messages from server) - if ( - sessionMessages.length !== prevSessionMessagesLengthRef.current && - !isLoading - ) { - // Only update if this is initial load, sessionMessages grew, or was cleared to empty - if (isInitialLoadRef.current || sessionMessages.length === 0 || sessionMessages.length > prevSessionMessagesLengthRef.current) { - setChatMessages(convertedMessages); - isInitialLoadRef.current = false; - } - prevSessionMessagesLengthRef.current = sessionMessages.length; - } - }, [convertedMessages, sessionMessages.length, isLoading, setChatMessages]); - - useEffect(() => { - if (selectedProject && chatMessages.length > 0) { - safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages)); - } - }, [chatMessages, selectedProject]); - - // Scroll to search target message after messages are loaded + // Scroll to search target useEffect(() => { if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return; const target = searchTarget; - // Clear immediately to prevent re-triggering setSearchTarget(null); const scrollToTarget = async () => { - // Always load all messages when navigating from search - // (hasMoreMessages may not be set yet due to race with loading effect) if (!allMessagesLoadedRef.current && selectedSession && selectedProject) { const sessionProvider = selectedSession.__provider || 'claude'; if (sessionProvider !== 'cursor') { try { - const response = await (api.sessionMessages as any)( - selectedProject.name, - selectedSession.id, - null, - 0, - sessionProvider, - ); - if (response.ok) { - const data = await response.json(); - const allMessages = data.messages || data; - setSessionMessages(Array.isArray(allMessages) ? allMessages : []); + // Load all messages into the store for search navigation + const slot = await sessionStore.fetchFromServer(selectedSession.id, { + provider: sessionProvider as SessionProvider, + projectName: selectedProject.name, + projectPath: selectedProject.fullPath || selectedProject.path || '', + limit: null, + offset: 0, + }); + if (slot) { setHasMoreMessages(false); - setTotalMessages(Array.isArray(allMessages) ? allMessages.length : 0); - messagesOffsetRef.current = Array.isArray(allMessages) ? allMessages.length : 0; + setTotalMessages(slot.total); + messagesOffsetRef.current = slot.total; setVisibleMessageCount(Infinity); setAllMessagesLoaded(true); allMessagesLoadedRef.current = true; - // Wait for messages to render after state update await new Promise(resolve => setTimeout(resolve, 300)); } } catch { @@ -596,45 +490,33 @@ export function useChatSessionState({ } setVisibleMessageCount(Infinity); - // Retry finding the element in the DOM until React finishes rendering all messages const findAndScroll = (retriesLeft: number) => { const container = scrollContainerRef.current; if (!container) return; let targetElement: Element | null = null; - // Match by snippet text content (most reliable) if (target.snippet) { const cleanSnippet = target.snippet.replace(/^\.{3}/, '').replace(/\.{3}$/, '').trim(); - // Use a contiguous substring from the snippet (don't filter words, it breaks matching) const searchPhrase = cleanSnippet.slice(0, 80).toLowerCase().trim(); - if (searchPhrase.length >= 10) { const messageElements = container.querySelectorAll('.chat-message'); for (const el of messageElements) { const text = (el.textContent || '').toLowerCase(); - if (text.includes(searchPhrase)) { - targetElement = el; - break; - } + if (text.includes(searchPhrase)) { targetElement = el; break; } } } } - // Fallback to timestamp matching if (!targetElement && target.timestamp) { const targetDate = new Date(target.timestamp).getTime(); const messageElements = container.querySelectorAll('[data-message-timestamp]'); let closestDiff = Infinity; - for (const el of messageElements) { const ts = el.getAttribute('data-message-timestamp'); if (!ts) continue; const diff = Math.abs(new Date(ts).getTime() - targetDate); - if (diff < closestDiff) { - closestDiff = diff; - targetElement = el; - } + if (diff < closestDiff) { closestDiff = diff; targetElement = el; } } } @@ -650,7 +532,6 @@ export function useChatSessionState({ } }; - // Start polling after a short delay to let React begin rendering setTimeout(() => findAndScroll(15), 150); }; @@ -658,24 +539,21 @@ export function useChatSessionState({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [chatMessages.length, isLoadingSessionMessages, searchTarget]); + // Token usage fetch for Claude useEffect(() => { if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) { setTokenBudget(null); return; } - const sessionProvider = selectedSession.__provider || 'claude'; - if (sessionProvider !== 'claude') { - return; - } + if (sessionProvider !== 'claude') return; const fetchInitialTokenUsage = async () => { try { const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`; const response = await authenticatedFetch(url); if (response.ok) { - const data = await response.json(); - setTokenBudget(data); + setTokenBudget(await response.json()); } else { setTokenBudget(null); } @@ -683,44 +561,28 @@ export function useChatSessionState({ console.error('Failed to fetch initial token usage:', error); } }; - fetchInitialTokenUsage(); }, [selectedProject, selectedSession?.id, selectedSession?.__provider]); const visibleMessages = useMemo(() => { - if (chatMessages.length <= visibleMessageCount) { - return chatMessages; - } + if (chatMessages.length <= visibleMessageCount) return chatMessages; return chatMessages.slice(-visibleMessageCount); }, [chatMessages, visibleMessageCount]); useEffect(() => { if (!autoScrollToBottom && scrollContainerRef.current) { const container = scrollContainerRef.current; - scrollPositionRef.current = { - height: container.scrollHeight, - top: container.scrollTop, - }; + scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop }; } }); useEffect(() => { - if (!scrollContainerRef.current || chatMessages.length === 0) { - return; - } - - if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) { - return; - } - - if (searchScrollActiveRef.current) { - return; - } + if (!scrollContainerRef.current || chatMessages.length === 0) return; + if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return; + if (searchScrollActiveRef.current) return; if (autoScrollToBottom) { - if (!isUserScrolledUp) { - setTimeout(() => scrollToBottom(), 50); - } + if (!isUserScrolledUp) setTimeout(() => scrollToBottom(), 50); return; } @@ -729,28 +591,19 @@ export function useChatSessionState({ const prevTop = scrollPositionRef.current.top; const newHeight = container.scrollHeight; const heightDiff = newHeight - prevHeight; - - if (heightDiff > 0 && prevTop > 0) { - container.scrollTop = prevTop + heightDiff; - } + if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff; }, [autoScrollToBottom, chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]); useEffect(() => { const container = scrollContainerRef.current; - if (!container) { - return; - } - + if (!container) return; container.addEventListener('scroll', handleScroll); return () => container.removeEventListener('scroll', handleScroll); }, [handleScroll]); useEffect(() => { const activeViewSessionId = selectedSession?.id || currentSessionId; - if (!activeViewSessionId || !processingSessions) { - return; - } - + if (!activeViewSessionId || !processingSessions) return; const shouldBeProcessing = processingSessions.has(activeViewSessionId); if (shouldBeProcessing && !isLoading) { setIsLoading(true); @@ -758,7 +611,7 @@ export function useChatSessionState({ } }, [currentSessionId, isLoading, processingSessions, selectedSession?.id]); - // Show "Load all" overlay after a batch finishes loading, persist for 2s then hide + // "Load all" overlay const prevLoadingRef = useRef(false); useEffect(() => { const wasLoading = prevLoadingRef.current; @@ -767,17 +620,13 @@ export function useChatSessionState({ if (wasLoading && !isLoadingMoreMessages && hasMoreMessages) { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); setShowLoadAllOverlay(true); - loadAllOverlayTimerRef.current = setTimeout(() => { - setShowLoadAllOverlay(false); - }, 2000); + loadAllOverlayTimerRef.current = setTimeout(() => setShowLoadAllOverlay(false), 2000); } if (!hasMoreMessages && !isLoadingMoreMessages) { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); setShowLoadAllOverlay(false); } - return () => { - if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); - }; + return () => { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); }; }, [isLoadingMoreMessages, hasMoreMessages]); const loadAllMessages = useCallback(async () => { @@ -790,15 +639,11 @@ export function useChatSessionState({ allMessagesLoadedRef.current = true; setLoadAllJustFinished(true); if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); - loadAllFinishedTimerRef.current = setTimeout(() => { - setLoadAllJustFinished(false); - setShowLoadAllOverlay(false); - }, 1000); + loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000); return; } const requestSessionId = selectedSession.id; - allMessagesLoadedRef.current = true; isLoadingMoreRef.current = true; setIsLoadingAllMessages(true); @@ -809,41 +654,30 @@ export function useChatSessionState({ const previousScrollTop = container ? container.scrollTop : 0; try { - const response = await (api.sessionMessages as any)( - selectedProject.name, - requestSessionId, - null, - 0, - sessionProvider, - ); + const slot = await sessionStore.fetchFromServer(requestSessionId, { + provider: sessionProvider as SessionProvider, + projectName: selectedProject.name, + projectPath: selectedProject.fullPath || selectedProject.path || '', + limit: null, + offset: 0, + }); if (currentSessionId !== requestSessionId) return; - if (response.ok) { - const data = await response.json(); - const allMessages = data.messages || data; - + if (slot) { if (container) { - pendingScrollRestoreRef.current = { - height: previousScrollHeight, - top: previousScrollTop, - }; + pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop }; } - setSessionMessages(Array.isArray(allMessages) ? allMessages : []); setHasMoreMessages(false); - setTotalMessages(Array.isArray(allMessages) ? allMessages.length : 0); - messagesOffsetRef.current = Array.isArray(allMessages) ? allMessages.length : 0; - + setTotalMessages(slot.total); + messagesOffsetRef.current = slot.total; setVisibleMessageCount(Infinity); setAllMessagesLoaded(true); setLoadAllJustFinished(true); if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current); - loadAllFinishedTimerRef.current = setTimeout(() => { - setLoadAllJustFinished(false); - setShowLoadAllOverlay(false); - }, 1000); + loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000); } else { allMessagesLoadedRef.current = false; setShowLoadAllOverlay(false); @@ -856,27 +690,25 @@ export function useChatSessionState({ isLoadingMoreRef.current = false; setIsLoadingAllMessages(false); } - }, [selectedSession, selectedProject, isLoadingAllMessages, currentSessionId]); + }, [selectedSession, selectedProject, isLoadingAllMessages, currentSessionId, sessionStore]); const loadEarlierMessages = useCallback(() => { - setVisibleMessageCount((previousCount) => previousCount + 100); + setVisibleMessageCount((prev) => prev + 100); }, []); return { chatMessages, - setChatMessages, + addMessage, + clearMessages, + rewindMessages, isLoading, setIsLoading, currentSessionId, setCurrentSessionId, - sessionMessages, - setSessionMessages, isLoadingSessionMessages, isLoadingMoreMessages, hasMoreMessages, totalMessages, - isSystemSessionChange, - setIsSystemSessionChange, canAbortSession, setCanAbortSession, isUserScrolledUp, @@ -899,7 +731,5 @@ export function useChatSessionState({ scrollToBottomAndReset, isNearBottom, handleScroll, - loadSessionMessages, - loadCursorSessionMessages, }; } diff --git a/src/components/chat/utils/chatStorage.ts b/src/components/chat/utils/chatStorage.ts index d1ae327..367a305 100644 --- a/src/components/chat/utils/chatStorage.ts +++ b/src/components/chat/utils/chatStorage.ts @@ -5,32 +5,12 @@ export const CLAUDE_SETTINGS_KEY = 'claude-settings'; export const safeLocalStorage = { setItem: (key: string, value: string) => { try { - if (key.startsWith('chat_messages_') && typeof value === 'string') { - try { - const parsed = JSON.parse(value); - if (Array.isArray(parsed) && parsed.length > 50) { - const truncated = parsed.slice(-50); - value = JSON.stringify(truncated); - } - } catch (parseError) { - console.warn('Could not parse chat messages for truncation:', parseError); - } - } - localStorage.setItem(key, value); } catch (error: any) { if (error?.name === 'QuotaExceededError') { console.warn('localStorage quota exceeded, clearing old data'); const keys = Object.keys(localStorage); - const chatKeys = keys.filter((k) => k.startsWith('chat_messages_')).sort(); - - if (chatKeys.length > 3) { - chatKeys.slice(0, chatKeys.length - 3).forEach((k) => { - localStorage.removeItem(k); - }); - } - const draftKeys = keys.filter((k) => k.startsWith('draft_input_')); draftKeys.forEach((k) => { localStorage.removeItem(k); @@ -40,17 +20,6 @@ export const safeLocalStorage = { localStorage.setItem(key, value); } catch (retryError) { console.error('Failed to save to localStorage even after cleanup:', retryError); - if (key.startsWith('chat_messages_') && typeof value === 'string') { - try { - const parsed = JSON.parse(value); - if (Array.isArray(parsed) && parsed.length > 10) { - const minimal = parsed.slice(-10); - localStorage.setItem(key, JSON.stringify(minimal)); - } - } catch (finalError) { - console.error('Final save attempt failed:', finalError); - } - } } } else { console.error('localStorage error:', error); diff --git a/src/components/chat/utils/messageTransforms.ts b/src/components/chat/utils/messageTransforms.ts index cf38e44..b0bfc1c 100644 --- a/src/components/chat/utils/messageTransforms.ts +++ b/src/components/chat/utils/messageTransforms.ts @@ -1,6 +1,3 @@ -import type { ChatMessage } from '../types/types'; -import { decodeHtmlEntities, unescapeWithMathProtection } from './chatFormatting'; - export interface DiffLine { type: 'added' | 'removed'; content: string; @@ -9,80 +6,6 @@ export interface DiffLine { export type DiffCalculator = (oldStr: string, newStr: string) => DiffLine[]; -type CursorBlob = { - id?: string; - sequence?: number; - rowid?: number; - content?: any; -}; - -const asArray = (value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []); - -const normalizeToolInput = (value: unknown): string => { - if (value === null || value === undefined || value === '') { - return ''; - } - - if (typeof value === 'string') { - return value; - } - - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -}; - -const CURSOR_INTERNAL_USER_BLOCK_PATTERNS = [ - /[\s\S]*?<\/user_info>/gi, - /[\s\S]*?<\/agent_skills>/gi, - /[\s\S]*?<\/available_skills>/gi, - /[\s\S]*?<\/environment_context>/gi, - /[\s\S]*?<\/environment_info>/gi, -]; - -const extractCursorUserQuery = (rawText: string): string => { - const userQueryMatches = [...rawText.matchAll(/([\s\S]*?)<\/user_query>/gi)]; - if (userQueryMatches.length === 0) { - return ''; - } - - return userQueryMatches - .map((match) => (match[1] || '').trim()) - .filter(Boolean) - .join('\n') - .trim(); -}; - -const sanitizeCursorUserMessageText = (rawText: string): string => { - const decodedText = decodeHtmlEntities(rawText || '').trim(); - if (!decodedText) { - return ''; - } - - // Cursor stores user-visible text inside and prepends hidden context blocks - // (, , etc). We only render the actual query in chat history. - const extractedUserQuery = extractCursorUserQuery(decodedText); - if (extractedUserQuery) { - return extractedUserQuery; - } - - let sanitizedText = decodedText; - CURSOR_INTERNAL_USER_BLOCK_PATTERNS.forEach((pattern) => { - sanitizedText = sanitizedText.replace(pattern, ''); - }); - - return sanitizedText.trim(); -}; - -const toAbsolutePath = (projectPath: string, filePath?: string) => { - if (!filePath) { - return filePath; - } - return filePath.startsWith('/') ? filePath : `${projectPath}/${filePath}`; -}; - export const calculateDiff = (oldStr: string, newStr: string): DiffLine[] => { const oldLines = oldStr.split('\n'); const newLines = newStr.split('\n'); @@ -162,434 +85,3 @@ export const createCachedDiffCalculator = (): DiffCalculator => { return calculated; }; }; - -export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: string): ChatMessage[] => { - const converted: ChatMessage[] = []; - const toolUseMap: Record = {}; - - for (let blobIdx = 0; blobIdx < blobs.length; blobIdx += 1) { - const blob = blobs[blobIdx]; - const content = blob.content; - let text = ''; - let role: ChatMessage['type'] = 'assistant'; - let reasoningText: string | null = null; - - try { - if (content?.role && content?.content) { - if (content.role === 'system') { - continue; - } - - if (content.role === 'tool') { - const toolItems = asArray(content.content); - for (const item of toolItems) { - if (item?.type !== 'tool-result') { - continue; - } - - const toolName = item.toolName === 'ApplyPatch' ? 'Edit' : item.toolName || 'Unknown Tool'; - const toolCallId = item.toolCallId || content.id; - const result = item.result || ''; - - if (toolCallId && toolUseMap[toolCallId]) { - toolUseMap[toolCallId].toolResult = { - content: result, - isError: false, - }; - } else { - converted.push({ - type: 'assistant', - content: '', - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid, - isToolUse: true, - toolName, - toolId: toolCallId, - toolInput: normalizeToolInput(null), - toolResult: { - content: result, - isError: false, - }, - }); - } - } - continue; - } - - role = content.role === 'user' ? 'user' : 'assistant'; - - if (Array.isArray(content.content)) { - const textParts: string[] = []; - - for (const part of content.content) { - if (part?.type === 'text' && part?.text) { - textParts.push(decodeHtmlEntities(part.text)); - continue; - } - - if (part?.type === 'reasoning' && part?.text) { - reasoningText = decodeHtmlEntities(part.text); - continue; - } - - if (part?.type === 'tool-call' || part?.type === 'tool_use') { - if (textParts.length > 0 || reasoningText) { - converted.push({ - type: role, - content: textParts.join('\n'), - reasoning: reasoningText ?? undefined, - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid, - }); - textParts.length = 0; - reasoningText = null; - } - - const toolNameRaw = part.toolName || part.name || 'Unknown Tool'; - const toolName = toolNameRaw === 'ApplyPatch' ? 'Edit' : toolNameRaw; - const toolId = part.toolCallId || part.id || `tool_${blobIdx}`; - let toolInput = part.args || part.input; - - if (toolName === 'Edit' && part.args) { - if (part.args.patch) { - const patchLines = String(part.args.patch).split('\n'); - const oldLines: string[] = []; - const newLines: string[] = []; - let inPatch = false; - - patchLines.forEach((line) => { - if (line.startsWith('@@')) { - inPatch = true; - return; - } - if (!inPatch) { - return; - } - - if (line.startsWith('-')) { - oldLines.push(line.slice(1)); - } else if (line.startsWith('+')) { - newLines.push(line.slice(1)); - } else if (line.startsWith(' ')) { - oldLines.push(line.slice(1)); - newLines.push(line.slice(1)); - } - }); - - toolInput = { - file_path: toAbsolutePath(projectPath, part.args.file_path), - old_string: oldLines.join('\n') || part.args.patch, - new_string: newLines.join('\n') || part.args.patch, - }; - } else { - toolInput = part.args; - } - } else if (toolName === 'Read' && part.args) { - const filePath = part.args.path || part.args.file_path; - toolInput = { - file_path: toAbsolutePath(projectPath, filePath), - }; - } else if (toolName === 'Write' && part.args) { - const filePath = part.args.path || part.args.file_path; - toolInput = { - file_path: toAbsolutePath(projectPath, filePath), - content: part.args.contents || part.args.content, - }; - } - - const toolMessage: ChatMessage = { - type: 'assistant', - content: '', - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid, - isToolUse: true, - toolName, - toolId, - toolInput: normalizeToolInput(toolInput), - toolResult: null, - }; - converted.push(toolMessage); - toolUseMap[toolId] = toolMessage; - continue; - } - - if (typeof part === 'string') { - textParts.push(part); - } - } - - if (textParts.length > 0) { - text = textParts.join('\n'); - if (reasoningText && !text) { - converted.push({ - type: role, - content: '', - reasoning: reasoningText, - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid, - }); - text = ''; - } - } else { - text = ''; - } - } else if (typeof content.content === 'string') { - text = content.content; - } - } else if (content?.message?.role && content?.message?.content) { - if (content.message.role === 'system') { - continue; - } - - role = content.message.role === 'user' ? 'user' : 'assistant'; - if (Array.isArray(content.message.content)) { - text = content.message.content - .map((part: any) => (typeof part === 'string' ? part : part?.text || '')) - .filter(Boolean) - .join('\n'); - } else if (typeof content.message.content === 'string') { - text = content.message.content; - } - } - } catch (error) { - console.log('Error parsing blob content:', error); - } - - if (role === 'user') { - text = sanitizeCursorUserMessageText(text); - } - - if (text && text.trim()) { - const message: ChatMessage = { - type: role, - content: text, - timestamp: new Date(Date.now() + blobIdx * 1000), - blobId: blob.id, - sequence: blob.sequence, - rowid: blob.rowid, - }; - if (reasoningText) { - message.reasoning = reasoningText; - } - converted.push(message); - } - } - - converted.sort((messageA, messageB) => { - if (messageA.sequence !== undefined && messageB.sequence !== undefined) { - return Number(messageA.sequence) - Number(messageB.sequence); - } - if (messageA.rowid !== undefined && messageB.rowid !== undefined) { - return Number(messageA.rowid) - Number(messageB.rowid); - } - return new Date(messageA.timestamp).getTime() - new Date(messageB.timestamp).getTime(); - }); - - return converted; -}; - -export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => { - const converted: ChatMessage[] = []; - const toolResults = new Map< - string, - { content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown; subagentTools?: unknown[] } - >(); - - rawMessages.forEach((message) => { - if (message.message?.role === 'user' && Array.isArray(message.message?.content)) { - message.message.content.forEach((part: any) => { - if (part.type !== 'tool_result') { - return; - } - toolResults.set(part.tool_use_id, { - content: part.content, - isError: Boolean(part.is_error), - timestamp: new Date(message.timestamp || Date.now()), - toolUseResult: message.toolUseResult || null, - subagentTools: message.subagentTools, - }); - }); - } - }); - - rawMessages.forEach((message) => { - if (message.message?.role === 'user' && message.message?.content) { - let content = ''; - if (Array.isArray(message.message.content)) { - const textParts: string[] = []; - message.message.content.forEach((part: any) => { - if (part.type === 'text') { - textParts.push(decodeHtmlEntities(part.text)); - } - }); - content = textParts.join('\n'); - } else if (typeof message.message.content === 'string') { - content = decodeHtmlEntities(message.message.content); - } else { - content = decodeHtmlEntities(String(message.message.content)); - } - - const shouldSkip = - !content || - content.startsWith('') || - content.startsWith('') || - content.startsWith('') || - content.startsWith('') || - content.startsWith('') || - content.startsWith('Caveat:') || - content.startsWith('This session is being continued from a previous') || - content.startsWith('[Request interrupted'); - - if (!shouldSkip) { - // Parse blocks into compact system messages - const taskNotifRegex = /\s*[^<]*<\/task-id>\s*[^<]*<\/output-file>\s*([^<]*)<\/status>\s*([^<]*)<\/summary>\s*<\/task-notification>/g; - const taskNotifMatch = taskNotifRegex.exec(content); - if (taskNotifMatch) { - const status = taskNotifMatch[1]?.trim() || 'completed'; - const summary = taskNotifMatch[2]?.trim() || 'Background task finished'; - converted.push({ - type: 'assistant', - content: summary, - timestamp: message.timestamp || new Date().toISOString(), - isTaskNotification: true, - taskStatus: status, - }); - } else { - converted.push({ - type: 'user', - content: unescapeWithMathProtection(content), - timestamp: message.timestamp || new Date().toISOString(), - }); - } - } - return; - } - - if (message.type === 'thinking' && message.message?.content) { - converted.push({ - type: 'assistant', - content: unescapeWithMathProtection(message.message.content), - timestamp: message.timestamp || new Date().toISOString(), - isThinking: true, - }); - return; - } - - if (message.type === 'tool_use' && message.toolName) { - converted.push({ - type: 'assistant', - content: '', - timestamp: message.timestamp || new Date().toISOString(), - isToolUse: true, - toolName: message.toolName, - toolInput: normalizeToolInput(message.toolInput), - toolCallId: message.toolCallId, - }); - return; - } - - if (message.type === 'tool_result') { - for (let index = converted.length - 1; index >= 0; index -= 1) { - const convertedMessage = converted[index]; - if (!convertedMessage.isToolUse || convertedMessage.toolResult) { - continue; - } - if (!message.toolCallId || convertedMessage.toolCallId === message.toolCallId) { - convertedMessage.toolResult = { - content: message.output || '', - isError: false, - }; - break; - } - } - return; - } - - if (message.message?.role === 'assistant' && message.message?.content) { - if (Array.isArray(message.message.content)) { - message.message.content.forEach((part: any) => { - if (part.type === 'text') { - let text = part.text; - if (typeof text === 'string') { - text = unescapeWithMathProtection(text); - } - converted.push({ - type: 'assistant', - content: text, - timestamp: message.timestamp || new Date().toISOString(), - }); - return; - } - - if (part.type === 'tool_use') { - const toolResult = toolResults.get(part.id); - const isSubagentContainer = part.name === 'Task'; - - // Build child tools from server-provided subagentTools data - const childTools: import('../types/types').SubagentChildTool[] = []; - if (isSubagentContainer && toolResult?.subagentTools && Array.isArray(toolResult.subagentTools)) { - for (const tool of toolResult.subagentTools as any[]) { - childTools.push({ - toolId: tool.toolId, - toolName: tool.toolName, - toolInput: tool.toolInput, - toolResult: tool.toolResult || null, - timestamp: new Date(tool.timestamp || Date.now()), - }); - } - } - - converted.push({ - type: 'assistant', - content: '', - timestamp: message.timestamp || new Date().toISOString(), - isToolUse: true, - toolName: part.name, - toolInput: normalizeToolInput(part.input), - toolId: part.id, - toolResult: toolResult - ? { - content: - typeof toolResult.content === 'string' - ? toolResult.content - : JSON.stringify(toolResult.content), - isError: toolResult.isError, - toolUseResult: toolResult.toolUseResult, - } - : null, - toolError: toolResult?.isError || false, - toolResultTimestamp: toolResult?.timestamp || new Date(), - isSubagentContainer, - subagentState: isSubagentContainer - ? { - childTools, - currentToolIndex: childTools.length > 0 ? childTools.length - 1 : -1, - isComplete: Boolean(toolResult), - } - : undefined, - }); - } - }); - return; - } - - if (typeof message.message.content === 'string') { - converted.push({ - type: 'assistant', - content: unescapeWithMathProtection(message.message.content), - timestamp: message.timestamp || new Date().toISOString(), - }); - } - } - }); - - return converted; -}; diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 79c99d8..9f9d0bc 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -3,10 +3,12 @@ import { useTranslation } from 'react-i18next'; import { useTasksSettings } from '../../../contexts/TasksSettingsContext'; import { QuickSettingsPanel } from '../../quick-settings-panel'; import type { ChatInterfaceProps, Provider } from '../types/types'; +import type { SessionProvider } from '../../../types/app'; import { useChatProviderState } from '../hooks/useChatProviderState'; import { useChatSessionState } from '../hooks/useChatSessionState'; import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers'; import { useChatComposerState } from '../hooks/useChatComposerState'; +import { useSessionStore } from '../../../stores/useSessionStore'; import ChatMessagesPane from './subcomponents/ChatMessagesPane'; import ChatComposer from './subcomponents/ChatComposer'; @@ -43,8 +45,10 @@ function ChatInterface({ const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings(); const { t } = useTranslation('chat'); + const sessionStore = useSessionStore(); const streamBufferRef = useRef(''); const streamTimerRef = useRef(null); + const accumulatedStreamRef = useRef(''); const pendingViewSessionRef = useRef(null); const resetStreamingState = useCallback(() => { @@ -53,6 +57,7 @@ function ChatInterface({ streamTimerRef.current = null; } streamBufferRef.current = ''; + accumulatedStreamRef.current = ''; }, []); const { @@ -76,18 +81,17 @@ function ChatInterface({ const { chatMessages, - setChatMessages, + addMessage, + clearMessages, + rewindMessages, isLoading, setIsLoading, currentSessionId, setCurrentSessionId, - sessionMessages, - setSessionMessages, isLoadingSessionMessages, isLoadingMoreMessages, hasMoreMessages, totalMessages, - setIsSystemSessionChange, canAbortSession, setCanAbortSession, isUserScrolledUp, @@ -109,7 +113,6 @@ function ChatInterface({ scrollToBottom, scrollToBottomAndReset, handleScroll, - loadSessionMessages, } = useChatSessionState({ selectedProject, selectedSession, @@ -120,6 +123,7 @@ function ChatInterface({ processingSessions, resetStreamingState, pendingViewSessionRef, + sessionStore, }); const { @@ -189,8 +193,9 @@ function ChatInterface({ onShowSettings, pendingViewSessionRef, scrollToBottom, - setChatMessages, - setSessionMessages, + addMessage, + clearMessages, + rewindMessages, setIsLoading, setCanAbortSession, setClaudeStatus, @@ -198,22 +203,19 @@ function ChatInterface({ setPendingPermissionRequests, }); - // On WebSocket reconnect, re-fetch the current session's messages from JSONL so missed - // streaming events (e.g. from long tool calls while iOS had the tab backgrounded) are shown. - // Also reset isLoading — if the server restarted or the session died mid-stream, the client - // would be stuck in "Processing..." forever without this reset. + // On WebSocket reconnect, re-fetch the current session's messages from the server + // so missed streaming events are shown. Also reset isLoading. const handleWebSocketReconnect = useCallback(async () => { if (!selectedProject || !selectedSession) return; - const provider = (localStorage.getItem('selected-provider') as any) || 'claude'; - const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, provider); - if (messages && messages.length > 0) { - setChatMessages(messages); - } - // Reset loading state — if the session is still active, new WebSocket messages will - // set it back to true. If it died, this clears the permanent frozen state. + const providerVal = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude'; + await sessionStore.refreshFromServer(selectedSession.id, { + provider: (selectedSession.__provider || providerVal) as SessionProvider, + projectName: selectedProject.name, + projectPath: selectedProject.fullPath || selectedProject.path || '', + }); setIsLoading(false); setCanAbortSession(false); - }, [selectedProject, selectedSession, loadSessionMessages, setChatMessages, setIsLoading, setCanAbortSession]); + }, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]); useChatRealtimeHandlers({ latestMessage, @@ -222,22 +224,22 @@ function ChatInterface({ selectedSession, currentSessionId, setCurrentSessionId, - setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setTokenBudget, - setIsSystemSessionChange, setPendingPermissionRequests, pendingViewSessionRef, streamBufferRef, streamTimerRef, + accumulatedStreamRef, onSessionInactive, onSessionProcessing, onSessionNotProcessing, onReplaceTemporarySession, onNavigateToSession, onWebSocketReconnect: handleWebSocketReconnect, + sessionStore, }); useEffect(() => { @@ -319,7 +321,7 @@ function ChatInterface({ isLoadingMoreMessages={isLoadingMoreMessages} hasMoreMessages={hasMoreMessages} totalMessages={totalMessages} - sessionMessagesCount={sessionMessages.length} + sessionMessagesCount={chatMessages.length} visibleMessageCount={visibleMessageCount} visibleMessages={visibleMessages} loadEarlierMessages={loadEarlierMessages} diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts new file mode 100644 index 0000000..60ace48 --- /dev/null +++ b/src/stores/useSessionStore.ts @@ -0,0 +1,455 @@ +/** + * Session-keyed message store. + * + * Holds per-session state in a Map keyed by sessionId. + * Session switch = change activeSessionId pointer. No clearing. Old data stays. + * WebSocket handler = store.appendRealtime(msg.sessionId, msg). One line. + * No localStorage for messages. Backend JSONL is the source of truth. + */ + +import { useCallback, useMemo, useRef, useState } from 'react'; +import type { SessionProvider } from '../types/app'; +import { authenticatedFetch } from '../utils/api'; + +// ─── NormalizedMessage (mirrors server/adapters/types.js) ──────────────────── + +export type MessageKind = + | 'text' + | 'tool_use' + | 'tool_result' + | 'thinking' + | 'stream_delta' + | 'stream_end' + | 'error' + | 'complete' + | 'status' + | 'permission_request' + | 'permission_cancelled' + | 'session_created' + | 'interactive_prompt' + | 'task_notification'; + +export interface NormalizedMessage { + id: string; + sessionId: string; + timestamp: string; + provider: SessionProvider; + kind: MessageKind; + + // kind-specific fields (flat for simplicity) + role?: 'user' | 'assistant'; + content?: string; + images?: string[]; + toolName?: string; + toolInput?: unknown; + toolId?: string; + toolResult?: { content: string; isError: boolean; toolUseResult?: unknown } | null; + isError?: boolean; + text?: string; + tokens?: number; + canInterrupt?: boolean; + tokenBudget?: unknown; + requestId?: string; + input?: unknown; + context?: unknown; + newSessionId?: string; + status?: string; + summary?: string; + exitCode?: number; + actualSessionId?: string; + parentToolUseId?: string; + subagentTools?: unknown[]; + isFinal?: boolean; + // Cursor-specific ordering + sequence?: number; + rowid?: number; +} + +// ─── Per-session slot ──────────────────────────────────────────────────────── + +export type SessionStatus = 'idle' | 'loading' | 'streaming' | 'error'; + +export interface SessionSlot { + serverMessages: NormalizedMessage[]; + realtimeMessages: NormalizedMessage[]; + merged: NormalizedMessage[]; + /** @internal Cache-invalidation refs for computeMerged */ + _lastServerRef: NormalizedMessage[]; + _lastRealtimeRef: NormalizedMessage[]; + status: SessionStatus; + fetchedAt: number; + total: number; + hasMore: boolean; + offset: number; + tokenUsage: unknown; +} + +const EMPTY: NormalizedMessage[] = []; + +function createEmptySlot(): SessionSlot { + return { + serverMessages: EMPTY, + realtimeMessages: EMPTY, + merged: EMPTY, + _lastServerRef: EMPTY, + _lastRealtimeRef: EMPTY, + status: 'idle', + fetchedAt: 0, + total: 0, + hasMore: false, + offset: 0, + tokenUsage: null, + }; +} + +/** + * Compute merged messages: server + realtime, deduped by id. + * Server messages take priority (they're the persisted source of truth). + * Realtime messages that aren't yet in server stay (in-flight streaming). + */ +function computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] { + if (realtime.length === 0) return server; + if (server.length === 0) return realtime; + const serverIds = new Set(server.map(m => m.id)); + const extra = realtime.filter(m => !serverIds.has(m.id)); + if (extra.length === 0) return server; + return [...server, ...extra]; +} + +/** + * Recompute slot.merged only when the input arrays have actually changed + * (by reference). Returns true if merged was recomputed. + */ +function recomputeMergedIfNeeded(slot: SessionSlot): boolean { + if (slot.serverMessages === slot._lastServerRef && slot.realtimeMessages === slot._lastRealtimeRef) { + return false; + } + slot._lastServerRef = slot.serverMessages; + slot._lastRealtimeRef = slot.realtimeMessages; + slot.merged = computeMerged(slot.serverMessages, slot.realtimeMessages); + return true; +} + +// ─── Stale threshold ───────────────────────────────────────────────────────── + +const STALE_THRESHOLD_MS = 30_000; + +const MAX_REALTIME_MESSAGES = 500; + +// ─── Hook ──────────────────────────────────────────────────────────────────── + +export function useSessionStore() { + const storeRef = useRef(new Map()); + const activeSessionIdRef = useRef(null); + // Bump to force re-render — only when the active session's data changes + const [, setTick] = useState(0); + const notify = useCallback((sessionId: string) => { + if (sessionId === activeSessionIdRef.current) { + setTick(n => n + 1); + } + }, []); + + const setActiveSession = useCallback((sessionId: string | null) => { + activeSessionIdRef.current = sessionId; + }, []); + + const getSlot = useCallback((sessionId: string): SessionSlot => { + const store = storeRef.current; + if (!store.has(sessionId)) { + store.set(sessionId, createEmptySlot()); + } + return store.get(sessionId)!; + }, []); + + const has = useCallback((sessionId: string) => storeRef.current.has(sessionId), []); + + /** + * Fetch messages from the unified endpoint and populate serverMessages. + */ + const fetchFromServer = useCallback(async ( + sessionId: string, + opts: { + provider?: SessionProvider; + projectName?: string; + projectPath?: string; + limit?: number | null; + offset?: number; + } = {}, + ) => { + const slot = getSlot(sessionId); + slot.status = 'loading'; + notify(sessionId); + + try { + const params = new URLSearchParams(); + if (opts.provider) params.append('provider', opts.provider); + if (opts.projectName) params.append('projectName', opts.projectName); + if (opts.projectPath) params.append('projectPath', opts.projectPath); + if (opts.limit !== null && opts.limit !== undefined) { + params.append('limit', String(opts.limit)); + params.append('offset', String(opts.offset ?? 0)); + } + + const qs = params.toString(); + const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; + const response = await authenticatedFetch(url); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + const messages: NormalizedMessage[] = data.messages || []; + + slot.serverMessages = messages; + slot.total = data.total ?? messages.length; + slot.hasMore = Boolean(data.hasMore); + slot.offset = (opts.offset ?? 0) + messages.length; + slot.fetchedAt = Date.now(); + slot.status = 'idle'; + recomputeMergedIfNeeded(slot); + if (data.tokenUsage) { + slot.tokenUsage = data.tokenUsage; + } + + notify(sessionId); + return slot; + } catch (error) { + console.error(`[SessionStore] fetch failed for ${sessionId}:`, error); + slot.status = 'error'; + notify(sessionId); + return slot; + } + }, [getSlot, notify]); + + /** + * Load older (paginated) messages and prepend to serverMessages. + */ + const fetchMore = useCallback(async ( + sessionId: string, + opts: { + provider?: SessionProvider; + projectName?: string; + projectPath?: string; + limit?: number; + } = {}, + ) => { + const slot = getSlot(sessionId); + if (!slot.hasMore) return slot; + + const params = new URLSearchParams(); + if (opts.provider) params.append('provider', opts.provider); + if (opts.projectName) params.append('projectName', opts.projectName); + if (opts.projectPath) params.append('projectPath', opts.projectPath); + const limit = opts.limit ?? 20; + params.append('limit', String(limit)); + params.append('offset', String(slot.offset)); + + const qs = params.toString(); + const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; + + try { + const response = await authenticatedFetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const olderMessages: NormalizedMessage[] = data.messages || []; + + // Prepend older messages (they're earlier in the conversation) + slot.serverMessages = [...olderMessages, ...slot.serverMessages]; + slot.hasMore = Boolean(data.hasMore); + slot.offset = slot.offset + olderMessages.length; + recomputeMergedIfNeeded(slot); + notify(sessionId); + return slot; + } catch (error) { + console.error(`[SessionStore] fetchMore failed for ${sessionId}:`, error); + return slot; + } + }, [getSlot, notify]); + + /** + * Append a realtime (WebSocket) message to the correct session slot. + * This works regardless of which session is actively viewed. + */ + const appendRealtime = useCallback((sessionId: string, msg: NormalizedMessage) => { + const slot = getSlot(sessionId); + let updated = [...slot.realtimeMessages, msg]; + if (updated.length > MAX_REALTIME_MESSAGES) { + updated = updated.slice(-MAX_REALTIME_MESSAGES); + } + slot.realtimeMessages = updated; + recomputeMergedIfNeeded(slot); + notify(sessionId); + }, [getSlot, notify]); + + /** + * Append multiple realtime messages at once (batch). + */ + const appendRealtimeBatch = useCallback((sessionId: string, msgs: NormalizedMessage[]) => { + if (msgs.length === 0) return; + const slot = getSlot(sessionId); + let updated = [...slot.realtimeMessages, ...msgs]; + if (updated.length > MAX_REALTIME_MESSAGES) { + updated = updated.slice(-MAX_REALTIME_MESSAGES); + } + slot.realtimeMessages = updated; + recomputeMergedIfNeeded(slot); + notify(sessionId); + }, [getSlot, notify]); + + /** + * Re-fetch serverMessages from the unified endpoint (e.g., on projects_updated). + */ + const refreshFromServer = useCallback(async ( + sessionId: string, + opts: { + provider?: SessionProvider; + projectName?: string; + projectPath?: string; + } = {}, + ) => { + const slot = getSlot(sessionId); + try { + const params = new URLSearchParams(); + if (opts.provider) params.append('provider', opts.provider); + if (opts.projectName) params.append('projectName', opts.projectName); + if (opts.projectPath) params.append('projectPath', opts.projectPath); + + const qs = params.toString(); + const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`; + const response = await authenticatedFetch(url); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + + slot.serverMessages = data.messages || []; + slot.total = data.total ?? slot.serverMessages.length; + slot.hasMore = Boolean(data.hasMore); + slot.fetchedAt = Date.now(); + // drop realtime messages that the server has caught up with to prevent unbounded growth. + slot.realtimeMessages = []; + recomputeMergedIfNeeded(slot); + notify(sessionId); + } catch (error) { + console.error(`[SessionStore] refresh failed for ${sessionId}:`, error); + } + }, [getSlot, notify]); + + /** + * Update session status. + */ + const setStatus = useCallback((sessionId: string, status: SessionStatus) => { + const slot = getSlot(sessionId); + slot.status = status; + notify(sessionId); + }, [getSlot, notify]); + + /** + * Check if a session's data is stale (>30s old). + */ + const isStale = useCallback((sessionId: string) => { + const slot = storeRef.current.get(sessionId); + if (!slot) return true; + return Date.now() - slot.fetchedAt > STALE_THRESHOLD_MS; + }, []); + + /** + * Update or create a streaming message (accumulated text so far). + * Uses a well-known ID so subsequent calls replace the same message. + */ + const updateStreaming = useCallback((sessionId: string, accumulatedText: string, msgProvider: SessionProvider) => { + const slot = getSlot(sessionId); + const streamId = `__streaming_${sessionId}`; + const msg: NormalizedMessage = { + id: streamId, + sessionId, + timestamp: new Date().toISOString(), + provider: msgProvider, + kind: 'stream_delta', + content: accumulatedText, + }; + const idx = slot.realtimeMessages.findIndex(m => m.id === streamId); + if (idx >= 0) { + slot.realtimeMessages = [...slot.realtimeMessages]; + slot.realtimeMessages[idx] = msg; + } else { + slot.realtimeMessages = [...slot.realtimeMessages, msg]; + } + recomputeMergedIfNeeded(slot); + notify(sessionId); + }, [getSlot, notify]); + + /** + * Finalize streaming: convert the streaming message to a regular text message. + * The well-known streaming ID is replaced with a unique text message ID. + */ + const finalizeStreaming = useCallback((sessionId: string) => { + const slot = storeRef.current.get(sessionId); + if (!slot) return; + const streamId = `__streaming_${sessionId}`; + const idx = slot.realtimeMessages.findIndex(m => m.id === streamId); + if (idx >= 0) { + const stream = slot.realtimeMessages[idx]; + slot.realtimeMessages = [...slot.realtimeMessages]; + slot.realtimeMessages[idx] = { + ...stream, + id: `text_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + kind: 'text', + role: 'assistant', + }; + recomputeMergedIfNeeded(slot); + notify(sessionId); + } + }, [notify]); + + /** + * Clear realtime messages for a session (e.g., after stream completes and server fetch catches up). + */ + const clearRealtime = useCallback((sessionId: string) => { + const slot = storeRef.current.get(sessionId); + if (slot) { + slot.realtimeMessages = []; + recomputeMergedIfNeeded(slot); + notify(sessionId); + } + }, [notify]); + + /** + * Get merged messages for a session (for rendering). + */ + const getMessages = useCallback((sessionId: string): NormalizedMessage[] => { + return storeRef.current.get(sessionId)?.merged ?? []; + }, []); + + /** + * Get session slot (for status, pagination info, etc.). + */ + const getSessionSlot = useCallback((sessionId: string): SessionSlot | undefined => { + return storeRef.current.get(sessionId); + }, []); + + return useMemo(() => ({ + getSlot, + has, + fetchFromServer, + fetchMore, + appendRealtime, + appendRealtimeBatch, + refreshFromServer, + setActiveSession, + setStatus, + isStale, + updateStreaming, + finalizeStreaming, + clearRealtime, + getMessages, + getSessionSlot, + }), [ + getSlot, has, fetchFromServer, fetchMore, + appendRealtime, appendRealtimeBatch, refreshFromServer, + setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming, + clearRealtime, getMessages, getSessionSlot, + ]); +} + +export type SessionStore = ReturnType; diff --git a/src/utils/api.js b/src/utils/api.js index e23c9ef..a3292b2 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -54,25 +54,18 @@ export const api = { projects: () => authenticatedFetch('/api/projects'), sessions: (projectName, limit = 5, offset = 0) => authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`), - sessionMessages: (projectName, sessionId, limit = null, offset = 0, provider = 'claude') => { + // Unified endpoint — all providers through one URL + unifiedSessionMessages: (sessionId, provider = 'claude', { projectName = '', projectPath = '', limit = null, offset = 0 } = {}) => { const params = new URLSearchParams(); + params.append('provider', provider); + if (projectName) params.append('projectName', projectName); + if (projectPath) params.append('projectPath', projectPath); if (limit !== null) { - params.append('limit', limit); - params.append('offset', offset); + params.append('limit', String(limit)); + params.append('offset', String(offset)); } const queryString = params.toString(); - - let url; - if (provider === 'codex') { - url = `/api/codex/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`; - } else if (provider === 'cursor') { - url = `/api/cursor/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`; - } else if (provider === 'gemini') { - url = `/api/gemini/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`; - } else { - url = `/api/projects/${projectName}/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`; - } - return authenticatedFetch(url); + return authenticatedFetch(`/api/sessions/${encodeURIComponent(sessionId)}/messages${queryString ? `?${queryString}` : ''}`); }, renameProject: (projectName, displayName) => authenticatedFetch(`/api/projects/${projectName}/rename`, {