feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
687
dexto/packages/webui/components/hooks/ChatContext.tsx
Normal file
687
dexto/packages/webui/components/hooks/ChatContext.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useChat, Message, UIUserMessage, UIAssistantMessage, UIToolMessage } from './useChat';
|
||||
import { useApproval } from './ApprovalContext';
|
||||
import { usePendingApprovals } from './useApprovals';
|
||||
import type { FilePart, ImagePart, TextPart, UIResourcePart } from '../../types';
|
||||
import type { SanitizedToolResult, ApprovalRequest } from '@dexto/core';
|
||||
import { getResourceKind } from '@dexto/core';
|
||||
import { useAnalytics } from '@/lib/analytics/index.js';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
import { client } from '@/lib/client.js';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
useAgentStore,
|
||||
useSessionStore,
|
||||
useChatStore,
|
||||
useCurrentSessionId,
|
||||
useIsWelcomeState,
|
||||
useSessionMessages,
|
||||
} from '@/lib/stores/index.js';
|
||||
|
||||
// Helper to get history endpoint type (workaround for string literal path)
|
||||
type HistoryEndpoint = (typeof client.api.sessions)[':sessionId']['history'];
|
||||
|
||||
// Derive history message type from Hono client response (server schema is source of truth)
|
||||
type HistoryResponse = Awaited<ReturnType<HistoryEndpoint['$get']>>;
|
||||
type HistoryData = Awaited<ReturnType<Extract<HistoryResponse, { ok: true }>['json']>>;
|
||||
type HistoryMessage = HistoryData['history'][number];
|
||||
|
||||
// Derive toolCall type from HistoryMessage
|
||||
type ToolCall = NonNullable<HistoryMessage['toolCalls']>[number];
|
||||
|
||||
interface ChatContextType {
|
||||
messages: Message[];
|
||||
sendMessage: (
|
||||
content: string,
|
||||
imageData?: { image: string; mimeType: string },
|
||||
fileData?: { data: string; mimeType: string; filename?: string }
|
||||
) => void;
|
||||
reset: () => void;
|
||||
switchSession: (sessionId: string) => void;
|
||||
loadSessionHistory: (sessionId: string) => Promise<void>;
|
||||
returnToWelcome: () => void;
|
||||
cancel: (sessionId?: string) => void;
|
||||
}
|
||||
|
||||
// Helper function to fetch and convert session history to UI messages
|
||||
async function fetchSessionHistory(
|
||||
sessionId: string
|
||||
): Promise<{ messages: Message[]; isBusy: boolean }> {
|
||||
const response = await client.api.sessions[':sessionId'].history.$get({
|
||||
param: { sessionId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch session history');
|
||||
}
|
||||
const data = await response.json();
|
||||
const history = data.history || [];
|
||||
return {
|
||||
messages: convertHistoryToMessages(history, sessionId),
|
||||
isBusy: data.isBusy ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to convert session history API response to UI messages
|
||||
function convertHistoryToMessages(history: HistoryMessage[], sessionId: string): Message[] {
|
||||
const uiMessages: Message[] = [];
|
||||
const pendingToolCalls = new Map<string, number>();
|
||||
|
||||
for (let index = 0; index < history.length; index++) {
|
||||
const msg = history[index];
|
||||
const createdAt = msg.timestamp ?? Date.now() - (history.length - index) * 1000;
|
||||
const baseId = `session-${sessionId}-${index}`;
|
||||
|
||||
// Skip system messages - they're not shown in UI
|
||||
if (msg.role === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const deriveResources = (
|
||||
content: Array<TextPart | ImagePart | FilePart | UIResourcePart>
|
||||
): SanitizedToolResult['resources'] => {
|
||||
const resources: NonNullable<SanitizedToolResult['resources']> = [];
|
||||
|
||||
for (const part of content) {
|
||||
if (
|
||||
part.type === 'image' &&
|
||||
typeof part.image === 'string' &&
|
||||
part.image.startsWith('@blob:')
|
||||
) {
|
||||
const uri = part.image.substring(1);
|
||||
resources.push({
|
||||
uri,
|
||||
kind: 'image',
|
||||
mimeType: part.mimeType ?? 'image/jpeg',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
part.type === 'file' &&
|
||||
typeof part.data === 'string' &&
|
||||
part.data.startsWith('@blob:')
|
||||
) {
|
||||
const uri = part.data.substring(1);
|
||||
const mimeType = part.mimeType ?? 'application/octet-stream';
|
||||
const kind = getResourceKind(mimeType);
|
||||
|
||||
resources.push({
|
||||
uri,
|
||||
kind,
|
||||
mimeType,
|
||||
...(part.filename ? { filename: part.filename } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return resources.length > 0 ? resources : undefined;
|
||||
};
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
// Create assistant message
|
||||
if (msg.content) {
|
||||
// Extract text content from string or ContentPart array
|
||||
let textContent: string | null = null;
|
||||
if (typeof msg.content === 'string') {
|
||||
textContent = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
// Extract text from ContentPart array
|
||||
const textParts = msg.content
|
||||
.filter((part): part is TextPart => part.type === 'text')
|
||||
.map((part) => part.text);
|
||||
textContent = textParts.length > 0 ? textParts.join('\n') : null;
|
||||
}
|
||||
|
||||
const assistantMessage: UIAssistantMessage = {
|
||||
id: baseId,
|
||||
role: 'assistant',
|
||||
content: textContent,
|
||||
createdAt,
|
||||
sessionId,
|
||||
tokenUsage: msg.tokenUsage,
|
||||
reasoning: msg.reasoning,
|
||||
model: msg.model,
|
||||
provider: msg.provider,
|
||||
};
|
||||
uiMessages.push(assistantMessage);
|
||||
}
|
||||
|
||||
// Create tool messages for tool calls
|
||||
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
||||
msg.toolCalls.forEach((toolCall: ToolCall, toolIndex: number) => {
|
||||
let toolArgs: Record<string, unknown> = {};
|
||||
if (toolCall?.function) {
|
||||
try {
|
||||
toolArgs = JSON.parse(toolCall.function.arguments || '{}');
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Failed to parse toolCall arguments for ${toolCall.function?.name || 'unknown'}: ${e}`
|
||||
);
|
||||
toolArgs = {};
|
||||
}
|
||||
}
|
||||
const toolName = toolCall.function?.name || 'unknown';
|
||||
|
||||
const toolMessage: UIToolMessage = {
|
||||
id: `${baseId}-tool-${toolIndex}`,
|
||||
role: 'tool',
|
||||
content: null,
|
||||
createdAt: createdAt + toolIndex,
|
||||
sessionId,
|
||||
toolName,
|
||||
toolArgs,
|
||||
toolCallId: toolCall.id,
|
||||
};
|
||||
|
||||
if (typeof toolCall.id === 'string' && toolCall.id.length > 0) {
|
||||
pendingToolCalls.set(toolCall.id, uiMessages.length);
|
||||
}
|
||||
|
||||
uiMessages.push(toolMessage);
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === 'tool') {
|
||||
const toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : undefined;
|
||||
const toolName = typeof msg.name === 'string' ? msg.name : 'unknown';
|
||||
const normalizedContent: Array<TextPart | ImagePart | FilePart> = Array.isArray(
|
||||
msg.content
|
||||
)
|
||||
? (msg.content as Array<TextPart | ImagePart | FilePart>)
|
||||
: typeof msg.content === 'string'
|
||||
? [{ type: 'text', text: msg.content }]
|
||||
: [];
|
||||
|
||||
const inferredResources = deriveResources(normalizedContent);
|
||||
// Extract success status from stored message (defaults to true for backwards compatibility)
|
||||
const success =
|
||||
'success' in msg && typeof msg.success === 'boolean' ? msg.success : true;
|
||||
const sanitizedFromHistory: SanitizedToolResult = {
|
||||
content: normalizedContent,
|
||||
...(inferredResources ? { resources: inferredResources } : {}),
|
||||
meta: {
|
||||
toolName,
|
||||
toolCallId: toolCallId ?? `tool-${index}`,
|
||||
success,
|
||||
},
|
||||
};
|
||||
|
||||
// Extract approval metadata if present (type-safe with optional chaining)
|
||||
const requireApproval: boolean | undefined =
|
||||
'requireApproval' in msg && typeof msg.requireApproval === 'boolean'
|
||||
? msg.requireApproval
|
||||
: undefined;
|
||||
const approvalStatus: 'pending' | 'approved' | 'rejected' | undefined =
|
||||
'approvalStatus' in msg &&
|
||||
(msg.approvalStatus === 'pending' ||
|
||||
msg.approvalStatus === 'approved' ||
|
||||
msg.approvalStatus === 'rejected')
|
||||
? msg.approvalStatus
|
||||
: undefined;
|
||||
|
||||
if (toolCallId && pendingToolCalls.has(toolCallId)) {
|
||||
// Update existing tool message with result
|
||||
const messageIndex = pendingToolCalls.get(toolCallId)!;
|
||||
const existingMessage = uiMessages[messageIndex] as UIToolMessage;
|
||||
uiMessages[messageIndex] = {
|
||||
...existingMessage,
|
||||
toolResult: sanitizedFromHistory,
|
||||
toolResultMeta: sanitizedFromHistory.meta,
|
||||
toolResultSuccess: sanitizedFromHistory.meta?.success,
|
||||
...(requireApproval !== undefined && { requireApproval }),
|
||||
...(approvalStatus !== undefined && { approvalStatus }),
|
||||
};
|
||||
} else {
|
||||
// Create new tool message with result
|
||||
const toolMessage: UIToolMessage = {
|
||||
id: baseId,
|
||||
role: 'tool',
|
||||
content: null,
|
||||
createdAt,
|
||||
sessionId,
|
||||
toolName,
|
||||
toolCallId,
|
||||
toolResult: sanitizedFromHistory,
|
||||
toolResultMeta: sanitizedFromHistory.meta,
|
||||
toolResultSuccess: sanitizedFromHistory.meta?.success,
|
||||
...(requireApproval !== undefined && { requireApproval }),
|
||||
...(approvalStatus !== undefined && { approvalStatus }),
|
||||
};
|
||||
uiMessages.push(toolMessage);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// User message (only remaining case after system/assistant/tool handled)
|
||||
if (msg.role === 'user') {
|
||||
const userMessage: UIUserMessage = {
|
||||
id: baseId,
|
||||
role: 'user',
|
||||
content: msg.content,
|
||||
createdAt,
|
||||
sessionId,
|
||||
};
|
||||
uiMessages.push(userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark any tool calls that never received results as failed (incomplete)
|
||||
// This happens when a run was interrupted or crashed before tool completion
|
||||
for (const [_callId, messageIndex] of pendingToolCalls) {
|
||||
const msg = uiMessages[messageIndex];
|
||||
if (msg && msg.role === 'tool' && msg.toolResult === undefined) {
|
||||
uiMessages[messageIndex] = {
|
||||
...msg,
|
||||
toolResultSuccess: false, // Mark as failed so UI doesn't show "running"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return uiMessages;
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextType | undefined>(undefined);
|
||||
|
||||
export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get state from stores using centralized selectors
|
||||
const currentSessionId = useCurrentSessionId();
|
||||
const isWelcomeState = useIsWelcomeState();
|
||||
|
||||
// Local state for UI flow control only (not shared/persisted state)
|
||||
const [isSwitchingSession, setIsSwitchingSession] = useState(false); // Guard against rapid session switches
|
||||
const [isCreatingSession, setIsCreatingSession] = useState(false); // Guard against double auto-creation
|
||||
const lastSwitchedSessionRef = useRef<string | null>(null); // Track last switched session to prevent duplicate switches
|
||||
const newSessionWithMessageRef = useRef<string | null>(null); // Track new sessions that already have first message sent
|
||||
const currentSessionIdRef = useRef<string | null>(null); // Synchronous session ID (updates before React state to prevent race conditions)
|
||||
|
||||
// Session abort controllers for cancellation
|
||||
const sessionAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
|
||||
|
||||
// useChat hook manages abort controllers internally
|
||||
const {
|
||||
sendMessage: originalSendMessage,
|
||||
reset: originalReset,
|
||||
cancel,
|
||||
} = useChat(currentSessionIdRef, sessionAbortControllersRef);
|
||||
|
||||
// Restore pending approvals when session changes (e.g., after page refresh)
|
||||
const { handleApprovalRequest } = useApproval();
|
||||
const { data: pendingApprovalsData } = usePendingApprovals(currentSessionId);
|
||||
const restoredApprovalsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingApprovalsData?.approvals || pendingApprovalsData.approvals.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore any pending approvals that haven't been restored yet
|
||||
for (const approval of pendingApprovalsData.approvals) {
|
||||
// Skip if we've already restored this approval
|
||||
if (restoredApprovalsRef.current.has(approval.approvalId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark as restored before calling handler to prevent duplicates
|
||||
restoredApprovalsRef.current.add(approval.approvalId);
|
||||
|
||||
// Convert API response format to ApprovalRequest format
|
||||
// TODO: The API returns a simplified format without full metadata because
|
||||
// ApprovalCoordinator only tracks approval IDs, not the full request data.
|
||||
// To fix properly: store full ApprovalRequest in ApprovalCoordinator when
|
||||
// requests are created, then return that data from GET /api/approvals.
|
||||
handleApprovalRequest({
|
||||
approvalId: approval.approvalId,
|
||||
type: approval.type,
|
||||
sessionId: approval.sessionId,
|
||||
timeout: approval.timeout,
|
||||
timestamp: new Date(approval.timestamp),
|
||||
metadata: approval.metadata,
|
||||
} as ApprovalRequest);
|
||||
}
|
||||
}, [pendingApprovalsData, handleApprovalRequest]);
|
||||
|
||||
// Clear restored approvals tracking when session changes
|
||||
useEffect(() => {
|
||||
restoredApprovalsRef.current.clear();
|
||||
}, [currentSessionId]);
|
||||
|
||||
// Messages from centralized selector (stable reference, handles null session)
|
||||
const messages = useSessionMessages(currentSessionId);
|
||||
|
||||
// Mutation for generating session title
|
||||
const { mutate: generateTitle } = useMutation({
|
||||
mutationFn: async (sessionId: string) => {
|
||||
const response = await client.api.sessions[':sessionId']['generate-title'].$post({
|
||||
param: { sessionId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate title');
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.title;
|
||||
},
|
||||
onSuccess: (_title, sessionId) => {
|
||||
// Invalidate sessions query to show the new title
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
|
||||
// Also invalidate the specific session query if needed
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.detail(sessionId) });
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-create session on first message with random UUID
|
||||
const createAutoSession = useCallback(async (): Promise<string> => {
|
||||
const response = await client.api.sessions.$post({
|
||||
json: {}, // Let server generate random UUID
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create session');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.session?.id) {
|
||||
throw new Error('Session ID not found in server response');
|
||||
}
|
||||
|
||||
const sessionId = data.session.id;
|
||||
|
||||
// Track session creation
|
||||
analytics.trackSessionCreated({
|
||||
sessionId,
|
||||
trigger: 'first_message',
|
||||
});
|
||||
|
||||
return sessionId;
|
||||
}, [analytics]);
|
||||
|
||||
// Enhanced sendMessage with auto-session creation
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
content: string,
|
||||
imageData?: { image: string; mimeType: string },
|
||||
fileData?: { data: string; mimeType: string; filename?: string }
|
||||
) => {
|
||||
let sessionId = currentSessionId;
|
||||
let isNewSession = false;
|
||||
|
||||
// Auto-create session on first message and wait for it to complete
|
||||
if (!sessionId && isWelcomeState) {
|
||||
if (isCreatingSession) return; // Another send in-flight; drop duplicate request
|
||||
try {
|
||||
setIsCreatingSession(true);
|
||||
sessionId = await createAutoSession();
|
||||
isNewSession = true;
|
||||
|
||||
// Mark this session as a new session before navigation
|
||||
// This allows switchSession to run but skip history load
|
||||
newSessionWithMessageRef.current = sessionId;
|
||||
|
||||
// Update ref BEFORE streaming to prevent race conditions
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
// Send message BEFORE navigating
|
||||
originalSendMessage(content, imageData, fileData, sessionId);
|
||||
|
||||
// Navigate - this will trigger switchSession via ChatApp useEffect
|
||||
navigate({ to: `/chat/${sessionId}`, replace: true });
|
||||
|
||||
// Generate title for newly created session after first message
|
||||
// Use setTimeout to delay title generation until message is complete
|
||||
setTimeout(() => {
|
||||
if (sessionId) generateTitle(sessionId);
|
||||
}, 0);
|
||||
|
||||
// Note: currentLLM will automatically refetch when currentSessionId changes
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
return; // Don't send message if session creation fails
|
||||
} finally {
|
||||
setIsCreatingSession(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Only send if we're using an existing session (not a newly created one)
|
||||
if (sessionId && !isNewSession) {
|
||||
originalSendMessage(content, imageData, fileData, sessionId);
|
||||
}
|
||||
|
||||
// Track message sent
|
||||
if (sessionId) {
|
||||
analytics.trackMessageSent({
|
||||
sessionId,
|
||||
provider: 'unknown', // Provider/model tracking moved to component level
|
||||
model: 'unknown',
|
||||
hasImage: !!imageData,
|
||||
hasFile: !!fileData,
|
||||
messageLength: content.length,
|
||||
});
|
||||
} else {
|
||||
console.error('No session available for sending message');
|
||||
}
|
||||
},
|
||||
[
|
||||
originalSendMessage,
|
||||
currentSessionId,
|
||||
isWelcomeState,
|
||||
isCreatingSession,
|
||||
createAutoSession,
|
||||
navigate,
|
||||
analytics,
|
||||
generateTitle,
|
||||
]
|
||||
);
|
||||
|
||||
// Enhanced reset with session support
|
||||
const reset = useCallback(() => {
|
||||
if (currentSessionId) {
|
||||
// Track conversation reset
|
||||
const messageCount = messages.filter((m) => m.sessionId === currentSessionId).length;
|
||||
analytics.trackSessionReset({
|
||||
sessionId: currentSessionId,
|
||||
messageCount,
|
||||
});
|
||||
|
||||
originalReset(currentSessionId);
|
||||
}
|
||||
}, [originalReset, currentSessionId, analytics, messages]);
|
||||
|
||||
// Load session history when switching sessions
|
||||
const { data: sessionHistoryData } = useQuery({
|
||||
queryKey: queryKeys.sessions.history(currentSessionId || ''),
|
||||
queryFn: async () => {
|
||||
if (!currentSessionId) {
|
||||
return { messages: [], isBusy: false };
|
||||
}
|
||||
try {
|
||||
return await fetchSessionHistory(currentSessionId);
|
||||
} catch {
|
||||
// Return empty result for 404 or other errors
|
||||
return { messages: [], isBusy: false };
|
||||
}
|
||||
},
|
||||
enabled: false, // Manual refetch only
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Sync session history data to messages when it changes
|
||||
useEffect(() => {
|
||||
if (sessionHistoryData && currentSessionId) {
|
||||
const currentMessages = useChatStore.getState().getMessages(currentSessionId);
|
||||
const hasSessionMsgs = currentMessages.some((m) => m.sessionId === currentSessionId);
|
||||
if (!hasSessionMsgs) {
|
||||
useChatStore
|
||||
.getState()
|
||||
.setMessages(currentSessionId, sessionHistoryData.messages as any);
|
||||
}
|
||||
// Cancel any active run on page refresh (we can't reconnect to the stream)
|
||||
if (sessionHistoryData.isBusy) {
|
||||
// Reset agent state since we're cancelling - we won't receive run:complete event
|
||||
useAgentStore.getState().setIdle();
|
||||
client.api.sessions[':sessionId'].cancel
|
||||
.$post({
|
||||
param: { sessionId: currentSessionId },
|
||||
json: { clearQueue: true },
|
||||
})
|
||||
.catch((e) => console.warn('Failed to cancel busy session:', e));
|
||||
}
|
||||
}
|
||||
}, [sessionHistoryData, currentSessionId]);
|
||||
|
||||
const loadSessionHistory = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
const result = await queryClient.fetchQuery({
|
||||
queryKey: queryKeys.sessions.history(sessionId),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await fetchSessionHistory(sessionId);
|
||||
} catch {
|
||||
// Return empty result for 404 or other errors
|
||||
return { messages: [], isBusy: false };
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const currentMessages = useChatStore.getState().getMessages(sessionId);
|
||||
const hasSessionMsgs = currentMessages.some((m) => m.sessionId === sessionId);
|
||||
if (!hasSessionMsgs) {
|
||||
// Populate chatStore with history (cast to compatible type)
|
||||
useChatStore.getState().initFromHistory(sessionId, result.messages as any);
|
||||
}
|
||||
|
||||
// Cancel any active run on page refresh (we can't reconnect to the stream)
|
||||
// This ensures clean state - user can see history and send new messages
|
||||
// TODO: Implement stream reconnection instead of cancelling
|
||||
// - Add GET /sessions/{sessionId}/events SSE endpoint for listen-only mode
|
||||
// - Connect to event stream when isBusy=true to resume receiving updates
|
||||
if (result.isBusy) {
|
||||
// Reset agent state since we're cancelling - we won't receive run:complete event
|
||||
useAgentStore.getState().setIdle();
|
||||
try {
|
||||
await client.api.sessions[':sessionId'].cancel.$post({
|
||||
param: { sessionId },
|
||||
json: { clearQueue: true }, // Hard cancel - clear queue too
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to cancel busy session on load:', e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading session history:', error);
|
||||
useChatStore.getState().clearMessages(sessionId);
|
||||
}
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
// Switch to a different session and load it on the backend
|
||||
const switchSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
// Guard against switching to same session or rapid successive switches
|
||||
// Use ref for immediate check (state updates are async)
|
||||
if (
|
||||
sessionId === currentSessionId ||
|
||||
sessionId === lastSwitchedSessionRef.current ||
|
||||
isSwitchingSession
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSwitchingSession(true);
|
||||
try {
|
||||
// Track session switch (defensive - analytics failures shouldn't block switching)
|
||||
try {
|
||||
analytics.trackSessionSwitched({
|
||||
fromSessionId: currentSessionId,
|
||||
toSessionId: sessionId,
|
||||
});
|
||||
} catch (analyticsError) {
|
||||
console.error('Failed to track session switch:', analyticsError);
|
||||
}
|
||||
|
||||
// Skip history load for newly created sessions with first message already sent
|
||||
// This prevents replacing message IDs and breaking error anchoring
|
||||
// TODO: Long-term fix - backend should generate and persist message IDs
|
||||
// so history reload doesn't cause ID mismatches
|
||||
const skipHistoryLoad = newSessionWithMessageRef.current === sessionId;
|
||||
if (skipHistoryLoad) {
|
||||
// Clear the ref after using it once
|
||||
newSessionWithMessageRef.current = null;
|
||||
}
|
||||
|
||||
// Update ref BEFORE store to prevent race conditions with streaming
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
// Update store (single source of truth)
|
||||
useSessionStore.getState().setCurrentSession(sessionId);
|
||||
|
||||
// Mark this session as being switched to after state update succeeds
|
||||
lastSwitchedSessionRef.current = sessionId;
|
||||
|
||||
if (!skipHistoryLoad) {
|
||||
await loadSessionHistory(sessionId);
|
||||
}
|
||||
// Note: currentLLM will automatically refetch when currentSessionId changes via useQuery
|
||||
} catch (error) {
|
||||
console.error('Error switching session:', error);
|
||||
throw error; // Re-throw so UI can handle the error
|
||||
} finally {
|
||||
// Always reset the switching flag, even if error occurs
|
||||
setIsSwitchingSession(false);
|
||||
}
|
||||
},
|
||||
[currentSessionId, isSwitchingSession, loadSessionHistory, analytics]
|
||||
);
|
||||
|
||||
// Return to welcome state (no active session)
|
||||
const returnToWelcome = useCallback(() => {
|
||||
currentSessionIdRef.current = null;
|
||||
lastSwitchedSessionRef.current = null; // Clear to allow switching to same session again
|
||||
|
||||
// Update store (single source of truth)
|
||||
useSessionStore.getState().returnToWelcome();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider
|
||||
value={{
|
||||
messages,
|
||||
sendMessage,
|
||||
reset,
|
||||
switchSession,
|
||||
loadSessionHistory,
|
||||
returnToWelcome,
|
||||
cancel,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatContext(): ChatContextType {
|
||||
const context = useContext(ChatContext);
|
||||
if (!context) {
|
||||
throw new Error('useChatContext must be used within a ChatProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user