feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)

Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70

Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.

Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.

Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).

Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].

Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.

Workspace panel FOUC fix: [data-workspace-panel] set at parse time.

Docs: docs/ui-ux/index.html + two-stage-proposal.html.

Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.

Reviewed and approved by @nesquena (independent review). 1361 tests passing.
This commit is contained in:
Aron Prins
2026-04-16 23:04:42 +02:00
committed by GitHub
parent 25d38a467a
commit 9a3dc10d93
20 changed files with 2770 additions and 469 deletions

6
.gitignore vendored
View File

@@ -26,5 +26,7 @@ full-UI.png
.DS_Store
Thumbs.db
# Local reference clones — never committed
docs/
# Local reference clones — never committed (except tracked design/UI-UX reference pages)
docs/*
!docs/ui-ux/
!docs/ui-ux/**

View File

@@ -7,10 +7,10 @@
>
> Keep this document updated as architecture changes are made.
> Current shipped build: `v0.50.36-local.1` (April 14, 2026).
> Current shipped build: `v0.50.36-local.1` (April 16, 2026).
> Baseline: upstream `nesquena/hermes-webui` `v0.50.36`.
> Intentional local delta: first-time password enablement from Settings immediately issues a `hermes_session` cookie so the current browser remains signed in. The previous `Assistant Reply Language` customization has been removed, and legacy `assistant_language` settings are filtered out on load/save.
> Automated coverage: 1059 passing tests.
> Intentional local delta: first-time password enablement from Settings immediately issues a `hermes_session` cookie so the current browser remains signed in. The previous `Assistant Reply Language` customization has been removed, legacy `assistant_language` settings are filtered out on load/save, the workspace panel closed/open state is preloaded via a `documentElement` dataset marker before `style.css` paints to avoid a first-load desktop flash, transcript disclosure cards now animate caret rotation and body expansion with transitionable `max-height`/`opacity` states instead of `display:none/block`, and thinking cards now share the same rounded bordered card chrome as tool cards while keeping their gold palette.
> Automated coverage: 1353 tests collected (`pytest tests/ --collect-only -q`).
---
@@ -23,6 +23,11 @@ and a demand-driven right panel used for workspace browsing and preview surfaces
The right panel is closed by default on desktop and opens only when it is actively
being used for browsing or previewing content.
To prevent a visible first-paint mismatch on refresh, `static/index.html` preloads the
saved workspace panel state into `document.documentElement.dataset.workspacePanel`
before the main stylesheet loads. Desktop CSS honors that preload marker immediately,
and `static/boot.js` keeps the dataset synchronized with the runtime panel state machine.
The design philosophy is deliberately minimal. There is no build step, no bundler, no
frontend framework. The Python server is split into a routing shell (server.py) and
business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/.

View File

@@ -3,9 +3,9 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.50.44 (April 14, 2026) — 1195 tests, 1195 passing
> Local delta: enabling password from Settings keeps the current browser signed in; the former Assistant Reply Language enhancement has been removed.
> Tests: 1059 total (1059 passing, 0 failures)
> Last updated: v0.50.44 (April 16, 2026) — 1353 tests collected
> Local delta: enabling password from Settings keeps the current browser signed in; the former Assistant Reply Language enhancement has been removed; workspace panel closed-state now preloads in `<head>` so desktop first paint no longer flashes open before boot sync; thinking cards and tool call cards now animate both their carets and disclosure bodies smoothly on expand/collapse, and thinking cards now use the same bordered rounded panel chrome as tool cards with a gold palette.
> Tests: 1353 collected (`pytest tests/ --collect-only -q`)
> Source: <repo>/
---

View File

@@ -8,8 +8,10 @@
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
>
> Automated tests: 1195 total (1195 passing, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, and the onboarding skip/existing-config guard.
> Automated coverage: 1353 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
> Run: `pytest tests/ -v --timeout=60`
>
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.
---
@@ -1686,6 +1688,13 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
- Click a directory toggle arrow (▸) → expands in-place showing children.
- Click again (▾) → collapses. Double-click navigates into it (breadcrumb view).
- If model returns thinking blocks (Claude extended thinking), verify collapsible gold card appears above response.
- Verify the thinking card has a tinted background, visible border, and rounded corners like a tool card, but in the gold thinking palette.
- Open and close a thinking card. Verify the caret rotation and the content reveal both animate smoothly instead of snapping open.
### UI Polish: Tool Card Disclosure Animation
- Trigger a response with at least one completed tool call card.
- Open and close the tool call card. Verify the caret rotates smoothly and the args/result section animates open and closed instead of appearing instantly.
- If a turn has 2+ tool cards, use "Expand all / Collapse all" and verify the same smooth animation applies to every card in the group.
### Sprint 19: Auth + Security
- No password set: everything works as normal. No login page.
@@ -1740,8 +1749,8 @@ Each has automated API-level tests in `tests/test_sprint{N}.py`.
---
*Last updated: v0.50.44, April 14, 2026*
*Total automated tests: 1195 (1195 passing, 0 failures)*
*Last updated: v0.50.44, April 16, 2026*
*Total automated tests collected: 1353*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*

View File

@@ -2085,7 +2085,9 @@ def _handle_chat_sync(handler, body):
"write_file, read_file, search_files, terminal workdir, and patch. "
"Never fall back to a hardcoded path when this tag is present."
)
from api.streaming import _sanitize_messages_for_api
from api.streaming import _sanitize_messages_for_api, _restore_reasoning_metadata
_previous_messages = list(s.messages or [])
result = agent.run_conversation(
user_message=workspace_ctx + msg,
@@ -2108,7 +2110,10 @@ def _handle_chat_sync(handler, body):
os.environ.pop("HERMES_SESSION_KEY", None)
else:
os.environ["HERMES_SESSION_KEY"] = old_session_key
s.messages = result.get("messages") or s.messages
s.messages = _restore_reasoning_metadata(
_previous_messages,
result.get("messages") or s.messages,
)
# Only auto-generate title when still default; preserves user renames
if s.title == "Untitled":
s.title = title_from(s.messages, s.title)

View File

@@ -10,6 +10,7 @@ import re
import threading
import time
import traceback
import copy
from pathlib import Path
from typing import Optional
@@ -539,6 +540,183 @@ def _sanitize_messages_for_api(messages):
return clean
def _api_safe_message_positions(messages):
"""Return [(original_index, sanitized_message)] for API-safe messages."""
valid_tool_call_ids: set = set()
for msg in messages:
if not isinstance(msg, dict):
continue
if msg.get('role') == 'assistant':
for tc in msg.get('tool_calls') or []:
if isinstance(tc, dict):
tid = tc.get('id') or tc.get('call_id') or ''
if tid:
valid_tool_call_ids.add(tid)
out = []
for idx, msg in enumerate(messages):
if not isinstance(msg, dict):
continue
role = msg.get('role')
if role == 'tool':
tid = msg.get('tool_call_id') or ''
if not tid or tid not in valid_tool_call_ids:
continue
sanitized = {k: v for k, v in msg.items() if k in _API_SAFE_MSG_KEYS}
if sanitized.get('role'):
out.append((idx, sanitized))
return out
def _restore_reasoning_metadata(previous_messages, updated_messages):
"""Carry forward assistant reasoning metadata lost during API-safe history sanitization.
The provider-facing history strips WebUI-only fields like `reasoning`. When the
agent returns its new full message history, prior assistant messages come back
without that metadata unless we merge it back in by API-history position.
"""
if not previous_messages or not updated_messages:
return updated_messages
updated_messages = list(updated_messages)
prev_safe = _api_safe_message_positions(previous_messages)
def _safe_projection(msg):
if not isinstance(msg, dict):
return None
return {k: v for k, v in msg.items() if k in _API_SAFE_MSG_KEYS and msg.get('role')}
def _reasoning_only_assistant(msg):
if not isinstance(msg, dict) or msg.get('role') != 'assistant' or not msg.get('reasoning'):
return False
if msg.get('tool_calls'):
return False
return not _message_text(msg.get('content'))
safe_pos = 0
while safe_pos < len(prev_safe):
prev_idx, _ = prev_safe[safe_pos]
prev_msg = previous_messages[prev_idx]
cur_msg = updated_messages[safe_pos] if safe_pos < len(updated_messages) else None
if isinstance(prev_msg, dict) and isinstance(cur_msg, dict) and _safe_projection(prev_msg) == _safe_projection(cur_msg):
if prev_msg.get('role') == 'assistant' and prev_msg.get('reasoning') and not cur_msg.get('reasoning'):
cur_msg['reasoning'] = prev_msg['reasoning']
safe_pos += 1
continue
if _reasoning_only_assistant(prev_msg):
updated_messages.insert(safe_pos, copy.deepcopy(prev_msg))
safe_pos += 1
continue
safe_pos += 1
return updated_messages
def _tool_result_snippet(raw) -> str:
"""Extract a compact result preview from a stored tool message payload."""
text = str(raw or '')
try:
data = json.loads(text)
if isinstance(data, dict):
return str(data.get('output') or data.get('result') or data.get('error') or text)[:200]
except Exception:
pass
return text[:200]
def _truncate_tool_args(args, limit: int = 6) -> dict:
"""Truncate tool args for compact session persistence."""
out = {}
if not isinstance(args, dict):
return out
for k, v in list(args.items())[:limit]:
s = str(v)
out[k] = s[:120] + ('...' if len(s) > 120 else '')
return out
def _nearest_assistant_msg_idx(messages, msg_idx: int) -> int:
"""Find the closest preceding assistant message index for a tool result."""
for idx in range(msg_idx - 1, -1, -1):
msg = messages[idx]
if isinstance(msg, dict) and msg.get('role') == 'assistant':
return idx
return -1
def _extract_tool_calls_from_messages(messages, live_tool_calls=None):
"""Build persisted tool-call summaries from final messages plus live progress fallback."""
tool_calls = []
pending_names = {}
pending_args = {}
pending_asst_idx = {}
tool_msg_sequence = []
for msg_idx, m in enumerate(messages or []):
if not isinstance(m, dict):
continue
role = m.get('role')
if role == 'assistant':
content = m.get('content', '')
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get('type') == 'tool_use':
tid = part.get('id', '')
if tid:
pending_names[tid] = part.get('name', '')
pending_args[tid] = part.get('input', {})
pending_asst_idx[tid] = msg_idx
for tc in m.get('tool_calls', []):
if not isinstance(tc, dict):
continue
tid = tc.get('id', '') or tc.get('call_id', '')
fn = tc.get('function', {})
name = fn.get('name', '')
try:
args = json.loads(fn.get('arguments', '{}') or '{}')
except Exception:
args = {}
if tid and name:
pending_names[tid] = name
pending_args[tid] = args
pending_asst_idx[tid] = msg_idx
elif role == 'tool':
tid = m.get('tool_call_id') or m.get('tool_use_id', '')
raw = m.get('content', '')
seq = {'msg_idx': msg_idx, 'raw': raw, 'resolved': False}
if tid:
name = pending_names.get(tid, '')
if name and name != 'tool':
tool_calls.append({
'name': name,
'snippet': _tool_result_snippet(raw),
'tid': tid,
'assistant_msg_idx': pending_asst_idx.get(tid, -1),
'args': _truncate_tool_args(pending_args.get(tid, {})),
})
seq['resolved'] = True
tool_msg_sequence.append(seq)
live = [tc for tc in (live_tool_calls or []) if isinstance(tc, dict) and tc.get('name') and tc.get('name') != 'clarify']
if live:
for seq_idx, seq in enumerate(tool_msg_sequence):
if seq.get('resolved'):
continue
if seq_idx >= len(live):
break
live_tc = live[seq_idx]
tool_calls.append({
'name': live_tc.get('name', 'tool'),
'snippet': _tool_result_snippet(seq.get('raw', '')),
'tid': live_tc.get('tid', '') or '',
'assistant_msg_idx': _nearest_assistant_msg_idx(messages, seq.get('msg_idx', -1)),
'args': _truncate_tool_args(live_tc.get('args', {}), limit=4),
})
return tool_calls
def _sse(handler, event, data):
"""Write one SSE event to the response stream."""
payload = f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
@@ -704,6 +882,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
try:
_token_sent = False # tracks whether any streamed tokens were sent
_reasoning_text = '' # accumulates reasoning/thinking trace for persistence
_live_tool_calls = [] # tool progress fallback when final messages omit tool IDs
def on_token(text):
nonlocal _token_sent
@@ -749,6 +928,10 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
args_snap[k] = s2[:120] + ('...' if len(s2) > 120 else '')
if event_type in (None, 'tool.started'):
_live_tool_calls.append({
'name': name,
'args': args if isinstance(args, dict) else {},
})
put('tool', {
'event_type': event_type or 'tool.started',
'name': name,
@@ -769,6 +952,14 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
return
if event_type == 'tool.completed':
for live_tc in reversed(_live_tool_calls):
if live_tc.get('done'):
continue
if not name or live_tc.get('name') == name:
live_tc['done'] = True
live_tc['duration'] = cb_kwargs.get('duration')
live_tc['is_error'] = bool(cb_kwargs.get('is_error', False))
break
put('tool_complete', {
'event_type': event_type,
'name': name,
@@ -903,6 +1094,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
# Pass personality via ephemeral_system_prompt (agent's own mechanism)
if _personality_prompt:
agent.ephemeral_system_prompt = _personality_prompt
_previous_messages = list(s.messages or [])
result = agent.run_conversation(
user_message=workspace_ctx + msg_text,
system_message=workspace_system_msg,
@@ -910,7 +1102,10 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
task_id=session_id,
persist_user_message=msg_text,
)
s.messages = result.get('messages') or s.messages
s.messages = _restore_reasoning_metadata(
_previous_messages,
result.get('messages') or s.messages,
)
# ── Detect silent agent failure (no assistant reply produced) ──
# When the agent catches an auth/network error internally it may return
@@ -1011,63 +1206,12 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
s.output_tokens = (s.output_tokens or 0) + output_tokens
if estimated_cost:
s.estimated_cost = (s.estimated_cost or 0) + estimated_cost
# Extract tool call metadata grouped by assistant message index
# Each tool call gets assistant_msg_idx so the client can render
# cards inline with the assistant bubble that triggered them.
tool_calls = []
pending_names = {} # tool_call_id -> name
pending_args = {} # tool_call_id -> args dict
pending_asst_idx = {} # tool_call_id -> index in s.messages
for msg_idx, m in enumerate(s.messages):
if m.get('role') == 'assistant':
c = m.get('content', '')
# Anthropic format: content is a list with type=tool_use blocks
if isinstance(c, list):
for p in c:
if isinstance(p, dict) and p.get('type') == 'tool_use':
tid = p.get('id', '')
pending_names[tid] = p.get('name', '')
pending_args[tid] = p.get('input', {})
pending_asst_idx[tid] = msg_idx
# OpenAI format: tool_calls as top-level field on the message
for tc in m.get('tool_calls', []):
if not isinstance(tc, dict):
continue
tid = tc.get('id', '') or tc.get('call_id', '')
fn = tc.get('function', {})
name = fn.get('name', '')
try:
import json as _j
args = _j.loads(fn.get('arguments', '{}') or '{}')
except Exception:
args = {}
if tid and name:
pending_names[tid] = name
pending_args[tid] = args
pending_asst_idx[tid] = msg_idx
elif m.get('role') == 'tool':
tid = m.get('tool_call_id') or m.get('tool_use_id', '')
name = pending_names.get(tid, '')
if not name or name == 'tool':
continue # skip unresolvable tool entries
asst_idx = pending_asst_idx.get(tid, -1)
args = pending_args.get(tid, {})
raw = str(m.get('content', ''))
try:
rd = json.loads(raw)
snippet = str(rd.get('output') or rd.get('result') or rd.get('error') or raw)[:200]
except Exception:
snippet = raw[:200]
# Truncate args values for storage
args_snap = {}
if isinstance(args, dict):
for k, v in list(args.items())[:6]:
s2 = str(v)
args_snap[k] = s2[:120] + ('...' if len(s2) > 120 else '')
tool_calls.append({
'name': name, 'snippet': snippet, 'tid': tid,
'assistant_msg_idx': asst_idx, 'args': args_snap,
})
# Persist tool-call summaries even when the final message history only
# kept bare tool rows and omitted explicit assistant tool_call IDs.
tool_calls = _extract_tool_calls_from_messages(
s.messages,
live_tool_calls=_live_tool_calls,
)
s.tool_calls = tool_calls
s.active_stream_id = None
s.pending_user_message = None
@@ -1085,6 +1229,15 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
if base_text[:60] in content or content[:60] in msg_text:
m['attachments'] = attachments
break
# Persist reasoning trace in the session so it survives reload.
# Must run BEFORE s.save() — otherwise the mutation lives only in
# memory until the next turn's save, and the last-turn thinking card
# is lost when the user reloads immediately after a response.
if _reasoning_text and s.messages:
for _rm in reversed(s.messages):
if isinstance(_rm, dict) and _rm.get('role') == 'assistant':
_rm['reasoning'] = _reasoning_text
break
s.save()
# Sync to state.db for /insights (opt-in setting)
try:
@@ -1109,12 +1262,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
usage['context_length'] = getattr(_cc, 'context_length', 0) or 0
usage['threshold_tokens'] = getattr(_cc, 'threshold_tokens', 0) or 0
usage['last_prompt_tokens'] = getattr(_cc, 'last_prompt_tokens', 0) or 0
# Persist reasoning trace in the session so it survives reload
if _reasoning_text and s.messages:
for _rm in reversed(s.messages):
if isinstance(_rm, dict) and _rm.get('role') == 'assistant':
_rm['reasoning'] = _reasoning_text
break
# (reasoning trace already attached + saved above, before s.save())
raw_session = s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}
put('done', {'session': redact_session_data(raw_session), 'usage': usage})
if _should_bg_title and _u0 and _a0:

862
docs/ui-ux/index.html Normal file
View File

@@ -0,0 +1,862 @@
<!doctype html>
<html lang="en" data-theme="slate">
<head>
<meta charset="utf-8">
<title>Hermes WebUI — Messages UI Inventory</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<!-- Real app stylesheet -->
<link rel="stylesheet" href="../../static/style.css">
<!-- Prism (same theme the app pulls at runtime) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
<!-- KaTeX -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<style>
/* Showcase scaffold — styles only for the doc chrome. Everything inside
.messages uses the real app CSS unchanged. */
/* Real app CSS makes <body> a fixed-height flex shell. Undo that so this
doc page can scroll normally with a stacked header + main. */
body{display:block !important;height:auto !important;min-height:100vh;overflow:auto !important;}
.doc-main{display:block;}
.doc-header{position:sticky;top:0;z-index:50;background:var(--topbar-bg);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:14px 24px;display:flex;flex-wrap:wrap;align-items:center;gap:14px;}
.doc-title{font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--text);}
.doc-title small{display:block;font-size:11px;font-weight:500;color:var(--muted);margin-top:3px;}
.doc-toggles{display:flex;flex-wrap:wrap;gap:6px;margin-left:auto;}
.doc-toggles button{font:inherit;font-size:11px;padding:5px 10px;border-radius:7px;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);cursor:pointer;}
.doc-toggles button.on{background:rgba(124,185,255,.12);border-color:rgba(124,185,255,.4);color:var(--blue);}
.doc-main{max-width:1100px;margin:0 auto;padding:24px 24px 120px;}
.doc-section{margin:40px 0 8px;padding-top:20px;border-top:1px dashed var(--border);}
.doc-section:first-of-type{border-top:none;padding-top:0;margin-top:0;}
.doc-kicker{font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--blue);}
.doc-h{font-size:18px;font-weight:700;color:var(--text);margin:4px 0 4px;}
.doc-note{font-size:12px;color:var(--muted);line-height:1.55;max-width:760px;margin-bottom:10px;}
.doc-card{position:relative;background:var(--main-bg);border:1px solid var(--border);border-radius:12px;padding:4px 6px;margin:12px 0;}
.doc-label{position:absolute;top:-9px;left:12px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;padding:2px 8px;background:var(--bg);color:var(--muted);border:1px solid var(--border);border-radius:999px;}
/* Force-show hover-only affordances inside explicitly flagged demos */
.force-show .msg-actions,
.force-show .msg-time,
.force-show .msg-foot{opacity:1 !important;}
/* Chat demo container mimics the app's .messages scroll wrapper but not fullscreen */
.messages.doc-messages{overflow:visible;display:block;}
.messages-inner.doc-inner{padding:14px 16px;}
/* Make the in-page demos of approval/clarify cards visible without JS */
.approval-card.doc-visible,
.clarify-card.doc-visible{display:block;}
.reconnect-banner.doc-visible{display:flex;align-items:center;justify-content:space-between;gap:12px;background:rgba(201,168,76,.12);border:1px solid rgba(201,168,76,.3);color:var(--gold);padding:8px 14px;border-radius:8px;font-size:12px;}
.reconnect-banner.doc-visible .reconnect-btn{background:none;border:1px solid rgba(201,168,76,.35);color:var(--gold);padding:4px 10px;border-radius:6px;font-size:11px;cursor:pointer;}
.bg-error-banner.doc-visible{border-radius:8px;}
/* Two-up grid for short comparisons */
.doc-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;}
</style>
</head>
<body>
<header class="doc-header">
<div class="doc-title">Hermes WebUI — Messages UI Inventory<small>Every message-area element &amp; combination, wired to the real <code>static/style.css</code>. &nbsp;·&nbsp; <a href="./two-stage-proposal.html" style="color:var(--blue);text-decoration:none;">Two-stage proposal (#536) →</a></small></div>
<div class="doc-toggles">
<strong style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;align-self:center;margin-right:4px;">Theme</strong>
<button data-theme-btn="default">Default</button>
<button data-theme-btn="slate" class="on">Slate</button>
<button data-theme-btn="light">Light</button>
<button data-theme-btn="solarized">Solarized</button>
<button data-theme-btn="monokai">Monokai</button>
<button data-theme-btn="nord">Nord</button>
<button data-theme-btn="oled">OLED</button>
<span style="width:1px;height:18px;background:var(--border);margin:0 4px;align-self:center;"></span>
<button id="toggleBubble">Bubble layout: off</button>
</div>
</header>
<main class="doc-main">
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">1 · Empty state</div>
<h2 class="doc-h">First load / no messages</h2>
<p class="doc-note">Renders inside <code>#messages</code> when <code>S.messages</code> is empty. Logo + title + subtitle + 3 suggestion buttons.</p>
<div class="doc-card"><span class="doc-label">.empty-state</span>
<div class="messages doc-messages">
<div class="empty-state" style="min-height:340px;flex:0 0 auto;">
<div class="empty-logo">H</div>
<h2>What can I help with?</h2>
<p>Ask anything, run commands, explore files, or manage your scheduled tasks.</p>
<div class="suggestion-grid">
<button class="suggestion">📁 What files are in this workspace?</button>
<button class="suggestion">📅 What's on my schedule today?</button>
<button class="suggestion">🗺️ Help me plan a small project.</button>
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">2 · User messages</div>
<h2 class="doc-h">Right-aligned bubble, attachments, and edit mode</h2>
<p class="doc-note">User rows have no avatar/label — the right-edge alignment and tinted bubble identify the sender. Timestamp + edit/copy live in a <code>.msg-foot</code> below the bubble, revealed on hover (forced visible here).</p>
<div class="doc-card"><span class="doc-label">.msg-row[data-role="user"] — plain</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row force-show" data-role="user" data-raw-text="How do I run the dev server and point it at a specific workspace path?">
<div class="msg-body"><p>How do I run the dev server and point it at a specific workspace path?</p></div>
<div class="msg-foot">
<span class="msg-time" title="Thu, Apr 16 2026, 10:42 AM">10:42</span>
<span class="msg-actions">
<button class="msg-action-btn" title="Edit"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
</span>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.msg-files — attachments above body (right-aligned)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row" data-role="user">
<div class="msg-files">
<span class="msg-file-badge">📎 architecture-notes.pdf</span>
<span class="msg-file-badge">📎 Q1-forecast.xlsx</span>
<span class="msg-file-badge">📎 meeting.docx</span>
<span class="msg-file-badge">📎 screenshot.png</span>
</div>
<div class="msg-body"><p>Please review these docs and summarise the key decisions.</p></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.msg-edit-area + .msg-edit-bar — edit mode</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row" data-role="user" data-editing="1">
<textarea class="msg-edit-area">How do I run the dev server and point it at a specific workspace path — and can I do it without docker?</textarea>
<div class="msg-edit-bar">
<button class="msg-edit-send">Send edit</button>
<button class="msg-edit-cancel">Cancel</button>
</div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">3 · Assistant — markdown basics</div>
<h2 class="doc-h">Paragraphs, emphasis, lists, blockquote, hr, links</h2>
<p class="doc-note">Assistant output is a single <code>.msg-row.assistant-turn</code> that holds one role header + an <code>.assistant-turn-blocks</code> column of one-or-more <code>.assistant-segment</code> children. Each segment may contain a <code>.thinking-card</code>, a <code>.msg-body</code>, and its own <code>.msg-foot</code> (copy / regen). This lets a turn stream reasoning → text → tool calls → more text without repeating the Hermes avatar each time.</p>
<div class="doc-card"><span class="doc-label">.msg-body — rich prose</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn force-show" data-role="assistant">
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
<span class="role-icon assistant">H</span>
<span>Hermes</span>
</div>
<div class="assistant-turn-blocks">
<div class="assistant-segment" data-raw-text="Running the dev server...">
<div class="msg-body">
<h1>Running the dev server</h1>
<p>You can start Hermes with the built-in launcher. The <strong>simplest path</strong> is <em>no docker, no proxy</em> — the CLI handles everything.</p>
<h2>Prerequisites</h2>
<ul>
<li>Node <code>&gt;= 18</code></li>
<li>A workspace directory you own
<ul>
<li>Read/write permissions</li>
<li>No existing <code>.hermes</code> folder</li>
</ul>
</li>
<li>An API key set via <code>HERMES_API_KEY</code></li>
</ul>
<h2>Steps</h2>
<ol>
<li>Clone the repo</li>
<li>Run <code>npm install</code></li>
<li>Start with <code>npm run dev -- --workspace ~/code</code></li>
</ol>
<blockquote>Tip: the <code>--workspace</code> flag accepts absolute or <code>~</code>-prefixed paths. Relative paths are resolved against the CWD.</blockquote>
<hr>
<p>For full setup options see the <a href="#">configuration guide</a>.</p>
</div>
<div class="msg-foot">
<span class="msg-actions">
<button class="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
<button class="msg-action-btn" title="Regenerate"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button>
</span>
</div>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.msg-body table</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks">
<div class="assistant-segment">
<div class="msg-body">
<p>Model comparison:</p>
<table>
<thead><tr><th>Model</th><th>Context</th><th>Good for</th><th>Cost / 1M in</th></tr></thead>
<tbody>
<tr><td>Opus 4.6</td><td>1M</td><td>Deep reasoning, long code</td><td><code>$15.00</code></td></tr>
<tr><td>Sonnet 4.6</td><td>1M</td><td>Daily driver, agents</td><td><code>$3.00</code></td></tr>
<tr><td>Haiku 4.5</td><td>200k</td><td>Fast tasks, tool loops</td><td><code>$0.80</code></td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">4 · Code blocks</div>
<h2 class="doc-h">Plain, with header, with copy button, multi-language</h2>
<div class="doc-card"><span class="doc-label">pre + code (no header)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<pre><code class="language-bash">npm install
npm run dev -- --workspace ~/code</code></pre>
</div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.pre-header + pre + .code-copy-btn</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<div style="position:relative;">
<div class="pre-header">typescript <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
<pre><code class="language-typescript">export async function startServer(opts: ServerOptions) {
const port = opts.port ?? 3000;
const app = createApp();
app.listen(port, () =&gt; {
console.log(`Hermes listening on :${port}`);
});
return app;
}</code></pre>
</div>
<div style="position:relative;margin-top:14px;">
<div class="pre-header">python <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
<pre><code class="language-python">from hermes import Agent
def main() -&gt; None:
agent = Agent(model="claude-opus-4-6")
reply = agent.run("Summarise today's commits")
print(reply)
if __name__ == "__main__":
main()</code></pre>
</div>
<div style="position:relative;margin-top:14px;">
<div class="pre-header">json <button class="code-copy-btn" style="margin-left:auto;">Copy</button></div>
<pre><code class="language-json">{
"model": "claude-sonnet-4-6",
"stream": true,
"tools": ["bash", "edit_file", "search"]
}</code></pre>
</div>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">5 · Inline media</div>
<h2 class="doc-h">Images (default &amp; zoomed) and downloadable links</h2>
<div class="doc-card"><span class="doc-label">.msg-media-img (default + .msg-media-img--full)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>Here's the screenshot you asked for (click to zoom):</p>
<img class="msg-media-img" alt="demo" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='360'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' x2='1'%3E%3Cstop offset='0' stop-color='%237cb9ff'/%3E%3Cstop offset='1' stop-color='%23c9a84c'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23g)' width='640' height='360'/%3E%3Ctext x='50%25' y='50%25' font-family='system-ui' font-size='28' fill='white' text-anchor='middle' dominant-baseline='middle'%3E.msg-media-img (480×400 cap)%3C/text%3E%3C/svg%3E">
<p style="margin-top:10px;">And the full-width variant:</p>
<img class="msg-media-img msg-media-img--full" alt="demo-full" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1280' height='320'%3E%3Crect fill='%231e2023' width='1280' height='320'/%3E%3Ctext x='50%25' y='50%25' font-family='system-ui' font-size='28' fill='%2382aaff' text-anchor='middle' dominant-baseline='middle'%3E.msg-media-img--full (unbounded)%3C/text%3E%3C/svg%3E">
</div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.msg-media-link — non-image downloads</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>I saved the generated files:</p>
<p><a class="msg-media-link" href="#">📎 report-2026-Q1.pdf</a> <a class="msg-media-link" href="#">📎 revenue.csv</a> <a class="msg-media-link" href="#">📎 diagram.svg</a></p>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">6 · Math &amp; diagrams</div>
<h2 class="doc-h">KaTeX inline / block &amp; Mermaid block</h2>
<div class="doc-card"><span class="doc-label">.katex-inline + .katex-block</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>Inline math: <span class="katex-inline" data-math-inline>\(E = mc^2\)</span> and the quadratic formula below:</p>
<div class="katex-block" data-math-block>$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$</div>
<p>A tidier form: <span class="katex-inline" data-math-inline>\(\sum_{i=1}^{n} i = \frac{n(n+1)}{2}\)</span>.</p>
<div class="katex-block" data-math-block>$$\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}$$</div>
</div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.mermaid-block (pre-render placeholder)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>The request flow:</p>
<div class="mermaid-block"><pre style="margin:0;background:none;border:none;padding:0;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;font-size:12px;">graph LR
U[User] --&gt; C[Composer]
C --&gt; API[/api/chat/]
API --&gt; M((Model))
M --&gt; T{tool?}
T -- yes --&gt; X[Tool Runner]
T -- no --&gt; R[Reply]
X --&gt; R
R --&gt; U</pre></div>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">7 · Thinking / reasoning</div>
<h2 class="doc-h">Bordered panel (collapsed / open, animated), live loader, streaming cursor</h2>
<p class="doc-note">Thinking cards are rendered at the top of an <code>.assistant-segment</code>. They're now bordered gold-tinted panels (no more left-rule-only look) and expand/collapse with a <code>max-height</code> + opacity transition. Click the header in either example below to see the animation live.</p>
<div class="doc-grid">
<div class="doc-card"><span class="doc-label">.thinking-card (collapsed, inside .assistant-segment)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner" style="padding-top:8px;">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="thinking-card">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 4.3s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>The user asked about the dev server...</pre></div>
</div>
<div class="msg-body"><p>Here's the shortest path…</p></div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.thinking-card.open (animated — max-height + opacity)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner" style="padding-top:8px;">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="thinking-card open">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 4.3s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>The user is asking about launching the dev server.
Options: npm script, docker, or the bundled CLI.
The CLI is the simplest — no container runtime needed.
I should show the exact commands and the --workspace flag,
then mention the env var for the API key at the end.</pre></div>
</div>
<div class="msg-body"><p>Here's the shortest path…</p></div>
</div></div>
</div>
</div></div>
</div>
</div>
<div class="doc-card"><span class="doc-label">.thinking — live 3-dot loader (pre-reasoning)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
<div class="thinking">Thinking <span class="dot"></span><span class="dot"></span><span class="dot"></span></div>
</div></div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">[data-live-assistant="1"] — streaming cursor at end of last child</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant" id="liveAssistantTurn">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
<div class="msg-body"><p>Sure — the simplest way is to run <code>npm run dev</code>. The CLI will pick up the default</p></div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">8 · Tool cards</div>
<h2 class="doc-h">Running, done, expanded, subagent, error, multi-card toggle</h2>
<p class="doc-note">Tool cards sit in <code>.tool-card-row</code> wrappers (no longer nested under <code>.msg-row</code>). The details panel now animates open/closed via <code>max-height</code> + opacity — click any header below to see the transition.</p>
<div class="doc-card"><span class="doc-label">.tool-card.tool-card-running (collapsed, pulsing dot)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card tool-card-running">
<div class="tool-card-header">
<span class="tool-card-running-dot"></span>
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">npm run build</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-card — done, collapsed</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">static/style.css · 1155 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-card.open — args table + result snippet + Show more (animated detail)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card open">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">grep -rn "msg-role" static/ · exit 0 · 380ms</span>
<span class="tool-card-toggle"></span>
</div>
<div class="tool-card-detail">
<div class="tool-card-args">
<div><span class="tool-arg-key">command:</span> <span class="tool-arg-val">grep -rn "msg-role" static/</span></div>
<div><span class="tool-arg-key">cwd:</span> <span class="tool-arg-val">/Users/aron/hermes-webui</span></div>
<div><span class="tool-arg-key">timeout:</span> <span class="tool-arg-val">30000</span></div>
</div>
<div class="tool-card-result">
<pre>static/style.css:430: .msg-role{font-size:12px;font-weight:500...}
static/style.css:431: .msg-role.user{color:rgba(124,185,255,0.65);}
static/style.css:432: .msg-role.assistant{color:rgba(201,168,76,0.6);}
static/ui.js:1141: const roleEl = el('div', 'msg-role ' + role);</pre>
<button class="tool-card-more">Show more (+142 lines)</button>
</div>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-card.tool-card-subagent — delegated work</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card tool-card-subagent">
<div class="tool-card-header">
<span class="tool-card-icon">🤖</span>
<span class="tool-card-name">Subagent</span>
<span class="tool-card-preview">Explore · Map chat messages UI elements</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card tool-card-subagent">
<div class="tool-card-header">
<span class="tool-card-icon">🤖</span>
<span class="tool-card-name">Delegate task</span>
<span class="tool-card-preview">Plan · Propose redesign variants</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-card (error snippet)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-card-row">
<div class="tool-card open">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">npm run typecheck · exit 1 · 2.3s</span>
<span class="tool-card-toggle"></span>
</div>
<div class="tool-card-detail">
<div class="tool-card-args">
<div><span class="tool-arg-key">command:</span> <span class="tool-arg-val">npm run typecheck</span></div>
</div>
<div class="tool-card-result">
<pre style="color:#fca5a5;">src/server.ts:42:7 - error TS2345: Argument of type 'string | undefined'
is not assignable to parameter of type 'number'.
42 app.listen(opts.port, () =&gt; {
~~~~~~~~~</pre>
</div>
</div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.tool-cards-toggle — Expand/Collapse All (≥2 cards)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="tool-cards-toggle">
<button>Expand all (3)</button>
<button>Collapse all</button>
</div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">package.json</span><span class="tool-card-toggle"></span></div></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">🔎</span><span class="tool-card-name">grep</span><span class="tool-card-preview">"listen" in src/</span><span class="tool-card-toggle"></span></div></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">bash</span><span class="tool-card-preview">npm run typecheck · exit 0 · 4.1s</span><span class="tool-card-toggle"></span></div></div></div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">9 · Meta affordances</div>
<h2 class="doc-h">Role timestamp tooltip, footer action toolbar, token-usage badge</h2>
<p class="doc-note">Assistant timestamps live on the <code>.msg-role</code> <code>title</code> attribute (hover for full date). Copy/regen buttons sit in the per-segment <code>.msg-foot</code>, 45% opacity at rest, full on turn hover. The <code>.msg-usage</code> badge is always visible at the bottom of the turn.</p>
<div class="doc-card"><span class="doc-label">Full hover state — .msg-foot actions + .msg-usage</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn force-show" data-role="assistant">
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
<span class="role-icon assistant">H</span>
<span>Hermes</span>
</div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body"><p>Built and type-checked successfully — server is running on <code>:3000</code>.</p></div>
<div class="msg-foot">
<span class="msg-actions">
<button class="msg-copy-btn msg-action-btn" title="Copy"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
<button class="msg-action-btn" title="Regenerate"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg></button>
</span>
</div>
</div></div>
<div class="msg-usage">3.2K in · 481 out · ~$0.012</div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">10 · Full composition</div>
<h2 class="doc-h">User turn → assistant turn (segment 1: thinking + body + tool cards) → usage</h2>
<p class="doc-note">A realistic turn: one role header up top, then the segment hosting a thinking card plus the first body; tool cards follow as siblings of the turn inside <code>.messages-inner</code>; the usage badge closes the turn.</p>
<div class="doc-card"><span class="doc-label">All-in-one turn</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row force-show" data-role="user">
<div class="msg-files"><span class="msg-file-badge">📎 server.ts</span></div>
<div class="msg-body"><p>The build fails — can you type-check and explain?</p></div>
<div class="msg-foot">
<span class="msg-time">10:40</span>
<span class="msg-actions">
<button class="msg-action-btn" title="Edit"></button>
<button class="msg-copy-btn msg-action-btn" title="Copy"></button>
</span>
</div>
</div>
<div class="msg-row assistant-turn force-show" data-role="assistant">
<div class="msg-role assistant" title="Thu, Apr 16 2026, 10:42 AM">
<span class="role-icon assistant">H</span><span>Hermes</span>
</div>
<div class="assistant-turn-blocks">
<div class="assistant-segment">
<div class="thinking-card open">
<div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 2.1s</span><span class="thinking-card-toggle"></span></div>
<div class="thinking-card-body"><pre>Attached server.ts — probably typing issue.
Run typecheck to confirm, then patch.</pre></div>
</div>
<div class="msg-body">
<p>The build fails because <code>opts.port</code> can be <code>undefined</code>. Two fixes below — pick the one that matches your intent.</p>
<h3>Option A — require the port</h3>
<pre><code class="language-typescript">export function startServer(opts: { port: number }) {
app.listen(opts.port);
}</code></pre>
<h3>Option B — default to 3000</h3>
<pre><code class="language-typescript">export function startServer(opts: { port?: number } = {}) {
const port = opts.port ?? 3000;
app.listen(port);
}</code></pre>
<p>I ran the checks below to confirm.</p>
</div>
<div class="msg-foot">
<span class="msg-actions">
<button class="msg-copy-btn msg-action-btn" title="Copy"></button>
<button class="msg-action-btn" title="Regenerate"></button>
</span>
</div>
</div>
</div>
<div class="msg-usage">11.4K in · 612 out · ~$0.049</div>
</div>
<div class="tool-cards-toggle">
<button>Expand all (3)</button><button>Collapse all</button>
</div>
<div class="tool-card-row"><div class="tool-card open">
<div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">src/server.ts · 58 lines</span><span class="tool-card-toggle"></span></div>
<div class="tool-card-detail">
<div class="tool-card-args"><div><span class="tool-arg-key">path:</span> <span class="tool-arg-val">src/server.ts</span></div></div>
<div class="tool-card-result"><pre>export function startServer(opts: ServerOptions) {
app.listen(opts.port, () =&gt; { ... });
}</pre></div>
</div>
</div></div>
<div class="tool-card-row"><div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">bash</span><span class="tool-card-preview">npm run typecheck · exit 1 · 2.3s</span><span class="tool-card-toggle"></span></div>
</div></div>
<div class="tool-card-row"><div class="tool-card">
<div class="tool-card-header"><span class="tool-card-icon">✏️</span><span class="tool-card-name">edit_file</span><span class="tool-card-preview">src/server.ts +1 / -1</span><span class="tool-card-toggle"></span></div>
</div></div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">11 · Bubble layout</div>
<h2 class="doc-h">Opt-in via <code>body.bubble-layout</code> — extra bubble padding for assistant too</h2>
<p class="doc-note">The default layout already right-aligns user messages (the redesign adopted it globally), so this toggle mostly affects additional padding / boundary handling. Flip the <strong>Bubble layout</strong> toggle in the header to see the mode applied.</p>
<div class="doc-card"><span class="doc-label">Conversation sample</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row" data-role="user"><div class="msg-body"><p>Can you add a retry button next to the regenerate one?</p></div></div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body"><p>Yes — it can share <code>.msg-action-btn</code> and live in the same <code>.msg-actions</code> container. I'll wire it up on <code>_lastError</code>.</p></div>
</div></div>
</div>
<div class="msg-row" data-role="user"><div class="msg-body"><p>Perfect, go for it.</p></div></div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">12 · System / inline notes</div>
<h2 class="doc-h">Compression, cancellation, errors — rendered as italicised assistant messages</h2>
<div class="doc-card"><span class="doc-label">Italic system notices (still italic — info, not errors)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks">
<div class="assistant-segment"><div class="msg-body"><p><em>[Context was auto-compressed to continue the conversation]</em></p></div></div>
<div class="assistant-segment"><div class="msg-body"><p><em>Task cancelled.</em></p></div></div>
</div>
</div>
</div></div>
</div>
<div class="doc-card"><span class="doc-label">.assistant-segment[data-error="1"] — real error card, red accent, no italic</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks">
<div class="assistant-segment" data-error="1"><div class="msg-body"><p><strong>Error:</strong> Connection lost. Your last message was saved — refresh to continue.</p></div></div>
<div class="assistant-segment" data-error="1"><div class="msg-body"><p><strong>Error:</strong> Upstream rate-limited (429). Retrying in 30s…</p></div></div>
</div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">12b · Turn boundaries &amp; date separators</div>
<h2 class="doc-h">Right-alignment separates user turns · day-change separator</h2>
<p class="doc-note">The dashed divider before each user turn was removed — the right-edge bubble alignment is its own visual break, so only a small vertical gap (10px top margin) remains between turns. Day changes still get a centred <code>.msg-date-sep</code>.</p>
<div class="doc-card"><span class="doc-label">.msg-date-sep — Today / Yesterday / weekday / date</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-date-sep">Yesterday</div>
<div class="msg-row" data-role="user"><div class="msg-body"><p>Can you summarise the PR I opened earlier?</p></div></div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment"><div class="msg-body"><p>Yes — three files changed, net +42 / -18. Main change is the new rail variable…</p></div></div></div>
</div>
<div class="msg-date-sep">Today</div>
<div class="msg-row" data-role="user"><div class="msg-body"><p>Did CI pass overnight?</p></div></div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment"><div class="msg-body"><p>All green — three jobs, 4m 12s total. Here's the breakdown:</p></div></div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">13 · Overlay cards (adjacent to transcript)</div>
<h2 class="doc-h">Approval &amp; Clarify cards + reconnect banner</h2>
<div class="doc-card"><span class="doc-label">.approval-card — 4 button variants (once / session / always / deny)</span>
<div class="approval-card doc-visible">
<div class="approval-inner">
<div class="approval-header">⚠ Approval required</div>
<div class="approval-desc" style="font-size:12px;color:var(--muted);margin-bottom:8px;">The agent wants to run a shell command in <code>/Users/aron/hermes-webui</code>.</div>
<div class="approval-cmd">rm -rf node_modules &amp;&amp; npm install</div>
<div class="approval-btns">
<button class="approval-btn once"><span class="approval-btn-label">Allow once</span><kbd class="approval-kbd"></kbd></button>
<button class="approval-btn session">🔒 <span class="approval-btn-label">Allow session</span></button>
<button class="approval-btn always"><span class="approval-btn-label">Always allow</span></button>
<button class="approval-btn deny"><span class="approval-btn-label">Deny</span></button>
</div>
</div>
</div>
</div>
<div class="doc-card"><span class="doc-label">.clarify-card — choice buttons + free-text fallback</span>
<div class="clarify-card doc-visible">
<div class="clarify-inner">
<div class="clarify-header">? Clarification needed</div>
<div class="clarify-question">Which environment should I deploy this to?</div>
<div class="clarify-choices">
<button class="clarify-choice"><span class="clarify-choice-badge">A</span><span class="clarify-choice-text">Staging — safe sandbox, auto-teardown nightly</span></button>
<button class="clarify-choice"><span class="clarify-choice-badge">B</span><span class="clarify-choice-text">Production EU — customer-facing, requires change ticket</span></button>
<button class="clarify-choice"><span class="clarify-choice-badge">C</span><span class="clarify-choice-text">Production US — same caveats as EU</span></button>
<button class="clarify-choice other"><span class="clarify-choice-badge other"></span><span class="clarify-choice-text">Other — I'll type it below</span></button>
</div>
<div class="clarify-response">
<input class="clarify-input" type="text" placeholder="Type your response…">
<button class="clarify-submit">Send</button>
</div>
<div class="clarify-hint">Pick a choice, or type your own answer below.</div>
</div>
</div>
</div>
<div class="doc-card"><span class="doc-label">Reconnect / mid-stream recovery banner</span>
<div class="reconnect-banner doc-visible">
<span>⚠ A response may have been in progress when you last left. Reload messages?</span>
<div style="display:flex;gap:8px;">
<button class="reconnect-btn">Dismiss</button>
<button class="reconnect-btn">↻ Reload</button>
</div>
</div>
<div class="bg-error-banner doc-visible" style="margin-top:8px;">
<span>⚠ Agent run exited with non-zero status (code 1). Check the logs.</span>
<button class="reconnect-btn">Dismiss</button>
</div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">14 · Structure &amp; data-attribute cheat sheet</div>
<h2 class="doc-h">Wrappers and state markers produced by <code>renderMessages()</code></h2>
<div class="doc-card" style="padding:14px 18px;">
<h3 style="font-size:13px;color:var(--text);margin:0 0 8px;">Wrappers</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li><code>.msg-row[data-role="user"]</code> — one user turn (right-aligned bubble, 60% max-width)</li>
<li><code>.msg-row.assistant-turn[data-role="assistant"]</code> — one assistant turn; contains <strong>one</strong> <code>.msg-role</code> and <strong>one</strong> <code>.assistant-turn-blocks</code></li>
<li><code>.assistant-turn-blocks</code> — flex-column holder for segments</li>
<li><code>.assistant-segment</code> — a single logical chunk inside a turn: optional <code>.thinking-card</code> + optional <code>.msg-body</code> + optional <code>.msg-foot</code></li>
<li><code>.assistant-segment-anchor</code> — hidden segment kept as a DOM anchor for tool cards when the model emitted no text</li>
<li><code>.tool-card-row</code> — per-tool-card wrapper, sibling of the turn inside <code>.messages-inner</code></li>
<li><code>.msg-foot</code> — per-segment (or per-user-row) footer holding <code>.msg-time</code> + <code>.msg-actions</code></li>
</ul>
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Data attributes &amp; IDs</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li><code>data-role="user|assistant"</code> — role marker on the row</li>
<li><code>data-msgIdx="N"</code> — index into <code>S.messages</code>; on user rows <em>and</em> assistant segments</li>
<li><code>data-raw-text="…"</code> — plain-text source for copy (now lives on <code>.assistant-segment</code> for assistant output)</li>
<li><code>data-live-assistant="1"</code> — the segment that's currently streaming</li>
<li><code>data-editing="1"</code> — row is in edit mode</li>
<li><code>data-error="1"</code> — error state; applies to <code>.msg-row</code> (user) or <code>.assistant-segment</code></li>
<li><code>id="liveAssistantTurn"</code> — on the turn that contains the streaming segment</li>
<li><code>.tool-card-row[data-live-tid="…"]</code> — live tool-call card (removed when the turn settles)</li>
<li><code>data-mermaid-id</code>, <code>data-katex</code>, <code>data-rendered</code> — block rendering state</li>
</ul>
</div>
</section>
</main>
<!-- ============================================================= -->
<!-- Prism autoloader for real syntax highlighting -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<!-- KaTeX auto-render -->
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false},{left:'$',right:'$',display:false}],throwOnError:false});"></script>
<script>
// Theme picker
document.querySelectorAll('[data-theme-btn]').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.themeBtn;
if (t === 'default') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', t);
document.querySelectorAll('[data-theme-btn]').forEach(b => b.classList.toggle('on', b === btn));
});
});
// Bubble-layout toggle
const bubbleBtn = document.getElementById('toggleBubble');
bubbleBtn.addEventListener('click', () => {
document.body.classList.toggle('bubble-layout');
const on = document.body.classList.contains('bubble-layout');
bubbleBtn.textContent = 'Bubble layout: ' + (on ? 'on' : 'off');
bubbleBtn.classList.toggle('on', on);
});
// Thinking / tool-card click-to-toggle (so the demo feels live)
document.querySelectorAll('.thinking-card-header, .tool-card-header').forEach(h => {
h.addEventListener('click', () => h.parentElement.classList.toggle('open'));
});
</script>
</body>
</html>

View File

@@ -0,0 +1,742 @@
<!doctype html>
<html lang="en" data-theme="slate">
<head>
<meta charset="utf-8">
<title>Hermes WebUI — Two-Stage Chat Proposal (Issue #536)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="../../static/style.css">
<style>
/* ──────────────────────────────────────────────────────────────
Doc-chrome scaffold (same pattern as index.html) — real app CSS
is used unchanged inside .messages / .msg-row. New proposed
elements are prefixed .p2s- so nothing collides with the app.
────────────────────────────────────────────────────────────── */
body{display:block !important;height:auto !important;min-height:100vh;overflow:auto !important;}
.doc-header{position:sticky;top:0;z-index:50;background:var(--topbar-bg);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:14px 24px;display:flex;flex-wrap:wrap;align-items:center;gap:14px;}
.doc-title{font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--text);}
.doc-title small{display:block;font-size:11px;font-weight:500;color:var(--muted);margin-top:3px;}
.doc-title a{color:var(--blue);text-decoration:none;}
.doc-toggles{display:flex;flex-wrap:wrap;gap:6px;margin-left:auto;}
.doc-toggles button{font:inherit;font-size:11px;padding:5px 10px;border-radius:7px;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);cursor:pointer;}
.doc-toggles button.on{background:rgba(124,185,255,.12);border-color:rgba(124,185,255,.4);color:var(--blue);}
.doc-main{max-width:1180px;margin:0 auto;padding:24px 24px 120px;}
.doc-section{margin:48px 0 8px;padding-top:22px;border-top:1px dashed var(--border);}
.doc-section:first-of-type{border-top:none;padding-top:0;margin-top:0;}
.doc-kicker{font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--blue);}
.doc-h{font-size:20px;font-weight:700;color:var(--text);margin:4px 0 6px;letter-spacing:-.01em;}
.doc-note{font-size:12.5px;color:var(--muted);line-height:1.6;max-width:780px;margin-bottom:14px;}
.doc-note code{color:var(--text);background:rgba(255,255,255,.05);padding:1px 5px;border-radius:4px;font-size:11.5px;}
.doc-card{position:relative;background:var(--main-bg);border:1px solid var(--border);border-radius:14px;padding:6px 8px;margin:14px 0;}
.doc-label{position:absolute;top:-9px;left:14px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;padding:2px 9px;background:var(--bg);color:var(--muted);border:1px solid var(--border);border-radius:999px;}
.doc-label.current{color:var(--muted);}
.doc-label.proposed{color:var(--gold);border-color:rgba(201,168,76,.35);background:var(--bg);}
.force-show .msg-actions,.force-show .msg-time,.force-show .msg-foot{opacity:1 !important;}
.messages.doc-messages{overflow:visible;display:block;}
.messages-inner.doc-inner{padding:14px 16px;}
.approval-card.doc-visible,.clarify-card.doc-visible{display:block;}
.doc-grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(440px,1fr));gap:14px;}
/* ──────────────────────────────────────────────────────────────
Proposed two-stage elements (prefix .p2s-)
The proposal introduces one container (.p2s-stage1) that wraps
the execution history (thinking + tool cards) and one visual
treatment (.p2s-answer) for the final-answer segment. The same
DOM can be rendered in three modes:
.p2s-stage1.is-live → Working timer + expanded history
.p2s-stage1.is-settled → Collapsed to one-line summary
.p2s-stage1.is-settled.is-open → expanded on demand
Everything else (thinking-card, tool-card-row, msg-body) is the
existing app CSS unchanged.
────────────────────────────────────────────────────────────── */
/* Worklog bar — the header of Stage 1.
Aligns with every other rail child via --msg-rail / --msg-max. */
.p2s-worklog{
display:flex;align-items:center;gap:10px;
margin:4px 0 6px var(--msg-rail);
max-width:var(--msg-max);
padding:8px 12px;
border:1px solid var(--border);
border-radius:10px;
background:rgba(255,255,255,.025);
font-size:12px;color:var(--muted);
cursor:pointer;user-select:none;
transition:border-color .15s,background .15s;
}
.p2s-worklog:hover{border-color:var(--border2);background:rgba(255,255,255,.04);}
.p2s-worklog-dot{
width:8px;height:8px;border-radius:50%;background:var(--gold);flex-shrink:0;
box-shadow:0 0 0 0 rgba(201,168,76,.4);
}
.p2s-stage1.is-live .p2s-worklog-dot{
animation:p2sPulse 1.4s ease-in-out infinite;
}
.p2s-stage1.is-settled .p2s-worklog-dot{
background:var(--muted);opacity:.6;
}
@keyframes p2sPulse{
0%,100%{box-shadow:0 0 0 0 rgba(201,168,76,.45);}
50%{box-shadow:0 0 0 6px rgba(201,168,76,0);}
}
.p2s-worklog-label{color:var(--text);font-weight:500;}
.p2s-worklog-stats{margin-left:auto;display:flex;gap:12px;color:var(--muted);font-size:11.5px;}
.p2s-worklog-stats b{color:var(--text);font-weight:600;}
.p2s-worklog-caret{
display:inline-block;width:14px;height:14px;line-height:14px;text-align:center;
color:var(--muted);font-size:10px;transition:transform .2s;
margin-left:6px;
}
.p2s-stage1.is-live .p2s-worklog-caret{display:none;}
.p2s-stage1.is-settled.is-open .p2s-worklog-caret{transform:rotate(90deg);}
/* Stage 1 body — holds thinking + tool cards + round separators. */
.p2s-stage1-body{
overflow:hidden;
transition:max-height .35s ease,opacity .25s ease;
}
.p2s-stage1.is-live .p2s-stage1-body,
.p2s-stage1.is-settled.is-open .p2s-stage1-body{
max-height:2000px;opacity:1;
}
.p2s-stage1.is-settled:not(.is-open) .p2s-stage1-body{
max-height:0;opacity:0;pointer-events:none;
}
/* Round separator — shown inside Stage 1 between execution rounds. */
.p2s-round-sep{
display:flex;align-items:center;gap:10px;
margin:10px 0 6px var(--msg-rail);
max-width:var(--msg-max);
color:var(--muted);
font-size:10.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;
}
.p2s-round-sep::before,.p2s-round-sep::after{
content:"";flex:1;height:1px;background:var(--border);
}
/* Stage 1 → Stage 2 transition divider. */
.p2s-transition{
margin:14px 0 10px var(--msg-rail);
max-width:var(--msg-max);
height:1px;
background:linear-gradient(
to right,transparent,var(--border) 20%,var(--border) 80%,transparent
);
}
/* Stage 2 — the final answer wrapper.
Design intent: nothing loud. A small "Answer" kicker in gold,
slightly taller line-height, the existing .msg-body styling,
and a gentle top breathing-space. The user arrives at this
block and it *feels* like a conclusion, not another tool row.
*/
.p2s-answer{margin-top:8px;}
.p2s-answer-kicker{
margin:0 0 4px var(--msg-rail);
max-width:var(--msg-max);
font-size:10px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;
color:var(--gold);opacity:.8;
}
.p2s-answer .msg-body{
font-size:14.5px;line-height:1.78;
}
/* Clarify slot — placed at the transition rather than inline. */
.p2s-clarify-slot{
margin:12px 0 4px var(--msg-rail);
max-width:var(--msg-max);
}
.p2s-clarify-slot .clarify-card{margin:0;}
/* Comparison-grid accents. */
.doc-compare-caption{
font-size:11px;color:var(--muted);text-align:center;padding:6px 0;
}
</style>
</head>
<body>
<header class="doc-header">
<div class="doc-title">
Two-Stage Chat UX — Proposal for <a href="https://github.com/nesquena/hermes-webui/issues/536" target="_blank">issue #536</a>
<small>Companion to <a href="./index.html">index.html</a> — shows <em>Working → Final answer</em> as a distinct two-phase interaction model.</small>
</div>
<div class="doc-toggles">
<strong style="font-size:10px;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;align-self:center;margin-right:4px;">Theme</strong>
<button data-theme-btn="default">Default</button>
<button data-theme-btn="slate" class="on">Slate</button>
<button data-theme-btn="light">Light</button>
<button data-theme-btn="solarized">Solarized</button>
<button data-theme-btn="monokai">Monokai</button>
<button data-theme-btn="nord">Nord</button>
<button data-theme-btn="oled">OLED</button>
</div>
</header>
<main class="doc-main">
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">0 · The model</div>
<h2 class="doc-h">One turn, two stages</h2>
<p class="doc-note">
Today an assistant turn is a flat stream: thinking card → tool cards → answer, all stacked
inline with equal visual weight. The proposal wraps the execution history in a
<code>.p2s-stage1</code> container with a <em>worklog bar</em> as its header, and marks the
final answer as <code>.p2s-answer</code>. The same DOM renders three ways:
</p>
<ul class="doc-note" style="padding-left:18px;list-style:disc;">
<li><b>Live</b> — worklog shows <em>Working… 0:42 · 2 tools</em> with a pulsing dot; history is fully visible.</li>
<li><b>Settled</b> — worklog collapses to a single line (<em>Worked 1:42 · 4 tools · 2 thinking</em>); final answer sits below as the calm conclusion.</li>
<li><b>Settled + opened</b> — user clicks the worklog to re-expand the history for audit.</li>
</ul>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">1 · Current vs proposed — settled turn</div>
<h2 class="doc-h">Side-by-side comparison</h2>
<p class="doc-note">
Same turn, same tool calls, same answer. Left is what #587 ships today. Right is the
proposal: execution history collapses to a one-line summary; the final answer stands alone
with a small <em>Answer</em> kicker.
</p>
<div class="doc-grid-2">
<!-- CURRENT ──────────────────────────────────────────────── -->
<div class="doc-card"><span class="doc-label current">Current (PR #587)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row" data-role="user">
<div class="msg-body"><p>Does our dev server pick up the workspace from an env var or a flag?</p></div>
</div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="thinking-card open">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 3.1s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>Check how the CLI resolves workspace:
grep for HERMES_WORKSPACE and --workspace
inspect argv vs env precedence.</pre></div>
</div>
</div></div>
</div>
<div class="tool-card-row">
<div class="tool-card open">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">grep -rn "HERMES_WORKSPACE" . · exit 0</span>
<span class="tool-card-toggle"></span>
</div>
<div class="tool-card-detail">
<div class="tool-card-result"><pre>cli/main.py:14:WORKSPACE_ENV = "HERMES_WORKSPACE"
cli/main.py:92: ws = os.getenv(WORKSPACE_ENV) or args.workspace</pre></div>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">cli/main.py · 148 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="msg-body">
<p>Both work, but <strong>env wins</strong>. The CLI reads
<code>HERMES_WORKSPACE</code> first and only falls back to the
<code>--workspace</code> flag if the env var is unset.</p>
<p>So in practice:</p>
<ul>
<li>CI / daemons → set the env var.</li>
<li>Ad-hoc runs → pass <code>--workspace</code>.</li>
</ul>
</div>
</div></div>
</div>
</div></div>
<div class="doc-compare-caption">Everything stacks equally — the answer is just the next block.</div>
</div>
<!-- PROPOSED ─────────────────────────────────────────────── -->
<div class="doc-card"><span class="doc-label proposed">Proposed — two-stage, settled</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row" data-role="user">
<div class="msg-body"><p>Does our dev server pick up the workspace from an env var or a flag?</p></div>
</div>
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<!-- Stage 1 — settled, collapsed to summary (click to expand) -->
<div class="p2s-stage1 is-settled" data-p2s-toggle>
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Worked for 0:08</span>
<span class="p2s-worklog-stats">
<span><b>2</b> tools</span>
<span><b>1</b> thinking round</span>
</span>
<span class="p2s-worklog-caret"></span>
</div>
<div class="p2s-stage1-body">
<div class="thinking-card open">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 3.1s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>Check how the CLI resolves workspace:
grep for HERMES_WORKSPACE and --workspace
inspect argv vs env precedence.</pre></div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">grep -rn "HERMES_WORKSPACE" . · exit 0</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">cli/main.py · 148 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Stage 2 — the final answer -->
<div class="p2s-transition"></div>
<div class="p2s-answer">
<div class="p2s-answer-kicker">Answer</div>
<div class="msg-body">
<p>Both work, but <strong>env wins</strong>. The CLI reads
<code>HERMES_WORKSPACE</code> first and only falls back to the
<code>--workspace</code> flag if the env var is unset.</p>
<p>So in practice:</p>
<ul>
<li>CI / daemons → set the env var.</li>
<li>Ad-hoc runs → pass <code>--workspace</code>.</li>
</ul>
</div>
</div>
</div></div>
</div>
</div></div>
<div class="doc-compare-caption">Click the worklog bar to expand the execution history.</div>
</div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">2 · Stage 1 · Live run</div>
<h2 class="doc-h">Working timer + live execution history</h2>
<p class="doc-note">
The worklog bar at the top is the anchor for the whole active run: pulsing dot, elapsed
timer that ticks every second, and live counts that increment as tool cards resolve.
Thinking cards and tool cards render inside <code>.p2s-stage1-body</code> exactly as today.
A <em>Round N</em> separator is inserted when the agent starts a new reasoning/tool cycle.
</p>
<div class="doc-card"><span class="doc-label proposed">.p2s-stage1.is-live — Round 1 done, Round 2 running</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
<div class="p2s-stage1 is-live">
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Working… <span id="p2sTimer">0:42</span></span>
<span class="p2s-worklog-stats">
<span><b>3</b> tools</span>
<span><b>2</b> thinking</span>
</span>
</div>
<div class="p2s-stage1-body">
<div class="thinking-card open">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 2.4s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>Need to map the streaming code path first,
then check the persistence layer.</pre></div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">api/streaming.py · 612 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">grep -rn "tool_call_id" api/ · exit 0 · 88ms</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="p2s-round-sep">Round 2</div>
<div class="thinking-card">
<div class="thinking-card-header">
<span class="thinking-card-icon">💡</span>
<span class="thinking-card-label">Thought for 1.8s</span>
<span class="thinking-card-toggle"></span>
</div>
<div class="thinking-card-body"><pre>Streaming looks fine — drill into how
tool_calls get attached before save.</pre></div>
</div>
<div class="tool-card-row">
<div class="tool-card tool-card-running">
<div class="tool-card-header">
<span class="tool-card-running-dot"></span>
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">pytest tests/test_tool_call_persistence.py -q</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">3 · Approve vs Clarify — placement</div>
<h2 class="doc-h">Approvals stay in Stage 1; Clarify moves to the transition</h2>
<p class="doc-note">
Per the issue: <em>approvals are part of doing the work</em> (they gate a single tool),
<em>clarifications stabilise the answer path</em> (they precede the conclusion). The
proposal keeps <code>.approval-card</code> inline among tool cards, and places
<code>.clarify-card</code> at the Stage 1 → Stage 2 seam, above the final answer.
</p>
<div class="doc-grid-2">
<!-- Approve inline in Stage 1 -->
<div class="doc-card"><span class="doc-label proposed">Approve card — inline in Stage 1</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment" data-live-assistant="1">
<div class="p2s-stage1 is-live">
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Working… 0:18</span>
<span class="p2s-worklog-stats"><span><b>1</b> tool</span></span>
</div>
<div class="p2s-stage1-body">
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">ls -la ~/.hermes/sessions · exit 0</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="approval-card doc-visible">
<div class="approval-card-header">
<span class="approval-card-icon">🔐</span>
<span class="approval-card-title">Approve command</span>
</div>
<div class="approval-card-body">
<p class="approval-card-desc">Hermes wants to run a potentially destructive command:</p>
<pre class="approval-card-cmd">rm -rf ~/.hermes/sessions/*.json.bak</pre>
</div>
<div class="approval-card-actions">
<button class="approval-btn approve">Approve</button>
<button class="approval-btn deny">Deny</button>
</div>
</div>
</div>
</div>
</div></div>
</div>
</div></div>
<div class="doc-compare-caption">Permission gate sits next to the tools it gates.</div>
</div>
<!-- Clarify at transition -->
<div class="doc-card"><span class="doc-label proposed">Clarify card — Stage 1 → Stage 2 transition</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="p2s-stage1 is-settled" data-p2s-toggle>
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Worked for 0:12</span>
<span class="p2s-worklog-stats"><span><b>2</b> tools</span></span>
<span class="p2s-worklog-caret"></span>
</div>
<div class="p2s-stage1-body">
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon">📄</span>
<span class="tool-card-name">read_file</span>
<span class="tool-card-preview">package.json · 48 lines</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
<div class="tool-card-row">
<div class="tool-card">
<div class="tool-card-header">
<span class="tool-card-icon"></span>
<span class="tool-card-name">bash</span>
<span class="tool-card-preview">ls src/ · exit 0</span>
<span class="tool-card-toggle"></span>
</div>
</div>
</div>
</div>
</div>
<div class="p2s-transition"></div>
<div class="p2s-clarify-slot">
<div class="clarify-card doc-visible">
<div class="clarify-card-header">
<span class="clarify-card-icon"></span>
<span class="clarify-card-title">One quick question before I answer</span>
</div>
<div class="clarify-card-body">
<p>I can wire the dev server either as an <strong>npm script</strong> in the
existing <code>package.json</code>, or as a standalone <strong>CLI
entry-point</strong>. Which would you prefer?</p>
</div>
<div class="clarify-card-actions">
<button class="clarify-opt">npm script</button>
<button class="clarify-opt">CLI entry-point</button>
<button class="clarify-opt">Let Hermes pick</button>
</div>
</div>
</div>
</div></div>
</div>
</div></div>
<div class="doc-compare-caption">Stage 1 is already settled; the answer is paused on clarification.</div>
</div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">4 · Stage 2 · Calm conclusion</div>
<h2 class="doc-h">What the "Answer" stage looks like on its own</h2>
<p class="doc-note">
Three small choices distinguish Stage 2 from a regular text block:
(1) a thin horizontal divider above it, (2) a tiny gold <em>Answer</em> kicker aligned to
the text rail, (3) a slightly taller line-height. No heavy borders, no boxed treatment —
the emphasis comes from <em>what is missing around it</em>, not ornament.
</p>
<div class="doc-card"><span class="doc-label proposed">.p2s-answer (Stage 1 collapsed above)</span>
<div class="messages doc-messages"><div class="messages-inner doc-inner">
<div class="msg-row assistant-turn" data-role="assistant">
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
<div class="assistant-turn-blocks"><div class="assistant-segment">
<div class="p2s-stage1 is-settled" data-p2s-toggle>
<div class="p2s-worklog">
<span class="p2s-worklog-dot"></span>
<span class="p2s-worklog-label">Worked for 1:42</span>
<span class="p2s-worklog-stats">
<span><b>4</b> tools</span>
<span><b>2</b> thinking</span>
<span><b>1</b> approval</span>
</span>
<span class="p2s-worklog-caret"></span>
</div>
<div class="p2s-stage1-body">
<div class="thinking-card"><div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 2.4s</span><span class="thinking-card-toggle"></span></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">📄</span><span class="tool-card-name">read_file</span><span class="tool-card-preview">api/streaming.py</span><span class="tool-card-toggle"></span></div></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">bash</span><span class="tool-card-preview">grep -rn "tool_call_id" api/</span><span class="tool-card-toggle"></span></div></div></div>
<div class="p2s-round-sep">Round 2</div>
<div class="thinking-card"><div class="thinking-card-header"><span class="thinking-card-icon">💡</span><span class="thinking-card-label">Thought for 1.8s</span><span class="thinking-card-toggle"></span></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon"></span><span class="tool-card-name">bash</span><span class="tool-card-preview">pytest -q · exit 0 · 2.4s</span><span class="tool-card-toggle"></span></div></div></div>
<div class="tool-card-row"><div class="tool-card"><div class="tool-card-header"><span class="tool-card-icon">✍️</span><span class="tool-card-name">edit_file</span><span class="tool-card-preview">api/streaming.py · +12 3</span><span class="tool-card-toggle"></span></div></div></div>
</div>
</div>
<div class="p2s-transition"></div>
<div class="p2s-answer">
<div class="p2s-answer-kicker">Answer</div>
<div class="msg-body">
<p>Tool-call persistence was breaking because <code>session.tool_calls</code> was
written <em>after</em> <code>s.save()</code> in <code>api/streaming.py</code>.
I moved the attach step above the save, and added a fallback that reconstructs
ordering from live tool-progress events when <code>tool_call_id</code> is absent
on older sessions.</p>
<p>Net result:</p>
<ul>
<li>Reloading mid-stream now preserves every tool card with args + output snippet.</li>
<li>Last-turn reasoning survives reload.</li>
<li>No schema migration needed — old sessions degrade gracefully.</li>
</ul>
<p>Covered by the new regression in <code>tests/test_tool_call_persistence.py</code>.</p>
</div>
<div class="msg-foot" style="opacity:1;padding-left:var(--msg-rail);">
<span class="msg-time">11:42 AM · 2,481 tokens · 1.42s</span>
<span class="msg-actions">
<button class="msg-act" title="Copy"></button>
<button class="msg-act" title="Regenerate"></button>
</span>
</div>
</div>
</div></div>
</div>
</div></div>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">5 · Open-question answers (picked defaults)</div>
<h2 class="doc-h">What this proposal commits to</h2>
<div class="doc-card" style="padding:16px 20px;">
<ul style="color:var(--muted);font-size:13px;line-height:1.85;list-style:disc;padding-left:22px;margin:0;">
<li><b style="color:var(--text);">Stage 1 on settle →</b> <em>partial</em> collapse to a
single worklog bar with counts. Click to re-expand. No "nuke to black box", no "keep
everything open forever".</li>
<li><b style="color:var(--text);">Final answer placement →</b> sits <em>beneath</em> Stage 1,
not replacing it. Visual distinction comes from the divider + kicker + spacing, not from
a two-panel layout.</li>
<li><b style="color:var(--text);">Clarify placement →</b> at the Stage 1 → Stage 2 seam.
Approvals stay inline with tools.</li>
<li><b style="color:var(--text);">Timer →</b> lives on Stage 1 only. Stops when the agent
emits the first Stage 2 token; final label becomes "Worked for N:NN".</li>
<li><b style="color:var(--text);">Signal for "answer has started" →</b> first assistant
text delta after all tool calls have resolved and no new <code>tool_use</code> is pending
in the current round. Already present in the SSE stream per maintainer comment.</li>
</ul>
</div>
</section>
<!-- ============================================================= -->
<section class="doc-section">
<div class="doc-kicker">6 · DOM cheat-sheet</div>
<h2 class="doc-h">What changes vs index.html</h2>
<div class="doc-card" style="padding:14px 18px;">
<h3 style="font-size:13px;color:var(--text);margin:0 0 8px;">New wrappers</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li><code>.p2s-stage1[is-live|is-settled][is-open]</code> — wraps the execution history inside an <code>.assistant-segment</code>.</li>
<li><code>.p2s-worklog</code> — header of Stage 1. Pulsing dot + label + counts + caret. Clickable when settled.</li>
<li><code>.p2s-stage1-body</code> — holds <code>.thinking-card</code> + <code>.tool-card-row</code> + <code>.p2s-round-sep</code>. Animated via <code>max-height</code>.</li>
<li><code>.p2s-round-sep</code> — inline horizontal separator between tool/reasoning rounds.</li>
<li><code>.p2s-transition</code> — thin gradient divider between Stage 1 and Stage 2.</li>
<li><code>.p2s-answer</code> — wraps the final <code>.msg-body</code> + <code>.msg-foot</code>.</li>
<li><code>.p2s-answer-kicker</code> — small gold <em>Answer</em> label.</li>
<li><code>.p2s-clarify-slot</code> — placement slot for <code>.clarify-card</code> at the Stage 1/2 seam.</li>
</ul>
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Unchanged</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li><code>.thinking-card</code>, <code>.tool-card</code>, <code>.approval-card</code>, <code>.clarify-card</code>, <code>.msg-body</code>, <code>.msg-foot</code> — all existing app CSS and existing markup.</li>
<li><code>.assistant-turn-blocks</code> and <code>.assistant-segment</code> remain the top-level wrappers.</li>
<li>Tool cards still live as <code>.tool-card-row</code> siblings — now nested <em>inside</em> <code>.p2s-stage1-body</code> rather than as direct children of <code>.messages-inner</code>.</li>
</ul>
<h3 style="font-size:13px;color:var(--text);margin:14px 0 8px;">Implementation notes</h3>
<ul style="color:var(--muted);font-size:12px;line-height:1.9;list-style:disc;padding-left:20px;">
<li>Renderer in <code>static/messages.js</code> wraps an assistant turn's non-final blocks in <code>.p2s-stage1-body</code> and appends the <code>.p2s-worklog</code> header once; toggles <code>is-live</code>/<code>is-settled</code> based on <code>data-live-assistant</code>.</li>
<li><code>static/boot.js</code> SSE handler ticks the timer while <code>is-live</code>, increments counts on each <code>tool_use</code>, and flips the class when the first Stage 2 delta arrives.</li>
<li>Persistence: no schema change needed — the worklog summary can be derived on reload from the existing persisted tool-call list + thinking rounds.</li>
</ul>
</div>
</section>
</main>
<script>
// Theme picker (matches index.html)
document.querySelectorAll('[data-theme-btn]').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.themeBtn;
if (t === 'default') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', t);
document.querySelectorAll('[data-theme-btn]').forEach(b => b.classList.toggle('on', b === btn));
});
});
// Existing thinking/tool cards click-to-toggle.
document.querySelectorAll('.thinking-card-header, .tool-card-header').forEach(h => {
h.addEventListener('click', (e) => {
e.stopPropagation();
h.parentElement.classList.toggle('open');
});
});
// Click the worklog bar on a settled Stage 1 to expand/collapse the history.
document.querySelectorAll('.p2s-stage1[data-p2s-toggle] .p2s-worklog').forEach(bar => {
bar.addEventListener('click', () => {
const stage = bar.closest('.p2s-stage1');
if (!stage.classList.contains('is-settled')) return;
stage.classList.toggle('is-open');
});
});
// Live timer demo in section 2 — ticks so the page feels alive.
(function(){
const el = document.getElementById('p2sTimer');
if (!el) return;
let [m, s] = el.textContent.split(':').map(Number);
setInterval(() => {
s = (s + 1) % 60;
if (s === 0) m += 1;
el.textContent = m + ':' + String(s).padStart(2,'0');
}, 1000);
})();
</script>
</body>
</html>

View File

@@ -40,6 +40,7 @@ function _setWorkspacePanelMode(mode){
if(!layout||!panel)return;
_workspacePanelMode=(mode==='browse'||mode==='preview')?mode:'closed';
const open=_workspacePanelMode!=='closed';
document.documentElement.dataset.workspacePanel=open?'open':'closed';
// Persist open/closed across refreshes (browse/preview → open; closed → closed)
localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');
layout.classList.toggle('workspace-panel-collapsed',!open);

View File

@@ -7,6 +7,7 @@
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t==='system'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';}if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
<link rel="stylesheet" href="static/style.css">
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
@@ -219,53 +220,55 @@
<button class="reconnect-btn" onclick="refreshSession()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Reload</button>
</div>
</div>
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
<div class="approval-inner">
<div class="approval-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span id="approvalHeading" data-i18n="approval_heading">Approval required</span>
</div>
<div class="approval-desc" id="approvalDesc"></div>
<div class="approval-cmd" id="approvalCmd"></div>
<div class="approval-counter" id="approvalCounter" style="display:none;font-size:0.75em;opacity:0.6;margin-top:4px;"></div>
<div class="approval-btns">
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
<kbd class="approval-kbd"></kbd>
</button>
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
<span class="approval-btn-icon"><svg width="14" height="14" 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="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
</button>
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
</button>
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
</button>
</div>
</div>
</div>
<div class="clarify-card" id="clarifyCard" role="dialog" aria-labelledby="clarifyHeading" aria-describedby="clarifyQuestion clarifyHint">
<div class="clarify-inner">
<div class="clarify-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
</div>
<div class="clarify-question" id="clarifyQuestion"></div>
<div class="clarify-choices" id="clarifyChoices"></div>
<div class="clarify-response">
<input class="clarify-input" id="clarifyInput" type="text" data-i18n-placeholder="clarify_input_placeholder" placeholder="Type your response…">
<button class="clarify-submit" id="clarifySubmit" onclick="respondClarify()" data-i18n="clarify_send">Send</button>
</div>
<div class="clarify-hint" id="clarifyHint" data-i18n="clarify_hint">Pick a choice, or type your own answer below.</div>
</div>
</div>
<div class="composer-wrap" id="composerWrap">
<div class="cmd-dropdown" id="cmdDropdown"></div>
<div class="composer-flyout">
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
<div class="approval-inner">
<div class="approval-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span id="approvalHeading" data-i18n="approval_heading">Approval required</span>
</div>
<div class="approval-desc" id="approvalDesc"></div>
<div class="approval-cmd" id="approvalCmd"></div>
<div class="approval-counter" id="approvalCounter" style="display:none;font-size:0.75em;opacity:0.6;margin-top:4px;"></div>
<div class="approval-btns">
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
<kbd class="approval-kbd"></kbd>
</button>
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
<span class="approval-btn-icon"><svg width="14" height="14" 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="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
</button>
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
</button>
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
</button>
</div>
</div>
</div>
<div class="clarify-card" id="clarifyCard" role="dialog" aria-labelledby="clarifyHeading" aria-describedby="clarifyQuestion clarifyHint">
<div class="clarify-inner">
<div class="clarify-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
</div>
<div class="clarify-question" id="clarifyQuestion"></div>
<div class="clarify-choices" id="clarifyChoices"></div>
<div class="clarify-response">
<input class="clarify-input" id="clarifyInput" type="text" data-i18n-placeholder="clarify_input_placeholder" placeholder="Type your response…">
<button class="clarify-submit" id="clarifySubmit" onclick="respondClarify()" data-i18n="clarify_send">Send</button>
</div>
<div class="clarify-hint" id="clarifyHint" data-i18n="clarify_hint">Pick a choice, or type your own answer below.</div>
</div>
</div>
</div>
<div class="composer-box" id="composerBox">
<div class="drop-hint" id="dropHint">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>

View File

@@ -138,6 +138,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
let assistantText='';
let reasoningText='';
let liveReasoningText='';
let assistantRow=null;
let assistantBody=null;
// Thinking tag patterns for streaming display
@@ -182,11 +183,22 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
inflight.messages.push({role:'assistant',content:assistantText,reasoning:reasoningText||undefined,_live:true,_ts:ts});
persistInflightState();
}
function ensureAssistantRow(){
function ensureAssistantRow(force=false){
if(!_isActiveSession()) return;
if(assistantRow&&!assistantRow.isConnected){assistantRow=null;assistantBody=null;}
if(!force&&!assistantRow){
const parsed=_parseStreamState();
if(!String((parsed&&parsed.displayText)||'').trim()) return;
}
let turn=$('liveAssistantTurn');
if(!turn){
appendThinking();
turn=$('liveAssistantTurn');
}
const blocks=(typeof _assistantTurnBlocks==='function')?_assistantTurnBlocks(turn):null;
if(!blocks) return;
if(!assistantRow){
const existing=$('msgInner').querySelector('.msg-row[data-live-assistant="1"]');
const existing=blocks.querySelector('[data-live-assistant="1"]');
if(existing){
assistantRow=existing;
assistantBody=existing.querySelector('.msg-body');
@@ -197,18 +209,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
return;
}
removeThinking();
const tr=$('toolRunningRow');if(tr)tr.remove();
$('emptyState').style.display='none';
assistantRow=document.createElement('div');assistantRow.className='msg-row';
assistantRow=document.createElement('div');
assistantRow.className='assistant-segment';
assistantRow.setAttribute('data-live-assistant','1');
assistantBody=document.createElement('div');assistantBody.className='msg-body';
const role=document.createElement('div');role.className='msg-role assistant';
const _bn=window._botName||'Hermes';
const icon=document.createElement('div');icon.className='role-icon assistant';icon.textContent=_bn.charAt(0).toUpperCase();
const lbl=document.createElement('span');lbl.style.fontSize='12px';lbl.textContent=_bn;
role.appendChild(icon);role.appendChild(lbl);
assistantRow.appendChild(role);assistantRow.appendChild(assistantBody);
$('msgInner').appendChild(assistantRow);
assistantRow.appendChild(assistantBody);
blocks.appendChild(assistantRow);
}
// ── Shared SSE handler wiring (used for initial connection and reconnect) ──
@@ -244,7 +252,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
function _parseStreamState(){
const raw=assistantText;
if(reasoningText){
return {thinkingText:reasoningText, displayText:_streamDisplay(), inThinking:false};
return {thinkingText:liveReasoningText, displayText:_streamDisplay(), inThinking:false};
}
for(const {open,close} of _thinkPairs){
const trimmed=raw.trimStart();
@@ -299,14 +307,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
assistantText+=d.text;
syncInflightAssistantMessage();
if(!S.session||S.session.session_id!==activeSid) return;
ensureAssistantRow();
const parsed=_parseStreamState();
if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();
_scheduleRender();
});
source.addEventListener('reasoning',e=>{
const d=JSON.parse(e.data);
reasoningText += d.text || '';
liveReasoningText += d.text || '';
syncInflightAssistantMessage();
if(!S.session||S.session.session_id!==activeSid) return;
_scheduleRender();
@@ -327,7 +336,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
persistInflightState();
if(!S.session||S.session.session_id!==activeSid) return;
removeThinking();
// NOTE: don't removeThinking() here — keep the thinking card visible
// above the tool card so the turn reads top-to-bottom as:
// user → thinking → tool cards → response. Removing it caused the card
// to be re-created below everything when reasoning resumed post-tool.
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
liveReasoningText='';
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
appendLiveToolCard(tc);
scrollIfPinned();
@@ -577,7 +591,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
clearLiveToolCards();if(!assistantText)removeThinking();
S.session=session;S.messages=session.messages||[];
S.session=session;S.messages=(session.messages||[]).filter(m=>m&&m.role);
const hasMessageToolMetadata=S.messages.some(m=>{
if(!m||m.role!=='assistant') return false;
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
return hasTc||hasTu;
});
if(!hasMessageToolMetadata&&session.tool_calls&&session.tool_calls.length){
S.toolCalls=(session.tool_calls||[]).map(tc=>({...tc,done:true}));
}else{
S.toolCalls=[];
}
syncTopbar();renderMessages();
}
renderSessionList();setBusy(false);setComposerStatus('');
@@ -736,12 +761,9 @@ function showApprovalCard(pending, pendingCount) {
const b = $(id); if (b) { b.disabled = false; b.classList.remove("loading"); }
});
card.classList.add("visible");
if (!sameApproval) card.scrollIntoView({block:"nearest", behavior:"smooth"});
// Apply current locale to data-i18n elements inside the card
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
// Focus Allow once button so Enter works immediately
const onceBtn = $("approvalBtnOnce");
if (onceBtn) setTimeout(() => onceBtn.focus(), 50);
if (onceBtn) setTimeout(() => onceBtn.focus({preventScroll: true}), 50);
}
async function respondApproval(choice) {
@@ -970,14 +992,9 @@ function showClarifyCard(pending) {
lockComposerForClarify(question ? `Clarification needed: ${question}` : "Clarification needed");
}
_clarifySetControlsDisabled(false, false);
const msgInner = $("msgInner");
if (msgInner && card.parentElement !== msgInner) {
msgInner.appendChild(card);
}
card.classList.add("visible");
if (!sameClarify) card.scrollIntoView({block:"nearest", behavior:"smooth"});
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
if (input && !sameClarify) setTimeout(() => input.focus(), 50);
if (input && !sameClarify) setTimeout(() => input.focus({preventScroll: true}), 50);
}
async function respondClarify(response) {

View File

@@ -44,35 +44,13 @@ async function loadSession(sid){
S.session=data.session;
S.lastUsage={...(data.session.last_usage||{})};
localStorage.setItem('hermes-webui-session',S.session.session_id);
// B9: sanitize empty assistant messages (PR #402) — build index map to remap
// session-level tool_calls.assistant_msg_idx to the new sanitized positions.
const allMsgs = data.session.messages || [];
const sanitized = [];
const origIdxToSanitizedIdx = {};
let lastKeptAsstIdx = -1;
for (let i = 0; i < allMsgs.length; i++) {
const m = allMsgs[i];
if (!m || !m.role) continue;
if (m.role === 'tool') continue;
if (m.role === 'assistant') {
let c = m.content || '';
if (Array.isArray(c)) c = c.filter(p => p && p.type === 'text').map(p => p.text || '').join('');
if (!String(c).trim().length) { continue; } // empty assistant — skip
lastKeptAsstIdx = sanitized.length;
}
origIdxToSanitizedIdx[i] = sanitized.length;
sanitized.push(m);
}
if (data.session.tool_calls && data.session.tool_calls.length) {
for (const tc of data.session.tool_calls) {
if (!tc || tc.assistant_msg_idx === undefined) continue;
const origIdx = tc.assistant_msg_idx;
tc.assistant_msg_idx = (origIdx in origIdxToSanitizedIdx)
? origIdxToSanitizedIdx[origIdx]
: (lastKeptAsstIdx >= 0 ? lastKeptAsstIdx : -1);
}
}
data.session.messages = sanitized;
data.session.messages = (data.session.messages || []).filter(m => m && m.role);
const hasMessageToolMetadata = (data.session.messages || []).some(m => {
if (!m || m.role !== 'assistant') return false;
const hasTc = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
const hasTu = Array.isArray(m.content) && m.content.some(p => p && p.type === 'tool_use');
return hasTc || hasTu;
});
const activeStreamId=data.session.active_stream_id||null;
if(!INFLIGHT[sid]&&activeStreamId&&typeof loadInflightState==='function'){
const stored=loadInflightState(sid, activeStreamId);
@@ -109,11 +87,14 @@ async function loadSession(sid){
S.messages=data.session.messages||[];
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(data.session):null;
if(pendingMsg) S.messages.push(pendingMsg);
// Fix (PR #402): do NOT pre-fill S.toolCalls from session-level tool_calls —
// those have stale assistant_msg_idx values after B9 sanitization. Instead,
// set S.toolCalls=[] and let renderMessages() derive them from per-message
// tool_calls (which already have correct sanitized-array indices).
S.toolCalls=[];
// Prefer reconstructing cards from per-message tool metadata when available.
// Fall back to persisted session summaries for older sessions that only
// saved session.tool_calls and bare role=tool results.
if(!hasMessageToolMetadata&&data.session.tool_calls&&data.session.tool_calls.length){
S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));
}else{
S.toolCalls=[];
}
clearLiveToolCards();
if(activeStreamId){
S.busy=true;

View File

@@ -304,10 +304,13 @@
.update-btn:hover{background:rgba(124,185,255,0.2);}
.update-primary{background:rgba(124,185,255,0.2);border-color:rgba(124,185,255,0.5);}
.update-btn:disabled{opacity:0.5;cursor:not-allowed;}
/* ── Composer flyout (approval/clarify slide up from behind composer) ── */
.composer-flyout{position:relative;height:0;z-index:1;}
/* ── Approval card ── */
.approval-card{display:none;max-width:780px;margin:0 auto 0;padding:0 20px 12px;}
.approval-card.visible{display:block;}
.approval-inner{background:var(--surface);backdrop-filter:blur(8px);border:1px solid rgba(233,69,96,0.35);border-radius:14px;padding:16px 18px;}
.approval-card{position:absolute;left:0;right:0;bottom:-24px;max-width:var(--msg-max);margin:0 auto;padding:0 20px;box-sizing:border-box;width:100%;overflow:hidden;pointer-events:none;}
.approval-card.visible{pointer-events:auto;}
.approval-inner{background:var(--surface);backdrop-filter:blur(8px);border:1px solid rgba(124,185,255,0.35);border-radius:14px;padding:16px 18px 40px;transform:translateY(100%);opacity:0;transition:transform .4s cubic-bezier(.32,.72,.16,1),opacity .25s ease;}
.approval-card.visible .approval-inner{transform:translateY(0);opacity:1;}
.approval-header{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:13px;font-weight:600;color:#e94560;}
.approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;line-height:1.5;}
.approval-cmd{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--pre-text);white-space:pre-wrap;word-break:break-all;margin-bottom:14px;max-height:120px;overflow-y:auto;}
@@ -329,9 +332,11 @@
.approval-btn.deny:hover{background:rgba(233,69,96,0.12);border-color:rgba(233,69,96,0.7);}
.approval-btn.loading{opacity:.7;cursor:wait;}
/* ── Clarify card ── */
.clarify-card{display:none;max-width:680px;margin:4px 0 2px 40px;padding:0;}
.clarify-card.visible{display:block;}
.clarify-inner{background:rgba(255,255,255,.03);backdrop-filter:blur(8px);border:1px solid rgba(124,185,255,0.16);border-radius:12px;padding:12px 14px 13px;box-shadow:0 1px 0 rgba(255,255,255,.02) inset;}
.clarify-card{position:absolute;left:0;right:0;bottom:-24px;max-width:var(--msg-max);margin:0 auto;padding:0 20px;box-sizing:border-box;width:100%;overflow:hidden;pointer-events:none;max-height:min(calc(100vh - 280px),420px);}
.clarify-card.visible{pointer-events:auto;}
.clarify-card .clarify-inner{max-height:min(calc(100vh - 280px),420px);overflow-y:auto;transform:translateY(100%);opacity:0;transition:transform .4s cubic-bezier(.32,.72,.16,1),opacity .25s ease;}
.clarify-card.visible .clarify-inner{transform:translateY(0);opacity:1;}
.clarify-inner{background:var(--surface);backdrop-filter:blur(8px);border:1px solid rgba(124,185,255,0.35);border-radius:12px;padding:12px 14px 36px;box-shadow:0 1px 0 rgba(255,255,255,.02) inset;}
.clarify-header{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:12px;font-weight:700;color:var(--blue);letter-spacing:.01em;}
.clarify-question{font-size:14px;color:var(--text);line-height:1.7;white-space:pre-wrap;margin-bottom:12px;}
.clarify-choices{display:flex;flex-direction:column;gap:8px;margin-bottom:12px;}
@@ -505,8 +510,8 @@
.suggestion{padding:12px 14px;background:var(--input-bg);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--muted);cursor:pointer;transition:all .15s;text-align:left;}
.suggestion:hover{background:rgba(124,185,255,0.07);color:var(--text);border-color:rgba(124,185,255,.3);transform:translateX(2px);}
/* ── Composer ── */
.composer-wrap{border-top:1px solid var(--border);padding:12px 20px 16px;background:var(--bg);flex-shrink:0;}
.composer-box{max-width:780px;margin:0 auto;background:var(--input-bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;}
.composer-wrap{padding:12px 20px 16px;background:var(--bg);flex-shrink:0;}
.composer-box{max-width:780px;margin:0 auto;background:linear-gradient(var(--input-bg),var(--input-bg)),var(--bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;z-index:2;}
.composer-box:focus-within{border-color:rgba(124,185,255,0.5);box-shadow:0 0 0 3px rgba(124,185,255,0.08);}
.composer-wrap.drag-over .composer-box{border-color:var(--blue);background:rgba(124,185,255,0.06);}
.drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:rgba(124,185,255,0.08);border:2px dashed var(--blue);border-radius:14px;font-size:14px;color:var(--blue);pointer-events:none;z-index:10;flex-direction:column;gap:8px;}
@@ -656,6 +661,7 @@
.mobile-overlay{display:none;}
@media(min-width:901px){
html[data-workspace-panel="closed"] .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
.layout.workspace-panel-collapsed .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
}
@@ -735,12 +741,12 @@
.suggestion-grid{max-width:100%!important;}
.suggestion{font-size:12px;padding:10px 12px;}
/* Approval card */
.approval-card{padding:0 10px 8px;}
.approval-card{padding-left:10px;padding-right:10px;}
.approval-btns{gap:6px;}
.approval-btn{padding:8px 12px;font-size:12px;min-height:44px;}
.approval-kbd{display:none;}
/* Clarify card */
.clarify-card{margin:6px 0 4px 0;max-width:100%;}
.clarify-card{padding-left:10px;padding-right:10px;}
.clarify-inner{padding:12px 12px 13px;}
.clarify-response{flex-direction:column;align-items:stretch;}
.clarify-input,.clarify-submit{width:100%;min-height:44px;}
@@ -886,7 +892,8 @@
.msg-role > span{line-height:1;}
/* Composer wrap: slightly less padding on smaller heights */
.composer-wrap{border-top:1px solid rgba(255,255,255,.07);padding:10px 20px 14px;position:relative;z-index:10;}
.composer-wrap{padding:10px 20px 14px;position:relative;z-index:10;}
.composer-wrap::before{content:"";position:absolute;left:0;right:0;bottom:100%;height:32px;background:linear-gradient(to bottom,transparent,var(--bg));pointer-events:none;}
/* Cron status badges: pill shape refinement */
.cron-status{border-radius:99px;font-size:10px;letter-spacing:.04em;}
@@ -1027,10 +1034,10 @@ body.resizing{user-select:none;cursor:col-resize;}
.tool-card-icon{font-size:13px;flex-shrink:0;opacity:.8;}
.tool-card-name{font-size:12px;font-weight:600;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;flex-shrink:0;}
.tool-card-preview{font-size:11px;color:var(--muted);opacity:.6;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.tool-card-toggle{font-size:10px;color:var(--muted);opacity:.5;flex-shrink:0;transition:transform .15s;}
.tool-card-toggle{font-size:10px;color:var(--muted);opacity:.5;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;}
.tool-card.open .tool-card-toggle{transform:rotate(90deg);}
.tool-card-detail{display:none;border-top:1px solid rgba(255,255,255,.06);padding:8px 12px;}
.tool-card.open .tool-card-detail{display:block;}
.tool-card-detail{display:block;max-height:0;opacity:0;overflow:hidden;border-top:1px solid transparent;padding:0 12px;transition:max-height .22s ease,opacity .18s ease,padding .22s ease,border-top-color .22s ease;}
.tool-card.open .tool-card-detail{max-height:520px;opacity:1;padding:8px 12px;border-top-color:rgba(255,255,255,.06);}
.tool-card-args{margin-bottom:6px;}
.tool-card-args div{font-size:11px;line-height:1.6;}
.tool-arg-key{color:var(--blue);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;}
@@ -1139,11 +1146,267 @@ body.resizing{user-select:none;cursor:col-resize;}
.thinking-card-header{display:flex;align-items:center;gap:6px;padding:6px 12px;cursor:pointer;font-size:12px;color:var(--gold);user-select:none;}
.thinking-card-icon{font-size:14px;}
.thinking-card-label{font-weight:600;letter-spacing:.02em;}
.thinking-card-toggle{margin-left:auto;font-size:10px;transition:transform .15s;}
.thinking-card-toggle{margin-left:auto;font-size:10px;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;}
.thinking-card.open .thinking-card-toggle{transform:rotate(90deg);}
.thinking-card-body{display:none;padding:0 12px 10px;max-height:300px;overflow-y:auto;}
.thinking-card.open .thinking-card-body{display:block;}
.thinking-card-body{display:block;max-height:0;opacity:0;overflow:hidden;padding:0 12px;transition:max-height .22s ease,opacity .18s ease,padding .22s ease;}
.thinking-card.open .thinking-card-body{max-height:300px;opacity:1;padding:0 12px 10px;}
.thinking-card-body pre{font-family:'SF Mono',ui-monospace,monospace;font-size:11px;line-height:1.5;color:var(--muted);white-space:pre-wrap;word-break:break-word;margin:0;}
.bg-error-banner{background:rgba(229,62,62,.15);border:1px solid rgba(229,62,62,.3);color:#fca5a5;padding:8px 16px;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:0;}
/* ── CLI / Agent session items in sidebar ── */
.session-item.cli-session {
padding-right: 40px; /* make room for the session actions trigger */
}
.session-item.cli-session::after {
content: attr(data-source);
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--gold);
opacity: .5;
margin-left: auto;
flex-shrink: 0;
pointer-events: none; /* don't block clicks on session-actions beneath */
}
.session-item.cli-session:hover::after {
display: none; /* hide badge on hover so the session menu trigger stays clear */
}
.session-item.cli-session.menu-open::after {
display: none;
}
/* Source-specific colors for gateway sessions */
.session-item.cli-session[data-source="telegram"] { border-left-color: rgba(0, 136, 204, 0.55); }
.session-item.cli-session[data-source="telegram"]::after { color: rgba(0, 136, 204, 0.55); }
.session-item.cli-session[data-source="discord"] { border-left-color: #5865F2; }
.session-item.cli-session[data-source="discord"]::after { color: #5865F2; }
.session-item.cli-session[data-source="slack"] { border-left-color: #4A154B; }
.session-item.cli-session[data-source="slack"]::after { color: #4A154B; }
/* ═══════════════════════════════════════════════════════════════════
Messages redesign — additive overrides for the transcript area.
Fixes the issues catalogued in docs/ui-ux/index.html:
• single indent rail (one var, one column)
• quieter thinking card (was louder than the answer)
• user-message bubble so user vs. assistant reads at a glance
• persistent affordances (timestamps, actions, usage always visible)
• unified widths (body + tool cards share one max)
• tamed inline-code colour (no longer outshouts links)
• streaming cursor at end of live assistant body
• [data-error="1"] marker for error bubbles
• .msg-date-sep for day-change separators
• tighter type scale (11/12/13/14/16 — no more 10/10.5/12.5)
═══════════════════════════════════════════════════════════════════ */
:root {
--msg-rail: 0;
--msg-max: 780px;
--user-bubble-bg: rgba(124,185,255,.05);
--user-bubble-border: rgba(124,185,255,.16);
}
:root[data-theme="light"] { --user-bubble-bg: rgba(45,111,163,.06); --user-bubble-border: rgba(45,111,163,.18); }
:root[data-theme="solarized"] { --user-bubble-bg: rgba(38,139,210,.08); --user-bubble-border: rgba(38,139,210,.22); }
:root[data-theme="monokai"] { --user-bubble-bg: rgba(102,217,232,.06); --user-bubble-border: rgba(102,217,232,.18); }
:root[data-theme="nord"] { --user-bubble-bg: rgba(129,161,193,.08); --user-bubble-border: rgba(129,161,193,.22); }
:root[data-theme="oled"] { --user-bubble-bg: rgba(108,180,255,.05); --user-bubble-border: rgba(108,180,255,.16); }
/* Inline code: stop shouting orange; inherit strong text colour instead */
.msg-body code { color: var(--strong); background: var(--code-inline-bg); font-size: 12.5px; }
/* ── Unified indent rail — every child of a turn lines up on --msg-rail ── */
.msg-row { padding: 12px 0; }
.msg-body { padding-left: var(--msg-rail); padding-top: 8px; max-width: var(--msg-max); }
.msg-body:empty { display: none; }
.assistant-turn { width: 100%; }
.assistant-turn-blocks { display: flex; flex-direction: column; }
.assistant-segment-anchor { display: none; }
/* ── Classic conversation layout: user right, half-width; assistant left ── */
.msg-row[data-role="user"] { align-self: flex-end; max-width: 60%; }
@media (max-width: 900px) { .msg-row[data-role="user"] { max-width: 78%; } }
@media (max-width: 600px) { .msg-row[data-role="user"] { max-width: 90%; } }
/* Hide the entire "empty tool-anchor" assistant row (content='' with
tool_calls). renderMessages keeps it in the DOM so tool cards can anchor
to it, but visually it adds a ghost "Hermes" header above the tool cards.
With the row hidden the transition from live → settled on 'done' is
seamless. */
.msg-row[data-role="assistant"]:has(.msg-body:empty) { padding: 0; margin: 0; }
.msg-row[data-role="assistant"]:has(.msg-body:empty) .msg-role,
.msg-row[data-role="assistant"]:has(.msg-body:empty) .msg-files { display: none; }
.msg-files { padding-left: var(--msg-rail); }
.msg-usage { padding-left: var(--msg-rail); opacity: 1; margin-top: 6px; font-size: 11px; }
.tool-card { margin-left: var(--msg-rail); max-width: var(--msg-max); }
.thinking-card { margin-left: var(--msg-rail); max-width: var(--msg-max); }
.tool-cards-toggle { margin-left: var(--msg-rail); }
.msg-row[data-editing="1"] { width: 100%; }
.msg-row[data-editing="1"] .msg-edit-area,
.msg-row[data-editing="1"] .msg-edit-bar { margin-left: var(--msg-rail); }
/* Quieter, always-visible role header (smaller avatar, always-visible timestamp) */
.msg-role { font-size: 11px; font-weight: 500; margin-bottom: 6px; opacity: .8; letter-spacing: 0; }
.msg-role:hover { opacity: 1; }
.role-icon { width: 20px; height: 20px; font-size: 9px; }
.msg-time { opacity: .65; font-size: 10px; }
.msg-role:hover .msg-time { opacity: 1; }
/* Persistent action toolbar: subtle at rest, full on hover */
.msg-actions { opacity: .25; }
.msg-row:hover .msg-actions { opacity: 1; }
.assistant-turn:hover .msg-actions { opacity: 1; }
/* ── User message: right-aligned bubble; no avatar/label — position identifies sender ── */
.msg-row[data-role="user"] .msg-body {
background: var(--user-bubble-bg);
border: 1px solid var(--user-bubble-border);
border-radius: 14px;
padding: 10px 14px;
margin-left: 0;
padding-left: 14px;
max-width: none;
}
.msg-row[data-role="user"] .msg-files { padding-left: 0; margin-left: 0; justify-content: flex-end; }
.msg-row[data-role="user"][data-editing="1"] .msg-edit-area { background: var(--user-bubble-bg); border-color: var(--user-bubble-border); }
/* Bubble-layout mode: user-card stays intact, just drop the rail margin.
(:has() form matches the existing bubble-layout rule's specificity so this
wins by source order rather than relying on !important.) */
body.bubble-layout .msg-row:has(.msg-role.user) .msg-body { margin-left: 0; padding: 10px 14px; max-width: none; }
body.bubble-layout .msg-row:has(.msg-role.user) .msg-files { margin-left: 0; padding-left: 0; }
body.bubble-layout .msg-row + .msg-row[data-role="user"] { border-top: none; padding-top: 10px; margin-top: 0; }
/* Turn boundary: right alignment already separates user turns — keep only vertical spacing */
.msg-row + .msg-row[data-role="user"] {
border-top: none;
margin-top: 10px;
padding-top: 12px;
}
/* ── Message footer: actions (and user timestamp) sit below the bubble ── */
.msg-foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
margin-top: 4px;
font-size: 11px;
color: var(--muted);
}
.msg-foot .msg-actions { opacity: 1; margin-left: 0; }
.msg-foot .msg-time { font-size: 10.5px; opacity: .75; }
/* User footer: visible only on row hover (bubble identifies sender without needing persistent chrome) */
.msg-row[data-role="user"] .msg-foot {
opacity: 0;
transition: opacity .15s;
padding-right: 2px;
}
.msg-row[data-role="user"]:hover .msg-foot,
.msg-row[data-role="user"]:focus-within .msg-foot { opacity: 1; }
/* Assistant footer: left-aligned under the body rail, subtle at rest */
.msg-row[data-role="assistant"] .msg-foot,
.assistant-turn .msg-foot {
justify-content: flex-start;
padding-left: var(--msg-rail);
max-width: var(--msg-max);
opacity: .45;
transition: opacity .15s;
}
.msg-row[data-role="assistant"]:hover .msg-foot,
.assistant-turn:hover .msg-foot { opacity: 1; }
/* Hide footer while editing to keep the edit bar the only footer-level affordance */
.msg-row[data-editing="1"] .msg-foot { display: none; }
/* Empty tool-anchor rows: hide footer alongside role/files so the row stays invisible */
.msg-row[data-role="assistant"]:has(.msg-body:empty) .msg-foot { display: none; }
/* ── Thinking card: quieter than before (no background panel) but still
clearly a gold-accented affordance so users know it's collapsible. ── */
.thinking-card {
background: rgba(201,168,76,.05);
border: 1px solid rgba(201,168,76,.18);
border-radius: 8px;
padding: 0;
margin: 3px 0 3px var(--msg-rail);
transition: border-color .15s, background .15s;
}
.thinking-card:hover {
border-color: rgba(201,168,76,.3);
background: rgba(201,168,76,.07);
}
.thinking-card-header { padding: 5px 10px; color: var(--gold); font-size: 12px; font-weight: 600; opacity: .85; }
.thinking-card-header:hover { opacity: 1; }
.thinking-card-icon { opacity: .7; }
.thinking-card-body {
max-height: 0;
opacity: 0;
overflow: hidden;
padding: 0 12px;
border-top: 1px solid transparent;
transition: max-height .22s ease, opacity .18s ease, padding .22s ease, border-top-color .22s ease;
}
.thinking-card.open .thinking-card-body { max-height: 260px; opacity: 1; padding: 8px 12px; border-top-color: rgba(201,168,76,.12); }
.thinking-card-body pre { font-size: 11px; line-height: 1.6; color: var(--muted); }
/* ── Tool cards: tighter chrome to match quieter thinking card ── */
.tool-card { border-radius: 8px; margin-top: 3px; margin-bottom: 3px; }
.tool-card-header { padding: 5px 10px; }
.tool-card-name { font-size: 11px; }
.tool-card-preview { font-size: 11px; }
/* ── Streaming cursor at the end of the live assistant body ── */
@keyframes hermes-cursor-blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0; } }
[data-live-assistant="1"] .msg-body > :last-child::after,
[data-live-assistant="1"] .msg-body:not(:has(> *))::after {
content: '';
display: inline-block;
width: 7px;
height: 1em;
background: var(--blue);
border-radius: 1px;
margin-left: 3px;
vertical-align: -0.16em;
animation: hermes-cursor-blink 1.05s steps(2, start) infinite;
}
/* ── Error state: distinct red-accent card, not italic emphasis ── */
.msg-row[data-error="1"] .msg-body,
.assistant-segment[data-error="1"] .msg-body {
background: rgba(233,69,96,.06);
border: 1px solid rgba(233,69,96,.22);
border-left: 2px solid var(--accent);
border-radius: 8px;
padding: 10px 14px;
margin-left: var(--msg-rail);
max-width: calc(var(--msg-max) - 40px);
color: var(--text);
}
.msg-row[data-error="1"] .msg-body em,
.msg-row[data-error="1"] .msg-body p em,
.assistant-segment[data-error="1"] .msg-body em,
.assistant-segment[data-error="1"] .msg-body p em { font-style: normal; color: inherit; }
.msg-row[data-error="1"] .msg-role,
.assistant-segment[data-error="1"] .msg-role { color: var(--accent); opacity: 1; }
.msg-row[data-error="1"] .role-icon,
.assistant-segment[data-error="1"] .role-icon { background: rgba(233,69,96,.15); color: var(--accent); border-color: rgba(233,69,96,.3); }
/* ── Day-change separator ── */
.msg-date-sep {
display: flex; align-items: center; gap: 10px;
margin: 22px 0 10px; padding: 0 var(--msg-rail);
color: var(--muted); font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: .12em; opacity: .55;
}
.msg-date-sep::before, .msg-date-sep::after { content: ''; flex: 1; height: 1px; background: var(--border); }
/* ── Widths: collapse messages-inner to match content column ── */
.messages-inner { max-width: var(--msg-max); }
@media (min-width: 1400px) { .messages-inner { max-width: calc(var(--msg-max) + 40px); } }
@media (min-width: 1800px) { .messages-inner { max-width: calc(var(--msg-max) + 80px); } }
@media (max-width: 700px) {
.msg-role { margin-bottom: 4px; }
.msg-row[data-role="user"] .msg-body { padding: 8px 12px; }
.msg-row[data-error="1"] .msg-body { padding: 8px 12px; }
}

View File

@@ -843,7 +843,7 @@ function showPromptDialog(opts={}){
function copyMsg(btn){
const row=btn.closest('.msg-row');
const row=btn.closest('[data-raw-text]');
const text=row?row.dataset.rawText:'';
if(!text)return;
navigator.clipboard.writeText(text).then(()=>{
@@ -1075,46 +1075,87 @@ function msgContent(m){
return String(c).trim();
}
function _fmtDateSep(d){
const todayStart=new Date();todayStart.setHours(0,0,0,0);
const dStart=new Date(d);dStart.setHours(0,0,0,0);
const diffDays=Math.round((todayStart-dStart)/86400000);
if(diffDays===0) return 'Today';
if(diffDays===1) return 'Yesterday';
if(diffDays>0 && diffDays<7) return dStart.toLocaleDateString([], {weekday:'long'});
const opts={month:'short', day:'numeric'};
if(todayStart.getFullYear()!==dStart.getFullYear()) opts.year='numeric';
return dStart.toLocaleDateString([], opts);
}
const _ERR_MSG_RE=/^(?:\*\*error\b|error:|connection lost|no response received)/i;
function _messageHasReasoningPayload(m){
if(!m||m.role!=='assistant') return false;
if(m.reasoning) return true;
if(Array.isArray(m.content)) return m.content.some(p=>p&&(p.type==='thinking'||p.type==='reasoning'));
return /<think>[\s\S]*?<\/think>|<\|channel>thought\n[\s\S]*?<channel\|>/.test(String(m.content||''));
}
function _assistantRoleHtml(tsTitle=''){
const _bn=window._botName||'Hermes';
return `<div class="msg-role assistant" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon assistant">${esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${esc(_bn)}</span></div>`;
}
function _createAssistantTurn(tsTitle=''){
const row=document.createElement('div');
row.className='msg-row assistant-turn';
row.dataset.role='assistant';
row.innerHTML=`${_assistantRoleHtml(tsTitle)}<div class="assistant-turn-blocks"></div>`;
return row;
}
function _assistantTurnBlocks(turn){
return turn?turn.querySelector('.assistant-turn-blocks'):null;
}
function _thinkingCardHtml(text){
return `<div class="thinking-card"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">${li('chevron-right',12)}</span></div><div class="thinking-card-body"><pre>${esc(text)}</pre></div></div>`;
}
function renderMessages(){
const inner=$('msgInner');
const vis=S.messages.filter(m=>{
if(!m||!m.role||m.role==='tool')return false;
// Keep assistant messages with tool_use content even if they have no text,
// so tool cards can be anchored to their DOM rows on page reload (#140).
if(m.role==='assistant'&&Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use'))return true;
if(m.role==='assistant'){
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
if(hasTc||hasTu||_messageHasReasoningPayload(m)) return true;
}
return msgContent(m)||m.attachments?.length;
});
$('emptyState').style.display=vis.length?'none':'';
inner.innerHTML='';
// Track original indices (in S.messages) so truncate knows the cut point.
// Also include assistant messages that have tool_calls (OpenAI format) or
// tool_use content (Anthropic format) even when their text is empty — these
// rows serve as DOM anchors for tool card insertion on page reload.
const visWithIdx=[];
let rawIdx=0;
for(const m of S.messages){
if(!m||!m.role||m.role==='tool'){rawIdx++;continue;}
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
if(msgContent(m)||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu))) visWithIdx.push({m,rawIdx});
if(msgContent(m)||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)))) visWithIdx.push({m,rawIdx});
rawIdx++;
}
let _prevSepKey=null;
let currentAssistantTurn=null;
const assistantSegments=new Map();
for(let vi=0;vi<visWithIdx.length;vi++){
const {m,rawIdx}=visWithIdx[vi];
const _tsSep=m._ts||m.timestamp;
if(_tsSep){
const _d=new Date(_tsSep*1000);
const _key=_d.toDateString();
if(_prevSepKey && _prevSepKey!==_key){
const sep=document.createElement('div');
sep.className='msg-date-sep';
sep.textContent=_fmtDateSep(_d);
inner.appendChild(sep);
}
_prevSepKey=_key;
}
let content=m.content||'';
// Extract thinking/reasoning blocks from structured content (Claude extended thinking, o3)
let thinkingText='';
if(Array.isArray(content)){
thinkingText=content.filter(p=>p&&(p.type==='thinking'||p.type==='reasoning')).map(p=>p.thinking||p.reasoning||p.text||'').join('\n');
content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n');
}
// Also check top-level reasoning field (Hermes format)
if(!thinkingText && m.reasoning){
thinkingText=m.reasoning;
}
// Parse inline thinking tags from plain text: <think>...</think> (DeepSeek, QwQ, MiniMax, etc.)
// and Gemma 4 channel tokens: <|channel>thought\n...<channel|>
// Note: no ^ anchor — some models emit leading whitespace/newlines before <think>.
if(!thinkingText && m.reasoning) thinkingText=m.reasoning;
if(!thinkingText && typeof content==='string'){
const thinkMatch=content.match(/<think>([\s\S]*?)<\/think>/);
if(thinkMatch){
@@ -1131,28 +1172,54 @@ function renderMessages(){
}
const isUser=m.role==='user';
const isLastAssistant=!isUser&&vi===visWithIdx.length-1;
// Render thinking card before the assistant message (collapsed by default)
if(thinkingText&&!isUser){
const thinkRow=document.createElement('div');thinkRow.className='msg-row thinking-card-row';
thinkRow.innerHTML=`<div class="thinking-card"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">${li('chevron-right',12)}</span></div><div class="thinking-card-body"><pre>${esc(thinkingText)}</pre></div></div>`;
inner.appendChild(thinkRow);
}
const row=document.createElement('div');row.className='msg-row';
row.dataset.msgIdx=rawIdx;row.dataset.role=m.role||'assistant';
if(m._live) row.setAttribute('data-live-assistant','1');
let filesHtml='';
if(m.attachments&&m.attachments.length)
if(m.attachments&&m.attachments.length){
filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">${li('paperclip',12)} ${esc(f)}</div>`).join('')}</div>`;
}
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(String(content));
// Action buttons for this bubble
const editBtn = isUser ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">${li('pencil',13)}</button>` : '';
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('regenerate')}" onclick="regenerateResponse(this)">${li('rotate-ccw',13)}</button>` : '';
const copyBtn = `<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">${li('copy',13)}</button>`;
const tsVal=m._ts||m.timestamp;
const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():'';
const _bn=window._botName||'Hermes';
row.innerHTML=`<div class="msg-role ${m.role}" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon ${m.role}">${isUser?'Y':esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${isUser?t('you'):esc(_bn)}</span>${tsTitle?`<span class="msg-time">${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>`:''}<span class="msg-actions">${editBtn}<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">${li('copy',13)}</button>${retryBtn}</span></div>${filesHtml}<div class="msg-body">${bodyHtml}</div>`;
row.dataset.rawText = String(content).trim();
inner.appendChild(row);
const tsTime=tsVal?new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}):'';
const userTimeHtml = (isUser && tsTime) ? `<span class="msg-time" title="${esc(tsTitle)}">${tsTime}</span>` : '';
const footHtml = `<div class="msg-foot">${userTimeHtml}<span class="msg-actions">${editBtn}${copyBtn}${retryBtn}</span></div>`;
if(isUser){
currentAssistantTurn=null;
const row=document.createElement('div');
row.className='msg-row';
row.dataset.msgIdx=rawIdx;
row.dataset.role='user';
row.dataset.rawText=String(content).trim();
row.innerHTML=`${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`;
inner.appendChild(row);
continue;
}
if(!currentAssistantTurn){
currentAssistantTurn=_createAssistantTurn(tsTitle);
inner.appendChild(currentAssistantTurn);
}
const seg=document.createElement('div');
seg.className='assistant-segment';
seg.dataset.msgIdx=rawIdx;
seg.dataset.rawText=String(content).trim();
if(m._live){
currentAssistantTurn.id='liveAssistantTurn';
seg.setAttribute('data-live-assistant','1');
}
if(_ERR_MSG_RE.test(String(content||'').trim())) seg.dataset.error='1';
if(thinkingText) seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText));
const hasVisibleBody=!!(String(content||'').trim()||filesHtml);
if(hasVisibleBody){
seg.insertAdjacentHTML('beforeend', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
}else if(!thinkingText){
seg.classList.add('assistant-segment-anchor');
}
_assistantTurnBlocks(currentAssistantTurn).appendChild(seg);
assistantSegments.set(rawIdx, seg);
}
// Insert settled tool call cards (history view only).
// During live streaming, tool cards are rendered in #liveToolCards by the
@@ -1163,9 +1230,43 @@ function renderMessages(){
// a display list from per-message tool_calls (OpenAI format) stored in each
// assistant message. This covers the reload case described in issue #140.
if(!S.busy && (!S.toolCalls||!S.toolCalls.length)){
// Pass 1: index tool outputs by tool_call_id / tool_use_id so the
// fallback-built cards carry their result snippet (not just the command).
// Without this step CLI-origin sessions reload with empty tool cards.
const resultsByTid={};
const _snipFromRaw=(raw)=>{
const s=String(raw||'');
try{
const rd=JSON.parse(s);
if(rd && typeof rd==='object') return String(rd.output||rd.result||rd.error||s).slice(0,200);
}catch(e){}
return s.slice(0,200);
};
S.messages.forEach(m=>{
if(!m) return;
// OpenAI / Hermes CLI format: role=tool with tool_call_id
if(m.role==='tool'){
const tid=m.tool_call_id||m.tool_use_id||'';
if(tid) resultsByTid[tid]=_snipFromRaw(m.content);
return;
}
// Anthropic format: tool_result blocks inside a user message content array
if(Array.isArray(m.content)){
m.content.forEach(p=>{
if(!p||typeof p!=='object'||p.type!=='tool_result') return;
const tid=p.tool_use_id||'';
if(!tid) return;
const raw=typeof p.content==='string'?p.content
:Array.isArray(p.content)?p.content.map(c=>c&&c.text?c.text:'').join('')
:'';
resultsByTid[tid]=_snipFromRaw(raw);
});
}
});
const derived=[];
S.messages.forEach((m,rawIdx)=>{
if(m.role!=='assistant') return;
// OpenAI format: top-level tool_calls field on the assistant message
(m.tool_calls||[]).forEach(tc=>{
if(!tc||typeof tc!=='object') return;
const fn=tc.function||{};
@@ -1174,8 +1275,23 @@ function renderMessages(){
try{ args=JSON.parse(fn.arguments||'{}'); }catch(e){}
let argsSnap={};
Object.keys(args).slice(0,4).forEach(k=>{ const v=String(args[k]); argsSnap[k]=v.slice(0,120)+(v.length>120?'...':''); });
derived.push({name,snippet:'',tid:tc.id||tc.call_id||'',assistant_msg_idx:rawIdx,args:argsSnap,done:true});
const tid=tc.id||tc.call_id||'';
derived.push({name,snippet:resultsByTid[tid]||'',tid,assistant_msg_idx:rawIdx,args:argsSnap,done:true});
});
// Anthropic format: tool_use blocks inside assistant content array
if(Array.isArray(m.content)){
m.content.forEach(p=>{
if(!p||typeof p!=='object'||p.type!=='tool_use') return;
const name=p.name||'tool';
const args=p.input||{};
const argsSnap={};
if(args && typeof args==='object'){
Object.keys(args).slice(0,4).forEach(k=>{ const v=String(args[k]); argsSnap[k]=v.slice(0,120)+(v.length>120?'...':''); });
}
const tid=p.id||'';
derived.push({name,snippet:resultsByTid[tid]||'',tid,assistant_msg_idx:rawIdx,args:argsSnap,done:true});
});
}
});
if(derived.length) S.toolCalls=derived;
}
@@ -1187,40 +1303,24 @@ function renderMessages(){
if(!byAssistant[key]) byAssistant[key] = [];
byAssistant[key].push(tc);
}
const allRows = Array.from(inner.querySelectorAll('.msg-row[data-msg-idx]'));
// Track the last inserted node per anchor so back-to-back groups for the
// same (filtered) anchor row are inserted in chronological order.
const assistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b);
const anchorInsertAfter = new Map();
for(const [key, cards] of Object.entries(byAssistant)){
const aIdx = parseInt(key);
// Find the right insertion point: cards go AFTER the assistant message
// that triggered them. We look for the row at aIdx, or the nearest
// visible ASSISTANT row at or before aIdx (the assistant message may be
// filtered out if it contained only tool_use blocks with no text response).
let anchorRow = null;
if(aIdx >= 0){
// First: exact match for the assistant row
for(const r of allRows){
const ri=parseInt(r.dataset.msgIdx||'-1');
if(ri===aIdx){anchorRow=r;break;}
}
// Fallback: nearest visible ASSISTANT row at or before aIdx
if(!anchorRow){
for(let i=allRows.length-1;i>=0;i--){
const ri=parseInt(allRows[i].dataset.msgIdx||'-1');
if(ri<=aIdx&&S.messages[ri]&&S.messages[ri].role==='assistant'){anchorRow=allRows[i];break;}
}
}
}
// aIdx === -1 or no assistant anchor found: attach after the last assistant row
if(!anchorRow){
for(let i=allRows.length-1;i>=0;i--){
const ri=parseInt(allRows[i].dataset.msgIdx||'-1',10);
if(ri>=0&&S.messages[ri]&&S.messages[ri].role==='assistant'){anchorRow=allRows[i];break;}
}
let anchorRow=assistantSegments.get(aIdx)||null;
if(!anchorRow&&assistantIdxs.length){
const fallbackIdx=[...assistantIdxs].reverse().find(idx=>idx<=aIdx);
anchorRow=fallbackIdx!==undefined?assistantSegments.get(fallbackIdx):assistantSegments.get(assistantIdxs[assistantIdxs.length-1]);
}
if(!anchorRow) continue;
const anchorParent=anchorRow.parentElement;
const frag=document.createDocumentFragment();
for(const tc of cards){frag.appendChild(buildToolCard(tc));}
let lastInsertedNode=null;
for(const tc of cards){
const card=buildToolCard(tc);
frag.appendChild(card);
lastInsertedNode=card;
}
// Add expand/collapse toggle for groups with 2+ cards
if(cards.length>=2){
const toggle=document.createElement('div');
@@ -1237,22 +1337,18 @@ function renderMessages(){
toggle.appendChild(collapseBtn);
frag.insertBefore(toggle,frag.firstChild);
}
// Insert after the anchor row (or after any previously inserted group for
// the same anchor), preserving chronological order for multi-step chains.
const insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
const refNode = insertAfterNode ? insertAfterNode.nextSibling : null;
if(refNode) inner.insertBefore(frag,refNode);
else inner.appendChild(frag);
// Record the last child we inserted so the next group for this anchor
// goes after it rather than back at anchorRow.nextSibling.
anchorInsertAfter.set(anchorRow, inner.lastChild);
if(refNode) anchorParent.insertBefore(frag,refNode);
else anchorParent.appendChild(frag);
if(anchorRow&&lastInsertedNode) anchorInsertAfter.set(anchorRow, lastInsertedNode);
}
}
// Render usage badge on the last assistant message row (if enabled and usage data exists)
// Render usage badge on the last assistant turn (if enabled and usage data exists)
if(window._showTokenUsage&&S.session&&(S.session.input_tokens||S.session.output_tokens)){
const rows=inner.querySelectorAll('.msg-row');
const rows=inner.querySelectorAll('.assistant-turn');
let lastAssist=null;
for(let i=rows.length-1;i>=0;i--){if(rows[i].dataset.role==='assistant'){lastAssist=rows[i];break;}}
for(let i=rows.length-1;i>=0;i--){lastAssist=rows[i];break;}
if(lastAssist&&!lastAssist.querySelector('.msg-usage')){
const usage=document.createElement('div');
usage.className='msg-usage';
@@ -1262,7 +1358,7 @@ function renderMessages(){
let text=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`;
if(cost) text+=` · ~$${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
usage.textContent=text;
lastAssist.appendChild(usage);
_assistantTurnBlocks(lastAssist).appendChild(usage);
}
}
scrollToBottom();
@@ -1299,7 +1395,7 @@ function toolIcon(name){
function buildToolCard(tc){
const row=document.createElement('div');
row.className='msg-row tool-card-row';
row.className='tool-card-row';
const icon=toolIcon(tc.name);
const hasDetail=tc.snippet||(tc.args&&Object.keys(tc.args).length>0);
let displaySnippet='';
@@ -1330,7 +1426,7 @@ function buildToolCard(tc){
<span class="tool-card-icon">${icon}</span>
<span class="tool-card-name">${esc(displayName)}</span>
<span class="tool-card-preview">${esc(previewText)}</span>
${hasDetail?'<span class="tool-card-toggle"></span>':''}
${hasDetail?`<span class="tool-card-toggle">${li('chevron-right',12)}</span>`:''}
</div>
${hasDetail?`<div class="tool-card-detail">
${tc.args&&Object.keys(tc.args).length?`<div class="tool-card-args">${
@@ -1346,30 +1442,55 @@ function buildToolCard(tc){
}
// ── Live tool card helpers (called during SSE streaming) ──
// Live cards are inserted INLINE inside #msgInner (tagged with data-live-tid)
// so the streaming layout matches the settled layout produced by renderMessages
// (user → thinking → tool cards → response). The legacy #liveToolCards
// sibling container is no longer used for placement — keeping the cards in the
// message column eliminates the visible "jump" users saw when renderMessages
// fired on the done event.
function appendLiveToolCard(tc){
const container=$('liveToolCards');
if(!container)return;
container.style.display='';
// Update existing card if same tool call id (e.g. snippet arrives after done)
const existing=container.querySelector(`[data-tid="${CSS.escape(tc.tid||'')}"]`);
if(existing){existing.replaceWith(buildToolCard(tc));return;}
const card=buildToolCard(tc);
if(tc.tid)card.dataset.tid=tc.tid;
container.appendChild(card);
let turn=$('liveAssistantTurn');
if(!turn){
appendThinking();
turn=$('liveAssistantTurn');
}
const inner=_assistantTurnBlocks(turn);
if(!inner) return;
const tid=tc.tid||'';
// Update existing card in place (tool_complete after tool_start)
if(tid){
const existing=inner.querySelector(`.tool-card-row[data-live-tid="${CSS.escape(tid)}"]`);
if(existing){
const replacement=buildToolCard(tc);
replacement.dataset.liveTid=tid;
existing.replaceWith(replacement);
return;
}
}
const row=buildToolCard(tc);
if(tid) row.dataset.liveTid=tid;
// Insert BEFORE the live assistant segment if it exists, so tool cards stay
// between the current thinking block(s) and the streaming response.
const liveAssistant=inner.querySelector('[data-live-assistant="1"]');
if(liveAssistant) inner.insertBefore(row, liveAssistant);
else inner.appendChild(row);
if(typeof scrollIfPinned==='function') scrollIfPinned();
}
function clearLiveToolCards(){
const inner=_assistantTurnBlocks($('liveAssistantTurn'));
if(inner) inner.querySelectorAll('.tool-card-row[data-live-tid]').forEach(el=>el.remove());
// Legacy #liveToolCards container cleanup — kept for safety in case any
// leftover cards were inserted there before this refactor took effect.
const container=$('liveToolCards');
if(!container)return;
container.innerHTML='';
container.style.display='none';
if(container){container.innerHTML='';container.style.display='none';}
}
// ── Edit + Regenerate ──
function editMessage(btn) {
if(S.busy) return;
const row = btn.closest('.msg-row');
const row = btn.closest('[data-msg-idx]');
if(!row) return;
const msgIdx = parseInt(row.dataset.msgIdx, 10);
const originalText = row.dataset.rawText || '';
@@ -1439,7 +1560,7 @@ async function regenerateResponse(btn) {
if(!S.session || S.busy) return;
// Find the last user message and re-run it
// Remove the last assistant message first (truncate to before it)
const row = btn.closest('.msg-row');
const row = btn.closest('[data-msg-idx]');
if(!row) return;
const assistantIdx = parseInt(row.dataset.msgIdx, 10);
// Find the last user message text (one before this assistant message)
@@ -1583,29 +1704,45 @@ function renderKatexBlocks(){
}
function _thinkingMarkup(text=''){
const _bn=window._botName||'Hermes';
const icon=esc(_bn.charAt(0).toUpperCase());
const label=esc(_bn);
const body=(text&&String(text).trim())
return (text&&String(text).trim())
? `<div class="thinking-card open"><div class="thinking-card-header"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span></div><div class="thinking-card-body"><pre>${esc(String(text).trim())}</pre></div></div>`
: `<div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
return `<div class="msg-role assistant"><div class="role-icon assistant">${icon}</div>${label}</div>${body}`;
}
function finalizeThinkingCard(){
const row=$('thinkingRow');
if(!row) return;
row.removeAttribute('id');
row.removeAttribute('data-thinking-active');
}
function appendThinking(text=''){
$('emptyState').style.display='none';
let turn=$('liveAssistantTurn');
if(!turn){
turn=_createAssistantTurn();
turn.id='liveAssistantTurn';
$('msgInner').appendChild(turn);
}
const blocks=_assistantTurnBlocks(turn);
let row=$('thinkingRow');
if(!row){
row=document.createElement('div');
row.className='msg-row';
row.className='assistant-segment';
row.id='thinkingRow';
$('msgInner').appendChild(row);
row.setAttribute('data-thinking-active','1');
blocks.appendChild(row);
}
row.className=(text&&String(text).trim())?'msg-row thinking-card-row':'msg-row';
row.className=(text&&String(text).trim())?'assistant-segment thinking-card-row':'assistant-segment';
row.innerHTML=_thinkingMarkup(text);
scrollToBottom();
}
function updateThinking(text=''){appendThinking(text);}
function removeThinking(){const el=$('thinkingRow');if(el)el.remove();}
function removeThinking(){
const el=$('thinkingRow');
if(el) el.remove();
const turn=$('liveAssistantTurn');
const blocks=_assistantTurnBlocks(turn);
if(turn&&blocks&&!blocks.children.length) turn.remove();
}
function fileIcon(name, type){
if(type==='dir') return li('folder',14);

View File

@@ -1,216 +1,114 @@
"""
Regression tests for issue #401 / PR #402:
Tool call cards show incorrect/duplicate entries on session load after context compaction.
Regression tests for tool-card persistence on session reload.
Root cause: loadSession() applied its own B9 sanitization (producing a new message array
with different indices) but did not remap the session-level tool_calls.assistant_msg_idx
values to match. It then assigned the broken tool_calls directly to S.toolCalls, bypassing
renderMessages()'s fallback that correctly derives tool calls from per-message tool_calls.
The older loadSession() path rewrote message history on the client:
- dropped role='tool' rows
- dropped empty assistant rows even when they carried tool_calls
- then ignored session.tool_calls on reload
Fix: build origIdxToSanitizedIdx during the B9 pass and remap each tc.assistant_msg_idx;
set S.toolCalls=[] so renderMessages() uses the fallback derivation.
These tests verify the JS logic statically (no server needed).
That broke both durable logging and page refresh for valid tool runs.
"""
import json
import pathlib
import subprocess
import textwrap
import json
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
# --- Static structural checks ---
def test_loadsession_sets_toolcalls_empty():
"""loadSession must set S.toolCalls=[] instead of pre-filling from session-level tool_calls."""
assert "S.toolCalls=[]" in SESSIONS_JS, (
"loadSession() must set S.toolCalls=[] so renderMessages() uses its fallback "
"derivation from per-message tool_calls with correct sanitized-array indices"
def test_loadsession_preserves_tool_rows():
"""Reload must keep tool rows in S.messages so snippets can be reconstructed."""
assert "if (m.role === 'tool') continue;" not in SESSIONS_JS, (
"loadSession() must not drop role='tool' messages; renderMessages() hides them "
"visually, but it still needs them for snippet reconstruction"
)
def test_loadsession_does_not_assign_broken_tool_calls():
"""loadSession must NOT assign session.tool_calls directly to S.toolCalls (causes index mismatch)."""
# The old broken pattern: S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}))
assert "S.toolCalls=(data.session.tool_calls" not in SESSIONS_JS, (
"loadSession() must not assign session-level tool_calls directly to S.toolCalls — "
"those indices are relative to the pre-sanitization array and will be wrong after B9 filtering"
)
def test_loadsession_uses_session_toolcalls_only_as_fallback():
"""Session summaries are the fallback, not the primary reload source."""
assert "if(!hasMessageToolMetadata&&data.session.tool_calls&&data.session.tool_calls.length)" in SESSIONS_JS
assert "S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));" in SESSIONS_JS
assert "S.toolCalls=[];" in SESSIONS_JS
def test_loadsession_builds_idx_remap():
"""loadSession must build an origIdxToSanitizedIdx map during B9 sanitization."""
assert "origIdxToSanitizedIdx" in SESSIONS_JS, (
"loadSession() must build origIdxToSanitizedIdx during B9 sanitization "
"to remap session-level tool_calls.assistant_msg_idx"
)
def test_rendermessages_treats_openai_toolcall_assistants_as_visible():
"""OpenAI assistant rows with empty content but tool_calls must stay anchorable."""
assert "const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;" in UI_JS
assert "if(hasTc||hasTu||_messageHasReasoningPayload(m)) return true;" in UI_JS
def test_loadsession_remaps_assistant_msg_idx():
"""loadSession must remap tc.assistant_msg_idx using the index map."""
assert "tc.assistant_msg_idx" in SESSIONS_JS, (
"loadSession() must update tc.assistant_msg_idx using the sanitized index map"
)
# --- Behavioural Node.js tests ---
def _run_js(script_body: str) -> dict:
"""Run a JS snippet that exercises the B9 sanitization logic extracted from sessions.js."""
# Extract just the B9 + index-remap block from loadSession
# We'll re-implement it inline for testability
script = textwrap.dedent(f"""
// Simulate the B9 sanitization + index remap logic from loadSession()
function sanitizeAndRemap(messages, tool_calls) {{
const allMsgs = messages || [];
const sanitized = [];
const origIdxToSanitizedIdx = {{}};
let lastKeptAsstIdx = -1;
for (let i = 0; i < allMsgs.length; i++) {{
const m = allMsgs[i];
if (!m || !m.role) continue;
if (m.role === 'tool') continue;
if (m.role === 'assistant') {{
let c = m.content || '';
if (Array.isArray(c)) c = c.filter(p => p && p.type === 'text').map(p => p.text || '').join('');
if (!String(c).trim().length) {{ continue; }}
lastKeptAsstIdx = sanitized.length;
}}
origIdxToSanitizedIdx[i] = sanitized.length;
sanitized.push(m);
}}
const remapped = (tool_calls || []).map(tc => {{
if (!tc || tc.assistant_msg_idx === undefined) return tc;
const origIdx = tc.assistant_msg_idx;
const newIdx = (origIdx in origIdxToSanitizedIdx)
? origIdxToSanitizedIdx[origIdx]
: (lastKeptAsstIdx >= 0 ? lastKeptAsstIdx : -1);
return {{ ...tc, assistant_msg_idx: newIdx }};
function loadSessionShape(messages, sessionToolCalls) {{
const filtered = (messages || []).filter(m => m && m.role);
const hasMessageToolMetadata = filtered.some(m => {{
if (!m || m.role !== 'assistant') return false;
const hasTc = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
const hasTu = Array.isArray(m.content) && m.content.some(p => p && p.type === 'tool_use');
return hasTc || hasTu;
}});
return {{ sanitized, remapped }};
const toolCalls = (!hasMessageToolMetadata && sessionToolCalls && sessionToolCalls.length)
? sessionToolCalls.map(tc => ({{ ...tc, done: true }}))
: [];
return {{ filtered, hasMessageToolMetadata, toolCalls }};
}}
{script_body}
""")
proc = subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
)
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
return json.loads(proc.stdout)
def test_b9_remaps_tool_call_idx_after_empty_assistant_filtered():
"""Tool call pointing to index 1 (empty assistant at orig idx 1, kept at idx 0) remaps correctly."""
def test_reload_keeps_empty_assistant_toolcall_anchor():
"""OpenAI-style assistant {content:'', tool_calls:[...]} must survive reload."""
result = _run_js("""
const messages = [
{ role: 'user', content: 'hello' }, // orig 0 -> sanitized 0
{ role: 'assistant', content: '' }, // orig 1 -> FILTERED (empty)
{ role: 'assistant', content: 'done.' }, // orig 2 -> sanitized 1
{ role: 'user', content: 'list files' },
{
role: 'assistant',
content: '',
tool_calls: [{ id: 'call-1', function: { name: 'terminal', arguments: '{}' } }]
},
{ role: 'tool', tool_call_id: 'call-1', content: '{"output":"ok"}' },
{ role: 'assistant', content: 'Done.' }
];
const tool_calls = [
{ name: 'terminal', assistant_msg_idx: 1 }, // pointed to filtered-out empty assistant
{ name: 'read_file', assistant_msg_idx: 2 }, // pointed to kept assistant
];
const { sanitized, remapped } = sanitizeAndRemap(messages, tool_calls);
const loaded = loadSessionShape(messages, [{ name: 'terminal', assistant_msg_idx: 1 }]);
process.stdout.write(JSON.stringify({
sanitized_length: sanitized.length,
tc0_new_idx: remapped[0].assistant_msg_idx, // should attach to lastKeptAsstIdx = 1
tc1_new_idx: remapped[1].assistant_msg_idx, // should remap 2 -> 1
filtered_len: loaded.filtered.length,
has_metadata: loaded.hasMessageToolMetadata,
fallback_len: loaded.toolCalls.length,
assistant_tool_idx: loaded.filtered.findIndex(m => m.role === 'assistant' && m.tool_calls),
tool_idx: loaded.filtered.findIndex(m => m.role === 'tool')
}));
""")
assert result["sanitized_length"] == 2, f"Expected 2 messages after B9, got {result['sanitized_length']}"
assert result["tc0_new_idx"] == 1, (
f"Tool call pointing to filtered empty assistant should attach to last kept assistant (idx 1), got {result['tc0_new_idx']}"
)
assert result["tc1_new_idx"] == 1, (
f"Tool call pointing to orig idx 2 should remap to sanitized idx 1, got {result['tc1_new_idx']}"
)
assert result["filtered_len"] == 4
assert result["has_metadata"] is True
assert result["fallback_len"] == 0
assert result["assistant_tool_idx"] == 1
assert result["tool_idx"] == 2
def test_b9_remaps_multiple_empty_assistants():
"""Multiple consecutive empty assistants all remap to the last (nearest) kept assistant.
Note: the remapping pass runs after the full sanitization loop, so lastKeptAsstIdx
already reflects the final kept-assistant position. This means even empty-assistant
tool calls that came BEFORE the kept assistant get attached to it — which is correct
behavior for context-compacted sessions where all tool calls belong to the one
non-empty assistant response.
"""
def test_reload_uses_session_summary_when_messages_have_no_tool_metadata():
"""Older sessions should still render from session.tool_calls on reload."""
result = _run_js("""
const messages = [
{ role: 'user', content: 'go' }, // orig 0 -> sanitized 0
{ role: 'assistant', content: '' }, // orig 1 -> FILTERED
{ role: 'assistant', content: '' }, // orig 2 -> FILTERED
{ role: 'assistant', content: '' }, // orig 3 -> FILTERED
{ role: 'assistant', content: 'result' }, // orig 4 -> sanitized 1
{ role: 'user', content: 'build site' },
{ role: 'assistant', content: 'Starting.' },
{ role: 'tool', content: '{"bytes_written": 4955}' },
{ role: 'assistant', content: '' }
];
const tool_calls = [
{ name: 'a', assistant_msg_idx: 1 },
{ name: 'b', assistant_msg_idx: 2 },
{ name: 'c', assistant_msg_idx: 3 },
{ name: 'd', assistant_msg_idx: 4 },
const sessionToolCalls = [
{ name: 'write_file', assistant_msg_idx: 1, snippet: 'bytes_written', tid: '' }
];
const { sanitized, remapped } = sanitizeAndRemap(messages, tool_calls);
const loaded = loadSessionShape(messages, sessionToolCalls);
process.stdout.write(JSON.stringify({
sanitized_length: sanitized.length,
tc0_idx: remapped[0].assistant_msg_idx,
tc1_idx: remapped[1].assistant_msg_idx,
tc2_idx: remapped[2].assistant_msg_idx,
tc3_idx: remapped[3].assistant_msg_idx,
has_metadata: loaded.hasMessageToolMetadata,
fallback_len: loaded.toolCalls.length,
done_flag: loaded.toolCalls[0] && loaded.toolCalls[0].done === true
}));
""")
assert result["sanitized_length"] == 2
# Tool calls from filtered empty assistants: after the full loop, lastKeptAsstIdx=1,
# so all filtered-assistant tool calls correctly attach to the kept assistant at idx 1.
assert result["tc0_idx"] == 1, f"Expected 1 (last kept asst), got {result['tc0_idx']}"
assert result["tc1_idx"] == 1
assert result["tc2_idx"] == 1
# Tool call from the kept assistant at orig idx 4 -> sanitized idx 1
assert result["tc3_idx"] == 1, f"Expected 1, got {result['tc3_idx']}"
def test_b9_no_filtering_needed_indices_preserved():
"""When no empty assistant messages exist, indices should pass through unchanged."""
result = _run_js("""
const messages = [
{ role: 'user', content: 'hi' }, // orig 0 -> sanitized 0
{ role: 'assistant', content: 'hello' }, // orig 1 -> sanitized 1
{ role: 'user', content: 'more' }, // orig 2 -> sanitized 2
{ role: 'assistant', content: 'yes' }, // orig 3 -> sanitized 3
];
const tool_calls = [
{ name: 'x', assistant_msg_idx: 1 },
{ name: 'y', assistant_msg_idx: 3 },
];
const { sanitized, remapped } = sanitizeAndRemap(messages, tool_calls);
process.stdout.write(JSON.stringify({
sanitized_length: sanitized.length,
tc0_idx: remapped[0].assistant_msg_idx,
tc1_idx: remapped[1].assistant_msg_idx,
}));
""")
assert result["sanitized_length"] == 4
assert result["tc0_idx"] == 1, f"Expected 1, got {result['tc0_idx']}"
assert result["tc1_idx"] == 3, f"Expected 3, got {result['tc1_idx']}"
def test_b9_tool_role_messages_filtered():
"""Messages with role='tool' must be filtered out and not affect index mapping."""
result = _run_js("""
const messages = [
{ role: 'user', content: 'run' }, // orig 0 -> sanitized 0
{ role: 'tool', content: 'output' }, // orig 1 -> FILTERED (tool role)
{ role: 'assistant', content: 'done' }, // orig 2 -> sanitized 1
];
const tool_calls = [
{ name: 'terminal', assistant_msg_idx: 2 },
];
const { sanitized, remapped } = sanitizeAndRemap(messages, tool_calls);
process.stdout.write(JSON.stringify({
sanitized_length: sanitized.length,
tc0_idx: remapped[0].assistant_msg_idx,
}));
""")
assert result["sanitized_length"] == 2, f"tool-role message must be filtered, got {result['sanitized_length']}"
assert result["tc0_idx"] == 1, f"Expected orig idx 2 -> sanitized idx 1, got {result['tc0_idx']}"
assert result["has_metadata"] is False
assert result["fallback_len"] == 1
assert result["done_flag"] is True

View File

@@ -602,6 +602,8 @@ def test_messages_js_supports_live_reasoning_and_tool_completion(cleanup_test_se
src = (REPO_ROOT / "static/messages.js").read_text()
assert "let reasoningText=''" in src, \
"messages.js must track streamed reasoning text separately from assistant text"
assert "let liveReasoningText=''" in src or 'let liveReasoningText = ""' in src, \
"messages.js must track the currently active reasoning segment separately from cumulative reasoning"
assert "source.addEventListener('reasoning'" in src or 'source.addEventListener("reasoning"' in src, \
"messages.js must listen for live reasoning SSE events"
assert "source.addEventListener('tool_complete'" in src or 'source.addEventListener("tool_complete"' in src, \
@@ -619,6 +621,71 @@ def test_ui_js_can_upgrade_thinking_spinner_into_live_reasoning_card(cleanup_tes
"ui.js must centralize thinking row markup so it can switch between spinner and live text"
assert "function updateThinking(text=''){appendThinking(text);}" in src or 'function updateThinking(text=""){appendThinking(text);}' in src, \
"ui.js must expose an updateThinking helper for live reasoning rendering"
assert "function finalizeThinkingCard()" in src, \
"ui.js must expose a helper to finalize one live thinking card before starting another"
def test_ui_js_keeps_split_thinking_cards_and_assistant_header(cleanup_test_sessions):
"""R19b: settled render should keep distinct thinking cards for split assistant
turns inside a single assistant turn container, preserving one assistant header
for the whole response while keeping multiple thinking cards distinct.
"""
src = (REPO_ROOT / "static" / "ui.js").read_text()
assert "pendingTurnThinking" not in src, \
"renderMessages must not merge distinct thinking blocks into one settled card"
assert "_createAssistantTurn(" in src, \
"renderMessages must build a shared assistant turn wrapper instead of separate top-level rows"
assert "assistant-segment" in src, \
"settled assistant turns must preserve per-message segments for multiple thinking/tool/result blocks"
def test_ui_js_keeps_reasoning_only_assistant_messages_visible(cleanup_test_sessions):
"""R19c: assistant messages that only contain reasoning must still survive
rerenders, otherwise prior thinking cards disappear on the next turn.
"""
src = (REPO_ROOT / "static" / "ui.js").read_text()
assert "function _messageHasReasoningPayload(m)" in src, \
"ui.js must detect reasoning-only assistant messages"
assert "hasTc||hasTu||_messageHasReasoningPayload(m)" in src.replace(' ', ''), \
"renderMessages visibility filter must preserve reasoning-only assistant messages"
def test_ui_js_does_not_hide_anchor_segments_that_contain_thinking(cleanup_test_sessions):
"""R19c2: assistant anchor segments that contain a thinking card must remain
visible; only truly empty tool-call anchor segments should be hidden.
"""
src = (REPO_ROOT / "static" / "ui.js").read_text()
compact = src.replace(' ', '').replace('\n', '')
assert "}elseif(!thinkingText){" in compact, \
"renderMessages must only hide assistant anchor segments when they have no thinking content"
def test_messages_js_live_assistant_segment_reuses_live_turn_wrapper(cleanup_test_sessions):
"""R19d: live streaming must reuse the existing live assistant turn wrapper created
by appendThinking(), otherwise the header gets recreated when answer tokens start.
"""
src = (REPO_ROOT / "static" / "messages.js").read_text()
assert "function ensureAssistantRow(force=false)" in src or 'function ensureAssistantRow(force = false)' in src, \
"ensureAssistantRow should manage the live assistant content segment"
assert "let turn=$('liveAssistantTurn');" in src, \
"ensureAssistantRow must bind to the existing live assistant turn wrapper"
assert "appendThinking();" in src, \
"ensureAssistantRow should create the live turn via appendThinking() when needed"
assert "assistantRow.className='assistant-segment';" in src or 'assistantRow.className = \'assistant-segment\';' in src, \
"live answer content should be appended as a segment inside the live turn wrapper"
assert "if(!force&&!assistantRow){" in src.replace(' ', ''), \
"ensureAssistantRow must still avoid creating the live answer segment when no display text exists yet"
assert "if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();" in src, \
"token handler must only create the live answer segment once visible answer text starts"
def test_messages_js_finalizes_thinking_card_before_tool_card(cleanup_test_sessions):
"""R19e: later reasoning after a tool call must render in a fresh card."""
src = (REPO_ROOT / "static/messages.js").read_text()
assert "finalizeThinkingCard" in src, \
"tool handler must finalize the current live thinking card before appending a tool card"
assert "liveReasoningText='';" in src or 'liveReasoningText = "";' in src, \
"tool handler must reset the active reasoning segment before post-tool reasoning arrives"
# ── R17: Stack traces must not leak to clients in 500 responses ────────────

View File

@@ -80,3 +80,24 @@ def test_workspace_panel_restore_before_sync():
assert sync_pos >= 0, "syncWorkspacePanelState call must be present in boot IIFE"
assert restore_pos < sync_pos, \
"Workspace panel restore must happen BEFORE syncWorkspacePanelState() so the correct mode is applied"
def test_workspace_panel_preload_marker_restored_in_head():
"""index.html must preload the workspace panel state before the main stylesheet paints."""
marker = "document.documentElement.dataset.workspacePanel"
css_link = '<link rel="stylesheet" href="static/style.css">'
marker_pos = HTML.find(marker)
css_pos = HTML.find(css_link)
assert marker_pos >= 0, "index.html must preload documentElement.dataset.workspacePanel from localStorage"
assert css_pos >= 0, "main stylesheet link missing from index.html"
assert marker_pos < css_pos, \
"workspace panel preload marker must be set before style.css loads to avoid first-paint flash"
def test_workspace_panel_mode_syncs_document_dataset():
"""_setWorkspacePanelMode must update documentElement.dataset.workspacePanel for runtime parity."""
fn_idx = BOOT_JS.find("function _setWorkspacePanelMode(")
fn_end = BOOT_JS.find("\n}", fn_idx) + 2
fn_body = BOOT_JS[fn_idx:fn_end]
assert "document.documentElement.dataset.workspacePanel" in fn_body, \
"_setWorkspacePanelMode must keep documentElement.dataset.workspacePanel in sync with the panel state"

View File

@@ -428,3 +428,29 @@ def test_rendermessages_reads_reasoning_from_messages():
# Specifically, the fallback that reads from top-level m.reasoning field
assert 'thinkingText=m.reasoning' in src.replace(' ', ''), \
"thinkingText=m.reasoning assignment not found in ui.js renderMessages"
def test_streaming_restores_prior_reasoning_metadata_after_followup():
"""Previous-turn thinking must survive later turns.
The provider-facing history strips WebUI-only `reasoning` fields, so the
streaming path must merge that metadata back onto the returned message
history before saving the session, including reinserting dropped
reasoning-only assistant segments.
"""
src = (REPO / 'api' / 'streaming.py').read_text()
assert "def _restore_reasoning_metadata(" in src, \
"streaming.py must define a helper to restore prior reasoning metadata"
assert "s.messages = _restore_reasoning_metadata(" in src, \
"streaming.py must merge prior reasoning metadata back after run_conversation()"
assert "updated_messages.insert(safe_pos, copy.deepcopy(prev_msg))" in src, \
"streaming.py must reinsert dropped reasoning-only assistant messages"
def test_routes_restores_prior_reasoning_metadata_after_followup():
"""The non-streaming route path must preserve prior reasoning metadata too."""
src = (REPO / 'api' / 'routes.py').read_text()
assert "_restore_reasoning_metadata" in src, \
"routes.py must import reasoning metadata restoration helper"
assert 's.messages = _restore_reasoning_metadata(' in src, \
"routes.py must merge prior reasoning metadata back after run_conversation()"

View File

@@ -0,0 +1,71 @@
"""Tests for backend tool-call summary extraction used by WebUI session persistence."""
import pathlib
import sys
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(REPO_ROOT))
from api.streaming import _extract_tool_calls_from_messages
def test_extract_tool_calls_from_openai_message_linkage():
messages = [
{"role": "user", "content": "ls"},
{
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "call-1",
"function": {"name": "terminal", "arguments": '{"command":"ls"}'},
}],
},
{
"role": "tool",
"tool_call_id": "call-1",
"content": '{"output":"file.txt","exit_code":0}',
},
]
result = _extract_tool_calls_from_messages(messages)
assert len(result) == 1
assert result[0]["name"] == "terminal"
assert result[0]["assistant_msg_idx"] == 1
assert result[0]["snippet"] == "file.txt"
def test_extract_tool_calls_falls_back_to_live_progress_when_ids_missing():
messages = [
{"role": "user", "content": "write spec"},
{"role": "assistant", "content": "Starting."},
{"role": "tool", "content": '{"bytes_written":4955}'},
{"role": "assistant", "content": ""},
]
live_tool_calls = [{"name": "write_file", "args": {"path": "/tmp/SPEC.md"}}]
result = _extract_tool_calls_from_messages(messages, live_tool_calls=live_tool_calls)
assert len(result) == 1
assert result[0]["name"] == "write_file"
assert result[0]["assistant_msg_idx"] == 1
assert "bytes_written" in result[0]["snippet"]
assert result[0]["args"]["path"] == "/tmp/SPEC.md"
def test_extract_tool_calls_preserves_mixed_linked_and_fallback_results():
messages = [
{
"role": "assistant",
"content": "",
"tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": '{"command":"pwd"}'}}],
},
{"role": "tool", "tool_call_id": "call-1", "content": '{"output":"/tmp"}'},
{"role": "assistant", "content": "Next"},
{"role": "tool", "content": '{"result":"saved"}'},
]
live_tool_calls = [
{"name": "terminal", "args": {"command": "pwd"}},
{"name": "write_file", "args": {"path": "/tmp/out.txt"}},
]
result = _extract_tool_calls_from_messages(messages, live_tool_calls=live_tool_calls)
assert len(result) == 2
assert result[0]["name"] == "terminal"
assert result[1]["name"] == "write_file"
assert result[1]["assistant_msg_idx"] == 2
assert result[1]["snippet"] == "saved"

View File

@@ -0,0 +1,43 @@
import pathlib
import re
STYLE_CSS = (pathlib.Path(__file__).parent.parent / "static" / "style.css").read_text(encoding="utf-8")
UI_JS = (pathlib.Path(__file__).parent.parent / "static" / "ui.js").read_text(encoding="utf-8")
COMPACT_CSS = re.sub(r"\s+", "", STYLE_CSS)
def test_tool_card_toggle_uses_transformable_layout_and_transition():
assert ".tool-card-toggle{" in COMPACT_CSS
assert "display:inline-flex" in COMPACT_CSS
assert "transition:transform.18sease" in COMPACT_CSS
def test_tool_card_detail_uses_transitionable_collapsed_state():
assert ".tool-card-detail{display:block;max-height:0;opacity:0;overflow:hidden;" in COMPACT_CSS
assert re.search(
r"\.tool-card\.open\s+\.tool-card-detail\s*\{[^}]*max-height:\s*520px;[^}]*opacity:\s*1;",
STYLE_CSS,
)
def test_thinking_card_toggle_and_body_use_animation_friendly_state():
assert ".thinking-card-toggle{margin-left:auto;font-size:10px;display:inline-flex;" in COMPACT_CSS
assert ".thinking-card-body{display:block;max-height:0;opacity:0;overflow:hidden;" in COMPACT_CSS
assert re.search(
r"\.thinking-card\.open\s+\.thinking-card-body\s*\{[^}]*max-height:\s*300px;[^}]*opacity:\s*1;",
STYLE_CSS,
)
def test_tool_card_toggle_uses_same_chevron_icon_markup_as_thinking_card():
assert "<span class=\"thinking-card-toggle\">${li('chevron-right',12)}</span>" in UI_JS
assert "<span class=\"tool-card-toggle\">${li('chevron-right',12)}</span>" in UI_JS
def test_thinking_card_uses_panel_chrome_with_gold_palette():
assert re.search(
r"\.thinking-card\s*\{[^}]*background:\s*rgba\(201,168,76,.05\);[^}]*border:\s*1px\s+solid\s+rgba\(201,168,76,.18\);[^}]*border-radius:\s*8px;",
STYLE_CSS,
)
assert "border-left: 2px solid rgba(201,168,76,.4);" not in STYLE_CSS