import "./TaskDetailModal.css"; import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; 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 { useColumnLabel } from "../i18n/labels"; import ReactMarkdown from "react-markdown"; import type { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import type { Task, TaskDetail, TaskAttachment, Column, ColumnId, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core"; import { DEFAULT_TASK_PRIORITY, REPO_OVERRIDE_RE, TASK_PRIORITIES, VALID_TRANSITIONS, isColumn, getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel, } from "@fusion/core"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, api } from "../api"; import type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api"; import { ApiRequestError } from "../api"; import { TaskFieldsSection } from "./TaskFieldsSection"; 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 type { SessionTerminalMode, SessionTerminalPosture } from "./SessionTerminal"; 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 type { TFunction } from "i18next"; 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" | "terminal" | `plugin-${string}`; // Lazy-load the terminal so xterm + addons stay out of the main bundle (U11). const LazySessionTerminal = lazy(() => import("./SessionTerminal").then((m) => ({ default: m.SessionTerminal })), ); /** CLI session record fields the terminal tab needs (mirrors @fusion/core CliSession). */ export interface CliSessionSummaryRecord { id: string; taskId: string | null; projectId: string; adapterId: string; agentState: | "starting" | "ready" | "busy" | "waitingOnInput" | "done" | "dead" | "needsAttention"; terminationReason: string | null; autonomyPosture?: Record | null; } type CliTabVisibility = | { kind: "hidden" } | { kind: "live"; readOnly: boolean; mode: SessionTerminalMode; showConfirmAdvance: boolean } | { kind: "replay"; mode: SessionTerminalMode }; /** * Tab visibility matrix (U11): * - starting/ready/busy/waitingOnInput → live terminal * - one-shot (planning/validator) live → read-only live + badge * - done (resumable) → replay "session idle" * - dead/needsAttention (PTY reaped) → replay "session ended" * - no recorded session → hidden */ export function deriveCliTabVisibility( session: CliSessionSummaryRecord | null, opts: { oneShot?: boolean; genericIdle?: boolean } = {}, ): CliTabVisibility { if (!session) return { kind: "hidden" }; const live = session.agentState === "starting" || session.agentState === "ready" || session.agentState === "busy" || session.agentState === "waitingOnInput"; if (live) { return { kind: "live", readOnly: Boolean(opts.oneShot), mode: "live", showConfirmAdvance: Boolean(opts.genericIdle), }; } if (session.agentState === "done") { // execute-done but resumable → scrollback replay with a "session idle" header. return { kind: "replay", mode: "idle" }; } // dead / needsAttention → PTY reaped → "session ended". return { kind: "replay", mode: "ended" }; } 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; onOpenWorkflowEditor?: () => void; /** Open the modal with this tab active instead of "definition" */ initialTab?: TabId; /** Mobile-only header affordance mode. */ mobileHeaderMode?: "close" | "back"; /** Pre-resolved workflow field defs for this task's workflow (U13/KTD-14). * When provided (e.g. threaded from a Board that already holds the payload) * the modal skips its own board-workflows fetch entirely. Falls back to the * self-fetch when absent (e.g. modal opened from non-board contexts). */ workflowFieldDefs?: WorkflowFieldDefinition[] | null; } 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; t?: TFunction<"app">; } 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 { const tr = options.t; switch (task.sourceType) { case "dashboard_ui": return { label: tr ? tr("taskDetail.provenance.dashboard", "Dashboard") : "Dashboard" }; case "quick_chat": return { label: tr ? tr("taskDetail.provenance.quickChat", "Quick Chat") : "Quick Chat" }; case "chat_session": return { label: tr ? tr("taskDetail.provenance.chatSession", "Chat Session") : "Chat Session" }; case "agent_heartbeat": { const sourceLabel = options.sourceAgentName ?? task.sourceAgentId; return { label: sourceLabel ?? (tr ? tr("taskDetail.provenance.agent", "agent") : "agent"), sourceAgentId: task.sourceAgentId, }; } case "automation": return { label: tr ? tr("taskDetail.provenance.automation", "Automation") : "Automation" }; case "cron": return { label: tr ? tr("taskDetail.provenance.scheduledTask", "Scheduled Task") : "Scheduled Task" }; case "workflow_step": return { label: tr ? tr("taskDetail.provenance.workflowStep", "Workflow Step") : "Workflow Step" }; case "github_import": { const issueUrl = getIssueUrlFromMetadata(task.sourceMetadata); const parsedIssue = issueUrl ? parseGithubIssueLabel(issueUrl) : null; return { label: tr ? tr("taskDetail.provenance.githubImport", "GitHub Import") : "GitHub Import", contextInfo: issueUrl ? (parsedIssue?.label ?? (tr ? tr("taskDetail.provenance.openIssue", "Open issue") : "Open issue")) : undefined, contextHref: issueUrl, contextInfoFull: issueUrl, }; } case "research": { const contextInfo = getResearchContextInfo(task.sourceMetadata); return { label: tr ? tr("taskDetail.provenance.research", "Research") : "Research", contextInfo, contextInfoFull: contextInfo, }; } case "task_refine": return { label: tr ? tr("taskDetail.provenance.refinement", "Refinement") : "Refinement", parentTaskId: task.sourceParentTaskId, }; case "task_duplicate": return { label: tr ? tr("taskDetail.provenance.duplicate", "Duplicate") : "Duplicate", parentTaskId: task.sourceParentTaskId, }; case "cli": return { label: tr ? tr("taskDetail.provenance.cli", "CLI") : "CLI" }; case "api": return { label: tr ? tr("taskDetail.provenance.api", "API") : "API" }; case "recovery": return { label: tr ? tr("taskDetail.provenance.recovery", "Recovery") : "Recovery" }; case "unknown": default: return null; } } const DESCRIPTION_TRUNCATE_LENGTH = 200; // #1403: widened to ColumnId so `.has(task.column)` accepts custom column ids // (non-members correctly resolve to false → not editable). 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, onOpenWorkflowEditor, initialTab = "definition", mobileHeaderMode = "close", embedded = false, onRequestClose, workflowFieldDefs: workflowFieldDefsProp, }: TaskDetailContentProps) { const { t } = useTranslation("app"); const columnLabel = useColumnLabel(); const [activeTab, setActiveTab] = useState(initialTab === "retries" ? "definition" : initialTab); // ── CLI agent session (U11) ──────────────────────────────────────────────── const [cliSession, setCliSession] = useState(null); // ── 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, t, }); // 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(task.column === "triage"); }, [task.column, task.id]); const [logSubview, setLogSubview] = useState<"activity" | "agent-log">("activity"); const [highlightStallCode, setHighlightStallCode] = useState(null); const [descriptionExpanded, setDescriptionExpanded] = useState(() => task.column === "triage"); 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); // Custom field definitions (U13/KTD-14). Resolved for this task's workflow // from the board-workflows payload; absent when the workflow declares none, // in which case the fields section renders nothing (today's UI byte-identical). // When `workflowFieldDefsProp` is provided by the caller (e.g. the Board // already holds the payload) we skip the self-fetch entirely. const [customFieldDefs, setCustomFieldDefs] = useState( workflowFieldDefsProp !== undefined ? (workflowFieldDefsProp ?? null) : null, ); const [customFieldValues, setCustomFieldValues] = useState>(task.customFields ?? {}); const [customFieldError, setCustomFieldError] = useState(null); // Keep local field values in sync when the task prop changes (SSE refresh). useEffect(() => { setCustomFieldValues(task.customFields ?? {}); }, [task.id, task.customFields]); // Resolve this task's workflow field definitions once per task. Skipped when // the caller supplies `workflowFieldDefs` directly (Board context). Best-effort: // a failed fetch (or flag-OFF empty payload) leaves defs null → no section. useEffect(() => { if (workflowFieldDefsProp !== undefined) { // Prop-driven path: keep in sync if the prop changes (task switch etc.). setCustomFieldDefs(workflowFieldDefsProp ?? null); return; } let cancelled = false; void fetchBoardWorkflows(projectId) .then((payload) => { if (cancelled) return; const workflowId = payload.taskWorkflowIds[task.id] ?? payload.defaultWorkflowId; const workflow = payload.workflows.find((w) => w.id === workflowId); setCustomFieldDefs(workflow?.fields ?? null); }) .catch(() => { if (!cancelled) setCustomFieldDefs(null); }); return () => { cancelled = true; }; }, [task.id, projectId, workflowFieldDefsProp]); const handleSaveCustomFields = useCallback( async (patch: Record) => { setCustomFieldError(null); try { const updated = await updateTaskCustomFields(task.id, patch, projectId); setCustomFieldValues(updated.customFields ?? {}); onTaskUpdated?.(updated); } catch (err) { if (err instanceof ApiRequestError && err.details && typeof err.details.fieldId === "string") { setCustomFieldError({ code: (err.details.code as CustomFieldRejection["code"]) ?? "type-mismatch", fieldId: err.details.fieldId, detail: typeof err.details.detail === "string" ? err.details.detail : err.message, }); return; } addToast(getErrorMessage(err) || t("taskFields.saveFailed", "Failed to save field"), "error"); } }, [task.id, projectId, onTaskUpdated, addToast, t], ); 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; // ── CLI terminal tab visibility + posture (U11) ──────────────────────────── const cliOneShot = cliSession?.adapterId != null && (cliSession?.autonomyPosture?.purpose === "planning" || cliSession?.autonomyPosture?.purpose === "validator" || cliSession?.autonomyPosture?.readOnly === true); const cliGenericIdle = cliSession?.autonomyPosture?.genericIdle === true; const cliTabVisibility = useMemo( () => deriveCliTabVisibility(cliSession, { oneShot: cliOneShot, genericIdle: cliGenericIdle, }), [cliSession, cliOneShot, cliGenericIdle], ); const showCliTab = cliTabVisibility.kind !== "hidden"; const cliPosture: SessionTerminalPosture | undefined = useMemo(() => { if (!cliSession) return undefined; const p = cliSession.autonomyPosture ?? {}; const flags = Array.isArray(p.elevatedFlags) ? (p.elevatedFlags as string[]) : undefined; return { adapterName: (p.adapterName as string) ?? cliSession.adapterId, mode: (p.mode as string) ?? (p.autoApprove ? "auto-approve" : undefined), elevated: p.elevated === true, elevatedFlags: flags, resolved: Array.isArray(p.resolved) ? (p.resolved as string[]) : undefined, }; }, [cliSession]); // Confirm-advance handler — POST /api/cli-sessions/:id/confirm-advance. const handleConfirmAdvance = useCallback( async (decision: "advance" | "not-yet") => { if (!cliSession) return; try { await api(`/cli-sessions/${encodeURIComponent(cliSession.id)}/confirm-advance`, { method: "POST", body: JSON.stringify({ decision, ...(projectId ? { projectId } : {}) }), }); } catch { /* surfaced via the strip's disabled state reset */ } }, [cliSession, projectId], ); // If the terminal tab is active but the session disappears, fall back. useEffect(() => { if (activeTab === "terminal" && !showCliTab) setActiveTab("definition"); }, [activeTab, showCliTab]); // 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(t("taskDetail.workflow.loadFailed", "Failed to load workflow results: {{error}}", { error: 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]); // Load the CLI agent session for this task (drives the terminal tab + matrix). useEffect(() => { let cancelled = false; const search = new URLSearchParams({ taskId: task.id }); if (projectId) search.set("projectId", projectId); void api<{ sessions: CliSessionSummaryRecord[] }>(`/cli-sessions?${search.toString()}`) .then((res) => { if (cancelled) return; // Most-recent session for the task (the list is store-ordered). const sessions = res.sessions ?? []; setCliSession(sessions.length > 0 ? sessions[sessions.length - 1] : null); }) .catch(() => { if (!cancelled) setCliSession(null); }); return () => { cancelled = true; }; }, [task.id, projectId]); // Live CLI session state via SSE — MERGE payload fields onto the record // (never wholesale-replace: the list fetch carries enriched fields the SSE // payload omits, e.g. adapterId / autonomyPosture). useEffect(() => { const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const handleCliState = (e: MessageEvent) => { try { const payload = JSON.parse(e.data) as { sessionId: string; taskId: string | null; state: string; terminationReason?: string | null; }; if (payload.taskId !== task.id) return; setCliSession((prev) => { if (!prev || prev.id !== payload.sessionId) { // Unknown/new session for this task — keep the enriched record from // the list fetch as the source of truth; ignore until it loads. if (!prev) return prev; } // The machine "idle"/"resuming" states map onto persisted enums; the // card/tab only need the persisted set, so coerce here. const next = { ...prev } as CliSessionSummaryRecord; if ( payload.state === "starting" || payload.state === "ready" || payload.state === "busy" || payload.state === "waitingOnInput" || payload.state === "done" || payload.state === "dead" || payload.state === "needsAttention" ) { next.agentState = payload.state; } else if (payload.state === "idle" || payload.state === "resuming") { next.agentState = "busy"; } if (payload.terminationReason !== undefined) { next.terminationReason = payload.terminationReason ?? null; } return next; }); } catch { /* skip malformed events */ } }; return subscribeSse(`/api/events${query}`, { events: { "cli:session:state": handleCliState }, }); }, [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: t("taskDetail.retries.stuckKill", "Stuck kills"), title: t("taskDetail.retries.stuckKillTitle", "Stuck-task detector forced agent kill retries"), value: retrySummary?.stuckKill ?? 0 }, { key: "recovery", label: t("taskDetail.retries.recovery", "Recovery retries"), title: t("taskDetail.retries.recoveryTitle", "Transient executor recovery retries"), value: retrySummary?.recovery ?? 0 }, { key: "taskDone", label: t("taskDetail.retries.taskDone", "task_done retries"), title: t("taskDetail.retries.taskDoneTitle", "Agent exited without task_done and task was retried"), value: retrySummary?.taskDone ?? 0 }, { key: "workflowStep", label: t("taskDetail.retries.workflowStep", "Workflow retries"), title: t("taskDetail.retries.workflowStepTitle", "Workflow step failure retries"), value: retrySummary?.workflowStep ?? 0 }, { key: "verification", label: t("taskDetail.retries.verification", "Verification bounces"), title: t("taskDetail.retries.verificationTitle", "Verification failure bounce retries"), value: retrySummary?.verification ?? 0 }, { key: "postReviewFix", label: t("taskDetail.retries.postReviewFix", "Post-review fixes"), title: t("taskDetail.retries.postReviewFixTitle", "Post-review remediation retries"), value: retrySummary?.postReviewFix ?? 0 }, { key: "mergeConflict", label: t("taskDetail.retries.mergeConflict", "Merge conflict bounces"), title: t("taskDetail.retries.mergeConflictTitle", "Merge conflict bounce retries"), value: retrySummary?.mergeConflict ?? 0 }, { key: "branchConflict", label: t("taskDetail.retries.branchConflict", "Branch conflict recovery"), title: t("taskDetail.retries.branchConflictTitle", "FN-4068 branch-conflict recovery retries"), value: retrySummary?.branchConflict ?? 0 }, { key: "reviewerContext", label: t("taskDetail.retries.reviewerContext", "Reviewer context retries"), title: t("taskDetail.retries.reviewerContextTitle", "FN-4082 compact reviewer retry"), value: retrySummary?.reviewerContext ?? 0 }, { key: "reviewerFallback", label: t("taskDetail.retries.reviewerFallback", "Reviewer fallback retries"), title: t("taskDetail.retries.reviewerFallbackTitle", "FN-4092 fallback-model retry"), value: retrySummary?.reviewerFallback ?? 0 }, ].filter((row) => row.value > 0); const githubTrackingStatus = githubTrackingDetailPending ? t("taskDetail.githubTracking.statusLoading", "Loading") : githubTrackedIssue ? t("taskDetail.githubTracking.statusLinked", "Linked") : githubTrackingEnabled ? t("taskDetail.githubTracking.statusEnabled", "Enabled") : t("taskDetail.githubTracking.statusDisabled", "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(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: 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(t("taskDetail.githubTracking.repoOverrideFormat", "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(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: 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(t("taskDetail.githubTracking.addTitleBeforeCreating", "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(t("taskDetail.githubTracking.issueCreationRequested", "Requested GitHub tracking issue creation"), "info"); } catch (err) { if (activeTaskIdRef.current !== requestTaskId) { return; } addToast(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: 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: t("taskDetail.edit.sourceIssueRequiredFields", "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: t("taskDetail.edit.sourceIssueIdentifierNumeric", "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(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: 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(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: 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(t("taskDetail.updateSuccess", "Updated {{id}}", { id: 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(t("taskDetail.priority.updated", "Priority updated to {{priority}}", { priority: normalizeTaskPriorityValue(updatedTask.priority) }), "success"); } catch (err) { setInlinePriority(previousPriority); addToast(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: 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(t("taskDetail.executionMode.updated", "Execution mode updated to {{mode}}", { mode: normalizedUpdatedMode }), "success"); } catch (err) { setInlineExecutionMode(previousMode); addToast(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: 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(normalizedUpdatedValue ? t("taskDetail.noCommits.enabled", "No-commits expectation enabled") : t("taskDetail.noCommits.disabled", "No-commits expectation disabled"), "success"); } catch (err) { setInlineNoCommitsExpected(previousValue); addToast(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: 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: t("taskDetail.githubTracking.unlinkTitle", "Unlink GitHub issue?"), message: t("taskDetail.githubTracking.unlinkMessage", "This stops Fusion from syncing with the linked GitHub issue. The issue itself will not be modified."), confirmLabel: t("taskDetail.githubTracking.unlinkConfirm", "Unlink"), danger: true, }); if (!confirmed) return; setIsSavingGithubTracking(true); try { const updatedTask = await updateTask(task.id, { githubTracking: { issue: null } }, projectId); onTaskUpdated?.(updatedTask); addToast(t("taskDetail.githubTracking.issueUnlinked", "GitHub issue unlinked"), "success"); } catch (err) { addToast(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: 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: t("taskDetail.move.preserveProgressTitle", "Preserve Progress?"), message: t("taskDetail.move.preserveProgressMessage", "This task has completed steps. Keep progress before moving?"), confirmLabel: t("taskDetail.move.keepProgress", "Keep Progress"), cancelLabel: t("taskDetail.move.resetProgress", "Reset Progress"), }); if (keepProgress) { moveOptions = { preserveProgress: true }; } else { const resetProgress = await confirm({ title: t("taskDetail.move.resetProgressTitle", "Reset Progress?"), message: t("taskDetail.move.resetProgressMessage", "Reset all step progress before moving this task?"), confirmLabel: t("taskDetail.move.resetProgress", "Reset Progress"), cancelLabel: t("taskDetail.move.cancelMove", "Cancel Move"), danger: true, }); if (!resetProgress) { return; } } } await onMoveTask(task.id, column, moveOptions); requestClose(); addToast(t("taskDetail.move.movedTo", "Moved to {{column}}", { column: columnLabel(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: t("taskDetail.delete.title", "Delete Task"), message: t("taskDetail.delete.message", "Delete {{id}}?", { id: task.id }), confirmLabel: t("taskDetail.delete.confirm", "Delete"), cancelLabel: t("common.cancel", "Cancel"), tertiaryLabel: t("taskDetail.delete.archiveInstead", "Archive Instead"), danger: true, }); if (deleteChoice === "tertiary") { try { await onArchiveTask(task.id); addToast(t("taskDetail.nearDuplicate.archived", "Archived {{id}}", { id: 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: t("taskDetail.delete.forceDeleteTitle", "Force Delete Task"), message: `${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` + t("taskDetail.delete.archiveUnlinkPrompt", "Archive anyway by unlinking these references first?"), danger: true, }); if (!confirmedArchive) { return; } try { await onArchiveTask(task.id, { removeLineageReferences: true }); addToast(t("taskDetail.delete.archivedAfterUnlink", "Archived {{id}} after unlinking lineage references", { id: task.id }), "success"); requestClose(); } catch (retryErr) { addToast(getErrorMessage(retryErr), "error"); } } return; } if (deleteChoice !== "primary") { return; } } else { const { choice, checkboxValue } = await confirmWithCheckbox({ title: t("taskDetail.delete.title", "Delete Task"), message: t("taskDetail.delete.message", "Delete {{id}}?", { id: task.id }), danger: true, checkbox: { label: t("taskDetail.delete.allowRecreation", "Allow re-creation later (operator unlock)"), description: t("taskDetail.delete.allowRecreationDesc", "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: t("taskDetail.delete.linkedIssueTitle", "Linked GitHub Issue"), message: t("taskDetail.delete.linkedIssueMessage", "Choose what to do with {{issueRef}} when deleting {{id}}.\n\nClose the issue?", { issueRef, id: task.id }), confirmLabel: t("taskDetail.delete.closeIssue", "Close Issue"), cancelLabel: t("taskDetail.delete.moreOptions", "More Options"), }); if (shouldCloseIssue) { githubIssueAction = "close"; } else { const shouldDeleteIssue = await confirm({ title: t("taskDetail.delete.deleteLinkedIssueTitle", "Delete Linked GitHub Issue"), message: t("taskDetail.delete.deleteLinkedIssueMessage", "Delete {{issueRef}} on GitHub, or leave it unchanged?", { issueRef }), confirmLabel: t("taskDetail.delete.deleteIssue", "Delete Issue"), cancelLabel: t("taskDetail.delete.leaveUnchanged", "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 ? ` ${t("taskDetail.delete.issueSuffix", "and {{action}} issue {{ref}}", { action: githubIssueAction === "close" ? t("taskDetail.delete.actionClosed", "closed") : githubIssueAction === "delete" ? t("taskDetail.delete.actionDeleted", "deleted") : t("taskDetail.delete.actionLeft", "left"), ref: `${trackedIssue.owner}/${trackedIssue.repo}#${trackedIssue.number}` })}` : ""; addToast(t("taskDetail.delete.deletedToast", "Deleted {{id}}{{suffix}}", { id: task.id, suffix: issueSuffix }), "info"); } catch (err) { const dependencyConflict = extractDependencyDeleteConflict(err); if (dependencyConflict && dependencyConflict.dependentIds.length > 0) { const dependentList = dependencyConflict.dependentIds.join(", "); const confirmed = await confirm({ title: t("taskDetail.delete.forceDeleteTitle", "Force Delete Task"), message: `${task.id} is a dependency of ${dependentList}.\n\n` + t("taskDetail.delete.deleteUnlinkDepsPrompt", "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(t("taskDetail.delete.deletedAfterRemovingDeps", "Deleted {{id}} after removing dependency references", { id: task.id }), "info"); } catch (retryErr) { const lineageConflict = extractLineageDeleteConflict(retryErr); if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) { addToast(getErrorMessage(retryErr), "error"); return; } const confirmedLineage = await confirm({ title: t("taskDetail.delete.forceDeleteTitle", "Force Delete Task"), message: `${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` + t("taskDetail.delete.deleteUnlinkLineagePrompt", "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(t("taskDetail.delete.deletedAfterUnlinkLineage", "Deleted {{id}} after unlinking lineage references", { id: task.id }), "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: t("taskDetail.delete.forceDeleteTitle", "Force Delete Task"), message: `${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` + t("taskDetail.delete.deleteUnlinkLineagePrompt", "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(t("taskDetail.delete.deletedAfterUnlinkLineage", "Deleted {{id}} after unlinking lineage references", { id: task.id }), "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: t("taskDetail.merge.title", "Merge Task"), message: t("taskDetail.merge.message", "Merge {{id}} into the current branch?", { id: task.id }), }); if (!shouldMerge) return; requestClose(); addToast(t("taskDetail.merge.merging", "Merging {{id}}…", { id: task.id }), "info"); onMergeTask(task.id) .then((result) => { const msg = result.merged ? t("taskDetail.merge.merged", "Merged {{id}} (branch: {{branch}})", { id: task.id, branch: result.branch }) : t("taskDetail.merge.closed", "Closed {{id}} ({{reason}})", { id: task.id, reason: result.error || t("taskDetail.merge.noBranchToMerge", "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(t("taskDetail.retry.retried", "Retried {{id}}", { id: task.id }), "success"); }) .catch((err) => { addToast(getErrorMessage(err), "error"); }); }, [task.id, onRetryTask, requestClose, addToast]); const handleReset = useCallback(() => { if (!onResetTask) return; if (!window.confirm(t("taskDetail.reset.confirmMessage", "This will erase all progress for {{id}} and start the task from scratch. Continue?", { id: task.id }))) return; requestClose(); onResetTask(task.id) .then(() => { addToast(t("taskDetail.reset.resetSuccess", "Reset {{id}} — fresh run will be allocated", { id: task.id }), "success"); }) .catch((err) => { addToast(getErrorMessage(err), "error"); }); }, [task.id, onResetTask, requestClose, addToast]); const handleDuplicate = useCallback(async () => { if (!onDuplicateTask) return; const shouldDuplicate = await confirm({ title: t("taskDetail.duplicate.title", "Duplicate Task"), message: t("taskDetail.duplicate.message", "Duplicate {{id}}? This will create a new task in Triage with the same description and prompt.", { id: task.id }), }); if (!shouldDuplicate) return; try { const newTask = await onDuplicateTask(task.id); requestClose(); addToast(t("taskDetail.duplicate.success", "Duplicated {{id}} → {{newId}}", { id: task.id, newId: 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(t("taskDetail.nearDuplicate.kept", "Kept {{id}} and dismissed duplicate warning", { id: task.id }), "success"); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, projectId, onTaskUpdated, addToast]); const handleArchiveNearDuplicate = useCallback(async () => { if (!onArchiveTask) return; const confirmed = await confirm({ title: t("taskDetail.nearDuplicate.archiveTitle", "Archive near-duplicate task"), message: t("taskDetail.nearDuplicate.archiveMessage", "Archive {{id}} as a duplicate of {{duplicateOf}}?", { id: task.id, duplicateOf: nearDuplicateOf }), confirmLabel: t("taskDetail.nearDuplicate.archiveConfirm", "Archive"), cancelLabel: t("common.cancel", "Cancel"), danger: true, }); if (!confirmed) return; try { await onArchiveTask(task.id); addToast(t("taskDetail.nearDuplicate.archived", "Archived {{id}}", { id: 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(t("taskDetail.branchBinding.reattached", "Reattached branch for {{id}} ({{branch}})", { id: task.id, branch: outcome.branch }), "success"); onTaskUpdated?.({ ...task, branch: outcome.branch, worktree: undefined }); } else { addToast(t("taskDetail.branchBinding.skipped", "Branch reattachment skipped for {{id}}: {{reason}}", { id: task.id, reason: 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(t("taskDetail.pause.unpaused", "Unpaused {{id}}", { id: task.id }), "success"); } else { await pauseTask(task.id, projectId); addToast(t("taskDetail.pause.paused", "Paused {{id}}", { id: 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(t("taskDetail.plan.approved", "Plan approved — {{id}} moved to Todo", { id: task.id }), "success"); requestClose(); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, requestClose, addToast]); const handleRejectPlan = useCallback(async () => { const shouldReject = await confirm({ title: t("taskDetail.plan.rejectTitle", "Reject Plan"), message: t("taskDetail.plan.rejectMessage", "Reject this plan? The specification will be discarded and regenerated."), danger: true, }); if (!shouldReject) return; try { await rejectPlan(task.id, projectId); addToast(t("taskDetail.plan.rejected", "Plan rejected — {{id}} returned to Planning for replanning", { id: task.id }), "info"); requestClose(); } catch (err) { addToast(getErrorMessage(err), "error"); } }, [task.id, requestClose, addToast, confirm]); const handleRespecify = useCallback(async () => { const shouldRebuild = await confirm({ title: t("taskDetail.plan.rebuildTitle", "Rebuild Plan"), message: t("taskDetail.plan.rebuildMessage", "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(t("taskDetail.plan.replanning", "Replanning {{id}}…", { id: 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(t("taskDetail.pr.statusRefreshed", "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(t("taskDetail.refine.feedbackRequired", "Please enter feedback describing what needs refinement"), "error"); return; } if (refineFeedback.length > 2000) { addToast(t("taskDetail.refine.feedbackTooLong", "Feedback must be 2000 characters or less"), "error"); return; } setIsRefining(true); try { const newTask = await refineTask(task.id, refineFeedback.trim(), projectId); addToast(t("taskDetail.refine.taskCreated", "Refinement task created: {{id}}", { id: 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(t("taskDetail.attachments.attached", "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(t("taskDetail.attachments.deleted", "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(t("taskDetail.workflow.stepsUpdated", "Workflow steps updated"), "success"); onTaskUpdated?.(updatedTask); } catch (err) { setWorkflowEnabledSteps(previousSteps); addToast(t("taskDetail.workflow.stepsUpdateFailed", "Failed to update workflow steps: {{error}}", { error: getErrorMessage(err) }), "error"); } }, [task.id, projectId, workflowEnabledSteps, onTaskUpdated, addToast]); // U5 (R20): a workflow switch re-homed the card to a new column. Refetch the // task and push it up so the board reflects the move before the SSE catch-up. const handleWorkflowReconciled = useCallback(async () => { try { const detail = await fetchTaskDetail(task.id, projectId); setFullDetail(detail); onTaskUpdated?.(detail); } catch { // Best-effort refresh; the SSE stream will catch the board up regardless. } }, [task.id, projectId, onTaskUpdated]); const loadAgents = useCallback(async () => { setAgentsLoading(true); try { const loadedAgents = await fetchAgents(undefined, projectId); setAgents(loadedAgents); setShowAgentPicker(true); } catch (err) { addToast(t("taskDetail.agent.loadFailed", "Failed to load agents: {{error}}", { error: 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(t("taskDetail.agent.assignedUpdated", "Assigned agent updated"), "success"); } catch (err) { addToast(t("taskDetail.agent.assignFailed", "Failed to assign agent: {{error}}", { error: 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(t("taskDetail.agent.unassigned", "Agent unassigned"), "success"); } catch (err) { addToast(t("taskDetail.agent.unassignFailed", "Failed to unassign agent: {{error}}", { error: 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(t("taskDetail.deps.loadFailed", "Failed to load dependency {{id}}", { id: 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(t("taskDetail.spec.updated", "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(t("taskDetail.spec.revisionRequested", "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(t("taskDetail.spec.revisionColumnError", "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]); // #1403: legacy transitions only exist for legacy columns; a custom column id // has no VALID_TRANSITIONS row, so the move menu shows no legacy targets. const transitions: Column[] = isColumn(task.column) ? [...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": t("taskDetail.pr.creatingPr", "Creating PR…"), "awaiting-pr-checks": t("taskDetail.pr.awaitingChecks", "Awaiting PR checks"), "merging-pr": t("taskDetail.pr.mergingPr", "Merging PR…"), "merging-fix": t("taskDetail.pr.mergingFixes", "Merging fixes…"), }; const prAutomationLabel = task.status ? prAutomationStatusLabels[task.status] : undefined; const mergeStrategy = settings?.mergeStrategy ?? "direct"; const autoMergeEnabled = settings?.autoMerge ?? false; const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMergeEnabled }); const isManualPrFlow = mergeStrategy === "pull-request" && !autoMergeEnabled; const isCheckPrStatusAction = isManualPrFlow && !prAutomationLabel && task.prInfo?.status === "open"; let manualReviewActionLabel = t("taskDetail.pr.mergeAndClose", "Merge & Close"); if (isManualPrFlow && !prAutomationLabel) { if (!task.prInfo) { manualReviewActionLabel = t("taskDetail.pr.startPrReview", "Start PR Review"); } else if (task.prInfo.status === "open") { manualReviewActionLabel = t("taskDetail.pr.checkPrStatus", "Check PR Status"); } else if (task.prInfo.status === "merged") { manualReviewActionLabel = t("taskDetail.pr.finishAndClose", "Finish & Close"); } } return (
    {task.id} {columnLabel(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 ? t("taskDetail.edit.nodeOverrideLocked", "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" />
    {t("taskDetail.edit.sourceIssueHint", "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 && ( )} ); })()} {customFieldDefs && customFieldDefs.length > 0 ? ( ) : null} {showNearDuplicateWarning && (

    {t("taskDetail.nearDuplicate.copy", "This task appears to be a near-duplicate of")}{" "} {". "}{t("taskDetail.nearDuplicate.actions", "Choose Archive to move this task to archived, or Keep to continue with this task.")}

    {onArchiveTask && ( )}
    )}
    {provenanceDisplay && (
    )} {(task.prInfo?.number || task.mergeDetails?.prNumber) && (
    )}
    {t("taskDetail.timestamps.created", "Created")}{" "} {t("taskDetail.timestamps.updated", "Updated")}{" "}
    {task.branchContext?.groupId && ( )} )} {task.status === "failed" && task.error && (
    ⚠
    {t("taskDetail.error.taskFailed", "Task Failed")}
    {task.error}
    )} {task.pausedReason === "worktrunk_operation_failed" && (
    {t("taskDetail.pause.worktrunkFailed", "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" && ( )} {showCliTab && ( )} {/* Plugin tabs */} {pluginTabs.map(({ entry, tabId }) => { return ( ); })}
    {activeTab === "workflow" ? (
    ) : activeTab === "model" ? (
    ) : activeTab === "logs" ? (
    {logSubview === "agent-log" ? ( ) : (

    {t("taskDetail.logs.activityHeading", "Activity")}

    {(workingTask as typeof workingTask & { activityLogTruncatedCount?: number }).activityLogTruncatedCount ? (
    {t("taskDetail.logs.truncated", "Showing the most recent {{count}} activity entries.", { count: workingTask.log.length })}
    ) : null} {detailLoading ? (
    ) : 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}
    )}
    ); }); })()}
    ) : (
    {t("taskDetail.logs.noActivity", "(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}
    {t("taskDetail.stall.observed", "Observed")} {formatTimestamp(workingTask.inReviewStall.observedAt)} {logMatch ? ( ) : ( {t("taskDetail.stall.noLogEntry", "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}
    {t("taskDetail.ageStaleness.age", "Age")} {formatDurationCompact(workingTask.stalePausedReview.ageMs)} {t("taskDetail.stall.threshold", "Threshold")} {formatDurationCompact(workingTask.stalePausedReview.thresholdMs)} {t("taskDetail.stall.observed", "Observed")} {formatTimestamp(workingTask.stalePausedReview.observedAt)} {logMatch ? ( ) : ( {t("taskDetail.stall.noLogEntry", "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" ? (
    ) : activeTab === "terminal" ? (
    {cliSession && cliTabVisibility.kind !== "hidden" ? ( {t("taskDetail.terminal.loading", "Loading terminal…")}
    }> ) : null}
    ) : ( <> {/* Summary section - only for done tasks with summary */} {task.column === "done" && task.summary && (

    {t("taskDetail.summary.heading", "Summary")}

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

    {t("taskDetail.retries.capReached", "Retry cap reached for this task.")}

    )}
    )} {task.sourceIssue && (
    {t("taskDetail.sourceIssue.label", "Source issue")} {task.sourceIssue.provider.toLowerCase() === "github" && ( )} {task.sourceIssue.url ? ( {`(#${task.sourceIssue.issueNumber})`} ) : ( {`(#${task.sourceIssue.issueNumber})`} )}
    {sourceIssueExpanded && (
    {t("taskDetail.sourceIssue.provider", "Provider")}
    {task.sourceIssue.provider}
    {t("taskDetail.sourceIssue.repository", "Repository")}
    {task.sourceIssue.repository}
    {t("taskDetail.sourceIssue.identifier", "Issue Identifier")}
    {task.sourceIssue.externalIssueId}
    {t("taskDetail.sourceIssue.url", "URL")}
    {task.sourceIssue.url ? ( {task.sourceIssue.url} ) : ( {t("taskDetail.sourceIssue.none", "(none)")} )}
    )}
    )} {showGithubTrackingSection && (
    {t("taskDetail.githubTracking.label", "GitHub tracking")} {!githubTrackedIssue && ( {githubTrackingDetailPending ? t("taskDetail.githubTracking.checking", "Checking tracking status") : githubTrackingEnabled ? t("taskDetail.githubTracking.notYetCreated", "Issue not yet created") : t("taskDetail.githubTracking.disabled", "Tracking is currently disabled")} )}
    {showInlineGithubTrackingEnableButton && ( )} {showGithubTrackingSpinner && ( )}
    {githubTrackingExpanded && (
    {githubTrackedIssue && (
    {t("taskDetail.githubTracking.issue", "Issue")}
    {githubTrackedIssue.url ? ( {`${githubTrackedIssue.owner}/${githubTrackedIssue.repo}#${githubTrackedIssue.number}`} ) : ( {`${githubTrackedIssue.owner}/${githubTrackedIssue.repo}#${githubTrackedIssue.number}`} )}
    {t("taskDetail.githubTracking.state", "State")}
    {task.issueInfo?.state ?? "open"}
    )}
    {!githubTrackedIssue && githubTrackingEnabled && ( <> {!canCreateTrackingIssue && ( {t("taskDetail.githubTracking.createIssueHelper", "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) => ( ))} )} {t("taskDetail.agent.label", "Agent")}
    {assignedAgentLabel ? ( {assignedAgentLabel} ) : ( )} {showAgentPicker && (
    {agentsLoading &&
    {t("taskDetail.agent.loadingAgents", "Loading agents...")}
    } {!agentsLoading && agents.map((a) => ( ))} {!agentsLoading && agents.length === 0 && (
    {t("taskDetail.agent.noAgents", "No agents available")}
    )}
    )}

    {t("taskDetail.progress.heading", "Progress")}

    {workingTask.steps && workingTask.steps.length > 0 ? (
    {workingTask.steps.map((step, index) => (
    ))}
    {t("taskDetail.progress.stepCount", { count: workingTask.steps.filter(s => s.status === "done").length, total: workingTask.steps.length, defaultValue_one: "{{count}}/{{total}} step", defaultValue_other: "{{count}}/{{total}} steps" })}
    ) : (
    {t("taskDetail.progress.noSteps", "(no steps defined)")}
    )}
    {!isEditingSpec && (
    )} {isEditingSpec ? (