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:
7
.changeset/fn-7405-task-detail-model-project-scope.md
Normal file
7
.changeset/fn-7405-task-detail-model-project-scope.md
Normal 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.
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user