mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-27 09:13:07 +08:00
feat: enhance dialog and drawer components with new styles and functionality
- Updated Dialog component to support fullscreen mode and added a back button. - Introduced Drawer component for side navigation with customizable size and position. - Added DialogStyleContext for managing dialog style preferences including smart mode and drawer settings. - Implemented pending question service for managing persistent storage of pending questions. - Enhanced WebSocket handling to request pending questions upon frontend readiness. - Created dashboard launcher utility to manage the Dashboard server lifecycle.
This commit is contained in:
@@ -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<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
fullscreen?: boolean;
|
||||
}
|
||||
>(({ className, children, fullscreen = false, ...props }, ref) => {
|
||||
if (fullscreen) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 flex flex-col bg-card duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute left-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="sr-only">Back</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
|
||||
165
ccw/frontend/src/components/ui/Drawer.tsx
Normal file
165
ccw/frontend/src/components/ui/Drawer.tsx
Normal file
@@ -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<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
// ========== Content ==========
|
||||
|
||||
interface DrawerContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
|
||||
VariantProps<typeof drawerVariants> {
|
||||
/** Whether to show the close button */
|
||||
showClose?: boolean;
|
||||
/** Whether clicking outside should close the drawer */
|
||||
closeOnOutsideClick?: boolean;
|
||||
}
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
DrawerContentProps
|
||||
>(
|
||||
(
|
||||
{ side = 'right', size = 'md', showClose = true, closeOnOutsideClick = true, className, children, ...props },
|
||||
ref
|
||||
) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(drawerVariants({ side, size }), className)}
|
||||
onInteractOutside={(e) => {
|
||||
if (!closeOnOutsideClick) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{showClose && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
);
|
||||
DrawerContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
// ========== Header ==========
|
||||
|
||||
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
DrawerHeader.displayName = 'DrawerHeader';
|
||||
|
||||
// ========== Footer ==========
|
||||
|
||||
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerFooter.displayName = 'DrawerFooter';
|
||||
|
||||
// ========== Title ==========
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
// ========== Description ==========
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
// ========== Exports ==========
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
type DrawerContentProps,
|
||||
};
|
||||
166
ccw/frontend/src/contexts/DialogStyleContext.tsx
Normal file
166
ccw/frontend/src/contexts/DialogStyleContext.tsx
Normal file
@@ -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: <K extends keyof A2UIPreferences>(
|
||||
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<string, DialogStyle> = {
|
||||
confirm: 'modal',
|
||||
select: 'modal',
|
||||
'multi-select': 'drawer',
|
||||
input: 'modal',
|
||||
'multi-question': 'drawer',
|
||||
form: 'drawer',
|
||||
wizard: 'fullscreen',
|
||||
complex: 'drawer',
|
||||
};
|
||||
|
||||
// ========== Context ==========
|
||||
|
||||
const DialogStyleContext = createContext<DialogStyleContextValue | null>(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(
|
||||
<K extends keyof A2UIPreferences>(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 (
|
||||
<DialogStyleContext.Provider value={value}>
|
||||
{children}
|
||||
</DialogStyleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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;
|
||||
|
||||
244
ccw/src/core/services/pending-question-service.ts
Normal file
244
ccw/src/core/services/pending-question-service.ts
Normal file
@@ -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<string, PendingQuestion>();
|
||||
|
||||
// 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();
|
||||
@@ -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<string, PendingQuestion>();
|
||||
|
||||
// ========== Validation ==========
|
||||
|
||||
/**
|
||||
@@ -454,12 +463,13 @@ export async function execute(params: AskQuestionParams): Promise<ToolResult<Ask
|
||||
resolve,
|
||||
reject,
|
||||
};
|
||||
pendingQuestions.set(question.id, pendingQuestion);
|
||||
addPendingQuestion(pendingQuestion);
|
||||
|
||||
// Set timeout
|
||||
setTimeout(() => {
|
||||
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<ToolResult<Ask
|
||||
});
|
||||
}
|
||||
|
||||
// If no local WS clients, start HTTP polling for answer from Dashboard
|
||||
// If no local WS clients, check Dashboard status and start HTTP polling
|
||||
if (sentCount === 0) {
|
||||
// Check if Dashboard server is running, attempt to start if not
|
||||
const dashboardRunning = await isDashboardServerRunning();
|
||||
if (!dashboardRunning) {
|
||||
console.warn(`[AskQuestion] Dashboard server not running. Attempting to start...`);
|
||||
const started = await startCcwServeProcess();
|
||||
if (!started) {
|
||||
console.error(`[AskQuestion] Failed to automatically start Dashboard server.`);
|
||||
}
|
||||
}
|
||||
startAnswerPolling(question.id);
|
||||
}
|
||||
|
||||
@@ -523,7 +542,7 @@ export async function execute(params: AskQuestionParams): Promise<ToolResult<Ask
|
||||
* @returns True if answer was processed
|
||||
*/
|
||||
export function handleAnswer(answer: QuestionAnswer): boolean {
|
||||
const pending = pendingQuestions.get(answer.questionId);
|
||||
const pending = getPendingQuestion(answer.questionId);
|
||||
if (!pending) {
|
||||
return false;
|
||||
}
|
||||
@@ -543,7 +562,7 @@ export function handleAnswer(answer: QuestionAnswer): boolean {
|
||||
});
|
||||
|
||||
// Remove from pending
|
||||
pendingQuestions.delete(answer.questionId);
|
||||
removePendingQuestion(answer.questionId);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -555,7 +574,7 @@ export function handleAnswer(answer: QuestionAnswer): boolean {
|
||||
* @returns True if answer was processed
|
||||
*/
|
||||
export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[]): boolean {
|
||||
const pending = pendingQuestions.get(compositeId);
|
||||
const pending = getPendingQuestion(compositeId);
|
||||
if (!pending) {
|
||||
return false;
|
||||
}
|
||||
@@ -568,7 +587,7 @@ export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[]
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
pendingQuestions.delete(compositeId);
|
||||
removePendingQuestion(compositeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -577,7 +596,7 @@ export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[]
|
||||
/**
|
||||
* Poll Dashboard server for answers when running in a separate MCP process.
|
||||
* Starts polling GET /api/a2ui/answer and resolves the pending promise when an answer arrives.
|
||||
* Automatically stops when the questionId is no longer in pendingQuestions (timeout cleanup).
|
||||
* Automatically stops when the questionId is no longer in pending questions (timeout cleanup).
|
||||
*/
|
||||
function startAnswerPolling(questionId: string, isComposite: boolean = false): void {
|
||||
const pollPath = `/api/a2ui/answer?questionId=${encodeURIComponent(questionId)}&composite=${isComposite}`;
|
||||
@@ -586,7 +605,7 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v
|
||||
|
||||
const poll = () => {
|
||||
// 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) {
|
||||
|
||||
201
ccw/src/utils/dashboard-launcher.ts
Normal file
201
ccw/src/utils/dashboard-launcher.ts
Normal file
@@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
// 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<void> {
|
||||
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');
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user