summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-12 20:38:57 +0900
committerAdam Malczewski <[email protected]>2026-06-12 20:38:57 +0900
commitbaa6f6c9d21de2f6ffc60e00f53c61d026155933 (patch)
treefecae91d99d906a7b5054b398e4d3d90894567a0 /src/features
parent7dcc06eecb5b691b0c0daec26db9d5e407d0a60e (diff)
downloaddispatch-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')
-rw-r--r--src/features/chat/index.ts13
-rw-r--r--src/features/chat/reasoning-effort.test.ts45
-rw-r--r--src/features/chat/reasoning-effort.ts66
-rw-r--r--src/features/chat/ui.test.ts74
-rw-r--r--src/features/chat/ui/ReasoningEffortSelector.svelte75
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>