import "./TaskDetailModal.css";
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle } from "lucide-react";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core";
import {
COLUMN_LABELS,
DEFAULT_TASK_PRIORITY,
REPO_OVERRIDE_RE,
TASK_PRIORITIES,
VALID_TRANSITIONS,
getErrorMessage,
resolveTaskExecutionModel,
resolveTaskPlanningModel,
resolveTaskValidatorModel,
} from "@fusion/core";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus } from "../api";
import type { RecoverBranchBindingOutcome } from "../api";
import type { ToastType } from "../hooks/useToast";
import { useAgentLogs } from "../hooks/useAgentLogs";
import { useConfirm } from "../hooks/useConfirm";
import { AgentLogViewer } from "./AgentLogViewer";
import { ModelSelectorTab } from "./ModelSelectorTab";
import { PrPanel } from "./PrPanel";
import { PrCreateModal } from "./PrCreateModal";
import { TaskComments } from "./TaskComments";
import { TaskReviewTab } from "./TaskReviewTab";
import { MergeDetails } from "./MergeDetails";
import { TaskChangesTab } from "./TaskChangesTab";
import { TaskForm, type PendingImage } from "./TaskForm";
import { useNodes } from "../hooks/useNodes";
import { WorkflowResultsTab } from "./WorkflowResultsTab";
import { RoutingTab } from "./RoutingTab";
import { TaskDocumentsTab } from "./TaskDocumentsTab";
import { TaskTokenStatsPanel } from "./TaskTokenStatsPanel";
import { BranchGroupCard } from "./BranchGroupCard";
import { PluginSlot } from "./PluginSlot";
import { ProviderIcon } from "./ProviderIcon";
import { subscribeSse } from "../sse-bus";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import { appendTokenQuery } from "../auth";
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
import { MAX_AUTO_MERGE_RETRIES, computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
import { resolveEffectiveGithubRepoDefault } from "./githubTracking";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy";
import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy";
import { getTaskAgeStalenessCopy } from "../utils/taskAgeStalenessCopy";
import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry";
interface ModelSelection {
provider?: string;
modelId?: string;
}
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
const STALE_PAUSED_REVIEW_LOG_REGEX = /^Stale paused review surfaced \[([^\]]+)\]/;
const markdownLinkifyComponents: Components = {
p: ({ children, ...props }) =>
{linkifyReactChildren(children)}
,
li: ({ children, ...props }) => {linkifyReactChildren(children)} ,
code: ({ children, ...props }) => {
const text = typeof children === "string" ? children : React.Children.toArray(children).join("");
const linkedChildren = linkifyFilePaths(text);
if (linkedChildren.length === 1 && typeof linkedChildren[0] === "string") {
return {children};
}
return {linkedChildren};
},
};
/**
* Resolve the effective executor model following the engine's resolution order:
* 1. Per-task modelProvider/modelId (both must be set)
* 2. Project/global execution lane fallback
*/
function extractExecutorModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
let result: { provider: string; modelId: string } | null = null;
for (const entry of entries) {
if (entry.agent !== "executor" || entry.type !== "text") continue;
const match = entry.text.match(/^Executor using model: (.+?)\/(.+)$/);
if (match) {
result = { provider: match[1], modelId: match[2] };
}
}
return result;
}
function extractReviewerModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
let result: { provider: string; modelId: string } | null = null;
for (const entry of entries) {
if (entry.agent !== "reviewer" || entry.type !== "text") continue;
const match = entry.text.match(/^Reviewer using model: (.+?)\/(.+)$/);
if (match) {
result = { provider: match[1], modelId: match[2] };
}
}
return result;
}
function hasUsableTrackingTitle(task: { title?: string | null; description?: string | null }): boolean {
if ((task.title ?? "").trim().length > 0) {
return true;
}
const firstMeaningfulLine = (task.description ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
return Boolean(firstMeaningfulLine);
}
function extractAssignedRuntimeModel(agent: Agent | null | undefined): ModelSelection {
const runtimeConfig = (agent?.runtimeConfig ?? undefined) as Record | undefined;
const model = typeof runtimeConfig?.model === "string" ? runtimeConfig.model.trim() : "";
if (model) {
const slashIdx = model.indexOf("/");
if (slashIdx > 0 && slashIdx < model.length - 1) {
return {
provider: model.slice(0, slashIdx),
modelId: model.slice(slashIdx + 1),
};
}
}
const provider = typeof runtimeConfig?.modelProvider === "string" ? runtimeConfig.modelProvider.trim() : "";
const modelId = typeof runtimeConfig?.modelId === "string" ? runtimeConfig.modelId.trim() : "";
return {
provider: provider || undefined,
modelId: modelId || undefined,
};
}
/**
* Resolve the effective executor model following the engine's resolution order:
* 1. Runtime executor model from agent log marker
* 2. Assigned agent runtime model (active runs only)
* 3. Per-task modelProvider/modelId override
* 4. Project/global execution lane fallback
*/
function resolveEffectiveExecutor(
task: Task | TaskDetail,
logEntries: AgentLogEntry[],
assignedAgent: Agent | null,
settings?: Settings,
): ModelSelection {
const fromLog = extractExecutorModelFromLog(logEntries);
if (fromLog) return fromLog;
if (ACTIVE_STATUSES.has(task.status ?? "") || task.column === "in-progress") {
const assignedModel = extractAssignedRuntimeModel(assignedAgent);
if (assignedModel.provider && assignedModel.modelId) {
return assignedModel;
}
}
return resolveTaskExecutionModel(task, settings);
}
/**
* Resolve the effective validator model following the engine's resolution order:
* 1. Runtime reviewer model from agent log marker
* 2. Assigned agent runtime model (active runs only)
* 3. Per-task validatorModelProvider/validatorModelId override
* 4. Project/global validator lane fallback
*/
function resolveEffectiveValidator(
task: Task | TaskDetail,
logEntries: AgentLogEntry[],
assignedAgent: Agent | null,
settings?: Settings,
): ModelSelection {
const fromLog = extractReviewerModelFromLog(logEntries);
if (fromLog) return fromLog;
if (ACTIVE_STATUSES.has(task.status ?? "") || task.column === "in-progress") {
const assignedModel = extractAssignedRuntimeModel(assignedAgent);
if (assignedModel.provider && assignedModel.modelId) {
return assignedModel;
}
}
return resolveTaskValidatorModel(task, settings);
}
/**
* Extract planning model from agent log entries.
* Looks for text entries with agent role "triage" matching the pattern:
* "Triage using model: /"
* Returns the latest match, or null if none found.
*/
function extractPlanningModelFromLog(entries: AgentLogEntry[]): { provider: string; modelId: string } | null {
// Iterate in chronological order; last match wins
let result: { provider: string; modelId: string } | null = null;
for (const entry of entries) {
if (entry.agent !== "triage" || entry.type !== "text") continue;
const match = entry.text.match(/^Triage using model: (.+?)\/(.+)$/);
if (match) {
result = { provider: match[1], modelId: match[2] };
}
}
return result;
}
/**
* Resolve the effective planning model following the resolution order:
* 1. Per-task planningModelProvider/planningModelId override
* 2. Runtime triage model from agent log marker (if present)
* 3. Project/global planning lane fallback
*/
function resolveEffectivePlanning(
task: Task | TaskDetail,
logEntries: AgentLogEntry[],
settings?: Settings,
): ModelSelection {
// 1. Per-task override takes precedence
if (task.planningModelProvider && task.planningModelId) {
return { provider: task.planningModelProvider, modelId: task.planningModelId };
}
// 2. Runtime triage model from agent log marker
const fromLog = extractPlanningModelFromLog(logEntries);
if (fromLog) {
return fromLog;
}
return resolveTaskPlanningModel(task, settings);
}
function getStepStatusColor(status: string): string {
switch (status) {
case "done":
return "var(--color-success)";
case "in-progress":
return "var(--in-progress)";
case "skipped":
return "var(--text-dim)";
case "pending":
default:
return "var(--border)";
}
}
function formatTimestamp(iso: string): string {
const date = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHr < 24) return `${diffHr}h ago`;
if (diffDay < 7) return `${diffDay}d ago`;
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDurationCompact(ageMs: number): string {
const totalMinutes = Math.max(1, Math.floor(ageMs / 60_000));
const days = Math.floor(totalMinutes / (24 * 60));
const hours = Math.floor((totalMinutes % (24 * 60)) / 60);
const minutes = totalMinutes % 60;
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
type TabId = "definition" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | `plugin-${string}`;
export interface TaskDetailModalProps {
task: Task | TaskDetail;
projectId?: string;
tasks?: Task[];
onClose: () => void;
onOpenDetail: (task: Task | TaskDetail) => void; // For clicking dependencies
onMoveTask: (id: string, column: Column, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise;
onDeleteTask: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
allowResurrection?: boolean;
}) => Promise;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise;
onMergeTask: (id: string) => Promise;
onRetryTask?: (id: string) => Promise;
onResetTask?: (id: string) => Promise;
onDuplicateTask?: (id: string) => Promise;
onTaskUpdated?: (task: Task) => void;
addToast: (message: string, type?: ToastType) => void;
prAuthAvailable?: boolean;
/** Open the modal with this tab active instead of "definition" */
initialTab?: TabId;
/** Mobile-only header affordance mode. */
mobileHeaderMode?: "close" | "back";
}
export type TaskDetailContentProps = Omit & {
embedded?: boolean;
onRequestClose?: () => void;
};
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + "…" : s;
}
function sameStringArray(a: string[] = [], b: string[] = []): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
function splitModelSelection(value: string): { provider: string; modelId: string } | null {
const slashIdx = value.indexOf("/");
if (!value || slashIdx === -1) return null;
return {
provider: value.slice(0, slashIdx),
modelId: value.slice(slashIdx + 1),
};
}
function normalizeSourceIssueText(value: string): string {
return value.trim();
}
function normalizeSourceIssueUrl(value: string): string | undefined {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeTaskPriorityValue(priority: Task["priority"]): TaskPriority {
return typeof priority === "string" && (TASK_PRIORITIES as readonly string[]).includes(priority)
? (priority as TaskPriority)
: DEFAULT_TASK_PRIORITY;
}
function normalizeExecutionModeValue(executionMode: Task["executionMode"]): "standard" | "fast" {
return executionMode === "fast" ? "fast" : "standard";
}
interface ProvenanceDisplay {
label: string;
parentTaskId?: string;
contextInfo?: string;
contextHref?: string;
contextInfoFull?: string;
sourceAgentId?: string;
}
interface ProvenanceLabelOptions {
sourceAgentName?: string;
}
function getIssueUrlFromMetadata(metadata: Task["sourceMetadata"]): string | undefined {
const issueUrl = metadata?.issueUrl;
return typeof issueUrl === "string" && issueUrl.length > 0 ? issueUrl : undefined;
}
function parseGithubIssueLabel(url: string): { label: string; href: string } | null {
const match = url.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:$|[/?#])/);
if (!match) {
return null;
}
const [, owner, repo, , number] = match;
return {
label: `${owner}/${repo}#${number}`,
href: url,
};
}
function getResearchContextInfo(metadata: Task["sourceMetadata"]): string | undefined {
const findingLabel = metadata?.findingLabel;
if (typeof findingLabel === "string" && findingLabel.length > 0) {
return findingLabel;
}
const runId = metadata?.runId;
return typeof runId === "string" && runId.length > 0 ? runId : undefined;
}
const AgentDetailView = lazy(() => import("./AgentDetailView").then((m) => ({ default: m.AgentDetailView })));
function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOptions = {}): ProvenanceDisplay | null {
switch (task.sourceType) {
case "dashboard_ui":
return { label: "Dashboard" };
case "quick_chat":
return { label: "Quick Chat" };
case "chat_session":
return { label: "Chat Session" };
case "agent_heartbeat": {
const sourceLabel = options.sourceAgentName ?? task.sourceAgentId;
return {
label: sourceLabel ?? "agent",
sourceAgentId: task.sourceAgentId,
};
}
case "automation":
return { label: "Automation" };
case "cron":
return { label: "Scheduled Task" };
case "workflow_step":
return { label: "Workflow Step" };
case "github_import": {
const issueUrl = getIssueUrlFromMetadata(task.sourceMetadata);
const parsedIssue = issueUrl ? parseGithubIssueLabel(issueUrl) : null;
return {
label: "GitHub Import",
contextInfo: issueUrl ? (parsedIssue?.label ?? "Open issue") : undefined,
contextHref: issueUrl,
contextInfoFull: issueUrl,
};
}
case "research": {
const contextInfo = getResearchContextInfo(task.sourceMetadata);
return {
label: "Research",
contextInfo,
contextInfoFull: contextInfo,
};
}
case "task_refine":
return {
label: "Refinement",
parentTaskId: task.sourceParentTaskId,
};
case "task_duplicate":
return {
label: "Duplicate",
parentTaskId: task.sourceParentTaskId,
};
case "cli":
return { label: "CLI" };
case "api":
return { label: "API" };
case "recovery":
return { label: "Recovery" };
case "unknown":
default:
return null;
}
}
const DESCRIPTION_TRUNCATE_LENGTH = 200;
const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]);
const GITHUB_TRACKING_EDITABLE_COLUMNS: Set = new Set(["triage", "todo", "in-progress", "in-review"]);
export function TaskDetailContent({
task,
projectId,
tasks = [],
onOpenDetail,
onMoveTask,
onDeleteTask,
onArchiveTask,
onMergeTask,
onRetryTask,
onResetTask,
onDuplicateTask,
onTaskUpdated,
addToast,
prAuthAvailable,
initialTab = "definition",
mobileHeaderMode = "close",
embedded = false,
onRequestClose,
}: TaskDetailContentProps) {
const [activeTab, setActiveTab] = useState(initialTab === "retries" ? "definition" : initialTab);
// ── Async detail loading ──────────────────────────────────────────────────
// When opened optimistically with a Task (no prompt), fetch the full
// TaskDetail in the background. The modal renders immediately with the
// lightweight data and shows a loading indicator in the spec section.
const [fullDetail, setFullDetail] = useState(() =>
"prompt" in task ? (task as TaskDetail) : null,
);
const [detailLoading, setDetailLoading] = useState(() =>
!("prompt" in task),
);
useEffect(() => {
// If the prop already has a prompt field, it's a full TaskDetail
if ("prompt" in task) {
setFullDetail(task as TaskDetail);
setDetailLoading(false);
return;
}
let cancelled = false;
setDetailLoading(true);
setFullDetail(null);
fetchTaskDetail(task.id, projectId)
.then((detail) => {
if (!cancelled) {
setFullDetail(detail);
setDetailLoading(false);
}
})
.catch(() => {
if (!cancelled) {
setDetailLoading(false);
}
});
return () => { cancelled = true; };
}, [task.id, projectId]);
// Derive a working task that always has all available fields.
// Falls back to the optimistic Task while loading, uses fullDetail once loaded.
// Live fields (tokenUsage, workflowStepResults, status, column, …) are taken
// from the parent `task` prop which receives SSE updates, so the stats tab
// keeps populating while a task runs after the modal was opened. `log` is
// stripped to [] in SSE payloads (stripTaskListHeavyFields), so we preserve
// fullDetail.log to keep the Activity timeline populated.
// FN-4161: board/restart flows open the modal from slim task rows where
// `githubTracking` is intentionally omitted; preserve the fetched full-detail
// tracking blob instead of letting the sparse parent prop overwrite it.
const workingTask: TaskDetail = fullDetail
? ({
...fullDetail,
...task,
prompt: fullDetail.prompt,
log: fullDetail.log,
githubTracking: task.githubTracking ?? fullDetail.githubTracking,
} as TaskDetail)
: ({ ...task, prompt: "" } as TaskDetail);
const canRetryTask =
task.status === "failed" ||
task.status === "stuck-killed" ||
task.status === "planning" ||
task.status === "needs-replan" ||
(task.stuckKillCount ?? 0) > 0 ||
(task.recoveryRetryCount ?? 0) > 0 ||
Boolean(task.nextRecoveryAt);
const nearDuplicateOf = typeof workingTask.sourceMetadata?.nearDuplicateOf === "string"
? workingTask.sourceMetadata.nearDuplicateOf
: null;
const showNearDuplicateWarning = Boolean(nearDuplicateOf)
&& workingTask.sourceMetadata?.nearDuplicateDismissed !== true
&& task.column !== "archived"
&& task.column !== "done";
const [sourceAgent, setSourceAgent] = useState(null);
const [selectedSourceAgentId, setSelectedSourceAgentId] = useState(null);
const provenanceDisplay = getProvenanceLabel(workingTask, {
sourceAgentName: sourceAgent?.name,
});
// Sync activeTab when the caller changes initialTab (e.g. opening a different tab)
useEffect(() => {
setActiveTab(initialTab === "retries" ? "definition" : initialTab);
if (initialTab === "retries") {
setRetriesExpanded(true);
}
}, [initialTab]);
useEffect(() => {
if (activeTab === "pr" && task.column !== "in-review") {
setActiveTab("definition");
}
}, [activeTab, task.column]);
// Reset description expanded state when task changes
useEffect(() => {
setDescriptionExpanded(false);
}, [task.id]);
const [logSubview, setLogSubview] = useState<"activity" | "agent-log">("activity");
const [highlightStallCode, setHighlightStallCode] = useState(null);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [attachments, setAttachments] = useState(task.attachments || []);
const [uploading, setUploading] = useState(false);
const [dependencies, setDependencies] = useState(task.dependencies || []);
const [showDepDropdown, setShowDepDropdown] = useState(false);
const [depSearch, setDepSearch] = useState("");
const [assignedAgent, setAssignedAgent] = useState(null);
const [agents, setAgents] = useState([]);
const [showAgentPicker, setShowAgentPicker] = useState(false);
const [agentsLoading, setAgentsLoading] = useState(false);
const [isSavingSpec, setIsSavingSpec] = useState(false);
const [isRequestingRevision, setIsRequestingRevision] = useState(false);
const [isEditingSpec, setIsEditingSpec] = useState(false);
const [specEditContent, setSpecEditContent] = useState(workingTask.prompt || "");
const [specFeedback, setSpecFeedback] = useState("");
const [showRefineModal, setShowRefineModal] = useState(false);
const [prCreateOpen, setPrCreateOpen] = useState(false);
useEffect(() => {
if (activeTab !== "logs" || logSubview !== "activity") {
setHighlightStallCode(null);
return;
}
if (!highlightStallCode) {
return;
}
const highlighted = activityListRef.current?.querySelector("[data-stall-highlight=\"true\"]");
if (highlighted && typeof highlighted.scrollIntoView === "function") {
highlighted.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [activeTab, logSubview, highlightStallCode]);
const [refineFeedback, setRefineFeedback] = useState("");
const [isRefining, setIsRefining] = useState(false);
// Edit mode state
const [isEditing, setIsEditing] = useState(false);
const [editTitle, setEditTitle] = useState(task.title || "");
const [editDescription, setEditDescription] = useState(task.description || "");
const [editDependencies, setEditDependencies] = useState(task.dependencies || []);
const [editBranch, setEditBranch] = useState(task.branch ?? "");
const [editBaseBranch, setEditBaseBranch] = useState(task.baseBranch ?? "");
const [editExecutorModel, setEditExecutorModel] = useState("");
const [editValidatorModel, setEditValidatorModel] = useState("");
const [editPlanningModel, setEditPlanningModel] = useState("");
const [editThinkingLevel, setEditThinkingLevel] = useState("");
const [editPresetMode, setEditPresetMode] = useState<"default" | "preset" | "custom">("default");
const [editReviewLevel, setEditReviewLevel] = useState(undefined);
const [editPriority, setEditPriority] = useState(DEFAULT_TASK_PRIORITY);
const [editNodeId, setEditNodeId] = useState(task.nodeId);
const [editExecutionMode, setEditExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
const [editSelectedPresetId, setEditSelectedPresetId] = useState("");
const [editSelectedWorkflowSteps, setEditSelectedWorkflowSteps] = useState(task.enabledWorkflowSteps || []);
const [editSourceIssueProvider, setEditSourceIssueProvider] = useState(task.sourceIssue?.provider ?? "");
const [editSourceIssueRepository, setEditSourceIssueRepository] = useState(task.sourceIssue?.repository ?? "");
const [editSourceIssueExternalId, setEditSourceIssueExternalId] = useState(task.sourceIssue?.externalIssueId ?? "");
const [editSourceIssueUrl, setEditSourceIssueUrl] = useState(task.sourceIssue?.url ?? "");
const [editPendingImages, setEditPendingImages] = useState([]);
const [isSaving, setIsSaving] = useState(false);
const [inlinePriority, setInlinePriority] = useState(normalizeTaskPriorityValue(task.priority));
const [isSavingInlinePriority, setIsSavingInlinePriority] = useState(false);
const [inlineExecutionMode, setInlineExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
const [isSavingInlineExecutionMode, setIsSavingInlineExecutionMode] = useState(false);
const [inlineNoCommitsExpected, setInlineNoCommitsExpected] = useState(task.noCommitsExpected === true);
const [isSavingInlineNoCommitsExpected, setIsSavingInlineNoCommitsExpected] = useState(false);
const mountedRef = useRef(false);
const activeTaskIdRef = useRef(task.id);
// Split-menu dropdown state for footer actions
const [showMoveMenu, setShowMoveMenu] = useState(false);
const [showActionsMenu, setShowActionsMenu] = useState(false);
const [sourceIssueExpanded, setSourceIssueExpanded] = useState(false);
const [retriesExpanded, setRetriesExpanded] = useState(initialTab === "retries");
const [githubTrackingExpanded, setGithubTrackingExpanded] = useState(false);
const [githubRepoOverrideDraft, setGithubRepoOverrideDraft] = useState(task.githubTracking?.repoOverride ?? "");
const [githubTrackingEnabledDraft, setGithubTrackingEnabledDraft] = useState(null);
const [githubRepoOverrideError, setGithubRepoOverrideError] = useState(null);
const [isSavingGithubTracking, setIsSavingGithubTracking] = useState(false);
const [isRecoveringBranchBinding, setIsRecoveringBranchBinding] = useState(false);
const [isCheckingPrStatus, setIsCheckingPrStatus] = useState(false);
const [recoverBranchBindingOutcome, setRecoverBranchBindingOutcome] = useState(null);
const moveMenuRef = useRef(null);
const activityListRef = useRef(null);
const moveButtonRef = useRef(null);
const actionsMenuRef = useRef(null);
// Plugin UI slots for task-detail-tab
const { getSlotsForId: getPluginSlots } = usePluginUiSlots(projectId);
const pluginTabSlots = getPluginSlots("task-detail-tab");
const pluginTabs = pluginTabSlots.map((entry, index) => ({
entry,
tabId: `plugin-${entry.pluginId}-${index}` as TabId,
}));
const activePluginTab =
typeof activeTab === "string" && activeTab.startsWith("plugin-")
? pluginTabs.find((tab) => tab.tabId === activeTab) ?? null
: null;
// Track mount state to avoid setting state on unmounted component
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
activeTaskIdRef.current = task.id;
}, [task.id]);
// Merged project settings for effective model resolution in Agent Log header
const [settings, setSettings] = useState(undefined);
const [globalSettings, setGlobalSettings] = useState(null);
// Workflow results state
const [workflowResults, setWorkflowResults] = useState([]);
const [workflowResultsLoading, setWorkflowResultsLoading] = useState(false);
const [workflowEnabledSteps, setWorkflowEnabledSteps] = useState(task.enabledWorkflowSteps || []);
const isNodeOverrideLocked = task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string);
// Reset edit state when task changes
useEffect(() => {
setEditTitle(task.title || "");
setEditDescription(task.description || "");
setEditBranch(task.branch ?? "");
setEditBaseBranch(task.baseBranch ?? "");
setEditSourceIssueProvider(task.sourceIssue?.provider ?? "");
setEditSourceIssueRepository(task.sourceIssue?.repository ?? "");
setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? "");
setEditSourceIssueUrl(task.sourceIssue?.url ?? "");
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
setSourceIssueExpanded(false);
setGithubTrackingExpanded(false);
setGithubRepoOverrideDraft(workingTask.githubTracking?.repoOverride ?? "");
setGithubTrackingEnabledDraft(null);
setGithubRepoOverrideError(null);
setIsEditing(false);
setRecoverBranchBindingOutcome(null);
setIsRecoveringBranchBinding(false);
}, [task.id, task.title, task.description, task.branch, task.baseBranch, task.sourceIssue, task.executionMode, workingTask.githubTracking]);
useEffect(() => {
setWorkflowEnabledSteps(task.enabledWorkflowSteps || []);
}, [task.id, task.enabledWorkflowSteps]);
useEffect(() => {
setInlinePriority(normalizeTaskPriorityValue(task.priority));
}, [task.id, task.priority]);
useEffect(() => {
setInlineExecutionMode(normalizeExecutionModeValue(task.executionMode));
}, [task.id, task.executionMode]);
useEffect(() => {
setInlineNoCommitsExpected(task.noCommitsExpected === true);
}, [task.id, task.noCommitsExpected]);
useEffect(() => {
if (githubTrackingEnabledDraft === null) return;
if ((workingTask.githubTracking?.enabled === true) === githubTrackingEnabledDraft) {
setGithubTrackingEnabledDraft(null);
}
}, [githubTrackingEnabledDraft, workingTask.githubTracking?.enabled]);
// Load merged settings for effective model resolution
useEffect(() => {
let cancelled = false;
fetchSettings(projectId)
.then((s) => {
if (!cancelled) setSettings(s);
})
.catch(() => {
// Settings fetch failure is non-blocking; fallback to "Using default"
});
fetchGlobalSettings()
.then((nextGlobalSettings) => {
if (!cancelled) setGlobalSettings(nextGlobalSettings);
})
.catch(() => {
if (!cancelled) setGlobalSettings(null);
});
return () => { cancelled = true; };
}, [projectId]);
// Load workflow results when workflow tab is active
useEffect(() => {
if (activeTab !== "workflow") return;
let cancelled = false;
setWorkflowResultsLoading(true);
fetchWorkflowResults(task.id, projectId)
.then((results) => {
if (!cancelled) setWorkflowResults(results);
})
.catch((err) => {
if (!cancelled) {
addToast(`Failed to load workflow results: ${getErrorMessage(err)}`, "error");
}
})
.finally(() => {
if (!cancelled) setWorkflowResultsLoading(false);
});
return () => { cancelled = true; };
}, [activeTab, task.id, projectId, addToast]);
// Subscribe to SSE for real-time workflow result updates while workflow tab is active
useEffect(() => {
if (activeTab !== "workflow") return;
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const handleTaskUpdated = (e: MessageEvent) => {
try {
const updatedTask = JSON.parse(e.data);
// Only update if this is for our task and has workflow step results
if (updatedTask.id === task.id && Array.isArray(updatedTask.workflowStepResults)) {
setWorkflowResults(updatedTask.workflowStepResults);
}
} catch {
// Skip malformed events
}
};
return subscribeSse(`/api/events${query}`, {
events: { "task:updated": handleTaskUpdated },
});
}, [activeTab, task.id, projectId]);
// Reset dependency search when dropdown closes
useEffect(() => {
if (!showDepDropdown) {
setDepSearch("");
}
}, [showDepDropdown]);
useEffect(() => {
if (!task.assignedAgentId) {
setAssignedAgent(null);
return;
}
const knownAgent = agents.find((agent) => agent.id === task.assignedAgentId);
if (knownAgent) {
setAssignedAgent(knownAgent);
return;
}
let cancelled = false;
void fetchAgent(task.assignedAgentId, projectId)
.then((agent) => {
if (!cancelled) setAssignedAgent(agent);
})
.catch(() => {
if (!cancelled) setAssignedAgent(null);
});
return () => {
cancelled = true;
};
}, [task.assignedAgentId, projectId, agents]);
useEffect(() => {
if (!task.sourceAgentId) {
setSourceAgent(null);
return;
}
const knownAgent = agents.find((agent) => agent.id === task.sourceAgentId);
if (knownAgent) {
setSourceAgent(knownAgent);
return;
}
let cancelled = false;
void Promise.resolve(fetchAgent(task.sourceAgentId, projectId))
.then((agent) => {
if (!cancelled) setSourceAgent(agent ?? null);
})
.catch(() => {
if (!cancelled) setSourceAgent(null);
});
return () => {
cancelled = true;
};
}, [task.sourceAgentId, projectId, agents]);
useEffect(() => {
setShowAgentPicker(false);
}, [task.id]);
// Close footer dropdown menus on outside click
useEffect(() => {
const hasOpenMenu = showMoveMenu || showActionsMenu;
if (!hasOpenMenu) return;
const handleClick = (e: MouseEvent) => {
const target = e.target as Node;
const inMoveMenu = moveMenuRef.current?.contains(target);
const inActionsMenu = actionsMenuRef.current?.contains(target);
if (!inMoveMenu && showMoveMenu) {
setShowMoveMenu(false);
}
if (!inActionsMenu && showActionsMenu) {
setShowActionsMenu(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [showMoveMenu, showActionsMenu]);
// Close footer dropdown menus on Escape key (before modal Escape handler)
useEffect(() => {
const hasOpenMenu = showMoveMenu || showActionsMenu;
if (!hasOpenMenu) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation(); // Prevent modal from closing
if (showMoveMenu) setShowMoveMenu(false);
if (showActionsMenu) setShowActionsMenu(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [showMoveMenu, showActionsMenu]);
// Reset spec edit state when task changes
useEffect(() => {
setIsEditingSpec(false);
setSpecEditContent(workingTask.prompt || "");
setSpecFeedback("");
}, [task.id, workingTask.prompt]);
// Note: TaskForm handles auto-focus internally via isActive prop
// Check if task can be edited
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isSaving;
const canEditGithubTracking = GITHUB_TRACKING_EDITABLE_COLUMNS.has(task.column) && !isSaving;
const githubTrackingEnabled = githubTrackingEnabledDraft ?? (workingTask.githubTracking?.enabled === true);
const githubTrackedIssue = workingTask.githubTracking?.issue;
const githubTrackingDetailPending = detailLoading && typeof task.githubTracking === "undefined";
const canCreateTrackingIssue = hasUsableTrackingTitle(task);
const showInlineGithubTrackingEnableButton =
canEditGithubTracking
&& !githubTrackedIssue
&& !githubTrackingDetailPending
&& (!githubTrackingEnabled || (isSavingGithubTracking && workingTask.githubTracking?.enabled !== true));
const showGithubTrackingSection = canEditGithubTracking || githubTrackingEnabled || Boolean(githubTrackedIssue);
const retrySummary = task.retrySummary;
const retryRows = [
{ key: "stuckKill", label: "Stuck kills", title: "Stuck-task detector forced agent kill retries", value: retrySummary?.stuckKill ?? 0 },
{ key: "recovery", label: "Recovery retries", title: "Transient executor recovery retries", value: retrySummary?.recovery ?? 0 },
{ key: "taskDone", label: "task_done retries", title: "Agent exited without task_done and task was retried", value: retrySummary?.taskDone ?? 0 },
{ key: "workflowStep", label: "Workflow retries", title: "Workflow step failure retries", value: retrySummary?.workflowStep ?? 0 },
{ key: "verification", label: "Verification bounces", title: "Verification failure bounce retries", value: retrySummary?.verification ?? 0 },
{ key: "postReviewFix", label: "Post-review fixes", title: "Post-review remediation retries", value: retrySummary?.postReviewFix ?? 0 },
{ key: "mergeConflict", label: "Merge conflict bounces", title: "Merge conflict bounce retries", value: retrySummary?.mergeConflict ?? 0 },
{ key: "branchConflict", label: "Branch conflict recovery", title: "FN-4068 branch-conflict recovery retries", value: retrySummary?.branchConflict ?? 0 },
{ key: "reviewerContext", label: "Reviewer context retries", title: "FN-4082 compact reviewer retry", value: retrySummary?.reviewerContext ?? 0 },
{ key: "reviewerFallback", label: "Reviewer fallback retries", title: "FN-4092 fallback-model retry", value: retrySummary?.reviewerFallback ?? 0 },
].filter((row) => row.value > 0);
const githubTrackingStatus = githubTrackingDetailPending
? "Loading"
: githubTrackedIssue
? "Linked"
: githubTrackingEnabled
? "Enabled"
: "Disabled";
const showGithubTrackingSpinner = !githubTrackedIssue && (isSavingGithubTracking || githubTrackingDetailPending);
const effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings ?? null, globalSettings);
const githubRepoOverrideTrimmed = githubRepoOverrideDraft.trim();
const handleToggleGithubTracking = useCallback(async () => {
if (!canEditGithubTracking || isSavingGithubTracking) return;
const requestTaskId = task.id;
const nextEnabled = !githubTrackingEnabled;
setGithubTrackingEnabledDraft(nextEnabled);
setIsSavingGithubTracking(true);
try {
const updatedTask = await updateTask(task.id, {
githubTracking: {
enabled: nextEnabled,
},
}, projectId);
if (activeTaskIdRef.current !== requestTaskId) {
return;
}
setFullDetail((prev) => prev
? ({ ...prev, ...updatedTask, githubTracking: updatedTask.githubTracking } as TaskDetail)
: (updatedTask as TaskDetail));
onTaskUpdated?.(updatedTask);
} catch (err) {
if (activeTaskIdRef.current !== requestTaskId) {
return;
}
setGithubTrackingEnabledDraft(workingTask.githubTracking?.enabled === true);
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current && activeTaskIdRef.current === requestTaskId) setIsSavingGithubTracking(false);
}
}, [addToast, canEditGithubTracking, githubTrackingEnabled, isSavingGithubTracking, onTaskUpdated, projectId, workingTask.githubTracking?.enabled, task.id]);
const handleSaveGithubRepoOverride = useCallback(async () => {
if (!canEditGithubTracking || isSavingGithubTracking) return;
const requestTaskId = task.id;
if (githubRepoOverrideTrimmed.length > 0 && !REPO_OVERRIDE_RE.test(githubRepoOverrideTrimmed)) {
setGithubRepoOverrideError("Repository override must be in owner/repo format");
return;
}
setGithubRepoOverrideError(null);
setIsSavingGithubTracking(true);
try {
const updatedTask = await updateTask(task.id, {
githubTracking: {
repoOverride: githubRepoOverrideTrimmed.length > 0 ? githubRepoOverrideTrimmed : null,
},
}, projectId);
if (activeTaskIdRef.current !== requestTaskId) {
return;
}
setFullDetail((prev) => prev
? ({ ...prev, ...updatedTask, githubTracking: updatedTask.githubTracking } as TaskDetail)
: (updatedTask as TaskDetail));
onTaskUpdated?.(updatedTask);
} catch (err) {
if (activeTaskIdRef.current !== requestTaskId) {
return;
}
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current && activeTaskIdRef.current === requestTaskId) setIsSavingGithubTracking(false);
}
}, [addToast, canEditGithubTracking, githubRepoOverrideTrimmed, isSavingGithubTracking, onTaskUpdated, projectId, task.id]);
const handleRetryGithubTrackingIssueCreate = useCallback(async () => {
if (!githubTrackingEnabled || githubTrackedIssue || isSavingGithubTracking) return;
if (!hasUsableTrackingTitle(task)) {
addToast("Add a title before creating a tracking issue", "info");
return;
}
const requestTaskId = task.id;
setIsSavingGithubTracking(true);
try {
const updatedTask = await updateTask(task.id, {
githubTracking: {
enabled: true,
},
}, projectId);
if (activeTaskIdRef.current !== requestTaskId) {
return;
}
setFullDetail((prev) => prev
? ({ ...prev, ...updatedTask, githubTracking: updatedTask.githubTracking } as TaskDetail)
: (updatedTask as TaskDetail));
onTaskUpdated?.(updatedTask);
addToast("Requested GitHub tracking issue creation", "info");
} catch (err) {
if (activeTaskIdRef.current !== requestTaskId) {
return;
}
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current && activeTaskIdRef.current === requestTaskId) setIsSavingGithubTracking(false);
}
}, [addToast, githubTrackedIssue, githubTrackingEnabled, isSavingGithubTracking, onTaskUpdated, projectId, task]);
const enterEditMode = useCallback(() => {
if (!canEdit) return;
setIsEditing(true);
setEditTitle(task.title || "");
setEditDescription(task.description || "");
setEditDependencies(task.dependencies || []);
setEditBranch(task.branch ?? "");
setEditBaseBranch(task.baseBranch ?? "");
// Populate model overrides from task
const execModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "";
const valModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : "";
const planModel = task.planningModelProvider && task.planningModelId ? `${task.planningModelProvider}/${task.planningModelId}` : "";
setEditExecutorModel(execModel);
setEditValidatorModel(valModel);
setEditPlanningModel(planModel);
setEditThinkingLevel(task.thinkingLevel ?? "");
setEditNodeId(task.nodeId);
setEditPresetMode(execModel || valModel || planModel ? "custom" : "default");
setEditSelectedPresetId("");
setEditSelectedWorkflowSteps(task.enabledWorkflowSteps || []);
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
setEditSourceIssueProvider(task.sourceIssue?.provider ?? "");
setEditSourceIssueRepository(task.sourceIssue?.repository ?? "");
setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? "");
setEditSourceIssueUrl(task.sourceIssue?.url ?? "");
setEditPendingImages([]);
setEditReviewLevel(task.reviewLevel);
setEditPriority(normalizeTaskPriorityValue(task.priority));
}, [canEdit, task]);
const exitEditMode = useCallback(() => {
setIsEditing(false);
setEditTitle(task.title || "");
setEditDescription(task.description || "");
setEditDependencies(task.dependencies || []);
setEditBranch(task.branch ?? "");
setEditBaseBranch(task.baseBranch ?? "");
setEditNodeId(task.nodeId);
setEditSourceIssueProvider(task.sourceIssue?.provider ?? "");
setEditSourceIssueRepository(task.sourceIssue?.repository ?? "");
setEditSourceIssueExternalId(task.sourceIssue?.externalIssueId ?? "");
setEditSourceIssueUrl(task.sourceIssue?.url ?? "");
setEditPriority(normalizeTaskPriorityValue(task.priority));
setEditExecutionMode(normalizeExecutionModeValue(task.executionMode));
editPendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
setEditPendingImages([]);
}, [task.title, task.description, task.dependencies, task.nodeId, task.priority, task.executionMode, editPendingImages]);
const [editAutoSaveStatus, setEditAutoSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const editAutoSaveTimeoutRef = useRef | null>(null);
const editAutoSaveRevisionRef = useRef(0);
const buildEditUpdates = useCallback((includeDescription: boolean) => {
const updates: Record = {};
const trimmedTitle = editTitle.trim();
const trimmedDescription = editDescription.trim();
if (trimmedTitle && trimmedTitle !== (task.title ?? "")) updates.title = trimmedTitle;
if (includeDescription && trimmedDescription && trimmedDescription !== (task.description ?? "")) updates.description = trimmedDescription;
if (!sameStringArray(editDependencies, task.dependencies ?? [])) updates.dependencies = editDependencies;
if (!sameStringArray(editSelectedWorkflowSteps, task.enabledWorkflowSteps ?? [])) updates.enabledWorkflowSteps = editSelectedWorkflowSteps;
const normalizedBranch = editBranch.trim() || null;
const currentBranch = task.branch ?? null;
if (normalizedBranch !== currentBranch) updates.branch = normalizedBranch;
const normalizedBaseBranch = editBaseBranch.trim() || null;
const currentBaseBranch = task.baseBranch ?? null;
if (normalizedBaseBranch !== currentBaseBranch) updates.baseBranch = normalizedBaseBranch;
const executorSelection = splitModelSelection(editExecutorModel);
const currentExecutorModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "";
if (editExecutorModel !== currentExecutorModel) {
updates.modelProvider = executorSelection?.provider ?? null;
updates.modelId = executorSelection?.modelId ?? null;
}
const validatorSelection = splitModelSelection(editValidatorModel);
const currentValidatorModel = task.validatorModelProvider && task.validatorModelId ? `${task.validatorModelProvider}/${task.validatorModelId}` : "";
if (editValidatorModel !== currentValidatorModel) {
updates.validatorModelProvider = validatorSelection?.provider ?? null;
updates.validatorModelId = validatorSelection?.modelId ?? null;
}
const planningSelection = splitModelSelection(editPlanningModel);
const currentPlanningModel = task.planningModelProvider && task.planningModelId ? `${task.planningModelProvider}/${task.planningModelId}` : "";
if (editPlanningModel !== currentPlanningModel) {
updates.planningModelProvider = planningSelection?.provider ?? null;
updates.planningModelId = planningSelection?.modelId ?? null;
}
const currentThinkingLevel = task.thinkingLevel ?? "";
if (editThinkingLevel !== currentThinkingLevel) updates.thinkingLevel = editThinkingLevel !== "" ? (editThinkingLevel as "minimal" | "low" | "medium" | "high") : null;
if ((task.nodeId ?? undefined) !== editNodeId) updates.nodeId = editNodeId ?? null;
if (editReviewLevel !== task.reviewLevel) updates.reviewLevel = editReviewLevel;
if (editPriority !== normalizeTaskPriorityValue(task.priority)) updates.priority = editPriority;
if (editExecutionMode !== normalizeExecutionModeValue(task.executionMode)) updates.executionMode = editExecutionMode === "fast" ? "fast" : null;
const normalizedProvider = normalizeSourceIssueText(editSourceIssueProvider);
const normalizedRepository = normalizeSourceIssueText(editSourceIssueRepository);
const normalizedExternalId = normalizeSourceIssueText(editSourceIssueExternalId);
const normalizedUrl = normalizeSourceIssueUrl(editSourceIssueUrl);
const allSourceFieldsEmpty = normalizedProvider.length === 0 && normalizedRepository.length === 0 && normalizedExternalId.length === 0 && !normalizedUrl;
if (allSourceFieldsEmpty) {
if (task.sourceIssue) updates.sourceIssue = null;
} else {
if (!normalizedProvider || !normalizedRepository || !normalizedExternalId) {
return { updates: null, error: "Source issue provider, repository, and issue identifier are required" };
}
const fallbackIssueNumber = Number.parseInt(normalizedExternalId, 10);
const issueNumber = task.sourceIssue?.issueNumber ?? fallbackIssueNumber;
if (!Number.isFinite(issueNumber) || issueNumber <= 0) {
return { updates: null, error: "Source issue identifier must be numeric for new metadata" };
}
const nextSourceIssue: TaskSourceIssue = {
provider: normalizedProvider,
repository: normalizedRepository,
externalIssueId: normalizedExternalId,
issueNumber,
...(normalizedUrl ? { url: normalizedUrl } : {}),
};
const previousSourceIssue = task.sourceIssue;
const sourceIssueChanged = !previousSourceIssue
|| previousSourceIssue.provider !== nextSourceIssue.provider
|| previousSourceIssue.repository !== nextSourceIssue.repository
|| previousSourceIssue.externalIssueId !== nextSourceIssue.externalIssueId
|| previousSourceIssue.issueNumber !== nextSourceIssue.issueNumber
|| (previousSourceIssue.url ?? undefined) !== nextSourceIssue.url;
if (sourceIssueChanged) updates.sourceIssue = nextSourceIssue;
}
return { updates, error: null as string | null };
}, [editBaseBranch, editBranch, editDependencies, editDescription, editExecutionMode, editExecutorModel, editNodeId, editPlanningModel, editPriority, editReviewLevel, editSelectedWorkflowSteps, editSourceIssueExternalId, editSourceIssueProvider, editSourceIssueRepository, editSourceIssueUrl, editThinkingLevel, editTitle, editValidatorModel, task]);
const persistEditChanges = useCallback(async (includeDescription: boolean) => {
const { updates, error } = buildEditUpdates(includeDescription);
if (!updates) {
setEditAutoSaveStatus("error");
if (error) {
addToast(`Failed to update ${task.id}: ${error}`, "error");
}
return false;
}
if (Object.keys(updates).length === 0) {
return true;
}
const revision = ++editAutoSaveRevisionRef.current;
setIsSaving(true);
setEditAutoSaveStatus("saving");
try {
const updatedTask = await updateTask(task.id, updates as never, projectId);
if (revision !== editAutoSaveRevisionRef.current) return;
onTaskUpdated?.(updatedTask);
setEditAutoSaveStatus("saved");
return true;
} catch (err) {
if (revision === editAutoSaveRevisionRef.current) {
setEditAutoSaveStatus("error");
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
}
return false;
} finally {
if (mountedRef.current && revision === editAutoSaveRevisionRef.current) {
setIsSaving(false);
}
}
}, [addToast, buildEditUpdates, onTaskUpdated, projectId, task.id]);
const handleAutoSaveDescription = useCallback(async (_description: string) => {
await persistEditChanges(true);
}, [persistEditChanges]);
const handleSave = useCallback(async () => {
const didSave = await persistEditChanges(true);
if (!didSave) {
return;
}
addToast(`Updated ${task.id}`, "success");
if (mountedRef.current) {
setIsEditing(false);
}
}, [addToast, persistEditChanges, task.id]);
useEffect(() => {
if (!isEditing) return;
if (editAutoSaveTimeoutRef.current) {
clearTimeout(editAutoSaveTimeoutRef.current);
}
editAutoSaveTimeoutRef.current = setTimeout(() => {
void persistEditChanges(false);
}, 700);
return () => {
if (editAutoSaveTimeoutRef.current) {
clearTimeout(editAutoSaveTimeoutRef.current);
editAutoSaveTimeoutRef.current = null;
}
};
}, [
isEditing,
editTitle,
editDependencies,
editBranch,
editBaseBranch,
editExecutorModel,
editValidatorModel,
editPlanningModel,
editThinkingLevel,
editNodeId,
editReviewLevel,
editPriority,
editExecutionMode,
editSelectedWorkflowSteps,
editSourceIssueProvider,
editSourceIssueRepository,
editSourceIssueExternalId,
editSourceIssueUrl,
persistEditChanges,
]);
const handleInlinePriorityChange = useCallback(async (nextValue: string) => {
const normalizedNextPriority = normalizeTaskPriorityValue(nextValue as Task["priority"]);
const currentPriority = normalizeTaskPriorityValue(task.priority);
if (normalizedNextPriority === currentPriority) {
setInlinePriority(currentPriority);
return;
}
const previousPriority = inlinePriority;
setInlinePriority(normalizedNextPriority);
setIsSavingInlinePriority(true);
try {
const updatedTask = await updateTask(task.id, { priority: normalizedNextPriority }, projectId);
setInlinePriority(normalizeTaskPriorityValue(updatedTask.priority));
onTaskUpdated?.(updatedTask);
addToast(`Priority updated to ${normalizeTaskPriorityValue(updatedTask.priority)}`, "success");
} catch (err) {
setInlinePriority(previousPriority);
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current) {
setIsSavingInlinePriority(false);
}
}
}, [task.id, task.priority, projectId, inlinePriority, onTaskUpdated, addToast]);
const handleInlineExecutionModeToggle = useCallback(async () => {
const currentMode = normalizeExecutionModeValue(task.executionMode);
const nextMode = currentMode === "fast" ? "standard" : "fast";
const previousMode = inlineExecutionMode;
setInlineExecutionMode(nextMode);
setIsSavingInlineExecutionMode(true);
try {
const updatedTask = await updateTask(task.id, { executionMode: nextMode === "fast" ? "fast" : null }, projectId);
const normalizedUpdatedMode = normalizeExecutionModeValue(updatedTask.executionMode);
setInlineExecutionMode(normalizedUpdatedMode);
onTaskUpdated?.(updatedTask);
addToast(`Execution mode updated to ${normalizedUpdatedMode}`, "success");
} catch (err) {
setInlineExecutionMode(previousMode);
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current) {
setIsSavingInlineExecutionMode(false);
}
}
}, [task.id, task.executionMode, projectId, inlineExecutionMode, onTaskUpdated, addToast]);
const handleInlineNoCommitsExpectedToggle = useCallback(async () => {
const nextValue = !inlineNoCommitsExpected;
const previousValue = inlineNoCommitsExpected;
setInlineNoCommitsExpected(nextValue);
setIsSavingInlineNoCommitsExpected(true);
try {
const updatedTask = await updateTask(task.id, { noCommitsExpected: nextValue }, projectId);
const normalizedUpdatedValue = updatedTask.noCommitsExpected === true;
setInlineNoCommitsExpected(normalizedUpdatedValue);
onTaskUpdated?.(updatedTask);
addToast(`No-commits expectation ${normalizedUpdatedValue ? "enabled" : "disabled"}`, "success");
} catch (err) {
setInlineNoCommitsExpected(previousValue);
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current) {
setIsSavingInlineNoCommitsExpected(false);
}
}
}, [task.id, projectId, inlineNoCommitsExpected, onTaskUpdated, addToast]);
// Handle keyboard shortcuts for edit mode
const handleEditKeyDown = useCallback((e: KeyboardEvent) => {
if (!isEditing) return;
if (e.key === "Escape") {
e.preventDefault();
exitEditMode();
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void handleSave();
}
}, [isEditing, exitEditMode, handleSave]);
useEffect(() => {
if (!isEditing) return;
document.addEventListener("keydown", handleEditKeyDown);
return () => document.removeEventListener("keydown", handleEditKeyDown);
}, [isEditing, handleEditKeyDown]);
const fileInputRef = useRef(null);
const { nodes } = useNodes();
const { confirm, confirmWithChoice, confirmWithCheckbox } = useConfirm();
const handleUnlinkGithubIssue = useCallback(async () => {
if (!canEdit || !githubTrackedIssue || isSavingGithubTracking) return;
const confirmed = await confirm({
title: "Unlink GitHub issue?",
message: "This stops Fusion from syncing with the linked GitHub issue. The issue itself will not be modified.",
confirmLabel: "Unlink",
danger: true,
});
if (!confirmed) return;
setIsSavingGithubTracking(true);
try {
const updatedTask = await updateTask(task.id, { githubTracking: { issue: null } }, projectId);
onTaskUpdated?.(updatedTask);
addToast("GitHub issue unlinked", "success");
} catch (err) {
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current) setIsSavingGithubTracking(false);
}
}, [addToast, canEdit, confirm, githubTrackedIssue, isSavingGithubTracking, onTaskUpdated, projectId, task.id]);
const {
entries: agentLogEntries,
loading: agentLogLoading,
loadMore: loadMoreAgentLogs,
hasMore: agentLogHasMore,
total: agentLogTotal,
loadingMore: agentLogLoadingMore,
} = useAgentLogs(
task.id,
activeTab === "logs" && logSubview === "agent-log",
projectId,
);
const requestClose = useCallback(() => {
onRequestClose?.();
}, [onRequestClose]);
useEffect(() => {
if (embedded) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !isEditing) requestClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [embedded, requestClose, isEditing]);
const handleMove = useCallback(
async (column: Column) => {
try {
const hasStepProgress = task.steps.some((step) => step.status !== "pending");
const shouldPrompt = (column === "todo" || column === "triage") && hasStepProgress;
let moveOptions: { preserveProgress?: boolean } | undefined;
if (shouldPrompt) {
const keepProgress = await confirm({
title: "Preserve Progress?",
message: "This task has completed steps. Keep progress before moving?",
confirmLabel: "Keep Progress",
cancelLabel: "Reset Progress",
});
if (keepProgress) {
moveOptions = { preserveProgress: true };
} else {
const resetProgress = await confirm({
title: "Reset Progress?",
message: "Reset all step progress before moving this task?",
confirmLabel: "Reset Progress",
cancelLabel: "Cancel Move",
danger: true,
});
if (!resetProgress) {
return;
}
}
}
await onMoveTask(task.id, column, moveOptions);
requestClose();
addToast(`Moved to ${COLUMN_LABELS[column]}`, "success");
} catch (err) {
addToast(getErrorMessage(err), "error");
}
},
[task.id, task.steps, onMoveTask, requestClose, addToast, confirm],
);
const handleDelete = useCallback(async () => {
let allowResurrection = false;
if (task.column === "done" && onArchiveTask) {
const deleteChoice = await confirmWithChoice({
title: "Delete Task",
message: `Delete ${task.id}?`,
confirmLabel: "Delete",
cancelLabel: "Cancel",
tertiaryLabel: "Archive Instead",
danger: true,
});
if (deleteChoice === "tertiary") {
try {
await onArchiveTask(task.id);
addToast(`Archived ${task.id}`, "success");
requestClose();
} catch (err) {
const lineageConflict = extractLineageDeleteConflict(err);
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
addToast(getErrorMessage(err), "error");
return;
}
const confirmedArchive = await confirm({
title: "Force Delete Task",
message:
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
"Archive anyway by unlinking these references first?",
danger: true,
});
if (!confirmedArchive) {
return;
}
try {
await onArchiveTask(task.id, { removeLineageReferences: true });
addToast(`Archived ${task.id} after unlinking lineage references`, "success");
requestClose();
} catch (retryErr) {
addToast(getErrorMessage(retryErr), "error");
}
}
return;
}
if (deleteChoice !== "primary") {
return;
}
} else {
const { choice, checkboxValue } = await confirmWithCheckbox({
title: "Delete Task",
message: `Delete ${task.id}?`,
danger: true,
checkbox: {
label: "Allow re-creation later (operator unlock)",
description: "Lets agents recreate this task ID without --force-resurrect. Leave unchecked to keep this task tombstoned.",
defaultChecked: false,
},
});
if (choice !== "primary") return;
allowResurrection = checkboxValue === true;
}
const trackedIssue = task.githubTracking?.enabled === true ? task.githubTracking.issue : undefined;
let githubIssueAction: GithubIssueAction | undefined;
if (trackedIssue?.owner && trackedIssue.repo && trackedIssue.number) {
const issueRef = `${trackedIssue.owner}/${trackedIssue.repo}#${trackedIssue.number}`;
const shouldCloseIssue = await confirm({
title: "Linked GitHub Issue",
message: `Choose what to do with ${issueRef} when deleting ${task.id}.\n\nClose the issue?`,
confirmLabel: "Close Issue",
cancelLabel: "More Options",
});
if (shouldCloseIssue) {
githubIssueAction = "close";
} else {
const shouldDeleteIssue = await confirm({
title: "Delete Linked GitHub Issue",
message: `Delete ${issueRef} on GitHub, or leave it unchanged?`,
confirmLabel: "Delete Issue",
cancelLabel: "Leave Unchanged",
danger: true,
});
githubIssueAction = shouldDeleteIssue ? "delete" : "leave";
}
}
try {
if (githubIssueAction) {
await onDeleteTask(task.id, { githubIssueAction, allowResurrection });
} else {
await onDeleteTask(task.id, { allowResurrection });
}
requestClose();
const issueSuffix = trackedIssue?.owner && trackedIssue.repo && trackedIssue.number && githubIssueAction
? ` and ${githubIssueAction === "close" ? "closed" : githubIssueAction === "delete" ? "deleted" : "left"} issue ${trackedIssue.owner}/${trackedIssue.repo}#${trackedIssue.number}`
: "";
addToast(`Deleted ${task.id}${issueSuffix}`, "info");
} catch (err) {
const dependencyConflict = extractDependencyDeleteConflict(err);
if (dependencyConflict && dependencyConflict.dependentIds.length > 0) {
const dependentList = dependencyConflict.dependentIds.join(", ");
const confirmed = await confirm({
title: "Force Delete Task",
message:
`${task.id} is a dependency of ${dependentList}.\n\n` +
"Delete anyway by removing these dependency references first?",
danger: true,
});
if (!confirmed) {
return;
}
try {
await onDeleteTask(task.id, {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction,
allowResurrection,
});
requestClose();
addToast(`Deleted ${task.id} after removing dependency references`, "info");
} catch (retryErr) {
const lineageConflict = extractLineageDeleteConflict(retryErr);
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
addToast(getErrorMessage(retryErr), "error");
return;
}
const confirmedLineage = await confirm({
title: "Force Delete Task",
message:
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
"Delete anyway by unlinking these references first?",
danger: true,
});
if (!confirmedLineage) {
return;
}
try {
await onDeleteTask(task.id, {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction,
allowResurrection,
});
requestClose();
addToast(`Deleted ${task.id} after unlinking lineage references`, "info");
} catch (lineageRetryErr) {
addToast(getErrorMessage(lineageRetryErr), "error");
}
}
return;
}
const lineageConflict = extractLineageDeleteConflict(err);
if (!lineageConflict || lineageConflict.lineageChildIds.length === 0) {
addToast(getErrorMessage(err), "error");
return;
}
const confirmed = await confirm({
title: "Force Delete Task",
message:
`${task.id} has lineage children (${lineageConflict.lineageChildIds.join(", ")}) that reference it as a source parent.\n\n` +
"Delete anyway by unlinking these references first?",
danger: true,
});
if (!confirmed) {
return;
}
try {
await onDeleteTask(task.id, {
removeDependencyReferences: true,
removeLineageReferences: true,
githubIssueAction,
allowResurrection,
});
requestClose();
addToast(`Deleted ${task.id} after unlinking lineage references`, "info");
} catch (retryErr) {
addToast(getErrorMessage(retryErr), "error");
}
}
}, [task.column, task.githubTracking?.enabled, task.githubTracking?.issue, task.id, onDeleteTask, onArchiveTask, requestClose, addToast, confirm, confirmWithChoice, confirmWithCheckbox]);
const handleMerge = useCallback(async () => {
const shouldMerge = await confirm({
title: "Merge Task",
message: `Merge ${task.id} into the current branch?`,
});
if (!shouldMerge) return;
requestClose();
addToast(`Merging ${task.id}...`, "info");
onMergeTask(task.id)
.then((result) => {
const msg = result.merged
? `Merged ${task.id} (branch: ${result.branch})`
: `Closed ${task.id} (${result.error || "no branch to merge"})`;
addToast(msg, "success");
})
.catch((err) => {
addToast(getErrorMessage(err), "error");
});
}, [task.id, onMergeTask, requestClose, addToast, confirm]);
const handleRetry = useCallback(() => {
if (!onRetryTask) return;
requestClose();
onRetryTask(task.id)
.then(() => {
addToast(`Retried ${task.id}`, "success");
})
.catch((err) => {
addToast(getErrorMessage(err), "error");
});
}, [task.id, onRetryTask, requestClose, addToast]);
const handleReset = useCallback(() => {
if (!onResetTask) return;
if (!window.confirm(`This will erase all progress for ${task.id} and start the task from scratch. Continue?`)) return;
requestClose();
onResetTask(task.id)
.then(() => {
addToast(`Reset ${task.id} — fresh run will be allocated`, "success");
})
.catch((err) => {
addToast(getErrorMessage(err), "error");
});
}, [task.id, onResetTask, requestClose, addToast]);
const handleDuplicate = useCallback(async () => {
if (!onDuplicateTask) return;
const shouldDuplicate = await confirm({
title: "Duplicate Task",
message: `Duplicate ${task.id}? This will create a new task in Triage with the same description and prompt.`,
});
if (!shouldDuplicate) return;
try {
const newTask = await onDuplicateTask(task.id);
requestClose();
addToast(`Duplicated ${task.id} → ${newTask.id}`, "success");
} catch (err) {
addToast(getErrorMessage(err), "error");
}
}, [task.id, onDuplicateTask, requestClose, addToast, confirm]);
const handleDismissNearDuplicate = useCallback(async () => {
try {
const updatedTask = await updateTask(task.id, { dismissNearDuplicate: true }, projectId);
onTaskUpdated?.(updatedTask);
addToast(`Kept ${task.id} and dismissed duplicate warning`, "success");
} catch (err) {
addToast(getErrorMessage(err), "error");
}
}, [task.id, projectId, onTaskUpdated, addToast]);
const handleArchiveNearDuplicate = useCallback(async () => {
if (!onArchiveTask) return;
const confirmed = await confirm({
title: "Archive near-duplicate task",
message: `Archive ${task.id} as a duplicate of ${nearDuplicateOf}?`,
confirmLabel: "Archive",
cancelLabel: "Cancel",
danger: true,
});
if (!confirmed) return;
try {
await onArchiveTask(task.id);
addToast(`Archived ${task.id}`, "success");
requestClose();
} catch (err) {
addToast(getErrorMessage(err), "error");
}
}, [onArchiveTask, confirm, task.id, nearDuplicateOf, addToast, requestClose]);
const isTaskPaused = task.paused || task.userPaused;
const showRecoverBranchBindingBanner = task.column === "in-review" && !task.branch;
const handleRecoverBranchBinding = useCallback(async () => {
setIsRecoveringBranchBinding(true);
try {
const outcome = await recoverBranchBinding(task.id, projectId);
setRecoverBranchBindingOutcome(outcome);
if (outcome.result === "applied") {
addToast(`Reattached branch for ${task.id} (${outcome.branch})`, "success");
onTaskUpdated?.({ ...task, branch: outcome.branch, worktree: undefined });
} else {
addToast(`Branch reattachment skipped for ${task.id}: ${outcome.reason}`, "info");
}
} catch (err) {
addToast(getErrorMessage(err), "error");
} finally {
setIsRecoveringBranchBinding(false);
}
}, [addToast, onTaskUpdated, projectId, task]);
const handleTogglePause = useCallback(async () => {
try {
if (isTaskPaused) {
await unpauseTask(task.id, projectId);
addToast(`Unpaused ${task.id}`, "success");
} else {
await pauseTask(task.id, projectId);
addToast(`Paused ${task.id}`, "success");
}
requestClose();
} catch (err) {
addToast(getErrorMessage(err), "error");
}
}, [isTaskPaused, task.id, requestClose, addToast]);
const handleApprovePlan = useCallback(async () => {
try {
await approvePlan(task.id, projectId);
addToast(`Plan approved — ${task.id} moved to Todo`, "success");
requestClose();
} catch (err) {
addToast(getErrorMessage(err), "error");
}
}, [task.id, requestClose, addToast]);
const handleRejectPlan = useCallback(async () => {
const shouldReject = await confirm({
title: "Reject Plan",
message: "Reject this plan? The specification will be discarded and regenerated.",
danger: true,
});
if (!shouldReject) return;
try {
await rejectPlan(task.id, projectId);
addToast(`Plan rejected — ${task.id} returned to Planning for replanning`, "info");
requestClose();
} catch (err) {
addToast(getErrorMessage(err), "error");
}
}, [task.id, requestClose, addToast, confirm]);
const handleRespecify = useCallback(async () => {
const shouldRebuild = await confirm({
title: "Rebuild Plan",
message: "Rebuild the plan for this task? The task will move to planning for replanning.",
});
if (!shouldRebuild) return;
try {
await rebuildTaskSpec(task.id, projectId);
requestClose();
addToast(`Replanning ${task.id}...`, "info");
} catch (err) {
addToast(getErrorMessage(err), "error");
}
}, [task.id, projectId, requestClose, addToast, confirm]);
const handleOpenRefineModal = useCallback(() => {
setShowRefineModal(true);
setRefineFeedback("");
}, []);
// Helper to close dropdown menus after action
const closeMenus = useCallback(() => {
setShowMoveMenu(false);
setShowActionsMenu(false);
}, []);
// Menu item click handlers that close menus after action
const handleMoveMenuItemClick = useCallback((column: Column) => {
closeMenus();
handleMove(column);
}, [closeMenus]);
const handleActionsMenuItemClick = useCallback((action: () => void) => {
closeMenus();
action();
}, [closeMenus]);
const handleMergeMenuItemClick = useCallback(() => {
closeMenus();
void handleMerge();
}, [closeMenus, handleMerge]);
const handleCheckPrStatus = useCallback(async () => {
if (isCheckingPrStatus) return;
closeMenus();
setIsCheckingPrStatus(true);
try {
const result = await refreshPrStatus(task.id, projectId);
addToast("PR status refreshed", "success");
onTaskUpdated?.({
...task,
prInfo: result.prInfo,
prInfos: result.all?.map((entry) => entry.prInfo) ?? task.prInfos,
});
} catch (err) {
addToast(getErrorMessage(err), "error");
} finally {
setIsCheckingPrStatus(false);
}
}, [addToast, closeMenus, isCheckingPrStatus, onTaskUpdated, projectId, task]);
const handleCloseRefineModal = useCallback(() => {
setShowRefineModal(false);
setRefineFeedback("");
setIsRefining(false);
}, []);
const handleSubmitRefine = useCallback(async () => {
if (!refineFeedback.trim()) {
addToast("Please enter feedback describing what needs refinement", "error");
return;
}
if (refineFeedback.length > 2000) {
addToast("Feedback must be 2000 characters or less", "error");
return;
}
setIsRefining(true);
try {
const newTask = await refineTask(task.id, refineFeedback.trim(), projectId);
addToast(`Refinement task created: ${newTask.id}`, "success");
requestClose();
} catch (err) {
addToast(getErrorMessage(err), "error");
} finally {
setIsRefining(false);
}
}, [task.id, refineFeedback, addToast, requestClose]);
const uploadFile = useCallback(async (file: File) => {
setUploading(true);
try {
const attachment = await uploadAttachment(task.id, file, projectId);
setAttachments((prev) => [...prev, attachment]);
addToast("Screenshot attached", "success");
} catch (err) {
addToast(getErrorMessage(err), "error");
} finally {
setUploading(false);
}
}, [task.id, addToast]);
const handleUpload = useCallback(async (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
if (!file) return;
await uploadFile(file);
if (fileInputRef.current) fileInputRef.current.value = "";
}, [uploadFile]);
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) {
e.preventDefault();
uploadFile(file);
return;
}
}
}
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, [uploadFile]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const files = e.dataTransfer.files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith("image/")) {
uploadFile(file);
return;
}
}
}, [uploadFile]);
const handleDeleteAttachment = useCallback(async (filename: string) => {
try {
await deleteAttachment(task.id, filename, projectId);
setAttachments((prev) => prev.filter((a) => a.filename !== filename));
addToast("Attachment deleted", "info");
} catch (err) {
addToast(getErrorMessage(err), "error");
}
}, [task.id, addToast]);
const handleWorkflowStepsChange = useCallback(async (enabledWorkflowSteps: string[]) => {
const previousSteps = workflowEnabledSteps;
setWorkflowEnabledSteps(enabledWorkflowSteps);
try {
const updatedTask = await updateTask(task.id, { enabledWorkflowSteps }, projectId);
addToast("Workflow steps updated", "success");
onTaskUpdated?.(updatedTask);
} catch (err) {
setWorkflowEnabledSteps(previousSteps);
addToast(`Failed to update workflow steps: ${getErrorMessage(err)}`, "error");
}
}, [task.id, projectId, workflowEnabledSteps, onTaskUpdated, addToast]);
const loadAgents = useCallback(async () => {
setAgentsLoading(true);
try {
const loadedAgents = await fetchAgents(undefined, projectId);
setAgents(loadedAgents);
setShowAgentPicker(true);
} catch (err) {
addToast(`Failed to load agents: ${getErrorMessage(err)}`, "error");
setShowAgentPicker(false);
} finally {
setAgentsLoading(false);
}
}, [projectId, addToast]);
const handleAssignAgent = useCallback(async (agentId: string) => {
try {
const updatedTask = await assignTask(task.id, agentId, projectId);
const selected = agents.find((agent) => agent.id === agentId) ?? null;
if (selected) {
setAssignedAgent(selected);
} else {
setAssignedAgent((prev) => (prev?.id === agentId ? prev : null));
}
setShowAgentPicker(false);
onTaskUpdated?.(updatedTask);
addToast("Assigned agent updated", "success");
} catch (err) {
addToast(`Failed to assign agent: ${getErrorMessage(err)}`, "error");
}
}, [task.id, projectId, agents, onTaskUpdated, addToast]);
const handleClearAgent = useCallback(async () => {
try {
const updatedTask = await assignTask(task.id, null, projectId);
setAssignedAgent(null);
setShowAgentPicker(false);
onTaskUpdated?.(updatedTask);
addToast("Agent unassigned", "success");
} catch (err) {
addToast(`Failed to unassign agent: ${getErrorMessage(err)}`, "error");
}
}, [task.id, projectId, onTaskUpdated, addToast]);
const handleAddDep = useCallback(async (depId: string) => {
const newDeps = [...dependencies, depId];
setDependencies(newDeps);
try {
await updateTask(task.id, { dependencies: newDeps }, projectId);
} catch (err) {
setDependencies(dependencies);
addToast(getErrorMessage(err), "error");
}
}, [task.id, dependencies, addToast]);
const handleRemoveDep = useCallback(async (e: React.MouseEvent, depId: string) => {
e.stopPropagation(); // Prevent triggering dependency click
const newDeps = dependencies.filter((d) => d !== depId);
setDependencies(newDeps);
try {
await updateTask(task.id, { dependencies: newDeps }, projectId);
} catch (err) {
setDependencies(dependencies);
addToast(getErrorMessage(err), "error");
}
}, [task.id, dependencies, addToast]);
const handleClearOverlapBlocker = useCallback(async () => {
if (!workingTask.overlapBlockedBy) return;
const requestTaskId = task.id;
const previousOverlapBlockedBy = workingTask.overlapBlockedBy;
const previousStatus = workingTask.status;
setFullDetail((prev) => prev
? {
...prev,
overlapBlockedBy: undefined,
...(previousStatus === "queued" ? { status: undefined } : {}),
}
: prev);
try {
const updatedTask = await updateTask(task.id, {
overlapBlockedBy: null,
status: previousStatus === "queued" ? null : undefined,
}, projectId);
if (activeTaskIdRef.current !== requestTaskId) {
return;
}
setFullDetail((prev) => prev ? ({ ...prev, ...updatedTask } as TaskDetail) : (updatedTask as TaskDetail));
onTaskUpdated?.(updatedTask);
} catch (err) {
if (activeTaskIdRef.current !== requestTaskId) {
return;
}
setFullDetail((prev) => prev
? {
...prev,
overlapBlockedBy: previousOverlapBlockedBy,
...(previousStatus === "queued" ? { status: previousStatus } : {}),
}
: prev);
addToast(getErrorMessage(err), "error");
}
}, [activeTaskIdRef, addToast, onTaskUpdated, projectId, task.id, workingTask.overlapBlockedBy, workingTask.status]);
const handleDepClick = useCallback(async (depId: string) => {
try {
const detail = await fetchTaskDetail(depId, projectId);
onOpenDetail(detail);
} catch {
addToast(`Failed to load dependency ${depId}`, "error");
}
}, [onOpenDetail, addToast]);
// Spec save handlers (must be declared before functions that use them)
const handleSaveSpec = useCallback(async (newContent: string) => {
setIsSavingSpec(true);
try {
await updateTask(workingTask.id, { prompt: newContent }, projectId);
addToast("Spec updated", "success");
// Update local detail data
if (fullDetail) {
fullDetail.prompt = newContent;
}
} catch (err) {
addToast(getErrorMessage(err), "error");
throw err;
} finally {
setIsSavingSpec(false);
}
}, [workingTask, fullDetail, addToast]);
const handleRequestSpecRevision = useCallback(async (feedback: string) => {
setIsRequestingRevision(true);
try {
await requestSpecRevision(task.id, feedback, projectId);
addToast("AI revision requested. Task moved to planning.", "success");
// Task has been moved to planning, close modal
requestClose();
} catch (err) {
const msg = getErrorMessage(err);
if (msg.includes("done") || msg.includes("archived")) {
addToast("Cannot request revision: Task must be in 'triage', 'todo', 'in-progress', or 'in-review' column.", "error");
} else {
addToast(msg, "error");
}
} finally {
setIsRequestingRevision(false);
}
}, [task.id, addToast, requestClose]);
// Spec editing handlers (depend on handleSaveSpec and handleRequestSpecRevision)
const enterSpecEditMode = useCallback(() => {
setIsEditingSpec(true);
setSpecEditContent(workingTask.prompt || "");
setSpecFeedback("");
}, [workingTask.prompt]);
const exitSpecEditMode = useCallback(() => {
setIsEditingSpec(false);
setSpecEditContent(workingTask.prompt || "");
setSpecFeedback("");
}, [workingTask.prompt]);
const handleSaveSpecFromEdit = useCallback(async () => {
if (specEditContent === (workingTask.prompt || "")) {
exitSpecEditMode();
return;
}
// Exit edit mode immediately so the UI transitions back to preview as soon
// as save is initiated. If save fails, restore edit mode for retry.
setIsEditingSpec(false);
try {
await handleSaveSpec(specEditContent);
} catch (err) {
setIsEditingSpec(true);
throw err;
}
}, [specEditContent, workingTask.prompt, handleSaveSpec, exitSpecEditMode]);
const handleRequestRevisionFromEdit = useCallback(async () => {
if (!specFeedback.trim()) return;
await handleRequestSpecRevision(specFeedback.trim());
}, [specFeedback, handleRequestSpecRevision]);
// Keyboard shortcuts for spec edit mode
const handleSpecTextareaKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
exitSpecEditMode();
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void handleSaveSpecFromEdit();
}
}, [exitSpecEditMode, handleSaveSpecFromEdit]);
const availableTasks = tasks
.filter((t) => t.id !== task.id && !dependencies.includes(t.id))
.sort((a, b) => {
const cmp = b.createdAt.localeCompare(a.createdAt);
if (cmp !== 0) return cmp;
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
return bNum - aNum;
});
const blockerFanoutMap = useMemo(() => computeBlockerFanoutMap(tasks), [tasks]);
const blockingEntry = blockerFanoutMap.get(task.id);
const blockingDependents = useMemo(() => {
if (!blockingEntry) return [] as Array<{ id: string; label: string; stale: boolean }>;
const staleSet = new Set(blockingEntry.staleBlockedByDependentIds);
return blockingEntry.dependentIds.map((dependentId) => {
const dependentTask = tasks.find((candidate) => candidate.id === dependentId);
return {
id: dependentId,
label: dependentTask?.title || dependentTask?.description || dependentId,
stale: staleSet.has(dependentId),
};
});
}, [blockingEntry, tasks]);
const overlapBlockingSummary = blockingEntry
? `${task.id} is blocking ${blockingEntry.overlapBlockedTodoCount} todo task(s) via blockedBy overlap`
: null;
const overlapBlockerTask = workingTask.overlapBlockedBy
? tasks.find((candidate) => candidate.id === workingTask.overlapBlockedBy)
: undefined;
const overlapBlockerActive = Boolean(
overlapBlockerTask && (overlapBlockerTask.column === "in-progress" || overlapBlockerTask.column === "in-review"),
);
const assignedAgentLabel = assignedAgent?.name ?? task.assignedAgentId ?? null;
const detailProviders = useMemo(() => {
const providers: string[] = [];
if (workingTask.modelProvider) providers.push(workingTask.modelProvider);
if (workingTask.validatorModelProvider && !providers.includes(workingTask.validatorModelProvider)) {
providers.push(workingTask.validatorModelProvider);
}
if (workingTask.planningModelProvider && !providers.includes(workingTask.planningModelProvider)) {
providers.push(workingTask.planningModelProvider);
}
return providers;
}, [workingTask.modelProvider, workingTask.validatorModelProvider, workingTask.planningModelProvider]);
const transitions = VALID_TRANSITIONS[task.column] || [];
const inReviewMoveTransitions: Column[] = ["todo", "in-progress"];
const moveTransitions = task.column === "in-review" ? inReviewMoveTransitions : transitions;
const primaryMoveTransition = moveTransitions[0];
const secondaryMoveTransitions = moveTransitions.slice(1);
const hasSecondaryMoveOptions = secondaryMoveTransitions.length > 0;
const closeMoveMenuAndFocusTrigger = useCallback(() => {
setShowMoveMenu(false);
moveButtonRef.current?.focus();
}, []);
const handleMoveButtonClick = useCallback((event: React.MouseEvent) => {
if (!hasSecondaryMoveOptions) {
if (primaryMoveTransition) {
void handleMoveMenuItemClick(primaryMoveTransition);
}
return;
}
const arrowZone = event.currentTarget.querySelector(".detail-move-btn__arrow");
const clickedArrow = Boolean(
(event.target instanceof Element && event.target.closest(".detail-move-btn__arrow")) ||
(arrowZone && event.clientX > 0 && event.clientX >= arrowZone.getBoundingClientRect().left),
);
if (clickedArrow) {
setShowMoveMenu((prev) => !prev);
setShowActionsMenu(false);
return;
}
if (primaryMoveTransition) {
void handleMoveMenuItemClick(primaryMoveTransition);
}
}, [hasSecondaryMoveOptions, primaryMoveTransition, handleMoveMenuItemClick]);
const handleMoveButtonKeyDown = useCallback((event: React.KeyboardEvent) => {
if (!hasSecondaryMoveOptions) {
return;
}
const shouldOpenMenu = event.key === "ArrowDown" || (event.altKey && event.key === "ArrowDown");
if (!shouldOpenMenu) {
return;
}
event.preventDefault();
setShowMoveMenu(true);
setShowActionsMenu(false);
}, [hasSecondaryMoveOptions]);
const handleMoveMenuKeyDown = useCallback((event: React.KeyboardEvent) => {
if (event.key !== "Escape") {
return;
}
event.preventDefault();
event.stopPropagation();
closeMoveMenuAndFocusTrigger();
}, [closeMoveMenuAndFocusTrigger]);
useEffect(() => {
if (!showMoveMenu) {
return;
}
const firstMenuItem = moveMenuRef.current?.querySelector(".detail-move-menu-item");
firstMenuItem?.focus();
}, [showMoveMenu]);
const prAutomationStatusLabels: Record = {
"creating-pr": "Creating PR…",
"awaiting-pr-checks": "Awaiting PR checks",
"merging-pr": "Merging PR…",
"merging-fix": "Merging fixes…",
};
const prAutomationLabel = task.status ? prAutomationStatusLabels[task.status] : undefined;
const mergeStrategy = settings?.mergeStrategy ?? "direct";
const autoMergeEnabled = settings?.autoMerge ?? false;
const isManualPrFlow = mergeStrategy === "pull-request" && !autoMergeEnabled;
let manualReviewActionLabel = "Merge & Close";
if (isManualPrFlow && !prAutomationLabel) {
if (!task.prInfo) {
manualReviewActionLabel = "Start PR Review";
} else if (task.prInfo.status === "open") {
manualReviewActionLabel = "Check PR Status";
} else if (task.prInfo.status === "merged") {
manualReviewActionLabel = "Finish & Close";
}
}
return (
{task.id}
{COLUMN_LABELS[task.column]}
{!isEditing && canEdit && (
)}
{!embedded && mobileHeaderMode === "back" && (
Back
)}
{!embedded && mobileHeaderMode !== "back" && (
×
)}
{isEditing ? (
t.id !== task.id)}
projectId={projectId}
disabled={isSaving}
addToast={addToast}
isActive={isEditing}
onAutoSaveDescription={handleAutoSaveDescription}
reviewLevel={editReviewLevel}
onReviewLevelChange={setEditReviewLevel}
priority={editPriority}
onPriorityChange={setEditPriority}
nodeId={editNodeId}
onNodeIdChange={setEditNodeId}
nodeOptions={nodes}
nodeOverrideDisabled={isNodeOverrideLocked}
nodeOverrideDisabledReason={isNodeOverrideLocked ? "Execution node override is locked while a task is active/in progress." : undefined}
executionMode={editExecutionMode}
onExecutionModeChange={setEditExecutionMode}
renderBelowModelConfiguration={(
)}
/>
) : (
<>
{(() => {
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 && (
setDescriptionExpanded(!descriptionExpanded)}
>
{descriptionExpanded ? "Show less" : "Show more"}
)}
>
);
})()}
{showNearDuplicateWarning && (
Potential duplicate detected
This task appears to be a near-duplicate of{" "}
{
if (nearDuplicateOf) {
handleDepClick(nearDuplicateOf);
}
}}
>
{nearDuplicateOf}
. Choose Archive to move this task to archived, or Keep to continue with this task.
{onArchiveTask && (
void handleArchiveNearDuplicate()}>
Archive
)}
void handleDismissNearDuplicate()}>
Keep
)}
Priority:
{
void handleInlinePriorityChange(event.target.value);
}}
disabled={isSavingInlinePriority}
aria-label="Task priority"
>
{TASK_PRIORITIES.map((priorityOption) => (
{priorityOption}
))}
{
void handleInlineExecutionModeToggle();
}}
disabled={isSavingInlineExecutionMode}
aria-label={`Execution mode: ${inlineExecutionMode}`}
aria-pressed={inlineExecutionMode === "fast"}
>
{inlineExecutionMode === "fast" ? "Fast" : "Standard"}
{provenanceDisplay && (
{workingTask.sourceType === "agent_heartbeat" ? (
<>
Created by{" "}
{provenanceDisplay.sourceAgentId ? (
setSelectedSourceAgentId(provenanceDisplay.sourceAgentId!)}
>
{provenanceDisplay.label}
) : (
provenanceDisplay.label
)}
>
) : (
<>Created via {provenanceDisplay.label}>
)}
{provenanceDisplay.parentTaskId && (
<>
{" "}of{" "}
handleDepClick(provenanceDisplay.parentTaskId!)}
>
{provenanceDisplay.parentTaskId}
>
)}
{provenanceDisplay.contextInfo ? (
<>
{" ("}
{provenanceDisplay.contextHref ? (
{provenanceDisplay.contextInfo}
) : (
{provenanceDisplay.contextInfo}
)}
{")"}
>
) : (
""
)}
)}
{(task.prInfo?.number || task.mergeDetails?.prNumber) && (
PR{" "}
{task.prInfo?.url ? (
#{task.prInfo.number}
) : (
#{task.prInfo?.number ?? task.mergeDetails?.prNumber}
)}
)}
Created {" "}
{formatTimestamp(task.createdAt)}
·
Updated {" "}
{formatTimestamp(task.updatedAt)}
{task.branchContext?.groupId && (
)}
>
)}
{task.status === "failed" && task.error && (
)}
{task.pausedReason === "worktrunk_operation_failed" && (
Worktrunk operation failed
{task.worktrunkFailure?.stderr && (
{task.worktrunkFailure.stderr.slice(0, 2048)}
)}
)}
{!isEditing && (
<>
setActiveTab("definition")}
>
Definition
setActiveTab("logs")}
>
Logs
{(task.column === "in-progress" || task.column === "in-review" || task.column === "done") && (
setActiveTab("changes")}
>
Changes
)}
setActiveTab("review")}
>
Review
{task.column === "in-review" && (
setActiveTab("pr")}
>
Pull Request
)}
setActiveTab("comments")}
>
Comments
setActiveTab("documents")}
>
Documents
setActiveTab("model")}
>
Model
setActiveTab("workflow")}
>
Workflow
setActiveTab("stats")}
>
Stats
setActiveTab("routing")}
>
Routing
{/* Plugin tabs */}
{pluginTabs.map(({ entry, tabId }) => {
return (
setActiveTab(tabId)}
>
{entry.slot.label}
);
})}
{activeTab === "workflow" ? (
) : activeTab === "model" ? (
) : activeTab === "logs" ? (
setLogSubview("activity")}
>
Activity
setLogSubview("agent-log")}
>
Agent Log
{logSubview === "agent-log" ? (
) : (
Activity
{(workingTask as typeof workingTask & { activityLogTruncatedCount?: number }).activityLogTruncatedCount ? (
Showing the most recent {workingTask.log.length} activity entries.
) : null}
{workingTask.log && workingTask.log.length > 0 ? (
{(() => {
let highlightedOnce = false;
return [...workingTask.log].reverse().map((entry, i) => {
const stallMatch = entry.action.match(IN_REVIEW_STALL_LOG_REGEX)
?? entry.action.match(STALE_PAUSED_REVIEW_LOG_REGEX);
const isHighlighted = !highlightedOnce
&& highlightStallCode != null
&& stallMatch?.[1] === highlightStallCode;
if (isHighlighted) {
highlightedOnce = true;
}
return (
{formatTimestamp(entry.timestamp)}
{entry.action}
{entry.outcome && (
{entry.outcome}
)}
);
});
})()}
) : (
(no activity)
)}
)}
) : activeTab === "changes" ? (
) : activeTab === "review" ? (
setPrCreateOpen(true)}
/>
) : activeTab === "pr" ? (
{task.column === "in-review" && (
<>
{shouldShowInReviewStallBadge(workingTask) && workingTask.inReviewStall && (() => {
const copy = getInReviewStallCopy(workingTask.inReviewStall, {
mergeRetries: workingTask.mergeRetries,
maxAutoMergeRetries: MAX_AUTO_MERGE_RETRIES,
});
const logMatch = findInReviewStallLogEntry(workingTask, workingTask.inReviewStall.code);
return (
{copy.badgeLabel}{copy.counter ? ` ${copy.counter}` : ""}
{copy.headline}
{workingTask.inReviewStall.reason}
{copy.description}
{copy.suggestedAction}
Observed {formatTimestamp(workingTask.inReviewStall.observedAt)}
{logMatch ? (
{
setActiveTab("logs");
setLogSubview("activity");
setHighlightStallCode(workingTask.inReviewStall?.code ?? null);
}}
>
View activity log
) : (
No log entry yet
)}
);
})()}
{shouldShowStalePausedReviewBadge(workingTask) && workingTask.stalePausedReview && (() => {
const copy = getStalePausedReviewCopy(workingTask.stalePausedReview);
const logMatch = [...(workingTask.log ?? [])].reverse().find((entry) => {
const match = entry.action.match(STALE_PAUSED_REVIEW_LOG_REGEX);
return match?.[1] === workingTask.stalePausedReview?.code;
});
return (
{copy.badgeLabel}
{copy.headline}
{workingTask.stalePausedReview.reason}
{copy.description}
{copy.suggestedAction}
Age {formatDurationCompact(workingTask.stalePausedReview.ageMs)}
Threshold {formatDurationCompact(workingTask.stalePausedReview.thresholdMs)}
Observed {formatTimestamp(workingTask.stalePausedReview.observedAt)}
{logMatch ? (
{
setActiveTab("logs");
setLogSubview("activity");
setHighlightStallCode(workingTask.stalePausedReview?.code ?? null);
}}
>
View activity log
) : (
No log entry yet
)}
);
})()}
setPrCreateOpen(true)}
onPrUpdated={(prInfo) => {
const existing = task.prInfos ?? (task.prInfo ? [task.prInfo] : []);
const nextPrInfos = existing.some((entry) => entry.number === prInfo.number)
? existing.map((entry) => (entry.number === prInfo.number ? prInfo : entry))
: [...existing, prInfo];
(task as TaskDetail).prInfos = nextPrInfos;
(task as TaskDetail).prInfo = nextPrInfos[0] ?? prInfo;
}}
onPrsRefreshed={(prInfos) => {
(task as TaskDetail).prInfos = prInfos;
(task as TaskDetail).prInfo = prInfos[0];
}}
onPrUnlinked={(prNumber) => {
const nextPrInfos = (task.prInfos ?? (task.prInfo ? [task.prInfo] : [])).filter((entry) => entry.number !== prNumber);
(task as TaskDetail).prInfos = nextPrInfos;
(task as TaskDetail).prInfo = nextPrInfos[0];
}}
addToast={addToast}
/>
>
)}
) : activeTab === "comments" ? (
) : activeTab === "documents" ? (
) : activePluginTab ? (
) : activeTab === "stats" ? (
) : activeTab === "routing" ? (
) : (
<>
{/* Summary section - only for done tasks with summary */}
{task.column === "done" && task.summary && (
)}
{(retrySummary?.total ?? 0) > 0 && (
Retries
{retrySummary?.total ?? 0}
setRetriesExpanded((expanded) => !expanded)}
>
{retriesExpanded && (
{retryRows.map((row) => (
{row.label}
{row.value}
))}
)}
{settings?.maxTotalRetriesBeforeFail != null && (retrySummary?.total ?? 0) >= settings.maxTotalRetriesBeforeFail && (
Retry cap reached for this task.
)}
)}
{task.sourceIssue && (
Source issue
{task.sourceIssue.provider.toLowerCase() === "github" && (
GitHub
)}
{task.sourceIssue.url ? (
{`(#${task.sourceIssue.issueNumber})`}
) : (
{`(#${task.sourceIssue.issueNumber})`}
)}
setSourceIssueExpanded((expanded) => !expanded)}
>
{sourceIssueExpanded && (
Provider
{task.sourceIssue.provider}
Repository
{task.sourceIssue.repository}
Issue Identifier
{task.sourceIssue.externalIssueId}
)}
)}
{showGithubTrackingSection && (
GitHub tracking
{githubTrackingStatus}
{!githubTrackedIssue && (
{githubTrackingDetailPending
? "Checking tracking status"
: githubTrackingEnabled
? "Issue not yet created"
: "Tracking is currently disabled"}
)}
{showInlineGithubTrackingEnableButton && (
void handleToggleGithubTracking()}
>
Enable
)}
{showGithubTrackingSpinner && (
{isSavingGithubTracking ? "Enabling GitHub tracking…" : "Loading GitHub tracking status…"}
)}
setGithubTrackingExpanded((expanded) => !expanded)}
>
{githubTrackingExpanded && (
{githubTrackedIssue && (
State
{task.issueInfo?.state ?? "open"}
)}
{!githubTrackedIssue && githubTrackingEnabled && (
<>
void handleRetryGithubTrackingIssueCreate()}
disabled={isSavingGithubTracking || !canCreateTrackingIssue}
title={!canCreateTrackingIssue ? "Add a title or description so a tracking issue can be created." : undefined}
>
Create tracking issue
{!canCreateTrackingIssue && (
Tracking issue will be created once this task has a title or description to summarize.
)}
>
)}
{canEditGithubTracking && (
<>
void handleToggleGithubTracking()}
/>
Enable GitHub tracking
{
setGithubRepoOverrideDraft(event.target.value);
setGithubRepoOverrideError(null);
}}
placeholder={effectiveGithubRepoDefault || "owner/repo"}
/>
void handleSaveGithubRepoOverride()} disabled={isSavingGithubTracking}>
Save
{githubRepoOverrideError &&
{githubRepoOverrideError} }
{githubTrackedIssue && (
void handleUnlinkGithubIssue()} disabled={isSavingGithubTracking}>
Unlink GitHub issue
)}
>
)}
)}
)}
{detailProviders.length > 0 && (
{detailProviders.map((provider) => (
))}
)}
Agent
{assignedAgentLabel ? (
{assignedAgentLabel}
void handleClearAgent()}
title="Unassign agent"
>
) : (
{
if (showAgentPicker) {
setShowAgentPicker(false);
} else {
void loadAgents();
}
}}
>
Assign Agent
)}
{showAgentPicker && (
{agentsLoading &&
Loading agents...
}
{!agentsLoading && agents.map((a) => (
void handleAssignAgent(a.id)}
>
{a.name}
{a.role}
))}
{!agentsLoading && agents.length === 0 && (
No agents available
)}
)}
Progress
{workingTask.steps && workingTask.steps.length > 0 ? (
{workingTask.steps.map((step, index) => (
))}
{workingTask.steps.filter(s => s.status === "done").length}/{workingTask.steps.length} step{workingTask.steps.length === 1 ? "" : "s"}
) : (
(no steps defined)
)}
{!isEditingSpec && (
Edit
)}
{isEditingSpec ? (
) : detailLoading ? (
Loading specification…
) : workingTask.prompt ? (
{workingTask.prompt.replace(/^#\s+[^\n]*\n+/, "")}
) : (
(no prompt)
)}
Attachments
{attachments.length > 0 ? (
{attachments.map((a) => {
const attachmentUrl = appendTokenQuery(`/api/tasks/${task.id}/attachments/${a.filename}`);
return (
{a.originalName} ({formatBytes(a.size)})
handleDeleteAttachment(a.filename)}
title="Delete attachment"
>
×
);
})}
) : (
(no attachments)
)}
fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? "Uploading…" : "Attach Screenshot"}
Dependencies
{dependencies.length > 0 ? (
{dependencies.map((dep) => {
// Look up dependency metadata from tasks prop
const depTask = tasks.find((t) => t.id === dep);
const depLabel = depTask?.title || depTask?.description || dep;
return (
handleDepClick(dep)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleDepClick(dep);
}
}}
role="link"
tabIndex={0}
title={`Click to view ${dep}`}
>
{dep}
{truncate(depLabel, 40)}
handleRemoveDep(e, dep)}
title={`Remove dependency ${dep}`}
>
×
);
})}
) : (
(no dependencies)
)}
{workingTask.overlapBlockedBy && (
File scope overlap blocker: {workingTask.overlapBlockedBy}
{!overlapBlockerActive && " (stale)"}
void handleClearOverlapBlocker()}
title={`Clear overlap blocker ${workingTask.overlapBlockedBy}`}
>
Clear
)}
{
if (showDepDropdown) setDepSearch("");
setShowDepDropdown((v) => !v);
}}
>
Add Dependency
{showDepDropdown && (() => {
const term = depSearch.toLowerCase();
const filtered = term
? availableTasks.filter((t) =>
t.id.toLowerCase().includes(term) ||
(t.title && t.title.toLowerCase().includes(term)) ||
(t.description && t.description.toLowerCase().includes(term))
)
: availableTasks;
return (
setDepSearch(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{filtered.length === 0 ? (
No available tasks
) : (
filtered.map((t) => (
{
handleAddDep(t.id);
setShowDepDropdown(false);
}}
>
{t.id}
{truncate(t.title || t.description || t.id, 30)}
))
)}
);
})()}
Blocking
{blockingEntry && (
{overlapBlockingSummary}
)}
{blockingDependents.length > 0 ? (
{blockingDependents.map((dependent) => (
handleDepClick(dependent.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleDepClick(dependent.id);
}
}}
role="link"
tabIndex={0}
title={`Click to view ${dependent.id}`}
>
{dependent.id}
{truncate(dependent.label, 40)}
{dependent.stale && (
(stale)
)}
))}
) : (
(no downstream tasks blocked)
)}
{workingTask.ageStaleness && (() => {
const copy = getTaskAgeStalenessCopy(workingTask.ageStaleness);
if (!copy) return null;
return (
Task age staleness
{copy.headline}
{copy.description}
Column {workingTask.ageStaleness.column}
Age {formatDurationCompact(workingTask.ageStaleness.ageMs)}
Warning {formatDurationCompact(workingTask.ageStaleness.warningThresholdMs)}
Critical {formatDurationCompact(workingTask.ageStaleness.criticalThresholdMs)}
Observed {formatTimestamp(workingTask.ageStaleness.observedAt)}
{workingTask.ageStaleness.paused ? "Paused" : "Active"}
);
})()}
>
)}
>
)}
{task.column === "in-review" && (
setPrCreateOpen(false)}
onCreated={(prInfo) => {
const nextPrInfos = [...(task.prInfos ?? (task.prInfo ? [task.prInfo] : [])), prInfo];
(task as TaskDetail).prInfo = nextPrInfos[0] ?? prInfo;
(task as TaskDetail).prInfos = nextPrInfos;
onTaskUpdated?.({ ...workingTask, prInfo: nextPrInfos[0] ?? prInfo, prInfos: nextPrInfos } as Task);
setPrCreateOpen(false);
}}
addToast={addToast}
/>
)}
{showRecoverBranchBindingBanner && (
Branch needs reattachment
This in-review task isn't currently attached to a fusion branch. If a live fusion branch still exists for it, you can reattach it here.
{recoverBranchBindingOutcome && (
{recoverBranchBindingOutcome.result === "applied"
? `Reattached ${recoverBranchBindingOutcome.branch} (${recoverBranchBindingOutcome.aheadCount} commits ahead of ${recoverBranchBindingOutcome.integrationBase}).`
: `Reattachment skipped: ${recoverBranchBindingOutcome.reason}`}
{recoverBranchBindingOutcome.result === "skipped" && recoverBranchBindingOutcome.candidates?.length ? (
{` Candidates: ${recoverBranchBindingOutcome.candidates.map((entry) => `${entry.branch} (${entry.aheadCount})`).join(", ")}`}
) : null}
)}
void handleRecoverBranchBinding()}
disabled={isRecoveringBranchBinding}
>
{isRecoveringBranchBinding ? (
<>
Reattaching…
>
) : "Reattach branch"}
)}
{isEditing ? (
<>
{editAutoSaveStatus === "saving" ? "Autosaving…" : editAutoSaveStatus === "saved" ? "Saved" : editAutoSaveStatus === "error" ? "Save failed" : "Changes autosave as you edit"}
Cancel
void handleSave()}
disabled={isSaving}
>
{isSaving ? "Saving…" : "Save"}
>
) : (
<>
{/* Approve/Reject Plan buttons for tasks awaiting approval — always visible */}
{task.column === "triage" && task.status === "awaiting-approval" && workingTask.prompt && (
<>
Approve Plan
Reject Plan
>
)}
{/* Standalone Delete button for triage-column tasks — triage tasks
hide the Actions dropdown (see condition below) so the user has
no quick way to delete a freshly-created task otherwise. */}
{task.column === "triage" && task.status !== "awaiting-approval" && !canRetryTask && (
Delete
)}
{/* Actions dropdown — less common operations */}
{(task.column !== "triage" || task.status === "awaiting-approval" || canRetryTask || isTaskPaused) && (
{
setShowActionsMenu((prev) => !prev);
setShowMoveMenu(false);
}}
aria-haspopup="menu"
aria-expanded={showActionsMenu}
>
Actions
{showActionsMenu && (
{/* Delete — destructive, always first */}
handleActionsMenuItemClick(handleDelete)}
>
Delete
{/* Duplicate */}
{onDuplicateTask && (
handleActionsMenuItemClick(handleDuplicate)}
>
Duplicate
)}
{/* Refine */}
{(task.column === "done" || task.column === "in-review") && (
handleActionsMenuItemClick(handleOpenRefineModal)}
>
Refine
)}
{/* Respecify */}
handleActionsMenuItemClick(handleRespecify)}
>
Respecify
{/* Retry */}
{canRetryTask && onRetryTask && (
handleActionsMenuItemClick(handleRetry)}
>
Retry
)}
{/* Reset (nuclear) — wipes all progress and reallocates worktree */}
{onResetTask && task.column !== "done" && task.column !== "archived" && (
handleActionsMenuItemClick(handleReset)}
>
Reset
)}
{/* Pause/Unpause */}
{task.column !== "done" && !task.assignedAgentId && (
handleActionsMenuItemClick(handleTogglePause)}
>
{isTaskPaused ? "Unpause" : "Pause"}
)}
{task.column !== "done" && task.paused && task.pausedByAgentId && (
Paused by agent
)}
)}
)}
{/* Move dropdown — column transitions and merge actions */}
{task.column === "in-review" ? (
Move to {primaryMoveTransition ? COLUMN_LABELS[primaryMoveTransition] : ""}
{hasSecondaryMoveOptions && (
)}
{showMoveMenu && hasSecondaryMoveOptions && (
{secondaryMoveTransitions.map((col) => (
handleMoveMenuItemClick(col)}
onKeyDown={handleMoveMenuKeyDown}
>
{col === "in-progress" ? "Back to In Progress" : `Move to ${COLUMN_LABELS[col]}`}
))}
)}
{prAutomationLabel ? (
{prAutomationLabel}
) : (
{manualReviewActionLabel}
)}
) : (
Move to {primaryMoveTransition ? COLUMN_LABELS[primaryMoveTransition] : ""}
{hasSecondaryMoveOptions && (
)}
{showMoveMenu && hasSecondaryMoveOptions && (
{secondaryMoveTransitions.map((col) => (
handleMoveMenuItemClick(col)}
onKeyDown={handleMoveMenuKeyDown}
>
Move to {COLUMN_LABELS[col]}
))}
)}
)}
>
)}
{showRefineModal && (
e.stopPropagation()}
>
Refine
×
Describe what needs to be refined or improved...
setRefineFeedback(e.target.value)}
placeholder="Enter your feedback here..."
rows={6}
maxLength={2000}
autoFocus
/>
{refineFeedback.length}/2000 characters
{isRefining ? "Creating..." : "Create Refinement Task"}
Cancel
)}
{selectedSourceAgentId && (
setSelectedSourceAgentId(null)}
addToast={addToast}
/>
)}
);
}
export function TaskDetailModal({ onClose, ...props }: TaskDetailModalProps) {
const modalRef = useRef(null);
useModalResizePersist(modalRef, true, "task-detail-modal-size");
useMobileScrollLock(true);
const overlayDismissProps = useOverlayDismiss(onClose);
return (
);
}