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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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/**
|
||||
|
||||
@@ -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/.
|
||||
|
||||
@@ -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>/
|
||||
|
||||
---
|
||||
|
||||
15
TESTING.md
15
TESTING.md
@@ -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>/*
|
||||
|
||||
@@ -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)
|
||||
|
||||
276
api/streaming.py
276
api/streaming.py
@@ -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
862
docs/ui-ux/index.html
Normal 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 & combination, wired to the real <code>static/style.css</code>. · <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>>= 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, () => {
|
||||
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() -> 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 & 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 & diagrams</div>
|
||||
<h2 class="doc-h">KaTeX inline / block & 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] --> C[Composer]
|
||||
C --> API[/api/chat/]
|
||||
API --> M((Model))
|
||||
M --> T{tool?}
|
||||
T -- yes --> X[Tool Runner]
|
||||
T -- no --> R[Reply]
|
||||
X --> R
|
||||
R --> 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, () => {
|
||||
~~~~~~~~~</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, () => { ... });
|
||||
}</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 & 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 & 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 && 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 & 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 & 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>
|
||||
742
docs/ui-ux/two-stage-proposal.html
Normal file
742
docs/ui-ux/two-stage-proposal.html
Normal 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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
297
static/style.css
297
static/style.css
@@ -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; }
|
||||
}
|
||||
|
||||
333
static/ui.js
333
static/ui.js
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ────────────
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()"
|
||||
|
||||
71
tests/test_tool_call_persistence.py
Normal file
71
tests/test_tool_call_persistence.py
Normal 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"
|
||||
43
tests/test_ui_card_animation.py
Normal file
43
tests/test_ui_card_animation.py
Normal 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
|
||||
Reference in New Issue
Block a user