diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/App.svelte | 21 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 82 | ||||
| -rw-r--r-- | src/app/store.test.ts | 97 | ||||
| -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 |
8 files changed, 470 insertions, 3 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index 4c5a82b..dffa937 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,4 +1,5 @@ <script lang="ts"> + import type { ReasoningEffort } from "@dispatch/transport-contract"; import type { InvokeMessage } from "@dispatch/ui-contract"; import { tick } from "svelte"; import Table from "../components/Table.svelte"; @@ -12,6 +13,8 @@ Composer, manifest as chatManifest, ModelSelector, + ReasoningEffortSelector, + type ReasoningEffortSaveResult, } from "../features/chat"; import { manifest as conversationCacheManifest } from "../features/conversation-cache"; import { manifest as markdownManifest } from "../features/markdown"; @@ -154,6 +157,17 @@ : { 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); + if (result === null) return null; + return result.ok + ? { ok: true, reasoningEffort: result.reasoningEffort } + : { ok: false, error: result.error }; + } + // Adapt the store's cwd/LSP results to the workspace feature's ports. async function saveCwd(cwd: string): Promise<CwdSaveResult | null> { const result = await store.setCwd(cwd); @@ -295,10 +309,11 @@ {#if kind === "model"} <div class="flex flex-col gap-3"> <ModelSelector models={store.models} selected={store.activeModel} onSelect={handleSelectModel} /> - <!-- Keyed on the workspace conversation (active tab OR draft) so the input - re-mounts per conversation — incl. switching between drafts — and can't - bleed across tabs. Editable for a draft too (cwd applies from turn 1). --> + <!-- Keyed on the workspace conversation (active tab OR draft) so the inputs + 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} /> <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} /> {/key} </div> diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 999f2be..05577a6 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -6,7 +6,10 @@ import type { CwdResponse, LspStatusResponse, ModelsResponse, + ReasoningEffort, + ReasoningEffortResponse, SetCwdRequest, + SetReasoningEffortRequest, WarmRequest, WarmResponse, } from "@dispatch/transport-contract"; @@ -52,6 +55,11 @@ export type LspResult = | { readonly ok: true; readonly response: LspStatusResponse } | { readonly ok: false; readonly error: string }; +/** Outcome of `PUT /conversations/:id/reasoning-effort`. */ +export type ReasoningEffortResult = + | { readonly ok: true; readonly reasoningEffort: ReasoningEffort } + | { readonly ok: false; readonly error: string }; + export interface AppStore { readonly tabs: readonly Tab[]; readonly activeConversationId: string | null; @@ -85,6 +93,18 @@ export interface AppStore { */ setCwd(cwd: string): Promise<CwdResult | null>; /** + * The workspace conversation's persisted reasoning effort, or null when never + * set (the server then resolves turns at the default, `"high"`). + */ + readonly reasoningEffort: ReasoningEffort | null; + /** + * Persist the workspace conversation's reasoning effort + * (`PUT /conversations/:id/reasoning-effort`). Works for a draft too (its id + * survives promotion), so the first turn already runs at the chosen level. + * Takes effect from the NEXT turn; resolution stays server-owned. + */ + setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null>; + /** * Fetch the workspace conversation's language-server status (`GET /conversations/:id/lsp`). * The backend lazily spawns servers, so this may take a moment on the first call for a cwd. */ @@ -234,6 +254,29 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } } + // The workspace conversation's persisted reasoning effort. Seeded from the + // backend on focus change; null = never set (the server default applies). + let reasoningEffort = $state<ReasoningEffort | null>(null); + + /** Refetch the workspace conversation's reasoning effort (works for a draft too). */ + async function refreshReasoningEffort(): Promise<void> { + const id = workspaceConversationId(); + // Clear immediately so a switch never shows the PREVIOUS conversation's level + // while the fetch is in flight (null renders as the server default). + reasoningEffort = null; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`, + ); + if (!res.ok) return; + const data = (await res.json()) as ReasoningEffortResponse; + // Guard a slow response losing a race with a conversation switch. + if (workspaceConversationId() === id) reasoningEffort = data.reasoningEffort ?? null; + } catch { + // Non-fatal: an effort fetch failure just leaves the default rendering. + } + } + function getActiveChat(): ChatStore { const activeId = tabsStore.activeConversationId; if (activeId === null) { @@ -434,6 +477,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { refreshActiveChat(); void refreshCwd(); + void refreshReasoningEffort(); return { get tabs(): readonly Tab[] { @@ -468,6 +512,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get cwd(): string | null { return cwd; }, + get reasoningEffort(): ReasoningEffort | null { + return reasoningEffort; + }, get currentConversationId(): string { return workspaceConversationId(); }, @@ -499,6 +546,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // surfaces (e.g. cache-warming) to its id. syncSubscriptions(); void refreshCwd(); + void refreshReasoningEffort(); // Now send on the promoted store chatStores.get(conversationId)?.send(text); } else { @@ -525,6 +573,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { refreshActiveChat(); syncSubscriptions(); void refreshCwd(); + void refreshReasoningEffort(); }, selectTab(conversationId: string): void { @@ -536,6 +585,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { refreshActiveChat(); syncSubscriptions(); void refreshCwd(); + void refreshReasoningEffort(); }, closeTab(conversationId: string): void { @@ -554,6 +604,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { refreshActiveChat(); syncSubscriptions(); void refreshCwd(); + void refreshReasoningEffort(); }, invoke(surfaceId: string, actionId: string, payload?: unknown): void { @@ -612,6 +663,37 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } }, + async setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null> { + const id = workspaceConversationId(); + const body: SetReasoningEffortRequest = { reasoningEffort: level }; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/reasoning-effort`, + { + 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 reasoning effort failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as ReasoningEffortResponse; + const next = data.reasoningEffort ?? level; + if (workspaceConversationId() === id) reasoningEffort = next; + return { ok: true, reasoningEffort: next }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set reasoning effort request failed", + }; + } + }, + async lspStatus(): Promise<LspResult | null> { const id = workspaceConversationId(); try { diff --git a/src/app/store.test.ts b/src/app/store.test.ts index f4b5a0f..db6fdaa 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -708,6 +708,103 @@ describe("createAppStore", () => { store.dispose(); }); + it("seeds reasoningEffort from GET /conversations/:id/reasoning-effort (null = never set)", 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("/reasoning-effort")) { + return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: "xhigh" }), { + status: 200, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + await vi.waitFor(() => { + expect(store.reasoningEffort).toBe("xhigh"); + }); + + store.dispose(); + }); + + it("setReasoningEffort PUTs the level 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("/reasoning-effort") && init?.method === "PUT") { + const sent = JSON.parse(init.body as string) as { reasoningEffort: string }; + return new Response( + JSON.stringify({ conversationId: "x", reasoningEffort: sent.reasoningEffort }), + { status: 200 }, + ); + } + if (url.endsWith("/reasoning-effort")) { + return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), { + status: 200, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.setReasoningEffort("max"); + expect(result).toEqual({ ok: true, reasoningEffort: "max" }); + expect(store.reasoningEffort).toBe("max"); + + const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/reasoning-effort")); + expect(put).toBeDefined(); + // The PUT targets the workspace conversation (draft id works too) and + // carries exactly the SetReasoningEffortRequest body. + expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`); + expect(JSON.parse(put?.body ?? "{}")).toEqual({ reasoningEffort: "max" }); + + store.dispose(); + }); + + it("setReasoningEffort surfaces a 400 error and leaves state unchanged", 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("/reasoning-effort") && init?.method === "PUT") { + return new Response(JSON.stringify({ error: "bad level" }), { status: 400 }); + } + if (url.endsWith("/reasoning-effort")) { + return new Response(JSON.stringify({ conversationId: "x", reasoningEffort: null }), { + status: 200, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.setReasoningEffort("max"); + expect(result).toEqual({ ok: false, error: "bad level" }); + expect(store.reasoningEffort).toBeNull(); + + 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 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> |
