FN-7265: preserve workflow on refinements

Preserve workflow context when creating refinement tasks from completed or reviewed work.

- Inherit explicit source workflow selections when creating refinement tasks and seed the new card into the workflow entry column.
- Write task rows and workflow-selection rows atomically so refinements cannot be stranded outside their intended board.
- Clear successful done-task chat refinement composer bubbles and document the dashboard behavior.
- Add core and dashboard regression coverage for refinement workflow preservation.

Files changed:
 .changeset/fuzzy-refinements-dance.md              |   7 +
 .changeset/preserve-refinement-workflow.md         |   7 +
 docs/dashboard-guide.md                            |   3 +-
 .../src/__tests__/workflow-selection-store.test.ts | 159 +++++++++++++++++++--
 packages/core/src/store.ts                         |  55 ++++++-
 .../workflow-selection-cross-surface.test.tsx      |  41 +++++-
 packages/dashboard/app/components/TaskChatTab.tsx  |   5 +
 .../app/components/__tests__/TaskChatTab.test.tsx  |  27 +++-
 .../TaskDetailModal.definition-actions.test.tsx    |  52 +++++++
 9 files changed, 335 insertions(+), 21 deletions(-)

Fusion-Task-Id: FN-7265

Fusion-Task-Lineage: 48700e3e-9f3a-474e-806c-47db0b75fca1

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 22:54:50 -07:00
parent cfd9d5b32e
commit 2a5a108123
9 changed files with 335 additions and 21 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep dashboard workflow selection stable after creating refinement tasks.
category: fix
dev: Done-task chat refinement now clears its temporary composer bubble after successful creation.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep refinement tasks on the source task workflow board.
category: fix
dev: TaskStore.refineTask now inherits explicit workflow selections atomically during creation.

View File

@@ -146,8 +146,9 @@ Features:
<!-- FNXC:WorkflowSelection 2026-06-29-18:37: The Board-only All workflows option renders an aggregate column set across workflows while keeping workflow-specific creates, edits, and durable selection scoped to real workflow ids. -->
<!-- FNXC:WorkflowSelection 2026-06-29-23:58: All workflows quick-create must use a real workflow intake/default column rather than a synthesized lifecycle column, so custom-default boards do not create tasks into invalid or disappearing columns. -->
<!-- FNXC:WorkflowSelection 2026-06-29-23:59: Workflow counts and All workflows grouping resolve each task's effective workflow before evaluating column visibility, so a shared column id hidden in one workflow does not leak that workflow's hidden tasks into another workflow's visible aggregate lane. -->
<!-- FNXC:WorkflowSelection 2026-06-29-21:40: Refinement creation from Task Detail and done-task chat must preserve both the source task workflow and the operator's selected Board/List lane, so non-default workflow users do not get bounced back to Coding/default after refinement. -->
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow name and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived and board-hidden columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Board also shows **All workflows** before real workflows as a dashboard-only aggregate view with combined counts and a deterministic union of visible workflow columns; shared column ids use the default workflow label/flags when available, otherwise the first workflow definition that declares the column. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable and is not saved as the durable selected workflow, and its quick-create affordance appears only on the chosen real workflow intake/default column so task creation still sends a real workflow id and column. Each real workflow option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, or respecification flows; if that saved workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible.
- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, respecification flows, or refinement creation from Task Detail and done-task chat; if that saved workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible.
- Briefly leaving Board/List for a task detail or another non-task-SSE view preserves the current in-memory task snapshot. Returning to Board/List reuses that fresh snapshot immediately and restores live SSE updates without an extra all-task fetch; Fusion still runs one catch-up fetch when task data is missing, stale, or from a failed refresh.
<!-- FNXC:BoardTaskCache 2026-06-29-20:05: Board/List returns from non-task-SSE views should reuse a fresh in-memory task snapshot to avoid redundant all-task fetches and loading flashes, while stale, missing, or errored snapshots still trigger one catch-up fetch and restore SSE updates. -->

View File

@@ -75,39 +75,50 @@ invariant `selection.stepIds === task.enabledWorkflowSteps` still holds.
*/
/** v2 workflow whose success path threads through two optional-group nodes
* (og-on defaultOn:true, og-off defaultOn:false). */
function optionalGroupIr(): WorkflowIr {
function optionalGroupIr(options: { onId?: string; offId?: string; name?: string } = {}): WorkflowIr {
const onId = options.onId ?? "og-on";
const offId = options.offId ?? "og-off";
const groupTemplate = (id: string) => ({
nodes: [{ id: `${id}-inner`, kind: "prompt" as const, config: { prompt: "x" } }],
edges: [],
});
return {
version: "v2",
name: "og-wf",
name: options.name ?? "og-wf",
columns: [{ id: "todo", name: "Todo", traits: [] }],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{
id: "og-on",
id: onId,
kind: "optional-group",
column: "todo",
config: { name: "On Group", defaultOn: true, template: groupTemplate("og-on") },
config: { name: "On Group", defaultOn: true, template: groupTemplate(onId) },
},
{
id: "og-off",
id: offId,
kind: "optional-group",
column: "todo",
config: { name: "Off Group", defaultOn: false, template: groupTemplate("og-off") },
config: { name: "Off Group", defaultOn: false, template: groupTemplate(offId) },
},
{ id: "end", kind: "end", column: "todo" },
],
edges: [
{ from: "start", to: "og-on", condition: "success" },
{ from: "og-on", to: "og-off", condition: "success" },
{ from: "og-off", to: "end", condition: "success" },
{ from: "start", to: onId, condition: "success" },
{ from: onId, to: offId, condition: "success" },
{ from: offId, to: "end", condition: "success" },
],
};
}
function nonTriageIntakeIr(): WorkflowIr {
const ir = optionalGroupIr({ name: "non-triage-intake" }) as Extract<WorkflowIr, { version: "v2" }>;
return {
...ir,
columns: [{ id: "intake", name: "Intake", traits: [{ trait: "intake" }] }],
nodes: ir.nodes.map((node) => ({ ...node, column: "intake" })),
};
}
describe("TaskStore workflow selection (U3)", () => {
const harness = createTaskStoreTestHarness();
let store: ReturnType<typeof harness.store>;
@@ -334,6 +345,136 @@ describe("TaskStore workflow selection (U3)", () => {
expect(await store.getDefaultWorkflowId()).toBeUndefined();
});
async function moveToDone(taskId: string): Promise<void> {
await store.moveTask(taskId, "todo");
await store.moveTask(taskId, "in-progress");
await store.moveTask(taskId, "in-review");
await store.moveTask(taskId, "done");
}
describe("refinement workflow inheritance (FN-7265)", () => {
it("preserves a custom workflow selection by reseeding its default-on optional groups", async () => {
const wf = await store.createWorkflowDefinition({ name: "QA", ir: optionalGroupIr() });
const otherWorkflow = await store.createWorkflowDefinition({
name: "Other QA",
ir: optionalGroupIr({ onId: "other-on", offId: "other-off", name: "other-og-wf" }),
});
const source = await store.createTask({ description: "source", enabledWorkflowSteps: [] });
await store.selectTaskWorkflow(source.id, otherWorkflow.id);
await store.selectTaskWorkflow(source.id, wf.id);
await store.updateTask(source.id, { enabledWorkflowSteps: ["other-on", "stale-manual-toggle"] });
await moveToDone(source.id);
const refined = await store.refineTask(source.id, "follow up");
const detail = await store.getTask(refined.id);
const refinedSelection = store.getTaskWorkflowSelection(refined.id);
expect(refinedSelection?.workflowId).toBe(wf.id);
expect(refinedSelection?.stepIds).toEqual(["og-on"]);
expect(detail.enabledWorkflowSteps).toEqual(["og-on"]);
expect(detail.enabledWorkflowSteps).not.toEqual(["other-on", "stale-manual-toggle"]);
expect(detail.enabledWorkflowSteps).not.toContain("other-on");
});
it("places a custom-workflow refinement in its non-triage entry column", async () => {
const wf = await store.createWorkflowDefinition({ name: "Non-triage QA", ir: nonTriageIntakeIr() });
const source = await store.createTask({ description: "source", enabledWorkflowSteps: [] });
await store.selectTaskWorkflow(source.id, wf.id);
await moveToDone(source.id);
const refined = await store.refineTask(source.id, "follow up");
const detail = await store.getTask(refined.id);
expect(store.getTaskWorkflowSelection(refined.id)).toEqual({ workflowId: wf.id, stepIds: ["og-on"] });
expect(detail.column).toBe("intake");
expect(detail.column).not.toBe("triage");
});
it("preserves a custom workflow selection with an empty optional-group seed set", async () => {
const wf = await store.createWorkflowDefinition({ name: "Linear", ir: linearIr() });
const source = await store.createTask({ description: "source", enabledWorkflowSteps: [] });
await store.selectTaskWorkflow(source.id, wf.id);
await moveToDone(source.id);
const refined = await store.refineTask(source.id, "follow up");
expect(store.getTaskWorkflowSelection(refined.id)).toEqual({ workflowId: wf.id, stepIds: [] });
expect((await store.getTask(refined.id)).enabledWorkflowSteps ?? []).toEqual([]);
});
it("preserves a non-default built-in workflow selection", async () => {
const source = await store.createTask({ description: "source", enabledWorkflowSteps: [] });
await store.selectTaskWorkflow(source.id, "builtin:quick-fix");
await moveToDone(source.id);
const refined = await store.refineTask(source.id, "follow up");
expect(store.getTaskWorkflowSelection(refined.id)?.workflowId).toBe("builtin:quick-fix");
});
it("uses normal default-workflow inheritance when the source has no explicit selection", async () => {
const defaultWorkflow = await store.createWorkflowDefinition({ name: "Default", ir: optionalGroupIr() });
await store.setDefaultWorkflowId(defaultWorkflow.id);
const source = await store.createTask({ description: "source", enabledWorkflowSteps: [] });
expect(store.getTaskWorkflowSelection(source.id)).toBeUndefined();
await moveToDone(source.id);
const refined = await store.refineTask(source.id, "follow up");
const detail = await store.getTask(refined.id);
expect(store.getTaskWorkflowSelection(refined.id)).toEqual({ workflowId: defaultWorkflow.id, stepIds: ["og-on"] });
expect(detail.enabledWorkflowSteps).toEqual(["og-on"]);
expect(detail.column).toBe("todo");
});
it("does not create an explicit selection row for no-selection sources without a default workflow", async () => {
const source = await store.createTask({ description: "source", enabledWorkflowSteps: [] });
expect(store.getTaskWorkflowSelection(source.id)).toBeUndefined();
await moveToDone(source.id);
const refined = await store.refineTask(source.id, "follow up");
expect(store.getTaskWorkflowSelection(refined.id)).toBeUndefined();
expect((await store.getTask(refined.id)).enabledWorkflowSteps ?? []).toEqual([]);
});
it("falls back to the default workflow when the source selection row is stale", async () => {
const defaultWorkflow = await store.createWorkflowDefinition({ name: "Default", ir: optionalGroupIr() });
await store.setDefaultWorkflowId(defaultWorkflow.id);
const source = await store.createTask({ description: "source", enabledWorkflowSteps: [] });
store.getDatabase().prepare(
`INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt)
VALUES (?, ?, ?, ?)
ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId, stepIds = excluded.stepIds, updatedAt = excluded.updatedAt`,
).run(source.id, "WF-MISSING", JSON.stringify(["stale-group"]), new Date().toISOString());
await moveToDone(source.id);
const before = (await store.listTasks({ includeArchived: true })).length;
const refined = await store.refineTask(source.id, "follow up");
const detail = await store.getTask(refined.id);
expect((await store.listTasks({ includeArchived: true })).length).toBe(before + 1);
expect(store.getTaskWorkflowSelection(refined.id)).toEqual({ workflowId: defaultWorkflow.id, stepIds: ["og-on"] });
expect(detail.column).toBe("todo");
});
it("fails before creating a refinement when the explicit source workflow cannot materialize", async () => {
const invalidWorkflow = await store.createWorkflowDefinition({ name: "Branchy", ir: branchingIr() });
const source = await store.createTask({ description: "source", enabledWorkflowSteps: [] });
store.getDatabase().prepare(
`INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt)
VALUES (?, ?, ?, ?)
ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId, stepIds = excluded.stepIds, updatedAt = excluded.updatedAt`,
).run(source.id, invalidWorkflow.id, JSON.stringify([]), new Date().toISOString());
await moveToDone(source.id);
const before = (await store.listTasks({ includeArchived: true })).length;
await expect(store.refineTask(source.id, "follow up")).rejects.toBeInstanceOf(WorkflowCompileError);
expect((await store.listTasks({ includeArchived: true })).length).toBe(before);
});
});
// U6/R3/KTD-4: create-time `workflowId` materializes the selection atomically.
describe("create-time workflowId (U6/R3)", () => {
it("seeds enabledWorkflowSteps atomically when workflowId is given", async () => {

View File

@@ -3691,6 +3691,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
task: Task,
operation: string,
reservationCommit?: { reservationId: string; nodeId: string },
workflowSelection?: { workflowId: string; stepIds: string[] },
): Promise<void> {
const id = this.getTaskIdFromDir(dir);
let deletedAt: string | undefined;
@@ -3698,6 +3699,9 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
deletedAt = this.getSoftDeletedWriteConflict(id, task);
if (deletedAt) return;
this.insertTaskWithFtsRecovery(task, operation);
if (workflowSelection) {
this.writeTaskWorkflowSelection(id, workflowSelection.workflowId, workflowSelection.stepIds);
}
if (reservationCommit) {
/*
FNXC:TaskIdReservation 2026-06-26-00:00:
@@ -5198,7 +5202,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
/**
* Create a refinement task from a completed or in-review task.
* The new task is created in triage with a dependency on the original task.
* The new task is created in the inherited workflow's entry column with a dependency on the original task.
* Validates the original is in 'done' or 'in-review' column.
*/
async refineTask(id: string, feedback: string): Promise<Task> {
@@ -5224,6 +5228,34 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
*/
const refinementTitle = `${id}: ${normalizedFeedback}`.slice(0, MAX_TITLE_LENGTH).trim();
const sourceWorkflowSelection = this.getTaskWorkflowSelection(id);
let inheritedWorkflowSelection: { workflowId: string; stepIds: string[]; entryColumnId?: string } | undefined;
if (sourceWorkflowSelection) {
try {
inheritedWorkflowSelection = await this.materializeExplicitWorkflowSteps(sourceWorkflowSelection.workflowId);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
if (!/not found/i.test(errorMessage)) throw err;
storeLog.warn("Source workflow selection was stale during refinement; falling back to default workflow", {
phase: "refineTask:source-workflow",
taskId: id,
workflowId: sourceWorkflowSelection.workflowId,
error: errorMessage,
});
}
}
if (!inheritedWorkflowSelection) {
try {
inheritedWorkflowSelection = await this.materializeDefaultWorkflowSteps();
} catch (err) {
storeLog.warn("Failed to apply default workflow during refinement creation; continuing without workflow selection", {
phase: "refineTask:default-workflow",
taskId: id,
error: err instanceof Error ? err.message : String(err),
});
}
}
return this.createTaskWithDistributedReservation({ description: feedback.trim() }, {
createTaskWithId: async (newId, reservationCommit) => {
const sourceGithubLinked = sourceTask.githubTracking?.enabled === true || Boolean(sourceTask.githubTracking?.issue);
@@ -5243,13 +5275,18 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
title: refinementTitle,
description: `${feedback.trim()}\n\nRefines: ${id}`,
priority: normalizeTaskPriority(sourceTask.priority),
column: "triage",
/*
FNXC:TaskRefinementWorkflow 2026-06-29-22:36:
Refinements must enter the inherited workflow's intake/default column, not hard-coded triage, because custom workflows can omit triage and would otherwise hide the new card from the workflow the operator returns to.
*/
column: inheritedWorkflowSelection?.entryColumnId ?? "triage",
dependencies: [id],
sourceType: "task_refine",
sourceParentTaskId: id,
githubTracking: refinementGithubTracking,
steps: [],
currentStep: 0,
enabledWorkflowSteps: inheritedWorkflowSelection?.stepIds,
log: [{ timestamp: now, action: `Created as refinement of ${id}` }],
columnMovedAt: now,
createdAt: now,
@@ -5261,7 +5298,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS}
this.assertTaskIdAvailable(newId);
const newDir = this.taskDir(newId);
await this.atomicCreateTaskJson(newDir, newTask, "refineTask", reservationCommit);
/*
FNXC:TaskRefinementWorkflow 2026-06-29-21:25:
Refinements keep the source task's explicit workflow selection, reseeded from the current workflow definition, so returning from a non-default workflow does not hide the new refinement on the default board. The task row and workflow-selection row are written in one SQLite transaction so creation cannot strand a refinement without its intended board lane.
*/
await this.atomicCreateTaskJson(newDir, newTask, "refineTask", reservationCommit, inheritedWorkflowSelection);
const prompt = `# ${newTask.title}\n\n${newTask.description}\n`;
const sanitizedPrompt = sanitizeFileScopeInPromptContent(prompt);
await mkdir(newDir, { recursive: true });
@@ -15938,7 +15979,7 @@ ${stepsSection}`;
/** Resolve the project-default workflow into the selection seed (workflow id +
* default-on optional-group node ids), or undefined when no default is set /
* it is missing / it is a fragment. */
private async materializeDefaultWorkflowSteps(): Promise<{ workflowId: string; stepIds: string[] } | undefined> {
private async materializeDefaultWorkflowSteps(): Promise<{ workflowId: string; stepIds: string[]; entryColumnId?: string } | undefined> {
const workflowId = await this.getDefaultWorkflowId();
if (!workflowId) return undefined;
const def = await this.getWorkflowDefinition(workflowId);
@@ -15952,7 +15993,7 @@ ${stepsSection}`;
// FNXC:WorkflowOptionalGroup 2026-06-21-14:20: seed `enabledWorkflowSteps`
// with the ids of `optional-group` nodes whose `defaultOn` is true. These group
// ids are the toggle keys the executor reads at the optional-group seam.
return { workflowId, stepIds: resolveDefaultOnOptionalGroupIds(def.ir) };
return { workflowId, stepIds: resolveDefaultOnOptionalGroupIds(def.ir), entryColumnId: resolveEntryColumnId(def.ir) };
}
/** Resolve an EXPLICITLY requested workflow id (U6/R3/KTD-4) into the selection
@@ -15962,14 +16003,14 @@ ${stepsSection}`;
* workflow. Validation happens up front so a non-compilable workflow aborts. */
private async materializeExplicitWorkflowSteps(
workflowId: string,
): Promise<{ workflowId: string; stepIds: string[] }> {
): Promise<{ workflowId: string; stepIds: string[]; entryColumnId?: string }> {
const def = await this.getWorkflowDefinition(workflowId);
if (!def) throw new Error(`Workflow '${workflowId}' not found`);
if (def.kind === "fragment") {
throw new Error(`Workflow '${workflowId}' is a fragment and cannot be selected for a task`);
}
this.validateWorkflowCompilable(workflowId, def);
return { workflowId, stepIds: resolveDefaultOnOptionalGroupIds(def.ir) };
return { workflowId, stepIds: resolveDefaultOnOptionalGroupIds(def.ir), entryColumnId: resolveEntryColumnId(def.ir) };
}
/**

View File

@@ -45,6 +45,8 @@ const TASKS = [
{ id: "FN-deleted", title: "Deleted workflow task" },
];
type HarnessTask = (typeof TASKS)[number];
function workflowPayload(overrides: Partial<BoardWorkflowsPayload> = {}): BoardWorkflowsPayload {
return {
flagEnabled: true,
@@ -58,10 +60,10 @@ function workflowPayload(overrides: Partial<BoardWorkflowsPayload> = {}): BoardW
};
}
function CrossSurfaceHarness({ projectId = "project-cross" }: { projectId?: string }) {
function CrossSurfaceHarness({ projectId = "project-cross", tasks = TASKS }: { projectId?: string; tasks?: HarnessTask[] }) {
const [graphSelection, setGraphSelection] = useState<GraphWorkflowSelection | null>(null);
const [headerSelection, setHeaderSelection] = useState<HeaderWorkflowSelection | null>(null);
const graphTasks = filterTasksByGraphWorkflowSelection(TASKS, projectId, graphSelection);
const graphTasks = filterTasksByGraphWorkflowSelection(tasks, projectId, graphSelection);
return (
<>
@@ -159,6 +161,41 @@ describe("workflow selection across dashboard surfaces", () => {
});
});
it("keeps non-default board/list workflow selection after refinement return refetch includes the new task", async () => {
const refinedTasks = [...TASKS, { id: "FN-refinement", title: "Refinement task" }];
render(<CrossSurfaceHarness tasks={refinedTasks} />);
const switchers = await screen.findAllByTestId("workflow-switcher");
await waitFor(() => {
expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id);
expect(screen.getByTestId("graph-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id);
});
fireEvent.click(switchers[1]);
fireEvent.click(screen.getByTestId(`workflow-switcher-option-${GRAPH_WORKFLOW.id}`));
await waitFor(() => expect(screen.getByTestId("graph-selection")).toHaveTextContent(GRAPH_WORKFLOW.id));
/*
FNXC:BoardWorkflowSelection 2026-06-29-22:05:
Refinement return refetches can add a freshly created child task to the board-workflows payload. The selected workflow is operator context, so the refetch must not repair a valid non-default workflow back to the project default `builtin:coding`.
*/
fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({
taskWorkflowIds: {
"FN-graph": GRAPH_WORKFLOW.id,
"FN-refinement": GRAPH_WORKFLOW.id,
},
}));
fireEvent.focus(window);
await waitFor(() => {
expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(5);
expect(screen.getByTestId("graph-selection")).toHaveTextContent(GRAPH_WORKFLOW.id);
expect(within(screen.getByTestId("graph-tasks")).getByTestId("graph-task-FN-refinement")).toBeInTheDocument();
});
expect(screen.getByTestId("graph-selection")).not.toHaveTextContent(DEFAULT_WORKFLOW.id);
expect(localStorage.getItem("kb:project-cross:kb-dashboard-board-workflow-selection")).toBe(GRAPH_WORKFLOW.id);
});
it("rehydrates selection per project instead of carrying it across projects", async () => {
const { rerender } = render(<CrossSurfaceHarness projectId="project-alpha" />);

View File

@@ -796,6 +796,11 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
if (isDoneTask) {
const newTask = await refineTask(task.id, text, projectId);
addToast(`Refinement task created: ${newTask.id}`, "success");
/*
FNXC:TaskDetailChat 2026-06-29-21:30:
Done-task refinement uses the source task's durable workflow inheritance on the backend, so the chat composer must not send board workflow filters or keep a submitted optimistic bubble that looks like steering on the completed task. Success clears only the draft and temporary bubble; failure keeps the draft and rolls back through the shared catch path.
*/
setOptimisticMessages((current) => current.filter((message) => message.id !== optimisticMessage.id));
} else {
const updatedTask = await addSteeringComment(task.id, text, projectId);
const persistedComment = updatedTask.steeringComments

View File

@@ -8,6 +8,7 @@ import { TaskChatTab } from "../TaskChatTab";
import { isCliSessionLive, type CliSessionSummaryRecord } from "../TaskDetailModal";
import { useAgentLogs } from "../../hooks/useAgentLogs";
import { addSteeringComment, refineTask } from "../../api";
import { readBoardWorkflowSelection, removeBoardWorkflowSelection, writeBoardWorkflowSelection } from "../../utils/boardWorkflowSelection";
vi.mock("../../hooks/useAgentLogs", () => ({
useAgentLogs: vi.fn(),
@@ -332,6 +333,7 @@ describe("TaskChatTab", () => {
});
afterEach(() => {
removeBoardWorkflowSelection("project-1");
vi.useRealTimers();
restoreMetricDescriptor("scrollTop", originalScrollTopDescriptor);
restoreMetricDescriptor("scrollHeight", originalScrollHeightDescriptor);
@@ -1471,14 +1473,35 @@ describe("TaskChatTab", () => {
expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", "Please add a follow-up report", "project-1");
});
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
expect(within(screen.getByTestId("task-chat-transcript")).getByText("You")).toBeVisible();
expect(within(screen.getByTestId("task-chat-transcript")).getByText("Please add a follow-up report")).toBeVisible();
expect(within(screen.getByTestId("task-chat-transcript")).queryByText("You")).not.toBeInTheDocument();
expect(within(screen.getByTestId("task-chat-transcript")).queryByText("Please add a follow-up report")).not.toBeInTheDocument();
expect(input).toHaveValue("");
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-222", "success");
expect(onTaskUpdated).not.toHaveBeenCalledWith(refinementTask);
expect(onTaskUpdated).not.toHaveBeenCalled();
});
it("preserves durable non-default workflow context after done-task refinement success", async () => {
const user = userEvent.setup();
const addToast = vi.fn();
writeBoardWorkflowSelection("project-1", "WF-custom");
mockedRefineTask.mockResolvedValue(makeTask({ id: "FN-225", column: "todo" }));
render(<TaskChatTab task={makeTask({ column: "done" })} projectId="project-1" active addToast={addToast} />);
const input = screen.getByLabelText("Message active agent session");
await user.type(input, "Create a focused follow-up");
await user.click(screen.getByRole("button", { name: "Send" }));
await waitFor(() => {
expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", "Create a focused follow-up", "project-1");
});
expect(input).toHaveValue("");
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-225", "success");
expect(readBoardWorkflowSelection("project-1")).toBe("WF-custom");
expect(readBoardWorkflowSelection("project-1")).not.toBe("builtin:coding");
});
it("sends an in-progress task steering message on plain Enter", async () => {
const onTaskUpdated = vi.fn();
const updatedTask = makeTask();

View File

@@ -15,6 +15,7 @@ import {
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
import { readBoardWorkflowSelection, removeBoardWorkflowSelection, writeBoardWorkflowSelection } from "../../utils/boardWorkflowSelection";
setupTaskDetailModalHooks();
@@ -1327,6 +1328,57 @@ describe("TaskDetailModal", () => {
});
});
it("preserves non-default workflow context when closing after refinement success", async () => {
const { fetchBoardWorkflows, refineTask } = await import("../../api");
vi.mocked(refineTask).mockResolvedValue({ id: "FN-003", column: "todo" } as Task);
vi.mocked(fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [
{ id: "builtin:coding", name: "Coding", columns: [] },
{ id: "WF-active", name: "Custom refinement lane", columns: [] },
],
taskWorkflowIds: { "FN-001": "WF-active" },
});
writeBoardWorkflowSelection("project-1", "WF-active");
const onClose = vi.fn();
const onTaskUpdated = vi.fn();
const addToast = vi.fn();
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done" })}
projectId="project-1"
initialTab="definition"
onClose={onClose}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={onTaskUpdated}
addToast={addToast}
/>,
);
await screen.findByTestId("task-detail-workflow-badge");
expect(screen.getByTestId("task-detail-workflow-badge")).toHaveTextContent("Custom refinement lane");
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
fireEvent.click(screen.getByRole("menuitem", { name: "Refine" }));
fireEvent.change(screen.getByPlaceholderText("Enter your feedback here..."), { target: { value: "Keep the same workflow lane" } });
fireEvent.click(screen.getByText("Create Refinement Task"));
await waitFor(() => {
expect(refineTask).toHaveBeenCalledWith("FN-001", "Keep the same workflow lane", "project-1");
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-003", "success");
expect(onClose).toHaveBeenCalled();
});
expect(onTaskUpdated).not.toHaveBeenCalled();
expect(readBoardWorkflowSelection("project-1")).toBe("WF-active");
expect(readBoardWorkflowSelection("project-1")).not.toBe("builtin:coding");
removeBoardWorkflowSelection("project-1");
});
it("shows error toast when refineTask fails", async () => {
const { refineTask } = await import("../../api");
vi.mocked(refineTask).mockRejectedValue(new Error("Task must be in 'done' or 'in-review' column"));