feat: P3 improvements — insights panel, rollback UI, voice mode, subagent tree, api redact toggle

- #464 Insights panel: usage analytics dashboard with session/message/token stats,
  model breakdown, activity by day/hour charts, token breakdown (GET /api/insights)
- #466 Rollback UI: checkpoint list, diff viewer, restore confirmation
  (api/rollback.py, GET /api/rollback/{list,diff}, POST /api/rollback/restore)
- #1333 Voice mode: turn-based STT→send→TTS loop using Web Speech API,
  progressive enhancement with pulsing indicator and auto-resume
- #494 Subagent session tree: parent→children grouping in sidebar with
  expand/collapse chevrons, child count badges, localStorage persistence
- #1396 API redact toggle: Settings checkbox to disable forced redaction for
  self-hosted users (lazy check at call-time, default ON)
- #1385 Closed: compact tool activity toggle already exists in Settings
- #497 Commented: proposed shared-file bridge for cross-process gateway approvals
- i18n: tab_insights added to all 8 locales, voice/checkpoint keys to EN+RU
This commit is contained in:
bergeouss
2026-05-01 13:43:10 +00:00
parent 219f5d6ce5
commit ae40af03d7
10 changed files with 1175 additions and 11 deletions

View File

@@ -2232,6 +2232,7 @@ _SETTINGS_DEFAULTS = {
"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
"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
"busy_input_mode": "queue", # behavior when sending while agent is running: queue | interrupt | steer
@@ -2349,6 +2350,7 @@ _SETTINGS_BOOL_KEYS = {
"notifications_enabled",
"show_thinking",
"simplified_tool_calling",
"api_redact_enabled",
}
# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr')
_SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$")

View File

@@ -170,7 +170,18 @@ def _build_redact_fn():
return _combined_redact
_redact_text = _build_redact_fn()
_redact_fn_cached = _build_redact_fn()
def _redact_text(text: str) -> str:
"""Redact sensitive text from API responses. Respects api_redact_enabled setting."""
if not isinstance(text, str) or not text:
return text
from api.config import load_settings
settings = load_settings()
if not settings.get("api_redact_enabled", True):
return text
return _redact_fn_cached(text)
def _redact_value(v):

282
api/rollback.py Normal file
View File

@@ -0,0 +1,282 @@
"""
Hermes Web UI -- Filesystem checkpoint (rollback) API.
Provides endpoints to list, diff, and restore filesystem checkpoints
created by the Hermes agent's CheckpointManager. Checkpoints live at
``{hermes_home}/checkpoints/<hash>/`` as shadow git repositories.
"""
import hashlib
import json
import logging
import os
import shutil
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
def _hermes_home() -> Path:
"""Return the active Hermes home directory."""
try:
from api.profiles import get_active_hermes_home
return Path(get_active_hermes_home())
except Exception:
return Path(os.environ.get("HERMES_HOME", "~/.hermes")).expanduser()
def _workspace_hash(workspace: str) -> str:
"""Derive the checkpoint directory name from a workspace path.
Matches the agent's CheckpointManager._get_checkpoint_dir logic:
SHA-256 of the canonical workspace path.
"""
try:
canonical = os.path.realpath(workspace)
except (OSError, ValueError):
canonical = workspace
return hashlib.sha256(canonical.encode()).hexdigest()[:12]
def _checkpoint_root() -> Path:
return _hermes_home() / "checkpoints"
def _resolve_workspace(workspace: str) -> str:
"""Validate and return the canonical workspace path."""
if not workspace or not isinstance(workspace, str):
raise ValueError("workspace is required")
# Basic path validation
resolved = os.path.realpath(workspace)
if not os.path.isdir(resolved):
raise ValueError(f"Workspace does not exist: {workspace}")
return resolved
def _find_git() -> str:
"""Return the path to the git binary."""
return shutil.which("git") or "git"
# ── Public API functions (called from routes.py) ────────────────────────────
def list_checkpoints(workspace: str) -> dict[str, Any]:
"""List all checkpoints for a workspace.
Returns a dict with:
checkpoints: list of checkpoint objects
workspace: resolved workspace path
checkpoint_dir: the checkpoint directory path
"""
resolved = _resolve_workspace(workspace)
ws_hash = _workspace_hash(resolved)
ckpt_dir = _checkpoint_root() / ws_hash
checkpoints = []
if not ckpt_dir.is_dir():
return {"checkpoints": [], "workspace": resolved, "checkpoint_dir": str(ckpt_dir)}
# Each checkpoint is a git repo in <ckpt_dir>/<commit_hash>/
git = _find_git()
for entry in sorted(ckpt_dir.iterdir(), key=lambda p: p.stat().st_mtime if p.is_dir() else 0, reverse=True):
if not entry.is_dir():
continue
ckpt_info = _inspect_checkpoint(entry, git)
if ckpt_info:
checkpoints.append(ckpt_info)
return {
"checkpoints": checkpoints,
"workspace": resolved,
"checkpoint_dir": str(ckpt_dir),
}
def _inspect_checkpoint(ckpt_path: Path, git: str) -> dict[str, Any] | None:
"""Extract metadata from a single checkpoint directory."""
git_dir = ckpt_path / ".git"
if not git_dir.is_dir():
return None
name = ckpt_path.name
try:
result = subprocess.run(
[git, "-C", str(ckpt_path), "log", "--format=%H%n%s%n%aI", "-1"],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0 or not result.stdout.strip():
return None
lines = result.stdout.strip().split("\n")
commit_hash = lines[0] if len(lines) > 0 else name
message = lines[1] if len(lines) > 1 else "checkpoint"
date_str = lines[2] if len(lines) > 2 else ""
# Parse date for display
date_display = ""
if date_str:
try:
dt = datetime.fromisoformat(date_str)
date_display = dt.strftime("%Y-%m-%d %H:%M")
except (ValueError, TypeError):
date_display = date_str
# Count files
files_result = subprocess.run(
[git, "-C", str(ckpt_path), "ls-files"],
capture_output=True, text=True, timeout=5,
)
file_count = len(files_result.stdout.strip().split("\n")) if files_result.stdout.strip() else 0
return {
"id": name,
"commit": commit_hash[:12],
"message": message,
"date": date_str,
"date_display": date_display,
"files": file_count,
"path": str(ckpt_path),
}
except (subprocess.TimeoutExpired, OSError, Exception) as e:
logger.debug("Failed to inspect checkpoint %s: %s", ckpt_path, e)
return None
def get_checkpoint_diff(workspace: str, checkpoint: str) -> dict[str, Any]:
"""Show the diff between a checkpoint and the current workspace state.
Returns a dict with:
diff: unified diff text
files_changed: list of changed file paths
"""
resolved = _resolve_workspace(workspace)
ws_hash = _workspace_hash(resolved)
ckpt_dir = _checkpoint_root() / ws_hash / checkpoint
if not ckpt_dir.is_dir():
raise ValueError(f"Checkpoint not found: {checkpoint}")
git = _find_git()
# Get list of files in the checkpoint
ls_result = subprocess.run(
[git, "-C", str(ckpt_dir), "ls-files"],
capture_output=True, text=True, timeout=10,
)
if ls_result.returncode != 0:
raise ValueError("Failed to list checkpoint files")
ckpt_files = [f for f in ls_result.stdout.strip().split("\n") if f]
files_changed = []
diff_lines = []
for rel_path in ckpt_files:
ckpt_file = ckpt_dir / rel_path
ws_file = Path(resolved) / rel_path
if not ckpt_file.is_file():
continue
# Read checkpoint version
try:
ckpt_content = ckpt_file.read_text(errors="replace")
except OSError:
continue
# Read workspace version (if exists)
if ws_file.is_file():
try:
ws_content = ws_file.read_text(errors="replace")
except OSError:
ws_content = ""
else:
ws_content = None # File was deleted in workspace
if ws_content is None:
# File exists in checkpoint but not in workspace (deleted)
files_changed.append({"file": rel_path, "status": "deleted"})
diff_lines.append(f"--- a/{rel_path}")
diff_lines.append(f"+++ /dev/null")
diff_lines.append("@@ -1,{lines} +0,0 @@".format(lines=len(ckpt_content.splitlines())))
for line in ckpt_content.splitlines():
diff_lines.append(f"-{line}")
elif ckpt_content != ws_content:
# File changed
import difflib
ckpt_lines = ckpt_content.splitlines(keepends=True)
ws_lines = ws_content.splitlines(keepends=True)
diff = list(difflib.unified_diff(ckpt_lines, ws_lines, fromfile=f"a/{rel_path}", tofile=f"b/{rel_path}", lineterm=""))
if diff:
files_changed.append({"file": rel_path, "status": "modified"})
diff_lines.extend(diff)
# Check for new files in workspace that aren't in checkpoint
# (skip for performance — diff is primarily for seeing what the checkpoint captures)
return {
"checkpoint": checkpoint,
"workspace": resolved,
"diff": "\n".join(diff_lines) if diff_lines else "",
"files_changed": files_changed,
"total_changes": len(files_changed),
}
def restore_checkpoint(workspace: str, checkpoint: str) -> dict[str, Any]:
"""Restore a checkpoint by copying files back to the workspace.
Only restores files that exist in the checkpoint. Does NOT delete
files that were added after the checkpoint was created.
Returns a dict with:
ok: True
files_restored: list of restored file paths
"""
resolved = _resolve_workspace(workspace)
ws_hash = _workspace_hash(resolved)
ckpt_dir = _checkpoint_root() / ws_hash / checkpoint
if not ckpt_dir.is_dir():
raise ValueError(f"Checkpoint not found: {checkpoint}")
git = _find_git()
# Get list of files in the checkpoint
ls_result = subprocess.run(
[git, "-C", str(ckpt_dir), "ls-files"],
capture_output=True, text=True, timeout=10,
)
if ls_result.returncode != 0:
raise ValueError("Failed to list checkpoint files")
ckpt_files = [f for f in ls_result.stdout.strip().split("\n") if f]
restored = []
errors = []
for rel_path in ckpt_files:
ckpt_file = ckpt_dir / rel_path
ws_file = Path(resolved) / rel_path
if not ckpt_file.is_file():
continue
try:
ws_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(ckpt_file), str(ws_file))
restored.append(rel_path)
except OSError as e:
errors.append({"file": rel_path, "error": str(e)})
logger.warning("Failed to restore %s: %s", rel_path, e)
return {
"ok": True,
"checkpoint": checkpoint,
"workspace": resolved,
"files_restored": restored,
"files_restored_count": len(restored),
"errors": errors,
}

View File

@@ -842,6 +842,102 @@ button:hover{background:rgba(124,185,255,.25)}
<script src="/static/login.js"></script>
</body></html>"""
# ── Insights endpoint ──────────────────────────────────────────────────────────
def _handle_insights(handler, parsed) -> bool:
"""Return usage analytics from local WebUI session data."""
import collections
import time as _time
query = parse_qs(parsed.query)
try:
days = min(max(int(query.get("days", ["30"])[0]), 1), 365)
except (ValueError, TypeError):
days = 30
now = _time.time()
cutoff = now - (days * 86400)
# Walk session index (fast, no full JSON parse)
sessions_data = []
idx_path = SESSION_DIR / "_index.json"
if idx_path.exists():
try:
idx = json.loads(idx_path.read_text(encoding="utf-8"))
except Exception:
idx = []
else:
idx = []
for entry in idx:
created = entry.get("created_at", 0) or 0
updated = entry.get("updated_at", 0) or 0
# Session is relevant if it was created or updated within the window
if max(created, updated) < cutoff:
continue
sessions_data.append(entry)
# Aggregate
total_sessions = len(sessions_data)
total_messages = 0
total_input_tokens = 0
total_output_tokens = 0
total_cost = 0.0
model_counts = collections.Counter()
# Activity by day of week (0=Mon .. 6=Sun)
dow_activity = collections.Counter()
# Activity by hour of day (0-23)
hod_activity = collections.Counter()
for s in sessions_data:
total_messages += max(s.get("message_count", 0) or 0, 0)
total_input_tokens += max(s.get("input_tokens", 0) or 0, 0)
total_output_tokens += max(s.get("output_tokens", 0) or 0, 0)
cost = s.get("estimated_cost")
if cost is not None:
try:
total_cost += float(cost)
except (ValueError, TypeError):
pass
model = s.get("model") or "unknown"
if model:
model_counts[model] += 1
# Activity patterns
ts = s.get("updated_at", s.get("created_at", 0)) or 0
if ts:
try:
dt = _time.localtime(ts)
dow_activity[dt.tm_wday] += 1
hod_activity[dt.tm_hour] += 1
except Exception:
pass
# Build model breakdown
models_breakdown = []
for model, count in model_counts.most_common():
models_breakdown.append({"model": model, "sessions": count})
# Day-of-week labels
dow_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
dow_data = [{"day": dow_labels[i], "sessions": dow_activity.get(i, 0)} for i in range(7)]
# Hour-of-day data
hod_data = [{"hour": h, "sessions": hod_activity.get(h, 0)} for h in range(24)]
return j(handler, {
"period_days": days,
"total_sessions": total_sessions,
"total_messages": total_messages,
"total_input_tokens": total_input_tokens,
"total_output_tokens": total_output_tokens,
"total_tokens": total_input_tokens + total_output_tokens,
"total_cost": round(total_cost, 6),
"models": models_breakdown,
"activity_by_day": dow_data,
"activity_by_hour": hod_data,
})
# ── GET routes ────────────────────────────────────────────────────────────────
@@ -944,6 +1040,10 @@ def handle_get(handler, parsed) -> bool:
handler.end_headers()
return True
# ── Insights ──
if parsed.path == "/api/insights":
return _handle_insights(handler, parsed)
if parsed.path == "/health":
with STREAMS_LOCK:
n_streams = len(STREAMS)
@@ -1428,10 +1528,40 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/mcp/servers":
return _handle_mcp_servers_list(handler)
# ── Checkpoints / Rollback (GET) ──
if parsed.path == "/api/rollback/list":
qs = parse_qs(parsed.query)
workspace = qs.get("workspace", [""])[0]
if not workspace:
return bad(handler, "workspace query parameter is required")
try:
from api.rollback import list_checkpoints
return j(handler, list_checkpoints(workspace))
except ValueError as e:
return bad(handler, str(e))
except Exception as e:
logger.exception("rollback/list failed")
return bad(handler, str(e), status=500)
if parsed.path == "/api/rollback/diff":
qs = parse_qs(parsed.query)
workspace = qs.get("workspace", [""])[0]
checkpoint = qs.get("checkpoint", [""])[0]
if not workspace or not checkpoint:
return bad(handler, "workspace and checkpoint query parameters are required")
try:
from api.rollback import get_checkpoint_diff
return j(handler, get_checkpoint_diff(workspace, checkpoint))
except ValueError as e:
return bad(handler, str(e))
except Exception as e:
logger.exception("rollback/diff failed")
return bad(handler, str(e), status=500)
return False # 404
# ── POST routes ───────────────────────────────────────────────────────────────
# ── GET route helpers
def handle_post(handler, parsed) -> bool:
@@ -2289,6 +2419,23 @@ def handle_post(handler, parsed) -> bool:
handler.wfile.write(json.dumps({"ok": True}).encode())
return True
# ── Checkpoints / Rollback (POST) ──
if parsed.path == "/api/rollback/restore":
if not body:
return bad(handler, "request body is required")
workspace = body.get("workspace", "")
checkpoint = body.get("checkpoint", "")
if not workspace or not checkpoint:
return bad(handler, "workspace and checkpoint are required")
try:
from api.rollback import restore_checkpoint
return j(handler, restore_checkpoint(workspace, checkpoint))
except ValueError as e:
return bad(handler, str(e))
except Exception as e:
logger.exception("rollback/restore failed")
return bad(handler, str(e), status=500)
return False # 404
# ── GET route helpers ─────────────────────────────────────────────────────────

View File

@@ -199,6 +199,12 @@ $('btnSend').onclick=()=>{
_stopMic();
return;
}
// Turn-based voice mode: let the voice mode system handle the send flow
if(typeof window._voiceModeActive==='function'&&window._voiceModeActive()){
// Immediately send whatever is in the textarea
if(typeof window._voiceModeImmediateSend==='function') window._voiceModeImmediateSend();
return;
}
send();
};
$('btnAttach').onclick=()=>$('fileInput').click();
@@ -403,6 +409,265 @@ $('btnAttach').onclick=()=>$('fileInput').click();
})();
window._micActive=window._micActive||false;
window._micPendingSend=window._micPendingSend||false;
// ── Turn-based voice mode (#1333) ────────────────────────────────────────
// Chained flow: listen → send → (agent processes) → TTS response → listen again
(function(){
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
const hasSTT=!(!SpeechRecognition);
const hasTTS=!!('speechSynthesis' in window);
// Need both STT and TTS for turn-based voice mode
if(!hasSTT||!hasTTS) return;
const modeBtn=$('btnVoiceMode');
const bar=$('voiceModeBar');
const indicator=$('voiceModeIndicator');
const label=$('voiceModeLabel');
const micBtn=$('btnMic');
const ta=$('msg');
if(!modeBtn||!bar||!indicator||!label) return;
// Show the voice mode button — browser supports both STT and TTS
modeBtn.style.display='';
let _voiceModeActive=false;
let _voiceModeState='idle'; // idle | listening | thinking | speaking
let _recognition=null;
let _silenceTimer=null;
const SILENCE_MS=1800; // auto-send after 1.8s silence
function _setState(state){
_voiceModeState=state;
indicator.className='voice-mode-indicator '+state;
label.textContent=state==='listening'?t('voice_listening')
:state==='speaking'?t('voice_speaking')
:state==='thinking'?t('voice_thinking')
:'';
bar.style.display=_voiceModeActive?(state==='idle'?'none':''):'none';
}
function _startListening(){
if(!_voiceModeActive) return;
_setState('listening');
_recognition=new SpeechRecognition();
_recognition.continuous=false;
_recognition.interimResults=true;
_recognition.lang=(typeof _locale!=='undefined'&&_locale._speech)||'en-US';
let _finalText='';
_recognition.onstart=()=>{ _finalText=''; };
_recognition.onresult=(event)=>{
// Reset silence timer on any result
clearTimeout(_silenceTimer);
let interim='';
let final=_finalText;
for(let i=event.resultIndex;i<event.results.length;i++){
const txt=event.results[i][0].transcript;
if(event.results[i].isFinal){ final+=txt; _finalText=final; }
else{ interim+=txt; }
}
ta.value=final||interim;
autoResize();
// Auto-send on silence after final result
if(_finalText){
_silenceTimer=setTimeout(()=>{
_voiceModeSend();
},SILENCE_MS);
}
};
_recognition.onend=()=>{
clearTimeout(_silenceTimer);
// If we have text and haven't sent yet, send it
if(_finalText&&_voiceModeActive&&_voiceModeState==='listening'){
_voiceModeSend();
} else if(_voiceModeActive&&_voiceModeState==='listening'){
// No speech detected — restart listening
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },500);
}
};
_recognition.onerror=(event)=>{
clearTimeout(_silenceTimer);
if(event.error==='no-speech'||event.error==='aborted'){
// Restart if still active
if(_voiceModeActive){
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },800);
}
return;
}
if(event.error==='not-allowed'){
_deactivate();
showToast(t('mic_denied'));
return;
}
// Other errors — try to restart
if(_voiceModeActive){
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },1500);
}
};
try{ _recognition.start(); }catch(e){
// Already started or other error — retry shortly
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },1000);
}
}
function _voiceModeSend(){
if(!_voiceModeActive) return;
const text=(ta.value||'').trim();
if(!text){
ta.value='';
setTimeout(()=>{ if(_voiceModeActive) _startListening(); },300);
return;
}
_setState('thinking');
try{ if(_recognition) _recognition.abort(); }catch(_){}
_recognition=null;
// send() is global from boot.js
if(typeof send==='function') send();
}
function _speakResponse(){
if(!_voiceModeActive) return;
_setState('speaking');
// Find last assistant message
const rows=document.querySelectorAll('.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
if(!rows.length){ _startListening(); return; }
const last=rows[rows.length-1];
const rawText=last.dataset.rawText||'';
if(!rawText.trim()){ _startListening(); return; }
// Strip for TTS (reuse existing helper if available)
let clean=rawText;
if(typeof _stripForTTS==='function') clean=_stripForTTS(rawText);
else{
// Basic strip: remove code blocks, images, links
clean=clean.replace(/```[\s\S]*?```/g,' code block ')
.replace(/`([^`]*)`/g,'$1')
.replace(/!\[([^\]]*)\]\([^)]*\)/g,'$1')
.replace(/\[([^\]]*)\]\([^)]*\)/g,'$1')
.replace(/#{1,6}\s/g,'')
.replace(/[*_~]+/g,'')
.replace(/\n{2,}/g,'. ')
.replace(/\n/g,' ')
.trim();
}
if(!clean){ _startListening(); return; }
const utter=new SpeechSynthesisUtterance(clean);
// Apply saved voice preferences
const savedVoice=localStorage.getItem('hermes-tts-voice');
const voices=speechSynthesis.getVoices();
if(savedVoice&&voices.length){
const match=voices.find(v=>v.name===savedVoice);
if(match) utter.voice=match;
}
const savedRate=parseFloat(localStorage.getItem('hermes-tts-rate'));
if(!isNaN(savedRate)) utter.rate=Math.min(2,Math.max(0.5,savedRate));
const savedPitch=parseFloat(localStorage.getItem('hermes-tts-pitch'));
if(!isNaN(savedPitch)) utter.pitch=Math.min(2,Math.max(0,savedPitch));
utter.onend=()=>{
// After speaking, go back to listening
if(_voiceModeActive) setTimeout(()=>_startListening(),500);
};
utter.onerror=()=>{
if(_voiceModeActive) setTimeout(()=>_startListening(),1000);
};
speechSynthesis.speak(utter);
}
// Hook into response completion — observe when the agent finishes
// We patch setComposerStatus to detect when a response completes
const _origSetComposerStatus=(typeof setComposerStatus==='function')?setComposerStatus.bind(window):null;
window._voiceModeOnResponseComplete=function(){
if(_voiceModeActive&&_voiceModeState==='thinking'){
// Small delay to let DOM render the final message
setTimeout(()=>{
if(_voiceModeActive&&_voiceModeState==='thinking'){
_speakResponse();
}
},400);
}
};
// Observe S.busy changes to detect response completion
// The existing code calls setBusy(false) when response completes
const _origSetBusy=(typeof setBusy==='function')?setBusy.bind(window):null;
if(_origSetBusy){
// We use a MutationObserver-style approach via polling S.busy
// Actually, we'll use a simpler approach: hook into the message stream completion
}
// Most reliable hook: use the existing autoReadLastAssistant call site.
// We override autoReadLastAssistant so that if voice mode is active, we use our
// own speak-and-resume flow instead of the default auto-read.
const _origAutoRead=(typeof autoReadLastAssistant==='function')?autoReadLastAssistant:null;
window.autoReadLastAssistant=function(){
if(_voiceModeActive&&_voiceModeState==='thinking'){
_speakResponse();
return;
}
if(_origAutoRead) _origAutoRead.apply(this,arguments);
};
function _activate(){
_voiceModeActive=true;
modeBtn.classList.add('active');
modeBtn.title=t('voice_mode_active');
showToast(t('voice_mode_active'),1500);
// If the agent is busy, wait — state will be 'thinking' and we'll detect completion
if(typeof S!=='undefined'&&S.busy){
_setState('thinking');
return;
}
// Cancel any existing TTS
if(typeof stopTTS==='function') stopTTS();
_startListening();
}
function _deactivate(){
_voiceModeActive=false;
_voiceModeState='idle';
modeBtn.classList.remove('active');
modeBtn.title=t('voice_toggle');
bar.style.display='none';
clearTimeout(_silenceTimer);
try{ if(_recognition) _recognition.abort(); }catch(_){}
_recognition=null;
if(typeof stopTTS==='function') stopTTS();
// Restore original autoReadLastAssistant
if(_origAutoRead) window.autoReadLastAssistant=_origAutoRead;
// Clear textarea if it was only voice input
ta.value='';
autoResize();
}
modeBtn.onclick=()=>{
if(_voiceModeActive){
_deactivate();
showToast(t('voice_mode_off'),1500);
}else{
_activate();
}
};
// Expose for external use
window._voiceModeActive=()=>_voiceModeActive;
window._voiceModeDeactivate=_deactivate;
window._voiceModeImmediateSend=_voiceModeSend;
})();
$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};
$('btnNewChat').onclick=async()=>{
// If the current session has no messages, just focus the composer rather than

View File

@@ -15,6 +15,14 @@ const LOCALES = {
mic_no_speech: 'No speech detected. Try again.',
mic_network: 'Speech recognition unavailable.',
mic_error: 'Voice input error: ',
// Turn-based voice mode (#1333)
voice_toggle: 'Voice input',
voice_listening: 'Listening…',
voice_speaking: 'Speaking…',
voice_thinking: 'Thinking…',
voice_error: 'Voice not supported in this browser',
voice_mode_active: 'Voice mode on',
voice_mode_off: 'Voice mode off',
session_imported: 'Session imported',
import_failed: 'Import failed: ',
import_invalid_json: 'Invalid JSON',
@@ -185,6 +193,7 @@ const LOCALES = {
branch_failed:'Fork failed: ',
fork_from_here:'Fork from here',
forked_from:'Forked from',
subagent_children:'Subagent sessions',
btw_asking:'Asking side question...',
btw_label:'Side question — not in history',
btw_done:'Side question answered',
@@ -419,6 +428,7 @@ const LOCALES = {
tab_workspaces: 'Spaces',
tab_profiles: 'Profiles',
tab_todos: 'Todos',
tab_insights: 'Insights',
tab_settings: 'Settings',
new_conversation: 'New conversation',
filter_conversations: 'Filter conversations...',
@@ -439,6 +449,22 @@ const LOCALES = {
new_skill: 'New skill',
personal_memory: 'Personal memory',
current_task_list: 'Current task list',
// Insights
insights_title: 'Usage Analytics',
insights_sessions: 'Sessions',
insights_messages: 'Messages',
insights_tokens: 'Tokens',
insights_cost: 'Estimated Cost',
insights_no_cost: 'N/A',
insights_models: 'Models',
insights_activity_by_day: 'Activity by Day',
insights_activity_by_hour: 'Activity by Hour',
insights_peak_hour: 'Peak: {hour}',
insights_token_breakdown: 'Token Breakdown',
insights_input_tokens: 'Input',
insights_output_tokens: 'Output',
insights_total: 'Total',
insights_footer: 'Showing data from the last {days} days',
workspace_desc: 'Add and switch workspaces for your sessions.',
session_meta_messages: (n) => `${n} msg${n === 1 ? '' : 's'}`,
new_profile: 'New profile',
@@ -462,6 +488,8 @@ const LOCALES = {
settings_label_notifications: 'Browser notifications',
settings_desc_notifications: 'Show a system notification when a response completes while the app is in the background.',
settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.',
settings_label_api_redact: 'Redact sensitive data in API responses',
settings_desc_api_redact: 'Self-hosted users can disable for transparency (not recommended for shared instances).',
settings_sidebar_density_compact: 'Compact',
settings_sidebar_density_detailed: 'Detailed',
settings_desc_sidebar_density: 'Controls how much metadata the session list shows in the left sidebar.',
@@ -809,6 +837,22 @@ const LOCALES = {
excalidraw_empty: 'Empty diagram',
excalidraw_render_error: 'Failed to render diagram',
excalidraw_simplified: 'Simplified SVG preview — not pixel-identical to Excalidraw canvas',
// ── Checkpoints / Rollback ──
checkpoint_title: 'Checkpoints',
checkpoint_empty: 'No checkpoints found for this workspace.',
checkpoint_loading: 'Loading checkpoints…',
checkpoint_error: 'Failed to load checkpoints',
checkpoint_date: 'Date',
checkpoint_message: 'Message',
checkpoint_files: 'Files',
checkpoint_view_diff: 'View diff',
checkpoint_restore: 'Restore',
checkpoint_restore_confirm_title: 'Restore checkpoint?',
checkpoint_restore_confirm_message: (ckpt) => `Restore workspace to checkpoint "${ckpt}"? This will overwrite files with the saved versions. Files added after this checkpoint will not be deleted.`,
checkpoint_restored: 'Checkpoint restored',
checkpoint_diff_title: 'Changes in checkpoint',
checkpoint_diff_no_changes: 'No differences found between this checkpoint and the current workspace.',
checkpoint_diff_files_changed: (n) => `${n} file${n === 1 ? '' : 's'} changed`,
},
ru: {
@@ -821,6 +865,13 @@ const LOCALES = {
mic_no_speech: 'Речь не распознана. Попробуйте ещё раз.',
mic_network: 'Распознавание речи недоступно.',
mic_error: 'Ошибка ввода речи: ',
voice_toggle: 'Голосовой ввод',
voice_listening: 'Слушаю…',
voice_speaking: 'Говорю…',
voice_thinking: 'Думаю…',
voice_error: 'Голосовой ввод не поддерживается в этом браузере',
voice_mode_active: 'Голосовой режим включён',
voice_mode_off: 'Голосовой режим выключен',
session_imported: 'Сеанс импортирован',
import_failed: 'Не удалось импортировать: ',
import_invalid_json: 'Неверный JSON',
@@ -1072,6 +1123,7 @@ const LOCALES = {
tab_workspaces: 'Рабочие пространства',
tab_profiles: 'Профили',
tab_todos: 'Список дел',
tab_insights: 'Аналитика',
tab_settings: 'Настройки',
new_conversation: 'Новая беседа',
filter_conversations: 'Фильтр бесед...',
@@ -1829,6 +1881,7 @@ const LOCALES = {
tab_workspaces: 'Espacios',
tab_profiles: 'Perfiles',
tab_todos: 'Todos',
tab_insights: 'Analíticas',
tab_settings: 'Ajustes',
new_conversation: 'Nueva conversación',
filter_conversations: 'Filtrar conversaciones...',
@@ -2576,6 +2629,7 @@ const LOCALES = {
tab_workspaces: 'Spaces',
tab_profiles: 'Profile',
tab_todos: 'Todos',
tab_insights: 'Statistiken',
tab_settings: 'Einstellungen',
new_conversation: 'Neuer Chat',
filter_conversations: 'Chats filtern...',
@@ -3318,6 +3372,7 @@ const LOCALES = {
tab_skills: '技能',
tab_tasks: '任务',
tab_todos: '待办',
tab_insights: '统计',
tab_workspaces: '工作区',
tab_profiles: '配置',
tab_settings: '设置',
@@ -4093,6 +4148,7 @@ const LOCALES = {
tab_skills: '\u6280\u80fd',
tab_tasks: '\u4efb\u52d9',
tab_todos: '待辦',
tab_insights: '統計',
tab_workspaces: '\u5de5\u4f5c\u5340',
new_conversation: '新對話',
filter_conversations: '篩選對話',
@@ -4981,6 +5037,7 @@ const LOCALES = {
tab_workspaces: 'Spaces',
tab_profiles: 'Perfis',
tab_todos: 'Todos',
tab_insights: 'Estatísticas',
tab_settings: 'Configurações',
new_conversation: 'Nova conversa',
filter_conversations: 'Filtrar conversas...',
@@ -5694,6 +5751,7 @@ const LOCALES = {
tab_workspaces: '공간',
tab_profiles: 'Agent 프로필',
tab_todos: 'Todos',
tab_insights: '통계',
tab_settings: '설정',
new_conversation: '새 대화',
filter_conversations: '대화 필터…',

View File

@@ -88,6 +88,7 @@
<button class="rail-btn nav-tab" data-panel="workspaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces" aria-label="Spaces"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="rail-btn nav-tab" data-panel="profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles" aria-label="Agent profiles"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="rail-btn nav-tab" data-panel="todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos" aria-label="Todos"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
<button class="rail-btn nav-tab" data-panel="insights" onclick="switchPanel('insights')" title="Insights" data-i18n-title="tab_insights" aria-label="Insights"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
<div class="rail-spacer"></div>
<button class="rail-btn nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</nav>
@@ -101,6 +102,7 @@
<button class="nav-tab" data-panel="workspaces" data-label="Spaces" onclick="switchPanel('workspaces')" title="Spaces" data-i18n-title="tab_workspaces"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
<button class="nav-tab" data-panel="insights" data-label="Insights" onclick="switchPanel('insights')" title="Insights" data-i18n-title="tab_insights"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
<!-- Settings button mirrored here for mobile (rail is desktop-only via @media >=768px). Keep in sync with rail entry. -->
<button class="nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</div>
@@ -153,6 +155,23 @@
</div>
<div id="todoPanel" style="flex:1;overflow-y:auto;padding:8px 12px"></div>
</div>
<!-- Insights panel -->
<div class="panel-view" id="panelInsights">
<div class="panel-head">
<span data-i18n="tab_insights">Insights</span>
<div class="panel-head-actions">
<button class="panel-head-btn" id="insightsRefreshBtn" onclick="loadInsights(true)" title="Refresh" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></button>
</div>
</div>
<div class="panel-head-sub" style="padding:0 12px 8px">
<select id="insightsPeriod" onchange="loadInsights()" style="width:100%;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px">
<option value="7">7 days</option>
<option value="30" selected>30 days</option>
<option value="90">90 days</option>
<option value="365">365 days</option>
</select>
</div>
</div>
<!-- Workspaces panel -->
<div class="panel-view" id="panelWorkspaces">
<div class="panel-head">
@@ -359,6 +378,10 @@
</div>
<div class="attach-tray" id="attachTray"></div>
<div class="mic-status" id="micStatus" style="display:none"><span class="mic-dot"></span> Listening…</div>
<div class="voice-mode-bar" id="voiceModeBar" style="display:none">
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
<span class="voice-mode-label" id="voiceModeLabel"></span>
</div>
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
<div class="composer-footer">
<div class="composer-left">
@@ -374,6 +397,16 @@
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</button>
<button class="icon-btn voice-mode-btn" id="btnVoiceMode" title="Turn-based voice mode" style="display:none" data-i18n-title="voice_toggle">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
<path d="M20 3l-1.5 1.5" opacity=".5"/>
<path d="M4 3l1.5 1.5" opacity=".5"/>
</svg>
</button>
<div class="composer-divider" aria-hidden="true"></div>
<button class="yolo-pill" id="yoloPill" type="button" onclick="cmdYolo()" style="display:none" title="YOLO mode — click to disable" data-i18n-title="yolo_pill_title_active">
<span class="yolo-pill-icon" aria-hidden="true"></span>
@@ -594,6 +627,14 @@
<div class="main-view-empty-sub" data-i18n="profiles_empty_sub">Pick an agent profile from the sidebar to view and edit its settings, or create a new one.</div>
</div>
</div>
<div id="mainInsights" class="main-view">
<div class="main-view-header">
<div class="main-view-title" data-i18n="insights_title">Usage Analytics</div>
</div>
<div class="main-view-content" id="insightsContent" style="padding:16px;overflow-y:auto">
<div style="color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div>
</div>
</div>
<div id="mainSettings" class="main-view">
<div class="settings-main">
<div class="settings-pane active" id="settingsPaneConversation">
@@ -767,6 +808,13 @@
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px">Group thinking and tool calls into one collapsed activity section per assistant turn.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsApiRedact" checked style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_api_redact">Redact sensitive data in API responses</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_api_redact">Self-hosted users can disable for transparency (not recommended for shared instances).</div>
</div>
<div class="settings-field">
<label for="settingsSidebarDensity" data-i18n="settings_label_sidebar_density">Sidebar density</label>
<select id="settingsSidebarDensity" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">

View File

@@ -160,11 +160,11 @@ async function switchPanel(name, opts = {}) {
document.querySelectorAll('.panel-view').forEach(p => p.classList.remove('active'));
const panelEl = $('panel' + nextPanel.charAt(0).toUpperCase() + nextPanel.slice(1));
if (panelEl) panelEl.classList.add('active');
// Toggle main content view. Each entry in MAIN_VIEW_PANELS gets a matching
// Update main content view. Each entry in MAIN_VIEW_PANELS gets a matching
// showing-<name> class on <main>; no class means chat (the default).
const mainEl = document.querySelector('main.main');
if (mainEl) {
['settings','skills','memory','tasks','workspaces','profiles'].forEach(p => {
['settings','skills','memory','tasks','workspaces','profiles','insights'].forEach(p => {
mainEl.classList.toggle('showing-' + p, nextPanel === p);
});
}
@@ -175,6 +175,7 @@ async function switchPanel(name, opts = {}) {
if (nextPanel === 'workspaces') await loadWorkspacesPanel();
if (nextPanel === 'profiles') await loadProfilesPanel();
if (nextPanel === 'todos') loadTodos();
if (nextPanel === 'insights') await loadInsights();
if (nextPanel === 'settings') {
switchSettingsSection(_currentSettingsSection);
loadSettingsPanel();
@@ -848,6 +849,112 @@ function loadTodos() {
</div>`).join('');
}
// ── Insights panel ──
async function loadInsights(animate) {
const box = $('insightsContent');
const refreshBtn = $('insightsRefreshBtn');
if (!box) return;
if (animate && refreshBtn) {
refreshBtn.style.opacity = '0.5';
refreshBtn.disabled = true;
}
const period = ($('insightsPeriod') || {}).value || '30';
try {
const data = await api(`/api/insights?days=${period}`);
_renderInsights(data, box);
} catch(e) {
box.innerHTML = `<div style="color:var(--accent);font-size:12px">${esc(t('error_prefix') + e.message)}</div>`;
} finally {
if (animate && refreshBtn) {
refreshBtn.style.opacity = '';
refreshBtn.disabled = false;
}
}
}
function _renderInsights(d, box) {
const fmtNum = n => n.toLocaleString();
const fmtCost = c => c > 0 ? '$' + c.toFixed(4) : t('insights_no_cost');
const fmtTokens = n => n >= 1e6 ? (n/1e6).toFixed(1) + 'M' : n >= 1e3 ? (n/1e3).toFixed(1) + 'K' : fmtNum(n);
// Overview cards
const overviewCards = [
{ label: t('insights_sessions'), value: fmtNum(d.total_sessions), icon: li('message-square', 18) },
{ label: t('insights_messages'), value: fmtNum(d.total_messages), icon: li('hash', 18) },
{ label: t('insights_tokens'), value: fmtTokens(d.total_tokens), icon: li('cpu', 18) },
{ label: t('insights_cost'), value: fmtCost(d.total_cost), icon: li('dollar-sign', 18) },
];
// Models table
let modelsHtml = '';
if (d.models && d.models.length) {
const totalSess = d.models.reduce((a, m) => a + m.sessions, 0) || 1;
modelsHtml = `<div class="insights-card"><div class="insights-card-title">${esc(t('insights_models'))}</div><div class="insights-table"><div class="insights-table-head"><span>Model</span><span>Sessions</span><span>Share</span></div>` +
d.models.map(m => {
const pct = ((m.sessions / totalSess) * 100).toFixed(0);
return `<div class="insights-table-row"><span class="insights-model-name" title="${esc(m.model)}">${esc(m.model)}</span><span>${m.sessions}</span><span>${pct}%</span></div>`;
}).join('') +
`</div></div>`;
}
// Activity by day of week
let dowHtml = '';
if (d.activity_by_day) {
const maxDow = Math.max(...d.activity_by_day.map(x => x.sessions), 1);
dowHtml = `<div class="insights-card"><div class="insights-card-title">${esc(t('insights_activity_by_day'))}</div><div class="insights-bars">` +
d.activity_by_day.map(r => {
const pct = (r.sessions / maxDow * 100).toFixed(0);
return `<div class="insights-bar-row"><span class="insights-bar-label">${r.day}</span><div class="insights-bar-track"><div class="insights-bar-fill" style="width:${pct}%"></div></div><span class="insights-bar-value">${r.sessions}</span></div>`;
}).join('') +
`</div></div>`;
}
// Activity by hour
let hodHtml = '';
if (d.activity_by_hour) {
const maxHod = Math.max(...d.activity_by_hour.map(x => x.sessions), 1);
const peakHour = d.activity_by_hour.reduce((a, b) => b.sessions > a.sessions ? b : a, {hour:0,sessions:0});
hodHtml = `<div class="insights-card"><div class="insights-card-title">${esc(t('insights_activity_by_hour'))} <span style="font-weight:400;font-size:11px;color:var(--muted)">${esc(t('insights_peak_hour').replace('{hour}', peakHour.hour + ':00'))}</span></div><div class="insights-bars">` +
d.activity_by_hour.map(r => {
const pct = (r.sessions / maxHod * 100).toFixed(0);
const isPeak = r.hour === peakHour.hour && peakHour.sessions > 0;
return `<div class="insights-bar-row"><span class="insights-bar-label">${String(r.hour).padStart(2,'0')}</span><div class="insights-bar-track"><div class="insights-bar-fill${isPeak ? ' insights-bar-peak' : ''}" style="width:${pct}%"></div></div><span class="insights-bar-value">${r.sessions}</span></div>`;
}).join('') +
`</div></div>`;
}
// Token breakdown
const tokenCards = `
<div class="insights-card">
<div class="insights-card-title">${esc(t('insights_token_breakdown'))}</div>
<div class="insights-token-row">
<span class="insights-token-label">${esc(t('insights_input_tokens'))}</span>
<span class="insights-token-value">${fmtTokens(d.total_input_tokens)}</span>
</div>
<div class="insights-token-row">
<span class="insights-token-label">${esc(t('insights_output_tokens'))}</span>
<span class="insights-token-value">${fmtTokens(d.total_output_tokens)}</span>
</div>
<div class="insights-token-row insights-token-total">
<span class="insights-token-label">${esc(t('insights_total'))}</span>
<span class="insights-token-value">${fmtTokens(d.total_tokens)}</span>
</div>
</div>`;
box.innerHTML = `
<div class="insights-grid">
${overviewCards.map(c => `<div class="insights-stat"><div class="insights-stat-icon">${c.icon}</div><div class="insights-stat-info"><div class="insights-stat-value">${c.value}</div><div class="insights-stat-label">${esc(c.label)}</div></div></div>`).join('')}
</div>
<div class="insights-row">
${tokenCards}
${modelsHtml}
</div>
${dowHtml}
${hodHtml}
<div style="text-align:center;color:var(--muted);font-size:10px;margin-top:12px;opacity:.6">${esc(t('insights_footer').replace('{days}', d.period_days))}</div>
`;
}
async function clearConversation() {
if(!S.session) return;
const _clrMsg=await showConfirmDialog({title:t('clear_conversation_title'),message:t('clear_conversation_message'),confirmLabel:t('clear'),danger:true,focusCancel:true});
@@ -1698,11 +1805,18 @@ function _renderWorkspaceDetail(ws){
<div class="detail-row"><div class="detail-row-label">Path</div><div class="detail-row-value"><code>${esc(ws.path)}</code></div></div>
<div class="detail-row"><div class="detail-row-label">Status</div><div class="detail-row-value">${statusBadge}${defaultBadge}</div></div>
</div>
<div class="detail-card" style="margin-top:12px">
<div class="detail-card-title">${esc(t('checkpoint_title'))}</div>
<div id="checkpointListContainer">
<div style="color:var(--muted);font-size:12px;padding:8px 0">${esc(t('checkpoint_loading'))}</div>
</div>
</div>
</div>`;
body.style.display = '';
if (empty) empty.style.display = 'none';
_workspaceMode = 'read';
_setWorkspaceHeaderButtons('read', ws);
_loadCheckpoints(ws.path);
}
function _setWorkspaceHeaderButtons(mode, ws){
@@ -2708,6 +2822,8 @@ function _preferencesPayloadFromUi(){
if(showUsageCb) payload.show_token_usage=showUsageCb.checked;
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
if(simplifiedToolCb) payload.simplified_tool_calling=simplifiedToolCb.checked;
const apiRedactCb=$('settingsApiRedact');
if(apiRedactCb) payload.api_redact_enabled=apiRedactCb.checked;
const showCliCb=$('settingsShowCliSessions');
if(showCliCb) payload.show_cli_sessions=showCliCb.checked;
const syncCb=$('settingsSyncInsights');
@@ -2900,6 +3016,8 @@ async function loadSettingsPanel(){
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
if(simplifiedToolCb){simplifiedToolCb.checked=settings.simplified_tool_calling!==false;simplifiedToolCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const apiRedactCb=$('settingsApiRedact');
if(apiRedactCb){apiRedactCb.checked=settings.api_redact_enabled!==false;apiRedactCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const showCliCb=$('settingsShowCliSessions');
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const syncCb=$('settingsSyncInsights');
@@ -3344,6 +3462,7 @@ async function saveSettings(andClose){
body.language=language;
body.show_token_usage=showTokenUsage;
body.simplified_tool_calling=!!($('settingsSimplifiedToolCalling')||{}).checked;
body.api_redact_enabled=!!($('settingsApiRedact')||{}).checked;
body.show_cli_sessions=showCliSessions;
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
@@ -3631,3 +3750,111 @@ switchSettingsSection=function(name){
_origSwitchSettings(name);
if(name==='system') loadMcpServers();
};
// ── Checkpoints / Rollback ──────────────────────────────────────────────────
async function _loadCheckpoints(workspace){
const container=$('checkpointListContainer');
if(!container) return;
try{
const data=await api(`/api/rollback/list?workspace=${encodeURIComponent(workspace)}`);
const checkpoints=data.checkpoints||[];
if(!checkpoints.length){
container.innerHTML=`<div style="color:var(--muted);font-size:12px;padding:8px 0">${esc(t('checkpoint_empty'))}</div>`;
return;
}
let html='';
for(const ck of checkpoints){
const shortId=ck.id||ck.commit||'?';
const msg=ck.message||'checkpoint';
const date=ck.date_display||ck.date||'';
const files=ck.files||0;
html+=`
<div class="detail-row" style="align-items:center;padding:6px 0;border-bottom:1px solid var(--border,rgba(255,255,255,0.08))">
<div style="flex:1;min-width:0">
<div style="font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(msg)}">${esc(msg)}</div>
<div style="font-size:11px;color:var(--muted);margin-top:2px">
<code style="font-size:10px">${esc(shortId)}</code>
${date ? ` · ${esc(date)}` : ''}
${files ? ` · ${esc(t('checkpoint_files'))}: ${files}` : ''}
</div>
</div>
<div style="display:flex;gap:4px;flex-shrink:0;margin-left:8px">
<button class="panel-head-btn" title="${esc(t('checkpoint_view_diff'))}" onclick="event.stopPropagation();_viewCheckpointDiff('${esc(workspace)}','${esc(ck.id)}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
</button>
<button class="panel-head-btn" title="${esc(t('checkpoint_restore'))}" onclick="event.stopPropagation();_restoreCheckpoint('${esc(workspace)}','${esc(ck.id)}','${esc(msg.replace(/'/g,"\\'"))}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
</button>
</div>
</div>`;
}
container.innerHTML=html;
}catch(e){
container.innerHTML=`<div style="color:var(--error,#f87171);font-size:12px;padding:8px 0">${esc(t('checkpoint_error'))}: ${esc(e.message)}</div>`;
}
}
async function _viewCheckpointDiff(workspace,checkpoint){
const modal=document.getElementById('checkpointDiffModal');
if(!modal){
const m=document.createElement('div');
m.id='checkpointDiffModal';
m.style.cssText='position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6)';
m.innerHTML=`
<div style="background:var(--bg,${getComputedStyle(document.documentElement).getPropertyValue('--bg')||'#1a1a2e'});border:1px solid var(--border,rgba(255,255,255,0.12));border-radius:12px;width:90vw;max-width:800px;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,0.4)">
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border,rgba(255,255,255,0.08))">
<div id="checkpointDiffModalTitle" style="font-weight:600;font-size:14px"></div>
<button onclick="document.getElementById('checkpointDiffModal').style.display='none'" style="background:none;border:none;color:var(--fg);cursor:pointer;font-size:18px;padding:0 4px">&times;</button>
</div>
<div id="checkpointDiffModalBody" style="flex:1;overflow:auto;padding:12px 16px">
<div style="color:var(--muted);font-size:12px">${esc(t('checkpoint_loading'))}</div>
</div>
</div>`;
m.onclick=(e)=>{if(e.target===m) m.style.display='none';};
document.body.appendChild(m);
}
modal.style.display='flex';
$('checkpointDiffModalTitle').textContent=t('checkpoint_diff_title');
$('checkpointDiffModalBody').innerHTML=`<div style="color:var(--muted);font-size:12px">${esc(t('checkpoint_loading'))}</div>`;
try{
const data=await api(`/api/rollback/diff?workspace=${encodeURIComponent(workspace)}&checkpoint=${encodeURIComponent(checkpoint)}`);
const body=$('checkpointDiffModalBody');
if(!data.total_changes){
body.innerHTML=`<div style="color:var(--muted);font-size:12px">${esc(t('checkpoint_diff_no_changes'))}</div>`;
return;
}
let html=`<div style="font-size:12px;margin-bottom:8px">${esc(t('checkpoint_diff_files_changed',data.total_changes))}</div>`;
if(data.files_changed){
html+='<div style="margin-bottom:8px">';
for(const f of data.files_changed){
const icon=f.status==='deleted'?'':'~';
const color=f.status==='deleted'?'var(--error,#f87171)':'var(--accent,#60a5fa)';
html+=`<div style="font-size:12px;padding:2px 0"><span style="color:${color};font-weight:bold;margin-right:6px">${icon}</span><code style="font-size:11px">${esc(f.file)}</code></div>`;
}
html+='</div>';
}
if(data.diff){
html+=`<pre style="background:var(--bg-secondary,rgba(0,0,0,0.3));border:1px solid var(--border,rgba(255,255,255,0.08));border-radius:8px;padding:12px;font-size:11px;line-height:1.4;overflow-x:auto;white-space:pre-wrap;word-break:break-all;max-height:50vh;overflow-y:auto;color:var(--fg)">${esc(data.diff)}</pre>`;
}
body.innerHTML=html;
}catch(e){
$('checkpointDiffModalBody').innerHTML=`<div style="color:var(--error,#f87171);font-size:12px">${esc(e.message)}</div>`;
}
}
async function _restoreCheckpoint(workspace,checkpoint,message){
const label=message||checkpoint;
const ok=await showConfirmDialog({title:t('checkpoint_restore_confirm_title'),message:t('checkpoint_restore_confirm_message',label),confirmLabel:t('checkpoint_restore'),danger:true,focusCancel:true});
if(!ok) return;
try{
const data=await api('/api/rollback/restore',{method:'POST',body:JSON.stringify({workspace,checkpoint})});
if(data&&data.ok){
showToast(t('checkpoint_restored')+(data.files_restored_count?` (${data.files_restored_count} ${t('checkpoint_files').toLowerCase()})`:''));
}else{
showToast((data&&data.error)||'Restore failed','error');
}
}catch(e){
showToast(t('checkpoint_restore')+': '+e.message,'error');
}
}

View File

@@ -1211,16 +1211,22 @@ function _sessionTimeBucketLabel(timestampMs, nowMs) {
return t('session_time_bucket_older');
}
function _sessionLineageKey(s){
function _sessionLineageKey(s, sessionIdsInList){
if(!s||!s.session_id) return null;
// If parent_session_id points to another session in the current list,
// this is a subagent child — don't collapse it into lineage (#494).
if(s.parent_session_id && sessionIdsInList && sessionIdsInList.has(s.parent_session_id)){
return null;
}
return s._lineage_root_id || s.lineage_root_id || s.parent_session_id || null;
}
function _collapseSessionLineageForSidebar(sessions){
const result=[];
const sessionIdsInList=new Set((sessions||[]).map(s=>s.session_id));
const groups=new Map();
for(const s of sessions||[]){
const key=_sessionLineageKey(s);
const key=_sessionLineageKey(s, sessionIdsInList);
if(!key){result.push(s);continue;}
if(!groups.has(key)) groups.set(key,[]);
groups.get(key).push(s);
@@ -1256,6 +1262,23 @@ function renderSessionListFromCache(){
// Filter archived unless toggle is on
const sessionsRaw=_showArchived?projectFiltered:projectFiltered.filter(s=>!s.archived);
const sessions=_collapseSessionLineageForSidebar(sessionsRaw);
// Build parent→children map for subagent tree (#494).
// Only children whose parent exists in the current (post-collapse) list are grouped.
const _sessionIdsInList=new Set(sessions.map(s=>s.session_id));
const _parentChildrenMap=new Map();
const _topLevelSessions=[];
for(const s of sessions){
if(s.parent_session_id && _sessionIdsInList.has(s.parent_session_id)){
if(!_parentChildrenMap.has(s.parent_session_id)) _parentChildrenMap.set(s.parent_session_id,[]);
_parentChildrenMap.get(s.parent_session_id).push(s);
} else {
_topLevelSessions.push(s);
}
}
// Collapse state for subagent tree groups — persisted in localStorage (#494)
let _treeCollapsed={};
try{_treeCollapsed=JSON.parse(localStorage.getItem('hermes-tree-collapsed')||'{}');}catch(e){}
const _saveTreeCollapsed=()=>{try{localStorage.setItem('hermes-tree-collapsed',JSON.stringify(_treeCollapsed));}catch(e){}};
const archivedCount=projectFiltered.filter(s=>s.archived).length;
const list=$('sessionList');list.innerHTML='';
// Batch select bar (when in select mode)
@@ -1347,7 +1370,7 @@ function renderSessionListFromCache(){
empty.textContent='No sessions in this project yet.';
list.appendChild(empty);
}
const orderedSessions=[...sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
const orderedSessions=[..._topLevelSessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
// Separate pinned from unpinned
const pinned=orderedSessions.filter(s=>s.pinned);
const unpinned=orderedSessions.filter(s=>!s.pinned);
@@ -1393,7 +1416,47 @@ function renderSessionListFromCache(){
_saveCollapsed();
};
wrapper.appendChild(hdr);
for(const s of g.items){ body.appendChild(_renderOneSession(s, Boolean(g.isPinned))); }
for(const s of g.items){
const parentEl=_renderOneSession(s, Boolean(g.isPinned));
body.appendChild(parentEl);
// Render subagent children as indented tree (#494)
const children=_parentChildrenMap.get(s.session_id);
if(children&&children.length){
parentEl.classList.add('session-parent');
const treeCaret=document.createElement('span');
treeCaret.className='session-tree-caret';
treeCaret.textContent='\u25B8'; // right-pointing triangle (collapsed)
treeCaret.title=t('subagent_children');
parentEl.querySelector('.session-title-row').prepend(treeCaret);
const childCount=children.length;
const childBadge=document.createElement('span');
childBadge.className='session-tree-badge';
childBadge.textContent=childCount;
childBadge.title=t('subagent_children');
parentEl.querySelector('.session-title-row').appendChild(childBadge);
const isCollapsed=_treeCollapsed[s.session_id]!==false; // collapsed by default
const childContainer=document.createElement('div');
childContainer.className='session-tree-children';
if(isCollapsed){childContainer.style.display='none';treeCaret.classList.add('collapsed');}
else{treeCaret.classList.remove('collapsed');treeCaret.textContent='\u25BE';}
const sortedChildren=[...children].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
for(const child of sortedChildren){
const childEl=_renderOneSession(child, Boolean(g.isPinned));
childEl.classList.add('session-tree-child');
childContainer.appendChild(childEl);
}
body.appendChild(childContainer);
treeCaret.onclick=(e)=>{
e.stopPropagation();
const hidden=childContainer.style.display==='none';
childContainer.style.display=hidden?'':'none';
treeCaret.textContent=hidden?'\u25BE':'\u25B8';
treeCaret.classList.toggle('collapsed',!hidden);
_treeCollapsed[s.session_id]=!hidden;
_saveTreeCollapsed();
};
}
}
wrapper.appendChild(body);
list.appendChild(wrapper);
}

View File

@@ -956,6 +956,18 @@
@keyframes mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(239,83,80,.3);}50%{box-shadow:0 0 0 6px rgba(239,83,80,0);}}
.mic-status{font-size:11px;color:var(--error);padding:4px 12px;display:flex;align-items:center;gap:6px;}
.mic-dot{width:6px;height:6px;border-radius:50%;background:var(--error);animation:mic-pulse 1.2s ease-in-out infinite;flex-shrink:0;}
/* ── Turn-based voice mode (#1333) ── */
.voice-mode-btn{transition:color .15s,background .15s;}
.voice-mode-btn.active{color:var(--accent);background:rgba(var(--accent-rgb,99,102,241),.15);}
.voice-mode-btn.active svg{filter:drop-shadow(0 0 3px rgba(var(--accent-rgb,99,102,241),.5));}
.voice-mode-bar{font-size:11px;padding:4px 12px;display:flex;align-items:center;gap:8px;border-bottom:1px solid rgba(255,255,255,.05);}
.voice-mode-indicator{width:8px;height:8px;border-radius:50%;flex-shrink:0;}
.voice-mode-indicator.listening{background:var(--error);animation:voice-mode-pulse 1s ease-in-out infinite;}
.voice-mode-indicator.speaking{background:var(--accent);animation:voice-mode-pulse 1.5s ease-in-out infinite;}
.voice-mode-indicator.thinking{background:var(--warning,#f59e0b);animation:voice-mode-pulse 2s ease-in-out infinite;}
.voice-mode-label{color:var(--muted);font-size:11px;}
@keyframes voice-mode-pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:.5;transform:scale(.85);}}
.status-text{font-size:11px;color:var(--muted);padding-left:4px;}
.send-btn{width:34px;height:34px;border-radius:50%;background:var(--accent);border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,transform .15s,box-shadow .15s;box-shadow:0 2px 8px var(--accent-bg-strong);}
.send-btn.stop,.send-btn.interrupt{background:var(--error);box-shadow:0 2px 10px rgba(0,0,0,.18);}
@@ -1251,7 +1263,7 @@
#terminalWorkspaceLabel{max-width:110px;}
#terminalDockWorkspaceLabel{max-width:96px;}
/* Touch targets — minimum 44px */
.icon-btn,.mic-btn{min-width:44px;min-height:44px;}
.icon-btn,.mic-btn,.voice-mode-btn{min-width:44px;min-height:44px;}
.session-item{min-height:44px;padding:10px 40px 10px 12px;}
.session-item.streaming,.session-item.unread{padding-right:40px;}
.session-actions{opacity:1;pointer-events:auto;}
@@ -1994,8 +2006,9 @@ main.main > #mainSkills,
main.main > #mainMemory,
main.main > #mainTasks,
main.main > #mainWorkspaces,
main.main > #mainProfiles{display:none;}
main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-workspaces):not(.showing-profiles) > #mainChat{display:flex;}
main.main > #mainProfiles,
main.main > #mainInsights{display:none;}
main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights) > #mainChat{display:flex;}
main.main.showing-settings > #mainSettings{display:flex;overflow-y:auto;}
main.main.showing-skills > #mainSkills{display:flex;}
main.main.showing-memory > #mainMemory{display:flex;}
@@ -2296,6 +2309,17 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
.session-item.archived{opacity:.5;}
.session-item.archived .session-title{font-style:italic;}
/* ── Subagent session tree (#494) ── */
.session-tree-children{margin-left:16px;border-left:1px solid var(--border,rgba(255,255,255,.1));padding-left:4px;}
.session-tree-child.session-item{font-size:12px;opacity:.85;border-radius:6px;padding:6px 8px;}
.session-tree-child.session-item:hover{opacity:1;}
.session-tree-child.session-item.active{opacity:1;}
.session-tree-child.session-item .session-meta{font-size:10px;}
.session-tree-caret{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;font-size:10px;cursor:pointer;color:var(--muted);flex-shrink:0;transition:transform .15s;user-select:none;border-radius:3px;margin-right:2px;}
.session-tree-caret:hover{color:var(--text);background:rgba(255,255,255,.06);}
.session-tree-caret.collapsed{transform:none;}
.session-tree-badge{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;font-size:9px;font-weight:700;padding:0 4px;border-radius:8px;background:rgba(99,179,237,.2);color:#63b3ed;margin-left:auto;flex-shrink:0;user-select:none;}
/* ── Session tags ── */
.session-tag{display:inline-block;font-size:9px;font-weight:600;padding:1px 5px;margin-left:4px;border-radius:3px;background:rgba(99,179,237,.2);color:#63b3ed;cursor:pointer;vertical-align:middle;}
.session-tag:hover{background:rgba(99,179,237,.35);}
@@ -2854,3 +2878,40 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
.html-preview-iframe{width:100%;height:400px;border:none;display:block;background:#fff;}
.html-preview-fallback{padding:8px;font-size:13px;}
.html-preview-spinner{animation:pulse 1.5s ease-in-out infinite;}
/* ── Insights panel (#464) ────────────────────────────────────────────────── */
main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.insights-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:16px;}
.insights-stat{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:14px;}
.insights-stat-value{font-size:22px;font-weight:700;color:var(--text);}
.insights-stat-label{font-size:11px;color:var(--muted);margin-top:4px;}
.insights-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;}
.insights-card{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:14px;}
.insights-card-title{font-size:13px;font-weight:600;color:var(--text);margin-bottom:10px;}
.insights-table{width:100%;font-size:12px;}
.insights-table-head{display:grid;grid-template-columns:1fr 80px;padding:4px 0;border-bottom:1px solid var(--border);font-weight:600;color:var(--muted);font-size:11px;}
.insights-table-row{display:grid;grid-template-columns:1fr 80px;padding:6px 0;border-bottom:1px solid var(--border,.05);}
.insights-model-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.insights-bars{display:flex;flex-direction:column;gap:6px;}
.insights-bar-row{display:grid;grid-template-columns:40px 1fr 40px;align-items:center;gap:8px;}
.insights-bar-label{font-size:11px;color:var(--muted);text-align:right;}
.insights-bar-track{height:16px;background:var(--border,.15);border-radius:4px;overflow:hidden;}
.insights-bar-fill{height:100%;background:var(--accent);border-radius:4px;min-width:2px;transition:width .3s;}
.insights-bar-fill.peak{background:#f6ad55;}
.insights-bar-value{font-size:11px;color:var(--text);}
.insights-token-row{display:flex;justify-content:space-between;padding:4px 0;font-size:12px;border-bottom:1px solid var(--border,.05);}
.insights-token-label{color:var(--muted);}
.insights-token-value{font-weight:600;}
/* ── Checkpoints / Rollback UI (#466) ─────────────────────────────────────── */
.checkpoint-list{display:flex;flex-direction:column;gap:8px;}
.checkpoint-item{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:var(--surface-2);border:1px solid var(--border);border-radius:6px;font-size:12px;}
.checkpoint-item-actions{display:flex;gap:6px;}
.checkpoint-item-actions button{background:none;border:none;color:var(--muted);cursor:pointer;padding:2px 4px;border-radius:4px;}
.checkpoint-item-actions button:hover{color:var(--accent);background:rgba(255,255,255,.06);}
.checkpoint-item-actions button.danger:hover{color:#fc8181;}
.checkpoint-diff{position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;}
.checkpoint-diff-modal{background:var(--surface);border:1px solid var(--border);border-radius:8px;max-width:700px;width:90%;max-height:80vh;display:flex;flex-direction:column;}
.checkpoint-diff-header{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--border);}
.checkpoint-diff-body{padding:12px 16px;overflow-y:auto;flex:1;}
.checkpoint-diff-body pre{font-size:11px;line-height:1.4;white-space:pre-wrap;word-break:break-all;}