import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Pencil, Bot, X, ChevronDown } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo, Settings, AgentLogEntry, Agent } from "@fusion/core"; import { COLUMN_LABELS, VALID_TRANSITIONS } 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 { 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"; interface ModelSelection { provider?: string; modelId?: string; } function normalizeModelField(value: string | null | undefined): string | undefined { return value ?? undefined; } function getExecutorSelection(task: Task | TaskDetail): ModelSelection { return { provider: normalizeModelField(task.modelProvider), modelId: normalizeModelField(task.modelId), }; } function getValidatorSelection(task: Task | TaskDetail): ModelSelection { return { provider: normalizeModelField(task.validatorModelProvider), modelId: normalizeModelField(task.validatorModelId), }; } /** * 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/triage 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/triage 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"; 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) => Promise; onMergeTask: (id: string) => Promise; onRetryTask?: (id: string) => Promise; onDuplicateTask?: (id: string) => Promise; onTaskUpdated?: (task: Task) => void; addToast: (message: string, type?: ToastType) => void; githubTokenConfigured?: boolean; /** Open the modal with this tab active instead of "definition" */ initialTab?: TabId; } function truncate(s: string, max: number): string { return s.length > max ? s.slice(0, max) + "…" : s; } const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); export function TaskDetailModal({ task, projectId, tasks = [], onClose, onOpenDetail, onMoveTask, onDeleteTask, onMergeTask, onRetryTask, onDuplicateTask, onTaskUpdated, addToast, githubTokenConfigured, 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. const workingTask: TaskDetail = fullDetail ?? { ...task, prompt: "" } as TaskDetail; const canRetryTask = task.status === "failed" || task.status === "stuck-killed" || task.status === "specifying" || task.status === "needs-respecify" || (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]); const [logSubview, setLogSubview] = useState<"activity" | "agent-log">("activity"); 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 [editSelectedPresetId, setEditSelectedPresetId] = useState(""); const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState(task.enabledWorkflowSteps || []); 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); // 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 || ""); setIsEditing(false); }, [task.id, task.title, task.description]); 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: any) => { if (!cancelled) { addToast(`Failed to load workflow results: ${err.message}`, "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 es = new EventSource(`/api/events${query}`); 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 } }; es.addEventListener("task:updated", handleTaskUpdated); return () => { es.removeEventListener("task:updated", handleTaskUpdated); es.close(); }; }, [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 || []); setEditPendingImages([]); }, [canEdit, task]); const exitEditMode = useCallback(() => { setIsEditing(false); setEditTitle(task.title || ""); setEditDescription(task.description || ""); setEditDependencies(task.dependencies || []); editPendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl)); setEditPendingImages([]); }, [task.title, task.description, task.dependencies, editPendingImages]); const handleSave = useCallback(async () => { setIsSaving(true); try { // Build update payload with all changed fields const executorSlashIdx = editExecutorModel.indexOf("/"); const validatorSlashIdx = editValidatorModel.indexOf("/"); const planningSlashIdx = editPlanningModel.indexOf("/"); const updates: Parameters[1] = { title: editTitle.trim() || undefined, description: editDescription.trim() || undefined, dependencies: editDependencies, enabledWorkflowSteps: editSelectedWorkflowSteps, modelProvider: editExecutorModel && executorSlashIdx !== -1 ? editExecutorModel.slice(0, executorSlashIdx) : null, modelId: editExecutorModel && executorSlashIdx !== -1 ? editExecutorModel.slice(executorSlashIdx + 1) : null, validatorModelProvider: editValidatorModel && validatorSlashIdx !== -1 ? editValidatorModel.slice(0, validatorSlashIdx) : null, validatorModelId: editValidatorModel && validatorSlashIdx !== -1 ? editValidatorModel.slice(validatorSlashIdx + 1) : null, planningModelProvider: editPlanningModel && planningSlashIdx !== -1 ? editPlanningModel.slice(0, planningSlashIdx) : null, planningModelId: editPlanningModel && planningSlashIdx !== -1 ? editPlanningModel.slice(planningSlashIdx + 1) : null, thinkingLevel: editThinkingLevel !== "" ? (editThinkingLevel as "minimal" | "low" | "medium" | "high") : null, }; await updateTask(task.id, updates, projectId); // 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: any) { addToast(`Failed to update ${task.id}: ${err.message}`, "error"); } finally { if (mountedRef.current) { setIsSaving(false); } } }, [task.id, editTitle, editDescription, editDependencies, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editSelectedWorkflowSteps, editPendingImages, addToast, projectId]); const handleAutoSaveDescription = useCallback(async (description: string) => { try { await updateTask(task.id, { description }, projectId); addToast("Description saved", "success"); } catch (err: any) { addToast(`Failed to save: ${err.message}`, "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 { 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 handleOverlayClick = useCallback( (e: React.MouseEvent) => { if (e.target === e.currentTarget) onClose(); }, [onClose], ); const handleMove = useCallback( async (column: Column) => { try { await onMoveTask(task.id, column); onClose(); addToast(`Moved to ${COLUMN_LABELS[column]}`, "success"); } catch (err: any) { addToast(err.message, "error"); } }, [task.id, onMoveTask, onClose, addToast], ); const handleDelete = useCallback(async () => { if (!confirm(`Delete ${task.id}?`)) return; try { await onDeleteTask(task.id); onClose(); addToast(`Deleted ${task.id}`, "info"); } catch (err: any) { addToast(err.message, "error"); } }, [task.id, onDeleteTask, onClose, addToast]); const handleMerge = useCallback(() => { if (!confirm(`Merge ${task.id} into the current branch?`)) 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: any) => { addToast(err.message, "error"); }); }, [task.id, onMergeTask, onClose, addToast]); const handleRetry = useCallback(async () => { if (!onRetryTask) return; try { await onRetryTask(task.id); onClose(); addToast(`Retrying ${task.id}...`, "info"); } catch (err: any) { addToast(err.message, "error"); } }, [task.id, onRetryTask, onClose, addToast]); const handleDuplicate = useCallback(async () => { if (!onDuplicateTask) return; if (!confirm(`Duplicate ${task.id}? This will create a new task in Triage with the same description and prompt.`)) return; try { const newTask = await onDuplicateTask(task.id); onClose(); addToast(`Duplicated ${task.id} → ${newTask.id}`, "success"); } catch (err: any) { addToast(err.message, "error"); } }, [task.id, onDuplicateTask, onClose, addToast]); 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: any) { addToast(err.message, "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: any) { addToast(err.message, "error"); } }, [task.id, onClose, addToast]); const handleRejectPlan = useCallback(async () => { if (!confirm("Reject this plan? The specification will be discarded and regenerated.")) return; try { await rejectPlan(task.id, projectId); addToast(`Plan rejected — ${task.id} returned to Triage for re-specification`, "info"); onClose(); } catch (err: any) { addToast(err.message, "error"); } }, [task.id, onClose, addToast]); const handleRespecify = useCallback(async () => { if (!confirm("Rebuild the specification for this task? The task will move to triage for re-specification.")) return; try { await rebuildTaskSpec(task.id, projectId); onClose(); addToast(`Respecifying ${task.id}...`, "info"); } catch (err: any) { addToast(err.message, "error"); } }, [task.id, projectId, onClose, addToast]); 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(); handleMerge(); }, [closeMenus]); 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: any) { addToast(err.message, "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: any) { addToast(err.message, "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: any) { addToast(err.message, "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: any) { setWorkflowEnabledSteps(previousSteps); addToast(`Failed to update workflow steps: ${err.message}`, "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: any) { addToast(`Failed to load agents: ${err.message}`, "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: any) { addToast(`Failed to assign agent: ${err.message}`, "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: any) { addToast(`Failed to unassign agent: ${err.message}`, "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: any) { setDependencies(dependencies); addToast(err.message, "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: any) { setDependencies(dependencies); addToast(err.message, "error"); } }, [task.id, dependencies, addToast]); const handleDepClick = useCallback(async (depId: string) => { try { const detail = await fetchTaskDetail(depId, projectId); onOpenDetail(detail); } catch (err: any) { 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: any) { addToast(err.message, "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 triage.", "success"); // Task has been moved to triage, close modal onClose(); } catch (err: any) { if (err.message?.includes("in-review") || err.message?.includes("done")) { addToast("Cannot request revision: Task must be in 'todo' or 'in-progress' column.", "error"); } else { addToast(err.message, "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 transitions = VALID_TRANSITIONS[task.column] || []; 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} />
) : ( <>

{task.title || task.description}

Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "} {new Date(task.updatedAt).toLocaleDateString()}
)} {task.status === "failed" && task.error && (
Task Failed
{task.error}
)} {!isEditing && ( <>
{(task.column === "in-progress" || task.column === "in-review" || task.column === "done") && ( )}
{activeTab === "workflow" ? (
) : activeTab === "model" ? (
) : activeTab === "logs" ? (
{logSubview === "agent-log" ? ( ) : (

Activity

{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" ? ( ) : ( <> {/* Summary section - only for done tasks with summary */} {task.column === "done" && task.summary && (

Summary

{task.summary}
)}
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} steps
) : (
(no steps defined)
)}
{!isEditingSpec && (
)} {isEditingSpec ? (