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(