import { useCallback, useEffect, useRef, useState } from "react"; import { Pencil } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, PrInfo } from "@kb/core"; import { COLUMN_LABELS, VALID_TRANSITIONS } from "@kb/core"; import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, requestSpecRevision, approvePlan, rejectPlan, refineTask } from "../api"; import type { ToastType } from "../hooks/useToast"; import { useAgentLogs } from "../hooks/useAgentLogs"; import { AgentLogViewer } from "./AgentLogViewer"; import { SteeringTab } from "./SteeringTab"; import { ModelSelectorTab } from "./ModelSelectorTab"; import { PrSection } from "./PrSection"; import { SpecEditor } from "./SpecEditor"; import { FileBrowserModal } from "./FileBrowserModal"; 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), }; } 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`; } interface TaskDetailModalProps { task: TaskDetail; tasks?: Task[]; onClose: () => void; onOpenDetail: (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; addToast: (message: string, type?: ToastType) => void; githubTokenConfigured?: boolean; } 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, tasks = [], onClose, onOpenDetail, onMoveTask, onDeleteTask, onMergeTask, onRetryTask, onDuplicateTask, addToast, githubTokenConfigured, }: TaskDetailModalProps) { const [activeTab, setActiveTab] = useState<"definition" | "activity" | "agent-log" | "steering" | "model" | "spec" | "files">("definition"); 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 [isSavingSpec, setIsSavingSpec] = useState(false); const [isRequestingRevision, setIsRequestingRevision] = useState(false); 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 [isSaving, setIsSaving] = useState(false); const titleInputRef = useRef(null); // Reset edit state when task changes useEffect(() => { setEditTitle(task.title || ""); setEditDescription(task.description || ""); setIsEditing(false); }, [task.id, task.title, task.description]); // Reset dependency search when dropdown closes useEffect(() => { if (!showDepDropdown) { setDepSearch(""); } }, [showDepDropdown]); // Auto-focus title when entering edit mode useEffect(() => { if (isEditing) { titleInputRef.current?.focus(); titleInputRef.current?.select(); } }, [isEditing]); // Check if task can be edited const canEdit = EDITABLE_COLUMNS.has(task.column) && !isSaving; const hasChanges = editTitle !== (task.title || "") || editDescription !== (task.description || ""); const enterEditMode = useCallback(() => { if (!canEdit) return; setIsEditing(true); setEditTitle(task.title || ""); setEditDescription(task.description || ""); }, [canEdit, task.title, task.description]); const exitEditMode = useCallback(() => { setIsEditing(false); setEditTitle(task.title || ""); setEditDescription(task.description || ""); }, [task.title, task.description]); const handleSave = useCallback(async () => { if (!hasChanges) { exitEditMode(); return; } setIsSaving(true); try { await updateTask(task.id, { title: editTitle.trim() || undefined, description: editDescription.trim() || undefined, }); addToast(`Updated ${task.id}`, "success"); setIsEditing(false); } catch (err: any) { addToast(`Failed to update ${task.id}: ${err.message}`, "error"); // Stay in edit mode on error so user can retry } finally { setIsSaving(false); } }, [task.id, editTitle, editDescription, hasChanges, exitEditMode, addToast]); const handleTitleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); // Move focus to description textarea const textarea = document.querySelector('.modal-edit-textarea') as HTMLTextAreaElement; textarea?.focus(); } else if (e.key === "Escape") { e.preventDefault(); exitEditMode(); } }, [exitEditMode]); const handleDescKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); handleSave(); } else if (e.key === "Escape") { e.preventDefault(); exitEditMode(); } }, [handleSave, exitEditMode]); const handleDescChange = useCallback((e: React.ChangeEvent) => { setEditDescription(e.target.value); // Auto-resize textarea const el = e.target; el.style.height = "auto"; el.style.height = el.scrollHeight + "px"; }, []); const fileInputRef = useRef(null); const { entries: agentLogEntries, loading: agentLogLoading } = useAgentLogs( task.id, activeTab === "agent-log", ); 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); addToast(`Unpaused ${task.id}`, "success"); } else { await pauseTask(task.id); 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); 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); 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 handleOpenRefineModal = useCallback(() => { setShowRefineModal(true); setRefineFeedback(""); }, []); 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()); 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); 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); setAttachments((prev) => prev.filter((a) => a.filename !== filename)); addToast("Attachment deleted", "info"); } catch (err: any) { addToast(err.message, "error"); } }, [task.id, addToast]); const handleAddDep = useCallback(async (depId: string) => { const newDeps = [...dependencies, depId]; setDependencies(newDeps); try { await updateTask(task.id, { dependencies: newDeps }); } 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 }); } 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); onOpenDetail(detail); } catch (err: any) { addToast(`Failed to load dependency ${depId}`, "error"); } }, [onOpenDetail, addToast]); const handleSaveSpec = useCallback(async (newContent: string) => { setIsSavingSpec(true); try { await updateTask(task.id, { prompt: newContent }); addToast("Spec updated", "success"); // Update local task data task.prompt = newContent; } catch (err: any) { addToast(err.message, "error"); throw err; } finally { setIsSavingSpec(false); } }, [task, addToast]); const handleRequestSpecRevision = useCallback(async (feedback: string) => { setIsRequestingRevision(true); try { await requestSpecRevision(task.id, feedback); 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]); 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 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 ? (
setEditTitle(e.target.value)} onKeyDown={handleTitleKeyDown} disabled={isSaving} />