From eedf526e7f8a42558fe3bb43aaac7a0e6cdcb6fa Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Jul 2026 23:01:45 -0700 Subject: [PATCH] FN-7405: preserve scoped task detail model saves Preserve scoped task-detail model saves so project tasks update without false not-found errors. - Thread the active project id from TaskDetailModal into ModelSelectorTab updates. - Pass project scope for executor, reviewer, planning, and thinking model saves and clears. - Cover scoped success paths and real reviewer-update failures with dashboard tests. - Add a patch changeset for the published Fusion package. Files changed: .../fn-7405-task-detail-model-project-scope.md | 7 ++ .../dashboard/app/components/ModelSelectorTab.tsx | 15 ++- .../dashboard/app/components/TaskDetailModal.tsx | 8 +- .../components/__tests__/ModelSelectorTab.test.tsx | 127 ++++++++++++++++++++- ...lModal.inline-editing-and-integrations.test.tsx | 76 +++++++++++- 5 files changed, 222 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-7405 Fusion-Task-Lineage: 59a18549-40fb-436d-a6f9-ec9f49f4a82f Co-authored-by: Fusion (runfusion.ai) --- ...fn-7405-task-detail-model-project-scope.md | 7 + .../app/components/ModelSelectorTab.tsx | 15 ++- .../app/components/TaskDetailModal.tsx | 8 +- .../__tests__/ModelSelectorTab.test.tsx | 127 +++++++++++++++++- ...l.inline-editing-and-integrations.test.tsx | 76 ++++++++++- 5 files changed, 222 insertions(+), 11 deletions(-) create mode 100644 .changeset/fn-7405-task-detail-model-project-scope.md diff --git a/.changeset/fn-7405-task-detail-model-project-scope.md b/.changeset/fn-7405-task-detail-model-project-scope.md new file mode 100644 index 0000000000..f989ce09e3 --- /dev/null +++ b/.changeset/fn-7405-task-detail-model-project-scope.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve project scope when saving task-detail model overrides. +category: fix +dev: Threads task-detail Model tab updates through projectId for executor, reviewer, planning, and thinking lanes. diff --git a/packages/dashboard/app/components/ModelSelectorTab.tsx b/packages/dashboard/app/components/ModelSelectorTab.tsx index 23ec9e6bc9..2d28113c1d 100644 --- a/packages/dashboard/app/components/ModelSelectorTab.tsx +++ b/packages/dashboard/app/components/ModelSelectorTab.tsx @@ -21,6 +21,7 @@ interface ModelSelectorTabProps { addToast: (message: string, type?: ToastType) => void; onTaskUpdated?: (task: Task) => void; settings?: Settings; + projectId?: string; } interface ModelSelection { @@ -120,7 +121,7 @@ function getSuccessToastMessage( }); } -export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: ModelSelectorTabProps) { +export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings, projectId }: ModelSelectorTabProps) { const { t } = useTranslation("app"); const { availableModels, @@ -220,7 +221,11 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo planningModelId: (target === "planning" ? nextSelection : savedPlanning).modelId ?? null, }; - const updatedTask = await updateTask(requestTaskId, updates); + /* + FNXC:TaskDetailModels 2026-07-01-00:00: + Task-detail model saves must carry the active project id through the shared update API. Multi-project task detail views can otherwise patch the default project route and surface a false "Task not found" toast for existing scoped tasks. + */ + const updatedTask = await updateTask(requestTaskId, updates, projectId); if (activeTaskIdRef.current !== requestTaskId) { return; @@ -268,7 +273,7 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo } } }, - [task.id, savedExecutor, savedValidator, savedPlanning, addToast, onTaskUpdated, t], + [task.id, savedExecutor, savedValidator, savedPlanning, addToast, onTaskUpdated, projectId, t], ); const handleExecutorChange = useCallback( @@ -326,7 +331,7 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo try { const updatedTask = await updateTask(requestTaskId, { thinkingLevel: nextValue, - }); + }, projectId); if (activeTaskIdRef.current !== requestTaskId) { return; @@ -362,7 +367,7 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo } } }, - [task.id, savedThinking, settings, addToast, onTaskUpdated, t], + [task.id, savedThinking, settings, addToast, onTaskUpdated, projectId, t], ); const executorUsingDefault = !savedExecutor.provider && !savedExecutor.modelId; diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 5f8be7cfd7..09953efd4f 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -3620,7 +3620,13 @@ export function TaskDetailContent({ ) : activeTab === "model" ? (
- +
) : activeTab === "summary" && task.column === "done" ? (
diff --git a/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx b/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx index eb34d0c1b5..e61d855fe7 100644 --- a/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/ModelSelectorTab.test.tsx @@ -29,6 +29,12 @@ function makeTask(overrides: Partial = {}): TaskDetail { } as unknown as TaskDetail; } +async function selectDropdownOption(user: ReturnType, label: string, option: string) { + await user.click(screen.getByLabelText(label)); + const listbox = await screen.findByRole("listbox"); + await user.click(within(listbox).getByText(option)); +} + describe("ModelSelectorTab", () => { beforeEach(() => { vi.clearAllMocks(); @@ -76,7 +82,7 @@ describe("ModelSelectorTab", () => { expect(mockUpdateTask).toHaveBeenCalledWith("FN-7398", { modelProvider: "pi-claude-cli", modelId: "claude-sonnet-5", - }); + }, undefined); expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({ modelProvider: "pi-claude-cli", modelId: "claude-sonnet-5", @@ -84,6 +90,125 @@ describe("ModelSelectorTab", () => { }); }); + it("passes the scoped project id for executor, reviewer, planning, and thinking saves", async () => { + const user = userEvent.setup(); + const addToast = vi.fn(); + const onTaskUpdated = vi.fn(); + const task = makeTask({ + modelProvider: "pi-claude-cli", + modelId: "claude-haiku-5", + validatorModelProvider: "pi-claude-cli", + validatorModelId: "claude-haiku-5", + planningModelProvider: "pi-claude-cli", + planningModelId: "claude-haiku-5", + thinkingLevel: "minimal", + }); + + mockFetchModels.mockResolvedValue({ + models: [ + { provider: "pi-claude-cli", id: "claude-haiku-5", name: "Claude Haiku 5 (CLI)", reasoning: true, contextWindow: 200_000 }, + { provider: "pi-claude-cli", id: "claude-sonnet-5", name: "Claude Sonnet 5 (CLI)", reasoning: true, contextWindow: 1_000_000 }, + ], + favoriteProviders: [], + favoriteModels: [], + }); + mockUpdateTask + .mockResolvedValueOnce({ + ...task, + modelProvider: "pi-claude-cli", + modelId: "claude-sonnet-5", + }) + .mockResolvedValueOnce({ + ...task, + validatorModelProvider: "pi-claude-cli", + validatorModelId: "claude-sonnet-5", + }) + .mockResolvedValueOnce({ + ...task, + planningModelProvider: "pi-claude-cli", + planningModelId: "claude-sonnet-5", + }) + .mockResolvedValueOnce({ + ...task, + thinkingLevel: "high", + }); + + render( + , + ); + + await waitFor(() => expect(screen.getByLabelText("Executor Model")).toBeInTheDocument()); + + await selectDropdownOption(user, "Executor Model", "Claude Sonnet 5 (CLI)"); + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenNthCalledWith(1, "FN-7398", { + modelProvider: "pi-claude-cli", + modelId: "claude-sonnet-5", + }, "project-alpha"); + }); + + await selectDropdownOption(user, "Reviewer Model", "Claude Sonnet 5 (CLI)"); + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenNthCalledWith(2, "FN-7398", { + validatorModelProvider: "pi-claude-cli", + validatorModelId: "claude-sonnet-5", + }, "project-alpha"); + }); + + await selectDropdownOption(user, "Planning Model", "Claude Sonnet 5 (CLI)"); + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenNthCalledWith(3, "FN-7398", { + planningModelProvider: "pi-claude-cli", + planningModelId: "claude-sonnet-5", + }, "project-alpha"); + }); + + await user.selectOptions(screen.getByLabelText("Thinking Level"), "high"); + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenNthCalledWith(4, "FN-7398", { + thinkingLevel: "high", + }, "project-alpha"); + }); + expect(addToast).toHaveBeenCalledWith(expect.stringContaining("set to"), "success"); + }); + + it("clears model overrides with the scoped project id", async () => { + const user = userEvent.setup(); + const task = makeTask({ + modelProvider: "pi-claude-cli", + modelId: "claude-sonnet-5", + }); + mockUpdateTask.mockResolvedValueOnce({ + ...task, + modelProvider: null, + modelId: null, + }); + + render( + , + ); + + await waitFor(() => expect(screen.getByLabelText("Executor Model")).toBeInTheDocument()); + await selectDropdownOption(user, "Executor Model", "Use default"); + + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenCalledWith("FN-7398", { + modelProvider: null, + modelId: null, + }, "project-alpha"); + }); + }); + it("updates from a cached empty catalog to populated Claude CLI rows without remounting", async () => { localStorage.setItem( SWR_CACHE_KEYS.MODELS, diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx index ad04d416df..aec68bf6fc 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.inline-editing-and-integrations.test.tsx @@ -3,7 +3,7 @@ FNXC:TaskDetailTabs 2026-06-17-08:20: FN-7306 labels the stable internal `chat` tab as Activity and keeps it as the default TaskDetailModal tab. Tests that assert Definition-only sections must opt into `initialTab="definition"` so they verify the intended surface instead of the Activity landing state. */ import { describe, it, expect, vi } from "vitest"; -import { useState } from "react"; +import { useState, type Dispatch, type SetStateAction } from "react"; import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { Task, TaskDetail } from "@fusion/core"; @@ -1555,7 +1555,7 @@ describe("TaskDetailModal", () => { }, { timeout: 3500 }); }); - it("uses updated model values in edit mode after saving from the Model tab", async () => { + it("saves task-detail model changes with the active project id", async () => { const { fetchModels, updateTask } = await import("../../api"); const mockFetchModels = vi.mocked(fetchModels); const mockUpdateTask = vi.mocked(updateTask); @@ -1587,20 +1587,28 @@ describe("TaskDetailModal", () => { .mockResolvedValueOnce(updatedAfterExecutor) .mockResolvedValueOnce(updatedAfterValidator); + const addToast = vi.fn(); + const onTaskUpdated = vi.fn((updated: Task) => { + setStatefulTask((prev) => ({ ...prev, ...updated })); + }); + let setStatefulTask: Dispatch>; + function StatefulModal() { const [task, setTask] = useState(initialTask); + setStatefulTask = setTask; return ( setTask((prev) => ({ ...prev, ...updated }))} - addToast={noop} + onTaskUpdated={onTaskUpdated} + addToast={addToast} /> ); } @@ -1623,6 +1631,7 @@ describe("TaskDetailModal", () => { modelProvider: "anthropic", modelId: "claude-sonnet-4-5", }), + "project-alpha", ); }); @@ -1637,7 +1646,13 @@ describe("TaskDetailModal", () => { validatorModelProvider: "openai", validatorModelId: "gpt-4o", }, + "project-alpha", ); + expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({ + validatorModelProvider: "openai", + validatorModelId: "gpt-4o", + })); + expect(addToast).not.toHaveBeenCalledWith(expect.any(String), "error"); }); fireEvent.click(container.querySelector(".modal-edit-btn")!); @@ -1648,6 +1663,59 @@ describe("TaskDetailModal", () => { }); }); + it("rolls back the scoped reviewer model change and shows one error toast on real failure", async () => { + const { fetchModels, updateTask } = await import("../../api"); + const mockFetchModels = vi.mocked(fetchModels); + const mockUpdateTask = vi.mocked(updateTask); + const user = userEvent.setup(); + const addToast = vi.fn(); + + mockFetchModels.mockResolvedValue({ + models: [ + { provider: "anthropic", id: "claude-haiku-5", name: "Claude Haiku 5", reasoning: true, contextWindow: 200000 }, + { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, + ], + favoriteProviders: [], + favoriteModels: [], + }); + mockUpdateTask.mockRejectedValueOnce(new Error("Task not found")); + + render( + , + ); + + await waitFor(() => expect(screen.getByLabelText("Reviewer Model")).toBeInTheDocument()); + await user.click(screen.getByLabelText("Reviewer Model")); + await user.click(await screen.findByText("GPT-4o")); + + await waitFor(() => { + expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", { + validatorModelProvider: "openai", + validatorModelId: "gpt-4o", + }, "project-alpha"); + expect(addToast).toHaveBeenCalledTimes(1); + expect(addToast).toHaveBeenCalledWith("Task not found", "error"); + expect(screen.getByLabelText("Reviewer Model")).toHaveTextContent("Claude Haiku 5"); + }); + }); + it("renders Save and Cancel in the modal footer, not inside the edit form body", () => { const { container } = render(