feat: implement FlowExecutor for executing flow definitions with DAG traversal and node execution

This commit is contained in:
catlog22
2026-01-30 16:59:18 +08:00
parent 0a7c1454d9
commit a5c3dff8d3
92 changed files with 23875 additions and 542 deletions

View File

@@ -0,0 +1,121 @@
// ========================================
// Hooks Barrel Export
// ========================================
// Re-export all custom hooks for convenient imports
export { useTheme } from './useTheme';
export type { UseThemeReturn } from './useTheme';
export { useSession } from './useSession';
export type { UseSessionReturn } from './useSession';
export { useConfig } from './useConfig';
export type { UseConfigReturn } from './useConfig';
export { useNotifications } from './useNotifications';
export type { UseNotificationsReturn, ToastOptions } from './useNotifications';
export { useDashboardStats, usePrefetchDashboardStats, dashboardStatsKeys } from './useDashboardStats';
export type { UseDashboardStatsOptions, UseDashboardStatsReturn } from './useDashboardStats';
export {
useSessions,
useCreateSession,
useUpdateSession,
useArchiveSession,
useDeleteSession,
useSessionMutations,
usePrefetchSessions,
sessionsKeys,
} from './useSessions';
export type {
SessionsFilter,
UseSessionsOptions,
UseSessionsReturn,
UseCreateSessionReturn,
UseUpdateSessionReturn,
UseArchiveSessionReturn,
UseDeleteSessionReturn,
} from './useSessions';
// ========== Loops ==========
export {
useLoops,
useLoop,
useCreateLoop,
useUpdateLoopStatus,
useDeleteLoop,
useLoopMutations,
loopsKeys,
} from './useLoops';
export type {
LoopsFilter,
UseLoopsOptions,
UseLoopsReturn,
UseCreateLoopReturn,
UseUpdateLoopStatusReturn,
UseDeleteLoopReturn,
} from './useLoops';
// ========== Issues ==========
export {
useIssues,
useIssueQueue,
useCreateIssue,
useUpdateIssue,
useDeleteIssue,
useIssueMutations,
issuesKeys,
} from './useIssues';
export type {
IssuesFilter,
UseIssuesOptions,
UseIssuesReturn,
UseCreateIssueReturn,
UseUpdateIssueReturn,
UseDeleteIssueReturn,
} from './useIssues';
// ========== Skills ==========
export {
useSkills,
useToggleSkill,
useSkillMutations,
skillsKeys,
} from './useSkills';
export type {
SkillsFilter,
UseSkillsOptions,
UseSkillsReturn,
UseToggleSkillReturn,
} from './useSkills';
// ========== Commands ==========
export {
useCommands,
useCommandSearch,
commandsKeys,
} from './useCommands';
export type {
CommandsFilter,
UseCommandsOptions,
UseCommandsReturn,
} from './useCommands';
// ========== Memory ==========
export {
useMemory,
useCreateMemory,
useUpdateMemory,
useDeleteMemory,
useMemoryMutations,
memoryKeys,
} from './useMemory';
export type {
MemoryFilter,
UseMemoryOptions,
UseMemoryReturn,
UseCreateMemoryReturn,
UseUpdateMemoryReturn,
UseDeleteMemoryReturn,
} from './useMemory';

View File

@@ -0,0 +1,128 @@
// ========================================
// useCommands Hook
// ========================================
// TanStack Query hooks for commands management
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
fetchCommands,
type Command,
} from '../lib/api';
// Query key factory
export const commandsKeys = {
all: ['commands'] as const,
lists: () => [...commandsKeys.all, 'list'] as const,
list: (filters?: CommandsFilter) => [...commandsKeys.lists(), filters] as const,
};
// Default stale time: 10 minutes (commands are static)
const STALE_TIME = 10 * 60 * 1000;
export interface CommandsFilter {
search?: string;
category?: string;
source?: Command['source'];
}
export interface UseCommandsOptions {
filter?: CommandsFilter;
staleTime?: number;
enabled?: boolean;
}
export interface UseCommandsReturn {
commands: Command[];
categories: string[];
commandsByCategory: Record<string, Command[]>;
totalCount: number;
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => Promise<void>;
invalidate: () => Promise<void>;
}
/**
* Hook for fetching and filtering commands
*/
export function useCommands(options: UseCommandsOptions = {}): UseCommandsReturn {
const { filter, staleTime = STALE_TIME, enabled = true } = options;
const queryClient = useQueryClient();
const query = useQuery({
queryKey: commandsKeys.list(filter),
queryFn: fetchCommands,
staleTime,
enabled,
retry: 2,
});
const allCommands = query.data?.commands ?? [];
// Apply filters
const filteredCommands = (() => {
let commands = allCommands;
if (filter?.search) {
const searchLower = filter.search.toLowerCase();
commands = commands.filter(
(c) =>
c.name.toLowerCase().includes(searchLower) ||
c.description.toLowerCase().includes(searchLower) ||
c.aliases?.some((a) => a.toLowerCase().includes(searchLower))
);
}
if (filter?.category) {
commands = commands.filter((c) => c.category === filter.category);
}
if (filter?.source) {
commands = commands.filter((c) => c.source === filter.source);
}
return commands;
})();
// Group by category
const commandsByCategory: Record<string, Command[]> = {};
const categories = new Set<string>();
for (const command of allCommands) {
const category = command.category || 'Uncategorized';
categories.add(category);
if (!commandsByCategory[category]) {
commandsByCategory[category] = [];
}
commandsByCategory[category].push(command);
}
const refetch = async () => {
await query.refetch();
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: commandsKeys.all });
};
return {
commands: filteredCommands,
categories: Array.from(categories).sort(),
commandsByCategory,
totalCount: allCommands.length,
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch,
invalidate,
};
}
/**
* Hook to search commands by name or alias
*/
export function useCommandSearch(searchTerm: string) {
const { commands } = useCommands({ filter: { search: searchTerm } });
return commands;
}

View File

@@ -0,0 +1,143 @@
// ========================================
// useConfig Hook
// ========================================
// Convenient hook for configuration management
import { useCallback } from 'react';
import {
useConfigStore,
selectCliTools,
selectDefaultCliTool,
selectApiEndpoints,
selectUserPreferences,
selectFeatureFlags,
getFirstEnabledCliTool,
} from '../stores/configStore';
import type { CliToolConfig, ApiEndpoints, UserPreferences, ConfigState } from '../types/store';
export interface UseConfigReturn {
/** CLI tools configuration */
cliTools: Record<string, CliToolConfig>;
/** Default CLI tool ID */
defaultCliTool: string;
/** First enabled CLI tool (fallback) */
firstEnabledTool: string;
/** API endpoints */
apiEndpoints: ApiEndpoints;
/** User preferences */
userPreferences: UserPreferences;
/** Feature flags */
featureFlags: Record<string, boolean>;
/** Update CLI tool config */
updateCliTool: (toolId: string, updates: Partial<CliToolConfig>) => void;
/** Set default CLI tool */
setDefaultCliTool: (toolId: string) => void;
/** Update user preferences */
setUserPreferences: (prefs: Partial<UserPreferences>) => void;
/** Reset user preferences to defaults */
resetUserPreferences: () => void;
/** Set a feature flag */
setFeatureFlag: (flag: string, enabled: boolean) => void;
/** Check if a feature is enabled */
isFeatureEnabled: (flag: string) => boolean;
/** Load full config */
loadConfig: (config: Partial<ConfigState>) => void;
}
/**
* Hook for managing configuration state
* @returns Config state and actions
*
* @example
* ```tsx
* const { cliTools, defaultCliTool, userPreferences, setUserPreferences } = useConfig();
*
* return (
* <SettingsPanel
* preferences={userPreferences}
* onUpdate={setUserPreferences}
* />
* );
* ```
*/
export function useConfig(): UseConfigReturn {
const cliTools = useConfigStore(selectCliTools);
const defaultCliTool = useConfigStore(selectDefaultCliTool);
const apiEndpoints = useConfigStore(selectApiEndpoints);
const userPreferences = useConfigStore(selectUserPreferences);
const featureFlags = useConfigStore(selectFeatureFlags);
// Actions
const updateCliToolAction = useConfigStore((state) => state.updateCliTool);
const setDefaultCliToolAction = useConfigStore((state) => state.setDefaultCliTool);
const setUserPreferencesAction = useConfigStore((state) => state.setUserPreferences);
const resetUserPreferencesAction = useConfigStore((state) => state.resetUserPreferences);
const setFeatureFlagAction = useConfigStore((state) => state.setFeatureFlag);
const loadConfigAction = useConfigStore((state) => state.loadConfig);
// Computed values
const firstEnabledTool = getFirstEnabledCliTool(cliTools);
// Callbacks
const updateCliTool = useCallback(
(toolId: string, updates: Partial<CliToolConfig>) => {
updateCliToolAction(toolId, updates);
},
[updateCliToolAction]
);
const setDefaultCliTool = useCallback(
(toolId: string) => {
setDefaultCliToolAction(toolId);
},
[setDefaultCliToolAction]
);
const setUserPreferences = useCallback(
(prefs: Partial<UserPreferences>) => {
setUserPreferencesAction(prefs);
},
[setUserPreferencesAction]
);
const resetUserPreferences = useCallback(() => {
resetUserPreferencesAction();
}, [resetUserPreferencesAction]);
const setFeatureFlag = useCallback(
(flag: string, enabled: boolean) => {
setFeatureFlagAction(flag, enabled);
},
[setFeatureFlagAction]
);
const isFeatureEnabled = useCallback(
(flag: string): boolean => {
return featureFlags[flag] ?? false;
},
[featureFlags]
);
const loadConfig = useCallback(
(config: Partial<ConfigState>) => {
loadConfigAction(config);
},
[loadConfigAction]
);
return {
cliTools,
defaultCliTool,
firstEnabledTool,
apiEndpoints,
userPreferences,
featureFlags,
updateCliTool,
setDefaultCliTool,
setUserPreferences,
resetUserPreferences,
setFeatureFlag,
isFeatureEnabled,
loadConfig,
};
}

View File

@@ -0,0 +1,111 @@
// ========================================
// useDashboardStats Hook
// ========================================
// TanStack Query hook for dashboard statistics
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchDashboardStats, type DashboardStats } from '../lib/api';
// Query key factory
export const dashboardStatsKeys = {
all: ['dashboardStats'] as const,
detail: () => [...dashboardStatsKeys.all, 'detail'] as const,
};
// Default stale time: 30 seconds
const STALE_TIME = 30 * 1000;
export interface UseDashboardStatsOptions {
/** Override default stale time (ms) */
staleTime?: number;
/** Enable/disable the query */
enabled?: boolean;
/** Refetch interval (ms), 0 to disable */
refetchInterval?: number;
}
export interface UseDashboardStatsReturn {
/** Dashboard statistics data */
stats: DashboardStats | undefined;
/** Loading state for initial fetch */
isLoading: boolean;
/** Fetching state (initial or refetch) */
isFetching: boolean;
/** Error object if query failed */
error: Error | null;
/** Whether data is stale */
isStale: boolean;
/** Manually refetch data */
refetch: () => Promise<void>;
/** Invalidate and refetch stats */
invalidate: () => Promise<void>;
}
/**
* Hook for fetching and managing dashboard statistics
*
* @example
* ```tsx
* const { stats, isLoading, error } = useDashboardStats();
*
* if (isLoading) return <LoadingSpinner />;
* if (error) return <ErrorMessage error={error} />;
*
* return (
* <StatsGrid>
* <StatCard title="Sessions" value={stats.totalSessions} />
* <StatCard title="Tasks" value={stats.totalTasks} />
* </StatsGrid>
* );
* ```
*/
export function useDashboardStats(
options: UseDashboardStatsOptions = {}
): UseDashboardStatsReturn {
const { staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
const queryClient = useQueryClient();
const query = useQuery({
queryKey: dashboardStatsKeys.detail(),
queryFn: fetchDashboardStats,
staleTime,
enabled,
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
});
const refetch = async () => {
await query.refetch();
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: dashboardStatsKeys.all });
};
return {
stats: query.data,
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
isStale: query.isStale,
refetch,
invalidate,
};
}
/**
* Hook to prefetch dashboard stats
* Use this to prefetch data before navigating to home page
*/
export function usePrefetchDashboardStats() {
const queryClient = useQueryClient();
return () => {
queryClient.prefetchQuery({
queryKey: dashboardStatsKeys.detail(),
queryFn: fetchDashboardStats,
staleTime: STALE_TIME,
});
};
}

View File

@@ -0,0 +1,295 @@
// ========================================
// useFlows Hook
// ========================================
// TanStack Query hooks for flow API operations
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { Flow } from '../types/flow';
// API base URL
const API_BASE = '/api/orchestrator';
// Query keys
export const flowKeys = {
all: ['flows'] as const,
lists: () => [...flowKeys.all, 'list'] as const,
list: (filters?: Record<string, unknown>) => [...flowKeys.lists(), filters] as const,
details: () => [...flowKeys.all, 'detail'] as const,
detail: (id: string) => [...flowKeys.details(), id] as const,
};
// API response types
interface FlowsListResponse {
flows: Flow[];
total: number;
}
interface ExecutionStartResponse {
execId: string;
flowId: string;
status: 'running';
startedAt: string;
}
interface ExecutionControlResponse {
execId: string;
status: 'paused' | 'running' | 'stopped';
message: string;
}
// ========== Fetch Functions ==========
async function fetchFlows(): Promise<FlowsListResponse> {
const response = await fetch(`${API_BASE}/flows`);
if (!response.ok) {
throw new Error(`Failed to fetch flows: ${response.statusText}`);
}
return response.json();
}
async function fetchFlow(id: string): Promise<Flow> {
const response = await fetch(`${API_BASE}/flows/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch flow: ${response.statusText}`);
}
return response.json();
}
async function createFlow(flow: Omit<Flow, 'id' | 'created_at' | 'updated_at'>): Promise<Flow> {
const response = await fetch(`${API_BASE}/flows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flow),
});
if (!response.ok) {
throw new Error(`Failed to create flow: ${response.statusText}`);
}
return response.json();
}
async function updateFlow(id: string, flow: Partial<Flow>): Promise<Flow> {
const response = await fetch(`${API_BASE}/flows/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flow),
});
if (!response.ok) {
throw new Error(`Failed to update flow: ${response.statusText}`);
}
return response.json();
}
async function deleteFlow(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/flows/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Failed to delete flow: ${response.statusText}`);
}
}
async function duplicateFlow(id: string): Promise<Flow> {
const response = await fetch(`${API_BASE}/flows/${id}/duplicate`, {
method: 'POST',
});
if (!response.ok) {
throw new Error(`Failed to duplicate flow: ${response.statusText}`);
}
return response.json();
}
// ========== Execution Functions ==========
async function executeFlow(flowId: string): Promise<ExecutionStartResponse> {
const response = await fetch(`${API_BASE}/flows/${flowId}/execute`, {
method: 'POST',
});
if (!response.ok) {
throw new Error(`Failed to execute flow: ${response.statusText}`);
}
return response.json();
}
async function pauseExecution(execId: string): Promise<ExecutionControlResponse> {
const response = await fetch(`${API_BASE}/executions/${execId}/pause`, {
method: 'POST',
});
if (!response.ok) {
throw new Error(`Failed to pause execution: ${response.statusText}`);
}
return response.json();
}
async function resumeExecution(execId: string): Promise<ExecutionControlResponse> {
const response = await fetch(`${API_BASE}/executions/${execId}/resume`, {
method: 'POST',
});
if (!response.ok) {
throw new Error(`Failed to resume execution: ${response.statusText}`);
}
return response.json();
}
async function stopExecution(execId: string): Promise<ExecutionControlResponse> {
const response = await fetch(`${API_BASE}/executions/${execId}/stop`, {
method: 'POST',
});
if (!response.ok) {
throw new Error(`Failed to stop execution: ${response.statusText}`);
}
return response.json();
}
// ========== Query Hooks ==========
/**
* Fetch all flows
*/
export function useFlows() {
return useQuery({
queryKey: flowKeys.lists(),
queryFn: fetchFlows,
staleTime: 30000, // 30 seconds
});
}
/**
* Fetch a single flow by ID
*/
export function useFlow(id: string | null) {
return useQuery({
queryKey: flowKeys.detail(id ?? ''),
queryFn: () => fetchFlow(id!),
enabled: !!id,
staleTime: 30000,
});
}
// ========== Mutation Hooks ==========
/**
* Create a new flow
*/
export function useCreateFlow() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createFlow,
onSuccess: (newFlow) => {
// Optimistically add to list
queryClient.setQueryData<FlowsListResponse>(flowKeys.lists(), (old) => {
if (!old) return { flows: [newFlow], total: 1 };
return {
flows: [...old.flows, newFlow],
total: old.total + 1,
};
});
// Invalidate to refetch
queryClient.invalidateQueries({ queryKey: flowKeys.lists() });
},
});
}
/**
* Update an existing flow
*/
export function useUpdateFlow() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, flow }: { id: string; flow: Partial<Flow> }) => updateFlow(id, flow),
onSuccess: (updatedFlow) => {
// Update in cache
queryClient.setQueryData<Flow>(flowKeys.detail(updatedFlow.id), updatedFlow);
queryClient.setQueryData<FlowsListResponse>(flowKeys.lists(), (old) => {
if (!old) return old;
return {
...old,
flows: old.flows.map((f) => (f.id === updatedFlow.id ? updatedFlow : f)),
};
});
},
});
}
/**
* Delete a flow
*/
export function useDeleteFlow() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteFlow,
onSuccess: (_, deletedId) => {
// Remove from cache
queryClient.removeQueries({ queryKey: flowKeys.detail(deletedId) });
queryClient.setQueryData<FlowsListResponse>(flowKeys.lists(), (old) => {
if (!old) return old;
return {
flows: old.flows.filter((f) => f.id !== deletedId),
total: old.total - 1,
};
});
},
});
}
/**
* Duplicate a flow
*/
export function useDuplicateFlow() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: duplicateFlow,
onSuccess: (newFlow) => {
// Add to list
queryClient.setQueryData<FlowsListResponse>(flowKeys.lists(), (old) => {
if (!old) return { flows: [newFlow], total: 1 };
return {
flows: [...old.flows, newFlow],
total: old.total + 1,
};
});
queryClient.invalidateQueries({ queryKey: flowKeys.lists() });
},
});
}
// ========== Execution Mutation Hooks ==========
/**
* Execute a flow
*/
export function useExecuteFlow() {
return useMutation({
mutationFn: executeFlow,
});
}
/**
* Pause execution
*/
export function usePauseExecution() {
return useMutation({
mutationFn: pauseExecution,
});
}
/**
* Resume execution
*/
export function useResumeExecution() {
return useMutation({
mutationFn: resumeExecution,
});
}
/**
* Stop execution
*/
export function useStopExecution() {
return useMutation({
mutationFn: stopExecution,
});
}

View File

@@ -0,0 +1,297 @@
// ========================================
// useIssues Hook
// ========================================
// TanStack Query hooks for issues with queue management
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchIssues,
fetchIssueHistory,
fetchIssueQueue,
createIssue,
updateIssue,
deleteIssue,
type Issue,
type IssuesResponse,
type IssueQueue,
} from '../lib/api';
// Query key factory
export const issuesKeys = {
all: ['issues'] as const,
lists: () => [...issuesKeys.all, 'list'] as const,
list: (filters?: IssuesFilter) => [...issuesKeys.lists(), filters] as const,
history: () => [...issuesKeys.all, 'history'] as const,
queue: () => [...issuesKeys.all, 'queue'] as const,
details: () => [...issuesKeys.all, 'detail'] as const,
detail: (id: string) => [...issuesKeys.details(), id] as const,
};
// Default stale time: 30 seconds
const STALE_TIME = 30 * 1000;
export interface IssuesFilter {
status?: Issue['status'][];
priority?: Issue['priority'][];
search?: string;
includeHistory?: boolean;
}
export interface UseIssuesOptions {
filter?: IssuesFilter;
projectPath?: string;
staleTime?: number;
enabled?: boolean;
refetchInterval?: number;
}
export interface UseIssuesReturn {
issues: Issue[];
historyIssues: Issue[];
allIssues: Issue[];
issuesByStatus: Record<Issue['status'], Issue[]>;
issuesByPriority: Record<Issue['priority'], Issue[]>;
openCount: number;
criticalCount: number;
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => Promise<void>;
invalidate: () => Promise<void>;
}
/**
* Hook for fetching and filtering issues
*/
export function useIssues(options: UseIssuesOptions = {}): UseIssuesReturn {
const { filter, projectPath, staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
const queryClient = useQueryClient();
const issuesQuery = useQuery({
queryKey: issuesKeys.list(filter),
queryFn: () => fetchIssues(projectPath),
staleTime,
enabled,
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
retry: 2,
});
const historyQuery = useQuery({
queryKey: issuesKeys.history(),
queryFn: () => fetchIssueHistory(projectPath),
staleTime,
enabled: enabled && (filter?.includeHistory ?? false),
retry: 2,
});
const allIssues = issuesQuery.data?.issues ?? [];
const historyIssues = historyQuery.data?.issues ?? [];
// Apply filters
const filteredIssues = (() => {
let issues = [...allIssues];
if (filter?.includeHistory) {
issues = [...issues, ...historyIssues];
}
if (filter?.status && filter.status.length > 0) {
issues = issues.filter((i) => filter.status!.includes(i.status));
}
if (filter?.priority && filter.priority.length > 0) {
issues = issues.filter((i) => filter.priority!.includes(i.priority));
}
if (filter?.search) {
const searchLower = filter.search.toLowerCase();
issues = issues.filter(
(i) =>
i.id.toLowerCase().includes(searchLower) ||
i.title.toLowerCase().includes(searchLower) ||
i.context?.toLowerCase().includes(searchLower)
);
}
return issues;
})();
// Group by status
const issuesByStatus: Record<Issue['status'], Issue[]> = {
open: [],
in_progress: [],
resolved: [],
closed: [],
completed: [],
};
for (const issue of allIssues) {
issuesByStatus[issue.status].push(issue);
}
// Group by priority
const issuesByPriority: Record<Issue['priority'], Issue[]> = {
low: [],
medium: [],
high: [],
critical: [],
};
for (const issue of allIssues) {
issuesByPriority[issue.priority].push(issue);
}
const refetch = async () => {
await Promise.all([issuesQuery.refetch(), historyQuery.refetch()]);
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: issuesKeys.all });
};
return {
issues: filteredIssues,
historyIssues,
allIssues,
issuesByStatus,
issuesByPriority,
openCount: issuesByStatus.open.length + issuesByStatus.in_progress.length,
criticalCount: issuesByPriority.critical.length,
isLoading: issuesQuery.isLoading,
isFetching: issuesQuery.isFetching || historyQuery.isFetching,
error: issuesQuery.error || historyQuery.error,
refetch,
invalidate,
};
}
/**
* Hook for fetching issue queue
*/
export function useIssueQueue(projectPath?: string) {
return useQuery({
queryKey: issuesKeys.queue(),
queryFn: () => fetchIssueQueue(projectPath),
staleTime: STALE_TIME,
retry: 2,
});
}
// ========== Mutations ==========
export interface UseCreateIssueReturn {
createIssue: (input: { title: string; context?: string; priority?: Issue['priority'] }) => Promise<Issue>;
isCreating: boolean;
error: Error | null;
}
export function useCreateIssue(): UseCreateIssueReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createIssue,
onSuccess: (newIssue) => {
queryClient.setQueryData<IssuesResponse>(issuesKeys.list(), (old) => {
if (!old) return { issues: [newIssue] };
return {
issues: [newIssue, ...old.issues],
};
});
},
});
return {
createIssue: mutation.mutateAsync,
isCreating: mutation.isPending,
error: mutation.error,
};
}
export interface UseUpdateIssueReturn {
updateIssue: (issueId: string, input: Partial<Issue>) => Promise<Issue>;
isUpdating: boolean;
error: Error | null;
}
export function useUpdateIssue(): UseUpdateIssueReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({ issueId, input }: { issueId: string; input: Partial<Issue> }) =>
updateIssue(issueId, input),
onSuccess: (updatedIssue) => {
queryClient.setQueryData<IssuesResponse>(issuesKeys.list(), (old) => {
if (!old) return old;
return {
issues: old.issues.map((i) => (i.id === updatedIssue.id ? updatedIssue : i)),
};
});
},
});
return {
updateIssue: (issueId, input) => mutation.mutateAsync({ issueId, input }),
isUpdating: mutation.isPending,
error: mutation.error,
};
}
export interface UseDeleteIssueReturn {
deleteIssue: (issueId: string) => Promise<void>;
isDeleting: boolean;
error: Error | null;
}
export function useDeleteIssue(): UseDeleteIssueReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: deleteIssue,
onMutate: async (issueId) => {
await queryClient.cancelQueries({ queryKey: issuesKeys.all });
const previousIssues = queryClient.getQueryData<IssuesResponse>(issuesKeys.list());
queryClient.setQueryData<IssuesResponse>(issuesKeys.list(), (old) => {
if (!old) return old;
return {
issues: old.issues.filter((i) => i.id !== issueId),
};
});
return { previousIssues };
},
onError: (_error, _issueId, context) => {
if (context?.previousIssues) {
queryClient.setQueryData(issuesKeys.list(), context.previousIssues);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: issuesKeys.all });
},
});
return {
deleteIssue: mutation.mutateAsync,
isDeleting: mutation.isPending,
error: mutation.error,
};
}
/**
* Combined hook for all issue mutations
*/
export function useIssueMutations() {
const create = useCreateIssue();
const update = useUpdateIssue();
const remove = useDeleteIssue();
return {
createIssue: create.createIssue,
updateIssue: update.updateIssue,
deleteIssue: remove.deleteIssue,
isCreating: create.isCreating,
isUpdating: update.isUpdating,
isDeleting: remove.isDeleting,
isMutating: create.isCreating || update.isUpdating || remove.isDeleting,
};
}

View File

@@ -0,0 +1,262 @@
// ========================================
// useLoops Hook
// ========================================
// TanStack Query hooks for loops with real-time updates
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchLoops,
fetchLoop,
createLoop,
updateLoopStatus,
deleteLoop,
type Loop,
type LoopsResponse,
} from '../lib/api';
// Query key factory
export const loopsKeys = {
all: ['loops'] as const,
lists: () => [...loopsKeys.all, 'list'] as const,
list: (filters?: LoopsFilter) => [...loopsKeys.lists(), filters] as const,
details: () => [...loopsKeys.all, 'detail'] as const,
detail: (id: string) => [...loopsKeys.details(), id] as const,
};
// Default stale time: 10 seconds (loops update frequently)
const STALE_TIME = 10 * 1000;
export interface LoopsFilter {
status?: Loop['status'][];
search?: string;
}
export interface UseLoopsOptions {
filter?: LoopsFilter;
staleTime?: number;
enabled?: boolean;
refetchInterval?: number;
}
export interface UseLoopsReturn {
loops: Loop[];
loopsByStatus: Record<Loop['status'], Loop[]>;
runningCount: number;
completedCount: number;
failedCount: number;
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => Promise<void>;
invalidate: () => Promise<void>;
}
/**
* Hook for fetching and filtering loops
*/
export function useLoops(options: UseLoopsOptions = {}): UseLoopsReturn {
const { filter, staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
const queryClient = useQueryClient();
const query = useQuery({
queryKey: loopsKeys.list(filter),
queryFn: fetchLoops,
staleTime,
enabled,
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
retry: 2,
});
const allLoops = query.data?.loops ?? [];
// Apply filters
const filteredLoops = (() => {
let loops = allLoops;
if (filter?.status && filter.status.length > 0) {
loops = loops.filter((l) => filter.status!.includes(l.status));
}
if (filter?.search) {
const searchLower = filter.search.toLowerCase();
loops = loops.filter(
(l) =>
l.id.toLowerCase().includes(searchLower) ||
l.name?.toLowerCase().includes(searchLower) ||
l.prompt?.toLowerCase().includes(searchLower)
);
}
return loops;
})();
// Group by status for Kanban
const loopsByStatus: Record<Loop['status'], Loop[]> = {
created: [],
running: [],
paused: [],
completed: [],
failed: [],
};
for (const loop of allLoops) {
loopsByStatus[loop.status].push(loop);
}
const refetch = async () => {
await query.refetch();
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: loopsKeys.all });
};
return {
loops: filteredLoops,
loopsByStatus,
runningCount: loopsByStatus.running.length,
completedCount: loopsByStatus.completed.length,
failedCount: loopsByStatus.failed.length,
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch,
invalidate,
};
}
/**
* Hook for fetching a single loop
*/
export function useLoop(loopId: string, options: { enabled?: boolean } = {}) {
return useQuery({
queryKey: loopsKeys.detail(loopId),
queryFn: () => fetchLoop(loopId),
enabled: options.enabled ?? !!loopId,
staleTime: STALE_TIME,
});
}
// ========== Mutations ==========
export interface UseCreateLoopReturn {
createLoop: (input: { prompt: string; tool?: string; mode?: string }) => Promise<Loop>;
isCreating: boolean;
error: Error | null;
}
export function useCreateLoop(): UseCreateLoopReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createLoop,
onSuccess: (newLoop) => {
queryClient.setQueryData<LoopsResponse>(loopsKeys.list(), (old) => {
if (!old) return { loops: [newLoop], total: 1 };
return {
loops: [newLoop, ...old.loops],
total: old.total + 1,
};
});
},
});
return {
createLoop: mutation.mutateAsync,
isCreating: mutation.isPending,
error: mutation.error,
};
}
export interface UseUpdateLoopStatusReturn {
updateStatus: (loopId: string, action: 'pause' | 'resume' | 'stop') => Promise<Loop>;
isUpdating: boolean;
error: Error | null;
}
export function useUpdateLoopStatus(): UseUpdateLoopStatusReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({ loopId, action }: { loopId: string; action: 'pause' | 'resume' | 'stop' }) =>
updateLoopStatus(loopId, action),
onSuccess: (updatedLoop) => {
queryClient.setQueryData<LoopsResponse>(loopsKeys.list(), (old) => {
if (!old) return old;
return {
...old,
loops: old.loops.map((l) => (l.id === updatedLoop.id ? updatedLoop : l)),
};
});
queryClient.setQueryData(loopsKeys.detail(updatedLoop.id), updatedLoop);
},
});
return {
updateStatus: (loopId, action) => mutation.mutateAsync({ loopId, action }),
isUpdating: mutation.isPending,
error: mutation.error,
};
}
export interface UseDeleteLoopReturn {
deleteLoop: (loopId: string) => Promise<void>;
isDeleting: boolean;
error: Error | null;
}
export function useDeleteLoop(): UseDeleteLoopReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: deleteLoop,
onMutate: async (loopId) => {
await queryClient.cancelQueries({ queryKey: loopsKeys.all });
const previousLoops = queryClient.getQueryData<LoopsResponse>(loopsKeys.list());
queryClient.setQueryData<LoopsResponse>(loopsKeys.list(), (old) => {
if (!old) return old;
return {
...old,
loops: old.loops.filter((l) => l.id !== loopId),
total: old.total - 1,
};
});
return { previousLoops };
},
onError: (_error, _loopId, context) => {
if (context?.previousLoops) {
queryClient.setQueryData(loopsKeys.list(), context.previousLoops);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: loopsKeys.all });
},
});
return {
deleteLoop: mutation.mutateAsync,
isDeleting: mutation.isPending,
error: mutation.error,
};
}
/**
* Combined hook for all loop mutations
*/
export function useLoopMutations() {
const create = useCreateLoop();
const update = useUpdateLoopStatus();
const remove = useDeleteLoop();
return {
createLoop: create.createLoop,
updateStatus: update.updateStatus,
deleteLoop: remove.deleteLoop,
isCreating: create.isCreating,
isUpdating: update.isUpdating,
isDeleting: remove.isDeleting,
isMutating: create.isCreating || update.isUpdating || remove.isDeleting,
};
}

View File

@@ -0,0 +1,244 @@
// ========================================
// useMemory Hook
// ========================================
// TanStack Query hooks for core memory management
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchMemories,
createMemory,
updateMemory,
deleteMemory,
type CoreMemory,
type MemoryResponse,
} from '../lib/api';
// Query key factory
export const memoryKeys = {
all: ['memory'] as const,
lists: () => [...memoryKeys.all, 'list'] as const,
list: (filters?: MemoryFilter) => [...memoryKeys.lists(), filters] as const,
details: () => [...memoryKeys.all, 'detail'] as const,
detail: (id: string) => [...memoryKeys.details(), id] as const,
};
// Default stale time: 1 minute
const STALE_TIME = 60 * 1000;
export interface MemoryFilter {
search?: string;
tags?: string[];
}
export interface UseMemoryOptions {
filter?: MemoryFilter;
staleTime?: number;
enabled?: boolean;
}
export interface UseMemoryReturn {
memories: CoreMemory[];
totalSize: number;
claudeMdCount: number;
allTags: string[];
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => Promise<void>;
invalidate: () => Promise<void>;
}
/**
* Hook for fetching and filtering memories
*/
export function useMemory(options: UseMemoryOptions = {}): UseMemoryReturn {
const { filter, staleTime = STALE_TIME, enabled = true } = options;
const queryClient = useQueryClient();
const query = useQuery({
queryKey: memoryKeys.list(filter),
queryFn: fetchMemories,
staleTime,
enabled,
retry: 2,
});
const allMemories = query.data?.memories ?? [];
const totalSize = query.data?.totalSize ?? 0;
const claudeMdCount = query.data?.claudeMdCount ?? 0;
// Apply filters
const filteredMemories = (() => {
let memories = allMemories;
if (filter?.search) {
const searchLower = filter.search.toLowerCase();
memories = memories.filter(
(m) =>
m.content.toLowerCase().includes(searchLower) ||
m.source?.toLowerCase().includes(searchLower) ||
m.tags?.some((t) => t.toLowerCase().includes(searchLower))
);
}
if (filter?.tags && filter.tags.length > 0) {
memories = memories.filter((m) =>
filter.tags!.some((tag) => m.tags?.includes(tag))
);
}
return memories;
})();
// Collect all unique tags
const allTags = Array.from(
new Set(allMemories.flatMap((m) => m.tags ?? []))
).sort();
const refetch = async () => {
await query.refetch();
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: memoryKeys.all });
};
return {
memories: filteredMemories,
totalSize,
claudeMdCount,
allTags,
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch,
invalidate,
};
}
// ========== Mutations ==========
export interface UseCreateMemoryReturn {
createMemory: (input: { content: string; tags?: string[] }) => Promise<CoreMemory>;
isCreating: boolean;
error: Error | null;
}
export function useCreateMemory(): UseCreateMemoryReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createMemory,
onSuccess: (newMemory) => {
queryClient.setQueryData<MemoryResponse>(memoryKeys.list(), (old) => {
if (!old) return { memories: [newMemory], totalSize: 0, claudeMdCount: 0 };
return {
...old,
memories: [newMemory, ...old.memories],
totalSize: old.totalSize + (newMemory.size ?? 0),
};
});
},
});
return {
createMemory: mutation.mutateAsync,
isCreating: mutation.isPending,
error: mutation.error,
};
}
export interface UseUpdateMemoryReturn {
updateMemory: (memoryId: string, input: Partial<CoreMemory>) => Promise<CoreMemory>;
isUpdating: boolean;
error: Error | null;
}
export function useUpdateMemory(): UseUpdateMemoryReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({ memoryId, input }: { memoryId: string; input: Partial<CoreMemory> }) =>
updateMemory(memoryId, input),
onSuccess: (updatedMemory) => {
queryClient.setQueryData<MemoryResponse>(memoryKeys.list(), (old) => {
if (!old) return old;
return {
...old,
memories: old.memories.map((m) =>
m.id === updatedMemory.id ? updatedMemory : m
),
};
});
},
});
return {
updateMemory: (memoryId, input) => mutation.mutateAsync({ memoryId, input }),
isUpdating: mutation.isPending,
error: mutation.error,
};
}
export interface UseDeleteMemoryReturn {
deleteMemory: (memoryId: string) => Promise<void>;
isDeleting: boolean;
error: Error | null;
}
export function useDeleteMemory(): UseDeleteMemoryReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: deleteMemory,
onMutate: async (memoryId) => {
await queryClient.cancelQueries({ queryKey: memoryKeys.all });
const previousMemories = queryClient.getQueryData<MemoryResponse>(memoryKeys.list());
queryClient.setQueryData<MemoryResponse>(memoryKeys.list(), (old) => {
if (!old) return old;
const removedMemory = old.memories.find((m) => m.id === memoryId);
return {
...old,
memories: old.memories.filter((m) => m.id !== memoryId),
totalSize: old.totalSize - (removedMemory?.size ?? 0),
};
});
return { previousMemories };
},
onError: (_error, _memoryId, context) => {
if (context?.previousMemories) {
queryClient.setQueryData(memoryKeys.list(), context.previousMemories);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: memoryKeys.all });
},
});
return {
deleteMemory: mutation.mutateAsync,
isDeleting: mutation.isPending,
error: mutation.error,
};
}
/**
* Combined hook for all memory mutations
*/
export function useMemoryMutations() {
const create = useCreateMemory();
const update = useUpdateMemory();
const remove = useDeleteMemory();
return {
createMemory: create.createMemory,
updateMemory: update.updateMemory,
deleteMemory: remove.deleteMemory,
isCreating: create.isCreating,
isUpdating: update.isUpdating,
isDeleting: remove.isDeleting,
isMutating: create.isCreating || update.isUpdating || remove.isDeleting,
};
}

View File

@@ -0,0 +1,232 @@
// ========================================
// useNotifications Hook
// ========================================
// Convenient hook for notification and toast management
import { useCallback } from 'react';
import {
useNotificationStore,
selectToasts,
selectWsStatus,
selectWsLastMessage,
selectIsPanelVisible,
selectPersistentNotifications,
} from '../stores/notificationStore';
import type { Toast, ToastType, WebSocketStatus, WebSocketMessage } from '../types/store';
export interface UseNotificationsReturn {
/** Current toast queue */
toasts: Toast[];
/** WebSocket connection status */
wsStatus: WebSocketStatus;
/** Last WebSocket message received */
wsLastMessage: WebSocketMessage | null;
/** Whether WebSocket is connected */
isWsConnected: boolean;
/** Whether notification panel is visible */
isPanelVisible: boolean;
/** Persistent notifications (stored in localStorage) */
persistentNotifications: Toast[];
/** Add a toast notification */
addToast: (type: ToastType, title: string, message?: string, options?: ToastOptions) => string;
/** Show info toast */
info: (title: string, message?: string) => string;
/** Show success toast */
success: (title: string, message?: string) => string;
/** Show warning toast */
warning: (title: string, message?: string) => string;
/** Show error toast (persistent by default) */
error: (title: string, message?: string) => string;
/** Remove a toast */
removeToast: (id: string) => void;
/** Clear all toasts */
clearAllToasts: () => void;
/** Set WebSocket status */
setWsStatus: (status: WebSocketStatus) => void;
/** Set last WebSocket message */
setWsLastMessage: (message: WebSocketMessage | null) => void;
/** Toggle notification panel */
togglePanel: () => void;
/** Set panel visibility */
setPanelVisible: (visible: boolean) => void;
/** Add persistent notification */
addPersistentNotification: (type: ToastType, title: string, message?: string) => void;
/** Remove persistent notification */
removePersistentNotification: (id: string) => void;
/** Clear all persistent notifications */
clearPersistentNotifications: () => void;
}
export interface ToastOptions {
/** Duration in ms (0 = persistent) */
duration?: number;
/** Whether toast can be dismissed */
dismissible?: boolean;
/** Action button */
action?: {
label: string;
onClick: () => void;
};
}
/**
* Hook for managing notifications and toasts
* @returns Notification state and actions
*
* @example
* ```tsx
* const { toasts, info, success, error, removeToast } = useNotifications();
*
* const handleSave = async () => {
* try {
* await save();
* success('Saved', 'Your changes have been saved');
* } catch (e) {
* error('Error', 'Failed to save changes');
* }
* };
* ```
*/
export function useNotifications(): UseNotificationsReturn {
const toasts = useNotificationStore(selectToasts);
const wsStatus = useNotificationStore(selectWsStatus);
const wsLastMessage = useNotificationStore(selectWsLastMessage);
const isPanelVisible = useNotificationStore(selectIsPanelVisible);
const persistentNotifications = useNotificationStore(selectPersistentNotifications);
// Actions
const addToastAction = useNotificationStore((state) => state.addToast);
const removeToastAction = useNotificationStore((state) => state.removeToast);
const clearAllToastsAction = useNotificationStore((state) => state.clearAllToasts);
const setWsStatusAction = useNotificationStore((state) => state.setWsStatus);
const setWsLastMessageAction = useNotificationStore((state) => state.setWsLastMessage);
const togglePanelAction = useNotificationStore((state) => state.togglePanel);
const setPanelVisibleAction = useNotificationStore((state) => state.setPanelVisible);
const addPersistentAction = useNotificationStore((state) => state.addPersistentNotification);
const removePersistentAction = useNotificationStore((state) => state.removePersistentNotification);
const clearPersistentAction = useNotificationStore((state) => state.clearPersistentNotifications);
// Computed
const isWsConnected = wsStatus === 'connected';
// Callbacks
const addToast = useCallback(
(type: ToastType, title: string, message?: string, options?: ToastOptions): string => {
return addToastAction({
type,
title,
message,
duration: options?.duration,
dismissible: options?.dismissible,
action: options?.action,
});
},
[addToastAction]
);
const info = useCallback(
(title: string, message?: string): string => {
return addToast('info', title, message);
},
[addToast]
);
const success = useCallback(
(title: string, message?: string): string => {
return addToast('success', title, message);
},
[addToast]
);
const warning = useCallback(
(title: string, message?: string): string => {
return addToast('warning', title, message);
},
[addToast]
);
const error = useCallback(
(title: string, message?: string): string => {
// Error toasts are persistent by default
return addToast('error', title, message, { duration: 0 });
},
[addToast]
);
const removeToast = useCallback(
(id: string) => {
removeToastAction(id);
},
[removeToastAction]
);
const clearAllToasts = useCallback(() => {
clearAllToastsAction();
}, [clearAllToastsAction]);
const setWsStatus = useCallback(
(status: WebSocketStatus) => {
setWsStatusAction(status);
},
[setWsStatusAction]
);
const setWsLastMessage = useCallback(
(message: WebSocketMessage | null) => {
setWsLastMessageAction(message);
},
[setWsLastMessageAction]
);
const togglePanel = useCallback(() => {
togglePanelAction();
}, [togglePanelAction]);
const setPanelVisible = useCallback(
(visible: boolean) => {
setPanelVisibleAction(visible);
},
[setPanelVisibleAction]
);
const addPersistentNotification = useCallback(
(type: ToastType, title: string, message?: string) => {
addPersistentAction({ type, title, message });
},
[addPersistentAction]
);
const removePersistentNotification = useCallback(
(id: string) => {
removePersistentAction(id);
},
[removePersistentAction]
);
const clearPersistentNotifications = useCallback(() => {
clearPersistentAction();
}, [clearPersistentAction]);
return {
toasts,
wsStatus,
wsLastMessage,
isWsConnected,
isPanelVisible,
persistentNotifications,
addToast,
info,
success,
warning,
error,
removeToast,
clearAllToasts,
setWsStatus,
setWsLastMessage,
togglePanel,
setPanelVisible,
addPersistentNotification,
removePersistentNotification,
clearPersistentNotifications,
};
}

View File

@@ -0,0 +1,155 @@
// ========================================
// useSession Hook
// ========================================
// Convenient hook for session management
import { useCallback, useMemo } from 'react';
import { useWorkflowStore, selectActiveSessionId } from '../stores/workflowStore';
import type { SessionMetadata, TaskData } from '../types/store';
export interface UseSessionReturn {
/** Currently active session ID */
activeSessionId: string | null;
/** Currently active session data */
activeSession: SessionMetadata | null;
/** All active sessions */
activeSessions: SessionMetadata[];
/** All archived sessions */
archivedSessions: SessionMetadata[];
/** Filtered sessions based on current filters */
filteredSessions: SessionMetadata[];
/** Set the active session */
setActiveSession: (sessionId: string | null) => void;
/** Add a new session */
addSession: (session: SessionMetadata) => void;
/** Update a session */
updateSession: (sessionId: string, updates: Partial<SessionMetadata>) => void;
/** Archive a session */
archiveSession: (sessionId: string) => void;
/** Remove a session */
removeSession: (sessionId: string) => void;
/** Add a task to a session */
addTask: (sessionId: string, task: TaskData) => void;
/** Update a task */
updateTask: (sessionId: string, taskId: string, updates: Partial<TaskData>) => void;
/** Get session by key */
getSessionByKey: (key: string) => SessionMetadata | undefined;
}
/**
* Hook for managing session state
* @returns Session state and actions
*
* @example
* ```tsx
* const { activeSession, activeSessions, setActiveSession } = useSession();
*
* return (
* <SessionList
* sessions={activeSessions}
* onSelect={(id) => setActiveSession(id)}
* />
* );
* ```
*/
export function useSession(): UseSessionReturn {
const activeSessionId = useWorkflowStore(selectActiveSessionId);
const workflowData = useWorkflowStore((state) => state.workflowData);
const sessionDataStore = useWorkflowStore((state) => state.sessionDataStore);
// Actions
const setActiveSessionId = useWorkflowStore((state) => state.setActiveSessionId);
const addSessionAction = useWorkflowStore((state) => state.addSession);
const updateSessionAction = useWorkflowStore((state) => state.updateSession);
const archiveSessionAction = useWorkflowStore((state) => state.archiveSession);
const removeSessionAction = useWorkflowStore((state) => state.removeSession);
const addTaskAction = useWorkflowStore((state) => state.addTask);
const updateTaskAction = useWorkflowStore((state) => state.updateTask);
const getFilteredSessionsAction = useWorkflowStore((state) => state.getFilteredSessions);
const getSessionByKeyAction = useWorkflowStore((state) => state.getSessionByKey);
// Memoized active session
const activeSession = useMemo(() => {
if (!activeSessionId) return null;
const key = `session-${activeSessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
return sessionDataStore[key] || null;
}, [activeSessionId, sessionDataStore]);
// Memoized filtered sessions
const filteredSessions = useMemo(() => {
return getFilteredSessionsAction();
}, [getFilteredSessionsAction, workflowData]);
// Callbacks
const setActiveSession = useCallback(
(sessionId: string | null) => {
setActiveSessionId(sessionId);
},
[setActiveSessionId]
);
const addSession = useCallback(
(session: SessionMetadata) => {
addSessionAction(session);
},
[addSessionAction]
);
const updateSession = useCallback(
(sessionId: string, updates: Partial<SessionMetadata>) => {
updateSessionAction(sessionId, updates);
},
[updateSessionAction]
);
const archiveSession = useCallback(
(sessionId: string) => {
archiveSessionAction(sessionId);
},
[archiveSessionAction]
);
const removeSession = useCallback(
(sessionId: string) => {
removeSessionAction(sessionId);
},
[removeSessionAction]
);
const addTask = useCallback(
(sessionId: string, task: TaskData) => {
addTaskAction(sessionId, task);
},
[addTaskAction]
);
const updateTask = useCallback(
(sessionId: string, taskId: string, updates: Partial<TaskData>) => {
updateTaskAction(sessionId, taskId, updates);
},
[updateTaskAction]
);
const getSessionByKey = useCallback(
(key: string) => {
return getSessionByKeyAction(key);
},
[getSessionByKeyAction]
);
return {
activeSessionId,
activeSession,
activeSessions: workflowData.activeSessions,
archivedSessions: workflowData.archivedSessions,
filteredSessions,
setActiveSession,
addSession,
updateSession,
archiveSession,
removeSession,
addTask,
updateTask,
getSessionByKey,
};
}

View File

@@ -0,0 +1,373 @@
// ========================================
// useSessions Hook
// ========================================
// TanStack Query hooks for sessions with optimistic updates
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchSessions,
createSession,
updateSession,
archiveSession,
deleteSession,
type SessionsResponse,
type CreateSessionInput,
type UpdateSessionInput,
} from '../lib/api';
import type { SessionMetadata } from '../types/store';
import { dashboardStatsKeys } from './useDashboardStats';
// Query key factory
export const sessionsKeys = {
all: ['sessions'] as const,
lists: () => [...sessionsKeys.all, 'list'] as const,
list: (filters?: SessionsFilter) => [...sessionsKeys.lists(), filters] as const,
details: () => [...sessionsKeys.all, 'detail'] as const,
detail: (id: string) => [...sessionsKeys.details(), id] as const,
};
// Default stale time: 30 seconds
const STALE_TIME = 30 * 1000;
export interface SessionsFilter {
status?: SessionMetadata['status'][];
search?: string;
location?: 'active' | 'archived' | 'all';
}
export interface UseSessionsOptions {
/** Filter options */
filter?: SessionsFilter;
/** Override default stale time (ms) */
staleTime?: number;
/** Enable/disable the query */
enabled?: boolean;
/** Refetch interval (ms), 0 to disable */
refetchInterval?: number;
}
export interface UseSessionsReturn {
/** All sessions data */
sessions: SessionsResponse | undefined;
/** Active sessions */
activeSessions: SessionMetadata[];
/** Archived sessions */
archivedSessions: SessionMetadata[];
/** Filtered sessions based on filter options */
filteredSessions: SessionMetadata[];
/** Loading state for initial fetch */
isLoading: boolean;
/** Fetching state (initial or refetch) */
isFetching: boolean;
/** Error object if query failed */
error: Error | null;
/** Manually refetch data */
refetch: () => Promise<void>;
/** Invalidate and refetch sessions */
invalidate: () => Promise<void>;
}
/**
* Hook for fetching sessions data
*
* @example
* ```tsx
* const { activeSessions, isLoading } = useSessions({
* filter: { location: 'active' }
* });
* ```
*/
export function useSessions(options: UseSessionsOptions = {}): UseSessionsReturn {
const { filter, staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
const queryClient = useQueryClient();
const query = useQuery({
queryKey: sessionsKeys.list(filter),
queryFn: fetchSessions,
staleTime,
enabled,
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
});
const activeSessions = query.data?.activeSessions ?? [];
const archivedSessions = query.data?.archivedSessions ?? [];
// Apply client-side filtering
const filteredSessions = (() => {
let sessions: SessionMetadata[] = [];
if (!filter?.location || filter.location === 'all') {
sessions = [...activeSessions, ...archivedSessions];
} else if (filter.location === 'active') {
sessions = activeSessions;
} else {
sessions = archivedSessions;
}
// Apply status filter
if (filter?.status && filter.status.length > 0) {
sessions = sessions.filter((s) => filter.status!.includes(s.status));
}
// Apply search filter
if (filter?.search) {
const searchLower = filter.search.toLowerCase();
sessions = sessions.filter(
(s) =>
s.session_id.toLowerCase().includes(searchLower) ||
s.title?.toLowerCase().includes(searchLower) ||
s.description?.toLowerCase().includes(searchLower)
);
}
return sessions;
})();
const refetch = async () => {
await query.refetch();
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: sessionsKeys.all });
};
return {
sessions: query.data,
activeSessions,
archivedSessions,
filteredSessions,
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch,
invalidate,
};
}
// ========== Mutations ==========
export interface UseCreateSessionReturn {
createSession: (input: CreateSessionInput) => Promise<SessionMetadata>;
isCreating: boolean;
error: Error | null;
}
/**
* Hook for creating a new session
*/
export function useCreateSession(): UseCreateSessionReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createSession,
onSuccess: (newSession) => {
// Update sessions cache
queryClient.setQueryData<SessionsResponse>(sessionsKeys.list(), (old) => {
if (!old) return { activeSessions: [newSession], archivedSessions: [] };
return {
...old,
activeSessions: [newSession, ...old.activeSessions],
};
});
// Invalidate dashboard stats
queryClient.invalidateQueries({ queryKey: dashboardStatsKeys.all });
},
});
return {
createSession: mutation.mutateAsync,
isCreating: mutation.isPending,
error: mutation.error,
};
}
export interface UseUpdateSessionReturn {
updateSession: (sessionId: string, input: UpdateSessionInput) => Promise<SessionMetadata>;
isUpdating: boolean;
error: Error | null;
}
/**
* Hook for updating a session
*/
export function useUpdateSession(): UseUpdateSessionReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({ sessionId, input }: { sessionId: string; input: UpdateSessionInput }) =>
updateSession(sessionId, input),
onSuccess: (updatedSession) => {
// Update sessions cache
queryClient.setQueryData<SessionsResponse>(sessionsKeys.list(), (old) => {
if (!old) return old;
return {
activeSessions: old.activeSessions.map((s) =>
s.session_id === updatedSession.session_id ? updatedSession : s
),
archivedSessions: old.archivedSessions.map((s) =>
s.session_id === updatedSession.session_id ? updatedSession : s
),
};
});
},
});
return {
updateSession: (sessionId, input) => mutation.mutateAsync({ sessionId, input }),
isUpdating: mutation.isPending,
error: mutation.error,
};
}
export interface UseArchiveSessionReturn {
archiveSession: (sessionId: string) => Promise<SessionMetadata>;
isArchiving: boolean;
error: Error | null;
}
/**
* Hook for archiving a session with optimistic update
*/
export function useArchiveSession(): UseArchiveSessionReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: archiveSession,
onMutate: async (sessionId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: sessionsKeys.all });
// Snapshot previous value
const previousSessions = queryClient.getQueryData<SessionsResponse>(sessionsKeys.list());
// Optimistically update
queryClient.setQueryData<SessionsResponse>(sessionsKeys.list(), (old) => {
if (!old) return old;
const session = old.activeSessions.find((s) => s.session_id === sessionId);
if (!session) return old;
const archivedSession: SessionMetadata = {
...session,
status: 'archived',
location: 'archived',
updated_at: new Date().toISOString(),
};
return {
activeSessions: old.activeSessions.filter((s) => s.session_id !== sessionId),
archivedSessions: [archivedSession, ...old.archivedSessions],
};
});
return { previousSessions };
},
onError: (_error, _sessionId, context) => {
// Rollback on error
if (context?.previousSessions) {
queryClient.setQueryData(sessionsKeys.list(), context.previousSessions);
}
},
onSettled: () => {
// Invalidate to ensure sync with server
queryClient.invalidateQueries({ queryKey: sessionsKeys.all });
queryClient.invalidateQueries({ queryKey: dashboardStatsKeys.all });
},
});
return {
archiveSession: mutation.mutateAsync,
isArchiving: mutation.isPending,
error: mutation.error,
};
}
export interface UseDeleteSessionReturn {
deleteSession: (sessionId: string) => Promise<void>;
isDeleting: boolean;
error: Error | null;
}
/**
* Hook for deleting a session with optimistic update
*/
export function useDeleteSession(): UseDeleteSessionReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: deleteSession,
onMutate: async (sessionId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: sessionsKeys.all });
// Snapshot previous value
const previousSessions = queryClient.getQueryData<SessionsResponse>(sessionsKeys.list());
// Optimistically remove
queryClient.setQueryData<SessionsResponse>(sessionsKeys.list(), (old) => {
if (!old) return old;
return {
activeSessions: old.activeSessions.filter((s) => s.session_id !== sessionId),
archivedSessions: old.archivedSessions.filter((s) => s.session_id !== sessionId),
};
});
return { previousSessions };
},
onError: (_error, _sessionId, context) => {
// Rollback on error
if (context?.previousSessions) {
queryClient.setQueryData(sessionsKeys.list(), context.previousSessions);
}
},
onSettled: () => {
// Invalidate to ensure sync with server
queryClient.invalidateQueries({ queryKey: sessionsKeys.all });
queryClient.invalidateQueries({ queryKey: dashboardStatsKeys.all });
},
});
return {
deleteSession: mutation.mutateAsync,
isDeleting: mutation.isPending,
error: mutation.error,
};
}
/**
* Combined hook for all session mutations
*/
export function useSessionMutations() {
const create = useCreateSession();
const update = useUpdateSession();
const archive = useArchiveSession();
const remove = useDeleteSession();
return {
createSession: create.createSession,
updateSession: update.updateSession,
archiveSession: archive.archiveSession,
deleteSession: remove.deleteSession,
isCreating: create.isCreating,
isUpdating: update.isUpdating,
isArchiving: archive.isArchiving,
isDeleting: remove.isDeleting,
isMutating: create.isCreating || update.isUpdating || archive.isArchiving || remove.isDeleting,
};
}
/**
* Hook to prefetch sessions data
*/
export function usePrefetchSessions() {
const queryClient = useQueryClient();
return (filter?: SessionsFilter) => {
queryClient.prefetchQuery({
queryKey: sessionsKeys.list(filter),
queryFn: fetchSessions,
staleTime: STALE_TIME,
});
};
}

View File

@@ -0,0 +1,193 @@
// ========================================
// useSkills Hook
// ========================================
// TanStack Query hooks for skills management
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchSkills,
toggleSkill,
type Skill,
type SkillsResponse,
} from '../lib/api';
// Query key factory
export const skillsKeys = {
all: ['skills'] as const,
lists: () => [...skillsKeys.all, 'list'] as const,
list: (filters?: SkillsFilter) => [...skillsKeys.lists(), filters] as const,
};
// Default stale time: 5 minutes (skills don't change frequently)
const STALE_TIME = 5 * 60 * 1000;
export interface SkillsFilter {
search?: string;
category?: string;
source?: Skill['source'];
enabledOnly?: boolean;
}
export interface UseSkillsOptions {
filter?: SkillsFilter;
staleTime?: number;
enabled?: boolean;
}
export interface UseSkillsReturn {
skills: Skill[];
enabledSkills: Skill[];
categories: string[];
skillsByCategory: Record<string, Skill[]>;
totalCount: number;
enabledCount: number;
isLoading: boolean;
isFetching: boolean;
error: Error | null;
refetch: () => Promise<void>;
invalidate: () => Promise<void>;
}
/**
* Hook for fetching and filtering skills
*/
export function useSkills(options: UseSkillsOptions = {}): UseSkillsReturn {
const { filter, staleTime = STALE_TIME, enabled = true } = options;
const queryClient = useQueryClient();
const query = useQuery({
queryKey: skillsKeys.list(filter),
queryFn: fetchSkills,
staleTime,
enabled,
retry: 2,
});
const allSkills = query.data?.skills ?? [];
// Apply filters
const filteredSkills = (() => {
let skills = allSkills;
if (filter?.search) {
const searchLower = filter.search.toLowerCase();
skills = skills.filter(
(s) =>
s.name.toLowerCase().includes(searchLower) ||
s.description.toLowerCase().includes(searchLower) ||
s.triggers.some((t) => t.toLowerCase().includes(searchLower))
);
}
if (filter?.category) {
skills = skills.filter((s) => s.category === filter.category);
}
if (filter?.source) {
skills = skills.filter((s) => s.source === filter.source);
}
if (filter?.enabledOnly) {
skills = skills.filter((s) => s.enabled);
}
return skills;
})();
// Group by category
const skillsByCategory: Record<string, Skill[]> = {};
const categories = new Set<string>();
for (const skill of allSkills) {
const category = skill.category || 'Uncategorized';
categories.add(category);
if (!skillsByCategory[category]) {
skillsByCategory[category] = [];
}
skillsByCategory[category].push(skill);
}
const enabledSkills = allSkills.filter((s) => s.enabled);
const refetch = async () => {
await query.refetch();
};
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: skillsKeys.all });
};
return {
skills: filteredSkills,
enabledSkills,
categories: Array.from(categories).sort(),
skillsByCategory,
totalCount: allSkills.length,
enabledCount: enabledSkills.length,
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch,
invalidate,
};
}
// ========== Mutations ==========
export interface UseToggleSkillReturn {
toggleSkill: (skillName: string, enabled: boolean) => Promise<Skill>;
isToggling: boolean;
error: Error | null;
}
export function useToggleSkill(): UseToggleSkillReturn {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({ skillName, enabled }: { skillName: string; enabled: boolean }) =>
toggleSkill(skillName, enabled),
onMutate: async ({ skillName, enabled }) => {
await queryClient.cancelQueries({ queryKey: skillsKeys.all });
const previousSkills = queryClient.getQueryData<SkillsResponse>(skillsKeys.list());
// Optimistic update
queryClient.setQueryData<SkillsResponse>(skillsKeys.list(), (old) => {
if (!old) return old;
return {
skills: old.skills.map((s) =>
s.name === skillName ? { ...s, enabled } : s
),
};
});
return { previousSkills };
},
onError: (_error, _vars, context) => {
if (context?.previousSkills) {
queryClient.setQueryData(skillsKeys.list(), context.previousSkills);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: skillsKeys.all });
},
});
return {
toggleSkill: (skillName, enabled) => mutation.mutateAsync({ skillName, enabled }),
isToggling: mutation.isPending,
error: mutation.error,
};
}
/**
* Combined hook for all skill mutations
*/
export function useSkillMutations() {
const toggle = useToggleSkill();
return {
toggleSkill: toggle.toggleSkill,
isToggling: toggle.isToggling,
isMutating: toggle.isToggling,
};
}

View File

@@ -0,0 +1,184 @@
// ========================================
// useTemplates Hook
// ========================================
// TanStack Query hooks for template API operations
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { FlowTemplate, TemplateInstallRequest, TemplateExportRequest } from '../types/execution';
import type { Flow } from '../types/flow';
// API base URL
const API_BASE = '/api/orchestrator';
// Query keys
export const templateKeys = {
all: ['templates'] as const,
lists: () => [...templateKeys.all, 'list'] as const,
list: (filters?: Record<string, unknown>) => [...templateKeys.lists(), filters] as const,
details: () => [...templateKeys.all, 'detail'] as const,
detail: (id: string) => [...templateKeys.details(), id] as const,
categories: () => [...templateKeys.all, 'categories'] as const,
};
// API response types
interface TemplatesListResponse {
templates: FlowTemplate[];
total: number;
categories: string[];
}
interface TemplateDetailResponse extends FlowTemplate {
flow: Flow;
}
interface InstallTemplateResponse {
flow: Flow;
message: string;
}
interface ExportTemplateResponse {
template: FlowTemplate;
message: string;
}
// ========== Fetch Functions ==========
async function fetchTemplates(category?: string): Promise<TemplatesListResponse> {
const url = category
? `${API_BASE}/templates?category=${encodeURIComponent(category)}`
: `${API_BASE}/templates`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch templates: ${response.statusText}`);
}
return response.json();
}
async function fetchTemplate(id: string): Promise<TemplateDetailResponse> {
const response = await fetch(`${API_BASE}/templates/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch template: ${response.statusText}`);
}
return response.json();
}
async function installTemplate(request: TemplateInstallRequest): Promise<InstallTemplateResponse> {
const response = await fetch(`${API_BASE}/templates/install`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`Failed to install template: ${response.statusText}`);
}
return response.json();
}
async function exportTemplate(request: TemplateExportRequest): Promise<ExportTemplateResponse> {
const response = await fetch(`${API_BASE}/templates/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`Failed to export template: ${response.statusText}`);
}
return response.json();
}
async function deleteTemplate(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/templates/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Failed to delete template: ${response.statusText}`);
}
}
// ========== Query Hooks ==========
/**
* Fetch all templates
*/
export function useTemplates(category?: string) {
return useQuery({
queryKey: templateKeys.list({ category }),
queryFn: () => fetchTemplates(category),
staleTime: 60000, // 1 minute
});
}
/**
* Fetch a single template by ID
*/
export function useTemplate(id: string | null) {
return useQuery({
queryKey: templateKeys.detail(id ?? ''),
queryFn: () => fetchTemplate(id!),
enabled: !!id,
staleTime: 60000,
});
}
// ========== Mutation Hooks ==========
/**
* Install a template as a new flow
*/
export function useInstallTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: installTemplate,
onSuccess: () => {
// Invalidate flows list to show the new flow
queryClient.invalidateQueries({ queryKey: ['flows'] });
},
});
}
/**
* Export a flow as a template
*/
export function useExportTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: exportTemplate,
onSuccess: (result) => {
// Add to templates list
queryClient.setQueryData<TemplatesListResponse>(templateKeys.lists(), (old) => {
if (!old) return { templates: [result.template], total: 1, categories: [] };
return {
...old,
templates: [...old.templates, result.template],
total: old.total + 1,
};
});
queryClient.invalidateQueries({ queryKey: templateKeys.lists() });
},
});
}
/**
* Delete a template
*/
export function useDeleteTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteTemplate,
onSuccess: (_, deletedId) => {
// Remove from cache
queryClient.removeQueries({ queryKey: templateKeys.detail(deletedId) });
queryClient.setQueryData<TemplatesListResponse>(templateKeys.lists(), (old) => {
if (!old) return old;
return {
...old,
templates: old.templates.filter((t) => t.id !== deletedId),
total: old.total - 1,
};
});
},
});
}

View File

@@ -0,0 +1,62 @@
// ========================================
// useTheme Hook
// ========================================
// Convenient hook for theme management
import { useCallback } from 'react';
import { useAppStore, selectTheme, selectResolvedTheme } from '../stores/appStore';
import type { Theme } from '../types/store';
export interface UseThemeReturn {
/** Current theme preference ('light', 'dark', 'system') */
theme: Theme;
/** Resolved theme based on preference and system settings */
resolvedTheme: 'light' | 'dark';
/** Whether the resolved theme is dark */
isDark: boolean;
/** Set theme preference */
setTheme: (theme: Theme) => void;
/** Toggle between light and dark (ignores system) */
toggleTheme: () => void;
}
/**
* Hook for managing theme state
* @returns Theme state and actions
*
* @example
* ```tsx
* const { theme, isDark, setTheme, toggleTheme } = useTheme();
*
* return (
* <button onClick={toggleTheme}>
* {isDark ? 'Switch to Light' : 'Switch to Dark'}
* </button>
* );
* ```
*/
export function useTheme(): UseThemeReturn {
const theme = useAppStore(selectTheme);
const resolvedTheme = useAppStore(selectResolvedTheme);
const setThemeAction = useAppStore((state) => state.setTheme);
const toggleThemeAction = useAppStore((state) => state.toggleTheme);
const setTheme = useCallback(
(newTheme: Theme) => {
setThemeAction(newTheme);
},
[setThemeAction]
);
const toggleTheme = useCallback(() => {
toggleThemeAction();
}, [toggleThemeAction]);
return {
theme,
resolvedTheme,
isDark: resolvedTheme === 'dark',
setTheme,
toggleTheme,
};
}

View File

@@ -0,0 +1,254 @@
// ========================================
// useWebSocket Hook
// ========================================
// Typed WebSocket connection management with auto-reconnect
import { useEffect, useRef, useCallback } from 'react';
import { useNotificationStore } from '@/stores';
import { useExecutionStore } from '@/stores/executionStore';
import { useFlowStore } from '@/stores';
import {
OrchestratorMessageSchema,
type OrchestratorWebSocketMessage,
type ExecutionLog,
} from '../types/execution';
// Constants
const RECONNECT_DELAY_BASE = 1000; // 1 second
const RECONNECT_DELAY_MAX = 30000; // 30 seconds
const RECONNECT_DELAY_MULTIPLIER = 1.5;
interface UseWebSocketOptions {
enabled?: boolean;
onMessage?: (message: OrchestratorWebSocketMessage) => void;
}
interface UseWebSocketReturn {
isConnected: boolean;
send: (message: unknown) => void;
reconnect: () => void;
}
export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketReturn {
const { enabled = true, onMessage } = options;
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectDelayRef = useRef(RECONNECT_DELAY_BASE);
// Notification store for connection status
const setWsStatus = useNotificationStore((state) => state.setWsStatus);
const setWsLastMessage = useNotificationStore((state) => state.setWsLastMessage);
const incrementReconnectAttempts = useNotificationStore((state) => state.incrementReconnectAttempts);
const resetReconnectAttempts = useNotificationStore((state) => state.resetReconnectAttempts);
// Execution store for state updates
const setExecutionStatus = useExecutionStore((state) => state.setExecutionStatus);
const setNodeStarted = useExecutionStore((state) => state.setNodeStarted);
const setNodeCompleted = useExecutionStore((state) => state.setNodeCompleted);
const setNodeFailed = useExecutionStore((state) => state.setNodeFailed);
const addLog = useExecutionStore((state) => state.addLog);
const completeExecution = useExecutionStore((state) => state.completeExecution);
const currentExecution = useExecutionStore((state) => state.currentExecution);
// Flow store for node status updates on canvas
const updateNode = useFlowStore((state) => state.updateNode);
// Handle incoming WebSocket messages
const handleMessage = useCallback(
(event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
// Store last message for debugging
setWsLastMessage(data);
// Check if this is an orchestrator message
if (!data.type?.startsWith('ORCHESTRATOR_')) {
return;
}
// Validate message with zod schema
const parsed = OrchestratorMessageSchema.safeParse(data);
if (!parsed.success) {
console.warn('[WebSocket] Invalid orchestrator message:', parsed.error.issues);
return;
}
// Cast validated data to our TypeScript interface
const message = parsed.data as OrchestratorWebSocketMessage;
// Only process messages for current execution
if (currentExecution && message.execId !== currentExecution.execId) {
return;
}
// Dispatch to execution store based on message type
switch (message.type) {
case 'ORCHESTRATOR_STATE_UPDATE':
setExecutionStatus(message.status, message.currentNodeId);
// Check for completion
if (message.status === 'completed' || message.status === 'failed') {
completeExecution(message.status);
}
break;
case 'ORCHESTRATOR_NODE_STARTED':
setNodeStarted(message.nodeId);
// Update canvas node status
updateNode(message.nodeId, { executionStatus: 'running' });
break;
case 'ORCHESTRATOR_NODE_COMPLETED':
setNodeCompleted(message.nodeId, message.result);
// Update canvas node status
updateNode(message.nodeId, {
executionStatus: 'completed',
executionResult: message.result,
});
break;
case 'ORCHESTRATOR_NODE_FAILED':
setNodeFailed(message.nodeId, message.error);
// Update canvas node status
updateNode(message.nodeId, {
executionStatus: 'failed',
executionError: message.error,
});
break;
case 'ORCHESTRATOR_LOG':
addLog(message.log as ExecutionLog);
break;
}
// Call custom message handler if provided
onMessage?.(message);
} catch (error) {
console.error('[WebSocket] Failed to parse message:', error);
}
},
[
currentExecution,
setWsLastMessage,
setExecutionStatus,
setNodeStarted,
setNodeCompleted,
setNodeFailed,
addLog,
completeExecution,
updateNode,
onMessage,
]
);
// Connect to WebSocket
const connect = useCallback(() => {
if (!enabled) return;
// Construct WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
try {
setWsStatus('connecting');
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log('[WebSocket] Connected');
setWsStatus('connected');
resetReconnectAttempts();
reconnectDelayRef.current = RECONNECT_DELAY_BASE;
};
ws.onmessage = handleMessage;
ws.onclose = () => {
console.log('[WebSocket] Disconnected');
setWsStatus('disconnected');
wsRef.current = null;
scheduleReconnect();
};
ws.onerror = (error) => {
console.error('[WebSocket] Error:', error);
setWsStatus('error');
};
} catch (error) {
console.error('[WebSocket] Failed to connect:', error);
setWsStatus('error');
scheduleReconnect();
}
}, [enabled, handleMessage, setWsStatus, resetReconnectAttempts]);
// Schedule reconnection with exponential backoff
const scheduleReconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
const delay = reconnectDelayRef.current;
console.log(`[WebSocket] Reconnecting in ${delay}ms...`);
setWsStatus('reconnecting');
incrementReconnectAttempts();
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, delay);
// Increase delay for next attempt (exponential backoff)
reconnectDelayRef.current = Math.min(
reconnectDelayRef.current * RECONNECT_DELAY_MULTIPLIER,
RECONNECT_DELAY_MAX
);
}, [connect, setWsStatus, incrementReconnectAttempts]);
// Send message through WebSocket
const send = useCallback((message: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
console.warn('[WebSocket] Cannot send message: not connected');
}
}, []);
// Manual reconnect
const reconnect = useCallback(() => {
if (wsRef.current) {
wsRef.current.close();
}
reconnectDelayRef.current = RECONNECT_DELAY_BASE;
connect();
}, [connect]);
// Check connection status
const isConnected = wsRef.current?.readyState === WebSocket.OPEN;
// Connect on mount, cleanup on unmount
useEffect(() => {
if (enabled) {
connect();
}
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [enabled, connect]);
return {
isConnected,
send,
reconnect,
};
}
export default useWebSocket;