diff --git a/ccw/frontend/src/components/ui/Dialog.tsx b/ccw/frontend/src/components/ui/Dialog.tsx index d0b130da..4b49a652 100644 --- a/ccw/frontend/src/components/ui/Dialog.tsx +++ b/ccw/frontend/src/components/ui/Dialog.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { X } from "lucide-react"; +import { ArrowLeft, X } from "lucide-react"; import { cn } from "@/lib/utils"; const Dialog = DialogPrimitive.Root; @@ -28,26 +28,51 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)); + React.ComponentPropsWithoutRef & { + fullscreen?: boolean; + } +>(({ className, children, fullscreen = false, ...props }, ref) => { + if (fullscreen) { + return ( + + + {children} + + + Back + + + + ); + } + + return ( + + + + {children} + + + Close + + + + ); +}); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ diff --git a/ccw/frontend/src/components/ui/Drawer.tsx b/ccw/frontend/src/components/ui/Drawer.tsx new file mode 100644 index 00000000..091c2a39 --- /dev/null +++ b/ccw/frontend/src/components/ui/Drawer.tsx @@ -0,0 +1,165 @@ +// ======================================== +// Drawer Component +// ======================================== +// Side drawer for A2UI surfaces with slide animation +// Supports left/right positioning and multiple sizes + +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; + +// ========== Variants ========== + +const drawerVariants = cva( + 'fixed z-50 gap-4 bg-card p-6 shadow-lg border-border ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', + { + variants: { + side: { + left: 'inset-y-0 left-0 h-full border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left', + right: 'inset-y-0 right-0 h-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right', + }, + size: { + sm: 'w-80', + md: 'w-96', + lg: 'w-[540px]', + xl: 'w-[720px]', + full: 'w-full', + }, + }, + defaultVariants: { + side: 'right', + size: 'md', + }, + } +); + +// ========== Root Components ========== + +const Drawer = DialogPrimitive.Root; +const DrawerTrigger = DialogPrimitive.Trigger; +const DrawerClose = DialogPrimitive.Close; +const DrawerPortal = DialogPrimitive.Portal; + +// ========== Overlay ========== + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = DialogPrimitive.Overlay.displayName; + +// ========== Content ========== + +interface DrawerContentProps + extends React.ComponentPropsWithoutRef, + VariantProps { + /** Whether to show the close button */ + showClose?: boolean; + /** Whether clicking outside should close the drawer */ + closeOnOutsideClick?: boolean; +} + +const DrawerContent = React.forwardRef< + React.ElementRef, + DrawerContentProps +>( + ( + { side = 'right', size = 'md', showClose = true, closeOnOutsideClick = true, className, children, ...props }, + ref + ) => ( + + + { + if (!closeOnOutsideClick) { + e.preventDefault(); + } + }} + {...props} + > + {showClose && ( + + + Close + + )} + {children} + + + ) +); +DrawerContent.displayName = DialogPrimitive.Content.displayName; + +// ========== Header ========== + +const DrawerHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = 'DrawerHeader'; + +// ========== Footer ========== + +const DrawerFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +DrawerFooter.displayName = 'DrawerFooter'; + +// ========== Title ========== + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = DialogPrimitive.Title.displayName; + +// ========== Description ========== + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerDescription.displayName = DialogPrimitive.Description.displayName; + +// ========== Exports ========== + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, + type DrawerContentProps, +}; diff --git a/ccw/frontend/src/contexts/DialogStyleContext.tsx b/ccw/frontend/src/contexts/DialogStyleContext.tsx new file mode 100644 index 00000000..ef6a4c81 --- /dev/null +++ b/ccw/frontend/src/contexts/DialogStyleContext.tsx @@ -0,0 +1,166 @@ +// ======================================== +// DialogStyleContext +// ======================================== +// Context provider for A2UI dialog style preferences +// Supports modal, drawer, sheet, and fullscreen modes + +import { createContext, useContext, useCallback, useMemo } from 'react'; +import { useConfigStore } from '@/stores/configStore'; + +// ========== Types ========== + +export type DialogStyle = 'modal' | 'drawer' | 'sheet' | 'fullscreen'; + +export interface A2UIPreferences { + /** Default dialog style */ + dialogStyle: DialogStyle; + /** Enable smart mode - auto-select style based on question type */ + smartModeEnabled: boolean; + /** Auto-selection countdown duration in seconds */ + autoSelectionDuration: number; + /** Enable sound notification before auto-submit */ + autoSelectionSoundEnabled: boolean; + /** Pause countdown on user interaction */ + pauseOnInteraction: boolean; + /** Show A2UI quick action button in toolbar */ + showA2UIButtonInToolbar: boolean; + /** Drawer side preference */ + drawerSide: 'left' | 'right'; + /** Drawer size preference */ + drawerSize: 'sm' | 'md' | 'lg' | 'xl' | 'full'; +} + +export interface DialogStyleContextValue { + /** Current preferences */ + preferences: A2UIPreferences; + /** Update a preference */ + updatePreference: ( + key: K, + value: A2UIPreferences[K] + ) => void; + /** Reset to defaults */ + resetPreferences: () => void; + /** Get recommended style for a question type */ + getRecommendedStyle: (questionType: string) => DialogStyle; +} + +// ========== Constants ========== + +export const DEFAULT_A2UI_PREFERENCES: A2UIPreferences = { + dialogStyle: 'modal', + smartModeEnabled: true, + autoSelectionDuration: 30, + autoSelectionSoundEnabled: false, + pauseOnInteraction: true, + showA2UIButtonInToolbar: true, + drawerSide: 'right', + drawerSize: 'md', +}; + +/** Style recommendations based on question type */ +const STYLE_RECOMMENDATIONS: Record = { + confirm: 'modal', + select: 'modal', + 'multi-select': 'drawer', + input: 'modal', + 'multi-question': 'drawer', + form: 'drawer', + wizard: 'fullscreen', + complex: 'drawer', +}; + +// ========== Context ========== + +const DialogStyleContext = createContext(null); + +// ========== Provider ========== + +interface DialogStyleProviderProps { + children: React.ReactNode; +} + +export function DialogStyleProvider({ children }: DialogStyleProviderProps) { + // Get preferences from config store + const a2uiPreferences = useConfigStore((state) => state.a2uiPreferences); + const setA2uiPreferences = useConfigStore((state) => state.setA2uiPreferences); + + // Ensure we have default values + const preferences: A2UIPreferences = useMemo( + () => ({ + ...DEFAULT_A2UI_PREFERENCES, + ...a2uiPreferences, + }), + [a2uiPreferences] + ); + + // Update a single preference + const updatePreference = useCallback( + (key: K, value: A2UIPreferences[K]) => { + setA2uiPreferences({ + ...preferences, + [key]: value, + }); + }, + [preferences, setA2uiPreferences] + ); + + // Reset to defaults + const resetPreferences = useCallback(() => { + setA2uiPreferences(DEFAULT_A2UI_PREFERENCES); + }, [setA2uiPreferences]); + + // Get recommended style based on question type + const getRecommendedStyle = useCallback( + (questionType: string): DialogStyle => { + if (!preferences.smartModeEnabled) { + return preferences.dialogStyle; + } + return STYLE_RECOMMENDATIONS[questionType] || preferences.dialogStyle; + }, + [preferences] + ); + + const value = useMemo( + () => ({ + preferences, + updatePreference, + resetPreferences, + getRecommendedStyle, + }), + [preferences, updatePreference, resetPreferences, getRecommendedStyle] + ); + + return ( + + {children} + + ); +} + +// ========== Hook ========== + +export function useDialogStyleContext(): DialogStyleContextValue { + const context = useContext(DialogStyleContext); + if (!context) { + throw new Error('useDialogStyleContext must be used within a DialogStyleProvider'); + } + return context; +} + +// Convenience hook for just getting the current style +export function useDialogStyle(): { + style: DialogStyle; + preferences: A2UIPreferences; + getRecommendedStyle: (questionType: string) => DialogStyle; +} { + const { preferences, getRecommendedStyle } = useDialogStyleContext(); + return { + style: preferences.dialogStyle, + preferences, + getRecommendedStyle, + }; +} + +// ========== Exports ========== + +export { DialogStyleContext }; diff --git a/ccw/frontend/src/hooks/useWebSocket.ts b/ccw/frontend/src/hooks/useWebSocket.ts index 6d925d64..590ad814 100644 --- a/ccw/frontend/src/hooks/useWebSocket.ts +++ b/ccw/frontend/src/hooks/useWebSocket.ts @@ -440,6 +440,12 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet s.setWsStatus('connected'); s.resetReconnectAttempts(); reconnectDelayRef.current = RECONNECT_DELAY_BASE; + + // Request any pending questions from backend + ws.send(JSON.stringify({ + type: 'FRONTEND_READY', + payload: { action: 'requestPendingQuestions' } + })); }; ws.onmessage = handleMessage; diff --git a/ccw/src/core/a2ui/A2UIWebSocketHandler.ts b/ccw/src/core/a2ui/A2UIWebSocketHandler.ts index 7430af43..4e321e99 100644 --- a/ccw/src/core/a2ui/A2UIWebSocketHandler.ts +++ b/ccw/src/core/a2ui/A2UIWebSocketHandler.ts @@ -7,7 +7,8 @@ import type { Duplex } from 'stream'; import http from 'http'; import type { IncomingMessage } from 'http'; import { createWebSocketFrame, parseWebSocketFrame, wsClients } from '../websocket.js'; -import type { QuestionAnswer, AskQuestionParams, Question } from './A2UITypes.js'; +import type { QuestionAnswer, AskQuestionParams, Question, PendingQuestion } from './A2UITypes.js'; +import { getAllPendingQuestions } from '../services/pending-question-service.js'; const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456); @@ -604,6 +605,215 @@ export class A2UIWebSocketHandler { // ========== WebSocket Integration ========== +/** + * Generate A2UI surface for a pending question + * This is used to resend pending questions when frontend reconnects + */ +function generatePendingQuestionSurface(pq: PendingQuestion): { + surfaceId: string; + components: unknown[]; + initialState: Record; + displayMode?: 'popup' | 'panel'; +} | null { + const question = pq.question; + const components: unknown[] = []; + + // Add title + components.push({ + id: 'title', + component: { + Text: { + text: { literalString: question.title }, + usageHint: 'h3', + }, + }, + }); + + // Add message if provided + if (question.message) { + components.push({ + id: 'message', + component: { + Text: { + text: { literalString: question.message }, + usageHint: 'p', + }, + }, + }); + } + + // Add description if provided + if (question.description) { + components.push({ + id: 'description', + component: { + Text: { + text: { literalString: question.description }, + usageHint: 'small', + }, + }, + }); + } + + // Add interactive components based on question type + switch (question.type) { + case 'confirm': + components.push({ + id: 'confirm-btn', + component: { + Button: { + onClick: { actionId: 'confirm', parameters: { questionId: question.id } }, + content: { Text: { text: { literalString: 'Confirm' } } }, + variant: 'primary', + }, + }, + }); + components.push({ + id: 'cancel-btn', + component: { + Button: { + onClick: { actionId: 'cancel', parameters: { questionId: question.id } }, + content: { Text: { text: { literalString: 'Cancel' } } }, + variant: 'secondary', + }, + }, + }); + break; + + case 'select': + if (question.options && question.options.length > 0) { + const options = question.options.map((opt) => ({ + label: { literalString: opt.label }, + value: opt.value, + description: opt.description ? { literalString: opt.description } : undefined, + isDefault: question.defaultValue !== undefined && opt.value === String(question.defaultValue), + })); + + options.push({ + label: { literalString: 'Other' }, + value: '__other__', + description: { literalString: 'Provide a custom answer' }, + isDefault: false, + }); + + components.push({ + id: 'radio-group', + component: { + RadioGroup: { + options, + selectedValue: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined, + onChange: { actionId: 'select', parameters: { questionId: question.id } }, + }, + }, + }); + + components.push({ + id: 'submit-btn', + component: { + Button: { + onClick: { actionId: 'submit', parameters: { questionId: question.id } }, + content: { Text: { text: { literalString: 'Submit' } } }, + variant: 'primary', + }, + }, + }); + components.push({ + id: 'cancel-btn', + component: { + Button: { + onClick: { actionId: 'cancel', parameters: { questionId: question.id } }, + content: { Text: { text: { literalString: 'Cancel' } } }, + variant: 'secondary', + }, + }, + }); + } + break; + + case 'multi-select': + if (question.options && question.options.length > 0) { + question.options.forEach((opt, idx) => { + components.push({ + id: `checkbox-${idx}`, + component: { + Checkbox: { + label: { literalString: opt.label }, + ...(opt.description && { description: { literalString: opt.description } }), + onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: opt.value } }, + checked: { literalBoolean: false }, + }, + }, + }); + }); + + components.push({ + id: 'checkbox-other', + component: { + Checkbox: { + label: { literalString: 'Other' }, + description: { literalString: 'Provide a custom answer' }, + onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: '__other__' } }, + checked: { literalBoolean: false }, + }, + }, + }); + + components.push({ + id: 'submit-btn', + component: { + Button: { + onClick: { actionId: 'submit', parameters: { questionId: question.id } }, + content: { Text: { text: { literalString: 'Submit' } } }, + variant: 'primary', + }, + }, + }); + components.push({ + id: 'cancel-btn', + component: { + Button: { + onClick: { actionId: 'cancel', parameters: { questionId: question.id } }, + content: { Text: { text: { literalString: 'Cancel' } } }, + variant: 'secondary', + }, + }, + }); + } + break; + + case 'input': + components.push({ + id: 'input', + component: { + TextField: { + value: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined, + onChange: { actionId: 'answer', parameters: { questionId: question.id } }, + placeholder: question.placeholder || 'Enter your answer', + type: 'text', + }, + }, + }); + break; + + default: + return null; + } + + return { + surfaceId: pq.surfaceId, + components, + initialState: { + questionId: question.id, + questionType: question.type, + options: question.options, + required: question.required, + timeoutAt: new Date(pq.timestamp + pq.timeout).toISOString(), + ...(question.defaultValue !== undefined && { defaultValue: question.defaultValue }), + }, + displayMode: 'popup', + }; +} + /** * Handle A2UI messages in WebSocket data handler * Called from main WebSocket handler @@ -620,6 +830,23 @@ export function handleA2UIMessage( try { const data = JSON.parse(payload); + // Handle FRONTEND_READY - frontend requesting pending questions + if (data.type === 'FRONTEND_READY' && data.payload?.action === 'requestPendingQuestions') { + console.log('[A2UI] Frontend ready, sending pending questions...'); + const pendingQuestions = getAllPendingQuestions(); + + for (const pq of pendingQuestions) { + // Regenerate surface for each pending question + const surfaceUpdate = generatePendingQuestionSurface(pq); + if (surfaceUpdate) { + a2uiHandler.sendSurface(surfaceUpdate); + } + } + + console.log(`[A2UI] Sent ${pendingQuestions.length} pending questions to frontend`); + return true; + } + // Handle A2UI action messages if (data.type === 'a2ui-action') { const action = data as A2UIActionMessage; diff --git a/ccw/src/core/services/pending-question-service.ts b/ccw/src/core/services/pending-question-service.ts new file mode 100644 index 00000000..86e21f05 --- /dev/null +++ b/ccw/src/core/services/pending-question-service.ts @@ -0,0 +1,244 @@ +// ======================================== +// Pending Question Service +// ======================================== +// Manages persistent storage of pending questions for ask_question tool + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import type { PendingQuestion } from '../a2ui/A2UITypes.js'; + +// Storage configuration +const STORAGE_DIR = join(homedir(), '.ccw', 'pending-questions'); +const STORAGE_FILE = join(STORAGE_DIR, 'questions.json'); + +// In-memory cache of pending questions +const pendingQuestions = new Map(); + +// Flag to track if service has been initialized +let initialized = false; + +/** + * Serializable representation of a pending question (without resolve/reject functions) + */ +interface SerializedPendingQuestion { + id: string; + surfaceId: string; + question: { + id: string; + type: string; + title: string; + message?: string; + description?: string; + options?: Array<{ value: string; label: string; description?: string }>; + defaultValue?: string | string[] | boolean; + required?: boolean; + placeholder?: string; + timeout?: number; + }; + timestamp: number; + timeout: number; +} + +/** + * Initialize the service by loading pending questions from disk. + * Called automatically on module load. + */ +function initialize(): void { + if (initialized) return; + + try { + // Ensure storage directory exists + if (!existsSync(STORAGE_DIR)) { + mkdirSync(STORAGE_DIR, { recursive: true }); + } + + // Load existing questions from disk + if (existsSync(STORAGE_FILE)) { + const content = readFileSync(STORAGE_FILE, 'utf8'); + const serialized: SerializedPendingQuestion[] = JSON.parse(content); + + for (const sq of serialized) { + // Create a PendingQuestion with placeholder resolve/reject + // These will be replaced when the question is actually awaited + const pendingQ: PendingQuestion = { + id: sq.id, + surfaceId: sq.surfaceId, + question: sq.question as PendingQuestion['question'], + timestamp: sq.timestamp, + timeout: sq.timeout, + resolve: () => { + console.warn(`[PendingQuestionService] Resolve called for restored question ${sq.id} - no promise attached`); + }, + reject: () => { + console.warn(`[PendingQuestionService] Reject called for restored question ${sq.id} - no promise attached`); + }, + }; + pendingQuestions.set(sq.id, pendingQ); + } + + console.log(`[PendingQuestionService] Loaded ${pendingQuestions.size} pending questions from storage`); + } + + initialized = true; + } catch (error) { + console.error('[PendingQuestionService] Failed to initialize:', error); + initialized = true; // Still mark as initialized to prevent retry loops + } +} + +/** + * Persist current pending questions to disk. + */ +function persistQuestions(): void { + try { + // Ensure storage directory exists + if (!existsSync(STORAGE_DIR)) { + mkdirSync(STORAGE_DIR, { recursive: true }); + } + + const serialized: SerializedPendingQuestion[] = []; + + for (const pq of pendingQuestions.values()) { + serialized.push({ + id: pq.id, + surfaceId: pq.surfaceId, + question: { + id: pq.question.id, + type: pq.question.type, + title: pq.question.title, + message: pq.question.message, + description: pq.question.description, + options: pq.question.options, + defaultValue: pq.question.defaultValue, + required: pq.question.required, + placeholder: pq.question.placeholder, + timeout: pq.timeout, + }, + timestamp: pq.timestamp, + timeout: pq.timeout, + }); + } + + writeFileSync(STORAGE_FILE, JSON.stringify(serialized, null, 2), 'utf8'); + } catch (error) { + console.error('[PendingQuestionService] Failed to persist questions:', error); + } +} + +/** + * Add a pending question to storage. + * @param pendingQ - The pending question to add + */ +export function addPendingQuestion(pendingQ: PendingQuestion): void { + initialize(); + pendingQuestions.set(pendingQ.id, pendingQ); + persistQuestions(); + console.log(`[PendingQuestionService] Added pending question: ${pendingQ.id}`); +} + +/** + * Get a pending question by ID. + * @param questionId - The question ID + * @returns The pending question or undefined + */ +export function getPendingQuestion(questionId: string): PendingQuestion | undefined { + initialize(); + return pendingQuestions.get(questionId); +} + +/** + * Update an existing pending question (e.g., to attach new resolve/reject). + * @param questionId - The question ID + * @param pendingQ - The updated pending question + */ +export function updatePendingQuestion(questionId: string, pendingQ: PendingQuestion): boolean { + initialize(); + if (pendingQuestions.has(questionId)) { + pendingQuestions.set(questionId, pendingQ); + // Don't persist here - resolve/reject functions aren't serializable + return true; + } + return false; +} + +/** + * Remove a pending question from storage. + * @param questionId - The question ID to remove + * @returns True if the question was found and removed + */ +export function removePendingQuestion(questionId: string): boolean { + initialize(); + const existed = pendingQuestions.delete(questionId); + if (existed) { + persistQuestions(); + console.log(`[PendingQuestionService] Removed pending question: ${questionId}`); + } + return existed; +} + +/** + * Get all pending questions. + * @returns Array of all pending questions + */ +export function getAllPendingQuestions(): PendingQuestion[] { + initialize(); + return Array.from(pendingQuestions.values()); +} + +/** + * Check if a pending question exists. + * @param questionId - The question ID to check + * @returns True if the question exists + */ +export function hasPendingQuestion(questionId: string): boolean { + initialize(); + return pendingQuestions.has(questionId); +} + +/** + * Get the count of pending questions. + * @returns Number of pending questions + */ +export function getPendingQuestionCount(): number { + initialize(); + return pendingQuestions.size; +} + +/** + * Clear all pending questions from storage. + */ +export function clearAllPendingQuestions(): void { + initialize(); + pendingQuestions.clear(); + persistQuestions(); + console.log(`[PendingQuestionService] Cleared all pending questions`); +} + +/** + * Clean up expired questions (older than their timeout). + * This can be called periodically to prevent stale data accumulation. + * @returns Number of questions removed + */ +export function cleanupExpiredQuestions(): number { + initialize(); + const now = Date.now(); + let removed = 0; + + for (const [id, pq] of pendingQuestions) { + if (now - pq.timestamp > pq.timeout) { + pendingQuestions.delete(id); + removed++; + } + } + + if (removed > 0) { + persistQuestions(); + console.log(`[PendingQuestionService] Cleaned up ${removed} expired questions`); + } + + return removed; +} + +// Initialize on module load +initialize(); diff --git a/ccw/src/tools/ask-question.ts b/ccw/src/tools/ask-question.ts index 41350f12..e846751c 100644 --- a/ccw/src/tools/ask-question.ts +++ b/ccw/src/tools/ask-question.ts @@ -18,6 +18,18 @@ import type { import http from 'http'; import { a2uiWebSocketHandler } from '../core/a2ui/A2UIWebSocketHandler.js'; import { remoteNotificationService } from '../core/services/remote-notification-service.js'; +import { + addPendingQuestion, + getPendingQuestion, + removePendingQuestion, + getAllPendingQuestions, + clearAllPendingQuestions, + hasPendingQuestion, +} from '../core/services/pending-question-service.js'; +import { + isDashboardServerRunning, + startCcwServeProcess, +} from '../utils/dashboard-launcher.js'; const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456); const POLL_INTERVAL_MS = 1000; @@ -32,9 +44,6 @@ a2uiWebSocketHandler.registerMultiAnswerCallback( /** Default question timeout (5 minutes) */ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; -/** Map of pending questions waiting for responses */ -const pendingQuestions = new Map(); - // ========== Validation ========== /** @@ -454,12 +463,13 @@ export async function execute(params: AskQuestionParams): Promise { - if (pendingQuestions.has(question.id)) { - pendingQuestions.delete(question.id); + const timedOutQuestion = getPendingQuestion(question.id); + if (timedOutQuestion) { + removePendingQuestion(question.id); if (question.defaultValue !== undefined) { resolve({ success: true, @@ -495,8 +505,17 @@ export async function execute(params: AskQuestionParams): Promise { // Stop if the question was already resolved or timed out - if (!pendingQuestions.has(questionId)) { + if (!hasPendingQuestion(questionId)) { console.error(`[A2UI-Poll] Stopping: questionId=${questionId} no longer pending`); return; } @@ -614,14 +633,14 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v if (isComposite && Array.isArray(parsed.answers)) { const ok = handleMultiAnswer(questionId, parsed.answers as QuestionAnswer[]); console.error(`[A2UI-Poll] handleMultiAnswer result: ${ok}`); - if (!ok && pendingQuestions.has(questionId)) { + if (!ok && hasPendingQuestion(questionId)) { // Answer consumed but delivery failed; keep polling for a new answer setTimeout(poll, POLL_INTERVAL_MS); } } else if (!isComposite && parsed.answer) { const ok = handleAnswer(parsed.answer as QuestionAnswer); console.error(`[A2UI-Poll] handleAnswer result: ${ok}`); - if (!ok && pendingQuestions.has(questionId)) { + if (!ok && hasPendingQuestion(questionId)) { // Answer consumed but validation/delivery failed; keep polling for a new answer setTimeout(poll, POLL_INTERVAL_MS); } @@ -638,7 +657,7 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v req.on('error', (err) => { console.error(`[A2UI-Poll] Network error: ${err.message}`); - if (pendingQuestions.has(questionId)) { + if (hasPendingQuestion(questionId)) { setTimeout(poll, POLL_INTERVAL_MS); } }); @@ -660,7 +679,7 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v * @returns True if question was cancelled */ export function cancelQuestion(questionId: string): boolean { - const pending = pendingQuestions.get(questionId); + const pending = getPendingQuestion(questionId); if (!pending) { return false; } @@ -674,7 +693,7 @@ export function cancelQuestion(questionId: string): boolean { error: 'Question cancelled', }); - pendingQuestions.delete(questionId); + removePendingQuestion(questionId); return true; } @@ -683,17 +702,17 @@ export function cancelQuestion(questionId: string): boolean { * @returns Array of pending questions */ export function getPendingQuestions(): PendingQuestion[] { - return Array.from(pendingQuestions.values()); + return getAllPendingQuestions(); } /** * Clear all pending questions */ export function clearPendingQuestions(): void { - for (const pending of pendingQuestions.values()) { + for (const pending of getAllPendingQuestions()) { pending.reject(new Error('Question cleared')); } - pendingQuestions.clear(); + clearAllPendingQuestions(); } // ========== Tool Schema ========== @@ -1076,7 +1095,7 @@ async function executeSimpleFormat( resolve, reject, }; - pendingQuestions.set(compositeId, pendingQuestion); + addPendingQuestion(pendingQuestion); // Also register each sub-question's questionId pointing to the same pending entry // so that select/toggle actions on individual questions get tracked @@ -1090,8 +1109,9 @@ async function executeSimpleFormat( } setTimeout(() => { - if (pendingQuestions.has(compositeId)) { - pendingQuestions.delete(compositeId); + const timedOutQuestion = getPendingQuestion(compositeId); + if (timedOutQuestion) { + removePendingQuestion(compositeId); // Collect default values from each sub-question const defaultAnswers: QuestionAnswer[] = []; for (const simpleQ of questions) { diff --git a/ccw/src/utils/dashboard-launcher.ts b/ccw/src/utils/dashboard-launcher.ts new file mode 100644 index 00000000..9f5f3c93 --- /dev/null +++ b/ccw/src/utils/dashboard-launcher.ts @@ -0,0 +1,201 @@ +// ======================================== +// Dashboard Launcher Utility +// ======================================== +// Detects Dashboard server status and auto-starts if needed + +import { spawn, type ChildProcess } from 'child_process'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import http from 'http'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Constants +const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456); +const DASHBOARD_HOST = process.env.CCW_HOST || '127.0.0.1'; +const DASHBOARD_CHECK_TIMEOUT_MS = 500; +const DASHBOARD_STARTUP_TIMEOUT_MS = 30000; +const DASHBOARD_STARTUP_POLL_INTERVAL_MS = 500; + +// Path to CCW CLI (adjust based on build output location) +const CCW_CLI_PATH = join(__dirname, '../../bin/ccw.js'); + +// Track spawned dashboard process +let dashboardProcess: ChildProcess | null = null; + +/** + * Check if the Dashboard server is running by attempting to connect to its health endpoint. + * @returns Promise that resolves to true if server is reachable + */ +export async function isDashboardServerRunning( + port: number = DASHBOARD_PORT, + host: string = DASHBOARD_HOST +): Promise { + return new Promise((resolve) => { + const req = http.get( + { + hostname: host, + port, + path: '/api/health', + timeout: DASHBOARD_CHECK_TIMEOUT_MS, + }, + (res) => { + res.resume(); // Consume response data + res.on('end', () => { + resolve(res.statusCode === 200); + }); + } + ); + + req.on('error', () => { + resolve(false); + }); + + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + }); +} + +/** + * Wait for Dashboard server to become available. + * Polls the health endpoint until it responds or timeout is reached. + * @param port - Port to check + * @param host - Host to check + * @param timeoutMs - Maximum time to wait + * @returns Promise that resolves to true if server became available + */ +export async function waitForDashboardReady( + port: number = DASHBOARD_PORT, + host: string = DASHBOARD_HOST, + timeoutMs: number = DASHBOARD_STARTUP_TIMEOUT_MS +): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const isRunning = await isDashboardServerRunning(port, host); + if (isRunning) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, DASHBOARD_STARTUP_POLL_INTERVAL_MS)); + } + + return false; +} + +/** + * Attempt to start the CCW Dashboard server in a detached child process. + * @param port - Port to start the server on + * @param host - Host to bind the server to + * @param openBrowser - Whether to open browser (default: false) + * @returns Promise that resolves to true if process was successfully spawned and became ready + */ +export async function startCcwServeProcess( + port: number = DASHBOARD_PORT, + host: string = DASHBOARD_HOST, + openBrowser: boolean = false +): Promise { + // Don't start if already running + const alreadyRunning = await isDashboardServerRunning(port, host); + if (alreadyRunning) { + console.log(`[DashboardLauncher] Dashboard already running at ${host}:${port}`); + return true; + } + + // Don't spawn duplicate process + if (dashboardProcess && !dashboardProcess.killed) { + console.log(`[DashboardLauncher] Dashboard process already spawned, waiting for ready...`); + return waitForDashboardReady(port, host); + } + + console.log(`[DashboardLauncher] Starting Dashboard server at ${host}:${port}...`); + + return new Promise((resolve) => { + try { + const args = ['serve', '--port', port.toString(), '--host', host]; + if (!openBrowser) { + args.push('--no-browser'); + } + + dashboardProcess = spawn('node', [CCW_CLI_PATH, ...args], { + detached: true, + stdio: 'ignore', // Detach stdio from parent + shell: process.platform === 'win32', // Use shell on Windows for better compatibility + env: { + ...process.env, + CCW_PORT: port.toString(), + CCW_HOST: host, + }, + }); + + dashboardProcess.unref(); // Allow parent to exit independently + + dashboardProcess.on('error', (err) => { + console.error(`[DashboardLauncher] Failed to start Dashboard: ${err.message}`); + dashboardProcess = null; + resolve(false); + }); + + // Wait for server to become ready + waitForDashboardReady(port, host) + .then((ready) => { + if (ready) { + console.log(`[DashboardLauncher] Dashboard started successfully (PID: ${dashboardProcess?.pid})`); + } else { + console.error(`[DashboardLauncher] Dashboard failed to start within timeout`); + dashboardProcess = null; + } + resolve(ready); + }) + .catch(() => { + resolve(false); + }); + } catch (err) { + console.error(`[DashboardLauncher] Exception while starting Dashboard: ${(err as Error).message}`); + dashboardProcess = null; + resolve(false); + } + }); +} + +/** + * Get the current dashboard process info. + * @returns Object with process status and PID + */ +export function getDashboardProcessStatus(): { running: boolean; pid: number | null } { + return { + running: dashboardProcess !== null && !dashboardProcess.killed, + pid: dashboardProcess?.pid ?? null, + }; +} + +/** + * Stop the spawned dashboard process (if any). + * Note: This only stops the process we spawned, not externally started servers. + */ +export async function stopSpawnedDashboard(): Promise { + if (dashboardProcess && !dashboardProcess.killed) { + console.log(`[DashboardLauncher] Stopping spawned Dashboard process (PID: ${dashboardProcess.pid})...`); + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + if (dashboardProcess && !dashboardProcess.killed) { + dashboardProcess.kill('SIGKILL'); + } + dashboardProcess = null; + resolve(); + }, 5000); + + dashboardProcess!.once('exit', () => { + clearTimeout(timeout); + dashboardProcess = null; + console.log(`[DashboardLauncher] Dashboard process stopped`); + resolve(); + }); + + dashboardProcess!.kill('SIGTERM'); + }); + } +}