mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
feat: implement FlowExecutor for executing flow definitions with DAG traversal and node execution
This commit is contained in:
121
ccw/frontend/src/hooks/index.ts
Normal file
121
ccw/frontend/src/hooks/index.ts
Normal 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';
|
||||
128
ccw/frontend/src/hooks/useCommands.ts
Normal file
128
ccw/frontend/src/hooks/useCommands.ts
Normal 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;
|
||||
}
|
||||
143
ccw/frontend/src/hooks/useConfig.ts
Normal file
143
ccw/frontend/src/hooks/useConfig.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
111
ccw/frontend/src/hooks/useDashboardStats.ts
Normal file
111
ccw/frontend/src/hooks/useDashboardStats.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
295
ccw/frontend/src/hooks/useFlows.ts
Normal file
295
ccw/frontend/src/hooks/useFlows.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
297
ccw/frontend/src/hooks/useIssues.ts
Normal file
297
ccw/frontend/src/hooks/useIssues.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
262
ccw/frontend/src/hooks/useLoops.ts
Normal file
262
ccw/frontend/src/hooks/useLoops.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
244
ccw/frontend/src/hooks/useMemory.ts
Normal file
244
ccw/frontend/src/hooks/useMemory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
232
ccw/frontend/src/hooks/useNotifications.ts
Normal file
232
ccw/frontend/src/hooks/useNotifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
155
ccw/frontend/src/hooks/useSession.ts
Normal file
155
ccw/frontend/src/hooks/useSession.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
373
ccw/frontend/src/hooks/useSessions.ts
Normal file
373
ccw/frontend/src/hooks/useSessions.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
193
ccw/frontend/src/hooks/useSkills.ts
Normal file
193
ccw/frontend/src/hooks/useSkills.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
184
ccw/frontend/src/hooks/useTemplates.ts
Normal file
184
ccw/frontend/src/hooks/useTemplates.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
62
ccw/frontend/src/hooks/useTheme.ts
Normal file
62
ccw/frontend/src/hooks/useTheme.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
254
ccw/frontend/src/hooks/useWebSocket.ts
Normal file
254
ccw/frontend/src/hooks/useWebSocket.ts
Normal 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;
|
||||
Reference in New Issue
Block a user