Files
fusion/packages/dashboard/app/components/ResearchTaskActionModal.tsx
gsxdsm 210a89d74f fix(dashboard): an operator's typed task title was wiped when board workflows resolved (#3286)
Found by chasing the deterministic half of #3264 (dashboard red on
`main`). **The tests were right; the product is broken.**

## The bug

Open **Create Task** from a research finding, type a title before the
board workflows settle, and the field silently reverts to the derived
default `Research: <heading>`. The task is then created with a title the
operator did not write. `description`, `priority` and `taskId` reset the
same way.

`ResearchTaskActionModal` reset those four fields in the same effect
that fetched the task list, and that effect's dependency list carried
`isArchivedColumn`:

```ts
const isArchivedColumn = useMemo(() => { … }, [boardWorkflows]);   // useBoardWorkflows() — async
useEffect(() => {
  setTitle(`Research: ${finding.heading || run.title}`);           // ← re-runs on every revalidation
  …
}, [open, mode, projectId, finding.heading, preview, run.title, isArchivedColumn]);
```

`useBoardWorkflows` resolves and revalidates asynchronously, so the
memo's identity changes and the reset re-runs over whatever the operator
has typed.

Introduced by #3215, which correctly added the archived-column filter
but hung its dependency on an effect that also owns form state. Same
class as the documented
`docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`.

## The fix

Split into two effects: the reset depends only on what it derives from;
the fetch keeps `isArchivedColumn`. No behaviour change to the archived
filter — #3215's guard is untouched.

## Verification, both directions

The three standing `ResearchView` tests fail without this and pass with
it:

```
isArchivedColumn back on the reset effect:  3 failed | 24 passed (27)
as committed:                               27 passed (27)
```

## What I tried and removed, because it matters

I wrote a dedicated invariant test (per "fix the invariant, not the
repro") asserting that *all* typed fields survive a revalidation. **I
deleted it, because it did not work.**

- First draft used `mockImplementationOnce` to defer
`fetchBoardWorkflows`. `ResearchView` resolves board workflows on mount,
so that once-implementation was consumed before the modal opened.
Reverting the product fix left the test **green** — it proved nothing.
- Second draft deferred *every* call. `beforeEach` uses
`vi.clearAllMocks()`, which clears calls but **not implementations**, so
the deferral leaked into later tests and left `fetchBoardWorkflows`
permanently pending — masking two of the three genuine failures. The
revert then showed `1 failed` instead of `3`, i.e. my test was hiding
real bugs.

Rather than ship a regression test that cannot regress, I removed it.
The three existing tests already fail without the fix, which is real
coverage; a broader invariant test needs a modal-level harness that
resets implementations between cases, and that is worth doing properly
rather than badly here.

## Scope

Also in #3264: `TaskCard.badge-wrap` (1 deterministic failure, unrelated
— CSS/layout), and `useChat` / `WorkflowNodeEditor` /
`PlanningModeModal`, which pass standalone and are cross-file
contamination, not product bugs. Untouched here; the issue has the
per-file matrix.

No changeset — `@fusion/dashboard` is private.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Preserved form entries during workflow revalidation in the research
task modal.
* Limited task selection to active workflow columns when enriching
findings.
  * Prevented outdated task results from replacing newer selections.
* Improved loading and task-list behavior when source findings or modal
state changes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 18:17:52 -07:00

217 lines
11 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]);
/*
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]);
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>
);
}