From 9a3dc10d93cb9b480fd93b38fc8f9cc19a3fd783 Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Thu, 16 Apr 2026 23:04:42 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20redesign=20chat=20transcript=20+=20fix?= =?UTF-8?q?=20streaming/persistence=20lifecycle=20=E2=80=94=20v0.50.70=20(?= =?UTF-8?q?PR=20#587=20by=20@aronprins)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 6 +- ARCHITECTURE.md | 11 +- ROADMAP.md | 6 +- TESTING.md | 15 +- api/routes.py | 9 +- api/streaming.py | 276 ++++++--- docs/ui-ux/index.html | 862 ++++++++++++++++++++++++++++ docs/ui-ux/two-stage-proposal.html | 742 ++++++++++++++++++++++++ static/boot.js | 1 + static/index.html | 93 +-- static/messages.js | 69 ++- static/sessions.js | 49 +- static/style.css | 297 +++++++++- static/ui.js | 333 +++++++---- tests/test_issue401.py | 242 +++----- tests/test_regressions.py | 67 +++ tests/test_sprint37.py | 21 + tests/test_sprint42.py | 26 + tests/test_tool_call_persistence.py | 71 +++ tests/test_ui_card_animation.py | 43 ++ 20 files changed, 2770 insertions(+), 469 deletions(-) create mode 100644 docs/ui-ux/index.html create mode 100644 docs/ui-ux/two-stage-proposal.html create mode 100644 tests/test_tool_call_persistence.py create mode 100644 tests/test_ui_card_animation.py diff --git a/.gitignore b/.gitignore index a5d8af8..5a767cd 100644 --- a/.gitignore +++ b/.gitignore @@ -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/** diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4a29aac..45e18bb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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/. diff --git a/ROADMAP.md b/ROADMAP.md index 1afb9ef..7e1bca0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 `` 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: / --- diff --git a/TESTING.md b/TESTING.md index 4f25fc5..1de4e1e 100644 --- a/TESTING.md +++ b/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: /* diff --git a/api/routes.py b/api/routes.py index 81153da..de65440 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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) diff --git a/api/streaming.py b/api/streaming.py index 52dbbb8..0e1d6a4 100644 --- a/api/streaming.py +++ b/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: diff --git a/docs/ui-ux/index.html b/docs/ui-ux/index.html new file mode 100644 index 0000000..3bb8b56 --- /dev/null +++ b/docs/ui-ux/index.html @@ -0,0 +1,862 @@ + + + + + Hermes WebUI — Messages UI Inventory + + + + + + + + + + + +
+
Hermes WebUI — Messages UI InventoryEvery message-area element & combination, wired to the real static/style.css.  ·  Two-stage proposal (#536) →
+
+ Theme + + + + + + + + + +
+
+ +
+ + +
+
1 · Empty state
+

First load / no messages

+

Renders inside #messages when S.messages is empty. Logo + title + subtitle + 3 suggestion buttons.

+
.empty-state +
+
+ +

What can I help with?

+

Ask anything, run commands, explore files, or manage your scheduled tasks.

+
+ + + +
+
+
+
+
+ + +
+
2 · User messages
+

Right-aligned bubble, attachments, and edit mode

+

User rows have no avatar/label — the right-edge alignment and tinted bubble identify the sender. Timestamp + edit/copy live in a .msg-foot below the bubble, revealed on hover (forced visible here).

+ +
.msg-row[data-role="user"] — plain +
+
+

How do I run the dev server and point it at a specific workspace path?

+
+ 10:42 + + + + +
+
+
+
+ +
.msg-files — attachments above body (right-aligned) +
+
+
+ 📎 architecture-notes.pdf + 📎 Q1-forecast.xlsx + 📎 meeting.docx + 📎 screenshot.png +
+

Please review these docs and summarise the key decisions.

+
+
+
+ +
.msg-edit-area + .msg-edit-bar — edit mode +
+
+ +
+ + +
+
+
+
+
+ + +
+
3 · Assistant — markdown basics
+

Paragraphs, emphasis, lists, blockquote, hr, links

+

Assistant output is a single .msg-row.assistant-turn that holds one role header + an .assistant-turn-blocks column of one-or-more .assistant-segment children. Each segment may contain a .thinking-card, a .msg-body, and its own .msg-foot (copy / regen). This lets a turn stream reasoning → text → tool calls → more text without repeating the Hermes avatar each time.

+ +
.msg-body — rich prose +
+
+
+ H + Hermes +
+
+
+
+

Running the dev server

+

You can start Hermes with the built-in launcher. The simplest path is no docker, no proxy — the CLI handles everything.

+

Prerequisites

+
    +
  • Node >= 18
  • +
  • A workspace directory you own +
      +
    • Read/write permissions
    • +
    • No existing .hermes folder
    • +
    +
  • +
  • An API key set via HERMES_API_KEY
  • +
+

Steps

+
    +
  1. Clone the repo
  2. +
  3. Run npm install
  4. +
  5. Start with npm run dev -- --workspace ~/code
  6. +
+
Tip: the --workspace flag accepts absolute or ~-prefixed paths. Relative paths are resolved against the CWD.
+
+

For full setup options see the configuration guide.

+
+
+ + + + +
+
+
+
+
+
+ +
.msg-body table +
+
+
HHermes
+
+
+
+

Model comparison:

+ + + + + + + +
ModelContextGood forCost / 1M in
Opus 4.61MDeep reasoning, long code$15.00
Sonnet 4.61MDaily driver, agents$3.00
Haiku 4.5200kFast tasks, tool loops$0.80
+
+
+
+
+
+
+
+ + +
+
4 · Code blocks
+

Plain, with header, with copy button, multi-language

+ +
pre + code (no header) +
+
+
HHermes
+
+
+
npm install
+npm run dev -- --workspace ~/code
+
+
+
+
+
+ +
.pre-header + pre + .code-copy-btn +
+
+
HHermes
+
+
+
+
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;
+}
+
+
+
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()
+
+
+
json
+
{
+  "model": "claude-sonnet-4-6",
+  "stream": true,
+  "tools": ["bash", "edit_file", "search"]
+}
+
+
+
+
+
+
+
+ + +
+
5 · Inline media
+

Images (default & zoomed) and downloadable links

+ +
.msg-media-img (default + .msg-media-img--full) +
+
+
HHermes
+
+
+

Here's the screenshot you asked for (click to zoom):

+ demo +

And the full-width variant:

+ demo-full +
+
+
+
+
+ +
.msg-media-link — non-image downloads +
+
+
HHermes
+
+
+

I saved the generated files:

+

📎 report-2026-Q1.pdf 📎 revenue.csv 📎 diagram.svg

+
+
+
+
+
+
+ + +
+
6 · Math & diagrams
+

KaTeX inline / block & Mermaid block

+ +
.katex-inline + .katex-block +
+
+
HHermes
+
+
+

Inline math: \(E = mc^2\) and the quadratic formula below:

+
$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$
+

A tidier form: \(\sum_{i=1}^{n} i = \frac{n(n+1)}{2}\).

+
$$\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}$$
+
+
+
+
+
+ +
.mermaid-block (pre-render placeholder) +
+
+
HHermes
+
+
+

The request flow:

+
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
+
+
+
+
+
+
+ + +
+
7 · Thinking / reasoning
+

Bordered panel (collapsed / open, animated), live loader, streaming cursor

+

Thinking cards are rendered at the top of an .assistant-segment. They're now bordered gold-tinted panels (no more left-rule-only look) and expand/collapse with a max-height + opacity transition. Click the header in either example below to see the animation live.

+ +
+
.thinking-card (collapsed, inside .assistant-segment) +
+
+
HHermes
+
+
+
+ 💡 + Thought for 4.3s + +
+
The user asked about the dev server...
+
+

Here's the shortest path…

+
+
+
+
+ +
.thinking-card.open (animated — max-height + opacity) +
+
+
HHermes
+
+
+
+ 💡 + Thought for 4.3s + +
+
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.
+
+

Here's the shortest path…

+
+
+
+
+
+ +
.thinking — live 3-dot loader (pre-reasoning) +
+
+
HHermes
+
+
Thinking
+
+
+
+
+ +
[data-live-assistant="1"] — streaming cursor at end of last child +
+
+
HHermes
+
+

Sure — the simplest way is to run npm run dev. The CLI will pick up the default

+
+
+
+
+
+ + +
+
8 · Tool cards
+

Running, done, expanded, subagent, error, multi-card toggle

+

Tool cards sit in .tool-card-row wrappers (no longer nested under .msg-row). The details panel now animates open/closed via max-height + opacity — click any header below to see the transition.

+ +
.tool-card.tool-card-running (collapsed, pulsing dot) +
+
+
+
+ + + bash + npm run build + +
+
+
+
+
+ +
.tool-card — done, collapsed +
+
+
+
+ 📄 + read_file + static/style.css · 1155 lines + +
+
+
+
+
+ +
.tool-card.open — args table + result snippet + Show more (animated detail) +
+
+
+
+ + bash + grep -rn "msg-role" static/ · exit 0 · 380ms + +
+
+
+
command: grep -rn "msg-role" static/
+
cwd: /Users/aron/hermes-webui
+
timeout: 30000
+
+
+
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);
+ +
+
+
+
+
+
+ +
.tool-card.tool-card-subagent — delegated work +
+
+
+
+ 🤖 + Subagent + Explore · Map chat messages UI elements + +
+
+
+
+
+
+ 🤖 + Delegate task + Plan · Propose redesign variants + +
+
+
+
+
+ +
.tool-card (error snippet) +
+
+
+
+ + bash + npm run typecheck · exit 1 · 2.3s + +
+
+
+
command: npm run typecheck
+
+
+
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, () => {
+         ~~~~~~~~~
+
+
+
+
+
+
+ +
.tool-cards-toggle — Expand/Collapse All (≥2 cards) +
+
+ + +
+
📄read_filepackage.json
+
🔎grep"listen" in src/
+
bashnpm run typecheck · exit 0 · 4.1s
+
+
+
+ + +
+
9 · Meta affordances
+

Role timestamp tooltip, footer action toolbar, token-usage badge

+

Assistant timestamps live on the .msg-role title attribute (hover for full date). Copy/regen buttons sit in the per-segment .msg-foot, 45% opacity at rest, full on turn hover. The .msg-usage badge is always visible at the bottom of the turn.

+ +
Full hover state — .msg-foot actions + .msg-usage +
+
+
+ H + Hermes +
+
+

Built and type-checked successfully — server is running on :3000.

+
+ + + + +
+
+
3.2K in · 481 out · ~$0.012
+
+
+
+
+ + +
+
10 · Full composition
+

User turn → assistant turn (segment 1: thinking + body + tool cards) → usage

+

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 .messages-inner; the usage badge closes the turn.

+ +
All-in-one turn +
+
+
📎 server.ts
+

The build fails — can you type-check and explain?

+
+ 10:40 + + + + +
+
+ +
+
+ HHermes +
+
+
+
+
💡Thought for 2.1s
+
Attached server.ts — probably typing issue.
+Run typecheck to confirm, then patch.
+
+
+

The build fails because opts.port can be undefined. Two fixes below — pick the one that matches your intent.

+

Option A — require the port

+
export function startServer(opts: { port: number }) {
+  app.listen(opts.port);
+}
+

Option B — default to 3000

+
export function startServer(opts: { port?: number } = {}) {
+  const port = opts.port ?? 3000;
+  app.listen(port);
+}
+

I ran the checks below to confirm.

+
+
+ + + + +
+
+
+
11.4K in · 612 out · ~$0.049
+
+ +
+ +
+
+
📄read_filesrc/server.ts · 58 lines
+
+
path: src/server.ts
+
export function startServer(opts: ServerOptions) {
+  app.listen(opts.port, () => { ... });
+}
+
+
+
+
bashnpm run typecheck · exit 1 · 2.3s
+
+
+
✏️edit_filesrc/server.ts +1 / -1
+
+
+
+
+ + +
+
11 · Bubble layout
+

Opt-in via body.bubble-layout — extra bubble padding for assistant too

+

The default layout already right-aligns user messages (the redesign adopted it globally), so this toggle mostly affects additional padding / boundary handling. Flip the Bubble layout toggle in the header to see the mode applied.

+
Conversation sample +
+

Can you add a retry button next to the regenerate one?

+
+
HHermes
+
+

Yes — it can share .msg-action-btn and live in the same .msg-actions container. I'll wire it up on _lastError.

+
+
+

Perfect, go for it.

+
+
+
+ + +
+
12 · System / inline notes
+

Compression, cancellation, errors — rendered as italicised assistant messages

+ +
Italic system notices (still italic — info, not errors) +
+
+
HHermes
+
+

[Context was auto-compressed to continue the conversation]

+

Task cancelled.

+
+
+
+
+ +
.assistant-segment[data-error="1"] — real error card, red accent, no italic +
+
+
HHermes
+
+

Error: Connection lost. Your last message was saved — refresh to continue.

+

Error: Upstream rate-limited (429). Retrying in 30s…

+
+
+
+
+
+ + +
+
12b · Turn boundaries & date separators
+

Right-alignment separates user turns · day-change separator

+

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 .msg-date-sep.

+
.msg-date-sep — Today / Yesterday / weekday / date +
+
Yesterday
+

Can you summarise the PR I opened earlier?

+
+
HHermes
+

Yes — three files changed, net +42 / -18. Main change is the new rail variable…

+
+
Today
+

Did CI pass overnight?

+
+
HHermes
+

All green — three jobs, 4m 12s total. Here's the breakdown:

+
+
+
+
+ + +
+
13 · Overlay cards (adjacent to transcript)
+

Approval & Clarify cards + reconnect banner

+ +
.approval-card — 4 button variants (once / session / always / deny) +
+
+
⚠ Approval required
+
The agent wants to run a shell command in /Users/aron/hermes-webui.
+
rm -rf node_modules && npm install
+
+ + + + +
+
+
+
+ +
.clarify-card — choice buttons + free-text fallback +
+
+
? Clarification needed
+
Which environment should I deploy this to?
+
+ + + + +
+
+ + +
+
Pick a choice, or type your own answer below.
+
+
+
+ +
Reconnect / mid-stream recovery banner +
+ ⚠ A response may have been in progress when you last left. Reload messages? +
+ + +
+
+
+ ⚠ Agent run exited with non-zero status (code 1). Check the logs. + +
+
+
+ + +
+
14 · Structure & data-attribute cheat sheet
+

Wrappers and state markers produced by renderMessages()

+ +
+

Wrappers

+
    +
  • .msg-row[data-role="user"] — one user turn (right-aligned bubble, 60% max-width)
  • +
  • .msg-row.assistant-turn[data-role="assistant"] — one assistant turn; contains one .msg-role and one .assistant-turn-blocks
  • +
  • .assistant-turn-blocks — flex-column holder for segments
  • +
  • .assistant-segment — a single logical chunk inside a turn: optional .thinking-card + optional .msg-body + optional .msg-foot
  • +
  • .assistant-segment-anchor — hidden segment kept as a DOM anchor for tool cards when the model emitted no text
  • +
  • .tool-card-row — per-tool-card wrapper, sibling of the turn inside .messages-inner
  • +
  • .msg-foot — per-segment (or per-user-row) footer holding .msg-time + .msg-actions
  • +
+

Data attributes & IDs

+
    +
  • data-role="user|assistant" — role marker on the row
  • +
  • data-msgIdx="N" — index into S.messages; on user rows and assistant segments
  • +
  • data-raw-text="…" — plain-text source for copy (now lives on .assistant-segment for assistant output)
  • +
  • data-live-assistant="1" — the segment that's currently streaming
  • +
  • data-editing="1" — row is in edit mode
  • +
  • data-error="1" — error state; applies to .msg-row (user) or .assistant-segment
  • +
  • id="liveAssistantTurn" — on the turn that contains the streaming segment
  • +
  • .tool-card-row[data-live-tid="…"] — live tool-call card (removed when the turn settles)
  • +
  • data-mermaid-id, data-katex, data-rendered — block rendering state
  • +
+
+
+ +
+ + + + + + + + + + + + + diff --git a/docs/ui-ux/two-stage-proposal.html b/docs/ui-ux/two-stage-proposal.html new file mode 100644 index 0000000..7702f63 --- /dev/null +++ b/docs/ui-ux/two-stage-proposal.html @@ -0,0 +1,742 @@ + + + + + Hermes WebUI — Two-Stage Chat Proposal (Issue #536) + + + + + + +
+
+ Two-Stage Chat UX — Proposal for issue #536 + Companion to index.html — shows Working → Final answer as a distinct two-phase interaction model. +
+
+ Theme + + + + + + + +
+
+ +
+ + +
+
0 · The model
+

One turn, two stages

+

+ 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 + .p2s-stage1 container with a worklog bar as its header, and marks the + final answer as .p2s-answer. The same DOM renders three ways: +

+
    +
  • Live — worklog shows Working… 0:42 · 2 tools with a pulsing dot; history is fully visible.
  • +
  • Settled — worklog collapses to a single line (Worked 1:42 · 4 tools · 2 thinking); final answer sits below as the calm conclusion.
  • +
  • Settled + opened — user clicks the worklog to re-expand the history for audit.
  • +
+
+ + +
+
1 · Current vs proposed — settled turn
+

Side-by-side comparison

+

+ 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 Answer kicker. +

+ +
+ + +
Current (PR #587) +
+
+

Does our dev server pick up the workspace from an env var or a flag?

+
+
+
HHermes
+
+
+
+ 💡 + Thought for 3.1s + +
+
Check how the CLI resolves workspace:
+grep for HERMES_WORKSPACE and --workspace
+inspect argv vs env precedence.
+
+
+
+
+
+
+ + bash + grep -rn "HERMES_WORKSPACE" . · exit 0 + +
+
+
cli/main.py:14:WORKSPACE_ENV = "HERMES_WORKSPACE"
+cli/main.py:92:    ws = os.getenv(WORKSPACE_ENV) or args.workspace
+
+
+
+
+
+
+ 📄 + read_file + cli/main.py · 148 lines + +
+
+
+
+
HHermes
+
+
+

Both work, but env wins. The CLI reads + HERMES_WORKSPACE first and only falls back to the + --workspace flag if the env var is unset.

+

So in practice:

+
    +
  • CI / daemons → set the env var.
  • +
  • Ad-hoc runs → pass --workspace.
  • +
+
+
+
+
+
Everything stacks equally — the answer is just the next block.
+
+ + +
Proposed — two-stage, settled +
+
+

Does our dev server pick up the workspace from an env var or a flag?

+
+
+
HHermes
+
+ + +
+
+ + Worked for 0:08 + + 2 tools + 1 thinking round + + +
+
+
+
+ 💡 + Thought for 3.1s + +
+
Check how the CLI resolves workspace:
+grep for HERMES_WORKSPACE and --workspace
+inspect argv vs env precedence.
+
+
+
+
+ + bash + grep -rn "HERMES_WORKSPACE" . · exit 0 + +
+
+
+
+
+
+ 📄 + read_file + cli/main.py · 148 lines + +
+
+
+
+
+ + +
+
+
Answer
+
+

Both work, but env wins. The CLI reads + HERMES_WORKSPACE first and only falls back to the + --workspace flag if the env var is unset.

+

So in practice:

+
    +
  • CI / daemons → set the env var.
  • +
  • Ad-hoc runs → pass --workspace.
  • +
+
+
+ +
+
+
+
Click the worklog bar to expand the execution history.
+
+
+
+ + +
+
2 · Stage 1 · Live run
+

Working timer + live execution history

+

+ 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 .p2s-stage1-body exactly as today. + A Round N separator is inserted when the agent starts a new reasoning/tool cycle. +

+ +
.p2s-stage1.is-live — Round 1 done, Round 2 running +
+
+
HHermes
+
+ +
+
+ + Working… 0:42 + + 3 tools + 2 thinking + +
+
+ +
+
+ 💡 + Thought for 2.4s + +
+
Need to map the streaming code path first,
+then check the persistence layer.
+
+ +
+
+
+ 📄 + read_file + api/streaming.py · 612 lines + +
+
+
+ +
+
+
+ + bash + grep -rn "tool_call_id" api/ · exit 0 · 88ms + +
+
+
+ +
Round 2
+ +
+
+ 💡 + Thought for 1.8s + +
+
Streaming looks fine — drill into how
+tool_calls get attached before save.
+
+ +
+
+
+ + + bash + pytest tests/test_tool_call_persistence.py -q + +
+
+
+ +
+
+ +
+
+
+
+
+ + +
+
3 · Approve vs Clarify — placement
+

Approvals stay in Stage 1; Clarify moves to the transition

+

+ Per the issue: approvals are part of doing the work (they gate a single tool), + clarifications stabilise the answer path (they precede the conclusion). The + proposal keeps .approval-card inline among tool cards, and places + .clarify-card at the Stage 1 → Stage 2 seam, above the final answer. +

+ +
+ + +
Approve card — inline in Stage 1 +
+
+
HHermes
+
+ +
+
+ + Working… 0:18 + 1 tool +
+
+
+
+
+ + bash + ls -la ~/.hermes/sessions · exit 0 + +
+
+
+
+
+ 🔐 + Approve command +
+
+

Hermes wants to run a potentially destructive command:

+
rm -rf ~/.hermes/sessions/*.json.bak
+
+
+ + +
+
+
+
+ +
+
+
+
Permission gate sits next to the tools it gates.
+
+ + +
Clarify card — Stage 1 → Stage 2 transition +
+
+
HHermes
+
+ +
+
+ + Worked for 0:12 + 2 tools + +
+
+
+
+
+ 📄 + read_file + package.json · 48 lines + +
+
+
+
+
+
+ + bash + ls src/ · exit 0 + +
+
+
+
+
+ +
+
+
+
+ + One quick question before I answer +
+
+

I can wire the dev server either as an npm script in the + existing package.json, or as a standalone CLI + entry-point. Which would you prefer?

+
+
+ + + +
+
+
+ +
+
+
+
Stage 1 is already settled; the answer is paused on clarification.
+
+
+
+ + +
+
4 · Stage 2 · Calm conclusion
+

What the "Answer" stage looks like on its own

+

+ Three small choices distinguish Stage 2 from a regular text block: + (1) a thin horizontal divider above it, (2) a tiny gold Answer kicker aligned to + the text rail, (3) a slightly taller line-height. No heavy borders, no boxed treatment — + the emphasis comes from what is missing around it, not ornament. +

+ +
.p2s-answer (Stage 1 collapsed above) +
+
+
HHermes
+
+ +
+
+ + Worked for 1:42 + + 4 tools + 2 thinking + 1 approval + + +
+
+
💡Thought for 2.4s
+
📄read_fileapi/streaming.py
+
bashgrep -rn "tool_call_id" api/
+
Round 2
+
💡Thought for 1.8s
+
bashpytest -q · exit 0 · 2.4s
+
✍️edit_fileapi/streaming.py · +12 −3
+
+
+ +
+
+
Answer
+
+

Tool-call persistence was breaking because session.tool_calls was + written after s.save() in api/streaming.py. + I moved the attach step above the save, and added a fallback that reconstructs + ordering from live tool-progress events when tool_call_id is absent + on older sessions.

+

Net result:

+
    +
  • Reloading mid-stream now preserves every tool card with args + output snippet.
  • +
  • Last-turn reasoning survives reload.
  • +
  • No schema migration needed — old sessions degrade gracefully.
  • +
+

Covered by the new regression in tests/test_tool_call_persistence.py.

+
+
+ 11:42 AM · 2,481 tokens · 1.42s + + + + +
+
+ +
+
+
+
+
+ + +
+
5 · Open-question answers (picked defaults)
+

What this proposal commits to

+
+
    +
  • Stage 1 on settle → partial collapse to a + single worklog bar with counts. Click to re-expand. No "nuke to black box", no "keep + everything open forever".
  • +
  • Final answer placement → sits beneath Stage 1, + not replacing it. Visual distinction comes from the divider + kicker + spacing, not from + a two-panel layout.
  • +
  • Clarify placement → at the Stage 1 → Stage 2 seam. + Approvals stay inline with tools.
  • +
  • Timer → lives on Stage 1 only. Stops when the agent + emits the first Stage 2 token; final label becomes "Worked for N:NN".
  • +
  • Signal for "answer has started" → first assistant + text delta after all tool calls have resolved and no new tool_use is pending + in the current round. Already present in the SSE stream per maintainer comment.
  • +
+
+
+ + +
+
6 · DOM cheat-sheet
+

What changes vs index.html

+
+

New wrappers

+
    +
  • .p2s-stage1[is-live|is-settled][is-open] — wraps the execution history inside an .assistant-segment.
  • +
  • .p2s-worklog — header of Stage 1. Pulsing dot + label + counts + caret. Clickable when settled.
  • +
  • .p2s-stage1-body — holds .thinking-card + .tool-card-row + .p2s-round-sep. Animated via max-height.
  • +
  • .p2s-round-sep — inline horizontal separator between tool/reasoning rounds.
  • +
  • .p2s-transition — thin gradient divider between Stage 1 and Stage 2.
  • +
  • .p2s-answer — wraps the final .msg-body + .msg-foot.
  • +
  • .p2s-answer-kicker — small gold Answer label.
  • +
  • .p2s-clarify-slot — placement slot for .clarify-card at the Stage 1/2 seam.
  • +
+

Unchanged

+
    +
  • .thinking-card, .tool-card, .approval-card, .clarify-card, .msg-body, .msg-foot — all existing app CSS and existing markup.
  • +
  • .assistant-turn-blocks and .assistant-segment remain the top-level wrappers.
  • +
  • Tool cards still live as .tool-card-row siblings — now nested inside .p2s-stage1-body rather than as direct children of .messages-inner.
  • +
+

Implementation notes

+
    +
  • Renderer in static/messages.js wraps an assistant turn's non-final blocks in .p2s-stage1-body and appends the .p2s-worklog header once; toggles is-live/is-settled based on data-live-assistant.
  • +
  • static/boot.js SSE handler ticks the timer while is-live, increments counts on each tool_use, and flips the class when the first Stage 2 delta arrives.
  • +
  • Persistence: no schema change needed — the worklog summary can be derived on reload from the existing persisted tool-call list + thinking rounds.
  • +
+
+
+ +
+ + + + + diff --git a/static/boot.js b/static/boot.js index a70b5e0..4c91da4 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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); diff --git a/static/index.html b/static/index.html index acacf13..7721335 100644 --- a/static/index.html +++ b/static/index.html @@ -7,6 +7,7 @@ + @@ -219,53 +220,55 @@ -
-
-
- - Approval required -
-
-
- -
- - - - -
-
-
-
+
+
+
+
+ + Approval required +
+
+
+ +
+ + + + +
+
+
+ +
diff --git a/static/messages.js b/static/messages.js index a9604ea..f2e0022 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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) { diff --git a/static/sessions.js b/static/sessions.js index ff509e0..9d7c86d 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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; diff --git a/static/style.css b/static/style.css index 79ce759..3b95b67 100644 --- a/static/style.css +++ b/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; } +} diff --git a/static/ui.js b/static/ui.js index 2be7178..aa62c24 100644 --- a/static/ui.js +++ b/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 /[\s\S]*?<\/think>|<\|channel>thought\n[\s\S]*?/.test(String(m.content||'')); +} +function _assistantRoleHtml(tsTitle=''){ + const _bn=window._botName||'Hermes'; + return `
${esc(_bn.charAt(0).toUpperCase())}
${esc(_bn)}
`; +} +function _createAssistantTurn(tsTitle=''){ + const row=document.createElement('div'); + row.className='msg-row assistant-turn'; + row.dataset.role='assistant'; + row.innerHTML=`${_assistantRoleHtml(tsTitle)}
`; + return row; +} +function _assistantTurnBlocks(turn){ + return turn?turn.querySelector('.assistant-turn-blocks'):null; +} +function _thinkingCardHtml(text){ + return `
${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
${esc(text)}
`; +} 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;vip&&(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: ... (DeepSeek, QwQ, MiniMax, etc.) - // and Gemma 4 channel tokens: <|channel>thought\n... - // Note: no ^ anchor — some models emit leading whitespace/newlines before . + if(!thinkingText && m.reasoning) thinkingText=m.reasoning; if(!thinkingText && typeof content==='string'){ const thinkMatch=content.match(/([\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=`
${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
${esc(thinkingText)}
`; - 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=`
${m.attachments.map(f=>`
${li('paperclip',12)} ${esc(f)}
`).join('')}
`; + } const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'
') : renderMd(String(content)); - // Action buttons for this bubble const editBtn = isUser ? `` : ''; const retryBtn = isLastAssistant ? `` : ''; + const copyBtn = ``; const tsVal=m._ts||m.timestamp; const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():''; - const _bn=window._botName||'Hermes'; - row.innerHTML=`
${isUser?'Y':esc(_bn.charAt(0).toUpperCase())}
${isUser?t('you'):esc(_bn)}${tsTitle?`${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`:''}${editBtn}${retryBtn}
${filesHtml}
${bodyHtml}
`; - 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) ? `${tsTime}` : ''; + const footHtml = `
${userTimeHtml}${editBtn}${copyBtn}${retryBtn}
`; + + 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}
${bodyHtml}
${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}
${bodyHtml}
${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){ ${icon} ${esc(displayName)} ${esc(previewText)} - ${hasDetail?'':''} + ${hasDetail?`${li('chevron-right',12)}`:''}
${hasDetail?`
${tc.args&&Object.keys(tc.args).length?`
${ @@ -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()) ? `
${li('lightbulb',14)}${t('thinking')}
${esc(String(text).trim())}
` : `
`; - return `
${icon}
${label}
${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); diff --git a/tests/test_issue401.py b/tests/test_issue401.py index ea4afe6..737c11b 100644 --- a/tests/test_issue401.py +++ b/tests/test_issue401.py @@ -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 diff --git a/tests/test_regressions.py b/tests/test_regressions.py index fba311f..10c0650 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -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 ──────────── diff --git a/tests/test_sprint37.py b/tests/test_sprint37.py index 4f60b52..173b2a5 100644 --- a/tests/test_sprint37.py +++ b/tests/test_sprint37.py @@ -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 = '' + 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" diff --git a/tests/test_sprint42.py b/tests/test_sprint42.py index 4f10dc0..c4b3fca 100644 --- a/tests/test_sprint42.py +++ b/tests/test_sprint42.py @@ -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()" diff --git a/tests/test_tool_call_persistence.py b/tests/test_tool_call_persistence.py new file mode 100644 index 0000000..2254791 --- /dev/null +++ b/tests/test_tool_call_persistence.py @@ -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" diff --git a/tests/test_ui_card_animation.py b/tests/test_ui_card_animation.py new file mode 100644 index 0000000..b8da00f --- /dev/null +++ b/tests/test_ui_card_animation.py @@ -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 "${li('chevron-right',12)}" in UI_JS + assert "${li('chevron-right',12)}" 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