summaryrefslogtreecommitdiffhomepage
path: root/backend-handoff.md
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
commit901bd56228ecb105bdbbf18bb9f848192f134791 (patch)
treed25ea66950c729387aa92a5e019f7c704a980dbe /backend-handoff.md
parent89956f09a45db8bdd302f49d4028c0acc92a58a2 (diff)
downloaddispatch-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 'backend-handoff.md')
-rw-r--r--backend-handoff.md95
1 files changed, 94 insertions, 1 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