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:
237
dexto/packages/webui/lib/stores/README.md
Normal file
237
dexto/packages/webui/lib/stores/README.md
Normal 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)
|
||||
211
dexto/packages/webui/lib/stores/agentStore.test.ts
Normal file
211
dexto/packages/webui/lib/stores/agentStore.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
298
dexto/packages/webui/lib/stores/agentStore.ts
Normal file
298
dexto/packages/webui/lib/stores/agentStore.ts
Normal 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;
|
||||
},
|
||||
}));
|
||||
331
dexto/packages/webui/lib/stores/approvalStore.test.ts
Normal file
331
dexto/packages/webui/lib/stores/approvalStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
158
dexto/packages/webui/lib/stores/approvalStore.ts
Normal file
158
dexto/packages/webui/lib/stores/approvalStore.ts
Normal 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;
|
||||
},
|
||||
}));
|
||||
338
dexto/packages/webui/lib/stores/chatStore.test.ts
Normal file
338
dexto/packages/webui/lib/stores/chatStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
552
dexto/packages/webui/lib/stores/chatStore.ts
Normal file
552
dexto/packages/webui/lib/stores/chatStore.ts
Normal 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);
|
||||
},
|
||||
}));
|
||||
394
dexto/packages/webui/lib/stores/eventLogStore.test.ts
Normal file
394
dexto/packages/webui/lib/stores/eventLogStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
185
dexto/packages/webui/lib/stores/eventLogStore.ts
Normal file
185
dexto/packages/webui/lib/stores/eventLogStore.ts
Normal 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);
|
||||
},
|
||||
}));
|
||||
66
dexto/packages/webui/lib/stores/index.ts
Normal file
66
dexto/packages/webui/lib/stores/index.ts
Normal 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';
|
||||
165
dexto/packages/webui/lib/stores/notificationStore.test.ts
Normal file
165
dexto/packages/webui/lib/stores/notificationStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
114
dexto/packages/webui/lib/stores/notificationStore.ts
Normal file
114
dexto/packages/webui/lib/stores/notificationStore.ts
Normal 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: [] });
|
||||
},
|
||||
}));
|
||||
86
dexto/packages/webui/lib/stores/preferenceStore.test.ts
Normal file
86
dexto/packages/webui/lib/stores/preferenceStore.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
63
dexto/packages/webui/lib/stores/preferenceStore.ts
Normal file
63
dexto/packages/webui/lib/stores/preferenceStore.ts
Normal 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,
|
||||
}
|
||||
)
|
||||
);
|
||||
41
dexto/packages/webui/lib/stores/recentAgentsStore.ts
Normal file
41
dexto/packages/webui/lib/stores/recentAgentsStore.ts
Normal 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',
|
||||
}
|
||||
)
|
||||
);
|
||||
255
dexto/packages/webui/lib/stores/selectors.ts
Normal file
255
dexto/packages/webui/lib/stores/selectors.ts
Normal 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 };
|
||||
}
|
||||
169
dexto/packages/webui/lib/stores/sessionStore.test.ts
Normal file
169
dexto/packages/webui/lib/stores/sessionStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
236
dexto/packages/webui/lib/stores/sessionStore.ts
Normal file
236
dexto/packages/webui/lib/stores/sessionStore.ts
Normal 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;
|
||||
},
|
||||
}));
|
||||
92
dexto/packages/webui/lib/stores/todoStore.ts
Normal file
92
dexto/packages/webui/lib/stores/todoStore.ts
Normal 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 };
|
||||
});
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user