mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-27 09:13:07 +08:00
feat: add Sheet component for bottom sheet UI with drag-to-dismiss and snap points
test: implement DialogStyleContext tests for preference management and style recommendations test: create tests for useAutoSelection hook, including countdown and pause functionality feat: implement useAutoSelection hook for enhanced auto-selection with sound notifications feat: create Zustand store for managing issue submission wizard state feat: add Zod validation schemas for issue-related API requests feat: implement issue service for CRUD operations and validation handling feat: define TypeScript types for issue submission and management
This commit is contained in:
@@ -13,6 +13,7 @@ import queryClient from './lib/query-client';
|
||||
import type { Locale } from './lib/i18n';
|
||||
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||
import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
|
||||
import { DialogStyleProvider } from '@/contexts/DialogStyleContext';
|
||||
|
||||
interface AppProps {
|
||||
locale: Locale;
|
||||
@@ -27,10 +28,12 @@ function App({ locale, messages }: AppProps) {
|
||||
return (
|
||||
<IntlProvider locale={locale} messages={messages}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryInvalidator />
|
||||
<CliExecutionSync />
|
||||
<RouterProvider router={router} />
|
||||
<Toaster richColors position="top-right" />
|
||||
<DialogStyleProvider>
|
||||
<QueryInvalidator />
|
||||
<CliExecutionSync />
|
||||
<RouterProvider router={router} />
|
||||
<Toaster richColors position="top-right" />
|
||||
</DialogStyleProvider>
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
382
ccw/frontend/src/components/IssueDialog/IssueDialog.tsx
Normal file
382
ccw/frontend/src/components/IssueDialog/IssueDialog.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
// ========================================
|
||||
// Issue Dialog Components
|
||||
// ========================================
|
||||
// Interactive wizard for submitting issues/requirements
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useIssueDialogStore } from '@/stores/issueDialogStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssueType, IssuePriority } from '@/stores/issueDialogStore';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
interface IssueDialogProps {
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
// ========== Step Components ==========
|
||||
|
||||
function TitleStep() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { formData, validationErrors, updateField } = useIssueDialogStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title" className="text-base font-medium">
|
||||
{formatMessage({ id: 'issueDialog.titleLabel', defaultMessage: '标题' })}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => updateField('title', e.target.value)}
|
||||
placeholder={formatMessage({
|
||||
id: 'issueDialog.titlePlaceholder',
|
||||
defaultMessage: '请输入Issue标题...'
|
||||
})}
|
||||
className={cn('mt-2', validationErrors.title && 'border-destructive')}
|
||||
maxLength={200}
|
||||
autoFocus
|
||||
/>
|
||||
{validationErrors.title && (
|
||||
<p className="text-sm text-destructive mt-1">{validationErrors.title}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formData.title.length}/200
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionStep() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { formData, validationErrors, updateField } = useIssueDialogStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="description" className="text-base font-medium">
|
||||
{formatMessage({ id: 'issueDialog.descriptionLabel', defaultMessage: '描述' })}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
placeholder={formatMessage({
|
||||
id: 'issueDialog.descriptionPlaceholder',
|
||||
defaultMessage: '请详细描述问题或需求...'
|
||||
})}
|
||||
className={cn('mt-2 min-h-[200px]', validationErrors.description && 'border-destructive')}
|
||||
maxLength={10000}
|
||||
/>
|
||||
{validationErrors.description && (
|
||||
<p className="text-sm text-destructive mt-1">{validationErrors.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formData.description.length}/10000
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeStep() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { formData, updateField } = useIssueDialogStore();
|
||||
|
||||
const typeOptions: { value: IssueType; label: string; description: string }[] = [
|
||||
{
|
||||
value: 'bug',
|
||||
label: formatMessage({ id: 'issueDialog.typeBug', defaultMessage: 'Bug' }),
|
||||
description: formatMessage({ id: 'issueDialog.typeBugDesc', defaultMessage: '功能异常或错误' })
|
||||
},
|
||||
{
|
||||
value: 'feature',
|
||||
label: formatMessage({ id: 'issueDialog.typeFeature', defaultMessage: 'Feature' }),
|
||||
description: formatMessage({ id: 'issueDialog.typeFeatureDesc', defaultMessage: '新功能需求' })
|
||||
},
|
||||
{
|
||||
value: 'improvement',
|
||||
label: formatMessage({ id: 'issueDialog.typeImprovement', defaultMessage: 'Improvement' }),
|
||||
description: formatMessage({ id: 'issueDialog.typeImprovementDesc', defaultMessage: '现有功能改进' })
|
||||
},
|
||||
{
|
||||
value: 'other',
|
||||
label: formatMessage({ id: 'issueDialog.typeOther', defaultMessage: 'Other' }),
|
||||
description: formatMessage({ id: 'issueDialog.typeOtherDesc', defaultMessage: '其他类型' })
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
{formatMessage({ id: 'issueDialog.typeLabel', defaultMessage: '选择类型' })}
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-3 mt-2">
|
||||
{typeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => updateField('type', option.value)}
|
||||
className={cn(
|
||||
'p-4 rounded-lg border text-left transition-all',
|
||||
formData.type === option.value
|
||||
? 'border-primary bg-primary/10 ring-1 ring-primary'
|
||||
: 'border-border hover:border-primary/50 hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">{option.label}</div>
|
||||
<div className="text-sm text-muted-foreground">{option.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriorityStep() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { formData, updateField } = useIssueDialogStore();
|
||||
|
||||
const priorityOptions: { value: IssuePriority; label: string; color: string }[] = [
|
||||
{ value: 'low', label: formatMessage({ id: 'issueDialog.priorityLow', defaultMessage: '低' }), color: 'bg-gray-500' },
|
||||
{ value: 'medium', label: formatMessage({ id: 'issueDialog.priorityMedium', defaultMessage: '中' }), color: 'bg-blue-500' },
|
||||
{ value: 'high', label: formatMessage({ id: 'issueDialog.priorityHigh', defaultMessage: '高' }), color: 'bg-orange-500' },
|
||||
{ value: 'urgent', label: formatMessage({ id: 'issueDialog.priorityUrgent', defaultMessage: '紧急' }), color: 'bg-red-500' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
{formatMessage({ id: 'issueDialog.priorityLabel', defaultMessage: '选择优先级' })}
|
||||
</Label>
|
||||
<Select value={formData.priority} onValueChange={(v) => updateField('priority', v as IssuePriority)}>
|
||||
<SelectTrigger className="mt-2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{priorityOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('w-2 h-2 rounded-full', option.color)} />
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryStep() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { formData, submitError } = useIssueDialogStore();
|
||||
|
||||
const typeLabels: Record<IssueType, string> = {
|
||||
bug: formatMessage({ id: 'issueDialog.typeBug', defaultMessage: 'Bug' }),
|
||||
feature: formatMessage({ id: 'issueDialog.typeFeature', defaultMessage: 'Feature' }),
|
||||
improvement: formatMessage({ id: 'issueDialog.typeImprovement', defaultMessage: 'Improvement' }),
|
||||
other: formatMessage({ id: 'issueDialog.typeOther', defaultMessage: 'Other' }),
|
||||
};
|
||||
|
||||
const priorityLabels: Record<IssuePriority, string> = {
|
||||
low: formatMessage({ id: 'issueDialog.priorityLow', defaultMessage: '低' }),
|
||||
medium: formatMessage({ id: 'issueDialog.priorityMedium', defaultMessage: '中' }),
|
||||
high: formatMessage({ id: 'issueDialog.priorityHigh', defaultMessage: '高' }),
|
||||
urgent: formatMessage({ id: 'issueDialog.priorityUrgent', defaultMessage: '紧急' }),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-muted/50 space-y-3">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issueDialog.summaryTitle', defaultMessage: '标题' })}
|
||||
</span>
|
||||
<p className="font-medium mt-1">{formData.title}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issueDialog.summaryDescription', defaultMessage: '描述' })}
|
||||
</span>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{formData.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issueDialog.summaryType', defaultMessage: '类型' })}
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline">{typeLabels[formData.type]}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issueDialog.summaryPriority', defaultMessage: '优先级' })}
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline">{priorityLabels[formData.priority]}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Dialog Component ==========
|
||||
|
||||
export function IssueDialog({ trigger }: IssueDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const {
|
||||
isOpen,
|
||||
currentStep,
|
||||
steps,
|
||||
isSubmitting,
|
||||
closeDialog,
|
||||
nextStep,
|
||||
prevStep,
|
||||
submitIssue,
|
||||
} = useIssueDialogStore();
|
||||
|
||||
const isFirstStep = currentStep === 0;
|
||||
const isLastStep = currentStep === steps.length - 1;
|
||||
|
||||
const currentStepData = steps[currentStep];
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const result = await submitIssue();
|
||||
if (result.success) {
|
||||
closeDialog();
|
||||
}
|
||||
}, [submitIssue, closeDialog]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (isLastStep) {
|
||||
handleSubmit();
|
||||
} else {
|
||||
nextStep();
|
||||
}
|
||||
}, [isLastStep, handleSubmit, nextStep]);
|
||||
|
||||
const stepContent = useMemo(() => {
|
||||
const field = currentStepData?.field;
|
||||
switch (field) {
|
||||
case 'title':
|
||||
return <TitleStep />;
|
||||
case 'description':
|
||||
return <DescriptionStep />;
|
||||
case 'type':
|
||||
return <TypeStep />;
|
||||
case 'priority':
|
||||
return <PriorityStep />;
|
||||
case 'summary':
|
||||
return <SummaryStep />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [currentStepData?.field]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{currentStepData?.title}</DialogTitle>
|
||||
{currentStepData?.description && (
|
||||
<DialogDescription>{currentStepData.description}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-center gap-1 py-2">
|
||||
{steps.map((step, index) => (
|
||||
<button
|
||||
key={step.id}
|
||||
type="button"
|
||||
onClick={() => useIssueDialogStore.getState().goToStep(index)}
|
||||
className={cn(
|
||||
'rounded-full transition-all duration-200',
|
||||
index === currentStep
|
||||
? 'bg-primary w-6 h-2'
|
||||
: index < currentStep
|
||||
? 'bg-primary/50 w-2 h-2'
|
||||
: 'bg-muted-foreground/30 w-2 h-2 hover:bg-muted-foreground/50'
|
||||
)}
|
||||
aria-label={`Step ${index + 1}: ${step.title}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="py-4 min-h-[200px]">
|
||||
{stepContent}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={isFirstStep ? closeDialog : prevStep}
|
||||
>
|
||||
{isFirstStep
|
||||
? formatMessage({ id: 'issueDialog.cancel', defaultMessage: '取消' })
|
||||
: formatMessage({ id: 'issueDialog.previous', defaultMessage: '上一步' })
|
||||
}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? formatMessage({ id: 'issueDialog.submitting', defaultMessage: '提交中...' })
|
||||
: isLastStep
|
||||
? formatMessage({ id: 'issueDialog.submit', defaultMessage: '提交' })
|
||||
: formatMessage({ id: 'issueDialog.next', defaultMessage: '下一步' })
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default IssueDialog;
|
||||
6
ccw/frontend/src/components/IssueDialog/index.ts
Normal file
6
ccw/frontend/src/components/IssueDialog/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// ========================================
|
||||
// IssueDialog Component Barrel Export
|
||||
// ========================================
|
||||
|
||||
export { IssueDialog } from './IssueDialog';
|
||||
export type { IssueType, IssuePriority, IssueFormData } from '@/stores/issueDialogStore';
|
||||
@@ -16,8 +16,23 @@ import {
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerFooter,
|
||||
} from '@/components/ui/Drawer';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/Sheet';
|
||||
import { A2UIRenderer } from '@/packages/a2ui-runtime/renderer';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { useDialogStyleContext, type DialogStyle } from '@/contexts/DialogStyleContext';
|
||||
import type { SurfaceUpdate, SurfaceComponent } from '@/packages/a2ui-runtime/core/A2UITypes';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -717,12 +732,257 @@ function MultiPagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
|
||||
const state = surface.initialState as Record<string, unknown> | undefined;
|
||||
const isMultiPage = state?.questionType === 'multi-question' && (state?.totalPages as number) > 1;
|
||||
const questionType = detectQuestionType(surface);
|
||||
|
||||
// Get dialog style from context
|
||||
const { preferences, getRecommendedStyle } = useDialogStyleContext();
|
||||
const dialogStyle = preferences.smartModeEnabled
|
||||
? getRecommendedStyle(questionType)
|
||||
: preferences.dialogStyle;
|
||||
|
||||
// Common props for all styles
|
||||
const styleProps = {
|
||||
surface,
|
||||
onClose,
|
||||
questionType,
|
||||
dialogStyle,
|
||||
drawerSide: preferences.drawerSide,
|
||||
drawerSize: preferences.drawerSize,
|
||||
};
|
||||
|
||||
if (isMultiPage) {
|
||||
return <MultiPagePopup surface={surface} onClose={onClose} />;
|
||||
}
|
||||
|
||||
return <SinglePagePopup surface={surface} onClose={onClose} />;
|
||||
// Render based on dialog style
|
||||
switch (dialogStyle) {
|
||||
case 'drawer':
|
||||
return <DrawerPopup {...styleProps} />;
|
||||
case 'sheet':
|
||||
return <SheetPopup {...styleProps} />;
|
||||
case 'fullscreen':
|
||||
return <FullscreenPopup {...styleProps} />;
|
||||
default:
|
||||
return <SinglePagePopup surface={surface} onClose={onClose} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Drawer Popup ==========
|
||||
|
||||
interface StyleProps {
|
||||
surface: SurfaceUpdate;
|
||||
onClose: () => void;
|
||||
questionType: QuestionType;
|
||||
dialogStyle: DialogStyle;
|
||||
drawerSide: 'left' | 'right';
|
||||
drawerSize: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
}
|
||||
|
||||
function DrawerPopup({ surface, onClose, drawerSide, drawerSize }: StyleProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
|
||||
|
||||
const titleComponent = surface.components.find(
|
||||
(c) => c.id === 'title' && 'Text' in (c.component as any)
|
||||
);
|
||||
const title = getTextContent(titleComponent) || formatMessage({ id: 'askQuestion.defaultTitle', defaultMessage: 'Question' });
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
sendA2UIAction('cancel', surface.surfaceId, {
|
||||
questionId: (surface.initialState as any)?.questionId,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, onClose]
|
||||
);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(actionId: string, params?: Record<string, unknown>) => {
|
||||
sendA2UIAction(actionId, surface.surfaceId, params);
|
||||
const resolvingActions = ['confirm', 'cancel', 'submit', 'answer'];
|
||||
if (resolvingActions.includes(actionId)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, onClose]
|
||||
);
|
||||
|
||||
const bodyComponents = surface.components.filter(
|
||||
(c) => !['title', 'message', 'description'].includes(c.id) && !isActionButton(c)
|
||||
);
|
||||
const actionButtons = surface.components.filter((c) => isActionButton(c));
|
||||
|
||||
return (
|
||||
<Drawer open onOpenChange={handleOpenChange}>
|
||||
<DrawerContent side={drawerSide} size={drawerSize} className="p-6">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{bodyComponents.length > 0 && (
|
||||
<A2UIRenderer
|
||||
surface={{ ...surface, components: bodyComponents }}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{actionButtons.length > 0 && (
|
||||
<DrawerFooter>
|
||||
{actionButtons.map((comp) => (
|
||||
<A2UIRenderer
|
||||
key={comp.id}
|
||||
surface={{ ...surface, components: [comp] }}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</DrawerFooter>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Sheet Popup ==========
|
||||
|
||||
function SheetPopup({ surface, onClose }: StyleProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
|
||||
|
||||
const titleComponent = surface.components.find(
|
||||
(c) => c.id === 'title' && 'Text' in (c.component as any)
|
||||
);
|
||||
const title = getTextContent(titleComponent) || formatMessage({ id: 'askQuestion.defaultTitle', defaultMessage: 'Question' });
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
sendA2UIAction('cancel', surface.surfaceId, {
|
||||
questionId: (surface.initialState as any)?.questionId,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, onClose]
|
||||
);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(actionId: string, params?: Record<string, unknown>) => {
|
||||
sendA2UIAction(actionId, surface.surfaceId, params);
|
||||
const resolvingActions = ['confirm', 'cancel', 'submit', 'answer'];
|
||||
if (resolvingActions.includes(actionId)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, onClose]
|
||||
);
|
||||
|
||||
const bodyComponents = surface.components.filter(
|
||||
(c) => !['title', 'message', 'description'].includes(c.id) && !isActionButton(c)
|
||||
);
|
||||
const actionButtons = surface.components.filter((c) => isActionButton(c));
|
||||
|
||||
return (
|
||||
<Sheet open onOpenChange={handleOpenChange}>
|
||||
<SheetContent size="content">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{bodyComponents.length > 0 && (
|
||||
<A2UIRenderer
|
||||
surface={{ ...surface, components: bodyComponents }}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{actionButtons.length > 0 && (
|
||||
<SheetFooter>
|
||||
{actionButtons.map((comp) => (
|
||||
<A2UIRenderer
|
||||
key={comp.id}
|
||||
surface={{ ...surface, components: [comp] }}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</SheetFooter>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Fullscreen Popup ==========
|
||||
|
||||
function FullscreenPopup({ surface, onClose }: StyleProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
|
||||
|
||||
const titleComponent = surface.components.find(
|
||||
(c) => c.id === 'title' && 'Text' in (c.component as any)
|
||||
);
|
||||
const title = getTextContent(titleComponent) || formatMessage({ id: 'askQuestion.defaultTitle', defaultMessage: 'Question' });
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
sendA2UIAction('cancel', surface.surfaceId, {
|
||||
questionId: (surface.initialState as any)?.questionId,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, onClose]
|
||||
);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(actionId: string, params?: Record<string, unknown>) => {
|
||||
sendA2UIAction(actionId, surface.surfaceId, params);
|
||||
const resolvingActions = ['confirm', 'cancel', 'submit', 'answer'];
|
||||
if (resolvingActions.includes(actionId)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, onClose]
|
||||
);
|
||||
|
||||
const bodyComponents = surface.components.filter(
|
||||
(c) => !['title', 'message', 'description'].includes(c.id) && !isActionButton(c)
|
||||
);
|
||||
const actionButtons = surface.components.filter((c) => isActionButton(c));
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent fullscreen className="p-6">
|
||||
<DialogHeader className="border-b border-border pb-4">
|
||||
<DialogTitle className="text-xl">{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto py-6">
|
||||
{bodyComponents.length > 0 && (
|
||||
<A2UIRenderer
|
||||
surface={{ ...surface, components: bodyComponents }}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{actionButtons.length > 0 && (
|
||||
<DialogFooter className="border-t border-border pt-4">
|
||||
<div className="flex gap-3">
|
||||
{actionButtons.map((comp) => (
|
||||
<A2UIRenderer
|
||||
key={comp.id}
|
||||
surface={{ ...surface, components: [comp] }}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default A2UIPopupCard;
|
||||
|
||||
53
ccw/frontend/src/components/layout/A2UIButton.tsx
Normal file
53
ccw/frontend/src/components/layout/A2UIButton.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// ========================================
|
||||
// A2UI Button Component
|
||||
// ========================================
|
||||
// Quick action button for A2UI dialog in toolbar
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useDialogStyleContext } from '@/contexts/DialogStyleContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface A2UIButtonProps {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function A2UIButton({ className, compact = false }: A2UIButtonProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { preferences } = useDialogStyleContext();
|
||||
|
||||
// Don't render if hidden in preferences
|
||||
if (!preferences.showA2UIButtonInToolbar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
// Trigger A2UI quick action - this would typically open a dialog
|
||||
// For now, we'll just log the action
|
||||
console.log('[A2UIButton] Quick action triggered');
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
size={compact ? 'icon' : 'sm'}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'gap-2 bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
className
|
||||
)}
|
||||
title={formatMessage({ id: 'toolbar.a2ui.quickAction', defaultMessage: 'A2UI Quick Action' })}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{!compact && (
|
||||
<span className="hidden sm:inline">
|
||||
{formatMessage({ id: 'toolbar.a2ui.button', defaultMessage: 'A2UI' })}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default A2UIButton;
|
||||
@@ -25,6 +25,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTheme } from '@/hooks';
|
||||
import { WorkspaceSelector } from '@/components/workspace/WorkspaceSelector';
|
||||
import { A2UIButton } from '@/components/layout/A2UIButton';
|
||||
import { useCliStreamStore, selectActiveExecutionCount } from '@/stores/cliStreamStore';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { useTerminalPanelStore, selectTerminalCount } from '@/stores/terminalPanelStore';
|
||||
@@ -78,6 +79,9 @@ export function Header({
|
||||
<span className="hidden sm:inline">{formatMessage({ id: 'navigation.header.brand' })}</span>
|
||||
<span className="sm:hidden">{formatMessage({ id: 'navigation.header.brandShort' })}</span>
|
||||
</Link>
|
||||
|
||||
{/* A2UI Quick Action Button */}
|
||||
<A2UIButton />
|
||||
</div>
|
||||
|
||||
{/* Right side - Actions */}
|
||||
|
||||
@@ -9,6 +9,8 @@ export type { AppShellProps } from './AppShell';
|
||||
export { Header } from './Header';
|
||||
export type { HeaderProps } from './Header';
|
||||
|
||||
export { A2UIButton } from './A2UIButton';
|
||||
|
||||
export { Sidebar } from './Sidebar';
|
||||
export type { SidebarProps } from './Sidebar';
|
||||
|
||||
|
||||
294
ccw/frontend/src/components/settings/A2UIPreferencesSection.tsx
Normal file
294
ccw/frontend/src/components/settings/A2UIPreferencesSection.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
// ========================================
|
||||
// A2UI Preferences Section
|
||||
// ========================================
|
||||
// Settings section for A2UI dialog style preferences
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { MessageSquare, Clock, Volume2, LayoutPanelLeft, Sparkles } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDialogStyleContext, type DialogStyle } from '@/contexts/DialogStyleContext';
|
||||
|
||||
// ========== Style Option Button ==========
|
||||
|
||||
interface StyleOptionProps {
|
||||
value: DialogStyle;
|
||||
label: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function StyleOption({ value, label, description, selected, onClick }: StyleOptionProps) {
|
||||
const icons: Record<DialogStyle, React.ReactNode> = {
|
||||
modal: (
|
||||
<div className="w-8 h-8 border-2 border-current rounded-lg flex items-center justify-center">
|
||||
<div className="w-4 h-3 border border-current rounded-sm" />
|
||||
</div>
|
||||
),
|
||||
drawer: (
|
||||
<div className="w-8 h-8 border-2 border-current rounded-lg flex items-end justify-end p-0.5">
|
||||
<div className="w-2 h-6 border border-current rounded-sm" />
|
||||
</div>
|
||||
),
|
||||
sheet: (
|
||||
<div className="w-8 h-8 border-2 border-current rounded-lg flex items-end justify-center p-0.5">
|
||||
<div className="w-6 h-2 border border-current rounded-sm" />
|
||||
</div>
|
||||
),
|
||||
fullscreen: (
|
||||
<div className="w-8 h-8 border-2 border-current rounded-lg flex items-center justify-center">
|
||||
<div className="w-5 h-4 border border-current rounded-sm" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all',
|
||||
'hover:bg-accent/50',
|
||||
selected
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{icons[value]}
|
||||
<div className="text-center">
|
||||
<div className={cn('text-sm font-medium', selected && 'text-primary')}>{label}</div>
|
||||
<div className="text-xs text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Duration Slider ==========
|
||||
|
||||
interface DurationSliderProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function DurationSlider({ value, onChange, disabled }: DurationSliderProps) {
|
||||
const presets = [10, 20, 30, 45, 60, 90, 120];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{presets.map((seconds) => (
|
||||
<Button
|
||||
key={seconds}
|
||||
type="button"
|
||||
variant={value === seconds ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(seconds)}
|
||||
>
|
||||
{seconds}s
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function A2UIPreferencesSection() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { preferences, updatePreference, resetPreferences } = useDialogStyleContext();
|
||||
|
||||
const styleOptions: Array<{ value: DialogStyle; label: string; description: string }> = [
|
||||
{
|
||||
value: 'modal',
|
||||
label: formatMessage({ id: 'settings.a2ui.styleModal', defaultMessage: 'Modal' }),
|
||||
description: formatMessage({ id: 'settings.a2ui.styleModalDesc', defaultMessage: 'Centered' }),
|
||||
},
|
||||
{
|
||||
value: 'drawer',
|
||||
label: formatMessage({ id: 'settings.a2ui.styleDrawer', defaultMessage: 'Drawer' }),
|
||||
description: formatMessage({ id: 'settings.a2ui.styleDrawerDesc', defaultMessage: 'Side panel' }),
|
||||
},
|
||||
{
|
||||
value: 'sheet',
|
||||
label: formatMessage({ id: 'settings.a2ui.styleSheet', defaultMessage: 'Sheet' }),
|
||||
description: formatMessage({ id: 'settings.a2ui.styleSheetDesc', defaultMessage: 'Bottom' }),
|
||||
},
|
||||
{
|
||||
value: 'fullscreen',
|
||||
label: formatMessage({ id: 'settings.a2ui.styleFullscreen', defaultMessage: 'Fullscreen' }),
|
||||
description: formatMessage({ id: 'settings.a2ui.styleFullscreenDesc', defaultMessage: 'Full screen' }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
{formatMessage({ id: 'settings.sections.a2ui', defaultMessage: 'A2UI Preferences' })}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Dialog Style Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
<LayoutPanelLeft className="w-4 h-4" />
|
||||
{formatMessage({ id: 'settings.a2ui.dialogStyle', defaultMessage: 'Dialog Style' })}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'settings.a2ui.dialogStyleDesc',
|
||||
defaultMessage: 'Choose how A2UI dialogs are displayed',
|
||||
})}
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{styleOptions.map((option) => (
|
||||
<StyleOption
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
selected={preferences.dialogStyle === option.value}
|
||||
onClick={() => updatePreference('dialogStyle', option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Smart Mode */}
|
||||
<div className="flex items-center justify-between py-2 border-t border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{formatMessage({ id: 'settings.a2ui.smartMode', defaultMessage: 'Smart Mode' })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'settings.a2ui.smartModeDesc',
|
||||
defaultMessage: 'Auto-select style based on question type',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={preferences.smartModeEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => updatePreference('smartModeEnabled', !preferences.smartModeEnabled)}
|
||||
>
|
||||
{preferences.smartModeEnabled
|
||||
? formatMessage({ id: 'common.enabled', defaultMessage: 'Enabled' })
|
||||
: formatMessage({ id: 'common.disabled', defaultMessage: 'Disabled' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Auto Selection Duration */}
|
||||
<div className="space-y-3 py-2 border-t border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-sm font-medium">
|
||||
{formatMessage({ id: 'settings.a2ui.autoSelectionDuration', defaultMessage: 'Auto-Selection Duration' })}
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'settings.a2ui.autoSelectionDurationDesc',
|
||||
defaultMessage: 'Countdown before auto-selecting default option',
|
||||
})}
|
||||
</p>
|
||||
<DurationSlider
|
||||
value={preferences.autoSelectionDuration}
|
||||
onChange={(v) => updatePreference('autoSelectionDuration', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pause on Interaction */}
|
||||
<div className="flex items-center justify-between py-2 border-t border-border">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{formatMessage({ id: 'settings.a2ui.pauseOnInteraction', defaultMessage: 'Pause on Interaction' })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'settings.a2ui.pauseOnInteractionDesc',
|
||||
defaultMessage: 'Pause countdown when you interact with the dialog',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={preferences.pauseOnInteraction ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => updatePreference('pauseOnInteraction', !preferences.pauseOnInteraction)}
|
||||
>
|
||||
{preferences.pauseOnInteraction
|
||||
? formatMessage({ id: 'common.enabled', defaultMessage: 'Enabled' })
|
||||
: formatMessage({ id: 'common.disabled', defaultMessage: 'Disabled' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Sound Notification */}
|
||||
<div className="flex items-center justify-between py-2 border-t border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="w-4 h-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{formatMessage({ id: 'settings.a2ui.soundNotification', defaultMessage: 'Sound Notification' })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'settings.a2ui.soundNotificationDesc',
|
||||
defaultMessage: 'Play sound before auto-submit (3 seconds before)',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={preferences.autoSelectionSoundEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => updatePreference('autoSelectionSoundEnabled', !preferences.autoSelectionSoundEnabled)}
|
||||
>
|
||||
{preferences.autoSelectionSoundEnabled
|
||||
? formatMessage({ id: 'common.enabled', defaultMessage: 'Enabled' })
|
||||
: formatMessage({ id: 'common.disabled', defaultMessage: 'Disabled' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Show A2UI Button in Toolbar */}
|
||||
<div className="flex items-center justify-between py-2 border-t border-border">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{formatMessage({ id: 'settings.a2ui.showToolbarButton', defaultMessage: 'Show Toolbar Button' })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'settings.a2ui.showToolbarButtonDesc',
|
||||
defaultMessage: 'Show A2UI quick action button in the toolbar',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={preferences.showA2UIButtonInToolbar ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => updatePreference('showA2UIButtonInToolbar', !preferences.showA2UIButtonInToolbar)}
|
||||
>
|
||||
{preferences.showA2UIButtonInToolbar
|
||||
? formatMessage({ id: 'common.enabled', defaultMessage: 'Enabled' })
|
||||
: formatMessage({ id: 'common.disabled', defaultMessage: 'Disabled' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Reset Button */}
|
||||
<div className="flex justify-end pt-4 border-t border-border">
|
||||
<Button variant="outline" size="sm" onClick={resetPreferences}>
|
||||
{formatMessage({ id: 'common.resetToDefaults', defaultMessage: 'Reset to Defaults' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default A2UIPreferencesSection;
|
||||
@@ -48,11 +48,15 @@ import { CliConfigModal, type CliSessionConfig } from './CliConfigModal';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type PanelId = 'issues' | 'queue' | 'inspector' | 'files';
|
||||
export type PanelId = 'issues' | 'queue' | 'inspector';
|
||||
|
||||
interface DashboardToolbarProps {
|
||||
activePanel: PanelId | null;
|
||||
onTogglePanel: (panelId: PanelId) => void;
|
||||
/** Whether the file sidebar is open */
|
||||
isFileSidebarOpen?: boolean;
|
||||
/** Callback to toggle file sidebar */
|
||||
onToggleFileSidebar?: () => void;
|
||||
}
|
||||
|
||||
// ========== Layout Presets ==========
|
||||
@@ -79,7 +83,7 @@ const LAUNCH_COMMANDS: Record<CliTool, Record<LaunchMode, string>> = {
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function DashboardToolbar({ activePanel, onTogglePanel }: DashboardToolbarProps) {
|
||||
export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen, onToggleFileSidebar }: DashboardToolbarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Issues count
|
||||
@@ -296,8 +300,8 @@ export function DashboardToolbar({ activePanel, onTogglePanel }: DashboardToolba
|
||||
<ToolbarButton
|
||||
icon={FolderOpen}
|
||||
label={formatMessage({ id: 'terminalDashboard.toolbar.files', defaultMessage: 'Files' })}
|
||||
isActive={activePanel === 'files'}
|
||||
onClick={() => onTogglePanel('files')}
|
||||
isActive={isFileSidebarOpen ?? false}
|
||||
onClick={() => onToggleFileSidebar?.()}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// ========================================
|
||||
// FileSidebarPanel Component
|
||||
// ========================================
|
||||
// Right sidebar file browser for Terminal Dashboard.
|
||||
// Displays file tree and allows showing files in focused terminal pane.
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FolderOpen, RefreshCw, Loader2, ChevronLeft, FileText } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { TreeView } from '@/components/shared/TreeView';
|
||||
import { useFileExplorer } from '@/hooks/useFileExplorer';
|
||||
import { useTerminalGridStore, selectTerminalGridFocusedPaneId } from '@/stores/terminalGridStore';
|
||||
import type { FileSystemNode } from '@/types/file-explorer';
|
||||
|
||||
export interface FileSidebarPanelProps {
|
||||
/** Root path for file explorer */
|
||||
rootPath: string;
|
||||
/** Whether the panel is enabled (has project path) */
|
||||
enabled?: boolean;
|
||||
/** Optional class name */
|
||||
className?: string;
|
||||
/** Callback when panel collapse is requested */
|
||||
onCollapse?: () => void;
|
||||
/** Initial width of the panel */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function FileSidebarPanel({
|
||||
rootPath,
|
||||
enabled = true,
|
||||
className,
|
||||
onCollapse,
|
||||
width = 280,
|
||||
}: FileSidebarPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Store
|
||||
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
|
||||
const showFileInPane = useTerminalGridStore((s) => s.showFileInPane);
|
||||
|
||||
// File explorer hook
|
||||
const {
|
||||
rootNodes,
|
||||
state,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
setSelectedFile,
|
||||
toggleExpanded,
|
||||
} = useFileExplorer({
|
||||
rootPath,
|
||||
maxDepth: 6,
|
||||
enabled,
|
||||
});
|
||||
|
||||
// Handle node click
|
||||
const handleNodeClick = React.useCallback(
|
||||
(node: FileSystemNode) => {
|
||||
// Only files can be shown in pane
|
||||
if (node.type === 'file') {
|
||||
setSelectedFile(node.path);
|
||||
// Show file in focused pane if one exists
|
||||
if (focusedPaneId) {
|
||||
showFileInPane(focusedPaneId, node.path);
|
||||
}
|
||||
}
|
||||
},
|
||||
[focusedPaneId, setSelectedFile, showFileInPane]
|
||||
);
|
||||
|
||||
// Handle refresh
|
||||
const handleRefresh = React.useCallback(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
// Handle collapse
|
||||
const handleCollapse = React.useCallback(() => {
|
||||
onCollapse?.();
|
||||
}, [onCollapse]);
|
||||
|
||||
// Disabled state (no project path)
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col h-full border-l border-border bg-background',
|
||||
className
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/30">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'terminalDashboard.fileSidebar.title', defaultMessage: 'Files' })}
|
||||
</span>
|
||||
{onCollapse && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={handleCollapse}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-4 text-center text-muted-foreground">
|
||||
<FolderOpen className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="text-xs">
|
||||
{formatMessage({ id: 'terminalDashboard.fileSidebar.noProject', defaultMessage: 'No project open' })}
|
||||
</p>
|
||||
<p className="text-[10px] mt-1 opacity-70">
|
||||
{formatMessage({ id: 'terminalDashboard.fileSidebar.openProjectHint', defaultMessage: 'Open a project to browse files' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col h-full border-l border-border bg-background',
|
||||
className
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-border bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-medium truncate">
|
||||
{formatMessage({ id: 'terminalDashboard.fileSidebar.title', defaultMessage: 'Files' })}
|
||||
</span>
|
||||
{isFetching && !isLoading && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={handleRefresh}
|
||||
disabled={isFetching}
|
||||
title={formatMessage({ id: 'terminalDashboard.fileSidebar.refresh', defaultMessage: 'Refresh' })}
|
||||
>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', isFetching && 'animate-spin')} />
|
||||
</Button>
|
||||
{onCollapse && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={handleCollapse}
|
||||
title={formatMessage({ id: 'terminalDashboard.fileSidebar.collapse', defaultMessage: 'Collapse' })}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current path indicator */}
|
||||
<div className="px-3 py-1.5 border-b border-border/50 bg-muted/10 shrink-0">
|
||||
<div className="text-[10px] text-muted-foreground truncate font-mono" title={rootPath}>
|
||||
{rootPath}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tree view */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="ml-2 text-xs">
|
||||
{formatMessage({ id: 'terminalDashboard.fileBrowser.loading', defaultMessage: 'Loading...' })}
|
||||
</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-3 text-xs text-destructive">
|
||||
{formatMessage({ id: 'terminalDashboard.fileBrowser.loadFailed', defaultMessage: 'Failed to load files' })}
|
||||
</div>
|
||||
) : rootNodes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 px-4 text-center text-muted-foreground">
|
||||
<FileText className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="text-xs">
|
||||
{formatMessage({ id: 'terminalDashboard.fileSidebar.empty', defaultMessage: 'No files found' })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<TreeView
|
||||
nodes={rootNodes}
|
||||
expandedPaths={state.expandedPaths}
|
||||
selectedPath={state.selectedFile}
|
||||
onNodeClick={handleNodeClick}
|
||||
onToggle={toggleExpanded}
|
||||
maxDepth={0}
|
||||
className="py-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
{!focusedPaneId && (
|
||||
<div className="px-3 py-2 border-t border-border bg-muted/10 shrink-0">
|
||||
<p className="text-[10px] text-muted-foreground text-center">
|
||||
{formatMessage({ id: 'terminalDashboard.fileSidebar.selectPaneHint', defaultMessage: 'Click a pane to show file preview' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileSidebarPanel;
|
||||
@@ -1,7 +1,8 @@
|
||||
// ========================================
|
||||
// TerminalPane Component
|
||||
// ========================================
|
||||
// Single terminal pane = PaneToolbar + TerminalInstance.
|
||||
// Single terminal pane = PaneToolbar + content area.
|
||||
// Content can be terminal output or file preview based on displayMode.
|
||||
// Renders within the TerminalGrid recursive layout.
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
@@ -19,10 +20,13 @@ import {
|
||||
Pause,
|
||||
Play,
|
||||
Loader2,
|
||||
FileText,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TerminalInstance } from './TerminalInstance';
|
||||
import { FloatingFileBrowser } from './FloatingFileBrowser';
|
||||
import { FilePreview } from '@/components/shared/FilePreview';
|
||||
import {
|
||||
useTerminalGridStore,
|
||||
selectTerminalGridPanes,
|
||||
@@ -41,6 +45,7 @@ import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import { getAllPaneIds } from '@/lib/layout-utils';
|
||||
import { sendCliSessionText } from '@/lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { useFileContent } from '@/hooks/useFileExplorer';
|
||||
import type { PaneId } from '@/stores/viewerStore';
|
||||
import type { TerminalStatus } from '@/types/terminal-dashboard';
|
||||
|
||||
@@ -73,11 +78,15 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
const closePane = useTerminalGridStore((s) => s.closePane);
|
||||
const assignSession = useTerminalGridStore((s) => s.assignSession);
|
||||
const setFocused = useTerminalGridStore((s) => s.setFocused);
|
||||
const setPaneDisplayMode = useTerminalGridStore((s) => s.setPaneDisplayMode);
|
||||
|
||||
const pane = panes[paneId];
|
||||
const sessionId = pane?.sessionId ?? null;
|
||||
const displayMode = pane?.displayMode ?? 'terminal';
|
||||
const filePath = pane?.filePath ?? null;
|
||||
const isFocused = focusedPaneId === paneId;
|
||||
const canClose = getAllPaneIds(layout).length > 1;
|
||||
const isFileMode = displayMode === 'file' && filePath;
|
||||
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const [isFileBrowserOpen, setIsFileBrowserOpen] = useState(false);
|
||||
@@ -97,6 +106,11 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [isTogglingPause, setIsTogglingPause] = useState(false);
|
||||
|
||||
// File content for preview mode
|
||||
const { content: fileContent, isLoading: isFileLoading, error: fileError } = useFileContent(filePath, {
|
||||
enabled: displayMode === 'file' && !!filePath,
|
||||
});
|
||||
|
||||
// Association chain for linked issue badge
|
||||
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
|
||||
const linkedIssueId = useMemo(() => {
|
||||
@@ -201,6 +215,11 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
}
|
||||
}, [sessionId, isTogglingPause, status, pauseSession, resumeSession]);
|
||||
|
||||
// Handle back to terminal from file preview
|
||||
const handleBackToTerminal = useCallback(() => {
|
||||
setPaneDisplayMode(paneId, 'terminal');
|
||||
}, [paneId, setPaneDisplayMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -211,38 +230,58 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
>
|
||||
{/* PaneToolbar */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-muted/30 shrink-0">
|
||||
{/* Left: Session selector + status */}
|
||||
{/* Left: Session selector + status (or file path in file mode) */}
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
{sessionId && (
|
||||
<span
|
||||
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[status])}
|
||||
/>
|
||||
)}
|
||||
<div className="relative min-w-0 flex-1 max-w-[180px]">
|
||||
<select
|
||||
value={sessionId ?? ''}
|
||||
onChange={handleSessionChange}
|
||||
className={cn(
|
||||
'w-full text-xs bg-transparent border-none outline-none cursor-pointer',
|
||||
'appearance-none pr-5 truncate',
|
||||
!sessionId && 'text-muted-foreground'
|
||||
{isFileMode ? (
|
||||
// File mode header
|
||||
<>
|
||||
<button
|
||||
onClick={handleBackToTerminal}
|
||||
className="p-0.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground shrink-0"
|
||||
title={formatMessage({ id: 'terminalDashboard.pane.backToTerminal', defaultMessage: 'Back to terminal' })}
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs truncate" title={filePath ?? undefined}>
|
||||
{filePath?.split('/').pop() ?? 'File'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
// Terminal mode header
|
||||
<>
|
||||
{sessionId && (
|
||||
<span
|
||||
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[status])}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<option value="">
|
||||
{formatMessage({ id: 'terminalDashboard.pane.selectSession' })}
|
||||
</option>
|
||||
{sessionOptions.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
<div className="relative min-w-0 flex-1 max-w-[180px]">
|
||||
<select
|
||||
value={sessionId ?? ''}
|
||||
onChange={handleSessionChange}
|
||||
className={cn(
|
||||
'w-full text-xs bg-transparent border-none outline-none cursor-pointer',
|
||||
'appearance-none pr-5 truncate',
|
||||
!sessionId && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<option value="">
|
||||
{formatMessage({ id: 'terminalDashboard.pane.selectSession' })}
|
||||
</option>
|
||||
{sessionOptions.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center: Linked issue badge */}
|
||||
{linkedIssueId && (
|
||||
{linkedIssueId && !isFileMode && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-primary/10 text-primary shrink-0">
|
||||
{linkedIssueId}
|
||||
</span>
|
||||
@@ -264,7 +303,7 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
>
|
||||
<SplitSquareVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{sessionId && (
|
||||
{!isFileMode && sessionId && (
|
||||
<>
|
||||
{/* Restart button */}
|
||||
<button
|
||||
@@ -318,20 +357,22 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleOpenFileBrowser}
|
||||
disabled={!projectPath}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-muted transition-colors',
|
||||
projectPath
|
||||
? 'text-muted-foreground hover:text-foreground'
|
||||
: 'text-muted-foreground/40 cursor-not-allowed'
|
||||
)}
|
||||
title={formatMessage({ id: 'terminalDashboard.fileBrowser.open' })}
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{alertCount > 0 && (
|
||||
{!isFileMode && (
|
||||
<button
|
||||
onClick={handleOpenFileBrowser}
|
||||
disabled={!projectPath}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-muted transition-colors',
|
||||
projectPath
|
||||
? 'text-muted-foreground hover:text-foreground'
|
||||
: 'text-muted-foreground/40 cursor-not-allowed'
|
||||
)}
|
||||
title={formatMessage({ id: 'terminalDashboard.fileBrowser.open' })}
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{alertCount > 0 && !isFileMode && (
|
||||
<span className="flex items-center gap-0.5 px-1 text-destructive">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span className="text-[10px] font-semibold tabular-nums">
|
||||
@@ -351,12 +392,24 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal content */}
|
||||
{sessionId ? (
|
||||
{/* Content area */}
|
||||
{isFileMode ? (
|
||||
// File preview mode
|
||||
<div className="flex-1 min-h-0">
|
||||
<FilePreview
|
||||
fileContent={fileContent}
|
||||
isLoading={isFileLoading}
|
||||
error={fileError?.message ?? null}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
) : sessionId ? (
|
||||
// Terminal mode with session
|
||||
<div className="flex-1 min-h-0">
|
||||
<TerminalInstance sessionId={sessionId} onRevealPath={handleRevealPath} />
|
||||
</div>
|
||||
) : (
|
||||
// Empty terminal state
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Terminal className="h-6 w-6 mx-auto mb-1.5 opacity-30" />
|
||||
|
||||
174
ccw/frontend/src/components/ui/Sheet.tsx
Normal file
174
ccw/frontend/src/components/ui/Sheet.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
// ========================================
|
||||
// Sheet Component
|
||||
// ========================================
|
||||
// Bottom sheet for A2UI surfaces with slide-up animation
|
||||
// Supports drag-to-dismiss and snap points
|
||||
|
||||
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 sheetVariants = cva(
|
||||
'fixed z-50 gap-4 bg-card shadow-lg border-t 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: {
|
||||
bottom: 'inset-x-0 bottom-0 rounded-t-lg data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
top: 'inset-x-0 top-0 rounded-b-lg data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
},
|
||||
size: {
|
||||
auto: 'h-auto',
|
||||
half: 'h-[50vh]',
|
||||
full: 'h-[90vh]',
|
||||
content: 'max-h-[90vh]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: 'bottom',
|
||||
size: 'content',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// ========== Root Components ==========
|
||||
|
||||
const Sheet = DialogPrimitive.Root;
|
||||
const SheetTrigger = DialogPrimitive.Trigger;
|
||||
const SheetClose = DialogPrimitive.Close;
|
||||
const SheetPortal = DialogPrimitive.Portal;
|
||||
|
||||
// ========== Overlay ==========
|
||||
|
||||
const SheetOverlay = 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}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
// ========== Content ==========
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {
|
||||
/** Whether to show the drag handle */
|
||||
showHandle?: boolean;
|
||||
/** Whether clicking outside should close the sheet */
|
||||
closeOnOutsideClick?: boolean;
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(
|
||||
(
|
||||
{ side = 'bottom', size = 'content', showHandle = true, closeOnOutsideClick = true, className, children, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side, size }), 'flex flex-col', className)}
|
||||
onInteractOutside={(e) => {
|
||||
if (!closeOnOutsideClick) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
{showHandle && side === 'bottom' && (
|
||||
<div className="flex justify-center pt-2 pb-1">
|
||||
<div className="w-10 h-1.5 rounded-full bg-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<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>
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
SheetContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
// ========== Header ==========
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left pt-2', className)} {...props} />
|
||||
);
|
||||
SheetHeader.displayName = 'SheetHeader';
|
||||
|
||||
// ========== Footer ==========
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 pt-4 border-t border-border mt-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = 'SheetFooter';
|
||||
|
||||
// ========== Title ==========
|
||||
|
||||
const SheetTitle = 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}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
// ========== Description ==========
|
||||
|
||||
const SheetDescription = 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}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
// ========== Exports ==========
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
type SheetContentProps,
|
||||
};
|
||||
101
ccw/frontend/src/contexts/__tests__/DialogStyleContext.test.tsx
Normal file
101
ccw/frontend/src/contexts/__tests__/DialogStyleContext.test.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
// ========================================
|
||||
// DialogStyleContext Tests
|
||||
// ========================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { ReactNode } from 'react';
|
||||
import { DialogStyleProvider, useDialogStyleContext, useDialogStyle } from '../DialogStyleContext';
|
||||
import type { A2UIPreferences } from '@/types/store';
|
||||
|
||||
// Create mock functions
|
||||
const mockSetA2uiPreferences = vi.fn();
|
||||
|
||||
const defaultPreferences: A2UIPreferences = {
|
||||
dialogStyle: 'modal',
|
||||
smartModeEnabled: true,
|
||||
autoSelectionDuration: 30,
|
||||
autoSelectionSoundEnabled: false,
|
||||
pauseOnInteraction: true,
|
||||
showA2UIButtonInToolbar: true,
|
||||
drawerSide: 'right',
|
||||
drawerSize: 'md',
|
||||
};
|
||||
|
||||
let currentPreferences: A2UIPreferences = { ...defaultPreferences };
|
||||
|
||||
vi.mock('@/stores/configStore', () => ({
|
||||
useConfigStore: vi.fn((selector: (state: object) => unknown) => {
|
||||
return selector({
|
||||
a2uiPreferences: currentPreferences,
|
||||
setA2uiPreferences: mockSetA2uiPreferences,
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('DialogStyleContext', () => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<DialogStyleProvider>{children}</DialogStyleProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
currentPreferences = { ...defaultPreferences };
|
||||
});
|
||||
|
||||
describe('useDialogStyleContext', () => {
|
||||
it('should provide default preferences', () => {
|
||||
const { result } = renderHook(() => useDialogStyleContext(), { wrapper });
|
||||
|
||||
expect(result.current.preferences.dialogStyle).toBe('modal');
|
||||
expect(result.current.preferences.smartModeEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should call setA2uiPreferences when updating preference', () => {
|
||||
const { result } = renderHook(() => useDialogStyleContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.updatePreference('dialogStyle', 'drawer');
|
||||
});
|
||||
|
||||
expect(mockSetA2uiPreferences).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should get recommended style based on question type', () => {
|
||||
const { result } = renderHook(() => useDialogStyleContext(), { wrapper });
|
||||
|
||||
expect(result.current.getRecommendedStyle('confirm')).toBe('modal');
|
||||
expect(result.current.getRecommendedStyle('multi-select')).toBe('drawer');
|
||||
expect(result.current.getRecommendedStyle('multi-question')).toBe('drawer');
|
||||
});
|
||||
|
||||
it('should return default style when smart mode is disabled', () => {
|
||||
currentPreferences.smartModeEnabled = false;
|
||||
currentPreferences.dialogStyle = 'fullscreen';
|
||||
|
||||
const { result } = renderHook(() => useDialogStyleContext(), { wrapper });
|
||||
|
||||
expect(result.current.getRecommendedStyle('confirm')).toBe('fullscreen');
|
||||
});
|
||||
|
||||
it('should reset preferences', () => {
|
||||
const { result } = renderHook(() => useDialogStyleContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.resetPreferences();
|
||||
});
|
||||
|
||||
expect(mockSetA2uiPreferences).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDialogStyle', () => {
|
||||
it('should return current style and preferences', () => {
|
||||
const { result } = renderHook(() => useDialogStyle(), { wrapper });
|
||||
|
||||
expect(result.current.style).toBe('modal');
|
||||
expect(result.current.preferences).toBeDefined();
|
||||
expect(result.current.getRecommendedStyle).toBeInstanceOf(Function);
|
||||
});
|
||||
});
|
||||
});
|
||||
212
ccw/frontend/src/hooks/__tests__/useAutoSelection.test.ts
Normal file
212
ccw/frontend/src/hooks/__tests__/useAutoSelection.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
// ========================================
|
||||
// useAutoSelection Hook Tests
|
||||
// ========================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useAutoSelection, useInteractionPause } from '../useAutoSelection';
|
||||
|
||||
// Mock DialogStyleContext
|
||||
vi.mock('@/contexts/DialogStyleContext', () => ({
|
||||
useDialogStyleContext: () => ({
|
||||
preferences: {
|
||||
autoSelectionDuration: 30,
|
||||
pauseOnInteraction: true,
|
||||
autoSelectionSoundEnabled: false,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useAutoSelection', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return null remaining when no timeout provided', () => {
|
||||
const onAutoSelect = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useAutoSelection({
|
||||
onAutoSelect,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.remaining).toBeNull();
|
||||
expect(result.current.isEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should count down from timeout', () => {
|
||||
const onAutoSelect = vi.fn();
|
||||
const timeoutAt = new Date(Date.now() + 10000).toISOString();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutoSelection({
|
||||
timeoutAt,
|
||||
defaultLabel: 'Yes',
|
||||
onAutoSelect,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.remaining).toBe(10);
|
||||
expect(result.current.isEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should pause and resume countdown', () => {
|
||||
const onAutoSelect = vi.fn();
|
||||
const timeoutAt = new Date(Date.now() + 10000).toISOString();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutoSelection({
|
||||
timeoutAt,
|
||||
defaultLabel: 'Yes',
|
||||
onAutoSelect,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.isPaused).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.pause();
|
||||
});
|
||||
|
||||
expect(result.current.isPaused).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.resume();
|
||||
});
|
||||
|
||||
expect(result.current.isPaused).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle pause state', () => {
|
||||
const onAutoSelect = vi.fn();
|
||||
const timeoutAt = new Date(Date.now() + 10000).toISOString();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutoSelection({
|
||||
timeoutAt,
|
||||
defaultLabel: 'Yes',
|
||||
onAutoSelect,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.isPaused).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.togglePause();
|
||||
});
|
||||
|
||||
expect(result.current.isPaused).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.togglePause();
|
||||
});
|
||||
|
||||
expect(result.current.isPaused).toBe(false);
|
||||
});
|
||||
|
||||
it('should calculate progress percentage', () => {
|
||||
const onAutoSelect = vi.fn();
|
||||
const timeoutAt = new Date(Date.now() + 10000).toISOString();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAutoSelection({
|
||||
timeoutAt,
|
||||
defaultLabel: 'Yes',
|
||||
onAutoSelect,
|
||||
})
|
||||
);
|
||||
|
||||
// At 10 seconds remaining out of 30, progress should be ~67%
|
||||
// But we use the calculated duration from timeout, so it starts at 0
|
||||
expect(result.current.progress).toBeGreaterThanOrEqual(0);
|
||||
expect(result.current.progress).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('should call onAutoSelect when countdown reaches 0', () => {
|
||||
const onAutoSelect = vi.fn();
|
||||
const timeoutAt = new Date(Date.now() + 1000).toISOString();
|
||||
|
||||
renderHook(() =>
|
||||
useAutoSelection({
|
||||
timeoutAt,
|
||||
defaultLabel: 'Yes',
|
||||
onAutoSelect,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
|
||||
expect(onAutoSelect).toHaveBeenCalledWith('Yes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useInteractionPause', () => {
|
||||
it('should return event handlers', () => {
|
||||
const pause = vi.fn();
|
||||
const resume = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInteractionPause(pause, resume, true)
|
||||
);
|
||||
|
||||
expect(result.current.onMouseEnter).toBeInstanceOf(Function);
|
||||
expect(result.current.onMouseLeave).toBeInstanceOf(Function);
|
||||
expect(result.current.onFocus).toBeInstanceOf(Function);
|
||||
expect(result.current.onBlur).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should call pause on mouse enter when enabled', () => {
|
||||
const pause = vi.fn();
|
||||
const resume = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInteractionPause(pause, resume, true)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onMouseEnter();
|
||||
});
|
||||
|
||||
expect(pause).toHaveBeenCalled();
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call resume on mouse leave when enabled', () => {
|
||||
const pause = vi.fn();
|
||||
const resume = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInteractionPause(pause, resume, true)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onMouseLeave();
|
||||
});
|
||||
|
||||
expect(resume).toHaveBeenCalled();
|
||||
expect(pause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call handlers when disabled', () => {
|
||||
const pause = vi.fn();
|
||||
const resume = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useInteractionPause(pause, resume, false)
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.onMouseEnter();
|
||||
result.current.onMouseLeave();
|
||||
});
|
||||
|
||||
expect(pause).not.toHaveBeenCalled();
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
202
ccw/frontend/src/hooks/useAutoSelection.ts
Normal file
202
ccw/frontend/src/hooks/useAutoSelection.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// ========================================
|
||||
// useAutoSelection Hook
|
||||
// ========================================
|
||||
// Enhanced auto-selection with pause and sound notification
|
||||
// Supports configurable duration and interaction pause
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useDialogStyleContext } from '@/contexts/DialogStyleContext';
|
||||
|
||||
export interface AutoSelectionOptions {
|
||||
/** Timeout timestamp from backend (ISO string) */
|
||||
timeoutAt?: string;
|
||||
/** Default label to auto-select */
|
||||
defaultLabel?: string;
|
||||
/** Question ID for the action */
|
||||
questionId?: string;
|
||||
/** Callback when auto-selection triggers */
|
||||
onAutoSelect: (defaultLabel: string) => void;
|
||||
}
|
||||
|
||||
export interface AutoSelectionState {
|
||||
/** Remaining seconds until auto-selection */
|
||||
remaining: number | null;
|
||||
/** Whether countdown is paused */
|
||||
isPaused: boolean;
|
||||
/** Whether auto-selection is enabled */
|
||||
isEnabled: boolean;
|
||||
/** Progress percentage (0-100) */
|
||||
progress: number;
|
||||
/** Pause the countdown */
|
||||
pause: () => void;
|
||||
/** Resume the countdown */
|
||||
resume: () => void;
|
||||
/** Toggle pause state */
|
||||
togglePause: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing auto-selection countdown with pause support
|
||||
*/
|
||||
export function useAutoSelection(options: AutoSelectionOptions): AutoSelectionState {
|
||||
const { timeoutAt, defaultLabel, onAutoSelect } = options;
|
||||
const { preferences } = useDialogStyleContext();
|
||||
|
||||
const [remaining, setRemaining] = useState<number | null>(null);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [totalDuration, setTotalDuration] = useState<number>(preferences.autoSelectionDuration);
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const soundPlayedRef = useRef(false);
|
||||
|
||||
// Calculate total duration from timeout or preferences
|
||||
useEffect(() => {
|
||||
if (timeoutAt) {
|
||||
const target = new Date(timeoutAt).getTime();
|
||||
const now = Date.now();
|
||||
const calculated = Math.ceil((target - now) / 1000);
|
||||
if (calculated > 0) {
|
||||
setTotalDuration(calculated);
|
||||
}
|
||||
} else {
|
||||
setTotalDuration(preferences.autoSelectionDuration);
|
||||
}
|
||||
}, [timeoutAt, preferences.autoSelectionDuration]);
|
||||
|
||||
// Countdown logic
|
||||
useEffect(() => {
|
||||
if (!timeoutAt || !defaultLabel) {
|
||||
setRemaining(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = new Date(timeoutAt).getTime();
|
||||
soundPlayedRef.current = false;
|
||||
|
||||
const tick = () => {
|
||||
if (isPaused) return;
|
||||
|
||||
const secs = Math.max(0, Math.ceil((target - Date.now()) / 1000));
|
||||
setRemaining(secs);
|
||||
|
||||
// Play sound notification 3 seconds before
|
||||
if (secs <= 3 && secs > 0 && !soundPlayedRef.current && preferences.autoSelectionSoundEnabled) {
|
||||
playNotificationSound();
|
||||
soundPlayedRef.current = true;
|
||||
}
|
||||
|
||||
// Auto-select when countdown reaches 0
|
||||
if (secs === 0) {
|
||||
onAutoSelect(defaultLabel);
|
||||
}
|
||||
};
|
||||
|
||||
tick();
|
||||
intervalRef.current = setInterval(tick, 1000);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [timeoutAt, defaultLabel, isPaused, onAutoSelect, preferences.autoSelectionSoundEnabled]);
|
||||
|
||||
// Pause/Resume handlers
|
||||
const pause = useCallback(() => {
|
||||
if (preferences.pauseOnInteraction) {
|
||||
setIsPaused(true);
|
||||
}
|
||||
}, [preferences.pauseOnInteraction]);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
setIsPaused(false);
|
||||
}, []);
|
||||
|
||||
const togglePause = useCallback(() => {
|
||||
setIsPaused((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Calculate progress
|
||||
const progress = remaining !== null && totalDuration > 0
|
||||
? Math.round(((totalDuration - remaining) / totalDuration) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
remaining,
|
||||
isPaused,
|
||||
isEnabled: !!timeoutAt && !!defaultLabel,
|
||||
progress,
|
||||
pause,
|
||||
resume,
|
||||
togglePause,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a short notification sound
|
||||
*/
|
||||
function playNotificationSound(): void {
|
||||
try {
|
||||
// Create a simple beep using Web Audio API
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.value = 880; // A5 note
|
||||
oscillator.type = 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.3);
|
||||
} catch (error) {
|
||||
console.warn('[useAutoSelection] Could not play notification sound:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling interaction pause
|
||||
* Automatically pauses countdown when user interacts with the dialog
|
||||
*/
|
||||
export function useInteractionPause(
|
||||
pause: () => void,
|
||||
resume: () => void,
|
||||
enabled: boolean
|
||||
) {
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (enabled) {
|
||||
pause();
|
||||
}
|
||||
}, [pause, enabled]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (enabled) {
|
||||
resume();
|
||||
}
|
||||
}, [resume, enabled]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (enabled) {
|
||||
pause();
|
||||
}
|
||||
}, [pause, enabled]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (enabled) {
|
||||
resume();
|
||||
}
|
||||
}, [resume, enabled]);
|
||||
|
||||
return {
|
||||
onMouseEnter: handleMouseEnter,
|
||||
onMouseLeave: handleMouseLeave,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
};
|
||||
}
|
||||
|
||||
export default useAutoSelection;
|
||||
@@ -676,6 +676,18 @@ export interface IssueSolution {
|
||||
estimatedEffort?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment entity for file uploads
|
||||
*/
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
path: string;
|
||||
type: string;
|
||||
size: number;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -687,6 +699,7 @@ export interface Issue {
|
||||
solutions?: IssueSolution[];
|
||||
labels?: string[];
|
||||
assignee?: string;
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
@@ -788,6 +801,71 @@ export async function deleteIssue(issueId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Attachment API ==========
|
||||
|
||||
export interface UploadAttachmentsResponse {
|
||||
success: boolean;
|
||||
issueId: string;
|
||||
attachments: Attachment[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ListAttachmentsResponse {
|
||||
success: boolean;
|
||||
issueId: string;
|
||||
attachments: Attachment[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload attachments to an issue
|
||||
*/
|
||||
export async function uploadAttachments(
|
||||
issueId: string,
|
||||
files: File[]
|
||||
): Promise<UploadAttachmentsResponse> {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/issues/${encodeURIComponent(issueId)}/attachments`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Upload failed' }));
|
||||
throw new Error(error.error || 'Failed to upload attachments');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* List attachments for an issue
|
||||
*/
|
||||
export async function listAttachments(issueId: string): Promise<ListAttachmentsResponse> {
|
||||
return fetchApi<ListAttachmentsResponse>(`/api/issues/${encodeURIComponent(issueId)}/attachments`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an attachment
|
||||
*/
|
||||
export async function deleteAttachment(issueId: string, attachmentId: string): Promise<void> {
|
||||
return fetchApi<void>(`/api/issues/${encodeURIComponent(issueId)}/attachments/${encodeURIComponent(attachmentId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attachment download URL
|
||||
*/
|
||||
export function getAttachmentUrl(issueId: string, filename: string): string {
|
||||
return `/api/issues/files/${encodeURIComponent(issueId)}/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull issues from GitHub
|
||||
*/
|
||||
|
||||
@@ -448,5 +448,34 @@
|
||||
"autoStart": {
|
||||
"label": "Auto-run when moved to In Progress"
|
||||
}
|
||||
},
|
||||
"issueDialog": {
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "Enter issue title...",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder": "Describe the issue or requirement in detail...",
|
||||
"typeLabel": "Select Type",
|
||||
"typeBug": "Bug",
|
||||
"typeBugDesc": "Feature not working or error",
|
||||
"typeFeature": "Feature",
|
||||
"typeFeatureDesc": "New feature request",
|
||||
"typeImprovement": "Improvement",
|
||||
"typeImprovementDesc": "Enhancement to existing feature",
|
||||
"typeOther": "Other",
|
||||
"typeOtherDesc": "Other type of issue",
|
||||
"priorityLabel": "Select Priority",
|
||||
"priorityLow": "Low",
|
||||
"priorityMedium": "Medium",
|
||||
"priorityHigh": "High",
|
||||
"priorityUrgent": "Urgent",
|
||||
"summaryTitle": "Title",
|
||||
"summaryDescription": "Description",
|
||||
"summaryType": "Type",
|
||||
"summaryPriority": "Priority",
|
||||
"cancel": "Cancel",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"submit": "Submit",
|
||||
"submitting": "Submitting..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,5 +94,11 @@
|
||||
"tokens": "Tokens",
|
||||
"duration": "Duration",
|
||||
"model": "Model"
|
||||
},
|
||||
"toolbar": {
|
||||
"a2ui": {
|
||||
"button": "A2UI",
|
||||
"quickAction": "A2UI Quick Action"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"systemStatus": "System Status",
|
||||
"hooks": "Git Hooks",
|
||||
"rules": "Rules",
|
||||
"about": "About"
|
||||
"about": "About",
|
||||
"a2ui": "A2UI Preferences"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
@@ -210,5 +211,28 @@
|
||||
"description": "Reset all user preferences to their default values. This cannot be undone.",
|
||||
"confirm": "Reset all settings to defaults?",
|
||||
"button": "Reset to Defaults"
|
||||
},
|
||||
"a2ui": {
|
||||
"title": "A2UI Preferences",
|
||||
"dialogStyle": "Dialog Style",
|
||||
"dialogStyleDesc": "Choose how A2UI dialogs are displayed",
|
||||
"styleModal": "Modal",
|
||||
"styleModalDesc": "Centered",
|
||||
"styleDrawer": "Drawer",
|
||||
"styleDrawerDesc": "Side panel",
|
||||
"styleSheet": "Sheet",
|
||||
"styleSheetDesc": "Bottom",
|
||||
"styleFullscreen": "Fullscreen",
|
||||
"styleFullscreenDesc": "Full screen",
|
||||
"smartMode": "Smart Mode",
|
||||
"smartModeDesc": "Auto-select style based on question type",
|
||||
"autoSelectionDuration": "Auto-Selection Duration",
|
||||
"autoSelectionDurationDesc": "Countdown before auto-selecting default option",
|
||||
"pauseOnInteraction": "Pause on Interaction",
|
||||
"pauseOnInteractionDesc": "Pause countdown when you interact with the dialog",
|
||||
"soundNotification": "Sound Notification",
|
||||
"soundNotificationDesc": "Play sound before auto-submit (3 seconds before)",
|
||||
"showToolbarButton": "Show Toolbar Button",
|
||||
"showToolbarButtonDesc": "Show A2UI quick action button in the toolbar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,5 +448,34 @@
|
||||
"autoStart": {
|
||||
"label": "拖到进行中自动执行"
|
||||
}
|
||||
},
|
||||
"issueDialog": {
|
||||
"titleLabel": "标题",
|
||||
"titlePlaceholder": "请输入Issue标题...",
|
||||
"descriptionLabel": "描述",
|
||||
"descriptionPlaceholder": "请详细描述问题或需求...",
|
||||
"typeLabel": "选择类型",
|
||||
"typeBug": "Bug",
|
||||
"typeBugDesc": "功能异常或错误",
|
||||
"typeFeature": "Feature",
|
||||
"typeFeatureDesc": "新功能需求",
|
||||
"typeImprovement": "Improvement",
|
||||
"typeImprovementDesc": "现有功能改进",
|
||||
"typeOther": "Other",
|
||||
"typeOtherDesc": "其他类型",
|
||||
"priorityLabel": "选择优先级",
|
||||
"priorityLow": "低",
|
||||
"priorityMedium": "中",
|
||||
"priorityHigh": "高",
|
||||
"priorityUrgent": "紧急",
|
||||
"summaryTitle": "标题",
|
||||
"summaryDescription": "描述",
|
||||
"summaryType": "类型",
|
||||
"summaryPriority": "优先级",
|
||||
"cancel": "取消",
|
||||
"previous": "上一步",
|
||||
"next": "下一步",
|
||||
"submit": "提交",
|
||||
"submitting": "提交中..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,5 +94,11 @@
|
||||
"tokens": "令牌数",
|
||||
"duration": "耗时",
|
||||
"model": "模型"
|
||||
},
|
||||
"toolbar": {
|
||||
"a2ui": {
|
||||
"button": "A2UI",
|
||||
"quickAction": "A2UI 快速操作"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"systemStatus": "系统状态",
|
||||
"hooks": "Git 钩子",
|
||||
"rules": "规则",
|
||||
"about": "关于"
|
||||
"about": "关于",
|
||||
"a2ui": "A2UI 偏好设置"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "外观",
|
||||
@@ -210,5 +211,28 @@
|
||||
"description": "将所有用户偏好重置为默认值。此操作无法撤销。",
|
||||
"confirm": "确定要将所有设置重置为默认值吗?",
|
||||
"button": "重置为默认值"
|
||||
},
|
||||
"a2ui": {
|
||||
"title": "A2UI 偏好设置",
|
||||
"dialogStyle": "对话框风格",
|
||||
"dialogStyleDesc": "选择 A2UI 对话框的显示方式",
|
||||
"styleModal": "弹窗",
|
||||
"styleModalDesc": "居中显示",
|
||||
"styleDrawer": "抽屉",
|
||||
"styleDrawerDesc": "侧边面板",
|
||||
"styleSheet": "底部弹出",
|
||||
"styleSheetDesc": "从底部弹出",
|
||||
"styleFullscreen": "全屏",
|
||||
"styleFullscreenDesc": "全屏显示",
|
||||
"smartMode": "智能模式",
|
||||
"smartModeDesc": "根据问题类型自动选择风格",
|
||||
"autoSelectionDuration": "自动选择倒计时",
|
||||
"autoSelectionDurationDesc": "自动选择默认选项前的倒计时",
|
||||
"pauseOnInteraction": "交互时暂停",
|
||||
"pauseOnInteractionDesc": "与对话框交互时暂停倒计时",
|
||||
"soundNotification": "声音提醒",
|
||||
"soundNotificationDesc": "自动提交前 3 秒播放提示音",
|
||||
"showToolbarButton": "显示工具栏按钮",
|
||||
"showToolbarButtonDesc": "在工具栏显示 A2UI 快速操作按钮"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,64 +22,159 @@ import { ObservabilityPanel } from '@/components/issue/hub/ObservabilityPanel';
|
||||
import { ExecutionPanel } from '@/components/issue/hub/ExecutionPanel';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import { useIssues, useIssueMutations, useIssueQueue } from '@/hooks';
|
||||
import { pullIssuesFromGitHub } from '@/lib/api';
|
||||
import { pullIssuesFromGitHub, uploadAttachments } from '@/lib/api';
|
||||
import type { Issue } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Issue types
|
||||
type IssueType = 'bug' | 'feature' | 'improvement' | 'other';
|
||||
|
||||
const ISSUE_TYPE_CONFIG: Record<IssueType, { label: string; description: string; color: string }> = {
|
||||
bug: { label: 'Bug', description: '功能异常或错误', color: 'bg-red-500' },
|
||||
feature: { label: 'Feature', description: '新功能需求', color: 'bg-green-500' },
|
||||
improvement: { label: 'Improvement', description: '现有功能改进', color: 'bg-blue-500' },
|
||||
other: { label: 'Other', description: '其他类型', color: 'bg-gray-500' },
|
||||
};
|
||||
|
||||
function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: { title: string; context?: string; priority?: Issue['priority'] }) => void;
|
||||
onSubmit: (data: { title: string; context?: string; priority?: Issue['priority']; type?: IssueType; attachments?: File[] }) => void;
|
||||
isCreating: boolean;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [title, setTitle] = useState('');
|
||||
const [context, setContext] = useState('');
|
||||
const [priority, setPriority] = useState<Issue['priority']>('medium');
|
||||
const [type, setType] = useState<IssueType>('other');
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
const validFiles = Array.from(files).filter(file => {
|
||||
const validTypes = ['image/', 'text/', 'application/pdf', '.md', '.txt', '.json'];
|
||||
return validTypes.some(t => file.type.includes(t) || file.name.endsWith(t));
|
||||
});
|
||||
setAttachments(prev => [...prev, ...validFiles]);
|
||||
};
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
setAttachments(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (title.trim()) {
|
||||
onSubmit({ title: title.trim(), context: context.trim() || undefined, priority });
|
||||
onSubmit({
|
||||
title: title.trim(),
|
||||
context: context.trim() || undefined,
|
||||
priority,
|
||||
type,
|
||||
attachments: attachments.length > 0 ? attachments : undefined
|
||||
});
|
||||
// Reset
|
||||
setTitle('');
|
||||
setContext('');
|
||||
setPriority('medium');
|
||||
setType('other');
|
||||
setAttachments([]);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formatMessage({ id: 'issues.createDialog.title' })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5 mt-4">
|
||||
{/* 标题 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.title' })}</label>
|
||||
<Label className="text-sm font-medium">
|
||||
{formatMessage({ id: 'issues.createDialog.labels.title' })}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.title' })}
|
||||
className="mt-1"
|
||||
className="mt-1.5"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">{title.length}/200</p>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.context' })}</label>
|
||||
<Label className="text-sm font-medium">
|
||||
{formatMessage({ id: 'issues.createDialog.labels.context' })}
|
||||
</Label>
|
||||
<textarea
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'issues.createDialog.placeholders.context' })}
|
||||
className="mt-1 w-full min-h-[100px] p-3 bg-background border border-input rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
className="mt-1.5 w-full min-h-[120px] p-3 bg-background border border-input rounded-md text-sm resize-y focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">{context.length}/10000</p>
|
||||
</div>
|
||||
|
||||
{/* 类型选择 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{formatMessage({ id: 'issues.createDialog.labels.priority' })}</label>
|
||||
<Label className="text-sm font-medium">
|
||||
{formatMessage({ id: 'issues.createDialog.labels.type', defaultMessage: '类型' })}
|
||||
</Label>
|
||||
<div className="grid grid-cols-4 gap-2 mt-1.5">
|
||||
{(Object.keys(ISSUE_TYPE_CONFIG) as IssueType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setType(t)}
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-md border text-sm transition-all',
|
||||
type === t
|
||||
? 'border-primary bg-primary/10 ring-1 ring-primary'
|
||||
: 'border-border hover:border-primary/50 hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<span className={cn('w-2 h-2 rounded-full', ISSUE_TYPE_CONFIG[t].color)} />
|
||||
<span>{ISSUE_TYPE_CONFIG[t].label}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 优先级 */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium">
|
||||
{formatMessage({ id: 'issues.createDialog.labels.priority' })}
|
||||
</Label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as Issue['priority'])}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectTrigger className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -90,7 +185,77 @@ function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
{/* 文件/图片上传 */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium">
|
||||
{formatMessage({ id: 'issues.createDialog.labels.attachments', defaultMessage: '附件' })}
|
||||
</Label>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1.5 border-2 border-dashed rounded-lg p-4 transition-colors cursor-pointer',
|
||||
dragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = true;
|
||||
input.accept = 'image/*,.md,.txt,.json,.pdf';
|
||||
input.onchange = (e) => handleFileSelect((e.target as HTMLInputElement).files);
|
||||
input.click();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-2">
|
||||
<svg className="w-8 h-8 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-sm">拖拽文件到此处,或点击上传</p>
|
||||
<p className="text-xs mt-1">支持图片、Markdown、文本、PDF</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已上传文件列表 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{attachments.map((file, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-2 bg-muted/50 rounded-md">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{file.type.startsWith('image/') ? (
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
className="w-8 h-8 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<svg className="w-8 h-8 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-sm truncate">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(1)}KB
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); removeAttachment(index); }}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{formatMessage({ id: 'issues.createDialog.buttons.cancel' })}
|
||||
</Button>
|
||||
@@ -150,9 +315,29 @@ export function IssueHubPage() {
|
||||
}
|
||||
}, [refetchIssues]);
|
||||
|
||||
const handleCreateIssue = async (data: { title: string; context?: string; priority?: Issue['priority'] }) => {
|
||||
await createIssue(data);
|
||||
setIsNewIssueOpen(false);
|
||||
const handleCreateIssue = async (data: { title: string; context?: string; priority?: Issue['priority']; type?: IssueType; attachments?: File[] }) => {
|
||||
try {
|
||||
// Create the issue first
|
||||
const newIssue = await createIssue({
|
||||
title: data.title,
|
||||
context: data.context,
|
||||
priority: data.priority,
|
||||
});
|
||||
|
||||
// Upload attachments if any
|
||||
if (data.attachments && data.attachments.length > 0 && newIssue.id) {
|
||||
try {
|
||||
await uploadAttachments(newIssue.id, data.attachments);
|
||||
} catch (uploadError) {
|
||||
console.error('Failed to upload attachments:', uploadError);
|
||||
// Don't fail the whole operation, just log the error
|
||||
}
|
||||
}
|
||||
|
||||
setIsNewIssueOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create issue:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Queue tab handler
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
useUpgradeCcwInstallation,
|
||||
} from '@/hooks/useSystemSettings';
|
||||
import { RemoteNotificationSection } from '@/components/settings/RemoteNotificationSection';
|
||||
import { A2UIPreferencesSection } from '@/components/settings/A2UIPreferencesSection';
|
||||
|
||||
// ========== File Path Input with Native File Picker ==========
|
||||
|
||||
@@ -1181,6 +1182,9 @@ export function SettingsPage() {
|
||||
{/* Response Language Settings */}
|
||||
<ResponseLanguageSection />
|
||||
|
||||
{/* A2UI Preferences */}
|
||||
<A2UIPreferencesSection />
|
||||
|
||||
{/* System Status */}
|
||||
<SystemStatusSection />
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
// ========================================
|
||||
// Terminal Dashboard Page (V2)
|
||||
// ========================================
|
||||
// Terminal-first layout with fixed session sidebar + floating panels.
|
||||
// Terminal-first layout with fixed session sidebar + floating panels + right file sidebar.
|
||||
// Left sidebar: SessionGroupTree + AgentList (always visible)
|
||||
// Main area: TerminalGrid (tmux-style split panes)
|
||||
// Right sidebar: FileSidebarPanel (file tree, resizable)
|
||||
// Top: DashboardToolbar with panel toggles and layout presets
|
||||
// Floating panels: Issues, Queue, Inspector (overlay, mutually exclusive)
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Allotment } from 'allotment';
|
||||
import 'allotment/dist/style.css';
|
||||
import { AssociationHighlightProvider } from '@/components/terminal-dashboard/AssociationHighlight';
|
||||
import { DashboardToolbar, type PanelId } from '@/components/terminal-dashboard/DashboardToolbar';
|
||||
import { TerminalGrid } from '@/components/terminal-dashboard/TerminalGrid';
|
||||
@@ -18,20 +21,17 @@ import { AgentList } from '@/components/terminal-dashboard/AgentList';
|
||||
import { IssuePanel } from '@/components/terminal-dashboard/IssuePanel';
|
||||
import { QueuePanel } from '@/components/terminal-dashboard/QueuePanel';
|
||||
import { InspectorContent } from '@/components/terminal-dashboard/BottomInspector';
|
||||
import { FloatingFileBrowser } from '@/components/terminal-dashboard/FloatingFileBrowser';
|
||||
import { FileSidebarPanel } from '@/components/terminal-dashboard/FileSidebarPanel';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { useTerminalGridStore, selectTerminalGridFocusedPaneId } from '@/stores/terminalGridStore';
|
||||
import { sendCliSessionText } from '@/lib/api';
|
||||
|
||||
// ========== Main Page Component ==========
|
||||
|
||||
export function TerminalDashboardPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activePanel, setActivePanel] = useState<PanelId | null>(null);
|
||||
const [isFileSidebarOpen, setIsFileSidebarOpen] = useState(true);
|
||||
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
|
||||
const panes = useTerminalGridStore((s) => s.panes);
|
||||
|
||||
const togglePanel = useCallback((panelId: PanelId) => {
|
||||
setActivePanel((prev) => (prev === panelId ? null : panelId));
|
||||
@@ -41,20 +41,6 @@ export function TerminalDashboardPage() {
|
||||
setActivePanel(null);
|
||||
}, []);
|
||||
|
||||
const handleInsertPath = useCallback(
|
||||
(path: string) => {
|
||||
if (!focusedPaneId) return;
|
||||
const sessionId = panes[focusedPaneId]?.sessionId;
|
||||
if (!sessionId) return;
|
||||
sendCliSessionText(
|
||||
sessionId,
|
||||
{ text: path, appendNewline: false },
|
||||
projectPath ?? undefined
|
||||
).catch((err) => console.error('[TerminalDashboard] insert path failed:', err));
|
||||
},
|
||||
[focusedPaneId, panes, projectPath]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="-m-4 md:-m-6 flex flex-col h-[calc(100vh-56px)] overflow-hidden">
|
||||
<AssociationHighlightProvider>
|
||||
@@ -62,24 +48,41 @@ export function TerminalDashboardPage() {
|
||||
<DashboardToolbar
|
||||
activePanel={activePanel}
|
||||
onTogglePanel={togglePanel}
|
||||
isFileSidebarOpen={isFileSidebarOpen}
|
||||
onToggleFileSidebar={() => setIsFileSidebarOpen((prev) => !prev)}
|
||||
/>
|
||||
|
||||
{/* Main content: session sidebar + terminal grid */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Fixed session sidebar */}
|
||||
<div className="w-[240px] shrink-0 flex flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<SessionGroupTree />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<AgentList />
|
||||
</div>
|
||||
</div>
|
||||
{/* Main content with three-column layout */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<Allotment>
|
||||
{/* Fixed session sidebar (240px) */}
|
||||
<Allotment.Pane preferredSize={240} minSize={180} maxSize={320}>
|
||||
<div className="h-full flex flex-col border-r border-border">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<SessionGroupTree />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<AgentList />
|
||||
</div>
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
|
||||
{/* Terminal grid (takes remaining space) */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<TerminalGrid />
|
||||
</div>
|
||||
{/* Terminal grid (flexible) */}
|
||||
<Allotment.Pane minSize={300}>
|
||||
<TerminalGrid />
|
||||
</Allotment.Pane>
|
||||
|
||||
{/* File sidebar (conditional, default 280px) */}
|
||||
{isFileSidebarOpen && (
|
||||
<Allotment.Pane preferredSize={280} minSize={200} maxSize={400}>
|
||||
<FileSidebarPanel
|
||||
rootPath={projectPath ?? '/'}
|
||||
enabled={!!projectPath}
|
||||
onCollapse={() => setIsFileSidebarOpen(false)}
|
||||
/>
|
||||
</Allotment.Pane>
|
||||
)}
|
||||
</Allotment>
|
||||
</div>
|
||||
|
||||
{/* Floating panels (conditional, overlay) */}
|
||||
@@ -112,15 +115,6 @@ export function TerminalDashboardPage() {
|
||||
>
|
||||
<InspectorContent />
|
||||
</FloatingPanel>
|
||||
|
||||
{/* File browser (half screen, right side) */}
|
||||
<FloatingFileBrowser
|
||||
isOpen={activePanel === 'files'}
|
||||
onClose={closePanel}
|
||||
rootPath={projectPath ?? '/'}
|
||||
onInsertPath={focusedPaneId ? handleInsertPath : undefined}
|
||||
width="50vw"
|
||||
/>
|
||||
</AssociationHighlightProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
CliToolConfig,
|
||||
ApiEndpoints,
|
||||
UserPreferences,
|
||||
A2UIPreferences,
|
||||
} from '../types/store';
|
||||
|
||||
// Default CLI tools configuration
|
||||
@@ -75,12 +76,25 @@ const defaultUserPreferences: UserPreferences = {
|
||||
defaultSortDirection: 'desc',
|
||||
};
|
||||
|
||||
// Default A2UI preferences
|
||||
const defaultA2uiPreferences: A2UIPreferences = {
|
||||
dialogStyle: 'modal',
|
||||
smartModeEnabled: true,
|
||||
autoSelectionDuration: 30,
|
||||
autoSelectionSoundEnabled: false,
|
||||
pauseOnInteraction: true,
|
||||
showA2UIButtonInToolbar: true,
|
||||
drawerSide: 'right',
|
||||
drawerSize: 'md',
|
||||
};
|
||||
|
||||
// Initial state
|
||||
const initialState: ConfigState = {
|
||||
cliTools: defaultCliTools,
|
||||
defaultCliTool: 'gemini',
|
||||
apiEndpoints: defaultApiEndpoints,
|
||||
userPreferences: defaultUserPreferences,
|
||||
a2uiPreferences: defaultA2uiPreferences,
|
||||
featureFlags: {
|
||||
orchestratorEnabled: true,
|
||||
darkModeEnabled: true,
|
||||
@@ -158,6 +172,16 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
set({ userPreferences: defaultUserPreferences }, false, 'resetUserPreferences');
|
||||
},
|
||||
|
||||
// ========== A2UI Preferences Actions ==========
|
||||
|
||||
setA2uiPreferences: (prefs: A2UIPreferences) => {
|
||||
set({ a2uiPreferences: prefs }, false, 'setA2uiPreferences');
|
||||
},
|
||||
|
||||
resetA2uiPreferences: () => {
|
||||
set({ a2uiPreferences: defaultA2uiPreferences }, false, 'resetA2uiPreferences');
|
||||
},
|
||||
|
||||
// ========== Feature Flags Actions ==========
|
||||
|
||||
setFeatureFlag: (flag: string, enabled: boolean) => {
|
||||
|
||||
@@ -298,3 +298,15 @@ export type {
|
||||
IssueQueueIntegrationActions,
|
||||
IssueQueueIntegrationStore,
|
||||
} from '../types/terminal-dashboard';
|
||||
|
||||
// Issue Dialog Store
|
||||
export {
|
||||
useIssueDialogStore,
|
||||
} from './issueDialogStore';
|
||||
|
||||
export type {
|
||||
IssueType,
|
||||
IssuePriority,
|
||||
IssueFormData,
|
||||
IssueDialogState,
|
||||
} from './issueDialogStore';
|
||||
|
||||
279
ccw/frontend/src/stores/issueDialogStore.ts
Normal file
279
ccw/frontend/src/stores/issueDialogStore.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
// ========================================
|
||||
// Issue Dialog Store
|
||||
// ========================================
|
||||
// Zustand store for managing issue submission wizard state
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type IssueType = 'bug' | 'feature' | 'improvement' | 'other';
|
||||
export type IssuePriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
|
||||
export interface IssueFormData {
|
||||
title: string;
|
||||
description: string;
|
||||
type: IssueType;
|
||||
priority: IssuePriority;
|
||||
tags: string[];
|
||||
project_id?: string;
|
||||
}
|
||||
|
||||
export interface WizardStep {
|
||||
id: string;
|
||||
field: keyof IssueFormData | 'summary';
|
||||
title: string;
|
||||
description?: string;
|
||||
isOptional?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueDialogState {
|
||||
// Dialog state
|
||||
isOpen: boolean;
|
||||
mode: 'wizard' | 'quick' | 'cli';
|
||||
|
||||
// Wizard state
|
||||
currentStep: number;
|
||||
steps: WizardStep[];
|
||||
|
||||
// Form data
|
||||
formData: IssueFormData;
|
||||
validationErrors: Partial<Record<keyof IssueFormData, string>>;
|
||||
|
||||
// Submission state
|
||||
isSubmitting: boolean;
|
||||
submitError: string | null;
|
||||
submittedIssueId: string | null;
|
||||
|
||||
// Actions - Dialog
|
||||
openDialog: (mode?: 'wizard' | 'quick' | 'cli') => void;
|
||||
closeDialog: () => void;
|
||||
|
||||
// Actions - Wizard navigation
|
||||
goToStep: (step: number) => void;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
|
||||
// Actions - Form
|
||||
updateField: <K extends keyof IssueFormData>(field: K, value: IssueFormData[K]) => void;
|
||||
setFormData: (data: Partial<IssueFormData>) => void;
|
||||
resetForm: () => void;
|
||||
validateCurrentStep: () => boolean;
|
||||
|
||||
// Actions - Submission
|
||||
submitIssue: () => Promise<{ success: boolean; issueId?: string; error?: string }>;
|
||||
}
|
||||
|
||||
// ========== Default Values ==========
|
||||
|
||||
const defaultFormData: IssueFormData = {
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'other',
|
||||
priority: 'medium',
|
||||
tags: [],
|
||||
project_id: undefined,
|
||||
};
|
||||
|
||||
const defaultSteps: WizardStep[] = [
|
||||
{
|
||||
id: 'title',
|
||||
field: 'title',
|
||||
title: 'Issue 标题',
|
||||
description: '请输入一个简洁明确的标题',
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
field: 'description',
|
||||
title: 'Issue 描述',
|
||||
description: '请详细描述问题或需求',
|
||||
},
|
||||
{
|
||||
id: 'type',
|
||||
field: 'type',
|
||||
title: 'Issue 类型',
|
||||
description: '选择 Issue 的类型',
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
field: 'priority',
|
||||
title: '优先级',
|
||||
description: '设置 Issue 的处理优先级',
|
||||
isOptional: true,
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
field: 'summary',
|
||||
title: '确认提交',
|
||||
description: '请确认以下信息后提交',
|
||||
},
|
||||
];
|
||||
|
||||
// ========== Store Implementation ==========
|
||||
|
||||
export const useIssueDialogStore = create<IssueDialogState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
// Initial state
|
||||
isOpen: false,
|
||||
mode: 'wizard',
|
||||
currentStep: 0,
|
||||
steps: defaultSteps,
|
||||
formData: { ...defaultFormData },
|
||||
validationErrors: {},
|
||||
isSubmitting: false,
|
||||
submitError: null,
|
||||
submittedIssueId: null,
|
||||
|
||||
// Dialog actions
|
||||
openDialog: (mode = 'wizard') => {
|
||||
set({
|
||||
isOpen: true,
|
||||
mode,
|
||||
currentStep: 0,
|
||||
formData: { ...defaultFormData },
|
||||
validationErrors: {},
|
||||
submitError: null,
|
||||
submittedIssueId: null,
|
||||
});
|
||||
},
|
||||
|
||||
closeDialog: () => {
|
||||
set({
|
||||
isOpen: false,
|
||||
isSubmitting: false,
|
||||
});
|
||||
},
|
||||
|
||||
// Wizard navigation
|
||||
goToStep: (step) => {
|
||||
const { steps, validateCurrentStep } = get();
|
||||
if (step >= 0 && step < steps.length) {
|
||||
// Validate current step before moving forward
|
||||
if (step > get().currentStep && !validateCurrentStep()) {
|
||||
return;
|
||||
}
|
||||
set({ currentStep: step });
|
||||
}
|
||||
},
|
||||
|
||||
nextStep: () => {
|
||||
const { currentStep, steps, validateCurrentStep } = get();
|
||||
if (currentStep < steps.length - 1) {
|
||||
if (validateCurrentStep()) {
|
||||
set({ currentStep: currentStep + 1 });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
prevStep: () => {
|
||||
const { currentStep } = get();
|
||||
if (currentStep > 0) {
|
||||
set({ currentStep: currentStep - 1 });
|
||||
}
|
||||
},
|
||||
|
||||
// Form actions
|
||||
updateField: (field, value) => {
|
||||
set((state) => ({
|
||||
formData: { ...state.formData, [field]: value },
|
||||
validationErrors: { ...state.validationErrors, [field]: undefined },
|
||||
}));
|
||||
},
|
||||
|
||||
setFormData: (data) => {
|
||||
set((state) => ({
|
||||
formData: { ...state.formData, ...data },
|
||||
}));
|
||||
},
|
||||
|
||||
resetForm: () => {
|
||||
set({
|
||||
formData: { ...defaultFormData },
|
||||
validationErrors: {},
|
||||
currentStep: 0,
|
||||
submitError: null,
|
||||
submittedIssueId: null,
|
||||
});
|
||||
},
|
||||
|
||||
validateCurrentStep: () => {
|
||||
const { currentStep, steps, formData } = get();
|
||||
const currentField = steps[currentStep]?.field;
|
||||
|
||||
if (currentField === 'summary') {
|
||||
return true; // Summary step doesn't need validation
|
||||
}
|
||||
|
||||
const errors: Partial<Record<keyof IssueFormData, string>> = {};
|
||||
|
||||
if (currentField === 'title') {
|
||||
if (!formData.title.trim()) {
|
||||
errors.title = '标题不能为空';
|
||||
} else if (formData.title.length > 200) {
|
||||
errors.title = '标题不能超过200个字符';
|
||||
}
|
||||
}
|
||||
|
||||
if (currentField === 'description') {
|
||||
if (!formData.description.trim()) {
|
||||
errors.description = '描述不能为空';
|
||||
} else if (formData.description.length > 10000) {
|
||||
errors.description = '描述不能超过10000个字符';
|
||||
}
|
||||
}
|
||||
|
||||
set({ validationErrors: errors });
|
||||
return Object.keys(errors).length === 0;
|
||||
},
|
||||
|
||||
// Submission
|
||||
submitIssue: async () => {
|
||||
const { formData } = get();
|
||||
set({ isSubmitting: true, submitError: null });
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/issues', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: `ISSUE-${Date.now()}`,
|
||||
title: formData.title,
|
||||
context: formData.description,
|
||||
priority: formData.priority === 'urgent' ? 1 :
|
||||
formData.priority === 'high' ? 2 :
|
||||
formData.priority === 'medium' ? 3 : 4,
|
||||
tags: formData.tags,
|
||||
status: 'registered',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || result.error) {
|
||||
set({
|
||||
isSubmitting: false,
|
||||
submitError: result.error || '提交失败,请稍后重试'
|
||||
});
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
set({
|
||||
isSubmitting: false,
|
||||
submittedIssueId: result.issue?.id
|
||||
});
|
||||
|
||||
return { success: true, issueId: result.issue?.id };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '网络错误,请稍后重试';
|
||||
set({ isSubmitting: false, submitError: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: 'issue-dialog-store' }
|
||||
)
|
||||
);
|
||||
|
||||
export default useIssueDialogStore;
|
||||
@@ -22,6 +22,10 @@ export interface TerminalPaneState {
|
||||
id: PaneId;
|
||||
/** Bound terminal session key (null = empty pane awaiting assignment) */
|
||||
sessionId: string | null;
|
||||
/** Display mode: 'terminal' for terminal output, 'file' for file preview */
|
||||
displayMode: 'terminal' | 'file';
|
||||
/** File path for file preview mode (null when in terminal mode) */
|
||||
filePath: string | null;
|
||||
}
|
||||
|
||||
export interface TerminalGridState {
|
||||
@@ -45,6 +49,12 @@ export interface TerminalGridActions {
|
||||
config: CreateCliSessionInput,
|
||||
projectPath: string | null
|
||||
) => Promise<{ paneId: PaneId; session: CliSession } | null>;
|
||||
/** Set pane display mode (terminal or file preview) */
|
||||
setPaneDisplayMode: (paneId: PaneId, mode: 'terminal' | 'file') => void;
|
||||
/** Set file path for file preview mode */
|
||||
setPaneFilePath: (paneId: PaneId, filePath: string | null) => void;
|
||||
/** Show file in pane (combines setPaneDisplayMode and setPaneFilePath) */
|
||||
showFileInPane: (paneId: PaneId, filePath: string) => void;
|
||||
}
|
||||
|
||||
export type TerminalGridStore = TerminalGridState & TerminalGridActions;
|
||||
@@ -52,7 +62,50 @@ export type TerminalGridStore = TerminalGridState & TerminalGridActions;
|
||||
// ========== Constants ==========
|
||||
|
||||
const GRID_STORAGE_KEY = 'terminal-grid-storage';
|
||||
const GRID_STORAGE_VERSION = 1;
|
||||
const GRID_STORAGE_VERSION = 2;
|
||||
|
||||
// ========== Migration ==========
|
||||
|
||||
interface LegacyPaneState {
|
||||
id: PaneId;
|
||||
sessionId: string | null;
|
||||
displayMode?: 'terminal' | 'file';
|
||||
filePath?: string | null;
|
||||
}
|
||||
|
||||
interface LegacyState {
|
||||
layout: AllotmentLayoutGroup;
|
||||
panes: Record<PaneId, LegacyPaneState>;
|
||||
focusedPaneId: PaneId | null;
|
||||
nextPaneIdCounter: number;
|
||||
}
|
||||
|
||||
function migratePaneState(pane: LegacyPaneState): TerminalPaneState {
|
||||
return {
|
||||
id: pane.id,
|
||||
sessionId: pane.sessionId,
|
||||
displayMode: pane.displayMode ?? 'terminal',
|
||||
filePath: pane.filePath ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateState(persisted: unknown, version: number): TerminalGridState {
|
||||
if (version < 2) {
|
||||
// Migration from v1 to v2: add displayMode and filePath to panes
|
||||
const legacy = persisted as LegacyState;
|
||||
const migratedPanes: Record<PaneId, TerminalPaneState> = {};
|
||||
for (const [paneId, pane] of Object.entries(legacy.panes)) {
|
||||
migratedPanes[paneId as PaneId] = migratePaneState(pane);
|
||||
}
|
||||
return {
|
||||
layout: legacy.layout,
|
||||
panes: migratedPanes,
|
||||
focusedPaneId: legacy.focusedPaneId,
|
||||
nextPaneIdCounter: legacy.nextPaneIdCounter,
|
||||
};
|
||||
}
|
||||
return persisted as TerminalGridState;
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
@@ -64,7 +117,7 @@ function createInitialLayout(): { layout: AllotmentLayoutGroup; panes: Record<Pa
|
||||
const paneId = generatePaneId(1);
|
||||
return {
|
||||
layout: { direction: 'horizontal', sizes: [100], children: [paneId] },
|
||||
panes: { [paneId]: { id: paneId, sessionId: null } },
|
||||
panes: { [paneId]: { id: paneId, sessionId: null, displayMode: 'terminal', filePath: null } },
|
||||
focusedPaneId: paneId,
|
||||
nextPaneIdCounter: 2,
|
||||
};
|
||||
@@ -109,7 +162,7 @@ export const useTerminalGridStore = create<TerminalGridStore>()(
|
||||
layout: newLayout,
|
||||
panes: {
|
||||
...state.panes,
|
||||
[newPaneId]: { id: newPaneId, sessionId: null },
|
||||
[newPaneId]: { id: newPaneId, sessionId: null, displayMode: 'terminal', filePath: null },
|
||||
},
|
||||
focusedPaneId: newPaneId,
|
||||
nextPaneIdCounter: state.nextPaneIdCounter + 1,
|
||||
@@ -175,7 +228,7 @@ export const useTerminalGridStore = create<TerminalGridStore>()(
|
||||
|
||||
const createPane = (): TerminalPaneState => {
|
||||
const id = generatePaneId(counter++);
|
||||
return { id, sessionId: null };
|
||||
return { id, sessionId: null, displayMode: 'terminal', filePath: null };
|
||||
};
|
||||
|
||||
let layout: AllotmentLayoutGroup;
|
||||
@@ -278,7 +331,7 @@ export const useTerminalGridStore = create<TerminalGridStore>()(
|
||||
layout: newLayout,
|
||||
panes: {
|
||||
...state.panes,
|
||||
[newPaneId]: { id: newPaneId, sessionId: session.sessionKey },
|
||||
[newPaneId]: { id: newPaneId, sessionId: session.sessionKey, displayMode: 'terminal', filePath: null },
|
||||
},
|
||||
focusedPaneId: newPaneId,
|
||||
nextPaneIdCounter: state.nextPaneIdCounter + 1,
|
||||
@@ -293,12 +346,65 @@ export const useTerminalGridStore = create<TerminalGridStore>()(
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
setPaneDisplayMode: (paneId, mode) => {
|
||||
const state = get();
|
||||
const pane = state.panes[paneId];
|
||||
if (!pane) return;
|
||||
|
||||
set(
|
||||
{
|
||||
panes: {
|
||||
...state.panes,
|
||||
[paneId]: { ...pane, displayMode: mode, filePath: mode === 'terminal' ? null : pane.filePath },
|
||||
},
|
||||
},
|
||||
false,
|
||||
'terminalGrid/setPaneDisplayMode'
|
||||
);
|
||||
},
|
||||
|
||||
setPaneFilePath: (paneId, filePath) => {
|
||||
const state = get();
|
||||
const pane = state.panes[paneId];
|
||||
if (!pane) return;
|
||||
|
||||
set(
|
||||
{
|
||||
panes: {
|
||||
...state.panes,
|
||||
[paneId]: { ...pane, filePath },
|
||||
},
|
||||
},
|
||||
false,
|
||||
'terminalGrid/setPaneFilePath'
|
||||
);
|
||||
},
|
||||
|
||||
showFileInPane: (paneId, filePath) => {
|
||||
const state = get();
|
||||
const pane = state.panes[paneId];
|
||||
if (!pane) return;
|
||||
|
||||
set(
|
||||
{
|
||||
panes: {
|
||||
...state.panes,
|
||||
[paneId]: { ...pane, displayMode: 'file', filePath },
|
||||
},
|
||||
focusedPaneId: paneId,
|
||||
},
|
||||
false,
|
||||
'terminalGrid/showFileInPane'
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ name: 'TerminalGridStore' }
|
||||
),
|
||||
{
|
||||
name: GRID_STORAGE_KEY,
|
||||
version: GRID_STORAGE_VERSION,
|
||||
migrate: migrateState,
|
||||
partialize: (state) => ({
|
||||
layout: state.layout,
|
||||
panes: state.panes,
|
||||
|
||||
@@ -413,6 +413,29 @@ export interface UserPreferences {
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
// ========== A2UI Preferences 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 ConfigState {
|
||||
// CLI tools configuration
|
||||
cliTools: Record<string, CliToolConfig>;
|
||||
@@ -424,6 +447,9 @@ export interface ConfigState {
|
||||
// User preferences
|
||||
userPreferences: UserPreferences;
|
||||
|
||||
// A2UI preferences
|
||||
a2uiPreferences?: A2UIPreferences;
|
||||
|
||||
// Feature flags
|
||||
featureFlags: Record<string, boolean>;
|
||||
}
|
||||
@@ -441,6 +467,10 @@ export interface ConfigActions {
|
||||
setUserPreferences: (prefs: Partial<UserPreferences>) => void;
|
||||
resetUserPreferences: () => void;
|
||||
|
||||
// A2UI preferences
|
||||
setA2uiPreferences: (prefs: A2UIPreferences) => void;
|
||||
resetA2uiPreferences: () => void;
|
||||
|
||||
// Feature flags
|
||||
setFeatureFlag: (flag: string, enabled: boolean) => void;
|
||||
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
* ├── queues/ # Queue history directory
|
||||
* │ ├── index.json # Queue index (active + history)
|
||||
* │ └── {queue-id}.json # Individual queue files
|
||||
* └── solutions/
|
||||
* ├── {issue-id}.jsonl # Solutions for issue (one per line)
|
||||
* └── ...
|
||||
* ├── solutions/
|
||||
* │ ├── {issue-id}.jsonl # Solutions for issue (one per line)
|
||||
* │ └── ...
|
||||
* └── attachments/
|
||||
* └── {issue-id}/ # Attachments for issue
|
||||
* └── {filename} # Uploaded files
|
||||
*
|
||||
* API Endpoints (8 total):
|
||||
* API Endpoints:
|
||||
* - GET /api/issues - List all issues
|
||||
* - POST /api/issues - Create new issue
|
||||
* - POST /api/issues - Create new issue (with Zod validation)
|
||||
* - GET /api/issues/:id - Get issue detail
|
||||
* - PATCH /api/issues/:id - Update issue (includes binding logic)
|
||||
* - DELETE /api/issues/:id - Delete issue
|
||||
@@ -21,10 +24,21 @@
|
||||
* - PATCH /api/issues/:id/tasks/:taskId - Update task
|
||||
* - GET /api/queue - Get execution queue
|
||||
* - POST /api/queue/reorder - Reorder queue items
|
||||
* - POST /api/issues/:id/attachments - Upload attachment (multipart/form-data)
|
||||
* - GET /api/issues/:id/attachments - List attachments
|
||||
* - DELETE /api/issues/:id/attachments/:attachmentId - Delete attachment
|
||||
* - GET /api/issues/files/:issueId/:filename - Download file
|
||||
*/
|
||||
import { readFileSync, existsSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
||||
import { join, resolve, normalize } from 'path';
|
||||
import { readFileSync, existsSync, writeFileSync, mkdirSync, unlinkSync, createReadStream, statSync } from 'fs';
|
||||
import { join, resolve, normalize, basename } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { RouteContext } from './types.js';
|
||||
import {
|
||||
processCreateIssueRequest,
|
||||
generateIssueId,
|
||||
type CreateIssueResult,
|
||||
} from '../services/issue-service.js';
|
||||
import type { Issue, Attachment } from '../types/issue.js';
|
||||
|
||||
// ========== JSONL Helper Functions ==========
|
||||
|
||||
@@ -85,6 +99,117 @@ function generateQueueFileId(): string {
|
||||
return `QUE-${ts}`;
|
||||
}
|
||||
|
||||
// ========== Attachment Helper Functions ==========
|
||||
|
||||
const ALLOWED_MIME_TYPES = [
|
||||
// Images
|
||||
'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml',
|
||||
// Documents
|
||||
'application/pdf',
|
||||
'text/plain', 'text/markdown', 'text/csv',
|
||||
// Code files
|
||||
'application/json', 'text/javascript', 'text/typescript', 'text/html', 'text/css',
|
||||
'application/xml', 'text/xml',
|
||||
// Archives
|
||||
'application/zip', 'application/x-gzip',
|
||||
];
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
function getAttachmentsDir(issuesDir: string, issueId: string): string {
|
||||
return join(issuesDir, 'attachments', issueId);
|
||||
}
|
||||
|
||||
function sanitizeFilename(filename: string): string {
|
||||
// Remove path traversal attempts and invalid characters
|
||||
const sanitized = basename(filename)
|
||||
.replace(/[<>:"|?*\x00-\x1f]/g, '')
|
||||
.replace(/\.\./g, '');
|
||||
// Add timestamp prefix to prevent collisions
|
||||
const ext = sanitized.includes('.') ? `.${sanitized.split('.').pop()}` : '';
|
||||
const base = ext ? sanitized.slice(0, -(ext.length)) : sanitized;
|
||||
return `${Date.now()}-${base}${ext}`;
|
||||
}
|
||||
|
||||
function isValidMimeType(mimeType: string): boolean {
|
||||
// Allow common code file types that might not have standard MIME types
|
||||
const additionalTypes = [
|
||||
'application/octet-stream', // Generic binary, often used for various file types
|
||||
];
|
||||
return ALLOWED_MIME_TYPES.includes(mimeType) || additionalTypes.includes(mimeType);
|
||||
}
|
||||
|
||||
function parseMultipartFormData(req: any): Promise<{ fields: Record<string, string>; files: Array<{ name: string; data: Buffer; filename: string; type: string }> }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const boundary = req.headers['content-type']?.match(/boundary=(.+)/)?.[1];
|
||||
if (!boundary) {
|
||||
reject(new Error('No boundary in content-type'));
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const boundaryBuffer = Buffer.from(`--${boundary}`);
|
||||
const fields: Record<string, string> = {};
|
||||
const files: Array<{ name: string; data: Buffer; filename: string; type: string }> = [];
|
||||
|
||||
// Split by boundary
|
||||
let start = 0;
|
||||
while (start < buffer.length) {
|
||||
const boundaryIndex = buffer.indexOf(boundaryBuffer, start);
|
||||
if (boundaryIndex === -1) break;
|
||||
|
||||
const nextBoundary = buffer.indexOf(boundaryBuffer, boundaryIndex + boundaryBuffer.length);
|
||||
if (nextBoundary === -1) break;
|
||||
|
||||
const part = buffer.slice(boundaryIndex + boundaryBuffer.length + 2, nextBoundary - 2); // +2 for \r\n, -2 for \r\n before boundary
|
||||
|
||||
// Parse headers
|
||||
const headerEnd = part.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) {
|
||||
start = nextBoundary;
|
||||
continue;
|
||||
}
|
||||
|
||||
const headers = part.slice(0, headerEnd).toString();
|
||||
const content = part.slice(headerEnd + 4);
|
||||
|
||||
// Extract content-disposition
|
||||
const nameMatch = headers.match(/name="([^"]+)"/);
|
||||
const filenameMatch = headers.match(/filename="([^"]+)"/);
|
||||
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||
|
||||
if (nameMatch) {
|
||||
const name = nameMatch[1];
|
||||
if (filenameMatch) {
|
||||
// It's a file
|
||||
files.push({
|
||||
name,
|
||||
data: content,
|
||||
filename: filenameMatch[1],
|
||||
type: contentTypeMatch?.[1] || 'application/octet-stream',
|
||||
});
|
||||
} else {
|
||||
// It's a field
|
||||
fields[name] = content.toString().replace(/\r\n$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
start = nextBoundary;
|
||||
}
|
||||
|
||||
resolve({ fields, files });
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function readQueue(issuesDir: string) {
|
||||
// Try new multi-queue structure first
|
||||
const queuesDir = join(issuesDir, 'queues');
|
||||
@@ -1140,30 +1265,31 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/issues - Create issue
|
||||
// POST /api/issues - Create issue (with Zod validation)
|
||||
if (pathname === '/api/issues' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body: any) => {
|
||||
if (!body.id || !body.title) return { error: 'id and title required' };
|
||||
// Use new validation service
|
||||
const result: CreateIssueResult = processCreateIssueRequest(body);
|
||||
|
||||
if (!result.success) {
|
||||
return { error: result.error.error.message, status: result.status, details: result.error.error };
|
||||
}
|
||||
|
||||
// TypeScript narrowing: result is now { success: true; issue: Issue; status: 201 }
|
||||
const { issue } = result;
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
if (issues.find(i => i.id === body.id)) return { error: `Issue ${body.id} exists` };
|
||||
|
||||
const newIssue = {
|
||||
id: body.id,
|
||||
title: body.title,
|
||||
status: body.status || 'registered',
|
||||
priority: body.priority || 3,
|
||||
context: body.context || '',
|
||||
source: body.source || 'text',
|
||||
source_url: body.source_url || null,
|
||||
tags: body.tags || [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
// Check for duplicate ID (auto-generated IDs should be unique)
|
||||
if (issues.find((i: any) => i.id === issue.id)) {
|
||||
return { error: `Issue ${issue.id} already exists`, status: 409 };
|
||||
}
|
||||
|
||||
issues.push(newIssue);
|
||||
// Store issue
|
||||
issues.push(issue);
|
||||
writeIssuesJsonl(issuesDir, issues);
|
||||
return { success: true, issue: newIssue };
|
||||
|
||||
// Return 201 Created response
|
||||
return { success: true, data: { issue }, status: 201 };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -1626,5 +1752,233 @@ export async function handleIssueRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== Attachment Routes =====
|
||||
|
||||
// POST /api/issues/:id/attachments - Upload attachment
|
||||
const uploadAttachmentMatch = pathname.match(/^\/api\/issues\/([^/]+)\/attachments$/);
|
||||
if (uploadAttachmentMatch && req.method === 'POST') {
|
||||
const issueId = decodeURIComponent(uploadAttachmentMatch[1]);
|
||||
|
||||
// Check if issue exists
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issueIndex = issues.findIndex(i => i.id === issueId);
|
||||
if (issueIndex === -1) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Issue not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse multipart form data
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Content-Type must be multipart/form-data' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const { files } = await parseMultipartFormData(req);
|
||||
|
||||
if (files.length === 0) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'No files uploaded' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const uploadedAttachments: Attachment[] = [];
|
||||
const attachmentsDir = getAttachmentsDir(issuesDir, issueId);
|
||||
if (!existsSync(attachmentsDir)) {
|
||||
mkdirSync(attachmentsDir, { recursive: true });
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
// Validate file size
|
||||
if (file.data.length > MAX_FILE_SIZE) {
|
||||
continue; // Skip files that are too large
|
||||
}
|
||||
|
||||
// Validate MIME type (allow common types)
|
||||
if (!isValidMimeType(file.type)) {
|
||||
continue; // Skip invalid file types
|
||||
}
|
||||
|
||||
// Generate safe filename
|
||||
const safeFilename = sanitizeFilename(file.filename);
|
||||
const filePath = join(attachmentsDir, safeFilename);
|
||||
|
||||
// Save file
|
||||
writeFileSync(filePath, file.data);
|
||||
|
||||
// Create attachment record
|
||||
const attachment: Attachment = {
|
||||
id: randomUUID(),
|
||||
filename: file.filename,
|
||||
path: `attachments/${issueId}/${safeFilename}`,
|
||||
type: file.type,
|
||||
size: file.data.length,
|
||||
uploaded_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
uploadedAttachments.push(attachment);
|
||||
}
|
||||
|
||||
if (uploadedAttachments.length === 0) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'No valid files uploaded. Check file size (max 10MB) and type.' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Update issue with attachments
|
||||
if (!issues[issueIndex].attachments) {
|
||||
issues[issueIndex].attachments = [];
|
||||
}
|
||||
issues[issueIndex].attachments!.push(...uploadedAttachments);
|
||||
issues[issueIndex].updated_at = new Date().toISOString();
|
||||
writeIssuesJsonl(issuesDir, issues);
|
||||
|
||||
res.writeHead(201, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
issueId,
|
||||
attachments: uploadedAttachments,
|
||||
count: uploadedAttachments.length,
|
||||
}));
|
||||
} catch (err: any) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message || 'Failed to upload attachments' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/issues/:id/attachments - List attachments
|
||||
const listAttachmentsMatch = pathname.match(/^\/api\/issues\/([^/]+)\/attachments$/);
|
||||
if (listAttachmentsMatch && req.method === 'GET') {
|
||||
const issueId = decodeURIComponent(listAttachmentsMatch[1]);
|
||||
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issue = issues.find(i => i.id === issueId);
|
||||
|
||||
if (!issue) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Issue not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
issueId,
|
||||
attachments: issue.attachments || [],
|
||||
count: (issue.attachments || []).length,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// DELETE /api/issues/:id/attachments/:attachmentId - Delete attachment
|
||||
const deleteAttachmentMatch = pathname.match(/^\/api\/issues\/([^/]+)\/attachments\/([^/]+)$/);
|
||||
if (deleteAttachmentMatch && req.method === 'DELETE') {
|
||||
const issueId = decodeURIComponent(deleteAttachmentMatch[1]);
|
||||
const attachmentId = decodeURIComponent(deleteAttachmentMatch[2]);
|
||||
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issueIndex = issues.findIndex(i => i.id === issueId);
|
||||
|
||||
if (issueIndex === -1) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Issue not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const issue = issues[issueIndex];
|
||||
if (!issue.attachments || issue.attachments.length === 0) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'No attachments found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const attachmentIndex = issue.attachments.findIndex((a: Attachment) => a.id === attachmentId);
|
||||
if (attachmentIndex === -1) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Attachment not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const attachment = issue.attachments[attachmentIndex];
|
||||
|
||||
// Delete file from disk
|
||||
const filePath = join(issuesDir, attachment.path);
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
unlinkSync(filePath);
|
||||
} catch {
|
||||
// Ignore file deletion errors
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from issue
|
||||
issue.attachments.splice(attachmentIndex, 1);
|
||||
issue.updated_at = new Date().toISOString();
|
||||
writeIssuesJsonl(issuesDir, issues);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
issueId,
|
||||
deletedAttachmentId: attachmentId,
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/issues/files/:issueId/:filename - Get/download file
|
||||
const fileMatch = pathname.match(/^\/api\/issues\/files\/([^/]+)\/(.+)$/);
|
||||
if (fileMatch && req.method === 'GET') {
|
||||
const issueId = decodeURIComponent(fileMatch[1]);
|
||||
const filename = decodeURIComponent(fileMatch[2]);
|
||||
|
||||
// Verify the file belongs to this issue
|
||||
const issues = readIssuesJsonl(issuesDir);
|
||||
const issue = issues.find(i => i.id === issueId);
|
||||
|
||||
if (!issue) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Issue not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Find attachment by filename (check both original and sanitized name)
|
||||
const attachment = (issue.attachments || []).find((a: Attachment) =>
|
||||
a.path.endsWith(filename) ||
|
||||
a.filename === filename
|
||||
);
|
||||
|
||||
if (!attachment) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'File not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const filePath = join(issuesDir, attachment.path);
|
||||
if (!existsSync(filePath)) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'File not found on disk' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': attachment.type,
|
||||
'Content-Length': stat.size,
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(attachment.filename)}"`,
|
||||
});
|
||||
const fileStream = createReadStream(filePath);
|
||||
fileStream.pipe(res);
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to read file' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
156
ccw/src/core/schemas/issue-schema.ts
Normal file
156
ccw/src/core/schemas/issue-schema.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Issue Zod Validation Schemas
|
||||
* Provides runtime validation for Issue-related API requests
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Issue type enum schema
|
||||
*/
|
||||
export const IssueTypeSchema = z.enum(['bug', 'feature', 'improvement', 'other']);
|
||||
|
||||
/**
|
||||
* Issue priority enum schema
|
||||
*/
|
||||
export const IssuePrioritySchema = z.enum(['low', 'medium', 'high', 'urgent']);
|
||||
|
||||
/**
|
||||
* Issue source enum schema
|
||||
*/
|
||||
export const IssueSourceSchema = z.enum(['text', 'github', 'file']);
|
||||
|
||||
/**
|
||||
* Attachment schema for file uploads
|
||||
*/
|
||||
export const AttachmentSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
filename: z.string().max(255),
|
||||
path: z.string(),
|
||||
type: z.string().max(100),
|
||||
size: z.number().int().nonnegative(),
|
||||
uploaded_at: z.string().datetime(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create Issue request schema with validation rules
|
||||
* - title: required, max 200 characters
|
||||
* - description: required, max 10000 characters
|
||||
* - type: optional enum
|
||||
* - priority: optional enum
|
||||
* - attachments: optional array of attachments
|
||||
*/
|
||||
export const CreateIssueRequestSchema = z.object({
|
||||
title: z.string()
|
||||
.min(1, 'Title is required')
|
||||
.max(200, 'Title must be at most 200 characters')
|
||||
.trim(),
|
||||
|
||||
description: z.string()
|
||||
.min(1, 'Description is required')
|
||||
.max(10000, 'Description must be at most 10000 characters'),
|
||||
|
||||
type: IssueTypeSchema.optional()
|
||||
.default('other'),
|
||||
|
||||
priority: IssuePrioritySchema.optional()
|
||||
.default('medium'),
|
||||
|
||||
context: z.string()
|
||||
.max(5000, 'Context must be at most 5000 characters')
|
||||
.optional()
|
||||
.default(''),
|
||||
|
||||
source: IssueSourceSchema.optional()
|
||||
.default('text'),
|
||||
|
||||
source_url: z.string()
|
||||
.url('Source URL must be a valid URL')
|
||||
.nullable()
|
||||
.optional()
|
||||
.default(null),
|
||||
|
||||
tags: z.array(z.string().max(50))
|
||||
.max(10, 'Maximum 10 tags allowed')
|
||||
.optional()
|
||||
.default([]),
|
||||
|
||||
attachments: z.array(AttachmentSchema)
|
||||
.max(10, 'Maximum 10 attachments allowed')
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
/**
|
||||
* Type inference from Zod schema
|
||||
*/
|
||||
export type CreateIssueRequestInput = z.infer<typeof CreateIssueRequestSchema>;
|
||||
|
||||
/**
|
||||
* Validation result type
|
||||
*/
|
||||
export interface ValidationResult<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
errors?: Array<{
|
||||
field: string;
|
||||
message: string;
|
||||
code: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate create issue request
|
||||
* Returns validated data or error details
|
||||
*/
|
||||
export function validateCreateIssueRequest(data: unknown): ValidationResult<CreateIssueRequestInput> {
|
||||
const result = CreateIssueRequestSchema.safeParse(data);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
const errors = result.error.issues.map((issue) => ({
|
||||
field: issue.path.join('.') || 'root',
|
||||
message: issue.message,
|
||||
code: issue.code,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format validation errors for API response
|
||||
*/
|
||||
export function formatValidationErrors(
|
||||
errors: Array<{ field: string; message: string; code: string }>
|
||||
): { message: string; suggestions: string[] } {
|
||||
const messages = errors.map((e) => `${e.field}: ${e.message}`);
|
||||
const suggestions: string[] = [];
|
||||
|
||||
// Add contextual suggestions based on error types
|
||||
for (const error of errors) {
|
||||
if (error.field === 'title' && error.code === 'too_big') {
|
||||
suggestions.push('Consider using a shorter, more descriptive title');
|
||||
}
|
||||
if (error.field === 'description' && error.code === 'too_big') {
|
||||
suggestions.push('Move detailed information to context field or attachments');
|
||||
}
|
||||
if (error.field === 'type' && error.code === 'invalid_enum_value') {
|
||||
suggestions.push('Valid types are: bug, feature, improvement, other');
|
||||
}
|
||||
if (error.field === 'priority' && error.code === 'invalid_enum_value') {
|
||||
suggestions.push('Valid priorities are: low, medium, high, urgent');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: `Validation failed: ${messages.join('; ')}`,
|
||||
suggestions: suggestions.length > 0 ? suggestions : ['Check your request body format'],
|
||||
};
|
||||
}
|
||||
@@ -93,7 +93,11 @@ function handlePostRequest(req: http.IncomingMessage, res: http.ServerResponse,
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
// Support custom success status codes (e.g., 201 Created)
|
||||
const successStatus = typeof statusValue === 'number' && statusValue >= 200 && statusValue < 300
|
||||
? statusValue
|
||||
: 200;
|
||||
res.writeHead(successStatus, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
147
ccw/src/core/services/issue-service.ts
Normal file
147
ccw/src/core/services/issue-service.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Issue Service
|
||||
* Business logic for Issue CRUD operations
|
||||
*/
|
||||
import { randomUUID } from 'crypto';
|
||||
import type {
|
||||
Issue,
|
||||
CreateIssueRequest,
|
||||
ApiResponse,
|
||||
ApiErrorResponse,
|
||||
ValidationErrorResponse,
|
||||
IssueType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
} from '../types/issue.js';
|
||||
import {
|
||||
validateCreateIssueRequest,
|
||||
formatValidationErrors,
|
||||
type CreateIssueRequestInput,
|
||||
} from '../schemas/issue-schema.js';
|
||||
|
||||
/**
|
||||
* Generate a unique Issue ID
|
||||
* Format: ISS-{timestamp}-{random}
|
||||
*/
|
||||
export function generateIssueId(): string {
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = randomUUID().split('-')[0];
|
||||
return `ISS-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map validated input to Issue entity
|
||||
*/
|
||||
export function createIssueEntity(input: CreateIssueRequestInput, customId?: string): Issue {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: customId || generateIssueId(),
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
type: input.type as IssueType,
|
||||
priority: input.priority as IssuePriority,
|
||||
status: 'registered' as IssueStatus,
|
||||
context: input.context || '',
|
||||
source: input.source || 'text',
|
||||
source_url: input.source_url || null,
|
||||
tags: input.tags || [],
|
||||
attachments: input.attachments || [],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create success response
|
||||
*/
|
||||
export function createSuccessResponse<T>(data: T): ApiResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error response
|
||||
*/
|
||||
export function createErrorResponse(
|
||||
code: string,
|
||||
message: string,
|
||||
suggestions?: string[]
|
||||
): ApiErrorResponse {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
suggestions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create validation error response with field details
|
||||
*/
|
||||
export function createValidationErrorResponse(
|
||||
errors: Array<{ field: string; message: string; code: string }>
|
||||
): ValidationErrorResponse {
|
||||
const formatted = formatValidationErrors(errors);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: formatted.message,
|
||||
details: errors,
|
||||
suggestions: formatted.suggestions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Service result type for create operation
|
||||
*/
|
||||
export type CreateIssueResult =
|
||||
| { success: true; issue: Issue; status: 201 }
|
||||
| { success: false; error: ValidationErrorResponse | ApiErrorResponse; status: 400 };
|
||||
|
||||
/**
|
||||
* Process create issue request with validation
|
||||
*/
|
||||
export function processCreateIssueRequest(
|
||||
body: unknown,
|
||||
options?: { customId?: string }
|
||||
): CreateIssueResult {
|
||||
// Validate request body
|
||||
const validation = validateCreateIssueRequest(body);
|
||||
|
||||
if (!validation.success || !validation.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: createValidationErrorResponse(validation.errors || []),
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
// Create issue entity
|
||||
const issue = createIssueEntity(validation.data, options?.customId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
issue,
|
||||
status: 201,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an issue ID already exists
|
||||
*/
|
||||
export function isIssueIdExists(issues: Issue[], id: string): boolean {
|
||||
return issues.some((issue) => issue.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate issue ID format
|
||||
*/
|
||||
export function isValidIssueId(id: string): boolean {
|
||||
return /^ISS-[a-z0-9]+-[a-z0-9]+$/i.test(id);
|
||||
}
|
||||
113
ccw/src/core/types/issue.ts
Normal file
113
ccw/src/core/types/issue.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Issue Type Definitions
|
||||
* TypeScript types for Issue submission and management
|
||||
*/
|
||||
|
||||
/**
|
||||
* Issue type enum values
|
||||
*/
|
||||
export type IssueType = 'bug' | 'feature' | 'improvement' | 'other';
|
||||
|
||||
/**
|
||||
* Issue priority enum values
|
||||
*/
|
||||
export type IssuePriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
|
||||
/**
|
||||
* Issue status enum values
|
||||
*/
|
||||
export type IssueStatus = 'registered' | 'analyzing' | 'planned' | 'executing' | 'completed' | 'cancelled';
|
||||
|
||||
/**
|
||||
* Attachment entity for file uploads
|
||||
*/
|
||||
export interface Attachment {
|
||||
id: string; // UUID
|
||||
filename: string; // 原始文件名
|
||||
path: string; // 相对存储路径
|
||||
type: string; // MIME类型
|
||||
size: number; // 文件大小(bytes)
|
||||
uploaded_at: string; // ISO时间戳
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Issue Request DTO
|
||||
* Required fields: title, description
|
||||
*/
|
||||
export interface CreateIssueRequest {
|
||||
title: string;
|
||||
description: string;
|
||||
type?: IssueType;
|
||||
priority?: IssuePriority;
|
||||
context?: string;
|
||||
source?: 'text' | 'github' | 'file';
|
||||
source_url?: string | null;
|
||||
tags?: string[];
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue entity stored in persistence layer
|
||||
*/
|
||||
export interface Issue {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: IssueType;
|
||||
priority: IssuePriority;
|
||||
status: IssueStatus;
|
||||
context: string;
|
||||
source: 'text' | 'github' | 'file';
|
||||
source_url: string | null;
|
||||
tags: string[];
|
||||
attachments?: Attachment[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API success response wrapper
|
||||
*/
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* API error response wrapper
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
suggestions?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all API responses
|
||||
*/
|
||||
export type ApiResult<T = unknown> = ApiResponse<T> | ApiErrorResponse;
|
||||
|
||||
/**
|
||||
* Validation error detail
|
||||
*/
|
||||
export interface ValidationErrorDetail {
|
||||
field: string;
|
||||
message: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error response with detailed field errors
|
||||
*/
|
||||
export interface ValidationErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR';
|
||||
message: string;
|
||||
details: ValidationErrorDetail[];
|
||||
suggestions: string[];
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user