FN-6101: pass planning model overrides to quick task workflows
Ensure quick task model overrides flow through planning and submission. - add planning model selection state to inline task creation and include it in submit payloads only when explicitly overridden - extend the model selection modal with an optional planning-model picker that clears preset mode consistently with other manual overrides - cover planning, combined overrides, and default omission behavior in inline create card and quick entry tests Files changed: packages/dashboard/app/components/InlineCreateCard.tsx | 26 ++++++++- packages/dashboard/app/components/ModelSelectionModal.tsx | 45 +++++++++++++++ packages/dashboard/app/components/__tests__/InlineCreateCard.test.tsx | 67 +++++++++++++++++++++- packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx | 49 ++++++++++++---- 4 files changed, 172 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-6101 Fusion-Task-Lineage: 7c1553d2-3edb-4b23-ad36-77756bbfe1dc
This commit is contained in:
@@ -108,6 +108,8 @@ export function InlineCreateCard({
|
||||
const [executorModelId, setExecutorModelId] = useState<string | undefined>(undefined);
|
||||
const [validatorProvider, setValidatorProvider] = useState<string | undefined>(undefined);
|
||||
const [validatorModelId, setValidatorModelId] = useState<string | undefined>(undefined);
|
||||
const [planningProvider, setPlanningProvider] = useState<string | undefined>(undefined);
|
||||
const [planningModelId, setPlanningModelId] = useState<string | undefined>(undefined);
|
||||
const [browserVerification, setBrowserVerification] = useState(false);
|
||||
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
@@ -254,12 +256,14 @@ export function InlineCreateCard({
|
||||
|
||||
const executorSelectionValue = getModelSelectionValue(executorProvider, executorModelId);
|
||||
const validatorSelectionValue = getModelSelectionValue(validatorProvider, validatorModelId);
|
||||
const planningSelectionValue = getModelSelectionValue(planningProvider, planningModelId);
|
||||
const availablePresets = settings?.modelPresets || [];
|
||||
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
|
||||
|
||||
const hasExecutorOverride = Boolean(executorProvider && executorModelId);
|
||||
const hasValidatorOverride = Boolean(validatorProvider && validatorModelId);
|
||||
const selectedModelCount = Number(hasExecutorOverride) + Number(hasValidatorOverride);
|
||||
const hasPlanningOverride = Boolean(planningProvider && planningModelId);
|
||||
const selectedModelCount = Number(hasExecutorOverride) + Number(hasValidatorOverride) + Number(hasPlanningOverride);
|
||||
|
||||
// Track focus-out for conditional cancel behavior and justResetRef cleanup.
|
||||
useEffect(() => {
|
||||
@@ -383,6 +387,8 @@ export function InlineCreateCard({
|
||||
setExecutorModelId(undefined);
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
setPlanningProvider(undefined);
|
||||
setPlanningModelId(undefined);
|
||||
setBrowserVerification(false);
|
||||
setPriority(DEFAULT_TASK_PRIORITY);
|
||||
setDependencies([]);
|
||||
@@ -434,6 +440,8 @@ export function InlineCreateCard({
|
||||
modelId: hasExecutorOverride ? executorModelId : undefined,
|
||||
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
|
||||
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
|
||||
planningModelProvider: hasPlanningOverride ? planningProvider : undefined,
|
||||
planningModelId: hasPlanningOverride ? planningModelId : undefined,
|
||||
enabledWorkflowSteps: browserVerification ? ["browser-verification"] : undefined,
|
||||
priority,
|
||||
nodeId,
|
||||
@@ -451,7 +459,7 @@ export function InlineCreateCard({
|
||||
}
|
||||
|
||||
await submitTask(input);
|
||||
}, [description, submitting, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, browserVerification, priority, nodeId, projectId, addToast, submitTask]);
|
||||
}, [description, submitting, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, hasPlanningOverride, planningProvider, planningModelId, browserVerification, priority, nodeId, projectId, addToast, submitTask]);
|
||||
|
||||
const handleDuplicateProceed = useCallback(async () => {
|
||||
const matches = duplicateMatches;
|
||||
@@ -595,6 +603,12 @@ export function InlineCreateCard({
|
||||
setValidatorModelId(next.modelId);
|
||||
}, []);
|
||||
|
||||
const handlePlanningModelChange = useCallback((value: string) => {
|
||||
const next = parseModelSelection(value);
|
||||
setPlanningProvider(next.provider);
|
||||
setPlanningModelId(next.modelId);
|
||||
}, []);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
@@ -655,6 +669,8 @@ export function InlineCreateCard({
|
||||
setExecutorModelId(undefined);
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
setPlanningProvider(undefined);
|
||||
setPlanningModelId(undefined);
|
||||
setBrowserVerification(false);
|
||||
setSelectedPresetId(undefined);
|
||||
setSelectedAgentId(null);
|
||||
@@ -681,6 +697,8 @@ export function InlineCreateCard({
|
||||
setExecutorModelId(undefined);
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
setPlanningProvider(undefined);
|
||||
setPlanningModelId(undefined);
|
||||
setBrowserVerification(false);
|
||||
setSelectedPresetId(undefined);
|
||||
setSelectedAgentId(null);
|
||||
@@ -1088,6 +1106,8 @@ export function InlineCreateCard({
|
||||
setExecutorModelId(undefined);
|
||||
setValidatorProvider(undefined);
|
||||
setValidatorModelId(undefined);
|
||||
setPlanningProvider(undefined);
|
||||
setPlanningModelId(undefined);
|
||||
setShowPresets(false);
|
||||
}}
|
||||
>
|
||||
@@ -1163,8 +1183,10 @@ export function InlineCreateCard({
|
||||
models={loadedModels}
|
||||
executorValue={executorSelectionValue}
|
||||
validatorValue={validatorSelectionValue}
|
||||
planningValue={planningSelectionValue}
|
||||
onExecutorChange={handleExecutorChange}
|
||||
onValidatorChange={handleValidatorChange}
|
||||
onPlanningChange={handlePlanningModelChange}
|
||||
modelsLoading={modelsLoading}
|
||||
modelsError={modelsError}
|
||||
onRetry={loadModels}
|
||||
|
||||
@@ -13,8 +13,10 @@ interface ModelSelectionModalProps {
|
||||
models: ModelInfo[];
|
||||
executorValue: string;
|
||||
validatorValue: string;
|
||||
planningValue?: string;
|
||||
onExecutorChange: (value: string) => void;
|
||||
onValidatorChange: (value: string) => void;
|
||||
onPlanningChange?: (value: string) => void;
|
||||
modelsLoading: boolean;
|
||||
modelsError: string | null;
|
||||
onRetry: () => void;
|
||||
@@ -46,8 +48,10 @@ export function ModelSelectionModal({
|
||||
models,
|
||||
executorValue,
|
||||
validatorValue,
|
||||
planningValue = "",
|
||||
onExecutorChange,
|
||||
onValidatorChange,
|
||||
onPlanningChange,
|
||||
modelsLoading,
|
||||
modelsError,
|
||||
onRetry,
|
||||
@@ -95,6 +99,7 @@ export function ModelSelectionModal({
|
||||
onPresetChange(undefined);
|
||||
onExecutorChange("");
|
||||
onValidatorChange("");
|
||||
onPlanningChange?.("");
|
||||
return;
|
||||
}
|
||||
if (value === "custom") {
|
||||
@@ -134,10 +139,22 @@ export function ModelSelectionModal({
|
||||
[onPresetChange, selectedPresetId, onValidatorChange],
|
||||
);
|
||||
|
||||
const handlePlanningChange = useCallback(
|
||||
(value: string) => {
|
||||
// Manual model selection clears preset mode
|
||||
if (onPresetChange && selectedPresetId) {
|
||||
onPresetChange(undefined);
|
||||
}
|
||||
onPlanningChange?.(value);
|
||||
},
|
||||
[onPresetChange, selectedPresetId, onPlanningChange],
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hasExecutorOverride = Boolean(executorValue);
|
||||
const hasValidatorOverride = Boolean(validatorValue);
|
||||
const hasPlanningOverride = Boolean(planningValue);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={handleOverlayClick} role="dialog" aria-modal="true" data-testid="model-selection-modal">
|
||||
@@ -211,6 +228,34 @@ export function ModelSelectionModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onPlanningChange ? (
|
||||
<div className="task-detail-section">
|
||||
<div className="inline-create-model-row">
|
||||
<label htmlFor="model-selection-planning" className="inline-create-model-label">
|
||||
{t("modelSelection.planningModel", "Planning Model")}
|
||||
</label>
|
||||
<span
|
||||
className={`model-badge ${hasPlanningOverride ? "model-badge-custom" : "model-badge-default"}`}
|
||||
data-testid="planning-badge"
|
||||
>
|
||||
{getModelBadgeLabel(models, planningValue, t)}
|
||||
</span>
|
||||
<CustomModelDropdown
|
||||
id="model-selection-planning"
|
||||
label={t("modelSelection.planningModel", "Planning Model")}
|
||||
value={planningValue}
|
||||
onChange={handlePlanningChange}
|
||||
models={models}
|
||||
placeholder={t("modelSelection.planningPlaceholder", "Select planning model…")}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="task-detail-section">
|
||||
<div className="inline-create-model-row">
|
||||
<label htmlFor="model-selection-executor" className="inline-create-model-label">
|
||||
|
||||
@@ -37,8 +37,10 @@ vi.mock("../ModelSelectionModal", () => ({
|
||||
models,
|
||||
executorValue,
|
||||
validatorValue,
|
||||
planningValue,
|
||||
onExecutorChange,
|
||||
onValidatorChange,
|
||||
onPlanningChange,
|
||||
modelsLoading,
|
||||
modelsError,
|
||||
onRetry,
|
||||
@@ -53,8 +55,10 @@ vi.mock("../ModelSelectionModal", () => ({
|
||||
models: typeof MOCK_MODELS;
|
||||
executorValue: string;
|
||||
validatorValue: string;
|
||||
planningValue: string;
|
||||
onExecutorChange: (value: string) => void;
|
||||
onValidatorChange: (value: string) => void;
|
||||
onPlanningChange: (value: string) => void;
|
||||
modelsLoading: boolean;
|
||||
modelsError: string | null;
|
||||
onRetry: () => void;
|
||||
@@ -69,6 +73,7 @@ vi.mock("../ModelSelectionModal", () => ({
|
||||
<div data-testid="model-selection-modal">
|
||||
<div data-testid="modal-props-executor-value">{executorValue}</div>
|
||||
<div data-testid="modal-props-validator-value">{validatorValue}</div>
|
||||
<div data-testid="modal-props-planning-value">{planningValue}</div>
|
||||
<div data-testid="modal-props-loading">{modelsLoading ? "loading" : "not-loading"}</div>
|
||||
<div data-testid="modal-props-error">{modelsError || "no-error"}</div>
|
||||
<div data-testid="modal-props-favorite-models">{JSON.stringify(favoriteModels ?? [])}</div>
|
||||
@@ -79,8 +84,10 @@ vi.mock("../ModelSelectionModal", () => ({
|
||||
<button data-testid="modal-close" onClick={onClose}>Close</button>
|
||||
<button data-testid="modal-select-executor" onClick={() => onExecutorChange("anthropic/claude-sonnet-4-5")}>Select Executor</button>
|
||||
<button data-testid="modal-select-validator" onClick={() => onValidatorChange("openai/gpt-4o")}>Select Reviewer</button>
|
||||
<button data-testid="modal-select-planning" onClick={() => onPlanningChange("anthropic/claude-sonnet-4-5")}>Select Planning</button>
|
||||
<button data-testid="modal-clear-executor" onClick={() => onExecutorChange("")}>Clear Executor</button>
|
||||
<button data-testid="modal-clear-validator" onClick={() => onValidatorChange("")}>Clear Reviewer</button>
|
||||
<button data-testid="modal-clear-planning" onClick={() => onPlanningChange("")}>Clear Planning</button>
|
||||
<button data-testid="modal-retry" onClick={onRetry}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
@@ -456,6 +463,29 @@ describe("InlineCreateCard model selector", () => {
|
||||
expect(screen.getByTestId("modal-props-executor-value").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("omits model fields from the submit payload when no overrides are selected", async () => {
|
||||
const { props } = renderCard();
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task using model defaults" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Task using model defaults",
|
||||
modelProvider: undefined,
|
||||
modelId: undefined,
|
||||
validatorModelProvider: undefined,
|
||||
validatorModelId: undefined,
|
||||
planningModelProvider: undefined,
|
||||
planningModelId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("omits model fields from the submit payload after clearing back to default", async () => {
|
||||
const { props } = renderCard();
|
||||
expandCard();
|
||||
@@ -477,6 +507,8 @@ describe("InlineCreateCard model selector", () => {
|
||||
modelId: undefined,
|
||||
validatorModelProvider: undefined,
|
||||
validatorModelId: undefined,
|
||||
planningModelProvider: undefined,
|
||||
planningModelId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -493,26 +525,55 @@ describe("InlineCreateCard model selector", () => {
|
||||
expect(saveButton.className).toContain("btn-task-create");
|
||||
});
|
||||
|
||||
it("includes selected models in the submit payload", async () => {
|
||||
it("includes the selected planning model in the submit payload", async () => {
|
||||
const { props } = renderCard();
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with model overrides" } });
|
||||
fireEvent.change(textarea, { target: { value: "Task with planning model override" } });
|
||||
openModelModal();
|
||||
fireEvent.click(screen.getByTestId("modal-select-executor"));
|
||||
fireEvent.click(screen.getByTestId("modal-select-validator"));
|
||||
fireEvent.click(screen.getByTestId("modal-select-planning"));
|
||||
fireEvent.click(screen.getByTestId("modal-close"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Task with model overrides",
|
||||
description: "Task with planning model override",
|
||||
modelProvider: undefined,
|
||||
modelId: undefined,
|
||||
validatorModelProvider: undefined,
|
||||
validatorModelId: undefined,
|
||||
planningModelProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("includes all selected model pairs in the submit payload", async () => {
|
||||
const { props } = renderCard();
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with all model overrides" } });
|
||||
openModelModal();
|
||||
fireEvent.click(screen.getByTestId("modal-select-executor"));
|
||||
fireEvent.click(screen.getByTestId("modal-select-validator"));
|
||||
fireEvent.click(screen.getByTestId("modal-select-planning"));
|
||||
fireEvent.click(screen.getByTestId("modal-close"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Task with all model overrides",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
planningModelProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1575,35 +1575,64 @@ describe("QuickEntryBox", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("includes selected models in submit payload", async () => {
|
||||
it("includes all three selected model pairs in submit payload", async () => {
|
||||
const { props } = renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with model" } });
|
||||
fireEvent.change(textarea, { target: { value: "Task with model overrides" } });
|
||||
openModelMenu();
|
||||
|
||||
// Menu should be open
|
||||
expect(screen.getByTestId("model-nested-menu")).toBeTruthy();
|
||||
fireEvent.click(screen.getByTestId("model-menu-plan"));
|
||||
fireEvent.click(screen.getByTestId("dropdown-select-plan model"));
|
||||
fireEvent.click(screen.getByTestId("model-submenu-back"));
|
||||
|
||||
// Navigate to executor submenu
|
||||
fireEvent.click(screen.getByTestId("model-menu-executor"));
|
||||
|
||||
// Select executor model via mocked dropdown
|
||||
fireEvent.click(screen.getByTestId("dropdown-select-executor model"));
|
||||
fireEvent.click(screen.getByTestId("model-submenu-back"));
|
||||
|
||||
// Close the menu via Escape
|
||||
fireEvent.click(screen.getByTestId("model-menu-validator"));
|
||||
fireEvent.click(screen.getByTestId("dropdown-select-validator model"));
|
||||
|
||||
// Close the submenu, then close the menu before submitting.
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
|
||||
// Submit the task
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Task with model",
|
||||
description: "Task with model overrides",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
validatorModelProvider: "anthropic",
|
||||
validatorModelId: "claude-sonnet-4-5",
|
||||
planningModelProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("omits model fields from submit payload when no overrides are selected", async () => {
|
||||
const { props } = renderQuickEntryBox({});
|
||||
expandQuickEntry();
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task without model overrides" } });
|
||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: "Task without model overrides",
|
||||
modelProvider: undefined,
|
||||
modelId: undefined,
|
||||
validatorModelProvider: undefined,
|
||||
validatorModelId: undefined,
|
||||
planningModelProvider: undefined,
|
||||
planningModelId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user