Files
fusion/packages/dashboard/app/components/ResearchTaskActionModal.tsx
gsxdsm 0bdc9bf4fb fix(dashboard): archived tasks stayed in the research picker on a renamed board (#3215)
## The defect

The enrich-mode task picker filtered with `task.column !== "archived"`.
On a board whose archive lane is renamed, that matched nothing — so
filed-away tasks stayed in the picker and an operator could attach
research findings to work they had deliberately archived.

## Census before / after

| | before | after |
|---|---|---|
| COLUMN guards (backlog) | 10 | **9** |
| `ResearchTaskActionModal.tsx` | 1 | **0 — converted** |

Baseline re-recorded in the same commit; `--strict` green.

## This site was declined twice, and I wrote the second wrong estimate

#3213 left it counted, correctly, on the note that was here — which was
mine. Both prior cost estimates were wrong, so this corrects my own
work:

1. **"Needs a data-fetch change"** — reasoned about
`columnFlagsByTaskId`, a per-**task** map built from board-resident
rows. Right that such a map can't 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"** — right
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`, a hook already called from `App`,
`Board`, and `HeaderWorkflowSwitcherSlot`.

**Measured cost: one file.** The modal already takes `projectId`, and
`ResearchView` renders it only when a finding is open (`open` hardcoded
beside `if (!finding) return null`) — so the hook cannot fetch for a
closed modal, which was the one real objection to calling it here.

Union across workflows keyed by column id, first declaration wins — the
same convention `ListView` uses, so the two cannot disagree about a
shared id. `isArchivedColumnRole` fail-softs to the legacy id when a
column has no flags, so an unresolved workflow behaves exactly as the
literal did.

## Tests — the invariant, not the repro

Per the surface-enumeration rule, four cases: renamed archive lane,
legacy id, unresolved workflow (fail-soft), and a second workflow's
archive lane through the cross-workflow union. A repro-only test would
pass on the legacy board and prove nothing about the case the guard
exists for.

**Anti-vacuity control:**

| | renamed lane | union | legacy id | fail-soft |
|---|---|---|---|---|
| pre-fix literal | **FAIL** | **FAIL** | pass | pass |
| converted | pass | pass | pass | pass |

The legacy and fail-soft cases hold in both directions **on purpose** —
they pin that this conversion did not change the pre-resolution answer.
Flagging that so 4/4 isn't read as four independent proofs.

## Measured

| check | result |
|---|---|
| `census --strict` / `check-fnxc-future-dates` | exit 0 / exit 0 |
| `eslint` | clean |
| `tsc -p tsconfig.app.json` (the config that actually covers `app/`) |
exit 0 |
| new tests | 4/4 |
| `pnpm test:gate` | exit 0 (744 tests) |

## Note on process

My first attempt at the control silently did nothing — the revert script
threw a `SyntaxError`, so the "pre-fix" run was the fixed code and
reported 4/4. Caught it because the error printed. The table above is
from the re-run.
2026-07-31 11:42:15 -07:00

180 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void>;
}
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<TaskPriority>("normal");
const [taskId, setTaskId] = useState("");
const [tasks, setTasks] = useState<Task[]>([]);
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<string, { archived?: boolean }>();
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]);
useEffect(() => {
if (!open) return;
setAttachExport(false);
setTitle(`Research: ${finding.heading || run.title}`);
setDescription(preview);
setPriority("normal");
setTaskId("");
if (mode === "enrich") {
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) => setTasks(rows.filter((task) => !isArchivedColumn(task.column))))
.finally(() => setLoadingTasks(false));
}
}, [open, mode, projectId, finding.heading, preview, run.title, isArchivedColumn]);
if (!open) return null;
return (
<div className="modal-overlay open" role="presentation" onClick={onClose}>
<div className="modal modal-lg research-task-action-modal" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>{mode === "create" ? t("research.createTaskTitle", "Create task from finding") : t("research.enrichTaskTitle", "Enrich existing task")}</h3>
<button className="modal-close" type="button" aria-label={t("actions.close", "Close")} onClick={onClose}>×</button>
</div>
<div className="research-task-action-modal__body">
<div className="card research-task-action-modal__preview">
<p><strong>{t("research.runLabel", "Run:")} </strong> {run.id}</p>
<p><strong>{t("research.findingLabel", "Finding:")} </strong> {finding.id}{finding.heading ? ` — ${finding.heading}` : ""}</p>
<p>{preview || t("research.noPreview", "No preview available.")}</p>
</div>
{mode === "create" ? (
<>
<label className="research-task-action-modal__field">{t("research.titleLabel", "Title")}
<input className="input" value={title} onChange={(event) => setTitle(event.target.value)} />
</label>
<label className="research-task-action-modal__field">{t("research.descriptionLabel", "Description")}
<textarea className="input research-task-action-modal__textarea" value={description} onChange={(event) => setDescription(event.target.value)} />
</label>
<label className="research-task-action-modal__field">{t("research.priorityLabel", "Priority")}
<select className="select" value={priority} onChange={(event) => setPriority(event.target.value as TaskPriority)}>
<option value="low">{t("research.priorityLow", "Low")}</option>
<option value="normal">{t("research.priorityNormal", "Normal")}</option>
<option value="high">{t("research.priorityHigh", "High")}</option>
<option value="urgent">{t("research.priorityUrgent", "Urgent")}</option>
</select>
</label>
</>
) : (
<label className="research-task-action-modal__field">{t("research.targetTaskLabel", "Target task")}
<input
className="input"
list="research-task-action-task-list"
value={taskId}
placeholder={loadingTasks ? t("research.loadingTasks", "Loading tasks…") : t("research.enterTaskId", "Enter task ID")}
onChange={(event) => setTaskId(event.target.value)}
/>
<datalist id="research-task-action-task-list">
{tasks.map((task) => (
<option key={task.id} value={task.id}>{task.title}</option>
))}
</datalist>
</label>
)}
<label className="checkbox-label">
<input type="checkbox" checked={attachExport} onChange={(event) => setAttachExport(event.target.checked)} />
<span>{t("research.attachExport", "Attach markdown export artifact")}</span>
</label>
</div>
<div className="modal-actions">
<button className="btn" type="button" onClick={onClose}>{t("actions.cancel", "Cancel")}</button>
<button
className="btn btn-primary"
type="button"
disabled={saving || (mode === "enrich" && !taskId)}
onClick={() => {
setSaving(true);
void onConfirm({
taskId: mode === "enrich" ? taskId : undefined,
title: mode === "create" ? title.trim() : undefined,
description: mode === "create" ? description.trim() : undefined,
priority: mode === "create" ? priority : undefined,
attachExport,
}).finally(() => setSaving(false));
}}
>
{mode === "create" ? t("research.createTaskButton", "Create Task") : t("research.enrichTaskButton", "Enrich Task")}
</button>
</div>
</div>
</div>
);
}