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

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