feat(extension): complete VS Code extension with kanban board interface (#997)

---------
Co-authored-by: DavidMaliglowka <13022280+DavidMaliglowka@users.noreply.github.com>
Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
DavidMaliglowka
2025-08-01 07:04:22 -05:00
committed by GitHub
parent 60c03c548d
commit 64302dc191
101 changed files with 20608 additions and 181 deletions

View File

@@ -0,0 +1,291 @@
import { ArrowLeft, RefreshCw, Settings } from 'lucide-react';
import type React from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from './ui/card';
import { ScrollArea } from './ui/scroll-area';
import { Separator } from './ui/separator';
interface ModelConfig {
provider: string;
modelId: string;
maxTokens: number;
temperature: number;
}
interface ConfigData {
models?: {
main?: ModelConfig;
research?: ModelConfig;
fallback?: ModelConfig;
};
global?: {
defaultNumTasks?: number;
defaultSubtasks?: number;
defaultPriority?: string;
projectName?: string;
responseLanguage?: string;
};
}
interface ConfigViewProps {
sendMessage: (message: any) => Promise<any>;
onNavigateBack: () => void;
}
export const ConfigView: React.FC<ConfigViewProps> = ({
sendMessage,
onNavigateBack
}) => {
const [config, setConfig] = useState<ConfigData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadConfig = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await sendMessage({ type: 'getConfig' });
setConfig(response);
} catch (err) {
setError('Failed to load configuration');
console.error('Error loading config:', err);
} finally {
setLoading(false);
}
}, [sendMessage]);
useEffect(() => {
loadConfig();
}, [loadConfig]);
const modelLabels = {
main: {
label: 'Main Model',
icon: '🤖',
description: 'Primary model for task generation'
},
research: {
label: 'Research Model',
icon: '🔍',
description: 'Model for research-backed operations'
},
fallback: {
label: 'Fallback Model',
icon: '🔄',
description: 'Backup model if primary fails'
}
};
return (
<div className="flex flex-col h-full bg-vscode-editor-background">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-vscode-border">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={onNavigateBack}
className="h-8 w-8"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
<Settings className="w-5 h-5" />
<h1 className="text-lg font-semibold">Task Master Configuration</h1>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={loadConfig}
className="h-8 w-8"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<ScrollArea className="flex-1 overflow-hidden">
<div className="p-6 pb-12">
{loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-6 h-6 animate-spin text-vscode-foreground/50" />
</div>
) : error ? (
<div className="text-red-500 text-center py-8">{error}</div>
) : config ? (
<div className="space-y-6 max-w-4xl mx-auto">
{/* Models Section */}
<Card>
<CardHeader>
<CardTitle>AI Models</CardTitle>
<CardDescription>
Models configured for different Task Master operations
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{config.models &&
Object.entries(config.models).map(([key, modelConfig]) => {
const label =
modelLabels[key as keyof typeof modelLabels];
if (!label || !modelConfig) return null;
return (
<div key={key} className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-lg">{label.icon}</span>
<div>
<h4 className="font-medium">{label.label}</h4>
<p className="text-xs text-vscode-foreground/60">
{label.description}
</p>
</div>
</div>
<div className="bg-vscode-input/20 rounded-md p-3 space-y-1">
<div className="flex justify-between">
<span className="text-sm text-vscode-foreground/80">
Provider:
</span>
<Badge variant="secondary">
{modelConfig.provider}
</Badge>
</div>
<div className="flex justify-between">
<span className="text-sm text-vscode-foreground/80">
Model:
</span>
<code className="text-xs font-mono bg-vscode-input/30 px-2 py-1 rounded">
{modelConfig.modelId}
</code>
</div>
<div className="flex justify-between">
<span className="text-sm text-vscode-foreground/80">
Max Tokens:
</span>
<span className="text-sm">
{modelConfig.maxTokens.toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-vscode-foreground/80">
Temperature:
</span>
<span className="text-sm">
{modelConfig.temperature}
</span>
</div>
</div>
</div>
);
})}
</CardContent>
</Card>
{/* Task Defaults Section */}
{config.global && (
<Card>
<CardHeader>
<CardTitle>Task Defaults</CardTitle>
<CardDescription>
Default values for new tasks and subtasks
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Default Number of Tasks
</span>
<Badge variant="outline">
{config.global.defaultNumTasks || 10}
</Badge>
</div>
<Separator />
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Default Number of Subtasks
</span>
<Badge variant="outline">
{config.global.defaultSubtasks || 5}
</Badge>
</div>
<Separator />
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Default Priority
</span>
<Badge
variant={
config.global.defaultPriority === 'high'
? 'destructive'
: config.global.defaultPriority === 'low'
? 'secondary'
: 'default'
}
>
{config.global.defaultPriority || 'medium'}
</Badge>
</div>
{config.global.projectName && (
<>
<Separator />
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Project Name
</span>
<span className="text-sm text-vscode-foreground/80">
{config.global.projectName}
</span>
</div>
</>
)}
{config.global.responseLanguage && (
<>
<Separator />
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Response Language
</span>
<span className="text-sm text-vscode-foreground/80">
{config.global.responseLanguage}
</span>
</div>
</>
)}
</div>
</CardContent>
</Card>
)}
{/* Info Card */}
<Card>
<CardContent className="pt-6">
<p className="text-sm text-vscode-foreground/60">
To modify these settings, go to{' '}
<code className="bg-vscode-input/30 px-1 py-0.5 rounded">
.taskmaster/config.json
</code>{' '}
and modify them, or use the MCP.
</p>
</CardContent>
</Card>
</div>
) : (
<div className="text-center py-8 text-vscode-foreground/50">
No configuration found. Please run `task-master init` in your
project.
</div>
)}
</div>
</ScrollArea>
</div>
);
};

View File

@@ -0,0 +1,207 @@
import type React from 'react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { CollapsibleSection } from '@/components/ui/CollapsibleSection';
import { Wand2, Loader2, PlusCircle } from 'lucide-react';
import {
useUpdateTask,
useUpdateSubtask
} from '../../webview/hooks/useTaskQueries';
import type { TaskMasterTask } from '../../webview/types';
interface AIActionsSectionProps {
currentTask: TaskMasterTask;
isSubtask: boolean;
parentTask?: TaskMasterTask | null;
sendMessage: (message: any) => Promise<any>;
refreshComplexityAfterAI: () => void;
onRegeneratingChange?: (isRegenerating: boolean) => void;
onAppendingChange?: (isAppending: boolean) => void;
}
export const AIActionsSection: React.FC<AIActionsSectionProps> = ({
currentTask,
isSubtask,
parentTask,
sendMessage,
refreshComplexityAfterAI,
onRegeneratingChange,
onAppendingChange
}) => {
const [prompt, setPrompt] = useState('');
const [lastAction, setLastAction] = useState<'regenerate' | 'append' | null>(
null
);
const updateTask = useUpdateTask();
const updateSubtask = useUpdateSubtask();
const handleRegenerate = async () => {
if (!currentTask || !prompt.trim()) {
return;
}
setLastAction('regenerate');
onRegeneratingChange?.(true);
try {
if (isSubtask && parentTask) {
await updateSubtask.mutateAsync({
taskId: `${parentTask.id}.${currentTask.id}`,
prompt: prompt,
options: { research: false }
});
} else {
await updateTask.mutateAsync({
taskId: currentTask.id,
updates: { description: prompt },
options: { append: false, research: false }
});
}
setPrompt('');
refreshComplexityAfterAI();
} catch (error) {
console.error('❌ TaskDetailsView: Failed to regenerate task:', error);
} finally {
setLastAction(null);
onRegeneratingChange?.(false);
}
};
const handleAppend = async () => {
if (!currentTask || !prompt.trim()) {
return;
}
setLastAction('append');
onAppendingChange?.(true);
try {
if (isSubtask && parentTask) {
await updateSubtask.mutateAsync({
taskId: `${parentTask.id}.${currentTask.id}`,
prompt: prompt,
options: { research: false }
});
} else {
await updateTask.mutateAsync({
taskId: currentTask.id,
updates: { description: prompt },
options: { append: true, research: false }
});
}
setPrompt('');
refreshComplexityAfterAI();
} catch (error) {
console.error('❌ TaskDetailsView: Failed to append to task:', error);
} finally {
setLastAction(null);
onAppendingChange?.(false);
}
};
// Track loading states based on the last action
const isLoading = updateTask.isPending || updateSubtask.isPending;
const isRegenerating = isLoading && lastAction === 'regenerate';
const isAppending = isLoading && lastAction === 'append';
return (
<CollapsibleSection
title="AI Actions"
icon={Wand2}
defaultExpanded={true}
buttonClassName="text-vscode-foreground/80 hover:text-vscode-foreground"
>
<div className="space-y-4">
<div>
<Label
htmlFor="ai-prompt"
className="block text-sm font-medium text-vscode-foreground/80 mb-2"
>
Enter your prompt
</Label>
<Textarea
id="ai-prompt"
placeholder={
isSubtask
? 'Describe implementation notes, progress updates, or findings to add to this subtask...'
: 'Describe what you want to change or add to this task...'
}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px] bg-vscode-input-background border-vscode-input-border text-vscode-input-foreground placeholder-vscode-input-foreground/50 focus:border-vscode-focusBorder focus:ring-vscode-focusBorder"
disabled={isRegenerating || isAppending}
/>
</div>
<div className="flex gap-3">
{!isSubtask && (
<Button
onClick={handleRegenerate}
disabled={!prompt.trim() || isRegenerating || isAppending}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{isRegenerating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Regenerating...
</>
) : (
<>
<Wand2 className="w-4 h-4 mr-2" />
Regenerate Task
</>
)}
</Button>
)}
<Button
onClick={handleAppend}
disabled={!prompt.trim() || isRegenerating || isAppending}
variant={isSubtask ? 'default' : 'outline'}
className={
isSubtask
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-secondary text-secondary-foreground hover:bg-secondary/90 border-widget-border'
}
>
{isAppending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{isSubtask ? 'Updating...' : 'Appending...'}
</>
) : (
<>
<PlusCircle className="w-4 h-4 mr-2" />
{isSubtask ? 'Add Notes to Subtask' : 'Append to Task'}
</>
)}
</Button>
</div>
<div className="text-xs text-vscode-foreground/60 space-y-1">
{isSubtask ? (
<p>
<strong>Add Notes:</strong> Appends timestamped implementation
notes, progress updates, or findings to this subtask's details
</p>
) : (
<>
<p>
<strong>Regenerate:</strong> Completely rewrites the task
description and subtasks based on your prompt
</p>
<p>
<strong>Append:</strong> Adds new content to the existing task
implementation details based on your prompt
</p>
</>
)}
</div>
</div>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,204 @@
import type React from 'react';
import { CollapsibleSection } from '@/components/ui/CollapsibleSection';
interface MarkdownRendererProps {
content: string;
className?: string;
}
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
className = ''
}) => {
const parseMarkdown = (text: string) => {
const parts = [];
const lines = text.split('\n');
let currentBlock = [];
let inCodeBlock = false;
let codeLanguage = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('```')) {
if (inCodeBlock) {
if (currentBlock.length > 0) {
parts.push({
type: 'code',
content: currentBlock.join('\n'),
language: codeLanguage
});
currentBlock = [];
}
inCodeBlock = false;
codeLanguage = '';
} else {
if (currentBlock.length > 0) {
parts.push({
type: 'text',
content: currentBlock.join('\n')
});
currentBlock = [];
}
inCodeBlock = true;
codeLanguage = line.substring(3).trim();
}
} else {
currentBlock.push(line);
}
}
if (currentBlock.length > 0) {
parts.push({
type: inCodeBlock ? 'code' : 'text',
content: currentBlock.join('\n'),
language: codeLanguage
});
}
return parts;
};
const parts = parseMarkdown(content);
return (
<div className={className}>
{parts.map((part, index) => {
if (part.type === 'code') {
return (
<pre
key={index}
className="bg-vscode-editor-background rounded-md p-4 overflow-x-auto mb-4 border border-vscode-editor-lineHighlightBorder"
>
<code className="text-sm text-vscode-editor-foreground font-mono">
{part.content}
</code>
</pre>
);
}
return (
<div key={index} className="whitespace-pre-wrap mb-4 last:mb-0">
{part.content.split('\n').map((line, lineIndex) => {
const bulletMatch = line.match(/^(\s*)([-*])\s(.+)$/);
if (bulletMatch) {
const indent = bulletMatch[1].length;
return (
<div
key={lineIndex}
className="flex gap-2 mb-1"
style={{ paddingLeft: `${indent * 16}px` }}
>
<span className="text-vscode-foreground/60"></span>
<span className="flex-1">{bulletMatch[3]}</span>
</div>
);
}
const numberedMatch = line.match(/^(\s*)(\d+\.)\s(.+)$/);
if (numberedMatch) {
const indent = numberedMatch[1].length;
return (
<div
key={lineIndex}
className="flex gap-2 mb-1"
style={{ paddingLeft: `${indent * 16}px` }}
>
<span className="text-vscode-foreground/60 font-mono">
{numberedMatch[2]}
</span>
<span className="flex-1">{numberedMatch[3]}</span>
</div>
);
}
const headingMatch = line.match(/^(#{1,6})\s(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
const headingLevel = Math.min(level + 2, 6);
const headingClassName =
'font-semibold text-vscode-foreground mb-2 mt-4 first:mt-0';
switch (headingLevel) {
case 3:
return (
<h3 key={lineIndex} className={headingClassName}>
{headingMatch[2]}
</h3>
);
case 4:
return (
<h4 key={lineIndex} className={headingClassName}>
{headingMatch[2]}
</h4>
);
case 5:
return (
<h5 key={lineIndex} className={headingClassName}>
{headingMatch[2]}
</h5>
);
case 6:
return (
<h6 key={lineIndex} className={headingClassName}>
{headingMatch[2]}
</h6>
);
default:
return (
<h3 key={lineIndex} className={headingClassName}>
{headingMatch[2]}
</h3>
);
}
}
if (line.trim() === '') {
return <div key={lineIndex} className="h-2" />;
}
return (
<div key={lineIndex} className="mb-2 last:mb-0">
{line}
</div>
);
})}
</div>
);
})}
</div>
);
};
interface DetailsSectionProps {
title: string;
content?: string;
error?: string | null;
emptyMessage?: string;
defaultExpanded?: boolean;
}
export const DetailsSection: React.FC<DetailsSectionProps> = ({
title,
content,
error,
emptyMessage = 'No details available',
defaultExpanded = false
}) => {
return (
<CollapsibleSection title={title} defaultExpanded={defaultExpanded}>
<div className={title.toLowerCase().replace(/\s+/g, '-') + '-content'}>
{error ? (
<div className="text-sm text-red-400 py-2">
Error loading {title.toLowerCase()}: {error}
</div>
) : content !== undefined && content !== '' ? (
<MarkdownRenderer content={content} />
) : (
<div className="text-sm text-vscode-foreground/50 py-2">
{emptyMessage}
</div>
)}
</div>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,47 @@
import type React from 'react';
import type { TaskMasterTask } from '../../webview/types';
// Custom Priority Badge Component with theme-adaptive styling
export const PriorityBadge: React.FC<{
priority: TaskMasterTask['priority'];
}> = ({ priority }) => {
const getPriorityColors = (priority: string) => {
switch (priority) {
case 'high':
return {
backgroundColor: 'rgba(239, 68, 68, 0.2)', // red-500 with opacity
color: '#dc2626', // red-600 - works in both themes
borderColor: 'rgba(239, 68, 68, 0.4)'
};
case 'medium':
return {
backgroundColor: 'rgba(245, 158, 11, 0.2)', // amber-500 with opacity
color: '#d97706', // amber-600 - works in both themes
borderColor: 'rgba(245, 158, 11, 0.4)'
};
case 'low':
return {
backgroundColor: 'rgba(34, 197, 94, 0.2)', // green-500 with opacity
color: '#16a34a', // green-600 - works in both themes
borderColor: 'rgba(34, 197, 94, 0.4)'
};
default:
return {
backgroundColor: 'rgba(156, 163, 175, 0.2)',
color: 'var(--vscode-foreground)',
borderColor: 'rgba(156, 163, 175, 0.4)'
};
}
};
const colors = getPriorityColors(priority || '');
return (
<span
className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-md border"
style={colors}
>
{priority || 'None'}
</span>
);
};

View File

@@ -0,0 +1,218 @@
import type React from 'react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { CollapsibleSection } from '@/components/ui/CollapsibleSection';
import { Plus, Loader2 } from 'lucide-react';
import type { TaskMasterTask } from '../../webview/types';
import { getStatusDotColor } from '../constants';
interface SubtasksSectionProps {
currentTask: TaskMasterTask;
isSubtask: boolean;
sendMessage: (message: any) => Promise<any>;
onNavigateToTask: (taskId: string) => void;
}
export const SubtasksSection: React.FC<SubtasksSectionProps> = ({
currentTask,
isSubtask,
sendMessage,
onNavigateToTask
}) => {
const [isAddingSubtask, setIsAddingSubtask] = useState(false);
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [newSubtaskDescription, setNewSubtaskDescription] = useState('');
const [isSubmittingSubtask, setIsSubmittingSubtask] = useState(false);
const handleAddSubtask = async () => {
if (!currentTask || !newSubtaskTitle.trim() || isSubtask) {
return;
}
setIsSubmittingSubtask(true);
try {
await sendMessage({
type: 'addSubtask',
data: {
parentTaskId: currentTask.id,
subtaskData: {
title: newSubtaskTitle.trim(),
description: newSubtaskDescription.trim() || undefined,
status: 'pending'
}
}
});
// Reset form and close
setNewSubtaskTitle('');
setNewSubtaskDescription('');
setIsAddingSubtask(false);
} catch (error) {
console.error('❌ TaskDetailsView: Failed to add subtask:', error);
} finally {
setIsSubmittingSubtask(false);
}
};
const handleCancelAddSubtask = () => {
setIsAddingSubtask(false);
setNewSubtaskTitle('');
setNewSubtaskDescription('');
};
if (
!((currentTask.subtasks && currentTask.subtasks.length > 0) || !isSubtask)
) {
return null;
}
const rightElement = (
<>
{currentTask.subtasks && currentTask.subtasks.length > 0 && (
<span className="text-sm text-vscode-foreground/50">
{currentTask.subtasks?.filter((st) => st.status === 'done').length}/
{currentTask.subtasks?.length}
</span>
)}
{!isSubtask && (
<Button
variant="ghost"
size="sm"
className="ml-auto p-1 h-6 w-6 hover:bg-vscode-button-hoverBackground"
onClick={() => setIsAddingSubtask(true)}
title="Add subtask"
>
<Plus className="w-4 h-4" />
</Button>
)}
</>
);
return (
<CollapsibleSection
title="Sub-issues"
defaultExpanded={true}
rightElement={rightElement}
>
<div className="space-y-3">
{/* Add Subtask Form */}
{isAddingSubtask && (
<div className="bg-widget-background rounded-lg p-4 border border-widget-border">
<h4 className="text-sm font-medium text-vscode-foreground mb-3">
Add New Subtask
</h4>
<div className="space-y-3">
<div>
<Label
htmlFor="subtask-title"
className="block text-sm text-vscode-foreground/80 mb-1"
>
Title*
</Label>
<input
id="subtask-title"
type="text"
placeholder="Enter subtask title..."
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
className="w-full px-3 py-2 text-sm bg-vscode-input-background border border-vscode-input-border text-vscode-input-foreground placeholder-vscode-input-foreground/50 rounded focus:border-vscode-focusBorder focus:ring-1 focus:ring-vscode-focusBorder"
disabled={isSubmittingSubtask}
/>
</div>
<div>
<Label
htmlFor="subtask-description"
className="block text-sm text-vscode-foreground/80 mb-1"
>
Description (Optional)
</Label>
<Textarea
id="subtask-description"
placeholder="Enter subtask description..."
value={newSubtaskDescription}
onChange={(e) => setNewSubtaskDescription(e.target.value)}
className="min-h-[80px] bg-vscode-input-background border-vscode-input-border text-vscode-input-foreground placeholder-vscode-input-foreground/50 focus:border-vscode-focusBorder focus:ring-vscode-focusBorder"
disabled={isSubmittingSubtask}
/>
</div>
<div className="flex gap-3 pt-2">
<Button
onClick={handleAddSubtask}
disabled={!newSubtaskTitle.trim() || isSubmittingSubtask}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{isSubmittingSubtask ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Adding...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Add Subtask
</>
)}
</Button>
<Button
onClick={handleCancelAddSubtask}
variant="outline"
disabled={isSubmittingSubtask}
className="border-widget-border"
>
Cancel
</Button>
</div>
</div>
</div>
)}
{/* Subtasks List */}
{currentTask.subtasks && currentTask.subtasks.length > 0 && (
<div className="space-y-2">
{currentTask.subtasks.map((subtask, index) => {
const subtaskId = `${currentTask.id}.${index + 1}`;
return (
<div
key={subtask.id}
className="flex items-center gap-3 p-3 rounded-md border border-textSeparator-foreground hover:border-vscode-border/70 transition-colors cursor-pointer"
onClick={() => onNavigateToTask(subtaskId)}
>
<div
className="w-4 h-4 rounded-full flex items-center justify-center"
style={{
backgroundColor: getStatusDotColor(subtask.status)
}}
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-vscode-foreground truncate">
{subtask.title}
</p>
{subtask.description && (
<p className="text-xs text-vscode-foreground/60 truncate mt-0.5">
{subtask.description}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge
variant="secondary"
className="text-xs bg-secondary/20 border-secondary/30 text-secondary-foreground px-2 py-0.5"
>
{subtask.status === 'pending' ? 'todo' : subtask.status}
</Badge>
</div>
</div>
);
})}
</div>
)}
</div>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,291 @@
import type React from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import { PriorityBadge } from './PriorityBadge';
import type { TaskMasterTask } from '../../webview/types';
interface TaskMetadataSidebarProps {
currentTask: TaskMasterTask;
tasks: TaskMasterTask[];
complexity: any;
isSubtask: boolean;
sendMessage: (message: any) => Promise<any>;
onStatusChange: (status: TaskMasterTask['status']) => void;
onDependencyClick: (depId: string) => void;
isRegenerating?: boolean;
isAppending?: boolean;
}
export const TaskMetadataSidebar: React.FC<TaskMetadataSidebarProps> = ({
currentTask,
tasks,
complexity,
isSubtask,
sendMessage,
onStatusChange,
onDependencyClick,
isRegenerating = false,
isAppending = false
}) => {
const [isLoadingComplexity, setIsLoadingComplexity] = useState(false);
const [mcpComplexityScore, setMcpComplexityScore] = useState<
number | undefined
>(undefined);
// Get complexity score from task
const currentComplexityScore = complexity?.score;
// Display logic - use MCP score if available, otherwise use current score
const displayComplexityScore =
mcpComplexityScore !== undefined
? mcpComplexityScore
: currentComplexityScore;
// Fetch complexity from MCP when needed
const fetchComplexityFromMCP = async (force = false) => {
if (!currentTask || (!force && currentComplexityScore !== undefined)) {
return;
}
setIsLoadingComplexity(true);
try {
const complexityResult = await sendMessage({
type: 'mcpRequest',
tool: 'complexity_report',
params: {}
});
if (complexityResult?.data?.report?.complexityAnalysis) {
const taskComplexity =
complexityResult.data.report.complexityAnalysis.tasks?.find(
(t: any) => t.id === currentTask.id
);
if (taskComplexity) {
setMcpComplexityScore(taskComplexity.complexityScore);
}
}
} catch (error) {
console.error('Failed to fetch complexity from MCP:', error);
} finally {
setIsLoadingComplexity(false);
}
};
// Handle running complexity analysis for a task
const handleRunComplexityAnalysis = async () => {
if (!currentTask) {
return;
}
setIsLoadingComplexity(true);
try {
// Run complexity analysis on this specific task
await sendMessage({
type: 'mcpRequest',
tool: 'analyze_project_complexity',
params: {
ids: currentTask.id.toString(),
research: false
}
});
// After analysis, fetch the updated complexity report
setTimeout(() => {
fetchComplexityFromMCP(true);
}, 1000);
} catch (error) {
console.error('Failed to run complexity analysis:', error);
} finally {
setIsLoadingComplexity(false);
}
};
// Effect to handle complexity on task change
useEffect(() => {
if (currentTask?.id) {
setMcpComplexityScore(undefined);
if (currentComplexityScore === undefined) {
fetchComplexityFromMCP();
}
}
}, [currentTask?.id, currentComplexityScore]);
return (
<div className="md:col-span-1 border-l border-textSeparator-foreground">
<div className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium text-vscode-foreground/70 mb-3">
Properties
</h3>
</div>
<div className="space-y-4">
{/* Status */}
<div className="flex items-center justify-between">
<span className="text-sm text-vscode-foreground/70">Status</span>
<select
value={currentTask.status}
onChange={(e) =>
onStatusChange(e.target.value as TaskMasterTask['status'])
}
className="border rounded-md px-3 py-1 text-sm font-medium focus:ring-1 focus:border-vscode-focusBorder focus:ring-vscode-focusBorder"
style={{
backgroundColor:
currentTask.status === 'pending'
? 'rgba(156, 163, 175, 0.2)'
: currentTask.status === 'in-progress'
? 'rgba(245, 158, 11, 0.2)'
: currentTask.status === 'review'
? 'rgba(59, 130, 246, 0.2)'
: currentTask.status === 'done'
? 'rgba(34, 197, 94, 0.2)'
: currentTask.status === 'deferred'
? 'rgba(239, 68, 68, 0.2)'
: 'var(--vscode-input-background)',
color:
currentTask.status === 'pending'
? 'var(--vscode-foreground)'
: currentTask.status === 'in-progress'
? '#d97706'
: currentTask.status === 'review'
? '#2563eb'
: currentTask.status === 'done'
? '#16a34a'
: currentTask.status === 'deferred'
? '#dc2626'
: 'var(--vscode-foreground)',
borderColor:
currentTask.status === 'pending'
? 'rgba(156, 163, 175, 0.4)'
: currentTask.status === 'in-progress'
? 'rgba(245, 158, 11, 0.4)'
: currentTask.status === 'review'
? 'rgba(59, 130, 246, 0.4)'
: currentTask.status === 'done'
? 'rgba(34, 197, 94, 0.4)'
: currentTask.status === 'deferred'
? 'rgba(239, 68, 68, 0.4)'
: 'var(--vscode-input-border)'
}}
>
<option value="pending">To do</option>
<option value="in-progress">In Progress</option>
<option value="review">Review</option>
<option value="done">Done</option>
<option value="deferred">Deferred</option>
</select>
</div>
{/* Priority */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Priority</span>
<PriorityBadge priority={currentTask.priority} />
</div>
{/* Complexity Score */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--vscode-foreground)]">
Complexity Score
</label>
{isLoadingComplexity ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-[var(--vscode-descriptionForeground)]" />
<span className="text-sm text-[var(--vscode-descriptionForeground)]">
Loading...
</span>
</div>
) : displayComplexityScore !== undefined ? (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--vscode-foreground)]">
{displayComplexityScore}/10
</span>
<div
className={`flex-1 rounded-full h-2 ${
displayComplexityScore >= 7
? 'bg-red-500/20'
: displayComplexityScore >= 4
? 'bg-yellow-500/20'
: 'bg-green-500/20'
}`}
>
<div
className={`h-2 rounded-full transition-all duration-300 ${
displayComplexityScore >= 7
? 'bg-red-500'
: displayComplexityScore >= 4
? 'bg-yellow-500'
: 'bg-green-500'
}`}
style={{
width: `${(displayComplexityScore || 0) * 10}%`
}}
/>
</div>
</div>
) : currentTask?.status === 'done' ||
currentTask?.status === 'deferred' ||
currentTask?.status === 'review' ? (
<div className="text-sm text-[var(--vscode-descriptionForeground)]">
N/A
</div>
) : (
<>
<div className="text-sm text-[var(--vscode-descriptionForeground)]">
No complexity score available
</div>
<div className="mt-3">
<Button
onClick={() => handleRunComplexityAnalysis()}
variant="outline"
size="sm"
className="text-xs"
disabled={isRegenerating || isAppending}
>
Run Complexity Analysis
</Button>
</div>
</>
)}
</div>
</div>
<div className="border-b border-textSeparator-foreground" />
{/* Dependencies */}
{currentTask.dependencies && currentTask.dependencies.length > 0 && (
<div>
<h4 className="text-sm font-medium text-vscode-foreground/70 mb-3">
Dependencies
</h4>
<div className="space-y-2">
{currentTask.dependencies.map((depId) => {
// Convert both to string for comparison since depId might be string or number
const depTask = tasks.find(
(t) => String(t.id) === String(depId)
);
const fullTitle = `Task ${depId}: ${depTask?.title || 'Unknown Task'}`;
const truncatedTitle =
fullTitle.length > 40
? fullTitle.substring(0, 37) + '...'
: fullTitle;
return (
<div
key={depId}
className="text-sm text-link cursor-pointer hover:text-link-hover"
onClick={() => onDependencyClick(depId)}
title={fullTitle}
>
{truncatedTitle}
</div>
);
})}
</div>
</div>
)}
{/* Divider after Dependencies */}
{currentTask.dependencies && currentTask.dependencies.length > 0 && (
<div className="border-b border-textSeparator-foreground" />
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,116 @@
import { useMemo } from 'react';
import { useTaskDetails as useTaskDetailsQuery } from '../../webview/hooks/useTaskQueries';
import type { TaskMasterTask } from '../../webview/types';
interface TaskFileData {
details?: string;
testStrategy?: string;
}
interface UseTaskDetailsProps {
taskId: string;
sendMessage: (message: any) => Promise<any>;
tasks: TaskMasterTask[];
}
export const useTaskDetails = ({
taskId,
sendMessage,
tasks
}: UseTaskDetailsProps) => {
// Parse task ID to determine if it's a subtask (e.g., "13.2")
const { isSubtask, parentId, subtaskIndex, taskIdForFetch } = useMemo(() => {
// Ensure taskId is a string
const taskIdStr = String(taskId);
const parts = taskIdStr.split('.');
if (parts.length === 2) {
return {
isSubtask: true,
parentId: parts[0],
subtaskIndex: parseInt(parts[1]) - 1, // Convert to 0-based index
taskIdForFetch: parts[0] // Always fetch parent task for subtasks
};
}
return {
isSubtask: false,
parentId: taskIdStr,
subtaskIndex: -1,
taskIdForFetch: taskIdStr
};
}, [taskId]);
// Use React Query to fetch full task details
const { data: fullTaskData, error: taskDetailsError } =
useTaskDetailsQuery(taskIdForFetch);
// Find current task from local state for immediate display
const { currentTask, parentTask } = useMemo(() => {
if (isSubtask) {
const parent = tasks.find((t) => t.id === parentId);
if (parent && parent.subtasks && parent.subtasks[subtaskIndex]) {
const subtask = parent.subtasks[subtaskIndex];
return { currentTask: subtask, parentTask: parent };
}
} else {
const task = tasks.find((t) => t.id === String(taskId));
if (task) {
return { currentTask: task, parentTask: null };
}
}
return { currentTask: null, parentTask: null };
}, [taskId, tasks, isSubtask, parentId, subtaskIndex]);
// Merge full task data from React Query with local state
const mergedCurrentTask = useMemo(() => {
if (!currentTask || !fullTaskData) return currentTask;
if (isSubtask && fullTaskData.subtasks) {
// Find the specific subtask in the full data
const subtaskData = fullTaskData.subtasks.find(
(st: any) =>
st.id === currentTask.id || st.id === parseInt(currentTask.id as any)
);
if (subtaskData) {
return { ...currentTask, ...subtaskData };
}
} else if (!isSubtask) {
// Merge parent task data
return { ...currentTask, ...fullTaskData };
}
return currentTask;
}, [currentTask, fullTaskData, isSubtask]);
// Extract task file data
const taskFileData: TaskFileData = useMemo(() => {
if (!mergedCurrentTask) return {};
return {
details: mergedCurrentTask.details || '',
testStrategy: mergedCurrentTask.testStrategy || ''
};
}, [mergedCurrentTask]);
// Get complexity score
const complexity = useMemo(() => {
if (mergedCurrentTask?.complexityScore !== undefined) {
return { score: mergedCurrentTask.complexityScore };
}
return null;
}, [mergedCurrentTask]);
// Function to refresh data after AI operations
const refreshComplexityAfterAI = () => {
// React Query will automatically refetch when mutations invalidate the query
// No need for manual refresh
};
return {
currentTask: mergedCurrentTask,
parentTask,
isSubtask,
taskFileData,
taskFileDataError: taskDetailsError ? 'Failed to load task details' : null,
complexity,
refreshComplexityAfterAI
};
};

View File

@@ -0,0 +1,218 @@
import type React from 'react';
import { useContext, useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { useQueryClient } from '@tanstack/react-query';
import { RefreshCw } from 'lucide-react';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbSeparator
} from '@/components/ui/breadcrumb';
import { VSCodeContext } from '../webview/contexts/VSCodeContext';
import { AIActionsSection } from './TaskDetails/AIActionsSection';
import { SubtasksSection } from './TaskDetails/SubtasksSection';
import { TaskMetadataSidebar } from './TaskDetails/TaskMetadataSidebar';
import { DetailsSection } from './TaskDetails/DetailsSection';
import { useTaskDetails } from './TaskDetails/useTaskDetails';
import { useTasks, taskKeys } from '../webview/hooks/useTaskQueries';
import type { TaskMasterTask } from '../webview/types';
interface TaskDetailsViewProps {
taskId: string;
onNavigateBack: () => void;
onNavigateToTask: (taskId: string) => void;
}
export const TaskDetailsView: React.FC<TaskDetailsViewProps> = ({
taskId,
onNavigateBack,
onNavigateToTask
}) => {
const context = useContext(VSCodeContext);
if (!context) {
throw new Error('TaskDetailsView must be used within VSCodeProvider');
}
const { state, sendMessage } = context;
const { currentTag } = state;
const queryClient = useQueryClient();
const [isRefreshing, setIsRefreshing] = useState(false);
// Use React Query to fetch all tasks
const { data: allTasks = [] } = useTasks({ tag: currentTag });
const {
currentTask,
parentTask,
isSubtask,
taskFileData,
taskFileDataError,
complexity,
refreshComplexityAfterAI
} = useTaskDetails({ taskId, sendMessage, tasks: allTasks });
const handleStatusChange = async (newStatus: TaskMasterTask['status']) => {
if (!currentTask) return;
try {
await sendMessage({
type: 'updateTaskStatus',
data: {
taskId:
isSubtask && parentTask
? `${parentTask.id}.${currentTask.id}`
: currentTask.id,
newStatus: newStatus
}
});
} catch (error) {
console.error('❌ TaskDetailsView: Failed to update task status:', error);
}
};
const handleDependencyClick = (depId: string) => {
onNavigateToTask(depId);
};
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
try {
// Invalidate all task queries
await queryClient.invalidateQueries({ queryKey: taskKeys.all });
} finally {
// Reset after a short delay to show the animation
setTimeout(() => setIsRefreshing(false), 500);
}
}, [queryClient]);
if (!currentTask) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-lg text-vscode-foreground/70 mb-4">
Task not found
</p>
<Button onClick={onNavigateBack} variant="outline">
Back to Kanban Board
</Button>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
<div className="flex-1 grid grid-cols-1 md:grid-cols-3 gap-6 p-6 overflow-auto">
{/* Left column - Main content (2/3 width) */}
<div className="md:col-span-2 space-y-6">
{/* Breadcrumb navigation */}
<div className="flex items-center justify-between">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink
onClick={onNavigateBack}
className="cursor-pointer hover:text-vscode-foreground text-link"
>
Kanban Board
</BreadcrumbLink>
</BreadcrumbItem>
{isSubtask && parentTask && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink
onClick={() => onNavigateToTask(parentTask.id)}
className="cursor-pointer hover:text-vscode-foreground"
>
{parentTask.title}
</BreadcrumbLink>
</BreadcrumbItem>
</>
)}
<BreadcrumbSeparator />
<BreadcrumbItem>
<span className="text-vscode-foreground">
{currentTask.title}
</span>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="p-1.5 rounded hover:bg-vscode-button-hoverBackground transition-colors"
title="Refresh task details"
>
<RefreshCw
className={`w-4 h-4 text-vscode-foreground/70 ${isRefreshing ? 'animate-spin' : ''}`}
/>
</button>
</div>
{/* Task title */}
<h1 className="text-2xl font-bold tracking-tight text-vscode-foreground">
{currentTask.title}
</h1>
{/* Description */}
<div className="mb-8">
<p className="text-vscode-foreground/80 leading-relaxed">
{currentTask.description || 'No description available.'}
</p>
</div>
{/* AI Actions */}
<AIActionsSection
currentTask={currentTask}
isSubtask={isSubtask}
parentTask={parentTask}
sendMessage={sendMessage}
refreshComplexityAfterAI={refreshComplexityAfterAI}
/>
{/* Implementation Details */}
<DetailsSection
title="Implementation Details"
content={taskFileData.details}
error={taskFileDataError}
emptyMessage="No implementation details available"
defaultExpanded={false}
/>
{/* Test Strategy */}
<DetailsSection
title="Test Strategy"
content={taskFileData.testStrategy}
error={taskFileDataError}
emptyMessage="No test strategy available"
defaultExpanded={false}
/>
{/* Subtasks */}
<SubtasksSection
currentTask={currentTask}
isSubtask={isSubtask}
sendMessage={sendMessage}
onNavigateToTask={onNavigateToTask}
/>
</div>
{/* Right column - Metadata (1/3 width) */}
<TaskMetadataSidebar
currentTask={currentTask}
tasks={allTasks}
complexity={complexity}
isSubtask={isSubtask}
sendMessage={sendMessage}
onStatusChange={handleStatusChange}
onDependencyClick={handleDependencyClick}
/>
</div>
</div>
);
};
export default TaskDetailsView;

View File

@@ -0,0 +1,23 @@
import React from 'react';
interface TaskMasterLogoProps {
className?: string;
}
export const TaskMasterLogo: React.FC<TaskMasterLogoProps> = ({
className = ''
}) => {
return (
<svg
className={className}
viewBox="0 0 224 291"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M101.635 286.568L71.4839 256.414C65.6092 250.539 65.6092 241.03 71.4839 235.155L142.52 164.11C144.474 162.156 147.643 162.156 149.61 164.11L176.216 190.719C178.17 192.673 181.339 192.673 183.305 190.719L189.719 184.305C191.673 182.35 191.673 179.181 189.719 177.214L163.113 150.605C161.159 148.651 161.159 145.481 163.113 143.514L191.26 115.365C193.214 113.41 193.214 110.241 191.26 108.274L182.316 99.3291C180.362 97.3748 177.193 97.3748 175.226 99.3291L55.8638 218.706C49.989 224.581 40.4816 224.581 34.6068 218.706L4.4061 188.501C-1.4687 182.626 -1.4687 173.117 4.4061 167.242L23.8342 147.811C25.7883 145.857 25.7883 142.688 23.8342 140.721L4.78187 121.666C-1.09293 115.791 -1.09293 106.282 4.78187 100.406L34.7195 70.4527C40.5943 64.5772 50.1017 64.5772 55.9765 70.4527L75.555 90.0335C77.5091 91.9879 80.6782 91.9879 82.6448 90.0335L124.144 48.5292C126.098 46.5749 126.098 43.4054 124.144 41.4385L115.463 32.7568C113.509 30.8025 110.34 30.8025 108.374 32.7568L99.8683 41.2632C97.9143 43.2175 94.7451 43.2175 92.7785 41.2632L82.1438 30.6271C80.1897 28.6728 80.1897 25.5033 82.1438 23.5364L101.271 4.40662C107.146 -1.46887 116.653 -1.46887 122.528 4.40662L152.478 34.3604C158.353 40.2359 158.353 49.7444 152.478 55.6199L82.6323 125.474C80.6782 127.429 77.5091 127.429 75.5425 125.474L48.8741 98.8029C46.9201 96.8486 43.7509 96.8486 41.7843 98.8029L33.1036 107.485C31.1496 109.439 31.1496 112.608 33.1036 114.575L59.2458 140.721C61.1999 142.675 61.1999 145.844 59.2458 147.811L32.7404 174.32C30.7863 176.274 30.7863 179.444 32.7404 181.411L41.6841 190.355C43.6382 192.31 46.8073 192.31 48.7739 190.355L168.136 70.9789C174.011 65.1034 183.518 65.1034 189.393 70.9789L219.594 101.183C225.469 107.059 225.469 116.567 219.594 122.443L198.537 143.502C196.583 145.456 196.583 148.626 198.537 150.592L218.053 170.111C223.928 175.986 223.928 185.495 218.053 191.37L190.37 219.056C184.495 224.932 174.988 224.932 169.113 219.056L149.597 199.538C147.643 197.584 144.474 197.584 142.508 199.538L99.8057 242.245C97.8516 244.2 97.8516 247.369 99.8057 249.336L108.699 258.231C110.653 260.185 113.823 260.185 115.789 258.231L122.954 251.065C124.908 249.11 128.077 249.11 130.044 251.065L140.679 261.701C142.633 263.655 142.633 266.825 140.679 268.791L122.879 286.593C117.004 292.469 107.497 292.469 101.622 286.593L101.635 286.568Z"
fill="currentColor"
/>
</svg>
);
};

View File

@@ -0,0 +1,26 @@
/**
* Shared constants for TaskDetails components
*/
/**
* Status color definitions for visual indicators
*/
export const STATUS_DOT_COLORS = {
done: '#22c55e', // Green
'in-progress': '#3b82f6', // Blue
review: '#a855f7', // Purple
deferred: '#ef4444', // Red
cancelled: '#6b7280', // Gray
pending: '#eab308' // Yellow (default)
} as const;
export type TaskStatus = keyof typeof STATUS_DOT_COLORS;
/**
* Get the color for a status dot indicator
* @param status - The task status
* @returns The hex color code for the status
*/
export function getStatusDotColor(status: string): string {
return STATUS_DOT_COLORS[status as TaskStatus] || STATUS_DOT_COLORS.pending;
}

View File

@@ -0,0 +1,61 @@
import type React from 'react';
import { useState } from 'react';
import { Button } from './button';
import { ChevronDown, ChevronRight } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
interface CollapsibleSectionProps {
title: string;
icon?: LucideIcon;
defaultExpanded?: boolean;
className?: string;
headerClassName?: string;
contentClassName?: string;
buttonClassName?: string;
children: React.ReactNode;
rightElement?: React.ReactNode;
}
export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
icon: Icon,
defaultExpanded = false,
className = '',
headerClassName = '',
contentClassName = '',
buttonClassName = 'text-vscode-foreground/70 hover:text-vscode-foreground',
children,
rightElement
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<div className={`mb-8 ${className}`}>
<div className={`flex items-center gap-2 mb-4 ${headerClassName}`}>
<Button
variant="ghost"
size="sm"
className={`p-0 h-auto ${buttonClassName}`}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 mr-1" />
) : (
<ChevronRight className="w-4 h-4 mr-1" />
)}
{Icon && <Icon className="w-4 h-4 mr-1" />}
{title}
</Button>
{rightElement}
</div>
{isExpanded && (
<div
className={`bg-widget-background rounded-lg p-4 border border-widget-border ${contentClassName}`}
>
{children}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,46 @@
import { Slot } from '@radix-ui/react-slot';
import { type VariantProps, cva } from 'class-variance-authority';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
}
},
defaultVariants: {
variant: 'default'
}
}
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span';
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,109 @@
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-item"
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot="breadcrumb-link"
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn('text-foreground font-normal', className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis
};

View File

@@ -0,0 +1,59 @@
import { Slot } from '@radix-ui/react-slot';
import { type VariantProps, cva } from 'class-variance-authority';
import type * as React from 'react';
import { cn } from '../../lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,92 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent
};

View File

@@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,257 @@
'use client';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const DROPDOWN_MENU_ITEM_CLASSES =
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
const DROPDOWN_MENU_SUB_CONTENT_CLASSES =
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg';
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(DROPDOWN_MENU_ITEM_CLASSES, className)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(DROPDOWN_MENU_SUB_CONTENT_CLASSES, className)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent
};

View File

@@ -0,0 +1,22 @@
import * as LabelPrimitive from '@radix-ui/react-label';
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,56 @@
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import type * as React from 'react';
import { cn } from '@/lib/utils';
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 overflow-y-auto"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,28 @@
'use client';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,188 @@
'use client';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import {
DndContext,
DragOverlay,
MouseSensor,
TouchSensor,
rectIntersection,
useDraggable,
useDroppable,
useSensor,
useSensors
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import type React from 'react';
import type { ReactNode } from 'react';
export type { DragEndEvent } from '@dnd-kit/core';
export type Status = {
id: string;
name: string;
color: string;
};
export type Feature = {
id: string;
name: string;
startAt: Date;
endAt: Date;
status: Status;
};
export type KanbanBoardProps = {
id: Status['id'];
children: ReactNode;
className?: string;
};
export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => {
const { isOver, setNodeRef } = useDroppable({ id });
return (
<div
className={cn(
'flex h-full min-h-40 flex-col gap-2 rounded-md border bg-secondary p-2 text-xs shadow-sm outline transition-all',
isOver ? 'outline-primary' : 'outline-transparent',
className
)}
ref={setNodeRef}
>
{children}
</div>
);
};
export type KanbanCardProps = Pick<Feature, 'id' | 'name'> & {
index: number;
parent: string;
children?: ReactNode;
className?: string;
onClick?: (event: React.MouseEvent) => void;
onDoubleClick?: (event: React.MouseEvent) => void;
};
export const KanbanCard = ({
id,
name,
index,
parent,
children,
className,
onClick,
onDoubleClick
}: KanbanCardProps) => {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id,
data: { index, parent }
});
return (
<Card
className={cn(
'rounded-md p-3 shadow-sm',
isDragging && 'cursor-grabbing opacity-0',
!isDragging && 'cursor-pointer',
className
)}
style={{
transform: transform
? `translateX(${transform.x}px) translateY(${transform.y}px)`
: 'none'
}}
{...attributes}
{...listeners}
onClick={(e) => !isDragging && onClick?.(e)}
onDoubleClick={onDoubleClick}
ref={setNodeRef}
>
{children ?? <p className="m-0 font-medium text-sm">{name}</p>}
</Card>
);
};
export type KanbanCardsProps = {
children: ReactNode;
className?: string;
};
export const KanbanCards = ({ children, className }: KanbanCardsProps) => (
<div className={cn('flex flex-1 flex-col gap-2', className)}>{children}</div>
);
export type KanbanHeaderProps =
| {
children: ReactNode;
}
| {
name: Status['name'];
color: Status['color'];
className?: string;
};
export const KanbanHeader = (props: KanbanHeaderProps) =>
'children' in props ? (
props.children
) : (
<div className={cn('flex shrink-0 items-center gap-2', props.className)}>
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: props.color }}
/>
<p className="m-0 font-semibold text-sm">{props.name}</p>
</div>
);
export type KanbanProviderProps = {
children: ReactNode;
onDragEnd: (event: DragEndEvent) => void;
onDragStart?: (event: DragEndEvent) => void;
onDragCancel?: () => void;
className?: string;
dragOverlay?: ReactNode;
};
export const KanbanProvider = ({
children,
onDragEnd,
onDragStart,
onDragCancel,
className,
dragOverlay
}: KanbanProviderProps) => {
// Configure sensors with activation constraints to prevent accidental drags
const sensors = useSensors(
// Only start a drag if you've moved more than 8px
useSensor(MouseSensor, {
activationConstraint: { distance: 8 }
}),
// On touch devices, require a short press + small move
useSensor(TouchSensor, {
activationConstraint: { delay: 150, tolerance: 5 }
})
);
return (
<DndContext
sensors={sensors}
collisionDetection={rectIntersection}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
onDragCancel={onDragCancel}
>
<div
className={cn(
'grid w-full auto-cols-fr grid-flow-col gap-4',
className
)}
>
{children}
</div>
<DragOverlay>{dragOverlay}</DragOverlay>
</DndContext>
);
};

View File

@@ -0,0 +1,18 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
{...props}
/>
);
}
export { Textarea };