mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
- Implemented `DiscoveryPage` with session management and findings display. - Added tests for `DiscoveryPage` to ensure proper rendering and functionality. - Created `QueuePage` for managing issue execution queues with stats and actions. - Added tests for `QueuePage` to verify UI elements and translations. - Introduced `useIssues` hooks for fetching and managing issue data. - Added loading skeletons and error handling for better user experience. - Created `vite-env.d.ts` for TypeScript support in Vite environment.
569 lines
17 KiB
TypeScript
569 lines
17 KiB
TypeScript
// ========================================
|
|
// Workflow Store
|
|
// ========================================
|
|
// Manages workflow sessions, tasks, and related data
|
|
|
|
import { create } from 'zustand';
|
|
import { devtools, persist } from 'zustand/middleware';
|
|
import type {
|
|
WorkflowStore,
|
|
WorkflowState,
|
|
SessionMetadata,
|
|
TaskData,
|
|
LiteTaskSession,
|
|
WorkflowFilters,
|
|
WorkflowSorting,
|
|
} from '../types/store';
|
|
import { switchWorkspace as apiSwitchWorkspace, fetchRecentPaths, removeRecentPath as apiRemoveRecentPath } from '../lib/api';
|
|
|
|
// Helper to generate session key from ID
|
|
const sessionKey = (sessionId: string): string => {
|
|
return `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
|
};
|
|
|
|
// Default filters
|
|
const defaultFilters: WorkflowFilters = {
|
|
status: null,
|
|
search: '',
|
|
dateRange: { start: null, end: null },
|
|
};
|
|
|
|
// Default sorting
|
|
const defaultSorting: WorkflowSorting = {
|
|
field: 'created_at',
|
|
direction: 'desc',
|
|
};
|
|
|
|
// Initial state
|
|
const initialState: WorkflowState = {
|
|
// Core data
|
|
workflowData: {
|
|
activeSessions: [],
|
|
archivedSessions: [],
|
|
},
|
|
projectPath: '',
|
|
recentPaths: [],
|
|
serverPlatform: 'win32',
|
|
|
|
// Data stores
|
|
sessionDataStore: {},
|
|
liteTaskDataStore: {},
|
|
taskJsonStore: {},
|
|
|
|
// Active session
|
|
activeSessionId: null,
|
|
|
|
// Filters and sorting
|
|
filters: defaultFilters,
|
|
sorting: defaultSorting,
|
|
};
|
|
|
|
export const useWorkflowStore = create<WorkflowStore>()(
|
|
devtools(
|
|
persist(
|
|
(set, get) => ({
|
|
...initialState,
|
|
|
|
// ========== Session Actions ==========
|
|
|
|
setSessions: (active: SessionMetadata[], archived: SessionMetadata[]) => {
|
|
const sessionDataStore: Record<string, SessionMetadata> = {};
|
|
|
|
// Build sessionDataStore from both arrays
|
|
[...active, ...archived].forEach((session) => {
|
|
const key = sessionKey(session.session_id);
|
|
sessionDataStore[key] = session;
|
|
});
|
|
|
|
set(
|
|
{
|
|
workflowData: {
|
|
activeSessions: active,
|
|
archivedSessions: archived,
|
|
},
|
|
sessionDataStore,
|
|
},
|
|
false,
|
|
'setSessions'
|
|
);
|
|
},
|
|
|
|
addSession: (session: SessionMetadata) => {
|
|
const key = sessionKey(session.session_id);
|
|
|
|
set(
|
|
(state) => ({
|
|
workflowData: {
|
|
...state.workflowData,
|
|
activeSessions: [...state.workflowData.activeSessions, session],
|
|
},
|
|
sessionDataStore: {
|
|
...state.sessionDataStore,
|
|
[key]: session,
|
|
},
|
|
}),
|
|
false,
|
|
'addSession'
|
|
);
|
|
},
|
|
|
|
updateSession: (sessionId: string, updates: Partial<SessionMetadata>) => {
|
|
const key = sessionKey(sessionId);
|
|
|
|
set(
|
|
(state) => {
|
|
const session = state.sessionDataStore[key];
|
|
if (!session) return state;
|
|
|
|
const updatedSession = { ...session, ...updates, updated_at: new Date().toISOString() };
|
|
|
|
// Update in the appropriate array
|
|
const isActive = session.location === 'active';
|
|
const targetArray = isActive ? 'activeSessions' : 'archivedSessions';
|
|
|
|
return {
|
|
sessionDataStore: {
|
|
...state.sessionDataStore,
|
|
[key]: updatedSession,
|
|
},
|
|
workflowData: {
|
|
...state.workflowData,
|
|
[targetArray]: state.workflowData[targetArray].map((s) =>
|
|
s.session_id === sessionId ? updatedSession : s
|
|
),
|
|
},
|
|
};
|
|
},
|
|
false,
|
|
'updateSession'
|
|
);
|
|
},
|
|
|
|
removeSession: (sessionId: string) => {
|
|
const key = sessionKey(sessionId);
|
|
|
|
set(
|
|
(state) => {
|
|
const { [key]: removed, ...remainingStore } = state.sessionDataStore;
|
|
|
|
return {
|
|
sessionDataStore: remainingStore,
|
|
workflowData: {
|
|
activeSessions: state.workflowData.activeSessions.filter(
|
|
(s) => s.session_id !== sessionId
|
|
),
|
|
archivedSessions: state.workflowData.archivedSessions.filter(
|
|
(s) => s.session_id !== sessionId
|
|
),
|
|
},
|
|
};
|
|
},
|
|
false,
|
|
'removeSession'
|
|
);
|
|
},
|
|
|
|
archiveSession: (sessionId: string) => {
|
|
const key = sessionKey(sessionId);
|
|
|
|
set(
|
|
(state) => {
|
|
const session = state.sessionDataStore[key];
|
|
if (!session || session.location === 'archived') return state;
|
|
|
|
const archivedSession: SessionMetadata = {
|
|
...session,
|
|
location: 'archived',
|
|
status: 'archived',
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
|
|
return {
|
|
sessionDataStore: {
|
|
...state.sessionDataStore,
|
|
[key]: archivedSession,
|
|
},
|
|
workflowData: {
|
|
activeSessions: state.workflowData.activeSessions.filter(
|
|
(s) => s.session_id !== sessionId
|
|
),
|
|
archivedSessions: [...state.workflowData.archivedSessions, archivedSession],
|
|
},
|
|
};
|
|
},
|
|
false,
|
|
'archiveSession'
|
|
);
|
|
},
|
|
|
|
// ========== Task Actions ==========
|
|
|
|
addTask: (sessionId: string, task: TaskData) => {
|
|
const key = sessionKey(sessionId);
|
|
|
|
set(
|
|
(state) => {
|
|
const session = state.sessionDataStore[key];
|
|
if (!session) return state;
|
|
|
|
// Check for duplicate
|
|
const existingTask = session.tasks?.find((t) => t.task_id === task.task_id);
|
|
if (existingTask) return state;
|
|
|
|
const updatedSession: SessionMetadata = {
|
|
...session,
|
|
tasks: [...(session.tasks || []), task],
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
|
|
return {
|
|
sessionDataStore: {
|
|
...state.sessionDataStore,
|
|
[key]: updatedSession,
|
|
},
|
|
};
|
|
},
|
|
false,
|
|
'addTask'
|
|
);
|
|
},
|
|
|
|
updateTask: (sessionId: string, taskId: string, updates: Partial<TaskData>) => {
|
|
const key = sessionKey(sessionId);
|
|
|
|
set(
|
|
(state) => {
|
|
const session = state.sessionDataStore[key];
|
|
if (!session?.tasks) return state;
|
|
|
|
const updatedTasks = session.tasks.map((task) =>
|
|
task.task_id === taskId
|
|
? { ...task, ...updates, updated_at: new Date().toISOString() }
|
|
: task
|
|
);
|
|
|
|
const updatedSession: SessionMetadata = {
|
|
...session,
|
|
tasks: updatedTasks,
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
|
|
return {
|
|
sessionDataStore: {
|
|
...state.sessionDataStore,
|
|
[key]: updatedSession,
|
|
},
|
|
};
|
|
},
|
|
false,
|
|
'updateTask'
|
|
);
|
|
},
|
|
|
|
removeTask: (sessionId: string, taskId: string) => {
|
|
const key = sessionKey(sessionId);
|
|
|
|
set(
|
|
(state) => {
|
|
const session = state.sessionDataStore[key];
|
|
if (!session?.tasks) return state;
|
|
|
|
const updatedSession: SessionMetadata = {
|
|
...session,
|
|
tasks: session.tasks.filter((t) => t.task_id !== taskId),
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
|
|
return {
|
|
sessionDataStore: {
|
|
...state.sessionDataStore,
|
|
[key]: updatedSession,
|
|
},
|
|
};
|
|
},
|
|
false,
|
|
'removeTask'
|
|
);
|
|
},
|
|
|
|
// ========== Lite Task Actions ==========
|
|
|
|
setLiteTaskSession: (key: string, session: LiteTaskSession) => {
|
|
set(
|
|
(state) => ({
|
|
liteTaskDataStore: {
|
|
...state.liteTaskDataStore,
|
|
[key]: session,
|
|
},
|
|
}),
|
|
false,
|
|
'setLiteTaskSession'
|
|
);
|
|
},
|
|
|
|
removeLiteTaskSession: (key: string) => {
|
|
set(
|
|
(state) => {
|
|
const { [key]: removed, ...remaining } = state.liteTaskDataStore;
|
|
return { liteTaskDataStore: remaining };
|
|
},
|
|
false,
|
|
'removeLiteTaskSession'
|
|
);
|
|
},
|
|
|
|
// ========== Task JSON Store ==========
|
|
|
|
setTaskJson: (key: string, data: unknown) => {
|
|
set(
|
|
(state) => ({
|
|
taskJsonStore: {
|
|
...state.taskJsonStore,
|
|
[key]: data,
|
|
},
|
|
}),
|
|
false,
|
|
'setTaskJson'
|
|
);
|
|
},
|
|
|
|
removeTaskJson: (key: string) => {
|
|
set(
|
|
(state) => {
|
|
const { [key]: removed, ...remaining } = state.taskJsonStore;
|
|
return { taskJsonStore: remaining };
|
|
},
|
|
false,
|
|
'removeTaskJson'
|
|
);
|
|
},
|
|
|
|
// ========== Active Session ==========
|
|
|
|
setActiveSessionId: (sessionId: string | null) => {
|
|
set({ activeSessionId: sessionId }, false, 'setActiveSessionId');
|
|
},
|
|
|
|
// ========== Project Path ==========
|
|
|
|
setProjectPath: (path: string) => {
|
|
set({ projectPath: path }, false, 'setProjectPath');
|
|
},
|
|
|
|
addRecentPath: (path: string) => {
|
|
set(
|
|
(state) => {
|
|
// Remove if exists, add to front
|
|
const filtered = state.recentPaths.filter((p) => p !== path);
|
|
const updated = [path, ...filtered].slice(0, 10); // Keep max 10
|
|
return { recentPaths: updated };
|
|
},
|
|
false,
|
|
'addRecentPath'
|
|
);
|
|
},
|
|
|
|
setServerPlatform: (platform: 'win32' | 'darwin' | 'linux') => {
|
|
set({ serverPlatform: platform }, false, 'setServerPlatform');
|
|
},
|
|
|
|
// ========== Workspace Actions ==========
|
|
|
|
switchWorkspace: async (path: string) => {
|
|
const response = await apiSwitchWorkspace(path);
|
|
const sessionDataStore: Record<string, SessionMetadata> = {};
|
|
|
|
// Build sessionDataStore from both arrays
|
|
[...response.activeSessions, ...response.archivedSessions].forEach((session) => {
|
|
const key = sessionKey(session.session_id);
|
|
sessionDataStore[key] = session;
|
|
});
|
|
|
|
set(
|
|
{
|
|
projectPath: response.projectPath,
|
|
recentPaths: response.recentPaths,
|
|
workflowData: {
|
|
activeSessions: response.activeSessions,
|
|
archivedSessions: response.archivedSessions,
|
|
},
|
|
sessionDataStore,
|
|
},
|
|
false,
|
|
'switchWorkspace'
|
|
);
|
|
|
|
// Trigger query invalidation callback
|
|
const callback = get()._invalidateQueriesCallback;
|
|
if (callback) {
|
|
callback();
|
|
}
|
|
},
|
|
|
|
removeRecentPath: async (path: string) => {
|
|
const updatedPaths = await apiRemoveRecentPath(path);
|
|
set({ recentPaths: updatedPaths }, false, 'removeRecentPath');
|
|
},
|
|
|
|
refreshRecentPaths: async () => {
|
|
const paths = await fetchRecentPaths();
|
|
set({ recentPaths: paths }, false, 'refreshRecentPaths');
|
|
},
|
|
|
|
registerQueryInvalidator: (callback: () => void) => {
|
|
set({ _invalidateQueriesCallback: callback }, false, 'registerQueryInvalidator');
|
|
},
|
|
|
|
// ========== Filters and Sorting ==========
|
|
|
|
setFilters: (filters: Partial<WorkflowFilters>) => {
|
|
set(
|
|
(state) => ({
|
|
filters: { ...state.filters, ...filters },
|
|
}),
|
|
false,
|
|
'setFilters'
|
|
);
|
|
},
|
|
|
|
setSorting: (sorting: Partial<WorkflowSorting>) => {
|
|
set(
|
|
(state) => ({
|
|
sorting: { ...state.sorting, ...sorting },
|
|
}),
|
|
false,
|
|
'setSorting'
|
|
);
|
|
},
|
|
|
|
resetFilters: () => {
|
|
set({ filters: defaultFilters, sorting: defaultSorting }, false, 'resetFilters');
|
|
},
|
|
|
|
// ========== Computed Selectors ==========
|
|
|
|
getActiveSession: () => {
|
|
const { activeSessionId, sessionDataStore } = get();
|
|
if (!activeSessionId) return null;
|
|
const key = sessionKey(activeSessionId);
|
|
return sessionDataStore[key] || null;
|
|
},
|
|
|
|
getFilteredSessions: () => {
|
|
const { workflowData, filters, sorting } = get();
|
|
|
|
// Combine active and archived based on filter
|
|
let sessions = [...workflowData.activeSessions, ...workflowData.archivedSessions];
|
|
|
|
// Apply status filter
|
|
if (filters.status && filters.status.length > 0) {
|
|
sessions = sessions.filter((s) => filters.status!.includes(s.status));
|
|
}
|
|
|
|
// Apply search filter
|
|
if (filters.search) {
|
|
const searchLower = filters.search.toLowerCase();
|
|
sessions = sessions.filter(
|
|
(s) =>
|
|
s.session_id.toLowerCase().includes(searchLower) ||
|
|
s.title?.toLowerCase().includes(searchLower) ||
|
|
s.description?.toLowerCase().includes(searchLower)
|
|
);
|
|
}
|
|
|
|
// Apply date range filter
|
|
if (filters.dateRange.start || filters.dateRange.end) {
|
|
sessions = sessions.filter((s) => {
|
|
const createdAt = new Date(s.created_at);
|
|
if (filters.dateRange.start && createdAt < filters.dateRange.start) return false;
|
|
if (filters.dateRange.end && createdAt > filters.dateRange.end) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// Apply sorting
|
|
sessions.sort((a, b) => {
|
|
let comparison = 0;
|
|
|
|
switch (sorting.field) {
|
|
case 'created_at':
|
|
comparison = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
|
break;
|
|
case 'updated_at':
|
|
comparison =
|
|
new Date(a.updated_at || a.created_at).getTime() -
|
|
new Date(b.updated_at || b.created_at).getTime();
|
|
break;
|
|
case 'title':
|
|
comparison = (a.title || a.session_id).localeCompare(b.title || b.session_id);
|
|
break;
|
|
case 'status':
|
|
comparison = a.status.localeCompare(b.status);
|
|
break;
|
|
}
|
|
|
|
return sorting.direction === 'desc' ? -comparison : comparison;
|
|
});
|
|
|
|
return sessions;
|
|
},
|
|
|
|
getSessionByKey: (key: string) => {
|
|
return get().sessionDataStore[key];
|
|
},
|
|
}),
|
|
{
|
|
name: 'ccw-workflow-store',
|
|
version: 1, // State version for migration support
|
|
partialize: (state) => ({
|
|
projectPath: state.projectPath,
|
|
}),
|
|
migrate: (persistedState, version) => {
|
|
// Migration logic for future state shape changes
|
|
if (version < 1) {
|
|
// No migrations needed for initial version
|
|
// Example: if (version === 0) { persistedState.newField = defaultValue; }
|
|
}
|
|
return persistedState as typeof persistedState;
|
|
},
|
|
onRehydrateStorage: () => {
|
|
// Only log in development to avoid noise in production
|
|
if (process.env.NODE_ENV === 'development') {
|
|
// eslint-disable-next-line no-console
|
|
console.log('[WorkflowStore] Hydrating from localStorage...');
|
|
}
|
|
return (state, error) => {
|
|
if (error) {
|
|
// eslint-disable-next-line no-console
|
|
console.error('[WorkflowStore] Rehydration error:', error);
|
|
return;
|
|
}
|
|
if (state?.projectPath) {
|
|
if (process.env.NODE_ENV === 'development') {
|
|
// eslint-disable-next-line no-console
|
|
console.log('[WorkflowStore] Found persisted projectPath, re-initializing workspace:', state.projectPath);
|
|
}
|
|
// Use setTimeout to ensure the store is fully initialized before calling switchWorkspace
|
|
setTimeout(() => {
|
|
if (state.switchWorkspace) {
|
|
state.switchWorkspace(state.projectPath);
|
|
}
|
|
}, 0);
|
|
}
|
|
};
|
|
},
|
|
}
|
|
),
|
|
{ name: 'WorkflowStore' }
|
|
)
|
|
);
|
|
|
|
// Selectors for common access patterns
|
|
export const selectWorkflowData = (state: WorkflowStore) => state.workflowData;
|
|
export const selectActiveSessions = (state: WorkflowStore) => state.workflowData.activeSessions;
|
|
export const selectArchivedSessions = (state: WorkflowStore) => state.workflowData.archivedSessions;
|
|
export const selectActiveSessionId = (state: WorkflowStore) => state.activeSessionId;
|
|
export const selectProjectPath = (state: WorkflowStore) => state.projectPath;
|
|
export const selectFilters = (state: WorkflowStore) => state.filters;
|
|
export const selectSorting = (state: WorkflowStore) => state.sorting;
|