diff options
| author | Adam Malczewski <[email protected]> | 2026-06-28 22:21:06 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-28 22:21:06 +0900 |
| commit | 901bd56228ecb105bdbbf18bb9f848192f134791 (patch) | |
| tree | d25ea66950c729387aa92a5e019f7c704a980dbe /src/features/chat/ui.test.ts | |
| parent | 89956f09a45db8bdd302f49d4028c0acc92a58a2 (diff) | |
| download | dispatch-web-901bd56228ecb105bdbbf18bb9f848192f134791.tar.gz dispatch-web-901bd56228ecb105bdbbf18bb9f848192f134791.zip | |
feat(chat): add "Off" option to Reasoning Effort dropdown (thinking on/off)
Add an "Off" choice as the FIRST option in the per-conversation Reasoning
Effort selector. Selecting it disables extended thinking ENTIRELY for the
conversation's turns — NOT "set the effort to its lowest level". The two axes
are kept SEPARATE on the wire (per the umans API model, where thinking-off is
`reasoning_effort: "none"`, distinct from the effort level):
- `reasoningEffort` is the thinking-DEPTH ladder (low→max) — UNCHANGED.
- `thinking` is the on/off switch — a NEW per-conversation boolean.
Turning thinking off PRESERVES the persisted effort level, so an off→on toggle
restores the previously-chosen depth. The selector CONFLATES the two axes into
one <select> (UX); the wire does not.
Pure logic (reasoning-effort.ts): ThinkingSelection = "off" | ReasoningEffort,
selectionOptions() (Off first + the ladder, default marked), isThinkingSelection,
effectiveSelection(persistedEffort, persistedThinking), + FE-local PROPOSED wire
types (ThinkingResponse, SetThinkingRequest) + SaveThinkingSelection port. The
existing effortOptions()/isReasoningEffort()/effectiveEffort() are UNCHANGED — the
heartbeat feature reuses them (its own dropdown is unaffected).
Store: `thinking` state (boolean|null, null⇒ON default), refreshThinking() (GET,
alongside refreshReasoningEffort() on every focus/draft/switch), setThinking()
(PUT). App.svelte's saveThinkingSelection adapter fans one selection out: "off"
→ PUT /thinking {thinking:false} (effort untouched); a level → ensure thinking ON
(if off) then PUT /reasoning-effort {level}.
Backend contract gap (CR-14, backend-handoff.md §2l): the backend has NO
thinking-off mechanism today (umans mapReasoningEffort can't emit "none"). The FE
is built against the PROPOSED additive contract (GET/PUT /conversations/:id/thinking
+ ThinkingResponse/SetThinkingRequest; umans maps thinking===false →
reasoning_effort:"none"). Until shipped, GET /thinking 404s and refreshThinking()
leaves `thinking` null⇒ON — the selector gracefully shows the effort level, no crash.
Verification: typecheck 0/0, 1128 tests green (run TWICE — touches the shared fetch
fake), biome clean, build OK. 9 files (8 source + backend-handoff.md).
Diffstat (limited to 'src/features/chat/ui.test.ts')
| -rw-r--r-- | src/features/chat/ui.test.ts | 79 |
1 files changed, 64 insertions, 15 deletions
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 5f8067d..f4006f7 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -1092,30 +1092,53 @@ describe("ModelSelector", () => { }); describe("ReasoningEffortSelector", () => { - it("renders null (never set) as the default level, marked '(default)'", () => { - render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } }); + it("renders null effort + null thinking (never set) as the default level, marked '(default)'", () => { + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: null, save: vi.fn() }, + }); const select = screen.getByRole("combobox", { name: "Reasoning effort" }); expect(select).toHaveValue("high"); expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument(); - // All five ladder levels are offered. - expect(within(select).getAllByRole("option")).toHaveLength(5); + // "Off" first, then the five ladder levels. + const options = within(select).getAllByRole("option"); + expect(options).toHaveLength(6); + expect(options[0]).toHaveValue("off"); + expect(within(select).getByRole("option", { name: "Off" })).toBeInTheDocument(); }); - it("renders a persisted level as selected", () => { - render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } }); + it("renders a persisted level as selected when thinking is on", () => { + render(ReasoningEffortSelector, { + props: { persistedEffort: "xhigh", persistedThinking: null, save: vi.fn() }, + }); expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh"); }); + it("renders 'off' as selected when thinking is disabled (effort level is preserved but hidden)", () => { + // thinking off is a SEPARATE axis: even with a persisted effort level, the + // selector shows "off" while thinking is disabled. + render(ReasoningEffortSelector, { + props: { persistedEffort: "xhigh", persistedThinking: false, save: vi.fn() }, + }); + + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off"); + // the level option is still present (restored on an off→on toggle) + expect( + within(screen.getByRole("combobox")).getByRole("option", { name: "xhigh" }), + ).toBeInTheDocument(); + }); + it("selecting a level saves it via the injected port and confirms", async () => { - const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({ + const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({ ok: true as const, - reasoningEffort: level, + selection, })); const user = userEvent.setup(); - render(ReasoningEffortSelector, { props: { persisted: null, save } }); + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: null, save }, + }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); @@ -1127,11 +1150,35 @@ describe("ReasoningEffortSelector", () => { expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max"); }); - it("a failed save shows the error and reverts to the persisted value", async () => { + it("selecting 'off' saves the separate disable signal (not a level)", async () => { + const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({ + ok: true as const, + selection, + })); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { + props: { persistedEffort: "high", persistedThinking: null, save }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "off"); + + expect(save).toHaveBeenCalledTimes(1); + // "off" is the separate thinking-disable signal — NOT a zero-effort level. + expect(save).toHaveBeenCalledWith("off"); + await vi.waitFor(() => { + expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument(); + }); + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off"); + }); + + it("a failed save shows the error and reverts to the persisted selection", async () => { const save = vi.fn(async () => ({ ok: false as const, error: "nope" })); const user = userEvent.setup(); - render(ReasoningEffortSelector, { props: { persisted: "low", save } }); + render(ReasoningEffortSelector, { + props: { persistedEffort: "low", persistedThinking: null, save }, + }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); @@ -1142,22 +1189,24 @@ describe("ReasoningEffortSelector", () => { }); it("disables the select while a save is in flight (no double-fire)", async () => { - let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined; + let resolveSave: ((r: { ok: true; selection: "max" }) => void) | undefined; const save = vi.fn( () => - new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => { + new Promise<{ ok: true; selection: "max" }>((resolve) => { resolveSave = resolve; }), ); const user = userEvent.setup(); - render(ReasoningEffortSelector, { props: { persisted: null, save } }); + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: null, save }, + }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled(); - resolveSave?.({ ok: true, reasoningEffort: "max" }); + resolveSave?.({ ok: true, selection: "max" }); await vi.waitFor(() => { expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled(); }); |
