- Introduced the CliInstallerService to manage CLI installation, including status checks and installation processes. - Added IPC handlers for CLI installer operations, enabling communication between main and renderer processes. - Implemented the CliStatusBanner and CliStatusSection components to display CLI installation status and controls in the dashboard and settings. - Enhanced the dashboard and settings UI to provide users with real-time feedback on CLI installation progress and errors. - Updated the TeamDataService to support task clarification features, improving task management capabilities.
562 lines
21 KiB
TypeScript
562 lines
21 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
|
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
|
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
|
|
import { Badge } from '@renderer/components/ui/badge';
|
|
import { Button } from '@renderer/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@renderer/components/ui/dialog';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@renderer/components/ui/select';
|
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|
import { markAsRead } from '@renderer/services/commentReadStorage';
|
|
import { useStore } from '@renderer/store';
|
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
|
import {
|
|
buildMemberColorMap,
|
|
KANBAN_COLUMN_DISPLAY,
|
|
TASK_STATUS_LABELS,
|
|
TASK_STATUS_STYLES,
|
|
} from '@renderer/utils/memberHelpers';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import {
|
|
AlignLeft,
|
|
ArrowLeftFromLine,
|
|
ArrowRightFromLine,
|
|
Clock,
|
|
FileCode,
|
|
FileDiff,
|
|
HelpCircle,
|
|
Link2,
|
|
Loader2,
|
|
MessageSquare,
|
|
PenLine,
|
|
ScrollText,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
|
|
import { TaskCommentInput } from './TaskCommentInput';
|
|
import { TaskCommentsSection } from './TaskCommentsSection';
|
|
|
|
import type { KanbanTaskState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
|
|
|
interface TaskDetailDialogProps {
|
|
open: boolean;
|
|
task: TeamTaskWithKanban | null;
|
|
teamName: string;
|
|
kanbanTaskState?: KanbanTaskState;
|
|
taskMap: Map<string, TeamTaskWithKanban>;
|
|
members: ResolvedTeamMember[];
|
|
onClose: () => void;
|
|
onScrollToTask?: (taskId: string) => void;
|
|
onOwnerChange?: (taskId: string, owner: string | null) => void;
|
|
onViewChanges?: (taskId: string, filePath?: string) => void;
|
|
onDeleteTask?: (taskId: string) => void;
|
|
/** Extra content rendered in the dialog header (e.g. "Open team" button). */
|
|
headerExtra?: React.ReactNode;
|
|
}
|
|
|
|
export const TaskDetailDialog = ({
|
|
open,
|
|
task,
|
|
teamName,
|
|
kanbanTaskState,
|
|
taskMap,
|
|
members,
|
|
onClose,
|
|
onScrollToTask,
|
|
onOwnerChange,
|
|
onViewChanges,
|
|
onDeleteTask,
|
|
headerExtra,
|
|
}: TaskDetailDialogProps): React.JSX.Element => {
|
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
|
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
|
const [replyTo, setReplyTo] = useState<{
|
|
taskId: string;
|
|
author: string;
|
|
text: string;
|
|
} | null>(null);
|
|
const handleReply = useCallback(
|
|
(author: string, text: string) => {
|
|
if (currentTask) setReplyTo({ taskId: currentTask.id, author, text });
|
|
},
|
|
[currentTask]
|
|
);
|
|
const clearReply = useCallback(() => setReplyTo(null), []);
|
|
|
|
const handleClose = useCallback(() => {
|
|
setReplyTo(null);
|
|
onClose();
|
|
}, [onClose]);
|
|
|
|
const effectiveReplyTo =
|
|
replyTo && replyTo.taskId === currentTask?.id
|
|
? { author: replyTo.author, text: replyTo.text }
|
|
: null;
|
|
|
|
useEffect(() => {
|
|
if (!open || !currentTask) return;
|
|
const comments = currentTask.comments ?? [];
|
|
if (comments.length === 0) return;
|
|
const latest = Math.max(...comments.map((c) => new Date(c.createdAt).getTime()));
|
|
if (latest > 0) markAsRead(teamName, currentTask.id, latest);
|
|
}, [open, teamName, currentTask]);
|
|
|
|
// Lazy-load task changes when dialog is open and task is completed
|
|
const isTaskCompleted = currentTask?.status === 'completed';
|
|
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
|
|
const activeChangeSet = useStore((s) => s.activeChangeSet);
|
|
const changeSetLoading = useStore((s) => s.changeSetLoading);
|
|
const fetchTaskChanges = useStore((s) => s.fetchTaskChanges);
|
|
|
|
// Use the lightweight cache to know if changes exist before full data loads
|
|
const changesCacheKey = currentTask ? `${teamName}:${currentTask.id}` : '';
|
|
const taskKnownHasChanges = useStore((s) => s.taskHasChanges[changesCacheKey]) === true;
|
|
|
|
const taskChangesFiles = useMemo(() => {
|
|
if (!activeChangeSet || !currentTask) return null;
|
|
if ('taskId' in activeChangeSet && activeChangeSet.taskId === currentTask.id) {
|
|
return activeChangeSet.files;
|
|
}
|
|
return null;
|
|
}, [activeChangeSet, currentTask]);
|
|
|
|
useEffect(() => {
|
|
if (!open || !currentTask || !isTaskCompleted || !onViewChanges) return;
|
|
// Only fetch if we don't already have data for this task
|
|
if (taskChangesFiles !== null) return;
|
|
void fetchTaskChanges(teamName, currentTask.id);
|
|
}, [
|
|
open,
|
|
currentTask,
|
|
isTaskCompleted,
|
|
teamName,
|
|
fetchTaskChanges,
|
|
taskChangesFiles,
|
|
onViewChanges,
|
|
]);
|
|
|
|
const handleDependencyClick = (taskId: string): void => {
|
|
handleClose();
|
|
onScrollToTask?.(taskId);
|
|
};
|
|
|
|
if (!currentTask) {
|
|
return (
|
|
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
|
<DialogContent className="sm:max-w-4xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Task not found</DialogTitle>
|
|
</DialogHeader>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
const kanbanColumn = kanbanTaskState?.column ?? currentTask.kanbanColumn;
|
|
const status = currentTask.status;
|
|
const statusStyle =
|
|
kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn]
|
|
? {
|
|
bg: KANBAN_COLUMN_DISPLAY[kanbanColumn].bg,
|
|
text: KANBAN_COLUMN_DISPLAY[kanbanColumn].text,
|
|
}
|
|
: TASK_STATUS_STYLES[status];
|
|
const statusLabel =
|
|
kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn]
|
|
? KANBAN_COLUMN_DISPLAY[kanbanColumn].label
|
|
: TASK_STATUS_LABELS[status];
|
|
const blockedByIds = currentTask.blockedBy?.filter((id) => id.length > 0) ?? [];
|
|
const blocksIds = currentTask.blocks?.filter((id) => id.length > 0) ?? [];
|
|
const relatedIds = (currentTask.related ?? []).filter(
|
|
(id) => id.length > 0 && id !== currentTask.id
|
|
);
|
|
const relatedByIds = Array.from(taskMap.values())
|
|
.filter(
|
|
(t) =>
|
|
t.id !== currentTask.id && Array.isArray(t.related) && t.related.includes(currentTask.id)
|
|
)
|
|
.map((t) => t.id);
|
|
const isTodo = status === 'pending' && !kanbanColumn;
|
|
const canReassign = isTodo && onOwnerChange;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
<DialogContent className="min-w-0 sm:max-w-4xl">
|
|
<DialogHeader>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
|
#{currentTask.id}
|
|
</Badge>
|
|
<span
|
|
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${statusStyle.bg} ${statusStyle.text}`}
|
|
>
|
|
{statusLabel}
|
|
</span>
|
|
{headerExtra ? <div className="ml-auto mr-4">{headerExtra}</div> : null}
|
|
</div>
|
|
<DialogTitle className="text-base">{currentTask.subject}</DialogTitle>
|
|
{currentTask.activeForm ? (
|
|
<DialogDescription>{currentTask.activeForm}</DialogDescription>
|
|
) : null}
|
|
</DialogHeader>
|
|
|
|
{/* Metadata */}
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
{canReassign ? (
|
|
<Select
|
|
value={currentTask.owner ?? '__unassigned__'}
|
|
onValueChange={(v) => {
|
|
onOwnerChange(currentTask.id, v === '__unassigned__' ? null : v);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 w-auto min-w-[140px] text-xs">
|
|
<SelectValue placeholder="Unassigned" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
|
{members.map((m) => {
|
|
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
|
const resolvedColor = colorMap.get(m.name);
|
|
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
|
|
return (
|
|
<SelectItem key={m.name} value={m.name}>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
{memberColor ? (
|
|
<span
|
|
className="inline-block size-2 shrink-0 rounded-full"
|
|
style={{ backgroundColor: memberColor.border }}
|
|
/>
|
|
) : null}
|
|
<span style={memberColor ? { color: memberColor.text } : undefined}>
|
|
{m.name}
|
|
</span>
|
|
{role ? (
|
|
<span className="text-[var(--color-text-muted)]">({role})</span>
|
|
) : null}
|
|
</span>
|
|
</SelectItem>
|
|
);
|
|
})}
|
|
</SelectContent>
|
|
</Select>
|
|
) : currentTask.owner ? (
|
|
<MemberBadge
|
|
name={currentTask.owner}
|
|
color={colorMap.get(currentTask.owner)}
|
|
size="md"
|
|
/>
|
|
) : (
|
|
<span className="text-xs text-[var(--color-text-muted)]">—</span>
|
|
)}
|
|
</div>
|
|
{currentTask.createdBy ? (
|
|
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
|
|
<PenLine size={12} />
|
|
<span className="text-[var(--color-text-secondary)]">{currentTask.createdBy}</span>
|
|
</div>
|
|
) : null}
|
|
{currentTask.createdAt
|
|
? (() => {
|
|
const date = new Date(currentTask.createdAt);
|
|
return isNaN(date.getTime()) ? null : (
|
|
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
|
|
<Clock size={12} />
|
|
<span className="text-[var(--color-text-secondary)]">
|
|
{formatDistanceToNow(date, { addSuffix: true })}
|
|
</span>
|
|
</div>
|
|
);
|
|
})()
|
|
: null}
|
|
</div>
|
|
|
|
{/* Clarification banner */}
|
|
{currentTask.needsClarification ? (
|
|
<div
|
|
className={`flex items-center justify-between rounded-md px-3 py-2 text-xs ${
|
|
currentTask.needsClarification === 'user'
|
|
? 'border border-red-500/20 bg-red-500/10 text-red-400'
|
|
: 'border border-blue-500/20 bg-blue-500/10 text-blue-400'
|
|
}`}
|
|
>
|
|
<span className="flex items-center gap-1.5">
|
|
<HelpCircle size={14} />
|
|
{currentTask.needsClarification === 'user'
|
|
? 'Awaiting clarification from you'
|
|
: 'Awaiting clarification from team lead'}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 text-xs"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void setTaskNeedsClarification(teamName, currentTask.id, null);
|
|
}}
|
|
>
|
|
Mark resolved
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Description */}
|
|
<CollapsibleTeamSection title="Description" icon={<AlignLeft size={14} />} defaultOpen>
|
|
{currentTask.description ? (
|
|
<div className="max-h-[200px] overflow-y-auto">
|
|
<MarkdownViewer content={currentTask.description} maxHeight="max-h-[180px]" />
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-[var(--color-text-muted)]">No description</p>
|
|
)}
|
|
</CollapsibleTeamSection>
|
|
|
|
{/* Changes */}
|
|
{isTaskCompleted && onViewChanges ? (
|
|
<CollapsibleTeamSection
|
|
title="Changes"
|
|
icon={<FileDiff size={14} />}
|
|
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
|
|
defaultOpen={taskKnownHasChanges}
|
|
>
|
|
{changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? (
|
|
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
|
|
<Loader2 size={14} className="animate-spin" />
|
|
Loading changes...
|
|
</div>
|
|
) : taskChangesFiles && taskChangesFiles.length > 0 ? (
|
|
<div className="max-h-[200px] space-y-0.5 overflow-y-auto">
|
|
{taskChangesFiles.map((file) => (
|
|
<button
|
|
key={file.filePath}
|
|
type="button"
|
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]"
|
|
onClick={() => {
|
|
handleClose();
|
|
onViewChanges(currentTask.id, file.filePath);
|
|
}}
|
|
>
|
|
<FileCode size={14} className="shrink-0 text-[var(--color-text-muted)]" />
|
|
<span className="truncate font-mono text-[var(--color-text-secondary)]">
|
|
{file.relativePath}
|
|
</span>
|
|
<span className="flex shrink-0 items-center gap-1.5">
|
|
{file.linesAdded > 0 ? (
|
|
<span className="text-emerald-400">+{file.linesAdded}</span>
|
|
) : null}
|
|
{file.linesRemoved > 0 ? (
|
|
<span className="text-red-400">-{file.linesRemoved}</span>
|
|
) : null}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-[var(--color-text-muted)]">No file changes detected</p>
|
|
)}
|
|
</CollapsibleTeamSection>
|
|
) : null}
|
|
|
|
{/* Execution Logs — sessions that reference this task */}
|
|
<CollapsibleTeamSection title="Execution Logs" icon={<ScrollText size={14} />} defaultOpen>
|
|
<div className="min-w-0 overflow-hidden">
|
|
<MemberLogsTab
|
|
teamName={teamName}
|
|
taskId={currentTask.id}
|
|
taskOwner={currentTask.owner}
|
|
taskStatus={currentTask.status}
|
|
/>
|
|
</div>
|
|
</CollapsibleTeamSection>
|
|
|
|
<div className="mb-3 space-y-2">
|
|
{/* Dependencies */}
|
|
{blockedByIds.length > 0 ? (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<span className="inline-flex items-center gap-0.5 text-xs text-yellow-300">
|
|
<ArrowLeftFromLine size={12} />
|
|
Blocked by
|
|
</span>
|
|
{blockedByIds.map((id) => {
|
|
const depTask = taskMap.get(id);
|
|
const isCompleted = depTask?.status === 'completed';
|
|
return (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
|
isCompleted
|
|
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
|
|
: 'bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/25'
|
|
} cursor-pointer`}
|
|
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
|
onClick={() => handleDependencyClick(id)}
|
|
>
|
|
#{id}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
|
|
{blocksIds.length > 0 ? (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<span className="inline-flex items-center gap-0.5 text-xs text-blue-400">
|
|
<ArrowRightFromLine size={12} />
|
|
Blocks
|
|
</span>
|
|
{blocksIds.map((id) => {
|
|
const depTask = taskMap.get(id);
|
|
const isCompleted = depTask?.status === 'completed';
|
|
return (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
|
|
isCompleted
|
|
? 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/25'
|
|
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
|
|
} cursor-pointer`}
|
|
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
|
onClick={() => handleDependencyClick(id)}
|
|
>
|
|
#{id}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Related tasks (explicit) */}
|
|
{relatedIds.length > 0 || relatedByIds.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
|
|
<Link2 size={12} />
|
|
Related tasks
|
|
</div>
|
|
|
|
{relatedIds.length > 0 ? (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<span className="text-xs text-[var(--color-text-muted)]">Links</span>
|
|
{relatedIds.map((id) => {
|
|
const depTask = taskMap.get(id);
|
|
return (
|
|
<button
|
|
key={`related:${currentTask.id}:${id}`}
|
|
type="button"
|
|
className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25"
|
|
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
|
onClick={() => handleDependencyClick(id)}
|
|
>
|
|
#{id}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
|
|
{relatedByIds.length > 0 ? (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<span className="text-xs text-[var(--color-text-muted)]">Linked from</span>
|
|
{relatedByIds.map((id) => {
|
|
const depTask = taskMap.get(id);
|
|
return (
|
|
<button
|
|
key={`related-by:${currentTask.id}:${id}`}
|
|
type="button"
|
|
className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25"
|
|
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
|
onClick={() => handleDependencyClick(id)}
|
|
>
|
|
#{id}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Review info */}
|
|
{kanbanTaskState ? (
|
|
<div className="flex items-center gap-2">
|
|
{kanbanTaskState.reviewer ? (
|
|
<span className="text-xs text-[var(--color-text-secondary)]">
|
|
Reviewer: {kanbanTaskState.reviewer}
|
|
</span>
|
|
) : null}
|
|
{kanbanTaskState.errorDescription ? (
|
|
<span className="text-xs text-red-400">{kanbanTaskState.errorDescription}</span>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* Comments */}
|
|
<CollapsibleTeamSection
|
|
title="Comments"
|
|
icon={<MessageSquare size={14} />}
|
|
badge={
|
|
(currentTask.comments?.length ?? 0) > 0
|
|
? (currentTask.comments?.length ?? 0)
|
|
: undefined
|
|
}
|
|
defaultOpen
|
|
>
|
|
<TaskCommentInput
|
|
teamName={teamName}
|
|
taskId={currentTask.id}
|
|
members={members}
|
|
replyTo={effectiveReplyTo}
|
|
onClearReply={clearReply}
|
|
/>
|
|
<TaskCommentsSection
|
|
teamName={teamName}
|
|
taskId={currentTask.id}
|
|
comments={currentTask.comments ?? []}
|
|
members={members}
|
|
hideHeader
|
|
hideInput
|
|
onReply={handleReply}
|
|
/>
|
|
</CollapsibleTeamSection>
|
|
|
|
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
|
{onDeleteTask && currentTask ? (
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => {
|
|
onDeleteTask(currentTask.id);
|
|
handleClose();
|
|
}}
|
|
>
|
|
<Trash2 size={14} className="mr-1" />
|
|
Delete
|
|
</Button>
|
|
) : (
|
|
<div />
|
|
)}
|
|
<Button variant="outline" onClick={handleClose}>
|
|
Close
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|