diff options
| author | Adam Malczewski <[email protected]> | 2026-06-12 20:38:57 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-12 20:38:57 +0900 |
| commit | baa6f6c9d21de2f6ffc60e00f53c61d026155933 (patch) | |
| tree | fecae91d99d906a7b5054b398e4d3d90894567a0 /src/features/chat | |
| parent | 7dcc06eecb5b691b0c0daec26db9d5e407d0a60e (diff) | |
| download | dispatch-web-baa6f6c9d21de2f6ffc60e00f53c61d026155933.tar.gz dispatch-web-baa6f6c9d21de2f6ffc60e00f53c61d026155933.zip | |
feat(chat): reasoning-effort selector — sticky per-conversation thinking-depth knob
Consume the backend's reasoning-effort handoff ([email protected] ReasoningEffort +
[email protected] GET/PUT /conversations/:id/reasoning-effort,
ChatRequest.reasoningEffort): a 5-level selector in the sidebar Model view,
under the provider + model dropdowns. null renders as 'high (default)' per
the server-owned resolution chain; PUT on change (effective next turn);
error + revert on 400; per-conversation re-mount incl. drafts (the draft id
survives promotion, so an effort set on a draft applies from turn 1).
Re-mirrored .dispatch references; GLOSSARY 'reasoning effort'; handoff
updated. 616 tests green; live curl probe passed.
Diffstat (limited to 'src/features/chat')
| -rw-r--r-- | src/features/chat/index.ts | 13 | ||||
| -rw-r--r-- | src/features/chat/reasoning-effort.test.ts | 45 | ||||
| -rw-r--r-- | src/features/chat/reasoning-effort.ts | 66 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 74 | ||||
| -rw-r--r-- | src/features/chat/ui/ReasoningEffortSelector.svelte | 75 |
5 files changed, 273 insertions, 0 deletions
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index 139a64f..9b94392 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -2,11 +2,24 @@ export type { RenderedChunk, RenderGroup, ToolBatchEntry } from "../../core/chun export { groupRenderedChunks } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; +export type { + EffortOption, + ReasoningEffortSaveResult, + SaveReasoningEffort, +} from "./reasoning-effort"; +export { + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effortOptions, + isReasoningEffort, + REASONING_EFFORT_LEVELS, +} from "./reasoning-effort"; export type { ChatStore, ChatStoreDependencies } from "./store.svelte"; export { createChatStore } from "./store.svelte"; export { default as ChatView } from "./ui/ChatView.svelte"; export { default as Composer } from "./ui/Composer.svelte"; export { default as ModelSelector } from "./ui/ModelSelector.svelte"; +export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts new file mode 100644 index 0000000..8f76dea --- /dev/null +++ b/src/features/chat/reasoning-effort.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effortOptions, + isReasoningEffort, + REASONING_EFFORT_LEVELS, +} from "./reasoning-effort"; + +describe("reasoning-effort helpers", () => { + it("ladder matches the wire contract, in ascending depth order", () => { + expect(REASONING_EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]); + }); + + it("the server default is high", () => { + expect(DEFAULT_REASONING_EFFORT).toBe("high"); + }); + + it("isReasoningEffort narrows ladder strings and rejects everything else", () => { + for (const level of REASONING_EFFORT_LEVELS) { + expect(isReasoningEffort(level)).toBe(true); + } + expect(isReasoningEffort("banana")).toBe(false); + expect(isReasoningEffort("")).toBe(false); + expect(isReasoningEffort("HIGH")).toBe(false); + }); + + it("effectiveEffort maps null (never set) to the default, not 'off'", () => { + expect(effectiveEffort(null)).toBe("high"); + }); + + it("effectiveEffort passes a persisted value through", () => { + expect(effectiveEffort("xhigh")).toBe("xhigh"); + expect(effectiveEffort("low")).toBe("low"); + }); + + it("effortOptions lists every level once and marks only the default", () => { + const options = effortOptions(); + expect(options.map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]); + expect(options.find((o) => o.value === "high")?.label).toBe("high (default)"); + for (const option of options) { + if (option.value !== "high") expect(option.label).toBe(option.value); + } + }); +}); diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts new file mode 100644 index 0000000..2a55089 --- /dev/null +++ b/src/features/chat/reasoning-effort.ts @@ -0,0 +1,66 @@ +import type { ReasoningEffort } from "@dispatch/transport-contract"; + +/** + * Pure helpers for the reasoning-effort selector (the thinking-depth knob). + * + * The canonical ladder + resolution chain are SERVER-owned (`[email protected]` + * `ReasoningEffort`; per-turn override → persisted conversation value → default + * `"high"`). These helpers only shape the persisted value for display: a `null` + * from `GET /conversations/:id/reasoning-effort` means "never set ⇒ the default + * applies", so the selector shows `high (default)` — never "off". Zero DOM, + * zero Svelte. + */ + +/** The canonical ladder, in ascending thinking-depth order (`[email protected]`). */ +export const REASONING_EFFORT_LEVELS: readonly ReasoningEffort[] = [ + "low", + "medium", + "high", + "xhigh", + "max", +]; + +/** The server's fallback when nothing is set (the resolution chain's tail). */ +export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "high"; + +/** Narrow an untrusted string (e.g. a `<select>` value) to the ladder. */ +export function isReasoningEffort(value: string): value is ReasoningEffort { + return (REASONING_EFFORT_LEVELS as readonly string[]).includes(value); +} + +/** + * The level the selector should show as selected: the persisted value, or the + * server default when never set (`null` = "default applies", not "off"). + */ +export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEffort { + return persisted ?? DEFAULT_REASONING_EFFORT; +} + +/** One `<option>` of the selector. */ +export interface EffortOption { + readonly value: ReasoningEffort; + readonly label: string; +} + +/** + * The selector's options: every ladder level, with the server default marked + * `(default)` so a never-set conversation reads "high (default)". + */ +export function effortOptions(): readonly EffortOption[] { + return REASONING_EFFORT_LEVELS.map((level) => ({ + value: level, + label: level === DEFAULT_REASONING_EFFORT ? `${level} (default)` : level, + })); +} + +// ── Injected port (consumer-defines-port; the composition root adapts the +// store's `PUT /conversations/:id/reasoning-effort` to this shape). ──────── + +/** Outcome of `PUT /conversations/:id/reasoning-effort`. */ +export type ReasoningEffortSaveResult = + | { readonly ok: true; readonly reasoningEffort: ReasoningEffort } + | { readonly ok: false; readonly error: string }; + +export type SaveReasoningEffort = ( + level: ReasoningEffort, +) => Promise<ReasoningEffortSaveResult | null>; diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 7174821..e541015 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -7,6 +7,7 @@ import type { TurnMetricsEntry } from "../../core/metrics"; import ChatView from "./ui/ChatView.svelte"; import Composer from "./ui/Composer.svelte"; import ModelSelector from "./ui/ModelSelector.svelte"; +import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte"; describe("ChatView", () => { it("renders a message's text chunk", () => { @@ -695,3 +696,76 @@ describe("ModelSelector", () => { expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o"); }); }); + +describe("ReasoningEffortSelector", () => { + it("renders null (never set) as the default level, marked '(default)'", () => { + render(ReasoningEffortSelector, { props: { persisted: 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); + }); + + it("renders a persisted level as selected", () => { + render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } }); + + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh"); + }); + + it("selecting a level saves it via the injected port and confirms", async () => { + const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({ + ok: true as const, + reasoningEffort: level, + })); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { props: { persisted: null, save } }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); + + expect(save).toHaveBeenCalledTimes(1); + expect(save).toHaveBeenCalledWith("max"); + await vi.waitFor(() => { + expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument(); + }); + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max"); + }); + + it("a failed save shows the error and reverts to the persisted value", async () => { + const save = vi.fn(async () => ({ ok: false as const, error: "nope" })); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { props: { persisted: "low", save } }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); + + await vi.waitFor(() => { + expect(screen.getByText("nope")).toBeInTheDocument(); + }); + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("low"); + }); + + it("disables the select while a save is in flight (no double-fire)", async () => { + let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined; + const save = vi.fn( + () => + new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => { + resolveSave = resolve; + }), + ); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { props: { persisted: 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" }); + 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 new file mode 100644 index 0000000..8c7b193 --- /dev/null +++ b/src/features/chat/ui/ReasoningEffortSelector.svelte @@ -0,0 +1,75 @@ +<script lang="ts"> + import type { ReasoningEffort } from "@dispatch/transport-contract"; + import { + effectiveEffort, + effortOptions, + isReasoningEffort, + type SaveReasoningEffort, + } from "../reasoning-effort"; + + let { + persisted, + save, + }: { + /** The conversation's persisted level, or null when never set (default applies). */ + persisted: ReasoningEffort | null; + save: SaveReasoningEffort; + } = $props(); + + const options = effortOptions(); + + // 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); + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state(false); + + const selected = $derived(chosen ?? effectiveEffort(persisted)); + + async function handleChange(value: string) { + if (!isReasoningEffort(value) || saving) return; + chosen = value; + saving = true; + error = null; + justSaved = false; + const result = await save(value); + saving = false; + if (result === null) return; + if (result.ok) { + justSaved = true; + } else { + error = result.error; + chosen = null; // revert to the persisted value + } + } +</script> + +<div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span> + <div class="flex items-center gap-2"> + <select + class="select select-sm w-full" + value={selected} + disabled={saving} + onchange={(e) => handleChange(e.currentTarget.value)} + aria-label="Reasoning effort" + > + {#each options as option (option.value)} + <option value={option.value}>{option.label}</option> + {/each} + </select> + {#if saving} + <span class="loading loading-spinner loading-xs" aria-label="Saving reasoning effort"></span> + {/if} + </div> + {#if error} + <p class="text-xs text-error">{error}</p> + {:else if justSaved} + <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. + </p> + {/if} +</div> |
