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:
catlog22
2026-02-07 22:50:36 +08:00
parent dc9a1a1efb
commit 13c4dd0032
13 changed files with 636 additions and 255 deletions

View File

@@ -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 */}

View File

@@ -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(

View File

@@ -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, ' ')