mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: add Sheet component for bottom sheet UI with drag-to-dismiss and snap points
test: implement DialogStyleContext tests for preference management and style recommendations test: create tests for useAutoSelection hook, including countdown and pause functionality feat: implement useAutoSelection hook for enhanced auto-selection with sound notifications feat: create Zustand store for managing issue submission wizard state feat: add Zod validation schemas for issue-related API requests feat: implement issue service for CRUD operations and validation handling feat: define TypeScript types for issue submission and management
This commit is contained in:
382
ccw/frontend/src/components/IssueDialog/IssueDialog.tsx
Normal file
382
ccw/frontend/src/components/IssueDialog/IssueDialog.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
// ========================================
|
||||
// Issue Dialog Components
|
||||
// ========================================
|
||||
// Interactive wizard for submitting issues/requirements
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useIssueDialogStore } from '@/stores/issueDialogStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { IssueType, IssuePriority } from '@/stores/issueDialogStore';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
interface IssueDialogProps {
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
// ========== Step Components ==========
|
||||
|
||||
function TitleStep() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { formData, validationErrors, updateField } = useIssueDialogStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title" className="text-base font-medium">
|
||||
{formatMessage({ id: 'issueDialog.titleLabel', defaultMessage: '标题' })}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => updateField('title', e.target.value)}
|
||||
placeholder={formatMessage({
|
||||
id: 'issueDialog.titlePlaceholder',
|
||||
defaultMessage: '请输入Issue标题...'
|
||||
})}
|
||||
className={cn('mt-2', validationErrors.title && 'border-destructive')}
|
||||
maxLength={200}
|
||||
autoFocus
|
||||
/>
|
||||
{validationErrors.title && (
|
||||
<p className="text-sm text-destructive mt-1">{validationErrors.title}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formData.title.length}/200
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionStep() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { formData, validationErrors, updateField } = useIssueDialogStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="description" className="text-base font-medium">
|
||||
{formatMessage({ id: 'issueDialog.descriptionLabel', defaultMessage: '描述' })}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
placeholder={formatMessage({
|
||||
id: 'issueDialog.descriptionPlaceholder',
|
||||
defaultMessage: '请详细描述问题或需求...'
|
||||
})}
|
||||
className={cn('mt-2 min-h-[200px]', validationErrors.description && 'border-destructive')}
|
||||
maxLength={10000}
|
||||
/>
|
||||
{validationErrors.description && (
|
||||
<p className="text-sm text-destructive mt-1">{validationErrors.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formData.description.length}/10000
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeStep() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { formData, updateField } = useIssueDialogStore();
|
||||
|
||||
const typeOptions: { value: IssueType; label: string; description: string }[] = [
|
||||
{
|
||||
value: 'bug',
|
||||
label: formatMessage({ id: 'issueDialog.typeBug', defaultMessage: 'Bug' }),
|
||||
description: formatMessage({ id: 'issueDialog.typeBugDesc', defaultMessage: '功能异常或错误' })
|
||||
},
|
||||
{
|
||||
value: 'feature',
|
||||
label: formatMessage({ id: 'issueDialog.typeFeature', defaultMessage: 'Feature' }),
|
||||
description: formatMessage({ id: 'issueDialog.typeFeatureDesc', defaultMessage: '新功能需求' })
|
||||
},
|
||||
{
|
||||
value: 'improvement',
|
||||
label: formatMessage({ id: 'issueDialog.typeImprovement', defaultMessage: 'Improvement' }),
|
||||
description: formatMessage({ id: 'issueDialog.typeImprovementDesc', defaultMessage: '现有功能改进' })
|
||||
},
|
||||
{
|
||||
value: 'other',
|
||||
label: formatMessage({ id: 'issueDialog.typeOther', defaultMessage: 'Other' }),
|
||||
description: formatMessage({ id: 'issueDialog.typeOtherDesc', defaultMessage: '其他类型' })
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
{formatMessage({ id: 'issueDialog.typeLabel', defaultMessage: '选择类型' })}
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-3 mt-2">
|
||||
{typeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => updateField('type', option.value)}
|
||||
className={cn(
|
||||
'p-4 rounded-lg border text-left transition-all',
|
||||
formData.type === option.value
|
||||
? 'border-primary bg-primary/10 ring-1 ring-primary'
|
||||
: 'border-border hover:border-primary/50 hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<div className="font-medium">{option.label}</div>
|
||||
<div className="text-sm text-muted-foreground">{option.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriorityStep() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { formData, updateField } = useIssueDialogStore();
|
||||
|
||||
const priorityOptions: { value: IssuePriority; label: string; color: string }[] = [
|
||||
{ value: 'low', label: formatMessage({ id: 'issueDialog.priorityLow', defaultMessage: '低' }), color: 'bg-gray-500' },
|
||||
{ value: 'medium', label: formatMessage({ id: 'issueDialog.priorityMedium', defaultMessage: '中' }), color: 'bg-blue-500' },
|
||||
{ value: 'high', label: formatMessage({ id: 'issueDialog.priorityHigh', defaultMessage: '高' }), color: 'bg-orange-500' },
|
||||
{ value: 'urgent', label: formatMessage({ id: 'issueDialog.priorityUrgent', defaultMessage: '紧急' }), color: 'bg-red-500' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
{formatMessage({ id: 'issueDialog.priorityLabel', defaultMessage: '选择优先级' })}
|
||||
</Label>
|
||||
<Select value={formData.priority} onValueChange={(v) => updateField('priority', v as IssuePriority)}>
|
||||
<SelectTrigger className="mt-2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{priorityOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('w-2 h-2 rounded-full', option.color)} />
|
||||
{option.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryStep() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { formData, submitError } = useIssueDialogStore();
|
||||
|
||||
const typeLabels: Record<IssueType, string> = {
|
||||
bug: formatMessage({ id: 'issueDialog.typeBug', defaultMessage: 'Bug' }),
|
||||
feature: formatMessage({ id: 'issueDialog.typeFeature', defaultMessage: 'Feature' }),
|
||||
improvement: formatMessage({ id: 'issueDialog.typeImprovement', defaultMessage: 'Improvement' }),
|
||||
other: formatMessage({ id: 'issueDialog.typeOther', defaultMessage: 'Other' }),
|
||||
};
|
||||
|
||||
const priorityLabels: Record<IssuePriority, string> = {
|
||||
low: formatMessage({ id: 'issueDialog.priorityLow', defaultMessage: '低' }),
|
||||
medium: formatMessage({ id: 'issueDialog.priorityMedium', defaultMessage: '中' }),
|
||||
high: formatMessage({ id: 'issueDialog.priorityHigh', defaultMessage: '高' }),
|
||||
urgent: formatMessage({ id: 'issueDialog.priorityUrgent', defaultMessage: '紧急' }),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-muted/50 space-y-3">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issueDialog.summaryTitle', defaultMessage: '标题' })}
|
||||
</span>
|
||||
<p className="font-medium mt-1">{formData.title}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issueDialog.summaryDescription', defaultMessage: '描述' })}
|
||||
</span>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{formData.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issueDialog.summaryType', defaultMessage: '类型' })}
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline">{typeLabels[formData.type]}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'issueDialog.summaryPriority', defaultMessage: '优先级' })}
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
<Badge variant="outline">{priorityLabels[formData.priority]}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Dialog Component ==========
|
||||
|
||||
export function IssueDialog({ trigger }: IssueDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const {
|
||||
isOpen,
|
||||
currentStep,
|
||||
steps,
|
||||
isSubmitting,
|
||||
closeDialog,
|
||||
nextStep,
|
||||
prevStep,
|
||||
submitIssue,
|
||||
} = useIssueDialogStore();
|
||||
|
||||
const isFirstStep = currentStep === 0;
|
||||
const isLastStep = currentStep === steps.length - 1;
|
||||
|
||||
const currentStepData = steps[currentStep];
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const result = await submitIssue();
|
||||
if (result.success) {
|
||||
closeDialog();
|
||||
}
|
||||
}, [submitIssue, closeDialog]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (isLastStep) {
|
||||
handleSubmit();
|
||||
} else {
|
||||
nextStep();
|
||||
}
|
||||
}, [isLastStep, handleSubmit, nextStep]);
|
||||
|
||||
const stepContent = useMemo(() => {
|
||||
const field = currentStepData?.field;
|
||||
switch (field) {
|
||||
case 'title':
|
||||
return <TitleStep />;
|
||||
case 'description':
|
||||
return <DescriptionStep />;
|
||||
case 'type':
|
||||
return <TypeStep />;
|
||||
case 'priority':
|
||||
return <PriorityStep />;
|
||||
case 'summary':
|
||||
return <SummaryStep />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [currentStepData?.field]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{currentStepData?.title}</DialogTitle>
|
||||
{currentStepData?.description && (
|
||||
<DialogDescription>{currentStepData.description}</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-center gap-1 py-2">
|
||||
{steps.map((step, index) => (
|
||||
<button
|
||||
key={step.id}
|
||||
type="button"
|
||||
onClick={() => useIssueDialogStore.getState().goToStep(index)}
|
||||
className={cn(
|
||||
'rounded-full transition-all duration-200',
|
||||
index === currentStep
|
||||
? 'bg-primary w-6 h-2'
|
||||
: index < currentStep
|
||||
? 'bg-primary/50 w-2 h-2'
|
||||
: 'bg-muted-foreground/30 w-2 h-2 hover:bg-muted-foreground/50'
|
||||
)}
|
||||
aria-label={`Step ${index + 1}: ${step.title}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="py-4 min-h-[200px]">
|
||||
{stepContent}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={isFirstStep ? closeDialog : prevStep}
|
||||
>
|
||||
{isFirstStep
|
||||
? formatMessage({ id: 'issueDialog.cancel', defaultMessage: '取消' })
|
||||
: formatMessage({ id: 'issueDialog.previous', defaultMessage: '上一步' })
|
||||
}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting
|
||||
? formatMessage({ id: 'issueDialog.submitting', defaultMessage: '提交中...' })
|
||||
: isLastStep
|
||||
? formatMessage({ id: 'issueDialog.submit', defaultMessage: '提交' })
|
||||
: formatMessage({ id: 'issueDialog.next', defaultMessage: '下一步' })
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default IssueDialog;
|
||||
6
ccw/frontend/src/components/IssueDialog/index.ts
Normal file
6
ccw/frontend/src/components/IssueDialog/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// ========================================
|
||||
// IssueDialog Component Barrel Export
|
||||
// ========================================
|
||||
|
||||
export { IssueDialog } from './IssueDialog';
|
||||
export type { IssueType, IssuePriority, IssueFormData } from '@/stores/issueDialogStore';
|
||||
@@ -16,8 +16,23 @@ import {
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerFooter,
|
||||
} from '@/components/ui/Drawer';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/Sheet';
|
||||
import { A2UIRenderer } from '@/packages/a2ui-runtime/renderer';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { useDialogStyleContext, type DialogStyle } from '@/contexts/DialogStyleContext';
|
||||
import type { SurfaceUpdate, SurfaceComponent } from '@/packages/a2ui-runtime/core/A2UITypes';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -717,12 +732,257 @@ function MultiPagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
|
||||
const state = surface.initialState as Record<string, unknown> | undefined;
|
||||
const isMultiPage = state?.questionType === 'multi-question' && (state?.totalPages as number) > 1;
|
||||
const questionType = detectQuestionType(surface);
|
||||
|
||||
// Get dialog style from context
|
||||
const { preferences, getRecommendedStyle } = useDialogStyleContext();
|
||||
const dialogStyle = preferences.smartModeEnabled
|
||||
? getRecommendedStyle(questionType)
|
||||
: preferences.dialogStyle;
|
||||
|
||||
// Common props for all styles
|
||||
const styleProps = {
|
||||
surface,
|
||||
onClose,
|
||||
questionType,
|
||||
dialogStyle,
|
||||
drawerSide: preferences.drawerSide,
|
||||
drawerSize: preferences.drawerSize,
|
||||
};
|
||||
|
||||
if (isMultiPage) {
|
||||
return <MultiPagePopup surface={surface} onClose={onClose} />;
|
||||
}
|
||||
|
||||
return <SinglePagePopup surface={surface} onClose={onClose} />;
|
||||
// Render based on dialog style
|
||||
switch (dialogStyle) {
|
||||
case 'drawer':
|
||||
return <DrawerPopup {...styleProps} />;
|
||||
case 'sheet':
|
||||
return <SheetPopup {...styleProps} />;
|
||||
case 'fullscreen':
|
||||
return <FullscreenPopup {...styleProps} />;
|
||||
default:
|
||||
return <SinglePagePopup surface={surface} onClose={onClose} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Drawer Popup ==========
|
||||
|
||||
interface StyleProps {
|
||||
surface: SurfaceUpdate;
|
||||
onClose: () => void;
|
||||
questionType: QuestionType;
|
||||
dialogStyle: DialogStyle;
|
||||
drawerSide: 'left' | 'right';
|
||||
drawerSize: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
}
|
||||
|
||||
function DrawerPopup({ surface, onClose, drawerSide, drawerSize }: StyleProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
|
||||
|
||||
const titleComponent = surface.components.find(
|
||||
(c) => c.id === 'title' && 'Text' in (c.component as any)
|
||||
);
|
||||
const title = getTextContent(titleComponent) || formatMessage({ id: 'askQuestion.defaultTitle', defaultMessage: 'Question' });
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
sendA2UIAction('cancel', surface.surfaceId, {
|
||||
questionId: (surface.initialState as any)?.questionId,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, onClose]
|
||||
);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(actionId: string, params?: Record<string, unknown>) => {
|
||||
sendA2UIAction(actionId, surface.surfaceId, params);
|
||||
const resolvingActions = ['confirm', 'cancel', 'submit', 'answer'];
|
||||
if (resolvingActions.includes(actionId)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, onClose]
|
||||
);
|
||||
|
||||
const bodyComponents = surface.components.filter(
|
||||
(c) => !['title', 'message', 'description'].includes(c.id) && !isActionButton(c)
|
||||
);
|
||||
const actionButtons = surface.components.filter((c) => isActionButton(c));
|
||||
|
||||
return (
|
||||
<Drawer open onOpenChange={handleOpenChange}>
|
||||
<DrawerContent side={drawerSide} size={drawerSize} className="p-6">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{bodyComponents.length > 0 && (
|
||||
<A2UIRenderer
|
||||
surface={{ ...surface, components: bodyComponents }}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{actionButtons.length > 0 && (
|
||||
<DrawerFooter>
|
||||
{actionButtons.map((comp) => (
|
||||
<A2UIRenderer
|
||||
key={comp.id}
|
||||
surface={{ ...surface, components: [comp] }}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</DrawerFooter>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Sheet Popup ==========
|
||||
|
||||
function SheetPopup({ surface, onClose }: StyleProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
|
||||
|
||||
const titleComponent = surface.components.find(
|
||||
(c) => c.id === 'title' && 'Text' in (c.component as any)
|
||||
);
|
||||
const title = getTextContent(titleComponent) || formatMessage({ id: 'askQuestion.defaultTitle', defaultMessage: 'Question' });
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
sendA2UIAction('cancel', surface.surfaceId, {
|
||||
questionId: (surface.initialState as any)?.questionId,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, onClose]
|
||||
);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(actionId: string, params?: Record<string, unknown>) => {
|
||||
sendA2UIAction(actionId, surface.surfaceId, params);
|
||||
const resolvingActions = ['confirm', 'cancel', 'submit', 'answer'];
|
||||
if (resolvingActions.includes(actionId)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, onClose]
|
||||
);
|
||||
|
||||
const bodyComponents = surface.components.filter(
|
||||
(c) => !['title', 'message', 'description'].includes(c.id) && !isActionButton(c)
|
||||
);
|
||||
const actionButtons = surface.components.filter((c) => isActionButton(c));
|
||||
|
||||
return (
|
||||
<Sheet open onOpenChange={handleOpenChange}>
|
||||
<SheetContent size="content">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{bodyComponents.length > 0 && (
|
||||
<A2UIRenderer
|
||||
surface={{ ...surface, components: bodyComponents }}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{actionButtons.length > 0 && (
|
||||
<SheetFooter>
|
||||
{actionButtons.map((comp) => (
|
||||
<A2UIRenderer
|
||||
key={comp.id}
|
||||
surface={{ ...surface, components: [comp] }}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</SheetFooter>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Fullscreen Popup ==========
|
||||
|
||||
function FullscreenPopup({ surface, onClose }: StyleProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
|
||||
|
||||
const titleComponent = surface.components.find(
|
||||
(c) => c.id === 'title' && 'Text' in (c.component as any)
|
||||
);
|
||||
const title = getTextContent(titleComponent) || formatMessage({ id: 'askQuestion.defaultTitle', defaultMessage: 'Question' });
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
sendA2UIAction('cancel', surface.surfaceId, {
|
||||
questionId: (surface.initialState as any)?.questionId,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, onClose]
|
||||
);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(actionId: string, params?: Record<string, unknown>) => {
|
||||
sendA2UIAction(actionId, surface.surfaceId, params);
|
||||
const resolvingActions = ['confirm', 'cancel', 'submit', 'answer'];
|
||||
if (resolvingActions.includes(actionId)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, onClose]
|
||||
);
|
||||
|
||||
const bodyComponents = surface.components.filter(
|
||||
(c) => !['title', 'message', 'description'].includes(c.id) && !isActionButton(c)
|
||||
);
|
||||
const actionButtons = surface.components.filter((c) => isActionButton(c));
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent fullscreen className="p-6">
|
||||
<DialogHeader className="border-b border-border pb-4">
|
||||
<DialogTitle className="text-xl">{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto py-6">
|
||||
{bodyComponents.length > 0 && (
|
||||
<A2UIRenderer
|
||||
surface={{ ...surface, components: bodyComponents }}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{actionButtons.length > 0 && (
|
||||
<DialogFooter className="border-t border-border pt-4">
|
||||
<div className="flex gap-3">
|
||||
{actionButtons.map((comp) => (
|
||||
<A2UIRenderer
|
||||
key={comp.id}
|
||||
surface={{ ...surface, components: [comp] }}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default A2UIPopupCard;
|
||||
|
||||
53
ccw/frontend/src/components/layout/A2UIButton.tsx
Normal file
53
ccw/frontend/src/components/layout/A2UIButton.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// ========================================
|
||||
// A2UI Button Component
|
||||
// ========================================
|
||||
// Quick action button for A2UI dialog in toolbar
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useDialogStyleContext } from '@/contexts/DialogStyleContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface A2UIButtonProps {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function A2UIButton({ className, compact = false }: A2UIButtonProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { preferences } = useDialogStyleContext();
|
||||
|
||||
// Don't render if hidden in preferences
|
||||
if (!preferences.showA2UIButtonInToolbar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
// Trigger A2UI quick action - this would typically open a dialog
|
||||
// For now, we'll just log the action
|
||||
console.log('[A2UIButton] Quick action triggered');
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
size={compact ? 'icon' : 'sm'}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'gap-2 bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
className
|
||||
)}
|
||||
title={formatMessage({ id: 'toolbar.a2ui.quickAction', defaultMessage: 'A2UI Quick Action' })}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{!compact && (
|
||||
<span className="hidden sm:inline">
|
||||
{formatMessage({ id: 'toolbar.a2ui.button', defaultMessage: 'A2UI' })}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default A2UIButton;
|
||||
@@ -25,6 +25,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTheme } from '@/hooks';
|
||||
import { WorkspaceSelector } from '@/components/workspace/WorkspaceSelector';
|
||||
import { A2UIButton } from '@/components/layout/A2UIButton';
|
||||
import { useCliStreamStore, selectActiveExecutionCount } from '@/stores/cliStreamStore';
|
||||
import { useNotificationStore } from '@/stores';
|
||||
import { useTerminalPanelStore, selectTerminalCount } from '@/stores/terminalPanelStore';
|
||||
@@ -78,6 +79,9 @@ export function Header({
|
||||
<span className="hidden sm:inline">{formatMessage({ id: 'navigation.header.brand' })}</span>
|
||||
<span className="sm:hidden">{formatMessage({ id: 'navigation.header.brandShort' })}</span>
|
||||
</Link>
|
||||
|
||||
{/* A2UI Quick Action Button */}
|
||||
<A2UIButton />
|
||||
</div>
|
||||
|
||||
{/* Right side - Actions */}
|
||||
|
||||
@@ -9,6 +9,8 @@ export type { AppShellProps } from './AppShell';
|
||||
export { Header } from './Header';
|
||||
export type { HeaderProps } from './Header';
|
||||
|
||||
export { A2UIButton } from './A2UIButton';
|
||||
|
||||
export { Sidebar } from './Sidebar';
|
||||
export type { SidebarProps } from './Sidebar';
|
||||
|
||||
|
||||
294
ccw/frontend/src/components/settings/A2UIPreferencesSection.tsx
Normal file
294
ccw/frontend/src/components/settings/A2UIPreferencesSection.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
// ========================================
|
||||
// A2UI Preferences Section
|
||||
// ========================================
|
||||
// Settings section for A2UI dialog style preferences
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { MessageSquare, Clock, Volume2, LayoutPanelLeft, Sparkles } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDialogStyleContext, type DialogStyle } from '@/contexts/DialogStyleContext';
|
||||
|
||||
// ========== Style Option Button ==========
|
||||
|
||||
interface StyleOptionProps {
|
||||
value: DialogStyle;
|
||||
label: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function StyleOption({ value, label, description, selected, onClick }: StyleOptionProps) {
|
||||
const icons: Record<DialogStyle, React.ReactNode> = {
|
||||
modal: (
|
||||
<div className="w-8 h-8 border-2 border-current rounded-lg flex items-center justify-center">
|
||||
<div className="w-4 h-3 border border-current rounded-sm" />
|
||||
</div>
|
||||
),
|
||||
drawer: (
|
||||
<div className="w-8 h-8 border-2 border-current rounded-lg flex items-end justify-end p-0.5">
|
||||
<div className="w-2 h-6 border border-current rounded-sm" />
|
||||
</div>
|
||||
),
|
||||
sheet: (
|
||||
<div className="w-8 h-8 border-2 border-current rounded-lg flex items-end justify-center p-0.5">
|
||||
<div className="w-6 h-2 border border-current rounded-sm" />
|
||||
</div>
|
||||
),
|
||||
fullscreen: (
|
||||
<div className="w-8 h-8 border-2 border-current rounded-lg flex items-center justify-center">
|
||||
<div className="w-5 h-4 border border-current rounded-sm" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all',
|
||||
'hover:bg-accent/50',
|
||||
selected
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{icons[value]}
|
||||
<div className="text-center">
|
||||
<div className={cn('text-sm font-medium', selected && 'text-primary')}>{label}</div>
|
||||
<div className="text-xs text-muted-foreground">{description}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Duration Slider ==========
|
||||
|
||||
interface DurationSliderProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function DurationSlider({ value, onChange, disabled }: DurationSliderProps) {
|
||||
const presets = [10, 20, 30, 45, 60, 90, 120];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{presets.map((seconds) => (
|
||||
<Button
|
||||
key={seconds}
|
||||
type="button"
|
||||
variant={value === seconds ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(seconds)}
|
||||
>
|
||||
{seconds}s
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function A2UIPreferencesSection() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { preferences, updatePreference, resetPreferences } = useDialogStyleContext();
|
||||
|
||||
const styleOptions: Array<{ value: DialogStyle; label: string; description: string }> = [
|
||||
{
|
||||
value: 'modal',
|
||||
label: formatMessage({ id: 'settings.a2ui.styleModal', defaultMessage: 'Modal' }),
|
||||
description: formatMessage({ id: 'settings.a2ui.styleModalDesc', defaultMessage: 'Centered' }),
|
||||
},
|
||||
{
|
||||
value: 'drawer',
|
||||
label: formatMessage({ id: 'settings.a2ui.styleDrawer', defaultMessage: 'Drawer' }),
|
||||
description: formatMessage({ id: 'settings.a2ui.styleDrawerDesc', defaultMessage: 'Side panel' }),
|
||||
},
|
||||
{
|
||||
value: 'sheet',
|
||||
label: formatMessage({ id: 'settings.a2ui.styleSheet', defaultMessage: 'Sheet' }),
|
||||
description: formatMessage({ id: 'settings.a2ui.styleSheetDesc', defaultMessage: 'Bottom' }),
|
||||
},
|
||||
{
|
||||
value: 'fullscreen',
|
||||
label: formatMessage({ id: 'settings.a2ui.styleFullscreen', defaultMessage: 'Fullscreen' }),
|
||||
description: formatMessage({ id: 'settings.a2ui.styleFullscreenDesc', defaultMessage: 'Full screen' }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
{formatMessage({ id: 'settings.sections.a2ui', defaultMessage: 'A2UI Preferences' })}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Dialog Style Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
<LayoutPanelLeft className="w-4 h-4" />
|
||||
{formatMessage({ id: 'settings.a2ui.dialogStyle', defaultMessage: 'Dialog Style' })}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'settings.a2ui.dialogStyleDesc',
|
||||
defaultMessage: 'Choose how A2UI dialogs are displayed',
|
||||
})}
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{styleOptions.map((option) => (
|
||||
<StyleOption
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
selected={preferences.dialogStyle === option.value}
|
||||
onClick={() => updatePreference('dialogStyle', option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Smart Mode */}
|
||||
<div className="flex items-center justify-between py-2 border-t border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{formatMessage({ id: 'settings.a2ui.smartMode', defaultMessage: 'Smart Mode' })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'settings.a2ui.smartModeDesc',
|
||||
defaultMessage: 'Auto-select style based on question type',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={preferences.smartModeEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => updatePreference('smartModeEnabled', !preferences.smartModeEnabled)}
|
||||
>
|
||||
{preferences.smartModeEnabled
|
||||
? formatMessage({ id: 'common.enabled', defaultMessage: 'Enabled' })
|
||||
: formatMessage({ id: 'common.disabled', defaultMessage: 'Disabled' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Auto Selection Duration */}
|
||||
<div className="space-y-3 py-2 border-t border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-muted-foreground" />
|
||||
<Label className="text-sm font-medium">
|
||||
{formatMessage({ id: 'settings.a2ui.autoSelectionDuration', defaultMessage: 'Auto-Selection Duration' })}
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'settings.a2ui.autoSelectionDurationDesc',
|
||||
defaultMessage: 'Countdown before auto-selecting default option',
|
||||
})}
|
||||
</p>
|
||||
<DurationSlider
|
||||
value={preferences.autoSelectionDuration}
|
||||
onChange={(v) => updatePreference('autoSelectionDuration', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pause on Interaction */}
|
||||
<div className="flex items-center justify-between py-2 border-t border-border">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{formatMessage({ id: 'settings.a2ui.pauseOnInteraction', defaultMessage: 'Pause on Interaction' })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'settings.a2ui.pauseOnInteractionDesc',
|
||||
defaultMessage: 'Pause countdown when you interact with the dialog',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={preferences.pauseOnInteraction ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => updatePreference('pauseOnInteraction', !preferences.pauseOnInteraction)}
|
||||
>
|
||||
{preferences.pauseOnInteraction
|
||||
? formatMessage({ id: 'common.enabled', defaultMessage: 'Enabled' })
|
||||
: formatMessage({ id: 'common.disabled', defaultMessage: 'Disabled' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Sound Notification */}
|
||||
<div className="flex items-center justify-between py-2 border-t border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="w-4 h-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{formatMessage({ id: 'settings.a2ui.soundNotification', defaultMessage: 'Sound Notification' })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'settings.a2ui.soundNotificationDesc',
|
||||
defaultMessage: 'Play sound before auto-submit (3 seconds before)',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={preferences.autoSelectionSoundEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => updatePreference('autoSelectionSoundEnabled', !preferences.autoSelectionSoundEnabled)}
|
||||
>
|
||||
{preferences.autoSelectionSoundEnabled
|
||||
? formatMessage({ id: 'common.enabled', defaultMessage: 'Enabled' })
|
||||
: formatMessage({ id: 'common.disabled', defaultMessage: 'Disabled' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Show A2UI Button in Toolbar */}
|
||||
<div className="flex items-center justify-between py-2 border-t border-border">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{formatMessage({ id: 'settings.a2ui.showToolbarButton', defaultMessage: 'Show Toolbar Button' })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'settings.a2ui.showToolbarButtonDesc',
|
||||
defaultMessage: 'Show A2UI quick action button in the toolbar',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={preferences.showA2UIButtonInToolbar ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => updatePreference('showA2UIButtonInToolbar', !preferences.showA2UIButtonInToolbar)}
|
||||
>
|
||||
{preferences.showA2UIButtonInToolbar
|
||||
? formatMessage({ id: 'common.enabled', defaultMessage: 'Enabled' })
|
||||
: formatMessage({ id: 'common.disabled', defaultMessage: 'Disabled' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Reset Button */}
|
||||
<div className="flex justify-end pt-4 border-t border-border">
|
||||
<Button variant="outline" size="sm" onClick={resetPreferences}>
|
||||
{formatMessage({ id: 'common.resetToDefaults', defaultMessage: 'Reset to Defaults' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default A2UIPreferencesSection;
|
||||
@@ -48,11 +48,15 @@ import { CliConfigModal, type CliSessionConfig } from './CliConfigModal';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type PanelId = 'issues' | 'queue' | 'inspector' | 'files';
|
||||
export type PanelId = 'issues' | 'queue' | 'inspector';
|
||||
|
||||
interface DashboardToolbarProps {
|
||||
activePanel: PanelId | null;
|
||||
onTogglePanel: (panelId: PanelId) => void;
|
||||
/** Whether the file sidebar is open */
|
||||
isFileSidebarOpen?: boolean;
|
||||
/** Callback to toggle file sidebar */
|
||||
onToggleFileSidebar?: () => void;
|
||||
}
|
||||
|
||||
// ========== Layout Presets ==========
|
||||
@@ -79,7 +83,7 @@ const LAUNCH_COMMANDS: Record<CliTool, Record<LaunchMode, string>> = {
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function DashboardToolbar({ activePanel, onTogglePanel }: DashboardToolbarProps) {
|
||||
export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen, onToggleFileSidebar }: DashboardToolbarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Issues count
|
||||
@@ -296,8 +300,8 @@ export function DashboardToolbar({ activePanel, onTogglePanel }: DashboardToolba
|
||||
<ToolbarButton
|
||||
icon={FolderOpen}
|
||||
label={formatMessage({ id: 'terminalDashboard.toolbar.files', defaultMessage: 'Files' })}
|
||||
isActive={activePanel === 'files'}
|
||||
onClick={() => onTogglePanel('files')}
|
||||
isActive={isFileSidebarOpen ?? false}
|
||||
onClick={() => onToggleFileSidebar?.()}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// ========================================
|
||||
// FileSidebarPanel Component
|
||||
// ========================================
|
||||
// Right sidebar file browser for Terminal Dashboard.
|
||||
// Displays file tree and allows showing files in focused terminal pane.
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FolderOpen, RefreshCw, Loader2, ChevronLeft, FileText } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { TreeView } from '@/components/shared/TreeView';
|
||||
import { useFileExplorer } from '@/hooks/useFileExplorer';
|
||||
import { useTerminalGridStore, selectTerminalGridFocusedPaneId } from '@/stores/terminalGridStore';
|
||||
import type { FileSystemNode } from '@/types/file-explorer';
|
||||
|
||||
export interface FileSidebarPanelProps {
|
||||
/** Root path for file explorer */
|
||||
rootPath: string;
|
||||
/** Whether the panel is enabled (has project path) */
|
||||
enabled?: boolean;
|
||||
/** Optional class name */
|
||||
className?: string;
|
||||
/** Callback when panel collapse is requested */
|
||||
onCollapse?: () => void;
|
||||
/** Initial width of the panel */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function FileSidebarPanel({
|
||||
rootPath,
|
||||
enabled = true,
|
||||
className,
|
||||
onCollapse,
|
||||
width = 280,
|
||||
}: FileSidebarPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Store
|
||||
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
|
||||
const showFileInPane = useTerminalGridStore((s) => s.showFileInPane);
|
||||
|
||||
// File explorer hook
|
||||
const {
|
||||
rootNodes,
|
||||
state,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
setSelectedFile,
|
||||
toggleExpanded,
|
||||
} = useFileExplorer({
|
||||
rootPath,
|
||||
maxDepth: 6,
|
||||
enabled,
|
||||
});
|
||||
|
||||
// Handle node click
|
||||
const handleNodeClick = React.useCallback(
|
||||
(node: FileSystemNode) => {
|
||||
// Only files can be shown in pane
|
||||
if (node.type === 'file') {
|
||||
setSelectedFile(node.path);
|
||||
// Show file in focused pane if one exists
|
||||
if (focusedPaneId) {
|
||||
showFileInPane(focusedPaneId, node.path);
|
||||
}
|
||||
}
|
||||
},
|
||||
[focusedPaneId, setSelectedFile, showFileInPane]
|
||||
);
|
||||
|
||||
// Handle refresh
|
||||
const handleRefresh = React.useCallback(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
// Handle collapse
|
||||
const handleCollapse = React.useCallback(() => {
|
||||
onCollapse?.();
|
||||
}, [onCollapse]);
|
||||
|
||||
// Disabled state (no project path)
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col h-full border-l border-border bg-background',
|
||||
className
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/30">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'terminalDashboard.fileSidebar.title', defaultMessage: 'Files' })}
|
||||
</span>
|
||||
{onCollapse && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={handleCollapse}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-4 text-center text-muted-foreground">
|
||||
<FolderOpen className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="text-xs">
|
||||
{formatMessage({ id: 'terminalDashboard.fileSidebar.noProject', defaultMessage: 'No project open' })}
|
||||
</p>
|
||||
<p className="text-[10px] mt-1 opacity-70">
|
||||
{formatMessage({ id: 'terminalDashboard.fileSidebar.openProjectHint', defaultMessage: 'Open a project to browse files' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col h-full border-l border-border bg-background',
|
||||
className
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-border bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-medium truncate">
|
||||
{formatMessage({ id: 'terminalDashboard.fileSidebar.title', defaultMessage: 'Files' })}
|
||||
</span>
|
||||
{isFetching && !isLoading && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={handleRefresh}
|
||||
disabled={isFetching}
|
||||
title={formatMessage({ id: 'terminalDashboard.fileSidebar.refresh', defaultMessage: 'Refresh' })}
|
||||
>
|
||||
<RefreshCw className={cn('h-3.5 w-3.5', isFetching && 'animate-spin')} />
|
||||
</Button>
|
||||
{onCollapse && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={handleCollapse}
|
||||
title={formatMessage({ id: 'terminalDashboard.fileSidebar.collapse', defaultMessage: 'Collapse' })}
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current path indicator */}
|
||||
<div className="px-3 py-1.5 border-b border-border/50 bg-muted/10 shrink-0">
|
||||
<div className="text-[10px] text-muted-foreground truncate font-mono" title={rootPath}>
|
||||
{rootPath}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tree view */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="ml-2 text-xs">
|
||||
{formatMessage({ id: 'terminalDashboard.fileBrowser.loading', defaultMessage: 'Loading...' })}
|
||||
</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-3 text-xs text-destructive">
|
||||
{formatMessage({ id: 'terminalDashboard.fileBrowser.loadFailed', defaultMessage: 'Failed to load files' })}
|
||||
</div>
|
||||
) : rootNodes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 px-4 text-center text-muted-foreground">
|
||||
<FileText className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="text-xs">
|
||||
{formatMessage({ id: 'terminalDashboard.fileSidebar.empty', defaultMessage: 'No files found' })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<TreeView
|
||||
nodes={rootNodes}
|
||||
expandedPaths={state.expandedPaths}
|
||||
selectedPath={state.selectedFile}
|
||||
onNodeClick={handleNodeClick}
|
||||
onToggle={toggleExpanded}
|
||||
maxDepth={0}
|
||||
className="py-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
{!focusedPaneId && (
|
||||
<div className="px-3 py-2 border-t border-border bg-muted/10 shrink-0">
|
||||
<p className="text-[10px] text-muted-foreground text-center">
|
||||
{formatMessage({ id: 'terminalDashboard.fileSidebar.selectPaneHint', defaultMessage: 'Click a pane to show file preview' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileSidebarPanel;
|
||||
@@ -1,7 +1,8 @@
|
||||
// ========================================
|
||||
// TerminalPane Component
|
||||
// ========================================
|
||||
// Single terminal pane = PaneToolbar + TerminalInstance.
|
||||
// Single terminal pane = PaneToolbar + content area.
|
||||
// Content can be terminal output or file preview based on displayMode.
|
||||
// Renders within the TerminalGrid recursive layout.
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
@@ -19,10 +20,13 @@ import {
|
||||
Pause,
|
||||
Play,
|
||||
Loader2,
|
||||
FileText,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TerminalInstance } from './TerminalInstance';
|
||||
import { FloatingFileBrowser } from './FloatingFileBrowser';
|
||||
import { FilePreview } from '@/components/shared/FilePreview';
|
||||
import {
|
||||
useTerminalGridStore,
|
||||
selectTerminalGridPanes,
|
||||
@@ -41,6 +45,7 @@ import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import { getAllPaneIds } from '@/lib/layout-utils';
|
||||
import { sendCliSessionText } from '@/lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { useFileContent } from '@/hooks/useFileExplorer';
|
||||
import type { PaneId } from '@/stores/viewerStore';
|
||||
import type { TerminalStatus } from '@/types/terminal-dashboard';
|
||||
|
||||
@@ -73,11 +78,15 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
const closePane = useTerminalGridStore((s) => s.closePane);
|
||||
const assignSession = useTerminalGridStore((s) => s.assignSession);
|
||||
const setFocused = useTerminalGridStore((s) => s.setFocused);
|
||||
const setPaneDisplayMode = useTerminalGridStore((s) => s.setPaneDisplayMode);
|
||||
|
||||
const pane = panes[paneId];
|
||||
const sessionId = pane?.sessionId ?? null;
|
||||
const displayMode = pane?.displayMode ?? 'terminal';
|
||||
const filePath = pane?.filePath ?? null;
|
||||
const isFocused = focusedPaneId === paneId;
|
||||
const canClose = getAllPaneIds(layout).length > 1;
|
||||
const isFileMode = displayMode === 'file' && filePath;
|
||||
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const [isFileBrowserOpen, setIsFileBrowserOpen] = useState(false);
|
||||
@@ -97,6 +106,11 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [isTogglingPause, setIsTogglingPause] = useState(false);
|
||||
|
||||
// File content for preview mode
|
||||
const { content: fileContent, isLoading: isFileLoading, error: fileError } = useFileContent(filePath, {
|
||||
enabled: displayMode === 'file' && !!filePath,
|
||||
});
|
||||
|
||||
// Association chain for linked issue badge
|
||||
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
|
||||
const linkedIssueId = useMemo(() => {
|
||||
@@ -201,6 +215,11 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
}
|
||||
}, [sessionId, isTogglingPause, status, pauseSession, resumeSession]);
|
||||
|
||||
// Handle back to terminal from file preview
|
||||
const handleBackToTerminal = useCallback(() => {
|
||||
setPaneDisplayMode(paneId, 'terminal');
|
||||
}, [paneId, setPaneDisplayMode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -211,38 +230,58 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
>
|
||||
{/* PaneToolbar */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-muted/30 shrink-0">
|
||||
{/* Left: Session selector + status */}
|
||||
{/* Left: Session selector + status (or file path in file mode) */}
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
{sessionId && (
|
||||
<span
|
||||
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[status])}
|
||||
/>
|
||||
)}
|
||||
<div className="relative min-w-0 flex-1 max-w-[180px]">
|
||||
<select
|
||||
value={sessionId ?? ''}
|
||||
onChange={handleSessionChange}
|
||||
className={cn(
|
||||
'w-full text-xs bg-transparent border-none outline-none cursor-pointer',
|
||||
'appearance-none pr-5 truncate',
|
||||
!sessionId && 'text-muted-foreground'
|
||||
{isFileMode ? (
|
||||
// File mode header
|
||||
<>
|
||||
<button
|
||||
onClick={handleBackToTerminal}
|
||||
className="p-0.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground shrink-0"
|
||||
title={formatMessage({ id: 'terminalDashboard.pane.backToTerminal', defaultMessage: 'Back to terminal' })}
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs truncate" title={filePath ?? undefined}>
|
||||
{filePath?.split('/').pop() ?? 'File'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
// Terminal mode header
|
||||
<>
|
||||
{sessionId && (
|
||||
<span
|
||||
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[status])}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<option value="">
|
||||
{formatMessage({ id: 'terminalDashboard.pane.selectSession' })}
|
||||
</option>
|
||||
{sessionOptions.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
<div className="relative min-w-0 flex-1 max-w-[180px]">
|
||||
<select
|
||||
value={sessionId ?? ''}
|
||||
onChange={handleSessionChange}
|
||||
className={cn(
|
||||
'w-full text-xs bg-transparent border-none outline-none cursor-pointer',
|
||||
'appearance-none pr-5 truncate',
|
||||
!sessionId && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<option value="">
|
||||
{formatMessage({ id: 'terminalDashboard.pane.selectSession' })}
|
||||
</option>
|
||||
{sessionOptions.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center: Linked issue badge */}
|
||||
{linkedIssueId && (
|
||||
{linkedIssueId && !isFileMode && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-primary/10 text-primary shrink-0">
|
||||
{linkedIssueId}
|
||||
</span>
|
||||
@@ -264,7 +303,7 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
>
|
||||
<SplitSquareVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{sessionId && (
|
||||
{!isFileMode && sessionId && (
|
||||
<>
|
||||
{/* Restart button */}
|
||||
<button
|
||||
@@ -318,20 +357,22 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleOpenFileBrowser}
|
||||
disabled={!projectPath}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-muted transition-colors',
|
||||
projectPath
|
||||
? 'text-muted-foreground hover:text-foreground'
|
||||
: 'text-muted-foreground/40 cursor-not-allowed'
|
||||
)}
|
||||
title={formatMessage({ id: 'terminalDashboard.fileBrowser.open' })}
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{alertCount > 0 && (
|
||||
{!isFileMode && (
|
||||
<button
|
||||
onClick={handleOpenFileBrowser}
|
||||
disabled={!projectPath}
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-muted transition-colors',
|
||||
projectPath
|
||||
? 'text-muted-foreground hover:text-foreground'
|
||||
: 'text-muted-foreground/40 cursor-not-allowed'
|
||||
)}
|
||||
title={formatMessage({ id: 'terminalDashboard.fileBrowser.open' })}
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{alertCount > 0 && !isFileMode && (
|
||||
<span className="flex items-center gap-0.5 px-1 text-destructive">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span className="text-[10px] font-semibold tabular-nums">
|
||||
@@ -351,12 +392,24 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal content */}
|
||||
{sessionId ? (
|
||||
{/* Content area */}
|
||||
{isFileMode ? (
|
||||
// File preview mode
|
||||
<div className="flex-1 min-h-0">
|
||||
<FilePreview
|
||||
fileContent={fileContent}
|
||||
isLoading={isFileLoading}
|
||||
error={fileError?.message ?? null}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
) : sessionId ? (
|
||||
// Terminal mode with session
|
||||
<div className="flex-1 min-h-0">
|
||||
<TerminalInstance sessionId={sessionId} onRevealPath={handleRevealPath} />
|
||||
</div>
|
||||
) : (
|
||||
// Empty terminal state
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Terminal className="h-6 w-6 mx-auto mb-1.5 opacity-30" />
|
||||
|
||||
174
ccw/frontend/src/components/ui/Sheet.tsx
Normal file
174
ccw/frontend/src/components/ui/Sheet.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
// ========================================
|
||||
// Sheet Component
|
||||
// ========================================
|
||||
// Bottom sheet for A2UI surfaces with slide-up animation
|
||||
// Supports drag-to-dismiss and snap points
|
||||
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Variants ==========
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 gap-4 bg-card shadow-lg border-t border-border ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
bottom: 'inset-x-0 bottom-0 rounded-t-lg data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
top: 'inset-x-0 top-0 rounded-b-lg data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
},
|
||||
size: {
|
||||
auto: 'h-auto',
|
||||
half: 'h-[50vh]',
|
||||
full: 'h-[90vh]',
|
||||
content: 'max-h-[90vh]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: 'bottom',
|
||||
size: 'content',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// ========== Root Components ==========
|
||||
|
||||
const Sheet = DialogPrimitive.Root;
|
||||
const SheetTrigger = DialogPrimitive.Trigger;
|
||||
const SheetClose = DialogPrimitive.Close;
|
||||
const SheetPortal = DialogPrimitive.Portal;
|
||||
|
||||
// ========== Overlay ==========
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
// ========== Content ==========
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {
|
||||
/** Whether to show the drag handle */
|
||||
showHandle?: boolean;
|
||||
/** Whether clicking outside should close the sheet */
|
||||
closeOnOutsideClick?: boolean;
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(
|
||||
(
|
||||
{ side = 'bottom', size = 'content', showHandle = true, closeOnOutsideClick = true, className, children, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side, size }), 'flex flex-col', className)}
|
||||
onInteractOutside={(e) => {
|
||||
if (!closeOnOutsideClick) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{/* Drag Handle */}
|
||||
{showHandle && side === 'bottom' && (
|
||||
<div className="flex justify-center pt-2 pb-1">
|
||||
<div className="w-10 h-1.5 rounded-full bg-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
SheetContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
// ========== Header ==========
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-2 text-center sm:text-left pt-2', className)} {...props} />
|
||||
);
|
||||
SheetHeader.displayName = 'SheetHeader';
|
||||
|
||||
// ========== Footer ==========
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 pt-4 border-t border-border mt-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
SheetFooter.displayName = 'SheetFooter';
|
||||
|
||||
// ========== Title ==========
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
// ========== Description ==========
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
// ========== Exports ==========
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
type SheetContentProps,
|
||||
};
|
||||
Reference in New Issue
Block a user