Files
fusion/packages/dashboard/app/components/ResearchTaskActionModal.tsx
gsxdsm b9612de748 FN-8646: fix dashboard task enrichment validation
Repair deterministic dashboard test regressions and reject whitespace-only enrichment targets.

- Normalize task IDs before enabling and submitting research enrichment.
- Update research, task-detail CSS, and chat visibility test fixtures.
- Add a patch changeset for the dashboard behavior fix.

Files changed:
 .changeset/fn-8646-dashboard-test-repairs.md         |  7 +++++++
 .../app/components/ResearchTaskActionModal.tsx       | 12 ++++++++++--
 .../app/components/__tests__/ResearchView.test.tsx   | 20 ++++++++++++++++----
 .../components/__tests__/TaskDetailModal.css.test.ts | 11 +++++++++--
 .../dashboard/app/hooks/__tests__/useChat.test.ts    |  9 ++++++++-
 5 files changed, 50 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-8646
Fusion-Task-Lineage: 033f4fa4-b096-4129-b82b-8fa7078dd6b2
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-31 20:39:37 -07:00

225 lines
12 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]);
/*
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 (
<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" && !normalizedTaskId)}
onClick={() => {
setSaving(true);
void onConfirm({
taskId: mode === "enrich" ? normalizedTaskId : 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>
);
}