From 578f19ba55cba09b5883012fa2e1f9297c4607ea Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 30 Jul 2026 17:19:50 -0700 Subject: [PATCH 1/2] keep model picker for effort-less models --- .../ReasoningLevelSelector.test.tsx | 98 ++++++++++++++ .../components/ReasoningLevelSelector.tsx | 128 ++++++++++-------- .../sessions/components/SessionView.tsx | 2 +- 3 files changed, 170 insertions(+), 58 deletions(-) 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 ? ( Date: Thu, 30 Jul 2026 17:20:19 -0700 Subject: [PATCH 2/2] mark settings hydrated after failed rehydrate --- .../features/settings/settingsStore.test.ts | 24 +++++++++++++++++++ .../ui/src/features/settings/settingsStore.ts | 8 +++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/settings/settingsStore.test.ts b/packages/ui/src/features/settings/settingsStore.test.ts index 048eb18647..917bf24fa7 100644 --- a/packages/ui/src/features/settings/settingsStore.test.ts +++ b/packages/ui/src/features/settings/settingsStore.test.ts @@ -575,3 +575,27 @@ describe("feature settingsStore terminal font", () => { ); }); }); + +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 = {