summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/chat')
-rw-r--r--src/features/chat/index.ts10
-rw-r--r--src/features/chat/ports.ts11
-rw-r--r--src/features/chat/reasoning-effort.test.ts39
-rw-r--r--src/features/chat/reasoning-effort.ts98
-rw-r--r--src/features/chat/store.svelte.ts27
-rw-r--r--src/features/chat/store.test.ts72
-rw-r--r--src/features/chat/test-helpers.ts12
-rw-r--r--src/features/chat/ui.test.ts79
-rw-r--r--src/features/chat/ui/ReasoningEffortSelector.svelte43
9 files changed, 354 insertions, 37 deletions
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index cf57cea..2f98a0e 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -12,13 +12,23 @@ export type {
EffortOption,
ReasoningEffortSaveResult,
SaveReasoningEffort,
+ SaveThinkingSelection,
+ SelectionOption,
+ SetThinkingRequest,
+ ThinkingResponse,
+ ThinkingSaveResult,
+ ThinkingSelection,
+ ThinkingSelectionSaveResult,
} from "./reasoning-effort";
export {
DEFAULT_REASONING_EFFORT,
effectiveEffort,
+ effectiveSelection,
effortOptions,
isReasoningEffort,
+ isThinkingSelection,
REASONING_EFFORT_LEVELS,
+ selectionOptions,
} from "./reasoning-effort";
export type { ChatStore, ChatStoreDependencies } from "./store.svelte";
export { createChatStore } from "./store.svelte";
diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts
index 2fe10dc..53ac236 100644
--- a/src/features/chat/ports.ts
+++ b/src/features/chat/ports.ts
@@ -1,4 +1,5 @@
import type {
+ ChatQueueCancelMessage,
ChatQueueMessage,
ChatSendMessage,
ConversationHistoryResponse,
@@ -6,12 +7,14 @@ import type {
} from "@dispatch/transport-contract";
/**
- * Injected transport port — sends chat messages to the server. Accepts both
- * `chat.send` (start a turn) and `chat.queue` (enqueue a steering message;
- * auto-starts a turn if idle).
+ * Injected transport port — sends chat messages to the server. Accepts
+ * `chat.send` (start a turn), `chat.queue` (enqueue a steering message;
+ * auto-starts a turn if idle), and `chat.queue.cancel` (remove a single queued
+ * message by id so it never runs — fire-and-forget, idempotent; the
+ * message-queue surface confirms the removal).
*/
export interface ChatTransport {
- send(msg: ChatSendMessage | ChatQueueMessage): void;
+ send(msg: ChatSendMessage | ChatQueueMessage | ChatQueueCancelMessage): void;
}
/**
diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts
index 6d409e9..e870bac 100644
--- a/src/features/chat/reasoning-effort.test.ts
+++ b/src/features/chat/reasoning-effort.test.ts
@@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest";
import {
DEFAULT_REASONING_EFFORT,
effectiveEffort,
+ effectiveSelection,
effortOptions,
isReasoningEffort,
+ isThinkingSelection,
REASONING_EFFORT_LEVELS,
+ selectionOptions,
} from "./reasoning-effort";
describe("reasoning-effort helpers", () => {
@@ -43,3 +46,39 @@ describe("reasoning-effort helpers", () => {
}
});
});
+
+describe("thinking selection (the separate on/off axis)", () => {
+ it("selectionOptions lists 'off' first, then the ladder (default marked)", () => {
+ const options = selectionOptions();
+ expect(options).toHaveLength(1 + REASONING_EFFORT_LEVELS.length);
+ expect(options[0]?.value).toBe("off");
+ expect(options[0]?.label).toBe("Off");
+ // the rest are the ladder, unchanged from effortOptions()
+ expect(options.slice(1).map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]);
+ expect(options.find((o) => o.value === "high")?.label).toBe("high (default)");
+ });
+
+ it("isThinkingSelection narrows 'off' + ladder strings, rejects the rest", () => {
+ expect(isThinkingSelection("off")).toBe(true);
+ for (const level of REASONING_EFFORT_LEVELS) {
+ expect(isThinkingSelection(level)).toBe(true);
+ }
+ expect(isThinkingSelection("banana")).toBe(false);
+ expect(isThinkingSelection("")).toBe(false);
+ expect(isThinkingSelection("OFF")).toBe(false);
+ expect(isThinkingSelection("none")).toBe(false); // NOT a wire value we send
+ });
+
+ it("effectiveSelection shows 'off' when thinking is explicitly disabled", () => {
+ // thinking off is a SEPARATE axis: the effort level is irrelevant while off.
+ expect(effectiveSelection("xhigh", false)).toBe("off");
+ expect(effectiveSelection(null, false)).toBe("off");
+ });
+
+ it("effectiveSelection shows the effort level when thinking is on (default)", () => {
+ // null thinking = never set ⇒ thinking ON (default) ⇒ show the effort level.
+ expect(effectiveSelection(null, null)).toBe("high"); // default effort
+ expect(effectiveSelection("low", null)).toBe("low");
+ expect(effectiveSelection("max", true)).toBe("max"); // explicitly on
+ });
+});
diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts
index 1eb77b6..39e1c5a 100644
--- a/src/features/chat/reasoning-effort.ts
+++ b/src/features/chat/reasoning-effort.ts
@@ -36,7 +36,7 @@ export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEff
return persisted ?? DEFAULT_REASONING_EFFORT;
}
-/** One `<option>` of the selector. */
+/** One `<option>` of the effort ladder. */
export interface EffortOption {
readonly value: ReasoningEffort;
readonly label: string;
@@ -64,3 +64,99 @@ export type ReasoningEffortSaveResult =
export type SaveReasoningEffort = (
level: ReasoningEffort,
) => Promise<ReasoningEffortSaveResult | null>;
+
+// ── Thinking on/off (a SEPARATE axis from the effort level) ─────────────────
+//
+// Per the umans API (and the user's mental model), "thinking off" is NOT a
+// zero-effort level — it is a distinct "disable extended thinking entirely"
+// signal. The umans route expresses it as `reasoning_effort: "none"`; Dispatch
+// surfaces it as a SEPARATE per-conversation boolean so the effort LEVEL is
+// preserved across an off→on toggle (turning thinking back on restores the
+// previously-chosen depth). The per-conversation selector CONFLATES the two
+// axes into one `<select>` (UX), but the WIRE keeps them separate.
+//
+// ⚠️ BACKEND CONTRACT GAP — see `backend-handoff.md`. The `thinking` endpoint +
+// wire types below are the PROPOSED shape; the backend has NOT shipped them yet.
+// They are defined FE-local (mirroring the shipped `ReasoningEffortResponse` /
+// `SetReasoningEffortRequest` shape) so the FE is built + tested against the
+// target contract. Re-pin + re-mirror once the backend ships them.
+
+/**
+ * Response of `GET /conversations/:id/thinking` (PROPOSED). `thinking` is null
+ * when never set (the server then resolves turns with thinking ON — the
+ * default), `false` when explicitly disabled, `true` when explicitly enabled.
+ */
+export interface ThinkingResponse {
+ readonly conversationId: string;
+ readonly thinking: boolean | null;
+}
+
+/** Body of `PUT /conversations/:id/thinking` (PROPOSED). */
+export interface SetThinkingRequest {
+ readonly thinking: boolean;
+}
+
+/**
+ * The per-conversation selector's value: `"off"` (thinking disabled — the
+ * separate axis) or a reasoning-effort LEVEL. NOT a widened ladder: `"off"` is
+ * not a degree of effort, it is the absence of thinking.
+ */
+export type ThinkingSelection = "off" | ReasoningEffort;
+
+/** One `<option>` of the combined selector (off or a level). */
+export interface SelectionOption {
+ readonly value: ThinkingSelection;
+ readonly label: string;
+}
+
+/**
+ * The selector's options: `"off"` first (the separate disable signal), then the
+ * effort ladder with the server default marked `(default)`. A never-set
+ * conversation (thinking on, effort null) reads "high (default)".
+ */
+export function selectionOptions(): readonly SelectionOption[] {
+ return [{ value: "off", label: "Off" }, ...effortOptions()];
+}
+
+/** Narrow an untrusted `<select>` value to a {@link ThinkingSelection}. */
+export function isThinkingSelection(value: string): value is ThinkingSelection {
+ return value === "off" || isReasoningEffort(value);
+}
+
+/**
+ * The selection the per-conversation selector should show as selected: `"off"`
+ * when thinking is explicitly disabled (`persistedThinking === false`), else the
+ * effective effort level. `persistedThinking === null` (never set) ⇒ thinking
+ * ON (the default) ⇒ the effort level is shown — NOT "off".
+ */
+export function effectiveSelection(
+ persistedEffort: ReasoningEffort | null,
+ persistedThinking: boolean | null,
+): ThinkingSelection {
+ if (persistedThinking === false) return "off";
+ return effectiveEffort(persistedEffort);
+}
+
+// ── Injected port for the combined selector (off OR a level) ─────────────────
+
+/** Outcome of persisting a {@link ThinkingSelection} (one or two PUTs). */
+export type ThinkingSelectionSaveResult =
+ | { readonly ok: true; readonly selection: ThinkingSelection }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Persist a thinking selection (consumer-defines-port; the composition root
+ * adapts the store's `PUT .../thinking` + `PUT .../reasoning-effort` to this).
+ * - `"off"` → disable thinking (the separate signal); the effort level is left
+ * untouched so an off→on toggle restores it.
+ * - a level → set the effort level AND ensure thinking is ON (the level is
+ * meaningless while thinking is off).
+ */
+export type SaveThinkingSelection = (
+ selection: ThinkingSelection,
+) => Promise<ThinkingSelectionSaveResult | null>;
+
+/** Outcome of `PUT /conversations/:id/thinking`. */
+export type ThinkingSaveResult =
+ | { readonly ok: true; readonly thinking: boolean }
+ | { readonly ok: false; readonly error: string };
diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts
index 9911438..24a1d06 100644
--- a/src/features/chat/store.svelte.ts
+++ b/src/features/chat/store.svelte.ts
@@ -1,6 +1,7 @@
import type {
ChatDeltaMessage,
ChatErrorMessage,
+ ChatQueueCancelMessage,
ChatQueueMessage,
ChatSendMessage,
} from "@dispatch/transport-contract";
@@ -131,6 +132,17 @@ export interface ChatStore {
* transcript. `text` must be non-empty (the server 400/errors otherwise).
*/
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: success is
+ * confirmed by the `message-queue` SURFACE updating (the cancelled message
+ * leaves the snapshot); a cancel of an already-drained / unknown message is a
+ * silent server-side no-op. The caller optimistically hides the row; the
+ * surface update reconciles. `messageId` is the stable `QueuedMessage.id`
+ * the queue surface snapshot carries. No transcript change — a cancelled
+ * message is never delivered as steering.
+ */
+ cancelQueuedMessage(messageId: string): void;
setModel(model: string): void;
/**
* Update the chat limit LIVE: re-normalizes, then adjusts the loaded window.
@@ -348,6 +360,21 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore {
deps.transport.send(msg);
},
+ cancelQueuedMessage(messageId: string): void {
+ // Fire-and-forget + idempotent (per the contract). The caller optimistically
+ // hides the row; the message-queue surface update reconciles. A cancel of
+ // an already-drained / unknown message is a silent server no-op, so there
+ // is no local-state change to make and nothing to roll back on a stray
+ // `chat.error` (which only fires for a malformed send — a client that
+ // sends the id it just rendered never hits it).
+ const msg: ChatQueueCancelMessage = {
+ type: "chat.queue.cancel",
+ conversationId: deps.conversationId,
+ messageId,
+ };
+ deps.transport.send(msg);
+ },
+
setModel(model: string): void {
_model = model;
},
diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts
index 8f36994..aa5560f 100644
--- a/src/features/chat/store.test.ts
+++ b/src/features/chat/store.test.ts
@@ -318,6 +318,78 @@ describe("createChatStore", () => {
});
});
+ describe("cancelQueuedMessage (chat.queue.cancel)", () => {
+ it("posts a chat.queue.cancel with conversationId + messageId", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.cancelQueuedMessage("msg-42");
+
+ expect(transport.sent).toHaveLength(0); // chat.send stays empty
+ expect(transport.sentQueue).toHaveLength(0); // chat.queue stays empty
+ expect(transport.sentCancels).toHaveLength(1);
+ expect(transport.sentCancels[0]?.type).toBe("chat.queue.cancel");
+ expect(transport.sentCancels[0]?.conversationId).toBe(CONV_ID);
+ expect(transport.sentCancels[0]?.messageId).toBe("msg-42");
+
+ store.dispose();
+ });
+
+ it("does NOT touch the transcript (a cancelled message never runs)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ store.cancelQueuedMessage("msg-42");
+
+ expect(store.chunks).toHaveLength(0); // no transcript echo / change
+ expect(store.error).toBeNull();
+
+ store.dispose();
+ });
+
+ it("sends for any messageId (cancel is idempotent server-side, no FE guard)", () => {
+ const transport = createFakeTransport();
+ const historySync = createFakeHistorySync();
+ const metricsSync = createFakeMetricsSync();
+ const cache = createFakeCache();
+ const store = createChatStore({
+ conversationId: CONV_ID,
+ transport: transport.impl,
+ historySync: historySync.impl,
+ metricsSync: metricsSync.impl,
+ cache: cache.impl,
+ });
+
+ // The server no-ops an already-drained / unknown id; the FE fires-and-
+ // forgetgets, so even a repeat cancel is forwarded.
+ store.cancelQueuedMessage("msg-42");
+ store.cancelQueuedMessage("msg-42");
+
+ expect(transport.sentCancels).toHaveLength(2);
+ expect(transport.sentCancels[1]?.messageId).toBe("msg-42");
+
+ store.dispose();
+ });
+ });
+
it("chat.error sets error", () => {
const transport = createFakeTransport();
const historySync = createFakeHistorySync();
diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts
index 26c5590..c99d1f4 100644
--- a/src/features/chat/test-helpers.ts
+++ b/src/features/chat/test-helpers.ts
@@ -1,4 +1,8 @@
-import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract";
+import type {
+ ChatQueueCancelMessage,
+ ChatQueueMessage,
+ ChatSendMessage,
+} from "@dispatch/transport-contract";
import type { StoredChunk } from "@dispatch/wire";
import type { ConversationCache } from "../conversation-cache";
import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
@@ -8,19 +12,25 @@ export interface FakeTransport {
readonly sent: ChatSendMessage[];
/** All `chat.queue` messages sent through the fake transport. */
readonly sentQueue: ChatQueueMessage[];
+ /** All `chat.queue.cancel` messages sent through the fake transport. */
+ readonly sentCancels: ChatQueueCancelMessage[];
readonly impl: ChatTransport;
}
export function createFakeTransport(): FakeTransport {
const sent: ChatSendMessage[] = [];
const sentQueue: ChatQueueMessage[] = [];
+ const sentCancels: ChatQueueCancelMessage[] = [];
return {
sent,
sentQueue,
+ sentCancels,
impl: {
send(msg) {
if (msg.type === "chat.queue") {
sentQueue.push(msg);
+ } else if (msg.type === "chat.queue.cancel") {
+ sentCancels.push(msg);
} else {
sent.push(msg);
}
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index 5f8067d..f4006f7 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -1092,30 +1092,53 @@ describe("ModelSelector", () => {
});
describe("ReasoningEffortSelector", () => {
- it("renders null (never set) as the default level, marked '(default)'", () => {
- render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } });
+ it("renders null effort + null thinking (never set) as the default level, marked '(default)'", () => {
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: null, persistedThinking: 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);
+ // "Off" first, then the five ladder levels.
+ const options = within(select).getAllByRole("option");
+ expect(options).toHaveLength(6);
+ expect(options[0]).toHaveValue("off");
+ expect(within(select).getByRole("option", { name: "Off" })).toBeInTheDocument();
});
- it("renders a persisted level as selected", () => {
- render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } });
+ it("renders a persisted level as selected when thinking is on", () => {
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "xhigh", persistedThinking: null, save: vi.fn() },
+ });
expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh");
});
+ it("renders 'off' as selected when thinking is disabled (effort level is preserved but hidden)", () => {
+ // thinking off is a SEPARATE axis: even with a persisted effort level, the
+ // selector shows "off" while thinking is disabled.
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "xhigh", persistedThinking: false, save: vi.fn() },
+ });
+
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off");
+ // the level option is still present (restored on an off→on toggle)
+ expect(
+ within(screen.getByRole("combobox")).getByRole("option", { name: "xhigh" }),
+ ).toBeInTheDocument();
+ });
+
it("selecting a level saves it via the injected port and confirms", async () => {
- const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({
+ const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({
ok: true as const,
- reasoningEffort: level,
+ selection,
}));
const user = userEvent.setup();
- render(ReasoningEffortSelector, { props: { persisted: null, save } });
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: null, persistedThinking: null, save },
+ });
await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
@@ -1127,11 +1150,35 @@ describe("ReasoningEffortSelector", () => {
expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max");
});
- it("a failed save shows the error and reverts to the persisted value", async () => {
+ it("selecting 'off' saves the separate disable signal (not a level)", async () => {
+ const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({
+ ok: true as const,
+ selection,
+ }));
+ const user = userEvent.setup();
+
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "high", persistedThinking: null, save },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "off");
+
+ expect(save).toHaveBeenCalledTimes(1);
+ // "off" is the separate thinking-disable signal — NOT a zero-effort level.
+ expect(save).toHaveBeenCalledWith("off");
+ await vi.waitFor(() => {
+ expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument();
+ });
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off");
+ });
+
+ it("a failed save shows the error and reverts to the persisted selection", async () => {
const save = vi.fn(async () => ({ ok: false as const, error: "nope" }));
const user = userEvent.setup();
- render(ReasoningEffortSelector, { props: { persisted: "low", save } });
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "low", persistedThinking: null, save },
+ });
await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
@@ -1142,22 +1189,24 @@ describe("ReasoningEffortSelector", () => {
});
it("disables the select while a save is in flight (no double-fire)", async () => {
- let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined;
+ let resolveSave: ((r: { ok: true; selection: "max" }) => void) | undefined;
const save = vi.fn(
() =>
- new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => {
+ new Promise<{ ok: true; selection: "max" }>((resolve) => {
resolveSave = resolve;
}),
);
const user = userEvent.setup();
- render(ReasoningEffortSelector, { props: { persisted: null, save } });
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: null, persistedThinking: 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" });
+ resolveSave?.({ ok: true, selection: "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
index d982905..6858779 100644
--- a/src/features/chat/ui/ReasoningEffortSelector.svelte
+++ b/src/features/chat/ui/ReasoningEffortSelector.svelte
@@ -1,34 +1,45 @@
<script lang="ts">
import type { ReasoningEffort } from "@dispatch/transport-contract";
import {
- effectiveEffort,
- effortOptions,
- isReasoningEffort,
- type SaveReasoningEffort,
+ effectiveSelection,
+ isThinkingSelection,
+ selectionOptions,
+ type SaveThinkingSelection,
+ type ThinkingSelection,
} from "../reasoning-effort";
let {
- persisted,
+ persistedEffort,
+ persistedThinking,
save,
}: {
- /** The conversation's persisted level, or null when never set (default applies). */
- persisted: ReasoningEffort | null;
- save: SaveReasoningEffort;
+ /** The conversation's persisted effort level, or null when never set (default applies). */
+ persistedEffort: ReasoningEffort | null;
+ /**
+ * The conversation's persisted thinking flag, or null when never set
+ * (thinking ON — the default). `false` ⇒ thinking disabled (the separate
+ * "off" axis); the effort level is preserved across an off→on toggle.
+ */
+ persistedThinking: boolean | null;
+ /** Persist a thinking selection (off or a level). */
+ save: SaveThinkingSelection;
} = $props();
- const options = effortOptions();
+ const options = selectionOptions();
- // 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);
+ // The user's in-flight choice; null = mirror the (async-loaded) persisted
+ // selection. Re-mounted per conversation, so there is no cross-tab bleed.
+ let chosen = $state<ThinkingSelection | null>(null);
let saving = $state(false);
let error = $state<string | null>(null);
let justSaved = $state(false);
- const selected = $derived(chosen ?? effectiveEffort(persisted));
+ const selected = $derived(
+ chosen ?? effectiveSelection(persistedEffort, persistedThinking),
+ );
async function handleChange(value: string) {
- if (!isReasoningEffort(value) || saving) return;
+ if (!isThinkingSelection(value) || saving) return;
chosen = value;
saving = true;
error = null;
@@ -40,7 +51,7 @@
justSaved = true;
} else {
error = result.error;
- chosen = null; // revert to the persisted value
+ chosen = null; // revert to the persisted selection
}
}
</script>
@@ -69,7 +80,7 @@
<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.
+ How long the model thinks before answering. “Off” disables thinking entirely. Changing it can re-prefill the prompt cache once.
</p>
{/if}
</div>