summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 22:21:06 +0900
committerAdam Malczewski <[email protected]>2026-06-28 22:21:06 +0900
commitc1082294dd98cadfecda58d854174610659cadec (patch)
tree00dbc2ebd40260d1ae82820d56f9ab8f62cf343b
parent1cdb866fbb708a1f6c5e802a075789cece85800d (diff)
downloaddispatch-web-c1082294dd98cadfecda58d854174610659cadec.tar.gz
dispatch-web-c1082294dd98cadfecda58d854174610659cadec.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).
-rw-r--r--backend-handoff.md95
-rw-r--r--src/app/App.svelte44
-rw-r--r--src/app/store.svelte.ts89
-rw-r--r--src/app/store.test.ts94
-rw-r--r--src/features/chat/index.ts10
-rw-r--r--src/features/chat/reasoning-effort.test.ts39
-rw-r--r--src/features/chat/reasoning-effort.ts98
-rw-r--r--src/features/chat/ui.test.ts79
-rw-r--r--src/features/chat/ui/ReasoningEffortSelector.svelte43
9 files changed, 549 insertions, 42 deletions
diff --git a/backend-handoff.md b/backend-handoff.md
index 4e3c6df..a9aaa5a 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -5,6 +5,17 @@
> **From:** dispatch-web orchestrator · **To:** `../backend` orchestrator · **Courier:** the user.
> `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here.
+_Last updated: 2026-06-28 (§2l — **thinking on/off** as a SEPARATE per-conversation axis from the
+reasoning-effort level. An `Off` option is now the FIRST choice in the Reasoning Effort dropdown;
+selecting it disables thinking entirely (the umans route maps it to `reasoning_effort: "none"`), NOT
+"effort to its lowest". The two axes are kept separate on the wire: a new `thinking: boolean|null`
+persisted per-conversation (null⇒ON default) + PROPOSED `GET`/`PUT /conversations/:id/thinking`
+(CR-14); the effort ladder + heartbeat dropdown are UNCHANGED. FE built against the proposed contract
+(FE-local `ThinkingResponse`/`SetThinkingRequest` in `reasoning-effort.ts`, swapped for the imported
+ones once the backend ships); a 404 (endpoint not yet shipped) leaves `thinking` null⇒ON, so the selector
+gracefully shows the effort level until then. typecheck 0/0, 1128 tests green (run TWICE), biome clean,
+build OK. Worktree env note: an untracked `dispatch-backend → backend` symlink in the worktree parent so
+the canonical `file:../dispatch-backend/...` paths resolve — NOT committed.)_
_Last updated: 2026-06-27 (FE-only slice: **workspace-active indicator** — loading-dots on
workspace cards when a workspace has ≥1 active/queued conversation. New `AppStore.workspaceHasActiveConversations(workspaceId)` derives from the existing open-tab set (every active/queued
conversation has an open tab stamped with its `workspaceId`) × the backend lifecycle statuses; a
@@ -26,7 +37,7 @@ _Last updated: 2026-06-27 (workspace starring — backend `feature/workspace-sta
return the updated `Workspace`). FE consumed: `adapter/http` `star`/`unstar`, pure `sortWorkspaces`/`applyStarred`,
`store.setStarred` (optimistic + revert, `$derived`-sorted list), `WorkspaceCard` star toggle (filled gold ★ / outline ☆).
Re-mirrored `.dispatch/wire.reference.md`. typecheck 0/0, 1045 tests green (+27), biome clean, build OK. No open backend asks.)_
-**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9**
+**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-14** (thinking on/off — §2l; FE built against the proposed contract, awaiting the `GET`/`PUT /conversations/:id/thinking` endpoint + `ThinkingResponse`/`SetThinkingRequest` types), **CR-9**
_Last updated: 2026-06-26 (backend: concurrency limits now PERSISTED across reboots — no API contract change, no FE
re-pin/re-mirror needed; §2j updated. FE: brief "Saved." confirmation on the limit row after a successful save. 926 tests
green.) Prior: CR-13 (`"queued"` ConversationStatus) RESOLVED; dev merged (e81df4c)._
@@ -1194,6 +1205,88 @@ click "Restore to N" → confirm the banner drops on the next poll.
---
+## 2l. Thinking on/off (separate from the reasoning-effort level) → **FE BUILT; 1 BACKEND ASK (CR-14)**
+
+A per-conversation **"thinking off"** affordance in the Reasoning Effort dropdown: an `Off` option as the
+FIRST choice (before `Low`). 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): `reasoningEffort` is the thinking-DEPTH ladder (`low`→`max`); `thinking` is the on/off switch.
+Turning thinking off PRESERVES the persisted effort level, so an off→on toggle restores the previously-chosen
+depth. The per-conversation selector CONFLATES the two axes into one `<select>` (UX), but the WIRE does not.
+
+### Why a separate signal (not a new ladder level)
+
+The umans route (`provider-umans/src/reasoning.ts`) accepts `reasoning_effort: "none"|"low"|"medium"|"high"`
+— `"none"` disables thinking. The current `mapReasoningEffort` can only emit `"low"|"medium"|"high"` (it
+caps `xhigh`/`max`→`"high"`, and emits NO field when `reasoningEffort` is `undefined`, which the umans
+route treats as "default on"). So there is today NO path through the backend to `reasoning_effort: "none"`,
+i.e. no way to turn thinking off. Extending `ReasoningEffort` with `"none"` would work for umans but
+CONFLATES the two axes the user explicitly wants kept separate, and would also bleed into the heartbeat
+config dropdown (which reuses `effortOptions()`/`isReasoningEffort()`). A separate `thinking` boolean keeps
+the axes independent and leaves the existing ladder + heartbeat control untouched.
+
+### What the FE built (against the PROPOSED contract below)
+
+- Pure logic (`src/features/chat/reasoning-effort.ts`): a `ThinkingSelection = "off" | ReasoningEffort`
+ type, `selectionOptions()` (`Off` first, then the ladder w/ default marked), `isThinkingSelection()`,
+ `effectiveSelection(persistedEffort, persistedThinking)`, + FE-local proposed wire types
+ `ThinkingResponse` / `SetThinkingRequest` + a `SaveThinkingSelection` port. The existing
+ `effortOptions()` / `isReasoningEffort()` / `effectiveEffort()` are UNCHANGED (the heartbeat feature
+ reuses them — its own dropdown is unaffected).
+- Selector (`ReasoningEffortSelector.svelte`): renders `Off` first; new props `persistedEffort` +
+ `persistedThinking` + `save`; the displayed selection derives from BOTH axes (`thinking===false` ⇒ `off`,
+ else the effective effort level). Selecting `Off` calls `save("off")`; selecting a level calls
+ `save(level)`.
+- Store (`store.svelte.ts`): `thinking` state (`boolean | null`, `null`=never set⇒ON), `refreshThinking()`
+ (GET, called alongside `refreshReasoningEffort()` on every focus/draft/switch), `setThinking()`
+ (PUT). `App.svelte`'s `saveThinkingSelection` adapter fans one selection out to the right PUT(s):
+ `"off"` → `PUT /thinking {thinking:false}` (leaves effort untouched); a level → ensure thinking ON
+ (`PUT /thinking {thinking:true}` if currently off) then `PUT /reasoning-effort {level}`.
+- **Graceful pending-backend behavior:** `refreshThinking()` returns on `!res.ok` (a 404 — endpoint not
+ yet shipped — leaves `thinking` `null` ⇒ ON/default), so the selector simply shows the effort level
+ until the backend ships CR-14. No crash, no broken UI.
+- Tests: pure-logic (`selectionOptions`/`isThinkingSelection`/`effectiveSelection` — incl. `thinking===false`
+ ⇒ `off` while a level is still persisted), component (`Off` first, selecting `Off` sends `"off"` not a
+ level, off shows `off` even with a persisted level, failed-save revert, in-flight disable), store
+ (seeds from GET, treats a 404 as null⇒ON, PUT round-trips + echoes). typecheck 0/0, **1128 tests green**
+ (run TWICE — touches the shared fetch fake), biome clean, build OK.
+
+### CR-14 — PROPOSED backend contract (the ask)
+
+A NEW, ADDITIVE per-conversation "thinking" boolean, fully separate from `reasoningEffort`. Mirrors the
+existing `reasoning-effort` shape so it slots in with minimal surface:
+
+- **Wire / transport-contract (additive, NO version bump needed):**
+ ```ts
+ // Response of GET /conversations/:id/thinking
+ export interface ThinkingResponse {
+ readonly conversationId: string;
+ readonly thinking: boolean | null; // null = never set ⇒ thinking ON (the default)
+ }
+ // Body of PUT /conversations/:id/thinking
+ export interface SetThinkingRequest { readonly thinking: boolean }
+ ```
+ Optionally also a per-turn `ChatRequest.thinking?: boolean` override (mirrors `reasoningEffort`).
+- **Endpoints (new, mirror `reasoning-effort`):**
+ - `GET /conversations/:id/thinking` → `ThinkingResponse`
+ - `PUT /conversations/:id/thinking` (body `SetThinkingRequest`) → `ThinkingResponse`
+- **Resolution (server-owned — do NOT re-implement in the FE):** per-turn `thinking` → persisted
+ conversation `thinking` → default **ON** (`true`). When resolved **OFF**, the orchestrator signals the
+ provider to DISABLE extended thinking; `provider-umans` maps that to `reasoning_effort: "none"` (its
+ `mapReasoningEffort` would gain a `thinking===false ⇒ "none"` short-circuit, ignoring the effort level
+ while off). Providers without a thinking knob ignore it (safe, as today).
+- **No change to `ReasoningEffort` / `reasoning-effort` endpoint** — the ladder and its validation are
+ untouched (so the heartbeat config + existing callers are unaffected).
+
+**FE re-pin/re-mirror on shipment:** once the backend ships CR-14, move `ThinkingResponse` /
+`SetThinkingRequest` into `@dispatch/transport-contract`, re-pin the `file:` dep, re-mirror
+`.dispatch/transport-contract.reference.md`, and swap the FE-local types for the imported ones (the store
++ `reasoning-effort.ts` already reference them by name — a one-line import-source change). The graceful-404
+fallback can then be tightened. **A live integration probe is pending** the endpoint existing
+(`scripts/live-probe.ts` would gain a `/thinking` round-trip).
+
+---
+
## 3. Likely NEXT backend asks (heads-up, not yet requested)
- **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns
diff --git a/src/app/App.svelte b/src/app/App.svelte
index 7e5ac7d..f87eddb 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -1,5 +1,5 @@
<script lang="ts">
- import type { ImageInput, ReasoningEffort } from "@dispatch/transport-contract";
+ import type { ImageInput } from "@dispatch/transport-contract";
import type { InvokeMessage } from "@dispatch/ui-contract";
import { tick } from "svelte";
import Table from "../components/Table.svelte";
@@ -17,8 +17,9 @@
ReasoningEffortSelector,
type CompactNowResult,
type ComposerStatus,
- type ReasoningEffortSaveResult,
type SaveCompactPercentResult,
+ type ThinkingSelection,
+ type ThinkingSelectionSaveResult,
} from "../features/chat";
import { manifest as conversationCacheManifest } from "../features/conversation-cache";
import { manifest as markdownManifest } from "../features/markdown";
@@ -317,14 +318,35 @@
: { ok: false, error: result.error };
}
- // Adapt the store's reasoning-effort result to the chat feature's port.
- async function saveReasoningEffort(
- level: ReasoningEffort,
- ): Promise<ReasoningEffortSaveResult | null> {
- const result = await store.setReasoningEffort(level);
+ // Adapt the store's reasoning-effort + thinking results to the chat
+ // feature's combined selector port. The selector sends ONE selection ("off"
+ // or a level); the adapter fans it out to the right per-axis PUT(s). "off" is
+ // a SEPARATE signal from the effort level: it persists `thinking: false`
+ // (the umans route maps that to `reasoning_effort: "none"`), leaving the
+ // effort level untouched so an off→on toggle restores it. A level ensures
+ // thinking is ON (the level is meaningless while thinking is off) then sets
+ // the effort level.
+ async function saveThinkingSelection(
+ selection: ThinkingSelection,
+ ): Promise<ThinkingSelectionSaveResult | null> {
+ if (selection === "off") {
+ const result = await store.setThinking(false);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, selection: "off" }
+ : { ok: false, error: result.error };
+ }
+ // A level: enable thinking first if it is currently off, then set the level.
+ if (store.thinking === false) {
+ const on = await store.setThinking(true);
+ if (on !== null && !on.ok) {
+ return { ok: false, error: on.error };
+ }
+ }
+ const result = await store.setReasoningEffort(selection);
if (result === null) return null;
return result.ok
- ? { ok: true, reasoningEffort: result.reasoningEffort }
+ ? { ok: true, selection: result.reasoningEffort }
: { ok: false, error: result.error };
}
@@ -728,7 +750,11 @@
re-mount per conversation — incl. switching between drafts — and can't
bleed across tabs. Editable for a draft too (cwd + effort apply from turn 1). -->
{#key store.currentConversationId}
- <ReasoningEffortSelector persisted={store.reasoningEffort} save={saveReasoningEffort} />
+ <ReasoningEffortSelector
+ persistedEffort={store.reasoningEffort}
+ persistedThinking={store.thinking}
+ save={saveThinkingSelection}
+ />
<CwdField cwd={store.cwd} canEdit={true} save={saveCwd} />
<ComputerField
computerId={store.computerId}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 22b0a25..e0491ab 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -54,6 +54,7 @@ import {
} from "../core/protocol";
import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
import { createChatStore } from "../features/chat";
+import type { SetThinkingRequest, ThinkingResponse } from "../features/chat/reasoning-effort";
import type {
ConcurrencyCooldownResult,
ConcurrencyDeleteResult,
@@ -132,6 +133,14 @@ export type ReasoningEffortResult =
| { readonly ok: true; readonly reasoningEffort: ReasoningEffort }
| { readonly ok: false; readonly error: string };
+/**
+ * Outcome of `PUT /conversations/:id/thinking` (PROPOSED — see
+ * `backend-handoff.md`; the endpoint is not yet shipped by the backend).
+ */
+export type ThinkingResult =
+ | { readonly ok: true; readonly thinking: boolean }
+ | { readonly ok: false; readonly error: string };
+
/** Outcome of `POST /conversations/:id/compact` (manual compaction). */
export type CompactResult =
| { readonly ok: true; readonly response: CompactResponse }
@@ -273,6 +282,22 @@ export interface AppStore {
*/
setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null>;
/**
+ * The workspace conversation's persisted thinking flag, or null when never
+ * set (the server then resolves turns with thinking ON — the default).
+ * `false` ⇒ thinking disabled entirely (the SEPARATE "off" axis — NOT a
+ * zero-effort level; the umans route maps it to `reasoning_effort: "none"`).
+ * PROPOSED backend contract — see `backend-handoff.md`.
+ */
+ readonly thinking: boolean | null;
+ /**
+ * Persist the workspace conversation's thinking flag
+ * (`PUT /conversations/:id/thinking`). Works for a draft too (its id survives
+ * promotion), so the first turn already runs with the chosen setting. Takes
+ * effect from the NEXT turn; resolution stays server-owned.
+ * PROPOSED backend contract — see `backend-handoff.md`.
+ */
+ setThinking(enabled: boolean): Promise<ThinkingResult | null>;
+ /**
* Manually trigger conversation compaction (`POST /conversations/:id/compact`).
* Summarizes old messages + retains the most recent N. Returns null when no
* conversation is focused (a draft has nothing to compact).
@@ -729,6 +754,30 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
}
+ // The workspace conversation's persisted thinking flag (SEPARATE from the
+ // effort level). Seeded from the backend on focus change; null = never set
+ // (thinking ON — the default). PROPOSED endpoint (see backend-handoff.md):
+ // a 404 (endpoint not yet shipped) leaves `thinking` null ⇒ ON (default), so
+ // the selector simply shows the effort level until the backend ships it.
+ let thinking = $state<boolean | null>(null);
+
+ /** Refetch the workspace conversation's thinking flag (works for a draft too). */
+ async function refreshThinking(): Promise<void> {
+ const id = workspaceConversationId();
+ // Clear immediately so a switch never shows the PREVIOUS conversation's
+ // setting while the fetch is in flight (null ⇒ ON, the default).
+ thinking = null;
+ try {
+ const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/thinking`);
+ if (!res.ok) return;
+ const data = (await res.json()) as ThinkingResponse;
+ // Guard a slow response losing a race with a conversation switch.
+ if (workspaceConversationId() === id) thinking = data.thinking ?? null;
+ } catch (err) {
+ reportError("Failed to load thinking setting", err);
+ }
+ }
+
// The workspace conversation's auto-compact percent. Seeded from the
// backend on focus change; null = not yet fetched. 0 = disabled.
let compactPercent = $state<number | null>(null);
@@ -975,6 +1024,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
void refreshCwd();
void refreshComputer();
void refreshReasoningEffort();
+ void refreshThinking();
void refreshCompactPercent();
}
@@ -1208,6 +1258,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
void refreshComputer();
void refreshModel();
void refreshReasoningEffort();
+ void refreshThinking();
void refreshCompactPercent();
void refreshVisionSettings();
@@ -1243,6 +1294,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
void refreshComputer();
void refreshModel();
void refreshReasoningEffort();
+ void refreshThinking();
void refreshCompactPercent();
},
get activeChat(): ChatStore {
@@ -1286,6 +1338,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
get reasoningEffort(): ReasoningEffort | null {
return reasoningEffort;
},
+ get thinking(): boolean | null {
+ return thinking;
+ },
get compactPercent(): number | null {
return compactPercent;
},
@@ -1340,6 +1395,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
void refreshCwd();
void refreshComputer();
void refreshReasoningEffort();
+ void refreshThinking();
void refreshCompactPercent();
// Now send on the promoted store
chatStores.get(conversationId)?.send(text, images);
@@ -1386,6 +1442,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
void refreshComputer();
void refreshModel();
void refreshReasoningEffort();
+ void refreshThinking();
void refreshCompactPercent();
},
@@ -1401,6 +1458,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
void refreshComputer();
void refreshModel();
void refreshReasoningEffort();
+ void refreshThinking();
void refreshCompactPercent();
},
@@ -1587,6 +1645,37 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
},
+ async setThinking(enabled: boolean): Promise<ThinkingResult | null> {
+ const id = workspaceConversationId();
+ const body: SetThinkingRequest = { thinking: enabled };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/conversations/${encodeURIComponent(id)}/thinking`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set thinking failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json()) as ThinkingResponse;
+ const next = data.thinking ?? enabled;
+ if (workspaceConversationId() === id) thinking = next;
+ return { ok: true, thinking: next };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set thinking request failed",
+ };
+ }
+ },
+
stopGeneration(): void {
const conversationId = tabsStore.activeConversationId;
if (conversationId === null) return;
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index a945ea7..dbceec5 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -891,6 +891,100 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("seeds thinking from GET /conversations/:id/thinking (null = never set ⇒ ON)", async () => {
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/thinking")) {
+ return new Response(JSON.stringify({ conversationId: "x", thinking: false }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => {
+ expect(store.thinking).toBe(false);
+ });
+
+ store.dispose();
+ });
+
+ it("treats a missing thinking endpoint (404) as 'never set' (null ⇒ ON)", async () => {
+ // The thinking endpoint is PROPOSED (backend-handoff.md); until the backend
+ // ships it, GET 404s and `thinking` stays null ⇒ the selector shows the
+ // effort level (thinking ON, the default) — graceful, never a crash.
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/thinking")) {
+ return new Response("not found", { status: 404 });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // give the (404ing) fetch a tick to settle
+ await vi.waitFor(() => {
+ expect(store.reasoningEffort).not.toBe(undefined);
+ });
+ expect(store.thinking).toBeNull();
+
+ store.dispose();
+ });
+
+ it("setThinking PUTs the flag and updates local state from the echo", async () => {
+ const calls: { url: string; method: string; body: string | undefined }[] = [];
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ calls.push({ url, method: init?.method ?? "GET", body: init?.body as string | undefined });
+ if (url.endsWith("/thinking") && init?.method === "PUT") {
+ const sent = JSON.parse(init.body as string) as { thinking: boolean };
+ return new Response(JSON.stringify({ conversationId: "x", thinking: sent.thinking }), {
+ status: 200,
+ });
+ }
+ if (url.endsWith("/thinking")) {
+ return new Response(JSON.stringify({ conversationId: "x", thinking: null }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.setThinking(false);
+ expect(result).toEqual({ ok: true, thinking: false });
+ expect(store.thinking).toBe(false);
+
+ const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/thinking"));
+ expect(put).toBeDefined();
+ expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`);
+ expect(JSON.parse(put?.body ?? "{}")).toEqual({ thinking: false });
+
+ store.dispose();
+ });
+
it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => {
const ws = fakeSocket();
const store = createAppStore({
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index cf57cea..2f98a0e 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -12,13 +12,23 @@ export type {
EffortOption,
ReasoningEffortSaveResult,
SaveReasoningEffort,
+ SaveThinkingSelection,
+ SelectionOption,
+ SetThinkingRequest,
+ ThinkingResponse,
+ ThinkingSaveResult,
+ ThinkingSelection,
+ ThinkingSelectionSaveResult,
} from "./reasoning-effort";
export {
DEFAULT_REASONING_EFFORT,
effectiveEffort,
+ effectiveSelection,
effortOptions,
isReasoningEffort,
+ isThinkingSelection,
REASONING_EFFORT_LEVELS,
+ selectionOptions,
} from "./reasoning-effort";
export type { ChatStore, ChatStoreDependencies } from "./store.svelte";
export { createChatStore } from "./store.svelte";
diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts
index 6d409e9..e870bac 100644
--- a/src/features/chat/reasoning-effort.test.ts
+++ b/src/features/chat/reasoning-effort.test.ts
@@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest";
import {
DEFAULT_REASONING_EFFORT,
effectiveEffort,
+ effectiveSelection,
effortOptions,
isReasoningEffort,
+ isThinkingSelection,
REASONING_EFFORT_LEVELS,
+ selectionOptions,
} from "./reasoning-effort";
describe("reasoning-effort helpers", () => {
@@ -43,3 +46,39 @@ describe("reasoning-effort helpers", () => {
}
});
});
+
+describe("thinking selection (the separate on/off axis)", () => {
+ it("selectionOptions lists 'off' first, then the ladder (default marked)", () => {
+ const options = selectionOptions();
+ expect(options).toHaveLength(1 + REASONING_EFFORT_LEVELS.length);
+ expect(options[0]?.value).toBe("off");
+ expect(options[0]?.label).toBe("Off");
+ // the rest are the ladder, unchanged from effortOptions()
+ expect(options.slice(1).map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]);
+ expect(options.find((o) => o.value === "high")?.label).toBe("high (default)");
+ });
+
+ it("isThinkingSelection narrows 'off' + ladder strings, rejects the rest", () => {
+ expect(isThinkingSelection("off")).toBe(true);
+ for (const level of REASONING_EFFORT_LEVELS) {
+ expect(isThinkingSelection(level)).toBe(true);
+ }
+ expect(isThinkingSelection("banana")).toBe(false);
+ expect(isThinkingSelection("")).toBe(false);
+ expect(isThinkingSelection("OFF")).toBe(false);
+ expect(isThinkingSelection("none")).toBe(false); // NOT a wire value we send
+ });
+
+ it("effectiveSelection shows 'off' when thinking is explicitly disabled", () => {
+ // thinking off is a SEPARATE axis: the effort level is irrelevant while off.
+ expect(effectiveSelection("xhigh", false)).toBe("off");
+ expect(effectiveSelection(null, false)).toBe("off");
+ });
+
+ it("effectiveSelection shows the effort level when thinking is on (default)", () => {
+ // null thinking = never set ⇒ thinking ON (default) ⇒ show the effort level.
+ expect(effectiveSelection(null, null)).toBe("high"); // default effort
+ expect(effectiveSelection("low", null)).toBe("low");
+ expect(effectiveSelection("max", true)).toBe("max"); // explicitly on
+ });
+});
diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts
index 1eb77b6..39e1c5a 100644
--- a/src/features/chat/reasoning-effort.ts
+++ b/src/features/chat/reasoning-effort.ts
@@ -36,7 +36,7 @@ export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEff
return persisted ?? DEFAULT_REASONING_EFFORT;
}
-/** One `<option>` of the selector. */
+/** One `<option>` of the effort ladder. */
export interface EffortOption {
readonly value: ReasoningEffort;
readonly label: string;
@@ -64,3 +64,99 @@ export type ReasoningEffortSaveResult =
export type SaveReasoningEffort = (
level: ReasoningEffort,
) => Promise<ReasoningEffortSaveResult | null>;
+
+// ── Thinking on/off (a SEPARATE axis from the effort level) ─────────────────
+//
+// Per the umans API (and the user's mental model), "thinking off" is NOT a
+// zero-effort level — it is a distinct "disable extended thinking entirely"
+// signal. The umans route expresses it as `reasoning_effort: "none"`; Dispatch
+// surfaces it as a SEPARATE per-conversation boolean so the effort LEVEL is
+// preserved across an off→on toggle (turning thinking back on restores the
+// previously-chosen depth). The per-conversation selector CONFLATES the two
+// axes into one `<select>` (UX), but the WIRE keeps them separate.
+//
+// ⚠️ BACKEND CONTRACT GAP — see `backend-handoff.md`. The `thinking` endpoint +
+// wire types below are the PROPOSED shape; the backend has NOT shipped them yet.
+// They are defined FE-local (mirroring the shipped `ReasoningEffortResponse` /
+// `SetReasoningEffortRequest` shape) so the FE is built + tested against the
+// target contract. Re-pin + re-mirror once the backend ships them.
+
+/**
+ * Response of `GET /conversations/:id/thinking` (PROPOSED). `thinking` is null
+ * when never set (the server then resolves turns with thinking ON — the
+ * default), `false` when explicitly disabled, `true` when explicitly enabled.
+ */
+export interface ThinkingResponse {
+ readonly conversationId: string;
+ readonly thinking: boolean | null;
+}
+
+/** Body of `PUT /conversations/:id/thinking` (PROPOSED). */
+export interface SetThinkingRequest {
+ readonly thinking: boolean;
+}
+
+/**
+ * The per-conversation selector's value: `"off"` (thinking disabled — the
+ * separate axis) or a reasoning-effort LEVEL. NOT a widened ladder: `"off"` is
+ * not a degree of effort, it is the absence of thinking.
+ */
+export type ThinkingSelection = "off" | ReasoningEffort;
+
+/** One `<option>` of the combined selector (off or a level). */
+export interface SelectionOption {
+ readonly value: ThinkingSelection;
+ readonly label: string;
+}
+
+/**
+ * The selector's options: `"off"` first (the separate disable signal), then the
+ * effort ladder with the server default marked `(default)`. A never-set
+ * conversation (thinking on, effort null) reads "high (default)".
+ */
+export function selectionOptions(): readonly SelectionOption[] {
+ return [{ value: "off", label: "Off" }, ...effortOptions()];
+}
+
+/** Narrow an untrusted `<select>` value to a {@link ThinkingSelection}. */
+export function isThinkingSelection(value: string): value is ThinkingSelection {
+ return value === "off" || isReasoningEffort(value);
+}
+
+/**
+ * The selection the per-conversation selector should show as selected: `"off"`
+ * when thinking is explicitly disabled (`persistedThinking === false`), else the
+ * effective effort level. `persistedThinking === null` (never set) ⇒ thinking
+ * ON (the default) ⇒ the effort level is shown — NOT "off".
+ */
+export function effectiveSelection(
+ persistedEffort: ReasoningEffort | null,
+ persistedThinking: boolean | null,
+): ThinkingSelection {
+ if (persistedThinking === false) return "off";
+ return effectiveEffort(persistedEffort);
+}
+
+// ── Injected port for the combined selector (off OR a level) ─────────────────
+
+/** Outcome of persisting a {@link ThinkingSelection} (one or two PUTs). */
+export type ThinkingSelectionSaveResult =
+ | { readonly ok: true; readonly selection: ThinkingSelection }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Persist a thinking selection (consumer-defines-port; the composition root
+ * adapts the store's `PUT .../thinking` + `PUT .../reasoning-effort` to this).
+ * - `"off"` → disable thinking (the separate signal); the effort level is left
+ * untouched so an off→on toggle restores it.
+ * - a level → set the effort level AND ensure thinking is ON (the level is
+ * meaningless while thinking is off).
+ */
+export type SaveThinkingSelection = (
+ selection: ThinkingSelection,
+) => Promise<ThinkingSelectionSaveResult | null>;
+
+/** Outcome of `PUT /conversations/:id/thinking`. */
+export type ThinkingSaveResult =
+ | { readonly ok: true; readonly thinking: boolean }
+ | { readonly ok: false; readonly error: string };
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();
});
diff --git a/src/features/chat/ui/ReasoningEffortSelector.svelte b/src/features/chat/ui/ReasoningEffortSelector.svelte
index d982905..6858779 100644
--- a/src/features/chat/ui/ReasoningEffortSelector.svelte
+++ b/src/features/chat/ui/ReasoningEffortSelector.svelte
@@ -1,34 +1,45 @@
<script lang="ts">
import type { ReasoningEffort } from "@dispatch/transport-contract";
import {
- effectiveEffort,
- effortOptions,
- isReasoningEffort,
- type SaveReasoningEffort,
+ effectiveSelection,
+ isThinkingSelection,
+ selectionOptions,
+ type SaveThinkingSelection,
+ type ThinkingSelection,
} from "../reasoning-effort";
let {
- persisted,
+ persistedEffort,
+ persistedThinking,
save,
}: {
- /** The conversation's persisted level, or null when never set (default applies). */
- persisted: ReasoningEffort | null;
- save: SaveReasoningEffort;
+ /** The conversation's persisted effort level, or null when never set (default applies). */
+ persistedEffort: ReasoningEffort | null;
+ /**
+ * The conversation's persisted thinking flag, or null when never set
+ * (thinking ON — the default). `false` ⇒ thinking disabled (the separate
+ * "off" axis); the effort level is preserved across an off→on toggle.
+ */
+ persistedThinking: boolean | null;
+ /** Persist a thinking selection (off or a level). */
+ save: SaveThinkingSelection;
} = $props();
- const options = effortOptions();
+ const options = selectionOptions();
- // The user's in-flight choice; null = mirror the (async-loaded) persisted prop.
- // Re-mounted per conversation, so there is no cross-tab bleed.
- let chosen = $state<ReasoningEffort | null>(null);
+ // The user's in-flight choice; null = mirror the (async-loaded) persisted
+ // selection. Re-mounted per conversation, so there is no cross-tab bleed.
+ let chosen = $state<ThinkingSelection | null>(null);
let saving = $state(false);
let error = $state<string | null>(null);
let justSaved = $state(false);
- const selected = $derived(chosen ?? effectiveEffort(persisted));
+ const selected = $derived(
+ chosen ?? effectiveSelection(persistedEffort, persistedThinking),
+ );
async function handleChange(value: string) {
- if (!isReasoningEffort(value) || saving) return;
+ if (!isThinkingSelection(value) || saving) return;
chosen = value;
saving = true;
error = null;
@@ -40,7 +51,7 @@
justSaved = true;
} else {
error = result.error;
- chosen = null; // revert to the persisted value
+ chosen = null; // revert to the persisted selection
}
}
</script>
@@ -69,7 +80,7 @@
<p class="text-xs text-success">Saved — applies from the next turn.</p>
{:else}
<p class="text-xs opacity-50">
- How long the model thinks before answering. Changing it can re-prefill the prompt cache once.
+ How long the model thinks before answering. “Off” disables thinking entirely. Changing it can re-prefill the prompt cache once.
</p>
{/if}
</div>