mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat: enhance issue management with edit functionality and UI improvements
- Added Edit Issue dialog to allow users to modify existing issues with fields for title, context, priority, and status. - Integrated form validation and state management for the edit dialog. - Updated the IssueManagerPage to handle opening and closing of the edit dialog, as well as submitting updates. - Improved UI components in SolutionDrawer and ExplorationSection for better user experience. - Refactored file path input with a browse dialog for selecting files and directories in SettingsPage. - Adjusted layout and styling in LeftSidebar and OrchestratorPage for better responsiveness and usability. - Updated localization files to include new strings for the edit dialog and task management.
This commit is contained in:
@@ -3,12 +3,13 @@
|
||||
// ========================================
|
||||
// Right-side solution detail drawer
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { X, FileText, CheckCircle, Circle, Loader2, XCircle, Clock, AlertTriangle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { useIssueQueue } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { QueueItem } from '@/lib/api';
|
||||
|
||||
@@ -36,17 +37,34 @@ const statusConfig: Record<string, { label: string; variant: 'default' | 'second
|
||||
export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('overview');
|
||||
const { data: queue } = useIssueQueue();
|
||||
const itemId = item?.item_id;
|
||||
const solutionId = item?.solution_id;
|
||||
|
||||
// ESC key to close
|
||||
useState(() => {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
});
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Reset tab when switching items
|
||||
useEffect(() => {
|
||||
if (!isOpen || !itemId) return;
|
||||
setActiveTab('overview');
|
||||
}, [itemId, isOpen]);
|
||||
|
||||
const tasksForSolution = useMemo(() => {
|
||||
if (!solutionId) return [];
|
||||
const allItems = Object.values(queue?.grouped_items || {}).flat();
|
||||
const isTaskItem = (qi: QueueItem) => Boolean(qi.task_id) || qi.item_id.startsWith('task-');
|
||||
return allItems
|
||||
.filter((qi) => qi.solution_id === solutionId && isTaskItem(qi))
|
||||
.sort((a, b) => a.execution_order - b.execution_order);
|
||||
}, [queue?.grouped_items, solutionId]);
|
||||
|
||||
if (!item || !isOpen) {
|
||||
return null;
|
||||
@@ -56,7 +74,6 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
// Get solution details (would need to fetch full solution data)
|
||||
const solutionId = item.solution_id;
|
||||
const issueId = item.issue_id;
|
||||
|
||||
return (
|
||||
@@ -93,10 +110,10 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'solution.issue' })}: <span className="font-mono">{issueId}</span>
|
||||
{formatMessage({ id: 'issues.solution.issue' })}: <span className="font-mono">{issueId}</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'solution.solution' })}: <span className="font-mono">{solutionId}</span>
|
||||
{formatMessage({ id: 'issues.solution.solution' })}: <span className="font-mono">{solutionId}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,15 +128,15 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="overview" className="flex-1">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'solution.tabs.overview' })}
|
||||
{formatMessage({ id: 'issues.solution.tabs.overview' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tasks" className="flex-1">
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'solution.tabs.tasks' })}
|
||||
{formatMessage({ id: 'issues.solution.tabs.tasks' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="flex-1">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'solution.tabs.json' })}
|
||||
{formatMessage({ id: 'issues.solution.tabs.json' })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -131,23 +148,23 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
{/* Execution Info */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
||||
{formatMessage({ id: 'solution.overview.executionInfo' })}
|
||||
{formatMessage({ id: 'issues.solution.overview.executionInfo' })}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'solution.overview.executionOrder' })}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.solution.overview.executionOrder' })}</p>
|
||||
<p className="text-lg font-semibold">{item.execution_order}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'solution.overview.semanticPriority' })}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.solution.overview.semanticPriority' })}</p>
|
||||
<p className="text-lg font-semibold">{item.semantic_priority}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'solution.overview.group' })}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.solution.overview.group' })}</p>
|
||||
<p className="text-sm font-mono truncate">{item.execution_group}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-muted/50 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'solution.overview.taskCount' })}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'issues.solution.overview.taskCount' })}</p>
|
||||
<p className="text-lg font-semibold">{item.task_count || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +174,7 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
{item.depends_on && item.depends_on.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'solution.overview.dependencies' })}
|
||||
{formatMessage({ id: 'issues.solution.overview.dependencies' })}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.depends_on.map((dep, index) => (
|
||||
@@ -173,7 +190,7 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
{item.files_touched && item.files_touched.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'solution.overview.filesTouched' })}
|
||||
{formatMessage({ id: 'issues.solution.overview.filesTouched' })}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{item.files_touched.map((file, index) => (
|
||||
@@ -189,10 +206,42 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
|
||||
|
||||
{/* Tasks Tab */}
|
||||
<TabsContent value="tasks" className="mt-4 pb-6 focus-visible:outline-none">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{formatMessage({ id: 'solution.tasks.comingSoon' })}</p>
|
||||
</div>
|
||||
{tasksForSolution.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{formatMessage({ id: 'issues.solution.tasks.empty' })}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasksForSolution.map((task) => {
|
||||
const taskStatus = statusConfig[task.status] || statusConfig.pending;
|
||||
const TaskStatusIcon = taskStatus.icon;
|
||||
const taskId = task.task_id || task.item_id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.item_id}
|
||||
className="flex items-center justify-between gap-3 p-3 bg-muted/50 rounded-md"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-mono truncate">{taskId}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatMessage({ id: 'issues.solution.overview.executionOrder' })}: {task.execution_order}
|
||||
{' · '}
|
||||
{formatMessage({ id: 'issues.solution.overview.group' })}: {task.execution_group}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={taskStatus.variant} className="gap-1 shrink-0">
|
||||
<TaskStatusIcon
|
||||
className={cn('h-3 w-3', task.status === 'executing' && 'animate-spin')}
|
||||
/>
|
||||
{formatMessage({ id: taskStatus.label })}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* JSON Tab */}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ExplorationCollapsible({
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full p-3 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<span className="font-medium text-foreground">{title}</span>
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
FileText,
|
||||
Layers
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { ExplorationCollapsible } from './ExplorationCollapsible';
|
||||
import { FieldRenderer } from './FieldRenderer';
|
||||
|
||||
export interface ExplorationsData {
|
||||
manifest: {
|
||||
@@ -51,16 +51,14 @@ export function ExplorationsSection({ data }: ExplorationsSectionProps) {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="w-5 h-5" />
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-4 flex items-center gap-2">
|
||||
<Search className="w-4 h-4" />
|
||||
{formatMessage({ id: 'sessionDetail.context.explorations.title' })}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
({data.manifest.exploration_count} {formatMessage({ id: 'sessionDetail.context.explorations.angles' })})
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="secondary">
|
||||
{data.manifest.exploration_count} {formatMessage({ id: 'sessionDetail.context.explorations.angles' })}
|
||||
</Badge>
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{explorationEntries.map(([angle, angleData]) => (
|
||||
<ExplorationCollapsible
|
||||
@@ -79,15 +77,28 @@ export function ExplorationsSection({ data }: ExplorationsSectionProps) {
|
||||
|
||||
interface AngleContentProps {
|
||||
data: {
|
||||
project_structure?: string[];
|
||||
relevant_files?: string[];
|
||||
patterns?: string[];
|
||||
dependencies?: string[];
|
||||
integration_points?: string[];
|
||||
testing?: string[];
|
||||
project_structure?: unknown;
|
||||
relevant_files?: unknown;
|
||||
patterns?: unknown;
|
||||
dependencies?: unknown;
|
||||
integration_points?: unknown;
|
||||
testing?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/** Check if a string looks like a file/directory path */
|
||||
function isPathLike(s: string): boolean {
|
||||
return /^[\w@.~\-/\\]+(\/[\w@.\-]+)+(\.\w+)?$/.test(s.trim());
|
||||
}
|
||||
|
||||
/** Safely coerce a field to string[] – handles string, array-of-non-strings, etc. */
|
||||
function toStringArray(val: unknown): string[] {
|
||||
if (Array.isArray(val)) return val.map(String);
|
||||
if (typeof val === 'string' && val.length > 0) return [val];
|
||||
return [];
|
||||
}
|
||||
|
||||
function AngleContent({ data }: AngleContentProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
@@ -95,60 +106,73 @@ function AngleContent({ data }: AngleContentProps) {
|
||||
key: string;
|
||||
icon: JSX.Element;
|
||||
label: string;
|
||||
data: unknown;
|
||||
items: string[];
|
||||
renderAs: 'paths' | 'text';
|
||||
}> = [];
|
||||
|
||||
if (data.project_structure && data.project_structure.length > 0) {
|
||||
const projectStructure = toStringArray(data.project_structure);
|
||||
if (projectStructure.length > 0) {
|
||||
sections.push({
|
||||
key: 'project_structure',
|
||||
icon: <FolderOpen className="w-4 h-4" />,
|
||||
label: formatMessage({ id: 'sessionDetail.context.explorations.projectStructure' }),
|
||||
data: data.project_structure,
|
||||
items: projectStructure,
|
||||
renderAs: 'paths',
|
||||
});
|
||||
}
|
||||
|
||||
if (data.relevant_files && data.relevant_files.length > 0) {
|
||||
const relevantFiles = toStringArray(data.relevant_files);
|
||||
if (relevantFiles.length > 0) {
|
||||
sections.push({
|
||||
key: 'relevant_files',
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
label: formatMessage({ id: 'sessionDetail.context.explorations.relevantFiles' }),
|
||||
data: data.relevant_files,
|
||||
items: relevantFiles,
|
||||
renderAs: 'paths',
|
||||
});
|
||||
}
|
||||
|
||||
if (data.patterns && data.patterns.length > 0) {
|
||||
const patterns = toStringArray(data.patterns);
|
||||
if (patterns.length > 0) {
|
||||
sections.push({
|
||||
key: 'patterns',
|
||||
icon: <Layers className="w-4 h-4" />,
|
||||
label: formatMessage({ id: 'sessionDetail.context.explorations.patterns' }),
|
||||
data: data.patterns,
|
||||
items: patterns,
|
||||
renderAs: 'text',
|
||||
});
|
||||
}
|
||||
|
||||
if (data.dependencies && data.dependencies.length > 0) {
|
||||
const dependencies = toStringArray(data.dependencies);
|
||||
if (dependencies.length > 0) {
|
||||
sections.push({
|
||||
key: 'dependencies',
|
||||
icon: <GitBranch className="w-4 h-4" />,
|
||||
label: formatMessage({ id: 'sessionDetail.context.explorations.dependencies' }),
|
||||
data: data.dependencies,
|
||||
items: dependencies,
|
||||
renderAs: 'text',
|
||||
});
|
||||
}
|
||||
|
||||
if (data.integration_points && data.integration_points.length > 0) {
|
||||
const integrationPoints = toStringArray(data.integration_points);
|
||||
if (integrationPoints.length > 0) {
|
||||
sections.push({
|
||||
key: 'integration_points',
|
||||
icon: <Link className="w-4 h-4" />,
|
||||
label: formatMessage({ id: 'sessionDetail.context.explorations.integrationPoints' }),
|
||||
data: data.integration_points,
|
||||
items: integrationPoints,
|
||||
renderAs: 'text',
|
||||
});
|
||||
}
|
||||
|
||||
if (data.testing && data.testing.length > 0) {
|
||||
const testing = toStringArray(data.testing);
|
||||
if (testing.length > 0) {
|
||||
sections.push({
|
||||
key: 'testing',
|
||||
icon: <TestTube className="w-4 h-4" />,
|
||||
label: formatMessage({ id: 'sessionDetail.context.explorations.testing' }),
|
||||
data: data.testing,
|
||||
items: testing,
|
||||
renderAs: 'text',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,22 +181,78 @@ function AngleContent({ data }: AngleContentProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{sections.map((section) => (
|
||||
<div key={section.key} className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground mt-0.5">{section.icon}</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">
|
||||
{section.label}
|
||||
</p>
|
||||
<FieldRenderer value={section.data} type="array" />
|
||||
</div>
|
||||
<div key={section.key}>
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1.5 flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">{section.icon}</span>
|
||||
{section.label}
|
||||
</h4>
|
||||
{section.renderAs === 'paths' ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{section.items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-muted rounded border text-[11px] font-mono text-foreground"
|
||||
>
|
||||
<FileText className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item, i) => (
|
||||
<li key={i} className="flex items-start gap-1.5 text-xs text-foreground">
|
||||
<span className="text-muted-foreground mt-0.5">•</span>
|
||||
<span className="flex-1">
|
||||
<FormattedTextItem text={item} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Render text with inline code/path highlighting */
|
||||
function FormattedTextItem({ text }: { text: string }) {
|
||||
// Split on backtick-wrapped or path-like segments
|
||||
const parts = text.split(/(`[^`]+`)/g);
|
||||
if (parts.length === 1) {
|
||||
// No backtick segments, check for embedded paths
|
||||
const pathParts = text.split(/(\S+\/\S+\.\w+)/g);
|
||||
if (pathParts.length === 1) return <>{text}</>;
|
||||
return (
|
||||
<>
|
||||
{pathParts.map((part, i) =>
|
||||
isPathLike(part) ? (
|
||||
<code key={i} className="px-1 py-0.5 bg-muted rounded text-[10px] font-mono">{part}</code>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
part.startsWith('`') && part.endsWith('`') ? (
|
||||
<code key={i} className="px-1 py-0.5 bg-muted rounded text-[10px] font-mono">
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAngleTitle(angle: string): string {
|
||||
return angle
|
||||
.replace(/_/g, ' ')
|
||||
|
||||
Reference in New Issue
Block a user