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.