fix(FN-1097): sync model tab saves with modal task state

- Pass TaskDetailModal's onTaskUpdated callback into ModelSelectorTab
- Propagate server-returned task updates after model and thinking-level saves so edit mode uses fresh values
- Add regression tests for ModelSelectorTab update callbacks and TaskDetailModal model-sync flow
- Add a patch changeset for @gsxdsm/fusion covering the model edit sync fix
This commit is contained in:
gsxdsm
2026-04-07 19:13:51 -07:00
parent 71fdffb37b
commit e952dd5760
5 changed files with 146 additions and 4 deletions

View File

@@ -9,6 +9,7 @@ import { ProviderIcon } from "./ProviderIcon";
interface ModelSelectorTabProps {
task: Task | TaskDetail;
addToast: (message: string, type?: ToastType) => void;
onTaskUpdated?: (task: Task) => void;
}
interface ModelSelection {
@@ -78,7 +79,7 @@ function getSuccessToastMessage(target: "executor" | "validator" | "planning", s
return `${label} model set to ${selection.provider}/${selection.modelId}`;
}
export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
export function ModelSelectorTab({ task, addToast, onTaskUpdated }: ModelSelectorTabProps) {
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
@@ -222,6 +223,7 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
setSelectedValidator(nextSavedValidator);
setSavedPlanning(nextSavedPlanning);
setSelectedPlanning(nextSavedPlanning);
onTaskUpdated?.(updatedTask);
const targetSelections: Record<string, ModelSelection> = {
executor: nextSavedExecutor,
@@ -253,7 +255,7 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
}
}
},
[task.id, savedExecutor, savedValidator, savedPlanning, addToast],
[task.id, savedExecutor, savedValidator, savedPlanning, addToast, onTaskUpdated],
);
const handleExecutorChange = useCallback(
@@ -318,6 +320,7 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
const nextThinking = updatedTask.thinkingLevel ?? "off";
setSavedThinking(nextThinking);
setSelectedThinking(nextThinking);
onTaskUpdated?.(updatedTask);
addToast(
nextThinking === "off"
@@ -338,7 +341,7 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
}
}
},
[task.id, savedThinking, addToast],
[task.id, savedThinking, addToast, onTaskUpdated],
);
const executorUsingDefault = !savedExecutor.provider && !savedExecutor.modelId;

View File

@@ -907,7 +907,7 @@ export function TaskDetailModal({
</div>
) : activeTab === "model" ? (
<div className="detail-section">
<ModelSelectorTab task={task} addToast={addToast} />
<ModelSelectorTab task={task} addToast={addToast} onTaskUpdated={onTaskUpdated} />
</div>
) : activeTab === "logs" ? (
<div className={`detail-section${logSubview === "agent-log" ? " detail-section--agent-log" : ""}`}>

View File

@@ -474,6 +474,25 @@ describe("ModelSelectorTab", () => {
});
});
it("calls onTaskUpdated with server task after saving executor model", async () => {
const onTaskUpdated = vi.fn();
const updatedTask = {
...FAKE_TASK,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
};
mockUpdateTask.mockResolvedValueOnce(updatedTask);
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} onTaskUpdated={onTaskUpdated} />);
await waitForSelectors();
await selectOption("Executor Model", "Claude Sonnet 4.5");
await waitFor(() => {
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
});
});
it("shows a specific validator success toast with the saved model name", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
@@ -916,6 +935,25 @@ describe("ModelSelectorTab", () => {
});
});
it("calls onTaskUpdated with server task after saving thinking level", async () => {
const onTaskUpdated = vi.fn();
const updatedTask = {
...FAKE_TASK,
thinkingLevel: "high" as const,
};
mockUpdateTask.mockResolvedValueOnce(updatedTask);
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} onTaskUpdated={onTaskUpdated} />);
await waitForSelectors();
await user.selectOptions(screen.getByLabelText("Thinking Level"), "high");
await waitFor(() => {
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
});
});
it("shows 'set to default' toast when clearing thinking level", async () => {
const taskWithThinking = { ...FAKE_TASK, thinkingLevel: "high" as const };
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({

View File

@@ -1,5 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { useState } from "react";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TaskDetailModal } from "../TaskDetailModal";
import type { TaskDetail, Column, MergeResult, Task } from "@fusion/core";
@@ -3707,6 +3709,100 @@ describe("TaskDetailModal", () => {
expect(descTextarea.value).toBe("My Description");
});
it("uses updated model values in edit mode after saving from the Model tab", async () => {
const { fetchModels, updateTask } = await import("../../api");
const mockFetchModels = vi.mocked(fetchModels);
const mockUpdateTask = vi.mocked(updateTask);
const user = userEvent.setup();
const availableModels = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
];
mockFetchModels.mockResolvedValue({
models: availableModels,
favoriteProviders: [],
favoriteModels: [],
});
const initialTask = makeTask({ id: "FN-001", column: "triage", title: "Model sync test" });
const updatedAfterExecutor: Task = {
...initialTask,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
};
const updatedAfterValidator: Task = {
...updatedAfterExecutor,
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
};
mockUpdateTask
.mockResolvedValueOnce(updatedAfterExecutor)
.mockResolvedValueOnce(updatedAfterValidator);
function StatefulModal() {
const [task, setTask] = useState<TaskDetail>(initialTask);
return (
<TaskDetailModal
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={(updated) => setTask((prev) => ({ ...prev, ...updated }))}
addToast={noop}
/>
);
}
const { container } = render(<StatefulModal />);
await user.click(screen.getByText("Model"));
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
await user.click(screen.getByLabelText("Executor Model"));
await user.click(screen.getByText("Claude Sonnet 4.5"));
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenNthCalledWith(
1,
"FN-001",
expect.objectContaining({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
}),
);
});
await user.click(screen.getByLabelText("Validator Model"));
await user.click(screen.getByText("GPT-4o"));
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenNthCalledWith(
2,
"FN-001",
expect.objectContaining({
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
}),
);
});
fireEvent.click(container.querySelector(".modal-edit-btn")!);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toHaveTextContent("Claude Sonnet 4.5");
expect(screen.getByLabelText("Validator Model")).toHaveTextContent("GPT-4o");
});
});
it("renders Save and Cancel in the modal footer, not inside the edit form body", () => {
const { container } = render(
<TaskDetailModal