Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions web/src/components/ChatSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand Down Expand Up @@ -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" />
Expand Down
123 changes: 123 additions & 0 deletions web/src/components/ReasoningPicker.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
48 changes: 48 additions & 0 deletions web/src/lib/reasoning-effort.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
36 changes: 36 additions & 0 deletions web/src/lib/reasoning-effort.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Loading