feat(dashboard): Enhanced chat, spec management, and Claude integration

Backend:
- spec.py: New AtomizerSpec REST API endpoints
- spec_manager.py: SpecManager service for unified config
- interview_engine.py: Study creation interview logic
- claude.py: Enhanced Claude API with context
- optimization.py: Extended optimization endpoints
- context_builder.py, session_manager.py: Improved services

Frontend:
- Chat components: Enhanced message rendering, tool call cards
- Hooks: useClaudeCode, useSpecWebSocket, improved useChat
- Pages: Updated Dashboard, Analysis, Insights, Setup, Home
- Components: ParallelCoordinatesPlot, ParetoPlot improvements
- App.tsx: Route updates for canvas/studio

Infrastructure:
- vite.config.ts: Build configuration updates
- start/stop-dashboard.bat: Script improvements
This commit is contained in:
2026-01-20 13:10:47 -05:00
parent b05412f807
commit ba0b9a1fae
31 changed files with 4836 additions and 349 deletions

View File

@@ -3,3 +3,27 @@ export { useCanvasStore } from './useCanvasStore';
export type { OptimizationConfig } from './useCanvasStore';
export { useCanvasChat } from './useCanvasChat';
export { useIntentParser } from './useIntentParser';
// Spec Store (AtomizerSpec v2.0)
export {
useSpecStore,
useSpec,
useSpecLoading,
useSpecError,
useSpecValidation,
useSelectedNodeId,
useSelectedEdgeId,
useSpecHash,
useSpecIsDirty,
useDesignVariables,
useExtractors,
useObjectives,
useConstraints,
useCanvasEdges,
useSelectedNode,
} from './useSpecStore';
// WebSocket Sync
export { useSpecWebSocket } from './useSpecWebSocket';
export type { ConnectionStatus } from './useSpecWebSocket';
export { ConnectionStatusIndicator } from '../components/canvas/ConnectionStatusIndicator';

View File

@@ -11,12 +11,25 @@ export interface CanvasState {
studyPath?: string;
}
export interface CanvasModification {
action: 'add_node' | 'update_node' | 'remove_node' | 'add_edge' | 'remove_edge';
nodeType?: string;
nodeId?: string;
edgeId?: string;
data?: Record<string, any>;
source?: string;
target?: string;
position?: { x: number; y: number };
}
interface UseChatOptions {
studyId?: string | null;
mode?: ChatMode;
useWebSocket?: boolean;
canvasState?: CanvasState | null;
onError?: (error: string) => void;
onCanvasModification?: (modification: CanvasModification) => void;
onSpecUpdated?: (spec: any) => void; // Called when Claude modifies the spec
}
interface ChatState {
@@ -35,6 +48,8 @@ export function useChat({
useWebSocket = true,
canvasState: initialCanvasState,
onError,
onCanvasModification,
onSpecUpdated,
}: UseChatOptions = {}) {
const [state, setState] = useState<ChatState>({
messages: [],
@@ -49,6 +64,23 @@ export function useChat({
// Track canvas state for sending with messages
const canvasStateRef = useRef<CanvasState | null>(initialCanvasState || null);
// Sync mode prop changes to internal state (triggers WebSocket reconnect)
useEffect(() => {
if (mode !== state.mode) {
console.log(`[useChat] Mode prop changed from ${state.mode} to ${mode}, triggering reconnect`);
// Close existing WebSocket
wsRef.current?.close();
wsRef.current = null;
// Update internal state to trigger reconnect
setState((prev) => ({
...prev,
mode,
sessionId: null,
isConnected: false,
}));
}
}, [mode]);
const abortControllerRef = useRef<AbortController | null>(null);
const conversationHistoryRef = useRef<Array<{ role: string; content: string }>>([]);
const wsRef = useRef<WebSocket | null>(null);
@@ -82,9 +114,16 @@ export function useChat({
const data = await response.json();
setState((prev) => ({ ...prev, sessionId: data.session_id }));
// Connect WebSocket
// Connect WebSocket - use backend directly in dev mode
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/claude/sessions/${data.session_id}/ws`;
// Use port 8001 to match start-dashboard.bat
const backendHost = import.meta.env.DEV ? 'localhost:8001' : window.location.host;
// Both modes use the same WebSocket - mode is handled by session config
// Power mode uses --dangerously-skip-permissions in CLI
// User mode uses --allowedTools to restrict access
const wsPath = `/api/claude/sessions/${data.session_id}/ws`;
const wsUrl = `${protocol}//${backendHost}${wsPath}`;
console.log(`[useChat] Connecting to WebSocket (${state.mode} mode): ${wsUrl}`);
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
@@ -126,6 +165,9 @@ export function useChat({
// Handle WebSocket messages
const handleWebSocketMessage = useCallback((data: any) => {
// Debug: log all incoming WebSocket messages
console.log('[useChat] WebSocket message received:', data.type, data);
switch (data.type) {
case 'text':
currentMessageRef.current += data.content || '';
@@ -212,11 +254,51 @@ export function useChat({
// Canvas state was updated - could show notification
break;
case 'canvas_modification':
// Assistant wants to modify the canvas (from MCP tools in user mode)
console.log('[useChat] Received canvas_modification:', data.modification);
if (onCanvasModification && data.modification) {
console.log('[useChat] Calling onCanvasModification callback');
onCanvasModification(data.modification);
} else {
console.warn('[useChat] canvas_modification received but no handler or modification:', {
hasCallback: !!onCanvasModification,
modification: data.modification
});
}
break;
case 'spec_updated':
// Assistant modified the spec - we receive the full updated spec
console.log('[useChat] Spec updated by assistant:', data.tool, data.reason);
if (onSpecUpdated && data.spec) {
// Directly update the canvas with the new spec
onSpecUpdated(data.spec);
}
break;
case 'spec_modified':
// Legacy: Assistant modified the spec directly (from power mode write tools)
console.log('[useChat] Spec was modified by assistant (legacy):', data.tool, data.changes);
// Treat this as a canvas modification to trigger reload
if (onCanvasModification) {
// Create a synthetic modification event to trigger canvas refresh
onCanvasModification({
action: 'add_node', // Use add_node as it triggers refresh
data: {
_refresh: true,
tool: data.tool,
changes: data.changes,
},
});
}
break;
case 'pong':
// Heartbeat response - ignore
break;
}
}, [onError]);
}, [onError, onCanvasModification]);
// Switch mode (requires new session)
const switchMode = useCallback(async (newMode: ChatMode) => {
@@ -462,6 +544,18 @@ export function useChat({
}
}, [useWebSocket]);
// Notify backend when user edits canvas (so Claude sees the changes)
const notifyCanvasEdit = useCallback((spec: any) => {
if (useWebSocket && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: 'canvas_edit',
spec: spec,
})
);
}
}, [useWebSocket]);
return {
messages: state.messages,
isThinking: state.isThinking,
@@ -475,5 +569,6 @@ export function useChat({
cancelRequest,
switchMode,
updateCanvasState,
notifyCanvasEdit,
};
}

View File

@@ -0,0 +1,349 @@
/**
* Hook for Claude Code CLI integration
*
* Connects to backend that spawns actual Claude Code CLI processes.
* This gives full power: file editing, command execution, etc.
*
* Unlike useChat (which uses MCP tools), this hook:
* - Spawns actual Claude Code CLI in the backend
* - Has full file system access
* - Can edit files directly (not just return instructions)
* - Uses Opus 4.5 model
* - Has all Claude Code capabilities
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { Message } from '../components/chat/ChatMessage';
import { useCanvasStore } from './useCanvasStore';
export interface CanvasState {
nodes: any[];
edges: any[];
studyName?: string;
studyPath?: string;
}
interface UseClaudeCodeOptions {
studyId?: string | null;
canvasState?: CanvasState | null;
onError?: (error: string) => void;
onCanvasRefresh?: (studyId: string) => void;
}
interface ClaudeCodeState {
messages: Message[];
isThinking: boolean;
error: string | null;
sessionId: string | null;
isConnected: boolean;
workingDir: string | null;
}
export function useClaudeCode({
studyId,
canvasState: initialCanvasState,
onError,
onCanvasRefresh,
}: UseClaudeCodeOptions = {}) {
const [state, setState] = useState<ClaudeCodeState>({
messages: [],
isThinking: false,
error: null,
sessionId: null,
isConnected: false,
workingDir: null,
});
// Track canvas state for sending with messages
const canvasStateRef = useRef<CanvasState | null>(initialCanvasState || null);
const wsRef = useRef<WebSocket | null>(null);
const currentMessageRef = useRef<string>('');
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 3;
// Keep canvas state in sync with prop changes
useEffect(() => {
if (initialCanvasState) {
canvasStateRef.current = initialCanvasState;
}
}, [initialCanvasState]);
// Get canvas store for auto-refresh
const { loadFromConfig } = useCanvasStore();
// Connect to Claude Code WebSocket
useEffect(() => {
const connect = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// In development, connect directly to backend (bypass Vite proxy for WebSockets)
// Use port 8001 to match start-dashboard.bat
const backendHost = import.meta.env.DEV ? 'localhost:8001' : window.location.host;
// Use study-specific endpoint if studyId provided
const wsUrl = studyId
? `${protocol}//${backendHost}/api/claude-code/ws/${encodeURIComponent(studyId)}`
: `${protocol}//${backendHost}/api/claude-code/ws`;
console.log('[ClaudeCode] Connecting to:', wsUrl);
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[ClaudeCode] Connected');
setState((prev) => ({ ...prev, isConnected: true, error: null }));
reconnectAttempts.current = 0;
// If no studyId in URL, send init message
if (!studyId) {
ws.send(JSON.stringify({ type: 'init', study_id: null }));
}
};
ws.onclose = () => {
console.log('[ClaudeCode] Disconnected');
setState((prev) => ({ ...prev, isConnected: false }));
// Attempt reconnection
if (reconnectAttempts.current < maxReconnectAttempts) {
reconnectAttempts.current++;
console.log(`[ClaudeCode] Reconnecting... attempt ${reconnectAttempts.current}`);
setTimeout(connect, 2000 * reconnectAttempts.current);
}
};
ws.onerror = (event) => {
console.error('[ClaudeCode] WebSocket error:', event);
setState((prev) => ({ ...prev, isConnected: false }));
onError?.('Claude Code connection error');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch (e) {
console.error('[ClaudeCode] Failed to parse message:', e);
}
};
wsRef.current = ws;
};
connect();
return () => {
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnection on unmount
wsRef.current?.close();
wsRef.current = null;
};
}, [studyId]);
// Handle WebSocket messages
const handleWebSocketMessage = useCallback(
(data: any) => {
switch (data.type) {
case 'initialized':
console.log('[ClaudeCode] Session initialized:', data.session_id);
setState((prev) => ({
...prev,
sessionId: data.session_id,
workingDir: data.working_dir || null,
}));
break;
case 'text':
currentMessageRef.current += data.content || '';
setState((prev) => ({
...prev,
messages: prev.messages.map((msg, idx) =>
idx === prev.messages.length - 1 && msg.role === 'assistant'
? { ...msg, content: currentMessageRef.current }
: msg
),
}));
break;
case 'done':
setState((prev) => ({
...prev,
isThinking: false,
messages: prev.messages.map((msg, idx) =>
idx === prev.messages.length - 1 && msg.role === 'assistant'
? { ...msg, isStreaming: false }
: msg
),
}));
currentMessageRef.current = '';
break;
case 'error':
console.error('[ClaudeCode] Error:', data.content);
setState((prev) => ({
...prev,
isThinking: false,
error: data.content || 'Unknown error',
}));
onError?.(data.content || 'Unknown error');
currentMessageRef.current = '';
break;
case 'refresh_canvas':
// Claude made file changes - trigger canvas refresh
console.log('[ClaudeCode] Canvas refresh requested:', data.reason);
if (data.study_id) {
onCanvasRefresh?.(data.study_id);
reloadCanvasFromStudy(data.study_id);
}
break;
case 'canvas_updated':
console.log('[ClaudeCode] Canvas state updated');
break;
case 'pong':
// Heartbeat response
break;
default:
console.log('[ClaudeCode] Unknown message type:', data.type);
}
},
[onError, onCanvasRefresh]
);
// Reload canvas from study config
const reloadCanvasFromStudy = useCallback(
async (studyIdToReload: string) => {
try {
console.log('[ClaudeCode] Reloading canvas for study:', studyIdToReload);
// Fetch fresh config from backend
const response = await fetch(`/api/optimization/studies/${encodeURIComponent(studyIdToReload)}/config`);
if (!response.ok) {
throw new Error(`Failed to fetch config: ${response.status}`);
}
const data = await response.json();
const config = data.config; // API returns { config: ..., path: ..., study_id: ... }
// Reload canvas with new config
loadFromConfig(config);
// Add system message about refresh
const refreshMessage: Message = {
id: `msg_${Date.now()}_refresh`,
role: 'system',
content: `Canvas refreshed with latest changes from ${studyIdToReload}`,
timestamp: new Date(),
};
setState((prev) => ({
...prev,
messages: [...prev.messages, refreshMessage],
}));
} catch (error) {
console.error('[ClaudeCode] Failed to reload canvas:', error);
}
},
[loadFromConfig]
);
const generateMessageId = () => {
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
const sendMessage = useCallback(
async (content: string) => {
if (!content.trim() || state.isThinking) return;
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
onError?.('Not connected to Claude Code');
return;
}
// Add user message
const userMessage: Message = {
id: generateMessageId(),
role: 'user',
content: content.trim(),
timestamp: new Date(),
};
// Add assistant message placeholder
const assistantMessage: Message = {
id: generateMessageId(),
role: 'assistant',
content: '',
timestamp: new Date(),
isStreaming: true,
};
setState((prev) => ({
...prev,
messages: [...prev.messages, userMessage, assistantMessage],
isThinking: true,
error: null,
}));
// Reset current message tracking
currentMessageRef.current = '';
// Send message via WebSocket with canvas state
wsRef.current.send(
JSON.stringify({
type: 'message',
content: content.trim(),
canvas_state: canvasStateRef.current || undefined,
})
);
},
[state.isThinking, onError]
);
const clearMessages = useCallback(() => {
setState((prev) => ({
...prev,
messages: [],
error: null,
}));
currentMessageRef.current = '';
}, []);
// Update canvas state (call this when canvas changes)
const updateCanvasState = useCallback((newCanvasState: CanvasState | null) => {
canvasStateRef.current = newCanvasState;
// Also send to backend to update context
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: 'set_canvas',
canvas_state: newCanvasState,
})
);
}
}, []);
// Send ping to keep connection alive
useEffect(() => {
const pingInterval = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // Every 30 seconds
return () => clearInterval(pingInterval);
}, []);
return {
messages: state.messages,
isThinking: state.isThinking,
error: state.error,
sessionId: state.sessionId,
isConnected: state.isConnected,
workingDir: state.workingDir,
sendMessage,
clearMessages,
updateCanvasState,
reloadCanvasFromStudy,
};
}

View File

@@ -0,0 +1,288 @@
/**
* useSpecWebSocket - WebSocket connection for real-time spec sync
*
* Connects to the backend WebSocket endpoint for live spec updates.
* Handles auto-reconnection, message parsing, and store updates.
*
* P2.11-P2.14: WebSocket sync implementation
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { useSpecStore } from './useSpecStore';
// ============================================================================
// Types
// ============================================================================
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
interface SpecWebSocketMessage {
type: 'modification' | 'full_sync' | 'error' | 'ping';
payload: unknown;
}
interface ModificationPayload {
operation: 'set' | 'add' | 'remove';
path: string;
value?: unknown;
modified_by: string;
timestamp: string;
hash: string;
}
interface ErrorPayload {
message: string;
code?: string;
}
interface UseSpecWebSocketOptions {
/**
* Enable auto-reconnect on disconnect (default: true)
*/
autoReconnect?: boolean;
/**
* Reconnect delay in ms (default: 3000)
*/
reconnectDelay?: number;
/**
* Max reconnect attempts (default: 10)
*/
maxReconnectAttempts?: number;
/**
* Client identifier for tracking modifications (default: 'canvas')
*/
clientId?: string;
}
interface UseSpecWebSocketReturn {
/**
* Current connection status
*/
status: ConnectionStatus;
/**
* Manually disconnect
*/
disconnect: () => void;
/**
* Manually reconnect
*/
reconnect: () => void;
/**
* Send a message to the WebSocket (for future use)
*/
send: (message: SpecWebSocketMessage) => void;
/**
* Last error message if any
*/
lastError: string | null;
}
// ============================================================================
// Hook
// ============================================================================
export function useSpecWebSocket(
studyId: string | null,
options: UseSpecWebSocketOptions = {}
): UseSpecWebSocketReturn {
const {
autoReconnect = true,
reconnectDelay = 3000,
maxReconnectAttempts = 10,
clientId = 'canvas',
} = options;
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttemptsRef = useRef(0);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [lastError, setLastError] = useState<string | null>(null);
// Get store actions
const reloadSpec = useSpecStore((s) => s.reloadSpec);
const setError = useSpecStore((s) => s.setError);
// Build WebSocket URL
const getWsUrl = useCallback((id: string): string => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
return `${protocol}//${host}/api/studies/${encodeURIComponent(id)}/spec/sync?client_id=${clientId}`;
}, [clientId]);
// Handle incoming messages
const handleMessage = useCallback((event: MessageEvent) => {
try {
const message: SpecWebSocketMessage = JSON.parse(event.data);
switch (message.type) {
case 'modification': {
const payload = message.payload as ModificationPayload;
// Skip if this is our own modification
if (payload.modified_by === clientId) {
return;
}
// Reload spec to get latest state
// In a more sophisticated implementation, we could apply the patch locally
reloadSpec().catch((err) => {
console.error('Failed to reload spec after modification:', err);
});
break;
}
case 'full_sync': {
// Full spec sync requested (e.g., after reconnect)
reloadSpec().catch((err) => {
console.error('Failed to reload spec during full_sync:', err);
});
break;
}
case 'error': {
const payload = message.payload as ErrorPayload;
console.error('WebSocket error:', payload.message);
setLastError(payload.message);
setError(payload.message);
break;
}
case 'ping': {
// Keep-alive ping, respond with pong
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'pong' }));
}
break;
}
default:
console.warn('Unknown WebSocket message type:', message.type);
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
}, [clientId, reloadSpec, setError]);
// Connect to WebSocket
const connect = useCallback(() => {
if (!studyId) return;
// Clean up existing connection
if (wsRef.current) {
wsRef.current.close();
}
setStatus('connecting');
setLastError(null);
const url = getWsUrl(studyId);
const ws = new WebSocket(url);
ws.onopen = () => {
setStatus('connected');
reconnectAttemptsRef.current = 0;
};
ws.onmessage = handleMessage;
ws.onerror = (event) => {
console.error('WebSocket error:', event);
setLastError('WebSocket connection error');
};
ws.onclose = (_event) => {
setStatus('disconnected');
// Check if we should reconnect
if (autoReconnect && reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current++;
setStatus('reconnecting');
// Clear any existing reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
// Schedule reconnect with exponential backoff
const delay = reconnectDelay * Math.min(reconnectAttemptsRef.current, 5);
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, delay);
} else if (reconnectAttemptsRef.current >= maxReconnectAttempts) {
setLastError('Max reconnection attempts reached');
}
};
wsRef.current = ws;
}, [studyId, getWsUrl, handleMessage, autoReconnect, reconnectDelay, maxReconnectAttempts]);
// Disconnect
const disconnect = useCallback(() => {
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
reconnectAttemptsRef.current = maxReconnectAttempts; // Prevent auto-reconnect
setStatus('disconnected');
}, [maxReconnectAttempts]);
// Reconnect
const reconnect = useCallback(() => {
reconnectAttemptsRef.current = 0;
connect();
}, [connect]);
// Send message
const send = useCallback((message: SpecWebSocketMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
console.warn('WebSocket not connected, cannot send message');
}
}, []);
// Connect when studyId changes
useEffect(() => {
if (studyId) {
connect();
} else {
disconnect();
}
return () => {
// Cleanup on unmount or studyId change
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [studyId, connect, disconnect]);
return {
status,
disconnect,
reconnect,
send,
lastError,
};
}
export default useSpecWebSocket;

View File

@@ -18,7 +18,8 @@ export const useOptimizationWebSocket = ({ studyId, onMessage }: UseOptimization
const host = window.location.host; // This will be localhost:3000 in dev
// If using proxy in vite.config.ts, this works.
// If not, we might need to hardcode backend URL for dev:
const backendHost = import.meta.env.DEV ? 'localhost:8000' : host;
// Use port 8001 to match start-dashboard.bat
const backendHost = import.meta.env.DEV ? 'localhost:8001' : host;
setSocketUrl(`${protocol}//${backendHost}/api/ws/optimization/${studyId}`);
} else {