From 2a5a108123100894fd45450b5464cbc948514a92 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 29 Jun 2026 22:54:50 -0700 Subject: [PATCH] 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) --- .changeset/fuzzy-refinements-dance.md | 7 + .changeset/preserve-refinement-workflow.md | 7 + docs/dashboard-guide.md | 3 +- .../workflow-selection-store.test.ts | 159 +++++++++++++++++- packages/core/src/store.ts | 55 +++++- .../workflow-selection-cross-surface.test.tsx | 41 ++++- .../dashboard/app/components/TaskChatTab.tsx | 5 + .../components/__tests__/TaskChatTab.test.tsx | 27 ++- ...askDetailModal.definition-actions.test.tsx | 52 ++++++ 9 files changed, 335 insertions(+), 21 deletions(-) create mode 100644 .changeset/fuzzy-refinements-dance.md create mode 100644 .changeset/preserve-refinement-workflow.md diff --git a/.changeset/fuzzy-refinements-dance.md b/.changeset/fuzzy-refinements-dance.md new file mode 100644 index 0000000000..18dea86d09 --- /dev/null +++ b/.changeset/fuzzy-refinements-dance.md @@ -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. diff --git a/.changeset/preserve-refinement-workflow.md b/.changeset/preserve-refinement-workflow.md new file mode 100644 index 0000000000..3bddbd1131 --- /dev/null +++ b/.changeset/preserve-refinement-workflow.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 08966d1051..4ee7b4b2ba 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -146,8 +146,9 @@ Features: + - 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. diff --git a/packages/core/src/__tests__/workflow-selection-store.test.ts b/packages/core/src/__tests__/workflow-selection-store.test.ts index e3b2124fc4..560364c771 100644 --- a/packages/core/src/__tests__/workflow-selection-store.test.ts +++ b/packages/core/src/__tests__/workflow-selection-store.test.ts @@ -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; + 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; @@ -334,6 +345,136 @@ describe("TaskStore workflow selection (U3)", () => { expect(await store.getDefaultWorkflowId()).toBeUndefined(); }); + async function moveToDone(taskId: string): Promise { + 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 () => { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index e8775e4e5e..a78bcc2a2f 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -3691,6 +3691,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} task: Task, operation: string, reservationCommit?: { reservationId: string; nodeId: string }, + workflowSelection?: { workflowId: string; stepIds: string[] }, ): Promise { 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 { @@ -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) }; } /** diff --git a/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx index 1b8c830ca6..95b4755faa 100644 --- a/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx +++ b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx @@ -45,6 +45,8 @@ const TASKS = [ { id: "FN-deleted", title: "Deleted workflow task" }, ]; +type HarnessTask = (typeof TASKS)[number]; + function workflowPayload(overrides: Partial = {}): BoardWorkflowsPayload { return { flagEnabled: true, @@ -58,10 +60,10 @@ function workflowPayload(overrides: Partial = {}): BoardW }; } -function CrossSurfaceHarness({ projectId = "project-cross" }: { projectId?: string }) { +function CrossSurfaceHarness({ projectId = "project-cross", tasks = TASKS }: { projectId?: string; tasks?: HarnessTask[] }) { const [graphSelection, setGraphSelection] = useState(null); const [headerSelection, setHeaderSelection] = useState(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(); + + 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(); diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index 54331211b7..d6f560c953 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -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 diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index e8e9033b2f..a2f84bab15 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -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(); + + 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(); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx index 530d788ff9..69c917df21 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx @@ -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( + , + ); + + 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"));