summaryrefslogtreecommitdiffhomepage
path: root/src/app
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/app
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/app')
-rw-r--r--src/app/App.svelte21
-rw-r--r--src/app/store.svelte.ts82
-rw-r--r--src/app/store.test.ts97
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({