feat(dashboard): add a reasoning-effort picker to the chat sidebar (#49141)

The web dashboard only showed a read-only "Reasoning" capability badge
with no way to set the effort level — unlike the desktop app, which has
an effort radio in its composer model menu. This adds a picker so the two
surfaces reach parity.

- ReasoningPicker: a Select rendered in the chat sidebar, gated on the
  effective model's supports_reasoning capability (from /api/model/info).
  Reads/writes agent.reasoning_effort via the existing config REST
  endpoints (read-modify-write, the dashboard's single-key save pattern),
  so the value lands in the config the agent boots a fresh chat from.
  Options mirror the desktop: Off/Minimal/Low/Medium/High/Max.
- ChatSidebar: capture supports_reasoning from the model-info fetch and
  render the picker; on change, show the same 'apply on /new or reload'
  notice the model switch uses.
- reasoning-effort.ts: DOM-free helpers (normalizeEffort + options) so the
  node-env vitest harness can cover the resolution logic, plus tests.
This commit is contained in:
Teknium 2026-06-19 11:37:40 -07:00 committed by GitHub
parent c06898098b
commit ac00e73688
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 233 additions and 0 deletions

View file

@ -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<string | null>(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({
</Badge>
</Card>
{supportsReasoning && (
<Card className="py-0">
<ReasoningPicker
currentModel={modelName}
refreshKey={modelRefreshKey}
onChanged={(effort) =>
setModelNotice(
`Reasoning effort set to ${effort}. Run /new or refresh the page to apply it to this chat.`,
)
}
/>
</Card>
)}
{modelNotice && (
<Card className="flex items-start gap-2 border-warning/40 bg-warning/5 px-3 py-2 text-xs">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />

View file

@ -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 <level>` 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<string, unknown> | 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<string, unknown>;
const agent =
base.agent && typeof base.agent === "object"
? { ...(base.agent as Record<string, unknown>) }
: {};
agent.reasoning_effort = next;
return api.saveConfig({ ...base, agent });
})
.then(() => {
onChanged?.(next);
})
.catch(() => {
setEffort(prev); // revert on failure
})
.finally(() => setSaving(false));
},
[effort, onChanged],
);
return (
<div className="flex items-center gap-2 px-3 py-2 text-xs">
<div className="flex items-center gap-1.5 text-text-tertiary">
<Brain className="h-3.5 w-3.5" />
<span className="text-display tracking-wider">reasoning</span>
</div>
<Select
className="ml-auto min-w-0"
disabled={!loaded || saving}
onValueChange={onSelect}
value={effort}
>
{EFFORT_OPTIONS.map((opt) => (
<SelectOption key={opt.value} value={opt.value}>
{opt.label}
</SelectOption>
))}
</Select>
</div>
);
}

View file

@ -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);
}
});
});

View file

@ -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<EffortOption> = [
{ 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<string> = 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";
}