mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +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 React from "react";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { X } from "lucide-react";
|
import { ArrowLeft, X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root;
|
const Dialog = DialogPrimitive.Root;
|
||||||
@@ -28,8 +28,32 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
>(({ className, children, ...props }, ref) => (
|
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>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
@@ -47,7 +71,8 @@ const DialogContent = React.forwardRef<
|
|||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
));
|
);
|
||||||
|
});
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DialogHeader = ({
|
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.setWsStatus('connected');
|
||||||
s.resetReconnectAttempts();
|
s.resetReconnectAttempts();
|
||||||
reconnectDelayRef.current = RECONNECT_DELAY_BASE;
|
reconnectDelayRef.current = RECONNECT_DELAY_BASE;
|
||||||
|
|
||||||
|
// Request any pending questions from backend
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'FRONTEND_READY',
|
||||||
|
payload: { action: 'requestPendingQuestions' }
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = handleMessage;
|
ws.onmessage = handleMessage;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type { Duplex } from 'stream';
|
|||||||
import http from 'http';
|
import http from 'http';
|
||||||
import type { IncomingMessage } from 'http';
|
import type { IncomingMessage } from 'http';
|
||||||
import { createWebSocketFrame, parseWebSocketFrame, wsClients } from '../websocket.js';
|
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);
|
const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456);
|
||||||
|
|
||||||
@@ -604,6 +605,215 @@ export class A2UIWebSocketHandler {
|
|||||||
|
|
||||||
// ========== WebSocket Integration ==========
|
// ========== 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
|
* Handle A2UI messages in WebSocket data handler
|
||||||
* Called from main WebSocket handler
|
* Called from main WebSocket handler
|
||||||
@@ -620,6 +830,23 @@ export function handleA2UIMessage(
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(payload);
|
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
|
// Handle A2UI action messages
|
||||||
if (data.type === 'a2ui-action') {
|
if (data.type === 'a2ui-action') {
|
||||||
const action = data as A2UIActionMessage;
|
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 http from 'http';
|
||||||
import { a2uiWebSocketHandler } from '../core/a2ui/A2UIWebSocketHandler.js';
|
import { a2uiWebSocketHandler } from '../core/a2ui/A2UIWebSocketHandler.js';
|
||||||
import { remoteNotificationService } from '../core/services/remote-notification-service.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 DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456);
|
||||||
const POLL_INTERVAL_MS = 1000;
|
const POLL_INTERVAL_MS = 1000;
|
||||||
@@ -32,9 +44,6 @@ a2uiWebSocketHandler.registerMultiAnswerCallback(
|
|||||||
/** Default question timeout (5 minutes) */
|
/** Default question timeout (5 minutes) */
|
||||||
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
/** Map of pending questions waiting for responses */
|
|
||||||
const pendingQuestions = new Map<string, PendingQuestion>();
|
|
||||||
|
|
||||||
// ========== Validation ==========
|
// ========== Validation ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -454,12 +463,13 @@ export async function execute(params: AskQuestionParams): Promise<ToolResult<Ask
|
|||||||
resolve,
|
resolve,
|
||||||
reject,
|
reject,
|
||||||
};
|
};
|
||||||
pendingQuestions.set(question.id, pendingQuestion);
|
addPendingQuestion(pendingQuestion);
|
||||||
|
|
||||||
// Set timeout
|
// Set timeout
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (pendingQuestions.has(question.id)) {
|
const timedOutQuestion = getPendingQuestion(question.id);
|
||||||
pendingQuestions.delete(question.id);
|
if (timedOutQuestion) {
|
||||||
|
removePendingQuestion(question.id);
|
||||||
if (question.defaultValue !== undefined) {
|
if (question.defaultValue !== undefined) {
|
||||||
resolve({
|
resolve({
|
||||||
success: true,
|
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) {
|
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);
|
startAnswerPolling(question.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,7 +542,7 @@ export async function execute(params: AskQuestionParams): Promise<ToolResult<Ask
|
|||||||
* @returns True if answer was processed
|
* @returns True if answer was processed
|
||||||
*/
|
*/
|
||||||
export function handleAnswer(answer: QuestionAnswer): boolean {
|
export function handleAnswer(answer: QuestionAnswer): boolean {
|
||||||
const pending = pendingQuestions.get(answer.questionId);
|
const pending = getPendingQuestion(answer.questionId);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -543,7 +562,7 @@ export function handleAnswer(answer: QuestionAnswer): boolean {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Remove from pending
|
// Remove from pending
|
||||||
pendingQuestions.delete(answer.questionId);
|
removePendingQuestion(answer.questionId);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -555,7 +574,7 @@ export function handleAnswer(answer: QuestionAnswer): boolean {
|
|||||||
* @returns True if answer was processed
|
* @returns True if answer was processed
|
||||||
*/
|
*/
|
||||||
export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[]): boolean {
|
export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[]): boolean {
|
||||||
const pending = pendingQuestions.get(compositeId);
|
const pending = getPendingQuestion(compositeId);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -568,7 +587,7 @@ export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[]
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
pendingQuestions.delete(compositeId);
|
removePendingQuestion(compositeId);
|
||||||
return true;
|
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.
|
* 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.
|
* 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 {
|
function startAnswerPolling(questionId: string, isComposite: boolean = false): void {
|
||||||
const pollPath = `/api/a2ui/answer?questionId=${encodeURIComponent(questionId)}&composite=${isComposite}`;
|
const pollPath = `/api/a2ui/answer?questionId=${encodeURIComponent(questionId)}&composite=${isComposite}`;
|
||||||
@@ -586,7 +605,7 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v
|
|||||||
|
|
||||||
const poll = () => {
|
const poll = () => {
|
||||||
// Stop if the question was already resolved or timed out
|
// 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`);
|
console.error(`[A2UI-Poll] Stopping: questionId=${questionId} no longer pending`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -614,14 +633,14 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v
|
|||||||
if (isComposite && Array.isArray(parsed.answers)) {
|
if (isComposite && Array.isArray(parsed.answers)) {
|
||||||
const ok = handleMultiAnswer(questionId, parsed.answers as QuestionAnswer[]);
|
const ok = handleMultiAnswer(questionId, parsed.answers as QuestionAnswer[]);
|
||||||
console.error(`[A2UI-Poll] handleMultiAnswer result: ${ok}`);
|
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
|
// Answer consumed but delivery failed; keep polling for a new answer
|
||||||
setTimeout(poll, POLL_INTERVAL_MS);
|
setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
} else if (!isComposite && parsed.answer) {
|
} else if (!isComposite && parsed.answer) {
|
||||||
const ok = handleAnswer(parsed.answer as QuestionAnswer);
|
const ok = handleAnswer(parsed.answer as QuestionAnswer);
|
||||||
console.error(`[A2UI-Poll] handleAnswer result: ${ok}`);
|
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
|
// Answer consumed but validation/delivery failed; keep polling for a new answer
|
||||||
setTimeout(poll, POLL_INTERVAL_MS);
|
setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
@@ -638,7 +657,7 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v
|
|||||||
|
|
||||||
req.on('error', (err) => {
|
req.on('error', (err) => {
|
||||||
console.error(`[A2UI-Poll] Network error: ${err.message}`);
|
console.error(`[A2UI-Poll] Network error: ${err.message}`);
|
||||||
if (pendingQuestions.has(questionId)) {
|
if (hasPendingQuestion(questionId)) {
|
||||||
setTimeout(poll, POLL_INTERVAL_MS);
|
setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -660,7 +679,7 @@ function startAnswerPolling(questionId: string, isComposite: boolean = false): v
|
|||||||
* @returns True if question was cancelled
|
* @returns True if question was cancelled
|
||||||
*/
|
*/
|
||||||
export function cancelQuestion(questionId: string): boolean {
|
export function cancelQuestion(questionId: string): boolean {
|
||||||
const pending = pendingQuestions.get(questionId);
|
const pending = getPendingQuestion(questionId);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -674,7 +693,7 @@ export function cancelQuestion(questionId: string): boolean {
|
|||||||
error: 'Question cancelled',
|
error: 'Question cancelled',
|
||||||
});
|
});
|
||||||
|
|
||||||
pendingQuestions.delete(questionId);
|
removePendingQuestion(questionId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,17 +702,17 @@ export function cancelQuestion(questionId: string): boolean {
|
|||||||
* @returns Array of pending questions
|
* @returns Array of pending questions
|
||||||
*/
|
*/
|
||||||
export function getPendingQuestions(): PendingQuestion[] {
|
export function getPendingQuestions(): PendingQuestion[] {
|
||||||
return Array.from(pendingQuestions.values());
|
return getAllPendingQuestions();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all pending questions
|
* Clear all pending questions
|
||||||
*/
|
*/
|
||||||
export function clearPendingQuestions(): void {
|
export function clearPendingQuestions(): void {
|
||||||
for (const pending of pendingQuestions.values()) {
|
for (const pending of getAllPendingQuestions()) {
|
||||||
pending.reject(new Error('Question cleared'));
|
pending.reject(new Error('Question cleared'));
|
||||||
}
|
}
|
||||||
pendingQuestions.clear();
|
clearAllPendingQuestions();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Tool Schema ==========
|
// ========== Tool Schema ==========
|
||||||
@@ -1076,7 +1095,7 @@ async function executeSimpleFormat(
|
|||||||
resolve,
|
resolve,
|
||||||
reject,
|
reject,
|
||||||
};
|
};
|
||||||
pendingQuestions.set(compositeId, pendingQuestion);
|
addPendingQuestion(pendingQuestion);
|
||||||
|
|
||||||
// Also register each sub-question's questionId pointing to the same pending entry
|
// Also register each sub-question's questionId pointing to the same pending entry
|
||||||
// so that select/toggle actions on individual questions get tracked
|
// so that select/toggle actions on individual questions get tracked
|
||||||
@@ -1090,8 +1109,9 @@ async function executeSimpleFormat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (pendingQuestions.has(compositeId)) {
|
const timedOutQuestion = getPendingQuestion(compositeId);
|
||||||
pendingQuestions.delete(compositeId);
|
if (timedOutQuestion) {
|
||||||
|
removePendingQuestion(compositeId);
|
||||||
// Collect default values from each sub-question
|
// Collect default values from each sub-question
|
||||||
const defaultAnswers: QuestionAnswer[] = [];
|
const defaultAnswers: QuestionAnswer[] = [];
|
||||||
for (const simpleQ of questions) {
|
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