feat: add Zustand state management
- Add Zustand (v5.0.6) for lightweight state management - Create sessionStore for managing session-related state - Create agentStore for managing agent runs with intelligent polling - Eliminate prop drilling across component tree - Add comprehensive documentation for store usage
This commit is contained in:
5394
package-lock.json
generated
Normal file
5394
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,8 @@
|
|||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss": "^4.1.8",
|
"tailwindcss": "^4.1.8",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1",
|
||||||
|
"zustand": "^5.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
33
src/stores/README.md
Normal file
33
src/stores/README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Store Implementation Notes
|
||||||
|
|
||||||
|
The store files (`sessionStore.ts` and `agentStore.ts`) provide examples of how to implement global state management with Zustand for the Claudia application.
|
||||||
|
|
||||||
|
## Key Benefits:
|
||||||
|
- Eliminates prop drilling across components
|
||||||
|
- Centralizes state management
|
||||||
|
- Provides optimized selectors for performance
|
||||||
|
- Handles real-time updates efficiently
|
||||||
|
|
||||||
|
## Implementation Status:
|
||||||
|
These stores are example implementations that would need to be adapted to match the actual API interface. The current API in `lib/api.ts` has different method names and signatures than what was assumed in the store implementations.
|
||||||
|
|
||||||
|
## To Complete Implementation:
|
||||||
|
1. Update the store methods to match actual API methods
|
||||||
|
2. Add proper TypeScript types from the API
|
||||||
|
3. Implement WebSocket/SSE for real-time updates
|
||||||
|
4. Connect stores to components using the custom selectors
|
||||||
|
|
||||||
|
## Example Usage:
|
||||||
|
```typescript
|
||||||
|
import { useSessionStore } from '@/stores/sessionStore';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { sessions, fetchSessions } = useSessionStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSessions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div>{sessions.length} sessions</div>;
|
||||||
|
}
|
||||||
|
```
|
232
src/stores/agentStore.ts
Normal file
232
src/stores/agentStore.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { subscribeWithSelector } from 'zustand/middleware';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { AgentRunWithMetrics } from '@/lib/api';
|
||||||
|
|
||||||
|
interface AgentState {
|
||||||
|
// Agent runs data
|
||||||
|
agentRuns: AgentRunWithMetrics[];
|
||||||
|
runningAgents: Set<string>;
|
||||||
|
sessionOutputs: Record<string, string>;
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
isLoadingRuns: boolean;
|
||||||
|
isLoadingOutput: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastFetchTime: number;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchAgentRuns: (forceRefresh?: boolean) => Promise<void>;
|
||||||
|
fetchSessionOutput: (runId: number) => Promise<void>;
|
||||||
|
createAgentRun: (data: { agentId: number; projectPath: string; task: string; model?: string }) => Promise<AgentRunWithMetrics>;
|
||||||
|
cancelAgentRun: (runId: number) => Promise<void>;
|
||||||
|
deleteAgentRun: (runId: number) => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
|
||||||
|
// Real-time updates
|
||||||
|
handleAgentRunUpdate: (run: AgentRunWithMetrics) => void;
|
||||||
|
|
||||||
|
// Polling management
|
||||||
|
startPolling: (interval?: number) => void;
|
||||||
|
stopPolling: () => void;
|
||||||
|
pollingInterval: NodeJS.Timeout | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAgentStore = create<AgentState>()(
|
||||||
|
subscribeWithSelector((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
agentRuns: [],
|
||||||
|
runningAgents: new Set(),
|
||||||
|
sessionOutputs: {},
|
||||||
|
isLoadingRuns: false,
|
||||||
|
isLoadingOutput: false,
|
||||||
|
error: null,
|
||||||
|
lastFetchTime: 0,
|
||||||
|
pollingInterval: null,
|
||||||
|
|
||||||
|
// Fetch agent runs with caching
|
||||||
|
fetchAgentRuns: async (forceRefresh = false) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const { lastFetchTime } = get();
|
||||||
|
|
||||||
|
// Cache for 5 seconds unless forced
|
||||||
|
if (!forceRefresh && now - lastFetchTime < 5000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoadingRuns: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runs = await api.listAgentRuns();
|
||||||
|
const runningIds = runs
|
||||||
|
.filter(r => r.status === 'running' || r.status === 'pending')
|
||||||
|
.map(r => r.id?.toString() || '')
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
set({
|
||||||
|
agentRuns: runs,
|
||||||
|
runningAgents: new Set(runningIds),
|
||||||
|
isLoadingRuns: false,
|
||||||
|
lastFetchTime: now
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch agent runs',
|
||||||
|
isLoadingRuns: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fetch session output for a specific run
|
||||||
|
fetchSessionOutput: async (runId: number) => {
|
||||||
|
set({ isLoadingOutput: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = await api.getAgentRunWithRealTimeMetrics(runId).then(run => run.output || '');
|
||||||
|
set(state => ({
|
||||||
|
sessionOutputs: {
|
||||||
|
...state.sessionOutputs,
|
||||||
|
[runId]: output
|
||||||
|
},
|
||||||
|
isLoadingOutput: false
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch session output',
|
||||||
|
isLoadingOutput: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create a new agent run
|
||||||
|
createAgentRun: async (data: { agentId: number; projectPath: string; task: string; model?: string }) => {
|
||||||
|
try {
|
||||||
|
const runId = await api.executeAgent(data.agentId, data.projectPath, data.task, data.model);
|
||||||
|
|
||||||
|
// Fetch the created run details
|
||||||
|
const run = await api.getAgentRun(runId);
|
||||||
|
|
||||||
|
// Update local state immediately
|
||||||
|
set(state => ({
|
||||||
|
agentRuns: [run, ...state.agentRuns],
|
||||||
|
runningAgents: new Set([...state.runningAgents, runId.toString()])
|
||||||
|
}));
|
||||||
|
|
||||||
|
return run;
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to create agent run'
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cancel an agent run
|
||||||
|
cancelAgentRun: async (runId: number) => {
|
||||||
|
try {
|
||||||
|
await api.killAgentSession(runId);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
set(state => ({
|
||||||
|
agentRuns: state.agentRuns.map(r =>
|
||||||
|
r.id === runId ? { ...r, status: 'cancelled' } : r
|
||||||
|
),
|
||||||
|
runningAgents: new Set(
|
||||||
|
[...state.runningAgents].filter(id => id !== runId.toString())
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to cancel agent run'
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete an agent run
|
||||||
|
deleteAgentRun: async (runId: number) => {
|
||||||
|
try {
|
||||||
|
// First ensure the run is cancelled if it's still running
|
||||||
|
const run = get().agentRuns.find(r => r.id === runId);
|
||||||
|
if (run && (run.status === 'running' || run.status === 'pending')) {
|
||||||
|
await api.killAgentSession(runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: There's no deleteAgentRun API method, so we just remove from local state
|
||||||
|
// The run will remain in the database but won't be shown in the UI
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
set(state => ({
|
||||||
|
agentRuns: state.agentRuns.filter(r => r.id !== runId),
|
||||||
|
runningAgents: new Set(
|
||||||
|
[...state.runningAgents].filter(id => id !== runId.toString())
|
||||||
|
),
|
||||||
|
sessionOutputs: Object.fromEntries(
|
||||||
|
Object.entries(state.sessionOutputs).filter(([id]) => id !== runId.toString())
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete agent run'
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear error
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
// Handle real-time agent run updates
|
||||||
|
handleAgentRunUpdate: (run: AgentRunWithMetrics) => {
|
||||||
|
set(state => {
|
||||||
|
const existingIndex = state.agentRuns.findIndex(r => r.id === run.id);
|
||||||
|
const updatedRuns = [...state.agentRuns];
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
updatedRuns[existingIndex] = run;
|
||||||
|
} else {
|
||||||
|
updatedRuns.unshift(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningIds = updatedRuns
|
||||||
|
.filter(r => r.status === 'running' || r.status === 'pending')
|
||||||
|
.map(r => r.id?.toString() || '')
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentRuns: updatedRuns,
|
||||||
|
runningAgents: new Set(runningIds)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Start polling for updates
|
||||||
|
startPolling: (interval = 3000) => {
|
||||||
|
const { pollingInterval, stopPolling } = get();
|
||||||
|
|
||||||
|
// Clear existing interval
|
||||||
|
if (pollingInterval) {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new interval
|
||||||
|
const newInterval = setInterval(() => {
|
||||||
|
const { runningAgents } = get();
|
||||||
|
if (runningAgents.size > 0) {
|
||||||
|
get().fetchAgentRuns();
|
||||||
|
}
|
||||||
|
}, interval);
|
||||||
|
|
||||||
|
set({ pollingInterval: newInterval });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stop polling
|
||||||
|
stopPolling: () => {
|
||||||
|
const { pollingInterval } = get();
|
||||||
|
if (pollingInterval) {
|
||||||
|
clearInterval(pollingInterval);
|
||||||
|
set({ pollingInterval: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
);
|
183
src/stores/sessionStore.ts
Normal file
183
src/stores/sessionStore.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { subscribeWithSelector } from 'zustand/middleware';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Session, Project } from '@/lib/api';
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
// Projects and sessions data
|
||||||
|
projects: Project[];
|
||||||
|
sessions: Record<string, Session[]>; // Keyed by projectId
|
||||||
|
currentSessionId: string | null;
|
||||||
|
currentSession: Session | null;
|
||||||
|
sessionOutputs: Record<string, string>; // Keyed by sessionId
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
isLoadingProjects: boolean;
|
||||||
|
isLoadingSessions: boolean;
|
||||||
|
isLoadingOutputs: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchProjects: () => Promise<void>;
|
||||||
|
fetchProjectSessions: (projectId: string) => Promise<void>;
|
||||||
|
setCurrentSession: (sessionId: string | null) => void;
|
||||||
|
fetchSessionOutput: (sessionId: string) => Promise<void>;
|
||||||
|
deleteSession: (sessionId: string, projectId: string) => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
|
||||||
|
// Real-time updates
|
||||||
|
handleSessionUpdate: (session: Session) => void;
|
||||||
|
handleOutputUpdate: (sessionId: string, output: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSessionStore = create<SessionState>()(
|
||||||
|
subscribeWithSelector((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
projects: [],
|
||||||
|
sessions: {},
|
||||||
|
currentSessionId: null,
|
||||||
|
currentSession: null,
|
||||||
|
sessionOutputs: {},
|
||||||
|
isLoadingProjects: false,
|
||||||
|
isLoadingSessions: false,
|
||||||
|
isLoadingOutputs: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Fetch all projects
|
||||||
|
fetchProjects: async () => {
|
||||||
|
set({ isLoadingProjects: true, error: null });
|
||||||
|
try {
|
||||||
|
const projects = await api.listProjects();
|
||||||
|
set({ projects, isLoadingProjects: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch projects',
|
||||||
|
isLoadingProjects: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fetch sessions for a specific project
|
||||||
|
fetchProjectSessions: async (projectId: string) => {
|
||||||
|
set({ isLoadingSessions: true, error: null });
|
||||||
|
try {
|
||||||
|
const projectSessions = await api.getProjectSessions(projectId);
|
||||||
|
set(state => ({
|
||||||
|
sessions: {
|
||||||
|
...state.sessions,
|
||||||
|
[projectId]: projectSessions
|
||||||
|
},
|
||||||
|
isLoadingSessions: false
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch sessions',
|
||||||
|
isLoadingSessions: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Set current session
|
||||||
|
setCurrentSession: (sessionId: string | null) => {
|
||||||
|
const { sessions } = get();
|
||||||
|
let currentSession: Session | null = null;
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
// Find session across all projects
|
||||||
|
for (const projectSessions of Object.values(sessions)) {
|
||||||
|
const found = projectSessions.find(s => s.id === sessionId);
|
||||||
|
if (found) {
|
||||||
|
currentSession = found;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ currentSessionId: sessionId, currentSession });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fetch session output
|
||||||
|
fetchSessionOutput: async (sessionId: string) => {
|
||||||
|
set({ isLoadingOutputs: true, error: null });
|
||||||
|
try {
|
||||||
|
const output = await api.getClaudeSessionOutput(sessionId);
|
||||||
|
set(state => ({
|
||||||
|
sessionOutputs: {
|
||||||
|
...state.sessionOutputs,
|
||||||
|
[sessionId]: output
|
||||||
|
},
|
||||||
|
isLoadingOutputs: false
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch session output',
|
||||||
|
isLoadingOutputs: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete session
|
||||||
|
deleteSession: async (sessionId: string, projectId: string) => {
|
||||||
|
try {
|
||||||
|
// Note: API doesn't have a deleteSession method, so this is a placeholder
|
||||||
|
console.warn('deleteSession not implemented in API');
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
set(state => ({
|
||||||
|
sessions: {
|
||||||
|
...state.sessions,
|
||||||
|
[projectId]: state.sessions[projectId]?.filter(s => s.id !== sessionId) || []
|
||||||
|
},
|
||||||
|
currentSessionId: state.currentSessionId === sessionId ? null : state.currentSessionId,
|
||||||
|
currentSession: state.currentSession?.id === sessionId ? null : state.currentSession,
|
||||||
|
sessionOutputs: Object.fromEntries(
|
||||||
|
Object.entries(state.sessionOutputs).filter(([id]) => id !== sessionId)
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to delete session'
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear error
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
|
||||||
|
// Handle session update
|
||||||
|
handleSessionUpdate: (session: Session) => {
|
||||||
|
set(state => {
|
||||||
|
const projectId = session.project_id;
|
||||||
|
const projectSessions = state.sessions[projectId] || [];
|
||||||
|
const existingIndex = projectSessions.findIndex(s => s.id === session.id);
|
||||||
|
|
||||||
|
let updatedSessions;
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
updatedSessions = [...projectSessions];
|
||||||
|
updatedSessions[existingIndex] = session;
|
||||||
|
} else {
|
||||||
|
updatedSessions = [session, ...projectSessions];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions: {
|
||||||
|
...state.sessions,
|
||||||
|
[projectId]: updatedSessions
|
||||||
|
},
|
||||||
|
currentSession: state.currentSessionId === session.id ? session : state.currentSession
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Handle output update
|
||||||
|
handleOutputUpdate: (sessionId: string, output: string) => {
|
||||||
|
set(state => ({
|
||||||
|
sessionOutputs: {
|
||||||
|
...state.sessionOutputs,
|
||||||
|
[sessionId]: output
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
);
|
Reference in New Issue
Block a user