diff --git a/.gitignore b/.gitignore index 75a7b67c..2bdceaad 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ Thumbs.db docs/* !docs/ui-ux/ !docs/ui-ux/** + +# Local-only PR review harness: rendering drivers, sample bank, fixtures. +# Used by Claude during deep reviews; never shared in the repo. +.local-review/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b8c5a7ad..5deb1c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## [Unreleased] + +## v0.50.216 — 2026-04-26 + +### Added +- **Compression chain collapse** — `get_importable_agent_sessions()` now merges linear compression continuation chains into a single sidebar entry, showing the chain tip's activity time and model. The chain root's title and start time are preserved for display; the latest importable segment is used for import. Non-compression parent/child pairs are unchanged. (`api/agent_sessions.py`, `tests/test_gateway_sync.py`) Closes #1012 [#1012 @franksong2702] +- **Comprehensive markdown renderer improvements** — blockquote grouping, strikethrough, task lists, CRLF normalisation, nested blockquotes, lists inside blockquotes. See details below. (`static/ui.js`) [#1073] + +### Fixed +- **Blockquote rendering** — consecutive `> lines` now group into one `
`, blank `>` continuation lines become `
`, bare `>` (no space) handled, `>>` nested blockquotes recurse correctly, lists inside blockquotes render ``, inline markdown (bold/italic/code) works inside quotes. (`static/ui.js`) [#1073] +- **Strikethrough** — `~~text~~` now renders as `
'; }); @@ -820,7 +879,7 @@ function renderMd(raw){ // Our pipeline only emits: ,,text` in all contexts (paragraphs, blockquotes, list items). (`static/ui.js`) [#1073] +- **Task lists** — `- [x]` renders as ✅, `- [ ]` renders as ☐ in all unordered list contexts including inside blockquotes. (`static/ui.js`) [#1073] +- **CRLF line endings** — Windows `\r\n` line endings are normalised at the start of `renderMd()` so `\r` never appears in rendered text. (`static/ui.js`) [#1073] +- **HTML/HTM preview in workspace** — `.html` and `.htm` files now render correctly in the workspace preview iframe. Root cause: `MIME_MAP` was missing these extensions; the fallback `application/octet-stream` caused browsers to refuse to render in the iframe. (`api/config.py`) [#1070] +- **Approval card obscured by queue flyout** — the approval card's "Allow once / Allow session / Always allow / Deny" buttons are no longer hidden behind the queue flyout when both are visible simultaneously. (`static/style.css` — one line: `z-index:3` on `.approval-card.visible`) [#1071] +- **`/steer`, `/interrupt`, `/queue` not working while agent is busy** — typing these commands while the agent is running now executes them immediately instead of queuing the raw text. Root cause: `send()` returned early inside the busy block before reaching the slash-command dispatcher. Fix: intercept the three control commands at the top of the busy block. (`static/messages.js`) [#1072] +- **Reasoning chip always visible** — the composer reasoning chip is now shown for all effort states. When effort is unset/default it shows a muted "Default" label; when explicitly set to `none` it shows "None". Previously both states hid the chip entirely, removing the affordance to inspect or change it. (`static/ui.js`, `static/style.css`) Closes #1068 [#1074 @franksong2702] +- **Steer settings copy updated** — removed "falls back to interrupt" / "interrupt + send" language across all 6 locales; steer mode now correctly described as "mid-turn correction without interrupting". (`static/i18n.js`, `static/index.html`) [#1072] + ## v0.50.215 — 2026-04-26 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 523f73a1..0735b3a4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,7 @@ > 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.215 (April 26, 2026) — 2319 tests collected +> Last updated: v0.50.216 (April 26, 2026) — 2428 tests collected > Tests: 2107 collected (`pytest tests/ --collect-only -q`) > Source:/ diff --git a/TESTING.md b/TESTING.md index cbb814ab..f513a021 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ > 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 coverage: 2319 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. +> Automated coverage: 2428 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. diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 00ab8b93..53f46132 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -6,13 +6,126 @@ from pathlib import Path logger = logging.getLogger(__name__) +def _optional_col(name: str, columns: set[str], fallback: str = "NULL") -> str: + return f"s.{name}" if name in columns else f"{fallback} AS {name}" + + +def _is_compression_continuation(parent: dict | None, child: dict) -> bool: + """Mirror Hermes Agent's compression-child guard. + + A child is a continuation only when the parent ended because of compression + and the child started after that compression boundary. Plain parent/child + relationships are left alone for future subagent-tree work. + """ + if not parent: + return False + if parent.get('end_reason') != 'compression': + return False + ended_at = parent.get('ended_at') + if ended_at is None: + return False + try: + return float(child.get('started_at') or 0) >= float(ended_at) + except (TypeError, ValueError): + return False + + +def _project_agent_session_rows(rows: list[dict]) -> list[dict]: + """Collapse compression chains into one logical sidebar row. + + The visible conversation should still look like the original chain head + (title and timestamps), while importing should use the latest importable + segment so the user continues from the current compressed state. + """ + rows_by_id = {row['id']: row for row in rows} + children_by_parent: dict[str, list[dict]] = {} + continuation_child_ids = set() + + for row in rows: + parent_id = row.get('parent_session_id') + if not parent_id: + continue + children_by_parent.setdefault(parent_id, []).append(row) + if _is_compression_continuation(rows_by_id.get(parent_id), row): + continuation_child_ids.add(row['id']) + + for children in children_by_parent.values(): + children.sort(key=lambda row: row.get('started_at') or 0, reverse=True) + + def compression_tip(row: dict) -> tuple[dict | None, int]: + current = row + seen = {row['id']} + latest_importable = row if (row.get('actual_message_count') or 0) > 0 else None + segment_count = 1 + for _ in range(len(rows_by_id) + 1): + candidates = [ + child for child in children_by_parent.get(current['id'], []) + if child['id'] not in seen and _is_compression_continuation(current, child) + ] + if not candidates: + return latest_importable, segment_count + current = candidates[0] + seen.add(current['id']) + segment_count += 1 + if (current.get('actual_message_count') or 0) > 0: + latest_importable = current + return latest_importable, segment_count + + projected = [] + for row in rows: + if row['id'] in continuation_child_ids: + continue + + segment_count = 1 + tip = row + if row.get('end_reason') == 'compression': + tip, segment_count = compression_tip(row) + if not tip or (tip.get('actual_message_count') or 0) <= 0: + continue + + if tip is row: + projected.append(dict(row)) + continue + + merged = dict(row) + # Keep the chain head's visible identity (title, started_at), but + # point the row at the latest importable segment for navigation AND + # surface the tip's recency so an actively-used chain bubbles to the + # top of the sidebar by its true last activity. Without overriding + # last_activity, a long-lived chain whose tip is being edited NOW + # would sort by the root's old timestamp and fall below recently + # touched standalone sessions — exactly the inverse of what a user + # expects from "Show agent sessions" sorted by activity. + for key in ( + 'id', 'model', 'message_count', 'actual_message_count', + 'ended_at', 'end_reason', 'last_activity', + ): + if key in tip: + merged[key] = tip[key] + if not merged.get('title'): + merged['title'] = tip.get('title') + if not merged.get('source'): + merged['source'] = tip.get('source') + merged['_lineage_root_id'] = row['id'] + merged['_lineage_tip_id'] = tip['id'] + merged['_compression_segment_count'] = segment_count + projected.append(merged) + + projected.sort( + key=lambda row: row.get('last_activity') or row.get('started_at') or 0, + reverse=True, + ) + return projected + + def read_importable_agent_session_rows(db_path: Path, limit: int = 200, log=None) -> list[dict]: - """Return non-WebUI agent sessions that have readable message rows. + """Return non-WebUI agent sessions projected as importable conversations. Hermes Agent can create rows in ``state.db.sessions`` before a session has - any messages. WebUI cannot import those rows, so both the regular - ``/api/sessions`` path and the gateway SSE watcher must filter them the - same way. + any messages, and long conversations can be split into compression-linked + rows. WebUI cannot import empty rows and should not show compression + segments as separate conversations, so both the regular ``/api/sessions`` + path and the gateway SSE watcher use this shared projection. """ db_path = Path(db_path) if not db_path.exists(): @@ -36,20 +149,27 @@ def read_importable_agent_session_rows(db_path: Path, limit: int = 200, log=None ) return [] + parent_expr = _optional_col('parent_session_id', session_cols) + ended_expr = _optional_col('ended_at', session_cols) + end_reason_expr = _optional_col('end_reason', session_cols) + cur.execute( - """ + f""" SELECT s.id, s.title, s.model, s.message_count, s.started_at, s.source, + {parent_expr}, + {ended_expr}, + {end_reason_expr}, COUNT(m.id) AS actual_message_count, MAX(m.timestamp) AS last_activity FROM sessions s LEFT JOIN messages m ON m.session_id = s.id WHERE s.source IS NOT NULL AND s.source != 'webui' GROUP BY s.id - HAVING COUNT(m.id) > 0 ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC - LIMIT ? """, - (int(limit),), ) - return [dict(row) for row in cur.fetchall()] + projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()]) + if limit is None: + return projected + return projected[:max(0, int(limit))] diff --git a/api/config.py b/api/config.py index 75909f31..1ad055ce 100644 --- a/api/config.py +++ b/api/config.py @@ -442,6 +442,8 @@ MIME_MAP = { ".bmp": "image/bmp", ".pdf": "application/pdf", ".json": "application/json", + ".html": "text/html", + ".htm": "text/html", ".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".doc": "application/msword", diff --git a/static/commands.js b/static/commands.js index e7d7cfdf..aeb25d71 100644 --- a/static/commands.js +++ b/static/commands.js @@ -595,8 +595,8 @@ async function cmdSteer(args){ * Shared implementation for /steer and the busy_input_mode='steer' path. * * Tries the real steer endpoint first. On any non-accept response (no cached - * agent, agent lacks steer, stream dead, etc.) falls back to interrupt mode: - * queue the message + cancel the stream so the existing drain re-sends. + * agent, agent lacks steer, stream dead, etc.) falls back to interrupt+queue: + * queues the message and cancels the stream so the drain re-sends it. * * @param {string} msg - The steer text. * @param {boolean} explicitSteer - True if the user explicitly invoked /steer diff --git a/static/i18n.js b/static/i18n.js index f2c8c3b8..58675e1b 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -101,23 +101,23 @@ const LOCALES = { no_active_session: 'No active session', cmd_queue: 'Queue a message for the next turn', cmd_interrupt: 'Cancel current turn and send a new message', - cmd_steer: 'Steer the agent with a correction (falls back to interrupt)', + cmd_steer: 'Inject a mid-turn correction without interrupting the agent', cmd_queue_no_msg: 'Usage: /queue ', cmd_queue_not_busy: 'No active task — just send normally', cmd_queue_confirm: 'Message queued', cmd_interrupt_no_msg: 'Usage: /interrupt ', cmd_interrupt_confirm: 'Interrupted — sending new message', cmd_steer_no_msg: 'Usage: /steer ', - cmd_steer_fallback: 'Steer unavailable — interrupted and queued instead', + cmd_steer_fallback: 'Steer unavailable — queued for next turn instead', cmd_steer_delivered: 'Steer delivered — agent will see it on its next tool result', steer_leftover_queued: 'Steer queued for next turn', - busy_steer_fallback: 'Steer not available — interrupted instead', + busy_steer_fallback: 'Steer unavailable — queued for next turn', busy_interrupt_confirm: 'Interrupted — sending new message', settings_label_busy_input_mode: 'Busy input mode', - settings_desc_busy_input_mode: 'Controls what happens when you send a message while the agent is running. Queue waits; Interrupt cancels and starts fresh; Steer sends a correction (currently falls back to interrupt).', + settings_desc_busy_input_mode: 'Controls what happens when you send a message while the agent is running. Queue waits; Interrupt cancels and starts fresh; Steer injects a correction mid-turn without interrupting (falls back to queue when agent or stream unavailable).', settings_busy_input_mode_queue: 'Queue follow-up', settings_busy_input_mode_interrupt: 'Interrupt current turn', - settings_busy_input_mode_steer: 'Steer (interrupt + send)', + settings_busy_input_mode_steer: 'Steer (mid-turn correction)', slash_skill_badge:'Skill', slash_skill_desc:'Invoke this skill', @@ -742,7 +742,7 @@ const LOCALES = { busy_steer_fallback: 'Steer недоступен — прервано', busy_interrupt_confirm: 'Прервано — отправка нового сообщения', settings_label_busy_input_mode: 'Режим ввода при занятости', - settings_desc_busy_input_mode: 'Определяет поведение при отправке сообщения во время работы агента. Очередь ждёт; Прерывание отменяет и начинает заново; Steer отправляет исправление (сейчас как прерывание).', + settings_desc_busy_input_mode: 'Определяет поведение при отправке сообщения во время работы агента. Очередь ждёт; Прерывание отменяет и начинает заново; Steer внедряет коррекцию без прерывания.', settings_busy_input_mode_queue: 'Поставить в очередь', settings_busy_input_mode_interrupt: 'Прервать текущий оборот', settings_busy_input_mode_steer: 'Steer (прерывание + отправка)', @@ -1340,23 +1340,23 @@ const LOCALES = { no_active_session: 'No hay ninguna sesión activa', cmd_queue: 'Poner mensaje en cola para el siguiente turno', cmd_interrupt: 'Cancelar turno actual y enviar nuevo mensaje', - cmd_steer: 'Redirigir al agente con una correcci\u00f3n (usa interrupci\u00f3n)', + cmd_steer: 'Inyectar una corrección a mitad del turno sin interrumpir al agente', cmd_queue_no_msg: 'Uso: /queue ', cmd_queue_not_busy: 'Sin tarea activa \u2014 env\u00eda normalmente', cmd_queue_confirm: 'Mensaje en cola', cmd_interrupt_no_msg: 'Uso: /interrupt ', cmd_interrupt_confirm: 'Interrumpido \u2014 enviando nuevo mensaje', cmd_steer_no_msg: 'Uso: /steer ', - cmd_steer_fallback: 'Steer no disponible \u2014 interrumpido y encolado', + cmd_steer_fallback: 'Steer no disponible — en cola para el siguiente turno', cmd_steer_delivered: 'Steer entregado \u2014 el agente lo ver\u00e1 en su pr\u00f3ximo resultado de herramienta', steer_leftover_queued: 'Steer en cola para el pr\u00f3ximo turno', - busy_steer_fallback: 'Steer no disponible \u2014 interrumpido', + busy_steer_fallback: 'Steer no disponible — en cola para el siguiente turno', busy_interrupt_confirm: 'Interrumpido \u2014 enviando nuevo mensaje', settings_label_busy_input_mode: 'Modo de entrada ocupada', - settings_desc_busy_input_mode: 'Controla qu\u00e9 sucede al enviar un mensaje mientras el agente est\u00e1 activo. Cola espera; Interrumpir cancela y empieza de nuevo; Steer env\u00eda una correcci\u00f3n (actualmente usa interrupci\u00f3n).', + settings_desc_busy_input_mode: 'Controla qué sucede al enviar mensajes mientras el agente está activo. Cola espera; Interrumpir cancela y empieza de nuevo; Steer inyecta una corrección sin interrumpir (usa cola si el agente no está disponible).', settings_busy_input_mode_queue: 'Poner en cola', settings_busy_input_mode_interrupt: 'Interrumpir turno actual', - settings_busy_input_mode_steer: 'Steer (interrupci\u00f3n + env\u00edo)', + settings_busy_input_mode_steer: 'Steer (corrección a mitad de turno)', no_personalities: 'No se encontraron personalidades (añádelas a ~/.hermes/personalities/)', available_personalities: 'Personalidades disponibles:', personality_switch_hint: '\n\nUsa `/personality ` para cambiar, o `/personality none` para limpiar.', @@ -1920,23 +1920,23 @@ const LOCALES = { no_active_session: 'Keine aktive Sitzung', cmd_queue: 'Nachricht f\u00fcr den n\u00e4chsten Durchgang einreihen', cmd_interrupt: 'Aktuellen Durchgang abbrechen und neue Nachricht senden', - cmd_steer: 'Agent mit Korrektur lenken (f\u00e4llt zur\u00fcck auf Unterbrechung)', + cmd_steer: 'Korrektursignal einf\u00fcgen ohne Unterbrechung', cmd_queue_no_msg: 'Verwendung: /queue ', cmd_queue_not_busy: 'Keine aktive Aufgabe \u2014 normal senden', cmd_queue_confirm: 'Nachricht eingereiht', cmd_interrupt_no_msg: 'Verwendung: /interrupt ', cmd_interrupt_confirm: 'Unterbrochen \u2014 neue Nachricht wird gesendet', cmd_steer_no_msg: 'Verwendung: /steer ', - cmd_steer_fallback: 'Steer nicht verf\u00fcgbar \u2014 unterbrochen und eingereiht', + cmd_steer_fallback: 'Steer nicht verf\u00fcgbar \u2014 f\u00fcr n\u00e4chsten Durchgang eingereiht', cmd_steer_delivered: 'Steer geliefert \u2014 der Agent sieht es bei seinem n\u00e4chsten Tool-Ergebnis', steer_leftover_queued: 'Steer f\u00fcr n\u00e4chsten Durchgang eingereiht', - busy_steer_fallback: 'Steer nicht verf\u00fcgbar \u2014 unterbrochen', + busy_steer_fallback: 'Steer nicht verf\u00fcgbar \u2014 f\u00fcr n\u00e4chsten Durchgang eingereiht', busy_interrupt_confirm: 'Unterbrochen \u2014 neue Nachricht wird gesendet', settings_label_busy_input_mode: 'Eingabemodus bei Besch\u00e4ftigung', - settings_desc_busy_input_mode: 'Steuert, was passiert, wenn Sie w\u00e4hrend der Agentenaktivit\u00e4t eine Nachricht senden. Warteschlange wartet; Unterbrechen bricht ab und startet neu; Steer sendet eine Korrektur (aktuell wie Unterbrechen).', + settings_desc_busy_input_mode: 'Steuert, was passiert, wenn Sie w\u00e4hrend der Agentenaktivit\u00e4t eine Nachricht senden. Warteschlange wartet; Unterbrechen bricht ab und startet neu; Steer f\u00fcgt eine Korrektur ein ohne zu unterbrechen.', settings_busy_input_mode_queue: 'In Warteschlange einreihen', settings_busy_input_mode_interrupt: 'Aktuellen Durchgang unterbrechen', - settings_busy_input_mode_steer: 'Steer (Unterbrechen + Senden)', + settings_busy_input_mode_steer: 'Steer (Korrektur ohne Unterbrechung)', no_personalities: 'Keine Persönlichkeiten gefunden (füge sie in ~/.hermes/personalities/ hinzu)', available_personalities: 'Verfügbare Persönlichkeiten:', personality_switch_hint: '\n\nNutze `/personality ` zum Wechseln, oder `/personality none` zum Löschen.', @@ -2303,7 +2303,7 @@ const LOCALES = { busy_steer_fallback: 'Steer \u4e0d\u53ef\u7528 \u2014 \u5df2\u4e2d\u65ad', busy_interrupt_confirm: '\u5df2\u4e2d\u65ad \u2014 \u6b63\u5728\u53d1\u9001\u65b0\u6d88\u606f', settings_label_busy_input_mode: '\u5fd9\u788c\u8f93\u5165\u6a21\u5f0f', - settings_desc_busy_input_mode: '\u63a7\u5236\u5728\u4ee3\u7406\u8fd0\u884c\u65f6\u53d1\u9001\u6d88\u606f\u7684\u884c\u4e3a\u3002\u961f\u5217\u7b49\u5f85\uff1b\u4e2d\u65ad\u53d6\u6d88\u5e76\u91cd\u65b0\u5f00\u59cb\uff1bSteer \u53d1\u9001\u7ea0\u6b63\uff08\u76ee\u524d\u56de\u9000\u4e3a\u4e2d\u65ad\uff09\u3002', + settings_desc_busy_input_mode: '\u63a7\u5236\u5728\u4ee3\u7406\u8fd0\u884c\u65f6\u53d1\u9001\u6d88\u606f\u7684\u884c\u4e3a\u3002\u961f\u5217\u7b49\u5f85\uff1b\u4e2d\u65ad\u53d6\u6d88\u5e76\u91cd\u65b0\u5f00\u59cb\uff1bSteer\u4e2d\u9014\u6ce8\u5165\u7ea0\u6b63\uff0c\u4e0d\u4e2d\u65ad\u3002', settings_busy_input_mode_queue: '\u52a0\u5165\u961f\u5217', settings_busy_input_mode_interrupt: '\u4e2d\u65ad\u5f53\u524d\u56de\u5408', settings_busy_input_mode_steer: 'Steer\uff08\u4e2d\u65ad + \u53d1\u9001\uff09', @@ -3215,23 +3215,23 @@ const LOCALES = { no_active_session: '\u7121\u6d3b\u8e8d\u6703\u8a71', cmd_queue: '\u5c07\u8a0a\u606f\u52a0\u5165\u4e0b\u4e00\u8f2a\u7684\u4f47\u5217', cmd_interrupt: '\u53d6\u6d88\u7576\u524d\u56de\u5408\u4e26\u767c\u9001\u65b0\u8a0a\u606f', - cmd_steer: '\u7528\u7d0a\u6b63\u8a0a\u606f\u5f15\u5c0e\u4ee3\u7406\uff08\u56de\u9000\u70ba\u4e2d\u65ad\uff09', + cmd_steer: '\u5728\u56de\u5408\u9032\u884c\u4e2d\u6ce8\u5165\u7d3a\u6b63\uff0c\u4e0d\u4e2d\u65b7\u4ee3\u7406', cmd_queue_no_msg: '\u7528\u6cd5\uff1a/queue <\u8a0a\u606f>', cmd_queue_not_busy: '\u6c92\u6709\u6d3b\u52d5\u4efb\u52d9 \u2014 \u76f4\u63a5\u767c\u9001\u5373\u53ef', cmd_queue_confirm: '\u8a0a\u606f\u5df2\u52a0\u5165\u4f47\u5217', cmd_interrupt_no_msg: '\u7528\u6cd5\uff1a/interrupt <\u8a0a\u606f>', cmd_interrupt_confirm: '\u5df2\u4e2d\u65ad \u2014 \u6b63\u5728\u767c\u9001\u65b0\u8a0a\u606f', cmd_steer_no_msg: '\u7528\u6cd5\uff1a/steer <\u8a0a\u606f>', - cmd_steer_fallback: 'Steer \u4e0d\u53ef\u7528 \u2014 \u5df2\u4e2d\u65ad\u4e26\u52a0\u5165\u4f47\u5217', + cmd_steer_fallback: 'Steer \u4e0d\u53ef\u7528 \u2014 \u5df2\u52a0\u5165\u4e0b\u4e00\u8f2a\u4f47\u5217', cmd_steer_delivered: 'Steer \u5df2\u9001\u9054 \u2014 \u4ee3\u7406\u5c07\u5728\u4e0b\u4e00\u500b\u5de5\u5177\u7d50\u679c\u4e2d\u770b\u5230', steer_leftover_queued: 'Steer \u5df2\u52a0\u5165\u4e0b\u4e00\u8f2a\u4f47\u5217', - busy_steer_fallback: 'Steer \u4e0d\u53ef\u7528 \u2014 \u5df2\u4e2d\u65ad', + busy_steer_fallback: 'Steer \u4e0d\u53ef\u7528 \u2014 \u5df2\u52a0\u5165\u4e0b\u4e00\u8f2a\u4f47\u5217', busy_interrupt_confirm: '\u5df2\u4e2d\u65ad \u2014 \u6b63\u5728\u767c\u9001\u65b0\u8a0a\u606f', settings_label_busy_input_mode: '\u5fd9\u788c\u8f38\u5165\u6a21\u5f0f', - settings_desc_busy_input_mode: '\u63a7\u5236\u5728\u4ee3\u7406\u904b\u884c\u6642\u767c\u9001\u8a0a\u606f\u7684\u884c\u70ba\u3002\u4f47\u5217\u7b49\u5f85\uff1b\u4e2d\u65ad\u53d6\u6d88\u4e26\u91cd\u65b0\u958b\u59cb\uff1bSteer \u767c\u9001\u7d0a\u6b63\uff08\u76ee\u524d\u56de\u9000\u70ba\u4e2d\u65ad\uff09\u3002', + settings_desc_busy_input_mode: '\u63a7\u5236\u5728\u4ee3\u7406\u904b\u884c\u6642\u767c\u9001\u8a0a\u606f\u7684\u884c\u70ba\u3002\u4f47\u5217\u7b49\u5f85\uff1b\u4e2d\u65b7\u53d6\u6d88\u4e26\u91cd\u65b0\u958b\u59cb\uff1bSteer\u4e2d\u9014\u6ce8\u5165\u7d3a\u6b63\uff0c\u4e0d\u4e2d\u65b7\u3002', settings_busy_input_mode_queue: '\u52a0\u5165\u4f47\u5217', settings_busy_input_mode_interrupt: '\u4e2d\u65ad\u7576\u524d\u56de\u5408', - settings_busy_input_mode_steer: 'Steer\uff08\u4e2d\u65ad + \u767c\u9001\uff09', + settings_busy_input_mode_steer: 'Steer\uff08\u4e2d\u9014\u7d3a\u6b63\uff09', no_active_task: '\u7121\u57f7\u884c\u4e2d\u7684\u4efb\u52d9\u53ef\u505c\u6b62\u3002', no_notes_yet: '\u5c1a\u7121\u5099\u8a3b\u3002', no_profile_yet: '\u5c1a\u7121\u8a2d\u5b9a\u6a94\u3002', diff --git a/static/index.html b/static/index.html index 1d63ca13..5bd43af5 100644 --- a/static/index.html +++ b/static/index.html @@ -667,7 +667,7 @@ Controls what happens when you send a message while the agent is running. Queue waits for the current task; Interrupt cancels and starts fresh; Steer injects a mid-turn correction without interrupting (falls back to interrupt when the agent is not yet cached or the stream has ended).diff --git a/static/messages.js b/static/messages.js index 64535066..4ee16886 100644 --- a/static/messages.js +++ b/static/messages.js @@ -14,6 +14,23 @@ async function send(){ if(S.busy||compressionRunning){ if(text){ if(!S.session){await newSession();await renderSessionList();} + // Busy-control slash commands must be intercepted HERE, before the + // busyMode routing block, so the user can always type /steer, /interrupt, + // or /queue while the agent is running and have them execute immediately. + // Without this intercept they fall through to the queue and execute after + // the current turn ends — by which point there is no active stream and + // cmdSteer / cmdInterrupt say "No active task to stop." + if(text.startsWith('/')){ + const _pc=typeof parseCommand==='function'&&parseCommand(text); + if(_pc&&['steer','interrupt','queue'].includes(_pc.name)){ + const _bc=COMMANDS.find(c=>c.name===_pc.name); + if(_bc){ + $('msg').value='';autoResize(); + await _bc.fn(_pc.args); + return; + } + } + } const busyMode=window._busyInputMode||'queue'; if(busyMode==='steer'&&S.activeStreamId&&typeof _trySteer==='function'){ // Real steer: clear the input first so the user gets immediate diff --git a/static/style.css b/static/style.css index e08bb26c..44bfd67d 100644 --- a/static/style.css +++ b/static/style.css @@ -401,7 +401,7 @@ .composer-flyout{position:relative;height:0;z-index:1;} /* ── Approval card ── */ .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-card.visible{pointer-events:auto;z-index:3;} .approval-inner{background:var(--surface);backdrop-filter:blur(8px);border:1px solid var(--accent-bg-strong);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:var(--error);} @@ -655,6 +655,7 @@ .composer-workspace-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .composer-reasoning-wrap{position:relative;flex:0 1 auto;min-width:0;} .composer-reasoning-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;} + .composer-reasoning-chip.inactive{opacity:.78;} .composer-reasoning-chip:hover{color:var(--text);background-color:var(--hover-bg);} .composer-reasoning-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);} .composer-reasoning-icon,.composer-reasoning-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;} diff --git a/static/ui.js b/static/ui.js index 77d842c4..cbb8d42c 100644 --- a/static/ui.js +++ b/static/ui.js @@ -432,15 +432,31 @@ window.addEventListener('resize',()=>{ // ── Reasoning effort chip ──────────────────────────────────────────────────── let _currentReasoningEffort=null; +function _normalizeReasoningEffort(eff){ + return String(eff||'').trim().toLowerCase(); +} + +function _formatReasoningEffortLabel(effort){ + if(effort==='none') return 'None'; + if(!effort) return 'Default'; + return effort; +} + function _applyReasoningChip(eff){ - _currentReasoningEffort=eff; + const effort=_normalizeReasoningEffort(eff); + _currentReasoningEffort=effort; const wrap=$('composerReasoningWrap'); const label=$('composerReasoningLabel'); + const chip=$('composerReasoningChip'); if(!wrap||!label) return; - if(!eff||eff==='none'){wrap.style.display='none';return;} wrap.style.display=''; - label.textContent=eff; - _highlightReasoningOption(eff); + label.textContent=_formatReasoningEffortLabel(effort); + if(chip){ + const inactive=!effort||effort==='none'; + chip.classList.toggle('inactive',inactive); + chip.title='Reasoning effort: '+_formatReasoningEffortLabel(effort); + } + _highlightReasoningOption(effort); } function fetchReasoningChip(){ @@ -664,7 +680,7 @@ function _sanitizeThinkingDisplayText(text){ } function renderMd(raw){ - let s=raw||''; + let s=(raw||'').replace(/\r\n/g,'\n').replace(/\r/g,'\n'); // ── MEDIA: token stash (must run first, before any other processing) ─────── // Detect MEDIA:tokens emitted by the agent (e.g. screenshots, // generated images) and replace them with inline or download links. @@ -731,6 +747,8 @@ function renderMd(raw){ t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`${esc(x)}`); t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`${esc(x)}`); t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`${esc(x)}`); + // Strikethrough: ~~text~~ →
text+ t=t.replace(/~~(.+?)~~/g,(_,x)=>`${esc(x)}`); // #487: Image pass — runs while code stash is active so  inside // backticks stays protected as a \x00C token and is never rendered as. // Must run before _code_stash restore and before _link_stash so the image @@ -748,7 +766,7 @@ function renderMd(raw){ t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]); // Escape any plain text that isn't already wrapped in a tag we produced // by escaping bare < > that are not part of our own tags - const SAFE_INLINE=/^<\/?(strong|em|code|a|img)([\s>]|$)/i; + const SAFE_INLINE=/^<\/?(strong|em|del|code|a|img)([\s>]|$)/i; t=t.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_INLINE.test(tag)?tag:esc(tag)); return t; } @@ -759,10 +777,47 @@ function renderMd(raw){ s=s.replace(/\*\*\*(.+?)\*\*\*/g,(_,t)=>`${esc(t)}`); s=s.replace(/\*\*(.+?)\*\*/g,(_,t)=>`${esc(t)}`); s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`${esc(t)}`); + s=s.replace(/~~(.+?)~~/g,(_,t)=>`
${esc(t)}`); s=s.replace(/\x00O(\d+)\x00/g,(_,i)=>_ob_stash[+i]); s=s.replace(/^### (.+)$/gm,(_,t)=>`${inlineMd(t)}
`).replace(/^## (.+)$/gm,(_,t)=>`${inlineMd(t)}
`).replace(/^# (.+)$/gm,(_,t)=>`${inlineMd(t)}
`); s=s.replace(/^---+$/gm,'
'); - s=s.replace(/^> (.+)$/gm,(_,t)=>`${inlineMd(t)}`); + // Group consecutive > lines into one. + // Handles: blank continuation lines (> alone), nested blockquotes (>>), + // lists inside blockquotes (> - item), and inline markdown in quoted text. + function _applyBlockquotes(src){ + return src.replace(/((?:^>[^\n]*(?:\n|$))+)/gm,block=>{ + const lines=block.split('\n'); + // Drop trailing bare '>' artifact + while(lines.length&&(lines[lines.length-1].trim()==='>'||lines[lines.length-1]==='')) + {if(lines[lines.length-1].trim()==='>'){lines.pop();break;}lines.pop();} + const stripped=lines.map(l=>l.replace(/^>[ \t]?/,'')); + const innerRaw=stripped.join('\n'); + let inner; + if(/^>/m.test(innerRaw)){ + // Nested blockquote: recurse so >> →+ inner=_applyBlockquotes(innerRaw); + } else if(/(^(?: )?[-*+] .+)/m.test(innerRaw)){ + // List inside blockquote: run list pass on stripped inner content + inner=innerRaw.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,lb=>{ + const ll=lb.trimEnd().split('\n');let h=''; + for(const li of ll){ + const txt=li.replace(/^ {0,4}[-*+] /,''); + let ih; + if(/^\[x\] /i.test(txt)) ih='✅ '+inlineMd(txt.slice(4)); + else if(/^\[ \] /.test(txt)) ih='☐ '+inlineMd(txt.slice(4)); + else ih=inlineMd(txt); + h+=`
'; + }); + } else { + // Plain lines: blank line →- ${ih}
`; + } + return h+'
, text → inlineMd + inner=stripped.map(l=>l.trim()===''?'
':inlineMd(l)).join('\n'); + } + return `${inner}`; + }); + } + s=_applyBlockquotes(s); // B8: improved list handling supporting up to 2 levels of indentation s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{ const lines=block.trimEnd().split('\n'); @@ -770,8 +825,12 @@ function renderMd(raw){ for(const l of lines){ const indent=/^ {2,}/.test(l); const text=l.replace(/^ {0,4}[-*+] /,''); - if(indent) html+=`- ${inlineMd(text)}
`; - else html+=`- ${inlineMd(text)}
`; + let _ih; + if(/^\[x\] /i.test(text)) _ih='✅ '+inlineMd(text.slice(4)); + else if(/^\[ \] /.test(text)) _ih='☐ '+inlineMd(text.slice(4)); + else _ih=inlineMd(text); + if(indent) html+=`- ${_ih}
`; + else html+=`- ${_ih}
`; } return html+',,, ,
,
- , //
,,,
, , ,
,,,
,, //