hermes-agent/web/src/components/ReasoningPicker.tsx
Teknium ac00e73688
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.
2026-06-19 11:37:40 -07:00

123 lines
4.4 KiB
TypeScript

/**
* 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>
);
}