diff --git a/agent/agent_init.py b/agent/agent_init.py index 2c2ded871e51a..3a648c1b95588 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -27,7 +27,7 @@ import time import uuid from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional from urllib.parse import urlparse, parse_qs, urlunparse from agent.context_compressor import ContextCompressor @@ -195,6 +195,7 @@ def init_agent( status_callback: callable = None, notice_callback: callable = None, notice_clear_callback: callable = None, + event_callback: Optional[Callable[[str, dict], None]] = None, max_tokens: int = None, reasoning_config: Dict[str, Any] = None, service_tier: str = None, @@ -426,6 +427,7 @@ def init_agent( agent.status_callback = status_callback agent.notice_callback = notice_callback agent.notice_clear_callback = notice_clear_callback + agent.event_callback = event_callback agent.tool_gen_callback = tool_gen_callback @@ -597,6 +599,7 @@ def init_agent( # (e.g. CLI voice mode adds a temporary prefix for the live call only). agent._persist_user_message_idx = None agent._persist_user_message_override = None + agent._persist_user_message_timestamp = None # Cache anthropic image-to-text fallbacks per image payload/URL so a # single tool loop does not repeatedly re-run auxiliary vision on the diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 3a2d3f68e17f4..4a586d7f0fd79 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -372,7 +372,7 @@ def _detect_claude_code_version() -> str: _CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude." -_MCP_TOOL_PREFIX = "mcp_" +_MCP_TOOL_PREFIX = "mcp__" def _get_claude_code_version() -> str: @@ -2349,25 +2349,46 @@ def build_anthropic_kwargs( text = text.replace("Nous Research", "Anthropic") block["text"] = text - # 3. Prefix tool names with mcp_ (Claude Code convention) - # Skip names that already begin with the marker — native MCP server - # tools (from mcp_servers: in config.yaml) are registered under their - # full mcp__ name and would double-prefix otherwise, - # breaking round-trip registry lookup in normalize_response. GH-25255. + # 3. Normalize tool names so NOTHING goes on the OAuth wire with a + # single-underscore ``mcp_`` prefix. Anthropic's subscription/OAuth + # billing classifier treats a single-underscore ``mcp_`` tool name as + # a third-party-app fingerprint and rejects the request with HTTP 400 + # "Third-party apps now draw from extra usage, not plan limits" + # (verified empirically: a single ``mcp_foo`` tool flips a request + # from plan-billing to the extra-usage lane; ``mcp__foo`` is accepted). + # + # Two cases, both must land on the double-underscore ``mcp__`` form: + # a) bare Hermes-native tools (``read_file``) -> ``mcp__read_file`` + # b) native MCP server tools registered under their full + # single-underscore ``mcp__`` name + # (``mcp_linear_get_issue``) -> ``mcp__linear_get_issue`` + # Case (b) is the gap that the bare ``mcp_``->``mcp__`` constant swap + # left open: those tools were *skipped* and stayed single-underscore, + # so any session with an MCP server configured still tripped the + # classifier. normalize_response reverses both forms via registry + # lookup so the dispatcher still sees the original name. GH-25255. + def _to_oauth_wire_name(name: str) -> str: + if name.startswith("mcp__"): + return name # already correct, don't double-prefix + if name.startswith("mcp_"): + # single-underscore native MCP tool -> promote to double + return "mcp__" + name[len("mcp_"):] + return _MCP_TOOL_PREFIX + name # bare name -> mcp__ + if anthropic_tools: for tool in anthropic_tools: - if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX): - tool["name"] = _MCP_TOOL_PREFIX + tool["name"] + if "name" in tool: + tool["name"] = _to_oauth_wire_name(tool["name"]) - # 4. Prefix tool names in message history (tool_use and tool_result blocks) + # 4. Apply the same normalization to tool names in message history + # (tool_use blocks) so replayed turns match the wire names above. for msg in anthropic_messages: content = msg.get("content") if isinstance(content, list): for block in content: if isinstance(block, dict): if block.get("type") == "tool_use" and "name" in block: - if not block["name"].startswith(_MCP_TOOL_PREFIX): - block["name"] = _MCP_TOOL_PREFIX + block["name"] + block["name"] = _to_oauth_wire_name(block["name"]) elif block.get("type") == "tool_result" and "tool_use_id" in block: pass # tool_result uses ID, not name diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index d5469a1b344f2..318e67d0faf2d 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -603,6 +603,20 @@ def _release_lock() -> None: force=True, ) + # Emit session:compress event so hooks (e.g. MemPalace sync) can ingest + # the completed old session before its details are lost. + _old_sid_for_event = locals().get("old_session_id") + if getattr(agent, "event_callback", None): + try: + agent.event_callback("session:compress", { + "platform": agent.platform or "", + "session_id": agent.session_id, + "old_session_id": _old_sid_for_event or "", + "compression_count": agent.context_compressor.compression_count, + }) + except Exception as e: + logger.debug("event_callback error on session:compress: %s", e) + # Keep the post-compression rough estimate for diagnostics, but do not # treat it as provider-reported prompt usage. Schema-heavy rough estimates # can remain above threshold even after the next real API request fits. diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 379a038a9e09b..099cefd36e15c 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -300,11 +300,20 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) agent.session_id, exc, ) - if stored_prompt: + if stored_prompt and _stored_prompt_matches_runtime(agent, stored_prompt): # Continuing session — reuse the exact system prompt from the # previous turn so the Anthropic cache prefix matches. agent._cached_system_prompt = stored_prompt return + if stored_prompt: + stored_state = "stale_runtime" + logger.info( + "Stored system prompt for session %s has stale runtime identity; " + "rebuilding for model=%s provider=%s.", + agent.session_id, + getattr(agent, "model", "") or "", + getattr(agent, "provider", "") or "", + ) if conversation_history and stored_state in ("null", "empty"): # Continuing session whose stored prompt is unusable. The @@ -366,6 +375,30 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) ) +def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: + """Return False when the persisted Model/Provider lines are stale.""" + + def line_value(label: str) -> str: + prefix = f"{label}:" + value = "" + for line in prompt.splitlines(): + if line.startswith(prefix): + value = line[len(prefix):].strip() + return value + + stored_model = line_value("Model") + current_model = str(getattr(agent, "model", "") or "").strip() + if stored_model and current_model and stored_model != current_model: + return False + + stored_provider = line_value("Provider") + current_provider = str(getattr(agent, "provider", "") or "").strip() + if stored_provider and current_provider and stored_provider != current_provider: + return False + + return True + + def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str: if is_partial_stub and dropped_tools: tool_list = ", ".join(dropped_tools[:3]) @@ -441,6 +474,7 @@ def run_conversation( task_id: str = None, stream_callback: Optional[callable] = None, persist_user_message: Optional[str] = None, + persist_user_timestamp: Optional[float] = None, ) -> Dict[str, Any]: """ Run a complete conversation with tool calling until completion. @@ -456,6 +490,8 @@ def run_conversation( persist_user_message: Optional clean user message to store in transcripts/history when user_message contains API-only synthetic prefixes. + persist_user_timestamp: Optional platform event timestamp to store + as metadata on that persisted user message. or queuing follow-up prefetch work. Returns: @@ -477,6 +513,7 @@ def run_conversation( task_id, stream_callback, persist_user_message, + persist_user_timestamp, restore_or_build_system_prompt=_restore_or_build_system_prompt, install_safe_stdio=_install_safe_stdio, sanitize_surrogates=_sanitize_surrogates, diff --git a/agent/curator.py b/agent/curator.py index 62630ce453bea..0ceebecbff20a 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -57,6 +57,11 @@ class _ReviewRuntimeBinding(NamedTuple): DEFAULT_MIN_IDLE_HOURS = 2 DEFAULT_STALE_AFTER_DAYS = 30 DEFAULT_ARCHIVE_AFTER_DAYS = 90 +# Consolidation (the LLM umbrella-building fork) is OFF by default. The +# deterministic inactivity prune (apply_automatic_transitions) still runs +# whenever the curator is enabled; only the opinionated, aux-model-cost +# consolidation pass is opt-in. +DEFAULT_CONSOLIDATE = False # --------------------------------------------------------------------------- @@ -182,6 +187,22 @@ def get_prune_builtins() -> bool: return bool(cfg.get("prune_builtins", True)) +def get_consolidate() -> bool: + """Whether the curator runs its LLM consolidation (umbrella-building) pass. + + OFF by default. When off, a curator run does ONLY the deterministic + inactivity prune (mark stale / archive long-unused skills) and skips the + forked aux-model review entirely — no consolidation, no umbrella-building, + no aux-model cost. Set ``curator.consolidate: true`` to opt back into the + LLM pass that merges overlapping skills into class-level umbrellas. + + The explicit ``hermes curator run --consolidate`` flag overrides this for + a single invocation regardless of the config value. + """ + cfg = _load_config() + return bool(cfg.get("consolidate", DEFAULT_CONSOLIDATE)) + + # --------------------------------------------------------------------------- # Idle / interval check # --------------------------------------------------------------------------- @@ -1408,25 +1429,38 @@ def run_curator_review( on_summary: Optional[Callable[[str], None]] = None, synchronous: bool = False, dry_run: bool = False, + consolidate: Optional[bool] = None, ) -> Dict[str, Any]: """Execute a single curator review pass. Steps: 1. Apply automatic state transitions (pure, no LLM). - 2. If there are agent-created skills, spawn a forked AIAgent that runs - the LLM review prompt against the current candidate list. + 2. If consolidation is enabled AND there are agent-created skills, spawn + a forked AIAgent that runs the LLM review prompt against the current + candidate list. 3. Update .curator_state with last_run_at and a one-line summary. 4. Invoke *on_summary* with a user-visible description. If *synchronous* is True, the LLM review runs in the calling thread; the default is to spawn a daemon thread so the caller returns immediately. + *consolidate* gates the LLM umbrella-building pass. ``None`` (the default) + reads ``curator.consolidate`` from config (OFF by default). Passing + ``True``/``False`` overrides the config for this invocation — used by the + ``hermes curator run --consolidate`` flag. When consolidation is off, only + the deterministic inactivity prune runs and the forked aux-model review is + skipped entirely (no aux-model cost). + If *dry_run* is True, the automatic stale/archive transitions are SKIPPED and the LLM review pass is instructed to produce a report only — no skill_manage mutations, no terminal archive moves. The REPORT.md still gets written and ``state.last_report_path`` still records it so users - can read what the curator WOULD have done. + can read what the curator WOULD have done. A dry-run also honors + *consolidate*: when consolidation is off, the preview only reports the + deterministic prune candidates. """ + if consolidate is None: + consolidate = get_consolidate() start = datetime.now(timezone.utc) if dry_run: # Count candidates without mutating state. @@ -1489,6 +1523,53 @@ def _llm_pass(): before_report = [] before_names = {r.get("name") for r in before_report if isinstance(r, dict)} + # Consolidation gate. When off (the default), the curator does ONLY the + # deterministic inactivity prune above — no forked aux-model review, no + # umbrella-building, no aux-model cost. Record the run, write a report + # reflecting the prune-only outcome, and return without spawning a fork. + if not consolidate: + final_summary = ( + f"{prefix}{auto_summary}; llm: skipped (consolidation off)" + ) + llm_meta = { + "final": "", + "summary": "skipped (consolidation off)", + "model": "", + "provider": "", + "tool_calls": [], + "error": None, + } + elapsed = (datetime.now(timezone.utc) - start).total_seconds() + state2 = load_state() + state2["last_run_duration_seconds"] = elapsed + state2["last_run_summary"] = final_summary + try: + after_report = skill_usage.agent_created_report() + except Exception: + after_report = [] + try: + report_path = _write_run_report( + started_at=start, + elapsed_seconds=elapsed, + auto_counts=counts, + auto_summary=auto_summary, + before_report=before_report, + before_names=before_names, + after_report=after_report, + llm_meta=llm_meta, + ) + if report_path is not None: + state2["last_report_path"] = str(report_path) + except Exception as e: + logger.debug("Curator report write failed: %s", e, exc_info=True) + save_state(state2) + if on_summary: + try: + on_summary(f"curator: {final_summary}") + except Exception: + pass + return + llm_meta: Dict[str, Any] = {} try: candidate_list = _render_candidate_list() diff --git a/agent/curator_backup.py b/agent/curator_backup.py index 944886d729a92..ddf8699e9bbf4 100644 --- a/agent/curator_backup.py +++ b/agent/curator_backup.py @@ -46,7 +46,7 @@ import tarfile from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple from hermes_constants import get_hermes_home from agent.skill_utils import is_excluded_skill_path @@ -208,13 +208,17 @@ def _write_manifest(dest: Path, reason: str, archive_path: Path, ) -def snapshot_skills(reason: str = "manual") -> Optional[Path]: +def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] = None) -> Optional[Path]: """Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones. Returns the snapshot directory path, or ``None`` if the snapshot was skipped (backup disabled, skills dir missing, or an IO error occurred — in which case we log at debug and return None so the curator never aborts a pass because of a backup failure). + + ``protect_ids`` is forwarded to the prune step so callers can guarantee + specific snapshot ids survive even when they fall outside the keep + window (rollback passes the id it is about to restore from). """ if not is_enabled(): logger.debug("Curator backup disabled by config; skipping snapshot") @@ -276,15 +280,19 @@ def snapshot_skills(reason: str = "manual") -> Optional[Path]: pass return None - _prune_old(keep=get_keep()) + _prune_old(keep=get_keep(), protect=protect_ids) logger.info("Curator snapshot created: %s (%s)", snap_id, reason) return dest -def _prune_old(keep: int) -> List[str]: +def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]: """Delete regular snapshots beyond the newest *keep*. Returns deleted - ids. Staging dirs (``.rollback-staging-*``) are implementation detail - and pruned independently on every call.""" + ids. Snapshot ids in *protect* are never deleted even when they fall + outside the keep window — rollback() uses this so the mandatory + pre-rollback safety snapshot can never evict the very snapshot being + restored. Staging dirs (``.rollback-staging-*``) are implementation + detail and pruned independently on every call.""" + protect = protect or set() backups = _backups_dir() if not backups.exists(): return [] @@ -305,6 +313,8 @@ def _prune_old(keep: int) -> List[str]: entries.sort(key=lambda t: t[0], reverse=True) deleted: List[str] = [] for _, path in entries[keep:]: + if path.name in protect: + continue try: shutil.rmtree(path) deleted.append(path.name) @@ -564,7 +574,13 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path] # out before touching anything — otherwise a failed extract could leave # the user with no skills. try: - snapshot_skills(reason=f"pre-rollback to {target.name}") + # Protect the target from this snapshot's prune step: at the steady + # keep limit, pruning the oldest snapshot would otherwise delete the + # very snapshot we are about to extract from. + snapshot_skills( + reason=f"pre-rollback to {target.name}", + protect_ids={target.name}, + ) except Exception as e: return (False, f"pre-rollback safety snapshot failed: {e}", None) diff --git a/agent/memory_manager.py b/agent/memory_manager.py index 240595a4eb374..dcd50a2997a1d 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -33,6 +33,7 @@ from typing import Any, Dict, List, Optional from agent.memory_provider import MemoryProvider +from agent.skill_commands import extract_user_instruction_from_skill_message from tools.registry import tool_error logger = logging.getLogger(__name__) @@ -430,16 +431,37 @@ def build_system_prompt(self) -> str: # -- Prefetch / recall --------------------------------------------------- + @staticmethod + def _strip_skill_scaffolding(text: str) -> Optional[str]: + """Return memory-worthy user text, or None to skip the turn. + + When a user invokes a /skill or /bundle, Hermes expands the turn into + a model-facing message that embeds the entire skill body. Feeding that + verbatim to memory providers pollutes their stores/embeddings with + prompt scaffolding instead of what the user actually asked. We recover + just the user's instruction here, once, for every provider — so this + is fixed for the whole provider fan-out, not per backend. + + - Non-skill messages pass through unchanged. + - Skill turns with a user instruction return that instruction. + - Bare skill invocations (no instruction) return None → callers skip + the turn, since there is no user content worth remembering. + """ + return extract_user_instruction_from_skill_message(text) + def prefetch_all(self, query: str, *, session_id: str = "") -> str: """Collect prefetch context from all providers. Returns merged context text labeled by provider. Empty providers are skipped. Failures in one provider don't block others. """ + clean_query = self._strip_skill_scaffolding(query) + if not clean_query: + return "" parts = [] for provider in self._providers: try: - result = provider.prefetch(query, session_id=session_id) + result = provider.prefetch(clean_query, session_id=session_id) if result and result.strip(): parts.append(result) except Exception as e: @@ -460,10 +482,14 @@ def queue_prefetch_all(self, query: str, *, session_id: str = "") -> None: if not providers: return + clean_query = self._strip_skill_scaffolding(query) + if not clean_query: + return + def _run() -> None: for provider in providers: try: - provider.queue_prefetch(query, session_id=session_id) + provider.queue_prefetch(clean_query, session_id=session_id) except Exception as e: logger.debug( "Memory provider '%s' queue_prefetch failed (non-fatal): %s", @@ -515,6 +541,11 @@ def sync_all( if not providers: return + clean_user_content = self._strip_skill_scaffolding(user_content) + if not clean_user_content: + return + user_content = clean_user_content + def _run() -> None: for provider in providers: try: diff --git a/agent/model_metadata.py b/agent/model_metadata.py index e31fcdea48db4..4d14826b9efee 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -275,6 +275,7 @@ def _save_model_metadata_disk_cache(data: Dict[str, Dict[str, Any]]) -> None: # via a custom provider. Values sourced from models.dev (2026-04). # Keys use substring matching (longest-first), so e.g. "grok-4.20" # matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309". + "grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI) "grok-build": 256000, # grok-build-0.1 "grok-code-fast": 256000, # grok-code-fast-1 "grok-2-vision": 8192, # grok-2-vision, -1212, -latest diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index b11cade39bd6c..bbae3c9a773de 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -8,6 +8,7 @@ import logging import os import threading +import contextvars from collections import OrderedDict from pathlib import Path @@ -957,6 +958,80 @@ def build_environment_hints() -> str: CONTEXT_TRUNCATE_HEAD_RATIO = 0.7 CONTEXT_TRUNCATE_TAIL_RATIO = 0.2 +# Dynamic-cap parameters (used when no explicit context_file_max_chars is set). +# The cap scales with the model's context window so large-context models rarely +# truncate a project doc, while small-context models stay at the historical +# 20K floor. ~4 chars/token is the usual English heuristic; we spend a small +# slice of the window on context files since they share the cached prefix with +# the system prompt, tools, memory, and the whole conversation. +_CONTEXT_FILE_CHARS_PER_TOKEN = 4 +_CONTEXT_FILE_WINDOW_FRACTION = 0.06 +_CONTEXT_FILE_DYNAMIC_CEILING = 500_000 + + +def _dynamic_context_file_max_chars(context_length: Optional[int]) -> int: + """Derive a char cap from the model's context window. + + Returns at least ``CONTEXT_FILE_MAX_CHARS`` (the historical 20K floor) and + at most ``_CONTEXT_FILE_DYNAMIC_CEILING``. When ``context_length`` is + unknown/invalid, returns the flat default so behavior is unchanged. + """ + if not isinstance(context_length, int) or context_length <= 0: + return CONTEXT_FILE_MAX_CHARS + budget = int( + context_length * _CONTEXT_FILE_CHARS_PER_TOKEN * _CONTEXT_FILE_WINDOW_FRACTION + ) + return max(CONTEXT_FILE_MAX_CHARS, min(budget, _CONTEXT_FILE_DYNAMIC_CEILING)) + + +def _get_context_file_max_chars(context_length: Optional[int] = None) -> int: + """Return the context-file truncation limit. + + Resolution order: + 1. Explicit ``context_file_max_chars`` in config.yaml — user knows best, + always wins (including over the dynamic cap). + 2. Dynamic cap derived from the model's ``context_length`` when provided + (scales the budget to the window; floor 20K, ceiling 500K). + 3. ``CONTEXT_FILE_MAX_CHARS`` (20K) as the upstream-compatible fallback. + """ + try: + from hermes_cli.config import load_config + + val = load_config().get("context_file_max_chars") + if isinstance(val, (int, float)) and val > 0: + return int(val) + except Exception as e: + logger.debug("Could not read context_file_max_chars from config: %s", e) + return _dynamic_context_file_max_chars(context_length) + +# Collect truncation warnings so the caller (run_agent) can surface them. +# A ContextVar (not a module-global list) isolates accumulation per thread / +# per async task, so concurrent gateway-session prompt builds can't drain or +# clear each other's pending warnings (cross-session leak). Each build runs in +# its own context, collects its own warnings, and drains them synchronously. +_truncation_warnings: "contextvars.ContextVar[Optional[list]]" = contextvars.ContextVar( + "context_file_truncation_warnings", default=None +) + + +def _record_truncation_warning(msg: str) -> None: + """Append a truncation warning to the current context's accumulator.""" + warnings = _truncation_warnings.get() + if warnings is None: + warnings = [] + _truncation_warnings.set(warnings) + warnings.append(msg) + + +def drain_truncation_warnings() -> list: + """Return and clear any truncation warnings accumulated in this context.""" + warnings = _truncation_warnings.get() + if not warnings: + return [] + drained = list(warnings) + warnings.clear() + return drained + # ========================================================================= # Skills prompt cache @@ -1463,19 +1538,47 @@ def _status_line(feature) -> str: # Context files (SOUL.md, AGENTS.md, .cursorrules) # ========================================================================= -def _truncate_content(content: str, filename: str, max_chars: int = CONTEXT_FILE_MAX_CHARS) -> str: - """Head/tail truncation with a marker in the middle.""" +def _truncate_content( + content: str, + filename: str, + max_chars: Optional[int] = None, + context_length: Optional[int] = None, + read_path: Optional[str] = None, +) -> str: + """Head/tail truncation with a marker in the middle. + + ``filename`` is the human label used in warnings. ``read_path`` is the + concrete path the agent should ``read_file`` to recover the full content + (defaults to ``filename`` when not supplied). ``context_length`` lets the + cap scale to the model's window when no explicit config override is set. + """ + if max_chars is None: + max_chars = _get_context_file_max_chars(context_length) if len(content) <= max_chars: return content + target = read_path or filename + msg = ( + f"⚠️ Context file {filename} TRUNCATED: " + f"{len(content)} chars exceeds limit of {max_chars} — " + f"trim the file, pin a larger context_file_max_chars, or use a " + f"larger-context model!" + ) + logger.warning(msg) + _record_truncation_warning(msg) head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO) tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO) head = content[:head_chars] tail = content[-tail_chars:] - marker = f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of {len(content)} chars. Use file tools to read the full file.]\n\n" + marker = ( + f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of " + f"{len(content)} chars. The middle is omitted — if you need the full " + f"instructions, read the complete file with the read_file tool: " + f"{target}]\n\n" + ) return head + marker + tail -def load_soul_md() -> Optional[str]: +def load_soul_md(context_length: Optional[int] = None) -> Optional[str]: """Load SOUL.md from HERMES_HOME and return its content, or None. Used as the agent identity (slot #1 in the system prompt). When this @@ -1496,14 +1599,17 @@ def load_soul_md() -> Optional[str]: if not content: return None content = _scan_context_content(content, "SOUL.md") - content = _truncate_content(content, "SOUL.md") + content = _truncate_content( + content, "SOUL.md", context_length=context_length, + read_path=str(soul_path), + ) return content except Exception as e: logger.debug("Could not read SOUL.md from %s: %s", soul_path, e) return None -def _load_hermes_md(cwd_path: Path) -> str: +def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str: """.hermes.md / HERMES.md — walk to git root.""" hermes_md_path = _find_hermes_md(cwd_path) if not hermes_md_path: @@ -1520,13 +1626,16 @@ def _load_hermes_md(cwd_path: Path) -> str: pass content = _scan_context_content(content, rel) result = f"## {rel}\n\n{content}" - return _truncate_content(result, ".hermes.md") + return _truncate_content( + result, ".hermes.md", context_length=context_length, + read_path=str(hermes_md_path), + ) except Exception as e: logger.debug("Could not read %s: %s", hermes_md_path, e) return "" -def _load_agents_md(cwd_path: Path) -> str: +def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str: """AGENTS.md — top-level only (no recursive walk).""" for name in ["AGENTS.md", "agents.md"]: candidate = cwd_path / name @@ -1536,13 +1645,16 @@ def _load_agents_md(cwd_path: Path) -> str: if content: content = _scan_context_content(content, name) result = f"## {name}\n\n{content}" - return _truncate_content(result, "AGENTS.md") + return _truncate_content( + result, "AGENTS.md", context_length=context_length, + read_path=str(candidate), + ) except Exception as e: logger.debug("Could not read %s: %s", candidate, e) return "" -def _load_claude_md(cwd_path: Path) -> str: +def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str: """CLAUDE.md / claude.md — cwd only.""" for name in ["CLAUDE.md", "claude.md"]: candidate = cwd_path / name @@ -1552,13 +1664,16 @@ def _load_claude_md(cwd_path: Path) -> str: if content: content = _scan_context_content(content, name) result = f"## {name}\n\n{content}" - return _truncate_content(result, "CLAUDE.md") + return _truncate_content( + result, "CLAUDE.md", context_length=context_length, + read_path=str(candidate), + ) except Exception as e: logger.debug("Could not read %s: %s", candidate, e) return "" -def _load_cursorrules(cwd_path: Path) -> str: +def _load_cursorrules(cwd_path: Path, context_length: Optional[int] = None) -> str: """.cursorrules + .cursor/rules/*.mdc — cwd only.""" cursorrules_content = "" cursorrules_file = cwd_path / ".cursorrules" @@ -1585,10 +1700,17 @@ def _load_cursorrules(cwd_path: Path) -> str: if not cursorrules_content: return "" - return _truncate_content(cursorrules_content, ".cursorrules") + return _truncate_content( + cursorrules_content, ".cursorrules", context_length=context_length, + read_path=str(cwd_path / ".cursorrules"), + ) -def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = False) -> str: +def build_context_files_prompt( + cwd: Optional[str] = None, + skip_soul: bool = False, + context_length: Optional[int] = None, +) -> str: """Discover and load context files for the system prompt. Priority (first found wins — only ONE project context type is loaded): @@ -1598,7 +1720,11 @@ def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = Fals 4. .cursorrules / .cursor/rules/*.mdc (cwd only) SOUL.md from HERMES_HOME is independent and always included when present. - Each context source is capped at 20,000 chars. + + Each context source is capped before injection. The cap defaults to the + model's context window (scaled — see ``_dynamic_context_file_max_chars``) + when *context_length* is provided, falling back to 20,000 chars otherwise. + An explicit ``context_file_max_chars`` in config.yaml always wins. When *skip_soul* is True, SOUL.md is not included here (it was already loaded via ``load_soul_md()`` for the identity slot). @@ -1611,17 +1737,17 @@ def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = Fals # Priority-based project context: first match wins project_context = ( - _load_hermes_md(cwd_path) - or _load_agents_md(cwd_path) - or _load_claude_md(cwd_path) - or _load_cursorrules(cwd_path) + _load_hermes_md(cwd_path, context_length) + or _load_agents_md(cwd_path, context_length) + or _load_claude_md(cwd_path, context_length) + or _load_cursorrules(cwd_path, context_length) ) if project_context: sections.append(project_context) # SOUL.md from HERMES_HOME only — skip when already loaded as identity if not skip_soul: - soul_content = load_soul_md() + soul_content = load_soul_md(context_length) if soul_content: sections.append(soul_content) diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 269c2fdd25eff..18264c44bd3bc 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -26,6 +26,91 @@ _SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]") _SKILL_MULTI_HYPHEN = re.compile(r"-{2,}") +# --------------------------------------------------------------------------- +# Skill-scaffolding markers and the canonical extractor. +# +# When a user invokes a /skill (or /bundle), Hermes expands the turn into a +# model-facing message that embeds the full skill body plus scaffolding. That +# expanded text is what flows into the agent loop — and into memory providers +# via MemoryManager. Providers that store or embed the raw user turn (mem0, +# openviking, hindsight, retaindb, byterover, honcho, supermemory) would +# otherwise capture the entire skill body instead of what the user actually +# asked. ``extract_user_instruction_from_skill_message`` recovers just the +# user's instruction so memory stays clean. +# +# These markers MUST stay byte-identical to the builders below +# (``_build_skill_message`` here, ``build_bundle_invocation_message`` in +# agent/skill_bundles.py). They are co-located with the single-skill builder +# on purpose, and the bundle markers are asserted against the bundle builder in +# tests/openviking_plugin/test_openviking.py::test_skill_markers_match_hermes_scaffolding. +# --------------------------------------------------------------------------- +_SKILL_INVOCATION_PREFIX = "[IMPORTANT: The user has invoked the " +_SINGLE_SKILL_MARKER = "The full skill content is loaded below.]" +_SINGLE_SKILL_INSTRUCTION = ( + "The user has provided the following instruction alongside the skill invocation: " +) +_RUNTIME_NOTE = "\n\n[Runtime note:" +_BUNDLE_MARKER = " skill bundle," +_BUNDLE_USER_INSTRUCTION = "\nUser instruction: " +_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the " + + +def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]: + """Recover the user's instruction from a slash-skill-expanded turn. + + Returns: + - The original string unchanged when it is NOT skill scaffolding + (a normal user message passes straight through). + - The extracted user instruction when the scaffolding carried one. + - ``None`` when the content is skill scaffolding with no user + instruction (i.e. a bare ``/skill`` invocation). Callers that feed + memory providers should skip the turn in that case — there is no + user content worth storing. + """ + if not isinstance(content, str): + return None + + if not content.startswith(_SKILL_INVOCATION_PREFIX): + return content + + if _BUNDLE_MARKER in content: + return _extract_bundle_user_instruction(content) + + if _SINGLE_SKILL_MARKER in content: + return _extract_single_skill_user_instruction(content) + + return None + + +def _extract_single_skill_user_instruction(message: str) -> Optional[str]: + # Single-skill format appends the user instruction after the skill body, so + # the last occurrence is the user-provided one; the body may quote this text. + marker_idx = message.rfind(_SINGLE_SKILL_INSTRUCTION) + if marker_idx < 0: + return None + + instruction = message[marker_idx + len(_SINGLE_SKILL_INSTRUCTION):] + runtime_idx = instruction.find(_RUNTIME_NOTE) + if runtime_idx >= 0: + instruction = instruction[:runtime_idx] + instruction = instruction.strip() + return instruction or None + + +def _extract_bundle_user_instruction(message: str) -> Optional[str]: + # Bundle format puts the user instruction before the loaded skills, so the + # first occurrence is the user-provided one. + marker_idx = message.find(_BUNDLE_USER_INSTRUCTION) + if marker_idx < 0: + return None + + instruction = message[marker_idx + len(_BUNDLE_USER_INSTRUCTION):] + first_skill_idx = instruction.find(_BUNDLE_FIRST_SKILL_BLOCK) + if first_skill_idx >= 0: + instruction = instruction[:first_skill_idx] + instruction = instruction.strip() + return instruction or None + def _resolve_skill_commands_platform() -> Optional[str]: """Return the current platform scope used for disabled-skill filtering. diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 6f68d3041b5e0..9f16534a450bf 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -43,14 +43,20 @@ ) ) +# Supporting files live inside a skill package and are loaded explicitly via +# skill_view(skill, file_path=...). They are not standalone skills and must not +# be scanned for active SKILL.md/DESCRIPTION.md entries, even if a Curator or +# archive workflow preserves a complete old skill package under references/. +SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts")) + def is_excluded_skill_path(path) -> bool: - """True if any component of *path* is in EXCLUDED_SKILL_DIRS. + """True if *path* should be skipped by active skill scanners. - Use this on every SKILL.md path produced by ``rglob`` to prune - dependency, virtualenv, VCS, and cache directories. Centralising the - check here keeps every skill-scanning site in sync with the shared - exclusion set. + Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to + prune dependency, virtualenv, VCS, cache, and progressive-disclosure + support-package paths. Centralising the check here keeps every + skill-scanning site in sync with the shared exclusion set. Accepts a Path or string. """ @@ -59,7 +65,36 @@ def is_excluded_skill_path(path) -> bool: except AttributeError: from pathlib import PurePath parts = PurePath(str(path)).parts - return any(part in EXCLUDED_SKILL_DIRS for part in parts) + return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path( + path + ) + + +def is_skill_support_path(path) -> bool: + """True if *path* is under a support dir of an actual skill root. + + ``references/``, ``templates/``, ``assets/``, and ``scripts/`` are + progressive-disclosure support areas when they sit directly inside a skill + directory containing ``SKILL.md``. They are not active discovery roots for + standalone skills. A preserved package such as + ``some-skill/references/old-skill-package/SKILL.md`` is documentation data + unless the caller explicitly loads it via ``file_path``. + + Legitimate categories or skill names such as ``skills/scripts/foo`` remain + discoverable because their ``scripts`` component is not directly under a + directory that contains ``SKILL.md``. + """ + path_obj = path if isinstance(path, Path) else Path(str(path)) + parts = path_obj.parts + # Last component may be a file or candidate skill directory name. Only + # components before the leaf can be containing support directories. + for idx, part in enumerate(parts[:-1]): + if part not in SKILL_SUPPORT_DIRS or idx == 0: + continue + skill_root = Path(*parts[:idx]) + if (skill_root / "SKILL.md").exists(): + return True + return False # ── Lazy YAML loader ───────────────────────────────────────────────────── @@ -661,12 +696,21 @@ def extract_skill_description(frontmatter: Dict[str, Any]) -> str: def iter_skill_index_files(skills_dir: Path, filename: str): """Walk skills_dir yielding sorted paths matching *filename*. - Excludes Hermes metadata, VCS, virtualenv/dependency, and cache - directories so dependencies cannot register nested skills. + Excludes Hermes metadata, VCS, virtualenv/dependency, cache, and skill + support directories. Support directories (references/templates/assets/ + scripts) can contain arbitrary markdown and even archived package + ``SKILL.md`` files, but they are progressive-disclosure data loaded through + ``skill_view(..., file_path=...)`` rather than active skill roots. """ matches = [] for root, dirs, files in os.walk(skills_dir, followlinks=True): - dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS] + has_skill_md = "SKILL.md" in files + dirs[:] = [ + d + for d in dirs + if d not in EXCLUDED_SKILL_DIRS + and not (has_skill_md and d in SKILL_SUPPORT_DIRS) + ] if filename in files: matches.append(Path(root) / filename) for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))): diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 76f57dfcdbc00..b3f39123fd541 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -40,6 +40,7 @@ TASK_COMPLETION_GUIDANCE, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, + drain_truncation_warnings, ) from agent.runtime_cwd import resolve_context_cwd @@ -82,6 +83,17 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # we resolve through ``_ra()`` to honor those patches. _r = _ra() + # Resolve the model's context window once so context-file caps can scale + # to it (dynamic cap — see prompt_builder._dynamic_context_file_max_chars). + # None falls back to the historical flat default. This value is stable for + # the life of the conversation, so it does not threaten prompt caching. + _ctx_len: Optional[int] = None + _cc = getattr(agent, "context_compressor", None) + if _cc is not None: + _cc_len = getattr(_cc, "context_length", None) + if isinstance(_cc_len, int) and _cc_len > 0: + _ctx_len = _cc_len + # ── Stable tier ──────────────────────────────────────────────── stable_parts: List[str] = [] @@ -90,7 +102,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # cwd project instructions disabled. _soul_loaded = False if agent.load_soul_identity or not agent.skip_context_files: - _soul_content = _r.load_soul_md() + _soul_content = _r.load_soul_md(_ctx_len) if _soul_content: stable_parts.append(_soul_content) _soul_loaded = True @@ -333,7 +345,8 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # dir — the user's real cwd there, but the install dir for the gateway # daemon, which is why the gateway sets TERMINAL_CWD. context_files_prompt = _r.build_context_files_prompt( - cwd=resolve_context_cwd(), skip_soul=_soul_loaded) + cwd=resolve_context_cwd(), skip_soul=_soul_loaded, + context_length=_ctx_len) if context_files_prompt: context_parts.append(context_files_prompt) @@ -400,7 +413,14 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str warm across turns. """ parts = build_system_prompt_parts(agent, system_message=system_message) - return "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p) + joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p) + + # Surface context-file truncation warnings through the normal agent status + # channel so gateway/CLI users see them in chat instead of only in logs. + for warning in drain_truncation_warnings(): + agent._emit_status(warning) + + return joined def invalidate_system_prompt(agent: Any) -> None: diff --git a/agent/transports/anthropic.py b/agent/transports/anthropic.py index aad49161385af..98721f7c5e638 100644 --- a/agent/transports/anthropic.py +++ b/agent/transports/anthropic.py @@ -88,7 +88,7 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse: from agent.transports.types import ToolCall strip_tool_prefix = kwargs.get("strip_tool_prefix", False) - _MCP_PREFIX = "mcp_" + _MCP_PREFIX = "mcp__" text_parts = [] reasoning_parts = [] @@ -132,17 +132,25 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse: elif block.type == "tool_use": name = block.name if strip_tool_prefix and name.startswith(_MCP_PREFIX): - stripped = name[len(_MCP_PREFIX):] - # Only strip the mcp_ prefix for OAuth-injected tools - # (where Hermes adds the prefix when sending to Anthropic - # and must remove it on the way back). Native MCP server - # tools (from mcp_servers: in config.yaml) are registered - # in the tool registry under their FULL mcp__ - # name and must NOT be stripped. GH-25255. + # On the OAuth wire every tool carries a double-underscore + # ``mcp__`` prefix (added in build_anthropic_kwargs to avoid + # Anthropic's single-underscore third-party classifier). + # Reverse it back to the name the registry/dispatcher knows. + # Two original forms map onto the same ``mcp__`` wire name: + # ``mcp__read_file`` <- bare native tool ``read_file`` + # ``mcp__linear_get_issue`` <- MCP server tool + # ``mcp_linear_get_issue`` + # Resolve by registry lookup, preferring whichever original + # is actually registered; never rewrite a name the LLM used + # that already resolves natively. GH-25255. from tools.registry import registry as _tool_registry - if (_tool_registry.get_entry(stripped) - and not _tool_registry.get_entry(name)): - name = stripped + if not _tool_registry.get_entry(name): + bare = name[len(_MCP_PREFIX):] # read_file + single = "mcp_" + bare # mcp_read_file / mcp_linear_get_issue + if _tool_registry.get_entry(single): + name = single + elif _tool_registry.get_entry(bare): + name = bare tool_calls.append( ToolCall( id=block.id, diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 1d24ac3355a79..eaf6160ae1d3a 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -218,10 +218,28 @@ def build_kwargs( kwargs.pop("timeout", None) if is_codex_backend: - # chatgpt.com/backend-api/codex rejects body-level - # ``extra_headers`` with HTTP 400. Correlation/cache routing for - # this backend must not be sent through the Responses payload. - kwargs.pop("extra_headers", None) + # The Codex backend rejects body-level ``extra_headers`` with + # HTTP 400, but the OpenAI SDK's ``extra_headers`` kwarg maps + # to actual HTTP request headers (not body fields). We need + # these headers for cache-scope routing so prompt cache hits + # remain high. Send session_id / x-client-request-id as HTTP + # headers while keeping ``prompt_cache_key`` in the body for + # standard OpenAI routing as a belt-and-braces fallback. + cache_scope_id = str(session_id or "").strip() + if cache_scope_id: + existing_extra_headers = kwargs.get("extra_headers") + merged_extra_headers: Dict[str, str] = {} + if isinstance(existing_extra_headers, dict): + merged_extra_headers.update( + { + str(key): str(value) + for key, value in existing_extra_headers.items() + if key and value is not None + } + ) + merged_extra_headers["session_id"] = cache_scope_id + merged_extra_headers["x-client-request-id"] = cache_scope_id + kwargs["extra_headers"] = merged_extra_headers max_tokens = params.get("max_tokens") if max_tokens is not None and not is_codex_backend: diff --git a/agent/turn_context.py b/agent/turn_context.py index e94d43279abd9..8041eabdb7f06 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -69,6 +69,7 @@ def build_turn_context( task_id: Optional[str], stream_callback, persist_user_message: Optional[str], + persist_user_timestamp: Optional[float] = None, *, restore_or_build_system_prompt, install_safe_stdio, @@ -121,6 +122,7 @@ def build_turn_context( agent._stream_callback = stream_callback agent._persist_user_message_idx = None agent._persist_user_message_override = persist_user_message + agent._persist_user_message_timestamp = persist_user_timestamp # Generate unique task_id if not provided to isolate VMs between tasks. effective_task_id = task_id or str(uuid.uuid4()) agent._current_task_id = effective_task_id diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 8bc1a2b7cf929..6d748c73b5f6a 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -9,6 +9,7 @@ import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' import type { ConversationStatus } from './hooks/use-voice-conversation' +import { ModelPill } from './model-pill' import type { ChatBarState, VoiceStatus } from './types' export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md' @@ -66,6 +67,7 @@ export function ComposerControls({ const c = t.composer const steerCombo = formatCombo('mod+enter') const steerLabel = `${c.steer} (${steerCombo})` + const steerTip = ( {c.steer} @@ -81,8 +83,10 @@ export function ComposerControls({ return (
- - {canSteer && ( + + {/* While the agent runs and the user is typing, steer takes over the mic's + slot rather than crowding the row with an extra button. */} + {canSteer ? ( + ) : ( + )} {showVoicePrimary ? ( diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx new file mode 100644 index 0000000000000..f04b6e2302b10 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -0,0 +1,86 @@ +import { useStore } from '@nanostores/react' +import { useState } from 'react' + +import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel' +import { Button } from '@/components/ui/button' +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { GlyphSpinner } from '@/components/ui/glyph-spinner' +import { useI18n } from '@/i18n' +import { ChevronDown } from '@/lib/icons' +import { formatModelStatusLabel } from '@/lib/model-status-label' +import { cn } from '@/lib/utils' +import { + $currentFastMode, + $currentModel, + $currentProvider, + $currentReasoningEffort, + setModelPickerOpen +} from '@/store/session' + +import type { ChatBarState } from './types' + +const PILL = cn( + 'h-(--composer-control-size) max-w-40 shrink-0 gap-1 rounded-md px-2 text-xs font-normal', + 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground' +) + +/** + * Composer model selector — the relocated status-bar pill. Reuses the live + * `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the + * full picker when the gateway is closed and no live menu exists. + */ +export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) { + const copy = useI18n().t.shell.statusbar + const currentModel = useStore($currentModel) + const currentProvider = useStore($currentProvider) + const fastMode = useStore($currentFastMode) + const reasoningEffort = useStore($currentReasoningEffort) + const [open, setOpen] = useState(false) + + // The model resolves a beat after the gateway/session comes up. Rather than + // flash a literal "No model", show a quiet loader (inherits the pill text + // color at half opacity) until a model lands. + const label = ( + <> + {currentModel.trim() ? ( + {formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })} + ) : ( + + )} + + + ) + + const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel + + if (!model.modelMenuContent) { + return ( + + ) + } + + return ( + + + + + + setOpen(false)}> + {model.modelMenuContent} + + + + ) +} diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts index 36b3b8e6d3d89..6d9444a6d9330 100644 --- a/apps/desktop/src/app/chat/composer/types.ts +++ b/apps/desktop/src/app/chat/composer/types.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react' + import type { HermesGateway } from '@/hermes' import type { ComposerAttachment } from '@/store/composer' @@ -22,6 +24,8 @@ export interface ChatBarState { canSwitch: boolean loading?: boolean quickModels?: QuickModelOption[] + /** Reused status-bar dropdown (built with gateway + selectModel upstream). */ + modelMenuContent?: ReactNode } tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] } voice: { enabled: boolean; active: boolean } diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index c9f525653e712..8982b14d5e66a 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -42,7 +42,7 @@ import { $sessions, sessionPinId } from '@/store/session' -import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows' +import { isSecondaryWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' import { routeSessionId } from '../routes' @@ -62,6 +62,7 @@ import { threadLoadingState } from './thread-loading' interface ChatViewProps extends Omit, 'onSubmit'> { gateway: HermesGateway | null + modelMenuContent?: React.ReactNode onToggleSelectedPin: () => void onDeleteSelectedSession: () => void onCancel: () => Promise | void @@ -120,10 +121,10 @@ function ChatHeader({ ? pinnedSessionIds.includes(selectedSessionId) : false - // A brand-new session has no session to pin/delete/rename, so the header is - // just a dead "New session" label + chevron. Drop it (and its border) - // entirely until there's a real session to act on. - if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) { + // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out) + // are compact side panels — they drop the session-actions header + border + // entirely. A brand-new draft has nothing to pin/delete/rename either. + if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) { return null } @@ -250,6 +251,7 @@ function ChatRuntimeBoundary({ export function ChatView({ className, gateway, + modelMenuContent, onToggleSelectedPin, onDeleteSelectedSession, onCancel, @@ -346,6 +348,7 @@ export function ChatView({ provider: currentProvider, canSwitch: gatewayOpen, loading: !gatewayOpen || (!currentModel && !currentProvider), + modelMenuContent, quickModels }, tools: { @@ -358,7 +361,7 @@ export function ChatView({ active: false } }), - [contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels] + [contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels] ) // Drop files anywhere in the conversation area, not just on the composer diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 5ff162a2ca4c9..45251ceef9b5c 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -711,7 +711,9 @@ export function DesktopController() { } lastGatewayProfileRef.current = activeGatewayProfile - void refreshCurrentModel() + // Force: the new profile has its own default, so reseed even if the composer + // already shows the previous profile's model. + void refreshCurrentModel(true) void refreshActiveProfile() }, [activeGatewayProfile, refreshCurrentModel]) @@ -859,7 +861,6 @@ export function DesktopController() { gatewayLogLines, gatewayState, inferenceStatus, - modelMenuContent, openAgents, freshDraftReady, openCommandCenterSection, @@ -981,6 +982,7 @@ export function DesktopController() { composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)} onAttachDroppedItems={composer.attachDroppedItems} diff --git a/apps/desktop/src/app/right-sidebar/store.ts b/apps/desktop/src/app/right-sidebar/store.ts index 8c07f0824506e..b0e26f038862a 100644 --- a/apps/desktop/src/app/right-sidebar/store.ts +++ b/apps/desktop/src/app/right-sidebar/store.ts @@ -9,3 +9,22 @@ export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false)) $terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active)) export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active) + +/** A command queued to run in the embedded terminal. The terminal pane flushes + * (and clears) it once its session is live, so a value set before the pane + * mounts still runs. Cleared after flush so a later remount can't replay it. */ +export const $terminalInjection = atom(null) + +/** Open the terminal pane and run a command in it. Used to disconnect external + * (CLI-managed) providers, which Hermes can't clear via the API — the user + * sees exactly what runs instead of Hermes silently deleting their creds. */ +export const runInTerminal = (command: string) => { + const trimmed = command.trim() + + if (!trimmed) { + return + } + + setTerminalTakeover(true) + $terminalInjection.set(trimmed) +} diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index 1e5d4d275b726..3479ed6db2f82 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -10,6 +10,8 @@ import { triggerHaptic } from '@/lib/haptics' import { $filePreviewTarget, $previewTarget } from '@/store/preview' import { useTheme } from '@/themes/context' +import { $terminalInjection } from '../store' + import { makeTerminalReader, setActiveTerminalReader } from './buffer' import { isAddSelectionShortcut, @@ -675,6 +677,28 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes return () => cancelAnimationFrame(raf) }, [activeTheme, themeName]) + // Flush a queued command (e.g. a provider-disconnect) into the live session. + // Only active while open; the subscribe fires immediately, so a command set + // before this pane mounted runs as soon as the session is ready. Clearing the + // atom after writing stops a later remount from replaying a stale command. + useEffect(() => { + if (status !== 'open') { + return + } + + return $terminalInjection.subscribe(command => { + const id = sessionIdRef.current + + if (!command || !id) { + return + } + + void window.hermesDesktop?.terminal?.write(id, `${command}\r`) + $terminalInjection.set(null) + termRef.current?.focus() + }) + }, [status]) + return { addSelectionToChat, hostRef, diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index c07222c689073..3ee52ec8eb7d4 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -1102,8 +1102,13 @@ export function useMessageStream({ if (looksLikeProviderSetup) { requestDesktopOnboarding(errorMessage) - } else if (isActiveEvent) { + } else { + // Toast globally, not just when the failing thread is focused: a + // turn-ending error (e.g. out of funds) blocks every thread, so the + // inline error alone is too easy to miss. The stable id collapses the + // same error from multiple blocked threads into one toast. notify({ + id: `gateway-error:${errorMessage}`, kind: 'error', title: 'Hermes error', message: errorMessage diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx index 612290800e065..f7765de04c595 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-model-controls.test.tsx @@ -130,7 +130,6 @@ describe('useModelControls', () => { await expect( controls.selectModel({ model: 'claude-sonnet-4.6', - persistGlobal: false, provider: 'anthropic' }) ).resolves.toBe(true) @@ -143,26 +142,57 @@ describe('useModelControls', () => { expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) }) - it('keeps the global path on setGlobalModel when there is no active session', async () => { - setGlobalModel.mockResolvedValue(undefined) + it('stores a no-session pick as UI state with no gateway or global write', async () => { + const requestGateway = vi.fn() let controls!: Controls render( (controls = value)} - requestGateway={vi.fn()} + requestGateway={requestGateway} /> ) await expect( controls.selectModel({ model: 'claude-sonnet-4.6', - persistGlobal: false, provider: 'anthropic' }) ).resolves.toBe(true) - expect(setGlobalModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4.6') + // The pick is plain UI state; session.create ships it later. Nothing touches + // the gateway or the profile default here. + expect($currentModel.get()).toBe('claude-sonnet-4.6') + expect($currentProvider.get()).toBe('anthropic') + expect(requestGateway).not.toHaveBeenCalled() + expect(setGlobalModel).not.toHaveBeenCalled() + }) + + it('seeds an empty composer model from global but never clobbers a pick', async () => { + vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' }) + + const { result } = renderHook(() => + useModelControls({ + activeSessionId: null, + queryClient: new QueryClient(), + requestGateway: vi.fn() + }) + ) + + // Empty → seeds the default. + await result.current.refreshCurrentModel() + expect($currentModel.get()).toBe('openai/gpt-5.5') + + // A user pick must survive the lifecycle refreshes that fire on boot / fresh + // draft / session events. + setCurrentModel('anthropic/claude-sonnet-4.6') + setCurrentProvider('anthropic') + await result.current.refreshCurrentModel() + expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6') + + // A profile swap forces a reseed to the new profile's default. + await result.current.refreshCurrentModel(true) + expect($currentModel.get()).toBe('openai/gpt-5.5') }) }) diff --git a/apps/desktop/src/app/session/hooks/use-model-controls.ts b/apps/desktop/src/app/session/hooks/use-model-controls.ts index 681eac871a21f..50788b1e0befe 100644 --- a/apps/desktop/src/app/session/hooks/use-model-controls.ts +++ b/apps/desktop/src/app/session/hooks/use-model-controls.ts @@ -1,7 +1,7 @@ import { type QueryClient } from '@tanstack/react-query' import { useCallback } from 'react' -import { getGlobalModelInfo, setGlobalModel } from '@/hermes' +import { getGlobalModelInfo } from '@/hermes' import { useI18n } from '@/i18n' import { notifyError } from '@/store/notifications' import { @@ -15,7 +15,6 @@ import type { ModelOptionsResponse } from '@/types/hermes' interface ModelSelection { model: string - persistGlobal: boolean provider: string } @@ -28,6 +27,7 @@ interface ModelControlsOptions { export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) { const { t } = useI18n() const copy = t.desktop + const updateModelOptionsCache = useCallback( (provider: string, model: string, includeGlobal: boolean) => { const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model }) @@ -41,14 +41,24 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway [activeSessionId, queryClient] ) - const refreshCurrentModel = useCallback(async () => { + // Seed the composer's model state from the profile default. `force` reseeds + // for a profile swap (the new profile has its own default); otherwise this + // only fills an EMPTY selection so a user's pick (plain UI state in + // $currentModel) survives the lifecycle refreshes that fire on boot / fresh + // draft / session events. A live session owns the footer, so skip entirely. + const refreshCurrentModel = useCallback(async (force = false) => { try { + if ($activeSessionId.get()) { + return + } + + if (!force && $currentModel.get()) { + return + } + const result = await getGlobalModelInfo() - // A resumed/live session owns the footer model state. Global config - // refreshes (gateway boot, profile swap, settings save) must not clobber - // the active chat's runtime model/provider in the status bar. - if ($activeSessionId.get()) { + if ($activeSessionId.get() || (!force && $currentModel.get())) { return } @@ -64,12 +74,14 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway } }, []) - // Returns whether the switch succeeded so callers can await it before - // applying follow-up changes (e.g. editing a model's reasoning/fast must land - // on the right active model — bail rather than write to the previous one). + // Returns whether the switch succeeded so callers can await it before applying + // follow-up changes. The composer model is plain UI state: with no live + // session it's just stored (and shipped on the next session.create); with one + // it's scoped to that session via config.set. It NEVER writes the profile + // default — that lives in Settings → Model — so picking a model here can't + // silently mutate global config. const selectModel = useCallback( async (selection: ModelSelection): Promise => { - const includeGlobal = selection.persistGlobal || !activeSessionId // Snapshot for rollback: the switch is applied optimistically, so a // failure must restore the prior model/provider (store + query cache) // rather than leave the UI showing a model the backend never selected. @@ -78,42 +90,34 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway setCurrentModel(selection.model) setCurrentProvider(selection.provider) - updateModelOptionsCache(selection.provider, selection.model, includeGlobal) - - try { - if (activeSessionId) { - await requestGateway('config.set', { - session_id: activeSessionId, - key: 'model', - value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}` - }) - - if (selection.persistGlobal) { - void refreshCurrentModel() - } + updateModelOptionsCache(selection.provider, selection.model, !activeSessionId) - void queryClient.invalidateQueries({ - queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId] - }) + // No live session yet: the pick is pure UI state. session.create reads + // $currentModel/$currentProvider and applies it as that session's override. + if (!activeSessionId) { + return true + } - return true - } + try { + await requestGateway('config.set', { + session_id: activeSessionId, + key: 'model', + value: `${selection.model} --provider ${selection.provider}` + }) - await setGlobalModel(selection.provider, selection.model) - void refreshCurrentModel() - void queryClient.invalidateQueries({ queryKey: ['model-options'] }) + void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] }) return true } catch (err) { setCurrentModel(prevModel) setCurrentProvider(prevProvider) - updateModelOptionsCache(prevProvider, prevModel, includeGlobal) + updateModelOptionsCache(prevProvider, prevModel, !activeSessionId) notifyError(err, copy.modelSwitchFailed) return false } }, - [activeSessionId, copy.modelSwitchFailed, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache] + [activeSessionId, copy.modelSwitchFailed, queryClient, requestGateway, updateModelOptionsCache] ) return { refreshCurrentModel, selectModel, updateModelOptionsCache } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 50b6bb0d27083..6f7a779e8ea5a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -15,6 +15,10 @@ import { requestDesktopOnboarding } from '@/store/onboarding' import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' import { $currentCwd, + $currentFastMode, + $currentModel, + $currentProvider, + $currentReasoningEffort, $messages, $sessions, $yoloActive, @@ -407,13 +411,13 @@ export function useSessionActions({ }) setSessionStartedAt(null) setTurnStartedAt(null) - // New chats start in the configured default project dir when set, - // otherwise the sticky last-used workspace (PR #37586). - setCurrentModel('') - setCurrentProvider('') - setCurrentReasoningEffort('') + // The composer's model/effort/fast is sticky UI state (persisted in + // localStorage) — a new chat FOLLOWS your last pick instead of snapping + // back to the profile default, so we deliberately don't reset it here. The + // profile default still owns first-run seeding and profile switches (see + // refreshCurrentModel). Only $currentServiceTier (a live-session mirror) + // is cleared. setCurrentServiceTier('') - setCurrentFastMode(false) setYoloActive(false) setCurrentCwd(workspaceCwdForNewSession()) setCurrentBranch('') @@ -443,11 +447,23 @@ export function useSessionActions({ const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get()) await ensureGatewayProfile(newChatProfile) const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession() + // The composer's model/effort/fast is sticky UI state ($currentModel, + // $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it + // with every session.create so the new chat opens on whatever the picker + // shows — applied as per-session overrides, never written to the profile + // default (that lives in Settings → Model). + const uiModel = $currentModel.get().trim() + const uiProvider = $currentProvider.get().trim() + const uiEffort = $currentReasoningEffort.get().trim() + const uiFast = $currentFastMode.get() const created = await requestGateway('session.create', { cols: 96, ...(cwd && { cwd }), - ...(newChatProfile ? { profile: newChatProfile } : {}) + ...(newChatProfile ? { profile: newChatProfile } : {}), + ...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}), + ...(uiEffort ? { reasoning_effort: uiEffort } : {}), + ...(uiFast ? { fast: true } : {}) }) const stored = created.stored_session_id ?? null diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx b/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx index e2a9735827318..681334aa2dcc7 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.test.tsx @@ -2,12 +2,14 @@ import { act, cleanup, render } from '@testing-library/react' import type { MutableRefObject } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ChatMessage } from '@/lib/chat-messages' import { $currentFastMode, $currentModel, $currentProvider, $currentReasoningEffort, $currentServiceTier, + $messages, $turnStartedAt, setCurrentFastMode, setCurrentModel, @@ -213,3 +215,113 @@ describe('useSessionStateCache — per-session turn timer', () => { expect($currentFastMode.get()).toBe(false) }) }) + +function userMessage(id: string, text: string): ChatMessage { + return { id, role: 'user', parts: [{ type: 'text', text }] } +} + +function assistantText(id: string, text: string): ChatMessage { + return { id, role: 'assistant', parts: [{ type: 'text', text }] } +} + +function assistantError(id: string, error: string): ChatMessage { + return { id, role: 'assistant', parts: [], error, pending: false } +} + +interface ViewHarnessProps { + activeSessionId: string | null + onReady: (cache: Cache) => void +} + +function ViewHarness({ activeSessionId, onReady }: ViewHarnessProps) { + const busyRef: MutableRefObject = { current: false } + const cache = useSessionStateCache({ + activeSessionId, + busyRef, + selectedStoredSessionId: null, + setAwaitingResponse: () => undefined, + setBusy: () => undefined, + // Wire the published view back into the real $messages atom the flush + // reads from, so the round-trip matches production. + setMessages: messages => $messages.set(messages) + }) + + onReady(cache) + + return null +} + +describe('useSessionStateCache — cross-thread error isolation', () => { + afterEach(() => { + cleanup() + $messages.set([]) + }) + + it('does not leak a failed turn into another thread on switch', () => { + $messages.set([]) + let cache!: Cache + const { rerender } = render( (cache = c)} />) + + // Thread A ends its turn with an out-of-funds error and is on screen. + act(() => { + cache.updateSessionState( + 'thread-A', + state => ({ + ...state, + busy: false, + messages: [userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'Out of funds')] + }), + 'stored-A' + ) + }) + + expect($messages.get().some(message => message.error === 'Out of funds')).toBe(true) + + // Switch to thread B (which completed cleanly). Its cached state syncs to + // the view while $messages still holds thread A's transcript. + rerender( (cache = c)} />) + act(() => { + cache.updateSessionState( + 'thread-B', + state => ({ + ...state, + busy: false, + messages: [userMessage('user-b', 'hello'), assistantText('assistant-b', 'hi there')] + }), + 'stored-B' + ) + }) + + expect($messages.get().map(message => message.id)).toEqual(['user-b', 'assistant-b']) + expect($messages.get().some(message => message.error === 'Out of funds')).toBe(false) + }) + + it('still preserves a same-session local error a heartbeat dropped', () => { + $messages.set([]) + let cache!: Cache + render( (cache = c)} />) + + // First paint establishes thread A as the on-screen session. + act(() => { + cache.updateSessionState( + 'thread-A', + state => ({ ...state, busy: false, messages: [userMessage('user-a', 'do the thing')] }), + 'stored-A' + ) + }) + + // A local error lands in the view (e.g. failAssistantMessage wrote it). + $messages.set([userMessage('user-a', 'do the thing'), assistantError('assistant-a-error', 'OpenRouter 403')]) + + // A later same-session heartbeat carries cached state that lost the error. + act(() => { + cache.updateSessionState('thread-A', state => ({ + ...state, + busy: false, + messages: [userMessage('user-a', 'do the thing')] + })) + }) + + expect($messages.get().some(message => message.error === 'OpenRouter 403')).toBe(true) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index a08eb1f16c9d1..1445dd17a7556 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -79,6 +79,9 @@ export function useSessionStateCache({ const runtimeIdByStoredSessionIdRef = useRef(new Map()) const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null) const viewSyncRafRef = useRef(null) + // Runtime id whose transcript currently occupies `$messages` — lets the + // flush below tell a same-session refresh from a thread switch. + const viewSessionIdRef = useRef(null) useEffect(() => { activeSessionIdRef.current = activeSessionId @@ -142,12 +145,22 @@ export function useSessionStateCache({ // jerks the scroll position while the user is reading. Skip the publish when // the merged result is content-identical to what's already on screen. const currentMessages = $messages.get() - const nextMessages = preserveLocalAssistantErrors(pending.state.messages, currentMessages) + // On a thread switch `$messages` still holds the *previous* thread, so + // preserving its local errors would graft that thread's failed turn (e.g. + // an out-of-funds error) onto this one — then cascade it everywhere as the + // polluted view becomes the next switch's baseline. Only carry errors + // across a same-session refresh; our cached state already keeps its own. + const nextMessages = + viewSessionIdRef.current === pending.sessionId + ? preserveLocalAssistantErrors(pending.state.messages, currentMessages) + : pending.state.messages if (!sameMessageList(nextMessages, currentMessages)) { setMessages(nextMessages) } + viewSessionIdRef.current = pending.sessionId + syncRuntimeMetadataToView(pending.state) setBusy(pending.state.busy) setMutableRef(busyRef, pending.state.busy) diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 6c832799eb239..ecf0f29377d80 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -228,7 +228,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang onMainModelChanged={onMainModelChanged} /> ) : activeView === 'providers' ? ( - + ) : activeView === 'keys' ? ( ) : activeView === 'mcp' ? ( diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index a0b1afdc95813..afe267b5fdace 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -16,6 +16,8 @@ const getAuxiliaryModels = vi.fn() const setModelAssignment = vi.fn() const getRecommendedDefaultModel = vi.fn() const setEnvVar = vi.fn() +const getHermesConfigRecord = vi.fn() +const saveHermesConfig = vi.fn() const startManualProviderOAuth = vi.fn() vi.mock('@/hermes', () => ({ @@ -24,7 +26,9 @@ vi.mock('@/hermes', () => ({ getAuxiliaryModels: () => getAuxiliaryModels(), setModelAssignment: (body: unknown) => setModelAssignment(body), getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug), - setEnvVar: (key: string, value: string) => setEnvVar(key, value) + setEnvVar: (key: string, value: string) => setEnvVar(key, value), + getHermesConfigRecord: () => getHermesConfigRecord(), + saveHermesConfig: (config: unknown) => saveHermesConfig(config) })) vi.mock('@/store/onboarding', () => ({ @@ -35,7 +39,13 @@ beforeEach(() => { getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' }) getGlobalModelOptions.mockResolvedValue({ providers: [ - { name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true }, + { + name: 'Nous', + slug: 'nous', + models: ['hermes-4', 'hermes-4-mini'], + authenticated: true, + capabilities: { 'hermes-4': { reasoning: true, fast: true } } + }, // An unconfigured api_key provider — surfaced by the full-universe payload. { name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' } ] @@ -47,6 +57,8 @@ beforeEach(() => { setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] }) getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null }) setEnvVar.mockResolvedValue({ ok: true }) + getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } }) + saveHermesConfig.mockResolvedValue({ ok: true }) }) afterEach(() => { @@ -100,6 +112,31 @@ describe('ModelSettings', () => { await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123')) }) + it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => { + await renderModelSettings() + await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled()) + + const fastSwitch = await screen.findByRole('switch') + fireEvent.click(fastSwitch) + + await waitFor(() => + expect(saveHermesConfig).toHaveBeenCalledWith( + expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) }) + ) + ) + }) + + it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => { + getGlobalModelOptions.mockResolvedValueOnce({ + providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4'], authenticated: true, capabilities: { 'hermes-4': { reasoning: false, fast: false } } }] + }) + + await renderModelSettings() + await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled()) + + expect(screen.queryByRole('switch')).toBeNull() + }) + it('renders the auxiliary task rows', async () => { await renderModelSettings() diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index c55fa6b477311..e88938def0663 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -3,11 +3,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, + getHermesConfigRecord, getRecommendedDefaultModel, + saveHermesConfig, setEnvVar, setModelAssignment } from '@/hermes' @@ -15,11 +18,26 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment } import { useI18n } from '@/i18n' import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons' import { cn } from '@/lib/utils' +import { notifyError } from '@/store/notifications' import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding' +import type { HermesConfigRecord } from '@/types/hermes' import { CONTROL_TEXT } from './constants' +import { getNested, setNested } from './helpers' import { ListRow, LoadingState, Pill, SectionHeading } from './primitives' +// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off. +// Empty config = Hermes default (medium), shown as Medium. +const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const + +// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is +// normal (mirrors tui_gateway _load_service_tier). +const isFastTier = (tier: unknown): boolean => + ['fast', 'priority', 'on'].includes(String(tier ?? '').trim().toLowerCase()) + +// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1). +const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal' + // A provider row is "ready" to pick a model from when it reports models. The // backend now surfaces the full `hermes model` universe (every canonical // provider), so unconfigured providers come back with `authenticated:false` @@ -97,6 +115,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { const [selectedProvider, setSelectedProvider] = useState('') const [selectedModel, setSelectedModel] = useState('') const [auxiliary, setAuxiliary] = useState(null) + // Full profile config, kept so the reasoning/speed defaults round-trip + // (read agent.* → write back the whole record) like the generic config page. + const [config, setConfig] = useState(null) const [applying, setApplying] = useState(false) const [editingAuxTask, setEditingAuxTask] = useState(null) const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' }) @@ -113,10 +134,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { setError('') try { - const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([ + const [modelInfo, modelOptions, auxiliaryModels, cfg] = await Promise.all([ getGlobalModelInfo(), getGlobalModelOptions(), - getAuxiliaryModels() + getAuxiliaryModels(), + getHermesConfigRecord() ]) setMainModel({ model: modelInfo.model, provider: modelInfo.provider }) @@ -124,6 +146,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { setSelectedProvider(prev => prev || modelInfo.provider) setSelectedModel(prev => prev || modelInfo.model) setAuxiliary(auxiliaryModels) + setConfig(cfg) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { @@ -181,6 +204,42 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { .map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model })) }, [auxiliary, mainModel]) + // Capabilities of the APPLIED main model — gates the profile-default + // reasoning/speed controls the same way the composer picker gates per-model + // edits (reasoning defaults on, fast defaults off when unreported). + const mainCaps = useMemo(() => { + const row = providers.find(provider => provider.slug === mainModel?.provider) + + return mainModel ? row?.capabilities?.[mainModel.model] : undefined + }, [providers, mainModel]) + + const reasoningSupported = mainCaps?.reasoning ?? true + const fastSupported = mainCaps?.fast ?? false + const effortValue = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '').trim().toLowerCase() || 'medium' + const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier')) + + // Persist a single agent.* default by round-tripping the whole config record + // (PUT /api/config replaces it) — optimistic, with rollback on failure. + const writeAgentDefault = useCallback( + async (key: string, value: string) => { + if (!config) { + return + } + + const prev = config + const next = setNested(config, key, value) + setConfig(next) + + try { + await saveHermesConfig(next) + } catch (err) { + setConfig(prev) + notifyError(err, m.defaultsFailed) + } + }, + [config, m.defaultsFailed] + ) + // Paste an API key for the selected `api_key` provider, persist it, then // refresh so the now-authenticated provider's models populate. Auto-selects // the recommended default model so the user can Apply in one more click. @@ -433,6 +492,38 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { : `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}

)} + {config && mainModel && (reasoningSupported || fastSupported) && ( +
+ {m.defaultsLabel} + {reasoningSupported && ( +
+ {m.reasoning} + +
+ )} + {fastSupported && ( + + )} +
+ )} {error &&
{error}
} {switchStaleAux.length > 0 && (
diff --git a/apps/desktop/src/app/settings/providers-settings.test.tsx b/apps/desktop/src/app/settings/providers-settings.test.tsx index 8379d203f6c1a..27c029b442c0e 100644 --- a/apps/desktop/src/app/settings/providers-settings.test.tsx +++ b/apps/desktop/src/app/settings/providers-settings.test.tsx @@ -55,7 +55,7 @@ afterEach(() => { async function renderProvidersSettings() { const { ProvidersSettings } = await import('./providers-settings') - return render() + return render() } describe('ProvidersSettings', () => { @@ -95,6 +95,6 @@ describe('ProvidersSettings', () => { expect(await screen.findByText('Qwen Code')).toBeTruthy() expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull() - expect(screen.getByText(/managed outside Hermes/)).toBeTruthy() + expect(screen.getByText(/managed by its own CLI/)).toBeTruthy() }) }) diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index f1132e6c33d3f..2585e13995d02 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -1,6 +1,8 @@ import { useStore } from '@nanostores/react' +import type { ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' +import { runInTerminal } from '@/app/right-sidebar/store' import { FEATURED_ID, FeaturedProviderRow, @@ -23,6 +25,20 @@ import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials' import { providerGroup, providerMeta, providerPriority } from './helpers' import { LoadingState, SettingsContent } from './primitives' +// The embedded terminal (and thus the "run disconnect command" path) only +// exists in the Electron desktop shell, not the web dashboard. +const canRunInTerminal = () => typeof window !== 'undefined' && Boolean(window.hermesDesktop?.terminal) + +// Parallel group headers ("Connected", "Other providers") so the expanded list +// reads as its own section instead of bleeding into the connected group. +function GroupLabel({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ) +} + // Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys. export const PROVIDER_VIEWS = ['accounts', 'keys'] as const @@ -90,11 +106,13 @@ function buildProviderKeyGroups(vars: Record): ProviderKeyGr function OAuthPicker({ disconnecting, onDisconnect, + onTerminalDisconnect, onWantApiKey, providers }: { disconnecting: null | string onDisconnect: (provider: OAuthProvider) => void + onTerminalDisconnect: (provider: OAuthProvider) => void onWantApiKey: () => void providers: OAuthProvider[] }) { @@ -138,15 +156,14 @@ function OAuthPicker({ {featured && } {connected.length > 0 && ( <> -

- {p.connected} -

+ {p.connected} {connected.map(p => ( ))} @@ -154,6 +171,7 @@ function OAuthPicker({ )} {showOthers && ( <> + {connected.length > 0 && {p.otherProviders}} {others.map(p => ( ))} @@ -180,21 +198,26 @@ function ConnectedProviderRow({ disconnecting, onDisconnect, onSelect, + onTerminalDisconnect, provider }: { disconnecting: boolean onDisconnect: (provider: OAuthProvider) => void onSelect: (provider: OAuthProvider) => void + onTerminalDisconnect: (provider: OAuthProvider) => void provider: OAuthProvider }) { const { t } = useI18n() + const copy = t.settings.providers const title = providerTitle(provider) const Trail = provider.flow === 'external' ? Terminal : ChevronRight + // Hermes can clear this provider's creds via the API. const canDisconnect = provider.disconnectable ?? provider.flow !== 'external' - - const disconnectHint = provider.flow === 'external' - ? t.settings.providers.removeExternal(title, provider.cli_command) - : t.settings.providers.removeKeyManaged(title) + // External (CLI-managed) provider Hermes can't clear via the API, but ships a + // command we can run in the embedded terminal (Electron shell only). + const terminalDisconnect = !canDisconnect && Boolean(provider.disconnect_command) && canRunInTerminal() + // Only fall back to a static "remove it elsewhere" hint when we offer no button. + const showHint = !canDisconnect && !terminalDisconnect return (
@@ -203,13 +226,13 @@ function ConnectedProviderRow({ {title} - {t.settings.providers.connected} + {copy.connected}

{t.onboarding.flowSubtitles[provider.flow]}

- {!canDisconnect && ( + {showHint && (

- {disconnectHint} + {provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}

)} @@ -228,6 +251,18 @@ function ConnectedProviderRow({ {disconnecting ? : } )} + {terminalDisconnect && ( + + )}
) @@ -243,7 +278,7 @@ function NoProviderKeys() { ) } -export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) { +export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSettingsProps) { const { t } = useI18n() const { rowProps, vars } = useEnvCredentials() const [oauthProviders, setOauthProviders] = useState([]) @@ -282,6 +317,29 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps return () => void (cancelled = true) }, [onboardingActive]) + // External (CLI-managed) providers can't be cleared via the API by design — + // Hermes never deletes creds another tool owns behind a silent API call. + // Instead we run the documented removal command in the embedded terminal so + // the user sees exactly what executes, then return them to chat to watch it. + function handleTerminalDisconnect(provider: OAuthProvider) { + const command = provider.disconnect_command + + if (!command) { + return + } + + const name = providerTitle(provider) + + if (!window.confirm(t.settings.providers.removeTerminalConfirm(name, command))) { + return + } + + // Leave the settings overlay so the terminal pane (chat-only) is visible. + onClose() + runInTerminal(command) + notify({ kind: 'info', title: t.settings.providers.removedTitle, message: t.settings.providers.removeTerminalRunning(name) }) + } + async function handleDisconnect(provider: OAuthProvider) { const name = providerTitle(provider) @@ -341,6 +399,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps void handleDisconnect(provider)} + onTerminalDisconnect={handleTerminalDisconnect} onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} /> @@ -359,6 +418,7 @@ interface ProviderKeyGroup { } interface ProvidersSettingsProps { + onClose: () => void onViewChange: (view: ProviderView) => void view: ProviderView } diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx index ade1f8a3c3c99..7cbcaacfb4181 100644 --- a/apps/desktop/src/app/shell/app-shell.tsx +++ b/apps/desktop/src/app/shell/app-shell.tsx @@ -16,7 +16,7 @@ import { } from '@/store/layout' import { $paneWidthOverride } from '@/store/panes' import { $connection } from '@/store/session' -import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows' +import { isSecondaryWindow } from '@/store/windows' import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants' @@ -80,7 +80,10 @@ export function AppShell({ const connection = useStore($connection) const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false) const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen - const hideTitlebarControls = isNewSessionWindow() + // Every secondary window (new-session scratch, subagent watch, cmd-click + // pop-out) is a compact side panel — none of them carry the full titlebar + // tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag. + const hideTitlebarControls = isSecondaryWindow() const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen) // Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero // on macOS, where window controls sit on the left and are reported via diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 53ce2dcc15026..b9a2d715454bf 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -1,5 +1,4 @@ import { useStore } from '@nanostores/react' -import type { ReactNode } from 'react' import { useCallback, useMemo } from 'react' import type { CommandCenterSection } from '@/app/command-center' @@ -9,7 +8,6 @@ import { useI18n } from '@/i18n' import { Activity, AlertCircle, - ChevronDown, Clock, Command, Hash, @@ -19,7 +17,6 @@ import { Zap, ZapFilled } from '@/lib/icons' -import { formatModelStatusLabel } from '@/lib/model-status-label' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { cn } from '@/lib/utils' @@ -30,16 +27,11 @@ import { $activeSessionId, $busy, $connection, - $currentFastMode, - $currentModel, - $currentProvider, - $currentReasoningEffort, $currentUsage, $sessionStartedAt, $turnStartedAt, $workingSessionIds, $yoloActive, - setModelPickerOpen, setYoloActive } from '@/store/session' import { $subagentsBySession, activeSubagentCount } from '@/store/subagents' @@ -65,7 +57,6 @@ interface StatusbarItemsOptions { gatewayLogLines: readonly string[] gatewayState: string inferenceStatus: RuntimeReadinessResult | null - modelMenuContent?: ReactNode openAgents: () => void openCommandCenterSection: (section: CommandCenterSection) => void freshDraftReady: boolean @@ -83,7 +74,6 @@ export function useStatusbarItems({ gatewayLogLines, gatewayState, inferenceStatus, - modelMenuContent, openAgents, openCommandCenterSection, freshDraftReady, @@ -97,10 +87,6 @@ export function useStatusbarItems({ const terminalTakeover = useStore($terminalTakeover) const yoloActive = useStore($yoloActive) const busy = useStore($busy) - const currentFastMode = useStore($currentFastMode) - const currentModel = useStore($currentModel) - const currentProvider = useStore($currentProvider) - const currentReasoningEffort = useStore($currentReasoningEffort) const currentUsage = useStore($currentUsage) const desktopActionTasks = useStore($desktopActionTasks) const previewServerRestartStatus = useStore($previewServerRestartStatus) @@ -416,37 +402,6 @@ export function useStatusbarItems({ title: yoloActive ? copy.yoloOn : copy.yoloOff, variant: 'action' }, - { - id: 'model-summary', - label: ( - - - {formatModelStatusLabel(currentModel, { - fastMode: currentFastMode, - reasoningEffort: currentReasoningEffort - })} - - - - ), - ...(modelMenuContent - ? { - menuAlign: 'end' as const, - menuClassName: 'w-64', - menuContent: modelMenuContent, - title: currentProvider - ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) - : copy.switchModel, - variant: 'menu' as const - } - : { - onSelect: () => setModelPickerOpen(true), - title: currentProvider - ? copy.providerModelTitle(currentProvider, currentModel || copy.noModel) - : copy.openModelPicker, - variant: 'action' as const - }) - }, { className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`, hidden: !chatOpen, @@ -465,11 +420,6 @@ export function useStatusbarItems({ contextBar, contextUsage, copy, - currentFastMode, - currentModel, - currentProvider, - currentReasoningEffort, - modelMenuContent, sessionStartedAt, showYoloToggle, terminalTakeover, diff --git a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx new file mode 100644 index 0000000000000..e2493c600200e --- /dev/null +++ b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx @@ -0,0 +1,84 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { DropdownMenu, DropdownMenuContent, DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu' +import { $modelPresets, getModelPreset } from '@/store/model-presets' +import { $activeSessionId } from '@/store/session' + +import { type FastControl, ModelEditSubmenu } from './model-edit-submenu' + +// Radix calls these on open; jsdom doesn't implement them. +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn() + Element.prototype.hasPointerCapture = vi.fn(() => false) + Element.prototype.releasePointerCapture = vi.fn() +}) + +beforeEach(() => { + $modelPresets.set({}) + $activeSessionId.set(null) +}) + +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 }) { + return render( + + + + edit + + + + + ) +} + +// 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 the preset but skips the gateway without a session', () => { + const requestGateway = vi.fn().mockResolvedValue({}) + renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway }) + + fireEvent.click(screen.getByRole('switch')) + + expect(getModelPreset('p1', 'm1').fast).toBe(true) + expect(requestGateway).not.toHaveBeenCalled() + }) + + it('reasoning: records the preset but skips the gateway without a session', () => { + const requestGateway = vi.fn().mockResolvedValue({}) + renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway }) + + // Thinking starts on (medium); toggling it off routes through patchReasoning. + fireEvent.click(screen.getByRole('switch')) + + expect(getModelPreset('p1', 'm1').effort).toBe('none') + expect(requestGateway).not.toHaveBeenCalled() + }) + + 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 }) + + fireEvent.click(screen.getByRole('switch')) + + expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' }) + }) +}) diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index 6872cca7f5ae0..881e33cab056b 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -12,13 +12,9 @@ import { } from '@/components/ui/dropdown-menu' import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' +import { setModelPreset } from '@/store/model-presets' import { notifyError } from '@/store/notifications' -import { - $activeSessionId, - $currentReasoningEffort, - setCurrentFastMode, - setCurrentReasoningEffort -} from '@/store/session' +import { $activeSessionId, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session' // Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned // by the Thinking toggle, not the radio. @@ -76,96 +72,104 @@ export function resolveFastControl( } interface ModelEditSubmenuProps { + /** 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 /** How fast mode is offered for this model (param toggle vs. variant swap). */ fastControl: FastControl /** Whether this row's model is the active one. */ isActive: boolean - /** Switch to this model (resolves false on failure). Awaited before applying - * edits when not active so a failed switch doesn't write to the old model. */ - onActivate: () => Promise | void + /** This row's model id — edits persist as its global preset. */ + 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. */ + provider: string /** Whether this model supports reasoning effort. */ reasoning: boolean requestGateway: (method: string, params?: Record) => Promise } export function ModelEditSubmenu({ + effort, fastControl, isActive, - onActivate, + model, onSelectModel, + provider, reasoning, requestGateway }: ModelEditSubmenuProps) { const { t } = useI18n() const copy = t.shell.modelOptions - // Reactive session state comes straight from the stores rather than being - // drilled through the panel, so editing it re-renders only this submenu. const activeSessionId = useStore($activeSessionId) - const currentReasoningEffort = useStore($currentReasoningEffort) - const effort = normalizeEffort(currentReasoningEffort) - const thinkingOn = isThinkingEnabled(currentReasoningEffort) + const effortValue = normalizeEffort(effort) + const thinkingOn = isThinkingEnabled(effort) - // Reasoning/fast are session-scoped (they apply to the active model), so - // editing a non-active model first switches to it. Returns false if the - // switch failed, so callers skip applying to the wrong (previous) model. - const ensureActive = async (): Promise => { - if (isActive) { - return true - } + // Editing always records the model's global preset; the active model also gets + // it pushed onto the live session. Non-active edits stay preset-only — they do + // not switch you to that model. + const patchReasoning = async (next: string) => { + setModelPreset(provider, model, { effort: next }) - return (await onActivate()) !== false - } + if (!isActive) { + return + } - const patchReasoning = async (next: string, rollback: string) => { setCurrentReasoningEffort(next) - try { - if (!(await ensureActive())) { - setCurrentReasoningEffort(rollback) - - return - } + // 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 / toggleFast. + if (!activeSessionId) { + return + } - await requestGateway('config.set', { - key: 'reasoning', - session_id: activeSessionId ?? '', - value: next - }) + try { + await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next }) } catch (err) { - setCurrentReasoningEffort(rollback) + setCurrentReasoningEffort(effort) + setModelPreset(provider, model, { effort }) notifyError(err, copy.updateFailed) } } const toggleFast = (enabled: boolean) => { if (fastControl.kind === 'variant') { - // Fast is a separate model id — swap to it (or back to the base). - void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId) + // 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 }) + + if (isActive) { + void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId) + } return } if (fastControl.kind === 'param') { + setModelPreset(provider, model, { fast: enabled }) + + if (!isActive) { + return + } + setCurrentFastMode(enabled) + // Preset-only without a session (see patchReasoning). + if (!activeSessionId) { + return + } void (async () => { try { - if (!(await ensureActive())) { - setCurrentFastMode(!enabled) - - return - } - - await requestGateway('config.set', { - key: 'fast', - session_id: activeSessionId ?? '', - value: enabled ? 'fast' : 'normal' - }) + await requestGateway('config.set', { key: 'fast', session_id: activeSessionId, value: enabled ? 'fast' : 'normal' }) } catch (err) { setCurrentFastMode(!enabled) + setModelPreset(provider, model, { fast: !enabled }) notifyError(err, copy.fastFailed) } })() @@ -188,9 +192,7 @@ export function ModelEditSubmenu({ - void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort) - } + onCheckedChange={checked => void patchReasoning(checked ? effortValue || 'medium' : 'none')} size="xs" /> @@ -205,10 +207,7 @@ export function ModelEditSubmenu({ <> {copy.effort} - void patchReasoning(value, currentReasoningEffort)} - value={effort} - > + void patchReasoning(value)} value={effortValue}> {EFFORT_OPTIONS.map(option => ( void>(() => {}) + interface ModelMenuPanelProps { gateway?: HermesGateway - onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise | void + onSelectModel: (selection: { model: string; provider: string }) => Promise | void requestGateway: (method: string, params?: Record) => Promise } @@ -54,6 +60,7 @@ interface ProviderGroup { export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) { const { t } = useI18n() const copy = t.shell.modelMenu + const closeMenu = useContext(ModelMenuCloseContext) const [search, setSearch] = useState('') // Reactive session state is read from the stores here (not drilled in), so // toggling effort/fast/model re-renders this panel in place without forcing @@ -63,6 +70,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model const currentModel = useStore($currentModel) const currentProvider = useStore($currentProvider) const currentReasoningEffort = useStore($currentReasoningEffort) + const modelPresets = useStore($modelPresets) const visibleModels = useStore($visibleModels) const modelOptions = useQuery({ @@ -76,8 +84,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model } }) - const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '') - const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '') + const { model: optionsModel, provider: optionsProvider } = currentPickerSelection( + !!activeSessionId, + { model: currentModel, provider: currentProvider }, + modelOptions.data + ) + const loading = modelOptions.isPending && !modelOptions.data const error = modelOptions.error @@ -87,13 +99,41 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model : null const providers = modelOptions.data?.providers + const effectiveVisibleModels = useMemo( () => effectiveVisibleKeys(visibleModels, providers ?? []), [visibleModels, providers] ) - const switchTo = (model: string, provider: string) => - onSelectModel({ model, persistGlobal: !activeSessionId, provider }) + // 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. + const switchTo = (model: string, provider: string) => onSelectModel({ model, provider }) + + // 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)] ?? {} + + // 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) { + return + } + + await applyModelPreset( + { + effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined, + fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined + }, + { failMessage: t.shell.modelOptions.updateFailed, request: requestGateway, sessionId: activeSessionId } + ) + } const groups = useMemo( () => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels), @@ -152,37 +192,42 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model // -fast variant carries the same param support as its base. const caps = group.provider.capabilities?.[family.id] - // Single source of truth for the active row's fast state — keeps - // the row label in lock-step with the submenu's Fast toggle and - // handles the standalone `-fast` id case. + // 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, - currentFastMode + effFast ) - // Grayed text is live session state only. Do not label inactive - // rows as "Fast" just because they have a fast-capable sibling: - // that makes an off Fast toggle look like it is already on. - const meta = isCurrent - ? [ - fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, - reasoningEffortLabel(currentReasoningEffort) || copy.medium - ] - .filter(Boolean) - .join(' ') - : '' + const meta = [ + fastControl.kind !== 'none' && fastControl.on ? copy.fast : null, + (caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null + ] + .filter(Boolean) + .join(' ') // Every row is a hover-Edit submenu trigger. Activating it - // (pointer or keyboard) switches to the family's base model; - // 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. + // (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 switchTo(family.id, group.provider.slug) + void selectFamily(family, group.provider) } + + closeMenu() } return ( @@ -204,10 +249,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model {isCurrent ? : null} switchTo(family.id, group.provider.slug)} + model={family.id} onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)} + provider={group.provider.slug} reasoning={caps?.reasoning ?? true} requestGateway={requestGateway} /> diff --git a/apps/desktop/src/components/assistant-ui/block-direction.test.tsx b/apps/desktop/src/components/assistant-ui/block-direction.test.tsx new file mode 100644 index 0000000000000..a206e8e847d2a --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/block-direction.test.tsx @@ -0,0 +1,129 @@ +// Lists and blockquotes have chrome beside the text (markers, the quote +// border) whose side is driven by the box's CSS direction, which the +// unicode-bidi:plaintext rules never touch. These tests pin the split of +// responsibilities: ul/ol/blockquote carry dir="auto" so the browser +// resolves their box direction from content, inline code carries dir="ltr" +// so it neither votes in that resolution nor reorders, and plain prose +// blocks stay attribute-free (the plaintext CSS owns them). jsdom does not +// resolve dir="auto", so the contract is asserted at the attribute level. +import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { Thread } from './thread' + +const createdAt = new Date('2026-06-01T00:00:00.000Z') + +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.stubGlobal('ResizeObserver', TestResizeObserver) +vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0) +) +vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) + +Element.prototype.scrollTo = function scrollTo() {} + +function stubOffsetDimension( + prop: 'offsetHeight' | 'offsetWidth', + clientProp: 'clientHeight' | 'clientWidth', + fallback: number +) { + const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop) + + Object.defineProperty(HTMLElement.prototype, prop, { + configurable: true, + get() { + return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback + } + }) +} + +stubOffsetDimension('offsetWidth', 'clientWidth', 800) +stubOffsetDimension('offsetHeight', 'clientHeight', 600) + +function userMessage(): ThreadMessage { + return { + id: 'user-1', + role: 'user', + content: [{ type: 'text', text: 'hi' }], + attachments: [], + createdAt, + metadata: { custom: {} } + } as ThreadMessage +} + +function assistantMessage(text: string): ThreadMessage { + return { + id: 'assistant-1', + role: 'assistant', + content: [{ type: 'text', text }], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function Harness({ text }: { text: string }) { + const runtime = useExternalStoreRuntime({ + messages: [userMessage(), assistantMessage(text)], + isRunning: false, + onNew: async () => {} + }) + + return ( + + + + ) +} + +describe('block-level direction chrome', () => { + it('lists carry dir="auto" so markers follow the resolved direction', async () => { + render() + + const item = await screen.findByText(/חוף גורדון/) + + expect(item.closest('ol')?.getAttribute('dir')).toBe('auto') + + const bullet = await screen.findByText(/פריט/) + + expect(bullet.closest('ul')?.getAttribute('dir')).toBe('auto') + }) + + it('blockquotes carry dir="auto" so the border follows the resolved direction', async () => { + render( ציטוט קצר בעברית'} />) + + const quote = await screen.findByText(/ציטוט קצר/) + + expect(quote.closest('blockquote')?.getAttribute('dir')).toBe('auto') + }) + + it('inline code carries dir="ltr" so it does not vote in dir="auto" resolution', async () => { + render() + + const code = await screen.findByText('npm install') + + expect(code.tagName).toBe('CODE') + expect(code.getAttribute('dir')).toBe('ltr') + expect(code.closest('ol')?.getAttribute('dir')).toBe('auto') + }) + + it('plain prose blocks stay attribute-free (plaintext CSS owns them)', async () => { + render() + + const paragraph = await screen.findByText(/שלום לכולם/) + + expect(paragraph.closest('p')?.hasAttribute('dir')).toBe(false) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index b870913b012fa..097b106281e64 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -322,13 +322,29 @@ function shortLabel(type: HermesRefType, id: string): string { return tail || id } +function safeEmbeddedImages(text: string) { + try { + return extractEmbeddedImages(text) + } catch { + return { cleanedText: text, images: [] as string[] } + } +} + +function safeDirectiveSegments(text: string): Unstable_DirectiveSegment[] { + try { + return [...hermesDirectiveFormatter.parse(text)] + } catch { + return [{ kind: 'text', text }] + } +} + /** * Renders text containing Hermes directives (`@file:...`, `@image:...`) as * inline chips. Embedded MEDIA images render below as a thumbnail row. */ export function DirectiveContent({ text }: { text: string }) { - const { cleanedText, images } = useMemo(() => extractEmbeddedImages(text ?? ''), [text]) - const segments = useMemo(() => hermesDirectiveFormatter.parse(cleanedText), [cleanedText]) + const { cleanedText, images } = useMemo(() => safeEmbeddedImages(text ?? ''), [text]) + const segments = useMemo(() => safeDirectiveSegments(cleanedText), [cleanedText]) return ( diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.test.ts b/apps/desktop/src/components/assistant-ui/markdown-text.test.ts index fad9944741f91..b3ea416d06649 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.test.ts +++ b/apps/desktop/src/components/assistant-ui/markdown-text.test.ts @@ -201,4 +201,13 @@ describe('preprocessMarkdown', () => { expect(output).toContain('') }) + + it('handles a fenced block larger than V8 spread-argument limit', () => { + // A single huge code block (e.g. a logged minified bundle) used to throw + // `RangeError: Maximum call stack size exceeded` via `out.push(...lines)`. + const body = Array.from({ length: 200_000 }, (_, i) => `line ${i}`).join('\n') + const input = `\`\`\`js\n${body}\n\`\`\`` + + expect(() => preprocessMarkdown(input)).not.toThrow() + }) }) diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index 2c87f6d0c3309..3da29aebbcc15 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -19,8 +19,9 @@ import { useState } from 'react' +import { ExpandableBlock } from '@/components/chat/expandable-block' import { PreviewAttachment } from '@/components/chat/preview-attachment' -import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter' +import { chunkByLines, SyntaxHighlighter } from '@/components/chat/shiki-highlighter' import { ZoomableImage } from '@/components/chat/zoomable-image' import { normalizeExternalUrl, openExternalLink, PrettyLink } from '@/lib/external-link' import { createMemoizedMathPlugin } from '@/lib/katex-memo' @@ -57,7 +58,11 @@ const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true }) // flush) with a tail-bounded repair — see lib/remend-tail.ts. Must stay // module-scope so the prop identity is stable across renders. function preprocessWithTailRepair(text: string): string { - return tailBoundedRemend(preprocessMarkdown(text)) + try { + return tailBoundedRemend(preprocessMarkdown(text)) + } catch { + return text + } } // Memoized block splitter. Streamdown calls `parseMarkdownIntoBlocks` (a full @@ -453,8 +458,35 @@ const MARKDOWN_CONTAINER_CLASS_NAME = cn( '[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*+*]:mt-(--paragraph-gap)' ) +const MAX_MARKDOWN_CHARS = 200_000 + +function HugeTextFallback({ containerClassName, text }: { containerClassName?: string; text: string }) { + const chunks = useMemo(() => chunkByLines(text, 200), [text]) + + return ( +
+ + {chunks.map((chunk, index) => ( +
+ {chunk.text} +
+ ))} +
+
+ ) +} + function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTextSurfaceProps) { - const { status } = useMessagePartText() + const { status, text } = useMessagePartText() const isStreaming = status.type === 'running' // Keep code parsing enabled while streaming so incomplete fenced blocks still @@ -484,19 +516,37 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex

), a: MarkdownLink, + // Inline code must not vote when an ancestor resolves `dir="auto"` + // (HTML's algorithm skips descendants that carry their own dir), + // mirroring the CSS isolate that already keeps it out of the + // plaintext scan. Fenced code never reaches this override; it goes + // through the code plugin's CodeCard path. + inlineCode: ({ className, ...props }: ComponentProps<'code'>) => ( + + ), // `---` as quiet spacing, not a heavy full-width rule. hr: (_props: ComponentProps<'hr'>) =>

, + // Lists and blockquotes have chrome that sits *beside* the text + // (markers, the quote border), and that side is driven by the CSS + // `direction` of the box, which `unicode-bidi: plaintext` never + // touches — an RTL list otherwise renders its numbers stranded at + // the far left. `dir="auto"` lets the browser resolve the box + // direction from content; the plaintext rules in styles.css keep + // owning per-line text direction. Inline code carries `dir="ltr"` + // (see the `code` override) so it doesn't vote here either, same + // contract as the CSS isolate. blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
), ul: ({ className, ...props }: ComponentProps<'ul'>) => ( -
    +
      ), ol: ({ className, ...props }: ComponentProps<'ol'>) => ( -
        +
          ), li: ({ className, ...props }: ComponentProps<'li'>) => (
        1. @@ -533,6 +583,10 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex [isStreaming] ) + if (text.length > MAX_MARKDOWN_CHARS) { + return + } + return ( = ({ const hiddenCount = firstVisible const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups const restoreFromBottomRef = useRef(null) - const newSessionWindow = isNewSessionWindow() - const newSessionTitlebarGap = 'calc(var(--titlebar-height)+0.75rem)' - const threadContentTopPad = newSessionWindow + // Secondary windows (new-session scratch, subagent watch, cmd-click pop-out) + // hide the titlebar tool cluster + session header, but the OS traffic lights + // still sit in the top-left, so reserve the titlebar gap above the transcript. + const secondaryWindow = isSecondaryWindow() + // NB: CSS calc() requires whitespace around the +/- operator. This string is + // assigned verbatim to the --sticky-human-top inline style below (it does not + // go through Tailwind, which would auto-space it), so the spaces are load- + // bearing — without them the declaration is invalid, gets dropped, and the + // sticky user bubble falls back to its ~4px default and slides under the OS + // traffic lights. + const secondaryTitlebarGap = 'calc(var(--titlebar-height) + 0.75rem)' + const threadContentTopPad = secondaryWindow ? 'pt-[calc(var(--titlebar-height)+0.75rem)]' - : isSecondaryWindow() - ? 'pt-6' - : 'pt-[calc(var(--titlebar-height)+1.5rem)]' + : 'pt-[calc(var(--titlebar-height)-0.5rem)]' useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom]) useEffect(() => () => resetThreadScroll(), []) @@ -247,10 +254,21 @@ const ThreadMessageListInner: FC = ({ style={ { height: clampToComposer ? 'var(--thread-viewport-height)' : '100%', - ...(newSessionWindow ? { '--sticky-human-top': newSessionTitlebarGap } : {}) + ...(secondaryWindow ? { '--sticky-human-top': secondaryTitlebarGap } : {}) } as CSSProperties } > + {secondaryWindow && ( + // Secondary windows hide the titlebar chrome, so the scroller runs to + // the window's top edge and streamed text slides up under the OS + // traffic lights. Content padding alone scrolls away with the text — a + // fixed opaque strip (the titlebar's drag region) masks anything behind + // it and keeps the window draggable, matching the main window's header. +