mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: update usage recommendations across multiple workflow commands to require user confirmation and improve clarity
This commit is contained in:
@@ -10,268 +10,191 @@ import {
|
||||
Search,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Code,
|
||||
BookOpen,
|
||||
Tag,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import { useCommands } from '@/hooks';
|
||||
import type { Command } from '@/lib/api';
|
||||
import { useCommands, useCommandMutations } from '@/hooks';
|
||||
import { CommandGroupAccordion } from '@/components/commands/CommandGroupAccordion';
|
||||
import { LocationSwitcher } from '@/components/commands/LocationSwitcher';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Command Card Component ==========
|
||||
|
||||
interface CommandCardProps {
|
||||
command: Command;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onCopy: (text: string) => void;
|
||||
}
|
||||
|
||||
function CommandCard({ command, isExpanded, onToggleExpand, onCopy }: CommandCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Terminal className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono font-medium text-foreground">
|
||||
/{command.name}
|
||||
</code>
|
||||
{command.source && (
|
||||
<Badge variant={command.source === 'builtin' ? 'default' : 'secondary'} className="text-xs">
|
||||
{command.source}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{command.description || formatMessage({ id: 'commands.card.noDescription' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy(`/${command.name}`);
|
||||
}}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category and Aliases */}
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{command.category && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Tag className="w-3 h-3 mr-1" />
|
||||
{command.category}
|
||||
</Badge>
|
||||
)}
|
||||
{command.aliases?.map((alias) => (
|
||||
<Badge key={alias} variant="secondary" className="text-xs font-mono">
|
||||
/{alias}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border p-4 space-y-4 bg-muted/30">
|
||||
{/* Usage */}
|
||||
{command.usage && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
|
||||
<Code className="w-4 h-4" />
|
||||
{formatMessage({ id: 'commands.card.usage' })}
|
||||
</div>
|
||||
<div className="p-3 bg-background rounded-md font-mono text-sm overflow-x-auto">
|
||||
<code>{command.usage}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Examples */}
|
||||
{command.examples && command.examples.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
{formatMessage({ id: 'commands.card.examples' })}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{command.examples.map((example, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-3 bg-background rounded-md font-mono text-sm flex items-center justify-between gap-2 group"
|
||||
>
|
||||
<code className="overflow-x-auto flex-1">{example}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => onCopy(example)}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Page Component ==========
|
||||
|
||||
export function CommandsManagerPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Location filter state
|
||||
const [locationFilter, setLocationFilter] = useState<'project' | 'user'>('project');
|
||||
// Show disabled commands state
|
||||
const [showDisabledCommands, setShowDisabledCommands] = useState(false);
|
||||
// Expanded groups state (default cli and workflow expanded)
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['cli', 'workflow']));
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [sourceFilter, setSourceFilter] = useState<string>('all');
|
||||
const [expandedCommands, setExpandedCommands] = useState<Set<string>>(new Set());
|
||||
|
||||
const {
|
||||
commands,
|
||||
categories,
|
||||
totalCount,
|
||||
groupedCommands,
|
||||
groups,
|
||||
enabledCount,
|
||||
disabledCount,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useCommands({
|
||||
filter: {
|
||||
location: locationFilter,
|
||||
showDisabled: showDisabledCommands,
|
||||
search: searchQuery || undefined,
|
||||
category: categoryFilter !== 'all' ? categoryFilter : undefined,
|
||||
source: sourceFilter !== 'all' ? sourceFilter as Command['source'] : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const toggleExpand = (commandName: string) => {
|
||||
setExpandedCommands((prev) => {
|
||||
const { toggleCommand, toggleGroup, isToggling } = useCommandMutations();
|
||||
|
||||
// Toggle group expand/collapse
|
||||
const toggleGroupExpand = (groupName: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(commandName)) {
|
||||
next.delete(commandName);
|
||||
if (next.has(groupName)) {
|
||||
next.delete(groupName);
|
||||
} else {
|
||||
next.add(commandName);
|
||||
next.add(groupName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Expand all groups
|
||||
const expandAll = () => {
|
||||
setExpandedCommands(new Set(commands.map((c) => c.name)));
|
||||
setExpandedGroups(new Set(groups));
|
||||
};
|
||||
|
||||
// Collapse all groups
|
||||
const collapseAll = () => {
|
||||
setExpandedCommands(new Set());
|
||||
setExpandedGroups(new Set());
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
// Toggle individual command
|
||||
const handleToggleCommand = async (name: string, enabled: boolean) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
// TODO: Show toast notification
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
await toggleCommand(name, enabled, locationFilter);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle command:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Count by source
|
||||
const builtinCount = useMemo(
|
||||
() => commands.filter((c) => c.source === 'builtin').length,
|
||||
// Toggle all commands in a group
|
||||
const handleToggleGroup = async (groupName: string, enable: boolean) => {
|
||||
try {
|
||||
await toggleGroup(groupName, enable, locationFilter);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle group:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate command counts per location
|
||||
const projectCount = useMemo(
|
||||
() => commands.filter((c) => c.location === 'project').length,
|
||||
[commands]
|
||||
);
|
||||
const customCount = useMemo(
|
||||
() => commands.filter((c) => c.source === 'custom').length,
|
||||
const userCount = useMemo(
|
||||
() => commands.filter((c) => c.location === 'user').length,
|
||||
[commands]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<Terminal className="w-6 h-6 text-primary" />
|
||||
{formatMessage({ id: 'commands.title' })}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'commands.description' })}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<Terminal className="w-6 h-6 text-primary" />
|
||||
{formatMessage({ id: 'commands.title' })}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'commands.description' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'commands.actions.create' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'commands.actions.create' })}
|
||||
</Button>
|
||||
|
||||
{/* Location and Show Disabled Controls */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<LocationSwitcher
|
||||
currentLocation={locationFilter}
|
||||
onLocationChange={setLocationFilter}
|
||||
projectCount={projectCount}
|
||||
userCount={userCount}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={showDisabledCommands ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowDisabledCommands((prev) => !prev)}
|
||||
disabled={isToggling}
|
||||
>
|
||||
{showDisabledCommands ? (
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
) : (
|
||||
<EyeOff className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{showDisabledCommands
|
||||
? formatMessage({ id: 'commands.actions.hideDisabled' })
|
||||
: formatMessage({ id: 'commands.actions.showDisabled' })}
|
||||
<span className="ml-1 text-xs opacity-70">({disabledCount})</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-primary" />
|
||||
<span className="text-2xl font-bold">{totalCount}</span>
|
||||
<span className="text-2xl font-bold">{commands.length}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'common.stats.totalCommands' })}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'commands.stats.total' })}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-5 h-5 text-info" />
|
||||
<span className="text-2xl font-bold">{builtinCount}</span>
|
||||
<CheckCircle2 className="w-5 h-5 text-success" />
|
||||
<span className="text-2xl font-bold">{enabledCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'commands.source.builtin' })}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'commands.stats.enabled' })}
|
||||
</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="w-5 h-5 text-success" />
|
||||
<span className="text-2xl font-bold">{customCount}</span>
|
||||
<XCircle className="w-5 h-5 text-muted-foreground" />
|
||||
<span className="text-2xl font-bold">{disabledCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'commands.source.custom' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="w-5 h-5 text-warning" />
|
||||
<span className="text-2xl font-bold">{categories.length}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'common.stats.categories' })}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'commands.stats.disabled' })}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
{/* Search and Expand/Collapse Controls */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
@@ -282,67 +205,51 @@ export function CommandsManagerPage() {
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder={formatMessage({ id: 'commands.filters.category' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{formatMessage({ id: 'commands.filters.allCategories' })}</SelectItem>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sourceFilter} onValueChange={setSourceFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder={formatMessage({ id: 'commands.filters.source' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{formatMessage({ id: 'commands.filters.allSources' })}</SelectItem>
|
||||
<SelectItem value="builtin">{formatMessage({ id: 'commands.source.builtin' })}</SelectItem>
|
||||
<SelectItem value="custom">{formatMessage({ id: 'commands.source.custom' })}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={expandAll} disabled={groups.length === 0}>
|
||||
{formatMessage({ id: 'commands.actions.expandAll' })}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={collapseAll} disabled={groups.length === 0}>
|
||||
{formatMessage({ id: 'commands.actions.collapseAll' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expand/Collapse All */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={expandAll}>
|
||||
{formatMessage({ id: 'commands.actions.expandAll' })}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={collapseAll}>
|
||||
{formatMessage({ id: 'commands.actions.collapseAll' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Commands List */}
|
||||
{/* Command Groups Accordion */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-24 bg-muted animate-pulse rounded-lg" />
|
||||
<div key={i} className="h-32 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : commands.length === 0 ? (
|
||||
) : groups.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Terminal className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">{formatMessage({ id: 'commands.emptyState.title' })}</h3>
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
{formatMessage({ id: 'commands.emptyState.title' })}
|
||||
</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{formatMessage({ id: 'commands.emptyState.message' })}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{commands.map((command) => (
|
||||
<CommandCard
|
||||
key={command.name}
|
||||
command={command}
|
||||
isExpanded={expandedCommands.has(command.name)}
|
||||
onToggleExpand={() => toggleExpand(command.name)}
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
))}
|
||||
<div className="commands-accordion">
|
||||
{groups.map((groupName) => {
|
||||
const groupCommands = groupedCommands[groupName] || [];
|
||||
return (
|
||||
<CommandGroupAccordion
|
||||
key={groupName}
|
||||
groupName={groupName}
|
||||
commands={groupCommands}
|
||||
isExpanded={expandedGroups.has(groupName)}
|
||||
onToggleExpand={toggleGroupExpand}
|
||||
onToggleCommand={handleToggleCommand}
|
||||
onToggleGroup={handleToggleGroup}
|
||||
isToggling={isToggling}
|
||||
showDisabled={showDisabledCommands}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -289,7 +289,7 @@ export function FixSessionPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-semibold bg-primary/10 text-primary border border-primary/20">
|
||||
{task.task_id || task.id || 'N/A'}
|
||||
</span>
|
||||
<Badge variant={statusBadge.variant} className="gap-1">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Play,
|
||||
Zap,
|
||||
Wrench,
|
||||
CheckCircle,
|
||||
@@ -32,6 +33,7 @@ import { cn } from '@/lib/utils';
|
||||
// ========== Types ==========
|
||||
|
||||
interface HooksByTrigger {
|
||||
SessionStart: HookCardData[];
|
||||
UserPromptSubmit: HookCardData[];
|
||||
PreToolUse: HookCardData[];
|
||||
PostToolUse: HookCardData[];
|
||||
@@ -41,7 +43,7 @@ interface HooksByTrigger {
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function isHookTriggerType(value: string): value is HookTriggerType {
|
||||
return ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'].includes(value);
|
||||
return ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'].includes(value);
|
||||
}
|
||||
|
||||
function toHookCardData(hook: { name: string; description?: string; enabled: boolean; trigger: string; matcher?: string; command?: string; script?: string }): HookCardData | null {
|
||||
@@ -60,6 +62,7 @@ function toHookCardData(hook: { name: string; description?: string; enabled: boo
|
||||
|
||||
function groupHooksByTrigger(hooks: HookCardData[]): HooksByTrigger {
|
||||
return {
|
||||
SessionStart: hooks.filter((h) => h.trigger === 'SessionStart'),
|
||||
UserPromptSubmit: hooks.filter((h) => h.trigger === 'UserPromptSubmit'),
|
||||
PreToolUse: hooks.filter((h) => h.trigger === 'PreToolUse'),
|
||||
PostToolUse: hooks.filter((h) => h.trigger === 'PostToolUse'),
|
||||
@@ -69,6 +72,10 @@ function groupHooksByTrigger(hooks: HookCardData[]): HooksByTrigger {
|
||||
|
||||
function getTriggerStats(hooksByTrigger: HooksByTrigger) {
|
||||
return {
|
||||
SessionStart: {
|
||||
total: hooksByTrigger.SessionStart.length,
|
||||
enabled: hooksByTrigger.SessionStart.filter((h) => h.enabled).length,
|
||||
},
|
||||
UserPromptSubmit: {
|
||||
total: hooksByTrigger.UserPromptSubmit.length,
|
||||
enabled: hooksByTrigger.UserPromptSubmit.filter((h) => h.enabled).length,
|
||||
@@ -215,6 +222,7 @@ export function HookManagerPage() {
|
||||
};
|
||||
|
||||
const TRIGGER_TYPES: Array<{ type: HookTriggerType; icon: typeof Zap }> = [
|
||||
{ type: 'SessionStart', icon: Play },
|
||||
{ type: 'UserPromptSubmit', icon: Zap },
|
||||
{ type: 'PreToolUse', icon: Wrench },
|
||||
{ type: 'PostToolUse', icon: CheckCircle },
|
||||
|
||||
@@ -393,7 +393,7 @@ export function LiteTaskDetailPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base font-medium flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono text-muted-foreground">{taskId}</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-semibold bg-primary/10 text-primary border border-primary/20">{taskId}</span>
|
||||
<Badge
|
||||
variant={task.status === 'completed' ? 'success' : task.status === 'in_progress' ? 'warning' : 'secondary'}
|
||||
>
|
||||
|
||||
@@ -12,13 +12,24 @@ import {
|
||||
FileEdit,
|
||||
MessagesSquare,
|
||||
Calendar,
|
||||
ListChecks,
|
||||
XCircle,
|
||||
Activity,
|
||||
Repeat,
|
||||
MessageCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Search,
|
||||
SortAsc,
|
||||
SortDesc,
|
||||
ListFilter,
|
||||
Hash,
|
||||
ListChecks,
|
||||
Package,
|
||||
Loader2,
|
||||
Compass,
|
||||
Stethoscope,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { useLiteTasks } from '@/hooks/useLiteTasks';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -26,10 +37,12 @@ import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { TaskDrawer } from '@/components/shared/TaskDrawer';
|
||||
import type { LiteTask, LiteTaskSession } from '@/lib/api';
|
||||
import { fetchLiteSessionContext, type LiteTask, type LiteTaskSession, type LiteSessionContext } from '@/lib/api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
type LiteTaskTab = 'lite-plan' | 'lite-fix' | 'multi-cli-plan';
|
||||
type SortField = 'date' | 'name' | 'tasks';
|
||||
type SortOrder = 'asc' | 'desc';
|
||||
|
||||
/**
|
||||
* Get i18n text from label object (supports {en, zh} format)
|
||||
@@ -41,6 +54,340 @@ function getI18nText(label: string | { en?: string; zh?: string } | undefined):
|
||||
return label.en || label.zh;
|
||||
}
|
||||
|
||||
type ExpandedTab = 'tasks' | 'context';
|
||||
|
||||
/**
|
||||
* ExpandedSessionPanel - Multi-tab panel shown when a lite session is expanded
|
||||
*/
|
||||
function ExpandedSessionPanel({
|
||||
session,
|
||||
onTaskClick,
|
||||
}: {
|
||||
session: LiteTaskSession;
|
||||
onTaskClick: (task: LiteTask) => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = React.useState<ExpandedTab>('tasks');
|
||||
const [contextData, setContextData] = React.useState<LiteSessionContext | null>(null);
|
||||
const [contextLoading, setContextLoading] = React.useState(false);
|
||||
const [contextError, setContextError] = React.useState<string | null>(null);
|
||||
|
||||
const tasks = session.tasks || [];
|
||||
const taskCount = tasks.length;
|
||||
|
||||
// Load context data lazily when context tab is selected
|
||||
React.useEffect(() => {
|
||||
if (activeTab !== 'context') return;
|
||||
if (contextData || contextLoading) return;
|
||||
if (!session.path) {
|
||||
setContextError('No session path available');
|
||||
return;
|
||||
}
|
||||
|
||||
setContextLoading(true);
|
||||
fetchLiteSessionContext(session.path)
|
||||
.then((data) => {
|
||||
setContextData(data);
|
||||
setContextError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setContextError(err.message || 'Failed to load context');
|
||||
})
|
||||
.finally(() => {
|
||||
setContextLoading(false);
|
||||
});
|
||||
}, [activeTab, session.path, contextData, contextLoading]);
|
||||
|
||||
return (
|
||||
<div className="mt-2 ml-6 pb-2">
|
||||
{/* Quick Info Cards */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setActiveTab('tasks'); }}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
activeTab === 'tasks'
|
||||
? 'bg-primary/10 text-primary border-primary/30'
|
||||
: 'bg-muted/50 text-muted-foreground border-border hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<ListChecks className="h-3.5 w-3.5" />
|
||||
{formatMessage({ id: 'liteTasks.quickCards.tasks' })}
|
||||
<Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0">
|
||||
{taskCount}
|
||||
</Badge>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setActiveTab('context'); }}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
activeTab === 'context'
|
||||
? 'bg-primary/10 text-primary border-primary/30'
|
||||
: 'bg-muted/50 text-muted-foreground border-border hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<Package className="h-3.5 w-3.5" />
|
||||
{formatMessage({ id: 'liteTasks.quickCards.context' })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tasks Tab */}
|
||||
{activeTab === 'tasks' && (
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task, index) => (
|
||||
<Card
|
||||
key={task.id || index}
|
||||
className="cursor-pointer hover:shadow-sm hover:border-primary/50 transition-all border-border"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskClick(task);
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="text-xs font-mono shrink-0 bg-primary/10 text-primary border-primary/20">
|
||||
{task.task_id || `#${index + 1}`}
|
||||
</Badge>
|
||||
<h4 className="text-sm font-medium text-foreground flex-1 line-clamp-1">
|
||||
{task.title || formatMessage({ id: 'liteTasks.untitled' })}
|
||||
</h4>
|
||||
{task.status && (
|
||||
<Badge
|
||||
variant={
|
||||
task.status === 'completed' ? 'success' :
|
||||
task.status === 'in_progress' ? 'warning' :
|
||||
task.status === 'blocked' ? 'destructive' : 'secondary'
|
||||
}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{task.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{task.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5 pl-[calc(1.5rem+0.75rem)] line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context Tab */}
|
||||
{activeTab === 'context' && (
|
||||
<div className="space-y-3">
|
||||
{contextLoading && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />
|
||||
<span className="text-sm">{formatMessage({ id: 'liteTasks.contextPanel.loading' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{contextError && !contextLoading && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive text-sm">
|
||||
<XCircle className="h-4 w-4 flex-shrink-0" />
|
||||
{formatMessage({ id: 'liteTasks.contextPanel.error' })}: {contextError}
|
||||
</div>
|
||||
)}
|
||||
{!contextLoading && !contextError && contextData && (
|
||||
<ContextContent contextData={contextData} session={session} />
|
||||
)}
|
||||
{!contextLoading && !contextError && !contextData && !session.path && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Package className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'liteTasks.contextPanel.empty' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ContextContent - Renders the context data sections
|
||||
*/
|
||||
function ContextContent({
|
||||
contextData,
|
||||
session,
|
||||
}: {
|
||||
contextData: LiteSessionContext;
|
||||
session: LiteTaskSession;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const plan = session.plan || {};
|
||||
const hasExplorations = !!(contextData.explorations?.manifest);
|
||||
const hasDiagnoses = !!(contextData.diagnoses?.manifest || contextData.diagnoses?.items?.length);
|
||||
const hasContext = !!contextData.context;
|
||||
const hasFocusPaths = !!(plan.focus_paths as string[] | undefined)?.length;
|
||||
const hasSummary = !!(plan.summary as string | undefined);
|
||||
const hasAnyContent = hasExplorations || hasDiagnoses || hasContext || hasFocusPaths || hasSummary;
|
||||
|
||||
if (!hasAnyContent) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Package className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'liteTasks.contextPanel.empty' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Explorations Section */}
|
||||
{hasExplorations && (
|
||||
<ContextSection
|
||||
icon={<Compass className="h-4 w-4" />}
|
||||
title={formatMessage({ id: 'liteTasks.contextPanel.explorations' })}
|
||||
badge={
|
||||
contextData.explorations?.manifest?.exploration_count
|
||||
? formatMessage(
|
||||
{ id: 'liteTasks.contextPanel.explorationsCount' },
|
||||
{ count: contextData.explorations.manifest.exploration_count as number }
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{!!contextData.explorations?.manifest?.task_description && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatMessage({ id: 'liteTasks.contextPanel.taskDescription' })}:
|
||||
</span>{' '}
|
||||
{String(contextData.explorations.manifest.task_description)}
|
||||
</div>
|
||||
)}
|
||||
{!!contextData.explorations?.manifest?.complexity && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatMessage({ id: 'liteTasks.contextPanel.complexity' })}:
|
||||
</span>{' '}
|
||||
<Badge variant="info" className="text-[10px]">
|
||||
{String(contextData.explorations.manifest.complexity)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{contextData.explorations?.data && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{Object.keys(contextData.explorations.data).map((angle) => (
|
||||
<Badge key={angle} variant="secondary" className="text-[10px] capitalize">
|
||||
{angle.replace(/-/g, ' ')}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContextSection>
|
||||
)}
|
||||
|
||||
{/* Diagnoses Section */}
|
||||
{hasDiagnoses && (
|
||||
<ContextSection
|
||||
icon={<Stethoscope className="h-4 w-4" />}
|
||||
title={formatMessage({ id: 'liteTasks.contextPanel.diagnoses' })}
|
||||
badge={
|
||||
contextData.diagnoses?.items?.length
|
||||
? formatMessage(
|
||||
{ id: 'liteTasks.contextPanel.diagnosesCount' },
|
||||
{ count: contextData.diagnoses.items.length }
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{contextData.diagnoses?.items?.map((item, i) => (
|
||||
<div key={i} className="text-xs text-muted-foreground py-1 border-b border-border/50 last:border-0">
|
||||
{(item.title as string) || (item.description as string) || `Diagnosis ${i + 1}`}
|
||||
</div>
|
||||
))}
|
||||
</ContextSection>
|
||||
)}
|
||||
|
||||
{/* Context Package Section */}
|
||||
{hasContext && (
|
||||
<ContextSection
|
||||
icon={<Package className="h-4 w-4" />}
|
||||
title={formatMessage({ id: 'liteTasks.contextPanel.contextPackage' })}
|
||||
>
|
||||
<pre className="text-xs text-muted-foreground overflow-auto max-h-48 bg-muted/50 rounded p-2 whitespace-pre-wrap">
|
||||
{JSON.stringify(contextData.context, null, 2)}
|
||||
</pre>
|
||||
</ContextSection>
|
||||
)}
|
||||
|
||||
{/* Focus Paths from Plan */}
|
||||
{hasFocusPaths && (
|
||||
<ContextSection
|
||||
icon={<FolderOpen className="h-4 w-4" />}
|
||||
title={formatMessage({ id: 'liteTasks.contextPanel.focusPaths' })}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(plan.focus_paths as string[]).map((p, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-[10px] font-mono">
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</ContextSection>
|
||||
)}
|
||||
|
||||
{/* Plan Summary */}
|
||||
{hasSummary && (
|
||||
<ContextSection
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
title={formatMessage({ id: 'liteTasks.contextPanel.summary' })}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">{plan.summary as string}</p>
|
||||
</ContextSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ContextSection - Collapsible section wrapper for context items
|
||||
*/
|
||||
function ContextSection({
|
||||
icon,
|
||||
title,
|
||||
badge,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
badge?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
|
||||
return (
|
||||
<Card className="border-border" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 p-3 text-left hover:bg-muted/50 transition-colors"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-sm font-medium text-foreground flex-1">{title}</span>
|
||||
{badge && (
|
||||
<Badge variant="secondary" className="text-[10px]">{badge}</Badge>
|
||||
)}
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<CardContent className="px-3 pb-3 pt-0">
|
||||
{children}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LiteTasksPage component - Display lite-plan and lite-fix sessions with expandable tasks
|
||||
*/
|
||||
@@ -51,6 +398,62 @@ export function LiteTasksPage() {
|
||||
const [activeTab, setActiveTab] = React.useState<LiteTaskTab>('lite-plan');
|
||||
const [expandedSessionId, setExpandedSessionId] = React.useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = React.useState<LiteTask | null>(null);
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [sortField, setSortField] = React.useState<SortField>('date');
|
||||
const [sortOrder, setSortOrder] = React.useState<SortOrder>('desc');
|
||||
|
||||
// Filter and sort sessions
|
||||
const filterAndSort = React.useCallback((sessions: LiteTaskSession[]) => {
|
||||
let filtered = sessions;
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = sessions.filter(session =>
|
||||
session.id.toLowerCase().includes(query) ||
|
||||
session.tasks?.some(task =>
|
||||
task.title?.toLowerCase().includes(query) ||
|
||||
task.task_id?.toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case 'date':
|
||||
comparison = new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime();
|
||||
break;
|
||||
case 'name':
|
||||
comparison = a.id.localeCompare(b.id);
|
||||
break;
|
||||
case 'tasks':
|
||||
comparison = (a.tasks?.length || 0) - (b.tasks?.length || 0);
|
||||
break;
|
||||
}
|
||||
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}, [searchQuery, sortField, sortOrder]);
|
||||
|
||||
// Filtered data
|
||||
const filteredLitePlan = React.useMemo(() => filterAndSort(litePlan), [litePlan, filterAndSort]);
|
||||
const filteredLiteFix = React.useMemo(() => filterAndSort(liteFix), [liteFix, filterAndSort]);
|
||||
const filteredMultiCliPlan = React.useMemo(() => filterAndSort(multiCliPlan), [multiCliPlan, filterAndSort]);
|
||||
|
||||
// Toggle sort
|
||||
const toggleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/sessions');
|
||||
@@ -96,7 +499,7 @@ export function LiteTasksPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-foreground text-sm">{session.id}</h3>
|
||||
<h3 className="font-bold text-foreground text-sm tracking-wide uppercase">{session.id}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={isLitePlan ? 'secondary' : 'warning'} className="gap-1 flex-shrink-0">
|
||||
@@ -104,64 +507,29 @@ export function LiteTasksPage() {
|
||||
{formatMessage({ id: isLitePlan ? 'liteTasks.type.plan' : 'liteTasks.type.fix' })}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{session.createdAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{new Date(session.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<ListChecks className="h-3.5 w-3.5" />
|
||||
{taskCount} {formatMessage({ id: 'session.tasks' })}
|
||||
</span>
|
||||
{taskCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Hash className="h-3.5 w-3.5" />
|
||||
{taskCount} {formatMessage({ id: 'liteTasks.tasksCount' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Expanded tasks list */}
|
||||
{/* Expanded tasks panel with tabs */}
|
||||
{isExpanded && session.tasks && session.tasks.length > 0 && (
|
||||
<div className="mt-2 ml-6 space-y-2 pb-2">
|
||||
{session.tasks.map((task, index) => {
|
||||
const taskStatusColor = task.status === 'completed' ? 'success' :
|
||||
task.status === 'in_progress' ? 'warning' :
|
||||
task.status === 'failed' ? 'destructive' : 'secondary';
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={task.id || index}
|
||||
className="cursor-pointer hover:shadow-sm hover:border-primary/50 transition-all border-border"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedTask(task);
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{task.task_id || `#${index + 1}`}
|
||||
</span>
|
||||
<Badge variant={taskStatusColor as 'success' | 'warning' | 'destructive' | 'secondary'} className="text-xs">
|
||||
{task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{task.title || formatMessage({ id: 'liteTasks.untitled' })}
|
||||
</h4>
|
||||
{task.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ExpandedSessionPanel
|
||||
session={session}
|
||||
onTaskClick={setSelectedTask}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -195,7 +563,7 @@ export function LiteTasksPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-foreground text-sm">{session.id}</h3>
|
||||
<h3 className="font-bold text-foreground text-sm tracking-wide uppercase">{session.id}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="gap-1 flex-shrink-0">
|
||||
@@ -308,6 +676,58 @@ export function LiteTasksPage() {
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Search and Sort Toolbar */}
|
||||
<div className="mt-4 flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={formatMessage({ id: 'liteTasks.searchPlaceholder' })}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 text-sm rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<ListFilter className="h-3.5 w-3.5" />
|
||||
{formatMessage({ id: 'liteTasks.sortBy' })}:
|
||||
</span>
|
||||
<Button
|
||||
variant={sortField === 'date' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => toggleSort('date')}
|
||||
className="h-8 px-3 text-xs gap-1"
|
||||
>
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{formatMessage({ id: 'liteTasks.sort.date' })}
|
||||
{sortField === 'date' && (sortOrder === 'desc' ? <SortDesc className="h-3 w-3" /> : <SortAsc className="h-3 w-3" />)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortField === 'name' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => toggleSort('name')}
|
||||
className="h-8 px-3 text-xs gap-1"
|
||||
>
|
||||
{formatMessage({ id: 'liteTasks.sort.name' })}
|
||||
{sortField === 'name' && (sortOrder === 'desc' ? <SortDesc className="h-3 w-3" /> : <SortAsc className="h-3 w-3" />)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortField === 'tasks' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => toggleSort('tasks')}
|
||||
className="h-8 px-3 text-xs gap-1"
|
||||
>
|
||||
<Hash className="h-3.5 w-3.5" />
|
||||
{formatMessage({ id: 'liteTasks.sort.tasks' })}
|
||||
{sortField === 'tasks' && (sortOrder === 'desc' ? <SortDesc className="h-3 w-3" /> : <SortAsc className="h-3 w-3" />)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lite Plan Tab */}
|
||||
<TabsContent value="lite-plan" className="mt-4">
|
||||
{litePlan.length === 0 ? (
|
||||
@@ -320,8 +740,18 @@ export function LiteTasksPage() {
|
||||
{formatMessage({ id: 'liteTasks.empty.message' })}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredLitePlan.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'liteTasks.noResults.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'liteTasks.noResults.message' })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">{litePlan.map(renderLiteTaskCard)}</div>
|
||||
<div className="grid gap-3">{filteredLitePlan.map(renderLiteTaskCard)}</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
@@ -337,8 +767,18 @@ export function LiteTasksPage() {
|
||||
{formatMessage({ id: 'liteTasks.empty.message' })}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredLiteFix.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'liteTasks.noResults.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'liteTasks.noResults.message' })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">{liteFix.map(renderLiteTaskCard)}</div>
|
||||
<div className="grid gap-3">{filteredLiteFix.map(renderLiteTaskCard)}</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
@@ -354,8 +794,18 @@ export function LiteTasksPage() {
|
||||
{formatMessage({ id: 'liteTasks.empty.message' })}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredMultiCliPlan.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{formatMessage({ id: 'liteTasks.noResults.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'liteTasks.noResults.message' })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">{multiCliPlan.map(renderMultiCliCard)}</div>
|
||||
<div className="grid gap-3">{filteredMultiCliPlan.map(renderMultiCliCard)}</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -7,7 +7,6 @@ import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Filter,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useSessions,
|
||||
useCreateSession,
|
||||
useArchiveSession,
|
||||
useDeleteSession,
|
||||
type SessionsFilter,
|
||||
@@ -61,15 +59,9 @@ export function SessionsPage() {
|
||||
const [statusFilter, setStatusFilter] = React.useState<SessionMetadata['status'][]>([]);
|
||||
|
||||
// Dialog state
|
||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
||||
const [sessionToDelete, setSessionToDelete] = React.useState<string | null>(null);
|
||||
|
||||
// Create session form state
|
||||
const [newSessionId, setNewSessionId] = React.useState('');
|
||||
const [newSessionTitle, setNewSessionTitle] = React.useState('');
|
||||
const [newSessionDescription, setNewSessionDescription] = React.useState('');
|
||||
|
||||
// Build filter object
|
||||
const filter: SessionsFilter = React.useMemo(
|
||||
() => ({
|
||||
@@ -90,39 +82,16 @@ export function SessionsPage() {
|
||||
} = useSessions({ filter });
|
||||
|
||||
// Mutations
|
||||
const { createSession, isCreating } = useCreateSession();
|
||||
const { archiveSession, isArchiving } = useArchiveSession();
|
||||
const { deleteSession, isDeleting } = useDeleteSession();
|
||||
|
||||
const isMutating = isCreating || isArchiving || isDeleting;
|
||||
const isMutating = isArchiving || isDeleting;
|
||||
|
||||
// Handlers
|
||||
const handleSessionClick = (sessionId: string) => {
|
||||
navigate(`/sessions/${sessionId}`);
|
||||
};
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
if (!newSessionId.trim()) return;
|
||||
|
||||
try {
|
||||
await createSession({
|
||||
session_id: newSessionId.trim(),
|
||||
title: newSessionTitle.trim() || undefined,
|
||||
description: newSessionDescription.trim() || undefined,
|
||||
});
|
||||
setCreateDialogOpen(false);
|
||||
resetCreateForm();
|
||||
} catch (err) {
|
||||
console.error('Failed to create session:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
setNewSessionId('');
|
||||
setNewSessionTitle('');
|
||||
setNewSessionDescription('');
|
||||
};
|
||||
|
||||
const handleArchive = async (sessionId: string) => {
|
||||
try {
|
||||
await archiveSession(sessionId);
|
||||
@@ -185,10 +154,6 @@ export function SessionsPage() {
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'common.actions.new' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -325,15 +290,10 @@ export function SessionsPage() {
|
||||
? formatMessage({ id: 'sessions.emptyState.message' })
|
||||
: formatMessage({ id: 'sessions.emptyState.createFirst' })}
|
||||
</p>
|
||||
{hasActiveFilters ? (
|
||||
{hasActiveFilters && (
|
||||
<Button variant="outline" onClick={clearFilters}>
|
||||
{formatMessage({ id: 'common.actions.clearFilters' })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'common.actions.new' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -352,70 +312,6 @@ export function SessionsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Session Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formatMessage({ id: 'common.dialog.createSession' })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatMessage({ id: 'common.dialog.createSessionDesc' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="sessionId" className="text-sm font-medium">
|
||||
{formatMessage({ id: 'common.form.sessionId' })} <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="sessionId"
|
||||
placeholder={formatMessage({ id: 'common.form.sessionIdPlaceholder' })}
|
||||
value={newSessionId}
|
||||
onChange={(e) => setNewSessionId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="sessionTitle" className="text-sm font-medium">
|
||||
{formatMessage({ id: 'common.form.title' })} ({formatMessage({ id: 'common.form.optional' })})
|
||||
</label>
|
||||
<Input
|
||||
id="sessionTitle"
|
||||
placeholder={formatMessage({ id: 'common.form.titlePlaceholder' })}
|
||||
value={newSessionTitle}
|
||||
onChange={(e) => setNewSessionTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="sessionDescription" className="text-sm font-medium">
|
||||
{formatMessage({ id: 'common.form.description' })} ({formatMessage({ id: 'common.form.optional' })})
|
||||
</label>
|
||||
<Input
|
||||
id="sessionDescription"
|
||||
placeholder={formatMessage({ id: 'common.form.descriptionPlaceholder' })}
|
||||
value={newSessionDescription}
|
||||
onChange={(e) => setNewSessionDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setCreateDialogOpen(false);
|
||||
resetCreateForm();
|
||||
}}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateSession}
|
||||
disabled={!newSessionId.trim() || isCreating}
|
||||
>
|
||||
{isCreating ? formatMessage({ id: 'common.status.creating' }) : formatMessage({ id: 'common.actions.create' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -13,12 +13,28 @@ import {
|
||||
Power,
|
||||
PowerOff,
|
||||
Tag,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
EyeOff,
|
||||
List,
|
||||
Grid3x3,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
} from '@/components/ui';
|
||||
import { SkillCard } from '@/components/shared/SkillCard';
|
||||
import { LocationSwitcher } from '@/components/commands/LocationSwitcher';
|
||||
import { useSkills, useSkillMutations } from '@/hooks';
|
||||
import type { Skill } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -90,12 +106,17 @@ export function SkillsManagerPage() {
|
||||
const [sourceFilter, setSourceFilter] = useState<string>('all');
|
||||
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'compact'>('grid');
|
||||
const [showDisabledSection, setShowDisabledSection] = useState(false);
|
||||
const [confirmDisable, setConfirmDisable] = useState<{ skill: Skill; enable: boolean } | null>(null);
|
||||
const [locationFilter, setLocationFilter] = useState<'project' | 'user'>('project');
|
||||
|
||||
const {
|
||||
skills,
|
||||
categories,
|
||||
totalCount,
|
||||
enabledCount,
|
||||
projectSkills,
|
||||
userSkills,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
@@ -105,11 +126,15 @@ export function SkillsManagerPage() {
|
||||
category: categoryFilter !== 'all' ? categoryFilter : undefined,
|
||||
source: sourceFilter !== 'all' ? sourceFilter as Skill['source'] : undefined,
|
||||
enabledOnly: enabledFilter === 'enabled',
|
||||
location: locationFilter,
|
||||
},
|
||||
});
|
||||
|
||||
const { toggleSkill, isToggling } = useSkillMutations();
|
||||
|
||||
// Calculate disabled count
|
||||
const disabledCount = totalCount - enabledCount;
|
||||
|
||||
// Filter skills based on enabled filter
|
||||
const filteredSkills = useMemo(() => {
|
||||
if (enabledFilter === 'disabled') {
|
||||
@@ -119,32 +144,63 @@ export function SkillsManagerPage() {
|
||||
}, [skills, enabledFilter]);
|
||||
|
||||
const handleToggle = async (skill: Skill, enabled: boolean) => {
|
||||
await toggleSkill(skill.name, enabled);
|
||||
// Use the skill's location property
|
||||
const location = skill.location || 'project';
|
||||
await toggleSkill(skill.name, enabled, location);
|
||||
};
|
||||
|
||||
const handleToggleWithConfirm = (skill: Skill, enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
// Show confirmation dialog when disabling
|
||||
setConfirmDisable({ skill, enable: false });
|
||||
} else {
|
||||
// Enable directly without confirmation
|
||||
handleToggle(skill, true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDisable = async () => {
|
||||
if (confirmDisable) {
|
||||
await handleToggle(confirmDisable.skill, false);
|
||||
setConfirmDisable(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6 text-primary" />
|
||||
{formatMessage({ id: 'skills.title' })}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'skills.description' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'skills.actions.install' })}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6 text-primary" />
|
||||
{formatMessage({ id: 'skills.title' })}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'skills.description' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'skills.actions.install' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location Switcher */}
|
||||
<LocationSwitcher
|
||||
currentLocation={locationFilter}
|
||||
onLocationChange={setLocationFilter}
|
||||
projectCount={projectSkills.length}
|
||||
userCount={userSkills.length}
|
||||
disabled={isToggling}
|
||||
translationPrefix="skills"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
@@ -229,34 +285,39 @@ export function SkillsManagerPage() {
|
||||
{/* Quick Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={enabledFilter === 'all' ? 'default' : 'outline'}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEnabledFilter('all')}
|
||||
className={enabledFilter === 'all' ? 'bg-primary text-primary-foreground' : ''}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'skills.filters.all' })} ({totalCount})
|
||||
</Button>
|
||||
<Button
|
||||
variant={enabledFilter === 'enabled' ? 'default' : 'outline'}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEnabledFilter('enabled')}
|
||||
className={enabledFilter === 'enabled' ? 'bg-primary text-primary-foreground' : ''}
|
||||
>
|
||||
<Power className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'skills.state.enabled' })} ({enabledCount})
|
||||
</Button>
|
||||
<Button
|
||||
variant={enabledFilter === 'disabled' ? 'default' : 'outline'}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEnabledFilter('disabled')}
|
||||
className={enabledFilter === 'disabled' ? 'bg-primary text-primary-foreground' : ''}
|
||||
>
|
||||
<PowerOff className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'skills.state.disabled' })} ({totalCount - enabledCount})
|
||||
{formatMessage({ id: 'skills.state.disabled' })} ({disabledCount})
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'compact' : 'grid')}
|
||||
>
|
||||
{viewMode === 'grid' ? <List className="w-4 h-4 mr-1" /> : <Grid3x3 className="w-4 h-4 mr-1" />}
|
||||
{formatMessage({ id: viewMode === 'grid' ? 'skills.view.compact' : 'skills.view.grid' })}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -265,11 +326,58 @@ export function SkillsManagerPage() {
|
||||
<SkillGrid
|
||||
skills={filteredSkills}
|
||||
isLoading={isLoading}
|
||||
onToggle={handleToggle}
|
||||
onToggle={handleToggleWithConfirm}
|
||||
onClick={() => {}}
|
||||
isToggling={isToggling}
|
||||
compact={viewMode === 'compact'}
|
||||
/>
|
||||
|
||||
{/* Disabled Skills Section */}
|
||||
{enabledFilter === 'all' && disabledCount > 0 && (
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowDisabledSection(!showDisabledSection)}
|
||||
className="mb-4 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showDisabledSection ? <ChevronDown className="w-4 h-4 mr-2" /> : <ChevronRight className="w-4 h-4 mr-2" />}
|
||||
<EyeOff className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'skills.disabledSkills.title' })} ({disabledCount})
|
||||
</Button>
|
||||
|
||||
{showDisabledSection && (
|
||||
<SkillGrid
|
||||
skills={skills.filter((s) => !s.enabled)}
|
||||
isLoading={false}
|
||||
onToggle={handleToggleWithConfirm}
|
||||
onClick={() => {}}
|
||||
isToggling={isToggling}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable Confirmation Dialog */}
|
||||
<AlertDialog open={!!confirmDisable} onOpenChange={(open) => !open && setConfirmDisable(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{formatMessage({ id: 'skills.disableConfirm.title' })}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{formatMessage(
|
||||
{ id: 'skills.disableConfirm.message' },
|
||||
{ name: confirmDisable?.skill.name || '' }
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{formatMessage({ id: 'skills.actions.cancel' })}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmDisable} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
{formatMessage({ id: 'skills.actions.confirmDisable' })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,29 +71,14 @@ export function SummaryTab({ summary, summaries }: SummaryTabProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{summaryList.length === 1 ? (
|
||||
// Single summary - inline display
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
{summaryList[0].name}
|
||||
</h3>
|
||||
<div className="prose prose-sm max-w-none text-foreground">
|
||||
<p className="whitespace-pre-wrap">{summaryList[0].content}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
// Multiple summaries - card list with modal viewer
|
||||
summaryList.map((item, index) => (
|
||||
<SummaryCard
|
||||
key={index}
|
||||
summary={item}
|
||||
onClick={() => setSelectedSummary(item)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{/* Always use truncated card display with modal viewer */}
|
||||
{summaryList.map((item, index) => (
|
||||
<SummaryCard
|
||||
key={index}
|
||||
summary={item}
|
||||
onClick={() => setSelectedSummary(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Modal Viewer */}
|
||||
@@ -119,24 +104,21 @@ interface SummaryCardProps {
|
||||
|
||||
function SummaryCard({ summary, onClick }: SummaryCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Get preview (first 3 lines)
|
||||
|
||||
// Get preview (first 5 lines, matching ImplPlanTab style)
|
||||
const lines = summary.content.split('\n');
|
||||
const preview = lines.slice(0, 3).join('\n');
|
||||
const hasMore = lines.length > 3;
|
||||
const preview = lines.slice(0, 5).join('\n');
|
||||
const hasMore = lines.length > 5;
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={onClick}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<FileText className="w-5 h-5" />
|
||||
{summary.name}
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Button variant="outline" size="sm" onClick={onClick}>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'common.actions.view' })}
|
||||
</Button>
|
||||
@@ -147,10 +129,15 @@ function SummaryCard({ summary, onClick }: SummaryCardProps) {
|
||||
{preview}{hasMore && '\n...'}
|
||||
</pre>
|
||||
{hasMore && (
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary">
|
||||
{lines.length} {formatMessage({ id: 'sessionDetail.summary.lines' })}
|
||||
</Badge>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className="w-full"
|
||||
>
|
||||
{formatMessage({ id: 'sessionDetail.summary.viewFull' }, { count: lines.length })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -8,16 +8,31 @@ import { useIntl } from 'react-intl';
|
||||
import {
|
||||
ListChecks,
|
||||
Code,
|
||||
GitBranch,
|
||||
Zap,
|
||||
Calendar,
|
||||
FileCode,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { TaskStatsBar, TaskStatusDropdown } from '@/components/session-detail/tasks';
|
||||
import type { SessionMetadata, TaskData } from '@/types/store';
|
||||
import type { TaskStatus } from '@/lib/api';
|
||||
import type { TaskStatus, FlowControl } from '@/lib/api';
|
||||
import { bulkUpdateTaskStatus, updateTaskStatus } from '@/lib/api';
|
||||
|
||||
export interface TaskListTabProps {
|
||||
session: SessionMetadata;
|
||||
onTaskClick?: (task: TaskData) => void;
|
||||
// Extended task type with all possible fields from JSON
|
||||
interface ExtendedTask extends TaskData {
|
||||
meta?: {
|
||||
type?: string;
|
||||
scope?: string;
|
||||
};
|
||||
context?: {
|
||||
focus_paths?: string[];
|
||||
acceptance?: string[];
|
||||
depends_on?: string[];
|
||||
};
|
||||
flow_control?: FlowControl;
|
||||
}
|
||||
|
||||
export interface TaskListTabProps {
|
||||
@@ -52,62 +67,72 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
|
||||
// Get session path for API calls
|
||||
const sessionPath = (session as any).path || session.session_id;
|
||||
|
||||
// Bulk action handlers
|
||||
// Bulk action handlers - mark ALL tasks (not just filtered ones) to the target status
|
||||
const handleMarkAllPending = async () => {
|
||||
const targetTasks = localTasks.filter((t) => t.status === 'pending');
|
||||
// Mark all non-pending tasks as pending
|
||||
const targetTasks = localTasks.filter((t) => t.status !== 'pending');
|
||||
if (targetTasks.length === 0) return;
|
||||
|
||||
setIsLoadingPending(true);
|
||||
// Optimistic update
|
||||
setLocalTasks((prev) => prev.map((t) => ({ ...t, status: 'pending' as const })));
|
||||
try {
|
||||
const taskIds = targetTasks.map((t) => t.task_id);
|
||||
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'pending');
|
||||
if (result.success) {
|
||||
// Optimistic update - will be refreshed when parent re-renders
|
||||
} else {
|
||||
if (!result.success) {
|
||||
console.error('[TaskListTab] Failed to mark all as pending:', result.error);
|
||||
// Rollback on error
|
||||
setLocalTasks(tasks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TaskListTab] Failed to mark all as pending:', error);
|
||||
setLocalTasks(tasks);
|
||||
} finally {
|
||||
setIsLoadingPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllInProgress = async () => {
|
||||
const targetTasks = localTasks.filter((t) => t.status === 'in_progress');
|
||||
// Mark all non-in_progress tasks as in_progress
|
||||
const targetTasks = localTasks.filter((t) => t.status !== 'in_progress');
|
||||
if (targetTasks.length === 0) return;
|
||||
|
||||
setIsLoadingInProgress(true);
|
||||
// Optimistic update
|
||||
setLocalTasks((prev) => prev.map((t) => ({ ...t, status: 'in_progress' as const })));
|
||||
try {
|
||||
const taskIds = targetTasks.map((t) => t.task_id);
|
||||
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'in_progress');
|
||||
if (result.success) {
|
||||
// Optimistic update - will be refreshed when parent re-renders
|
||||
} else {
|
||||
if (!result.success) {
|
||||
console.error('[TaskListTab] Failed to mark all as in_progress:', result.error);
|
||||
setLocalTasks(tasks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TaskListTab] Failed to mark all as in_progress:', error);
|
||||
setLocalTasks(tasks);
|
||||
} finally {
|
||||
setIsLoadingInProgress(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllCompleted = async () => {
|
||||
const targetTasks = localTasks.filter((t) => t.status === 'completed');
|
||||
// Mark all non-completed tasks as completed
|
||||
const targetTasks = localTasks.filter((t) => t.status !== 'completed');
|
||||
if (targetTasks.length === 0) return;
|
||||
|
||||
setIsLoadingCompleted(true);
|
||||
// Optimistic update
|
||||
setLocalTasks((prev) => prev.map((t) => ({ ...t, status: 'completed' as const })));
|
||||
try {
|
||||
const taskIds = targetTasks.map((t) => t.task_id);
|
||||
const result = await bulkUpdateTaskStatus(sessionPath, taskIds, 'completed');
|
||||
if (result.success) {
|
||||
// Optimistic update - will be refreshed when parent re-renders
|
||||
} else {
|
||||
if (!result.success) {
|
||||
console.error('[TaskListTab] Failed to mark all as completed:', result.error);
|
||||
setLocalTasks(tasks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TaskListTab] Failed to mark all as completed:', error);
|
||||
setLocalTasks(tasks);
|
||||
} finally {
|
||||
setIsLoadingCompleted(false);
|
||||
}
|
||||
@@ -170,6 +195,32 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{localTasks.map((task, index) => {
|
||||
// Cast to extended type to access all possible fields
|
||||
const extTask = task as unknown as ExtendedTask;
|
||||
|
||||
// Priority config
|
||||
const priorityConfig: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'warning' | 'info' }> = {
|
||||
critical: { label: formatMessage({ id: 'sessionDetail.tasks.priority.critical' }), variant: 'destructive' },
|
||||
high: { label: formatMessage({ id: 'sessionDetail.tasks.priority.high' }), variant: 'warning' },
|
||||
medium: { label: formatMessage({ id: 'sessionDetail.tasks.priority.medium' }), variant: 'info' },
|
||||
low: { label: formatMessage({ id: 'sessionDetail.tasks.priority.low' }), variant: 'secondary' },
|
||||
};
|
||||
const priority = extTask.priority ? priorityConfig[extTask.priority] : null;
|
||||
|
||||
// Get depends_on from either root level or context
|
||||
const dependsOn = extTask.depends_on || extTask.context?.depends_on || [];
|
||||
const dependsCount = dependsOn.length;
|
||||
|
||||
// Get meta info
|
||||
const taskType = extTask.meta?.type;
|
||||
const taskScope = extTask.meta?.scope;
|
||||
|
||||
// Get implementation steps count from flow_control
|
||||
const stepsCount = extTask.flow_control?.implementation_approach?.length || 0;
|
||||
|
||||
// Get target files count
|
||||
const filesCount = extTask.flow_control?.target_files?.length || 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={task.task_id || index}
|
||||
@@ -177,22 +228,13 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
|
||||
onClick={() => onTaskClick?.(task as TaskData)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* Left: Task ID, Title, Description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono font-semibold bg-primary/10 text-primary border border-primary/20">
|
||||
{task.task_id}
|
||||
</span>
|
||||
<TaskStatusDropdown
|
||||
currentStatus={task.status as TaskStatus}
|
||||
onStatusChange={(newStatus) => handleTaskStatusChange(task.task_id, newStatus)}
|
||||
size="sm"
|
||||
/>
|
||||
{task.priority && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{task.priority}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground text-sm">
|
||||
{task.title || formatMessage({ id: 'sessionDetail.tasks.untitled' })}
|
||||
@@ -202,18 +244,63 @@ export function TaskListTab({ session, onTaskClick }: TaskListTabProps) {
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
{task.depends_on && task.depends_on.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
|
||||
<Code className="h-3 w-3" />
|
||||
<span>Depends on: {task.depends_on.join(', ')}</span>
|
||||
</div>
|
||||
|
||||
{/* Right: Status and Meta info */}
|
||||
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
{/* Row 1: Status dropdown */}
|
||||
<TaskStatusDropdown
|
||||
currentStatus={task.status as TaskStatus}
|
||||
onStatusChange={(newStatus) => handleTaskStatusChange(task.task_id, newStatus)}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{/* Row 2: Meta info */}
|
||||
<div className="flex items-center gap-3 flex-wrap justify-end text-xs text-muted-foreground">
|
||||
{priority && (
|
||||
<Badge variant={priority.variant} className="text-xs gap-1">
|
||||
<Zap className="h-3 w-3" />
|
||||
{priority.label}
|
||||
</Badge>
|
||||
)}
|
||||
{taskType && (
|
||||
<span className="bg-muted px-1.5 py-0.5 rounded">{taskType}</span>
|
||||
)}
|
||||
{stepsCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Layers className="h-3 w-3" />
|
||||
{stepsCount} {formatMessage({ id: 'sessionDetail.tasks.steps' })}
|
||||
</span>
|
||||
)}
|
||||
{filesCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FileCode className="h-3 w-3" />
|
||||
{filesCount} {formatMessage({ id: 'sessionDetail.tasks.files' })}
|
||||
</span>
|
||||
)}
|
||||
{dependsCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{dependsCount} {formatMessage({ id: 'sessionDetail.tasks.deps' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 3: Scope or Date */}
|
||||
{(taskScope || task.created_at) && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{taskScope && (
|
||||
<span className="bg-muted px-1.5 py-0.5 rounded">{taskScope}</span>
|
||||
)}
|
||||
{task.created_at && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(task.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{task.created_at && (
|
||||
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(task.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user