diff --git a/packages/ui/src/features/sessions/components/ReasoningLevelSelector.test.tsx b/packages/ui/src/features/sessions/components/ReasoningLevelSelector.test.tsx
index cbf39c94e1..e041a29d26 100644
--- a/packages/ui/src/features/sessions/components/ReasoningLevelSelector.test.tsx
+++ b/packages/ui/src/features/sessions/components/ReasoningLevelSelector.test.tsx
@@ -65,6 +65,21 @@ function claudeModelOption(
} as unknown as SessionConfigOption;
}
+function effortlessModelOption(): SessionConfigOption {
+ return {
+ type: "select",
+ id: "model",
+ name: "Model",
+ category: "model",
+ currentValue: "moonshotai/kimi-k3",
+ options: [
+ { name: "Claude Sonnet 5", value: "claude-sonnet-5" },
+ { name: "Claude Opus 5", value: "claude-opus-5" },
+ { name: "Kimi K3", value: "moonshotai/kimi-k3" },
+ ],
+ } as unknown as SessionConfigOption;
+}
+
function contextOption(currentValue = "1m"): SessionConfigOption {
return {
type: "select",
@@ -423,4 +438,87 @@ describe("ReasoningLevelSelector", () => {
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
+
+ it("keeps the model picker when the model has no reasoning levels", async () => {
+ const onModelChange = vi.fn();
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Model: Kimi K3" }));
+ await openSub(user, /^Model/);
+ fireEvent.click(
+ await screen.findByRole("menuitemradio", { name: "Claude Opus 5" }),
+ );
+
+ await pollUntil(() => onModelChange.mock.calls.length > 0);
+ expect(onModelChange).toHaveBeenCalledWith("claude-opus-5");
+ });
+
+ it("hides the reasoning submenu and slider for an effort-less model", async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Model: Kimi K3" }));
+ expect(
+ await screen.findByRole("menuitem", { name: /^Model/ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("menuitem", { name: /^Reasoning/ }),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByRole("slider")).not.toBeInTheDocument();
+ });
+
+ it("drops the stale effort label when switching to an effort-less model", () => {
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(
+ screen.getByRole("button", { name: "Model: Kimi K3" }),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("High")).not.toBeInTheDocument();
+ });
+
+ it("shows a loading placeholder while the first config loads", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: /Loading/ })).toHaveAttribute(
+ "aria-disabled",
+ "true",
+ );
+ });
});
diff --git a/packages/ui/src/features/sessions/components/ReasoningLevelSelector.tsx b/packages/ui/src/features/sessions/components/ReasoningLevelSelector.tsx
index 48f47a2d7e..03179f28ce 100644
--- a/packages/ui/src/features/sessions/components/ReasoningLevelSelector.tsx
+++ b/packages/ui/src/features/sessions/components/ReasoningLevelSelector.tsx
@@ -144,26 +144,39 @@ export function ReasoningLevelSelector({
const displayModel = useRetainedConfigOption(modelOption);
const fastModeFlagEnabled = useFeatureFlag(FAST_MODE_FLAG);
- // Genuinely no reasoning levels for this harness/model: hide. While the
- // preview config reloads (a harness switch) keep showing the last value,
- // disabled, so the toolbar doesn't collapse mid-switch.
- if (!thoughtOption && !isLoading) return null;
- if (!displayThought || displayThought.type !== "select") {
+ // An effort-less model has no thought option once the config settles; the
+ // pill is the only model picker, so it must stay rendered to switch away.
+ const effortless = !thoughtOption && !isLoading;
+ const thoughtSelect =
+ !effortless && displayThought?.type === "select"
+ ? displayThought
+ : undefined;
+ const effortOptions = thoughtSelect ? toDropdownOptions(thoughtSelect) : [];
+ const hasEffort = effortOptions.length > 0;
+
+ const modelSelect =
+ displayModel?.type === "select" ? displayModel : undefined;
+
+ if (!hasEffort && !modelSelect) {
+ if (isLoading) {
+ return (
+
+ );
+ }
return null;
}
- const isReloading = !thoughtOption;
+ const isReloading = !effortless && !thoughtOption;
const isDisabled = disabled || isReloading;
- const effortOptions = toDropdownOptions(displayThought);
- if (effortOptions.length === 0) return null;
- const currentEffort = displayThought.currentValue;
- const effortLabel =
- effortOptions.find((option) => option.value === currentEffort)?.label ??
- currentEffort;
-
- const modelSelect =
- displayModel?.type === "select" ? displayModel : undefined;
+ const currentEffort = thoughtSelect?.currentValue;
+ const effortLabel = currentEffort
+ ? (effortOptions.find((option) => option.value === currentEffort)?.label ??
+ currentEffort)
+ : undefined;
const modelEntries = modelSelect
? flattenSelectOptions(modelSelect.options)
: [];
@@ -218,13 +231,14 @@ export function ReasoningLevelSelector({
}));
const currentStopKey = useLadder
? `${currentModel}${STOP_SEPARATOR}${currentEffort}`
- : currentEffort;
+ : (currentEffort ?? "");
// A custom Advanced combination (off the preset ladder) hides the slider:
// the menu opens straight on the Advanced view until Reset to default puts
// the session back on a notch.
- const onNotch =
- !useLadder || ladderStops.some((stop) => stop.key === currentStopKey);
+ const onNotch = useLadder
+ ? ladderStops.some((stop) => stop.key === currentStopKey)
+ : hasEffort;
const handleStopSelect = (key: string) => {
if (key.includes(STOP_SEPARATOR)) {
@@ -305,18 +319,12 @@ export function ReasoningLevelSelector({
});
};
- if (isLoading && !open && !displayThought) {
- return (
-
- );
- }
-
- const triggerAriaLabel = modelLabel
- ? `Model and reasoning: ${modelLabel} ${effortLabel}`
- : `Reasoning: ${effortLabel}`;
+ const triggerAriaLabel =
+ modelLabel && effortLabel
+ ? `Model and reasoning: ${modelLabel} ${effortLabel}`
+ : modelLabel
+ ? `Model: ${modelLabel}`
+ : `Reasoning: ${effortLabel}`;
return (
{modelLabel}
)}
-
- {effortLabel}
-
+ {effortLabel && (
+
+ {effortLabel}
+
+ )}
)}
-
-
- Reasoning
-
- {effortLabel}
-
-
-
-
- selectAndClose(() => onChange?.(value))
- }
- >
- {effortOptions.map((option) => (
-
- ))}
-
-
-
+ {hasEffort && (
+
+
+ Reasoning
+
+ {effortLabel}
+
+
+
+
+ selectAndClose(() => onChange?.(value))
+ }
+ >
+ {effortOptions.map((option) => (
+
+ ))}
+
+
+
+ )}
{toggleRows.map((row) => (
diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx
index cbaa79033e..b213f0c005 100644
--- a/packages/ui/src/features/sessions/components/SessionView.tsx
+++ b/packages/ui/src/features/sessions/components/SessionView.tsx
@@ -767,7 +767,7 @@ export function SessionView({
enableBashMode={!isCloudRun}
modelSelector={null}
reasoningSelector={
- thoughtOption ? (
+ thoughtOption || sessionModelOption ? (
{
);
});
});
+
+describe("feature settingsStore hydration", () => {
+ beforeEach(async () => {
+ await resetPersistenceMocks();
+ });
+
+ it("marks the store hydrated after a successful rehydration", async () => {
+ getItem.mockResolvedValue(JSON.stringify({ state: {}, version: 1 }));
+ useSettingsStore.setState({ _hasHydrated: false });
+
+ await useSettingsStore.persist.rehydrate();
+
+ expect(useSettingsStore.getState()._hasHydrated).toBe(true);
+ });
+
+ it("marks the store hydrated when rehydration fails", async () => {
+ getItem.mockRejectedValue(new Error("storage unavailable"));
+ useSettingsStore.setState({ _hasHydrated: false });
+
+ await useSettingsStore.persist.rehydrate();
+
+ expect(useSettingsStore.getState()._hasHydrated).toBe(true);
+ });
+});
diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts
index caf59645ed..806000165e 100644
--- a/packages/ui/src/features/settings/settingsStore.ts
+++ b/packages/ui/src/features/settings/settingsStore.ts
@@ -633,8 +633,12 @@ export const useSettingsStore = create()(
// Onboarding hints
hints: state.hints,
}),
- onRehydrateStorage: () => (state) => {
- state?.setHasHydrated(true);
+ onRehydrateStorage: () => (state, error) => {
+ if (error) {
+ useSettingsStore.getState().setHasHydrated(true);
+ } else {
+ state?.setHasHydrated(true);
+ }
},
merge: (persisted, current) => {
const merged = {