import "./TaskDetailModal.css"; import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle } from "lucide-react"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import ReactMarkdown from "react-markdown"; import type { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core"; import { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, REPO_OVERRIDE_RE, TASK_PRIORITIES, VALID_TRANSITIONS, getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel, } from "@fusion/core"; import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus } from "../api"; import type { RecoverBranchBindingOutcome } from "../api"; 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 { PrPanel } from "./PrPanel"; import { PrCreateModal } from "./PrCreateModal"; import { TaskComments } from "./TaskComments"; import { TaskReviewTab } from "./TaskReviewTab"; import { MergeDetails } from "./MergeDetails"; import { TaskChangesTab } from "./TaskChangesTab"; import { TaskForm, type PendingImage } from "./TaskForm"; import { useNodes } from "../hooks/useNodes"; import { WorkflowResultsTab } from "./WorkflowResultsTab"; import { RoutingTab } from "./RoutingTab"; import { TaskDocumentsTab } from "./TaskDocumentsTab"; import { TaskTokenStatsPanel } from "./TaskTokenStatsPanel"; import { BranchGroupCard } from "./BranchGroupCard"; import { PluginSlot } from "./PluginSlot"; import { ProviderIcon } from "./ProviderIcon"; import { subscribeSse } from "../sse-bus"; import { usePluginUiSlots } from "../hooks/usePluginUiSlots"; import { appendTokenQuery } from "../auth"; import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete"; import { MAX_AUTO_MERGE_RETRIES, computeBlockerFanoutMap } from "../hooks/useBlockerFanout"; import { resolveEffectiveGithubRepoDefault } from "./githubTracking"; import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy"; import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy"; import { getTaskAgeStalenessCopy } from "../utils/taskAgeStalenessCopy"; import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry"; interface ModelSelection { provider?: string; modelId?: string; } const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]); const STALE_PAUSED_REVIEW_LOG_REGEX = /^Stale paused review surfaced \[([^\]]+)\]/; const markdownLinkifyComponents: Components = { p: ({ children, ...props }) =>

{linkifyReactChildren(children)}

, li: ({ children, ...props }) =>
  • {linkifyReactChildren(children)}
  • , code: ({ children, ...props }) => { const text = typeof children === "string" ? children : React.Children.toArray(children).join(""); const linkedChildren = linkifyFilePaths(text); if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") { return {children}; } return {linkedChildren}; }, }; /** * Resolve the effective executor model following the engine's resolution order: * 1. Per-task modelProvider/modelId (both must be set) * 2. Project/global execution lane fallback */ function extractExecutorModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null { let result: { provider: string; modelId: string } | null = null; for (const entry of entries) { if (entry.agent !== "executor" || entry.type !== "text") continue; const match = entry.text.match(/^Executor using model: (.+?)\/(.+)$/); if (match) { result = { provider: match[1], modelId: match[2] }; } } return result; } function extractReviewerModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null { let result: { provider: string; modelId: string } | null = null; for (const entry of entries) { if (entry.agent !== "reviewer" || entry.type !== "text") continue; const match = entry.text.match(/^Reviewer using model: (.+?)\/(.+)$/); if (match) { result = { provider: match[1], modelId: match[2] }; } } return result; } function hasUsableTrackingTitle(task: { title?: string | null; description?: string | null }): boolean { if ((task.title ?? "").trim().length > 0) { return true; } const firstMeaningfulLine = (task.description ?? "") .split(/\r?\n/) .map((line) => line.trim()) .find((line) => line.length > 0); return Boolean(firstMeaningfulLine); } function extractAssignedRuntimeModel(agent: Agent | null | undefined): ModelSelection { const runtimeConfig = (agent?.runtimeConfig ?? undefined) as Record | undefined; const model = typeof runtimeConfig?.model === "string" ? runtimeConfig.model.trim() : ""; if (model) { const slashIdx = model.indexOf("/"); if (slashIdx > 0 && slashIdx < model.length - 1) { return { provider: model.slice(0, slashIdx), modelId: model.slice(slashIdx + 1), }; } } const provider = typeof runtimeConfig?.modelProvider === "string" ? runtimeConfig.modelProvider.trim() : ""; const modelId = typeof runtimeConfig?.modelId === "string" ? runtimeConfig.modelId.trim() : ""; return { provider: provider || undefined, modelId: modelId || undefined, }; } /** * Resolve the effective executor model following the engine's resolution order: * 1. Runtime executor model from agent log marker * 2. Assigned agent runtime model (active runs only) * 3. Per-task modelProvider/modelId override * 4. Project/global execution lane fallback */ function resolveEffectiveExecutor( task: Task | TaskDetail, logEntries: AgentLogEntry[], assignedAgent: Agent | null, settings?: Settings, ): ModelSelection { const fromLog = extractExecutorModelFromLog(logEntries); if (fromLog) return fromLog; if (ACTIVE_STATUSES.has(task.status ?? "") || task.column === "in-progress") { const assignedModel = extractAssignedRuntimeModel(assignedAgent); if (assignedModel.provider && assignedModel.modelId) { return assignedModel; } } return resolveTaskExecutionModel(task, settings); } /** * Resolve the effective validator model following the engine's resolution order: * 1. Runtime reviewer model from agent log marker * 2. Assigned agent runtime model (active runs only) * 3. Per-task validatorModelProvider/validatorModelId override * 4. Project/global validator lane fallback */ function resolveEffectiveValidator( task: Task | TaskDetail, logEntries: AgentLogEntry[], assignedAgent: Agent | null, settings?: Settings, ): ModelSelection { const fromLog = extractReviewerModelFromLog(logEntries); if (fromLog) return fromLog; if (ACTIVE_STATUSES.has(task.status ?? "") || task.column === "in-progress") { const assignedModel = extractAssignedRuntimeModel(assignedAgent); if (assignedModel.provider && assignedModel.modelId) { return assignedModel; } } return resolveTaskValidatorModel(task, settings); } /** * 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/global planning lane fallback */ 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; } return resolveTaskPlanningModel(task, settings); } function getStepStatusColor(status: string): string { switch (status) { case "done": return "var(--color-success)"; case "in-progress": return "var(--in-progress)"; case "skipped": return "var(--text-dim)"; case "pending": default: return "var(--border)"; } } 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(undefined, { month: "short", day: "numeric" }); } 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`; } function formatDurationCompact(ageMs: number): string { const totalMinutes = Math.max(1, Math.floor(ageMs / 60_000)); const days = Math.floor(totalMinutes / (24 * 60)); const hours = Math.floor((totalMinutes % (24 * 60)) / 60); const minutes = totalMinutes % 60; if (days > 0) return `${days}d ${hours}h`; if (hours > 0) return `${hours}h ${minutes}m`; return `${minutes}m`; } type TabId = "definition" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | `plugin-${string}`; export interface TaskDetailModalProps { task: Task | TaskDetail; projectId?: string; tasks?: Task[]; onClose: () => void; onOpenDetail: (task: Task | TaskDetail) => void; // For clicking dependencies onMoveTask: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise; onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; allowResurrection?: boolean; }) => Promise; onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise; onMergeTask: (id: string) => Promise; onRetryTask?: (id: string) => Promise; onResetTask?: (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; /** Mobile-only header affordance mode. */ mobileHeaderMode?: "close" | "back"; } export type TaskDetailContentProps = Omit & { embedded?: boolean; onRequestClose?: () => void; }; 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"; } interface ProvenanceDisplay { label: string; parentTaskId?: string; contextInfo?: string; contextHref?: string; contextInfoFull?: string; sourceAgentId?: string; } interface ProvenanceLabelOptions { sourceAgentName?: string; } function getIssueUrlFromMetadata(metadata: Task["sourceMetadata"]): string | undefined { const issueUrl = metadata?.issueUrl; return typeof issueUrl === "string" && issueUrl.length > 0 ? issueUrl : undefined; } function parseGithubIssueLabel(url: string): { label: string; href: string } | null { const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:$|[/?#])/); if (!match) { return null; } const [, owner, repo, , number] = match; return { label: `${owner}/${repo}#${number}`, href: url, }; } function getResearchContextInfo(metadata: Task["sourceMetadata"]): string | undefined { const findingLabel = metadata?.findingLabel; if (typeof findingLabel === "string" && findingLabel.length > 0) { return findingLabel; } const runId = metadata?.runId; return typeof runId === "string" && runId.length > 0 ? runId : undefined; } const AgentDetailView = lazy(() => import("./AgentDetailView").then((m) => ({ default: m.AgentDetailView }))); function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOptions = {}): ProvenanceDisplay | null { switch (task.sourceType) { case "dashboard_ui": return { label: "Dashboard" }; case "quick_chat": return { label: "Quick Chat" }; case "chat_session": return { label: "Chat Session" }; case "agent_heartbeat": { const sourceLabel = options.sourceAgentName ?? task.sourceAgentId; return { label: sourceLabel ?? "agent", sourceAgentId: task.sourceAgentId, }; } case "automation": return { label: "Automation" }; case "cron": return { label: "Scheduled Task" }; case "workflow_step": return { label: "Workflow Step" }; case "github_import": { const issueUrl = getIssueUrlFromMetadata(task.sourceMetadata); const parsedIssue = issueUrl ? parseGithubIssueLabel(issueUrl) : null; return { label: "GitHub Import", contextInfo: issueUrl ? (parsedIssue?.label ?? "Open issue") : undefined, contextHref: issueUrl, contextInfoFull: issueUrl, }; } case "research": { const contextInfo = getResearchContextInfo(task.sourceMetadata); return { label: "Research", contextInfo, contextInfoFull: contextInfo, }; } case "task_refine": return { label: "Refinement", parentTaskId: task.sourceParentTaskId, }; case "task_duplicate": return { label: "Duplicate", parentTaskId: task.sourceParentTaskId, }; case "cli": return { label: "CLI" }; case "api": return { label: "API" }; case "recovery": return { label: "Recovery" }; case "unknown": default: return null; } } const DESCRIPTION_TRUNCATE_LENGTH = 200; const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); const GITHUB_TRACKING_EDITABLE_COLUMNS: Set = new Set(["triage", "todo", "in-progress", "in-review"]); export function TaskDetailContent({ task, projectId, tasks = [], onOpenDetail, onMoveTask, onDeleteTask, onArchiveTask, onMergeTask, onRetryTask, onResetTask, onDuplicateTask, onTaskUpdated, addToast, prAuthAvailable, initialTab = "definition", mobileHeaderMode = "close", embedded = false, onRequestClose, }: TaskDetailContentProps) { const [activeTab, setActiveTab] = useState(initialTab === "retries" ? "definition" : 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. // FN-4161: board/restart flows open the modal from slim task rows where // `githubTracking` is intentionally omitted; preserve the fetched full-detail // tracking blob instead of letting the sparse parent prop overwrite it. const workingTask: TaskDetail = fullDetail ? ({ ...fullDetail, ...task, prompt: fullDetail.prompt, log: fullDetail.log, githubTracking: task.githubTracking ?? fullDetail.githubTracking, } 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); const nearDuplicateOf = typeof workingTask.sourceMetadata?.nearDuplicateOf === "string" ? workingTask.sourceMetadata.nearDuplicateOf : null; const showNearDuplicateWarning = Boolean(nearDuplicateOf) && workingTask.sourceMetadata?.nearDuplicateDismissed !== true && task.column !== "archived" && task.column !== "done"; const [sourceAgent, setSourceAgent] = useState(null); const [selectedSourceAgentId, setSelectedSourceAgentId] = useState(null); const provenanceDisplay = getProvenanceLabel(workingTask, { sourceAgentName: sourceAgent?.name, }); // Sync activeTab when the caller changes initialTab (e.g. opening a different tab) useEffect(() => { setActiveTab(initialTab === "retries" ? "definition" : initialTab); if (initialTab === "retries") { setRetriesExpanded(true); } }, [initialTab]); useEffect(() => { if (activeTab === "pr" && task.column !== "in-review") { setActiveTab("definition"); } }, [activeTab, task.column]); // Reset description expanded state when task changes useEffect(() => { setDescriptionExpanded(false); }, [task.id]); const [logSubview, setLogSubview] = useState<"activity" | "agent-log">("activity"); const [highlightStallCode, setHighlightStallCode] = useState(null); 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 [prCreateOpen, setPrCreateOpen] = useState(false); useEffect(() => { if (activeTab !== "logs" || logSubview !== "activity") { setHighlightStallCode(null); return; } if (!highlightStallCode) { return; } const highlighted = activityListRef.current?.querySelector("[data-stall-highlight=\"true\"]"); if (highlighted && typeof highlighted.scrollIntoView === "function") { highlighted.scrollIntoView({ block: "nearest", behavior: "smooth" }); } }, [activeTab, logSubview, highlightStallCode]); 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 [editBranch, setEditBranch] = useState(task.branch ?? ""); const [editBaseBranch, setEditBaseBranch] = useState(task.baseBranch ?? ""); 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 [editNodeId, setEditNodeId] = useState(task.nodeId); 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 [inlinePriority, setInlinePriority] = useState(normalizeTaskPriorityValue(task.priority)); const [isSavingInlinePriority, setIsSavingInlinePriority] = useState(false); const [inlineExecutionMode, setInlineExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode)); const [isSavingInlineExecutionMode, setIsSavingInlineExecutionMode] = useState(false); const [inlineNoCommitsExpected, setInlineNoCommitsExpected] = useState(task.noCommitsExpected === true); const [isSavingInlineNoCommitsExpected, setIsSavingInlineNoCommitsExpected] = useState(false); const mountedRef = useRef(false); const activeTaskIdRef = useRef(task.id); // Split-menu dropdown state for footer actions const [showMoveMenu, setShowMoveMenu] = useState(false); const [showActionsMenu, setShowActionsMenu] = useState(false); const [sourceIssueExpanded, setSourceIssueExpanded] = useState(false); const [retriesExpanded, setRetriesExpanded] = useState(initialTab === "retries"); const [githubTrackingExpanded, setGithubTrackingExpanded] = useState(false); const [githubRepoOverrideDraft, setGithubRepoOverrideDraft] = useState(task.githubTracking?.repoOverride ?? ""); const [githubTrackingEnabledDraft, setGithubTrackingEnabledDraft] = useState(null); const [githubRepoOverrideError, setGithubRepoOverrideError] = useState(null); const [isSavingGithubTracking, setIsSavingGithubTracking] = useState(false); const [isRecoveringBranchBinding, setIsRecoveringBranchBinding] = useState(false); const [isCheckingPrStatus, setIsCheckingPrStatus] = useState(false); const [recoverBranchBindingOutcome, setRecoverBranchBindingOutcome] = useState(null); const moveMenuRef = useRef(null); const activityListRef = useRef(null); const moveButtonRef = useRef(null); const actionsMenuRef = useRef(null); // Plugin UI slots for task-detail-tab const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId); const pluginTabSlots = getPluginSlots("task-detail-tab"); const pluginTabs = pluginTabSlots.map((entry, index) => ({ entry, tabId: `plugin-${entry.pluginId}-${index}` as TabId, })); const activePluginTab = typeof activeTab === "string" && activeTab.startsWith("plugin-") ? pluginTabs.find((tab) => tab.tabId === activeTab) ?? null : null; // Track mount state to avoid setting state on unmounted component useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); useEffect(() => { activeTaskIdRef.current = task.id; }, [task.id]); // Merged project settings for effective model resolution in Agent Log header const [settings, setSettings] = useState(undefined); const [globalSettings, setGlobalSettings] = useState(null); // Workflow results state const [workflowResults, setWorkflowResults] = useState([]); const [workflowResultsLoading, setWorkflowResultsLoading] = useState(false); const [workflowEnabledSteps, setWorkflowEnabledSteps] = useState(task.enabledWorkflowSteps || []); const isNodeOverrideLocked = task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string); // Reset edit state when task changes useEffect(() => { setEditTitle(task.title || ""); setEditDescription(task.description || ""); setEditBranch(task.branch ?? ""); setEditBaseBranch(task.baseBranch ?? ""); setEditSourceIssueProvider(task.sourceIssue?.provider ?? ""); setEditSourceIssueRepository(task.sourceIssue?.repository ?? ""); setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? ""); setEditSourceIssueUrl(task.sourceIssue?.url ?? ""); setEditExecutionMode(normalizeExecutionModeValue(task.executionMode)); setSourceIssueExpanded(false); setGithubTrackingExpanded(false); setGithubRepoOverrideDraft(workingTask.githubTracking?.repoOverride ?? ""); setGithubTrackingEnabledDraft(null); setGithubRepoOverrideError(null); setIsEditing(false); setRecoverBranchBindingOutcome(null); setIsRecoveringBranchBinding(false); }, [task.id, task.title, task.description, task.branch, task.baseBranch, task.sourceIssue, task.executionMode, workingTask.githubTracking]); useEffect(() => { setWorkflowEnabledSteps(task.enabledWorkflowSteps || []); }, [task.id, task.enabledWorkflowSteps]); useEffect(() => { setInlinePriority(normalizeTaskPriorityValue(task.priority)); }, [task.id, task.priority]); useEffect(() => { setInlineExecutionMode(normalizeExecutionModeValue(task.executionMode)); }, [task.id, task.executionMode]); useEffect(() => { setInlineNoCommitsExpected(task.noCommitsExpected === true); }, [task.id, task.noCommitsExpected]); useEffect(() => { if (githubTrackingEnabledDraft === null) return; if ((workingTask.githubTracking?.enabled === true) === githubTrackingEnabledDraft) { setGithubTrackingEnabledDraft(null); } }, [githubTrackingEnabledDraft, workingTask.githubTracking?.enabled]); // 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" }); fetchGlobalSettings() .then((nextGlobalSettings) => { if (!cancelled) setGlobalSettings(nextGlobalSettings); }) .catch(() => { if (!cancelled) setGlobalSettings(null); }); 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(() => { if (!task.sourceAgentId) { setSourceAgent(null); return; } const knownAgent = agents.find((agent) => agent.id === task.sourceAgentId); if (knownAgent) { setSourceAgent(knownAgent); return; } let cancelled = false; void Promise.resolve(fetchAgent(task.sourceAgentId, projectId)) .then((agent) => { if (!cancelled) setSourceAgent(agent ?? null); }) .catch(() => { if (!cancelled) setSourceAgent(null); }); return () => { cancelled = true; }; }, [task.sourceAgentId, 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 canEditGithubTracking = GITHUB_TRACKING_EDITABLE_COLUMNS.has(task.column) && !isSaving; const githubTrackingEnabled = githubTrackingEnabledDraft ?? (workingTask.githubTracking?.enabled === true); const githubTrackedIssue = workingTask.githubTracking?.issue; const githubTrackingDetailPending = detailLoading && typeof task.githubTracking === "undefined"; const canCreateTrackingIssue = hasUsableTrackingTitle(task); const showInlineGithubTrackingEnableButton = canEditGithubTracking && !githubTrackedIssue && !githubTrackingDetailPending && (!githubTrackingEnabled || (isSavingGithubTracking && workingTask.githubTracking?.enabled !== true)); const showGithubTrackingSection = canEditGithubTracking || githubTrackingEnabled || Boolean(githubTrackedIssue); const retrySummary = task.retrySummary; const retryRows = [ { key: "stuckKill", label: "Stuck kills", title: "Stuck-task detector forced agent kill retries", value: retrySummary?.stuckKill ?? 0 }, { key: "recovery", label: "Recovery retries", title: "Transient executor recovery retries", value: retrySummary?.recovery ?? 0 }, { key: "taskDone", label: "task_done retries", title: "Agent exited without task_done and task was retried", value: retrySummary?.taskDone ?? 0 }, { key: "workflowStep", label: "Workflow retries", title: "Workflow step failure retries", value: retrySummary?.workflowStep ?? 0 }, { key: "verification", label: "Verification bounces", title: "Verification failure bounce retries", value: retrySummary?.verification ?? 0 }, { key: "postReviewFix", label: "Post-review fixes", title: "Post-review remediation retries", value: retrySummary?.postReviewFix ?? 0 }, { key: "mergeConflict", label: "Merge conflict bounces", title: "Merge conflict bounce retries", value: retrySummary?.mergeConflict ?? 0 }, { key: "branchConflict", label: "Branch conflict recovery", title: "FN-4068 branch-conflict recovery retries", value: retrySummary?.branchConflict ?? 0 }, { key: "reviewerContext", label: "Reviewer context retries", title: "FN-4082 compact reviewer retry", value: retrySummary?.reviewerContext ?? 0 }, { key: "reviewerFallback", label: "Reviewer fallback retries", title: "FN-4092 fallback-model retry", value: retrySummary?.reviewerFallback ?? 0 }, ].filter((row) => row.value > 0); const githubTrackingStatus = githubTrackingDetailPending ? "Loading" : githubTrackedIssue ? "Linked" : githubTrackingEnabled ? "Enabled" : "Disabled"; const showGithubTrackingSpinner = !githubTrackedIssue && (isSavingGithubTracking || githubTrackingDetailPending); const effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings ?? null, globalSettings); const githubRepoOverrideTrimmed = githubRepoOverrideDraft.trim(); const handleToggleGithubTracking = useCallback(async () => { if (!canEditGithubTracking || isSavingGithubTracking) return; const requestTaskId = task.id; const nextEnabled = !githubTrackingEnabled; setGithubTrackingEnabledDraft(nextEnabled); setIsSavingGithubTracking(true); try { const updatedTask = await updateTask(task.id, { githubTracking: { enabled: nextEnabled, }, }, projectId); if (activeTaskIdRef.current !== requestTaskId) { return; } setFullDetail((prev) => prev ? ({ ...prev, ...updatedTask, githubTracking: updatedTask.githubTracking } as TaskDetail) : (updatedTask as TaskDetail)); onTaskUpdated?.(updatedTask); } catch (err) { if (activeTaskIdRef.current !== requestTaskId) { return; } setGithubTrackingEnabledDraft(workingTask.githubTracking?.enabled === true); addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error"); } finally { if (mountedRef.current && activeTaskIdRef.current === requestTaskId) setIsSavingGithubTracking(false); } }, [addToast, canEditGithubTracking, githubTrackingEnabled, isSavingGithubTracking, onTaskUpdated, projectId, workingTask.githubTracking?.enabled, task.id]); const handleSaveGithubRepoOverride = useCallback(async () => { if (!canEditGithubTracking || isSavingGithubTracking) return; const requestTaskId = task.id; if (githubRepoOverrideTrimmed.length > 0 && !REPO_OVERRIDE_RE.test(githubRepoOverrideTrimmed)) { setGithubRepoOverrideError("Repository override must be in owner/repo format"); return; } setGithubRepoOverrideError(null); setIsSavingGithubTracking(true); try { const updatedTask = await updateTask(task.id, { githubTracking: { repoOverride: githubRepoOverrideTrimmed.length > 0 ? githubRepoOverrideTrimmed : null, }, }, projectId); if (activeTaskIdRef.current !== requestTaskId) { return; } setFullDetail((prev) => prev ? ({ ...prev, ...updatedTask, githubTracking: updatedTask.githubTracking } as TaskDetail) : (updatedTask as TaskDetail)); onTaskUpdated?.(updatedTask); } catch (err) { if (activeTaskIdRef.current !== requestTaskId) { return; } addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error"); } finally { if (mountedRef.current && activeTaskIdRef.current === requestTaskId) setIsSavingGithubTracking(false); } }, [addToast, canEditGithubTracking, githubRepoOverrideTrimmed, isSavingGithubTracking, onTaskUpdated, projectId, task.id]); const handleRetryGithubTrackingIssueCreate = useCallback(async () => { if (!githubTrackingEnabled || githubTrackedIssue || isSavingGithubTracking) return; if (!hasUsableTrackingTitle(task)) { addToast("Add a title before creating a tracking issue", "info"); return; } const requestTaskId = task.id; setIsSavingGithubTracking(true); try { const updatedTask = await updateTask(task.id, { githubTracking: { enabled: true, }, }, projectId); if (activeTaskIdRef.current !== requestTaskId) { return; } setFullDetail((prev) => prev ? ({ ...prev, ...updatedTask, githubTracking: updatedTask.githubTracking } as TaskDetail) : (updatedTask as TaskDetail)); onTaskUpdated?.(updatedTask); addToast("Requested GitHub tracking issue creation", "info"); } catch (err) { if (activeTaskIdRef.current !== requestTaskId) { return; } addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error"); } finally { if (mountedRef.current && activeTaskIdRef.current === requestTaskId) setIsSavingGithubTracking(false); } }, [addToast, githubTrackedIssue, githubTrackingEnabled, isSavingGithubTracking, onTaskUpdated, projectId, task]); const enterEditMode = useCallback(() => { if (!canEdit) return; setIsEditing(true); setEditTitle(task.title || ""); setEditDescription(task.description || ""); setEditDependencies(task.dependencies || []); setEditBranch(task.branch ?? ""); setEditBaseBranch(task.baseBranch ?? ""); // 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 ?? ""); setEditNodeId(task.nodeId); 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 || []); setEditBranch(task.branch ?? ""); setEditBaseBranch(task.baseBranch ?? ""); setEditNodeId(task.nodeId); 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.nodeId, task.priority, task.executionMode, editPendingImages]); const [editAutoSaveStatus, setEditAutoSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle"); const editAutoSaveTimeoutRef = useRef | null>(null); const editAutoSaveRevisionRef = useRef(0); const buildEditUpdates = useCallback((includeDescription: boolean) => { const updates: Record = {}; const trimmedTitle = editTitle.trim(); const trimmedDescription = editDescription.trim(); if (trimmedTitle && trimmedTitle !== (task.title ?? "")) updates.title = trimmedTitle; if (includeDescription && trimmedDescription && trimmedDescription !== (task.description ?? "")) updates.description = trimmedDescription; if (!sameStringArray(editDependencies, task.dependencies ?? [])) updates.dependencies = editDependencies; if (!sameStringArray(editSelectedWorkflowSteps, task.enabledWorkflowSteps ?? [])) updates.enabledWorkflowSteps = editSelectedWorkflowSteps; const normalizedBranch = editBranch.trim() || null; const currentBranch = task.branch ?? null; if (normalizedBranch !== currentBranch) updates.branch = normalizedBranch; const normalizedBaseBranch = editBaseBranch.trim() || null; const currentBaseBranch = task.baseBranch ?? null; if (normalizedBaseBranch !== currentBaseBranch) updates.baseBranch = normalizedBaseBranch; 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; if ((task.nodeId ?? undefined) !== editNodeId) updates.nodeId = editNodeId ?? null; if (editReviewLevel !== task.reviewLevel) updates.reviewLevel = editReviewLevel; if (editPriority !== normalizeTaskPriorityValue(task.priority)) updates.priority = editPriority; if (editExecutionMode !== normalizeExecutionModeValue(task.executionMode)) 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) { return { updates: null, error: "Source issue provider, repository, and issue identifier are required" }; } const fallbackIssueNumber = Number.parseInt(normalizedExternalId, 10); const issueNumber = task.sourceIssue?.issueNumber ?? fallbackIssueNumber; if (!Number.isFinite(issueNumber) || issueNumber <= 0) { return { updates: null, error: "Source issue identifier must be numeric for new metadata" }; } 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; } return { updates, error: null as string | null }; }, [editBaseBranch, editBranch, editDependencies, editDescription, editExecutionMode, editExecutorModel, editNodeId, editPlanningModel, editPriority, editReviewLevel, editSelectedWorkflowSteps, editSourceIssueExternalId, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueUrl, editThinkingLevel, editTitle, editValidatorModel, task]); const persistEditChanges = useCallback(async (includeDescription: boolean) => { const { updates, error } = buildEditUpdates(includeDescription); if (!updates) { setEditAutoSaveStatus("error"); if (error) { addToast(`Failed to update ${task.id}: ${error}`, "error"); } return false; } if (Object.keys(updates).length === 0) { return true; } const revision = ++editAutoSaveRevisionRef.current; setIsSaving(true); setEditAutoSaveStatus("saving"); try { const updatedTask = await updateTask(task.id, updates as never, projectId); if (revision !== editAutoSaveRevisionRef.current) return; onTaskUpdated?.(updatedTask); setEditAutoSaveStatus("saved"); return true; } catch (err) { if (revision === editAutoSaveRevisionRef.current) { setEditAutoSaveStatus("error"); addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error"); } return false; } finally { if (mountedRef.current && revision === editAutoSaveRevisionRef.current) { setIsSaving(false); } } }, [addToast, buildEditUpdates, onTaskUpdated, projectId, task.id]); const handleAutoSaveDescription = useCallback(async (_description: string) => { await persistEditChanges(true); }, [persistEditChanges]); const handleSave = useCallback(async () => { const didSave = await persistEditChanges(true); if (!didSave) { return; } addToast(`Updated ${task.id}`, "success"); if (mountedRef.current) { setIsEditing(false); } }, [addToast, persistEditChanges, task.id]); useEffect(() => { if (!isEditing) return; if (editAutoSaveTimeoutRef.current) { clearTimeout(editAutoSaveTimeoutRef.current); } editAutoSaveTimeoutRef.current = setTimeout(() => { void persistEditChanges(false); }, 700); return () => { if (editAutoSaveTimeoutRef.current) { clearTimeout(editAutoSaveTimeoutRef.current); editAutoSaveTimeoutRef.current = null; } }; }, [ isEditing, editTitle, editDependencies, editBranch, editBaseBranch, editExecutorModel, editValidatorModel, editPlanningModel, editThinkingLevel, editNodeId, editReviewLevel, editPriority, editExecutionMode, editSelectedWorkflowSteps, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueExternalId, editSourceIssueUrl, persistEditChanges, ]); const handleInlinePriorityChange = useCallback(async (nextValue: string) => { const normalizedNextPriority = normalizeTaskPriorityValue(nextValue as Task["priority"]); const currentPriority = normalizeTaskPriorityValue(task.priority); if (normalizedNextPriority === currentPriority) { setInlinePriority(currentPriority); return; } const previousPriority = inlinePriority; setInlinePriority(normalizedNextPriority); setIsSavingInlinePriority(true); try { const updatedTask = await updateTask(task.id, { priority: normalizedNextPriority }, projectId); setInlinePriority(normalizeTaskPriorityValue(updatedTask.priority)); onTaskUpdated?.(updatedTask); addToast(`Priority updated to ${normalizeTaskPriorityValue(updatedTask.priority)}`, "success"); } catch (err) { setInlinePriority(previousPriority); addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error"); } finally { if (mountedRef.current) { setIsSavingInlinePriority(false); } } }, [task.id, task.priority, projectId, inlinePriority, onTaskUpdated, addToast]); const handleInlineExecutionModeToggle = useCallback(async () => { const currentMode = normalizeExecutionModeValue(task.executionMode); const nextMode = currentMode === "fast" ? "standard" : "fast"; const previousMode = inlineExecutionMode; setInlineExecutionMode(nextMode); setIsSavingInlineExecutionMode(true); try { const updatedTask = await updateTask(task.id, { executionMode: nextMode === "fast" ? "fast" : null }, projectId); const normalizedUpdatedMode = normalizeExecutionModeValue(updatedTask.executionMode); setInlineExecutionMode(normalizedUpdatedMode); onTaskUpdated?.(updatedTask); addToast(`Execution mode updated to ${normalizedUpdatedMode}`, "success"); } catch (err) { setInlineExecutionMode(previousMode); addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error"); } finally { if (mountedRef.current) { setIsSavingInlineExecutionMode(false); } } }, [task.id, task.executionMode, projectId, inlineExecutionMode, onTaskUpdated, addToast]); const handleInlineNoCommitsExpectedToggle = useCallback(async () => { const nextValue = !inlineNoCommitsExpected; const previousValue = inlineNoCommitsExpected; setInlineNoCommitsExpected(nextValue); setIsSavingInlineNoCommitsExpected(true); try { const updatedTask = await updateTask(task.id, { noCommitsExpected: nextValue }, projectId); const normalizedUpdatedValue = updatedTask.noCommitsExpected === true; setInlineNoCommitsExpected(normalizedUpdatedValue); onTaskUpdated?.(updatedTask); addToast(`No-commits expectation ${normalizedUpdatedValue ? "enabled" : "disabled"}`, "success"); } catch (err) { setInlineNoCommitsExpected(previousValue); addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error"); } finally { if (mountedRef.current) { setIsSavingInlineNoCommitsExpected(false); } } }, [task.id, projectId, inlineNoCommitsExpected, onTaskUpdated, addToast]); // 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(); void handleSave(); } }, [isEditing, exitEditMode, handleSave]); useEffect(() => { if (!isEditing) return; document.addEventListener("keydown", handleEditKeyDown); return () => document.removeEventListener("keydown", handleEditKeyDown); }, [isEditing, handleEditKeyDown]); const fileInputRef = useRef(null); const { nodes } = useNodes(); const { confirm, confirmWithChoice, confirmWithCheckbox } = useConfirm(); const handleUnlinkGithubIssue = useCallback(async () => { if (!canEdit || !githubTrackedIssue || isSavingGithubTracking) return; const confirmed = await confirm({ title: "Unlink GitHub issue?", message: "This stops Fusion from syncing with the linked GitHub issue. The issue itself will not be modified.", confirmLabel: "Unlink", danger: true, }); if (!confirmed) return; setIsSavingGithubTracking(true); try { const updatedTask = await updateTask(task.id, { githubTracking: { issue: null } }, projectId); onTaskUpdated?.(updatedTask); addToast("GitHub issue unlinked", "success"); } catch (err) { addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error"); } finally { if (mountedRef.current) setIsSavingGithubTracking(false); } }, [addToast, canEdit, confirm, githubTrackedIssue, isSavingGithubTracking, onTaskUpdated, projectId, task.id]); const { entries: agentLogEntries, loading: agentLogLoading, loadMore: loadMoreAgentLogs, hasMore: agentLogHasMore, total: agentLogTotal, loadingMore: agentLogLoadingMore, } = useAgentLogs( task.id, activeTab === "logs" && logSubview === "agent-log", projectId, ); const requestClose = useCallback(() => { onRequestClose?.(); }, [onRequestClose]); useEffect(() => { if (embedded) return; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !isEditing) requestClose(); }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); }, [embedded, requestClose, isEditing]); const handleMove = useCallback( async (column: Column) => { try { const hasStepProgress = task.steps.some((step) => step.status !== "pending"); const shouldPrompt = (column === "todo" || column === "triage") && hasStepProgress; let moveOptions: { preserveProgress?: boolean } | undefined; if (shouldPrompt) { const keepProgress = await confirm({ title: "Preserve Progress?", message: "This task has completed steps. Keep progress before moving?", confirmLabel: "Keep Progress", cancelLabel: "Reset Progress", }); if (keepProgress) { moveOptions = { preserveProgress: true }; } else { const resetProgress = await confirm({ title: "Reset Progress?", message: "Reset all step progress before moving this task?", confirmLabel: "Reset Progress", cancelLabel: "Cancel Move", danger: true, }); if (!resetProgress) { return; } } } await onMoveTask(task.id, column, moveOptions); requestClose(); addToast(`Moved to ${COLUMN_LABELS[column]}`, "success"); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, task.steps, onMoveTask, requestClose, addToast, confirm], ); const handleDelete = useCallback(async () => { let allowResurrection = false; if (task.column === "done" && onArchiveTask) { const deleteChoice = await confirmWithChoice({ title: "Delete Task", message: `Delete ${task.id}?`, confirmLabel: "Delete", cancelLabel: "Cancel", tertiaryLabel: "Archive Instead", danger: true, }); if (deleteChoice === "tertiary") { try { await onArchiveTask(task.id); addToast(`Archived ${task.id}`, "success"); requestClose(); } catch (err) { const lineageConflict = extractLineageDeleteConflict(err); if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) { addToast(getErrorMessage(err), "error"); return; } const confirmedArchive = await confirm({ title: "Force Delete Task", message: `${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` + "Archive anyway by unlinking these references first?", danger: true, }); if (!confirmedArchive) { return; } try { await onArchiveTask(task.id, { removeLineageReferences: true }); addToast(`Archived ${task.id} after unlinking lineage references`, "success"); requestClose(); } catch (retryErr) { addToast(getErrorMessage(retryErr), "error"); } } return; } if (deleteChoice !== "primary") { return; } } else { const { choice, checkboxValue } = await confirmWithCheckbox({ title: "Delete Task", message: `Delete ${task.id}?`, danger: true, checkbox: { label: "Allow re-creation later (operator unlock)", description: "Lets agents recreate this task ID without --force-resurrect. Leave unchecked to keep this task tombstoned.", defaultChecked: false, }, }); if (choice !== "primary") return; allowResurrection = checkboxValue === true; } const trackedIssue = task.githubTracking?.enabled === true ? task.githubTracking.issue : undefined; let githubIssueAction: GithubIssueAction | undefined; if (trackedIssue?.owner && trackedIssue.repo && trackedIssue.number) { const issueRef = `${trackedIssue.owner}/${trackedIssue.repo}#${trackedIssue.number}`; const shouldCloseIssue = await confirm({ title: "Linked GitHub Issue", message: `Choose what to do with ${issueRef} when deleting ${task.id}.\n\nClose the issue?`, confirmLabel: "Close Issue", cancelLabel: "More Options", }); if (shouldCloseIssue) { githubIssueAction = "close"; } else { const shouldDeleteIssue = await confirm({ title: "Delete Linked GitHub Issue", message: `Delete ${issueRef} on GitHub, or leave it unchanged?`, confirmLabel: "Delete Issue", cancelLabel: "Leave Unchanged", danger: true, }); githubIssueAction = shouldDeleteIssue ? "delete" : "leave"; } } try { if (githubIssueAction) { await onDeleteTask(task.id, { githubIssueAction, allowResurrection }); } else { await onDeleteTask(task.id, { allowResurrection }); } requestClose(); const issueSuffix = trackedIssue?.owner && trackedIssue.repo && trackedIssue.number && githubIssueAction ? ` and ${githubIssueAction === "close" ? "closed" : githubIssueAction === "delete" ? "deleted" : "left"} issue ${trackedIssue.owner}/${trackedIssue.repo}#${trackedIssue.number}` : ""; addToast(`Deleted ${task.id}${issueSuffix}`, "info"); } catch (err) { const dependencyConflict = extractDependencyDeleteConflict(err); if (dependencyConflict && dependencyConflict.dependentIds.length > 0) { const dependentList = dependencyConflict.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, removeLineageReferences: true, githubIssueAction, allowResurrection, }); requestClose(); addToast(`Deleted ${task.id} after removing dependency references`, "info"); } catch (retryErr) { const lineageConflict = extractLineageDeleteConflict(retryErr); if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) { addToast(getErrorMessage(retryErr), "error"); return; } const confirmedLineage = await confirm({ title: "Force Delete Task", message: `${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` + "Delete anyway by unlinking these references first?", danger: true, }); if (!confirmedLineage) { return; } try { await onDeleteTask(task.id, { removeDependencyReferences: true, removeLineageReferences: true, githubIssueAction, allowResurrection, }); requestClose(); addToast(`Deleted ${task.id} after unlinking lineage references`, "info"); } catch (lineageRetryErr) { addToast(getErrorMessage(lineageRetryErr), "error"); } } return; } const lineageConflict = extractLineageDeleteConflict(err); if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) { addToast(getErrorMessage(err), "error"); return; } const confirmed = await confirm({ title: "Force Delete Task", message: `${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` + "Delete anyway by unlinking these references first?", danger: true, }); if (!confirmed) { return; } try { await onDeleteTask(task.id, { removeDependencyReferences: true, removeLineageReferences: true, githubIssueAction, allowResurrection, }); requestClose(); addToast(`Deleted ${task.id} after unlinking lineage references`, "info"); } catch (retryErr) { addToast(getErrorMessage(retryErr), "error"); } } }, [task.column, task.githubTracking?.enabled, task.githubTracking?.issue, task.id, onDeleteTask, onArchiveTask, requestClose, addToast, confirm, confirmWithChoice, confirmWithCheckbox]); const handleMerge = useCallback(async () => { const shouldMerge = await confirm({ title: "Merge Task", message: `Merge ${task.id} into the current branch?`, }); if (!shouldMerge) return; requestClose(); 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, requestClose, addToast, confirm]); const handleRetry = useCallback(() => { if (!onRetryTask) return; requestClose(); onRetryTask(task.id) .then(() => { addToast(`Retried ${task.id}`, "success"); }) .catch((err) => { addToast(getErrorMessage(err), "error"); }); }, [task.id, onRetryTask, requestClose, addToast]); const handleReset = useCallback(() => { if (!onResetTask) return; if (!window.confirm(`This will erase all progress for ${task.id} and start the task from scratch. Continue?`)) return; requestClose(); onResetTask(task.id) .then(() => { addToast(`Reset ${task.id} — fresh run will be allocated`, "success"); }) .catch((err) => { addToast(getErrorMessage(err), "error"); }); }, [task.id, onResetTask, requestClose, 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); requestClose(); addToast(`Duplicated ${task.id} → ${newTask.id}`, "success"); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, onDuplicateTask, requestClose, addToast, confirm]); const handleDismissNearDuplicate = useCallback(async () => { try { const updatedTask = await updateTask(task.id, { dismissNearDuplicate: true }, projectId); onTaskUpdated?.(updatedTask); addToast(`Kept ${task.id} and dismissed duplicate warning`, "success"); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, projectId, onTaskUpdated, addToast]); const handleArchiveNearDuplicate = useCallback(async () => { if (!onArchiveTask) return; const confirmed = await confirm({ title: "Archive near-duplicate task", message: `Archive ${task.id} as a duplicate of ${nearDuplicateOf}?`, confirmLabel: "Archive", cancelLabel: "Cancel", danger: true, }); if (!confirmed) return; try { await onArchiveTask(task.id); addToast(`Archived ${task.id}`, "success"); requestClose(); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [onArchiveTask, confirm, task.id, nearDuplicateOf, addToast, requestClose]); const isTaskPaused = task.paused || task.userPaused; const showRecoverBranchBindingBanner = task.column === "in-review" && !task.branch; const handleRecoverBranchBinding = useCallback(async () => { setIsRecoveringBranchBinding(true); try { const outcome = await recoverBranchBinding(task.id, projectId); setRecoverBranchBindingOutcome(outcome); if (outcome.result === "applied") { addToast(`Reattached branch for ${task.id} (${outcome.branch})`, "success"); onTaskUpdated?.({ ...task, branch: outcome.branch, worktree: undefined }); } else { addToast(`Branch reattachment skipped for ${task.id}: ${outcome.reason}`, "info"); } } catch (err) { addToast(getErrorMessage(err), "error"); } finally { setIsRecoveringBranchBinding(false); } }, [addToast, onTaskUpdated, projectId, task]); const handleTogglePause = useCallback(async () => { try { if (isTaskPaused) { await unpauseTask(task.id, projectId); addToast(`Unpaused ${task.id}`, "success"); } else { await pauseTask(task.id, projectId); addToast(`Paused ${task.id}`, "success"); } requestClose(); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [isTaskPaused, task.id, requestClose, addToast]); const handleApprovePlan = useCallback(async () => { try { await approvePlan(task.id, projectId); addToast(`Plan approved — ${task.id} moved to Todo`, "success"); requestClose(); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, requestClose, 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"); requestClose(); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, requestClose, 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); requestClose(); addToast(`Replanning ${task.id}...`, "info"); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, projectId, requestClose, 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 handleCheckPrStatus = useCallback(async () => { if (isCheckingPrStatus) return; closeMenus(); setIsCheckingPrStatus(true); try { const result = await refreshPrStatus(task.id, projectId); addToast("PR status refreshed", "success"); onTaskUpdated?.({ ...task, prInfo: result.prInfo, prInfos: result.all?.map((entry) => entry.prInfo) ?? task.prInfos, }); } catch (err) { addToast(getErrorMessage(err), "error"); } finally { setIsCheckingPrStatus(false); } }, [addToast, closeMenus, isCheckingPrStatus, onTaskUpdated, projectId, task]); 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"); requestClose(); } catch (err) { addToast(getErrorMessage(err), "error"); } finally { setIsRefining(false); } }, [task.id, refineFeedback, addToast, requestClose]); 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 handleClearOverlapBlocker = useCallback(async () => { if (!workingTask.overlapBlockedBy) return; const requestTaskId = task.id; const previousOverlapBlockedBy = workingTask.overlapBlockedBy; const previousStatus = workingTask.status; setFullDetail((prev) => prev ? { ...prev, overlapBlockedBy: undefined, ...(previousStatus === "queued" ? { status: undefined } : {}), } : prev); try { const updatedTask = await updateTask(task.id, { overlapBlockedBy: null, status: previousStatus === "queued" ? null : undefined, }, projectId); if (activeTaskIdRef.current !== requestTaskId) { return; } setFullDetail((prev) => prev ? ({ ...prev, ...updatedTask } as TaskDetail) : (updatedTask as TaskDetail)); onTaskUpdated?.(updatedTask); } catch (err) { if (activeTaskIdRef.current !== requestTaskId) { return; } setFullDetail((prev) => prev ? { ...prev, overlapBlockedBy: previousOverlapBlockedBy, ...(previousStatus === "queued" ? { status: previousStatus } : {}), } : prev); addToast(getErrorMessage(err), "error"); } }, [activeTaskIdRef, addToast, onTaskUpdated, projectId, task.id, workingTask.overlapBlockedBy, workingTask.status]); 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 requestClose(); } catch (err) { const msg = getErrorMessage(err); if (msg.includes("done") || msg.includes("archived")) { addToast("Cannot request revision: Task must be in 'triage', 'todo', 'in-progress', or 'in-review' column.", "error"); } else { addToast(msg, "error"); } } finally { setIsRequestingRevision(false); } }, [task.id, addToast, requestClose]); // 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; } // Exit edit mode immediately so the UI transitions back to preview as soon // as save is initiated. If save fails, restore edit mode for retry. setIsEditingSpec(false); try { await handleSaveSpec(specEditContent); } catch (err) { setIsEditingSpec(true); throw err; } }, [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 blockerFanoutMap = useMemo(() => computeBlockerFanoutMap(tasks), [tasks]); const blockingEntry = blockerFanoutMap.get(task.id); const blockingDependents = useMemo(() => { if (!blockingEntry) return [] as Array<{ id: string; label: string; stale: boolean }>; const staleSet = new Set(blockingEntry.staleBlockedByDependentIds); return blockingEntry.dependentIds.map((dependentId) => { const dependentTask = tasks.find((candidate) => candidate.id === dependentId); return { id: dependentId, label: dependentTask?.title || dependentTask?.description || dependentId, stale: staleSet.has(dependentId), }; }); }, [blockingEntry, tasks]); const overlapBlockingSummary = blockingEntry ? `${task.id} is blocking ${blockingEntry.overlapBlockedTodoCount} todo task(s) via blockedBy overlap` : null; const overlapBlockerTask = workingTask.overlapBlockedBy ? tasks.find((candidate) => candidate.id === workingTask.overlapBlockedBy) : undefined; const overlapBlockerActive = Boolean( overlapBlockerTask && (overlapBlockerTask.column === "in-progress" || overlapBlockerTask.column === "in-review"), ); 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 closeMoveMenuAndFocusTrigger = useCallback(() => { setShowMoveMenu(false); moveButtonRef.current?.focus(); }, []); const handleMoveButtonClick = useCallback((event: React.MouseEvent) => { if (!hasSecondaryMoveOptions) { if (primaryMoveTransition) { void handleMoveMenuItemClick(primaryMoveTransition); } return; } const arrowZone = event.currentTarget.querySelector(".detail-move-btn__arrow"); const clickedArrow = Boolean( (event.target instanceof Element && event.target.closest(".detail-move-btn__arrow")) || (arrowZone && event.clientX > 0 && event.clientX >= arrowZone.getBoundingClientRect().left), ); if (clickedArrow) { setShowMoveMenu((prev) => !prev); setShowActionsMenu(false); return; } if (primaryMoveTransition) { void handleMoveMenuItemClick(primaryMoveTransition); } }, [hasSecondaryMoveOptions, primaryMoveTransition, handleMoveMenuItemClick]); const handleMoveButtonKeyDown = useCallback((event: React.KeyboardEvent) => { if (!hasSecondaryMoveOptions) { return; } const shouldOpenMenu = event.key === "ArrowDown" || (event.altKey && event.key === "ArrowDown"); if (!shouldOpenMenu) { return; } event.preventDefault(); setShowMoveMenu(true); setShowActionsMenu(false); }, [hasSecondaryMoveOptions]); const handleMoveMenuKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key !== "Escape") { return; } event.preventDefault(); event.stopPropagation(); closeMoveMenuAndFocusTrigger(); }, [closeMoveMenuAndFocusTrigger]); useEffect(() => { if (!showMoveMenu) { return; } const firstMenuItem = moveMenuRef.current?.querySelector(".detail-move-menu-item"); firstMenuItem?.focus(); }, [showMoveMenu]); const prAutomationStatusLabels: Record = { "creating-pr": "Creating PR…", "awaiting-pr-checks": "Awaiting PR checks", "merging-pr": "Merging PR…", "merging-fix": "Merging fixes…", }; const prAutomationLabel = task.status ? prAutomationStatusLabels[task.status] : undefined; const mergeStrategy = settings?.mergeStrategy ?? "direct"; const autoMergeEnabled = settings?.autoMerge ?? false; const isManualPrFlow = mergeStrategy === "pull-request" && !autoMergeEnabled; let manualReviewActionLabel = "Merge & Close"; if (isManualPrFlow && !prAutomationLabel) { if (!task.prInfo) { manualReviewActionLabel = "Start PR Review"; } else if (task.prInfo.status === "open") { manualReviewActionLabel = "Check PR Status"; } else if (task.prInfo.status === "merged") { manualReviewActionLabel = "Finish & Close"; } } return (
    {task.id} {COLUMN_LABELS[task.column]}
    {!isEditing && canEdit && ( )} {!embedded && mobileHeaderMode === "back" && ( )} {!embedded && mobileHeaderMode !== "back" && ( )}
    {isEditing ? (
    t.id !== task.id)} projectId={projectId} disabled={isSaving} addToast={addToast} isActive={isEditing} onAutoSaveDescription={handleAutoSaveDescription} reviewLevel={editReviewLevel} onReviewLevelChange={setEditReviewLevel} priority={editPriority} onPriorityChange={setEditPriority} nodeId={editNodeId} onNodeIdChange={setEditNodeId} nodeOptions={nodes} nodeOverrideDisabled={isNodeOverrideLocked} nodeOverrideDisabledReason={isNodeOverrideLocked ? "Execution node override is locked while a task is active/in progress." : undefined} 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 && ( )} ); })()} {showNearDuplicateWarning && (

    This task appears to be a near-duplicate of{" "} . Choose Archive to move this task to archived, or Keep to continue with this task.

    {onArchiveTask && ( )}
    )}
    {provenanceDisplay && (
    )} {(task.prInfo?.number || task.mergeDetails?.prNumber) && (
    )}
    Created{" "} Updated{" "}
    {task.branchContext?.groupId && ( )} )} {task.status === "failed" && task.error && (
    Task Failed
    {task.error}
    )} {task.pausedReason === "worktrunk_operation_failed" && (
    Worktrunk operation failed
    {task.worktrunkFailure?.stderr && (
    {task.worktrunkFailure.stderr.slice(0, 2048)}
    )}
    )} {!isEditing && ( <>
    {(task.column === "in-progress" || task.column === "in-review" || task.column === "done") && ( )} {task.column === "in-review" && ( )} {/* Plugin tabs */} {pluginTabs.map(({ entry, 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 ? (
    {(() => { let highlightedOnce = false; return [...workingTask.log].reverse().map((entry, i) => { const stallMatch = entry.action.match(IN_REVIEW_STALL_LOG_REGEX) ?? entry.action.match(STALE_PAUSED_REVIEW_LOG_REGEX); const isHighlighted = !highlightedOnce && highlightStallCode != null && stallMatch?.[1] === highlightStallCode; if (isHighlighted) { highlightedOnce = true; } return (
    {formatTimestamp(entry.timestamp)} {entry.action}
    {entry.outcome && (
    {entry.outcome}
    )}
    ); }); })()}
    ) : (
    (no activity)
    )}
    )}
    ) : activeTab === "changes" ? ( ) : activeTab === "review" ? ( setPrCreateOpen(true)} /> ) : activeTab === "pr" ? (
    {task.column === "in-review" && ( <> {shouldShowInReviewStallBadge(workingTask) && workingTask.inReviewStall && (() => { const copy = getInReviewStallCopy(workingTask.inReviewStall, { mergeRetries: workingTask.mergeRetries, maxAutoMergeRetries: MAX_AUTO_MERGE_RETRIES, }); const logMatch = findInReviewStallLogEntry(workingTask, workingTask.inReviewStall.code); return (
    {copy.badgeLabel}{copy.counter ? ` ${copy.counter}` : ""} {copy.headline}
    {workingTask.inReviewStall.reason}
    {copy.description}
    {copy.suggestedAction}
    Observed {formatTimestamp(workingTask.inReviewStall.observedAt)} {logMatch ? ( ) : ( No log entry yet )}
    ); })()} {shouldShowStalePausedReviewBadge(workingTask) && workingTask.stalePausedReview && (() => { const copy = getStalePausedReviewCopy(workingTask.stalePausedReview); const logMatch = [...(workingTask.log ?? [])].reverse().find((entry) => { const match = entry.action.match(STALE_PAUSED_REVIEW_LOG_REGEX); return match?.[1] === workingTask.stalePausedReview?.code; }); return (
    {copy.badgeLabel} {copy.headline}
    {workingTask.stalePausedReview.reason}
    {copy.description}
    {copy.suggestedAction}
    Age {formatDurationCompact(workingTask.stalePausedReview.ageMs)} Threshold {formatDurationCompact(workingTask.stalePausedReview.thresholdMs)} Observed {formatTimestamp(workingTask.stalePausedReview.observedAt)} {logMatch ? ( ) : ( No log entry yet )}
    ); })()}
    setPrCreateOpen(true)} onPrUpdated={(prInfo) => { const existing = task.prInfos ?? (task.prInfo ? [task.prInfo] : []); const nextPrInfos = existing.some((entry) => entry.number === prInfo.number) ? existing.map((entry) => (entry.number === prInfo.number ? prInfo : entry)) : [...existing, prInfo]; (task as TaskDetail).prInfos = nextPrInfos; (task as TaskDetail).prInfo = nextPrInfos[0] ?? prInfo; }} onPrsRefreshed={(prInfos) => { (task as TaskDetail).prInfos = prInfos; (task as TaskDetail).prInfo = prInfos[0]; }} onPrUnlinked={(prNumber) => { const nextPrInfos = (task.prInfos ?? (task.prInfo ? [task.prInfo] : [])).filter((entry) => entry.number !== prNumber); (task as TaskDetail).prInfos = nextPrInfos; (task as TaskDetail).prInfo = nextPrInfos[0]; }} addToast={addToast} />
    )}
    ) : activeTab === "comments" ? ( ) : activeTab === "documents" ? ( ) : activePluginTab ? (
    ) : activeTab === "stats" ? (
    ) : activeTab === "routing" ? (
    ) : ( <> {/* Summary section - only for done tasks with summary */} {task.column === "done" && task.summary && (

    Summary

    {task.summary}
    )} {(retrySummary?.total ?? 0) > 0 && (
    Retries {retrySummary?.total ?? 0}
    {retriesExpanded && (
    {retryRows.map((row) => (
    {row.label}
    {row.value}
    ))}
    )} {settings?.maxTotalRetriesBeforeFail != null && (retrySummary?.total ?? 0) >= settings.maxTotalRetriesBeforeFail && (

    Retry cap reached for this task.

    )}
    )} {task.sourceIssue && (
    Source issue {task.sourceIssue.provider.toLowerCase() === "github" && ( )} {task.sourceIssue.url ? ( {`(#${task.sourceIssue.issueNumber})`} ) : ( {`(#${task.sourceIssue.issueNumber})`} )}
    {sourceIssueExpanded && (
    Provider
    {task.sourceIssue.provider}
    Repository
    {task.sourceIssue.repository}
    Issue Identifier
    {task.sourceIssue.externalIssueId}
    URL
    {task.sourceIssue.url ? ( {task.sourceIssue.url} ) : ( (none) )}
    )}
    )} {showGithubTrackingSection && (
    GitHub tracking {!githubTrackedIssue && ( {githubTrackingDetailPending ? "Checking tracking status" : githubTrackingEnabled ? "Issue not yet created" : "Tracking is currently disabled"} )}
    {showInlineGithubTrackingEnableButton && ( )} {showGithubTrackingSpinner && ( )}
    {githubTrackingExpanded && (
    {githubTrackedIssue && (
    Issue
    {githubTrackedIssue.url ? ( {`${githubTrackedIssue.owner}/${githubTrackedIssue.repo}#${githubTrackedIssue.number}`} ) : ( {`${githubTrackedIssue.owner}/${githubTrackedIssue.repo}#${githubTrackedIssue.number}`} )}
    State
    {task.issueInfo?.state ?? "open"}
    )}
    {!githubTrackedIssue && githubTrackingEnabled && ( <> {!canCreateTrackingIssue && ( Tracking issue will be created once this task has a title or description to summarize. )} )} {canEditGithubTracking && ( <>
    { setGithubRepoOverrideDraft(event.target.value); setGithubRepoOverrideError(null); }} placeholder={effectiveGithubRepoDefault || "owner/repo"} />
    {githubRepoOverrideError && {githubRepoOverrideError}} {githubTrackedIssue && ( )} )}
    )}
    )}
    {detailProviders.length > 0 && ( {detailProviders.map((provider) => ( ))} )} Agent
    {assignedAgentLabel ? ( {assignedAgentLabel} ) : ( )} {showAgentPicker && (
    {agentsLoading &&
    Loading agents...
    } {!agentsLoading && agents.map((a) => ( ))} {!agentsLoading && agents.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 ? (