fix(site): remember reasoning effort per model on new chat (#27457)

This commit is contained in:
Danielle Maywood
2026-07-23 13:37:50 +01:00
committed by GitHub
parent 591edcb050
commit 9ce366414e
4 changed files with 264 additions and 6 deletions
@@ -18,6 +18,10 @@ import {
MockWorkspace,
} from "#/testHelpers/entities";
import { withDashboardProvider } from "#/testHelpers/storybook";
import {
getReasoningEffortForModel,
saveReasoningEffortForModel,
} from "../utils/reasoningEffort";
import { AgentCreateForm } from "./AgentCreateForm";
// Query key used by permittedOrganizations() in the form.
@@ -329,6 +333,157 @@ const effortModelOptions = [
},
] as const;
export const RemembersReasoningEffortByModel: Story = {
args: {
...defaultArgs,
modelOptions: [...effortModelOptions],
},
beforeEach: () => {
localStorage.clear();
saveReasoningEffortForModel(modelConfigID, "high");
saveReasoningEffortForModel(claudeModelConfigID, "medium");
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const modelSelector = canvas.getByRole("combobox", { name: "GPT-4o" });
await userEvent.click(modelSelector);
expect(await body.findByRole("slider")).toHaveAttribute(
"aria-valuenow",
"4",
);
await userEvent.click(
await body.findByRole("option", { name: /Claude Sonnet 4/i }),
);
await userEvent.click(
canvas.getByRole("combobox", { name: "Claude Sonnet 4" }),
);
expect(await body.findByRole("slider")).toHaveAttribute(
"aria-valuenow",
"1",
);
await userEvent.click(await body.findByRole("option", { name: /GPT-4o/i }));
await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" }));
const restoredSlider = await body.findByRole("slider");
expect(restoredSlider).toHaveAttribute("aria-valuenow", "4");
restoredSlider.focus();
await userEvent.keyboard("{ArrowRight}");
await waitFor(() => {
expect(getReasoningEffortForModel(modelConfigID)).toBe("xhigh");
});
await userEvent.keyboard("{Escape}");
},
};
export const PersistedReasoningEffortOutranksRootOverride: Story = {
args: {
...defaultArgs,
onCreateChat: fn().mockResolvedValue(undefined),
modelOptions: [...effortModelOptions],
modelConfigs: defaultModelConfigs,
rootPersonalModelOverride: buildRootPersonalModelOverride({
mode: "model",
model_config_id: modelConfigID,
reasoning_effort: "high",
}),
},
beforeEach: () => {
localStorage.clear();
saveReasoningEffortForModel(modelConfigID, "low");
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
// The persisted per-model value wins over the root override.
await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" }));
expect(await body.findByRole("slider")).toHaveAttribute(
"aria-valuenow",
"2",
);
await userEvent.keyboard("{Escape}");
await submitMessage(canvasElement, "create with persisted effort");
await waitFor(() => {
expect(args.onCreateChat).toHaveBeenCalled();
});
expect(getCreateOptions(args.onCreateChat).reasoningEffort).toBe("low");
},
};
export const ManualReselectKeepsRootOverrideEffort: Story = {
args: {
...defaultArgs,
modelOptions: [...effortModelOptions],
modelConfigs: defaultModelConfigs,
rootPersonalModelOverride: buildRootPersonalModelOverride({
mode: "model",
model_config_id: modelConfigID,
reasoning_effort: "high",
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
// Re-selecting the override's own model keeps the override effort.
await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" }));
await userEvent.click(await body.findByRole("option", { name: /GPT-4o/i }));
await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" }));
expect(await body.findByRole("slider")).toHaveAttribute(
"aria-valuenow",
"4",
);
await userEvent.keyboard("{Escape}");
},
};
export const StalePersistedEffortFallsThroughToRootOverride: Story = {
args: {
...defaultArgs,
onCreateChat: fn().mockResolvedValue(undefined),
modelOptions: [
{
...modelOptions[0],
reasoningEffortDefault: "low",
reasoningEfforts: ["low", "medium"],
},
],
modelConfigs: defaultModelConfigs,
rootPersonalModelOverride: buildRootPersonalModelOverride({
mode: "model",
model_config_id: modelConfigID,
reasoning_effort: "medium",
}),
},
beforeEach: () => {
localStorage.clear();
saveReasoningEffortForModel(modelConfigID, "max");
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
// The stored "max" is no longer valid for this model, so the
// root override's "medium" applies instead of the default "low".
await userEvent.click(canvas.getByRole("combobox", { name: "GPT-4o" }));
expect(await body.findByRole("slider")).toHaveAttribute(
"aria-valuenow",
"1",
);
await userEvent.keyboard("{Escape}");
await submitMessage(canvasElement, "create with stale persisted effort");
await waitFor(() => {
expect(args.onCreateChat).toHaveBeenCalled();
});
expect(getCreateOptions(args.onCreateChat).reasoningEffort).toBe("medium");
},
};
export const SubmitsReasoningEffort: Story = {
// TODO: This story fails when pixel runs its play function. Fix it and remove the exclude.
parameters: { pixel: { exclude: true } },
@@ -348,6 +503,11 @@ export const SubmitsReasoningEffort: Story = {
expect(slider).toHaveAttribute("aria-valuenow", "3");
// Bump the effort to "high" with the keyboard, then close.
// The info button precedes the slider in tab order.
await userEvent.tab();
expect(
body.getByRole("button", { name: "About reasoning effort" }),
).toHaveFocus();
await userEvent.tab();
expect(slider).toHaveFocus();
await userEvent.keyboard("{ArrowRight}");
@@ -20,7 +20,11 @@ import {
hasConfiguredModelsInCatalog,
hasUserFixableProviders,
} from "../utils/modelOptions";
import { pickReasoningEffort } from "../utils/reasoningEffort";
import {
getReasoningEffortForModel,
pickReasoningEffort,
saveReasoningEffortForModel,
} from "../utils/reasoningEffort";
import {
formatUsageLimitMessage,
isChatUsageLimitExceededResponse,
@@ -240,13 +244,29 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
}
return selectedModel || undefined;
})();
const [selectedReasoningEffort, setSelectedReasoningEffort] = useState("");
const [selectedReasoningEfforts, setSelectedReasoningEfforts] = useState<
Record<string, string>
>({});
const selectedModelOption = modelOptions.find(
(option) => option.id === selectedModel,
);
// Persisted per-model choice wins over a root override; a stale
// stored value is ignored so the override still applies. The
// override applies to its own model even after a manual re-select.
const rootOverrideReasoningEffort =
selectedModel === rootOverrideModelID
? rootPersonalModelOverride?.reasoning_effort
: undefined;
const persistedReasoningEffort = (() => {
const stored = getReasoningEffortForModel(selectedModel);
const efforts = selectedModelOption?.reasoningEfforts;
return stored && efforts?.includes(stored) ? stored : undefined;
})();
const effectiveReasoningEffort = selectedModelOption
? pickReasoningEffort(
selectedReasoningEffort,
selectedReasoningEfforts[selectedModel] ??
persistedReasoningEffort ??
rootOverrideReasoningEffort,
selectedModelOption.reasoningEfforts ?? [],
selectedModelOption.reasoningEffortDefault,
)
@@ -351,6 +371,14 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
setUserSelectedModel(value);
};
const handleReasoningEffortChange = (value: string) => {
setSelectedReasoningEfforts((current) => ({
...current,
[selectedModel]: value,
}));
saveReasoningEffortForModel(selectedModel, value);
};
const isForbidden = !canCreateChat;
// Filter workspaces by the selected organization. We use
@@ -544,7 +572,7 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
modelOptions={modelOptions}
modelSelectorPlaceholder={modelSelectorPlaceholder}
reasoningEffort={effectiveReasoningEffort}
onReasoningEffortChange={setSelectedReasoningEffort}
onReasoningEffortChange={handleReasoningEffortChange}
isModelCatalogLoading={isModelCatalogLoading}
hasModelOptions={hasModelOptions}
planModeEnabled={planModeEnabled}
@@ -1,5 +1,41 @@
import { describe, expect, it } from "vitest";
import { formatReasoningEffort, pickReasoningEffort } from "./reasoningEffort";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
formatReasoningEffort,
getReasoningEffortForModel,
pickReasoningEffort,
saveReasoningEffortForModel,
} from "./reasoningEffort";
describe("reasoning effort storage", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("stores the latest effort independently for each model", () => {
saveReasoningEffortForModel("model-a", "high");
saveReasoningEffortForModel("model-b", "medium");
saveReasoningEffortForModel("model-a", "low");
expect(getReasoningEffortForModel("model-a")).toBe("low");
expect(getReasoningEffortForModel("model-b")).toBe("medium");
});
it("handles unavailable storage", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new DOMException("Storage unavailable", "SecurityError");
});
expect(getReasoningEffortForModel("model-a")).toBeUndefined();
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new DOMException("Storage full", "QuotaExceededError");
});
expect(() => saveReasoningEffortForModel("model-a", "high")).not.toThrow();
});
});
describe("formatReasoningEffort", () => {
it("formats xhigh", () => {
@@ -1,3 +1,37 @@
const reasoningEffortStorageKeyPrefix = "agents.reasoning-effort.";
const reasoningEffortStorageKey = (modelConfigID: string) =>
`${reasoningEffortStorageKeyPrefix}${modelConfigID}`;
/** Reads the persisted effort for a model, or undefined when none is stored or storage is unavailable. */
export const getReasoningEffortForModel = (
modelConfigID: string,
): string | undefined => {
try {
return (
localStorage.getItem(reasoningEffortStorageKey(modelConfigID)) ??
undefined
);
} catch {
return undefined;
}
};
/** Persists the effort for a model. Swallows storage errors (private mode, quota) so the caller's in-memory selection is unaffected. */
export const saveReasoningEffortForModel = (
modelConfigID: string,
reasoningEffort: string,
): void => {
try {
localStorage.setItem(
reasoningEffortStorageKey(modelConfigID),
reasoningEffort,
);
} catch {
// Keep the in-memory selection when storage is unavailable.
}
};
/** Display label for an effort value, e.g. "xhigh" renders as "Xhigh". */
export const formatReasoningEffort = (value: string): string =>
value.charAt(0).toUpperCase() + value.slice(1);