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 | c1082294dd98cadfecda58d854174610659cadec (patch) | |
| tree | 00dbc2ebd40260d1ae82820d56f9ab8f62cf343b | |
| parent | 1cdb866fbb708a1f6c5e802a075789cece85800d (diff) | |
| download | dispatch-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.md | 95 | ||||
| -rw-r--r-- | src/app/App.svelte | 44 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 89 | ||||
| -rw-r--r-- | src/app/store.test.ts | 94 | ||||
| -rw-r--r-- | src/features/chat/index.ts | 10 | ||||
| -rw-r--r-- | src/features/chat/reasoning-effort.test.ts | 39 | ||||
| -rw-r--r-- | src/features/chat/reasoning-effort.ts | 98 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 79 | ||||
| -rw-r--r-- | src/features/chat/ui/ReasoningEffortSelector.svelte | 43 |
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> |
