summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/App.svelte54
-rw-r--r--src/app/store.svelte.ts107
-rw-r--r--src/app/store.test.ts95
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,