import { createContext, useContextSelector } from 'use-context-selector'; import { ReactNode, useMemo, useCallback, useRef, useEffect } from 'react'; import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; import { v4 as uuidv4 } from 'uuid'; import { API_BASE_URL, TARGET_ENV, ALLOW_ACTIONPANEL_AI_REQUESTS, PLUGIN_REST_ENDPOINTS, getRestNonce } from '../constants'; import { useState } from 'react'; import { usePluginSettings } from './PluginSettingsContext'; import { type ConversationsPage, type ConversationsPagesData, flattenConversationPages, normalizeConversationsPage, prependConversationToPages, removeConversationFromPages, updateConversationTitleInPages, } from '../utils/conversationPages'; import type { ChatActivity, ChatApprovalState, ChatBackendStreamEvent, ChatRuntimeUiEvent, FinalizedThinkingSummary, ThinkingTimelineEntry, } from '../types/chatStream'; import { useSetIsRateLimitModalOpen } from './UIContext'; import { useOpenProposeChanges } from './UIContext'; import type { ChatAttachmentInput } from '../services/attachments'; import { createBrowserCommandRunner } from '../services/v1BrowserCommands'; import { isV1ErrorEvent, isV1FinalEvent, isV1PauseEvent, type V1ChatHttpResponse, type V1ChatResponseEvent, type V1ChatStreamEnvelope, } from '../services/v1ChatProtocol'; import { buildV1RuntimeContext, createFetchV1CommandClient, executeV1PauseEvent, fetchPluginV1RuntimeDiscovery, type V1CommandApprovalRequest, type V1CommandApprovalPolicy, type V1CommandRuntimeEvent, type V1PauseEvent, type V1PauseResumePayload, type V1RuntimeContext, } from '../services/v1CommandRuntime'; const DEBUG_UI = false; const CONVERSATIONS_PAGE_SIZE = 30; const TITLE_REFRESH_DELAYS_MS = [1200, 2400, 4800, 8000]; declare global { interface Window { __actionpanelV1DeterministicHelper?: { executePause: ( pause: V1PauseEvent, options?: { conversationId?: string } ) => Promise; }; } } // Empty arrays for stable references const EMPTY_MESSAGES: Message[] = []; const EMPTY_CONVERSATIONS: Conversation[] = []; const EMPTY_ARRAY: any[] = []; // Message interface export interface Message { message_id: string; sent_at: number; role: 'user' | 'assistant' | 'assistant-streaming' | 'error'; content: string; attachments?: Array<{ kind: 'image'; name?: string; url?: string; display_url?: string; mime?: string; attachment_id?: number; width?: number; height?: number; }>; status?: 'complete' | 'interrupted'; finishReason?: string | null; userMessageId?: string; } function toDisplayAttachments(attachments?: ChatAttachmentInput[]): Message['attachments'] { if (!attachments?.length) return undefined; return attachments.map(attachment => ({ kind: attachment.kind, name: attachment.name, url: attachment.url, display_url: attachment.url ?? attachment.data_url, mime: attachment.mime, attachment_id: attachment.attachment_id, width: attachment.width, height: attachment.height, })); } // Conversation metadata interface export interface Conversation { conversation_id: string; title: string; created_at: number; updated_at: number; } // Client-only conversation state interface EphemeralConversationState { phase: 'IDLE' | 'THINKING' | 'STREAMING' | 'ERROR'; isPersisted: boolean; // On server activities: ChatActivity[]; approvalState?: ChatApprovalState; timeline: ThinkingTimelineEntry[]; hasStartedStreaming: boolean; startedAt?: number; thoughtDurationMs?: number; } // Chat provider props interface interface ChatProviderProps { children: ReactNode; } // Types for currently streaming message type PartialContentsMap = Record; type LatestChunksMap = Record; interface OptimisticCancelledMessage { latestUserSentAt: number; latestUserContent: string; } // Chat context interface interface ChatContextProps { currentConversationId: string | null; setCurrentConversationId: (conversationId: string | null) => void; conversations: Conversation[]; messages: Message[]; conversationsLoading: boolean; hasMoreConversations: boolean; isFetchingMoreConversations: boolean; messagesLoading: boolean; ephemeralMap: Record; finalizedThinkingMap: Record; stoppedWorkingMap: Record; sendMessage: (message: string, attachments?: ChatAttachmentInput[]) => Promise; cancelCurrentStream: () => void; editAndResubmitLatestUserMessage: (messageId: string, text: string) => Promise; retryError: (userMessageId: string) => Promise; dismissError: (userMessageId: string) => Promise; deleteConversation: (conversationId: string) => Promise; renameConversation: (conversationId: string, title: string) => Promise; loadMoreConversations: () => Promise; setOnStreamChunk?: (cb?: (msgId: string, partialText: string, opts?: { isFinal?: boolean }) => void) => void; partialContents: PartialContentsMap; latestChunks: LatestChunksMap; } // Default context value (for typescript) const defaultContextValue: ChatContextProps = { currentConversationId: null, setCurrentConversationId: () => {}, conversations: [], messages: [], conversationsLoading: false, hasMoreConversations: false, isFetchingMoreConversations: false, messagesLoading: false, ephemeralMap: {}, finalizedThinkingMap: {}, stoppedWorkingMap: {}, sendMessage: async () => {}, cancelCurrentStream: () => {}, editAndResubmitLatestUserMessage: async () => {}, retryError: async () => {}, dismissError: async () => {}, deleteConversation: async () => {}, renameConversation: async () => {}, loadMoreConversations: async () => {}, partialContents: {}, latestChunks: {}, }; // Chat context const ChatContext = createContext(defaultContextValue); const DEFAULT_EPHEMERAL_STATE: EphemeralConversationState = { phase: 'IDLE', isPersisted: false, activities: [], timeline: [], hasStartedStreaming: false, }; const CANCEL_REFETCH_DELAYS_MS = [750, 2000, 5000]; const isVisibleToolActivity = (name?: string) => ( name === 'site_read' || name === 'content_read' || name === 'content_write' || name === 'site_write' || name === 'v1.command' ); const buildActivityLabel = (name?: string, target?: string) => { const targetLabel = target?.trim(); if (name === 'site_read') return targetLabel ? `Checking ${targetLabel}` : 'Checking site'; if (name === 'content_read') return targetLabel ? `Reading ${targetLabel}` : 'Reading page content'; if (name === 'content_write') return targetLabel ? `Editing ${targetLabel}` : 'Editing page content'; if (name === 'site_write') return targetLabel ? `Preparing ${targetLabel}` : 'Preparing changes'; if (name === 'v1.command') return 'Working with your site'; if (targetLabel) return targetLabel; return 'Working'; }; const compactStepLabel = (value?: unknown, fallback = 'Working') => { const label = (value == null ? '' : String(value)).replace(/\s+/g, ' ').trim(); if (!label) return fallback; if (label.length <= 140) return label; const boundary = label.lastIndexOf(' ', 136); return `${label.slice(0, boundary > 48 ? boundary : 137).trim()}...`; }; const toolTimelineId = (requestId: string, batchId?: string) => ( `tool-${batchId || 'direct'}-${requestId}` ); const buildConversationTurnKey = (conversationId: string, userTurnIndex: number) => `${conversationId}:turn:${userTurnIndex}`; const buildFallbackConversationTitle = (messageContent: string) => { const words = messageContent.trim().split(/\s+/).filter(Boolean); if (words.length === 0) return 'New Chat'; const title = words.slice(0, 6).join(' ').replace(/^[.,!?;:\s]+|[.,!?;:\s]+$/g, ''); if (!title) return 'New Chat'; return title.length <= 60 ? title : `${title.slice(0, 57).trimEnd()}...`; }; const isToolHarnessBridgeEnabled = () => ( typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('actionpanel_tool_harness') === '1' ); const getV1CallingSurface = () => ( TARGET_ENV === 'wordpress' ? 'wp_admin_plugin_ui' : 'hosted_web_app' ); type V1ChatSource = 'web' | 'plugin'; const getV1ChatSource = (): V1ChatSource => ( TARGET_ENV === 'wordpress' ? 'plugin' : 'web' ); type V1RuntimeApprovalEvent = Extract< V1CommandRuntimeEvent, { type: 'approval_needed' | 'approval_granted' | 'approval_rejected' } >; const getV1RuntimeApprovalState = ( event: V1RuntimeApprovalEvent ): Extract['state'] => { switch (event.type) { case 'approval_needed': return 'needed'; case 'approval_granted': return 'granted'; case 'approval_rejected': return 'rejected'; } }; const readV1NdjsonResponse = async ( response: Response, onStreamEnvelope?: (envelope: V1ChatStreamEnvelope) => Promise, ): Promise => { const reader = response.body?.getReader(); if (!reader) { return await response.json(); } const decoder = new TextDecoder(); let buffered = ''; let terminalEnvelope: V1ChatHttpResponse | null = null; const consumeLine = async (line: string) => { const trimmed = line.trim(); if (!trimmed) return; const envelope = JSON.parse(trimmed) as V1ChatStreamEnvelope; if (envelope.outcome === 'streaming') { await onStreamEnvelope?.(envelope); return; } terminalEnvelope = envelope as V1ChatHttpResponse; }; while (true) { const { done, value } = await reader.read(); if (value) { buffered += decoder.decode(value, { stream: !done }); let newlineIndex = buffered.indexOf('\n'); while (newlineIndex >= 0) { const line = buffered.slice(0, newlineIndex); buffered = buffered.slice(newlineIndex + 1); await consumeLine(line); newlineIndex = buffered.indexOf('\n'); } } if (done) break; } buffered += decoder.decode(); await consumeLine(buffered); return terminalEnvelope; }; // Chat provider component export const ChatProvider = ({ children }: ChatProviderProps) => { // React Query client const queryClient = useQueryClient(); // State variables const [currentConversationId, setCurrentConversationId] = useState(null); const [partialContents, setPartialContents] = useState({}); const [latestChunks, setLatestChunks] = useState({}); const [ephemeralMap, setEphemeralMap] = useState>({}); const [finalizedThinkingMap, setFinalizedThinkingMap] = useState>({}); const [stoppedWorkingMap, setStoppedWorkingMap] = useState>({}); const setIsRateLimitModalOpen = useSetIsRateLimitModalOpen(); const openProposeChanges = useOpenProposeChanges(); const pluginSettings = usePluginSettings(); // Reader and abort controller refs const readerRefs = useRef>({}); const abortRefs = useRef>({}); const activeTurnRefs = useRef>({}); const cancellationRequestedRef = useRef>({}); const optimisticCancelledMessagesRef = useRef>({}); const ephemeralMapRef = useRef>({}); const v1ApprovalPolicyByConversationRef = useRef>({}); const titleRefreshTimersRef = useRef>({}); // Stream callback ref to update partial content const streamCallbackRef = useRef<(msgId: string, partialText: string, opts?: { isFinal?: boolean }) => void>(); useEffect(() => () => { Object.values(titleRefreshTimersRef.current) .flat() .forEach(timerId => window.clearTimeout(timerId)); titleRefreshTimersRef.current = {}; }, []); const setStoppedWorkingSummary = useCallback((conversationId: string, summary: FinalizedThinkingSummary | null) => { setStoppedWorkingMap(prev => { if (!summary) { if (!(conversationId in prev)) return prev; const copy = { ...prev }; delete copy[conversationId]; return copy; } return { ...prev, [conversationId]: summary }; }); }, []); // ================= // // REACT QUERY STUFF // // ================= // const allowActionPanelAiRequests = TARGET_ENV === 'wordpress' ? pluginSettings.allowActionPanelAiRequests : ALLOW_ACTIONPANEL_AI_REQUESTS; const requireReadApproval = Boolean(pluginSettings.requireReadApproval); const updateConversationState = useCallback(( conversationId: string, partial: Partial ): EphemeralConversationState => { let newState: EphemeralConversationState; setEphemeralMap(prev => { const oldState = prev[conversationId] ?? DEFAULT_EPHEMERAL_STATE; newState = { ...oldState, ...partial }; if ( oldState.phase === newState.phase && oldState.isPersisted === newState.isPersisted && oldState.timeline === newState.timeline && oldState.hasStartedStreaming === newState.hasStartedStreaming && oldState.startedAt === newState.startedAt && oldState.thoughtDurationMs === newState.thoughtDurationMs && oldState.activities === newState.activities && oldState.approvalState === newState.approvalState ) { return prev; } const nextMap = { ...prev, [conversationId]: newState }; ephemeralMapRef.current = nextMap; return nextMap; }); return newState!; }, []); // Function to fetch one page of conversations const fetchConversationsPage = useCallback(async (cursor: string | null): Promise => { if (!allowActionPanelAiRequests) { return { items: [], next_cursor: null }; } const params = new URLSearchParams({ limit: String(CONVERSATIONS_PAGE_SIZE) }); if (cursor) { params.set('cursor', cursor); } const response = await fetch(`${API_BASE_URL}/conversations?${params.toString()}`, { method: 'GET', credentials: 'include', }); if (!response.ok) { throw new Error('Failed to fetch conversations'); } const page = normalizeConversationsPage(await response.json()); page.items.forEach((conversation: Conversation) => { updateConversationState(conversation.conversation_id, { ...DEFAULT_EPHEMERAL_STATE, isPersisted: true, }); }); return page; }, [allowActionPanelAiRequests, updateConversationState]); const fetchMessagesForConversation = useCallback(async (conversationId: string): Promise => { if (!allowActionPanelAiRequests) return []; const response = await fetch(`${API_BASE_URL}/conversations/${conversationId}`, { method: 'GET', credentials: 'include', }); if (!response.ok) { throw new Error('Failed to fetch messages'); } const data = await response.json(); // Map `message_id` to `id` const mappedMessages: Message[] = data.messages.map((msg: any) => ({ message_id: msg.message_id, sent_at: msg.sent_at, role: msg.role, content: typeof msg.content === 'string' ? msg.content : '', attachments: Array.isArray(msg.attachments) ? msg.attachments : undefined, status: msg.status ?? 'complete', finishReason: msg.finish_reason ?? null, })); const optimisticCancellation = optimisticCancelledMessagesRef.current[conversationId]; if (!optimisticCancellation) { return mappedMessages; } const serverCancelledUserIndex = mappedMessages.reduce((latestIndex, message, index) => ( message.role === 'user' && ( message.sent_at >= optimisticCancellation.latestUserSentAt || message.content === optimisticCancellation.latestUserContent ) ? index : latestIndex ), -1); const serverAssistantAfterCancelledTurn = mappedMessages.find((message, index) => { const isAfterCancelledTurn = ( message.sent_at > optimisticCancellation.latestUserSentAt || (serverCancelledUserIndex >= 0 && index > serverCancelledUserIndex) ); const isDisplayableAssistant = ( message.role === 'assistant' && (message.content.trim() || message.status === 'interrupted') ); return isAfterCancelledTurn && isDisplayableAssistant; }); if (serverAssistantAfterCancelledTurn) { delete optimisticCancelledMessagesRef.current[conversationId]; setStoppedWorkingSummary(conversationId, null); return mappedMessages; } const cachedMessages = queryClient.getQueryData(['messages', conversationId]) || EMPTY_MESSAGES; const hasServerUserAfterCancelledTurn = serverCancelledUserIndex >= 0; const localCancelledTurnMessages = cachedMessages.filter(message => ( message.sent_at >= optimisticCancellation.latestUserSentAt && !(hasServerUserAfterCancelledTurn && message.role === 'user') && !mappedMessages.some(serverMessage => serverMessage.message_id === message.message_id) )); return [...mappedMessages, ...localCancelledTurnMessages].sort((left, right) => left.sent_at - right.sent_at); }, [allowActionPanelAiRequests, queryClient, setStoppedWorkingSummary]); // Function to fetch messages for current conversation const fetchMessages = useCallback(async (): Promise => { if (!currentConversationId) return []; return fetchMessagesForConversation(currentConversationId); }, [currentConversationId, fetchMessagesForConversation]); // React Query hook to fetch conversations const { data: conversationPages, isFetching: conversationsFetching, fetchNextPage: fetchNextConversationsPage, hasNextPage: hasMoreConversations = false, isFetchingNextPage: isFetchingMoreConversations, } = useInfiniteQuery({ queryKey: ['conversations'], queryFn: ({ pageParam }) => fetchConversationsPage(pageParam), initialPageParam: null, getNextPageParam: (lastPage) => lastPage.next_cursor, enabled: allowActionPanelAiRequests && Object.values(ephemeralMap).every(state => state.phase == 'IDLE'), retry: false, staleTime: 30000, }); // Conversation state variable const conversations = useMemo(() => { const flattened = flattenConversationPages(conversationPages); return flattened.length > 0 ? flattened : EMPTY_CONVERSATIONS; }, [conversationPages]); // React Query hook to fetch messages for current conversation const { data: rawMessages = EMPTY_MESSAGES, isFetching: messagesFetching, } = useQuery({ queryKey: ['messages', currentConversationId], queryFn: fetchMessages, enabled: ( allowActionPanelAiRequests && !!currentConversationId && (ephemeralMap[currentConversationId]?.phase ?? 'IDLE') === 'IDLE' && (ephemeralMap[currentConversationId]?.isPersisted ?? true) ), staleTime: 1000 * 60, // 1 minute gcTime: 1000 * 60, // 1 minute }); // ================ // // HELPER FUNCTIONS // // ================ // // Update the ephemeral state of a conversation // Add user message to an existing conversation const addNewMessage = useCallback(( role: 'user' | 'assistant' | 'error', messageContent: string, targetConversationId: string, userMessageId?: string, metadata?: Pick ) => { // Create user message const newMessageId = uuidv4(); const newMessage: Message = { message_id: newMessageId, sent_at: Date.now(), role: role, content: messageContent, status: metadata?.status, finishReason: metadata?.finishReason, attachments: metadata?.attachments, userMessageId: userMessageId, }; // Add user message to react query queryClient.setQueryData( ['messages', targetConversationId], (old = []) => [...old, newMessage] ); return newMessageId; }, [queryClient]); const clearTitleRefreshTimers = useCallback((conversationId: string) => { titleRefreshTimersRef.current[conversationId]?.forEach(timerId => window.clearTimeout(timerId)); delete titleRefreshTimersRef.current[conversationId]; }, []); const applyConversationTitle = useCallback((conversationId: string, title: string) => { queryClient.setQueryData( ['conversations'], (old) => updateConversationTitleInPages(old, conversationId, title) ); }, [queryClient]); const scheduleGeneratedTitleRefresh = useCallback((conversationId: string, fallbackTitle: string) => { clearTitleRefreshTimers(conversationId); titleRefreshTimersRef.current[conversationId] = TITLE_REFRESH_DELAYS_MS.map((delayMs, index) => ( window.setTimeout(async () => { try { const response = await fetch(`${API_BASE_URL}/conversations/${conversationId}/metadata`, { method: 'GET', credentials: 'include', }); if (!response.ok) return; const payload = await response.json(); const generatedTitle = typeof payload.title === 'string' ? payload.title.trim() : ''; if (generatedTitle && generatedTitle !== fallbackTitle) { applyConversationTitle(conversationId, generatedTitle); clearTitleRefreshTimers(conversationId); } } catch { // Generated titles are a progressive enhancement; fallback titles remain valid. } finally { if (index === TITLE_REFRESH_DELAYS_MS.length - 1) { delete titleRefreshTimersRef.current[conversationId]; } } }, delayMs) )); }, [applyConversationTitle, clearTitleRefreshTimers]); // Add a new conversation to react query const addNewConversation = useCallback((messageContent: string) => { // Create new conversation const newConversationId = uuidv4(); const fallbackTitle = buildFallbackConversationTitle(messageContent); const newConversation: Conversation = { conversation_id: newConversationId, title: fallbackTitle, created_at: Date.now(), updated_at: Date.now(), }; // Add it to react query queryClient.setQueryData( ['conversations'], (old) => prependConversationToPages(old, newConversation) ); return { conversationId: newConversationId, fallbackTitle }; }, [queryClient]); const buildCurrentV1RuntimeContext = useCallback(async (): Promise => { if (TARGET_ENV !== 'wordpress') { throw new Error('V1 Capability Snapshots are only available in WordPress runtime.'); } return buildV1RuntimeContext({ fetchJson: () => fetchPluginV1RuntimeDiscovery({ endpoint: PLUGIN_REST_ENDPOINTS.capabilities, restNonce: getRestNonce(), }), callingSurface: getV1CallingSurface(), assistantSettings: { allow_log_reads: Boolean(pluginSettings.allowLogReads), allow_write_operations: Boolean(pluginSettings.allowWriteOperations), }, requireReadApproval, }); }, [ pluginSettings.allowLogReads, pluginSettings.allowWriteOperations, requireReadApproval, ]); const parseV1ChatResponse = useCallback(async ( response: Response, onStreamEnvelope?: (envelope: V1ChatStreamEnvelope) => Promise, ): Promise => { if (!response.ok) { if (response.status === 429) { const error = new Error('Rate limit exceeded'); error.name = 'RateLimitError'; throw error; } throw new Error(response.statusText); } const contentType = response.headers.get('content-type') || ''; if (contentType.includes('application/x-ndjson')) { const terminalEnvelope = await readV1NdjsonResponse(response, onStreamEnvelope); if (!terminalEnvelope) { throw new Error('V1 chat stream ended without a terminal event.'); } return terminalEnvelope; } return await response.json(); }, []); const sendV1ChatStartRequest = useCallback(async ( messageContent: string, conversationId: string, signal: AbortSignal, onStreamEnvelope?: (envelope: V1ChatStreamEnvelope) => Promise, editTarget?: { message_id: string; sent_at: number }, attachments?: ChatAttachmentInput[], ): Promise => { if (!allowActionPanelAiRequests) { const error = new Error('AssistantDisabled'); error.name = 'AssistantDisabledError'; throw error; } const source = getV1ChatSource(); const runtimeContext = source === 'plugin' ? await buildCurrentV1RuntimeContext() : null; const payload: Record = { source, message: messageContent, conversation_id: conversationId, ...(editTarget ? { edit_target: editTarget } : {}), ...(attachments?.length ? { attachments } : {}), }; if (runtimeContext) { payload.capability_snapshot = runtimeContext.capabilitySnapshot; v1ApprovalPolicyByConversationRef.current[conversationId] = runtimeContext.approvalPolicy; } const response = await fetch(`${API_BASE_URL}/v1/chat`, { method: 'POST', credentials: 'include', headers: { 'Accept': 'application/x-ndjson', 'Content-Type': 'application/json', }, body: JSON.stringify(payload), signal: signal, }); return parseV1ChatResponse(response, onStreamEnvelope); }, [allowActionPanelAiRequests, buildCurrentV1RuntimeContext, parseV1ChatResponse]); const sendV1ChatResumeRequest = useCallback(async ( turnId: string, resumePayload: V1PauseResumePayload, signal: AbortSignal, onStreamEnvelope?: (envelope: V1ChatStreamEnvelope) => Promise, ): Promise => { const response = await fetch(`${API_BASE_URL}/v1/chat/${turnId}/resume`, { method: 'POST', credentials: 'include', headers: { 'Accept': 'application/x-ndjson', 'Content-Type': 'application/json', }, body: JSON.stringify(resumePayload), signal, }); return parseV1ChatResponse(response, onStreamEnvelope); }, [parseV1ChatResponse]); const requestTurnCancellation = useCallback(async ( turnId: string, partialContent = '', ) => { try { await fetch(`${API_BASE_URL}/v1/chat/${turnId}/cancel`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ partial_content: partialContent }), }); } catch { // Local abort cancels active streams. The cancel endpoint covers paused // turns and pause/cancel races, so refetch still reconciles server state. } }, []); // Helper to set or clear partial text and recent chunks const setPartialContent = useCallback( (targetConversationId: string, content: string | null, chunk: string | null) => { // Update partial contents setPartialContents(old => { const oldContent = old[targetConversationId] ?? null; // If the new content is identical to old content, skip the update: if (oldContent === content) { return old; } const copy = { ...old }; if (content === null) { delete copy[targetConversationId]; } else { copy[targetConversationId] = content; } return copy; }); // Update recent chunks setLatestChunks(old => { const copy = { ...old }; if (content === null) { delete copy[targetConversationId]; } else if (chunk) { copy[targetConversationId] = chunk; } return copy; }); }, [] ); const patchConversationState = useCallback(( targetConversationId: string, updater: (current: EphemeralConversationState) => EphemeralConversationState ) => { setEphemeralMap(prev => { const current = prev[targetConversationId] ?? DEFAULT_EPHEMERAL_STATE; const next = updater(current); const nextMap = { ...prev, [targetConversationId]: next }; ephemeralMapRef.current = nextMap; return nextMap; }); }, []); const setFinalizedThinkingSummary = useCallback((messageId: string, summary: FinalizedThinkingSummary | null) => { setFinalizedThinkingMap(prev => { if (!summary) { if (!(messageId in prev)) return prev; const copy = { ...prev }; delete copy[messageId]; return copy; } return { ...prev, [messageId]: summary }; }); }, []); const applyStreamEvent = useCallback((targetConversationId: string, event: ChatBackendStreamEvent | ChatRuntimeUiEvent) => { if (event.type === 'tool_start') { patchConversationState(targetConversationId, (current) => { let activities = current.activities; let timeline = current.timeline; if (isVisibleToolActivity(event.name)) { const displayId = toolTimelineId(event.id, event.batchId); const label = event.label ? compactStepLabel(event.label) : buildActivityLabel(event.name, event.target); const existingIdx = current.activities.findIndex(activity => activity.id === displayId); const nextActivity: ChatActivity = { id: displayId, requestId: event.id, name: event.name, surface: event.surface, state: 'running', label, target: event.target, batchId: event.batchId, }; activities = existingIdx < 0 ? [...current.activities, nextActivity] : current.activities.map((activity, idx) => idx === existingIdx ? { ...activity, ...nextActivity } : activity); const existingTimelineIdx = current.timeline.findIndex(item => item.id === displayId); const nextTimelineEntry: ThinkingTimelineEntry = { id: displayId, kind: 'tool', name: event.name, label, state: 'running', target: event.target, }; timeline = existingTimelineIdx < 0 ? [...current.timeline, nextTimelineEntry] : current.timeline.map((item, idx) => idx === existingTimelineIdx ? nextTimelineEntry : item); } return { ...current, phase: 'THINKING', approvalState: undefined, activities, timeline, }; }); return; } if (event.type === 'tool_end') { patchConversationState(targetConversationId, (current) => { const displayId = toolTimelineId(event.id, event.batchId); const activities = current.activities.map(activity => ( activity.id === displayId ? { ...activity, state: event.ok ? 'done' as const : 'error' as const, summary: compactStepLabel(event.summary, activity.summary || activity.label), } : activity )); const timeline = current.timeline.map(item => ( item.id === displayId && item.kind === 'tool' ? { ...item, state: event.ok ? 'done' as const : 'error' as const, label: compactStepLabel(event.summary, item.label), } : item )); return { ...current, activities, timeline, }; }); return; } if (event.type === 'work_step') { patchConversationState(targetConversationId, (current) => { const label = compactStepLabel(event.label); const id = `work-${event.id}`; const nextEntry: ThinkingTimelineEntry = { id, kind: 'work_step', label, state: event.state || 'done', source: event.source, }; const existingIdx = current.timeline.findIndex(item => item.id === id); const existingEntry = existingIdx >= 0 ? current.timeline[existingIdx] : null; const shouldUpdateExisting = existingEntry?.kind === 'work_step' && existingEntry.state === 'running'; const timeline = existingIdx < 0 ? [...current.timeline, nextEntry] : shouldUpdateExisting ? current.timeline.map((item, idx) => idx === existingIdx ? nextEntry : item) : [...current.timeline, { ...nextEntry, id: `${id}-${current.timeline.length}` }]; return { ...current, timeline, }; }); return; } if (event.type === 'approval') { patchConversationState(targetConversationId, (current) => { const displayId = toolTimelineId(event.id, event.batchId); const state = event.state === 'rejected' ? 'error' as const : 'running' as const; const approvalLabel = event.state === 'needed' ? 'Waiting for approval' : event.state === 'granted' ? 'Approval granted' : 'Approval rejected'; const updateMatchingTool = (item: T): T => ( item.id === displayId ? { ...item, state, label: approvalLabel } : item ); const activities = current.activities.map(updateMatchingTool); const timeline = current.timeline.map(item => ( item.kind === 'tool' ? updateMatchingTool(item) : item )); return { ...current, phase: 'THINKING', approvalState: event.state === 'needed' ? { state: event.state, tool: event.tool, target: event.target, } : undefined, activities, timeline, }; }); return; } if (event.type === 'message_delta') { patchConversationState(targetConversationId, (current) => ({ ...current, phase: 'STREAMING', hasStartedStreaming: true, thoughtDurationMs: current.thoughtDurationMs ?? ( current.startedAt ? Math.max(1, Date.now() - current.startedAt) : current.thoughtDurationMs ), })); return; } }, [patchConversationState]); const clearConversationLocally = useCallback((conversationId: string) => { clearTitleRefreshTimers(conversationId); queryClient.setQueryData( ['conversations'], (old) => removeConversationFromPages(old, conversationId) ); queryClient.removeQueries({ queryKey: ['messages', conversationId], exact: true }); setPartialContent(conversationId, null, null); delete optimisticCancelledMessagesRef.current[conversationId]; setStoppedWorkingSummary(conversationId, null); setEphemeralMap(prev => { if (!(conversationId in prev)) return prev; const copy = { ...prev }; delete copy[conversationId]; ephemeralMapRef.current = copy; return copy; }); setFinalizedThinkingMap(prev => { const prefix = `${conversationId}:turn:`; let changed = false; const next = Object.fromEntries( Object.entries(prev).filter(([key]) => { if (key.startsWith(prefix)) { changed = true; return false; } return true; }) ); return changed ? next : prev; }); }, [clearTitleRefreshTimers, queryClient, setPartialContent, setStoppedWorkingSummary]); const deleteConversationSilently = useCallback(async (conversationId: string) => { try { await fetch(`${API_BASE_URL}/conversations/${conversationId}`, { method: 'DELETE', credentials: 'include', }); } catch (error) { console.error('Error deleting conversation:', error); } finally { clearConversationLocally(conversationId); if (currentConversationId === conversationId) { setCurrentConversationId(null); } } }, [clearConversationLocally, currentConversationId, setCurrentConversationId]); const buildFinalizedThinkingSummary = useCallback(( conversationId: string, options?: { includeEmpty?: boolean } ): FinalizedThinkingSummary | null => { const state = ephemeralMapRef.current[conversationId] ?? DEFAULT_EPHEMERAL_STATE; const durationMs = state.thoughtDurationMs ?? ( state.startedAt ? Math.max(1, Date.now() - state.startedAt) : 0 ); const visibleTimeline = state.timeline; if (visibleTimeline.length === 0 && !options?.includeEmpty) { return null; } return { durationMs: Math.max(1, durationMs), activities: state.activities.filter(activity => isVisibleToolActivity(activity.name)), approvalState: state.approvalState, timeline: visibleTimeline, }; }, []); const getCurrentUserTurnKey = useCallback((conversationId: string): string | null => { const conversationMessages = queryClient.getQueryData(['messages', conversationId]) || EMPTY_MESSAGES; let userTurnIndex = 0; for (const message of conversationMessages) { if (message.role === 'user') { userTurnIndex += 1; } } if (userTurnIndex <= 0) return null; return buildConversationTurnKey(conversationId, userTurnIndex); }, [queryClient]); const isFailedFirstTurnConversation = useCallback((conversationMessages: Message[], userMessageId: string) => { const nonErrorMessages = conversationMessages.filter(msg => msg.role !== 'error'); return ( nonErrorMessages.length === 1 && nonErrorMessages[0].role === 'user' && nonErrorMessages[0].message_id === userMessageId ); }, []); const deletePersistedUserMessage = useCallback(async ( conversationId: string, userMessageId: string, sentAt: number ) => { try { await fetch( `${API_BASE_URL}/conversations/${conversationId}/messages/${userMessageId}?sent_at=${sentAt}`, { method: 'DELETE', credentials: 'include', } ); } catch (error) { console.error('Error deleting persisted user message:', error); } }, []); const emitV1RuntimeEvent = useCallback(( targetConversationId: string, event: V1CommandRuntimeEvent ) => { if (event.type === 'command_started') { applyStreamEvent(targetConversationId, { type: 'tool_start', id: event.id, name: 'v1.command', surface: 'site', target: event.target, batchId: event.batch_id, label: event.label, }); return; } if (event.type === 'command_completed') { applyStreamEvent(targetConversationId, { type: 'tool_end', id: event.id, ok: event.ok, summary: event.summary, batchId: event.batch_id, }); return; } applyStreamEvent(targetConversationId, { type: 'approval', id: event.id, state: getV1RuntimeApprovalState(event), tool: event.label, target: event.target, batchId: event.batch_id, }); }, [applyStreamEvent]); const requestV1CommandApproval = useCallback(async ( request: V1CommandApprovalRequest ) => { const decision = await openProposeChanges({ protocol: 'v1', approval: request.approval, commands: request.commands, results: request.results, display: request.display, }); return { approved: decision.approved, reason: decision.user_reason, approval: decision.approved ? { approved_at: new Date().toISOString(), ...(decision.user_reason ? { user_reason: decision.user_reason } : {}), } : undefined, }; }, [openProposeChanges]); const v1BrowserCommandRunner = useMemo(() => createBrowserCommandRunner(), []); const executeV1PauseRequest = useCallback(async ( pause: V1PauseEvent, targetConversationId: string, ) => ( executeV1PauseEvent(pause, { commandClient: createFetchV1CommandClient({ endpoint: PLUGIN_REST_ENDPOINTS.commands, restNonce: getRestNonce(), }), browserCommandRunner: v1BrowserCommandRunner, approvalPolicy: v1ApprovalPolicyByConversationRef.current[targetConversationId], requestApproval: requestV1CommandApproval, emitEvent: event => emitV1RuntimeEvent(targetConversationId, event), }) ), [emitV1RuntimeEvent, requestV1CommandApproval, v1BrowserCommandRunner]); useEffect(() => { if (!isToolHarnessBridgeEnabled()) return undefined; window.__actionpanelV1DeterministicHelper = { executePause: async (pause, options = {}) => { const conversationId = options.conversationId || currentConversationId || `v1-deterministic-${uuidv4()}`; return executeV1PauseRequest(pause, conversationId); }, }; return () => { delete window.__actionpanelV1DeterministicHelper; }; }, [currentConversationId, executeV1PauseRequest]); // Handles the V1 turn envelope from the Backend route family. const handleV1ChatResponse = useCallback(async ( startTurn: (onStreamEnvelope: (envelope: V1ChatStreamEnvelope) => Promise) => Promise, targetConversationId: string, ) => { let assistantMessageContent = ''; let envelope: V1ChatHttpResponse | null = null; const appendAssistantMessageContent = (chunk: string) => { assistantMessageContent += chunk; setPartialContent(targetConversationId, assistantMessageContent, chunk); streamCallbackRef.current?.(targetConversationId, assistantMessageContent); }; const resumeAfterPause = async ( pause: V1PauseEvent, turnId: string, ): Promise => { const resumePayload = await executeV1PauseRequest(pause, targetConversationId); return await sendV1ChatResumeRequest( turnId, resumePayload, abortRefs.current[targetConversationId]?.signal ?? new AbortController().signal, handleStreamEnvelope, ); }; const handleEvent = async ( event: V1ChatResponseEvent, turnId: string, ): Promise => { if (isV1PauseEvent(event)) { return await resumeAfterPause(event, turnId); } if (isV1ErrorEvent(event)) { throw new Error(event.message); } if (isV1FinalEvent(event)) { if (event.message) { appendAssistantMessageContent(event.message); } return null; } if (event.type === 'v1.cancelled') { return null; } applyStreamEvent(targetConversationId, event); if (event.type === 'message_delta') { appendAssistantMessageContent(event.text); } return null; }; const handleStreamEnvelope = async (streamEnvelope: V1ChatStreamEnvelope) => { activeTurnRefs.current[targetConversationId] = streamEnvelope.turn_id; for (const event of streamEnvelope.events || []) { if ( isV1PauseEvent(event) || isV1FinalEvent(event) || isV1ErrorEvent(event) || event.type === 'v1.cancelled' ) { continue; } applyStreamEvent(targetConversationId, event); if (event.type === 'message_delta') { appendAssistantMessageContent(event.text); } } }; envelope = await startTurn(handleStreamEnvelope); while (envelope) { activeTurnRefs.current[targetConversationId] = envelope.turn_id; const events = Array.isArray(envelope.events) ? envelope.events : []; let nextEnvelope: V1ChatHttpResponse | null = null; for (const event of events) { nextEnvelope = await handleEvent(event, envelope.turn_id); if (nextEnvelope) break; } if (nextEnvelope) { envelope = nextEnvelope; continue; } if (envelope.outcome === 'error') { throw new Error('V1 chat turn failed.'); } if (envelope.outcome === 'paused') { throw new Error('V1 chat turn paused without a resumable pause event.'); } envelope = null; } if (cancellationRequestedRef.current[targetConversationId]) { return; } // Finalize assistant message if (assistantMessageContent) { const finalizedThinkingSummary = buildFinalizedThinkingSummary(targetConversationId); addNewMessage('assistant', assistantMessageContent, targetConversationId); if (finalizedThinkingSummary) { const turnKey = getCurrentUserTurnKey(targetConversationId); if (turnKey) { setFinalizedThinkingSummary(turnKey, finalizedThinkingSummary); } } } try { const persistedMessages = await fetchMessagesForConversation(targetConversationId); queryClient.setQueryData(['messages', targetConversationId], persistedMessages); } catch { queryClient.invalidateQueries({ queryKey: ['messages', targetConversationId] }); } updateConversationState(targetConversationId, { ...DEFAULT_EPHEMERAL_STATE, isPersisted: true, }); queryClient.invalidateQueries({ queryKey: ['conversations'] }); if (DEBUG_UI) console.log('[UI] phase=IDLE', { conv: targetConversationId }); setPartialContent(targetConversationId, null, null); }, [ addNewMessage, applyStreamEvent, executeV1PauseRequest, fetchMessagesForConversation, sendV1ChatResumeRequest, buildFinalizedThinkingSummary, getCurrentUserTurnKey, queryClient, setPartialContent, setFinalizedThinkingSummary, updateConversationState, ]); // Handles rate limit errors and other errors const handleChatError = useCallback(( error: Error, targetConversationId: string, userMessageId: string ) => { setPartialContent(targetConversationId!, null, null); // If it's a rate limit error... if (error.name === 'RateLimitError') { updateConversationState(targetConversationId!, { ...DEFAULT_EPHEMERAL_STATE, isPersisted: true }); // Set conversation as idle setIsRateLimitModalOpen(true); // Show rate limit modal // Refresh conversations and messages (deletes any optimistic updates not saved to server) queryClient.invalidateQueries({ queryKey: ['conversations'] }); queryClient.invalidateQueries({ queryKey: ['messages'] }); setCurrentConversationId(null); // Reset current conversation ID // Otherwise, it's a different error... } else if (userMessageId) { // Set error state updateConversationState(targetConversationId!, { ...DEFAULT_EPHEMERAL_STATE, phase: 'ERROR', }); // Add error message to conversation addNewMessage('error', 'Something went wrong. Please try again.', targetConversationId!, userMessageId); } }, [addNewMessage, queryClient, setCurrentConversationId, setIsRateLimitModalOpen, setPartialContent, updateConversationState]); // Cleanup reader and abort controller refs for a conversation const cleanup = useCallback((conversationId: string) => { if (abortRefs.current[conversationId]) { abortRefs.current[conversationId]?.abort(); abortRefs.current[conversationId] = null; } if (readerRefs.current[conversationId]) { readerRefs.current[conversationId]?.cancel(); readerRefs.current[conversationId]?.releaseLock(); readerRefs.current[conversationId] = null; } delete activeTurnRefs.current[conversationId]; }, []); // ================== // // EXPORTED FUNCTIONS // // ================== // // Function to send a message and handle streaming const sendMessageInternal = useCallback(async ( messageContent: string, options?: { forceNewConversation?: boolean; editTarget?: { message_id: string; sent_at: number }; attachments?: ChatAttachmentInput[]; throwOnError?: boolean; } ) => { if (!allowActionPanelAiRequests) { return Promise.reject(new Error('actionpanel_ai_requests_disabled')); } // No current conversation ID means this is the first message in a new conversation const isNewConversation = Boolean(options?.forceNewConversation) || !currentConversationId; // Initialize variables let userMessageId: string | null = null; let targetConversationId: string | null = null; let newConversationFallbackTitle: string | null = null; // If this is the first message in a new conversation... if (isNewConversation) { // Setup new conversation const conversation = addNewConversation(messageContent); targetConversationId = conversation.conversationId; newConversationFallbackTitle = conversation.fallbackTitle; userMessageId = addNewMessage('user', messageContent, targetConversationId, undefined, { attachments: toDisplayAttachments(options?.attachments), }); setCurrentConversationId(targetConversationId); // If this is NOT the first message in a new conversation... } else { // Add message to existing conversation userMessageId = addNewMessage('user', messageContent, currentConversationId!, undefined, { attachments: toDisplayAttachments(options?.attachments), }); targetConversationId = currentConversationId!; } setStoppedWorkingSummary(targetConversationId, null); // Set conversation as thinking. const existingConversationState = ephemeralMap[targetConversationId!] ?? DEFAULT_EPHEMERAL_STATE; updateConversationState(targetConversationId!, { phase: 'THINKING', isPersisted: existingConversationState.isPersisted, activities: [], approvalState: undefined, timeline: [], hasStartedStreaming: false, startedAt: Date.now(), thoughtDurationMs: undefined, }); if (DEBUG_UI) console.log('[UI] phase=THINKING', { conv: targetConversationId }); // Create an abort controller const abortController = new AbortController(); abortRefs.current[targetConversationId!] = abortController; // Try to send the message try { // Send the message await handleV1ChatResponse( (onStreamEnvelope) => sendV1ChatStartRequest( messageContent, targetConversationId!, abortController.signal, onStreamEnvelope, options?.editTarget, options?.attachments, ), targetConversationId!, ); if (newConversationFallbackTitle) { scheduleGeneratedTitleRefresh(targetConversationId!, newConversationFallbackTitle); } // Handle the error (rate limit modal or error message) } catch (error: any) { const wasUserCancelled = cancellationRequestedRef.current[targetConversationId!]; if (!(wasUserCancelled && error?.name === 'AbortError')) { handleChatError(error, targetConversationId!, userMessageId!); } if (options?.throwOnError) { throw error; } // Cleanup } finally { delete cancellationRequestedRef.current[targetConversationId!]; cleanup(targetConversationId!); } }, [ addNewConversation, addNewMessage, allowActionPanelAiRequests, cleanup, currentConversationId, handleChatError, handleV1ChatResponse, scheduleGeneratedTitleRefresh, sendV1ChatStartRequest, setCurrentConversationId, setStoppedWorkingSummary, updateConversationState, ephemeralMap, ]); const sendMessage = useCallback(async (messageContent: string, attachments?: ChatAttachmentInput[]) => { await sendMessageInternal(messageContent, { attachments }); }, [sendMessageInternal]); const refetchMessagesSoon = useCallback((conversationId: string) => { CANCEL_REFETCH_DELAYS_MS.forEach(delay => { window.setTimeout(() => { queryClient.invalidateQueries({ queryKey: ['messages', conversationId] }); queryClient.invalidateQueries({ queryKey: ['conversations'] }); }, delay); }); }, [queryClient]); const cancelCurrentStream = useCallback(() => { if (!currentConversationId) return; const state = ephemeralMapRef.current[currentConversationId] ?? DEFAULT_EPHEMERAL_STATE; if (state.phase !== 'THINKING' && state.phase !== 'STREAMING') return; cancellationRequestedRef.current[currentConversationId] = true; const partialContent = partialContents[currentConversationId] ?? ''; const partialText = partialContent.trim(); const activeTurnId = activeTurnRefs.current[currentConversationId]; if (activeTurnId) { void requestTurnCancellation(activeTurnId, partialContent); } const existingMessages = queryClient.getQueryData(['messages', currentConversationId]) || EMPTY_MESSAGES; const latestUserMessage = [...existingMessages] .reverse() .find(message => message.role === 'user'); const latestUserSentAt = latestUserMessage?.sent_at ?? 0; const latestUserContent = latestUserMessage?.content ?? ''; const finalizedThinkingSummary = buildFinalizedThinkingSummary( currentConversationId, { includeEmpty: !partialText } ); const currentTurnKey = getCurrentUserTurnKey(currentConversationId); if (partialText) { const optimisticMessageId = addNewMessage('assistant', partialContent, currentConversationId, undefined, { status: 'interrupted', finishReason: 'cancelled', }); const optimisticMessage = queryClient .getQueryData(['messages', currentConversationId]) ?.find(message => message.message_id === optimisticMessageId); if (optimisticMessage) { optimisticCancelledMessagesRef.current[currentConversationId] = { latestUserSentAt, latestUserContent, }; } if (currentTurnKey && finalizedThinkingSummary) { setFinalizedThinkingSummary(currentTurnKey, finalizedThinkingSummary); } setStoppedWorkingSummary(currentConversationId, null); } else if (finalizedThinkingSummary) { setStoppedWorkingSummary(currentConversationId, finalizedThinkingSummary); } setPartialContent(currentConversationId, null, null); updateConversationState(currentConversationId, { ...DEFAULT_EPHEMERAL_STATE, isPersisted: true, }); abortRefs.current[currentConversationId]?.abort(); abortRefs.current[currentConversationId] = null; if (readerRefs.current[currentConversationId]) { readerRefs.current[currentConversationId]?.cancel().catch(() => {}); try { readerRefs.current[currentConversationId]?.releaseLock(); } catch { // Reader may already be released by the streaming loop. } readerRefs.current[currentConversationId] = null; } refetchMessagesSoon(currentConversationId); }, [ addNewMessage, currentConversationId, partialContents, queryClient, refetchMessagesSoon, requestTurnCancellation, buildFinalizedThinkingSummary, getCurrentUserTurnKey, setPartialContent, setFinalizedThinkingSummary, setStoppedWorkingSummary, updateConversationState, ]); const editAndResubmitLatestUserMessage = useCallback(async (messageId: string, text: string) => { if (!allowActionPanelAiRequests || !currentConversationId) return; const trimmed = text.trim(); if (!trimmed) return; const state = ephemeralMapRef.current[currentConversationId] ?? DEFAULT_EPHEMERAL_STATE; if (state.phase !== 'IDLE') return; const existingMessages = queryClient.getQueryData(['messages', currentConversationId]) || EMPTY_MESSAGES; const latestUserIndex = existingMessages.reduce((latestIndex, message, index) => ( message.role === 'user' ? index : latestIndex ), -1); const latestUserMessage = latestUserIndex >= 0 ? existingMessages[latestUserIndex] : null; if (!latestUserMessage || latestUserMessage.message_id !== messageId) return; queryClient.setQueryData( ['messages', currentConversationId], existingMessages.slice(0, latestUserIndex) ); try { await sendMessageInternal(trimmed, { editTarget: { message_id: latestUserMessage.message_id, sent_at: latestUserMessage.sent_at, }, throwOnError: true, }); } catch { queryClient.setQueryData(['messages', currentConversationId], existingMessages); updateConversationState(currentConversationId, { ...DEFAULT_EPHEMERAL_STATE, isPersisted: true }); } }, [ allowActionPanelAiRequests, currentConversationId, queryClient, sendMessageInternal, updateConversationState, ]); // Function to retry a message const retryError = useCallback(async (userMessageId: string) => { if (!allowActionPanelAiRequests) return; if (!currentConversationId) return; const messages = queryClient.getQueryData(['messages', currentConversationId]) || EMPTY_ARRAY; const userMessage = messages.find((msg) => msg.message_id === userMessageId && msg.role === 'user'); // Remove error and user messages if (userMessage) { if (isFailedFirstTurnConversation(messages, userMessageId)) { await deleteConversationSilently(currentConversationId); await sendMessageInternal(userMessage.content, { forceNewConversation: true }); return; } await deletePersistedUserMessage(currentConversationId, userMessageId, userMessage.sent_at); queryClient.setQueryData(['messages', currentConversationId], (old = []) => old?.filter(msg => msg.message_id !== userMessageId && !(msg.role === 'error' && msg.userMessageId === userMessageId) ) ); // Resend message await sendMessageInternal(userMessage.content); } }, [ allowActionPanelAiRequests, currentConversationId, deleteConversationSilently, deletePersistedUserMessage, isFailedFirstTurnConversation, queryClient, sendMessageInternal, ]); // Function to dismiss an error message (will also delete the user message that caused it) const dismissError = useCallback(async (userMessageId: string) => { if (!currentConversationId) return; const messages = queryClient.getQueryData(['messages', currentConversationId]) || EMPTY_ARRAY; const userMessage = messages.find((msg) => msg.message_id === userMessageId && msg.role === 'user'); if (isFailedFirstTurnConversation(messages, userMessageId)) { await deleteConversationSilently(currentConversationId); return; } if (userMessage) { await deletePersistedUserMessage(currentConversationId, userMessageId, userMessage.sent_at); } // Remove user message and error message queryClient.setQueryData(['messages', currentConversationId], (old = []) => old?.filter(msg => msg.message_id !== userMessageId && !(msg.role === 'error' && msg.userMessageId === userMessageId) ) ); // Set conversation as idle updateConversationState(currentConversationId, { ...DEFAULT_EPHEMERAL_STATE, isPersisted: true }); // If no messages left, reset current conversation const remainingMessages = queryClient.getQueryData(['messages', currentConversationId]) || []; if (remainingMessages.length === 0) { setCurrentConversationId(null); } }, [ currentConversationId, deleteConversationSilently, deletePersistedUserMessage, isFailedFirstTurnConversation, queryClient, setCurrentConversationId, updateConversationState, ]); // Function to delete a conversation const deleteConversation = useCallback(async (conversationId: string) => { if (!allowActionPanelAiRequests) return; try { const response = await fetch(`${API_BASE_URL}/conversations/${conversationId}`, { method: 'DELETE', credentials: 'include', }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.detail || 'Failed to delete conversation'); } clearConversationLocally(conversationId); queryClient.invalidateQueries({ queryKey: ['conversations'] }); queryClient.invalidateQueries({ queryKey: ['messages', conversationId] }); if (currentConversationId === conversationId) { setCurrentConversationId(null); } } catch (error) { console.error('Error deleting conversation:', error); } }, [allowActionPanelAiRequests, clearConversationLocally, currentConversationId, queryClient, setCurrentConversationId]); const renameConversation = useCallback(async (conversationId: string, title: string) => { if (!allowActionPanelAiRequests) return; const trimmedTitle = title.trim().replace(/\s+/g, ' '); if (!trimmedTitle) return; const previousTitle = conversations.find( conversation => conversation.conversation_id === conversationId )?.title ?? 'New Chat'; applyConversationTitle(conversationId, trimmedTitle); try { const response = await fetch(`${API_BASE_URL}/conversations/${conversationId}/metadata`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: trimmedTitle }), }); if (!response.ok) { throw new Error('Failed to rename conversation'); } const payload = await response.json(); const persistedTitle = typeof payload.title === 'string' ? payload.title.trim() : trimmedTitle; applyConversationTitle(conversationId, persistedTitle || trimmedTitle); } catch (error) { applyConversationTitle(conversationId, previousTitle); console.error('Error renaming conversation:', error); } }, [applyConversationTitle, allowActionPanelAiRequests, conversations]); const loadMoreConversations = useCallback(async () => { if (!hasMoreConversations || isFetchingMoreConversations) return; await fetchNextConversationsPage(); }, [fetchNextConversationsPage, hasMoreConversations, isFetchingMoreConversations]); // Memoize conversations and messages const memoizedConversations = useMemo(() => conversations.length > 0 ? conversations : EMPTY_CONVERSATIONS , [conversations]); // Memoize messages const memoizedMessages = useMemo(() => rawMessages.length > 0 ? rawMessages : EMPTY_MESSAGES , [rawMessages]); // Initial-load flags: fetching with nothing to show yet (background refetches, // which keep existing data, don't count). const conversationsLoading = conversationsFetching && memoizedConversations.length === 0; const messagesLoading = Boolean(currentConversationId) && messagesFetching && memoizedMessages.length === 0; // Memoize the context value const contextValue = useMemo(() => ({ currentConversationId, setCurrentConversationId, conversations: memoizedConversations, messages: memoizedMessages, conversationsLoading, hasMoreConversations, isFetchingMoreConversations, messagesLoading, ephemeralMap, finalizedThinkingMap, stoppedWorkingMap, sendMessage, cancelCurrentStream, editAndResubmitLatestUserMessage, retryError, dismissError, deleteConversation, renameConversation, loadMoreConversations, partialContents, setPartialContent, latestChunks, }), [ currentConversationId, setCurrentConversationId, memoizedConversations, memoizedMessages, conversationsLoading, hasMoreConversations, isFetchingMoreConversations, messagesLoading, ephemeralMap, finalizedThinkingMap, stoppedWorkingMap, sendMessage, cancelCurrentStream, editAndResubmitLatestUserMessage, retryError, dismissError, deleteConversation, renameConversation, loadMoreConversations, partialContents, setPartialContent, latestChunks, ]); return ( {children} ); }; // ================= // // CONTEXT SELECTORS // // ================= // // Base selector hook export const useChatSelector = (selector: (state: ChatContextProps) => T): T => { return useContextSelector(ChatContext, selector); }; // Memoize individual selectors const currentConversationIdSelector = (state: ChatContextProps) => state.currentConversationId; const setCurrentConversationIdSelector = (state: ChatContextProps) => state.setCurrentConversationId; const conversationsSelector = (state: ChatContextProps) => state.conversations; const messagesSelector = (state: ChatContextProps) => state.messages; const conversationsLoadingSelector = (state: ChatContextProps) => state.conversationsLoading; const hasMoreConversationsSelector = (state: ChatContextProps) => state.hasMoreConversations; const isFetchingMoreConversationsSelector = (state: ChatContextProps) => state.isFetchingMoreConversations; const messagesLoadingSelector = (state: ChatContextProps) => state.messagesLoading; const sendMessageSelector = (state: ChatContextProps) => state.sendMessage; const cancelCurrentStreamSelector = (state: ChatContextProps) => state.cancelCurrentStream; const editAndResubmitLatestUserMessageSelector = (state: ChatContextProps) => state.editAndResubmitLatestUserMessage; const retryErrorSelector = (state: ChatContextProps) => state.retryError; const dismissErrorSelector = (state: ChatContextProps) => state.dismissError; const deleteConversationSelector = (state: ChatContextProps) => state.deleteConversation; const renameConversationSelector = (state: ChatContextProps) => state.renameConversation; const loadMoreConversationsSelector = (state: ChatContextProps) => state.loadMoreConversations; // Individual selector hooks using memoized selectors export const useCurrentConversationId = () => useChatSelector(currentConversationIdSelector); export const useSetCurrentConversationId = () => useChatSelector(setCurrentConversationIdSelector); export const useConversations = () => useChatSelector(conversationsSelector); export const useMessages = () => useChatSelector(messagesSelector); export const useConversationsLoading = () => useChatSelector(conversationsLoadingSelector); export const useHasMoreConversations = () => useChatSelector(hasMoreConversationsSelector); export const useIsFetchingMoreConversations = () => useChatSelector(isFetchingMoreConversationsSelector); export const useMessagesLoading = () => useChatSelector(messagesLoadingSelector); export const useSendMessage = () => useChatSelector(sendMessageSelector); export const useCancelCurrentStream = () => useChatSelector(cancelCurrentStreamSelector); export const useEditAndResubmitLatestUserMessage = () => useChatSelector(editAndResubmitLatestUserMessageSelector); export const useRetryError = () => useChatSelector(retryErrorSelector); export const useDismissError = () => useChatSelector(dismissErrorSelector); export const useDeleteConversation = () => useChatSelector(deleteConversationSelector); export const useRenameConversation = () => useChatSelector(renameConversationSelector); export const useLoadMoreConversations = () => useChatSelector(loadMoreConversationsSelector); // Ephemeral conversation state for only current conversation export function useCurrentConversationState() { const currentId = useContextSelector(ChatContext, ctx => ctx.currentConversationId); return useContextSelector(ChatContext, ctx => { if (!currentId) return null; return ctx.ephemeralMap[currentId] ?? DEFAULT_EPHEMERAL_STATE; }); } // Streaming message content only for the current conversation export function useCurrentPartialContent() { const currentId = useContextSelector(ChatContext, ctx => ctx.currentConversationId); return useContextSelector(ChatContext, ctx => { if (!currentId) return null; return ctx.partialContents[currentId] ?? ''; }); } export function useAssistantThinkingSummary(messageId?: string) { return useContextSelector(ChatContext, ctx => { if (!messageId) return null; return ctx.finalizedThinkingMap[messageId] ?? null; }); } export function useCurrentStoppedWorkingSummary() { const currentId = useContextSelector(ChatContext, ctx => ctx.currentConversationId); return useContextSelector(ChatContext, ctx => { if (!currentId) return null; return ctx.stoppedWorkingMap[currentId] ?? null; }); } // Latest streaming chunk for current conversation export function useCurrentLatestChunk() { const currentId = useContextSelector(ChatContext, ctx => ctx.currentConversationId); return useContextSelector(ChatContext, ctx => { if (!currentId) return null; return ctx.latestChunks[currentId] ?? ''; }); }