Files
SuperCharged-Claude-Code-Up…/dexto/packages/webui/components/hooks/ChatContext.tsx
admin b52318eeae 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>
2026-01-28 00:27:56 +04:00

688 lines
28 KiB
TypeScript

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;
}