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:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
/**
* Approval Context
*
* Provides approval handling functionality via React Context.
* Wraps the approvalStore to provide a clean API for components.
*/
import { createContext, useContext, useCallback, type ReactNode } from 'react';
import { useApprovalStore } from '@/lib/stores/approvalStore';
import type { ApprovalRequest } from '@dexto/core';
// =============================================================================
// Types
// =============================================================================
interface ApprovalContextType {
/**
* Handle an incoming approval request (add to store)
*/
handleApprovalRequest: (request: ApprovalRequest) => void;
}
// =============================================================================
// Context
// =============================================================================
const ApprovalContext = createContext<ApprovalContextType | null>(null);
// =============================================================================
// Provider
// =============================================================================
interface ApprovalProviderProps {
children: ReactNode;
}
export function ApprovalProvider({ children }: ApprovalProviderProps) {
const addApproval = useApprovalStore((s) => s.addApproval);
const handleApprovalRequest = useCallback(
(request: ApprovalRequest) => {
addApproval(request);
},
[addApproval]
);
return (
<ApprovalContext.Provider value={{ handleApprovalRequest }}>
{children}
</ApprovalContext.Provider>
);
}
// =============================================================================
// Hook
// =============================================================================
/**
* Hook to access approval handling functions
*
* @throws Error if used outside ApprovalProvider
*/
export function useApproval(): ApprovalContextType {
const context = useContext(ApprovalContext);
if (!context) {
throw new Error('useApproval must be used within an ApprovalProvider');
}
return context;
}

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

View File

@@ -0,0 +1,54 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { client } from '@/lib/client.js';
import { queryKeys } from '@/lib/queryKeys.js';
// Fetch agent configuration
export function useAgentConfig(enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.agent.config,
queryFn: async () => {
const response = await client.api.agent.config.$get();
return await response.json();
},
enabled,
staleTime: 30000, // 30 seconds
});
}
// Validate agent configuration
export function useValidateAgent() {
return useMutation({
mutationFn: async ({ yaml }: { yaml: string }) => {
const response = await client.api.agent.validate.$post({
json: { yaml },
});
return await response.json();
},
});
}
// Export types inferred from hook return values
export type ValidationError = NonNullable<
ReturnType<typeof useValidateAgent>['data']
>['errors'][number];
export type ValidationWarning = NonNullable<
ReturnType<typeof useValidateAgent>['data']
>['warnings'][number];
// Save agent configuration
export function useSaveAgentConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ yaml }: { yaml: string }) => {
const response = await client.api.agent.config.$post({
json: { yaml },
});
return await response.json();
},
onSuccess: () => {
// Invalidate agent config to refresh after save
queryClient.invalidateQueries({ queryKey: queryKeys.agent.config });
},
});
}

View File

@@ -0,0 +1,115 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { client } from '@/lib/client';
export function useAgents() {
return useQuery({
queryKey: queryKeys.agents.all,
queryFn: async () => {
const response = await client.api.agents.$get();
if (!response.ok) {
throw new Error(`Failed to fetch agents: ${response.status}`);
}
return await response.json();
},
staleTime: 5 * 60 * 1000, // 5 minutes - agent list rarely changes
});
}
export function useAgentPath() {
return useQuery({
queryKey: queryKeys.agents.path,
queryFn: async () => {
const response = await client.api.agent.path.$get();
if (!response.ok) {
throw new Error(`Failed to fetch agent path: ${response.status}`);
}
return await response.json();
},
retry: false,
staleTime: 5 * 60 * 1000, // 5 minutes - current agent path only changes on explicit switch
});
}
export function useSwitchAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
payload: Parameters<typeof client.api.agents.switch.$post>[0]['json']
) => {
const response = await client.api.agents.switch.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to switch agent: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.path });
},
});
}
export function useInstallAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
payload: Parameters<typeof client.api.agents.install.$post>[0]['json']
) => {
const response = await client.api.agents.install.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to install agent: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
},
});
}
export function useUninstallAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
payload: Parameters<typeof client.api.agents.uninstall.$post>[0]['json']
) => {
const response = await client.api.agents.uninstall.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to uninstall agent: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.path });
},
});
}
export function useCreateAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
payload: Parameters<typeof client.api.agents.custom.create.$post>[0]['json']
) => {
const response = await client.api.agents.custom.create.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to create agent: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
},
});
}
// Export inferred types for components to use
export type CreateAgentPayload = Parameters<
typeof client.api.agents.custom.create.$post
>[0]['json'];

View File

@@ -0,0 +1,62 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { client } from '@/lib/client';
import { queryKeys } from '@/lib/queryKeys';
type ApprovalPayload = Parameters<(typeof client.api.approvals)[':approvalId']['$post']>[0]['json'];
export function useSubmitApproval() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
payload: { approvalId: string; sessionId: string } & ApprovalPayload
) => {
const { approvalId, sessionId: _sessionId, ...body } = payload;
const response = await client.api.approvals[':approvalId'].$post({
param: { approvalId },
json: body,
header: {},
});
if (!response.ok) {
throw new Error(`Failed to submit approval: ${response.status}`);
}
return await response.json();
},
onSuccess: (_, variables) => {
// Invalidate pending approvals cache when an approval is submitted
// Query is keyed by sessionId, not approvalId
queryClient.invalidateQueries({
queryKey: queryKeys.approvals.pending(variables.sessionId),
});
},
});
}
/**
* Hook to fetch pending approvals for a session.
* Use this to restore approval UI state after page refresh.
*
* @param sessionId - The session ID to fetch pending approvals for
* @param options.enabled - Whether to enable the query (default: true if sessionId provided)
*/
export function usePendingApprovals(sessionId: string | null, options?: { enabled?: boolean }) {
return useQuery({
queryKey: queryKeys.approvals.pending(sessionId || ''),
queryFn: async () => {
if (!sessionId) return { approvals: [] };
const response = await client.api.approvals.$get({
query: { sessionId },
});
if (!response.ok) {
throw new Error('Failed to fetch pending approvals');
}
return await response.json();
},
enabled: (options?.enabled ?? true) && !!sessionId,
});
}
// Export inferred types for consumers
export type PendingApproval = NonNullable<
ReturnType<typeof usePendingApprovals>['data']
>['approvals'][number];

View File

@@ -0,0 +1,405 @@
import React, { useRef, useEffect, useCallback } from 'react';
import type { SanitizedToolResult } from '@dexto/core';
import { useQueryClient } from '@tanstack/react-query';
import { useAnalytics } from '@/lib/analytics/index.js';
import { client } from '@/lib/client.js';
import { queryKeys } from '@/lib/queryKeys.js';
import { createMessageStream } from '@dexto/client-sdk';
import type { MessageStreamEvent } from '@dexto/client-sdk';
import { eventBus } from '@/lib/events/EventBus.js';
import { useChatStore } from '@/lib/stores/chatStore.js';
import type { Session } from './useSessions.js';
// Content part types - import from centralized types.ts
import type { FileData } from '../../types.js';
// Tool result types
export interface ToolResultError {
error: string | Record<string, unknown>;
}
export type ToolResultContent = SanitizedToolResult;
export type ToolResult = ToolResultError | SanitizedToolResult | string | Record<string, unknown>;
// Type guards for tool results
export function isToolResultError(result: unknown): result is ToolResultError {
return typeof result === 'object' && result !== null && 'error' in result;
}
export function isToolResultContent(result: unknown): result is ToolResultContent {
return (
typeof result === 'object' &&
result !== null &&
'content' in result &&
Array.isArray((result as ToolResultContent).content)
);
}
// =============================================================================
// Re-export Message types from chatStore (single source of truth)
// =============================================================================
// Import from chatStore
import type { Message } from '@/lib/stores/chatStore.js';
// Re-export for API compatibility - components can import these from either place
export type { Message, ErrorMessage } from '@/lib/stores/chatStore.js';
// Legacy type aliases for code that uses the discriminated union pattern
// These are intersection types that narrow the Message type by role
export type UIUserMessage = Message & { role: 'user' };
export type UIAssistantMessage = Message & { role: 'assistant' };
export type UIToolMessage = Message & { role: 'tool' };
// =============================================================================
// Message Type Guards
// =============================================================================
/** Type guard for user messages */
export function isUserMessage(msg: Message): msg is UIUserMessage {
return msg.role === 'user';
}
/** Type guard for assistant messages */
export function isAssistantMessage(msg: Message): msg is UIAssistantMessage {
return msg.role === 'assistant';
}
/** Type guard for tool messages */
export function isToolMessage(msg: Message): msg is UIToolMessage {
return msg.role === 'tool';
}
export type StreamStatus = 'idle' | 'connecting' | 'open' | 'closed';
const generateUniqueId = () => `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
export function useChat(
activeSessionIdRef: React.MutableRefObject<string | null>,
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>
) {
const analytics = useAnalytics();
const analyticsRef = useRef(analytics);
const queryClient = useQueryClient();
// Helper to update sessions cache (replaces DOM events)
const updateSessionActivity = useCallback(
(sessionId: string, incrementMessageCount: boolean = true) => {
queryClient.setQueryData<Session[]>(queryKeys.sessions.all, (old = []) => {
const exists = old.some((s) => s.id === sessionId);
if (exists) {
return old.map((session) =>
session.id === sessionId
? {
...session,
...(incrementMessageCount && {
messageCount: session.messageCount + 1,
}),
lastActivity: Date.now(),
}
: session
);
} else {
// Create new session entry
const newSession: Session = {
id: sessionId,
createdAt: Date.now(),
lastActivity: Date.now(),
messageCount: 1,
title: null,
};
return [newSession, ...old];
}
});
},
[queryClient]
);
const updateSessionTitle = useCallback(
(sessionId: string, title: string) => {
queryClient.setQueryData<Session[]>(queryKeys.sessions.all, (old = []) =>
old.map((session) => (session.id === sessionId ? { ...session, title } : session))
);
},
[queryClient]
);
// Track message IDs for error anchoring
const lastUserMessageIdRef = useRef<string | null>(null);
const lastMessageIdRef = useRef<string | null>(null);
// Map callId to message index for O(1) tool result pairing
const pendingToolCallsRef = useRef<Map<string, number>>(new Map());
// Keep analytics ref updated
useEffect(() => {
analyticsRef.current = analytics;
}, [analytics]);
// Abort controller management (moved from ChatContext)
const getAbortController = useCallback(
(sessionId: string): AbortController => {
const existing = abortControllersRef.current.get(sessionId);
if (existing) {
return existing;
}
const controller = new AbortController();
abortControllersRef.current.set(sessionId, controller);
return controller;
},
[abortControllersRef]
);
const abortSession = useCallback(
(sessionId: string) => {
const controller = abortControllersRef.current.get(sessionId);
if (controller) {
controller.abort();
abortControllersRef.current.delete(sessionId);
}
},
[abortControllersRef]
);
const isForActiveSession = useCallback(
(sessionId?: string): boolean => {
if (!sessionId) return false;
const current = activeSessionIdRef.current;
return !!current && sessionId === current;
},
[activeSessionIdRef]
);
const processEvent = useCallback(
(event: MessageStreamEvent) => {
// All streaming events must have sessionId
if (!event.sessionId) {
console.error(`Event missing sessionId: ${JSON.stringify(event)}`);
return;
}
// Check session match
if (!isForActiveSession(event.sessionId)) {
return;
}
// Dispatch to EventBus - handlers.ts will update chatStore
// NOTE: All store updates (messages, streaming, processing) are handled by handlers.ts
// This function only handles React-specific side effects not in handlers.ts:
// - TanStack Query cache updates
// - Analytics tracking
// - Ref updates for error anchoring
eventBus.dispatch(event);
// Handle React-specific side effects not in handlers.ts
// IMPORTANT: Do NOT update chatStore here - that's handled by handlers.ts via EventBus
switch (event.name) {
case 'llm:response': {
// Update sessions cache (response received)
updateSessionActivity(event.sessionId);
break;
}
case 'llm:tool-call': {
const { toolName, sessionId } = event;
// Track tool called analytics
if (toolName) {
analyticsRef.current.trackToolCalled({
toolName,
sessionId,
});
}
break;
}
case 'llm:tool-result': {
const { toolName, success } = event;
// Track tool result analytics
if (toolName) {
analyticsRef.current.trackToolResult({
toolName,
success: success !== false,
sessionId: event.sessionId,
});
}
break;
}
case 'session:title-updated': {
// Update TanStack Query cache
updateSessionTitle(event.sessionId, event.title);
break;
}
case 'message:dequeued': {
// Update sessions cache
updateSessionActivity(event.sessionId);
// Invalidate queue cache so UI removes the message
queryClient.invalidateQueries({
queryKey: queryKeys.queue.list(event.sessionId),
});
break;
}
}
},
[isForActiveSession, analyticsRef, updateSessionActivity, updateSessionTitle, queryClient]
);
const sendMessage = useCallback(
async (
content: string,
imageData?: { image: string; mimeType: string },
fileData?: FileData,
sessionId?: string
) => {
if (!sessionId) {
console.error('Session ID required for sending message');
return;
}
// Abort previous request if any
abortSession(sessionId);
const abortController = getAbortController(sessionId) || new AbortController();
useChatStore.getState().setProcessing(sessionId, true);
// Add user message to state
const userId = generateUniqueId();
lastUserMessageIdRef.current = userId;
lastMessageIdRef.current = userId; // Track for error anchoring
useChatStore.getState().addMessage(sessionId, {
id: userId,
role: 'user',
content,
createdAt: Date.now(),
sessionId,
imageData,
fileData,
});
// Update sessions cache (user message sent)
updateSessionActivity(sessionId);
try {
// Build content parts array from text, image, and file data
// New API uses unified ContentInput = string | ContentPart[]
const contentParts: Array<
| { type: 'text'; text: string }
| { type: 'image'; image: string; mimeType?: string }
| { type: 'file'; data: string; mimeType: string; filename?: string }
> = [];
if (content) {
contentParts.push({ type: 'text', text: content });
}
if (imageData) {
contentParts.push({
type: 'image',
image: imageData.image,
mimeType: imageData.mimeType,
});
}
if (fileData) {
contentParts.push({
type: 'file',
data: fileData.data,
mimeType: fileData.mimeType,
filename: fileData.filename,
});
}
// Always use SSE for all events (tool calls, approvals, responses)
// The 'stream' flag only controls whether chunks update UI incrementally
const responsePromise = client.api['message-stream'].$post({
json: {
content:
contentParts.length === 1 && contentParts[0]?.type === 'text'
? content // Simple text-only case: send as string
: contentParts, // Multimodal: send as array
sessionId,
},
});
const iterator = createMessageStream(responsePromise, {
signal: abortController.signal,
});
for await (const event of iterator) {
processEvent(event);
}
} catch (error: unknown) {
// Handle abort gracefully
if (error instanceof Error && error.name === 'AbortError') {
console.log('Stream aborted by user');
return;
}
console.error(
`Stream error: ${error instanceof Error ? error.message : String(error)}`
);
useChatStore.getState().setProcessing(sessionId, false);
const message = error instanceof Error ? error.message : 'Failed to send message';
useChatStore.getState().setError(sessionId, {
id: generateUniqueId(),
message,
timestamp: Date.now(),
context: 'stream',
recoverable: true,
sessionId,
anchorMessageId: lastMessageIdRef.current || undefined,
});
}
},
[processEvent, abortSession, getAbortController, updateSessionActivity]
);
const reset = useCallback(async (sessionId?: string) => {
if (!sessionId) return;
try {
await client.api.reset.$post({
json: { sessionId },
});
} catch (e) {
console.error(`Failed to reset session: ${e instanceof Error ? e.message : String(e)}`);
}
// Note: Messages are now in chatStore, not local state
useChatStore.getState().setError(sessionId, null);
lastUserMessageIdRef.current = null;
lastMessageIdRef.current = null;
pendingToolCallsRef.current.clear();
useChatStore.getState().setProcessing(sessionId, false);
}, []);
const cancel = useCallback(async (sessionId?: string, clearQueue: boolean = false) => {
if (!sessionId) return;
// Tell server to cancel the LLM stream
// Soft cancel (default): Only cancel current response, queued messages continue
// Hard cancel (clearQueue=true): Cancel current response AND clear all queued messages
try {
await client.api.sessions[':sessionId'].cancel.$post({
param: { sessionId },
json: { clearQueue },
});
} catch (err) {
// Server cancel is best-effort - log but don't throw
console.warn('Failed to cancel server-side:', err);
}
// UI state will be updated when server sends run:complete event
pendingToolCallsRef.current.clear();
}, []);
return {
sendMessage,
reset,
cancel,
};
}

View File

@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { client } from '@/lib/client';
/**
* Hook to fetch the current LLM configuration for a session
* Centralized access point for currentLLM data
*/
export function useCurrentLLM(sessionId: string | null, enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.llm.current(sessionId),
queryFn: async () => {
const response = await client.api.llm.current.$get({
query: sessionId ? { sessionId } : {},
});
if (!response.ok) {
throw new Error('Failed to fetch current LLM config');
}
const data = await response.json();
const cfg = data.config || data;
return {
provider: cfg.provider,
model: cfg.model,
displayName: cfg.displayName,
baseURL: cfg.baseURL,
viaDexto: data.routing?.viaDexto ?? false,
};
},
// Always enabled - API returns default config when no sessionId
// This ensures the model name shows on welcome screen
enabled,
retry: false, // Don't retry on error - UI can still operate
});
}
// Export type for components to use
export type CurrentLLM = NonNullable<ReturnType<typeof useCurrentLLM>['data']>;

View File

@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { client } from '@/lib/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* Hook to fetch Dexto authentication status.
* Returns whether dexto auth is enabled, user is authenticated, and can use dexto provider.
*/
export function useDextoAuth(enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.dextoAuth.status,
queryFn: async () => {
const res = await client.api['dexto-auth'].status.$get();
if (!res.ok) throw new Error('Failed to fetch dexto auth status');
return await res.json();
},
enabled,
staleTime: 30 * 1000, // 30 seconds - auth status may change
});
}
// Export types using the standard inference pattern
export type DextoAuthStatus = NonNullable<ReturnType<typeof useDextoAuth>['data']>;

View File

@@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query';
import { client } from '@/lib/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* Hook to fetch available providers and capabilities.
* Returns blob storage providers, compression strategies, custom tool providers, and internal tools.
*/
export function useDiscovery(enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.discovery.all,
queryFn: async () => {
const res = await client.api.discovery.$get();
if (!res.ok) throw new Error('Failed to fetch discovery data');
return await res.json();
},
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes - providers don't change often
});
}
// Export types using the standard inference pattern
export type DiscoveryResponse = NonNullable<ReturnType<typeof useDiscovery>['data']>;
export type DiscoveredProvider = DiscoveryResponse['blob'][number];
export type InternalToolInfo = DiscoveryResponse['internalTools'][number];

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
// Returns true when the browser reports that page fonts are loaded.
// This lets us defer first-measure actions (like autosize) until
// typographic metrics are stable to avoid initial reflow.
export function useFontsReady(): boolean {
const [ready, setReady] = useState(false);
useEffect(() => {
if (typeof document === 'undefined') return;
// If Font Loading API is unavailable, assume ready to avoid blocking.
const anyDoc = document as any;
if (!anyDoc.fonts || !anyDoc.fonts.ready) {
setReady(true);
return;
}
let cancelled = false;
anyDoc.fonts.ready.then(() => {
if (!cancelled) setReady(true);
});
return () => {
cancelled = true;
};
}, []);
return ready;
}

View File

@@ -0,0 +1,28 @@
import { useQuery } from '@tanstack/react-query';
import { client } from '@/lib/client.js';
import { queryKeys } from '@/lib/queryKeys.js';
async function fetchGreeting(sessionId?: string | null): Promise<string | null> {
const data = await client.api.greeting.$get({
query: sessionId ? { sessionId } : {},
});
if (!data.ok) {
throw new Error(`Failed to fetch greeting: ${data.status}`);
}
const json = await data.json();
return json.greeting ?? null;
}
export function useGreeting(sessionId?: string | null) {
const {
data: greeting = null,
isLoading,
error,
} = useQuery({
queryKey: queryKeys.greeting(sessionId),
queryFn: () => fetchGreeting(sessionId),
staleTime: 5 * 60 * 1000, // 5 minutes - greeting is static per agent
});
return { greeting, isLoading, error: error?.message ?? null };
}

View File

@@ -0,0 +1,187 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { client } from '@/lib/client';
import { queryKeys } from '@/lib/queryKeys';
import { isTextPart } from '../../types';
const MAX_HISTORY_SIZE = 100;
/**
* Hook to fetch user messages from session history
*/
function useSessionUserMessages(sessionId: string | null) {
return useQuery({
queryKey: queryKeys.sessions.history(sessionId ?? ''),
queryFn: async () => {
if (!sessionId) return [];
const response = await client.api.sessions[':sessionId'].history.$get({
param: { sessionId },
});
if (!response.ok) return [];
const data = await response.json();
const historyMessages = data.history || [];
// Extract text content from user messages
const userTexts: string[] = [];
for (const msg of historyMessages) {
if (msg.role !== 'user') continue;
if (!msg.content || !Array.isArray(msg.content)) continue;
const textParts = msg.content
.filter(isTextPart)
.map((part) => part.text.trim())
.filter((t) => t.length > 0);
if (textParts.length > 0) {
userTexts.push(textParts.join('\n'));
}
}
// Deduplicate consecutive entries
const deduplicated: string[] = [];
for (const text of userTexts) {
if (deduplicated.length === 0 || deduplicated[deduplicated.length - 1] !== text) {
deduplicated.push(text);
}
}
return deduplicated.slice(-MAX_HISTORY_SIZE);
},
enabled: !!sessionId,
staleTime: 30000, // Consider fresh for 30s
});
}
/**
* Hook for managing input history with shell-style navigation.
*
* - Up arrow: Navigate to older entries
* - Down arrow: Navigate to newer entries
* - History cursor resets when user types new input
* - Loads previous user messages from session history via TanStack Query
*
*/
export function useInputHistory(sessionId: string | null) {
const queryClient = useQueryClient();
// Fetch historical user messages from session
const { data: history = [] } = useSessionUserMessages(sessionId);
// Current position in history (-1 means not browsing, 0 = oldest, length-1 = newest)
const [cursor, setCursor] = useState<number>(-1);
// Track the text that was in input before browsing started
const savedInputRef = useRef<string>('');
// Track last recalled text to prevent hijacking normal editing
const lastRecalledRef = useRef<string | null>(null);
// Reset cursor when session changes
useEffect(() => {
setCursor(-1);
lastRecalledRef.current = null;
savedInputRef.current = '';
}, [sessionId]);
/**
* Invalidate history cache after sending a message.
* Call this after successfully sending to refresh the history.
*/
const invalidateHistory = useCallback(() => {
if (sessionId) {
queryClient.invalidateQueries({
queryKey: queryKeys.sessions.history(sessionId),
});
}
}, [queryClient, sessionId]);
/**
* Check if we should handle navigation (up/down) vs normal cursor movement.
* Only handle navigation when:
* 1. Input is empty, OR
* 2. Cursor is at position 0 AND text matches last recalled history
*/
const shouldHandleNavigation = useCallback(
(currentText: string, cursorPosition: number): boolean => {
if (!currentText) return true;
if (cursorPosition === 0 && lastRecalledRef.current === currentText) {
return true;
}
return false;
},
[]
);
/**
* Navigate up (older entries)
* Returns the text to display, or null if at end of history
*/
const navigateUp = useCallback(
(currentText: string): string | null => {
if (history.length === 0) return null;
// If not currently browsing, save current input and start from newest
if (cursor === -1) {
savedInputRef.current = currentText;
const idx = history.length - 1;
setCursor(idx);
const text = history[idx];
lastRecalledRef.current = text ?? null;
return text ?? null;
}
// Already at oldest entry
if (cursor === 0) return null;
// Move to older entry
const newCursor = cursor - 1;
setCursor(newCursor);
const text = history[newCursor];
lastRecalledRef.current = text ?? null;
return text ?? null;
},
[history, cursor]
);
/**
* Navigate down (newer entries)
* Returns the text to display, or null if back to current input
*/
const navigateDown = useCallback((): string | null => {
if (cursor === -1) return null;
// At newest entry - return to saved input
if (cursor === history.length - 1) {
setCursor(-1);
lastRecalledRef.current = null;
return savedInputRef.current;
}
// Move to newer entry
const newCursor = cursor + 1;
setCursor(newCursor);
const text = history[newCursor];
lastRecalledRef.current = text ?? null;
return text ?? null;
}, [history, cursor]);
/**
* Reset history browsing (call when user types)
*/
const resetCursor = useCallback(() => {
if (cursor !== -1) {
setCursor(-1);
lastRecalledRef.current = null;
}
}, [cursor]);
return {
history,
cursor,
invalidateHistory,
navigateUp,
navigateDown,
resetCursor,
shouldHandleNavigation,
isBrowsing: cursor !== -1,
};
}

View File

@@ -0,0 +1,165 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { client } from '@/lib/client';
export function useLLMCatalog(options?: { enabled?: boolean; mode?: 'grouped' | 'flat' }) {
const mode = options?.mode ?? 'grouped';
return useQuery({
queryKey: [...queryKeys.llm.catalog, mode],
queryFn: async () => {
const response = await client.api.llm.catalog.$get({ query: { mode } });
if (!response.ok) {
throw new Error(`Failed to fetch LLM catalog: ${response.status}`);
}
return await response.json();
},
enabled: options?.enabled ?? true,
staleTime: 5 * 60 * 1000, // 5 minutes - catalog rarely changes
});
}
export function useSwitchLLM() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: SwitchLLMPayload) => {
const response = await client.api.llm.switch.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to switch LLM: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
// Invalidate catalog and all current LLM queries to refresh all views
queryClient.invalidateQueries({ queryKey: queryKeys.llm.catalog });
queryClient.invalidateQueries({ queryKey: ['llm', 'current'] });
},
});
}
export function useProviderApiKey(provider: LLMProvider | null, options?: { enabled?: boolean }) {
return useQuery({
queryKey: [...queryKeys.llm.catalog, 'key', provider],
queryFn: async () => {
if (!provider) return null;
const response = await client.api.llm.key[':provider'].$get({
param: { provider },
});
if (!response.ok) {
throw new Error(`Failed to fetch API key: ${response.status}`);
}
return await response.json();
},
enabled: (options?.enabled ?? true) && !!provider,
staleTime: 30 * 1000, // 30 seconds
});
}
export function useSaveApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: SaveApiKeyPayload) => {
const response = await client.api.llm.key.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to save API key: ${response.status}`);
}
return await response.json();
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.llm.catalog });
// Also invalidate the specific provider key query
queryClient.invalidateQueries({
queryKey: [...queryKeys.llm.catalog, 'key', variables.provider],
});
},
});
}
// Custom models hooks
export function useCustomModels(options?: { enabled?: boolean }) {
return useQuery({
queryKey: queryKeys.llm.customModels,
queryFn: async () => {
const response = await client.api.llm['custom-models'].$get();
if (!response.ok) {
throw new Error(`Failed to fetch custom models: ${response.status}`);
}
const data = await response.json();
return data.models;
},
enabled: options?.enabled ?? true,
staleTime: 60 * 1000, // 1 minute
});
}
export function useCreateCustomModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: CustomModelPayload) => {
const response = await client.api.llm['custom-models'].$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to create custom model: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.llm.customModels });
},
});
}
export function useDeleteCustomModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (name: string) => {
// URL-encode the name to handle OpenRouter model IDs with slashes (e.g., anthropic/claude-3.5-sonnet)
const encodedName = encodeURIComponent(name);
const response = await client.api.llm['custom-models'][':name'].$delete({
param: { name: encodedName },
});
if (!response.ok) {
throw new Error(`Failed to delete custom model: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.llm.customModels });
},
});
}
// Model capabilities hook - resolves gateway providers to underlying model capabilities
export function useModelCapabilities(
provider: LLMProvider | null | undefined,
model: string | null | undefined,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: [...queryKeys.llm.catalog, 'capabilities', provider, model],
queryFn: async () => {
if (!provider || !model) return null;
const response = await client.api.llm.capabilities.$get({
query: { provider, model },
});
if (!response.ok) {
throw new Error(`Failed to fetch model capabilities: ${response.status}`);
}
return await response.json();
},
enabled: (options?.enabled ?? true) && !!provider && !!model,
staleTime: 5 * 60 * 1000, // 5 minutes - capabilities rarely change
});
}
// Export inferred types for components to use
export type SaveApiKeyPayload = Parameters<typeof client.api.llm.key.$post>[0]['json'];
export type LLMProvider = SaveApiKeyPayload['provider'];
export type SwitchLLMPayload = Parameters<typeof client.api.llm.switch.$post>[0]['json'];
// Helper to extract the custom-models endpoint (Prettier can't parse hyphenated bracket notation in Parameters<>)
type CustomModelsEndpoint = (typeof client.api.llm)['custom-models'];
export type CustomModelPayload = Parameters<CustomModelsEndpoint['$post']>[0]['json'];
export type CustomModel = NonNullable<ReturnType<typeof useCustomModels>['data']>[number];

View File

@@ -0,0 +1,56 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { client } from '@/lib/client';
export function useMemories(enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.memories.all,
queryFn: async () => {
const response = await client.api.memory.$get({ query: {} });
if (!response.ok) {
throw new Error(`Failed to fetch memories: ${response.status}`);
}
const data = await response.json();
return data.memories;
},
enabled,
staleTime: 30 * 1000, // 30 seconds - memories can be added during chat
});
}
export function useDeleteMemory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ memoryId }: { memoryId: string }) => {
const response = await client.api.memory[':id'].$delete({ param: { id: memoryId } });
if (!response.ok) {
throw new Error(`Failed to delete memory: ${response.status}`);
}
return memoryId;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.memories.all });
},
});
}
export function useCreateMemory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: Parameters<typeof client.api.memory.$post>[0]['json']) => {
const response = await client.api.memory.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to create memory: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.memories.all });
},
});
}
// Export inferred types for components to use
export type Memory = NonNullable<ReturnType<typeof useMemories>['data']>[number];

View File

@@ -0,0 +1,114 @@
/**
* Hooks for local GGUF and Ollama model management.
*
* These hooks expose model discovery that was previously only available in CLI.
* Used by the model picker to display installed local models and Ollama models.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { client } from '@/lib/client';
/**
* Fetch installed local GGUF models from state.json.
* These are models downloaded via CLI or manually registered.
*/
export function useLocalModels(options?: { enabled?: boolean }) {
return useQuery({
queryKey: queryKeys.models.local,
queryFn: async () => {
const response = await client.api.models.local.$get();
if (!response.ok) {
throw new Error(`Failed to fetch local models: ${response.status}`);
}
return await response.json();
},
enabled: options?.enabled ?? true,
staleTime: 30 * 1000, // 30 seconds - models don't change often
});
}
/**
* Fetch available Ollama models from the Ollama server.
* Returns empty list with available=false if Ollama is not running.
*/
export function useOllamaModels(options?: { enabled?: boolean; baseURL?: string }) {
return useQuery({
queryKey: queryKeys.models.ollama(options?.baseURL),
queryFn: async () => {
const response = await client.api.models.ollama.$get({
query: options?.baseURL ? { baseURL: options.baseURL } : {},
});
if (!response.ok) {
throw new Error(`Failed to fetch Ollama models: ${response.status}`);
}
return await response.json();
},
enabled: options?.enabled ?? true,
staleTime: 30 * 1000, // 30 seconds
retry: false, // Don't retry if Ollama not running
});
}
/**
* Validate a local GGUF file path.
* Used by the custom model form to validate file paths before saving.
*/
export function useValidateLocalFile() {
return useMutation({
mutationFn: async (filePath: string) => {
const response = await client.api.models.local.validate.$post({
json: { filePath },
});
if (!response.ok) {
throw new Error(`Failed to validate file: ${response.status}`);
}
return await response.json();
},
});
}
/**
* Delete an installed local model.
* Removes from state.json and optionally deletes the GGUF file from disk.
*/
export function useDeleteInstalledModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
modelId,
deleteFile = true,
}: {
modelId: string;
deleteFile?: boolean;
}) => {
const response = await client.api.models.local[':modelId'].$delete({
param: { modelId },
json: { deleteFile },
});
if (!response.ok) {
let errorMessage = `Failed to delete model: ${response.status}`;
try {
const data = await response.json();
if (data.error) errorMessage = data.error;
} catch {
// Response body not JSON-parseable (e.g., network error, proxy error), use default message
}
throw new Error(errorMessage);
}
return await response.json();
},
onSuccess: () => {
// Invalidate local models cache to refresh the list
queryClient.invalidateQueries({ queryKey: queryKeys.models.local });
},
});
}
// Export inferred types for components to use
export type LocalModel = NonNullable<ReturnType<typeof useLocalModels>['data']>['models'][number];
export type OllamaModel = NonNullable<ReturnType<typeof useOllamaModels>['data']>['models'][number];
export type ValidateLocalFileResult = Awaited<
ReturnType<ReturnType<typeof useValidateLocalFile>['mutateAsync']>
>;

View File

@@ -0,0 +1,27 @@
import { useMutation } from '@tanstack/react-query';
import { client } from '@/lib/client';
/**
* Validate an OpenRouter model ID against the registry.
* Returns validation result with status and optional error.
*/
export function useValidateOpenRouterModel() {
return useMutation({
mutationFn: async (modelId: string) => {
// URL-encode the model ID to handle slashes (e.g., anthropic/claude-3.5-sonnet)
const encodedModelId = encodeURIComponent(modelId);
const response = await client.api.openrouter.validate[':modelId'].$get({
param: { modelId: encodedModelId },
});
if (!response.ok) {
throw new Error(`Failed to validate model: ${response.status}`);
}
return await response.json();
},
});
}
// Export inferred types
export type ValidateOpenRouterModelResult = Awaited<
ReturnType<ReturnType<typeof useValidateOpenRouterModel>['mutateAsync']>
>;

View File

@@ -0,0 +1,69 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { client } from '@/lib/client.js';
/**
* Hook for fetching prompts with TanStack Query caching
*
* Replaces the old promptCache.ts in-memory cache with proper
* persistent caching that survives page refreshes.
*/
export function usePrompts(options?: { enabled?: boolean }) {
return useQuery({
queryKey: queryKeys.prompts.all,
queryFn: async () => {
const response = await client.api.prompts.$get();
const data = await response.json();
return data.prompts;
},
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
...options,
});
}
export function useCreatePrompt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
payload: Parameters<typeof client.api.prompts.custom.$post>[0]['json']
) => {
const response = await client.api.prompts.custom.$post({ json: payload });
if (!response.ok) {
throw new Error(`Failed to create prompt: ${response.status}`);
}
return await response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
},
});
}
type ResolvePromptParams = Parameters<(typeof client.api.prompts)[':name']['resolve']['$get']>[0];
export function useResolvePrompt() {
return useMutation({
mutationFn: async (
payload: {
name: string;
} & ResolvePromptParams['query']
) => {
const { name, ...query } = payload;
const response = await client.api.prompts[':name'].resolve.$get({
param: { name: encodeURIComponent(name) },
query,
});
if (!response.ok) {
throw new Error(`Failed to resolve prompt: ${response.status}`);
}
return await response.json();
},
});
}
// Export inferred types for components to use
export type Prompt = NonNullable<ReturnType<typeof usePrompts>['data']>[number];

View File

@@ -0,0 +1,145 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { client } from '@/lib/client';
import { queryKeys } from '@/lib/queryKeys';
/**
* Hook to fetch queued messages for a session
*/
export function useQueuedMessages(sessionId: string | null) {
return useQuery({
queryKey: queryKeys.queue.list(sessionId ?? ''),
queryFn: async () => {
if (!sessionId) return { messages: [], count: 0 };
const response = await client.api.queue[':sessionId'].$get({
param: { sessionId },
});
if (!response.ok) {
throw new Error('Failed to fetch queued messages');
}
return await response.json();
},
enabled: !!sessionId,
// Refetch frequently while processing to show queue updates
refetchInterval: (query) => ((query.state.data?.count ?? 0) > 0 ? 2000 : false),
});
}
// Export type for queued message
export type QueuedMessage = NonNullable<
ReturnType<typeof useQueuedMessages>['data']
>['messages'][number];
/**
* Hook to queue a new message
*/
export function useQueueMessage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
sessionId,
message,
imageData,
fileData,
}: {
sessionId: string;
message?: string;
imageData?: { image: string; mimeType: string };
fileData?: { data: string; mimeType: string; filename?: string };
}) => {
// Build content parts array from text, image, and file data
// New API uses unified ContentInput = string | ContentPart[]
const contentParts: Array<
| { type: 'text'; text: string }
| { type: 'image'; image: string; mimeType?: string }
| { type: 'file'; data: string; mimeType: string; filename?: string }
> = [];
if (message) {
contentParts.push({ type: 'text', text: message });
}
if (imageData) {
contentParts.push({
type: 'image',
image: imageData.image,
mimeType: imageData.mimeType,
});
}
if (fileData) {
contentParts.push({
type: 'file',
data: fileData.data,
mimeType: fileData.mimeType,
filename: fileData.filename,
});
}
const response = await client.api.queue[':sessionId'].$post({
param: { sessionId },
json: {
content:
contentParts.length === 1 && contentParts[0]?.type === 'text'
? message! // Simple text-only case: send as string
: contentParts, // Multimodal: send as array
},
});
if (!response.ok) {
throw new Error('Failed to queue message');
}
return await response.json();
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.queue.list(variables.sessionId),
});
},
});
}
/**
* Hook to remove a single queued message
*/
export function useRemoveQueuedMessage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ sessionId, messageId }: { sessionId: string; messageId: string }) => {
const response = await client.api.queue[':sessionId'][':messageId'].$delete({
param: { sessionId, messageId },
});
if (!response.ok) {
throw new Error('Failed to remove queued message');
}
return await response.json();
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.queue.list(variables.sessionId),
});
},
});
}
/**
* Hook to clear all queued messages for a session
*/
export function useClearQueue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (sessionId: string) => {
const response = await client.api.queue[':sessionId'].$delete({
param: { sessionId },
});
if (!response.ok) {
throw new Error('Failed to clear queue');
}
return await response.json();
},
onSuccess: (_, sessionId) => {
queryClient.invalidateQueries({
queryKey: queryKeys.queue.list(sessionId),
});
},
});
}

View File

@@ -0,0 +1,203 @@
import { useMemo } from 'react';
import { useQueries } from '@tanstack/react-query';
import { client } from '@/lib/client';
type NormalizedResourceItem =
| {
kind: 'text';
text: string;
mimeType?: string;
}
| {
kind: 'image';
src: string;
mimeType: string;
alt?: string;
}
| {
kind: 'audio';
src: string;
mimeType: string;
filename?: string;
}
| {
kind: 'video';
src: string;
mimeType: string;
filename?: string;
}
| {
kind: 'file';
src?: string;
mimeType?: string;
filename?: string;
};
export interface NormalizedResource {
uri: string;
name?: string;
meta?: Record<string, unknown>;
items: NormalizedResourceItem[];
}
export interface ResourceState {
status: 'loading' | 'loaded' | 'error';
data?: NormalizedResource;
error?: string;
}
type ResourceStateMap = Record<string, ResourceState>;
function buildDataUrl(base64: string, mimeType: string): string {
return `data:${mimeType};base64,${base64}`;
}
function normalizeResource(uri: string, payload: any): NormalizedResource {
const contents = Array.isArray(payload?.contents) ? payload.contents : [];
const meta = (payload?._meta ?? {}) as Record<string, unknown>;
const name =
(typeof meta.originalName === 'string' && meta.originalName.trim().length > 0
? meta.originalName
: undefined) || uri;
const items: NormalizedResourceItem[] = [];
for (const item of contents) {
if (!item || typeof item !== 'object') continue;
if (typeof (item as { text?: unknown }).text === 'string') {
items.push({
kind: 'text',
text: (item as { text: string }).text,
mimeType: typeof item.mimeType === 'string' ? item.mimeType : undefined,
});
continue;
}
const blobData = typeof item.blob === 'string' ? item.blob : undefined;
const rawData = typeof item.data === 'string' ? item.data : undefined;
const mimeType = typeof item.mimeType === 'string' ? item.mimeType : undefined;
const filename = typeof item.filename === 'string' ? item.filename : undefined;
if ((blobData || rawData) && mimeType) {
const base64 = blobData ?? rawData!;
const src = buildDataUrl(base64, mimeType);
if (mimeType.startsWith('image/')) {
items.push({
kind: 'image',
src,
mimeType,
alt: filename || name,
});
} else if (mimeType.startsWith('audio/')) {
items.push({
kind: 'audio',
src,
mimeType,
filename: filename || name,
});
} else if (mimeType.startsWith('video/')) {
items.push({
kind: 'video',
src,
mimeType,
filename: filename || name,
});
} else {
items.push({
kind: 'file',
src,
mimeType,
filename: filename || name,
});
}
continue;
}
if (mimeType && mimeType.startsWith('text/') && typeof item.value === 'string') {
items.push({
kind: 'text',
text: item.value,
mimeType,
});
}
}
return {
uri,
name,
meta,
items,
};
}
async function fetchResourceContent(uri: string): Promise<NormalizedResource> {
const response = await client.api.resources[':resourceId'].content.$get({
param: { resourceId: encodeURIComponent(uri) },
});
const body = await response.json();
const contentPayload = body?.content;
if (!contentPayload) {
throw new Error('No content returned for resource');
}
return normalizeResource(uri, contentPayload);
}
export function useResourceContent(resourceUris: string[]): ResourceStateMap {
// Serialize array for stable dependency comparison.
// Arrays are compared by reference in React, so ['a','b'] !== ['a','b'] even though
// values are identical. Serializing to 'a|b' allows value-based comparison to avoid
// unnecessary re-computation when parent passes new array reference with same contents.
const serializedUris = resourceUris.join('|');
const normalizedUris = useMemo(() => {
const seen = new Set<string>();
const ordered: string[] = [];
for (const uri of resourceUris) {
if (!uri || typeof uri !== 'string') continue;
const trimmed = uri.trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
ordered.push(trimmed);
}
return ordered;
// We use resourceUris inside but only depend on serializedUris. This is safe because
// serializedUris is derived from resourceUris - when the string changes, the array
// values changed too. This is an intentional optimization to prevent re-runs when
// array reference changes but values remain the same.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serializedUris]);
const queries = useQueries({
queries: normalizedUris.map((uri) => ({
queryKey: ['resourceContent', uri],
queryFn: () => fetchResourceContent(uri),
enabled: !!uri,
retry: false,
})),
});
const resources: ResourceStateMap = useMemo(() => {
const result: ResourceStateMap = {};
queries.forEach((query, index) => {
const uri = normalizedUris[index];
if (!uri) return;
if (query.isLoading) {
result[uri] = { status: 'loading' };
} else if (query.error) {
result[uri] = {
status: 'error',
error: query.error instanceof Error ? query.error.message : String(query.error),
};
} else if (query.data) {
result[uri] = { status: 'loaded', data: query.data };
}
});
return result;
}, [queries, normalizedUris]);
return resources;
}
export type { NormalizedResourceItem };

View File

@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query';
import { client } from '@/lib/client.js';
import { queryKeys } from '@/lib/queryKeys.js';
async function fetchResources() {
const response = await client.api.resources.$get();
const data = await response.json();
if (!data.ok || !Array.isArray(data.resources)) {
throw new Error('Invalid response shape');
}
return data.resources;
}
export function useResources() {
const {
data: resources = [],
isLoading: loading,
error,
refetch: refresh,
} = useQuery({
queryKey: queryKeys.resources.all,
queryFn: fetchResources,
staleTime: 60 * 1000, // 1 minute - resources can change when servers connect/disconnect
});
return {
resources,
loading,
error: error?.message ?? null,
refresh: async () => {
await refresh();
},
} as const;
}

View File

@@ -0,0 +1,50 @@
import { useQuery } from '@tanstack/react-query';
import { client } from '@/lib/client.js';
import { queryKeys } from '@/lib/queryKeys.js';
// Search messages
export function useSearchMessages(
query: string,
sessionId?: string,
limit: number = 50,
enabled: boolean = true
) {
return useQuery({
queryKey: queryKeys.search.messages(query, sessionId, limit),
queryFn: async () => {
const response = await client.api.search.messages.$get({
query: {
q: query,
limit: limit,
...(sessionId && { sessionId }),
},
});
return await response.json();
},
enabled: enabled && query.trim().length > 0,
staleTime: 30000, // 30 seconds
});
}
// Search sessions
export function useSearchSessions(query: string, enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.search.sessions(query),
queryFn: async () => {
const response = await client.api.search.sessions.$get({
query: { q: query },
});
return await response.json();
},
enabled: enabled && query.trim().length > 0,
staleTime: 30000, // 30 seconds
});
}
// Export types inferred from hook return values
export type SearchResult = NonNullable<
ReturnType<typeof useSearchMessages>['data']
>['results'][number];
export type SessionSearchResult = NonNullable<
ReturnType<typeof useSearchSessions>['data']
>['results'][number];

View File

@@ -0,0 +1,74 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { serverRegistry } from '@/lib/serverRegistry';
import type { ServerRegistryEntry, ServerRegistryFilter } from '@dexto/registry';
import { queryKeys } from '@/lib/queryKeys.js';
interface UseServerRegistryOptions {
autoLoad?: boolean;
initialFilter?: ServerRegistryFilter;
}
export function useServerRegistry(options: UseServerRegistryOptions = {}) {
const { autoLoad = true, initialFilter } = options;
const queryClient = useQueryClient();
const [filter, setFilter] = useState<ServerRegistryFilter>(initialFilter || {});
const {
data: entries = [],
isLoading,
error,
} = useQuery({
queryKey: queryKeys.serverRegistry(filter),
queryFn: () => serverRegistry.getEntries(filter),
enabled: autoLoad,
});
const markAsInstalledMutation = useMutation({
mutationFn: async (entryId: string) => {
await serverRegistry.setInstalled(entryId, true);
return entryId;
},
onSuccess: (entryId) => {
// Optimistically update the cache
queryClient.setQueryData<ServerRegistryEntry[]>(
queryKeys.serverRegistry(filter),
(old) =>
old?.map((entry) =>
entry.id === entryId ? { ...entry, isInstalled: true } : entry
) ?? []
);
},
});
const updateFilter = (newFilter: ServerRegistryFilter) => {
setFilter(newFilter);
};
const loadEntries = async (newFilter?: ServerRegistryFilter) => {
if (newFilter) {
setFilter(newFilter);
} else {
// Trigger a refetch with current filter
await queryClient.refetchQueries({ queryKey: queryKeys.serverRegistry(filter) });
}
};
const markAsInstalled = async (entryId: string) => {
await markAsInstalledMutation.mutateAsync(entryId);
};
return {
entries,
isLoading,
error: error?.message ?? null,
filter,
loadEntries,
updateFilter,
markAsInstalled,
clearError: () => {
// Errors are automatically cleared when query succeeds
},
};
}

View File

@@ -0,0 +1,109 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { client } from '@/lib/client';
import { queryKeys } from '@/lib/queryKeys';
export function useServers(enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.servers.all,
queryFn: async () => {
const res = await client.api.mcp.servers.$get();
if (!res.ok) {
throw new Error('Failed to fetch servers');
}
const data = await res.json();
// Type is inferred from Hono client response schema
return data.servers;
},
enabled,
staleTime: 30 * 1000, // 30 seconds - server status can change
});
}
export function useServerTools(serverId: string | null, enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.servers.tools(serverId || ''),
queryFn: async () => {
if (!serverId) return [];
const res = await client.api.mcp.servers[':serverId'].tools.$get({
param: { serverId },
});
if (!res.ok) {
throw new Error('Failed to fetch tools');
}
const data = await res.json();
// Type is inferred from Hono client response schema
return data.tools;
},
enabled: enabled && !!serverId,
staleTime: 2 * 60 * 1000, // 2 minutes - tools don't change once server is connected
});
}
// Add new MCP server
export function useAddServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: Parameters<typeof client.api.mcp.servers.$post>[0]['json']) => {
const res = await client.api.mcp.servers.$post({ json: payload });
if (!res.ok) {
const error = await res.text();
throw new Error(error || 'Failed to add server');
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
},
});
}
// Delete MCP server
export function useDeleteServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (serverId: string) => {
const res = await client.api.mcp.servers[':serverId'].$delete({
param: { serverId },
});
if (!res.ok) {
throw new Error('Failed to delete server');
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
},
});
}
// Restart MCP server
export function useRestartServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (serverId: string) => {
const res = await client.api.mcp.servers[':serverId'].restart.$post({
param: { serverId },
});
if (!res.ok) {
throw new Error('Failed to restart server');
}
return serverId;
},
onSuccess: (serverId) => {
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
// Invalidate tools for this server as they may have changed after restart
queryClient.invalidateQueries({ queryKey: queryKeys.servers.tools(serverId) });
},
});
}
// Export types inferred from hook return values
export type McpServer = NonNullable<ReturnType<typeof useServers>['data']>[number];
export type McpTool = NonNullable<ReturnType<typeof useServerTools>['data']>[number];

View File

@@ -0,0 +1,85 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { client } from '@/lib/client.js';
import { queryKeys } from '@/lib/queryKeys.js';
export function useSessions(enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.sessions.all,
queryFn: async () => {
const response = await client.api.sessions.$get();
if (!response.ok) {
throw new Error(`Failed to fetch sessions: ${response.status}`);
}
const data = await response.json();
return data.sessions;
},
enabled,
staleTime: 30 * 1000, // 30 seconds - sessions can be created frequently
});
}
// Create a new session
export function useCreateSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ sessionId }: { sessionId?: string }) => {
const response = await client.api.sessions.$post({
json: { sessionId: sessionId?.trim() || undefined },
});
if (!response.ok) {
throw new Error(`Failed to create session: ${response.status}`);
}
const data = await response.json();
return data.session;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
},
});
}
// Delete a session
export function useDeleteSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ sessionId }: { sessionId: string }) => {
const response = await client.api.sessions[':sessionId'].$delete({
param: { sessionId },
});
if (!response.ok) {
throw new Error(`Failed to delete session: ${response.status}`);
}
},
onSuccess: () => {
// Invalidate sessions list to refresh after deletion
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
},
});
}
// Rename a session (update title)
export function useRenameSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ sessionId, title }: { sessionId: string; title: string }) => {
const response = await client.api.sessions[':sessionId'].$patch({
param: { sessionId },
json: { title },
});
if (!response.ok) {
throw new Error('Failed to rename session');
}
const data = await response.json();
return data.session;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
},
});
}
// Export inferred types for components to use
export type Session = NonNullable<ReturnType<typeof useSessions>['data']>[number];

View File

@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
export function useTheme() {
// Initialize from SSR-provided class on <html> to avoid flicker
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
if (typeof document !== 'undefined') {
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
}
// Match SSR default from layout (dark)
return 'dark';
});
// Sync DOM class, localStorage and cookie when theme changes
useEffect(() => {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('dark', theme === 'dark');
try {
localStorage.setItem('theme', theme);
const isSecure =
typeof window !== 'undefined' && window.location?.protocol === 'https:';
document.cookie = `theme=${encodeURIComponent(theme)}; path=/; max-age=31536000; SameSite=Lax${isSecure ? '; Secure' : ''}`;
} catch {
// Ignore storage errors in restrictive environments
}
}, [theme]);
const toggleTheme = (checked: boolean) => {
setTheme(checked ? 'dark' : 'light');
};
// Keep API shape backward-compatible
return { theme, toggleTheme, hasMounted: true } as const;
}

View File

@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { client } from '@/lib/client';
import { queryKeys } from '@/lib/queryKeys';
export function useAllTools(enabled: boolean = true) {
return useQuery({
queryKey: queryKeys.tools.all,
queryFn: async () => {
const res = await client.api.tools.$get();
if (!res.ok) throw new Error('Failed to fetch tools');
return await res.json();
},
enabled,
});
}
// Export types using the standard inference pattern
export type AllToolsResponse = NonNullable<ReturnType<typeof useAllTools>['data']>;
export type ToolInfo = AllToolsResponse['tools'][number];