/** * Composable for managing Agent Chat state and messages. * Handles message sending, receiving, and cancellation. */ import { ref, computed } from 'vue'; import type { AgentMessage, AgentActRequest, AgentActRequestClientMeta, AgentAttachment, RealtimeEvent, AgentStatusEvent, AgentCliPreference, AgentUsageStats, } from 'chrome-mcp-shared'; /** * Request lifecycle state. * - 'idle': No active request * - 'starting': Request accepted, waiting for engine initialization * - 'ready': Engine initialized, preparing to run * - 'running': Engine actively processing (may emit tool_use/tool_result) * - 'completed': Request finished successfully * - 'cancelled': Request was cancelled by user * - 'error': Request failed with error */ export type RequestState = 'idle' | AgentStatusEvent['status']; export interface UseAgentChatOptions { getServerPort: () => number | null; getSessionId: () => string; ensureServer: () => Promise; openEventSource: () => void; } export function useAgentChat(options: UseAgentChatOptions) { // State const messages = ref([]); const input = ref(''); const sending = ref(false); /** * Message-level streaming state. * True when receiving delta updates for assistant/tool messages. * Note: This is separate from requestState - a request can be 'running' * even when isStreaming is false (e.g., during tool execution). */ const isStreaming = ref(false); /** * Request lifecycle state driven by status events. * Use this (via isRequestActive) for UI elements like stop button, * loading indicators, and running badges. */ const requestState = ref('idle'); const errorMessage = ref(null); const currentRequestId = ref(null); const cancelling = ref(false); const attachments = ref([]); const lastUsage = ref(null); // Computed const canSend = computed(() => { return input.value.trim().length > 0 && !sending.value; }); /** * Whether there is an active request in progress. * Use this for UI elements like stop button, loading indicators, and running badges. */ const isRequestActive = computed(() => { return ( requestState.value === 'starting' || requestState.value === 'ready' || requestState.value === 'running' ); }); /** * Check if an incoming event belongs to a different active request. * Used to filter out stale events from previous requests. */ function isDifferentActiveRequest(incomingRequestId?: string): boolean { const incoming = incomingRequestId?.trim(); const current = currentRequestId.value?.trim(); // No incoming ID or no current ID means we can't determine - don't filter if (!incoming || !current) return false; // Same request ID - don't filter if (incoming === current) return false; // Different request ID while we have an active request - filter it out return isRequestActive.value; } /** * Handle incoming realtime events. * Events are filtered by sessionId to prevent cross-session state pollution * when user switches sessions while SSE connection is still active. */ function handleRealtimeEvent(event: RealtimeEvent): void { const currentSessionId = options.getSessionId(); switch (event.type) { case 'message': // Guard: only handle messages for the current session if (event.data.sessionId !== currentSessionId) { return; } handleMessageEvent(event.data); break; case 'status': // Guard: only handle status for the current session if (event.data.sessionId !== currentSessionId) { return; } handleStatusEvent(event.data); break; case 'error': // Error events may not have sessionId, but if they do, filter if (event.data?.sessionId && event.data.sessionId !== currentSessionId) { return; } // Filter out errors from different active requests if (isDifferentActiveRequest(event.data?.requestId)) { return; } errorMessage.value = event.error; isStreaming.value = false; requestState.value = 'error'; // Clear requestId if it matches the error event's requestId (or unconditionally if no requestId in error) if (!event.data?.requestId || event.data.requestId === currentRequestId.value) { currentRequestId.value = null; } break; case 'connected': console.log('[AgentChat] Connected to session:', event.data.sessionId); break; case 'heartbeat': // Heartbeat received, connection is alive break; case 'usage': // Guard: only accept usage for the current session if (event.data?.sessionId && event.data.sessionId !== currentSessionId) { return; } lastUsage.value = event.data; break; } } // Handle message events function handleMessageEvent(msg: AgentMessage): void { // For user messages from server, replace local optimistic message // Server echoes user message with real id/metadata, but we want to keep our display text // (which doesn't include injected context like web editor selection) if (msg.role === 'user' && msg.requestId) { const optimisticIndex = messages.value.findIndex( (m) => m.role === 'user' && m.requestId === msg.requestId && m.id.startsWith('temp-'), ); if (optimisticIndex >= 0) { // Replace optimistic message: keep display content, update id and metadata const optimistic = messages.value[optimisticIndex]; messages.value[optimisticIndex] = { ...msg, // Preserve the display content (user's raw input without injected context) content: optimistic.content, // Prefer server metadata, fallback to optimistic metadata (for chip rendering) metadata: msg.metadata ?? optimistic.metadata, }; return; } } // Check if this message belongs to a different active request // Note: We still save the message to messages array (for auditing/replay), // but skip state updates if it's from a stale request const msgRequestId = msg.requestId?.trim() || undefined; const isStaleForState = isDifferentActiveRequest(msgRequestId); const existingIndex = messages.value.findIndex((m) => m.id === msg.id); if (existingIndex >= 0) { // Update existing message (streaming update) messages.value[existingIndex] = msg; } else { // Add new message - always save, even if stale for state messages.value.push(msg); } // Skip state updates for messages from different active requests if (isStaleForState) { return; } // Track requestId from messages (handles cases where status events were missed) if (msgRequestId && msgRequestId !== currentRequestId.value) { currentRequestId.value = msgRequestId; } // Update message-level streaming state (delta updates) // Note: This does NOT affect requestState - tool_use with isStreaming=false // should not stop the overall request, only indicate this message is complete if (msg.role === 'assistant' || msg.role === 'tool') { isStreaming.value = msg.isStreaming === true && !msg.isFinal; // If we're receiving model/tool output but requestState hasn't progressed to 'running', // update it. This handles: // 1. Edge case where status events were missed due to SSE timing // 2. User enters AgentChat mid-request (e.g., from Quick Panel/toolbar trigger) // 3. SSE reconnection after temporary disconnect if ( requestState.value === 'idle' || requestState.value === 'starting' || requestState.value === 'ready' ) { requestState.value = 'running'; } } } // Handle status events function handleStatusEvent(status: AgentStatusEvent): void { const statusRequestId = status.requestId?.trim() || undefined; // Filter out status events from different active requests if (isDifferentActiveRequest(statusRequestId)) { return; } // Track requestId from status events if (statusRequestId && statusRequestId !== currentRequestId.value) { currentRequestId.value = statusRequestId; } // Update request lifecycle state (driven by status events only) requestState.value = status.status; switch (status.status) { case 'starting': case 'ready': case 'running': // Request is active - no additional state changes needed break; case 'completed': case 'error': case 'cancelled': // Request finished - clear message streaming and requestId isStreaming.value = false; // Reset cancelling state (in case we were waiting for SSE confirmation) cancelling.value = false; if (!statusRequestId || statusRequestId === currentRequestId.value) { currentRequestId.value = null; } break; } } // Send message async function send( chatOptions: { cliPreference?: string; model?: string; projectId?: string; projectRoot?: string; dbSessionId?: string; /** * Optional instruction to send instead of input.value. * When provided, this is used as the actual instruction sent to the server, * while input.value is still used for UI display in the optimistic message. * This is useful for injecting context (e.g., web editor selection) into the prompt * without showing it in the chat UI. */ instruction?: string; /** * Optional compact display text stored in the user message metadata. * When provided, the UI can render a special header (e.g., a chip) instead * of the raw prompt content. */ displayText?: string; /** * Optional client metadata to persist with the user message. * Used for special UI rendering (e.g., web editor apply/selection chips). */ clientMeta?: AgentActRequestClientMeta; } = {}, ): Promise { // User-visible content is always the user's raw input const userText = input.value.trim(); // Actual instruction sent to server can be overridden (e.g., with context prepended) const instructionText = chatOptions.instruction?.trim() || userText; if (!userText) return; const ready = await options.ensureServer(); const serverPort = options.getServerPort(); const sessionId = options.getSessionId(); if (!ready || !serverPort) { errorMessage.value = 'Agent server is not available.'; return; } // Ensure SSE is connected before sending options.openEventSource(); // Generate requestId on client side for optimistic message matching // Server will use this requestId when echoing user message via SSE const requestId = crypto.randomUUID(); // Create optimistic user message for immediate feedback // Note: Use userText for UI, not instructionText (which may contain injected context) const tempMessageId = `temp-${Date.now()}`; const optimisticMessage: AgentMessage = { id: tempMessageId, sessionId: sessionId, role: 'user', content: userText, messageType: 'chat', requestId, // Include requestId so we can match with server-echoed message createdAt: new Date().toISOString(), // Include metadata for immediate chip rendering (before server echo) metadata: chatOptions.displayText || chatOptions.clientMeta ? { displayText: chatOptions.displayText?.trim(), clientMeta: chatOptions.clientMeta, } : undefined, }; // Add user message immediately messages.value.push(optimisticMessage); const payload: AgentActRequest = { // Use instructionText which may include injected context (e.g., web editor selection) instruction: instructionText, requestId, // Send requestId to server so it can be used in SSE events // Optional metadata for special UI rendering (stored with the user message) displayText: chatOptions.displayText?.trim() || undefined, clientMeta: chatOptions.clientMeta, cliPreference: chatOptions.cliPreference ? (chatOptions.cliPreference as AgentCliPreference) : undefined, model: chatOptions.model?.trim() || undefined, projectId: chatOptions.projectId || undefined, projectRoot: chatOptions.projectRoot?.trim() || undefined, dbSessionId: chatOptions.dbSessionId || undefined, attachments: attachments.value.length > 0 ? attachments.value : undefined, }; sending.value = true; // Initialize request lifecycle state - request begins once we dispatch /act requestState.value = 'starting'; currentRequestId.value = requestId; // Reset message-level streaming; it will be driven by message.isStreaming deltas isStreaming.value = false; errorMessage.value = null; // Clear input immediately for better UX const savedInput = input.value; input.value = ''; const savedAttachments = [...attachments.value]; attachments.value = []; try { const url = `http://127.0.0.1:${serverPort}/agent/chat/${encodeURIComponent(sessionId)}/act`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(text || `HTTP ${response.status}`); } const result = await response.json().catch(() => ({})); // Guard: only update state if we're still on the same session // This prevents cross-session state pollution when user switches during request const currentSessionId = options.getSessionId(); if (currentSessionId !== sessionId) { // Session changed during request - discard result silently // The optimistic message will be cleared when messages are reloaded isStreaming.value = false; requestState.value = 'idle'; currentRequestId.value = null; return; } // Update currentRequestId from response (should match our client-generated one) // This is used for cancel functionality if (result.requestId) { currentRequestId.value = result.requestId; } else { // Fallback: use our client-generated requestId currentRequestId.value = requestId; } } catch (error: unknown) { // Guard: only handle error if still on same session const currentSessionId = options.getSessionId(); if (currentSessionId !== sessionId) { isStreaming.value = false; requestState.value = 'idle'; currentRequestId.value = null; return; } console.error('Failed to send agent act request:', error); errorMessage.value = error instanceof Error ? error.message : 'Failed to send request to agent server.'; // Restore input on error input.value = savedInput; attachments.value = savedAttachments; // Remove optimistic message on error const msgIndex = messages.value.findIndex((m) => m.id === tempMessageId); if (msgIndex >= 0) { messages.value.splice(msgIndex, 1); } isStreaming.value = false; requestState.value = 'idle'; currentRequestId.value = null; } finally { sending.value = false; } } // Cancel current request async function cancelCurrentRequest(): Promise { if (!currentRequestId.value) return; const serverPort = options.getServerPort(); const sessionId = options.getSessionId(); if (!serverPort) return; cancelling.value = true; try { const url = `http://127.0.0.1:${serverPort}/agent/chat/${encodeURIComponent(sessionId)}/cancel/${encodeURIComponent(currentRequestId.value)}`; const response = await fetch(url, { method: 'DELETE' }); const data = await response.json().catch(() => null); // Check if cancel was successful // Backend returns { success: boolean, message?: string } const isSuccess = response.ok && data?.success !== false; if (!isSuccess) { // Cancel failed - show error but keep request state intact // so user can try again or wait for natural completion const errorMsg = data?.message || `Failed to cancel request (HTTP ${response.status})`; console.error('Cancel request failed:', errorMsg); errorMessage.value = errorMsg; return; } // Cancel request sent successfully // Note: We intentionally do NOT clear currentRequestId/requestState here // The actual state cleanup will happen when we receive the 'cancelled' status event via SSE // This ensures UI stays consistent with backend state and avoids race conditions // Keep cancelling=true so UI shows "Stopping..." until SSE confirms // cancelling will be reset when handleStatusEvent receives 'cancelled' status } catch (error) { console.error('Failed to cancel request:', error); errorMessage.value = error instanceof Error ? error.message : 'Failed to cancel request'; // Only reset cancelling on error, not on success cancelling.value = false; } } // Clear messages function clearMessages(): void { messages.value = []; } // Set messages (for loading history) function setMessages(newMessages: AgentMessage[]): void { messages.value = newMessages; } return { // State messages, input, sending, isStreaming, requestState, errorMessage, currentRequestId, cancelling, attachments, lastUsage, // Computed canSend, isRequestActive, // Methods handleRealtimeEvent, send, cancelCurrentRequest, clearMessages, setMessages, }; }