From 0b69a6ac021c454ef6496d943ccd61d241e51dda Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 1 Aug 2026 16:09:56 -0500 Subject: [PATCH 1/6] feat(kanban): let a task pin its own thinking depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A task could already pin a model and provider, but not how hard the worker thinks: reasoning effort came from the assigned profile's config and nothing per-task could reach it. Pairing a small model with high effort, or a big one with thinking off, meant editing the worker profile itself. Adds a tasks.reasoning_effort column (migrated, NULL = inherit the profile) with set_reasoning_effort(), a create_task kwarg, and a --reasoning spawn flag. Kept deliberately independent of model_override: a task may run the profile's own model at a different depth, and clearing a model override no longer resets the depth the operator chose. "none" is a value (thinking off), not a clear. --reasoning is new on the CLI too — the level was only reachable through the /reasoning slash command, so the dispatcher had no flag to pass. It overrides agent.reasoning_effort for one run and is never persisted. --- cli.py | 19 ++++++++ hermes_cli/_parser.py | 23 +++++++++ hermes_cli/kanban_db.py | 101 +++++++++++++++++++++++++++++++++++++++- hermes_cli/main.py | 1 + 4 files changed, 143 insertions(+), 1 deletion(-) diff --git a/cli.py b/cli.py index 6a81546911a4c..0dfe7b34fce5b 100644 --- a/cli.py +++ b/cli.py @@ -4205,6 +4205,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): model: str = None, toolsets: List[str] = None, provider: str = None, + reasoning: str = None, api_key: str = None, base_url: str = None, max_turns: int = None, @@ -4222,6 +4223,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): model: Model to use (default: from env or claude-sonnet) toolsets: List of toolsets to enable (default: all) provider: Inference provider ("auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn") + reasoning: Reasoning effort override for this run (none|minimal|low|medium|high|xhigh|max|ultra). Wins over config. api_key: API key (default: from environment) base_url: API base URL (default: OpenRouter) max_turns: Maximum tool-calling iterations shared with subagents (default: 500) @@ -4487,6 +4489,20 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # shared chokepoint in hermes_constants (Closes #21256). from hermes_constants import resolve_reasoning_config self.reasoning_config = resolve_reasoning_config(CLI_CONFIG, self.model) + # An explicit --reasoning wins over config for this run only (never + # persisted). Kanban's dispatcher uses it to pin a task's thinking + # depth without touching the worker profile's config.yaml. An + # unparseable level is ignored with a warning rather than silently + # swapping in the default — same contract as the config path. + if reasoning is not None and str(reasoning).strip(): + _cli_reasoning = _parse_reasoning_config(reasoning) + if _cli_reasoning is None: + logger.warning( + "Unknown --reasoning '%s', keeping the configured level", + reasoning, + ) + else: + self.reasoning_config = _cli_reasoning self.service_tier = _parse_service_tier_config( CLI_CONFIG["agent"].get("service_tier", "") ) @@ -17842,6 +17858,7 @@ def main( skills: str | list[str] | tuple[str, ...] = None, model: str = None, provider: str = None, + reasoning: str = None, api_key: str = None, base_url: str = None, max_turns: int = None, @@ -17870,6 +17887,7 @@ def main( skills: Comma-separated or repeated list of skills to preload for the session model: Model to use (default: anthropic/claude-opus-4-20250514) provider: Inference provider ("auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn") + reasoning: Reasoning effort for this run (none|minimal|low|medium|high|xhigh|max|ultra). Overrides agent.reasoning_effort. api_key: API key for authentication base_url: Base URL for the API max_turns: Maximum tool-calling iterations (default: 60) @@ -17984,6 +18002,7 @@ def main( model=model, toolsets=toolsets_list, provider=provider, + reasoning=reasoning, api_key=api_key, base_url=base_url, max_turns=max_turns, diff --git a/hermes_cli/_parser.py b/hermes_cli/_parser.py index cb5be6b935893..b5098f6c98d68 100644 --- a/hermes_cli/_parser.py +++ b/hermes_cli/_parser.py @@ -147,6 +147,18 @@ def build_top_level_parser(): "under model.provider — use `hermes setup` or edit the file to change it." ), ) + _inherited_flag( + parser, + "--reasoning", + default=None, + metavar="LEVEL", + help=( + "Reasoning effort for this invocation: none, minimal, low, medium, " + "high, xhigh, max, or ultra. Overrides agent.reasoning_effort in " + "config.yaml for this run only; the persistent level lives there " + "(or per-model under agent.reasoning_overrides)." + ), + ) parser.add_argument( "-t", "--toolsets", @@ -299,6 +311,17 @@ def build_top_level_parser(): default=argparse.SUPPRESS, help="Comma-separated toolsets to enable", ) + _inherited_flag( + chat_parser, + "--reasoning", + default=argparse.SUPPRESS, + metavar="LEVEL", + help=( + "Reasoning effort for this session: none, minimal, low, medium, " + "high, xhigh, max, or ultra. Overrides agent.reasoning_effort for " + "this run only (same levels as the /reasoning slash command)." + ), + ) _inherited_flag( chat_parser, "-s", diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 72fa92cf20d20..b64d54e53ab7c 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -133,6 +133,30 @@ VALID_BLOCK_KINDS = {"dependency", "needs_input", "capability", "transient"} # not dispatcher spawn/crash/timeout failures. BLOCK_RECURRENCE_LIMIT = 2 VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"} + + +def normalize_reasoning_effort(effort: Optional[str]) -> Optional[str]: + """Normalize a per-task reasoning effort into a storable level. + + Accepts any level in ``hermes_constants.VALID_REASONING_EFFORTS`` plus + ``"none"`` (thinking disabled), case-insensitively. Empty / None means + "inherit the worker profile's own ``agent.reasoning_effort``" and stores + NULL. Anything else is rejected rather than silently dropped — a typo'd + level must not quietly hand the task back to the profile default. + """ + from hermes_constants import VALID_REASONING_EFFORTS + + value = str(effort or "").strip().lower() + if not value: + return None + if value == "none" or value in VALID_REASONING_EFFORTS: + return value + allowed = ", ".join(("none", *VALID_REASONING_EFFORTS)) + raise ValueError( + f"reasoning_effort must be one of {allowed}, got {effort!r}" + ) + + KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names()) _IS_WINDOWS = sys.platform == "win32" KANBAN_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024 @@ -929,6 +953,12 @@ class Task: # model (pre-existing behaviour). Solves the "model from provider A, # profile configured for provider B" mismatch class. provider_override: Optional[str] = None + # Per-task reasoning effort for the worker (one of + # ``hermes_constants.VALID_REASONING_EFFORTS``, or ``"none"`` for thinking + # off). When set, the dispatcher passes ``--reasoning `` so the + # worker runs at that depth regardless of the profile's + # ``agent.reasoning_effort``. NULL = the worker profile's own setting. + reasoning_effort: Optional[str] = None # Per-task override for the consecutive-failure circuit breaker. # The value is the failure count at which the breaker trips — e.g. # ``max_retries=1`` blocks on the first failure (zero retries), @@ -1032,6 +1062,11 @@ class Task: if "provider_override" in keys and row["provider_override"] else None ), + reasoning_effort=( + row["reasoning_effort"] + if "reasoning_effort" in keys and row["reasoning_effort"] + else None + ), max_retries=( row["max_retries"] if "max_retries" in keys else None ), @@ -1200,6 +1235,11 @@ CREATE TABLE IF NOT EXISTS tasks ( -- worker resolves the model against the right backend instead of the -- profile's configured provider. NULL = profile provider. provider_override TEXT, + -- Per-task reasoning effort for the worker (minimal|low|medium|high| + -- xhigh|max|ultra, or 'none' for thinking off). When set, the dispatcher + -- passes --reasoning so the worker runs at that depth regardless + -- of the profile's agent.reasoning_effort. NULL = profile setting. + reasoning_effort TEXT, -- Per-task override for the consecutive-failure circuit breaker. -- The value is the failure count at which the breaker trips — e.g. -- ``max_retries=1`` blocks on the first failure. NULL (the common @@ -2388,6 +2428,13 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: conn, "tasks", "provider_override", "provider_override TEXT" ) + if "reasoning_effort" not in cols: + # Per-task thinking depth for the worker. NULL = the worker profile's + # own agent.reasoning_effort, which is what existing rows were getting. + _add_column_if_missing( + conn, "tasks", "reasoning_effort", "reasoning_effort TEXT" + ) + if "goal_mode" not in cols: # Ralph-style goal loop toggle for the dispatched worker. 0 (the # default) = classic single-shot worker, preserving the behaviour @@ -2851,6 +2898,7 @@ def create_task( max_retries: Optional[int] = None, model_override: Optional[str] = None, provider_override: Optional[str] = None, + reasoning_effort: Optional[str] = None, goal_mode: bool = False, goal_max_turns: Optional[int] = None, initial_status: str = "running", @@ -2887,6 +2935,11 @@ def create_task( config — passed to the worker as ``-m [--provider ]``. ``provider_override`` requires ``model_override``. + ``reasoning_effort`` pins the worker's thinking depth for this task + (``minimal``…``ultra``, or ``none`` to disable thinking), passed as + ``--reasoning ``. It is independent of ``model_override``: a task + can run the profile's own model at a different depth. + ``project_source_task_id`` is an internal cross-profile fallback for a worker-created child. When the active profile cannot resolve ``project_id`` in its own projects.db, a matching canonical project-linked task in this @@ -2895,6 +2948,7 @@ def create_task( """ model_override = (model_override or "").strip() or None provider_override = (provider_override or "").strip() or None + reasoning_effort = normalize_reasoning_effort(reasoning_effort) if provider_override and not model_override: raise ValueError("provider_override requires a model_override") assignee = _canonical_assignee(assignee) @@ -3162,8 +3216,9 @@ def create_task( branch_name, project_id, tenant, idempotency_key, max_runtime_seconds, skills, max_retries, model_override, provider_override, + reasoning_effort, goal_mode, goal_max_turns, session_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( task_id, @@ -3185,6 +3240,7 @@ def create_task( int(max_retries) if max_retries is not None else None, model_override, provider_override, + reasoning_effort, 1 if goal_mode else 0, int(goal_max_turns) if goal_max_turns is not None else None, session_id, @@ -3427,6 +3483,44 @@ def set_model_override( return True +def set_reasoning_effort( + conn: sqlite3.Connection, + task_id: str, + effort: Optional[str], +) -> bool: + """Set (or clear) the per-task reasoning effort. + + ``effort=None`` (or empty) clears the override — the worker falls back to + its profile's own ``agent.reasoning_effort``. ``"none"`` is a real value, + not a clear: it pins thinking OFF for this task. + + Deliberately independent of :func:`set_model_override`: a task may run the + profile's own model at a different depth, and clearing a model override + must not silently reset the depth the operator chose. Like the model + override, it takes effect on the NEXT dispatch, so it is settable on a + running task. Returns True on success. + """ + effort = normalize_reasoning_effort(effort) + with write_txn(conn): + row = conn.execute( + "SELECT status FROM tasks WHERE id = ?", (task_id,) + ).fetchone() + if not row: + return False + if row["status"] == "archived": + raise RuntimeError( + f"cannot set reasoning effort on archived task {task_id}" + ) + conn.execute( + "UPDATE tasks SET reasoning_effort = ? WHERE id = ?", + (effort, task_id), + ) + _append_event( + conn, task_id, "reasoning_effort_set", {"reasoning_effort": effort} + ) + return True + + # --------------------------------------------------------------------------- # Links # --------------------------------------------------------------------------- @@ -9026,6 +9120,11 @@ def _default_spawn( # the classic mis-set that stalls a board). if task.provider_override: cmd.extend(["--provider", task.provider_override]) + # Per-task thinking depth. Independent of the model override — a task can + # run the profile's own model at a different depth — so this is its own + # branch, not a nested one. + if task.reasoning_effort: + cmd.extend(["--reasoning", task.reasoning_effort]) worker_toolsets = _resolve_worker_cli_toolsets(env.get("HERMES_HOME")) if worker_toolsets: cmd.extend(["--toolsets", ",".join(worker_toolsets)]) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a8fb2ffc473ce..cfc331d9b16b5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2693,6 +2693,7 @@ def cmd_chat(args): kwargs = { "model": args.model, "provider": getattr(args, "provider", None), + "reasoning": getattr(args, "reasoning", None), "toolsets": args.toolsets, "skills": getattr(args, "skills", None), "verbose": getattr(args, "verbose", None), From f0ed0aebbca787ea68975dc20d60309338f4b996 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 1 Aug 2026 16:10:03 -0500 Subject: [PATCH 2/6] feat(kanban): expose the per-task reasoning effort over REST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the new column through create, PATCH, and bulk. Clearing is an explicit clear_reasoning_effort flag rather than a null, because a null in a PATCH body means "field not sent", not "set to NULL" — the same shape the model override already uses, and the reason "none" can stay a real value. Tests cover normalization, the depth-survives-a-model-clear invariant, both spawn-argv branches, and the REST round-trip. One asserts the worker CLI actually accepts the --reasoning flag the dispatcher emits: a spawn arg no parser accepts would fail every dispatch while every unit test stayed green. --- plugins/kanban/dashboard/plugin_api.py | 37 +++++++ tests/plugins/test_kanban_model_override.py | 115 ++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 68bba09aa8195..fdc49da34f135 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -610,6 +610,9 @@ class CreateTaskBody(BaseModel): goal_max_turns: Optional[int] = None model_override: Optional[str] = None provider_override: Optional[str] = None + # Per-task thinking depth (none|minimal|…|ultra). None = inherit the + # assigned profile's own agent.reasoning_effort. + reasoning_effort: Optional[str] = None # Explicit project link; when omitted, create_task inherits the board's # scoped project (if any) so a project-scoped board anchors every task. project_id: Optional[str] = None @@ -639,6 +642,7 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)): goal_max_turns=payload.goal_max_turns, model_override=payload.model_override, provider_override=payload.provider_override, + reasoning_effort=payload.reasoning_effort, project_id=payload.project_id, board=board, ) @@ -839,6 +843,12 @@ class UpdateTaskBody(BaseModel): model_override: Optional[str] = None provider_override: Optional[str] = None clear_model_override: bool = False + # Per-task thinking depth. ``"none"`` is a VALUE (thinking off), not a + # clear — use ``clear_reasoning_effort=True`` to fall back to the + # profile's own level. Separate from the model clear so dropping a model + # override doesn't silently reset the depth the operator chose. + reasoning_effort: Optional[str] = None + clear_reasoning_effort: bool = False @router.patch("/tasks/{task_id}") @@ -934,6 +944,19 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu if not ok: raise HTTPException(status_code=404, detail="task not found") + # --- reasoning effort ---------------------------------------------- + if payload.clear_reasoning_effort or payload.reasoning_effort is not None: + new_effort = ( + None if payload.clear_reasoning_effort + else payload.reasoning_effort + ) + try: + ok = kanban_db.set_reasoning_effort(conn, task_id, new_effort) + except (ValueError, RuntimeError) as e: + raise HTTPException(status_code=400, detail=str(e)) + if not ok: + raise HTTPException(status_code=404, detail="task not found") + # --- priority ----------------------------------------------------- if payload.priority is not None: with kanban_db.write_txn(conn): @@ -1199,6 +1222,9 @@ class BulkTaskBody(BaseModel): model_override: Optional[str] = None provider_override: Optional[str] = None clear_model_override: bool = False + # Bulk thinking-depth override — same semantics as UpdateTaskBody. + reasoning_effort: Optional[str] = None + clear_reasoning_effort: bool = False @router.post("/tasks/bulk") @@ -1304,6 +1330,17 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)): entry.update(ok=False, error="model override refused") except (ValueError, RuntimeError) as e: entry.update(ok=False, error=str(e)) + if payload.clear_reasoning_effort or payload.reasoning_effort is not None: + new_effort = ( + None if payload.clear_reasoning_effort + else payload.reasoning_effort + ) + try: + ok = kanban_db.set_reasoning_effort(conn, tid, new_effort) + if not ok: + entry.update(ok=False, error="reasoning override refused") + except (ValueError, RuntimeError) as e: + entry.update(ok=False, error=str(e)) except Exception as e: # defensive — one bad id shouldn't kill the batch entry.update(ok=False, error=str(e)) results.append(entry) diff --git a/tests/plugins/test_kanban_model_override.py b/tests/plugins/test_kanban_model_override.py index 9823e93c397c1..22221b5ac0ea4 100644 --- a/tests/plugins/test_kanban_model_override.py +++ b/tests/plugins/test_kanban_model_override.py @@ -205,3 +205,118 @@ def test_model_options_endpoint_shape(client, monkeypatch): assert "slug" in row and "label" in row and "models" in row assert isinstance(row["models"], list) assert len(row["models"]) >= 1 # empty-model rows are filtered out + + +# --------------------------------------------------------------------------- +# Per-task reasoning effort — the depth half of the board's model picker +# --------------------------------------------------------------------------- + + +def test_reasoning_effort_normalizes_and_rejects(conn): + tid = kb.create_task(conn, title="t", assignee="worker", reasoning_effort=" HIGH ") + assert kb.get_task(conn, tid).reasoning_effort == "high" + + # "none" is a VALUE (thinking off), not a clear. + assert kb.set_reasoning_effort(conn, tid, "none") + assert kb.get_task(conn, tid).reasoning_effort == "none" + + # Empty clears back to "inherit the profile". + assert kb.set_reasoning_effort(conn, tid, "") + assert kb.get_task(conn, tid).reasoning_effort is None + + with pytest.raises(ValueError): + kb.set_reasoning_effort(conn, tid, "extremely-hard") + + +def test_reasoning_effort_survives_clearing_the_model(conn): + """Depth and model are independent knobs: dropping a model override must + not silently reset the thinking depth the operator chose.""" + tid = kb.create_task( + conn, title="t", assignee="worker", + model_override="glm-5", provider_override="openrouter", + reasoning_effort="ultra", + ) + assert kb.set_model_override(conn, tid, None) + t = kb.get_task(conn, tid) + assert t.model_override is None + assert t.provider_override is None + assert t.reasoning_effort == "ultra" + + +def test_reasoning_effort_without_a_model_override(conn): + """A task may run the profile's OWN model at a different depth.""" + tid = kb.create_task(conn, title="t", assignee="worker", reasoning_effort="low") + t = kb.get_task(conn, tid) + assert t.model_override is None + assert t.reasoning_effort == "low" + + +def test_spawn_passes_reasoning_without_a_model(monkeypatch, tmp_path, conn): + tid = kb.create_task(conn, title="t", assignee="elias", reasoning_effort="high") + task = kb.get_task(conn, tid) + cmd = _spawn_and_capture(monkeypatch, tmp_path, task) + assert "-m" not in cmd + i = cmd.index("--reasoning") + assert cmd[i + 1] == "high" + + +def test_spawn_omits_reasoning_when_unset(monkeypatch, tmp_path, conn): + tid = kb.create_task(conn, title="t", assignee="elias") + task = kb.get_task(conn, tid) + cmd = _spawn_and_capture(monkeypatch, tmp_path, task) + assert "--reasoning" not in cmd + + +def test_worker_cli_accepts_the_reasoning_flag(): + """The dispatcher's --reasoning must be a real flag on the worker's CLI — + a spawn arg no parser accepts fails every dispatch.""" + from hermes_cli._parser import build_top_level_parser + + parser = build_top_level_parser()[0] + args = parser.parse_args(["--cli", "chat", "-q", "hi", "--reasoning", "high"]) + assert args.reasoning == "high" + + +def test_patch_sets_and_clears_reasoning_effort(client): + task = _create(client) + r = client.patch( + f"/api/plugins/kanban/tasks/{task['id']}", + json={"reasoning_effort": "xhigh"}, + ) + assert r.status_code == 200, r.text + assert r.json()["task"]["reasoning_effort"] == "xhigh" + + r = client.patch( + f"/api/plugins/kanban/tasks/{task['id']}", + json={"clear_reasoning_effort": True}, + ) + assert r.status_code == 200, r.text + assert r.json()["task"]["reasoning_effort"] is None + + +def test_patch_rejects_an_unknown_level(client): + task = _create(client) + r = client.patch( + f"/api/plugins/kanban/tasks/{task['id']}", + json={"reasoning_effort": "bogus"}, + ) + assert r.status_code == 400 + + +def test_create_accepts_reasoning_effort(client): + task = _create(client, reasoning_effort="minimal") + assert task["reasoning_effort"] == "minimal" + + +def test_bulk_reasoning_effort(client): + t1 = _create(client) + t2 = _create(client) + r = client.post( + "/api/plugins/kanban/tasks/bulk", + json={"ids": [t1["id"], t2["id"]], "reasoning_effort": "max"}, + ) + assert r.status_code == 200, r.text + assert all(entry["ok"] for entry in r.json()["results"]) + for tid in (t1["id"], t2["id"]): + got = client.get(f"/api/plugins/kanban/tasks/{tid}").json()["task"] + assert got["reasoning_effort"] == "max" From b35c34d58cbff05b096b363df1eb6874967b41b8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 1 Aug 2026 16:10:13 -0500 Subject: [PATCH 3/6] refactor(desktop): make the composer's model picker a reusable primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model menu — search, provider grouping, -fast family collapse, keyboard selection, the per-row thinking/effort submenu — was welded to the chat composer's session writes, so any other surface wanting a model picker had to fork it and drift. Splits rendering from meaning. ModelCatalogMenu owns the catalog and the navigation; a ModelMenuController decides what a selection DOES. The composer is now one controller over it, keeping its session scoping, sticky manual pick, preset restore, MoA presets, and rollback-on-failed-write intact. ModelEditSubmenu becomes pure: it reports edits instead of performing them. It previously called setCurrentReasoningEffort and config.set inline, so any non-composer host would have silently retargeted the user's live chat when they picked an effort. Its default effort is passed in rather than read from a store, which is what lets it render outside a session at all. Exported through the SDK so plugins consume the real component instead of a copy. The composer's existing behaviour suite passes unchanged against it. --- .../src/app/shell/model-catalog-menu.tsx | 573 +++++++++++++++ .../src/app/shell/model-edit-submenu.test.tsx | 125 ++-- .../src/app/shell/model-edit-submenu.tsx | 134 +--- .../src/app/shell/model-menu-panel.tsx | 669 ++++-------------- apps/desktop/src/sdk/index.ts | 31 +- 5 files changed, 843 insertions(+), 689 deletions(-) create mode 100644 apps/desktop/src/app/shell/model-catalog-menu.tsx diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx new file mode 100644 index 0000000000000..7e8aac12d33a1 --- /dev/null +++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx @@ -0,0 +1,573 @@ +import { useStore } from '@nanostores/react' +import { useQuery } from '@tanstack/react-query' +import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { DisclosureCaret } from '@/components/ui/disclosure-caret' +import { + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + dropdownMenuRow, + DropdownMenuSearch, + dropdownMenuSectionLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubTrigger +} from '@/components/ui/dropdown-menu' +import { HighlightMatches } from '@/components/ui/highlight-matches' +import { usePointerQuiet } from '@/components/ui/keyboard-first' +import { Skeleton } from '@/components/ui/skeleton' +import type { HermesGateway } from '@/hermes' +import { useI18n } from '@/i18n' +import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' +import { displayModelName, modelDisplayParts } from '@/lib/model-status-label' +import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' +import { normalize } from '@/lib/text' +import { cn } from '@/lib/utils' +import { + collapseModelFamilies, + DEFAULT_VISIBLE_PER_PROVIDER, + effectiveVisibleKeys, + type ModelFamily, + modelVisibilityKey +} from '@/store/model-visibility' +import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse' +import { $defaultReasoningEffort } from '@/store/session' +import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' + +import { type FastControl, ModelEditSubmenu, resolveFastControl } from './model-edit-submenu' + +// Lets the host dropdown (model-pill, a kanban field trigger, …) hand the panel +// a way to dismiss itself so clicking a model row commits + closes, while the +// hover-revealed edit submenu (reasoning/fast) stays open to play with (its +// items preventDefault on select). +export const ModelMenuCloseContext = createContext<() => void>(() => {}) + +/** One model choice, everything a caller needs to act on a selection. + * `effort` is '' for "inherit the default" and 'none' for thinking off. */ +export interface ModelChoice { + effort: string + fast: boolean + model: string + provider: string +} + +/** + * What a surface DOES with the catalog. The menu renders and navigates; the + * controller owns meaning — the composer writes through to a live session, + * the kanban override just holds a value in dialog state. + * + * `presetFor` supplies the remembered settings shown on a non-active row. + * Returning `{}` is fine — the row then shows Hermes' defaults. + */ +export interface ModelMenuController { + /** Restore a model's remembered settings after it is selected. Separate from + * `setOptions` because it is one atomic "apply this model's preset" write, + * not a user editing one control — surfaces that write through to a session + * need to batch it. Values are already capability-gated by the menu. */ + applyPreset: (preset: { effort?: string; fast?: boolean }, row: { model: string; provider: string }) => void + current: ModelChoice + presetFor: (provider: string, model: string) => { effort?: string; fast?: boolean } + /** Commit a model row. Return false to abort (a failed session switch). */ + select: (model: string, provider: string) => Promise | void + /** Edit ONE option on a row. `isActive` says whether it's the current model. */ + setOptions: ( + patch: { effort?: string; fast?: boolean }, + row: { isActive: boolean; model: string; provider: string } + ) => void +} + +interface ModelCatalogMenuProps { + controller: ModelMenuController + /** Rows appended under the catalog (Refresh Models, Edit Models, …). */ + footer?: ReactNode + gateway?: HermesGateway + /** Render the virtual `moa` provider's presets as a selectable section. + * Off for override surfaces, where a MoA preset isn't a worker model. */ + includeMoa?: boolean + profile?: string + /** The user's STORED visible-model keys (null = never customized). Resolved + * against the fetched catalog inside the menu — a caller can't resolve it + * early against an unpopulated cache without hiding every row. Pass + * `undefined` to skip visibility filtering entirely. */ + visibleModels?: Set | null +} + +interface ProviderGroup { + families: ModelFamily[] + provider: ModelOptionProvider +} + +/** + * THE model catalog menu: searchable, provider-grouped, `-fast` families + * collapsed to one row, per-row hover submenu for thinking/effort/fast, full + * keyboard selection. Shared verbatim by the composer's model pill and by + * plugin surfaces that pick a model without a session behind it — so the two + * can never drift apart. + */ +export function ModelCatalogMenu({ + controller, + footer, + gateway, + includeMoa = false, + profile = 'default', + visibleModels +}: ModelCatalogMenuProps) { + const { t } = useI18n() + const copy = t.shell.modelMenu + const closeMenu = useContext(ModelMenuCloseContext) + const [search, setSearch] = useState('') + const collapsedProviders = useStoreCollapsed() + const defaultEffort = useDefaultEffort() + + const modelOptions = useQuery({ + queryKey: modelOptionsQueryKey(profile, null), + // Gateway-first even with no session: a connected (possibly remote) + // gateway owns the model catalog, including virtual providers the local + // REST fallback can't know about (#53817). + queryFn: (): Promise => requestModelOptions({ gateway }) + }) + + const loading = modelOptions.isPending && !modelOptions.data + + const error = modelOptions.error + ? modelOptions.error instanceof Error + ? modelOptions.error.message + : String(modelOptions.error) + : null + + const providers = modelOptions.data?.providers + + // The catalog carries MoA presets as a virtual `moa` provider row. Keep it + // out of the main groups so presets never show up twice. + const moaPresets = useMemo( + () => (includeMoa ? (providers?.find(p => p.slug.toLowerCase() === 'moa')?.models ?? []) : []), + [providers, includeMoa] + ) + + const pickerProviders = useMemo( + () => providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? [], + [providers] + ) + + const current = controller.current + + // Resolve visibility HERE, against the catalog we actually fetched: an empty + // provider list would otherwise resolve to an empty key set that reads as + // "user hid everything" and blanks the menu on first open. + const shownKeys = useMemo( + () => (visibleModels === undefined ? null : effectiveVisibleKeys(visibleModels, pickerProviders)), + [visibleModels, pickerProviders] + ) + + const groups = useMemo( + () => groupModels(pickerProviders, search, { model: current.model, provider: current.provider }, shownKeys), + [pickerProviders, search, current.model, current.provider, shownKeys] + ) + + const q = normalize(search) + + // Presets are searchable rows like everything else — an unfiltered preset + // sitting under zero model matches would otherwise become the "first match" + // Enter commits. + const shownMoaPresets = useMemo( + () => (q ? moaPresets.filter(preset => `moa ${preset}`.toLowerCase().includes(q)) : moaPresets), + [moaPresets, q] + ) + + const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => { + const caps = provider.capabilities?.[family.id] + const preset = controller.presetFor(provider.slug, family.id) + + // Variant-fast models (no speed param) express "fast" as a separate `-fast` + // id, so honor the remembered preset by selecting that sibling. Param-fast + // is applied through setOptions below instead. + const variantFast = !(caps?.fast ?? false) && !!family.fastId + const targetId = variantFast && preset.fast === true ? family.fastId! : family.id + + if ((await controller.select(targetId, provider.slug)) === false) { + return + } + + controller.applyPreset( + { + effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined, + fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined + }, + { model: family.id, provider: provider.slug } + ) + } + + const selectMoaPreset = async (preset: string) => { + if ((await controller.select(preset, 'moa')) === false) { + return + } + + closeMenu() + } + + // ── Keyboard selection (cmdk semantics on a Radix menu) ─────────────────── + // One flat list mirroring EXACTLY what's rendered (collapse, filter, presets), + // so the selection can never sit on a hidden row. + type KbRow = + | { family: ModelFamily; key: string; kind: 'family'; provider: ModelOptionProvider } + | { key: string; kind: 'moa'; preset: string } + + const kbRows = useMemo( + () => [ + ...groups.flatMap(group => + collapsedProviders.includes(group.provider.slug) && !search + ? [] + : group.families.map((family): KbRow => ({ + family, + key: `${group.provider.slug}:${family.id}`, + kind: 'family', + provider: group.provider + })) + ), + ...shownMoaPresets.map((preset): KbRow => ({ key: `moa:${preset}`, kind: 'moa', preset })) + ], + [groups, collapsedProviders, search, shownMoaPresets] + ) + + const [kbOverride, setKbOverride] = useState(null) + // A parked cursor is not a cursor in use: until the mouse actually moves, + // hover can't take rows out from under the keyboard. + const pointerQuiet = usePointerQuiet() + + const currentKey = current.provider === 'moa' ? `moa:${current.model}` : `${current.provider}:${current.model}` + + const autoIndex = q + ? kbRows.length > 0 + ? 0 + : -1 + : kbRows.findIndex(row => row.key === currentKey || (row.kind === 'family' && row.family.fastId === current.model)) + + const kbIndex = kbOverride !== null && kbOverride < kbRows.length ? kbOverride : autoIndex + const kbActiveKey = kbIndex >= 0 ? kbRows[kbIndex].key : null + + const stepKb = (delta: -1 | 1) => { + if (kbRows.length === 0) { + return + } + + const from = kbIndex >= 0 ? kbIndex : delta === 1 ? -1 : 0 + + setKbOverride((from + delta + kbRows.length) % kbRows.length) + } + + const commitKbRow = () => { + const row = kbIndex >= 0 ? kbRows[kbIndex] : undefined + + if (!row) { + return + } + + if (row.kind === 'moa') { + void selectMoaPreset(row.preset) + + return + } + + if (row.key !== currentKey && row.family.fastId !== current.model) { + void selectFamily(row.family, row.provider) + } + + closeMenu() + } + + // Keep the selected row in view while arrowing through the scrollable list. + const listRef = useRef(null) + + useEffect(() => { + listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' }) + }, [kbActiveKey]) + + const kbRowProps = (key: string) => { + const active = kbActiveKey === key + + return { + className: cn(dropdownMenuRow, active && 'bg-(--ui-control-active-background) text-foreground'), + ...(active ? { 'data-kb-active': '' } : {}) + } + } + + // Rows are hover-selectable, so they go inert with the pointer. + const quietRows = pointerQuiet && 'pointer-events-none' + + return ( + <> + { + // Claim arrows and Enter from Radix so DOM focus stays in the input + // and Enter commits the highlighted row without a DownArrow first. + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + event.stopPropagation() + stepKb(event.key === 'ArrowDown' ? 1 : -1) + } else if (event.key === 'Enter') { + event.preventDefault() + event.stopPropagation() + commitKbRow() + } + }} + onValueChange={value => { + setSearch(value) + setKbOverride(null) + }} + placeholder={copy.search} + value={search} + /> + + + + {loading ? ( + + {Array.from({ length: 4 }, (_, index) => ( + event.preventDefault()} + > + + + ))} + + ) : error ? ( + + {error} + + ) : groups.length === 0 && moaPresets.length === 0 ? ( + + {copy.noModels} + + ) : ( +
+ {groups.map(group => { + const slug = group.provider.slug + + // Collapsed when the user stored it (and not while searching, which + // spans every model regardless of collapse state). + const collapsed = collapsedProviders.includes(slug) && !search + + return ( + + { + event.preventDefault() + toggleCollapsedProvider(slug) + }} + textValue="" + > + + + + + + {!collapsed && + group.families.map(family => { + // The active id may be the base or its -fast sibling; either + // way this one family row represents both. + const activeId = + group.provider.slug === current.provider && + (current.model === family.id || current.model === family.fastId) + ? current.model + : null + + const isCurrent = activeId !== null + const name = modelDisplayParts(family.id).name + const caps = group.provider.capabilities?.[family.id] + + // Effective settings for this row: the live choice when it's + // the active model, otherwise its remembered preset. Row + // label AND submenu read from these so they never disagree. + const preset = controller.presetFor(group.provider.slug, family.id) + const effEffort = isCurrent ? current.effort : (preset.effort ?? '') + const effFast = isCurrent ? current.fast : (preset.fast ?? false) + + const fastControl: FastControl = resolveFastControl( + activeId ?? family.id, + group.provider.models ?? [], + caps?.fast ?? false, + effFast + ) + + const meta = [ + fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, + (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null + ] + .filter(Boolean) + .join(' ') + + // Clicking the row commits the model and closes; the edit + // submenu (reasoning/fast) is reached by HOVER, so you can + // tweak those without the click dismissing everything. + const activate = () => { + if (!isCurrent) { + void selectFamily(family, group.provider) + } + + closeMenu() + } + + return ( + + { + if (event.key === 'Enter' || event.key === ' ') { + activate() + } + }} + {...kbRowProps(`${group.provider.slug}:${family.id}`)} + > + + + {meta ? {meta} : null} + + {isCurrent ? ( + + ) : null} + + controller.select(nextModel, group.provider.slug)} + onSetOptions={patch => + controller.setOptions(patch, { + isActive: isCurrent, + model: family.id, + provider: group.provider.slug + }) + } + provider={group.provider.slug} + reasoning={caps?.reasoning ?? true} + /> + + ) + })} + + ) + })} +
+ )} + + {shownMoaPresets.length > 0 ? ( +
+ + MoA presets + {shownMoaPresets.map(preset => { + const isCurrentMoa = current.provider === 'moa' && current.model === preset + + return ( + { + event.preventDefault() + void selectMoaPreset(preset) + }} + {...kbRowProps(`moa:${preset}`)} + > + + MoA: + + {isCurrentMoa ? : null} + + ) + })} +
+ ) : null} + + {footer ? ( + <> + + {footer} + + ) : null} + + ) +} + +/** Re-exported so callers building a footer row match the catalog's rows. */ +export { dropdownMenuRow } + +// Collapsed we show the user's chosen models (or the curated default); typing +// spans every available model so anything is reachable past the cut. A search +// is itself a narrowing action, so we do NOT cap per-provider matches. +function groupModels( + providers: ModelOptionProvider[], + search: string, + current: { model: string; provider: string }, + visible: Set | null +): ProviderGroup[] { + const q = normalize(search) + const groups: ProviderGroup[] = [] + + for (const provider of providers) { + const allFamilies = collapseModelFamilies(provider.models ?? []) + + if (allFamilies.length === 0) { + continue + } + + const matches = (family: ModelFamily) => + `${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}` + .toLowerCase() + .includes(q) + + let shown: Set + + if (q) { + // Search spans every family, regardless of visibility. + shown = new Set(allFamilies.filter(matches).map(family => family.id)) + } else if (visible) { + // User has customized which models show — honor their selection exactly. + shown = new Set( + allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id) + ) + } else { + shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id)) + } + + // Always include the active model — but keep every row in the provider's + // stable curated order, so selecting a model can't shuffle the list. While + // SEARCHING the pin is skipped: a query means "show me matches". + const activeId = + !q && provider.slug === current.provider && current.model + ? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id + : undefined + + const families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId) + + if (families.length > 0) { + groups.push({ families, provider }) + } + } + + // Stable, logical group order: alphabetical by provider name. (The backend + // floats the current provider first, which would reshuffle on every switch.) + groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name)) + + return groups +} + +// Small hooks kept at the bottom so the component reads top-down. +function useStoreCollapsed(): string[] { + return useStore($collapsedProviders) +} + +function useDefaultEffort(): string { + return useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT +} diff --git a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx index 4e552303b572b..52513c4d00805 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx @@ -1,5 +1,5 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { DropdownMenu, @@ -7,26 +7,9 @@ import { DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu' -import type * as HermesApi from '@/hermes' -import { $modelPresets, getModelPreset } from '@/store/model-presets' -import { - $activeSessionId, - $currentFastMode, - $currentReasoningEffort, - getCurrentModelSource, - setCurrentFastMode, - setCurrentModelSource, - setCurrentReasoningEffort -} from '@/store/session' import { type FastControl, ModelEditSubmenu } from './model-edit-submenu' -vi.mock('@/hermes', async importOriginal => { - const actual = await importOriginal() - - return { ...actual, setApiRequestProfile: vi.fn() } -}) - // Radix calls these on open; jsdom doesn't implement them. beforeAll(() => { Element.prototype.scrollIntoView = vi.fn() @@ -34,35 +17,36 @@ beforeAll(() => { Element.prototype.releasePointerCapture = vi.fn() }) -beforeEach(() => { - $modelPresets.set({}) - $activeSessionId.set(null) - setCurrentFastMode(false) - setCurrentModelSource('') - setCurrentReasoningEffort('') -}) - afterEach(() => { cleanup() vi.clearAllMocks() }) // Render the submenu inside an open menu/sub so its content (switches) mounts. -function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise }) { +function renderSubmenu(opts: { + defaultEffort?: string + effort?: string + fastControl: FastControl + isActive?: boolean + onSelectModel?: (model: string) => void + onSetOptions: (patch: { effort?: string; fast?: boolean }) => void + reasoning: boolean +}) { return render( edit @@ -70,43 +54,72 @@ function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; req ) } -// Regression: editing the active row before a live session exists must stay -// preset-only — the gateway's config.set falls back to global config when no -// session matches, so it must not be called. (Caught in the second review.) -describe('ModelEditSubmenu no-session guard', () => { - it('param fast: records explicit off in the draft but skips the gateway without a session', () => { - const requestGateway = vi.fn().mockResolvedValue({}) - setCurrentFastMode(true) - renderSubmenu({ fastControl: { kind: 'param', on: true }, reasoning: false, requestGateway }) +// The submenu is PURE: it reports edits and never writes to a session, a +// preset store, or the gateway. That's the invariant that lets the same +// component drive a live chat session AND a detached per-task override — if it +// ever writes directly again, picking an effort for a kanban card would reach +// over and change the user's live chat. +describe('ModelEditSubmenu reports edits without performing them', () => { + it('param fast: reports the toggle', () => { + const onSetOptions = vi.fn() + renderSubmenu({ fastControl: { kind: 'param', on: true }, onSetOptions, reasoning: false }) fireEvent.click(screen.getByRole('switch')) - expect(getModelPreset('p1', 'm1').fast).toBe(false) - expect($currentFastMode.get()).toBe(false) - expect(getCurrentModelSource()).toBe('manual') - expect(requestGateway).not.toHaveBeenCalled() + expect(onSetOptions).toHaveBeenCalledWith({ fast: false }) }) - it('reasoning: records the preset but skips the gateway without a session', () => { - const requestGateway = vi.fn().mockResolvedValue({}) - renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway }) + it('thinking: toggling off reports the none level', () => { + const onSetOptions = vi.fn() + renderSubmenu({ fastControl: { kind: 'none' }, onSetOptions, reasoning: true }) - // Thinking starts on (medium); toggling it off routes through patchReasoning. + // Thinking starts on (medium); toggling it off reports 'none'. fireEvent.click(screen.getByRole('switch')) - expect(getModelPreset('p1', 'm1').effort).toBe('none') - expect($currentReasoningEffort.get()).toBe('none') - expect(getCurrentModelSource()).toBe('manual') - expect(requestGateway).not.toHaveBeenCalled() + expect(onSetOptions).toHaveBeenCalledWith({ effort: 'none' }) }) - it('param fast: pushes to the gateway once a session is active', async () => { - const requestGateway = vi.fn().mockResolvedValue({}) - $activeSessionId.set('sess1') - renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway }) + it('thinking: toggling back on restores the row level, not the hardcoded default', () => { + const onSetOptions = vi.fn() + renderSubmenu({ defaultEffort: 'high', effort: 'none', fastControl: { kind: 'none' }, onSetOptions, reasoning: true }) fireEvent.click(screen.getByRole('switch')) - expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' }) + expect(onSetOptions).toHaveBeenCalledWith({ effort: 'high' }) + }) + + it('variant fast: swaps the model only when the row is active', () => { + const onSelectModel = vi.fn() + const onSetOptions = vi.fn() + + renderSubmenu({ + fastControl: { baseId: 'm1', fastId: 'm1-fast', kind: 'variant', on: false }, + isActive: false, + onSelectModel, + onSetOptions, + reasoning: false + }) + + fireEvent.click(screen.getByRole('switch')) + + // Inactive rows stay preference-only — no model switch. + expect(onSetOptions).toHaveBeenCalledWith({ fast: true }) + expect(onSelectModel).not.toHaveBeenCalled() + }) + + it('variant fast: active row swaps to the -fast sibling', () => { + const onSelectModel = vi.fn() + const onSetOptions = vi.fn() + + renderSubmenu({ + fastControl: { baseId: 'm1', fastId: 'm1-fast', kind: 'variant', on: false }, + onSelectModel, + onSetOptions, + reasoning: false + }) + + fireEvent.click(screen.getByRole('switch')) + + expect(onSelectModel).toHaveBeenCalledWith('m1-fast') }) }) diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index 94c241bdf04e6..1d5b5dc598dd7 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -1,6 +1,3 @@ -import { useStore } from '@nanostores/react' - -import { useSessionView } from '@/app/chat/session-view' import { DropdownMenuItem, DropdownMenuLabel, @@ -13,21 +10,7 @@ import { } from '@/components/ui/dropdown-menu' import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' -import { - DEFAULT_REASONING_EFFORT, - isThinkingEnabled, - REASONING_EFFORTS, - resolveReasoningEffort -} from '@/lib/reasoning-effort' -import { setModelPreset } from '@/store/model-presets' -import { notifyError } from '@/store/notifications' -import { - $defaultReasoningEffort, - markComposerSelectionManual, - setCurrentFastMode, - setCurrentReasoningEffort -} from '@/store/session' -import { sessionTileDelegate } from '@/store/session-states' +import { isThinkingEnabled, REASONING_EFFORTS, resolveReasoningEffort } from '@/lib/reasoning-effort' // Hermes' real reasoning levels live in lib/reasoning-effort; `none` is owned // by the Thinking toggle, not the radio. @@ -76,6 +59,9 @@ export function resolveFastControl( } interface ModelEditSubmenuProps { + /** The profile's configured default effort — what an unset row inherits. + * Passed in (not read from a store) so this submenu stays pure. */ + defaultEffort: string /** This row's effective reasoning effort (live for the active model, else its * preset) — the submenu shows and edits from this, never the raw session. */ effort: string @@ -83,15 +69,19 @@ interface ModelEditSubmenuProps { fastControl: FastControl /** Whether this row's model is the active one. */ isActive: boolean - /** This row's model id — edits persist as its global preset. */ + /** This row's model id. */ model: string /** Switch to a specific model id (used to swap base ⇄ -fast variant). */ - onSelectModel: (model: string) => Promise | void - /** This row's provider slug — edits persist as its global preset. */ + onSelectModel: (model: string) => Promise | void + /** Report an option change. This submenu is PURE: it never writes to a + * session, a preset store, or the gateway itself — the owning surface's + * controller decides what an edit means. That's what lets the same submenu + * drive a live chat session and a detached per-task override. */ + onSetOptions: (patch: { effort?: string; fast?: boolean }) => void + /** This row's provider slug. */ provider: string /** Whether this model supports reasoning effort. */ reasoning: boolean - requestGateway: (method: string, params?: Record) => Promise } export function ModelEditSubmenu(props: ModelEditSubmenuProps) { @@ -108,72 +98,26 @@ export function ModelEditSubmenu(props: ModelEditSubmenuProps) { } function ModelEditSubmenuBody({ + defaultEffort, effort, fastControl, isActive, - model, onSelectModel, - provider, - reasoning, - requestGateway + onSetOptions, + reasoning }: ModelEditSubmenuProps) { const { t } = useI18n() const copy = t.shell.modelOptions - const view = useSessionView() - const activeSessionId = useStore(view.$runtimeId) - const touchesPrimary = view.kind === 'primary' - const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT const effortValue = resolveReasoningEffort(effort, defaultEffort) const thinkingOn = isThinkingEnabled(effort, defaultEffort) - // Editing always records the model's global preset (keyed by provider::model, - // not per-surface — a tile edit re-applies to that model everywhere); the - // active model also gets it pushed onto its OWN session (primary → globals, - // tile → its slice). Non-active edits stay preset-only — no model switch. - const patchReasoning = async (next: string) => { - setModelPreset(provider, model, { effort: next }) - - if (!isActive) { - return - } - - if (touchesPrimary) { - markComposerSelectionManual() - setCurrentReasoningEffort(next) - } else if (activeSessionId) { - sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: next })) - } - - // Preset-only without a session: `isActive` holds for the global/default - // row pre-session, and the gateway's `config.set` falls back to global - // config when none matches — so don't reach it (preset + optimistic store - // are the whole effect). Same guard in applyModelPreset / setFast. - if (!activeSessionId) { - return - } - - try { - await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next }) - } catch (err) { - if (touchesPrimary) { - setCurrentReasoningEffort(effort) - } else if (activeSessionId) { - sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: effort })) - } - - setModelPreset(provider, model, { effort }) - notifyError(err, copy.updateFailed) - } - } - const setFast = (enabled: boolean) => { if (fastControl.kind === 'variant') { - // Fast is a separate model id. Record the choice on the base model's - // preset (selectFamily picks the `-fast` sibling later when set), and - // only swap models now if this is the active row — inactive edits must - // stay preset-only, same as the param path below. - setModelPreset(provider, fastControl.baseId, { fast: enabled }) + // Fast is a separate model id. Report the choice so the controller can + // record it against the base model, and only swap models now if this is + // the active row — inactive edits stay preference-only. + onSetOptions({ fast: enabled }) if (isActive) { void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId) @@ -183,41 +127,7 @@ function ModelEditSubmenuBody({ } if (fastControl.kind === 'param') { - setModelPreset(provider, model, { fast: enabled }) - - if (!isActive) { - return - } - - if (touchesPrimary) { - markComposerSelectionManual() - setCurrentFastMode(enabled) - } else if (activeSessionId) { - sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: enabled })) - } - - // Preset-only without a session (see patchReasoning). - if (!activeSessionId) { - return - } - void (async () => { - try { - await requestGateway('config.set', { - key: 'fast', - session_id: activeSessionId, - value: enabled ? 'fast' : 'normal' - }) - } catch (err) { - if (touchesPrimary) { - setCurrentFastMode(!enabled) - } else if (activeSessionId) { - sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: !enabled })) - } - - setModelPreset(provider, model, { fast: !enabled }) - notifyError(err, copy.fastFailed) - } - })() + onSetOptions({ fast: enabled }) } } @@ -235,7 +145,7 @@ function ModelEditSubmenuBody({ void patchReasoning(checked ? effortValue || defaultEffort : 'none')} + onCheckedChange={checked => onSetOptions({ effort: checked ? effortValue || defaultEffort : 'none' })} size="xs" /> @@ -250,7 +160,7 @@ function ModelEditSubmenuBody({ <> {copy.effort} - void patchReasoning(value)} value={effortValue}> + onSetOptions({ effort: value })} value={effortValue}> {REASONING_EFFORTS.map(value => ( void>(() => {}) +export { ModelMenuCloseContext } from './model-catalog-menu' export interface ModelSelection { model: string @@ -62,16 +42,15 @@ interface ModelMenuPanelProps { requestGateway: (method: string, params?: Record) => Promise } -interface ProviderGroup { - families: ModelFamily[] - provider: ModelOptionProvider -} - +/** + * The composer's model menu: `ModelCatalogMenu` (the shared renderer) plus the + * controller that gives a selection its meaning HERE — write through to this + * surface's session, remember the pick as a global preset, keep the optimistic + * stores honest, and roll back on a failed gateway write. + */ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', requestGateway }: ModelMenuPanelProps) { const { t } = useI18n() const copy = t.shell.modelMenu - const closeMenu = useContext(ModelMenuCloseContext) - const [search, setSearch] = useState('') const [refreshing, setRefreshing] = useState(false) const queryClient = useQueryClient() // Bind to THIS surface's SessionView (primary or tile) so each pane's menu @@ -85,57 +64,15 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re const modelPresets = useStore($modelPresets) const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT const visibleModels = useStore($visibleModels) - const collapsedProviders = useStore($collapsedProviders) + const touchesPrimary = view.kind === 'primary' - const modelOptions = useQuery({ - queryKey: modelOptionsQueryKey(profile, activeSessionId), - // Gateway-first even with no session yet: a connected (possibly remote) - // gateway owns the model catalog, including virtual providers like `moa` - // that the local REST fallback can't know about (#53817). - queryFn: (): Promise => requestModelOptions({ gateway, sessionId: activeSessionId }) - }) + const cached = queryClient.getQueryData(modelOptionsQueryKey(profile, activeSessionId)) const { model: optionsModel, provider: optionsProvider } = currentPickerSelection( { model: currentModel, provider: currentProvider }, - modelOptions.data + cached ) - const loading = modelOptions.isPending && !modelOptions.data - - const error = modelOptions.error - ? modelOptions.error instanceof Error - ? modelOptions.error.message - : String(modelOptions.error) - : null - - const providers = modelOptions.data?.providers - - // The catalog carries MoA presets as a virtual `moa` provider row. Render - // them in their dedicated section below and keep the row out of the main - // provider groups so presets don't show up twice. - const moaPresets = useMemo( - () => providers?.find(provider => provider.slug.toLowerCase() === 'moa')?.models ?? [], - [providers] - ) - - const pickerProviders = useMemo( - () => providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? [], - [providers] - ) - - const effectiveVisibleModels = useMemo( - () => effectiveVisibleKeys(visibleModels, pickerProviders), - [visibleModels, pickerProviders] - ) - - // The composer picker never persists the profile default. With a session it - // scopes the switch to that session; with none it's UI state shipped on the - // next session.create (see selectModel). The default lives in Settings → Model. - // Always stamp sessionId from this surface so a tile switch never hits the - // primary (busy) session by accident. - const switchTo = (model: string, provider: string) => - onSelectModel({ model, provider, sessionId: activeSessionId || null }) - // Explicit "Refresh Models": re-fetch the catalog with refresh:true so the // backend busts its 1h provider-model disk cache and re-pulls each provider's // live list. Fixes live-only models (e.g. OpenCode Zen free tier) vanishing @@ -162,448 +99,148 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re } } - // Selecting a model row restores that model's remembered preset onto the - // session (effort/fast), gated by capability. Unset → Hermes defaults. - const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => { - const caps = provider.capabilities?.[family.id] - const preset = modelPresets[modelPresetKey(provider.slug, family.id)] ?? {} + // Push a reasoning change onto the session that owns it, with rollback. + const patchReasoning = async (next: string, previous: string, provider: string, model: string) => { + if (touchesPrimary) { + markComposerSelectionManual() + setCurrentReasoningEffort(next) + } else if (activeSessionId) { + sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: next })) + } - // Variant-fast models (no speed param) express "fast" as a separate `-fast` - // id, so honor the saved preset by selecting that sibling. Param-fast is - // applied via applyModelPreset below instead. - const variantFast = !(caps?.fast ?? false) && !!family.fastId - const targetId = variantFast && preset.fast === true ? family.fastId! : family.id - - if ((await switchTo(targetId, provider.slug)) === false) { + // Preset-only without a session: the gateway's `config.set` falls back to + // global config when none matches — so don't reach it (preset + optimistic + // store are the whole effect). + if (!activeSessionId) { return } - await applyModelPreset( - { - effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined, - fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined - }, - { + try { + await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next }) + } catch (err) { + if (touchesPrimary) { + setCurrentReasoningEffort(previous) + } else { + sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: previous })) + } + + setModelPreset(provider, model, { effort: previous }) + notifyError(err, t.shell.modelOptions.updateFailed) + } + } + + const patchFast = async (enabled: boolean, provider: string, model: string) => { + if (touchesPrimary) { + markComposerSelectionManual() + setCurrentFastMode(enabled) + } else if (activeSessionId) { + sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: enabled })) + } + + if (!activeSessionId) { + return + } + + try { + await requestGateway('config.set', { + key: 'fast', + session_id: activeSessionId, + value: enabled ? 'fast' : 'normal' + }) + } catch (err) { + if (touchesPrimary) { + setCurrentFastMode(!enabled) + } else { + sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: !enabled })) + } + + setModelPreset(provider, model, { fast: !enabled }) + notifyError(err, t.shell.modelOptions.fastFailed) + } + } + + const controller: ModelMenuController = { + // Selecting a model row restores that model's remembered preset onto the + // session (effort/fast). applyModelPreset owns the batched gateway write. + applyPreset: (preset, row) => { + setModelPreset(row.provider, row.model, preset) + + void applyModelPreset(preset, { failMessage: t.shell.modelOptions.updateFailed, - primary: view.kind === 'primary', + primary: touchesPrimary, request: requestGateway, sessionId: activeSessionId + }) + }, + + current: { + effort: currentReasoningEffort, + fast: currentFastMode, + model: optionsModel, + provider: optionsProvider + }, + + presetFor: (provider, model) => modelPresets[modelPresetKey(provider, model)] ?? {}, + + // The composer picker never persists the profile default. With a session it + // scopes the switch to that session; with none it's UI state shipped on the + // next session.create. Always stamp sessionId from this surface so a tile + // switch never hits the primary (busy) session by accident. + select: (model, provider) => onSelectModel({ model, provider, sessionId: activeSessionId || null }), + + setOptions: (patch, row) => { + // Editing always records the model's global preset (keyed by + // provider::model, not per-surface — a tile edit re-applies to that model + // everywhere); the active model also gets it pushed onto its OWN session. + // Non-active edits stay preset-only — no model switch, no session write. + if (patch.effort !== undefined || patch.fast !== undefined) { + setModelPreset(row.provider, row.model, patch) } - ) - } - // Selecting a MoA preset switches the session to it PERSISTENTLY, using the - // same path real provider selections use (onSelectModel → config.set with - // --session for live sessions → the gateway's persistent switch_model). - // Previously this dispatched the one-shot `/moa` command, which ran a single - // turn through MoA and then silently reverted to the prior model (#54670) — - // the dropdown presented presets like persistent selections but they weren't. - // No session gate: like regular model rows, a pre-session pick is UI state - // shipped on the next session.create. - const selectMoaPreset = async (preset: string) => { - if ((await switchTo(preset, 'moa')) === false) { - return - } + if (!row.isActive) { + return + } - closeMenu() - } + if (patch.effort !== undefined) { + void patchReasoning(patch.effort, currentReasoningEffort, row.provider, row.model) + } - const groups = useMemo( - () => - groupModels(pickerProviders, search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels), - [pickerProviders, search, optionsModel, optionsProvider, effectiveVisibleModels] - ) - - const q = normalize(search) - - // Presets are searchable rows like everything else — an unfiltered preset - // sitting under zero model matches would otherwise become the "first match" - // Enter commits. - const shownMoaPresets = useMemo( - () => (q ? moaPresets.filter(preset => `moa ${preset}`.toLowerCase().includes(q)) : moaPresets), - [moaPresets, q] - ) - - // ── Keyboard selection (cmdk semantics on a Radix menu) ─────────────────── - // One flat list mirroring EXACTLY what's rendered (collapse, filter, presets), - // so the selection can never sit on a hidden row. The selected index is - // derived — current model with no query (Enter = close), first match while - // typing — with an arrow-key override that resets on every keystroke. Focus - // stays in the search input throughout: ⌘⇧M → type → ↑/↓ → Enter. - type KbRow = - | { family: ModelFamily; key: string; kind: 'family'; provider: ModelOptionProvider } - | { key: string; kind: 'moa'; preset: string } - - const kbRows = useMemo( - () => [ - ...groups.flatMap(group => - collapsedProviders.includes(group.provider.slug) && !search - ? [] - : group.families.map((family): KbRow => ({ - family, - key: `${group.provider.slug}:${family.id}`, - kind: 'family', - provider: group.provider - })) - ), - ...shownMoaPresets.map((preset): KbRow => ({ key: `moa:${preset}`, kind: 'moa', preset })) - ], - [groups, collapsedProviders, search, shownMoaPresets] - ) - - const [kbOverride, setKbOverride] = useState(null) - // A parked cursor is not a cursor in use: until the mouse actually moves, - // hover can't take rows out from under the keyboard (rows re-flow beneath it - // as the filter narrows). One real movement hands hover back. - const pointerQuiet = usePointerQuiet() - - const currentKey = optionsProvider === 'moa' ? `moa:${optionsModel}` : `${optionsProvider}:${optionsModel}` - - const autoIndex = q - ? kbRows.length > 0 - ? 0 - : -1 - : kbRows.findIndex(row => row.key === currentKey || (row.kind === 'family' && row.family.fastId === optionsModel)) - - const kbIndex = kbOverride !== null && kbOverride < kbRows.length ? kbOverride : autoIndex - const kbActiveKey = kbIndex >= 0 ? kbRows[kbIndex].key : null - - const stepKb = (delta: -1 | 1) => { - if (kbRows.length === 0) { - return - } - - const from = kbIndex >= 0 ? kbIndex : delta === 1 ? -1 : 0 - - setKbOverride((from + delta + kbRows.length) % kbRows.length) - } - - const commitKbRow = () => { - const row = kbIndex >= 0 ? kbRows[kbIndex] : undefined - - if (!row) { - return - } - - if (row.kind === 'moa') { - void selectMoaPreset(row.preset) - - return - } - - if (row.key !== currentKey && row.family.fastId !== optionsModel) { - void selectFamily(row.family, row.provider) - } - - closeMenu() - } - - // Keep the selected row in view while arrowing through the scrollable list. - const listRef = useRef(null) - - useEffect(() => { - listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' }) - }, [kbActiveKey]) - - // The keyboard-selected row, styled + tagged for scrollIntoView. Pointer - // suppression is NOT here — it belongs on the containers (below), so one - // class covers every row inside them. - const kbRowProps = (key: string) => { - const active = kbActiveKey === key - - return { - className: cn(dropdownMenuRow, active && 'bg-(--ui-control-active-background) text-foreground'), - ...(active ? { 'data-kb-active': '' } : {}) + if (patch.fast !== undefined) { + void patchFast(patch.fast, row.provider, row.model) + } } } - // Rows are hover-selectable, so they go inert with the pointer (usePointerQuiet). - const quietRows = pointerQuiet && 'pointer-events-none' - return ( - <> - { - // Claim arrows and Enter from Radix so DOM focus stays in the input - // and Enter commits the highlighted row without a DownArrow first - // (VS Code's checked-or-first pattern). - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault() - event.stopPropagation() - stepKb(event.key === 'ArrowDown' ? 1 : -1) - } else if (event.key === 'Enter') { - event.preventDefault() - event.stopPropagation() - commitKbRow() - } - }} - onValueChange={value => { - setSearch(value) - setKbOverride(null) - }} - placeholder={copy.search} - value={search} - /> + + { + event.preventDefault() + void refreshModels() + }} + > + + {copy.refreshModels} + - - - {loading ? ( - - {Array.from({ length: 4 }, (_, index) => ( - event.preventDefault()} - > - - - ))} - - ) : error ? ( - - {error} - - ) : groups.length === 0 && moaPresets.length === 0 ? ( - - {copy.noModels} - - ) : ( -
- {groups.map(group => { - const slug = group.provider.slug - - // Collapsed when the user stored it (and not while searching, which - // spans every model regardless of collapse state). - const collapsed = collapsedProviders.includes(slug) && !search - - return ( - - { - event.preventDefault() - toggleCollapsedProvider(slug) - }} - textValue="" - > - - - - - - {!collapsed && - group.families.map(family => { - // The active id may be the base or its -fast sibling; either - // way this one family row represents both. - const activeId = - group.provider.slug === optionsProvider && - (optionsModel === family.id || optionsModel === family.fastId) - ? optionsModel - : null - - const isCurrent = activeId !== null - const name = modelDisplayParts(family.id).name - // Capabilities are looked up against the active/base id; the - // -fast variant carries the same param support as its base. - const caps = group.provider.capabilities?.[family.id] - - // Effective settings for this row: live session state when it's - // the active model, otherwise its remembered preset (Hermes - // defaults when unset). Row label AND submenu read from these so - // they never disagree. - const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {} - const effEffort = isCurrent ? currentReasoningEffort : (preset.effort ?? '') - const effFast = isCurrent ? currentFastMode : (preset.fast ?? false) - - const fastControl = resolveFastControl( - activeId ?? family.id, - group.provider.models ?? [], - caps?.fast ?? false, - effFast - ) - - const meta = [ - fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, - (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null - ] - .filter(Boolean) - .join(' ') - - // Every row is a hover-Edit submenu trigger. Activating it - // (pointer or keyboard) switches to the family's base model and - // restores its preset; the Fast toggle inside swaps to the -fast - // sibling (or flips the speed param). The sub-trigger has no - // `onSelect`, so wire both click and Enter/Space for keyboard parity. - // Clicking the row commits the model and closes the picker; the - // edit submenu (reasoning/fast) is reached by HOVER, so you can - // still tweak those without the click dismissing everything. - const activate = () => { - if (!isCurrent) { - void selectFamily(family, group.provider) - } - - closeMenu() - } - - return ( - - { - if (event.key === 'Enter' || event.key === ' ') { - activate() - } - }} - {...kbRowProps(`${group.provider.slug}:${family.id}`)} - > - - - {meta ? {meta} : null} - - {isCurrent ? ( - - ) : null} - - switchTo(nextModel, group.provider.slug)} - provider={group.provider.slug} - reasoning={caps?.reasoning ?? true} - requestGateway={requestGateway} - /> - - ) - })} - - ) - })} -
- )} - - - - {shownMoaPresets.length > 0 ? ( -
- MoA presets - {shownMoaPresets.map(preset => { - const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset - - return ( - { - event.preventDefault() - void selectMoaPreset(preset) - }} - {...kbRowProps(`moa:${preset}`)} - > - - MoA: - - {isCurrentMoa ? : null} - - ) - })} - -
- ) : null} - - { - event.preventDefault() - void refreshModels() - }} - > - - {copy.refreshModels} - - - setModelVisibilityOpen(true)} - > - - {copy.editModels} - - + setModelVisibilityOpen(true)} + > + + {copy.editModels} + + + } + gateway={gateway} + includeMoa + profile={profile} + visibleModels={visibleModels} + /> ) } - -// Collapsed we show the user's chosen models (or the curated default); typing -// spans every available model so anything is reachable past the cut. A search -// is itself a narrowing action, so we do NOT cap per-provider matches — a -// provider serving 19 models (e.g. opencode-go) must show all 19 when the user -// searches for it, not a truncated subset. (#47077 follow-up) - -function groupModels( - providers: ModelOptionProvider[], - search: string, - current: { model: string; provider: string }, - visible: Set | null -): ProviderGroup[] { - const q = normalize(search) - const groups: ProviderGroup[] = [] - - for (const provider of providers) { - const allFamilies = collapseModelFamilies(provider.models ?? []) - - if (allFamilies.length === 0) { - continue - } - - const matches = (family: ModelFamily) => - `${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}` - .toLowerCase() - .includes(q) - - // Which model ids to show (the active one is always added on top of this). - let shown: Set - - if (q) { - // Search spans every family, regardless of visibility. - shown = new Set(allFamilies.filter(matches).map(family => family.id)) - } else if (visible) { - // User has customized which models show — honor their selection exactly. - shown = new Set( - allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id) - ) - } else { - // Default: curated top-N families per provider. - shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id)) - } - - // Always include the active model — but keep every row in the provider's - // stable curated order (filter `allFamilies`, never reorder), so selecting - // a model can't shuffle the list. While SEARCHING, the pin is skipped: a - // query means "show me matches", and a pinned non-match sitting above them - // reads like the top result (type "grok", see the current Fable first). - const activeId = - !q && provider.slug === current.provider && current.model - ? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id - : undefined - - const families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId) - - if (families.length > 0) { - groups.push({ families, provider }) - } - } - - // Stable, logical group order: alphabetical by provider name. (The backend - // floats the current provider first, which would reshuffle on every switch.) - groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name)) - - return groups -} diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index ffbc3a3067bf0..1c1b696d37638 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -122,6 +122,18 @@ export { COMPOSER_AREAS, type ComposerAttachmentProvider, type ComposerMiddlewar export { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib' export { type RouteContribution, ROUTES_AREA, SIDEBAR_NAV_AREA, type SidebarNavContribution } from '@/app/routes' +/** THE model catalog menu — the same searchable, provider-grouped, family- + * collapsing picker the chat composer uses, including the per-row + * thinking/effort/fast submenu. Drive it with a `ModelMenuController`: the + * menu renders and navigates, your controller decides what a selection MEANS + * (write to a session, hold a per-task override, …). Never fork it — a copy + * drifts from the composer the first time either side changes. */ +export { + ModelCatalogMenu, + type ModelChoice, + ModelMenuCloseContext, + type ModelMenuController +} from '@/app/shell/model-catalog-menu' export type { StatusbarItem } from '@/app/shell/statusbar-controls' export type { TitlebarTool } from '@/app/shell/titlebar-controls' @@ -184,9 +196,6 @@ export { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' export { Textarea } from '@/components/ui/textarea' export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' export type { GatewayEventListener } from '@/contrib/events' - -// -- contracts ---------------------------------------------------------------- - export type { HermesPlugin, PluginContext, @@ -194,6 +203,9 @@ export type { PluginRestOptions, PluginStorage } from '@/contrib/plugin' + +// -- contracts ---------------------------------------------------------------- + /** Mount-scoped contribution: while the rendering component is mounted, its * children render in the target area's slot; unmount disposes it. Use for * page-owned chrome (a page's titlebar control leaves with the page) — @@ -228,12 +240,21 @@ export { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions' * authors) + its translucent tag fill — so plugin-rendered identities read * the same hue as everywhere else. */ export { profileColor, profileColorSoft } from '@/lib/profile-color' - -export const PANES_AREA = 'panes' /** The shared client itself, for invalidation OUTSIDE React (e.g. a * `ctx.socket` frame invalidating a query). Inside components keep using * `useQueryClient`. */ export { queryClient } from '@/lib/query-client' + +export const PANES_AREA = 'panes' +/** Hermes' reasoning levels + their compact labels, so a plugin surfacing a + * thinking depth uses the same scale and spelling as the rest of the app. */ +export { + DEFAULT_REASONING_EFFORT, + REASONING_EFFORT_VALUES, + REASONING_EFFORTS, + type ReasoningEffort, + reasoningEffortLabel +} from '@/lib/reasoning-effort' export const STATUSBAR_AREAS = { left: 'statusBar.left', right: 'statusBar.right' } as const export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left', right: 'titleBar.right' } as const From 602fc5f9f5aded91102ea3003931dbe40a6103ea Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 1 Aug 2026 16:10:25 -0500 Subject: [PATCH 4/6] feat(kanban): pick a task's model and thinking depth from the board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop board had no model control at all: a task ran whatever the profile you assigned it to happened to be configured with, and the only way to point one task elsewhere was `hermes kanban set-model` or the browser dashboard's flat provider:model select. Adds a Model row to New Task and to the task drawer, rendering the composer's own picker via the SDK — same search, same provider groups, same submenu — so the board and the chat bar cannot drift. Unset reads "Profile default" and changes nothing; a pin reads "provider: model · High" with an inline clear. Presets are read-only here: picking a model seeds the depth from what you last used for it, but a per-task choice never rewrites what the composer opens at. Fast mode is omitted rather than shown-and-ignored — it's a live-session request parameter with no worker-spawn equivalent. The New Task dialog opts out of DialogContent's clip: the dialog publishes itself as the portal container for popovers opened inside it, so its overflow-y-auto cropped the menu at the dialog's edge. The general fix is in flight as #75600; this override is scoped to one dialog to avoid conflicting with it and disappears when that lands. --- apps/desktop/src/plugins/kanban/board.tsx | 24 ++- apps/desktop/src/plugins/kanban/drawer.tsx | 11 ++ apps/desktop/src/plugins/kanban/i18n.ts | 20 +++ .../plugins/kanban/model-override.test.tsx | 148 +++++++++++++++ .../src/plugins/kanban/model-override.tsx | 170 ++++++++++++++++++ apps/desktop/src/plugins/kanban/types.ts | 5 + 6 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/plugins/kanban/model-override.test.tsx create mode 100644 apps/desktop/src/plugins/kanban/model-override.tsx diff --git a/apps/desktop/src/plugins/kanban/board.tsx b/apps/desktop/src/plugins/kanban/board.tsx index 5b069703a255a..2663fe7c86349 100644 --- a/apps/desktop/src/plugins/kanban/board.tsx +++ b/apps/desktop/src/plugins/kanban/board.tsx @@ -78,6 +78,12 @@ import { } from './api' import { BoardSwitcher } from './board-switcher' import { TaskDrawer } from './drawer' +import { + EMPTY_OVERRIDE, + ModelOverrideField, + overrideCreateFields, + type TaskModelOverride +} from './model-override' import { OrchestrationPanel } from './orchestration' import { columnMeta, type KanbanBoard, type KanbanTask, type TaskEstimate } from './types' import { @@ -570,6 +576,7 @@ function NewTaskDialog({ // a path here overrides just this task. Only meaningful for dir/worktree. const [workspacePath, setWorkspacePath] = useState('') const [parent, setParent] = useState('') + const [modelOverride, setModelOverride] = useState(EMPTY_OVERRIDE) const [goalMode, setGoalMode] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) @@ -602,6 +609,7 @@ function NewTaskDialog({ setWorkspaceKind(boardDefaultKind) setWorkspacePath('') setParent('') + setModelOverride(EMPTY_OVERRIDE) setGoalMode(false) setError(null) setBusy(false) @@ -637,6 +645,7 @@ function NewTaskDialog({ title: trimmed, triage: isTriage, workspace_kind: workspaceKind, + ...overrideCreateFields(modelOverride), // Empty → backend inherits the board's default project dir. workspace_path: workspaceKind !== 'scratch' && workspacePath.trim() ? workspacePath.trim() : undefined }) @@ -661,7 +670,15 @@ function NewTaskDialog({ return ( !open && onClose()} open={Boolean(target)}> - + {/* `overflow-visible`: DialogContent publishes ITSELF as the portal + container for popovers opened inside it (dialog-portal-context), and + its default `overflow-y-auto` then crops them at the dialog's edge — + the model menu below is born inside that scroll box. This dialog + already owns a scroller on its body div, so the shell's clip is + redundant here and dropping it is safe. The general fix to + DialogContent is in flight as #75600; when that lands this override + becomes a no-op and can go. */} + {target ? k.newTaskIn(columnLabel(k, target)) : k.newTask} @@ -742,6 +759,11 @@ function NewTaskDialog({ setSkills(event.target.value)} placeholder={k.skillsPlaceholder} value={skills} /> + + + {k.modelHint} + + {parents.length > 0 && (