diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 8d7d5505e6c..8bb0f3a60de 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -30,6 +30,7 @@ import { Card } from "@nous-research/ui/ui/components/card"; import { ModelPickerDialog } from "@/components/ModelPickerDialog"; import { ModelReloadConfirm } from "@/components/ModelReloadConfirm"; +import { ReasoningPicker } from "@/components/ReasoningPicker"; import { ToolCall, type ToolEntry } from "@/components/ToolCall"; import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; import { api, HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; @@ -113,6 +114,14 @@ export function ChatSidebar({ // elsewhere, so the badge would go stale. `/api/model/info` is profile-scoped // by `fetchJSON`, so it reads the same profile this sidebar is scoped to. const [effectiveModel, setEffectiveModel] = useState(""); + // Whether the effective model supports reasoning effort — gates the + // ReasoningPicker. Read from the same `/api/model/info` capabilities the + // (currently unused) ModelInfoCard surfaces, so the dashboard exposes a + // control to *set* the level, not just a read-only "Reasoning" badge. + const [supportsReasoning, setSupportsReasoning] = useState(false); + // Bumped on model change/save so ReasoningPicker re-reads the saved effort + // (config is profile-scoped the same way the model badge is). + const [modelRefreshKey, setModelRefreshKey] = useState(0); // Set after the picker saves a model and the user declines the reload: config // is updated but the running session keeps its model until rebuilt. const [modelNotice, setModelNotice] = useState(null); @@ -127,6 +136,9 @@ export function ChatSidebar({ .getModelInfo() .then((r) => { if (r?.model) setEffectiveModel(String(r.model)); + setSupportsReasoning(!!r?.capabilities?.supports_reasoning); + // Bump so ReasoningPicker re-reads the saved effort for the new model. + setModelRefreshKey((k) => k + 1); }) .catch(() => { // Best-effort: keep the last known label rather than blanking it. @@ -404,6 +416,20 @@ export function ChatSidebar({ + {supportsReasoning && ( + + + setModelNotice( + `Reasoning effort set to ${effort}. Run /new or refresh the page to apply it to this chat.`, + ) + } + /> + + )} + {modelNotice && ( diff --git a/web/src/components/ReasoningPicker.tsx b/web/src/components/ReasoningPicker.tsx new file mode 100644 index 00000000000..77ef2e35bdd --- /dev/null +++ b/web/src/components/ReasoningPicker.tsx @@ -0,0 +1,123 @@ +/** + * ReasoningPicker — sets the main model's reasoning effort from the dashboard + * Chat sidebar, mirroring the desktop app's composer effort radio. + * + * The dashboard previously only showed a read-only "Reasoning" capability + * badge (see ModelInfoCard) with no way to actually choose the effort level — + * unlike the desktop app, which exposes a radio in its model menu. This closes + * that parity gap. + * + * Storage: the effort persists to config.yaml at `agent.reasoning_effort` + * (the same key the TUI's `/reasoning ` command and the desktop radio + * write). We read the whole config and write it back — the established + * single-key pattern on the dashboard (see ConfigPage) — so the value lands in + * the config the agent boots a fresh chat from. As with the model picker, the + * running chat session adopts the change on the next `/new` or page reload; + * we surface that hint rather than forcing a reload here. + * + * Profile scoping: `/api/config` is profile-scoped by `fetchJSON` via the + * global management profile — the same scope the sidebar's `/api/model/info` + * badge reads from — so this writes the profile the sidebar is showing. + */ + +import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; +import { Brain } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { api } from "@/lib/api"; +import { + EFFORT_OPTIONS, + normalizeEffort, + VALID_EFFORTS, +} from "@/lib/reasoning-effort"; + +interface ReasoningPickerProps { + /** Current model string from config — re-reads the saved effort when it + * changes (a different model may have been selected). */ + currentModel: string; + /** Bumped after the model picker saves, to re-read config in lockstep. */ + refreshKey?: number; + /** Called after a successful change so the sidebar can show an "apply on + * /new or reload" notice, matching the model-switch UX. */ + onChanged?: (effort: string) => void; +} + +export function ReasoningPicker({ + currentModel, + refreshKey = 0, + onChanged, +}: ReasoningPickerProps) { + const [effort, setEffort] = useState("medium"); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const lastFetchKeyRef = useRef(""); + + useEffect(() => { + const fetchKey = `${currentModel}:${refreshKey}`; + if (fetchKey === lastFetchKeyRef.current) return; + lastFetchKeyRef.current = fetchKey; + void api + .getConfig() + .then((cfg) => { + const agent = (cfg?.agent as Record | undefined) ?? {}; + setEffort(normalizeEffort(agent.reasoning_effort)); + setLoaded(true); + }) + .catch(() => { + // Best-effort: keep the last known value rather than blanking it. + setLoaded(true); + }); + }, [currentModel, refreshKey]); + + const onSelect = useCallback( + (next: string) => { + if (!VALID_EFFORTS.has(next) || next === effort) return; + const prev = effort; + setEffort(next); // optimistic + setSaving(true); + // Read-modify-write the whole config — the dashboard's single-key save + // pattern — so we never clobber sibling keys. `saveConfig` PUTs the full + // object the agent boots from. + void api + .getConfig() + .then((cfg) => { + const base = (cfg ?? {}) as Record; + const agent = + base.agent && typeof base.agent === "object" + ? { ...(base.agent as Record) } + : {}; + agent.reasoning_effort = next; + return api.saveConfig({ ...base, agent }); + }) + .then(() => { + onChanged?.(next); + }) + .catch(() => { + setEffort(prev); // revert on failure + }) + .finally(() => setSaving(false)); + }, + [effort, onChanged], + ); + + return ( +
+
+ + reasoning +
+ +
+ ); +} diff --git a/web/src/lib/reasoning-effort.test.ts b/web/src/lib/reasoning-effort.test.ts new file mode 100644 index 00000000000..3ade0034724 --- /dev/null +++ b/web/src/lib/reasoning-effort.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { + EFFORT_OPTIONS, + VALID_EFFORTS, + normalizeEffort, +} from "./reasoning-effort"; + +describe("normalizeEffort", () => { + it("treats empty/unset as the Hermes default (medium)", () => { + expect(normalizeEffort("")).toBe("medium"); + expect(normalizeEffort(null)).toBe("medium"); + expect(normalizeEffort(undefined)).toBe("medium"); + expect(normalizeEffort(" ")).toBe("medium"); + }); + + it("passes through every valid effort level", () => { + for (const level of ["none", "minimal", "low", "medium", "high", "xhigh"]) { + expect(normalizeEffort(level)).toBe(level); + } + }); + + it("is case- and whitespace-insensitive", () => { + expect(normalizeEffort("HIGH")).toBe("high"); + expect(normalizeEffort(" XHigh ")).toBe("xhigh"); + }); + + it("falls back to medium for unknown values", () => { + expect(normalizeEffort("turbo")).toBe("medium"); + expect(normalizeEffort("max")).toBe("medium"); // 'max' is a label, not a value + expect(normalizeEffort(42)).toBe("medium"); + }); +}); + +describe("EFFORT_OPTIONS", () => { + it("every option value is in VALID_EFFORTS (no orphan labels)", () => { + for (const opt of EFFORT_OPTIONS) { + expect(VALID_EFFORTS.has(opt.value)).toBe(true); + } + }); + + it("covers the real reasoning levels plus thinking-off", () => { + // Invariant against hermes_constants.VALID_REASONING_EFFORTS + 'none'. + const values = new Set(EFFORT_OPTIONS.map((o) => o.value)); + for (const level of ["none", "minimal", "low", "medium", "high", "xhigh"]) { + expect(values.has(level)).toBe(true); + } + }); +}); diff --git a/web/src/lib/reasoning-effort.ts b/web/src/lib/reasoning-effort.ts new file mode 100644 index 00000000000..1e8313e0489 --- /dev/null +++ b/web/src/lib/reasoning-effort.ts @@ -0,0 +1,36 @@ +/** + * Pure reasoning-effort helpers shared by the dashboard ReasoningPicker. + * + * Kept DOM-free so the node-environment vitest harness can cover the + * resolution logic without loading React or the UI kit. + * + * Values mirror hermes_constants.VALID_REASONING_EFFORTS plus `none` + * (thinking-off). An empty/unset config value means the Hermes default, + * which is `medium`. + */ + +export interface EffortOption { + value: string; + label: string; +} + +export const EFFORT_OPTIONS: ReadonlyArray = [ + { value: "none", label: "Off (no thinking)" }, + { value: "minimal", label: "Minimal" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Max" }, +]; + +export const VALID_EFFORTS: ReadonlySet = new Set( + EFFORT_OPTIONS.map((o) => o.value), +); + +/** Normalize a raw `agent.reasoning_effort` config value to a selectable + * option. Empty/unknown → `medium` (Hermes' default when unset). */ +export function normalizeEffort(raw: unknown): string { + const value = String(raw ?? "").trim().toLowerCase(); + if (!value) return "medium"; + return VALID_EFFORTS.has(value) ? value : "medium"; +}