Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
<Theme>
<ReasoningLevelSelector
modelOption={effortlessModelOption()}
adapter="claude"
onModelChange={onModelChange}
/>
</Theme>,
);

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(
<Theme>
<ReasoningLevelSelector
modelOption={effortlessModelOption()}
adapter="claude"
/>
</Theme>,
);

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(
<Theme>
<ReasoningLevelSelector
thoughtOption={thoughtOption()}
modelOption={claudeModelOption()}
adapter="claude"
/>
</Theme>,
);

rerender(
<Theme>
<ReasoningLevelSelector
modelOption={effortlessModelOption()}
adapter="claude"
/>
</Theme>,
);

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(
<Theme>
<ReasoningLevelSelector isLoading />
</Theme>,
);

expect(screen.getByRole("button", { name: /Loading/ })).toHaveAttribute(
"aria-disabled",
"true",
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Button type="button" variant="default" size="sm" disabled>
<Spinner size={12} className="animate-spin" />
Loading...
</Button>
);
}
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)
: [];
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -305,18 +319,12 @@ export function ReasoningLevelSelector({
});
};

if (isLoading && !open && !displayThought) {
return (
<Button type="button" variant="default" size="sm" disabled>
<Spinner size={12} className="animate-spin" />
Loading...
</Button>
);
}

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 (
<DropdownMenu
Expand Down Expand Up @@ -363,13 +371,17 @@ export function ReasoningLevelSelector({
{modelLabel && (
<span className="font-medium text-foreground">{modelLabel}</span>
)}
<span
className={
modelLabel ? "font-normal text-muted-foreground/80" : undefined
}
>
{effortLabel}
</span>
{effortLabel && (
<span
className={
modelLabel
? "font-normal text-muted-foreground/80"
: undefined
}
>
{effortLabel}
</span>
)}
<CaretDown
size={10}
weight="bold"
Expand Down Expand Up @@ -475,26 +487,28 @@ export function ReasoningLevelSelector({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>Reasoning</span>
<span className="flex-1 text-right text-muted-foreground">
{effortLabel}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={currentEffort}
onValueChange={(value) =>
selectAndClose(() => onChange?.(value))
}
>
{effortOptions.map((option) => (
<LevelItem key={option.value} option={option} />
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
{hasEffort && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>Reasoning</span>
<span className="flex-1 text-right text-muted-foreground">
{effortLabel}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={currentEffort ?? ""}
onValueChange={(value) =>
selectAndClose(() => onChange?.(value))
}
>
{effortOptions.map((option) => (
<LevelItem key={option.value} option={option} />
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{toggleRows.map((row) => (
<DropdownMenuSub key={row.id}>
<DropdownMenuSubTrigger>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -767,7 +767,7 @@ export function SessionView({
enableBashMode={!isCloudRun}
modelSelector={null}
reasoningSelector={
thoughtOption ? (
thoughtOption || sessionModelOption ? (
<ReasoningLevelSelector
thoughtOption={thoughtOption}
modelOption={sessionModelOption}
Expand Down
24 changes: 24 additions & 0 deletions packages/ui/src/features/settings/settingsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
8 changes: 6 additions & 2 deletions packages/ui/src/features/settings/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,8 +633,12 @@ export const useSettingsStore = create<SettingsStore>()(
// 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 = {
Expand Down
Loading