import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import type { Task, TaskPriority } from "@fusion/core"; import { fetchTasks } from "../api"; import { useBoardWorkflows } from "../hooks/useBoardWorkflows"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { isArchivedColumnRole } from "../utils/columnRoles"; import type { ResearchRunDetail } from "../research-types"; import "./ResearchTaskActionModal.css"; type Mode = "create" | "enrich"; interface ResearchTaskActionModalProps { open: boolean; mode: Mode; run: ResearchRunDetail; finding: { id: string; heading?: string; content?: string }; projectId?: string; onClose: () => void; onConfirm: (payload: { taskId?: string; title?: string; description?: string; priority?: TaskPriority; attachExport: boolean }) => Promise; } export function ResearchTaskActionModal({ open, mode, run, finding, projectId, onClose, onConfirm }: ResearchTaskActionModalProps) { const { t } = useTranslation("app"); useMobileScrollLock(open); const [attachExport, setAttachExport] = useState(false); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [priority, setPriority] = useState("normal"); const [taskId, setTaskId] = useState(""); const [tasks, setTasks] = useState([]); const [loadingTasks, setLoadingTasks] = useState(false); const [saving, setSaving] = useState(false); /* FNXC:WorkflowResolvedColumns 2026-07-31-11:40 (u12 — CONVERTED, and the earlier cost estimate was wrong): The note this replaces said converting the filter below needed prop threading through three components (MainContent -> ResearchView -> here) because `ListView.tsx:756` builds `columnFlagsById` locally. That was wrong about WHERE the data lives: `listColumns` derives from `useBoardWorkflows`, a hook already called from App, Board and HeaderWorkflowSwitcherSlot. ListView only looked like the owner because it happens to build the map inline. So the real cost is this file, and nothing else. The modal already takes `projectId`, and ResearchView renders it ONLY when a finding is open (`open` is hardcoded true beside a `if (!finding) return null`), so the hook cannot fetch for a closed modal. Union across workflows keyed by column id, first declaration wins — the same convention `ListView.tsx` uses for its cross-workflow map, so the two cannot disagree about a shared id. */ const { boardWorkflows } = useBoardWorkflows({ projectId }); const isArchivedColumn = useMemo(() => { const flagsById = new Map(); for (const workflow of boardWorkflows?.workflows ?? []) { for (const column of workflow.columns) { if (!flagsById.has(column.id)) flagsById.set(column.id, column.flags); } } /* `isArchivedColumnRole` fail-softs to the legacy id when a column has no flags, which is the pre-resolution answer — so an unresolved workflow behaves exactly as this filter did before. */ return (column: string): boolean => isArchivedColumnRole(flagsById.get(column), column); }, [boardWorkflows]); const preview = useMemo(() => { const firstSentence = (finding.content ?? "").split(/(?<=[.!?])\s+/)[0] ?? ""; return `${finding.heading || t("research.defaultFindingHeading", "Research finding")} — ${firstSentence}`.trim(); }, [finding.content, finding.heading, t]); /* FNXC:ResearchTaskModal 2026-08-01-00:41 (an operator's typed title was wiped when board workflows resolved): These two jobs were one effect, and its dependency list carried `isArchivedColumn` for the fetch's sake. `isArchivedColumn` is a `useMemo` over `boardWorkflows` from `useBoardWorkflows()`, so its identity changes every time that hook resolves or revalidates — and each change re-ran the whole effect, calling `setTitle`/`setDescription`/`setPriority`/`setTaskId` over whatever the operator had already typed. Opening the modal and typing before the workflows settled silently reverted the form to its defaults. Split so the reset depends only on what the reset is derived from, and the fetch keeps the dependency it actually needs. Same class as `docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`: user input reset by an async revalidation the user cannot see. */ useEffect(() => { if (!open) return; setAttachExport(false); setTitle(`Research: ${finding.heading || run.title}`); setDescription(preview); setPriority("normal"); setTaskId(""); }, [open, mode, finding.heading, preview, run.title]); useEffect(() => { if (!open || mode !== "enrich") return; /* FNXC:ResearchTaskPicker 2026-08-01-00:30 (#3286 review — "ignore results from superseded task requests"): LAST REQUEST WINS, AND THE STALE LIST IS NOT SELECTABLE MEANWHILE. `projectId` / `isArchivedColumn` changing starts a second fetch while the first is in flight. With no guard the slower one resolves last and repopulates the picker from the OLD project — and because the previous rows stayed listed while loading, an operator could attach a finding to a task from a project they had already switched away from. Wrong-row attachment, not a cosmetic flicker. Clearing on entry also removes the stale-but-selectable window: the picker is empty while loading rather than showing rows the current filters have not vetted. */ let superseded = false; setTasks([]); setLoadingTasks(true); void fetchTasks(50, 0, projectId) /* FNXC:WorkflowResolvedColumns 2026-07-31-11:45 (u12 — the history, kept short because it is CONVERTED now): This guard was sized twice and declined twice, each time on a cost that turned out to be wrong. Recorded because both wrong answers are instructive, not to relitigate them: 1. "Needs a data-fetch change" — reasoning about `columnFlagsByTaskId`, a per-TASK map built from board-resident rows. Correct that such a map cannot help (archived rows are exactly what a board map omits), but this guard asks a per-COLUMN question, so it never needed one. 2. "Needs prop threading, MainContent -> ResearchView -> here" — correct that the answer is column-keyed, wrong about where it lives. `ListView` builds `columnFlagsById` inline, which made it look like the owner; the data is `useBoardWorkflows`, callable from here directly. The guard was real either way: on a renamed board `archived` matched nothing, so filed-away tasks stayed in this picker and an operator could attach findings to work they had archived. */ .then((rows) => { if (superseded) return; setTasks(rows.filter((task) => !isArchivedColumn(task.column))); }) .finally(() => { if (!superseded) setLoadingTasks(false); }); return () => { superseded = true; }; }, [open, mode, projectId, isArchivedColumn]); /* FNXC:ResearchEnrich 2026-08-01-02:35: Enriching requires a real task identifier. Whitespace made the action appear available and sent a non-actionable identifier downstream, so validate the normalized value and submit that same value rather than preserving an input-only representation. */ const normalizedTaskId = taskId.trim(); if (!open) return null; return (
event.stopPropagation()}>

{mode === "create" ? t("research.createTaskTitle", "Create task from finding") : t("research.enrichTaskTitle", "Enrich existing task")}

{t("research.runLabel", "Run:")} {run.id}

{t("research.findingLabel", "Finding:")} {finding.id}{finding.heading ? ` — ${finding.heading}` : ""}

{preview || t("research.noPreview", "No preview available.")}

{mode === "create" ? ( <>