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:
@@ -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;
|
||||
|
||||
@@ -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" : ""}`}>
|
||||
|
||||
@@ -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>) => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user