diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/App.svelte | 54 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 107 | ||||
| -rw-r--r-- | src/app/store.test.ts | 95 |
3 files changed, 246 insertions, 10 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index 7e5ac7d..f0cd7ec 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import type { ImageInput, ReasoningEffort } from "@dispatch/transport-contract"; + import type { ImageInput } from "@dispatch/transport-contract"; import type { InvokeMessage } from "@dispatch/ui-contract"; import { tick } from "svelte"; import Table from "../components/Table.svelte"; @@ -17,8 +17,9 @@ ReasoningEffortSelector, type CompactNowResult, type ComposerStatus, - type ReasoningEffortSaveResult, type SaveCompactPercentResult, + type ThinkingSelection, + type ThinkingSelectionSaveResult, } from "../features/chat"; import { manifest as conversationCacheManifest } from "../features/conversation-cache"; import { manifest as markdownManifest } from "../features/markdown"; @@ -296,6 +297,10 @@ store.queueMessage(text); } + function handleCancelQueuedMessage(messageId: string) { + store.cancelQueuedMessage(messageId); + } + function handleStop() { store.stopGeneration(); } @@ -317,14 +322,35 @@ : { 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); + // Adapt the store's reasoning-effort + thinking results to the chat + // feature's combined selector port. The selector sends ONE selection ("off" + // or a level); the adapter fans it out to the right per-axis PUT(s). "off" is + // a SEPARATE signal from the effort level: it persists `thinking: false` + // (the umans route maps that to `reasoning_effort: "none"`), leaving the + // effort level untouched so an off→on toggle restores it. A level ensures + // thinking is ON (the level is meaningless while thinking is off) then sets + // the effort level. + async function saveThinkingSelection( + selection: ThinkingSelection, + ): Promise<ThinkingSelectionSaveResult | null> { + if (selection === "off") { + const result = await store.setThinking(false); + if (result === null) return null; + return result.ok + ? { ok: true, selection: "off" } + : { ok: false, error: result.error }; + } + // A level: enable thinking first if it is currently off, then set the level. + if (store.thinking === false) { + const on = await store.setThinking(true); + if (on !== null && !on.ok) { + return { ok: false, error: on.error }; + } + } + const result = await store.setReasoningEffort(selection); if (result === null) return null; return result.ok - ? { ok: true, reasoningEffort: result.reasoningEffort } + ? { ok: true, selection: result.reasoningEffort } : { ok: false, error: result.error }; } @@ -625,7 +651,11 @@ the generic SurfaceView (dispatches on rendererId, never surface id); only shown when the queue is non-empty — an idle queue is hidden. --> <div class="px-4 pt-2"> - <SurfaceView spec={messageQueueSpec} onInvoke={handleInvoke} /> + <SurfaceView + spec={messageQueueSpec} + onInvoke={handleInvoke} + onCancelQueuedMessage={handleCancelQueuedMessage} + /> </div> {/if} @@ -728,7 +758,11 @@ 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} /> + <ReasoningEffortSelector + persistedEffort={store.reasoningEffort} + persistedThinking={store.thinking} + save={saveThinkingSelection} + /> <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} /> <ComputerField computerId={store.computerId} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 22b0a25..629e6c6 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -54,6 +54,7 @@ import { } from "../core/protocol"; import type { ChatStore, HistorySync, MetricsSync } from "../features/chat"; import { createChatStore } from "../features/chat"; +import type { SetThinkingRequest, ThinkingResponse } from "../features/chat/reasoning-effort"; import type { ConcurrencyCooldownResult, ConcurrencyDeleteResult, @@ -132,6 +133,14 @@ export type ReasoningEffortResult = | { readonly ok: true; readonly reasoningEffort: ReasoningEffort } | { readonly ok: false; readonly error: string }; +/** + * Outcome of `PUT /conversations/:id/thinking` (PROPOSED — see + * `backend-handoff.md`; the endpoint is not yet shipped by the backend). + */ +export type ThinkingResult = + | { readonly ok: true; readonly thinking: boolean } + | { readonly ok: false; readonly error: string }; + /** Outcome of `POST /conversations/:id/compact` (manual compaction). */ export type CompactResult = | { readonly ok: true; readonly response: CompactResponse } @@ -206,6 +215,15 @@ export interface AppStore { * wants to add input — the server owns the idle-vs-generating decision. */ queueMessage(text: string): void; + /** + * Cancel (remove) a single queued steering message by id so it never runs + * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: the + * message-queue surface update reconciles the queue UI (the cancelled message + * leaves the snapshot). Targets the focused conversation's queue. The caller + * optimistically hides the row; a cancel of an already-drained / unknown + * message is a silent server no-op (nothing to roll back). + */ + cancelQueuedMessage(messageId: string): void; selectModel(model: string): void; newDraft(): void; /** Switch the active workspace (on route change) + reset to a fresh draft in it. */ @@ -273,6 +291,22 @@ export interface AppStore { */ setReasoningEffort(level: ReasoningEffort): Promise<ReasoningEffortResult | null>; /** + * The workspace conversation's persisted thinking flag, or null when never + * set (the server then resolves turns with thinking ON — the default). + * `false` ⇒ thinking disabled entirely (the SEPARATE "off" axis — NOT a + * zero-effort level; the umans route maps it to `reasoning_effort: "none"`). + * PROPOSED backend contract — see `backend-handoff.md`. + */ + readonly thinking: boolean | null; + /** + * Persist the workspace conversation's thinking flag + * (`PUT /conversations/:id/thinking`). Works for a draft too (its id survives + * promotion), so the first turn already runs with the chosen setting. Takes + * effect from the NEXT turn; resolution stays server-owned. + * PROPOSED backend contract — see `backend-handoff.md`. + */ + setThinking(enabled: boolean): Promise<ThinkingResult | null>; + /** * Manually trigger conversation compaction (`POST /conversations/:id/compact`). * Summarizes old messages + retains the most recent N. Returns null when no * conversation is focused (a draft has nothing to compact). @@ -729,6 +763,30 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } } + // The workspace conversation's persisted thinking flag (SEPARATE from the + // effort level). Seeded from the backend on focus change; null = never set + // (thinking ON — the default). PROPOSED endpoint (see backend-handoff.md): + // a 404 (endpoint not yet shipped) leaves `thinking` null ⇒ ON (default), so + // the selector simply shows the effort level until the backend ships it. + let thinking = $state<boolean | null>(null); + + /** Refetch the workspace conversation's thinking flag (works for a draft too). */ + async function refreshThinking(): Promise<void> { + const id = workspaceConversationId(); + // Clear immediately so a switch never shows the PREVIOUS conversation's + // setting while the fetch is in flight (null ⇒ ON, the default). + thinking = null; + try { + const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/thinking`); + if (!res.ok) return; + const data = (await res.json()) as ThinkingResponse; + // Guard a slow response losing a race with a conversation switch. + if (workspaceConversationId() === id) thinking = data.thinking ?? null; + } catch (err) { + reportError("Failed to load thinking setting", err); + } + } + // The workspace conversation's auto-compact percent. Seeded from the // backend on focus change; null = not yet fetched. 0 = disabled. let compactPercent = $state<number | null>(null); @@ -975,6 +1033,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { void refreshCwd(); void refreshComputer(); void refreshReasoningEffort(); + void refreshThinking(); void refreshCompactPercent(); } @@ -1208,6 +1267,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { void refreshComputer(); void refreshModel(); void refreshReasoningEffort(); + void refreshThinking(); void refreshCompactPercent(); void refreshVisionSettings(); @@ -1243,6 +1303,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { void refreshComputer(); void refreshModel(); void refreshReasoningEffort(); + void refreshThinking(); void refreshCompactPercent(); }, get activeChat(): ChatStore { @@ -1286,6 +1347,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get reasoningEffort(): ReasoningEffort | null { return reasoningEffort; }, + get thinking(): boolean | null { + return thinking; + }, get compactPercent(): number | null { return compactPercent; }, @@ -1340,6 +1404,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { void refreshCwd(); void refreshComputer(); void refreshReasoningEffort(); + void refreshThinking(); void refreshCompactPercent(); // Now send on the promoted store chatStores.get(conversationId)?.send(text, images); @@ -1357,6 +1422,15 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { activeChat.queueMessage(text); }, + cancelQueuedMessage(messageId: string): void { + // Fire-and-forget + idempotent. The message-queue surface (conversation- + // scoped) reconciles the queue UI — the cancelled row leaves the snapshot. + // A cancel of an already-drained / unknown message is a silent no-op, so + // there is no local state to roll back. Delegates to the focused + // conversation's chat store, which owns the conversationId + transport. + activeChat.cancelQueuedMessage(messageId); + }, + selectModel(model: string): void { activeModel = model; const activeId = tabsStore.activeConversationId; @@ -1386,6 +1460,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { void refreshComputer(); void refreshModel(); void refreshReasoningEffort(); + void refreshThinking(); void refreshCompactPercent(); }, @@ -1401,6 +1476,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { void refreshComputer(); void refreshModel(); void refreshReasoningEffort(); + void refreshThinking(); void refreshCompactPercent(); }, @@ -1587,6 +1663,37 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } }, + async setThinking(enabled: boolean): Promise<ThinkingResult | null> { + const id = workspaceConversationId(); + const body: SetThinkingRequest = { thinking: enabled }; + try { + const res = await fetchImpl( + `${httpBase}/conversations/${encodeURIComponent(id)}/thinking`, + { + 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 thinking failed (HTTP ${res.status})`, + }; + } + const data = (await res.json()) as ThinkingResponse; + const next = data.thinking ?? enabled; + if (workspaceConversationId() === id) thinking = next; + return { ok: true, thinking: next }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set thinking request failed", + }; + } + }, + stopGeneration(): void { const conversationId = tabsStore.activeConversationId; if (conversationId === null) return; diff --git a/src/app/store.test.ts b/src/app/store.test.ts index a945ea7..47c3977 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -891,6 +891,100 @@ describe("createAppStore", () => { store.dispose(); }); + it("seeds thinking from GET /conversations/:id/thinking (null = never set ⇒ ON)", 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("/thinking")) { + return new Response(JSON.stringify({ conversationId: "x", thinking: false }), { + status: 200, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + await vi.waitFor(() => { + expect(store.thinking).toBe(false); + }); + + store.dispose(); + }); + + it("treats a missing thinking endpoint (404) as 'never set' (null ⇒ ON)", async () => { + // The thinking endpoint is PROPOSED (backend-handoff.md); until the backend + // ships it, GET 404s and `thinking` stays null ⇒ the selector shows the + // effort level (thinking ON, the default) — graceful, never a crash. + 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("/thinking")) { + return new Response("not found", { status: 404 }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // give the (404ing) fetch a tick to settle + await vi.waitFor(() => { + expect(store.reasoningEffort).not.toBe(undefined); + }); + expect(store.thinking).toBeNull(); + + store.dispose(); + }); + + it("setThinking PUTs the flag 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("/thinking") && init?.method === "PUT") { + const sent = JSON.parse(init.body as string) as { thinking: boolean }; + return new Response(JSON.stringify({ conversationId: "x", thinking: sent.thinking }), { + status: 200, + }); + } + if (url.endsWith("/thinking")) { + return new Response(JSON.stringify({ conversationId: "x", thinking: null }), { + status: 200, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + const result = await store.setThinking(false); + expect(result).toEqual({ ok: true, thinking: false }); + expect(store.thinking).toBe(false); + + const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/thinking")); + expect(put).toBeDefined(); + expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`); + expect(JSON.parse(put?.body ?? "{}")).toEqual({ thinking: false }); + + store.dispose(); + }); + it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => { const ws = fakeSocket(); const store = createAppStore({ @@ -1223,6 +1317,7 @@ describe("createAppStore", () => { if (!result.ok) throw new Error("unreachable"); expect(result.config).toEqual({ enabled: true, + inactiveOnly: true, systemPrompt: "sys", taskPrompt: "task", intervalMinutes: 15, |
