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>
This commit is contained in:
gsxdsm
2026-07-31 20:39:37 -07:00
parent 7e4e892fce
commit b9612de748
5 changed files with 50 additions and 9 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Prevent blank task IDs from enabling Research finding enrichment.
category: fix
dev: Trims the target task ID before enabling and submitting the enrich action.

View File

@@ -133,6 +133,14 @@ export function ResearchTaskActionModal({ open, mode, run, finding, projectId, o
}; };
}, [open, mode, projectId, isArchivedColumn]); }, [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; if (!open) return null;
return ( return (
@@ -195,11 +203,11 @@ export function ResearchTaskActionModal({ open, mode, run, finding, projectId, o
<button <button
className="btn btn-primary" className="btn btn-primary"
type="button" type="button"
disabled={saving || (mode === "enrich" && !taskId)} disabled={saving || (mode === "enrich" && !normalizedTaskId)}
onClick={() => { onClick={() => {
setSaving(true); setSaving(true);
void onConfirm({ void onConfirm({
taskId: mode === "enrich" ? taskId : undefined, taskId: mode === "enrich" ? normalizedTaskId : undefined,
title: mode === "create" ? title.trim() : undefined, title: mode === "create" ? title.trim() : undefined,
description: mode === "create" ? description.trim() : undefined, description: mode === "create" ? description.trim() : undefined,
priority: mode === "create" ? priority : undefined, priority: mode === "create" ? priority : undefined,

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture"; import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
import { Header } from "../Header"; import { Header } from "../Header";
import { ResearchView } from "../ResearchView"; import { ResearchView } from "../ResearchView";
@@ -291,7 +292,14 @@ describe("ResearchView", () => {
}); });
}); });
/*
FNXC:ResearchEnrich 2026-08-01-02:35:
The enrich control is a real editable combobox, not a broken product path. Drive it with
per-character user input so this regression test validates the same state updates as a user;
synthetic change events previously masked that interaction contract.
*/
it("triggers enrich-task action from finding modal", async () => { it("triggers enrich-task action from finding modal", async () => {
const user = userEvent.setup();
const attachRunToTask = vi.fn().mockResolvedValue({}); const attachRunToTask = vi.fn().mockResolvedValue({});
mockUseResearch.mockReturnValue({ mockUseResearch.mockReturnValue({
...baseHookValue, ...baseHookValue,
@@ -314,8 +322,8 @@ describe("ResearchView", () => {
const enrichDialog = await screen.findByRole("dialog"); const enrichDialog = await screen.findByRole("dialog");
const targetInput = within(enrichDialog).getByRole("combobox", { name: "Target task" }); const targetInput = within(enrichDialog).getByRole("combobox", { name: "Target task" });
fireEvent.change(targetInput, { target: { value: "FN-1" } }); await user.type(targetInput, "FN-1");
fireEvent.click(within(enrichDialog).getByRole("button", { name: "Enrich Task" })); await user.click(within(enrichDialog).getByRole("button", { name: "Enrich Task" }));
await waitFor(() => { await waitFor(() => {
expect(attachRunToTask).toHaveBeenCalledWith("RR-1", "FN-1", "finding-1", false); expect(attachRunToTask).toHaveBeenCalledWith("RR-1", "FN-1", "finding-1", false);
@@ -533,7 +541,8 @@ describe("ResearchView", () => {
}); });
}); });
it("keeps enrich action disabled until a task id is provided", async () => { it("keeps enrich action disabled for empty and whitespace-only task ids, then enables for a typed id", async () => {
const user = userEvent.setup();
mockUseResearch.mockReturnValue({ mockUseResearch.mockReturnValue({
...baseHookValue, ...baseHookValue,
runs: [{ id: "RR-1", title: "t", query: "q", status: "completed" }], runs: [{ id: "RR-1", title: "t", query: "q", status: "completed" }],
@@ -557,7 +566,10 @@ describe("ResearchView", () => {
expect(enrichButton).toBeDisabled(); expect(enrichButton).toBeDisabled();
const targetInput = within(dialog).getByRole("combobox", { name: "Target task" }); const targetInput = within(dialog).getByRole("combobox", { name: "Target task" });
fireEvent.change(targetInput, { target: { value: "FN-1" } }); await user.type(targetInput, " ");
expect(enrichButton).toBeDisabled();
await user.clear(targetInput);
await user.type(targetInput, "FN-1");
await waitFor(() => expect(enrichButton).not.toBeDisabled()); await waitFor(() => expect(enrichButton).not.toBeDisabled());
}); });

View File

@@ -28,13 +28,20 @@ describe("TaskDetailModal CSS contract", () => {
expect(css).toMatch(/\.detail-tab\s*\{[^}]*flex-shrink\s*:\s*0\s*;/); expect(css).toMatch(/\.detail-tab\s*\{[^}]*flex-shrink\s*:\s*0\s*;/);
}); });
/*
FNXC:TaskDetailPadding 2026-08-01-03:20:
FN-8634 moved the canonical tab inset from the scrolling `.detail-body` to its
`.detail-body-content` child so scrollbar tracks remain outside the content inset.
FN-7408 must assert that visual contract at the owning element while chat and
planner variants remain forbidden from introducing competing outer padding.
*/
it("FN-7408 keeps task-detail tab body padding canonical across Activity, planner Chat, and Plan surfaces", async () => { it("FN-7408 keeps task-detail tab body padding canonical across Activity, planner Chat, and Plan surfaces", async () => {
const css = await loadAllAppCssBaseOnly(); const css = await loadAllAppCssBaseOnly();
const detailBodyBlock = getCssRuleBlock(css, ".detail-body"); const detailBodyContentBlock = getCssRuleBlock(css, ".detail-body-content");
const rawBodyBlock = getCssRuleBlock(css, ".detail-body--agent-log"); const rawBodyBlock = getCssRuleBlock(css, ".detail-body--agent-log");
const planBlock = getCssRuleBlock(css, ".detail-section--plan-prompt"); const planBlock = getCssRuleBlock(css, ".detail-section--plan-prompt");
expect(detailBodyBlock).toContain("padding: calc(var(--space-lg) + var(--space-xs));"); expect(detailBodyContentBlock).toContain("padding: calc(var(--space-lg) + var(--space-xs));");
expectNoOuterPaddingOverride(css, ".detail-body--chat"); expectNoOuterPaddingOverride(css, ".detail-body--chat");
expectNoOuterPaddingOverride(css, ".detail-body--planner-chat"); expectNoOuterPaddingOverride(css, ".detail-body--planner-chat");
expectNoOuterPaddingOverride(css, ".task-detail-content--chat-expanded .detail-body--chat"); expectNoOuterPaddingOverride(css, ".task-detail-content--chat-expanded .detail-body--chat");

View File

@@ -1827,7 +1827,14 @@ describe("useChat", () => {
}, },
}; };
mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] }); mockFetchChatSessions.mockResolvedValueOnce({ sessions: [session] });
mockFetchChatSession.mockResolvedValueOnce({ session: generatingSession }); /*
FNXC:ChatVisibilityResume 2026-08-01-02:35:
The visible-edge reconciliation now performs its own authoritative session read after
selection. Keep both reads generating: a one-shot mock made the second read fall back to an
unrelated idle fixture, which falsely modeled a completed user-visible generation and hid the
prior thread this test is meant to protect.
*/
mockFetchChatSession.mockResolvedValue({ session: generatingSession });
mockFetchChatMessages mockFetchChatMessages
.mockResolvedValueOnce({ messages: [] }) .mockResolvedValueOnce({ messages: [] })
.mockResolvedValueOnce({ messages: priorThreadNewestFirst }); .mockResolvedValueOnce({ messages: priorThreadNewestFirst });