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:
catlog22
2026-02-16 11:51:21 +08:00
parent 374a1e1c2c
commit 2202c2ccfd
35 changed files with 3717 additions and 145 deletions

View File

@@ -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>
);

View 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;

View File

@@ -0,0 +1,6 @@
// ========================================
// IssueDialog Component Barrel Export
// ========================================
export { IssueDialog } from './IssueDialog';
export type { IssueType, IssuePriority, IssueFormData } from '@/stores/issueDialogStore';

View File

@@ -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;

View 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;

View File

@@ -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 */}

View File

@@ -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';

View 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;

View File

@@ -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 */}

View File

@@ -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;

View File

@@ -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" />

View 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,
};

View 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);
});
});
});

View 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();
});
});

View 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;

View File

@@ -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
*/

View File

@@ -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..."
}
}

View File

@@ -94,5 +94,11 @@
"tokens": "Tokens",
"duration": "Duration",
"model": "Model"
},
"toolbar": {
"a2ui": {
"button": "A2UI",
"quickAction": "A2UI Quick Action"
}
}
}

View File

@@ -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"
}
}

View File

@@ -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": "提交中..."
}
}

View File

@@ -94,5 +94,11 @@
"tokens": "令牌数",
"duration": "耗时",
"model": "模型"
},
"toolbar": {
"a2ui": {
"button": "A2UI",
"quickAction": "A2UI 快速操作"
}
}
}

View File

@@ -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 快速操作按钮"
}
}

View File

@@ -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">MarkdownPDF</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

View File

@@ -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 />

View File

@@ -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>
);

View File

@@ -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) => {

View File

@@ -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';

View 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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}

View 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'],
};
}

View File

@@ -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);

View 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
View 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[];
};
}