import "./TaskDetailModal.css"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Pencil, Bot, X, ChevronDown } from "lucide-react"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue } from "@fusion/core"; import { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, VALID_TRANSITIONS, getErrorMessage } from "@fusion/core"; import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent } from "../api"; import type { WorkflowStepResult } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { useAgentLogs } from "../hooks/useAgentLogs"; import { useConfirm } from "../hooks/useConfirm"; import { AgentLogViewer } from "./AgentLogViewer"; import { ModelSelectorTab } from "./ModelSelectorTab"; import { PrSection } from "./PrSection"; import { TaskComments } from "./TaskComments"; import { MergeDetails } from "./MergeDetails"; import { TaskChangesTab } from "./TaskChangesTab"; import { TaskForm, type PendingImage } from "./TaskForm"; import { WorkflowResultsTab } from "./WorkflowResultsTab"; import { TaskDocumentsTab } from "./TaskDocumentsTab"; import { TaskTokenStatsPanel } from "./TaskTokenStatsPanel"; import { PluginSlot } from "./PluginSlot"; import { ProviderIcon } from "./ProviderIcon"; import { subscribeSse } from "../sse-bus"; import { usePluginUiSlots } from "../hooks/usePluginUiSlots"; import { appendTokenQuery } from "../auth"; interface ModelSelection { provider?: string; modelId?: string; } /** * Resolve the effective executor model following the engine's resolution order: * 1. Per-task modelProvider/modelId (both must be set) * 2. Global settings defaultProvider/defaultModelId */ function resolveEffectiveExecutor( task: Task | TaskDetail, settings?: Settings, ): ModelSelection { if (task.modelProvider && task.modelId) { return { provider: task.modelProvider, modelId: task.modelId }; } if (settings?.defaultProvider && settings.defaultModelId) { return { provider: settings.defaultProvider, modelId: settings.defaultModelId }; } return {}; } /** * Resolve the effective validator model following the engine's resolution order: * 1. Per-task validatorModelProvider/validatorModelId (both must be set) * 2. Project settings validatorProvider/validatorModelId * 3. Global settings defaultProvider/defaultModelId */ function resolveEffectiveValidator( task: Task | TaskDetail, settings?: Settings, ): ModelSelection { if (task.validatorModelProvider && task.validatorModelId) { return { provider: task.validatorModelProvider, modelId: task.validatorModelId }; } if (settings?.validatorProvider && settings.validatorModelId) { return { provider: settings.validatorProvider, modelId: settings.validatorModelId }; } if (settings?.defaultProvider && settings.defaultModelId) { return { provider: settings.defaultProvider, modelId: settings.defaultModelId }; } return {}; } /** * Extract planning model from agent log entries. * Looks for text entries with agent role "triage" matching the pattern: * "Triage using model: /" * Returns the latest match, or null if none found. */ function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null { // Iterate in chronological order; last match wins let result: { provider: string; modelId: string } | null = null; for (const entry of entries) { if (entry.agent !== "triage" || entry.type !== "text") continue; const match = entry.text.match(/^Triage using model: (.+?)\/(.+)$/); if (match) { result = { provider: match[1], modelId: match[2] }; } } return result; } /** * Resolve the effective planning model following the resolution order: * 1. Per-task planningModelProvider/planningModelId override * 2. Runtime triage model from agent log marker (if present) * 3. Project settings planningProvider/planningModelId * 4. Global settings defaultProvider/defaultModelId */ function resolveEffectivePlanning( task: Task | TaskDetail, logEntries: AgentLogEntry[], settings?: Settings, ): ModelSelection { // 1. Per-task override takes precedence if (task.planningModelProvider && task.planningModelId) { return { provider: task.planningModelProvider, modelId: task.planningModelId }; } // 2. Runtime triage model from agent log marker const fromLog = extractPlanningModelFromLog(logEntries); if (fromLog) { return fromLog; } // 3. Project settings planningProvider/planningModelId if (settings?.planningProvider && settings.planningModelId) { return { provider: settings.planningProvider, modelId: settings.planningModelId }; } // 4. Global settings defaultProvider/defaultModelId if (settings?.defaultProvider && settings.defaultModelId) { return { provider: settings.defaultProvider, modelId: settings.defaultModelId }; } return {}; } function getStepStatusColor(status: string): string { switch (status) { case "done": return "var(--color-success, #3fb950)"; case "in-progress": return "var(--todo, #58a6ff)"; case "skipped": return "var(--text-dim, #484f58)"; case "pending": default: return "var(--border, #30363d)"; } } function formatTimestamp(iso: string): string { const date = new Date(iso); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMin = Math.floor(diffMs / 60000); const diffHr = Math.floor(diffMin / 60); const diffDay = Math.floor(diffHr / 24); if (diffMin < 1) return "just now"; if (diffMin < 60) return `${diffMin}m ago`; if (diffHr < 24) return `${diffHr}h ago`; if (diffDay < 7) return `${diffDay}d ago`; return date.toLocaleDateString(); } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } type TabId = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "documents" | "stats" | `plugin-${string}`; interface TaskDetailModalProps { task: Task | TaskDetail; projectId?: string; tasks?: Task[]; onClose: () => void; onOpenDetail: (task: Task | TaskDetail) => void; // For clicking dependencies onMoveTask: (id: string, column: Column) => Promise; onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean }) => Promise; onMergeTask: (id: string) => Promise; onRetryTask?: (id: string) => Promise; onDuplicateTask?: (id: string) => Promise; onTaskUpdated?: (task: Task) => void; addToast: (message: string, type?: ToastType) => void; prAuthAvailable?: boolean; /** Open the modal with this tab active instead of "definition" */ initialTab?: TabId; } function extractDependencyDeleteConflict(err: unknown): { dependentIds: string[] } | null { if (!(err instanceof Error)) { return null; } const details = (err as { details?: { code?: string; dependentIds?: unknown } }).details; if (details?.code === "TASK_HAS_DEPENDENTS" && Array.isArray(details.dependentIds)) { return { dependentIds: details.dependentIds.filter((id): id is string => typeof id === "string") }; } const idsInMessage = err.message.match(/[A-Z]+-\d+/g) ?? []; if (idsInMessage.length > 1) { return { dependentIds: [...new Set(idsInMessage.slice(1))] }; } return null; } function truncate(s: string, max: number): string { return s.length > max ? s.slice(0, max) + "…" : s; } function sameStringArray(a: string[] = [], b: string[] = []): boolean { return a.length === b.length && a.every((value, index) => value === b[index]); } function splitModelSelection(value: string): { provider: string; modelId: string } | null { const slashIdx = value.indexOf("/"); if (!value || slashIdx === -1) return null; return { provider: value.slice(0, slashIdx), modelId: value.slice(slashIdx + 1), }; } function normalizeSourceIssueText(value: string): string { return value.trim(); } function normalizeSourceIssueUrl(value: string): string | undefined { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } function normalizeTaskPriorityValue(priority: Task["priority"]): TaskPriority { return typeof priority === "string" && (TASK_PRIORITIES as readonly string[]).includes(priority) ? (priority as TaskPriority) : DEFAULT_TASK_PRIORITY; } function normalizeExecutionModeValue(executionMode: Task["executionMode"]): "standard" | "fast" { return executionMode === "fast" ? "fast" : "standard"; } const DESCRIPTION_TRUNCATE_LENGTH = 200; const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); export function TaskDetailModal({ task, projectId, tasks = [], onClose, onOpenDetail, onMoveTask, onDeleteTask, onMergeTask, onRetryTask, onDuplicateTask, onTaskUpdated, addToast, prAuthAvailable, initialTab = "definition", }: TaskDetailModalProps) { const [activeTab, setActiveTab] = useState(initialTab); // ── Async detail loading ────────────────────────────────────────────────── // When opened optimistically with a Task (no prompt), fetch the full // TaskDetail in the background. The modal renders immediately with the // lightweight data and shows a loading indicator in the spec section. const [fullDetail, setFullDetail] = useState(() => "prompt" in task ? (task as TaskDetail) : null, ); const [detailLoading, setDetailLoading] = useState(() => !("prompt" in task), ); useEffect(() => { // If the prop already has a prompt field, it's a full TaskDetail if ("prompt" in task) { setFullDetail(task as TaskDetail); setDetailLoading(false); return; } let cancelled = false; setDetailLoading(true); setFullDetail(null); fetchTaskDetail(task.id, projectId) .then((detail) => { if (!cancelled) { setFullDetail(detail); setDetailLoading(false); } }) .catch(() => { if (!cancelled) { setDetailLoading(false); } }); return () => { cancelled = true; }; }, [task.id, projectId]); // Derive a working task that always has all available fields. // Falls back to the optimistic Task while loading, uses fullDetail once loaded. // Live fields (tokenUsage, workflowStepResults, status, column, …) are taken // from the parent `task` prop which receives SSE updates, so the stats tab // keeps populating while a task runs after the modal was opened. `log` is // stripped to [] in SSE payloads (stripTaskListHeavyFields), so we preserve // fullDetail.log to keep the Activity timeline populated. const workingTask: TaskDetail = fullDetail ? ({ ...fullDetail, ...task, prompt: fullDetail.prompt, log: fullDetail.log } as TaskDetail) : ({ ...task, prompt: "" } as TaskDetail); const canRetryTask = task.status === "failed" || task.status === "stuck-killed" || task.status === "planning" || task.status === "needs-replan" || (task.stuckKillCount ?? 0) > 0 || (task.recoveryRetryCount ?? 0) > 0 || Boolean(task.nextRecoveryAt); // Sync activeTab when the caller changes initialTab (e.g. opening a different tab) useEffect(() => { setActiveTab(initialTab); }, [initialTab]); // Reset description expanded state when task changes useEffect(() => { setDescriptionExpanded(false); }, [task.id]); const [logSubview, setLogSubview] = useState<"activity" | "agent-log">("activity"); const [descriptionExpanded, setDescriptionExpanded] = useState(false); const [attachments, setAttachments] = useState(task.attachments || []); const [uploading, setUploading] = useState(false); const [dependencies, setDependencies] = useState(task.dependencies || []); const [showDepDropdown, setShowDepDropdown] = useState(false); const [depSearch, setDepSearch] = useState(""); const [assignedAgent, setAssignedAgent] = useState(null); const [agents, setAgents] = useState([]); const [showAgentPicker, setShowAgentPicker] = useState(false); const [agentsLoading, setAgentsLoading] = useState(false); const [isSavingSpec, setIsSavingSpec] = useState(false); const [isRequestingRevision, setIsRequestingRevision] = useState(false); const [isEditingSpec, setIsEditingSpec] = useState(false); const [specEditContent, setSpecEditContent] = useState(workingTask.prompt || ""); const [specFeedback, setSpecFeedback] = useState(""); const [showRefineModal, setShowRefineModal] = useState(false); const [refineFeedback, setRefineFeedback] = useState(""); const [isRefining, setIsRefining] = useState(false); // Edit mode state const [isEditing, setIsEditing] = useState(false); const [editTitle, setEditTitle] = useState(task.title || ""); const [editDescription, setEditDescription] = useState(task.description || ""); const [editDependencies, setEditDependencies] = useState(task.dependencies || []); const [editExecutorModel, setEditExecutorModel] = useState(""); const [editValidatorModel, setEditValidatorModel] = useState(""); const [editPlanningModel, setEditPlanningModel] = useState(""); const [editThinkingLevel, setEditThinkingLevel] = useState(""); const [editPresetMode, setEditPresetMode] = useState<"default" | "preset" | "custom">("default"); const [editReviewLevel, setEditReviewLevel] = useState(undefined); const [editPriority, setEditPriority] = useState(DEFAULT_TASK_PRIORITY); const [editExecutionMode, setEditExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode)); const [editSelectedPresetId, setEditSelectedPresetId] = useState(""); const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState(task.enabledWorkflowSteps || []); const [editSourceIssueProvider, setEditSourceIssueProvider] = useState(task.sourceIssue?.provider ?? ""); const [editSourceIssueRepository, setEditSourceIssueRepository] = useState(task.sourceIssue?.repository ?? ""); const [editSourceIssueExternalId, setEditSourceIssueExternalId] = useState(task.sourceIssue?.externalIssueId ?? ""); const [editSourceIssueUrl, setEditSourceIssueUrl] = useState(task.sourceIssue?.url ?? ""); const [editPendingImages, setEditPendingImages] = useState([]); const [isSaving, setIsSaving] = useState(false); const mountedRef = useRef(false); // Split-menu dropdown state for footer actions const [showMoveMenu, setShowMoveMenu] = useState(false); const [showActionsMenu, setShowActionsMenu] = useState(false); const moveMenuRef = useRef(null); const actionsMenuRef = useRef(null); const modalRef = useRef(null); useModalResizePersist(modalRef, true, "fusion:task-detail-modal-size"); // Plugin UI slots for task-detail-tab const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId); const pluginTabSlots = getPluginSlots("task-detail-tab"); // Track mount state to avoid setting state on unmounted component useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // Merged project settings for effective model resolution in Agent Log header const [settings, setSettings] = useState(undefined); // Workflow results state const [workflowResults, setWorkflowResults] = useState([]); const [workflowResultsLoading, setWorkflowResultsLoading] = useState(false); const [workflowEnabledSteps, setWorkflowEnabledSteps] = useState(task.enabledWorkflowSteps || []); // Reset edit state when task changes useEffect(() => { setEditTitle(task.title || ""); setEditDescription(task.description || ""); setEditSourceIssueProvider(task.sourceIssue?.provider ?? ""); setEditSourceIssueRepository(task.sourceIssue?.repository ?? ""); setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? ""); setEditSourceIssueUrl(task.sourceIssue?.url ?? ""); setEditExecutionMode(normalizeExecutionModeValue(task.executionMode)); setIsEditing(false); }, [task.id, task.title, task.description, task.sourceIssue, task.executionMode]); useEffect(() => { setWorkflowEnabledSteps(task.enabledWorkflowSteps || []); }, [task.id, task.enabledWorkflowSteps]); // Load merged settings for effective model resolution useEffect(() => { let cancelled = false; fetchSettings(projectId) .then((s) => { if (!cancelled) setSettings(s); }) .catch(() => { // Settings fetch failure is non-blocking; fallback to "Using default" }); return () => { cancelled = true; }; }, [projectId]); // Load workflow results when workflow tab is active useEffect(() => { if (activeTab !== "workflow") return; let cancelled = false; setWorkflowResultsLoading(true); fetchWorkflowResults(task.id, projectId) .then((results) => { if (!cancelled) setWorkflowResults(results); }) .catch((err) => { if (!cancelled) { addToast(`Failed to load workflow results: ${getErrorMessage(err)}`, "error"); } }) .finally(() => { if (!cancelled) setWorkflowResultsLoading(false); }); return () => { cancelled = true; }; }, [activeTab, task.id, projectId, addToast]); // Subscribe to SSE for real-time workflow result updates while workflow tab is active useEffect(() => { if (activeTab !== "workflow") return; const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const handleTaskUpdated = (e: MessageEvent) => { try { const updatedTask = JSON.parse(e.data); // Only update if this is for our task and has workflow step results if (updatedTask.id === task.id && Array.isArray(updatedTask.workflowStepResults)) { setWorkflowResults(updatedTask.workflowStepResults); } } catch { // Skip malformed events } }; return subscribeSse(`/api/events${query}`, { events: { "task:updated": handleTaskUpdated }, }); }, [activeTab, task.id, projectId]); // Reset dependency search when dropdown closes useEffect(() => { if (!showDepDropdown) { setDepSearch(""); } }, [showDepDropdown]); useEffect(() => { if (!task.assignedAgentId) { setAssignedAgent(null); return; } const knownAgent = agents.find((agent) => agent.id === task.assignedAgentId); if (knownAgent) { setAssignedAgent(knownAgent); return; } let cancelled = false; void fetchAgent(task.assignedAgentId, projectId) .then((agent) => { if (!cancelled) setAssignedAgent(agent); }) .catch(() => { if (!cancelled) setAssignedAgent(null); }); return () => { cancelled = true; }; }, [task.assignedAgentId, projectId, agents]); useEffect(() => { setShowAgentPicker(false); }, [task.id]); // Close footer dropdown menus on outside click useEffect(() => { const hasOpenMenu = showMoveMenu || showActionsMenu; if (!hasOpenMenu) return; const handleClick = (e: MouseEvent) => { const target = e.target as Node; const inMoveMenu = moveMenuRef.current?.contains(target); const inActionsMenu = actionsMenuRef.current?.contains(target); if (!inMoveMenu && showMoveMenu) { setShowMoveMenu(false); } if (!inActionsMenu && showActionsMenu) { setShowActionsMenu(false); } }; document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [showMoveMenu, showActionsMenu]); // Close footer dropdown menus on Escape key (before modal Escape handler) useEffect(() => { const hasOpenMenu = showMoveMenu || showActionsMenu; if (!hasOpenMenu) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); // Prevent modal from closing if (showMoveMenu) setShowMoveMenu(false); if (showActionsMenu) setShowActionsMenu(false); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [showMoveMenu, showActionsMenu]); // Reset spec edit state when task changes useEffect(() => { setIsEditingSpec(false); setSpecEditContent(workingTask.prompt || ""); setSpecFeedback(""); }, [task.id, workingTask.prompt]); // Note: TaskForm handles auto-focus internally via isActive prop // Check if task can be edited const canEdit = EDITABLE_COLUMNS.has(task.column) && !isSaving; const enterEditMode = useCallback(() => { if (!canEdit) return; setIsEditing(true); setEditTitle(task.title || ""); setEditDescription(task.description || ""); setEditDependencies(task.dependencies || []); // Populate model overrides from task const execModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : ""; const valModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : ""; const planModel = task.planningModelProvider && task.planningModelId ? `${task.planningModelProvider}/${task.planningModelId}` : ""; setEditExecutorModel(execModel); setEditValidatorModel(valModel); setEditPlanningModel(planModel); setEditThinkingLevel(task.thinkingLevel ?? ""); setEditPresetMode(execModel || valModel || planModel ? "custom" : "default"); setEditSelectedPresetId(""); setEditSelectedWorkflowSteps(task.enabledWorkflowSteps || []); setEditExecutionMode(normalizeExecutionModeValue(task.executionMode)); setEditSourceIssueProvider(task.sourceIssue?.provider ?? ""); setEditSourceIssueRepository(task.sourceIssue?.repository ?? ""); setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? ""); setEditSourceIssueUrl(task.sourceIssue?.url ?? ""); setEditPendingImages([]); setEditReviewLevel(task.reviewLevel); setEditPriority(normalizeTaskPriorityValue(task.priority)); }, [canEdit, task]); const exitEditMode = useCallback(() => { setIsEditing(false); setEditTitle(task.title || ""); setEditDescription(task.description || ""); setEditDependencies(task.dependencies || []); setEditSourceIssueProvider(task.sourceIssue?.provider ?? ""); setEditSourceIssueRepository(task.sourceIssue?.repository ?? ""); setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? ""); setEditSourceIssueUrl(task.sourceIssue?.url ?? ""); setEditPriority(normalizeTaskPriorityValue(task.priority)); setEditExecutionMode(normalizeExecutionModeValue(task.executionMode)); editPendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); setEditPendingImages([]); }, [task.title, task.description, task.dependencies, task.priority, task.executionMode, editPendingImages]); const handleSave = useCallback(async () => { setIsSaving(true); try { const updates: Parameters[1] = {}; const trimmedTitle = editTitle.trim(); const trimmedDescription = editDescription.trim(); if (trimmedTitle && trimmedTitle !== (task.title ?? "")) { updates.title = trimmedTitle; } if (trimmedDescription && trimmedDescription !== (task.description ?? "")) { updates.description = trimmedDescription; } if (!sameStringArray(editDependencies, task.dependencies ?? [])) { updates.dependencies = editDependencies; } if (!sameStringArray(editSelectedWorkflowSteps, task.enabledWorkflowSteps ?? [])) { updates.enabledWorkflowSteps = editSelectedWorkflowSteps; } const executorSelection = splitModelSelection(editExecutorModel); const currentExecutorModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : ""; if (editExecutorModel !== currentExecutorModel) { updates.modelProvider = executorSelection?.provider ?? null; updates.modelId = executorSelection?.modelId ?? null; } const validatorSelection = splitModelSelection(editValidatorModel); const currentValidatorModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : ""; if (editValidatorModel !== currentValidatorModel) { updates.validatorModelProvider = validatorSelection?.provider ?? null; updates.validatorModelId = validatorSelection?.modelId ?? null; } const planningSelection = splitModelSelection(editPlanningModel); const currentPlanningModel = task.planningModelProvider && task.planningModelId ? `${task.planningModelProvider}/${task.planningModelId}` : ""; if (editPlanningModel !== currentPlanningModel) { updates.planningModelProvider = planningSelection?.provider ?? null; updates.planningModelId = planningSelection?.modelId ?? null; } const currentThinkingLevel = task.thinkingLevel ?? ""; if (editThinkingLevel !== currentThinkingLevel) { updates.thinkingLevel = editThinkingLevel !== "" ? (editThinkingLevel as "minimal" | "low" | "medium" | "high") : null; } const currentReviewLevel = task.reviewLevel; if (editReviewLevel !== currentReviewLevel) { updates.reviewLevel = editReviewLevel; } const currentPriority = normalizeTaskPriorityValue(task.priority); if (editPriority !== currentPriority) { updates.priority = editPriority; } const currentExecutionMode = normalizeExecutionModeValue(task.executionMode); if (editExecutionMode !== currentExecutionMode) { updates.executionMode = editExecutionMode === "fast" ? "fast" : null; } const normalizedProvider = normalizeSourceIssueText(editSourceIssueProvider); const normalizedRepository = normalizeSourceIssueText(editSourceIssueRepository); const normalizedExternalId = normalizeSourceIssueText(editSourceIssueExternalId); const normalizedUrl = normalizeSourceIssueUrl(editSourceIssueUrl); const allSourceFieldsEmpty = normalizedProvider.length === 0 && normalizedRepository.length === 0 && normalizedExternalId.length === 0 && !normalizedUrl; if (allSourceFieldsEmpty) { if (task.sourceIssue) { updates.sourceIssue = null; } } else { if (!normalizedProvider || !normalizedRepository || !normalizedExternalId) { addToast("Source issue provider, repository, and issue identifier are required", "error"); setIsSaving(false); return; } const fallbackIssueNumber = Number.parseInt(normalizedExternalId, 10); const issueNumber = task.sourceIssue?.issueNumber ?? fallbackIssueNumber; if (!Number.isFinite(issueNumber) || issueNumber <= 0) { addToast("Source issue identifier must be numeric for new metadata", "error"); setIsSaving(false); return; } const nextSourceIssue: TaskSourceIssue = { provider: normalizedProvider, repository: normalizedRepository, externalIssueId: normalizedExternalId, issueNumber, ...(normalizedUrl ? { url: normalizedUrl } : {}), }; const previousSourceIssue = task.sourceIssue; const sourceIssueChanged = !previousSourceIssue || previousSourceIssue.provider !== nextSourceIssue.provider || previousSourceIssue.repository !== nextSourceIssue.repository || previousSourceIssue.externalIssueId !== nextSourceIssue.externalIssueId || previousSourceIssue.issueNumber !== nextSourceIssue.issueNumber || (previousSourceIssue.url ?? undefined) !== nextSourceIssue.url; if (sourceIssueChanged) { updates.sourceIssue = nextSourceIssue; } } const hasTaskUpdates = Object.keys(updates).length > 0; if (hasTaskUpdates) { const updatedTask = await updateTask(task.id, updates, projectId); onTaskUpdated?.(updatedTask); } // Upload pending images as attachments if (editPendingImages.length > 0) { const failures: string[] = []; for (const img of editPendingImages) { try { const attachment = await uploadAttachment(task.id, img.file, projectId); setAttachments((prev) => [...prev, attachment]); } catch { failures.push(img.file.name); } } if (failures.length > 0) { addToast(`Failed to upload: ${failures.join(", ")}`, "error"); } } // Clean up editPendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); setEditPendingImages([]); addToast(`Updated ${task.id}`, "success"); setIsEditing(false); } catch (err) { addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error"); } finally { if (mountedRef.current) { setIsSaving(false); } } }, [task, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editReviewLevel, editPriority, editExecutionMode, editSelectedWorkflowSteps, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueExternalId, editSourceIssueUrl, editPendingImages, addToast, projectId, onTaskUpdated]); const handleAutoSaveDescription = useCallback(async (description: string) => { try { await updateTask(task.id, { description }, projectId); addToast("Description saved", "success"); } catch (err) { addToast(`Failed to save: ${getErrorMessage(err)}`, "error"); } }, [task.id, addToast, projectId]); // Handle keyboard shortcuts for edit mode const handleEditKeyDown = useCallback((e: KeyboardEvent) => { if (!isEditing) return; if (e.key === "Escape") { e.preventDefault(); exitEditMode(); } else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); handleSave(); } }, [isEditing, exitEditMode, handleSave]); useEffect(() => { if (!isEditing) return; document.addEventListener("keydown", handleEditKeyDown); return () => document.removeEventListener("keydown", handleEditKeyDown); }, [isEditing, handleEditKeyDown]); const fileInputRef = useRef(null); const { confirm } = useConfirm(); const { entries: agentLogEntries, loading: agentLogLoading, loadMore: loadMoreAgentLogs, hasMore: agentLogHasMore, total: agentLogTotal, loadingMore: agentLogLoadingMore, } = useAgentLogs( task.id, activeTab === "logs" && logSubview === "agent-log", projectId, ); useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !isEditing) onClose(); }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); }, [onClose, isEditing]); const overlayDismissProps = useOverlayDismiss(onClose); const handleMove = useCallback( async (column: Column) => { try { await onMoveTask(task.id, column); onClose(); addToast(`Moved to ${COLUMN_LABELS[column]}`, "success"); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, onMoveTask, onClose, addToast], ); const handleDelete = useCallback(async () => { const shouldDelete = await confirm({ title: "Delete Task", message: `Delete ${task.id}?`, danger: true, }); if (!shouldDelete) return; try { await onDeleteTask(task.id); onClose(); addToast(`Deleted ${task.id}`, "info"); } catch (err) { const conflict = extractDependencyDeleteConflict(err); if (!conflict || conflict.dependentIds.length === 0) { addToast(getErrorMessage(err), "error"); return; } const dependentList = conflict.dependentIds.join(", "); const confirmed = await confirm({ title: "Force Delete Task", message: `${task.id} is a dependency of ${dependentList}.\n\n` + "Delete anyway by removing these dependency references first?", danger: true, }); if (!confirmed) { return; } try { await onDeleteTask(task.id, { removeDependencyReferences: true }); onClose(); addToast(`Deleted ${task.id} after removing dependency references`, "info"); } catch (retryErr) { addToast(getErrorMessage(retryErr), "error"); } } }, [task.id, onDeleteTask, onClose, addToast, confirm]); const handleMerge = useCallback(async () => { const shouldMerge = await confirm({ title: "Merge Task", message: `Merge ${task.id} into the current branch?`, }); if (!shouldMerge) return; onClose(); addToast(`Merging ${task.id}...`, "info"); onMergeTask(task.id) .then((result) => { const msg = result.merged ? `Merged ${task.id} (branch: ${result.branch})` : `Closed ${task.id} (${result.error || "no branch to merge"})`; addToast(msg, "success"); }) .catch((err) => { addToast(getErrorMessage(err), "error"); }); }, [task.id, onMergeTask, onClose, addToast, confirm]); const handleRetry = useCallback(() => { if (!onRetryTask) return; onClose(); onRetryTask(task.id) .then(() => { addToast(`Retried ${task.id}`, "success"); }) .catch((err) => { addToast(getErrorMessage(err), "error"); }); }, [task.id, onRetryTask, onClose, addToast]); const handleDuplicate = useCallback(async () => { if (!onDuplicateTask) return; const shouldDuplicate = await confirm({ title: "Duplicate Task", message: `Duplicate ${task.id}? This will create a new task in Triage with the same description and prompt.`, }); if (!shouldDuplicate) return; try { const newTask = await onDuplicateTask(task.id); onClose(); addToast(`Duplicated ${task.id} → ${newTask.id}`, "success"); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, onDuplicateTask, onClose, addToast, confirm]); const handleTogglePause = useCallback(async () => { try { if (task.paused) { await unpauseTask(task.id, projectId); addToast(`Unpaused ${task.id}`, "success"); } else { await pauseTask(task.id, projectId); addToast(`Paused ${task.id}`, "success"); } onClose(); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, task.paused, onClose, addToast]); const handleApprovePlan = useCallback(async () => { try { await approvePlan(task.id, projectId); addToast(`Plan approved — ${task.id} moved to Todo`, "success"); onClose(); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, onClose, addToast]); const handleRejectPlan = useCallback(async () => { const shouldReject = await confirm({ title: "Reject Plan", message: "Reject this plan? The specification will be discarded and regenerated.", danger: true, }); if (!shouldReject) return; try { await rejectPlan(task.id, projectId); addToast(`Plan rejected — ${task.id} returned to Planning for replanning`, "info"); onClose(); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, onClose, addToast, confirm]); const handleRespecify = useCallback(async () => { const shouldRebuild = await confirm({ title: "Rebuild Plan", message: "Rebuild the plan for this task? The task will move to planning for replanning.", }); if (!shouldRebuild) return; try { await rebuildTaskSpec(task.id, projectId); onClose(); addToast(`Replanning ${task.id}...`, "info"); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, projectId, onClose, addToast, confirm]); const handleOpenRefineModal = useCallback(() => { setShowRefineModal(true); setRefineFeedback(""); }, []); // Helper to close dropdown menus after action const closeMenus = useCallback(() => { setShowMoveMenu(false); setShowActionsMenu(false); }, []); // Menu item click handlers that close menus after action const handleMoveMenuItemClick = useCallback((column: Column) => { closeMenus(); handleMove(column); }, [closeMenus]); const handleActionsMenuItemClick = useCallback((action: () => void) => { closeMenus(); action(); }, [closeMenus]); const handleMergeMenuItemClick = useCallback(() => { closeMenus(); void handleMerge(); }, [closeMenus, handleMerge]); const handleCloseRefineModal = useCallback(() => { setShowRefineModal(false); setRefineFeedback(""); setIsRefining(false); }, []); const handleSubmitRefine = useCallback(async () => { if (!refineFeedback.trim()) { addToast("Please enter feedback describing what needs refinement", "error"); return; } if (refineFeedback.length > 2000) { addToast("Feedback must be 2000 characters or less", "error"); return; } setIsRefining(true); try { const newTask = await refineTask(task.id, refineFeedback.trim(), projectId); addToast(`Refinement task created: ${newTask.id}`, "success"); onClose(); } catch (err) { addToast(getErrorMessage(err), "error"); } finally { setIsRefining(false); } }, [task.id, refineFeedback, addToast, onClose]); const uploadFile = useCallback(async (file: File) => { setUploading(true); try { const attachment = await uploadAttachment(task.id, file, projectId); setAttachments((prev) => [...prev, attachment]); addToast("Screenshot attached", "success"); } catch (err) { addToast(getErrorMessage(err), "error"); } finally { setUploading(false); } }, [task.id, addToast]); const handleUpload = useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; await uploadFile(file); if (fileInputRef.current) fileInputRef.current.value = ""; }, [uploadFile]); useEffect(() => { const handlePaste = (e: ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.type.startsWith("image/")) { const file = item.getAsFile(); if (file) { e.preventDefault(); uploadFile(file); return; } } } }; document.addEventListener("paste", handlePaste); return () => document.removeEventListener("paste", handlePaste); }, [uploadFile]); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); const files = e.dataTransfer.files; for (let i = 0; i < files.length; i++) { const file = files[i]; if (file.type.startsWith("image/")) { uploadFile(file); return; } } }, [uploadFile]); const handleDeleteAttachment = useCallback(async (filename: string) => { try { await deleteAttachment(task.id, filename, projectId); setAttachments((prev) => prev.filter((a) => a.filename !== filename)); addToast("Attachment deleted", "info"); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, addToast]); const handleWorkflowStepsChange = useCallback(async (enabledWorkflowSteps: string[]) => { const previousSteps = workflowEnabledSteps; setWorkflowEnabledSteps(enabledWorkflowSteps); try { const updatedTask = await updateTask(task.id, { enabledWorkflowSteps }, projectId); addToast("Workflow steps updated", "success"); onTaskUpdated?.(updatedTask); } catch (err) { setWorkflowEnabledSteps(previousSteps); addToast(`Failed to update workflow steps: ${getErrorMessage(err)}`, "error"); } }, [task.id, projectId, workflowEnabledSteps, onTaskUpdated, addToast]); const loadAgents = useCallback(async () => { setAgentsLoading(true); try { const loadedAgents = await fetchAgents(undefined, projectId); setAgents(loadedAgents); setShowAgentPicker(true); } catch (err) { addToast(`Failed to load agents: ${getErrorMessage(err)}`, "error"); setShowAgentPicker(false); } finally { setAgentsLoading(false); } }, [projectId, addToast]); const handleAssignAgent = useCallback(async (agentId: string) => { try { const updatedTask = await assignTask(task.id, agentId, projectId); const selected = agents.find((agent) => agent.id === agentId) ?? null; if (selected) { setAssignedAgent(selected); } else { setAssignedAgent((prev) => (prev?.id === agentId ? prev : null)); } setShowAgentPicker(false); onTaskUpdated?.(updatedTask); addToast("Assigned agent updated", "success"); } catch (err) { addToast(`Failed to assign agent: ${getErrorMessage(err)}`, "error"); } }, [task.id, projectId, agents, onTaskUpdated, addToast]); const handleClearAgent = useCallback(async () => { try { const updatedTask = await assignTask(task.id, null, projectId); setAssignedAgent(null); setShowAgentPicker(false); onTaskUpdated?.(updatedTask); addToast("Agent unassigned", "success"); } catch (err) { addToast(`Failed to unassign agent: ${getErrorMessage(err)}`, "error"); } }, [task.id, projectId, onTaskUpdated, addToast]); const handleAddDep = useCallback(async (depId: string) => { const newDeps = [...dependencies, depId]; setDependencies(newDeps); try { await updateTask(task.id, { dependencies: newDeps }, projectId); } catch (err) { setDependencies(dependencies); addToast(getErrorMessage(err), "error"); } }, [task.id, dependencies, addToast]); const handleRemoveDep = useCallback(async (e: React.MouseEvent, depId: string) => { e.stopPropagation(); // Prevent triggering dependency click const newDeps = dependencies.filter((d) => d !== depId); setDependencies(newDeps); try { await updateTask(task.id, { dependencies: newDeps }, projectId); } catch (err) { setDependencies(dependencies); addToast(getErrorMessage(err), "error"); } }, [task.id, dependencies, addToast]); const handleDepClick = useCallback(async (depId: string) => { try { const detail = await fetchTaskDetail(depId, projectId); onOpenDetail(detail); } catch { addToast(`Failed to load dependency ${depId}`, "error"); } }, [onOpenDetail, addToast]); // Spec save handlers (must be declared before functions that use them) const handleSaveSpec = useCallback(async (newContent: string) => { setIsSavingSpec(true); try { await updateTask(workingTask.id, { prompt: newContent }, projectId); addToast("Spec updated", "success"); // Update local detail data if (fullDetail) { fullDetail.prompt = newContent; } } catch (err) { addToast(getErrorMessage(err), "error"); throw err; } finally { setIsSavingSpec(false); } }, [workingTask, fullDetail, addToast]); const handleRequestSpecRevision = useCallback(async (feedback: string) => { setIsRequestingRevision(true); try { await requestSpecRevision(task.id, feedback, projectId); addToast("AI revision requested. Task moved to planning.", "success"); // Task has been moved to planning, close modal onClose(); } catch (err) { const msg = getErrorMessage(err); if (msg.includes("in-review") || msg.includes("done")) { addToast("Cannot request revision: Task must be in 'todo' or 'in-progress' column.", "error"); } else { addToast(msg, "error"); } } finally { setIsRequestingRevision(false); } }, [task.id, addToast, onClose]); // Spec editing handlers (depend on handleSaveSpec and handleRequestSpecRevision) const enterSpecEditMode = useCallback(() => { setIsEditingSpec(true); setSpecEditContent(workingTask.prompt || ""); setSpecFeedback(""); }, [workingTask.prompt]); const exitSpecEditMode = useCallback(() => { setIsEditingSpec(false); setSpecEditContent(workingTask.prompt || ""); setSpecFeedback(""); }, [workingTask.prompt]); const handleSaveSpecFromEdit = useCallback(async () => { if (specEditContent === (workingTask.prompt || "")) { exitSpecEditMode(); return; } await handleSaveSpec(specEditContent); setIsEditingSpec(false); }, [specEditContent, workingTask.prompt, handleSaveSpec, exitSpecEditMode]); const handleRequestRevisionFromEdit = useCallback(async () => { if (!specFeedback.trim()) return; await handleRequestSpecRevision(specFeedback.trim()); }, [specFeedback, handleRequestSpecRevision]); // Keyboard shortcuts for spec edit mode const handleSpecTextareaKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); exitSpecEditMode(); } else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); void handleSaveSpecFromEdit(); } }, [exitSpecEditMode, handleSaveSpecFromEdit]); const availableTasks = tasks .filter((t) => t.id !== task.id && !dependencies.includes(t.id)) .sort((a, b) => { const cmp = b.createdAt.localeCompare(a.createdAt); if (cmp !== 0) return cmp; const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0; const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0; return bNum - aNum; }); const assignedAgentLabel = assignedAgent?.name ?? task.assignedAgentId ?? null; const detailProviders = useMemo(() => { const providers: string[] = []; if (workingTask.modelProvider) providers.push(workingTask.modelProvider); if (workingTask.validatorModelProvider && !providers.includes(workingTask.validatorModelProvider)) { providers.push(workingTask.validatorModelProvider); } if (workingTask.planningModelProvider && !providers.includes(workingTask.planningModelProvider)) { providers.push(workingTask.planningModelProvider); } return providers; }, [workingTask.modelProvider, workingTask.validatorModelProvider, workingTask.planningModelProvider]); const transitions = VALID_TRANSITIONS[task.column] || []; const inReviewMoveTransitions: Column[] = ["todo", "in-progress"]; const moveTransitions = task.column === "in-review" ? inReviewMoveTransitions : transitions; const primaryMoveTransition = moveTransitions[0]; const secondaryMoveTransitions = moveTransitions.slice(1); const hasSecondaryMoveOptions = secondaryMoveTransitions.length > 0; const prAutomationStatusLabels: Record = { "creating-pr": "Creating PR…", "awaiting-pr-checks": "Awaiting PR checks", "merging-pr": "Merging PR…", }; const prAutomationLabel = task.status ? prAutomationStatusLabels[task.status] : undefined; return (
{task.id} {COLUMN_LABELS[task.column]}
{!isEditing && canEdit && ( )}
{isEditing ? (
t.id !== task.id)} projectId={projectId} disabled={isSaving} addToast={addToast} isActive={isEditing} onAutoSaveDescription={handleAutoSaveDescription} reviewLevel={editReviewLevel} onReviewLevelChange={setEditReviewLevel} priority={editPriority} onPriorityChange={setEditPriority} executionMode={editExecutionMode} onExecutionModeChange={setEditExecutionMode} renderBelowModelConfiguration={(
setEditSourceIssueProvider(e.target.value)} disabled={isSaving} data-testid="task-source-provider-input" /> setEditSourceIssueRepository(e.target.value)} disabled={isSaving} data-testid="task-source-repository-input" /> setEditSourceIssueExternalId(e.target.value)} disabled={isSaving} data-testid="task-source-external-id-input" /> setEditSourceIssueUrl(e.target.value)} disabled={isSaving} data-testid="task-source-url-input" />
Leave all fields empty to clear source issue metadata.
)} />
) : ( <> {(() => { const displayText = task.title || task.description || task.id; const shouldTruncate = !descriptionExpanded && displayText.length > DESCRIPTION_TRUNCATE_LENGTH; return ( <>

{shouldTruncate ? displayText.slice(0, DESCRIPTION_TRUNCATE_LENGTH) + "…" : displayText}

{displayText.length > DESCRIPTION_TRUNCATE_LENGTH && ( )} ); })()}
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "} {new Date(task.updatedAt).toLocaleDateString()} · Priority: {normalizeTaskPriorityValue(task.priority)}
)} {task.status === "failed" && task.error && (
Task Failed
{task.error}
)} {!isEditing && ( <>
{(task.column === "in-progress" || task.column === "in-review" || task.column === "done") && ( )} {/* Plugin tabs */} {pluginTabSlots.map((entry, index) => { const pluginTabId = `plugin-${index}` as TabId; return ( ); })}
{activeTab === "workflow" ? (
) : activeTab === "model" ? (
) : activeTab === "logs" ? (
{logSubview === "agent-log" ? ( ) : (

Activity

{(workingTask as typeof workingTask & { activityLogTruncatedCount?: number }).activityLogTruncatedCount ? (
Showing the most recent {workingTask.log.length} activity entries.
) : null} {workingTask.log && workingTask.log.length > 0 ? (
{[...workingTask.log].reverse().map((entry, i) => (
{formatTimestamp(entry.timestamp)} {entry.action}
{entry.outcome && (
{entry.outcome}
)}
))}
) : (
(no activity)
)}
)}
) : activeTab === "changes" ? ( ) : activeTab === "comments" ? ( ) : activeTab === "documents" ? ( ) : typeof activeTab === "string" && activeTab.startsWith("plugin-") ? (
) : activeTab === "stats" ? (
) : ( <> {/* Summary section - only for done tasks with summary */} {task.column === "done" && task.summary && (

Summary

{task.summary}
)} {task.sourceIssue && (

Source Issue

Provider
{task.sourceIssue.provider}
Repository
{task.sourceIssue.repository}
Issue Identifier
{task.sourceIssue.externalIssueId}
URL
{task.sourceIssue.url ? ( {task.sourceIssue.url} ) : ( (none) )}
)}
{detailProviders.length > 0 && ( {detailProviders.map((provider) => ( ))} )} Agent
{assignedAgentLabel ? ( {assignedAgentLabel} ) : ( )} {showAgentPicker && (
{agentsLoading &&
Loading agents...
} {!agentsLoading && agents.filter((a) => a.state !== "terminated").map((a) => ( ))} {!agentsLoading && agents.filter((a) => a.state !== "terminated").length === 0 && (
No agents available
)}
)}

Progress

{workingTask.steps && workingTask.steps.length > 0 ? (
{workingTask.steps.map((step, index) => (
))}
{workingTask.steps.filter(s => s.status === "done").length}/{workingTask.steps.length} step{workingTask.steps.length === 1 ? "" : "s"}
) : (
(no steps defined)
)}
{!isEditingSpec && (
)} {isEditingSpec ? (