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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 23:01:45 -07:00
parent 3167dbc839
commit eedf526e7f
5 changed files with 222 additions and 11 deletions

View File

@@ -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.

View File

@@ -21,6 +21,7 @@ interface ModelSelectorTabProps {
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
onTaskUpdated?: (task: Task) => void; onTaskUpdated?: (task: Task) => void;
settings?: Settings; settings?: Settings;
projectId?: string;
} }
interface ModelSelection { 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 { t } = useTranslation("app");
const { const {
availableModels, availableModels,
@@ -220,7 +221,11 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
planningModelId: (target === "planning" ? nextSelection : savedPlanning).modelId ?? null, 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) { if (activeTaskIdRef.current !== requestTaskId) {
return; 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( const handleExecutorChange = useCallback(
@@ -326,7 +331,7 @@ export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: Mo
try { try {
const updatedTask = await updateTask(requestTaskId, { const updatedTask = await updateTask(requestTaskId, {
thinkingLevel: nextValue, thinkingLevel: nextValue,
}); }, projectId);
if (activeTaskIdRef.current !== requestTaskId) { if (activeTaskIdRef.current !== requestTaskId) {
return; 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; const executorUsingDefault = !savedExecutor.provider && !savedExecutor.modelId;

View File

@@ -3620,7 +3620,13 @@ export function TaskDetailContent({
</div> </div>
) : activeTab === "model" ? ( ) : activeTab === "model" ? (
<div className="detail-section"> <div className="detail-section">
<ModelSelectorTab task={task} addToast={addToast} onTaskUpdated={onTaskUpdated} settings={settings} /> <ModelSelectorTab
task={task}
addToast={addToast}
onTaskUpdated={onTaskUpdated}
settings={settings}
projectId={projectId}
/>
</div> </div>
) : activeTab === "summary" && task.column === "done" ? ( ) : activeTab === "summary" && task.column === "done" ? (
<div className="detail-section detail-section--summary"> <div className="detail-section detail-section--summary">

View File

@@ -29,6 +29,12 @@ function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
} as unknown as TaskDetail; } as unknown as TaskDetail;
} }
async function selectDropdownOption(user: ReturnType<typeof userEvent.setup>, 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", () => { describe("ModelSelectorTab", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -76,7 +82,7 @@ describe("ModelSelectorTab", () => {
expect(mockUpdateTask).toHaveBeenCalledWith("FN-7398", { expect(mockUpdateTask).toHaveBeenCalledWith("FN-7398", {
modelProvider: "pi-claude-cli", modelProvider: "pi-claude-cli",
modelId: "claude-sonnet-5", modelId: "claude-sonnet-5",
}); }, undefined);
expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({ expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({
modelProvider: "pi-claude-cli", modelProvider: "pi-claude-cli",
modelId: "claude-sonnet-5", 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(
<ModelSelectorTab
task={task}
addToast={addToast}
onTaskUpdated={onTaskUpdated}
projectId="project-alpha"
/>,
);
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(
<ModelSelectorTab
task={task}
addToast={vi.fn()}
onTaskUpdated={vi.fn()}
projectId="project-alpha"
/>,
);
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 () => { it("updates from a cached empty catalog to populated Claude CLI rows without remounting", async () => {
localStorage.setItem( localStorage.setItem(
SWR_CACHE_KEYS.MODELS, SWR_CACHE_KEYS.MODELS,

View File

@@ -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. 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 { 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 { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import type { Task, TaskDetail } from "@fusion/core"; import type { Task, TaskDetail } from "@fusion/core";
@@ -1555,7 +1555,7 @@ describe("TaskDetailModal", () => {
}, { timeout: 3500 }); }, { 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 { fetchModels, updateTask } = await import("../../api");
const mockFetchModels = vi.mocked(fetchModels); const mockFetchModels = vi.mocked(fetchModels);
const mockUpdateTask = vi.mocked(updateTask); const mockUpdateTask = vi.mocked(updateTask);
@@ -1587,20 +1587,28 @@ describe("TaskDetailModal", () => {
.mockResolvedValueOnce(updatedAfterExecutor) .mockResolvedValueOnce(updatedAfterExecutor)
.mockResolvedValueOnce(updatedAfterValidator); .mockResolvedValueOnce(updatedAfterValidator);
const addToast = vi.fn();
const onTaskUpdated = vi.fn((updated: Task) => {
setStatefulTask((prev) => ({ ...prev, ...updated }));
});
let setStatefulTask: Dispatch<SetStateAction<TaskDetail>>;
function StatefulModal() { function StatefulModal() {
const [task, setTask] = useState<TaskDetail>(initialTask); const [task, setTask] = useState<TaskDetail>(initialTask);
setStatefulTask = setTask;
return ( return (
<TaskDetailModal <TaskDetailModal
initialTab="definition" initialTab="definition"
task={task} task={task}
projectId="project-alpha"
onClose={noop} onClose={noop}
onMoveTask={noopMove} onMoveTask={noopMove}
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail} onOpenDetail={noopOpenDetail}
onTaskUpdated={(updated) => setTask((prev) => ({ ...prev, ...updated }))} onTaskUpdated={onTaskUpdated}
addToast={noop} addToast={addToast}
/> />
); );
} }
@@ -1623,6 +1631,7 @@ describe("TaskDetailModal", () => {
modelProvider: "anthropic", modelProvider: "anthropic",
modelId: "claude-sonnet-4-5", modelId: "claude-sonnet-4-5",
}), }),
"project-alpha",
); );
}); });
@@ -1637,7 +1646,13 @@ describe("TaskDetailModal", () => {
validatorModelProvider: "openai", validatorModelProvider: "openai",
validatorModelId: "gpt-4o", 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")!); 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(
<TaskDetailModal
initialTab="model"
task={makeTask({
id: "FN-001",
column: "triage",
title: "Scoped reviewer failure",
validatorModelProvider: "anthropic",
validatorModelId: "claude-haiku-5",
})}
projectId="project-alpha"
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={vi.fn()}
addToast={addToast}
/>,
);
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", () => { it("renders Save and Cancel in the modal footer, not inside the edit form body", () => {
const { container } = render( const { container } = render(
<TaskDetailModal <TaskDetailModal