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:
Vivek R
2025-07-16 20:01:10 +05:30
parent 9887b9d14a
commit b812d9ff05
5 changed files with 5844 additions and 1 deletions

5394
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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