feat: Add intelligent auto-router and enhanced integrations

- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,237 @@
# WebUI State Management Architecture
## Hybrid Context + Zustand Approach
The WebUI uses a **hybrid architecture** combining React Context and Zustand stores. This isn't redundant—each serves a distinct purpose.
## Why Zustand?
### The Key Reason: Event Handlers Run Outside React
```typescript
// Event handlers are plain functions, not React components
function handleLLMThinking(event: EventByName<'llm:thinking'>): void {
const { sessionId } = event;
// ✅ Imperative access - works outside React
useChatStore.getState().setProcessing(sessionId, true);
useAgentStore.getState().setThinking(sessionId);
}
```
**Event handlers can't use React hooks.** They need imperative state access from outside the component tree. Zustand provides this through `.getState()`.
With React Context, you'd need hacky global variables or complex callback registration—exactly what Zustand does, but type-safe and battle-tested.
### Secondary Benefits
1. **Granular subscriptions** - Components only re-render when their specific slice changes
2. **Multi-session state** - Efficient Map-based per-session storage
3. **No provider hell** - No need for nested provider components
4. **DevTools** - Time-travel debugging and state inspection
## Architecture Pattern
```
┌─────────────────────────────────────────┐
│ React Components │
│ - Use ChatContext for actions │
│ - Use Zustand stores for state │
└────────────┬────────────────────────────┘
┌──────┴──────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ Context │ │ Zustand │
│ │ │ │
│ Actions │ │ State │
│ Hooks │ │ │
│ Query │ │ Pure │
└──────────┘ └────┬─────┘
┌────────┴────────┐
│ Event Handlers │
│ (outside React) │
└─────────────────┘
```
## When to Use What
### Use Zustand Stores For:
- ✅ State that needs access from **outside React** (event handlers, middleware)
-**Per-session state** (messages, errors, processing)
-**High-frequency updates** (streaming, real-time events)
- ✅ State accessed by **many components** (current session, notifications)
**Examples**: `chatStore`, `sessionStore`, `approvalStore`, `notificationStore`, `agentStore`
### Use React Context For:
-**React-specific orchestration** (combining hooks, effects, callbacks)
-**API integration** (TanStack Query, mutations)
-**Lifecycle management** (connection setup, cleanup)
-**Derived state** that depends on multiple sources
**Examples**: `ChatContext`, `EventBusProvider`
## Store Organization
```
lib/stores/
├── README.md # This file
├── index.ts # Barrel exports
├── chatStore.ts # Per-session messages, streaming, errors
├── sessionStore.ts # Current session, navigation state
├── approvalStore.ts # Approval requests with queueing
├── notificationStore.ts # Toast notifications
├── agentStore.ts # Agent status, connection, heartbeat
└── eventLogStore.ts # Event history for debugging
```
## Usage Patterns
### Reading State in Components
```typescript
import { useChatStore } from '@/lib/stores/chatStore';
import { useSessionStore } from '@/lib/stores/sessionStore';
function ChatApp() {
// Granular subscription - only re-renders when messages change
const messages = useChatStore((s) => {
if (!currentSessionId) return EMPTY_MESSAGES;
const session = s.sessions.get(currentSessionId);
return session?.messages ?? EMPTY_MESSAGES;
});
// Simple value access
const currentSessionId = useSessionStore((s) => s.currentSessionId);
// ...
}
```
### Updating State from Event Handlers
```typescript
import { useChatStore } from '@/lib/stores/chatStore';
import { useAgentStore } from '@/lib/stores/agentStore';
function handleLLMChunk(event: EventByName<'llm:chunk'>): void {
const { sessionId, content, chunkType } = event;
// Imperative access from outside React
const chatStore = useChatStore.getState();
chatStore.appendToStreamingMessage(sessionId, content, chunkType);
}
```
### Updating State from Context/Components
```typescript
// In ChatContext or components with hooks
const setProcessing = useCallback((sessionId: string, isProcessing: boolean) => {
useChatStore.getState().setProcessing(sessionId, isProcessing);
}, []);
```
## Event Flow
```
SSE Stream (server)
useChat.ts (line 219)
eventBus.dispatch(event)
Middleware Pipeline (logging, activity, notifications)
Event Handlers (handlers.ts)
Zustand Stores (.getState() imperative updates)
React Components (via selectors, triggers re-render)
```
## Best Practices
### 1. Use Stable References for Empty Arrays
```typescript
// ✅ DO: Prevents infinite re-render loops
const EMPTY_MESSAGES: Message[] = [];
const messages = useChatStore((s) => {
if (!currentSessionId) return EMPTY_MESSAGES;
return s.sessions.get(currentSessionId)?.messages ?? EMPTY_MESSAGES;
});
// ❌ DON'T: Creates new array reference on every render
const messages = useChatStore((s) => {
if (!currentSessionId) return []; // New reference each time!
return s.sessions.get(currentSessionId)?.messages ?? [];
});
```
### 2. Selector Efficiency
```typescript
// ✅ DO: Narrow selectors for specific data
const processing = useChatStore((s) => {
const session = s.sessions.get(currentSessionId);
return session?.processing ?? false;
});
// ❌ DON'T: Selecting entire store triggers unnecessary re-renders
const store = useChatStore(); // Re-renders on any store change!
const processing = store.sessions.get(currentSessionId)?.processing;
```
### 3. Imperative vs Hook Usage
```typescript
// ✅ In React components - use hook
function MyComponent() {
const messages = useChatStore((s) => s.getMessages(sessionId));
// ...
}
// ✅ In event handlers - use .getState()
function handleEvent(event) {
useChatStore.getState().addMessage(sessionId, message);
}
// ❌ DON'T: Use hooks outside React
function handleEvent(event) {
const store = useChatStore(); // ❌ Can't use hooks here!
}
```
## Testing
All stores have comprehensive test coverage. See `*.test.ts` files for examples:
```typescript
import { useChatStore } from './chatStore';
describe('chatStore', () => {
beforeEach(() => {
useChatStore.setState({ sessions: new Map() });
});
it('should add message to session', () => {
const store = useChatStore.getState();
store.addMessage('session-1', { id: 'msg-1', ... });
const messages = store.getMessages('session-1');
expect(messages).toHaveLength(1);
});
});
```
## Related Documentation
- Event system: `packages/webui/lib/events/README.md`
- Event handlers: `packages/webui/lib/events/handlers.ts`
- Event middleware: `packages/webui/lib/events/middleware/`
- Main architecture: `/docs` (to be updated)

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { useAgentStore } from './agentStore.js';
describe('agentStore', () => {
beforeEach(() => {
// Reset store to default state
useAgentStore.setState({
status: 'idle',
connectionStatus: 'disconnected',
lastHeartbeat: null,
activeSessionId: null,
currentToolName: null,
connectionError: null,
reconnectAttempts: 0,
});
});
afterEach(() => {
vi.useRealTimers();
});
describe('status actions', () => {
it('should set status with setStatus', () => {
useAgentStore.getState().setStatus('thinking', 'session-1');
expect(useAgentStore.getState().status).toBe('thinking');
expect(useAgentStore.getState().activeSessionId).toBe('session-1');
});
it('should clear activeSessionId when setting to idle', () => {
useAgentStore.getState().setStatus('thinking', 'session-1');
useAgentStore.getState().setStatus('idle');
expect(useAgentStore.getState().activeSessionId).toBeNull();
});
it('should set thinking status', () => {
useAgentStore.getState().setThinking('session-1');
expect(useAgentStore.getState().status).toBe('thinking');
expect(useAgentStore.getState().activeSessionId).toBe('session-1');
expect(useAgentStore.getState().currentToolName).toBeNull();
});
it('should set executing tool status with tool name', () => {
useAgentStore.getState().setExecutingTool('session-1', 'read_file');
expect(useAgentStore.getState().status).toBe('executing_tool');
expect(useAgentStore.getState().activeSessionId).toBe('session-1');
expect(useAgentStore.getState().currentToolName).toBe('read_file');
});
it('should set awaiting approval status', () => {
useAgentStore.getState().setAwaitingApproval('session-1');
expect(useAgentStore.getState().status).toBe('awaiting_approval');
expect(useAgentStore.getState().activeSessionId).toBe('session-1');
});
it('should set idle and clear all', () => {
useAgentStore.getState().setExecutingTool('session-1', 'bash');
useAgentStore.getState().setIdle();
expect(useAgentStore.getState().status).toBe('idle');
expect(useAgentStore.getState().activeSessionId).toBeNull();
expect(useAgentStore.getState().currentToolName).toBeNull();
});
it('should clear tool name when transitioning from executing_tool to other status', () => {
useAgentStore.getState().setExecutingTool('session-1', 'bash');
useAgentStore.getState().setThinking('session-1');
expect(useAgentStore.getState().currentToolName).toBeNull();
});
});
describe('connection actions', () => {
it('should set connection status', () => {
useAgentStore.getState().setConnectionStatus('connected');
expect(useAgentStore.getState().connectionStatus).toBe('connected');
});
it('should handle setConnected', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01'));
useAgentStore.setState({
connectionError: 'Previous error',
reconnectAttempts: 5,
});
useAgentStore.getState().setConnected();
expect(useAgentStore.getState().connectionStatus).toBe('connected');
expect(useAgentStore.getState().connectionError).toBeNull();
expect(useAgentStore.getState().reconnectAttempts).toBe(0);
expect(useAgentStore.getState().lastHeartbeat).toBe(Date.now());
});
it('should handle setDisconnected without error', () => {
useAgentStore.getState().setDisconnected();
expect(useAgentStore.getState().connectionStatus).toBe('disconnected');
expect(useAgentStore.getState().connectionError).toBeNull();
});
it('should handle setDisconnected with error', () => {
useAgentStore.getState().setDisconnected('Network error');
expect(useAgentStore.getState().connectionStatus).toBe('disconnected');
expect(useAgentStore.getState().connectionError).toBe('Network error');
});
it('should handle setReconnecting', () => {
useAgentStore.getState().setReconnecting();
expect(useAgentStore.getState().connectionStatus).toBe('reconnecting');
});
it('should update heartbeat timestamp', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00'));
useAgentStore.getState().updateHeartbeat();
expect(useAgentStore.getState().lastHeartbeat).toBe(Date.now());
});
it('should increment reconnect attempts', () => {
useAgentStore.getState().incrementReconnectAttempts();
expect(useAgentStore.getState().reconnectAttempts).toBe(1);
useAgentStore.getState().incrementReconnectAttempts();
expect(useAgentStore.getState().reconnectAttempts).toBe(2);
});
it('should reset reconnect attempts', () => {
useAgentStore.setState({ reconnectAttempts: 5 });
useAgentStore.getState().resetReconnectAttempts();
expect(useAgentStore.getState().reconnectAttempts).toBe(0);
});
});
describe('selectors', () => {
it('isBusy should return true when not idle', () => {
useAgentStore.getState().setThinking('session-1');
expect(useAgentStore.getState().isBusy()).toBe(true);
});
it('isBusy should return false when idle', () => {
expect(useAgentStore.getState().isBusy()).toBe(false);
});
it('isConnected should return true when connected', () => {
useAgentStore.getState().setConnected();
expect(useAgentStore.getState().isConnected()).toBe(true);
});
it('isConnected should return false when disconnected', () => {
expect(useAgentStore.getState().isConnected()).toBe(false);
});
it('isConnected should return false when reconnecting', () => {
useAgentStore.getState().setReconnecting();
expect(useAgentStore.getState().isConnected()).toBe(false);
});
it('isActiveForSession should return true for matching session', () => {
useAgentStore.getState().setThinking('session-1');
expect(useAgentStore.getState().isActiveForSession('session-1')).toBe(true);
});
it('isActiveForSession should return false for different session', () => {
useAgentStore.getState().setThinking('session-1');
expect(useAgentStore.getState().isActiveForSession('session-2')).toBe(false);
});
it('isActiveForSession should return false when idle', () => {
expect(useAgentStore.getState().isActiveForSession('session-1')).toBe(false);
});
it('getHeartbeatAge should return null when no heartbeat', () => {
expect(useAgentStore.getState().getHeartbeatAge()).toBeNull();
});
it('getHeartbeatAge should return age in milliseconds', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00'));
useAgentStore.getState().updateHeartbeat();
// Advance time by 5 seconds
vi.advanceTimersByTime(5000);
expect(useAgentStore.getState().getHeartbeatAge()).toBe(5000);
});
});
describe('status transitions', () => {
it('should handle full lifecycle: idle -> thinking -> executing -> idle', () => {
expect(useAgentStore.getState().status).toBe('idle');
useAgentStore.getState().setThinking('session-1');
expect(useAgentStore.getState().status).toBe('thinking');
useAgentStore.getState().setExecutingTool('session-1', 'read_file');
expect(useAgentStore.getState().status).toBe('executing_tool');
useAgentStore.getState().setIdle();
expect(useAgentStore.getState().status).toBe('idle');
});
it('should handle approval flow: thinking -> awaiting_approval -> idle', () => {
useAgentStore.getState().setThinking('session-1');
useAgentStore.getState().setAwaitingApproval('session-1');
expect(useAgentStore.getState().status).toBe('awaiting_approval');
// After approval, back to thinking or idle
useAgentStore.getState().setThinking('session-1');
expect(useAgentStore.getState().status).toBe('thinking');
});
});
});

View File

@@ -0,0 +1,298 @@
/**
* Agent Store
*
* Manages the agent's status and connection state.
* This is global state (not per-session) as there's one agent connection.
*/
import { create } from 'zustand';
// =============================================================================
// Types
// =============================================================================
/**
* Agent's current activity status
*/
export type AgentStatus =
| 'idle' // Ready for input
| 'thinking' // Processing/generating response
| 'executing_tool' // Running a tool
| 'awaiting_approval'; // Waiting for user approval
/**
* Connection status to the backend
*/
export type ConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';
/**
* Agent state
*/
export interface AgentState {
/**
* Current agent activity status
*/
status: AgentStatus;
/**
* Connection status to the backend
*/
connectionStatus: ConnectionStatus;
/**
* Timestamp of last heartbeat (for connection health monitoring)
*/
lastHeartbeat: number | null;
/**
* Currently active session for the agent (for status context)
*/
activeSessionId: string | null;
/**
* Name of the tool currently being executed (if any)
*/
currentToolName: string | null;
/**
* Error message if connection failed
*/
connectionError: string | null;
/**
* Number of reconnection attempts
*/
reconnectAttempts: number;
}
// =============================================================================
// Store Interface
// =============================================================================
interface AgentStore extends AgentState {
// -------------------------------------------------------------------------
// Status Actions
// -------------------------------------------------------------------------
/**
* Set the agent's activity status
*/
setStatus: (status: AgentStatus, sessionId?: string) => void;
/**
* Set status to thinking
*/
setThinking: (sessionId: string) => void;
/**
* Set status to executing tool
*/
setExecutingTool: (sessionId: string, toolName: string) => void;
/**
* Set status to awaiting approval
*/
setAwaitingApproval: (sessionId: string) => void;
/**
* Set status to idle
*/
setIdle: () => void;
// -------------------------------------------------------------------------
// Connection Actions
// -------------------------------------------------------------------------
/**
* Set the connection status
*/
setConnectionStatus: (status: ConnectionStatus) => void;
/**
* Mark connection as established
*/
setConnected: () => void;
/**
* Mark connection as lost
*/
setDisconnected: (error?: string) => void;
/**
* Mark as attempting reconnection
*/
setReconnecting: () => void;
/**
* Update the heartbeat timestamp
*/
updateHeartbeat: () => void;
/**
* Increment reconnection attempt counter
*/
incrementReconnectAttempts: () => void;
/**
* Reset reconnection attempt counter
*/
resetReconnectAttempts: () => void;
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
/**
* Check if the agent is busy (not idle)
*/
isBusy: () => boolean;
/**
* Check if connected
*/
isConnected: () => boolean;
/**
* Check if the agent is working on a specific session
*/
isActiveForSession: (sessionId: string) => boolean;
/**
* Get time since last heartbeat (ms), or null if no heartbeat
*/
getHeartbeatAge: () => number | null;
}
// =============================================================================
// Default State
// =============================================================================
const defaultState: AgentState = {
status: 'idle',
connectionStatus: 'disconnected',
lastHeartbeat: null,
activeSessionId: null,
currentToolName: null,
connectionError: null,
reconnectAttempts: 0,
};
// =============================================================================
// Store Implementation
// =============================================================================
export const useAgentStore = create<AgentStore>()((set, get) => ({
...defaultState,
// -------------------------------------------------------------------------
// Status Actions
// -------------------------------------------------------------------------
setStatus: (status, sessionId) => {
set({
status,
activeSessionId: sessionId ?? (status === 'idle' ? null : get().activeSessionId),
// Clear tool name if not executing
currentToolName: status === 'executing_tool' ? get().currentToolName : null,
});
},
setThinking: (sessionId) => {
set({
status: 'thinking',
activeSessionId: sessionId,
currentToolName: null,
});
},
setExecutingTool: (sessionId, toolName) => {
set({
status: 'executing_tool',
activeSessionId: sessionId,
currentToolName: toolName,
});
},
setAwaitingApproval: (sessionId) => {
set({
status: 'awaiting_approval',
activeSessionId: sessionId,
currentToolName: null,
});
},
setIdle: () => {
set({
status: 'idle',
activeSessionId: null,
currentToolName: null,
});
},
// -------------------------------------------------------------------------
// Connection Actions
// -------------------------------------------------------------------------
setConnectionStatus: (status) => {
set({ connectionStatus: status });
},
setConnected: () => {
set({
connectionStatus: 'connected',
connectionError: null,
reconnectAttempts: 0,
lastHeartbeat: Date.now(),
});
},
setDisconnected: (error) => {
set({
connectionStatus: 'disconnected',
connectionError: error ?? null,
});
},
setReconnecting: () => {
set({
connectionStatus: 'reconnecting',
});
},
updateHeartbeat: () => {
set({ lastHeartbeat: Date.now() });
},
incrementReconnectAttempts: () => {
set((state) => ({
reconnectAttempts: state.reconnectAttempts + 1,
}));
},
resetReconnectAttempts: () => {
set({ reconnectAttempts: 0 });
},
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
isBusy: () => {
return get().status !== 'idle';
},
isConnected: () => {
return get().connectionStatus === 'connected';
},
isActiveForSession: (sessionId) => {
const state = get();
return state.status !== 'idle' && state.activeSessionId === sessionId;
},
getHeartbeatAge: () => {
const { lastHeartbeat } = get();
if (lastHeartbeat === null) return null;
return Date.now() - lastHeartbeat;
},
}));

View File

@@ -0,0 +1,331 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useApprovalStore } from './approvalStore.js';
import type { ApprovalRequest, ApprovalResponse } from '@dexto/core';
import { ApprovalType, ApprovalStatus } from '@dexto/core';
// Helper to create test approval requests
function createTestApprovalRequest(overrides: Partial<ApprovalRequest> = {}): ApprovalRequest {
return {
approvalId: `approval-${Math.random().toString(36).slice(2, 11)}`,
type: ApprovalType.TOOL_CONFIRMATION,
sessionId: 'test-session',
timeout: 30000,
timestamp: new Date(),
metadata: {
toolName: 'test_tool',
args: {},
},
...overrides,
} as ApprovalRequest;
}
// Helper to create test approval responses
function createTestApprovalResponse(
approvalId: string,
status: ApprovalStatus,
overrides: Partial<ApprovalResponse> = {}
): ApprovalResponse {
return {
approvalId,
status,
sessionId: 'test-session',
...overrides,
} as ApprovalResponse;
}
describe('approvalStore', () => {
beforeEach(() => {
// Reset store to default state
useApprovalStore.setState({
pendingApproval: null,
queue: [],
});
});
describe('addApproval', () => {
it('should set pendingApproval when empty', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
expect(useApprovalStore.getState().pendingApproval).toEqual(request);
expect(useApprovalStore.getState().queue).toHaveLength(0);
});
it('should queue when pendingApproval exists', () => {
const request1 = createTestApprovalRequest();
const request2 = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
expect(useApprovalStore.getState().pendingApproval).toEqual(request1);
expect(useApprovalStore.getState().queue).toHaveLength(1);
expect(useApprovalStore.getState().queue[0]).toEqual(request2);
});
it('should queue multiple requests in order', () => {
const request1 = createTestApprovalRequest();
const request2 = createTestApprovalRequest();
const request3 = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
useApprovalStore.getState().addApproval(request3);
expect(useApprovalStore.getState().pendingApproval).toEqual(request1);
expect(useApprovalStore.getState().queue).toHaveLength(2);
expect(useApprovalStore.getState().queue[0]).toEqual(request2);
expect(useApprovalStore.getState().queue[1]).toEqual(request3);
});
});
describe('processResponse', () => {
it('should clear pendingApproval for approved status', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
const response = createTestApprovalResponse(
request.approvalId,
ApprovalStatus.APPROVED
);
useApprovalStore.getState().processResponse(response);
expect(useApprovalStore.getState().pendingApproval).toBeNull();
});
it('should clear pendingApproval for denied status', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
const response = createTestApprovalResponse(request.approvalId, ApprovalStatus.DENIED);
useApprovalStore.getState().processResponse(response);
expect(useApprovalStore.getState().pendingApproval).toBeNull();
});
it('should clear pendingApproval for cancelled status', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
const response = createTestApprovalResponse(
request.approvalId,
ApprovalStatus.CANCELLED
);
useApprovalStore.getState().processResponse(response);
expect(useApprovalStore.getState().pendingApproval).toBeNull();
});
it('should process next in queue after terminal status', () => {
const request1 = createTestApprovalRequest();
const request2 = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
const response = createTestApprovalResponse(
request1.approvalId,
ApprovalStatus.APPROVED
);
useApprovalStore.getState().processResponse(response);
expect(useApprovalStore.getState().pendingApproval).toEqual(request2);
expect(useApprovalStore.getState().queue).toHaveLength(0);
});
it('should handle multiple queued items', () => {
const request1 = createTestApprovalRequest();
const request2 = createTestApprovalRequest();
const request3 = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
useApprovalStore.getState().addApproval(request3);
// Process first
const response1 = createTestApprovalResponse(
request1.approvalId,
ApprovalStatus.APPROVED
);
useApprovalStore.getState().processResponse(response1);
expect(useApprovalStore.getState().pendingApproval).toEqual(request2);
expect(useApprovalStore.getState().queue).toHaveLength(1);
expect(useApprovalStore.getState().queue[0]).toEqual(request3);
// Process second
const response2 = createTestApprovalResponse(
request2.approvalId,
ApprovalStatus.DENIED
);
useApprovalStore.getState().processResponse(response2);
expect(useApprovalStore.getState().pendingApproval).toEqual(request3);
expect(useApprovalStore.getState().queue).toHaveLength(0);
});
it('should not process response for mismatched approvalId', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
const response = createTestApprovalResponse('wrong-id', ApprovalStatus.APPROVED);
useApprovalStore.getState().processResponse(response);
expect(useApprovalStore.getState().pendingApproval).toEqual(request);
});
it('should handle response when no pending approval', () => {
const response = createTestApprovalResponse('some-id', ApprovalStatus.APPROVED);
useApprovalStore.getState().processResponse(response);
expect(useApprovalStore.getState().pendingApproval).toBeNull();
});
});
describe('clearApproval', () => {
it('should clear current and process next in queue', () => {
const request1 = createTestApprovalRequest();
const request2 = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
useApprovalStore.getState().clearApproval();
expect(useApprovalStore.getState().pendingApproval).toEqual(request2);
expect(useApprovalStore.getState().queue).toHaveLength(0);
});
it('should set pendingApproval to null when queue is empty', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
useApprovalStore.getState().clearApproval();
expect(useApprovalStore.getState().pendingApproval).toBeNull();
});
it('should handle clearApproval when nothing is pending', () => {
useApprovalStore.getState().clearApproval();
expect(useApprovalStore.getState().pendingApproval).toBeNull();
expect(useApprovalStore.getState().queue).toHaveLength(0);
});
});
describe('clearAll', () => {
it('should clear everything', () => {
const request1 = createTestApprovalRequest();
const request2 = createTestApprovalRequest();
const request3 = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
useApprovalStore.getState().addApproval(request3);
useApprovalStore.getState().clearAll();
expect(useApprovalStore.getState().pendingApproval).toBeNull();
expect(useApprovalStore.getState().queue).toHaveLength(0);
});
});
describe('selectors', () => {
describe('getPendingCount', () => {
it('should return 0 when nothing is pending', () => {
expect(useApprovalStore.getState().getPendingCount()).toBe(0);
});
it('should return 1 when only pendingApproval exists', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
expect(useApprovalStore.getState().getPendingCount()).toBe(1);
});
it('should return correct count with queue', () => {
const request1 = createTestApprovalRequest();
const request2 = createTestApprovalRequest();
const request3 = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
useApprovalStore.getState().addApproval(request3);
expect(useApprovalStore.getState().getPendingCount()).toBe(3);
});
});
describe('getPendingForSession', () => {
it('should return empty array for session with no approvals', () => {
const result = useApprovalStore.getState().getPendingForSession('session-1');
expect(result).toHaveLength(0);
});
it('should return only approvals for specified session', () => {
const request1 = createTestApprovalRequest({ sessionId: 'session-1' });
const request2 = createTestApprovalRequest({ sessionId: 'session-2' });
const request3 = createTestApprovalRequest({ sessionId: 'session-1' });
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
useApprovalStore.getState().addApproval(request3);
const result = useApprovalStore.getState().getPendingForSession('session-1');
expect(result).toHaveLength(2);
expect(result[0]).toEqual(request1);
expect(result[1]).toEqual(request3);
});
it('should include both pending and queued for session', () => {
const request1 = createTestApprovalRequest({ sessionId: 'session-1' });
const request2 = createTestApprovalRequest({ sessionId: 'session-1' });
useApprovalStore.getState().addApproval(request1);
useApprovalStore.getState().addApproval(request2);
const result = useApprovalStore.getState().getPendingForSession('session-1');
expect(result).toHaveLength(2);
});
});
describe('hasPendingApproval', () => {
it('should return false when nothing is pending', () => {
expect(useApprovalStore.getState().hasPendingApproval()).toBe(false);
});
it('should return true when approval is pending', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
expect(useApprovalStore.getState().hasPendingApproval()).toBe(true);
});
it('should return false after clearing', () => {
const request = createTestApprovalRequest();
useApprovalStore.getState().addApproval(request);
useApprovalStore.getState().clearApproval();
expect(useApprovalStore.getState().hasPendingApproval()).toBe(false);
});
});
});
describe('session isolation', () => {
it('should keep different sessions separate in queue', () => {
const session1Request1 = createTestApprovalRequest({ sessionId: 'session-1' });
const session2Request1 = createTestApprovalRequest({ sessionId: 'session-2' });
const session1Request2 = createTestApprovalRequest({ sessionId: 'session-1' });
useApprovalStore.getState().addApproval(session1Request1);
useApprovalStore.getState().addApproval(session2Request1);
useApprovalStore.getState().addApproval(session1Request2);
const session1Pending = useApprovalStore.getState().getPendingForSession('session-1');
const session2Pending = useApprovalStore.getState().getPendingForSession('session-2');
expect(session1Pending).toHaveLength(2);
expect(session2Pending).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,158 @@
/**
* Approval Store
*
* Manages approval requests from the agent using Zustand.
* Handles queueing when multiple approvals arrive simultaneously.
*
* Flow:
* 1. approval:request event → addApproval() → sets pendingApproval or queues
* 2. User responds via UI → sends response to agent
* 3. approval:response event → processResponse() → clears and processes next
*/
import { create } from 'zustand';
import { ApprovalStatus } from '@dexto/core';
import type { ApprovalRequest, ApprovalResponse } from '@dexto/core';
export interface PendingApproval {
request: ApprovalRequest;
timestamp: number;
}
interface ApprovalStore {
// Current pending approval being displayed
pendingApproval: ApprovalRequest | null;
// Queue of pending approvals waiting to be displayed
queue: ApprovalRequest[];
// Actions
addApproval: (request: ApprovalRequest) => void;
processResponse: (response: ApprovalResponse) => void;
clearApproval: () => void;
clearAll: () => void;
// Selectors
getPendingCount: () => number;
getPendingForSession: (sessionId: string) => ApprovalRequest[];
hasPendingApproval: () => boolean;
}
/**
* Check if approval response status is terminal (ends the approval)
*/
function isTerminalStatus(status: ApprovalStatus): boolean {
return (
status === ApprovalStatus.APPROVED ||
status === ApprovalStatus.DENIED ||
status === ApprovalStatus.CANCELLED
);
}
export const useApprovalStore = create<ApprovalStore>((set, get) => ({
pendingApproval: null,
queue: [],
/**
* Add a new approval request
* If there's already a pending approval, queue it
*/
addApproval: (request: ApprovalRequest) => {
set((state) => {
// If there's already a pending approval, add to queue
if (state.pendingApproval) {
return {
queue: [...state.queue, request],
};
}
// Otherwise, set as pending
return {
pendingApproval: request,
};
});
},
/**
* Process an approval response
* If status is terminal (approved/denied/cancelled), clear pending and process next
*/
processResponse: (response: ApprovalResponse) => {
set((state) => {
// Only process if this response matches the current pending approval
if (state.pendingApproval?.approvalId !== response.approvalId) {
return state;
}
// If terminal status, clear pending and process next
if (isTerminalStatus(response.status)) {
// Get next from queue
const [next, ...rest] = state.queue;
return {
pendingApproval: next ?? null,
queue: rest,
};
}
// Non-terminal status (e.g., future streaming updates), keep pending
return state;
});
},
/**
* Clear current approval and process next in queue
* Used for manual dismissal or timeout
*/
clearApproval: () => {
set((state) => {
const [next, ...rest] = state.queue;
return {
pendingApproval: next ?? null,
queue: rest,
};
});
},
/**
* Clear all pending approvals
* Used when switching sessions or resetting state
*/
clearAll: () => {
set({
pendingApproval: null,
queue: [],
});
},
/**
* Get total count of pending approvals (current + queued)
*/
getPendingCount: () => {
const state = get();
return (state.pendingApproval ? 1 : 0) + state.queue.length;
},
/**
* Get all pending approvals for a specific session
*/
getPendingForSession: (sessionId: string) => {
const state = get();
const results: ApprovalRequest[] = [];
if (state.pendingApproval?.sessionId === sessionId) {
results.push(state.pendingApproval);
}
results.push(...state.queue.filter((req) => req.sessionId === sessionId));
return results;
},
/**
* Check if there's a pending approval
*/
hasPendingApproval: () => {
return get().pendingApproval !== null;
},
}));

View File

@@ -0,0 +1,338 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useChatStore, generateMessageId, type Message } from './chatStore.js';
// Helper to create a test message
function createTestMessage(overrides: Partial<Message> = {}): Message {
return {
id: generateMessageId(),
role: 'user',
content: 'Test message',
createdAt: Date.now(),
...overrides,
};
}
describe('chatStore', () => {
const sessionId = 'test-session';
beforeEach(() => {
// Reset store to default state
useChatStore.setState({ sessions: new Map() });
});
describe('generateMessageId', () => {
it('should generate unique IDs', () => {
const id1 = generateMessageId();
const id2 = generateMessageId();
expect(id1).not.toBe(id2);
});
it('should start with msg- prefix', () => {
const id = generateMessageId();
expect(id.startsWith('msg-')).toBe(true);
});
});
describe('initSession', () => {
it('should initialize a session with default state', () => {
useChatStore.getState().initSession(sessionId);
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.messages).toEqual([]);
expect(state.streamingMessage).toBeNull();
expect(state.processing).toBe(false);
expect(state.error).toBeNull();
expect(state.loadingHistory).toBe(false);
});
it('should not overwrite existing session', () => {
const message = createTestMessage();
useChatStore.getState().initSession(sessionId);
useChatStore.getState().addMessage(sessionId, message);
useChatStore.getState().initSession(sessionId);
const messages = useChatStore.getState().getMessages(sessionId);
expect(messages).toHaveLength(1);
});
});
describe('addMessage', () => {
it('should add a message to a session', () => {
const message = createTestMessage();
useChatStore.getState().addMessage(sessionId, message);
const messages = useChatStore.getState().getMessages(sessionId);
expect(messages).toHaveLength(1);
expect(messages[0]).toEqual(message);
});
it('should create session if not exists', () => {
const message = createTestMessage();
useChatStore.getState().addMessage('new-session', message);
const messages = useChatStore.getState().getMessages('new-session');
expect(messages).toHaveLength(1);
});
it('should append multiple messages in order', () => {
const msg1 = createTestMessage({ content: 'First' });
const msg2 = createTestMessage({ content: 'Second' });
useChatStore.getState().addMessage(sessionId, msg1);
useChatStore.getState().addMessage(sessionId, msg2);
const messages = useChatStore.getState().getMessages(sessionId);
expect(messages).toHaveLength(2);
expect(messages[0].content).toBe('First');
expect(messages[1].content).toBe('Second');
});
});
describe('updateMessage', () => {
it('should update an existing message', () => {
const message = createTestMessage({ content: 'Original' });
useChatStore.getState().addMessage(sessionId, message);
useChatStore.getState().updateMessage(sessionId, message.id, {
content: 'Updated',
});
const updated = useChatStore.getState().getMessage(sessionId, message.id);
expect(updated?.content).toBe('Updated');
});
it('should not modify state for non-existent message', () => {
const message = createTestMessage();
useChatStore.getState().addMessage(sessionId, message);
useChatStore.getState().updateMessage(sessionId, 'non-existent', {
content: 'Updated',
});
// State should be unchanged - original message still present
const messages = useChatStore.getState().getMessages(sessionId);
expect(messages).toHaveLength(1);
expect(messages[0].content).toBe(message.content);
});
it('should not modify state for non-existent session', () => {
useChatStore.getState().updateMessage('non-existent', 'msg-id', {
content: 'Updated',
});
expect(useChatStore.getState().sessions.has('non-existent')).toBe(false);
});
});
describe('removeMessage', () => {
it('should remove a message from a session', () => {
const message = createTestMessage();
useChatStore.getState().addMessage(sessionId, message);
useChatStore.getState().removeMessage(sessionId, message.id);
const messages = useChatStore.getState().getMessages(sessionId);
expect(messages).toHaveLength(0);
});
it('should not affect other messages', () => {
const msg1 = createTestMessage({ content: 'Keep' });
const msg2 = createTestMessage({ content: 'Remove' });
useChatStore.getState().addMessage(sessionId, msg1);
useChatStore.getState().addMessage(sessionId, msg2);
useChatStore.getState().removeMessage(sessionId, msg2.id);
const messages = useChatStore.getState().getMessages(sessionId);
expect(messages).toHaveLength(1);
expect(messages[0].content).toBe('Keep');
});
});
describe('clearMessages', () => {
it('should clear all messages in a session', () => {
useChatStore.getState().addMessage(sessionId, createTestMessage());
useChatStore.getState().addMessage(sessionId, createTestMessage());
useChatStore.getState().clearMessages(sessionId);
const messages = useChatStore.getState().getMessages(sessionId);
expect(messages).toHaveLength(0);
});
it('should also clear streaming message', () => {
const streaming = createTestMessage({ role: 'assistant' });
useChatStore.getState().setStreamingMessage(sessionId, streaming);
useChatStore.getState().clearMessages(sessionId);
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.streamingMessage).toBeNull();
});
});
describe('streaming message', () => {
it('should set streaming message', () => {
const message = createTestMessage({ role: 'assistant', content: '' });
useChatStore.getState().setStreamingMessage(sessionId, message);
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.streamingMessage).toEqual(message);
});
it('should clear streaming message', () => {
const message = createTestMessage({ role: 'assistant' });
useChatStore.getState().setStreamingMessage(sessionId, message);
useChatStore.getState().setStreamingMessage(sessionId, null);
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.streamingMessage).toBeNull();
});
it('should append text content to streaming message', () => {
const message = createTestMessage({ role: 'assistant', content: 'Hello' });
useChatStore.getState().setStreamingMessage(sessionId, message);
useChatStore.getState().appendToStreamingMessage(sessionId, ' World');
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.streamingMessage?.content).toBe('Hello World');
});
it('should append reasoning content to streaming message', () => {
const message = createTestMessage({ role: 'assistant', content: '', reasoning: '' });
useChatStore.getState().setStreamingMessage(sessionId, message);
useChatStore.getState().appendToStreamingMessage(sessionId, 'Thinking...', 'reasoning');
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.streamingMessage?.reasoning).toBe('Thinking...');
});
it('should not append if no streaming message', () => {
// Should not throw
useChatStore.getState().appendToStreamingMessage(sessionId, 'Test');
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.streamingMessage).toBeNull();
});
it('should finalize streaming message', () => {
const message = createTestMessage({ role: 'assistant', content: 'Response' });
useChatStore.getState().setStreamingMessage(sessionId, message);
useChatStore.getState().finalizeStreamingMessage(sessionId, {
tokenUsage: { totalTokens: 100 },
});
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.streamingMessage).toBeNull();
expect(state.messages).toHaveLength(1);
expect(state.messages[0].content).toBe('Response');
expect(state.messages[0].tokenUsage?.totalTokens).toBe(100);
});
it('should not finalize if no streaming message', () => {
useChatStore.getState().finalizeStreamingMessage(sessionId);
const state = useChatStore.getState().getSessionState(sessionId);
expect(state.messages).toHaveLength(0);
});
});
describe('state flags', () => {
it('should set processing flag', () => {
useChatStore.getState().setProcessing(sessionId, true);
expect(useChatStore.getState().getSessionState(sessionId).processing).toBe(true);
useChatStore.getState().setProcessing(sessionId, false);
expect(useChatStore.getState().getSessionState(sessionId).processing).toBe(false);
});
it('should set error state', () => {
const error = {
id: 'error-1',
message: 'Test error',
timestamp: Date.now(),
};
useChatStore.getState().setError(sessionId, error);
expect(useChatStore.getState().getSessionState(sessionId).error).toEqual(error);
useChatStore.getState().setError(sessionId, null);
expect(useChatStore.getState().getSessionState(sessionId).error).toBeNull();
});
it('should set loading history flag', () => {
useChatStore.getState().setLoadingHistory(sessionId, true);
expect(useChatStore.getState().getSessionState(sessionId).loadingHistory).toBe(true);
});
});
describe('removeSession', () => {
it('should remove a session completely', () => {
useChatStore.getState().addMessage(sessionId, createTestMessage());
useChatStore.getState().removeSession(sessionId);
expect(useChatStore.getState().sessions.has(sessionId)).toBe(false);
});
});
describe('selectors', () => {
it('getSessionState should return default for unknown session', () => {
const state = useChatStore.getState().getSessionState('unknown');
expect(state.messages).toEqual([]);
expect(state.processing).toBe(false);
});
it('getMessage should find message by ID', () => {
const message = createTestMessage();
useChatStore.getState().addMessage(sessionId, message);
const found = useChatStore.getState().getMessage(sessionId, message.id);
expect(found).toEqual(message);
});
it('getMessage should return undefined for unknown ID', () => {
const found = useChatStore.getState().getMessage(sessionId, 'unknown');
expect(found).toBeUndefined();
});
it('getMessageByToolCallId should find tool message', () => {
const message = createTestMessage({
role: 'tool',
toolCallId: 'tool-call-123',
});
useChatStore.getState().addMessage(sessionId, message);
const found = useChatStore
.getState()
.getMessageByToolCallId(sessionId, 'tool-call-123');
expect(found).toEqual(message);
});
});
describe('session isolation', () => {
it('should keep sessions separate', () => {
const session1 = 'session-1';
const session2 = 'session-2';
useChatStore
.getState()
.addMessage(session1, createTestMessage({ content: 'Session 1' }));
useChatStore
.getState()
.addMessage(session2, createTestMessage({ content: 'Session 2' }));
expect(useChatStore.getState().getMessages(session1)).toHaveLength(1);
expect(useChatStore.getState().getMessages(session2)).toHaveLength(1);
expect(useChatStore.getState().getMessages(session1)[0].content).toBe('Session 1');
expect(useChatStore.getState().getMessages(session2)[0].content).toBe('Session 2');
});
it('should not affect other sessions when clearing', () => {
const session1 = 'session-1';
const session2 = 'session-2';
useChatStore.getState().addMessage(session1, createTestMessage());
useChatStore.getState().addMessage(session2, createTestMessage());
useChatStore.getState().clearMessages(session1);
expect(useChatStore.getState().getMessages(session1)).toHaveLength(0);
expect(useChatStore.getState().getMessages(session2)).toHaveLength(1);
});
});
});

View File

@@ -0,0 +1,552 @@
/**
* Chat Store
*
* Manages message state per session using Zustand.
* Each session has isolated message state to support multi-session scenarios.
*/
import { create } from 'zustand';
import type { InternalMessage, Issue, SanitizedToolResult, LLMProvider } from '@dexto/core';
import type { TextPart, ImagePart, AudioPart, FilePart, FileData, UIResourcePart } from '@/types';
// =============================================================================
// Types
// =============================================================================
/**
* UI Message role - excludes 'system' which is filtered out before reaching UI
*/
export type UIMessageRole = 'user' | 'assistant' | 'tool';
/**
* Tool result type for UI messages
* Broader than SanitizedToolResult to handle legacy formats and edge cases
*/
export type ToolResult =
| SanitizedToolResult
| { error: string | Record<string, unknown> }
| string
| Record<string, unknown>;
/**
* Sub-agent progress data for spawn_agent tool calls
*/
export interface SubAgentProgress {
/** Short task description */
task: string;
/** Agent ID (e.g., 'explore-agent') */
agentId: string;
/** Number of tools called by the sub-agent */
toolsCalled: number;
/** Current tool being executed */
currentTool: string;
/** Current tool arguments (optional) */
currentArgs?: Record<string, unknown>;
}
/**
* Message in the chat UI
* Extends core InternalMessage with UI-specific fields
* Note: Excludes 'system' role as system messages are not displayed in UI
*/
export interface Message extends Omit<InternalMessage, 'content' | 'role'> {
id: string;
role: UIMessageRole;
createdAt: number;
content: string | null | Array<TextPart | ImagePart | AudioPart | FilePart | UIResourcePart>;
// User attachments
imageData?: { image: string; mimeType: string };
fileData?: FileData;
// Tool-related fields
toolName?: string;
toolArgs?: Record<string, unknown>;
toolCallId?: string;
toolResult?: ToolResult;
toolResultMeta?: SanitizedToolResult['meta'];
toolResultSuccess?: boolean;
/** Sub-agent progress data (for spawn_agent tool calls) */
subAgentProgress?: SubAgentProgress;
// Approval fields
requireApproval?: boolean;
approvalStatus?: 'pending' | 'approved' | 'rejected';
// LLM metadata
tokenUsage?: {
inputTokens?: number;
outputTokens?: number;
reasoningTokens?: number;
totalTokens?: number;
};
reasoning?: string;
model?: string;
provider?: LLMProvider;
// Session reference
sessionId?: string;
}
/**
* Error state for a session
*/
export interface ErrorMessage {
id: string;
message: string;
timestamp: number;
context?: string;
recoverable?: boolean;
sessionId?: string;
anchorMessageId?: string;
detailedIssues?: Issue[];
}
/**
* State for a single session
*/
export interface SessionChatState {
messages: Message[];
streamingMessage: Message | null;
processing: boolean;
error: ErrorMessage | null;
loadingHistory: boolean;
}
/**
* Default state for a new session
*/
const defaultSessionState: SessionChatState = {
messages: [],
streamingMessage: null,
processing: false,
error: null,
loadingHistory: false,
};
// =============================================================================
// Store Interface
// =============================================================================
interface ChatStore {
/**
* Session states keyed by session ID
*/
sessions: Map<string, SessionChatState>;
// -------------------------------------------------------------------------
// Message Actions
// -------------------------------------------------------------------------
/**
* Add a message to a session
*/
addMessage: (sessionId: string, message: Message) => void;
/**
* Update an existing message
*/
updateMessage: (sessionId: string, messageId: string, updates: Partial<Message>) => void;
/**
* Remove a message from a session
*/
removeMessage: (sessionId: string, messageId: string) => void;
/**
* Clear all messages in a session
*/
clearMessages: (sessionId: string) => void;
/**
* Set all messages for a session at once
*/
setMessages: (sessionId: string, messages: Message[]) => void;
/**
* Initialize or replace session state with history
*/
initFromHistory: (sessionId: string, messages: Message[]) => void;
// -------------------------------------------------------------------------
// Streaming Actions
// -------------------------------------------------------------------------
/**
* Set the current streaming message for a session
*/
setStreamingMessage: (sessionId: string, message: Message | null) => void;
/**
* Append content to the streaming message
*/
appendToStreamingMessage: (
sessionId: string,
content: string,
chunkType?: 'text' | 'reasoning'
) => void;
/**
* Finalize streaming message (move to messages array)
*/
finalizeStreamingMessage: (sessionId: string, updates?: Partial<Message>) => void;
// -------------------------------------------------------------------------
// State Actions
// -------------------------------------------------------------------------
/**
* Set processing state for a session
*/
setProcessing: (sessionId: string, processing: boolean) => void;
/**
* Set error state for a session
*/
setError: (sessionId: string, error: ErrorMessage | null) => void;
/**
* Set loading history state for a session
*/
setLoadingHistory: (sessionId: string, loading: boolean) => void;
// -------------------------------------------------------------------------
// Session Actions
// -------------------------------------------------------------------------
/**
* Initialize a session with default state
*/
initSession: (sessionId: string) => void;
/**
* Remove a session completely
*/
removeSession: (sessionId: string) => void;
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
/**
* Get state for a session (creates default if not exists)
*/
getSessionState: (sessionId: string) => SessionChatState;
/**
* Get messages for a session
*/
getMessages: (sessionId: string) => Message[];
/**
* Get a specific message by ID
*/
getMessage: (sessionId: string, messageId: string) => Message | undefined;
/**
* Find message by tool call ID
*/
getMessageByToolCallId: (sessionId: string, toolCallId: string) => Message | undefined;
}
// =============================================================================
// Helper Functions
// =============================================================================
/**
* Get or create session state
*/
function getOrCreateSession(
sessions: Map<string, SessionChatState>,
sessionId: string
): SessionChatState {
const existing = sessions.get(sessionId);
if (existing) return existing;
return { ...defaultSessionState };
}
/**
* Generate unique message ID
*/
export function generateMessageId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
// =============================================================================
// Store Implementation
// =============================================================================
export const useChatStore = create<ChatStore>()((set, get) => ({
sessions: new Map(),
// -------------------------------------------------------------------------
// Message Actions
// -------------------------------------------------------------------------
addMessage: (sessionId, message) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
newSessions.set(sessionId, {
...sessionState,
messages: [...sessionState.messages, message],
});
return { sessions: newSessions };
});
},
updateMessage: (sessionId, messageId, updates) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = newSessions.get(sessionId);
if (!sessionState) return state;
const messageIndex = sessionState.messages.findIndex((m) => m.id === messageId);
if (messageIndex === -1) return state;
const newMessages = [...sessionState.messages];
newMessages[messageIndex] = { ...newMessages[messageIndex], ...updates };
newSessions.set(sessionId, {
...sessionState,
messages: newMessages,
});
return { sessions: newSessions };
});
},
removeMessage: (sessionId, messageId) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = newSessions.get(sessionId);
if (!sessionState) return state;
newSessions.set(sessionId, {
...sessionState,
messages: sessionState.messages.filter((m) => m.id !== messageId),
});
return { sessions: newSessions };
});
},
clearMessages: (sessionId) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = newSessions.get(sessionId);
if (!sessionState) return state;
newSessions.set(sessionId, {
...sessionState,
messages: [],
streamingMessage: null,
});
return { sessions: newSessions };
});
},
setMessages: (sessionId, messages) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
newSessions.set(sessionId, {
...sessionState,
messages,
});
return { sessions: newSessions };
});
},
initFromHistory: (sessionId, messages) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
newSessions.set(sessionId, {
...sessionState,
messages,
processing: false,
error: null,
streamingMessage: null,
});
return { sessions: newSessions };
});
},
// -------------------------------------------------------------------------
// Streaming Actions
// -------------------------------------------------------------------------
setStreamingMessage: (sessionId, message) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
newSessions.set(sessionId, {
...sessionState,
streamingMessage: message,
});
return { sessions: newSessions };
});
},
appendToStreamingMessage: (sessionId, content, chunkType = 'text') => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
if (!sessionState.streamingMessage) return state;
const currentMessage = sessionState.streamingMessage;
let updatedMessage: Message;
if (chunkType === 'reasoning') {
// Append to reasoning field
updatedMessage = {
...currentMessage,
reasoning: (currentMessage.reasoning || '') + content,
};
} else {
// Append to content
const currentContent =
typeof currentMessage.content === 'string' ? currentMessage.content : '';
updatedMessage = {
...currentMessage,
content: currentContent + content,
};
}
newSessions.set(sessionId, {
...sessionState,
streamingMessage: updatedMessage,
});
return { sessions: newSessions };
});
},
finalizeStreamingMessage: (sessionId, updates = {}) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
if (!sessionState.streamingMessage) return state;
const finalizedMessage: Message = {
...sessionState.streamingMessage,
...updates,
};
// Ensure messages array exists (defensive)
const existingMessages = sessionState.messages ?? [];
newSessions.set(sessionId, {
...sessionState,
messages: [...existingMessages, finalizedMessage],
streamingMessage: null,
});
return { sessions: newSessions };
});
},
// -------------------------------------------------------------------------
// State Actions
// -------------------------------------------------------------------------
setProcessing: (sessionId, processing) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
newSessions.set(sessionId, {
...sessionState,
processing,
});
return { sessions: newSessions };
});
},
setError: (sessionId, error) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
newSessions.set(sessionId, {
...sessionState,
error,
});
return { sessions: newSessions };
});
},
setLoadingHistory: (sessionId, loading) => {
set((state) => {
const newSessions = new Map(state.sessions);
const sessionState = getOrCreateSession(newSessions, sessionId);
newSessions.set(sessionId, {
...sessionState,
loadingHistory: loading,
});
return { sessions: newSessions };
});
},
// -------------------------------------------------------------------------
// Session Actions
// -------------------------------------------------------------------------
initSession: (sessionId) => {
set((state) => {
if (state.sessions.has(sessionId)) return state;
const newSessions = new Map(state.sessions);
newSessions.set(sessionId, { ...defaultSessionState });
return { sessions: newSessions };
});
},
removeSession: (sessionId) => {
set((state) => {
const newSessions = new Map(state.sessions);
newSessions.delete(sessionId);
return { sessions: newSessions };
});
},
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
getSessionState: (sessionId) => {
const state = get().sessions.get(sessionId);
return state ?? { ...defaultSessionState };
},
getMessages: (sessionId) => {
return get().getSessionState(sessionId).messages;
},
getMessage: (sessionId, messageId) => {
return get()
.getMessages(sessionId)
.find((m) => m.id === messageId);
},
getMessageByToolCallId: (sessionId, toolCallId) => {
return get()
.getMessages(sessionId)
.find((m) => m.toolCallId === toolCallId);
},
}));

View File

@@ -0,0 +1,394 @@
/**
* Event Log Store Tests
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useEventLogStore } from './eventLogStore.js';
describe('eventLogStore', () => {
beforeEach(() => {
// Reset store to default state
useEventLogStore.setState({
events: [],
maxEvents: 1000,
});
});
describe('addEvent', () => {
it('should add event with generated id', () => {
const { addEvent } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Agent started processing',
timestamp: Date.now(),
sessionId: 'session-1',
});
const { events } = useEventLogStore.getState();
expect(events).toHaveLength(1);
expect(events[0].id).toMatch(/^evt-\d+-[a-z0-9]+$/);
expect(events[0].name).toBe('llm:thinking');
expect(events[0].category).toBe('agent');
expect(events[0].sessionId).toBe('session-1');
});
it('should add multiple events in order', () => {
const { addEvent } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'First event',
timestamp: 1000,
});
addEvent({
name: 'llm:response',
category: 'agent',
description: 'Second event',
timestamp: 2000,
});
const { events } = useEventLogStore.getState();
expect(events).toHaveLength(2);
expect(events[0].description).toBe('First event');
expect(events[1].description).toBe('Second event');
});
it('should store metadata', () => {
const { addEvent } = useEventLogStore.getState();
addEvent({
name: 'llm:tool-call',
category: 'tool',
description: 'Tool call',
timestamp: Date.now(),
metadata: {
toolName: 'read_file',
args: { path: '/test.txt' },
},
});
const { events } = useEventLogStore.getState();
expect(events[0].metadata).toEqual({
toolName: 'read_file',
args: { path: '/test.txt' },
});
});
});
describe('maxEvents limit', () => {
it('should cap events at maxEvents', () => {
const { addEvent, setMaxEvents } = useEventLogStore.getState();
setMaxEvents(3);
// Add 5 events
for (let i = 0; i < 5; i++) {
addEvent({
name: 'llm:chunk',
category: 'agent',
description: `Event ${i}`,
timestamp: Date.now() + i,
});
}
const { events } = useEventLogStore.getState();
expect(events).toHaveLength(3);
// Should keep the newest 3
expect(events[0].description).toBe('Event 2');
expect(events[1].description).toBe('Event 3');
expect(events[2].description).toBe('Event 4');
});
it('should trim existing events when maxEvents is reduced', () => {
const { addEvent, setMaxEvents } = useEventLogStore.getState();
// Add 5 events
for (let i = 0; i < 5; i++) {
addEvent({
name: 'llm:chunk',
category: 'agent',
description: `Event ${i}`,
timestamp: Date.now() + i,
});
}
let events = useEventLogStore.getState().events;
expect(events).toHaveLength(5);
// Reduce maxEvents to 2
setMaxEvents(2);
events = useEventLogStore.getState().events;
expect(events).toHaveLength(2);
// Should keep the newest 2
expect(events[0].description).toBe('Event 3');
expect(events[1].description).toBe('Event 4');
});
it('should not trim if maxEvents is increased', () => {
const { addEvent, setMaxEvents } = useEventLogStore.getState();
setMaxEvents(3);
// Add 3 events
for (let i = 0; i < 3; i++) {
addEvent({
name: 'llm:chunk',
category: 'agent',
description: `Event ${i}`,
timestamp: Date.now() + i,
});
}
let events = useEventLogStore.getState().events;
expect(events).toHaveLength(3);
// Increase maxEvents
setMaxEvents(100);
events = useEventLogStore.getState().events;
expect(events).toHaveLength(3);
});
});
describe('clearEvents', () => {
it('should clear all events', () => {
const { addEvent, clearEvents } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Event 1',
timestamp: Date.now(),
});
addEvent({
name: 'llm:response',
category: 'agent',
description: 'Event 2',
timestamp: Date.now(),
});
let events = useEventLogStore.getState().events;
expect(events).toHaveLength(2);
clearEvents();
events = useEventLogStore.getState().events;
expect(events).toHaveLength(0);
});
});
describe('clearSessionEvents', () => {
it('should remove only matching session events', () => {
const { addEvent, clearSessionEvents } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Session 1 event',
timestamp: Date.now(),
sessionId: 'session-1',
});
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Session 2 event',
timestamp: Date.now(),
sessionId: 'session-2',
});
addEvent({
name: 'connection:status',
category: 'system',
description: 'No session event',
timestamp: Date.now(),
});
expect(useEventLogStore.getState().events).toHaveLength(3);
clearSessionEvents('session-1');
const events = useEventLogStore.getState().events;
expect(events).toHaveLength(2);
expect(events[0].description).toBe('Session 2 event');
expect(events[1].description).toBe('No session event');
});
it('should handle clearing non-existent session', () => {
const { addEvent, clearSessionEvents } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Event',
timestamp: Date.now(),
sessionId: 'session-1',
});
let events = useEventLogStore.getState().events;
expect(events).toHaveLength(1);
clearSessionEvents('non-existent');
events = useEventLogStore.getState().events;
expect(events).toHaveLength(1);
});
});
describe('getEventsBySession', () => {
it('should filter events by session id', () => {
const { addEvent, getEventsBySession } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Session 1 event 1',
timestamp: Date.now(),
sessionId: 'session-1',
});
addEvent({
name: 'llm:response',
category: 'agent',
description: 'Session 2 event',
timestamp: Date.now(),
sessionId: 'session-2',
});
addEvent({
name: 'llm:chunk',
category: 'agent',
description: 'Session 1 event 2',
timestamp: Date.now(),
sessionId: 'session-1',
});
const session1Events = getEventsBySession('session-1');
expect(session1Events).toHaveLength(2);
expect(session1Events[0].description).toBe('Session 1 event 1');
expect(session1Events[1].description).toBe('Session 1 event 2');
});
it('should return empty array for non-existent session', () => {
const { addEvent, getEventsBySession } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Event',
timestamp: Date.now(),
sessionId: 'session-1',
});
const events = getEventsBySession('non-existent');
expect(events).toHaveLength(0);
});
});
describe('getEventsByCategory', () => {
it('should filter events by category', () => {
const { addEvent, getEventsByCategory } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Agent event 1',
timestamp: Date.now(),
});
addEvent({
name: 'llm:tool-call',
category: 'tool',
description: 'Tool event',
timestamp: Date.now(),
});
addEvent({
name: 'llm:response',
category: 'agent',
description: 'Agent event 2',
timestamp: Date.now(),
});
const agentEvents = getEventsByCategory('agent');
expect(agentEvents).toHaveLength(2);
expect(agentEvents[0].description).toBe('Agent event 1');
expect(agentEvents[1].description).toBe('Agent event 2');
const toolEvents = getEventsByCategory('tool');
expect(toolEvents).toHaveLength(1);
expect(toolEvents[0].description).toBe('Tool event');
});
it('should return empty array for category with no events', () => {
const { addEvent, getEventsByCategory } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Agent event',
timestamp: Date.now(),
});
const approvalEvents = getEventsByCategory('approval');
expect(approvalEvents).toHaveLength(0);
});
});
describe('getRecentEvents', () => {
it('should return correct number of recent events', () => {
const { addEvent, getRecentEvents } = useEventLogStore.getState();
// Add 5 events
for (let i = 0; i < 5; i++) {
addEvent({
name: 'llm:chunk',
category: 'agent',
description: `Event ${i}`,
timestamp: Date.now() + i,
});
}
const recent = getRecentEvents(3);
expect(recent).toHaveLength(3);
// Should get the last 3
expect(recent[0].description).toBe('Event 2');
expect(recent[1].description).toBe('Event 3');
expect(recent[2].description).toBe('Event 4');
});
it('should return all events if limit exceeds count', () => {
const { addEvent, getRecentEvents } = useEventLogStore.getState();
addEvent({
name: 'llm:thinking',
category: 'agent',
description: 'Event 1',
timestamp: Date.now(),
});
addEvent({
name: 'llm:response',
category: 'agent',
description: 'Event 2',
timestamp: Date.now(),
});
const recent = getRecentEvents(10);
expect(recent).toHaveLength(2);
});
it('should return empty array if no events', () => {
const { getRecentEvents } = useEventLogStore.getState();
const recent = getRecentEvents(5);
expect(recent).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,185 @@
/**
* Event Log Store
*
* Stores activity events for debugging and monitoring.
* Provides an audit trail of all events flowing through the event bus.
*/
import { create } from 'zustand';
import type { StreamingEventName } from '@dexto/core';
// =============================================================================
// Types
// =============================================================================
/**
* Event categories for organization and filtering
*/
export type EventCategory = 'agent' | 'tool' | 'system' | 'user' | 'approval';
/**
* Activity event stored in the log
*/
export interface ActivityEvent {
/**
* Unique event ID
*/
id: string;
/**
* Event name from SSE
*/
name: StreamingEventName | string;
/**
* Event category
*/
category: EventCategory;
/**
* Human-readable description
*/
description: string;
/**
* Timestamp when event was logged
*/
timestamp: number;
/**
* Session ID if event is session-scoped
*/
sessionId?: string;
/**
* Additional metadata (full event payload)
*/
metadata?: Record<string, unknown>;
}
// =============================================================================
// Store Interface
// =============================================================================
interface EventLogStore {
/**
* Stored events (newest last)
*/
events: ActivityEvent[];
/**
* Maximum number of events to keep
*/
maxEvents: number;
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
/**
* Add a new event to the log
*/
addEvent: (event: Omit<ActivityEvent, 'id'>) => void;
/**
* Clear all events
*/
clearEvents: () => void;
/**
* Clear events for a specific session
*/
clearSessionEvents: (sessionId: string) => void;
/**
* Set the maximum number of events to keep
*/
setMaxEvents: (max: number) => void;
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
/**
* Get events for a specific session
*/
getEventsBySession: (sessionId: string) => ActivityEvent[];
/**
* Get events by category
*/
getEventsByCategory: (category: EventCategory) => ActivityEvent[];
/**
* Get most recent N events
*/
getRecentEvents: (limit: number) => ActivityEvent[];
}
// =============================================================================
// Store Implementation
// =============================================================================
export const useEventLogStore = create<EventLogStore>()((set, get) => ({
events: [],
maxEvents: 1000,
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
addEvent: (event) => {
const id = `evt-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
set((state) => {
const newEvents = [...state.events, { ...event, id }];
// Trim to maxEvents, keeping newest
if (newEvents.length > state.maxEvents) {
return { events: newEvents.slice(-state.maxEvents) };
}
return { events: newEvents };
});
},
clearEvents: () => {
set({ events: [] });
},
clearSessionEvents: (sessionId) => {
set((state) => ({
events: state.events.filter((event) => event.sessionId !== sessionId),
}));
},
setMaxEvents: (max) => {
set((state) => {
// If reducing max, trim events immediately
if (state.events.length > max) {
return {
maxEvents: max,
events: state.events.slice(-max),
};
}
return { maxEvents: max };
});
},
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
getEventsBySession: (sessionId) => {
return get().events.filter((event) => event.sessionId === sessionId);
},
getEventsByCategory: (category) => {
return get().events.filter((event) => event.category === category);
},
getRecentEvents: (limit) => {
const events = get().events;
return events.slice(-limit);
},
}));

View File

@@ -0,0 +1,66 @@
/**
* Store Exports
*
* Central export point for all Zustand stores.
* Import stores from here rather than individual files.
*/
// Chat store - per-session message state
export { useChatStore, generateMessageId } from './chatStore.js';
export type { Message, ErrorMessage, SessionChatState } from './chatStore.js';
// Session store - current session navigation state
export { useSessionStore } from './sessionStore.js';
export type { SessionState } from './sessionStore.js';
// Agent store - agent status and connection state
export { useAgentStore } from './agentStore.js';
export type { AgentStatus, ConnectionStatus, AgentState } from './agentStore.js';
// Notification store - toast notifications
export { useNotificationStore } from './notificationStore.js';
export type { Toast, ToastIntent } from './notificationStore.js';
// Event log store - activity logging for debugging
export { useEventLogStore } from './eventLogStore.js';
export type { ActivityEvent, EventCategory } from './eventLogStore.js';
// Approval store - approval request queue management
export { useApprovalStore } from './approvalStore.js';
export type { PendingApproval } from './approvalStore.js';
// Preference store - user preferences with localStorage persistence
export { usePreferenceStore } from './preferenceStore.js';
export type { PreferenceState } from './preferenceStore.js';
// Todo store - agent task tracking
export { useTodoStore } from './todoStore.js';
export type { Todo, TodoStatus } from './todoStore.js';
// Selectors - shared selector hooks for common patterns
export {
// Constants
EMPTY_MESSAGES,
// Session selectors
useCurrentSessionId,
useIsWelcomeState,
useIsSessionOperationPending,
useIsReplayingHistory,
// Chat selectors
useSessionMessages,
useStreamingMessage,
useAllMessages,
useSessionProcessing,
useSessionError,
useSessionLoadingHistory,
// Agent selectors
useCurrentToolName,
useAgentStatus,
useConnectionStatus,
useIsAgentBusy,
useIsAgentConnected,
useAgentActiveSession,
// Combined selectors
useSessionChatState,
useAgentState,
} from './selectors.js';

View File

@@ -0,0 +1,165 @@
/**
* Tests for notificationStore
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useNotificationStore } from './notificationStore.js';
describe('notificationStore', () => {
beforeEach(() => {
// Reset store to default state
useNotificationStore.setState({ toasts: [], maxToasts: 5 });
});
describe('addToast', () => {
it('should add a toast with generated id and timestamp', () => {
const { addToast } = useNotificationStore.getState();
addToast({
title: 'Test Toast',
intent: 'info',
});
const { toasts } = useNotificationStore.getState();
expect(toasts).toHaveLength(1);
expect(toasts[0].title).toBe('Test Toast');
expect(toasts[0].intent).toBe('info');
expect(toasts[0].id).toMatch(/^toast-/);
expect(toasts[0].timestamp).toBeGreaterThan(0);
});
it('should add multiple toasts', () => {
const { addToast } = useNotificationStore.getState();
addToast({ title: 'Toast 1', intent: 'info' });
addToast({ title: 'Toast 2', intent: 'success' });
addToast({ title: 'Toast 3', intent: 'warning' });
const { toasts } = useNotificationStore.getState();
expect(toasts).toHaveLength(3);
expect(toasts[0].title).toBe('Toast 1');
expect(toasts[1].title).toBe('Toast 2');
expect(toasts[2].title).toBe('Toast 3');
});
it('should include optional fields when provided', () => {
const { addToast } = useNotificationStore.getState();
addToast({
title: 'Test',
description: 'Description text',
intent: 'danger',
duration: 10000,
sessionId: 'session-123',
});
const { toasts } = useNotificationStore.getState();
expect(toasts[0].description).toBe('Description text');
expect(toasts[0].duration).toBe(10000);
expect(toasts[0].sessionId).toBe('session-123');
});
it('should enforce maxToasts limit by removing oldest', () => {
const { addToast } = useNotificationStore.getState();
// Add 6 toasts (max is 5)
addToast({ title: 'Toast 1', intent: 'info' });
addToast({ title: 'Toast 2', intent: 'info' });
addToast({ title: 'Toast 3', intent: 'info' });
addToast({ title: 'Toast 4', intent: 'info' });
addToast({ title: 'Toast 5', intent: 'info' });
addToast({ title: 'Toast 6', intent: 'info' });
const { toasts } = useNotificationStore.getState();
expect(toasts).toHaveLength(5);
// Oldest (Toast 1) should be removed
expect(toasts[0].title).toBe('Toast 2');
expect(toasts[4].title).toBe('Toast 6');
});
it('should enforce custom maxToasts limit', () => {
// Set custom max toasts
useNotificationStore.setState({ maxToasts: 3 });
const { addToast } = useNotificationStore.getState();
addToast({ title: 'Toast 1', intent: 'info' });
addToast({ title: 'Toast 2', intent: 'info' });
addToast({ title: 'Toast 3', intent: 'info' });
addToast({ title: 'Toast 4', intent: 'info' });
const { toasts } = useNotificationStore.getState();
expect(toasts).toHaveLength(3);
expect(toasts[0].title).toBe('Toast 2');
expect(toasts[2].title).toBe('Toast 4');
});
});
describe('removeToast', () => {
it('should remove toast by id', () => {
const { addToast, removeToast } = useNotificationStore.getState();
addToast({ title: 'Toast 1', intent: 'info' });
addToast({ title: 'Toast 2', intent: 'info' });
const { toasts } = useNotificationStore.getState();
const toastId = toasts[0].id;
removeToast(toastId);
const updatedToasts = useNotificationStore.getState().toasts;
expect(updatedToasts).toHaveLength(1);
expect(updatedToasts[0].title).toBe('Toast 2');
});
it('should do nothing if id does not exist', () => {
const { addToast, removeToast } = useNotificationStore.getState();
addToast({ title: 'Toast 1', intent: 'info' });
removeToast('non-existent-id');
const { toasts } = useNotificationStore.getState();
expect(toasts).toHaveLength(1);
});
it('should remove all toasts with same id', () => {
const { addToast, removeToast } = useNotificationStore.getState();
addToast({ title: 'Toast 1', intent: 'info' });
const { toasts: toasts1 } = useNotificationStore.getState();
const toastId = toasts1[0].id;
addToast({ title: 'Toast 2', intent: 'info' });
removeToast(toastId);
const { toasts } = useNotificationStore.getState();
expect(toasts).toHaveLength(1);
expect(toasts[0].title).toBe('Toast 2');
});
});
describe('clearAll', () => {
it('should remove all toasts', () => {
const { addToast, clearAll } = useNotificationStore.getState();
addToast({ title: 'Toast 1', intent: 'info' });
addToast({ title: 'Toast 2', intent: 'info' });
addToast({ title: 'Toast 3', intent: 'info' });
expect(useNotificationStore.getState().toasts).toHaveLength(3);
clearAll();
expect(useNotificationStore.getState().toasts).toHaveLength(0);
});
it('should work when there are no toasts', () => {
const { clearAll } = useNotificationStore.getState();
clearAll();
expect(useNotificationStore.getState().toasts).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,114 @@
/**
* Notification Store
*
* Manages toast notifications for the WebUI.
* Toasts are displayed in the bottom-right corner and auto-dismiss after a duration.
*/
import { create } from 'zustand';
/**
* Toast intent determines the visual styling
*/
export type ToastIntent = 'info' | 'success' | 'warning' | 'danger';
/**
* Toast notification interface
*/
export interface Toast {
/** Unique identifier */
id: string;
/** Toast title (required) */
title: string;
/** Optional description/body text */
description?: string;
/** Visual intent/severity */
intent: ToastIntent;
/** Auto-dismiss duration in milliseconds (default: 5000) */
duration?: number;
/** Session ID for "Go to session" action */
sessionId?: string;
/** Creation timestamp */
timestamp: number;
}
/**
* Notification store state
*/
interface NotificationStore {
/** Active toast notifications */
toasts: Toast[];
/** Maximum number of toasts to show simultaneously */
maxToasts: number;
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
/**
* Add a new toast notification
* Automatically generates ID and timestamp
*/
addToast: (toast: Omit<Toast, 'id' | 'timestamp'>) => void;
/**
* Remove a toast by ID
*/
removeToast: (id: string) => void;
/**
* Clear all toasts
*/
clearAll: () => void;
}
/**
* Generate a unique toast ID
*/
function generateToastId(): string {
return `toast-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Default values
*/
const DEFAULT_MAX_TOASTS = 5;
/**
* Notification store implementation
*/
export const useNotificationStore = create<NotificationStore>()((set, _get) => ({
toasts: [],
maxToasts: DEFAULT_MAX_TOASTS,
addToast: (toast) => {
const newToast: Toast = {
...toast,
id: generateToastId(),
timestamp: Date.now(),
};
set((state) => {
const newToasts = [...state.toasts, newToast];
// Enforce max toasts limit (remove oldest)
if (newToasts.length > state.maxToasts) {
return {
toasts: newToasts.slice(newToasts.length - state.maxToasts),
};
}
return { toasts: newToasts };
});
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
}));
},
clearAll: () => {
set({ toasts: [] });
},
}));

View File

@@ -0,0 +1,86 @@
/**
* Preference Store Tests
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { usePreferenceStore } from './preferenceStore.js';
// Mock localStorage for Node.js test environment
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => {
store[key] = value.toString();
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
// Assign to global
global.localStorage = localStorageMock as any;
describe('preferenceStore', () => {
beforeEach(() => {
// Reset to default state before each test
usePreferenceStore.setState({ isStreaming: true });
// Clear localStorage
localStorage.clear();
});
describe('Initialization', () => {
it('should initialize with default values', () => {
const state = usePreferenceStore.getState();
expect(state.isStreaming).toBe(true);
});
});
describe('setStreaming', () => {
it('should update streaming preference to false', () => {
const store = usePreferenceStore.getState();
store.setStreaming(false);
expect(usePreferenceStore.getState().isStreaming).toBe(false);
});
it('should update streaming preference to true', () => {
const store = usePreferenceStore.getState();
// Set to false first
store.setStreaming(false);
expect(usePreferenceStore.getState().isStreaming).toBe(false);
// Then back to true
store.setStreaming(true);
expect(usePreferenceStore.getState().isStreaming).toBe(true);
});
});
describe('localStorage persistence', () => {
it('should have persist middleware configured', () => {
// The store uses zustand persist middleware with 'dexto-preferences' key
// In browser environment, this will automatically persist to localStorage
// Here we just verify the store works correctly
const store = usePreferenceStore.getState();
// Change preference
store.setStreaming(false);
expect(usePreferenceStore.getState().isStreaming).toBe(false);
// Change it back
store.setStreaming(true);
expect(usePreferenceStore.getState().isStreaming).toBe(true);
// Note: Actual localStorage persistence is tested in browser/e2e tests
// The persist middleware is a well-tested zustand feature
});
});
});

View File

@@ -0,0 +1,63 @@
/**
* Preference Store
*
* Manages user preferences with localStorage persistence.
* Uses zustand persist middleware for automatic sync.
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// =============================================================================
// Types
// =============================================================================
/**
* User preference state
*/
export interface PreferenceState {
/**
* Whether streaming mode is enabled (SSE vs sync)
* @default true
*/
isStreaming: boolean;
}
// =============================================================================
// Store Interface
// =============================================================================
interface PreferenceStore extends PreferenceState {
/**
* Toggle streaming mode
*/
setStreaming: (enabled: boolean) => void;
}
// =============================================================================
// Default State
// =============================================================================
const defaultState: PreferenceState = {
isStreaming: true, // Default to streaming enabled
};
// =============================================================================
// Store Implementation
// =============================================================================
export const usePreferenceStore = create<PreferenceStore>()(
persist(
(set) => ({
...defaultState,
setStreaming: (enabled) => {
set({ isStreaming: enabled });
},
}),
{
name: 'dexto-preferences', // localStorage key
version: 1,
}
)
);

View File

@@ -0,0 +1,41 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface RecentAgent {
id: string;
name: string;
path: string;
lastUsed: number;
}
interface RecentAgentsStore {
recentAgents: RecentAgent[];
addRecentAgent: (agent: { id: string; name: string; path: string }) => void;
clearRecentAgents: () => void;
}
const MAX_RECENT_AGENTS = 5;
export const useRecentAgentsStore = create<RecentAgentsStore>()(
persist(
(set) => ({
recentAgents: [],
addRecentAgent: (agent) =>
set((state) => {
const filtered = state.recentAgents.filter((a) => a.path !== agent.path);
const updated: RecentAgent[] = [
{ ...agent, lastUsed: Date.now() },
...filtered,
].slice(0, MAX_RECENT_AGENTS);
return { recentAgents: updated };
}),
clearRecentAgents: () => set({ recentAgents: [] }),
}),
{
name: 'dexto:recentAgents',
}
)
);

View File

@@ -0,0 +1,255 @@
/**
* Store Selectors
*
* Centralized selector hooks for Zustand stores.
* These hooks encapsulate common selector patterns to:
* - Reduce duplication across components
* - Ensure consistent null-safety handling
* - Provide stable references to prevent re-renders
*
* @example
* ```tsx
* // Instead of repeating this pattern:
* const messages = useChatStore((s) => {
* if (!sessionId) return EMPTY_MESSAGES;
* return s.sessions.get(sessionId)?.messages ?? EMPTY_MESSAGES;
* });
*
* // Use:
* const messages = useSessionMessages(sessionId);
* ```
*/
import { useMemo } from 'react';
import { useChatStore, type Message, type ErrorMessage } from './chatStore.js';
import { useSessionStore } from './sessionStore.js';
import { useAgentStore, type AgentStatus, type ConnectionStatus } from './agentStore.js';
// =============================================================================
// Constants
// =============================================================================
/**
* Stable empty array reference to prevent re-renders.
* Using a constant reference ensures React's shallow comparison
* doesn't trigger unnecessary re-renders when there are no messages.
*/
export const EMPTY_MESSAGES: Message[] = [];
// =============================================================================
// Session Selectors
// =============================================================================
/**
* Get the current session ID
*/
export function useCurrentSessionId(): string | null {
return useSessionStore((s) => s.currentSessionId);
}
/**
* Check if in welcome state (no active session)
*/
export function useIsWelcomeState(): boolean {
return useSessionStore((s) => s.isWelcomeState);
}
/**
* Check if a session operation is in progress
*/
export function useIsSessionOperationPending(): boolean {
return useSessionStore(
(s) => s.isCreatingSession || s.isSwitchingSession || s.isLoadingHistory
);
}
/**
* Check if history replay is in progress (for suppressing notifications)
*/
export function useIsReplayingHistory(): boolean {
return useSessionStore((s) => s.isReplayingHistory);
}
// =============================================================================
// Chat Selectors
// =============================================================================
/**
* Get messages for a session (without streaming message)
*
* @param sessionId - Session ID or null
* @returns Array of messages, empty array if no session
*/
export function useSessionMessages(sessionId: string | null): Message[] {
return useChatStore((s) => {
if (!sessionId) return EMPTY_MESSAGES;
const session = s.sessions.get(sessionId);
return session?.messages ?? EMPTY_MESSAGES;
});
}
/**
* Get the streaming message for a session
*
* @param sessionId - Session ID or null
* @returns Streaming message or null
*/
export function useStreamingMessage(sessionId: string | null): Message | null {
return useChatStore((s) => {
if (!sessionId) return null;
const session = s.sessions.get(sessionId);
return session?.streamingMessage ?? null;
});
}
/**
* Get all messages including streaming message.
* Uses useMemo internally for stable reference when combining.
*
* @param sessionId - Session ID or null
* @returns Combined array of messages + streaming message
*/
export function useAllMessages(sessionId: string | null): Message[] {
const baseMessages = useSessionMessages(sessionId);
const streamingMessage = useStreamingMessage(sessionId);
return useMemo(() => {
if (streamingMessage) {
return [...baseMessages, streamingMessage];
}
return baseMessages;
}, [baseMessages, streamingMessage]);
}
/**
* Get processing state for a session
*
* @param sessionId - Session ID or null
* @returns True if session is processing
*/
export function useSessionProcessing(sessionId: string | null): boolean {
return useChatStore((s) => {
if (!sessionId) return false;
const session = s.sessions.get(sessionId);
return session?.processing ?? false;
});
}
/**
* Get error state for a session
*
* @param sessionId - Session ID or null
* @returns Error object or null
*/
export function useSessionError(sessionId: string | null): ErrorMessage | null {
return useChatStore((s) => {
if (!sessionId) return null;
const session = s.sessions.get(sessionId);
return session?.error ?? null;
});
}
/**
* Get loading history state for a session
*
* @param sessionId - Session ID or null
* @returns True if loading history
*/
export function useSessionLoadingHistory(sessionId: string | null): boolean {
return useChatStore((s) => {
if (!sessionId) return false;
const session = s.sessions.get(sessionId);
return session?.loadingHistory ?? false;
});
}
// =============================================================================
// Agent Selectors
// =============================================================================
/**
* Get the current tool name being executed
*/
export function useCurrentToolName(): string | null {
return useAgentStore((s) => s.currentToolName);
}
/**
* Get the agent's current status
*/
export function useAgentStatus(): AgentStatus {
return useAgentStore((s) => s.status);
}
/**
* Get the agent's connection status
*/
export function useConnectionStatus(): ConnectionStatus {
return useAgentStore((s) => s.connectionStatus);
}
/**
* Check if the agent is busy (not idle)
*/
export function useIsAgentBusy(): boolean {
return useAgentStore((s) => s.status !== 'idle');
}
/**
* Check if the agent is connected
*/
export function useIsAgentConnected(): boolean {
return useAgentStore((s) => s.connectionStatus === 'connected');
}
/**
* Get the active session ID for the agent
*/
export function useAgentActiveSession(): string | null {
return useAgentStore((s) => s.activeSessionId);
}
// =============================================================================
// Combined Selectors (Convenience Hooks)
// =============================================================================
/**
* Combined chat state for a session.
* Use this when a component needs multiple pieces of session state.
*
* @param sessionId - Session ID or null
* @returns Object with messages, processing, error, and loadingHistory
*
* @example
* ```tsx
* const { messages, processing, error } = useSessionChatState(sessionId);
* ```
*/
export function useSessionChatState(sessionId: string | null) {
const messages = useAllMessages(sessionId);
const processing = useSessionProcessing(sessionId);
const error = useSessionError(sessionId);
const loadingHistory = useSessionLoadingHistory(sessionId);
return { messages, processing, error, loadingHistory };
}
/**
* Combined agent state.
* Use this when a component needs multiple pieces of agent state.
*
* @returns Object with status, connectionStatus, currentToolName, and isBusy
*
* @example
* ```tsx
* const { status, isBusy, currentToolName } = useAgentState();
* ```
*/
export function useAgentState() {
const status = useAgentStatus();
const connectionStatus = useConnectionStatus();
const currentToolName = useCurrentToolName();
const isBusy = useIsAgentBusy();
return { status, connectionStatus, currentToolName, isBusy };
}

View File

@@ -0,0 +1,169 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useSessionStore } from './sessionStore.js';
describe('sessionStore', () => {
beforeEach(() => {
// Reset store to default state
useSessionStore.setState({
currentSessionId: null,
isWelcomeState: true,
isCreatingSession: false,
isSwitchingSession: false,
isReplayingHistory: false,
isLoadingHistory: false,
});
});
describe('setCurrentSession', () => {
it('should set the current session ID', () => {
useSessionStore.getState().setCurrentSession('session-123');
expect(useSessionStore.getState().currentSessionId).toBe('session-123');
});
it('should set isWelcomeState to false when setting a session', () => {
useSessionStore.getState().setCurrentSession('session-123');
expect(useSessionStore.getState().isWelcomeState).toBe(false);
});
it('should set isWelcomeState to true when setting null', () => {
useSessionStore.getState().setCurrentSession('session-123');
useSessionStore.getState().setCurrentSession(null);
expect(useSessionStore.getState().isWelcomeState).toBe(true);
expect(useSessionStore.getState().currentSessionId).toBeNull();
});
});
describe('setWelcomeState', () => {
it('should set welcome state to true and clear session', () => {
useSessionStore.getState().setCurrentSession('session-123');
useSessionStore.getState().setWelcomeState(true);
expect(useSessionStore.getState().isWelcomeState).toBe(true);
expect(useSessionStore.getState().currentSessionId).toBeNull();
});
it('should set welcome state to false without clearing session', () => {
useSessionStore.getState().setCurrentSession('session-123');
useSessionStore.getState().setWelcomeState(false);
expect(useSessionStore.getState().isWelcomeState).toBe(false);
expect(useSessionStore.getState().currentSessionId).toBe('session-123');
});
});
describe('returnToWelcome', () => {
it('should reset to welcome state and clear all flags', () => {
useSessionStore.setState({
currentSessionId: 'session-123',
isWelcomeState: false,
isCreatingSession: true,
isSwitchingSession: true,
isReplayingHistory: true,
isLoadingHistory: true,
});
useSessionStore.getState().returnToWelcome();
expect(useSessionStore.getState().currentSessionId).toBeNull();
expect(useSessionStore.getState().isWelcomeState).toBe(true);
expect(useSessionStore.getState().isCreatingSession).toBe(false);
expect(useSessionStore.getState().isSwitchingSession).toBe(false);
expect(useSessionStore.getState().isReplayingHistory).toBe(false);
expect(useSessionStore.getState().isLoadingHistory).toBe(false);
});
});
describe('session creation flow', () => {
it('should handle beginSessionCreation', () => {
useSessionStore.getState().beginSessionCreation();
expect(useSessionStore.getState().isCreatingSession).toBe(true);
expect(useSessionStore.getState().isWelcomeState).toBe(false);
});
it('should handle completeSessionCreation', () => {
useSessionStore.getState().beginSessionCreation();
useSessionStore.getState().completeSessionCreation('new-session-id');
expect(useSessionStore.getState().currentSessionId).toBe('new-session-id');
expect(useSessionStore.getState().isCreatingSession).toBe(false);
expect(useSessionStore.getState().isWelcomeState).toBe(false);
});
it('should handle cancelSessionCreation returning to welcome', () => {
// Start from welcome state
useSessionStore.getState().beginSessionCreation();
useSessionStore.getState().cancelSessionCreation();
expect(useSessionStore.getState().isCreatingSession).toBe(false);
expect(useSessionStore.getState().isWelcomeState).toBe(true);
});
it('should handle cancelSessionCreation staying in session', () => {
// Start from existing session
useSessionStore.getState().setCurrentSession('existing-session');
useSessionStore.getState().beginSessionCreation();
useSessionStore.getState().cancelSessionCreation();
expect(useSessionStore.getState().isCreatingSession).toBe(false);
// Should stay on existing session, not go to welcome
expect(useSessionStore.getState().isWelcomeState).toBe(false);
});
});
describe('selectors', () => {
it('isSessionOperationPending should return true when creating', () => {
useSessionStore.setState({ isCreatingSession: true });
expect(useSessionStore.getState().isSessionOperationPending()).toBe(true);
});
it('isSessionOperationPending should return true when switching', () => {
useSessionStore.setState({ isSwitchingSession: true });
expect(useSessionStore.getState().isSessionOperationPending()).toBe(true);
});
it('isSessionOperationPending should return true when loading history', () => {
useSessionStore.setState({ isLoadingHistory: true });
expect(useSessionStore.getState().isSessionOperationPending()).toBe(true);
});
it('isSessionOperationPending should return false when idle', () => {
expect(useSessionStore.getState().isSessionOperationPending()).toBe(false);
});
it('shouldSuppressNotifications should return true during replay', () => {
useSessionStore.setState({ isReplayingHistory: true });
expect(useSessionStore.getState().shouldSuppressNotifications()).toBe(true);
});
it('shouldSuppressNotifications should return true during switch', () => {
useSessionStore.setState({ isSwitchingSession: true });
expect(useSessionStore.getState().shouldSuppressNotifications()).toBe(true);
});
it('shouldSuppressNotifications should return false during normal operation', () => {
expect(useSessionStore.getState().shouldSuppressNotifications()).toBe(false);
});
});
describe('individual setters', () => {
it('should set creating session flag', () => {
useSessionStore.getState().setCreatingSession(true);
expect(useSessionStore.getState().isCreatingSession).toBe(true);
useSessionStore.getState().setCreatingSession(false);
expect(useSessionStore.getState().isCreatingSession).toBe(false);
});
it('should set switching session flag', () => {
useSessionStore.getState().setSwitchingSession(true);
expect(useSessionStore.getState().isSwitchingSession).toBe(true);
});
it('should set replaying history flag', () => {
useSessionStore.getState().setReplayingHistory(true);
expect(useSessionStore.getState().isReplayingHistory).toBe(true);
});
it('should set loading history flag', () => {
useSessionStore.getState().setLoadingHistory(true);
expect(useSessionStore.getState().isLoadingHistory).toBe(true);
});
});
});

View File

@@ -0,0 +1,236 @@
/**
* Session Store
*
* Manages the current session state and navigation state.
* Separate from chatStore which handles per-session message state.
*/
import { create } from 'zustand';
// =============================================================================
// Types
// =============================================================================
/**
* Session navigation and UI state
*/
export interface SessionState {
/**
* Currently active session ID (null = welcome state)
*/
currentSessionId: string | null;
/**
* Whether we're showing the welcome/landing screen
*/
isWelcomeState: boolean;
/**
* Session is being created (new session in progress)
*/
isCreatingSession: boolean;
/**
* Session switch in progress
*/
isSwitchingSession: boolean;
/**
* History replay in progress (suppress notifications during this)
*/
isReplayingHistory: boolean;
/**
* Loading history for a session
*/
isLoadingHistory: boolean;
}
// =============================================================================
// Store Interface
// =============================================================================
interface SessionStore extends SessionState {
// -------------------------------------------------------------------------
// Session Actions
// -------------------------------------------------------------------------
/**
* Set the current active session
* Setting to null transitions to welcome state
*/
setCurrentSession: (sessionId: string | null) => void;
/**
* Explicitly set welcome state
*/
setWelcomeState: (isWelcome: boolean) => void;
/**
* Set session creation in progress
*/
setCreatingSession: (isCreating: boolean) => void;
/**
* Set session switch in progress
*/
setSwitchingSession: (isSwitching: boolean) => void;
/**
* Set history replay in progress
*/
setReplayingHistory: (isReplaying: boolean) => void;
/**
* Set history loading state
*/
setLoadingHistory: (isLoading: boolean) => void;
// -------------------------------------------------------------------------
// Composite Actions
// -------------------------------------------------------------------------
/**
* Return to welcome screen (clear current session)
*/
returnToWelcome: () => void;
/**
* Start creating a new session
*/
beginSessionCreation: () => void;
/**
* Complete session creation and activate the new session
* @param newSessionId - The newly created session ID
*/
completeSessionCreation: (newSessionId: string) => void;
/**
* Cancel session creation (e.g., on error)
*/
cancelSessionCreation: () => void;
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
/**
* Check if any session operation is in progress
*/
isSessionOperationPending: () => boolean;
/**
* Check if we should suppress notifications
* (during history replay or session operations)
*/
shouldSuppressNotifications: () => boolean;
}
// =============================================================================
// Default State
// =============================================================================
const defaultState: SessionState = {
currentSessionId: null,
isWelcomeState: true,
isCreatingSession: false,
isSwitchingSession: false,
isReplayingHistory: false,
isLoadingHistory: false,
};
// =============================================================================
// Store Implementation
// =============================================================================
export const useSessionStore = create<SessionStore>()((set, get) => ({
...defaultState,
// -------------------------------------------------------------------------
// Session Actions
// -------------------------------------------------------------------------
setCurrentSession: (sessionId) => {
set({
currentSessionId: sessionId,
isWelcomeState: sessionId === null,
});
},
setWelcomeState: (isWelcome) => {
set({
isWelcomeState: isWelcome,
// Clear session when going to welcome
...(isWelcome ? { currentSessionId: null } : {}),
});
},
setCreatingSession: (isCreating) => {
set({ isCreatingSession: isCreating });
},
setSwitchingSession: (isSwitching) => {
set({ isSwitchingSession: isSwitching });
},
setReplayingHistory: (isReplaying) => {
set({ isReplayingHistory: isReplaying });
},
setLoadingHistory: (isLoading) => {
set({ isLoadingHistory: isLoading });
},
// -------------------------------------------------------------------------
// Composite Actions
// -------------------------------------------------------------------------
returnToWelcome: () => {
set({
currentSessionId: null,
isWelcomeState: true,
isCreatingSession: false,
isSwitchingSession: false,
isReplayingHistory: false,
isLoadingHistory: false,
});
},
beginSessionCreation: () => {
set({
isCreatingSession: true,
isWelcomeState: false,
});
},
completeSessionCreation: (newSessionId) => {
set({
currentSessionId: newSessionId,
isCreatingSession: false,
isWelcomeState: false,
});
},
cancelSessionCreation: () => {
set({
isCreatingSession: false,
// Return to welcome if we were there before
isWelcomeState: get().currentSessionId === null,
});
},
// -------------------------------------------------------------------------
// Selectors
// -------------------------------------------------------------------------
isSessionOperationPending: () => {
const state = get();
return state.isCreatingSession || state.isSwitchingSession || state.isLoadingHistory;
},
shouldSuppressNotifications: () => {
const state = get();
return state.isReplayingHistory || state.isSwitchingSession || state.isLoadingHistory;
},
}));

View File

@@ -0,0 +1,92 @@
/**
* Todo Store
*
* Manages todo/task state for agent workflow tracking.
* State is per-session and not persisted (todos come from server events).
*/
import { create } from 'zustand';
// =============================================================================
// Types
// =============================================================================
/**
* Todo status
*/
export type TodoStatus = 'pending' | 'in_progress' | 'completed';
/**
* Todo item
*/
export interface Todo {
id: string;
sessionId: string;
content: string;
activeForm: string;
status: TodoStatus;
position: number;
createdAt: Date | string;
updatedAt: Date | string;
}
/**
* State per session
*/
interface SessionTodoState {
todos: Todo[];
}
// =============================================================================
// Store Interface
// =============================================================================
interface TodoStore {
/**
* Todo state by session ID
*/
sessions: Map<string, SessionTodoState>;
/**
* Get todos for a session
*/
getTodos: (sessionId: string) => Todo[];
/**
* Update todos for a session (replaces entire list)
*/
setTodos: (sessionId: string, todos: Todo[]) => void;
/**
* Clear todos for a session
*/
clearTodos: (sessionId: string) => void;
}
// =============================================================================
// Store Implementation
// =============================================================================
export const useTodoStore = create<TodoStore>()((set, get) => ({
sessions: new Map(),
getTodos: (sessionId: string): Todo[] => {
return get().sessions.get(sessionId)?.todos ?? [];
},
setTodos: (sessionId: string, todos: Todo[]): void => {
set((state) => {
const newSessions = new Map(state.sessions);
newSessions.set(sessionId, { todos });
return { sessions: newSessions };
});
},
clearTodos: (sessionId: string): void => {
set((state) => {
const newSessions = new Map(state.sessions);
newSessions.delete(sessionId);
return { sessions: newSessions };
});
},
}));