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/app | |
| 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/app')
| -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 |
3 files changed, 197 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({ |
