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>
225 lines
12 KiB
TypeScript
225 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|