diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdde4..bbe6646c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ## [Unreleased] +## [v0.51.83] — 2026-05-17 — Release BG (stage-376 — 12-PR contributor batch — chat-start adapter parity + populated-core journal recovery + thinking card dedup + context metadata refresh + model cache fingerprint + stream fade cap + manual cron delivery + active-session spinner + email gateway label + thinking copy button + /theme i18n + compact activity semantics) + +### Added + +- **PR #2460** by @Michaelyklam (closes #2449) — Add a copy button to Thinking card headers so users can copy the card's reasoning text without selecting the `
` manually. The button stops header-toggle propagation and shows the same short checkmark feedback pattern used by existing copy actions.
+
+### Fixed
+
+- **PR #2438** by @franksong2702 (fixes #2435) — Keep the default-off `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` chat-start path response-compatible with the legacy-direct path by not adding adapter-internal `run_id`, `status`, or `active_controls` fields to `/api/chat/start` responses. The adapter facade for the future #1925 runtime split stays an internal protocol-translator seam instead of expanding the public chat-start contract.
+- **PR #2439** by @franksong2702 (fixes #2434) — Recover already-journaled visible assistant text and tool cards even when restart repair first syncs a populated Hermes core transcript into an otherwise empty WebUI sidecar. The core-sync branch now merges non-duplicate run-journal output before clearing stale stream state, closing the carve-out from PR #2427 where recoverable partial output could be silently skipped. Adds `_append_journaled_partial_output(..., dedupe_existing=True)` plus helpers `_run_journal_has_visible_output`, `_find_existing_assistant_for_journal_content`, and `_journal_tool_already_present`.
+- **PR #2441** by @Michaelyklam (fixes #2440) — Compact live Thinking cards now reuse the same timeline card across sequential tool calls within a single assistant turn. `finalizeThinkingCard` clears the `data-thinking-active` marker by searching the entire assistant turn instead of only the tool group, and `appendThinking` reuses the most recent Thinking card when no active marker is set, preventing repeated Thinking cards from stacking as reasoning resumes between tool calls.
+- **PR #2444** by @franksong2702 (fixes #2442) — Refresh session context-window metadata when a session's resolved model changes during deferred hydration or when the user switches models, so high-context models do not stay stuck on a stale prior window and trigger premature compression. Adds a shared `_resolve_context_length_for_session_model` helper, updates `GET /api/session?resolve_model=1` to refresh non-zero persisted windows from current model metadata, resets context metadata on `/api/session/update` model/provider changes, and applies returned `context_length`/`threshold_tokens`/`last_prompt_tokens` in the deferred client-side resolution path with an immediate context-indicator resync.
+- **PR #2445** by @Michaelyklam (fixes #2443) — `/api/models` now fingerprints the in-module provider catalog plus the local Codex `models_cache.json` as part of its persisted cache metadata, so server-side catalog additions and Codex local catalog refreshes invalidate `models_cache.json` immediately on the next restart instead of waiting for the 24-hour TTL or manual cache deletion.
+- **PR #2450** by @Michaelyklam (fixes #2447) — Cap the optional streaming word-fade drain after the final `done` SSE event so very large or bursty completed responses render from the canonical session promptly instead of keeping the chat in a live/working state until Stop is pressed. The existing caught-up path and per-token animation wait are preserved for normal responses.
+- **PR #2452** by @Michaelyklam (fixes #2451) — Manual WebUI cron triggers now deliver the same final response or failure notice as scheduled cron runs. The manual-run wrapper reuses the scheduler delivery contract (`[SILENT]` skipping, separate `last_delivery_error` metadata, error-notice fallback) with a `TypeError` shim for legacy `mark_job_run` signatures used by older WebUI test doubles.
+- **PR #2455** by @franksong2702 (fixes #2454) — Keep the sidebar spinner in sync with server session metadata when the currently open session has finished but the browser still has stale local busy state. A new `_reconcileActiveSessionIdleStateFromList` helper clears `S.busy`, `S.activeStreamId`, the `INFLIGHT` cache, and active-session stream metadata before optimistic merging can re-mark the row as streaming.
+- **PR #2457** by @Michaelyklam (closes #2456) — Email gateway sessions imported from Hermes Agent `state.db` now normalize as messaging sessions and show an `Email` source label in the WebUI sidebar instead of falling through as unlabelled generic agent sessions. Keeps the Python source-normalization contract (`MESSAGING_SOURCES`, `SOURCE_LABELS`), gateway status platform labels, and frontend `static/sessions.js` whitelist in sync.
+- **PR #2463** by @Michaelyklam (closes #2462) — Align `/theme` command help strings in Russian, German, Simplified Chinese, Traditional Chinese, and French with the current Theme × Skin contract. The localized command descriptions now mention `system/dark/light` plus the full skin list through `nous`, and French invalid-usage text now uses the actual `/theme ` slash command prefix instead of `/thème`. Supersedes the parallel-discovery duplicate at #2464 (closed in favor of this PR).
+
+### Changed
+
+- **PR #2466** by @franksong2702 (closes #2465) — Clarify `Compact tool activity` semantics in Preferences: the setting now describes compact inline activity that preserves the agent timeline, matching the current long-running turn behavior where thinking cards, visible progress notes, and tool Activity bursts stay in chronological order instead of being described as one top-of-turn collapsed block. Renderer behavior is unchanged; this is a description-only correction plus the `simplified_tool_calling` default comment and regression-test wording.
+
 ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin)
 
 ### Added
diff --git a/api/agent_sessions.py b/api/agent_sessions.py
index 41556fade7..641e3a22dd 100644
--- a/api/agent_sessions.py
+++ b/api/agent_sessions.py
@@ -9,6 +9,7 @@
 
 MESSAGING_SOURCES = {
     'discord',
+    'email',
     'slack',
     'telegram',
     'weixin',
@@ -22,6 +23,7 @@
     'cli': 'CLI',
     'cron': 'Cron',
     'discord': 'Discord',
+    'email': 'Email',
     'slack': 'Slack',
     'telegram': 'Telegram',
     'tool': 'Tool',
diff --git a/api/config.py b/api/config.py
index ea890e6366..4351f997aa 100644
--- a/api/config.py
+++ b/api/config.py
@@ -11,6 +11,7 @@
 
 import collections
 import copy
+import hashlib
 import json
 import logging
 import os
@@ -2205,11 +2206,45 @@ def _models_cache_file_fingerprint(path: Path) -> dict:
     return fingerprint
 
 
+def _models_cache_catalog_fingerprint() -> dict:
+    """Return non-secret model-catalog identity metadata for cache invalidation.
+
+    The /api/models payload is not only a function of user config/auth files.
+    It also depends on the provider/model catalog baked into this module and on
+    small local catalogs such as Codex's models_cache.json. Keep this cheap and
+    deterministic so a server restart after catalog changes does not keep
+    serving an otherwise-valid persisted models_cache.json until the 24h TTL
+    expires (#2443).
+    """
+    catalog_payload = {
+        "provider_models": _PROVIDER_MODELS,
+        "provider_display": _PROVIDER_DISPLAY,
+    }
+    try:
+        encoded = json.dumps(
+            catalog_payload,
+            sort_keys=True,
+            separators=(",", ":"),
+            ensure_ascii=True,
+            default=str,
+        ).encode("utf-8")
+        provider_catalog_sha = hashlib.sha256(encoded).hexdigest()
+    except Exception:
+        provider_catalog_sha = "unavailable"
+
+    codex_home = Path(os.getenv("CODEX_HOME", "").strip() or (HOME / ".codex")).expanduser()
+    return {
+        "provider_catalog_sha256": provider_catalog_sha,
+        "codex_models_cache": _models_cache_file_fingerprint(codex_home / "models_cache.json"),
+    }
+
+
 def _models_cache_source_fingerprint() -> dict:
-    """Return the current config/auth-store fingerprint for /api/models cache."""
+    """Return the current config/auth/catalog fingerprint for /api/models cache."""
     return {
         "config_yaml": _models_cache_file_fingerprint(_get_config_path()),
         "auth_json": _models_cache_file_fingerprint(_get_auth_store_path()),
+        "catalog": _models_cache_catalog_fingerprint(),
     }
 
 
@@ -4057,7 +4092,7 @@ def _get_session_agent_lock(session_id: str) -> threading.Lock:
     "rtl": False,  # right-to-left chat layout (chat messages + composer only)
     "notifications_enabled": False,  # browser notification when tab is in background
     "show_thinking": True,  # show/hide thinking/reasoning blocks in chat view
-    "simplified_tool_calling": True,  # group tools/thinking into one quiet activity disclosure
+    "simplified_tool_calling": True,  # render tools/thinking as compact inline timeline activity
     "api_redact_enabled": True,  # redact sensitive data (API keys, secrets) from API responses
     "sidebar_density": "compact",  # compact | detailed
     "auto_title_refresh_every": "0",  # adaptive title refresh: 0=off, 5/10/20=every N exchanges
diff --git a/api/models.py b/api/models.py
index c45e33c0d6..0518b227b7 100644
--- a/api/models.py
+++ b/api/models.py
@@ -718,7 +718,76 @@ def _truncate_journal_tool_args(args, limit: int = 4) -> dict:
     return out
 
 
-def _append_journaled_partial_output(session, stream_id: str | None) -> bool:
+def _normalize_journal_recovery_text(value) -> str:
+    return " ".join(str(value or "").split())
+
+
+def _find_existing_assistant_for_journal_content(session, content: str) -> int | None:
+    candidate = _normalize_journal_recovery_text(content)
+    if not candidate:
+        return None
+    for idx, message in enumerate(session.messages or []):
+        if not isinstance(message, dict) or message.get('role') != 'assistant':
+            continue
+        if message.get('_error'):
+            continue
+        existing = _normalize_journal_recovery_text(message.get('content'))
+        if not existing:
+            continue
+        if existing == candidate:
+            return idx
+        if len(candidate) >= 24 and candidate in existing:
+            return idx
+    return None
+
+
+def _journal_tool_already_present(session, name: str, preview: str) -> bool:
+    candidate_name = str(name or '')
+    candidate_preview = _normalize_journal_recovery_text(preview)
+    for tool_call in session.tool_calls or []:
+        if not isinstance(tool_call, dict):
+            continue
+        if str(tool_call.get('name') or '') != candidate_name:
+            continue
+        existing_preview = _normalize_journal_recovery_text(
+            tool_call.get('preview') or tool_call.get('snippet') or ''
+        )
+        if existing_preview == candidate_preview:
+            return True
+    return False
+
+
+def _run_journal_has_visible_output(session, stream_id: str | None) -> bool:
+    if not stream_id:
+        return False
+    try:
+        from api.run_journal import read_run_events
+        journal = read_run_events(session.session_id, stream_id)
+    except Exception:
+        return False
+    for event in journal.get('events') or []:
+        if not isinstance(event, dict):
+            continue
+        event_name = str(event.get('event') or event.get('type') or '')
+        payload = event.get('payload') if isinstance(event.get('payload'), dict) else {}
+        if event_name == 'token' and str(payload.get('text') or ''):
+            return True
+        if event_name == 'interim_assistant':
+            if payload.get('already_streamed'):
+                continue
+            if str(payload.get('text') or '').strip():
+                return True
+        if event_name == 'tool':
+            return True
+    return False
+
+
+def _append_journaled_partial_output(
+    session,
+    stream_id: str | None,
+    *,
+    dedupe_existing: bool = False,
+) -> bool:
     """Recover already-emitted visible output from a dead stream journal.
 
     This repair path is intentionally conservative: it restores user-visible
@@ -757,6 +826,12 @@ def flush_assistant() -> int | None:
         assistant_parts = []
         if not content:
             return current_assistant_idx
+        if dedupe_existing:
+            existing_idx = _find_existing_assistant_for_journal_content(session, content)
+            if existing_idx is not None:
+                current_assistant_idx = existing_idx
+                assistant_started_at = None
+                return existing_idx
         timestamp = int(assistant_started_at or time.time())
         session.messages.append({
             'role': 'assistant',
@@ -821,6 +896,9 @@ def ensure_assistant_anchor(created_at: float | None = None) -> int:
                 anchor_idx = ensure_assistant_anchor(created_at)
             name = str(payload.get('name') or 'tool')
             preview = str(payload.get('preview') or '')
+            if dedupe_existing and _journal_tool_already_present(session, name, preview):
+                current_assistant_idx = anchor_idx
+                continue
             recovered_tool_calls.append({
                 'name': name,
                 'preview': preview,
@@ -946,19 +1024,48 @@ def _apply_core_sync_or_error_marker(
             core = json.load(f)
         core_messages = core.get('messages', [])
         if core_messages:
+            _stream_id = stream_id_for_recheck or session.active_stream_id
             session.messages = core_messages
             session.tool_calls = core.get('tool_calls', [])
             for field in ('input_tokens', 'output_tokens', 'estimated_cost'):
                 if core.get(field) is not None:
                     setattr(session, field, core[field])
+            _pending_text = _normalize_journal_recovery_text(session.pending_user_message)
+            _already_checkpointed = False
+            if _pending_text and session.messages:
+                for _last_msg in reversed(session.messages):
+                    if isinstance(_last_msg, dict) and _last_msg.get('role') == 'user':
+                        _last_text = _normalize_journal_recovery_text(_last_msg.get('content'))
+                        _already_checkpointed = _last_text == _pending_text
+                        break
+            if (
+                _pending_text
+                and not _already_checkpointed
+                and _run_journal_has_visible_output(session, _stream_id)
+            ):
+                _recovered_ts = int(time.time())
+                if isinstance(session.pending_started_at, (int, float)) and session.pending_started_at > 0:
+                    _recovered_ts = int(session.pending_started_at)
+                _append_recovered_pending_turn(session, timestamp=_recovered_ts)
+            recovered_output = _append_journaled_partial_output(
+                session,
+                _stream_id,
+                dedupe_existing=True,
+            )
             session.active_stream_id = None
             session.pending_user_message = None
             session.pending_attachments = []
             session.pending_started_at = None
+            if recovered_output:
+                session.messages.append(
+                    _interrupted_recovery_marker(recovered_output=True)
+                )
             session.save(touch_updated_at=touch_updated_at)
             logger.info(
-                "Session %s: synced %d messages from core transcript",
-                sid, len(core_messages),
+                "Session %s: synced %d messages from core transcript%s",
+                sid,
+                len(core_messages),
+                " and recovered journaled output" if recovered_output else "",
             )
             return True
 
diff --git a/api/routes.py b/api/routes.py
index 677a667765..93900d76b0 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -754,8 +754,15 @@ def _run_cron_tracked(job, profile_home=None, execution_profile_home=None):
     agent config/.env while running. When no job profile is selected, both homes
     are the same and legacy server-default behavior is preserved.
     """
+    import importlib
+
     from cron.jobs import mark_job_run, save_job_output
 
+    _cron_scheduler = importlib.import_module("cron.scheduler")
+
+    _silent_marker = getattr(_cron_scheduler, "SILENT_MARKER", "[SILENT]")
+    _deliver_result = getattr(_cron_scheduler, "_deliver_result", None)
+
     job_id = job.get("id", "")
     execution_profile_home = execution_profile_home or profile_home
 
@@ -772,11 +779,29 @@ def _with_cron_home(home, fn):
             job, execution_profile_home
         )
 
-        # Persist output and run metadata back to the job's owning cron store,
-        # even when the selected execution profile is different.
+        # Persist output, deliver the same content the scheduled cron path would
+        # send, and write run metadata back to the job's owning cron store even
+        # when the selected execution profile is different.
         def _persist_success():
             save_job_output(job_id, output)
 
+            deliver_content = (
+                final_response
+                if success
+                else f"⚠️ Cron job '{job.get('name', job_id)}' failed:\n{error}"
+            )
+            should_deliver = bool(deliver_content)
+            if should_deliver and success and _silent_marker in deliver_content.strip().upper():
+                should_deliver = False
+
+            delivery_error = None
+            if should_deliver and _deliver_result is not None:
+                try:
+                    delivery_error = _deliver_result(job, deliver_content)
+                except Exception as de:
+                    delivery_error = str(de)
+                    logger.error("Delivery failed for manual cron job %s: %s", job_id, de)
+
             # Match the scheduled cron path: an apparently successful run with no
             # final response should not leave the job looking healthy.
             _success, _error = success, error
@@ -784,7 +809,14 @@ def _persist_success():
                 _success = False
                 _error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)"
 
-            mark_job_run(job_id, _success, _error)
+            try:
+                mark_job_run(job_id, _success, _error, delivery_error=delivery_error)
+            except TypeError:
+                # Older/fake cron.jobs modules used by focused WebUI tests may
+                # not expose the newer delivery_error parameter. Real Hermes
+                # scheduler builds do, so this is only a compatibility shim for
+                # legacy test doubles and deployments.
+                mark_job_run(job_id, _success, _error)
 
         _with_cron_home(profile_home, _persist_success)
     except Exception as e:
@@ -1630,6 +1662,57 @@ def _resolve_effective_session_model_provider_for_display(session) -> str | None
     return provider
 
 
+def _resolve_context_length_for_session_model(
+    model: str | None,
+    provider: str | None = None,
+) -> int:
+    """Best-effort current context window for a session model.
+
+    Persisted session context metadata is a snapshot from a prior model call.
+    During session hydration/model switching, the current model metadata should
+    be allowed to replace that stale snapshot.
+    """
+    model_for_lookup = str(model or "").strip()
+    if not model_for_lookup:
+        return 0
+    try:
+        from agent.model_metadata import get_model_context_length as _get_cl
+        from api.config import get_config as _get_config_for_cl
+
+        _cfg_for_cl = _get_config_for_cl()
+        _cfg_ctx_len_load = None
+        _cfg_custom_providers_load = None
+        try:
+            _model_cfg_load = _cfg_for_cl.get('model', {}) if isinstance(_cfg_for_cl, dict) else {}
+            if isinstance(_model_cfg_load, dict):
+                _raw_cfg_ctx_load = _model_cfg_load.get('context_length')
+                if _raw_cfg_ctx_load is not None:
+                    try:
+                        _parsed_load = int(_raw_cfg_ctx_load)
+                        if _parsed_load > 0:
+                            _cfg_ctx_len_load = _parsed_load
+                    except (TypeError, ValueError):
+                        pass
+            _raw_cp_load = _cfg_for_cl.get('custom_providers') if isinstance(_cfg_for_cl, dict) else None
+            if isinstance(_raw_cp_load, list):
+                _cfg_custom_providers_load = _raw_cp_load
+        except Exception:
+            pass
+        try:
+            return _get_cl(
+                model_for_lookup,
+                "",
+                config_context_length=_cfg_ctx_len_load,
+                provider=provider or "",
+                custom_providers=_cfg_custom_providers_load,
+            ) or 0
+        except TypeError:
+            # Older hermes-agent builds: legacy 2-arg form.
+            return _get_cl(model_for_lookup, "") or 0
+    except Exception:
+        return 0
+
+
 def _session_model_state_from_request(
     model: str | None,
     requested_provider: str | None,
@@ -3553,48 +3636,22 @@ def handle_get(handler, parsed) -> bool:
             # /api/session/get response — the same wrong-window display this
             # fix addresses on the streaming side.
             _persisted_cl = getattr(s, "context_length", 0) or 0
-            if not _persisted_cl:
+            _threshold_tokens = getattr(s, "threshold_tokens", 0) or 0
+            if (not _persisted_cl) or resolve_model:
                 _model_for_lookup = (
-                    getattr(s, "model", "") or effective_model or ""
+                    effective_model or getattr(s, "model", "") or ""
                 ).strip()
-                if _model_for_lookup:
-                    try:
-                        from agent.model_metadata import get_model_context_length as _get_cl
-                        from api.config import get_config as _get_config_for_cl
-                        _cfg_for_cl = _get_config_for_cl()
-                        _cfg_ctx_len_load = None
-                        _cfg_custom_providers_load = None
-                        try:
-                            _model_cfg_load = _cfg_for_cl.get('model', {}) if isinstance(_cfg_for_cl, dict) else {}
-                            if isinstance(_model_cfg_load, dict):
-                                _raw_cfg_ctx_load = _model_cfg_load.get('context_length')
-                                if _raw_cfg_ctx_load is not None:
-                                    try:
-                                        _parsed_load = int(_raw_cfg_ctx_load)
-                                        if _parsed_load > 0:
-                                            _cfg_ctx_len_load = _parsed_load
-                                    except (TypeError, ValueError):
-                                        pass
-                            _raw_cp_load = _cfg_for_cl.get('custom_providers') if isinstance(_cfg_for_cl, dict) else None
-                            if isinstance(_raw_cp_load, list):
-                                _cfg_custom_providers_load = _raw_cp_load
-                        except Exception:
-                            pass
-                        try:
-                            _fb_cl = _get_cl(
-                                _model_for_lookup,
-                                "",
-                                config_context_length=_cfg_ctx_len_load,
-                                provider=effective_provider or "",
-                                custom_providers=_cfg_custom_providers_load,
-                            ) or 0
-                        except TypeError:
-                            # Older hermes-agent builds: legacy 2-arg form.
-                            _fb_cl = _get_cl(_model_for_lookup, "") or 0
-                        if _fb_cl:
-                            _persisted_cl = _fb_cl
-                    except Exception:
-                        pass
+                _fb_cl = _resolve_context_length_for_session_model(
+                    _model_for_lookup,
+                    effective_provider or getattr(s, "model_provider", None) or "",
+                )
+                if _fb_cl:
+                    if _persisted_cl and _fb_cl != _persisted_cl:
+                        # The old threshold belongs to the old window. Hiding it
+                        # is less misleading than rendering a stale compression
+                        # threshold against a freshly resolved context length.
+                        _threshold_tokens = 0
+                    _persisted_cl = _fb_cl
             _session_tool_calls = getattr(s, "tool_calls", []) if load_messages else []
             if (
                 load_messages
@@ -3613,7 +3670,7 @@ def handle_get(handler, parsed) -> bool:
                 "pending_attachments": getattr(s, "pending_attachments", []) if load_messages else [],
                 "pending_started_at": getattr(s, "pending_started_at", None),
                 "context_length": _persisted_cl,
-                "threshold_tokens": getattr(s, "threshold_tokens", 0) or 0,
+                "threshold_tokens": _threshold_tokens,
                 "last_prompt_tokens": getattr(s, "last_prompt_tokens", 0) or 0,
             }
             if original_stream_id:
@@ -4183,6 +4240,7 @@ def handle_get(handler, parsed) -> bool:
             "telegram": "Telegram",
             "discord": "Discord",
             "slack": "Slack",
+            "email": "Email",
             "web": "Web",
             "api": "API",
         }
@@ -4638,6 +4696,8 @@ def handle_post(handler, parsed) -> bool:
         except KeyError:
             return bad(handler, "Session not found", 404)
         old_ws = getattr(s, "workspace", "")
+        old_model = getattr(s, "model", None)
+        old_provider = getattr(s, "model_provider", None)
         try:
             new_ws = str(resolve_trusted_workspace(body.get("workspace", s.workspace)))
         except ValueError as e:
@@ -4653,6 +4713,16 @@ def handle_post(handler, parsed) -> bool:
                 if model is not None:
                     s.model = model
                 s.model_provider = provider
+                if (
+                    str(old_model or "") != str(getattr(s, "model", "") or "")
+                    or str(old_provider or "") != str(getattr(s, "model_provider", "") or "")
+                ):
+                    s.context_length = _resolve_context_length_for_session_model(
+                        getattr(s, "model", None),
+                        getattr(s, "model_provider", None),
+                    )
+                    s.threshold_tokens = 0
+                    s.last_prompt_tokens = 0
             s.save()
         if str(old_ws or "") != str(new_ws or ""):
             try:
@@ -7800,9 +7870,6 @@ def _legacy_start_run(request: StartRunRequest) -> dict:
             response = dict(result.payload)
             response.setdefault("stream_id", result.stream_id)
             response.setdefault("session_id", result.session_id)
-            response.setdefault("run_id", result.run_id)
-            response.setdefault("status", result.status)
-            response.setdefault("active_controls", result.active_controls)
         else:
             response = _start_chat_stream_for_session(
                 s,
diff --git a/docs/pr-media/2449/after-thinking-copy.png b/docs/pr-media/2449/after-thinking-copy.png
new file mode 100644
index 0000000000..789f13d231
Binary files /dev/null and b/docs/pr-media/2449/after-thinking-copy.png differ
diff --git a/docs/pr-media/2449/before-thinking-copy.png b/docs/pr-media/2449/before-thinking-copy.png
new file mode 100644
index 0000000000..a3d5315dc1
Binary files /dev/null and b/docs/pr-media/2449/before-thinking-copy.png differ
diff --git a/static/boot.js b/static/boot.js
index d48123e11e..9464f1670d 100644
--- a/static/boot.js
+++ b/static/boot.js
@@ -907,6 +907,25 @@ function clearPreview(opts={}){
 }
 $('btnClearPreview').onclick=handleWorkspaceClose;
 // workspacePath click handler removed -- use topbar workspace chip dropdown instead
+function _applySessionContextMetadataUpdate(data){
+  if(!S.session||!data||!data.session)return;
+  S.session.context_length=data.session.context_length||0;
+  S.session.threshold_tokens=data.session.threshold_tokens||0;
+  S.session.last_prompt_tokens=data.session.last_prompt_tokens||0;
+  if(typeof _syncCtxIndicator==='function'){
+    const u=S.lastUsage||{};
+    const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt);
+    _syncCtxIndicator({
+      input_tokens:_pick(u.input_tokens,S.session.input_tokens),
+      output_tokens:_pick(u.output_tokens,S.session.output_tokens),
+      estimated_cost:_pick(u.estimated_cost,S.session.estimated_cost),
+      context_length:S.session.context_length||0,
+      last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens),
+      threshold_tokens:S.session.threshold_tokens||0,
+    });
+  }
+}
+
 $('modelSelect').onchange=async()=>{
   if(!S.session)return;
   const selectedModel=$('modelSelect').value;
@@ -916,7 +935,11 @@ $('modelSelect').onchange=async()=>{
   if(typeof closeModelDropdown==='function') closeModelDropdown();
   if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider);
   else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{}
-  await api('/api/session/update',{method:'POST',body:JSON.stringify({
+  // Clarify scope: composer model changes are session-local, not the global default.
+  if(typeof showToast==='function'){
+    showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000);
+  }
+  const data=await api('/api/session/update',{method:'POST',body:JSON.stringify({
     session_id:S.session.session_id,
     workspace:S.session.workspace,
     model:modelState.model,
@@ -926,10 +949,7 @@ $('modelSelect').onchange=async()=>{
   S.session.model_provider=modelState.model_provider||null;
   if(typeof syncModelChip==='function') syncModelChip();
   syncTopbar();
-  // Clarify scope: composer model changes are session-local, not the global default.
-  if(typeof showToast==='function'){
-    showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000);
-  }
+  _applySessionContextMetadataUpdate(data);
   // Warn if selected model belongs to a different provider than what Hermes is configured for
   if(typeof _checkProviderMismatch==='function'){
     const warn=_checkProviderMismatch(selectedModel);
diff --git a/static/i18n.js b/static/i18n.js
index d108fc205c..084c6ce8e4 100644
--- a/static/i18n.js
+++ b/static/i18n.js
@@ -3762,7 +3762,7 @@ const LOCALES = {
     cmd_terminal: 'Открыть терминал рабочей области',
     cmd_new: 'Начать новую сессию чата',
     cmd_usage: 'Показать или скрыть использование токенов',
-    cmd_theme: 'Переключить тему (dark/light/slate/solarized/monokai/nord/oled)',
+    cmd_theme: 'Переключить внешний вид (тема: system/dark/light, скин: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
     cmd_personality: 'Переключить личность агента',
     cmd_skills: 'Показать доступные навыки Hermes',
     available_commands: 'Доступные команды:',
@@ -6042,7 +6042,7 @@ const LOCALES = {
     cmd_terminal: 'Workspace-Terminal öffnen',
     cmd_new: 'Neue Chat-Sitzung starten',
     cmd_usage: 'Token-Verbrauchsanzeige umschalten',
-    cmd_theme: 'Darstellung wechseln (Theme: system/dark/light, Skin: default/ares/mono/slate/poseidon/sisyphus/charizard)',
+    cmd_theme: 'Darstellung wechseln (Theme: system/dark/light, Skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
     cmd_personality: 'Agenten-Persönlichkeit wechseln',
     cmd_skills: 'Verfügbare Hermes-Skills auflisten',
     available_commands: 'Verfügbare Befehle:',
@@ -7206,7 +7206,7 @@ const LOCALES = {
     cmd_terminal: '打开工作区 Terminal',
     cmd_new: '新建聊天会话',
     cmd_usage: '切换 token 用量显示',
-    cmd_theme: '切换外观(主题:system/dark/light,皮肤:default/ares/mono/slate/poseidon/sisyphus/charizard)',
+    cmd_theme: '切换外观(主题:system/dark/light,皮肤:default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
     cmd_personality: '切换 Agent 人设',
     cmd_skills: '列出可用的 Hermes 技能',
     available_commands: '可用命令:',
@@ -8307,7 +8307,7 @@ const LOCALES = {
     cmd_terminal: '\u6253\u958b\u5de5\u4f5c\u5340 Terminal',
     cmd_new: '\u65b0\u5efa\u804a\u5929\u6703\u8a71',
     cmd_usage: '\u5207\u63db token \u7528\u91cf\u986f\u793a',
-    cmd_theme: '\u5207\u63db\u5916\u89c0\uff08\u4e3b\u984c\uff1asystem/dark/light\uff0c\u76ae\u819a\uff1adefault/ares/mono/slate/poseidon/sisyphus/charizard\uff09',
+    cmd_theme: '\u5207\u63db\u5916\u89c0\uff08\u4e3b\u984c\uff1asystem/dark/light\uff0c\u76ae\u819a\uff1adefault/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous\uff09',
     cmd_personality: '\u5207\u63db Agent \u4eba\u8a2d',
     cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd',
     available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
@@ -11779,7 +11779,7 @@ const LOCALES = {
     cmd_terminal: 'Ouvrez le terminal de l\'espace de travail',
     cmd_new: 'Démarrer une nouvelle session de discussion',
     cmd_usage: 'Activer/désactiver l\'affichage de l\'utilisation du jeton',
-    cmd_theme: 'Changer d\'apparence (thème : système/dark/light, skin : default/ares/mono/slate/poseidon/sisyphus/charizard)',
+    cmd_theme: 'Changer d\'apparence (thème : system/dark/light, skin : default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
     cmd_personality: 'Personnalité de l\'agent de commutation',
     cmd_skills: 'Lister les compétences Hermès disponibles',
     available_commands: 'Commandes disponibles :',
@@ -11805,7 +11805,7 @@ const LOCALES = {
     focus_label: 'Se concentrer',
     token_usage_on: 'Utilisation du jeton sur',
     token_usage_off: 'Utilisation des jetons désactivée',
-    theme_usage: 'Utilisation : /thème',
+    theme_usage: 'Utilisation : /theme ',
     theme_set: 'Thème:',
     no_active_session: 'Aucune session active',
     cmd_queue: 'Mettre un message en file d\'attente pour le prochain tour',
diff --git a/static/index.html b/static/index.html
index b56dda19c6..271cc33d93 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1047,7 +1047,7 @@ 

What can I help with?

Compact tool activity -
Group thinking and tool calls into one collapsed activity section per assistant turn.
+
Show thinking and tool calls as compact inline activity while preserving the agent timeline.