2026-04-23 16:05:57 +00:00
function _markSessionViewed ( sid , messageCount ) {
if ( typeof _setSessionViewedCount !== 'function' || ! sid ) return ;
const next = Number . isFinite ( messageCount ) ? Number ( messageCount ) : 0 ;
_setSessionViewedCount ( sid , next ) ;
}
2026-04-28 04:55:45 +00:00
function _isDocumentVisibleAndFocused ( ) {
if ( typeof document !== 'undefined' && document . visibilityState && document . visibilityState !== 'visible' ) return false ;
if ( typeof document !== 'undefined' && typeof document . hasFocus === 'function' && ! document . hasFocus ( ) ) return false ;
return true ;
}
function _isSessionCurrentPane ( sid ) {
2026-04-28 03:20:05 +00:00
if ( ! sid || ! S . session || S . session . session _id !== sid ) return false ;
// During session switching, S.session still points at the previous row until
2026-04-28 04:55:45 +00:00
// the next metadata request resolves. Do not let a just-finished old stream
// update the chat pane while the user is moving to another session.
2026-04-28 03:20:05 +00:00
if ( typeof _loadingSessionId !== 'undefined' && _loadingSessionId && _loadingSessionId !== sid ) return false ;
return true ;
}
2026-04-28 04:55:45 +00:00
function _isSessionActivelyViewed ( sid ) {
if ( ! _isSessionCurrentPane ( sid ) ) return false ;
if ( ! _isDocumentVisibleAndFocused ( ) ) return false ;
return true ;
}
function _markActiveSessionViewedOnReturn ( ) {
if ( ! _isDocumentVisibleAndFocused ( ) || ! S . session || ! S . session . session _id ) return ;
_markSessionViewed ( S . session . session _id , S . session . message _count || ( S . messages && S . messages . length ) || 0 ) ;
if ( typeof _clearSessionCompletionUnread === 'function' ) _clearSessionCompletionUnread ( S . session . session _id ) ;
if ( typeof renderSessionListFromCache === 'function' ) renderSessionListFromCache ( ) ;
}
2026-05-08 10:40:21 +00:00
function _deferStreamErrorIfOffline ( ) {
if ( typeof isOfflineBannerVisible === 'function' && isOfflineBannerVisible ( ) ) {
setComposerStatus ( t ( 'offline_stream_waiting' ) ) ;
return true ;
}
if ( typeof showOfflineBanner === 'function' && navigator . onLine === false ) {
showOfflineBanner ( 'browser' ) ;
setComposerStatus ( t ( 'offline_stream_waiting' ) ) ;
return true ;
}
return false ;
}
2026-04-28 04:55:45 +00:00
document . addEventListener ( 'visibilitychange' , _markActiveSessionViewedOnReturn ) ;
window . addEventListener ( 'focus' , _markActiveSessionViewedOnReturn ) ;
2026-04-30 04:34:27 +00:00
// TTS: pause speech synthesis when user focuses the composer (#499)
const _msgEl = document . getElementById ( 'msg' ) ;
if ( _msgEl ) _msgEl . addEventListener ( 'focus' , ( ) => { if ( 'speechSynthesis' in window && speechSynthesis . speaking ) speechSynthesis . pause ( ) ; } ) ;
if ( _msgEl ) _msgEl . addEventListener ( 'blur' , ( ) => { if ( 'speechSynthesis' in window && speechSynthesis . paused ) speechSynthesis . resume ( ) ; } ) ;
2026-04-28 04:55:45 +00:00
2026-03-31 03:40:19 +00:00
async function send ( ) {
const text = $ ( 'msg' ) . value . trim ( ) ;
if ( ! text && ! S . pendingFiles . length ) return ;
// Don't send while an inline message edit is active
if ( document . querySelector ( '.msg-edit-area' ) ) return ;
2026-05-01 13:29:10 +00:00
// Dismiss handoff hint when user sends a message (resets seen_at).
if ( S . session && S . session . session _id && typeof _dismissHandoffHint === 'function' ) {
_dismissHandoffHint ( S . session . session _id ) ;
}
2026-04-18 06:55:04 +00:00
const compressionRunning = typeof isCompressionUiRunning === 'function' && isCompressionUiRunning ( ) ;
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067)
* feat: busy input modes with queue/interrupt/steer slash commands
- Add busy_input_mode setting (queue/interrupt/steer) to config defaults
- Add /queue, /interrupt, /steer slash commands with handlers
- Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior)
- Add settings dropdown in settings panel with load/save/apply wiring
- Initialize window._busyInputMode at boot and on settings save
- Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant)
Addresses #720
* test: 17 regression tests for busy_input_mode + slash commands
PR description noted manual testing only. Added structural tests
matching the pattern used by recent contributor PRs (#1010, #1011,
#1018, #1022, #1058) so future refactors don't silently regress
the wiring:
Backend (api/config.py):
- default 'queue' is set in _DEFAULT_SETTINGS
- enum validator restricts to {queue, interrupt, steer}
Slash commands (static/commands.js):
- /queue, /interrupt, /steer all registered with correct fns
- /interrupt and /steer set noEcho:true (the queued payload
becomes the visible turn, not the slash invocation)
- cmdQueue requires S.busy
- cmdInterrupt + cmdSteer call queueSessionMessage before
cancelStream (otherwise the drain has nothing to pick up)
send() busy branch (static/messages.js):
- reads window._busyInputMode
- calls cancelStream on interrupt/steer
- queues before cancelling (ordering invariant)
Boot init + panels.js wiring (static/boot.js, static/panels.js):
- both success and fallback paths set window._busyInputMode
- load/save/apply path threads busy_input_mode through
i18n (static/i18n.js):
- all 17 new keys present in each of the 6 locale blocks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers
1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text
as a user bubble, then the drain would send the queued message, causing a
double-bubble in the conversation (#840 pattern).
2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue
payload but never cleared S.pendingFiles or called renderTray(). Staged files
would remain in the tray and be re-attached on the next send(), duplicating
attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge().
3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only
interrupt + steer), now documents that all three must set noEcho:true.
4. test_slash_commands_clear_pending_files: new test that all three handlers clear
S.pendingFiles and call renderTray() after enqueuing.
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* docs: v0.50.214 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
// If busy or a manual compression is still running, handle based on busy_input_mode
2026-04-18 06:55:04 +00:00
if ( S . busy || compressionRunning ) {
2026-03-31 03:40:19 +00:00
if ( text ) {
2026-04-13 23:18:15 +00:00
if ( ! S . session ) { await newSession ( ) ; await renderSessionList ( ) ; }
v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly
MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.
The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.
* test(workspace): lock in MIME_MAP entry for .html/.htm
PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).
Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(composer): raise .approval-card.visible z-index above .queue-card
.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.
Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.
* test(composer): pin approval-card z-index > queue-card invariant
PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.
Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.
Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: intercept /steer /interrupt /queue before busy-mode routing in send()
Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."
Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.
Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
_bc.fn(_pc.args) and returns, not queues
* test(busy-intercept): pin sync input-clear before await in slash intercept
PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."
Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: update steer i18n and settings copy — steer no longer interrupts
With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.
Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
cmd_steer: "falls back to interrupt" removed
settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
cmd_steer_fallback: "interrupted" → "queued for next turn"
busy_steer_fallback: "interrupted instead" → "queued for next turn"
settings_desc_busy_input_mode: "currently falls back to interrupt" removed
Also:
static/index.html: inline fallback text updated to match
static/commands.js: internal comment clarified (fallback = queue+cancel,
not "interrupt mode" which implies the primary action)
* fix(renderer): group consecutive blockquote lines into single element
Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
1. `.+` required at least one character — bare `>` lines (blank
continuation lines) did not match and passed through as literal `>`
2. Each matching line became its own `<blockquote>` element — a 10-line
blockquote produced 10 stacked `<blockquote>` tags with no grouping
3. When a fenced code block sat inside a blockquote, the fence-stash
pass consumed the code content and left orphaned `>` lines that the
old `.+` pattern could not match
Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.
14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph
* fix(renderer): drop empty trailing line from blockquote match
The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.
Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.
Reproducer:
'> Hello\n\nThe rest of the message.'
→ '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
^^^ phantom <br>
Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).
Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
- input ending with \n
- quote followed by paragraph (the real-world case)
- multi-line quote ending with \n
- quote with blank continuation + trailing \n (internal <br> stays,
trailing <br> does not)
Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes
Five additional fixes on top of the blockquote grouping from the initial commit:
1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
line endings do not produce stray \r characters in rendered output
2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
inside blockquotes/lists) and the outer pass (for plain paragraphs).
Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.
3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐
via task-done/task-todo span wrappers. Checks [X] (uppercase) too.
4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
own <blockquote> element rather than passing through as literal >.
Implemented by extracting the blockquote rule into _applyBlockquotes()
which calls itself recursively on the stripped inner content.
5. Lists inside blockquotes: > - item now renders <ul><li> inside the
blockquote instead of a literal "- item" string. Task list items work
inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>).
Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.
68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.
* fix(renderer): restore space in blockquote prefix-strip regex
Commit 04e7b53 changed the blockquote prefix-strip regex from
/^>[ \t]?/ (consume "> ", "\t>", or just ">")
to
/^>[\t]?/ (only consume "\t>" or just ">")
The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?: )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.
Reproducer (against the actual ui.js via node, before the fix):
> Hello world → <blockquote> Hello world</blockquote>
^ phantom leading space
> Steps: → <blockquote>Steps:
> - one - one
> - two - two</blockquote>
^ literal text, NOT a <ul>; lists-in-quote feature broken
> - [x] done → blockquote with literal "[x] done", no checkbox span
Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.
Fix: restore the space character in the strip regex's character class.
Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).
Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.
Suite: 2354 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Collapse agent session compression chains
* Restore upstream changelog entries
* fix(agent_sessions): bubble active compression chains to top by tip last_activity
The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.
Reproducer (with the harness against actual code, before the fix):
- root: started 30 days ago, last msg 30 days ago
- tip: started 28 days ago (parent_session_id=root), last msg 5 seconds ago
- standalone: last msg 2 days ago
Sidebar order with original PR:
[0] standalone (48h ago)
[1] active_tip (last_activity=root's 720h ago) ← wrong
Sidebar order after fix:
[0] active_tip (last_activity=tip's 0h ago) ← correct
[1] standalone (48h ago)
This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.
Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.
The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.216 release notes and version bump
Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.
* chore: gitignore local-only review harness directory
Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Keep reasoning chip visible for None effort
* test(reasoning): pin chip render output via node, not just source regex
The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.
Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:
TestChipAlwaysVisible
- empty / null -> "Default" label, inactive=true
- "none" -> "None" label, inactive=true
- "low"/"high" -> verbatim label, inactive=false
TestNormalizationEdgeCases
- "NONE" -> normalises to "None"
- " none " -> trims and normalises
- unknown junk -> falls through visible, never hidden
TestTitleAttributeAccessibility
- title attribute carries the human-readable label for tooltip /
screen-reader use
Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.
This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add #1074 to v0.50.216 changelog, bump test count to 2428
* fix(i18n): restore broken Unicode in Russian and Spanish steer strings
Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:
Spanish (es):
- cmd_steer: correcci00f3n → corrección
- cmd_steer_fallback: 2014 en cola → — en cola
- busy_steer_fallback: 2014 en cola → — en cola
- settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
- settings_busy_input_mode_steer: correcci00f3n → corrección
Russian (ru):
- settings_desc_busy_input_mode: the entire Cyrillic string was
replaced with raw 4-hex-char code-points without the \u prefix
(041e043f... instead of actual Cyrillic). Decoded:
"Определяет поведение при отправке сообщения во время работы
агента. Очередь ждёт; Прерывание отменяет и начинает заново;
Steer внедряет коррекцию без прерывания."
Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).
All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.
Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: remove duplicate compression chain entry from [Unreleased]
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-26 04:06:31 +00:00
// 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 ) ;
2026-05-08 02:32:06 +00:00
if ( _pc && [ 'steer' , 'interrupt' , 'queue' , 'terminal' , 'goal' ] . includes ( _pc . name ) ) {
v0.50.216: compression chains, renderer fixes, HTML preview, approval z-index, /steer fix, reasoning chip (#1075)
* fix(workspace): add .html/.htm to MIME_MAP so HTML preview renders correctly
MIME_MAP was missing entries for .html and .htm. The server fell back to
Content-Type: application/octet-stream, which browsers refuse to render as
HTML in an iframe — causing a blank white preview.
The rest of the pipeline was already correct: the iframe exists in
static/index.html, openFile() in static/workspace.js routes .html to
showPreview('html'), and _handle_file_raw() in api/routes.py sets the
correct CSP sandbox header when ?inline=1 is present. The only missing
piece was the MIME type.
* test(workspace): lock in MIME_MAP entry for .html/.htm
PR #1070 added .html/.htm → text/html to MIME_MAP in api/config.py
to fix the blank workspace HTML preview iframe. Without a direct
assertion on the MIME_MAP entries, the fix could silently regress
(the existing test_779_html_preview.py tests cover the iframe wiring,
the inline=1 query handling, and the CSP sandbox header — but none of
them touch MIME_MAP itself).
Add a single regression test that asserts MIME_MAP['.html'] and
MIME_MAP['.htm'] are both 'text/html' so any future removal of those
entries fails CI immediately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(composer): raise .approval-card.visible z-index above .queue-card
.queue-card has z-index:2. .approval-card.visible had no z-index, so the
queue flyout would render on top of the approval card when both were visible
simultaneously — obscuring the Allow/Deny buttons.
Fix: add z-index:3 to .approval-card.visible so approvals always render
above the queue flyout. Approval is a blocking, security-relevant interaction
and must never be obscured by passive UI elements.
* test(composer): pin approval-card z-index > queue-card invariant
PR #1071 raises .approval-card.visible to z-index:3 so the security-
relevant Allow / Deny buttons stay clickable when the queue flyout is
also open. Without a regression test, a future CSS edit could silently
drop the z-index back below queue-card (z-index:2) and reintroduce the
bug — there is no automated UI test covering this stacking interaction.
Add a focused regex check that pins the invariant:
.approval-card.visible z-index must be strictly greater than
.queue-card z-index.
Modeled on the existing CSS-regex regression style in
tests/test_mobile_layout.py (test_profile_dropdown_not_clipped_by_overflow).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: intercept /steer /interrupt /queue before busy-mode routing in send()
Root cause: slash commands entered while the agent is busy never reached
the command dispatcher. send() enters the busy block and returns early at
line ~50, so the slash-command intercept (~line 56) is never reached.
The text was queued as a plain message. When it drained after the turn
ended, cmdSteer / cmdInterrupt ran on an idle session, saw no active stream,
and showed "No active task to stop."
Fix: at the top of the busy block, before checking busyMode, check if the
text starts with / and is one of the three control commands. If so, dispatch
the handler immediately and return. This lets the user type /steer, /interrupt,
or /queue at any time — including while the agent is mid-stream — and have
them execute against the live session.
Two new regression tests added:
- test_slash_commands_intercepted_before_busymode_routing: verifies the
intercept appears before the busyMode routing in the busy block
- test_steer_intercept_calls_handler_directly: verifies the intercept calls
_bc.fn(_pc.args) and returns, not queues
* test(busy-intercept): pin sync input-clear before await in slash intercept
PR #1072's intercept clears the msg input before awaiting the handler.
Order matters: if the await happens first (or if the clear is moved
inside the handler), the input still shows '/steer foo' for the duration
of the await. A reflexive second Enter press during that window — common
while waiting for the toast — re-runs send(): either re-fires the
handler (double-steer) or, if the turn just ended, falls through to the
non-busy slash dispatcher and drops a confusing "No active task to stop."
Add test_steer_intercept_clears_input_before_await pinning the order so
this UX invariant cannot silently regress.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: update steer i18n and settings copy — steer no longer interrupts
With the real /steer implementation (agent.steer() via /api/chat/steer),
steer injects a correction mid-turn WITHOUT interrupting the current stream.
The previous copy said "falls back to interrupt", "Steer (interrupt + send)",
etc. — accurate only for the old placeholder, not the real implementation.
Changes across all 6 locales (en/ru/es/de/zh/zh-Hant):
cmd_steer: "falls back to interrupt" removed
settings_busy_input_mode_steer: "interrupt + send" → "mid-turn correction"
cmd_steer_fallback: "interrupted" → "queued for next turn"
busy_steer_fallback: "interrupted instead" → "queued for next turn"
settings_desc_busy_input_mode: "currently falls back to interrupt" removed
Also:
static/index.html: inline fallback text updated to match
static/commands.js: internal comment clarified (fallback = queue+cancel,
not "interrupt mode" which implies the primary action)
* fix(renderer): group consecutive blockquote lines into single element
Root cause: the old rule `s.replace(/^> (.+)$/gm, ...)` had three bugs:
1. `.+` required at least one character — bare `>` lines (blank
continuation lines) did not match and passed through as literal `>`
2. Each matching line became its own `<blockquote>` element — a 10-line
blockquote produced 10 stacked `<blockquote>` tags with no grouping
3. When a fenced code block sat inside a blockquote, the fence-stash
pass consumed the code content and left orphaned `>` lines that the
old `.+` pattern could not match
Fix: replace the single-line regex with a group-based approach that matches
one or more consecutive `>` lines as a single block, strips the `>` prefix
from each line, passes each non-empty line through inlineMd(), turns blank
`>` lines into `<br>`, and wraps the entire group in one `<blockquote>`.
14 regression tests added covering:
- Single-line blockquotes (regression)
- Multi-line grouping (2 and 10 lines)
- Two separate blockquotes staying separate
- Bare `>` and `>text` (no space) edge cases
- Blank continuation lines → <br>
- Bold / italic / inline-code inside blockquotes
- Blockquote followed by normal paragraph
* fix(renderer): drop empty trailing line from blockquote match
The new group-based blockquote rule introduced in this PR captures the
trailing newline in its (?:\n|$) clause. After block.split('\n') that
trailing newline produces an empty final element. The original filter
only dropped lone bare '>' artifacts on the last line, so the empty
final element survived, and the .map(blank → '<br>') step turned it
into a phantom <br> immediately before </blockquote>.
Visible symptom: any blockquote whose source ends with \n (the common
case — a quote followed by another paragraph or end-of-message) renders
with an extra blank line at the bottom of the quote.
Reproducer:
'> Hello\n\nThe rest of the message.'
→ '<blockquote>Hello\n<br></blockquote>\nThe rest of the message.'
^^^ phantom <br>
Fix: replace the single-line filter with a while-loop that pops trailing
lines while they are either empty OR a bare '>'. This matches the
intent the Python test mirror in tests/test_blockquote_rendering.py
already had (the mirror was correct; the JS was not — that's why
the original tests passed despite the bug).
Also add four new regression tests in TestNoPhantomTrailingBr that pin
the no-trailing-<br> invariant for the common shapes:
- input ending with \n
- quote followed by paragraph (the real-world case)
- multi-line quote ending with \n
- quote with blank continuation + trailing \n (internal <br> stays,
trailing <br> does not)
Verified end-to-end with node against the actual JS regex.
244 renderer-adjacent tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(renderer): comprehensive markdown fixes — strikethrough, task lists, CRLF, nested blockquotes
Five additional fixes on top of the blockquote grouping from the initial commit:
1. CRLF normalisation: strip \r\n → \n at start of renderMd so Windows
line endings do not produce stray \r characters in rendered output
2. Strikethrough: ~~text~~ → <del>text</del> in both inlineMd() (for use
inside blockquotes/lists) and the outer pass (for plain paragraphs).
Added <del> to SAFE_TAGS and SAFE_INLINE so it is not HTML-escaped.
3. Task lists: - [x] / - [ ] items in unordered lists render as ✅/☐
via task-done/task-todo span wrappers. Checks [X] (uppercase) too.
4. Nested blockquotes: >> / >>> etc. now recurse so each level gets its
own <blockquote> element rather than passing through as literal >.
Implemented by extracting the blockquote rule into _applyBlockquotes()
which calls itself recursively on the stripped inner content.
5. Lists inside blockquotes: > - item now renders <ul><li> inside the
blockquote instead of a literal "- item" string. Task list items work
inside blockquotes too (> - [x] done → ✅ inside <blockquote><ul>).
Also fixed test_issue342.py search window (5000→10000 chars) — the CRLF
strip at the top of renderMd pushed the autolink regex past the old limit.
68 new tests in test_renderer_comprehensive.py + test_blockquote_rendering.py
covering all constructs, edge cases, and combinations.
* fix(renderer): restore space in blockquote prefix-strip regex
Commit 04e7b53 changed the blockquote prefix-strip regex from
/^>[ \t]?/ (consume "> ", "\t>", or just ">")
to
/^>[\t]?/ (only consume "\t>" or just ">")
The space character was dropped from the character class. Since
practically every blockquote an LLM produces is "> " (greater-than
followed by a space), this leaves a leading space artifact on every
stripped blockquote line. Worse, the leading space breaks the
list-detection regex `^(?: )?[-*+] ` inside the new `_applyBlockquotes`
helper — that regex requires either zero or two leading spaces, never
one — so the new "list inside blockquote" feature never fired for
the canonical input shape `> - item`.
Reproducer (against the actual ui.js via node, before the fix):
> Hello world → <blockquote> Hello world</blockquote>
^ phantom leading space
> Steps: → <blockquote>Steps:
> - one - one
> - two - two</blockquote>
^ literal text, NOT a <ul>; lists-in-quote feature broken
> - [x] done → blockquote with literal "[x] done", no checkbox span
Tests passed despite the bug because tests/test_blockquote_rendering.py
and tests/test_renderer_comprehensive.py validate against a Python
mirror (`_apply_blockquotes`) whose strip regex is `^>[ \t]?` — i.e.
the mirror is correct, the JS is not, and the static-mirror tests
can't catch the divergence. Same shape of bug as commit 94d63d0
(phantom <br> in trailing line) where the mirror was right and the JS
was wrong.
Fix: restore the space character in the strip regex's character class.
Add tests/test_renderer_js_behaviour.py — 11 tests that drive the
ACTUAL renderMd via node and assert on rendered output for the most
common LLM shapes (single-line quote, multi-line quote, list inside
quote, task list inside quote, nested >>>, strikethrough inside and
outside quote, top-level task list, quote followed by heading,
multi-paragraph quote with list, CRLF normalisation).
Verified: the buggy regex makes 6 of those 11 tests fail; the corrected
regex makes all 11 pass.
Suite: 2354 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Collapse agent session compression chains
* Restore upstream changelog entries
* fix(agent_sessions): bubble active compression chains to top by tip last_activity
The original PR merge kept the chain head's id/title/started_at and overrode
id/model/message_count/ended_at/end_reason from the tip — but did NOT override
last_activity. Since the projected list is sorted by last_activity DESC and
the WebUI sidebar surfaces updated_at = last_activity, an actively-used
compression chain whose tip is being edited NOW would sort by the ROOT's
old last_activity and fall below recently touched standalone sessions.
Reproducer (with the harness against actual code, before the fix):
- root: started 30 days ago, last msg 30 days ago
- tip: started 28 days ago (parent_session_id=root), last msg 5 seconds ago
- standalone: last msg 2 days ago
Sidebar order with original PR:
[0] standalone (48h ago)
[1] active_tip (last_activity=root's 720h ago) ← wrong
Sidebar order after fix:
[0] active_tip (last_activity=tip's 0h ago) ← correct
[1] standalone (48h ago)
This matches Hermes Agent's own list_sessions_rich projection at
hermes_state.py:903-909, which overrides "last_active" from the tip
exactly so that the agent CLI's session list orders the same way.
Add ``last_activity`` to the merge-from-tip key list, update the existing
test_compression_chain_collapses_to_latest_tip_in_sidebar assertion to
expect tip-derived updated_at, and add
test_compression_chain_bubbles_to_top_by_tip_activity locking in the
bubble-to-top invariant — without this regression test the previous
behaviour passed CI because no test exercised the sort order against a
mixed set of chains and standalone sessions.
The chain head's started_at (created_at) and title remain preserved, so
users can still find the conversation by its original date and name.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.216 release notes and version bump
Compression chains, renderer fixes, HTML preview, approval z-index, /steer fix.
* chore: gitignore local-only review harness directory
Adds .local-review/ to .gitignore so renderer drivers, sample inputs,
fixture builders, and other reviewer scratch files do not accidentally
get committed. Nothing under that path is ever shared in the repo;
keeping the entry tracked makes the boundary explicit for any future
contributor who creates the directory locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Keep reasoning chip visible for None effort
* test(reasoning): pin chip render output via node, not just source regex
The PR's static checks in test_reasoning_chip_btw_fixes.py validate the
shape of _applyReasoningChip (no display='none' literal, the right
classList.toggle call exists, the right label literals are in the
function body) but pass even if the runtime detail is wrong — for
example if `inactive` were inverted, _normalizeReasoningEffort
mishandled whitespace, or _formatReasoningEffortLabel returned the
wrong literal for an unknown input.
Add tests/test_reasoning_chip_js_behaviour.py — 11 tests that drive
the actual _applyReasoningChip() via node and assert on the rendered
DOM state for each effort value:
TestChipAlwaysVisible
- empty / null -> "Default" label, inactive=true
- "none" -> "None" label, inactive=true
- "low"/"high" -> verbatim label, inactive=false
TestNormalizationEdgeCases
- "NONE" -> normalises to "None"
- " none " -> trims and normalises
- unknown junk -> falls through visible, never hidden
TestTitleAttributeAccessibility
- title attribute carries the human-readable label for tooltip /
screen-reader use
Sanity-checked against master's pre-fix ui.js: 11/11 fail (bug caught).
Against this PR's ui.js: 11/11 pass.
This pattern (drive the actual JS via node) caught two regex-only
regressions in PR #1073 where the Python mirror was correct while the
JS was broken. Same protection added here so the chip-visibility
contract can't silently break in a future refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: add #1074 to v0.50.216 changelog, bump test count to 2428
* fix(i18n): restore broken Unicode in Russian and Spanish steer strings
Commit 56c7a14 (fix: update steer i18n and settings copy) accidentally
stripped the `\u` prefix from Unicode escape sequences in two locales,
producing garbled literal hex strings visible to users:
Spanish (es):
- cmd_steer: correcci00f3n → corrección
- cmd_steer_fallback: 2014 en cola → — en cola
- busy_steer_fallback: 2014 en cola → — en cola
- settings_desc_busy_input_mode: qu00e9, est00e1, correcci00f3n → qué, está, corrección
- settings_busy_input_mode_steer: correcci00f3n → corrección
Russian (ru):
- settings_desc_busy_input_mode: the entire Cyrillic string was
replaced with raw 4-hex-char code-points without the \u prefix
(041e043f... instead of actual Cyrillic). Decoded:
"Определяет поведение при отправке сообщения во время работы
агента. Очередь ждёт; Прерывание отменяет и начинает заново;
Steer внедряет коррекцию без прерывания."
Fix: write the correct characters directly (UTF-8 is the file encoding
so embedding them literally is cleaner than \u escapes for long text).
All other locales (en, de, zh, zh-Hant) were not affected — confirmed
by grepping for bare hex run-ons in the updated file.
Verified: node --check static/i18n.js passes; full pytest suite green
(2365 passed, 47 skipped).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: remove duplicate compression chain entry from [Unreleased]
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-04-26 04:06:31 +00:00
const _bc = COMMANDS . find ( c => c . name === _pc . name ) ;
if ( _bc ) {
$ ( 'msg' ) . value = '' ; autoResize ( ) ;
await _bc . fn ( _pc . args ) ;
return ;
}
}
}
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067)
* feat: busy input modes with queue/interrupt/steer slash commands
- Add busy_input_mode setting (queue/interrupt/steer) to config defaults
- Add /queue, /interrupt, /steer slash commands with handlers
- Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior)
- Add settings dropdown in settings panel with load/save/apply wiring
- Initialize window._busyInputMode at boot and on settings save
- Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant)
Addresses #720
* test: 17 regression tests for busy_input_mode + slash commands
PR description noted manual testing only. Added structural tests
matching the pattern used by recent contributor PRs (#1010, #1011,
#1018, #1022, #1058) so future refactors don't silently regress
the wiring:
Backend (api/config.py):
- default 'queue' is set in _DEFAULT_SETTINGS
- enum validator restricts to {queue, interrupt, steer}
Slash commands (static/commands.js):
- /queue, /interrupt, /steer all registered with correct fns
- /interrupt and /steer set noEcho:true (the queued payload
becomes the visible turn, not the slash invocation)
- cmdQueue requires S.busy
- cmdInterrupt + cmdSteer call queueSessionMessage before
cancelStream (otherwise the drain has nothing to pick up)
send() busy branch (static/messages.js):
- reads window._busyInputMode
- calls cancelStream on interrupt/steer
- queues before cancelling (ordering invariant)
Boot init + panels.js wiring (static/boot.js, static/panels.js):
- both success and fallback paths set window._busyInputMode
- load/save/apply path threads busy_input_mode through
i18n (static/i18n.js):
- all 17 new keys present in each of the 6 locale blocks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers
1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text
as a user bubble, then the drain would send the queued message, causing a
double-bubble in the conversation (#840 pattern).
2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue
payload but never cleared S.pendingFiles or called renderTray(). Staged files
would remain in the tray and be re-attached on the next send(), duplicating
attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge().
3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only
interrupt + steer), now documents that all three must set noEcho:true.
4. test_slash_commands_clear_pending_files: new test that all three handlers clear
S.pendingFiles and call renderTray() after enqueuing.
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* docs: v0.50.214 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
const busyMode = window . _busyInputMode || 'queue' ;
2026-04-26 02:21:00 +00:00
if ( busyMode === 'steer' && S . activeStreamId && typeof _trySteer === 'function' ) {
// Real steer: clear the input first so the user gets immediate
// feedback, then ship the steer payload via /api/chat/steer.
// _trySteer falls back to queue+cancel internally if the agent
// isn't running / cached / steer-capable.
$ ( 'msg' ) . value = '' ; autoResize ( ) ;
// Do NOT clear pendingFiles yet — _trySteer may fall back to
// interrupt+queue and needs the files for queueSessionMessage.
// _trySteer clears pendingFiles itself in the fallback path, and
// the server returns accepted:true (no files sent) on success.
await _trySteer ( text , /*explicitSteer=*/ false ) ;
// After _trySteer: clear any remaining files (success path).
S . pendingFiles = [ ] ; renderTray ( ) ;
} else if ( busyMode === 'interrupt' ) {
// Queue the message, then cancel so drain re-sends it.
2026-05-01 05:23:47 +00:00
queueSessionMessage ( S . session . session _id , { text , files : [ ... S . pendingFiles ] , model : S . session && S . session . model || ( $ ( 'modelSelect' ) && $ ( 'modelSelect' ) . value ) || '' , model _provider : S . session && S . session . model _provider || null , profile : S . activeProfile || 'default' } ) ;
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067)
* feat: busy input modes with queue/interrupt/steer slash commands
- Add busy_input_mode setting (queue/interrupt/steer) to config defaults
- Add /queue, /interrupt, /steer slash commands with handlers
- Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior)
- Add settings dropdown in settings panel with load/save/apply wiring
- Initialize window._busyInputMode at boot and on settings save
- Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant)
Addresses #720
* test: 17 regression tests for busy_input_mode + slash commands
PR description noted manual testing only. Added structural tests
matching the pattern used by recent contributor PRs (#1010, #1011,
#1018, #1022, #1058) so future refactors don't silently regress
the wiring:
Backend (api/config.py):
- default 'queue' is set in _DEFAULT_SETTINGS
- enum validator restricts to {queue, interrupt, steer}
Slash commands (static/commands.js):
- /queue, /interrupt, /steer all registered with correct fns
- /interrupt and /steer set noEcho:true (the queued payload
becomes the visible turn, not the slash invocation)
- cmdQueue requires S.busy
- cmdInterrupt + cmdSteer call queueSessionMessage before
cancelStream (otherwise the drain has nothing to pick up)
send() busy branch (static/messages.js):
- reads window._busyInputMode
- calls cancelStream on interrupt/steer
- queues before cancelling (ordering invariant)
Boot init + panels.js wiring (static/boot.js, static/panels.js):
- both success and fallback paths set window._busyInputMode
- load/save/apply path threads busy_input_mode through
i18n (static/i18n.js):
- all 17 new keys present in each of the 6 locale blocks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers
1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text
as a user bubble, then the drain would send the queued message, causing a
double-bubble in the conversation (#840 pattern).
2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue
payload but never cleared S.pendingFiles or called renderTray(). Staged files
would remain in the tray and be re-attached on the next send(), duplicating
attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge().
3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only
interrupt + steer), now documents that all three must set noEcho:true.
4. test_slash_commands_clear_pending_files: new test that all three handlers clear
S.pendingFiles and call renderTray() after enqueuing.
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* docs: v0.50.214 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
updateQueueBadge ( S . session . session _id ) ;
$ ( 'msg' ) . value = '' ; autoResize ( ) ;
S . pendingFiles = [ ] ; renderTray ( ) ;
if ( S . activeStreamId && typeof cancelStream === 'function' ) {
2026-04-26 02:21:00 +00:00
showToast ( t ( 'busy_interrupt_confirm' ) , 2000 ) ;
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067)
* feat: busy input modes with queue/interrupt/steer slash commands
- Add busy_input_mode setting (queue/interrupt/steer) to config defaults
- Add /queue, /interrupt, /steer slash commands with handlers
- Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior)
- Add settings dropdown in settings panel with load/save/apply wiring
- Initialize window._busyInputMode at boot and on settings save
- Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant)
Addresses #720
* test: 17 regression tests for busy_input_mode + slash commands
PR description noted manual testing only. Added structural tests
matching the pattern used by recent contributor PRs (#1010, #1011,
#1018, #1022, #1058) so future refactors don't silently regress
the wiring:
Backend (api/config.py):
- default 'queue' is set in _DEFAULT_SETTINGS
- enum validator restricts to {queue, interrupt, steer}
Slash commands (static/commands.js):
- /queue, /interrupt, /steer all registered with correct fns
- /interrupt and /steer set noEcho:true (the queued payload
becomes the visible turn, not the slash invocation)
- cmdQueue requires S.busy
- cmdInterrupt + cmdSteer call queueSessionMessage before
cancelStream (otherwise the drain has nothing to pick up)
send() busy branch (static/messages.js):
- reads window._busyInputMode
- calls cancelStream on interrupt/steer
- queues before cancelling (ordering invariant)
Boot init + panels.js wiring (static/boot.js, static/panels.js):
- both success and fallback paths set window._busyInputMode
- load/save/apply path threads busy_input_mode through
i18n (static/i18n.js):
- all 17 new keys present in each of the 6 locale blocks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers
1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text
as a user bubble, then the drain would send the queued message, causing a
double-bubble in the conversation (#840 pattern).
2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue
payload but never cleared S.pendingFiles or called renderTray(). Staged files
would remain in the tray and be re-attached on the next send(), duplicating
attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge().
3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only
interrupt + steer), now documents that all three must set noEcho:true.
4. test_slash_commands_clear_pending_files: new test that all three handlers clear
S.pendingFiles and call renderTray() after enqueuing.
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* docs: v0.50.214 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
await cancelStream ( ) ;
} else {
showToast ( ` Queued: " ${ text . slice ( 0 , 40 ) } ${ text . length > 40 ? '…' : '' } " ` , 2000 ) ;
}
} else {
2026-04-26 02:21:00 +00:00
// Default: queue mode (current behavior). Also the fallback for
// 'steer' mode when no stream is active or _trySteer is unavailable.
2026-05-01 05:23:47 +00:00
queueSessionMessage ( S . session . session _id , { text , files : [ ... S . pendingFiles ] , model : S . session && S . session . model || ( $ ( 'modelSelect' ) && $ ( 'modelSelect' ) . value ) || '' , model _provider : S . session && S . session . model _provider || null , profile : S . activeProfile || 'default' } ) ;
v0.50.214: busy input modes + queue/interrupt/steer slash commands (#1067)
* feat: busy input modes with queue/interrupt/steer slash commands
- Add busy_input_mode setting (queue/interrupt/steer) to config defaults
- Add /queue, /interrupt, /steer slash commands with handlers
- Modify send() to respect busy_input_mode (interrupt cancels and resends, steer falls back to interrupt with toast, queue preserves existing behavior)
- Add settings dropdown in settings panel with load/save/apply wiring
- Initialize window._busyInputMode at boot and on settings save
- Add 17 i18n keys across all 6 locale blocks (en/ru/es/de/zh/zh-Hant)
Addresses #720
* test: 17 regression tests for busy_input_mode + slash commands
PR description noted manual testing only. Added structural tests
matching the pattern used by recent contributor PRs (#1010, #1011,
#1018, #1022, #1058) so future refactors don't silently regress
the wiring:
Backend (api/config.py):
- default 'queue' is set in _DEFAULT_SETTINGS
- enum validator restricts to {queue, interrupt, steer}
Slash commands (static/commands.js):
- /queue, /interrupt, /steer all registered with correct fns
- /interrupt and /steer set noEcho:true (the queued payload
becomes the visible turn, not the slash invocation)
- cmdQueue requires S.busy
- cmdInterrupt + cmdSteer call queueSessionMessage before
cancelStream (otherwise the drain has nothing to pick up)
send() busy branch (static/messages.js):
- reads window._busyInputMode
- calls cancelStream on interrupt/steer
- queues before cancelling (ordering invariant)
Boot init + panels.js wiring (static/boot.js, static/panels.js):
- both success and fallback paths set window._busyInputMode
- load/save/apply path threads busy_input_mode through
i18n (static/i18n.js):
- all 17 new keys present in each of the 6 locale blocks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: add noEcho:true to /queue; clear pendingFiles in all three slash handlers
1. /queue was missing noEcho:true — the dispatcher would echo the raw slash text
as a user bubble, then the drain would send the queued message, causing a
double-bubble in the conversation (#840 pattern).
2. cmdQueue, cmdInterrupt, and cmdSteer all captured S.pendingFiles into the queue
payload but never cleared S.pendingFiles or called renderTray(). Staged files
would remain in the tray and be re-attached on the next send(), duplicating
attachments. Fix: add S.pendingFiles=[];renderTray() after updateQueueBadge().
3. test_all_three_busy_commands_are_no_echo: expanded to cover /queue (was only
interrupt + steer), now documents that all three must set noEcho:true.
4. test_slash_commands_clear_pending_files: new test that all three handlers clear
S.pendingFiles and call renderTray() after enqueuing.
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* docs: v0.50.214 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-26 01:51:06 +00:00
$ ( 'msg' ) . value = '' ; autoResize ( ) ;
S . pendingFiles = [ ] ; renderTray ( ) ;
updateQueueBadge ( S . session . session _id ) ;
showToast ( ` Queued: " ${ text . slice ( 0 , 40 ) } ${ text . length > 40 ? '…' : '' } " ` , 2000 ) ;
}
2026-03-31 03:40:19 +00:00
}
return ;
}
2026-05-05 00:24:49 +00:00
if ( S . session && ( S . session . read _only || S . session . is _read _only ) ) {
if ( typeof showToast === 'function' ) showToast ( 'Read-only imported sessions cannot be modified.' , 3000 ) ;
return ;
}
fix(ui): slash command input now echoed as user message in chat (closes #840)
* fix(ui): echo slash command input as user message in chat (#840)
Slash commands like /skills, /help, /status previously showed only the
assistant response with no user message above it — the conversation
appeared to start from nowhere.
Fix: executeCommand() now returns {noEcho:bool} instead of true/false
(returns null when no command matched). send() in messages.js pushes a
user message bubble before returning when noEcho is false.
Commands with noEcho:true are action-only and don't get echoed:
/clear, /new, /stop, /retry, /undo, /voice, /model, /workspace,
/theme, /usage, /reasoning.
Commands without noEcho (get echoed):
/help, /skills, /status, /title, /compress, /compact, /personality.
16 new tests in test_issue840_slash_echo.py.
* fix(ui): push user message BEFORE running slash handler (ordering bug)
The PR as originally written pushed the user message AFTER the slash
command handler ran. That works correctly for async handlers (the
assistant response lands later, after the user push) but breaks for
sync handlers like cmdHelp which push their assistant response
synchronously:
S.messages = [assistant response, user "/help"] ← reverse order
The chat would render the help content ABOVE the user's own "/help"
input — not what the issue asked for.
Fix: look up the command inline, push the user message first (for
echo-worthy commands), then run the handler. If the handler opts out
(returns false — e.g. /reasoning <level>), pop the user message back
off so the normal send path can add it cleanly when forwarding to the
agent.
Renamed the flow so it's clear we're not calling executeCommand twice
(my first attempt did that by accident). executeCommand() stays as a
public API returning null or {noEcho:bool} — just isn't the only path
send() uses now.
Added 2 regression tests:
- test_send_pushes_user_message_before_running_handler: asserts
the user push appears before the handler invocation in source order.
- test_send_rolls_back_user_push_on_handler_optout: asserts the
S.messages.pop() for the opt-out case.
Also tightened the existing `test_send_checks_noecho_flag` and
`test_send_pushes_user_message_for_echo_commands` tests to look at
the new `_cmd.noEcho` pattern inline (vs the original
`cmdResult.noEcho`). Removed `test_send_uses_null_check_not_truthy`
(obsoleted — the control flow no longer stores the executeCommand
return in a variable).
Full suite: 1767 passed, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui): compress/compact noEcho + title/personality confirmation messages
Applied Opus mentor review fixes:
- compress and compact: add noEcho:true (S.messages reset internally causes
user bubble to flicker/disappear without noEcho)
- /title <name>: push assistant confirmation message after rename succeeds
- /personality <name>: push assistant confirmation message after set succeeds
- 4 new regression tests covering the above invariants
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 06:08:24 +00:00
// Slash command intercept -- local commands handled without agent round-trip.
// We push the user message BEFORE running the handler for echo-worthy
// commands so chat order is correct: some handlers (e.g. cmdHelp) push
// their assistant response synchronously. If we pushed AFTER, S.messages
// would be [assistant, user] and the chat would show the response above
// the user's own input — reverse chronological order (#840 ordering bug).
if ( text . startsWith ( '/' ) && ! S . pendingFiles . length ) {
const _parsedCmd = parseCommand ( text ) ;
const _cmd = _parsedCmd ? COMMANDS . find ( c => c . name === _parsedCmd . name ) : null ;
if ( _cmd ) {
let _pushedUser = false ;
if ( ! _cmd . noEcho ) {
if ( ! S . session ) { await newSession ( ) ; await renderSessionList ( ) ; }
S . messages . push ( { role : 'user' , content : text , _ts : Date . now ( ) / 1000 } ) ;
_pushedUser = true ;
renderMessages ( ) ;
}
// Run the handler directly (we already looked it up). If it returns
// false it's opting out — e.g. /reasoning <level> falls through so the
// agent sees the raw text. Roll back the echo push in that case so
// the normal send path doesn't duplicate it.
if ( _cmd . fn ( _parsedCmd . args ) === false ) {
if ( _pushedUser ) { S . messages . pop ( ) ; renderMessages ( ) ; }
// Fall through to normal send path
} else {
$ ( 'msg' ) . value = '' ; autoResize ( ) ; hideCmdDropdown ( ) ; return ;
}
}
2026-05-01 04:46:30 +00:00
if ( _parsedCmd && ! _cmd ) {
const _agentCmd = typeof getAgentCommandMetadata === 'function'
? await getAgentCommandMetadata ( _parsedCmd . name )
: null ;
if ( _agentCmd && _agentCmd . cli _only ) {
if ( ! S . session ) { await newSession ( ) ; await renderSessionList ( ) ; }
S . messages . push ( { role : 'user' , content : text , _ts : Date . now ( ) / 1000 } ) ;
S . messages . push ( { role : 'assistant' , content : cliOnlyCommandResponse ( _parsedCmd . name , _agentCmd ) , _ts : Date . now ( ) / 1000 } ) ;
renderMessages ( ) ;
$ ( 'msg' ) . value = '' ; autoResize ( ) ; hideCmdDropdown ( ) ; return ;
}
2026-05-09 21:14:21 +00:00
if ( _agentCmd && _agentCmd . category === 'Plugin' ) {
if ( ! S . session ) { await newSession ( ) ; await renderSessionList ( ) ; }
S . messages . push ( { role : 'user' , content : text , _ts : Date . now ( ) / 1000 } ) ;
let _pluginOutput = '(no output)' ;
try {
_pluginOutput = typeof executeAgentPluginCommand === 'function'
? await executeAgentPluginCommand ( text , _agentCmd )
: 'Plugin command runtime unavailable in WebUI.' ;
} catch ( e ) {
_pluginOutput = ` Plugin command error: ${ e && e . message || e } ` ;
}
S . messages . push ( { role : 'assistant' , content : String ( _pluginOutput || '(no output)' ) , _ts : Date . now ( ) / 1000 } ) ;
renderMessages ( ) ;
$ ( 'msg' ) . value = '' ; autoResize ( ) ; hideCmdDropdown ( ) ; return ;
}
2026-05-01 04:46:30 +00:00
}
2026-04-18 06:55:04 +00:00
}
2026-03-31 03:40:19 +00:00
if ( ! S . session ) { await newSession ( ) ; await renderSessionList ( ) ; }
const activeSid = S . session . session _id ;
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
* Polish workspace panel behavior and app dialogs
* Replace remaining emoji UI glyphs with Lucide icons
* Redesign composer footer around model and context controls
Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.
Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/
* Remove obsolete activity bar
Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.
This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.
* Move workspace and model switching into composer footer
* Move profile switching into composer footer
* Refactor Hermes control center UI
* Redesign control center settings modal layout
Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Put session item actions in a dropdown
* Use Hermes mark in sidebar control button
* Reset control center section on close
* Drop session-item left border indicator
Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Increase session search input vertical padding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Normalise odd pixel values across UI
Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)
The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints
Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.
* Improve composer footer mobile responsiveness and UX
- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior
* Fix duplicate files button and broken workspace close on mobile
Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix model chip icon vertical alignment in composer footer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix workspace toggle button hidden on desktop by conflicting CSS class
Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix session actions dots button inaccessible on mobile sidebar
Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer footer manage links not opening sidebar panel
The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Widen icon-only composer chips breakpoint from 400px to 768px
Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer-left vertical scrollbar by setting overflow-y:hidden
When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve rebase conflicts and fix control center test assertions
- Resolved 4 conflicts during rebase onto master (workspace.js,
boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update version badge in System tab to v0.49.4
* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge
---------
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
setComposerStatus ( S . pendingFiles && S . pendingFiles . length ? 'Uploading…' : '' ) ;
2026-03-31 03:40:19 +00:00
let uploaded = [ ] ;
try { uploaded = await uploadPendingFiles ( ) ; }
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
* Polish workspace panel behavior and app dialogs
* Replace remaining emoji UI glyphs with Lucide icons
* Redesign composer footer around model and context controls
Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.
Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/
* Remove obsolete activity bar
Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.
This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.
* Move workspace and model switching into composer footer
* Move profile switching into composer footer
* Refactor Hermes control center UI
* Redesign control center settings modal layout
Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Put session item actions in a dropdown
* Use Hermes mark in sidebar control button
* Reset control center section on close
* Drop session-item left border indicator
Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Increase session search input vertical padding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Normalise odd pixel values across UI
Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)
The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints
Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.
* Improve composer footer mobile responsiveness and UX
- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior
* Fix duplicate files button and broken workspace close on mobile
Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix model chip icon vertical alignment in composer footer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix workspace toggle button hidden on desktop by conflicting CSS class
Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix session actions dots button inaccessible on mobile sidebar
Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer footer manage links not opening sidebar panel
The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Widen icon-only composer chips breakpoint from 400px to 768px
Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer-left vertical scrollbar by setting overflow-y:hidden
When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve rebase conflicts and fix control center test assertions
- Resolved 4 conflicts during rebase onto master (workspace.js,
boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update version badge in System tab to v0.49.4
* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge
---------
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
catch ( e ) { if ( ! text ) { setComposerStatus ( ` Upload error: ${ e . message } ` ) ; return ; } }
2026-04-30 21:32:45 +00:00
// Clear the uploading status now that upload is done — if we don't clear here
// it stays visible for the entire duration of the agent stream, since
// setComposerStatus('') is only called in setBusy(false), not setBusy(true).
setComposerStatus ( '' ) ;
2026-03-31 03:40:19 +00:00
2026-04-25 06:09:44 +00:00
const uploadedNames = uploaded . map ( u => u . name || u ) ;
2026-04-28 15:18:51 +00:00
const uploadedPaths = uploaded . map ( u => u && u . is _image ? ( u . name || u . filename || u ) : ( u . path || u . name || u ) ) ;
2026-03-31 03:40:19 +00:00
let msgText = text ;
2026-04-25 06:09:44 +00:00
if ( uploaded . length && ! msgText ) msgText = ` I've uploaded ${ uploaded . length } file(s): ${ uploadedPaths . join ( ', ' ) } ` ;
else if ( uploaded . length ) msgText = ` ${ text } \n \n [Attached files: ${ uploadedPaths . join ( ', ' ) } ] ` ;
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
* Polish workspace panel behavior and app dialogs
* Replace remaining emoji UI glyphs with Lucide icons
* Redesign composer footer around model and context controls
Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.
Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/
* Remove obsolete activity bar
Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.
This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.
* Move workspace and model switching into composer footer
* Move profile switching into composer footer
* Refactor Hermes control center UI
* Redesign control center settings modal layout
Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Put session item actions in a dropdown
* Use Hermes mark in sidebar control button
* Reset control center section on close
* Drop session-item left border indicator
Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Increase session search input vertical padding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Normalise odd pixel values across UI
Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)
The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints
Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.
* Improve composer footer mobile responsiveness and UX
- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior
* Fix duplicate files button and broken workspace close on mobile
Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix model chip icon vertical alignment in composer footer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix workspace toggle button hidden on desktop by conflicting CSS class
Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix session actions dots button inaccessible on mobile sidebar
Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer footer manage links not opening sidebar panel
The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Widen icon-only composer chips breakpoint from 400px to 768px
Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer-left vertical scrollbar by setting overflow-y:hidden
When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve rebase conflicts and fix control center test assertions
- Resolved 4 conflicts during rebase onto master (workspace.js,
boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update version badge in System tab to v0.49.4
* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge
---------
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
if ( ! msgText ) { setComposerStatus ( 'Nothing to send' ) ; return ; }
2026-03-31 03:40:19 +00:00
$ ( 'msg' ) . value = '' ; autoResize ( ) ;
2026-05-09 12:44:15 +00:00
// Clear persisted composer draft since message was sent.
if ( activeSid && typeof _clearComposerDraft === 'function' ) _clearComposerDraft ( activeSid ) ;
2026-04-25 06:09:44 +00:00
const displayText = text || ( uploaded . length ? ` Uploaded: ${ uploadedNames . join ( ', ' ) } ` : '(file upload)' ) ;
const userMsg = { role : 'user' , content : displayText , attachments : uploaded . length ? uploadedNames : undefined , _ts : Date . now ( ) / 1000 } ;
2026-03-31 03:40:19 +00:00
S . toolCalls = [ ] ; // clear tool calls from previous turn
clearLiveToolCards ( ) ; // clear any leftover live cards from last turn
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
* Polish workspace panel behavior and app dialogs
* Replace remaining emoji UI glyphs with Lucide icons
* Redesign composer footer around model and context controls
Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.
Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/
* Remove obsolete activity bar
Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.
This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.
* Move workspace and model switching into composer footer
* Move profile switching into composer footer
* Refactor Hermes control center UI
* Redesign control center settings modal layout
Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Put session item actions in a dropdown
* Use Hermes mark in sidebar control button
* Reset control center section on close
* Drop session-item left border indicator
Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Increase session search input vertical padding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Normalise odd pixel values across UI
Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)
The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints
Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.
* Improve composer footer mobile responsiveness and UX
- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior
* Fix duplicate files button and broken workspace close on mobile
Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix model chip icon vertical alignment in composer footer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix workspace toggle button hidden on desktop by conflicting CSS class
Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix session actions dots button inaccessible on mobile sidebar
Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer footer manage links not opening sidebar panel
The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Widen icon-only composer chips breakpoint from 400px to 768px
Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer-left vertical scrollbar by setting overflow-y:hidden
When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve rebase conflicts and fix control center test assertions
- Resolved 4 conflicts during rebase onto master (workspace.js,
boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update version badge in System tab to v0.49.4
* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge
---------
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
S . messages . push ( userMsg ) ; renderMessages ( ) ; appendThinking ( ) ; setBusy ( true ) ;
2026-05-04 04:14:21 +00:00
// First optimistic pass: make the local user turn visible before /api/chat/start
// can save pending state on the server.
2026-05-04 03:10:05 +00:00
if ( typeof upsertActiveSessionForLocalTurn === 'function' ) {
upsertActiveSessionForLocalTurn ( { title : displayText . slice ( 0 , 64 ) , messageCount : S . messages . length , timestampMs : Date . now ( ) } ) ;
}
2026-04-25 06:09:44 +00:00
INFLIGHT [ activeSid ] = { messages : [ ... S . messages ] , uploaded : uploadedNames , toolCalls : [ ] } ;
2026-04-13 23:25:31 +00:00
if ( typeof saveInflightState === 'function' ) {
2026-04-25 06:09:44 +00:00
saveInflightState ( activeSid , { streamId : null , messages : INFLIGHT [ activeSid ] . messages , uploaded : uploadedNames , toolCalls : [ ] } ) ;
2026-04-13 23:25:31 +00:00
}
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
if ( typeof renderSessionListFromCache === 'function' ) renderSessionListFromCache ( ) ;
2026-03-31 03:40:19 +00:00
startApprovalPolling ( activeSid ) ;
2026-04-15 05:10:50 +00:00
startClarifyPolling ( activeSid ) ;
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
Merges PRs #1208, #1209, #1210 (#1152 rebased):
- fix(providers): OAuth provider cards show correct Configured status in Settings.
get_providers() was discarding has_key=True from _provider_has_key() for OAuth
providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers
from the Settings panel. Surfaces auth_error string. (closes #1202)
- ux(profiles): profile chip shows spinner and new name immediately on switch.
Optimistic name update + .switching CSS class + chip disabled + finally cleanup.
populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all.
- feat: YOLO mode toggle — skip all approvals per session.
/yolo slash command, "Skip all this session" button on approval cards,
amber ⚡ pill indicator in composer footer. Session-scoped, in-memory.
Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes #467)
Original author: @bergeouss (PR #1152)
Tests: 2837 passed (+50 new tests vs previous release)
QA harness: 20/20 passed + all browser API checks passed
2026-04-28 05:56:12 +00:00
_fetchYoloState ( activeSid ) ; // sync YOLO pill with backend state
2026-03-31 03:40:19 +00:00
S . activeStreamId = null ; // will be set after stream starts
2026-05-10 17:15:16 +00:00
if ( typeof updateSendBtn === 'function' ) updateSendBtn ( ) ;
2026-03-31 03:40:19 +00:00
// Set provisional title from user message immediately so session appears
// in the sidebar right away with a meaningful name (server may refine later)
if ( S . session && ( S . session . title === 'Untitled' || ! S . session . title ) ) {
const provisionalTitle = displayText . slice ( 0 , 64 ) ;
S . session . title = provisionalTitle ;
syncTopbar ( ) ;
2026-05-04 03:10:05 +00:00
// Persist it in the background; keep the optimistic sidebar cache as the
// immediate source of truth until /api/chat/start saves pending state.
2026-03-31 03:40:19 +00:00
api ( '/api/session/rename' , { method : 'POST' , body : JSON . stringify ( {
session _id : activeSid , title : provisionalTitle
} ) } ) . catch ( ( ) => { } ) ; // fire-and-forget, server refines on done
2026-05-04 03:10:05 +00:00
if ( typeof upsertActiveSessionForLocalTurn === 'function' ) {
2026-05-04 04:14:21 +00:00
// Second optimistic pass: carry the provisional title into the cached row
// without re-fetching /api/sessions before pending state exists server-side.
2026-05-04 03:10:05 +00:00
upsertActiveSessionForLocalTurn ( { title : provisionalTitle , messageCount : S . messages . length , timestampMs : Date . now ( ) } ) ;
} else if ( typeof renderSessionListFromCache === 'function' ) renderSessionListFromCache ( ) ;
} else if ( typeof upsertActiveSessionForLocalTurn === 'function' ) {
upsertActiveSessionForLocalTurn ( { title : S . session && S . session . title || displayText . slice ( 0 , 64 ) , messageCount : S . messages . length , timestampMs : Date . now ( ) } ) ;
2026-03-31 03:40:19 +00:00
} else {
2026-05-04 03:10:05 +00:00
renderSessionListFromCache ( ) ; // ensure it's visible even if already titled
2026-03-31 03:40:19 +00:00
}
// Start the agent via POST, get a stream_id back
let streamId ;
try {
const startData = await api ( '/api/chat/start' , { method : 'POST' , body : JSON . stringify ( {
session _id : activeSid , message : msgText ,
model : S . session . model || $ ( 'modelSelect' ) . value , workspace : S . session . workspace ,
2026-05-01 05:23:47 +00:00
model _provider : S . session . model _provider || null ,
2026-05-06 01:39:11 +00:00
profile : S . activeProfile || S . session . profile || 'default' ,
2026-04-28 15:18:51 +00:00
attachments : uploaded . length ? uploaded : undefined
2026-03-31 03:40:19 +00:00
} ) } ) ;
2026-05-10 17:15:16 +00:00
2026-04-20 06:22:26 +00:00
if ( startData . effective _model && S . session ) {
S . session . model = startData . effective _model ;
2026-05-01 05:23:47 +00:00
S . session . model _provider = startData . effective _model _provider || S . session . model _provider || null ;
2026-04-20 06:22:26 +00:00
localStorage . setItem ( 'hermes-webui-model' , startData . effective _model ) ;
2026-05-01 05:23:47 +00:00
if ( typeof _writePersistedModelState === 'function' ) _writePersistedModelState ( startData . effective _model , S . session . model _provider || null ) ;
if ( $ ( 'modelSelect' ) ) _applyModelToDropdown ( startData . effective _model , $ ( 'modelSelect' ) , S . session . model _provider || null ) ;
if ( typeof syncTopbar === 'function' ) syncTopbar ( ) ;
} else if ( startData . effective _model _provider && S . session ) {
S . session . model _provider = startData . effective _model _provider ;
if ( typeof _writePersistedModelState === 'function' ) _writePersistedModelState ( S . session . model || '' , S . session . model _provider || null ) ;
if ( $ ( 'modelSelect' ) && typeof _applyModelToDropdown === 'function' ) _applyModelToDropdown ( S . session . model || '' , $ ( 'modelSelect' ) , S . session . model _provider || null ) ;
if ( typeof syncModelChip === 'function' ) syncModelChip ( ) ;
2026-04-20 06:22:26 +00:00
if ( typeof syncTopbar === 'function' ) syncTopbar ( ) ;
}
2026-03-31 03:40:19 +00:00
streamId = startData . stream _id ;
S . activeStreamId = streamId ;
2026-05-10 17:15:16 +00:00
// setBusy(true) already ran with activeStreamId=null; refresh now that we
// have a stream id so the primary button can switch to Stop (see getComposerPrimaryAction).
if ( typeof updateSendBtn === 'function' ) updateSendBtn ( ) ;
2026-05-05 20:42:47 +00:00
if ( S . session && typeof startData . pending _started _at === 'number' ) {
S . session . pending _started _at = startData . pending _started _at ;
}
2026-04-23 16:05:57 +00:00
if ( S . session && S . session . session _id === activeSid ) {
S . session . active _stream _id = streamId ;
}
2026-05-04 03:10:05 +00:00
if ( typeof upsertActiveSessionForLocalTurn === 'function' ) {
2026-05-04 04:14:21 +00:00
// Third optimistic pass: stream_id is now known, so the row can reconcile
// against real active-stream metadata before the background refresh lands.
2026-05-04 03:10:05 +00:00
upsertActiveSessionForLocalTurn ( { title : S . session && S . session . title || displayText . slice ( 0 , 64 ) , messageCount : S . messages . length , timestampMs : Date . now ( ) } ) ;
}
2026-03-31 03:40:19 +00:00
markInflight ( activeSid , streamId ) ;
2026-04-13 23:25:31 +00:00
if ( typeof saveInflightState === 'function' ) {
2026-04-25 06:09:44 +00:00
saveInflightState ( activeSid , { streamId , messages : INFLIGHT [ activeSid ] . messages , uploaded : uploadedNames , toolCalls : INFLIGHT [ activeSid ] . toolCalls || [ ] } ) ;
2026-04-13 23:25:31 +00:00
}
2026-04-23 16:05:57 +00:00
// Refresh session list so background streaming indicators appear immediately for the
// session that was just started and any others that may already be running.
if ( typeof renderSessionList === 'function' ) {
void renderSessionList ( ) ;
}
2026-03-31 03:40:19 +00:00
} catch ( e ) {
2026-04-15 05:10:50 +00:00
const errMsg = String ( ( e && e . message ) || '' ) ;
const conflictActiveStream = /session already has an active stream/i . test ( errMsg ) ;
if ( conflictActiveStream ) {
delete INFLIGHT [ activeSid ] ;
if ( typeof clearInflightState === 'function' ) clearInflightState ( activeSid ) ;
stopApprovalPolling ( ) ;
stopClarifyPolling ( ) ;
// Keep the user's attempted turn by queueing it for after the current run.
2026-05-01 05:23:47 +00:00
queueSessionMessage ( activeSid , { text : msgText , files : [ ] , model : S . session && S . session . model || ( $ ( 'modelSelect' ) && $ ( 'modelSelect' ) . value ) || '' , model _provider : S . session && S . session . model _provider || null , profile : S . activeProfile || 'default' } ) ;
2026-04-15 05:10:50 +00:00
updateQueueBadge ( activeSid ) ;
showToast ( 'Current session is still running. Reconnected and queued your message.' , 2600 ) ;
try {
await loadSession ( activeSid ) ;
setComposerStatus ( '' ) ;
return ;
} catch ( _ ) {
// Fall through to standard error handling if session reload fails.
}
}
2026-03-31 03:40:19 +00:00
delete INFLIGHT [ activeSid ] ;
stopApprovalPolling ( ) ;
2026-04-15 05:10:50 +00:00
stopClarifyPolling ( ) ;
2026-03-31 03:40:19 +00:00
// Only hide approval card if it belongs to the session that just finished
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons
* fix: approval pending check broken by stale has_pending import (#228)
api/routes.py imported has_pending/pop_pending from tools.approval, but the
agent module renamed has_pending to has_blocking_approval (checks gateway
queue, not _pending dict) and removed pop_pending. The import fell through
to fallback lambdas that always returned False, making GET /api/approval/pending
always return {pending:null} even after a successful inject_test.
Fix: check _pending directly under _lock — same dict submit_pending writes to.
Stale imports removed.
Before: 554 pass, 1 fail | After: 555 pass, 0 fail
* fix: move login JS into external file, remove inline handlers (#226)
Login page used inline onsubmit/onkeydown handlers and an inline <script>
block — all blocked by strict script-src CSP, causing silent login failure.
Fix: extract doLogin() and Enter key listener into static/login.js (served
from /static/, already a public path). Form uses id='login-form' and
data-* attributes for i18n strings instead of injected JS literals.
Also guards res.json() parse with try/catch so non-JSON error bodies
(e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'.
Fixes #222.
* fix: improve update error messages when pull fails (#227)
_apply_update_inner() ran git pull --ff-only and returned only raw stderr
on failure, making all failure modes indistinguishable.
Fix: explicit git fetch before pull; if fetch fails, returns human-readable
network error. Diverged history and missing upstream tracking branch each
get distinct messages with exact recovery commands. Generic fallback
truncates to 300 chars and shows sentinel when git produces no output.
Also adds tests/test_update_checker.py with 13 tests covering all 4 new
diagnostic code paths (0 tests existed before).
Fixes #223.
* fix: stabilize 30s terminal approval prompt visibility (#225)
Adds minimum 30-second visibility guard for the approval card using
_approvalVisibleSince, _approvalHideTimer, and a signature fingerprint
to deduplicate repeated poll ticks.
Fix: respondApproval() and all stream-end paths (done/cancel/apperror/
error/start-error) now call hideApprovalCard(true) so the card hides
immediately when the user responds or the session ends. The 30s guard
only applies to mid-session poll ticks where the approval is still live
but briefly absent.
Adds 11 structural tests covering the new timer variables, force
parameter, force-on-respond, force-on-stream-end, and poll-loop
no-force behavior.
* feat: replace emoji icons with self-hosted Lucide SVG icons (#221)
Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled
in static/icons.js (no CDN dependency). Adds li(name) function returning
inline SVG geometry from a hardcoded whitelist — unknown keys return '' so
dynamic server-supplied names never inject arbitrary SVG.
Changes:
- static/icons.js: new file with 21 icon paths + li() renderer
- static/index.html: all nav/action buttons now use li() icons
- static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons
- static/messages.js: cancelStream button uses SVG square stop icon
- .gitignore: adds node_modules/ entry
Verified: all 35 onclick= functions exist in JS, all 21 li() calls
reference defined icons, applyBotName() selectors intact, version
label present, no removed IDs referenced by JS.
* docs: v0.44.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
if ( ! _approvalSessionId || _approvalSessionId === activeSid ) hideApprovalCard ( true ) ; removeThinking ( ) ;
2026-04-28 06:57:58 +00:00
if ( ! _clarifySessionId || _clarifySessionId === activeSid ) hideClarifyCard ( true , 'terminal' ) ;
2026-04-15 05:10:50 +00:00
S . messages . push ( { role : 'assistant' , content : ` **Error:** ${ errMsg } ` } ) ;
2026-04-24 19:33:56 +00:00
_queueDrainSid = activeSid ; renderMessages ( ) ; setBusy ( false ) ; setComposerStatus ( ` Error: ${ errMsg } ` ) ;
2026-05-04 04:14:21 +00:00
if ( typeof clearOptimisticSessionStreaming === 'function' ) clearOptimisticSessionStreaming ( activeSid ) ;
// Reconcile with server truth after immediately clearing the optimistic spinner.
if ( typeof renderSessionList === 'function' ) void renderSessionList ( ) ;
2026-03-31 03:40:19 +00:00
return ;
}
// Open SSE stream and render tokens live
2026-04-25 06:09:44 +00:00
attachLiveStream ( activeSid , streamId , uploadedNames ) ;
2026-04-13 23:18:15 +00:00
}
const LIVE _STREAMS = { } ;
function closeLiveStream ( sessionId , streamId ) {
const live = LIVE _STREAMS [ sessionId ] ;
if ( ! live ) return ;
if ( streamId && live . streamId !== streamId ) return ;
try { live . source . close ( ) ; } catch ( _ ) { }
delete LIVE _STREAMS [ sessionId ] ;
}
function attachLiveStream ( activeSid , streamId , uploaded = [ ] , options = { } ) {
if ( ! activeSid || ! streamId ) return ;
const reconnecting = ! ! options . reconnecting ;
if ( ! INFLIGHT [ activeSid ] ) INFLIGHT [ activeSid ] = { messages : [ ... S . messages ] , uploaded : [ ... uploaded ] , toolCalls : [ ] } ;
else {
if ( uploaded . length ) INFLIGHT [ activeSid ] . uploaded = [ ... uploaded ] ;
if ( ! Array . isArray ( INFLIGHT [ activeSid ] . toolCalls ) ) INFLIGHT [ activeSid ] . toolCalls = [ ] ;
}
2026-05-02 11:12:26 +00:00
const existingLive = LIVE _STREAMS [ activeSid ] ;
if (
existingLive && existingLive . streamId === streamId && existingLive . source &&
2026-05-02 14:29:14 +00:00
// A same-stream transport can be reused unless the browser has already
// marked it closed; closed streams must still fall through to reopen.
2026-05-02 11:12:26 +00:00
( typeof EventSource === 'undefined' || existingLive . source . readyState !== EventSource . CLOSED )
) {
return ;
}
closeLiveStream ( activeSid ) ;
2026-04-13 23:18:15 +00:00
2026-03-31 03:40:19 +00:00
let assistantText = '' ;
2026-04-13 23:18:15 +00:00
let reasoningText = '' ;
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
let liveReasoningText = '' ;
2026-05-08 02:32:06 +00:00
let _latestGoalStatus = null ;
let _pendingGoalContinuation = null ;
2026-03-31 03:40:19 +00:00
let assistantRow = null ;
let assistantBody = null ;
2026-04-23 20:23:43 +00:00
let segmentStart = 0 ; // char offset in assistantText where current segment begins
let _freshSegment = false ; // true after a tool call — forces a new DOM segment
2026-04-23 23:09:08 +00:00
// streaming-markdown state: incremental DOM-building parser per segment
let _smdParser = null ; // current smd parser instance (null until first content)
let _smdWrittenLen = 0 ; // how many chars of displayText have been fed to smd parser
2026-04-24 18:04:32 +00:00
let _smdWrittenText = '' ; // exact displayText snapshot used for prefix-alignment checks
2026-04-23 23:09:08 +00:00
// On reconnect, the assistantBody already has partial smd-rendered content.
// We clear it on first new token and restart the parser from the reconnect point.
let _smdReconnect = reconnecting ;
2026-04-08 16:51:57 +00:00
// Thinking tag patterns for streaming display
const _thinkPairs = [
{ open : '<think>' , close : '</think>' } ,
2026-04-18 06:45:39 +00:00
{ open : '<|channel>thought\n' , close : '<channel|>' } ,
{ open : '<|turn|>thinking\n' , close : '<turn|>' } // Gemma 4
2026-04-08 16:51:57 +00:00
] ;
2026-03-31 03:40:19 +00:00
2026-04-13 23:18:15 +00:00
function _isActiveSession ( ) {
return ! ! ( S . session && S . session . session _id === activeSid ) ;
}
2026-05-06 21:56:17 +00:00
function _clearActivePaneInflightIfOwner ( ) {
if ( _isActiveSession ( ) ) clearInflight ( ) ;
}
function _approvalBelongsToOwner ( ) {
return _approvalSessionId === activeSid || ( ! _approvalSessionId && _isActiveSession ( ) ) ;
}
function _clarifyBelongsToOwner ( ) {
return _clarifySessionId === activeSid || ( ! _clarifySessionId && _isActiveSession ( ) ) ;
}
function _clearApprovalForOwner ( ) {
2026-05-07 15:14:30 +00:00
_clearApprovalPendingForSession ( activeSid ) ;
2026-05-06 21:56:17 +00:00
if ( ! _approvalBelongsToOwner ( ) ) return ;
stopApprovalPolling ( ) ;
hideApprovalCard ( true ) ;
}
function _clearClarifyForOwner ( reason ) {
2026-05-07 15:14:30 +00:00
_clearClarifyPendingForSession ( activeSid ) ;
2026-05-06 21:56:17 +00:00
if ( ! _clarifyBelongsToOwner ( ) ) return ;
stopClarifyPolling ( ) ;
hideClarifyCard ( true , reason || 'terminal' ) ;
}
function _clearOwnerInflightState ( ) {
delete INFLIGHT [ activeSid ] ;
clearInflightState ( activeSid ) ;
_clearActivePaneInflightIfOwner ( ) ;
}
function _setActivePaneIdleIfOwner ( ) {
2026-05-06 22:02:37 +00:00
if ( _isActiveSession ( ) || ! S . session || ! INFLIGHT [ S . session . session _id ] ) {
2026-05-06 21:56:17 +00:00
setBusy ( false ) ;
setComposerStatus ( '' ) ;
if ( typeof setStatus === 'function' ) setStatus ( '' ) ;
}
}
2026-04-13 23:25:31 +00:00
function persistInflightState ( ) {
const inflight = INFLIGHT [ activeSid ] ;
if ( ! inflight || typeof saveInflightState !== 'function' ) return ;
saveInflightState ( activeSid , {
streamId ,
messages : inflight . messages || [ ] ,
uploaded : inflight . uploaded || [ ... uploaded ] ,
toolCalls : inflight . toolCalls || [ ] ,
} ) ;
}
2026-04-24 19:33:16 +00:00
// Throttled variant for token-by-token updates. persistInflightState()
// calls saveInflightState() which does JSON.parse + JSON.stringify + write
// on the entire inflight map every call. On a fast model at 60 tok/s with
// a 10KB messages array this is ~36MB of JSON churn per second — a major
// GC pressure source that causes the renderer to crash under load.
// State transitions (tool events, done, error) still call persistInflightState()
// directly so no more than 2s of progress is lost on a crash.
let _persistTimer = null ;
function _throttledPersist ( ) {
if ( _persistTimer ) return ;
_persistTimer = setTimeout ( ( ) => { _persistTimer = null ; persistInflightState ( ) ; } , 2000 ) ;
}
2026-04-13 23:18:15 +00:00
function _closeSource ( ) {
closeLiveStream ( activeSid , streamId ) ;
}
function syncInflightAssistantMessage ( ) {
const inflight = INFLIGHT [ activeSid ] ;
if ( ! inflight ) return ;
if ( ! Array . isArray ( inflight . messages ) ) inflight . messages = [ ] ;
let assistantIdx = - 1 ;
for ( let i = inflight . messages . length - 1 ; i >= 0 ; i -- ) {
const msg = inflight . messages [ i ] ;
if ( msg && msg . role === 'assistant' && msg . _live ) { assistantIdx = i ; break ; }
}
const ts = Date . now ( ) / 1000 ;
if ( assistantIdx >= 0 ) {
inflight . messages [ assistantIdx ] . content = assistantText ;
inflight . messages [ assistantIdx ] . reasoning = reasoningText || undefined ;
inflight . messages [ assistantIdx ] . _ts = inflight . messages [ assistantIdx ] . _ts || ts ;
2026-04-24 19:33:16 +00:00
_throttledPersist ( ) ;
2026-04-13 23:18:15 +00:00
return ;
}
inflight . messages . push ( { role : 'assistant' , content : assistantText , reasoning : reasoningText || undefined , _live : true , _ts : ts } ) ;
2026-04-24 19:33:16 +00:00
_throttledPersist ( ) ;
2026-04-13 23:18:15 +00:00
}
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
function ensureAssistantRow ( force = false ) {
2026-04-13 23:18:15 +00:00
if ( ! _isActiveSession ( ) ) return ;
if ( assistantRow && ! assistantRow . isConnected ) { assistantRow = null ; assistantBody = null ; }
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
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 ;
2026-04-13 23:18:15 +00:00
if ( ! assistantRow ) {
2026-04-23 20:23:43 +00:00
// Only reuse an existing segment on the very first creation (e.g. reconnect).
// After a tool call _freshSegment=true, so we always create a new segment
// below the tool card rather than re-attaching to the old one above it.
if ( ! _freshSegment ) {
const existing = blocks . querySelector ( '[data-live-assistant="1"]' ) ;
if ( existing ) {
assistantRow = existing ;
assistantBody = existing . querySelector ( '.msg-body' ) ;
}
2026-04-13 23:18:15 +00:00
}
}
if ( assistantRow ) {
if ( typeof placeLiveToolCardsHost === 'function' ) placeLiveToolCardsHost ( ) ;
return ;
}
2026-03-31 03:40:19 +00:00
const tr = $ ( 'toolRunningRow' ) ; if ( tr ) tr . remove ( ) ;
$ ( 'emptyState' ) . style . display = 'none' ;
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
assistantRow = document . createElement ( 'div' ) ;
assistantRow . className = 'assistant-segment' ;
assistantRow . setAttribute ( 'data-live-assistant' , '1' ) ;
2026-03-31 03:40:19 +00:00
assistantBody = document . createElement ( 'div' ) ; assistantBody . className = 'msg-body' ;
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
assistantRow . appendChild ( assistantBody ) ;
blocks . appendChild ( assistantRow ) ;
2026-04-23 20:23:43 +00:00
_freshSegment = false ; // consumed — next reuse check is normal again
2026-03-31 03:40:19 +00:00
}
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
// ── Shared SSE handler wiring (used for initial connection and reconnect) ──
let _reconnectAttempted = false ;
2026-04-16 03:25:31 +00:00
let _terminalStateReached = false ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
2026-04-22 01:47:40 +00:00
// Bug A fix (#631): track whether the stream has been finalized so any rAF
// scheduled by a trailing 'token'/'reasoning' event that arrives in the same
// microtask batch as 'done' does not fire after renderMessages() has already
// settled the DOM — which was causing the thinking card to reappear below
// the final answer or the response to render twice.
let _streamFinalized = false ;
let _pendingRafHandle = null ;
2026-04-04 21:05:51 +00:00
// rAF-throttled rendering: buffer tokens, render at most once per frame
let _renderPending = false ;
2026-04-08 16:51:57 +00:00
// Extract display text from assistantText, stripping completed thinking blocks
// and hiding content still inside an open thinking block.
2026-04-19 05:40:37 +00:00
function _stripXmlToolCalls ( s ) {
// Strip <function_calls>...</function_calls> blocks (DeepSeek XML tool syntax).
// These are processed as tool calls server-side; showing them raw in the bubble
// looks broken. Also handles orphaned opening tags mid-stream. (#702)
2026-04-24 18:04:16 +00:00
// Also handles DSML-prefixed variants from DeepSeek/Bedrock, including
// spacing variants like "<| DSML |function_calls" and truncated prefixes.
if ( ! s ) return s ;
const lo = String ( s ) . toLowerCase ( ) ;
if ( lo . indexOf ( 'function_calls' ) === - 1 && lo . indexOf ( 'dsml' ) === - 1 ) return s ;
// Support both plain <function_calls> and DSML-prefixed variants.
s = s . replace ( /<(?:\s*| \s*DSML\s*[| |]\s*)?function_calls>[\s\S]*?<\/(?:\s*| \s*DSML\s*[| |]\s*)?function_calls>/gi , '' ) ;
// Also remove truncated opening tags (missing closing ">" at stream tail).
s = s . replace ( /<(?:\s*| \s*DSML\s*[| |]\s*)?function_calls(?:>|$)[\s\S]*$/i , '' ) ;
// Remove malformed DSML tag fragments like "<| DSML |" that can leak in tokens.
s = s . replace ( /<\s*| \s*DSML\s*[| |]\s*/gi , '' ) ;
2026-04-19 05:40:37 +00:00
return s . trim ( ) ;
}
2026-04-08 16:51:57 +00:00
function _streamDisplay ( ) {
2026-04-19 05:40:37 +00:00
const raw = _stripXmlToolCalls ( assistantText ) ;
2026-04-22 20:21:42 +00:00
// Always run think-block stripping even when reasoningText is populated.
// Some providers emit reasoning content via on_reasoning AND wrap it in
// <think> tags in the token stream — the early-return caused the thinking
// card and main response to show identical content (closes #852).
2026-04-08 16:51:57 +00:00
for ( const { open , close } of _thinkPairs ) {
2026-04-12 21:07:00 +00:00
// Trim leading whitespace before checking for the open tag — some models
// (e.g. MiniMax) emit newlines before <think>.
const trimmed = raw . trimStart ( ) ;
if ( trimmed . startsWith ( open ) ) {
const ci = trimmed . indexOf ( close , open . length ) ;
2026-04-08 18:14:47 +00:00
if ( ci !== - 1 ) {
// Thinking block complete — strip it, show the rest
2026-04-12 21:07:00 +00:00
return trimmed . slice ( ci + close . length ) . replace ( /^\s+/ , '' ) ;
2026-04-08 18:14:47 +00:00
}
// Still inside thinking block — show placeholder
return '' ;
2026-04-08 16:51:57 +00:00
}
2026-04-08 18:14:47 +00:00
// Hide partial tag prefixes while streaming so users don't see
// `<thi`, `<think`, etc. before the model finishes the token.
2026-04-12 21:07:00 +00:00
if ( open . startsWith ( trimmed ) ) return '' ;
2026-04-08 16:51:57 +00:00
}
2026-04-08 18:14:47 +00:00
return raw ;
2026-04-08 16:51:57 +00:00
}
2026-04-13 23:18:15 +00:00
function _parseStreamState ( ) {
2026-04-19 05:40:37 +00:00
const raw = _stripXmlToolCalls ( assistantText ) ;
2026-04-13 23:18:15 +00:00
if ( reasoningText ) {
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
return { thinkingText : liveReasoningText , displayText : _streamDisplay ( ) , inThinking : false } ;
2026-04-13 23:18:15 +00:00
}
for ( const { open , close } of _thinkPairs ) {
const trimmed = raw . trimStart ( ) ;
if ( trimmed . startsWith ( open ) ) {
const ci = trimmed . indexOf ( close , open . length ) ;
if ( ci !== - 1 ) {
return {
thinkingText : trimmed . slice ( open . length , ci ) . trim ( ) ,
displayText : trimmed . slice ( ci + close . length ) . replace ( /^\s+/ , '' ) ,
inThinking : false ,
} ;
}
return {
thinkingText : trimmed . slice ( open . length ) . trim ( ) ,
displayText : '' ,
inThinking : true ,
} ;
}
if ( open . startsWith ( trimmed ) ) {
return { thinkingText : '' , displayText : '' , inThinking : true } ;
}
}
return { thinkingText : '' , displayText : raw , inThinking : false } ;
}
function _renderLiveThinking ( parsed ) {
2026-04-21 22:26:52 +00:00
if ( window . _showThinking === false ) { removeThinking ( ) ; return ; }
2026-04-13 23:18:15 +00:00
const text = ( parsed && parsed . thinkingText ) || '' ;
if ( text || ( parsed && parsed . inThinking ) ) {
if ( typeof updateThinking === 'function' ) updateThinking ( text || 'Thinking…' ) ;
else appendThinking ( ) ;
return ;
}
2026-04-23 20:23:43 +00:00
// Only remove thinking if we're not in an active reasoning phase.
// When reasoningText is set but liveReasoningText was just reset (post-tool),
// don't wipe the finalized thinking card — it has no id anymore so
// removeThinking() won't find it anyway, but guard explicitly.
if ( ! reasoningText ) removeThinking ( ) ;
2026-04-13 23:18:15 +00:00
}
2026-04-23 23:09:08 +00:00
// Helper: create (or recreate) the smd parser bound to a given DOM element.
// Called when assistantBody is first created and after each tool-call segment reset.
function _smdNewParser ( el ) {
_smdWrittenLen = 0 ;
2026-04-24 18:04:32 +00:00
_smdWrittenText = '' ;
2026-04-23 23:09:08 +00:00
if ( ! window . smd ) { _smdParser = null ; return ; }
const renderer = window . smd . default _renderer ( el ) ;
_smdParser = window . smd . parser ( renderer ) ;
}
// Helper: end the current smd parser (flushes remaining state) and null it out.
function _smdEndParser ( ) {
if ( _smdParser && window . smd ) {
try { window . smd . parser _end ( _smdParser ) ; } catch ( _ ) { }
fix(smd): strip javascript:/data:/vbscript: URLs — smd does not sanitize schemes
streaming-markdown@0.2.15 preserves arbitrary URL schemes in href/src.
Verified with a Node + jsdom harness:
IN : [click](javascript:alert(1))
OUT: <p><a href="javascript:alert(1">click</a>)</p> ← XSS vector
Confirmed unsafe for: javascript:, vbscript:, data:text/html, file://.
The library uses only safe DOM primitives (createElement/appendChild/
createTextNode — no innerHTML/eval), so <script> tags are escaped as
text, but URL-scheme filtering is absent. The existing renderMd() path
implicitly filtered to http(s) via its regex, so this is a regression
the moment streaming markdown is enabled.
Attack path: agent echoes prompt-injection content containing a
markdown link with javascript: href → smd renders it live → user clicks
during the streaming window → JS executes in webui origin → session
cookie, API calls, etc.
Fix: walk the live DOM after each parser_write (and again after
parser_end) and remove href/src attributes whose scheme isn't on the
safe allowlist (http, https, mailto, tel, and relative/anchor paths).
Blocked anchors keep their text content but lose href; blocked images
lose src and get data-blocked-scheme="1" for debugging.
Harness confirms all 10 tested cases behave correctly — javascript:,
vbscript:, data:text/html, file:// all stripped; https://, /path,
#anchor, mailto:, tel: all preserved.
Added 5 regression tests in TestSmdUrlSchemeSanitization that lock:
- the sanitize helper exists
- the allowlist regex permits https? and forbids javascript/vbscript/data:
- _smdWrite invokes sanitize after parser_write
- _smdEndParser invokes sanitize after parser_end
- the sanitizer covers both <a href> and <img src>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:28:40 +00:00
// parser_end may flush remaining markdown that creates new links/images —
// re-sanitize the body before the DOM is handed off to highlightCode / renderMessages.
if ( assistantBody ) { _sanitizeSmdLinks ( assistantBody ) ; }
2026-04-23 23:09:08 +00:00
}
_smdParser = null ;
_smdWrittenLen = 0 ;
2026-04-24 18:04:32 +00:00
_smdWrittenText = '' ;
2026-04-23 23:09:08 +00:00
}
// Helper: feed new displayText delta to the smd parser.
// Only feeds chars beyond what has already been written (_smdWrittenLen).
function _smdWrite ( displayText ) {
if ( ! _smdParser || ! window . smd ) return ;
2026-04-24 18:04:32 +00:00
displayText = String ( displayText || '' ) ;
// Self-heal desyncs: if displayText no longer starts with what we've already
// written (e.g. due to stream sanitization/tag stripping), incremental slicing
// can skip characters. Rebuild parser from the full current displayText.
if ( _smdWrittenText && ! displayText . startsWith ( _smdWrittenText ) ) {
_smdParser = null ;
_smdWrittenLen = 0 ;
_smdWrittenText = '' ;
if ( assistantBody ) assistantBody . innerHTML = '' ;
_smdNewParser ( assistantBody ) ;
if ( ! _smdParser ) return ;
}
const delta = displayText . slice ( _smdWrittenText . length ) ;
2026-04-23 23:09:08 +00:00
if ( ! delta ) return ;
try { window . smd . parser _write ( _smdParser , delta ) ; } catch ( _ ) { }
_smdWrittenLen = displayText . length ;
2026-04-24 18:04:32 +00:00
_smdWrittenText = displayText ;
fix(smd): strip javascript:/data:/vbscript: URLs — smd does not sanitize schemes
streaming-markdown@0.2.15 preserves arbitrary URL schemes in href/src.
Verified with a Node + jsdom harness:
IN : [click](javascript:alert(1))
OUT: <p><a href="javascript:alert(1">click</a>)</p> ← XSS vector
Confirmed unsafe for: javascript:, vbscript:, data:text/html, file://.
The library uses only safe DOM primitives (createElement/appendChild/
createTextNode — no innerHTML/eval), so <script> tags are escaped as
text, but URL-scheme filtering is absent. The existing renderMd() path
implicitly filtered to http(s) via its regex, so this is a regression
the moment streaming markdown is enabled.
Attack path: agent echoes prompt-injection content containing a
markdown link with javascript: href → smd renders it live → user clicks
during the streaming window → JS executes in webui origin → session
cookie, API calls, etc.
Fix: walk the live DOM after each parser_write (and again after
parser_end) and remove href/src attributes whose scheme isn't on the
safe allowlist (http, https, mailto, tel, and relative/anchor paths).
Blocked anchors keep their text content but lose href; blocked images
lose src and get data-blocked-scheme="1" for debugging.
Harness confirms all 10 tested cases behave correctly — javascript:,
vbscript:, data:text/html, file:// all stripped; https://, /path,
#anchor, mailto:, tel: all preserved.
Added 5 regression tests in TestSmdUrlSchemeSanitization that lock:
- the sanitize helper exists
- the allowlist regex permits https? and forbids javascript/vbscript/data:
- _smdWrite invokes sanitize after parser_write
- _smdEndParser invokes sanitize after parser_end
- the sanitizer covers both <a href> and <img src>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:28:40 +00:00
// streaming-markdown does NOT sanitize URL schemes — `[click](javascript:...)`
// and `` survive as href/src. Strip any unsafe schemes
// from anchors/images that were just added to the live DOM. The existing
// renderMd() path filters these via its http(s)-only regex; we need a matching
// guard here so the live-stream path isn't an XSS vector for agent-echoed
// prompt-injection content. The final renderMessages() call at `done` uses
// renderMd which is already safe, but during streaming the user could click
// a malicious link before that replacement happens.
if ( assistantBody ) { _sanitizeSmdLinks ( assistantBody ) ; }
}
// Allowed URL schemes for anchors and images rendered from agent-streamed markdown.
// Matches the effective allowlist of renderMd() (http/https via regex + relative).
const _SMD _SAFE _URL _RE = /^(?:https?:|mailto:|tel:|\/|#|\?|\.)/i ;
function _sanitizeSmdLinks ( root ) {
if ( ! root || ! root . querySelectorAll ) return ;
const _a = root . querySelectorAll ( 'a[href]' ) ;
for ( let i = 0 ; i < _a . length ; i ++ ) {
const n = _a [ i ] , v = n . getAttribute ( 'href' ) || '' ;
if ( ! _SMD _SAFE _URL _RE . test ( v ) ) { n . removeAttribute ( 'href' ) ; n . setAttribute ( 'data-blocked-scheme' , '1' ) ; }
}
const _im = root . querySelectorAll ( 'img[src]' ) ;
for ( let i = 0 ; i < _im . length ; i ++ ) {
const n = _im [ i ] , v = n . getAttribute ( 'src' ) || '' ;
if ( ! _SMD _SAFE _URL _RE . test ( v ) ) { n . removeAttribute ( 'src' ) ; n . setAttribute ( 'data-blocked-scheme' , '1' ) ; }
}
2026-04-23 23:09:08 +00:00
}
2026-05-08 02:28:45 +00:00
function _resetAssistantSegment ( ) {
assistantRow = null ;
assistantBody = null ;
segmentStart = assistantText . length ;
_freshSegment = true ;
_smdEndParser ( ) ;
}
2026-04-24 18:44:47 +00:00
let _lastRenderMs = 0 ;
2026-04-04 21:05:51 +00:00
function _scheduleRender ( ) {
if ( _renderPending ) return ;
2026-04-22 01:47:40 +00:00
if ( _streamFinalized ) return ; // Bug A: don't schedule new rAF after stream finalized
2026-04-04 21:05:51 +00:00
_renderPending = true ;
2026-04-24 18:44:47 +00:00
// Cap render rate to ~15fps. The browser's rAF fires at 60fps, but each DOM
// update takes 50-150ms on large sessions. During GC pauses, rAF callbacks
// accumulate and then execute all at once, blocking the main thread for
// multi-second stretches and crashing the renderer (Chrome error code 4/5).
// Throttling to 66ms intervals prevents this pileup without noticeable
// visual degradation — streaming text updates still feel immediate.
// performance.now() is monotonic so tab suspend/resume and NTP adjustments
// can't produce negative or enormous deltas.
const sinceLastMs = performance . now ( ) - _lastRenderMs ;
const _doRender = ( ) => {
2026-04-22 01:47:40 +00:00
_pendingRafHandle = null ;
2026-04-04 21:05:51 +00:00
_renderPending = false ;
2026-04-24 18:44:47 +00:00
// Guard: a pending setTimeout+rAF can outlive stream finalization.
if ( _streamFinalized ) return ;
_lastRenderMs = performance . now ( ) ;
2026-04-13 23:18:15 +00:00
const parsed = _parseStreamState ( ) ;
_renderLiveThinking ( parsed ) ;
2026-04-08 16:51:57 +00:00
if ( assistantBody ) {
2026-04-23 23:09:08 +00:00
const displayText = segmentStart === 0
? parsed . displayText // first segment: uses think-tag stripping
: _stripXmlToolCalls ( assistantText . slice ( segmentStart ) ) ;
if ( ! _smdParser && window . smd ) {
// On reconnect: prior content in assistantBody came from a different smd parser run.
// Clear it and start fresh — renderMessages() on done will restore the full content.
if ( _smdReconnect ) { assistantBody . innerHTML = '' ; _smdReconnect = false ; }
_smdNewParser ( assistantBody ) ;
}
if ( _smdParser ) {
_smdWrite ( displayText ) ;
} else {
// Fallback: smd not loaded yet, reconnect session, or smd unavailable — use renderMd
2026-05-04 06:55:45 +00:00
// for every live segment. Without this, the first segment inserts raw
// parsed.displayText and users see unformatted markdown until done.
const fallbackText = segmentStart === 0
2026-04-23 23:09:08 +00:00
? parsed . displayText
2026-05-04 06:55:45 +00:00
: _stripXmlToolCalls ( assistantText . slice ( segmentStart ) ) ;
assistantBody . innerHTML = renderMd ? renderMd ( fallbackText ) : esc ( fallbackText ) ;
2026-04-23 23:09:08 +00:00
}
2026-04-08 16:51:57 +00:00
}
2026-04-04 21:05:51 +00:00
scrollIfPinned ( ) ;
2026-04-24 18:44:47 +00:00
} ;
if ( sinceLastMs >= 66 ) {
_pendingRafHandle = requestAnimationFrame ( _doRender ) ;
} else {
_pendingRafHandle = setTimeout ( ( ) => requestAnimationFrame ( _doRender ) , 66 - sinceLastMs ) ;
}
2026-04-04 21:05:51 +00:00
}
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
function _wireSSE ( source ) {
2026-04-22 01:47:40 +00:00
// Note on #631 Bug B: the original PR description stated the server
// "replays buffered token events" on reconnect, and proposed resetting
// the accumulators here so the re-sent tokens wouldn't double the prefix.
// That is NOT how the server actually works — api/routes._handle_sse_stream
// reads a one-shot queue.Queue() that delivers each event to exactly one
// consumer; a reconnect picks up from the current queue position and gets
// only events produced during the outage. Resetting the accumulators here
// would wipe the already-displayed content and restart the response from
// the first post-reconnect token — a real data-loss regression.
//
// The "doubled response" / "stuck cursor" symptom is fully explained by
// Bug A (trailing rAF after `done` inserting a new live-turn wrapper) —
// the fixes below (_streamFinalized guard + cancelAnimationFrame in the
// terminal handlers) address it without needing a reset here.
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
source . addEventListener ( 'token' , e => {
if ( ! S . session || S . session . session _id !== activeSid ) return ;
const d = JSON . parse ( e . data ) ;
assistantText += d . text ;
2026-04-13 23:18:15 +00:00
syncInflightAssistantMessage ( ) ;
if ( ! S . session || S . session . session _id !== activeSid ) return ;
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
const parsed = _parseStreamState ( ) ;
if ( String ( ( parsed && parsed . displayText ) || '' ) . trim ( ) || assistantRow ) ensureAssistantRow ( ) ;
2026-04-04 21:05:51 +00:00
_scheduleRender ( ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
} ) ;
2026-05-08 02:28:45 +00:00
source . addEventListener ( 'interim_assistant' , e => {
if ( ! S . session || S . session . session _id !== activeSid ) return ;
const d = JSON . parse ( e . data ) ;
const visible = String ( d && d . text ? d . text : '' ) . trim ( ) ;
const alreadyStreamed = ! ! ( d && d . already _streamed ) ;
if ( ! visible ) {
return ;
}
if ( alreadyStreamed ) {
_resetAssistantSegment ( ) ;
return ;
}
assistantText += visible ;
syncInflightAssistantMessage ( ) ;
if ( ! S . session || S . session . session _id !== activeSid ) return ;
const parsed = _parseStreamState ( ) ;
if ( String ( ( parsed && parsed . displayText ) || '' ) . trim ( ) || assistantRow ) ensureAssistantRow ( ) ;
_scheduleRender ( ) ;
} ) ;
2026-04-13 23:18:15 +00:00
source . addEventListener ( 'reasoning' , e => {
const d = JSON . parse ( e . data ) ;
reasoningText += d . text || '' ;
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
liveReasoningText += d . text || '' ;
2026-04-13 23:18:15 +00:00
syncInflightAssistantMessage ( ) ;
if ( ! S . session || S . session . session _id !== activeSid ) return ;
2026-04-23 20:23:43 +00:00
// Render thinking card synchronously — not via rAF — so the DOM is
// up-to-date before a 'tool' event in the same microtask batch calls
// finalizeThinkingCard(). The old rAF-only path caused a race where
// the thinking row was still a spinner when finalized.
if ( window . _showThinking !== false ) {
if ( typeof updateThinking === 'function' ) updateThinking ( liveReasoningText || 'Thinking…' ) ;
else appendThinking ( liveReasoningText ) ;
}
2026-04-13 23:18:15 +00:00
_scheduleRender ( ) ;
} ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
source . addEventListener ( 'tool' , e => {
const d = JSON . parse ( e . data ) ;
2026-04-15 05:10:50 +00:00
if ( d . name === 'clarify' ) return ;
2026-04-13 23:18:15 +00:00
const tc = { name : d . name , preview : d . preview || '' , args : d . args || { } , snippet : '' , done : false , tid : d . tid || ` live- ${ Date . now ( ) } - ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` } ;
2026-04-15 14:50:59 +00:00
const inflight = INFLIGHT [ activeSid ] || ( INFLIGHT [ activeSid ] = {
messages : [ ... S . messages ] ,
uploaded : [ ] ,
toolCalls : [ ]
} ) ;
if ( ! Array . isArray ( inflight . toolCalls ) ) inflight . toolCalls = [ ] ;
2026-04-15 15:49:07 +00:00
INFLIGHT [ activeSid ] . toolCalls . push ( tc ) ;
S . toolCalls = INFLIGHT [ activeSid ] . toolCalls ;
2026-04-13 23:25:31 +00:00
persistInflightState ( ) ;
2026-04-13 23:18:15 +00:00
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
if ( ! S . session || S . session . session _id !== activeSid ) return ;
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
// 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 = '' ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
const oldRow = $ ( 'toolRunningRow' ) ; if ( oldRow ) oldRow . remove ( ) ;
2026-04-13 23:18:15 +00:00
appendLiveToolCard ( tc ) ;
2026-04-23 20:23:43 +00:00
// Reset the live assistant row reference so that any text tokens arriving
// after this tool call create a NEW segment appended below the tool card,
// rather than updating the old segment that sits above it in the DOM.
2026-05-08 02:38:29 +00:00
_freshSegment = true ;
_smdEndParser ( ) ;
2026-05-08 02:28:45 +00:00
_resetAssistantSegment ( ) ;
2026-04-13 23:18:15 +00:00
scrollIfPinned ( ) ;
} ) ;
source . addEventListener ( 'tool_complete' , e => {
const d = JSON . parse ( e . data ) ;
2026-04-15 05:10:50 +00:00
if ( d . name === 'clarify' ) return ;
2026-04-13 23:18:15 +00:00
const inflight = INFLIGHT [ activeSid ] ;
if ( ! inflight ) return ;
if ( ! Array . isArray ( inflight . toolCalls ) ) inflight . toolCalls = [ ] ;
let tc = null ;
for ( let i = inflight . toolCalls . length - 1 ; i >= 0 ; i -- ) {
const cur = inflight . toolCalls [ i ] ;
if ( cur && cur . done === false && ( ! d . name || cur . name === d . name ) ) {
tc = cur ;
break ;
}
}
if ( ! tc ) {
tc = { name : d . name || 'tool' , preview : d . preview || '' , args : d . args || { } , snippet : '' , done : true } ;
inflight . toolCalls . push ( tc ) ;
}
tc . preview = d . preview || tc . preview || '' ;
tc . args = d . args || tc . args || { } ;
tc . done = true ;
tc . is _error = ! ! d . is _error ;
if ( d . duration !== undefined ) tc . duration = d . duration ;
S . toolCalls = inflight . toolCalls ;
2026-04-13 23:25:31 +00:00
persistInflightState ( ) ;
2026-04-13 23:18:15 +00:00
if ( ! S . session || S . session . session _id !== activeSid ) return ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
appendLiveToolCard ( tc ) ;
scrollIfPinned ( ) ;
} ) ;
source . addEventListener ( 'approval' , e => {
const d = JSON . parse ( e . data ) ;
2026-05-07 15:14:30 +00:00
showApprovalForSession ( activeSid , d , 1 ) ;
2026-04-08 16:02:02 +00:00
playNotificationSound ( ) ;
sendBrowserNotification ( 'Approval required' , d . description || 'Tool approval needed' ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
} ) ;
2026-04-15 05:10:50 +00:00
source . addEventListener ( 'clarify' , e => {
const d = JSON . parse ( e . data ) ;
2026-05-07 15:14:30 +00:00
showClarifyForSession ( activeSid , d ) ;
2026-04-15 05:10:50 +00:00
playNotificationSound ( ) ;
sendBrowserNotification ( 'Clarification needed' , d . question || 'Tool clarification needed' ) ;
} ) ;
2026-04-15 14:50:59 +00:00
source . addEventListener ( 'title' , e => {
let d = { } ;
try { d = JSON . parse ( e . data || '{}' ) ; } catch ( _ ) { }
if ( ( d . session _id || activeSid ) !== activeSid ) return ;
const newTitle = String ( d . title || '' ) . trim ( ) ;
if ( ! newTitle ) return ;
if ( S . session && S . session . session _id === activeSid ) {
S . session . title = newTitle ;
syncTopbar ( ) ;
}
if ( typeof _allSessions !== 'undefined' && Array . isArray ( _allSessions ) ) {
const row = _allSessions . find ( s => s && s . session _id === activeSid ) ;
if ( row ) row . title = newTitle ;
}
if ( typeof renderSessionListFromCache === 'function' ) renderSessionListFromCache ( ) ;
else if ( typeof renderSessionList === 'function' ) renderSessionList ( ) ;
} ) ;
source . addEventListener ( 'title_status' , e => {
let d = { } ;
try { d = JSON . parse ( e . data || '{}' ) ; } catch ( _ ) { }
if ( ( d . session _id || activeSid ) !== activeSid ) return ;
try {
console . info ( '[title]' , {
status : String ( d . status || '' ) ,
reason : String ( d . reason || '' ) ,
title : String ( d . title || '' ) ,
raw _preview : String ( d . raw _preview || '' ) ,
session _id : String ( d . session _id || activeSid )
} ) ;
} catch ( _ ) { }
} ) ;
2026-05-10 07:21:24 +00:00
function _resolveGoalMessage ( d ) {
const key = String ( d && d . message _key ? d . message _key : '' ) . trim ( ) ;
const args = Array . isArray ( d && d . message _args ) ? d . message _args : [ ] ;
const raw = String ( d && d . message || '' ) . trim ( ) ;
if ( key && typeof t === 'function' ) {
try {
const translated = String ( t ( key , ... args ) ) ;
if ( translated && translated !== key ) return translated ;
} catch ( _ ) { }
}
return raw ;
}
2026-05-08 02:32:06 +00:00
source . addEventListener ( 'goal' , e => {
try {
const d = JSON . parse ( e . data || '{}' ) ;
if ( ( d . session _id || activeSid ) !== activeSid ) return ;
2026-05-08 15:20:24 +00:00
const goalState = String ( d . state || '' ) . trim ( ) ;
2026-05-10 07:21:24 +00:00
const goalEvaluatingMessage = t ( 'goal_evaluating_progress' ) ;
2026-05-08 15:20:24 +00:00
if ( goalState === 'evaluating' ) {
setComposerStatus ( goalEvaluatingMessage ) ;
return ;
}
2026-05-10 07:21:24 +00:00
const msg = _resolveGoalMessage ( d ) ;
2026-05-08 02:32:06 +00:00
if ( ! msg ) return ;
2026-05-08 15:20:24 +00:00
_latestGoalStatus = { message : msg , decision : d . decision || null , state : goalState || null } ;
2026-05-08 02:32:06 +00:00
setComposerStatus ( msg ) ;
showToast ( msg . split ( '\n' ) [ 0 ] , 2600 ) ;
} catch ( _ ) { }
} ) ;
source . addEventListener ( 'goal_continue' , e => {
try {
const d = JSON . parse ( e . data || '{}' ) ;
const sid = d . session _id || activeSid ;
const continuation _prompt = String ( d . continuation _prompt || d . text || '' ) . trim ( ) ;
if ( ! continuation _prompt || sid !== activeSid ) return ;
_pendingGoalContinuation = {
sid ,
text : continuation _prompt ,
model : S . session && S . session . model || '' ,
model _provider : S . session && S . session . model _provider || null ,
profile : S . activeProfile || 'default' ,
} ;
2026-05-10 07:21:24 +00:00
const toast = t ( 'goal_continuing_toast' ) ;
const cmsg = _resolveGoalMessage ( d ) ;
showToast ( ( toast && cmsg && cmsg !== toast ) ? cmsg . split ( '\n' ) [ 0 ] : toast , 2200 ) ;
2026-05-08 02:32:06 +00:00
} catch ( _ ) { }
} ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
source . addEventListener ( 'done' , e => {
2026-04-16 03:25:31 +00:00
_terminalStateReached = true ;
2026-04-24 19:33:16 +00:00
if ( _persistTimer ) { clearTimeout ( _persistTimer ) ; _persistTimer = null ; }
2026-04-22 01:47:40 +00:00
// Bug A fix: cancel any pending rAF and mark stream finalized before
// the DOM is settled by renderMessages, so no trailing token/reasoning rAF
// can reintroduce a stale thinking card or duplicate content.
_streamFinalized = true ;
2026-04-24 18:57:48 +00:00
if ( _pendingRafHandle !== null ) { cancelAnimationFrame ( _pendingRafHandle ) ; clearTimeout ( _pendingRafHandle ) ; _pendingRafHandle = null ; _renderPending = false ; }
2026-04-22 01:47:40 +00:00
if ( typeof finalizeThinkingCard === 'function' ) finalizeThinkingCard ( ) ;
2026-04-23 23:09:08 +00:00
// Finalize smd parser — flushes any remaining buffered markdown state
// and runs Prism + copy buttons on the live segment before the DOM is replaced
if ( assistantBody ) {
const _finBody = assistantBody ;
_smdEndParser ( ) ;
requestAnimationFrame ( ( ) => {
if ( typeof highlightCode === 'function' ) highlightCode ( _finBody ) ;
if ( typeof addCopyButtons === 'function' ) addCopyButtons ( _finBody ) ;
if ( typeof renderKatexBlocks === 'function' ) renderKatexBlocks ( ) ;
} ) ;
} else {
_smdEndParser ( ) ;
}
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
const d = JSON . parse ( e . data ) ;
2026-04-28 04:55:45 +00:00
const isActiveSession = _isSessionCurrentPane ( activeSid ) ;
2026-04-28 03:20:05 +00:00
const isSessionViewed = _isSessionActivelyViewed ( activeSid ) ;
2026-04-28 09:23:24 +00:00
const completedSession = d . session || { session _id : activeSid } ;
const completedSid = completedSession . session _id || activeSid ;
2026-04-28 03:20:05 +00:00
if ( ! isSessionViewed && typeof _markSessionCompletionUnread === 'function' ) {
2026-04-28 09:23:24 +00:00
_markSessionCompletionUnread ( completedSid , completedSession . message _count ) ;
2026-04-28 03:20:05 +00:00
}
2026-05-06 21:56:17 +00:00
_clearOwnerInflightState ( ) ;
2026-04-28 09:23:24 +00:00
if ( typeof _markSessionCompletedInList === 'function' ) {
_markSessionCompletedInList ( completedSession , activeSid ) ;
}
2026-05-06 21:56:17 +00:00
_clearApprovalForOwner ( ) ;
_clearClarifyForOwner ( 'terminal' ) ;
2026-05-08 16:11:49 +00:00
const shouldFollowOnDone = isActiveSession && ( ( typeof _shouldFollowMessagesOnDomReplace === 'function' )
? _shouldFollowMessagesOnDomReplace ( )
: ( typeof _isMessagePaneNearBottom === 'function' && _isMessagePaneNearBottom ( 1200 ) ) ) ;
2026-04-28 04:55:45 +00:00
if ( isActiveSession ) {
2026-03-31 03:40:19 +00:00
S . activeStreamId = null ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
}
2026-04-28 04:55:45 +00:00
if ( isActiveSession ) {
fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Merged as v0.50.227. 2634 tests passing, browser QA 21/21 (desktop + mobile). Full attribution below.
Thanks to all 12 contributors:
@jundev0001 (#1138), @franksong2702 (#1142, #1157, #1162), @dso2ng (#1143), @bergeouss (#1145, #1146, #1156, #1159), @jasonjcwu (#1149), @ccqqlo (#1161), @frap129 (#1165)
Two fixes applied during integration and two more by the independent reviewer (@nesquena):
- messages.js: per-turn cost delta capture order (#1159)
- workspace.py: symlink target blocked-roots check + HOME sanity guard (#1149, #1165)
- panels.js: cron unread counter bookkeeping (in-loop increment bug)
- tests/test_symlink_cycle_detection.py: register workspace before session/new
2026-04-27 20:34:59 +00:00
// Capture previous session totals BEFORE overwriting S.session with the new
// cumulative values from the done event. prevIn/prevOut are the totals as of
// the start of this turn; curIn/curOut are the full post-turn totals — the
// delta is the per-turn usage for #1159.
const _prevIn = ( S . session && S . session . input _tokens ) || 0 ;
const _prevOut = ( S . session && S . session . output _tokens ) || 0 ;
const _prevCost = ( S . session && S . session . estimated _cost ) || 0 ;
2026-04-27 23:27:03 +00:00
S . session = d . session ; S . messages = d . session . messages || [ ] ; if ( typeof _messagesTruncated !== 'undefined' ) _messagesTruncated = ! ! d . session . _messages _truncated ;
2026-05-01 08:36:53 +00:00
if ( S . session && S . session . session _id ) {
localStorage . setItem ( 'hermes-webui-session' , S . session . session _id ) ;
if ( typeof _setActiveSessionUrl === 'function' ) _setActiveSessionUrl ( S . session . session _id ) ;
}
fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Merged as v0.50.227. 2634 tests passing, browser QA 21/21 (desktop + mobile). Full attribution below.
Thanks to all 12 contributors:
@jundev0001 (#1138), @franksong2702 (#1142, #1157, #1162), @dso2ng (#1143), @bergeouss (#1145, #1146, #1156, #1159), @jasonjcwu (#1149), @ccqqlo (#1161), @frap129 (#1165)
Two fixes applied during integration and two more by the independent reviewer (@nesquena):
- messages.js: per-turn cost delta capture order (#1159)
- workspace.py: symlink target blocked-roots check + HOME sanity guard (#1149, #1165)
- panels.js: cron unread counter bookkeeping (in-loop increment bug)
- tests/test_symlink_cycle_detection.py: register workspace before session/new
2026-04-27 20:34:59 +00:00
if (
window . _compressionUi && window . _compressionUi . automatic &&
window . _compressionUi . sessionId === activeSid &&
d . session && d . session . session _id
) {
window . _compressionUi = { ... window . _compressionUi , sessionId : d . session . session _id } ;
}
2026-04-14 20:56:54 +00:00
// Find the last assistant message once for both reasoning persistence and timestamp
const lastAsst = [ ... S . messages ] . reverse ( ) . find ( m => m . role === 'assistant' ) ;
2026-04-14 20:56:53 +00:00
// Persist reasoning trace so thinking card survives page reload
2026-04-14 20:56:54 +00:00
if ( reasoningText && lastAsst && ! lastAsst . reasoning ) lastAsst . reasoning = reasoningText ;
2026-04-04 02:04:41 +00:00
// Stamp _ts on the last assistant message if it has no timestamp
if ( lastAsst && ! lastAsst . _ts && ! lastAsst . timestamp ) lastAsst . _ts = Date . now ( ) / 1000 ;
fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Merged as v0.50.227. 2634 tests passing, browser QA 21/21 (desktop + mobile). Full attribution below.
Thanks to all 12 contributors:
@jundev0001 (#1138), @franksong2702 (#1142, #1157, #1162), @dso2ng (#1143), @bergeouss (#1145, #1146, #1156, #1159), @jasonjcwu (#1149), @ccqqlo (#1161), @frap129 (#1165)
Two fixes applied during integration and two more by the independent reviewer (@nesquena):
- messages.js: per-turn cost delta capture order (#1159)
- workspace.py: symlink target blocked-roots check + HOME sanity guard (#1149, #1165)
- panels.js: cron unread counter bookkeeping (in-loop increment bug)
- tests/test_symlink_cycle_detection.py: register workspace before session/new
2026-04-27 20:34:59 +00:00
if ( d . usage ) {
S . lastUsage = d . usage ; _syncCtxIndicator ( d . usage ) ;
// #503 — compute per-turn cost delta and attach to last assistant message
if ( lastAsst ) {
const prevIn = _prevIn ;
const prevOut = _prevOut ;
const prevCost = _prevCost ;
const curIn = d . usage . input _tokens || 0 ;
const curOut = d . usage . output _tokens || 0 ;
const curCost = d . usage . estimated _cost || 0 ;
// Only set delta if values actually increased (skip no-op turns)
if ( curIn > prevIn || curOut > prevOut ) {
lastAsst . _turnUsage = {
input _tokens : Math . max ( 0 , curIn - prevIn ) ,
output _tokens : Math . max ( 0 , curOut - prevOut ) ,
estimated _cost : Math . max ( 0 , curCost - prevCost ) ,
} ;
}
2026-05-04 03:14:49 +00:00
if ( typeof d . usage . duration _seconds === 'number' ) {
lastAsst . _turnDuration = d . usage . duration _seconds ;
}
2026-05-04 17:55:23 +00:00
if ( typeof d . usage . tps === 'number' && d . usage . tps > 0 ) {
lastAsst . _turnTps = d . usage . tps ;
}
2026-05-05 00:08:23 +00:00
if ( d . usage . gateway _routing ) {
lastAsst . _gatewayRouting = d . usage . gateway _routing ;
if ( S . session ) S . session . gateway _routing = d . usage . gateway _routing ;
if ( S . session && Array . isArray ( S . session . gateway _routing _history ) ) S . session . gateway _routing _history . push ( d . usage . gateway _routing ) ;
else if ( S . session ) S . session . gateway _routing _history = [ d . usage . gateway _routing ] ;
}
fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Merged as v0.50.227. 2634 tests passing, browser QA 21/21 (desktop + mobile). Full attribution below.
Thanks to all 12 contributors:
@jundev0001 (#1138), @franksong2702 (#1142, #1157, #1162), @dso2ng (#1143), @bergeouss (#1145, #1146, #1156, #1159), @jasonjcwu (#1149), @ccqqlo (#1161), @frap129 (#1165)
Two fixes applied during integration and two more by the independent reviewer (@nesquena):
- messages.js: per-turn cost delta capture order (#1159)
- workspace.py: symlink target blocked-roots check + HOME sanity guard (#1149, #1165)
- panels.js: cron unread counter bookkeeping (in-loop increment bug)
- tests/test_symlink_cycle_detection.py: register workspace before session/new
2026-04-27 20:34:59 +00:00
}
}
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
if ( d . session . tool _calls && d . session . tool _calls . length ) {
S . toolCalls = d . session . tool _calls . map ( tc => ( { ... tc , done : true } ) ) ;
} else {
S . toolCalls = S . toolCalls . map ( tc => ( { ... tc , done : true } ) ) ;
}
2026-05-06 02:07:20 +00:00
if ( typeof _copyActivityDisclosureState === 'function' && lastAsst ) {
const assistantIdx = S . messages . indexOf ( lastAsst ) ;
if ( assistantIdx >= 0 ) _copyActivityDisclosureState ( 'live:' + streamId , 'assistant:' + assistantIdx ) ;
}
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
if ( uploaded . length ) {
const lastUser = [ ... S . messages ] . reverse ( ) . find ( m => m . role === 'user' ) ;
if ( lastUser ) lastUser . attachments = uploaded ;
}
2026-05-08 02:32:06 +00:00
if ( _latestGoalStatus && _latestGoalStatus . message ) {
S . messages . push ( {
role : 'assistant' ,
content : String ( _latestGoalStatus . message ) ,
_ts : Date . now ( ) / 1000 ,
_goalStatus : true ,
_transient : true ,
} ) ;
}
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
clearLiveToolCards ( ) ;
S . busy = false ;
fix: silent agent errors, stale model list, live model fetching (#377)
* fix: silent errors, stale models, live model fetching (#373, #374, #375)
- api/streaming.py: detect empty agent response (_assistant_added check),
emit apperror(type='no_response' or 'auth_mismatch') instead of silent done
- api/streaming.py: add _token_sent flag so guard works for streaming agents
- static/messages.js: done handler belt-and-suspenders guard for zero replies
- static/messages.js: apperror handler labels 'no_response' type distinctly
- api/config.py: remove gpt-4o and o3 from _FALLBACK_MODELS and
_PROVIDER_MODELS['openai'] (superseded by gpt-5.4-mini and o4-mini)
- api/routes.py: new /api/models/live?provider= endpoint, fetches /v1/models
from provider API with B310 scheme check + SSRF guard
- static/ui.js: _fetchLiveModels() background fetch after static list loads,
appends new models to dropdown, caches per session, skips unsupported providers
Other:
- tests/test_issues_373_374_375.py: 25 new structural tests
- tests/test_regressions.py: extend done-handler window 1500->2500 chars
- CHANGELOG.md: v0.50.19 entry; 947 tests (up from 922)
* fix: SSRF hostname bypass + auth detection operator precedence
1. routes.py: SSRF guard used substring matching (any(k in hostname))
which allows bypass via hostnames like evil-ollama.attacker.com.
Changed to exact hostname matching against a fixed set of known
local hostnames (localhost, 127.0.0.1, 0.0.0.0, ::1).
2. streaming.py: _is_auth detection had a Python operator precedence
bug on the ternary expression. The line:
'AuthenticationError' in type(...).__name__ if _last_err else False
parsed as the ternary absorbing the rest of the or-chain when
_last_err was falsy. Fixed to: (_last_err and 'AuthenticationError' in ...)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: fix v0.50.20 CHANGELOG version number and test count (949 tests)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:52:35 +00:00
// No-reply guard (#373): if agent returned nothing, show inline error
if ( ! S . messages . some ( m => m . role === 'assistant' && String ( m . content || '' ) . trim ( ) ) && ! assistantText ) { removeThinking ( ) ; S . messages . push ( { role : 'assistant' , content : '**No response received.** Check your API key and model selection.' } ) ; }
2026-04-28 09:23:24 +00:00
if ( isSessionViewed ) _markSessionViewed ( completedSid , completedSession . message _count ? ? S . messages . length ) ;
2026-05-08 16:11:49 +00:00
syncTopbar ( ) ; renderMessages ( { preserveScroll : true } ) ;
if ( shouldFollowOnDone && typeof scrollToBottom === 'function' ) scrollToBottom ( ) ;
loadDir ( '.' ) ;
2026-04-30 04:34:27 +00:00
// TTS auto-read: speak the last assistant response if enabled (#499)
if ( typeof autoReadLastAssistant === 'function' ) setTimeout ( ( ) => autoReadLastAssistant ( ) , 300 ) ;
2026-03-31 03:40:19 +00:00
}
2026-05-08 02:32:06 +00:00
if ( isActiveSession && _pendingGoalContinuation && typeof queueSessionMessage === 'function' ) {
const _goalNext = _pendingGoalContinuation ;
_pendingGoalContinuation = null ;
queueSessionMessage ( _goalNext . sid , {
text : _goalNext . text ,
files : [ ] ,
model : _goalNext . model ,
model _provider : _goalNext . model _provider ,
profile : _goalNext . profile ,
} ) ;
if ( typeof updateQueueBadge === 'function' ) updateQueueBadge ( _goalNext . sid ) ;
}
2026-05-06 21:56:17 +00:00
if ( isActiveSession ) _queueDrainSid = activeSid ;
2026-05-06 18:07:11 +00:00
renderSessionList ( ) ;
2026-05-06 21:56:17 +00:00
_setActivePaneIdleIfOwner ( ) ;
2026-04-08 16:02:02 +00:00
playNotificationSound ( ) ;
sendBrowserNotification ( 'Response complete' , assistantText ? assistantText . slice ( 0 , 100 ) : 'Task finished' ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
} ) ;
2026-04-15 14:50:59 +00:00
source . addEventListener ( 'stream_end' , e => {
2026-04-16 03:25:31 +00:00
_terminalStateReached = true ;
2026-04-15 14:50:59 +00:00
try {
const d = JSON . parse ( e . data || '{}' ) ;
if ( ( d . session _id || activeSid ) !== activeSid ) return ;
} catch ( _ ) { }
source . close ( ) ;
} ) ;
2026-04-26 02:21:00 +00:00
source . addEventListener ( 'pending_steer_leftover' , e => {
// The agent finished its turn with steer text still stashed (no
// tool-result boundary fired). Match the CLI's leftover-delivery
// behaviour: queue the leftover text as a next-turn user message
// so the existing drain in setBusy(false) ships it.
try {
const d = JSON . parse ( e . data || '{}' ) ;
const sid = d . session _id || activeSid ;
const txt = String ( d . text || '' ) . trim ( ) ;
if ( ! txt || sid !== activeSid ) return ;
if ( typeof queueSessionMessage === 'function' ) {
queueSessionMessage ( sid , {
text : txt , files : [ ] ,
model : S . session && S . session . model || '' ,
2026-05-01 05:23:47 +00:00
model _provider : S . session && S . session . model _provider || null ,
2026-04-26 02:21:00 +00:00
profile : S . activeProfile || 'default' ,
} ) ;
if ( typeof updateQueueBadge === 'function' ) updateQueueBadge ( sid ) ;
showToast ( t ( 'steer_leftover_queued' ) , 3000 ) ;
}
} catch ( _ ) { }
} ) ;
2026-05-07 18:24:52 +00:00
source . addEventListener ( 'compressing' , e => {
// Context auto-compression is starting. Surface the same calm running
// compression card as manual /compress while the summarizer LLM call runs.
if ( ! S . session || S . session . session _id !== activeSid ) return ;
let d = { } ;
try { d = JSON . parse ( e . data || '{}' ) || { } ; } catch ( _ ) { d = { } ; }
if ( d . session _id && d . session _id !== activeSid ) return ;
if ( typeof setCompressionUi === 'function' ) {
setCompressionUi ( {
sessionId : activeSid ,
phase : 'running' ,
automatic : true ,
message : d . message || 'Auto-compressing context...' ,
} ) ;
}
if ( typeof renderMessages === 'function' ) renderMessages ( { preserveScroll : true } ) ;
} ) ;
2026-04-05 01:46:34 +00:00
source . addEventListener ( 'compressed' , e => {
fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Merged as v0.50.227. 2634 tests passing, browser QA 21/21 (desktop + mobile). Full attribution below.
Thanks to all 12 contributors:
@jundev0001 (#1138), @franksong2702 (#1142, #1157, #1162), @dso2ng (#1143), @bergeouss (#1145, #1146, #1156, #1159), @jasonjcwu (#1149), @ccqqlo (#1161), @frap129 (#1165)
Two fixes applied during integration and two more by the independent reviewer (@nesquena):
- messages.js: per-turn cost delta capture order (#1159)
- workspace.py: symlink target blocked-roots check + HOME sanity guard (#1149, #1165)
- panels.js: cron unread counter bookkeeping (in-loop increment bug)
- tests/test_symlink_cycle_detection.py: register workspace before session/new
2026-04-27 20:34:59 +00:00
// Context was auto-compressed during this turn. Render it through the
// same transient compression-card path as manual /compress, without
// inserting a fake assistant message into history or model context.
2026-04-05 01:46:34 +00:00
if ( ! S . session || S . session . session _id !== activeSid ) return ;
fix: batch triage — 12 contributor PRs (v0.50.227) (#1168)
Merged as v0.50.227. 2634 tests passing, browser QA 21/21 (desktop + mobile). Full attribution below.
Thanks to all 12 contributors:
@jundev0001 (#1138), @franksong2702 (#1142, #1157, #1162), @dso2ng (#1143), @bergeouss (#1145, #1146, #1156, #1159), @jasonjcwu (#1149), @ccqqlo (#1161), @frap129 (#1165)
Two fixes applied during integration and two more by the independent reviewer (@nesquena):
- messages.js: per-turn cost delta capture order (#1159)
- workspace.py: symlink target blocked-roots check + HOME sanity guard (#1149, #1165)
- panels.js: cron unread counter bookkeeping (in-loop increment bug)
- tests/test_symlink_cycle_detection.py: register workspace before session/new
2026-04-27 20:34:59 +00:00
let d = { } ;
try { d = JSON . parse ( e . data || '{}' ) || { } ; } catch ( _ ) { d = { } ; }
const message = String ( d . message || 'Context auto-compressed to continue the conversation' ) . trim ( ) ;
if ( typeof setCompressionUi === 'function' ) {
setCompressionUi ( {
sessionId : activeSid ,
phase : 'done' ,
automatic : true ,
message ,
summary : { headline : message } ,
} ) ;
}
if ( typeof _setCompressionSessionLock === 'function' ) _setCompressionSessionLock ( null ) ;
if ( ! S . busy && typeof renderMessages === 'function' ) renderMessages ( ) ;
2026-05-10 02:35:05 +00:00
showToast ( message || 'Context compressed' , 8000 ) ;
2026-04-05 01:46:34 +00:00
} ) ;
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
source . addEventListener ( 'metering' , e => {
try {
const d = JSON . parse ( e . data || '{}' ) ;
2026-05-04 17:55:23 +00:00
if ( ( d . session _id || activeSid ) !== activeSid ) return ;
2026-05-10 12:51:46 +00:00
if ( d . usage && typeof _syncCtxIndicator === 'function' ) {
S . lastUsage = { ... ( S . lastUsage || { } ) , ... d . usage } ;
_syncCtxIndicator ( S . lastUsage ) ;
}
2026-05-04 17:55:23 +00:00
if ( d . estimated === true || d . tps _available !== true || typeof d . tps !== 'number' || d . tps <= 0 ) {
if ( typeof _setLiveAssistantTps === 'function' ) _setLiveAssistantTps ( null ) ;
return ;
}
if ( typeof _setLiveAssistantTps === 'function' ) _setLiveAssistantTps ( d . tps ) ;
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
} catch ( _ ) { }
} ) ;
2026-04-03 20:34:52 +00:00
source . addEventListener ( 'apperror' , e => {
2026-04-16 03:25:31 +00:00
_terminalStateReached = true ;
2026-04-24 19:33:16 +00:00
if ( _persistTimer ) { clearTimeout ( _persistTimer ) ; _persistTimer = null ; }
2026-04-22 01:47:40 +00:00
_streamFinalized = true ;
2026-04-24 18:57:48 +00:00
if ( _pendingRafHandle !== null ) { cancelAnimationFrame ( _pendingRafHandle ) ; clearTimeout ( _pendingRafHandle ) ; _pendingRafHandle = null ; _renderPending = false ; }
2026-04-23 23:09:08 +00:00
_smdEndParser ( ) ;
2026-04-22 01:47:40 +00:00
if ( typeof finalizeThinkingCard === 'function' ) finalizeThinkingCard ( ) ;
2026-04-03 20:34:52 +00:00
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
// This is distinct from the SSE network 'error' event below.
source . close ( ) ;
2026-05-06 21:56:17 +00:00
_clearOwnerInflightState ( ) ;
_clearApprovalForOwner ( ) ;
_clearClarifyForOwner ( 'terminal' ) ;
2026-04-03 20:34:52 +00:00
if ( S . session && S . session . session _id === activeSid ) {
fix: remove deprecated btnCancel; localise composer tooltips with disabled reason branching
- Drop btnCancel element and all JS show/hide call sites across
boot.js, messages.js, sessions.js, ui.js (superseded by single
primary action button)
- Remove .cancel-btn CSS rules including mobile media-query override
- Route updateSendBtn() title/aria-label through t() with English
fallbacks; add composer_send/queue/interrupt/steer/stop keys to all
7 locales (en, ru, es, de, zh, zh-Hant, ko)
- Branch disabled-state tooltip on reason: clarify lock, compression
running, or idle-empty, each with its own i18n key
- Update test_sprint10 / test_sprint36 to reflect single-button model:
assert btnSend present and id="btnCancel" absent; replace
test_hides_cancel_button with test_clears_composer_status
2026-04-28 06:31:11 +00:00
S . activeStreamId = null ;
2026-04-03 20:34:52 +00:00
clearLiveToolCards ( ) ; if ( ! assistantText ) removeThinking ( ) ;
try {
const d = JSON . parse ( e . data ) ;
const isRateLimit = d . type === 'rate_limit' ;
2026-04-20 22:48:19 +00:00
const isQuotaExhausted = d . type === 'quota_exhausted' ;
fix: warn on provider/model mismatch, surface auth errors (#266)
* fix: warn on provider/model mismatch, surface auth errors (#266)
Fixes #266 — WebUI silently ignores provider/model selection mismatch.
The problem: selecting an OpenRouter (or Anthropic/OpenAI) model while
Hermes is configured for a different provider (e.g. local Ollama) sends
the request to the wrong endpoint, which returns a 401 Unauthorized error
with no UI indication of why.
Three-layer fix:
1. api/streaming.py — detect 401/auth errors explicitly
Added is_auth_error detection covering '401', 'AuthenticationError',
'authentication', 'unauthorized', 'invalid api key', and the specific
Ollama error string 'no cookie auth credentials'. Auth errors emit
apperror with type='auth_mismatch' and a hint pointing to 'hermes model'.
2. static/ui.js — expose active_provider and warn on selection
- populateModelDropdown() stores data.active_provider from /api/models
as window._activeProvider (the field was already in the response but
the frontend never used it)
- New _checkProviderMismatch(modelId) helper: compares the selected
model's slash-prefix (e.g. 'openai/' from 'openai/gpt-4o') against
the active provider. Skips the check for 'openrouter' and 'custom'
to avoid false positives on configs that legitimately route any model.
3. static/boot.js — warn on model dropdown change
modelSelect.onchange calls _checkProviderMismatch() and shows a toast
when the selected model looks incompatible with the configured provider.
4. static/messages.js — distinct UI label for auth errors
apperror handler now distinguishes type='auth_mismatch' and shows
'Provider mismatch' as the error label instead of 'Error'.
5. static/i18n.js — provider_mismatch_warning and provider_mismatch_label
keys added to all 5 locales (en, es, de, zh-Hans, zh-Hant).
Tests: 21 new tests in tests/test_provider_mismatch.py covering all
five change areas. 679/679 total pass (658 baseline + 21 new).
* fix: t() call args spread + use i18n label for auth mismatch
1. ui.js: _checkProviderMismatch passed [modelId, ap] as a single
array arg to t(). Since t(key, ...args) spreads, the function
received the array as m and undefined as p. Fixed to pass as
separate args: t('provider_mismatch_warning', modelId, ap).
2. messages.js: 'Provider mismatch' label was hardcoded instead of
using t('provider_mismatch_label'). Now uses the i18n key with
fallback for when t() isn't available.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 04:25:18 +00:00
const isAuthMismatch = d . type === 'auth_mismatch' ;
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
const isModelNotFound = d . type === 'model_not_found' ;
2026-05-07 01:15:53 +00:00
const isNoResponse = d . type === 'no_response' || d . type === 'silent_failure' ;
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object
Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.
The correct translations already exist in the zh-Hant locale block.
Fixes #1008
* fix: block stale SSE events from polluting new session's DOM
- appendThinking(): guard with !S.session||!S.activeStreamId to drop
events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
streaming if user is watching (scroll pinned)
* Fix empty agent sessions in sidebar
* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status
Fixes #995 — three sub-issues in the Cron Jobs UI:
1. Dual play icons ambiguous: Resume button now shows a distinct
play+bar icon (play triangle + vertical line) instead of the
identical triangle used by Run now.
2. Toast notification overlapping header buttons: Added
position:relative; z-index:10 to .main-view-header so it
stacks above the fixed toast (z-index:100 within its layer).
3. No running status after trigger: After triggering a job, the
status badge immediately shows 'running…' with a CSS spinner
animation, and polls the cron list every 3s (up to 30s) to
refresh when the job completes.
- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()
* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback
- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback
* feat: create folder and add space directly from UI (#782)
- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales
* fix: validate workspace path before mkdir to prevent orphan directories
Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.
New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir
Also imports _workspace_blocked_roots for the pre-mutation blocklist check.
* fix(#1014): classify model-not-found errors with helpful message
- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n
* feat(ui): add live TPS stat to header
Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.
Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock
Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end
Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
focus/blur was replaced with simple always-1s-when-active approach)
UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)
* feat: session restore speed + title gen reasoning hardening (#1025, #1026)
PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session
PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback
Co-authored-by: Frank Song <franksong2702@gmail.com>
* feat: session attention indicators in right slot + last_message_at timestamps (#1024)
PR #1024 (@franksong2702): Polish session attention indicators
- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
so metadata-only saves don't make old sessions appear under Today
Co-authored-by: Frank Song <franksong2702@gmail.com>
* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
const label = isQuotaExhausted ? 'Out of credits' : isRateLimit ? 'Rate limit reached' : isAuthMismatch ? ( typeof t === 'function' ? t ( 'provider_mismatch_label' ) : 'Provider mismatch' ) : isModelNotFound ? ( typeof t === 'function' ? t ( 'model_not_found_label' ) : 'Model not found' ) : isNoResponse ? 'No response received' : 'Error' ;
2026-04-15 23:59:28 +00:00
const hint = d . hint ? ` \n \n * ${ d . hint } * ` : '' ;
2026-05-07 01:15:53 +00:00
const details = d . details ? String ( d . details ) . replace ( /```/g , '`\u200b``' ) : '' ;
S . messages . push ( { role : 'assistant' , content : ` ** ${ label } :** ${ d . message } ${ hint } ` , provider _details : details } ) ;
2026-04-03 20:34:52 +00:00
} catch ( _ ) {
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons
* fix: approval pending check broken by stale has_pending import (#228)
api/routes.py imported has_pending/pop_pending from tools.approval, but the
agent module renamed has_pending to has_blocking_approval (checks gateway
queue, not _pending dict) and removed pop_pending. The import fell through
to fallback lambdas that always returned False, making GET /api/approval/pending
always return {pending:null} even after a successful inject_test.
Fix: check _pending directly under _lock — same dict submit_pending writes to.
Stale imports removed.
Before: 554 pass, 1 fail | After: 555 pass, 0 fail
* fix: move login JS into external file, remove inline handlers (#226)
Login page used inline onsubmit/onkeydown handlers and an inline <script>
block — all blocked by strict script-src CSP, causing silent login failure.
Fix: extract doLogin() and Enter key listener into static/login.js (served
from /static/, already a public path). Form uses id='login-form' and
data-* attributes for i18n strings instead of injected JS literals.
Also guards res.json() parse with try/catch so non-JSON error bodies
(e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'.
Fixes #222.
* fix: improve update error messages when pull fails (#227)
_apply_update_inner() ran git pull --ff-only and returned only raw stderr
on failure, making all failure modes indistinguishable.
Fix: explicit git fetch before pull; if fetch fails, returns human-readable
network error. Diverged history and missing upstream tracking branch each
get distinct messages with exact recovery commands. Generic fallback
truncates to 300 chars and shows sentinel when git produces no output.
Also adds tests/test_update_checker.py with 13 tests covering all 4 new
diagnostic code paths (0 tests existed before).
Fixes #223.
* fix: stabilize 30s terminal approval prompt visibility (#225)
Adds minimum 30-second visibility guard for the approval card using
_approvalVisibleSince, _approvalHideTimer, and a signature fingerprint
to deduplicate repeated poll ticks.
Fix: respondApproval() and all stream-end paths (done/cancel/apperror/
error/start-error) now call hideApprovalCard(true) so the card hides
immediately when the user responds or the session ends. The 30s guard
only applies to mid-session poll ticks where the approval is still live
but briefly absent.
Adds 11 structural tests covering the new timer variables, force
parameter, force-on-respond, force-on-stream-end, and poll-loop
no-force behavior.
* feat: replace emoji icons with self-hosted Lucide SVG icons (#221)
Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled
in static/icons.js (no CDN dependency). Adds li(name) function returning
inline SVG geometry from a hardcoded whitelist — unknown keys return '' so
dynamic server-supplied names never inject arbitrary SVG.
Changes:
- static/icons.js: new file with 21 icon paths + li() renderer
- static/index.html: all nav/action buttons now use li() icons
- static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons
- static/messages.js: cancelStream button uses SVG square stop icon
- .gitignore: adds node_modules/ entry
Verified: all 35 onclick= functions exist in JS, all 21 li() calls
reference defined icons, applyBotName() selectors intact, version
label present, no removed IDs referenced by JS.
* docs: v0.44.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
S . messages . push ( { role : 'assistant' , content : '**Error:** An error occurred. Check server logs.' } ) ;
2026-04-03 20:34:52 +00:00
}
2026-04-23 16:05:57 +00:00
_markSessionViewed ( activeSid , S . messages . length ) ;
2026-05-05 16:21:25 +00:00
renderMessages ( { preserveScroll : true } ) ;
2026-04-03 20:34:52 +00:00
} else if ( typeof trackBackgroundError === 'function' ) {
const _errTitle = ( typeof _allSessions !== 'undefined' && _allSessions . find ( s => s . session _id === activeSid ) || { } ) . title || null ;
try { const d = JSON . parse ( e . data ) ; trackBackgroundError ( activeSid , _errTitle , d . message || 'Error' ) ; }
catch ( _ ) { trackBackgroundError ( activeSid , _errTitle , 'Error' ) ; }
}
2026-05-06 21:56:17 +00:00
_setActivePaneIdleIfOwner ( ) ;
2026-04-23 16:05:57 +00:00
renderSessionList ( ) ; // clear streaming indicator immediately on apperror
2026-04-03 20:34:52 +00:00
} ) ;
source . addEventListener ( 'warning' , e => {
// Non-fatal warning from server (e.g. fallback activated, retrying)
if ( ! S . session || S . session . session _id !== activeSid ) return ;
try {
const d = JSON . parse ( e . data ) ;
// Show as a small inline notice, not a full error
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
* Polish workspace panel behavior and app dialogs
* Replace remaining emoji UI glyphs with Lucide icons
* Redesign composer footer around model and context controls
Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.
Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/
* Remove obsolete activity bar
Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.
This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.
* Move workspace and model switching into composer footer
* Move profile switching into composer footer
* Refactor Hermes control center UI
* Redesign control center settings modal layout
Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Put session item actions in a dropdown
* Use Hermes mark in sidebar control button
* Reset control center section on close
* Drop session-item left border indicator
Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Increase session search input vertical padding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Normalise odd pixel values across UI
Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)
The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints
Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.
* Improve composer footer mobile responsiveness and UX
- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior
* Fix duplicate files button and broken workspace close on mobile
Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix model chip icon vertical alignment in composer footer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix workspace toggle button hidden on desktop by conflicting CSS class
Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix session actions dots button inaccessible on mobile sidebar
Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer footer manage links not opening sidebar panel
The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Widen icon-only composer chips breakpoint from 400px to 768px
Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer-left vertical scrollbar by setting overflow-y:hidden
When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve rebase conflicts and fix control center test assertions
- Resolved 4 conflicts during rebase onto master (workspace.js,
boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update version badge in System tab to v0.49.4
* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge
---------
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
setComposerStatus ( ` ${ d . message || 'Warning' } ` ) ;
2026-04-03 20:34:52 +00:00
// If it's a fallback notice, show it briefly then clear
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
* Polish workspace panel behavior and app dialogs
* Replace remaining emoji UI glyphs with Lucide icons
* Redesign composer footer around model and context controls
Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.
Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/
* Remove obsolete activity bar
Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.
This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.
* Move workspace and model switching into composer footer
* Move profile switching into composer footer
* Refactor Hermes control center UI
* Redesign control center settings modal layout
Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Put session item actions in a dropdown
* Use Hermes mark in sidebar control button
* Reset control center section on close
* Drop session-item left border indicator
Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Increase session search input vertical padding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Normalise odd pixel values across UI
Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)
The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints
Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.
* Improve composer footer mobile responsiveness and UX
- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior
* Fix duplicate files button and broken workspace close on mobile
Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix model chip icon vertical alignment in composer footer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix workspace toggle button hidden on desktop by conflicting CSS class
Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix session actions dots button inaccessible on mobile sidebar
Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer footer manage links not opening sidebar panel
The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Widen icon-only composer chips breakpoint from 400px to 768px
Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer-left vertical scrollbar by setting overflow-y:hidden
When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve rebase conflicts and fix control center test assertions
- Resolved 4 conflicts during rebase onto master (workspace.js,
boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update version badge in System tab to v0.49.4
* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge
---------
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
if ( d . type === 'fallback' ) setTimeout ( ( ) => setComposerStatus ( '' ) , 4000 ) ;
2026-04-03 20:34:52 +00:00
} catch ( _ ) { }
} ) ;
2026-04-16 03:25:31 +00:00
source . addEventListener ( 'error' , async e => {
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
source . close ( ) ;
2026-05-08 10:40:21 +00:00
if ( _deferStreamErrorIfOffline ( ) ) return ;
2026-04-22 01:47:40 +00:00
if ( _terminalStateReached || _streamFinalized ) {
2026-04-16 03:25:31 +00:00
_closeSource ( ) ;
return ;
}
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
// Attempt one reconnect if the stream is still active server-side
if ( ! _reconnectAttempted && streamId ) {
_reconnectAttempted = true ;
2026-04-15 23:59:28 +00:00
setComposerStatus ( 'Reconnecting…' ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
setTimeout ( async ( ) => {
try {
const st = await api ( ` /api/chat/stream/status?stream_id= ${ encodeURIComponent ( streamId ) } ` ) ;
if ( st . active ) {
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242)
* Polish workspace panel behavior and app dialogs
* Replace remaining emoji UI glyphs with Lucide icons
* Redesign composer footer around model and context controls
Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill.
Design credit and inspiration: Theo / T3 Code.
Reference implementation: https://github.com/pingdotgg/t3code/
* Remove obsolete activity bar
Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts.
This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status.
* Move workspace and model switching into composer footer
* Move profile switching into composer footer
* Refactor Hermes control center UI
* Redesign control center settings modal layout
Widen the modal to 860px, simplify the tab list to icon+label rows,
stretch the tab column's divider to full height, lock the panel to a
fixed height so switching tabs no longer resizes the outer shell, and
always open on the Conversation tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Put session item actions in a dropdown
* Use Hermes mark in sidebar control button
* Reset control center section on close
* Drop session-item left border indicator
Remove the left-border accent used for active, CLI, and project rows —
each state already has a dedicated cue (gold fill, cli badge, project
dot), so the border was redundant. Fully round the row, add 2px
bottom spacing between rows, and strip the matching JS/CSS overrides.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Increase session search input vertical padding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Normalise odd pixel values across UI
Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid
across composer chips, sidebar panels, cron list, settings, approval
buttons, dropdowns, and inline message edit — eliminating the 7/9/11px
drift that was making sibling elements feel subtly misaligned.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite)
The mobile layout regression suite (test_mobile_layout.py) requires:
- #btnMobileFiles onclick=toggleMobileFiles() in topbar chips
- .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints
Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports.
* Improve composer footer mobile responsiveness and UX
- Collapse composer chips to icon-only at <=400px viewports
- Add model chip icon (CPU) so it remains tappable when labels are hidden
- Show send button always (disabled state when empty, hidden during streaming)
- Show context usage indicator on session load, not just after streaming
- Add cancel status fallback timeout to prevent stale "Cancelling..." text
- Update tests to match new send button and busy state behavior
* Fix duplicate files button and broken workspace close on mobile
Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle
in the mobile topbar. Fix workspace panel close button calling undefined
closeMobileFiles() — now calls closeWorkspacePanel().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix model chip icon vertical alignment in composer footer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix workspace toggle button hidden on desktop by conflicting CSS class
Remove mobile-files-btn class from #btnWorkspacePanelToggle — its
display:none!important rule was overriding workspace-toggle-btn visibility
on non-mobile viewports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix session actions dots button inaccessible on mobile sidebar
Always show the session actions trigger on mobile (no hover state on
touch devices) and restore right padding so text truncates with
ellipsis before the dots icon.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer footer manage links not opening sidebar panel
The "Manage profiles" and "Manage workspaces" links in the composer
footer dropdowns called switchPanel() which only changes the active
panel content but doesn't open the sidebar. Replaced with
mobileSwitchPanel() which also opens the sidebar so the panel is
actually visible.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Widen icon-only composer chips breakpoint from 400px to 768px
Move the icon-only chip styling up into the existing max-width:768px
media query so chips collapse to icon-only on tablets too, preventing
composer footer overflow on mid-size screens.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix composer-left vertical scrollbar by setting overflow-y:hidden
When overflow-x is set to auto, the CSS spec implicitly changes
overflow-y from visible to auto, allowing a vertical scrollbar to
appear from slight chip padding/border overflow. Explicitly set
overflow-y:hidden to prevent this.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve rebase conflicts and fix control center test assertions
- Resolved 4 conflicts during rebase onto master (workspace.js,
boot.js, index.html, test_sprint34.py)
- Fixed test_sprint34.py: _controlSection -> _settingsSection,
cc-tab -> settings-tabs (matching actual implementation)
- Fixed quoting syntax error in test assertion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update version badge in System tab to v0.49.4
* docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge
---------
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
setComposerStatus ( 'Reconnected' ) ;
2026-05-01 04:47:36 +00:00
_wireSSE ( new EventSource ( new URL ( ` api/chat/stream?stream_id= ${ encodeURIComponent ( streamId ) } ` , document . baseURI || location . href ) . href , { withCredentials : true } ) ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
return ;
}
2026-05-08 10:40:21 +00:00
} catch ( _ ) {
if ( _deferStreamErrorIfOffline ( ) ) return ;
}
2026-04-16 03:25:31 +00:00
if ( await _restoreSettledSession ( ) ) return ;
2026-05-08 10:40:21 +00:00
if ( _deferStreamErrorIfOffline ( ) ) return ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
_handleStreamError ( ) ;
} , 1500 ) ;
return ;
2026-03-31 03:40:19 +00:00
}
2026-04-16 03:25:31 +00:00
if ( await _restoreSettledSession ( ) ) return ;
2026-05-08 10:40:21 +00:00
if ( _deferStreamErrorIfOffline ( ) ) return ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
_handleStreamError ( ) ;
} ) ;
source . addEventListener ( 'cancel' , e => {
2026-04-16 03:25:31 +00:00
_terminalStateReached = true ;
2026-04-24 19:33:16 +00:00
if ( _persistTimer ) { clearTimeout ( _persistTimer ) ; _persistTimer = null ; }
2026-04-22 01:47:40 +00:00
_streamFinalized = true ;
2026-04-24 18:57:48 +00:00
if ( _pendingRafHandle !== null ) { cancelAnimationFrame ( _pendingRafHandle ) ; clearTimeout ( _pendingRafHandle ) ; _pendingRafHandle = null ; _renderPending = false ; }
2026-04-23 23:09:08 +00:00
_smdEndParser ( ) ;
2026-04-22 01:47:40 +00:00
if ( typeof finalizeThinkingCard === 'function' ) finalizeThinkingCard ( ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
source . close ( ) ;
2026-05-06 21:56:17 +00:00
_clearOwnerInflightState ( ) ;
_clearApprovalForOwner ( ) ;
_clearClarifyForOwner ( 'cancelled' ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
if ( S . session && S . session . session _id === activeSid ) {
fix: remove deprecated btnCancel; localise composer tooltips with disabled reason branching
- Drop btnCancel element and all JS show/hide call sites across
boot.js, messages.js, sessions.js, ui.js (superseded by single
primary action button)
- Remove .cancel-btn CSS rules including mobile media-query override
- Route updateSendBtn() title/aria-label through t() with English
fallbacks; add composer_send/queue/interrupt/steer/stop keys to all
7 locales (en, ru, es, de, zh, zh-Hant, ko)
- Branch disabled-state tooltip on reason: clarify lock, compression
running, or idle-empty, each with its own i18n key
- Update test_sprint10 / test_sprint36 to reflect single-button model:
assert btnSend present and id="btnCancel" absent; replace
test_hides_cancel_button with test_clears_composer_status
2026-04-28 06:31:11 +00:00
S . activeStreamId = null ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
}
2026-04-23 05:17:40 +00:00
// Fetch latest session from server to get accurate message list (includes cancel status)
// This ensures messages stay in sync with server, fixing race condition where local
// "*Task cancelled.*" message gets lost when done event overwrites S.messages
( async ( ) => {
try {
const data = await api ( ` /api/session?session_id= ${ encodeURIComponent ( activeSid ) } ` ) ;
if ( data && data . session && S . session && S . session . session _id === activeSid ) {
S . session = data . session ;
S . messages = ( data . session . messages || [ ] ) . filter ( m => m && m . role ) ;
clearLiveToolCards ( ) ; if ( ! assistantText ) removeThinking ( ) ;
2026-04-23 16:05:57 +00:00
_markSessionViewed ( activeSid , data . session . message _count ? ? S . messages . length ) ;
2026-05-05 16:21:25 +00:00
renderMessages ( { preserveScroll : true } ) ;
2026-04-23 05:17:40 +00:00
}
} catch ( _ ) {
// Fallback to local cancel message if API fails
if ( S . session && S . session . session _id === activeSid ) {
clearLiveToolCards ( ) ; if ( ! assistantText ) removeThinking ( ) ;
2026-05-05 16:21:25 +00:00
S . messages . push ( { role : 'assistant' , content : '*Task cancelled.*' } ) ; renderMessages ( { preserveScroll : true } ) ;
2026-04-23 16:05:57 +00:00
_markSessionViewed ( activeSid , S . messages . length ) ;
2026-04-23 05:17:40 +00:00
}
}
} ) ( ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
renderSessionList ( ) ;
2026-05-06 21:56:17 +00:00
_setActivePaneIdleIfOwner ( ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
} ) ;
}
2026-04-16 03:25:31 +00:00
async function _restoreSettledSession ( ) {
try {
const data = await api ( ` /api/session?session_id= ${ encodeURIComponent ( activeSid ) } ` ) ;
const session = data && data . session ;
if ( ! session ) return false ;
if ( session . active _stream _id || session . pending _user _message ) return false ;
2026-05-06 21:56:17 +00:00
_clearOwnerInflightState ( ) ;
2026-04-16 03:25:31 +00:00
_closeSource ( ) ;
2026-05-06 21:56:17 +00:00
_clearApprovalForOwner ( ) ;
_clearClarifyForOwner ( 'terminal' ) ;
2026-04-28 05:10:09 +00:00
const isSessionViewed = _isSessionActivelyViewed ( activeSid ) ;
2026-04-28 09:23:24 +00:00
const completedSid = session . session _id || activeSid ;
2026-04-28 05:10:09 +00:00
if ( ! isSessionViewed && typeof _markSessionCompletionUnread === 'function' ) {
2026-04-28 09:23:24 +00:00
_markSessionCompletionUnread ( completedSid , session . message _count ) ;
2026-04-28 05:10:09 +00:00
}
2026-05-06 18:07:11 +00:00
const isActiveSession = _isSessionCurrentPane ( activeSid ) ;
if ( isActiveSession ) {
fix: remove deprecated btnCancel; localise composer tooltips with disabled reason branching
- Drop btnCancel element and all JS show/hide call sites across
boot.js, messages.js, sessions.js, ui.js (superseded by single
primary action button)
- Remove .cancel-btn CSS rules including mobile media-query override
- Route updateSendBtn() title/aria-label through t() with English
fallbacks; add composer_send/queue/interrupt/steer/stop keys to all
7 locales (en, ru, es, de, zh, zh-Hant, ko)
- Branch disabled-state tooltip on reason: clarify lock, compression
running, or idle-empty, each with its own i18n key
- Update test_sprint10 / test_sprint36 to reflect single-button model:
assert btnSend present and id="btnCancel" absent; replace
test_hides_cancel_button with test_clears_composer_status
2026-04-28 06:31:11 +00:00
S . activeStreamId = null ;
2026-04-16 03:25:31 +00:00
clearLiveToolCards ( ) ; if ( ! assistantText ) removeThinking ( ) ;
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
S . session = session ; S . messages = ( session . messages || [ ] ) . filter ( m => m && m . role ) ;
2026-05-01 08:36:53 +00:00
if ( S . session && S . session . session _id ) {
localStorage . setItem ( 'hermes-webui-session' , S . session . session _id ) ;
if ( typeof _setActiveSessionUrl === 'function' ) _setActiveSessionUrl ( S . session . session _id ) ;
}
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
const hasMessageToolMetadata = S . messages . some ( m => {
if ( ! m || m . role !== 'assistant' ) return false ;
fix(cancel-stream): rename tool_calls to _partial_tool_calls (Opus MUST-FIX)
Opus pass-2 review of v0.50.251 caught a critical regression in PR
#1375:
The cancel-partial message stored captured tool calls under the
'tool_calls' key. That key is whitelisted by _API_SAFE_MSG_KEYS so
_sanitize_messages_for_api forwarded the entries to the next-turn
LLM call. But the captured entries use the WebUI internal shape
({name, args, done, duration, is_error}) — they don't have the
OpenAI/Anthropic id + function: {name, arguments} envelope. Strict
providers (OpenAI, Anthropic, Z.AI/GLM) would 400 on the malformed
entries. Net effect: the very cancel-then-continue scenario PR
#1375 aimed to improve becomes a hard fail.
Fix:
- Rename the persisted key to '_partial_tool_calls' (underscore-
prefixed private key NOT in _API_SAFE_MSG_KEYS, so sanitize
correctly strips it).
- Update static/messages.js hasMessageToolMetadata check to also
recognize _partial_tool_calls for UI rendering.
- Update test_issue1361_cancel_data_loss.py assertion to check
_partial_tool_calls (and tool_calls as legacy fallback).
Plus 2 NIT fixes from the same Opus review:
NIT 1 (api/profiles.py:153): re.match → re.fullmatch for consistency
with other _PROFILE_ID_RE callers in the codebase. The trailing-
newline footgun ($ matches before final \n in re.match) is now
closed. Without #1373's is_dir() guard, a name like 'valid\n' would
have created a directory named 'valid\n' on Linux. Doesn't escape
<HERMES_HOME>/profiles/ via Path joining, but unintended.
NIT 2 (test_issue798.py): R19j coverage gaps — added trailing-
newline tests, length-boundary tests (64-char valid, 65-char
rejected), single-char minimum, and non-ASCII / Unicode-trick tests.
New regression test (tests/test_pr1375_partial_tool_calls_sanitize.py):
- test_partial_tool_calls_field_not_forwarded_to_llm: pins that
sanitize-for-API strips _partial_tool_calls + reasoning + does
NOT have tool_calls on a partial message
- test_legitimate_tool_calls_are_preserved_for_completed_turns:
pins that real OpenAI-shape tool_calls on completed turns survive
sanitize unchanged
Tests: 3486 passing (3484 → 3486, +2 sanitize tests).
2026-04-30 23:43:23 +00:00
// Recognize both the standard `tool_calls` (used by completed assistant
// turns where the LLM emitted tool_call entries) and the WebUI-internal
// `_partial_tool_calls` (used on Stop/Cancel partial messages — see
// api/streaming.py cancel_stream).
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
const hasTc = Array . isArray ( m . tool _calls ) && m . tool _calls . length > 0 ;
fix(cancel-stream): rename tool_calls to _partial_tool_calls (Opus MUST-FIX)
Opus pass-2 review of v0.50.251 caught a critical regression in PR
#1375:
The cancel-partial message stored captured tool calls under the
'tool_calls' key. That key is whitelisted by _API_SAFE_MSG_KEYS so
_sanitize_messages_for_api forwarded the entries to the next-turn
LLM call. But the captured entries use the WebUI internal shape
({name, args, done, duration, is_error}) — they don't have the
OpenAI/Anthropic id + function: {name, arguments} envelope. Strict
providers (OpenAI, Anthropic, Z.AI/GLM) would 400 on the malformed
entries. Net effect: the very cancel-then-continue scenario PR
#1375 aimed to improve becomes a hard fail.
Fix:
- Rename the persisted key to '_partial_tool_calls' (underscore-
prefixed private key NOT in _API_SAFE_MSG_KEYS, so sanitize
correctly strips it).
- Update static/messages.js hasMessageToolMetadata check to also
recognize _partial_tool_calls for UI rendering.
- Update test_issue1361_cancel_data_loss.py assertion to check
_partial_tool_calls (and tool_calls as legacy fallback).
Plus 2 NIT fixes from the same Opus review:
NIT 1 (api/profiles.py:153): re.match → re.fullmatch for consistency
with other _PROFILE_ID_RE callers in the codebase. The trailing-
newline footgun ($ matches before final \n in re.match) is now
closed. Without #1373's is_dir() guard, a name like 'valid\n' would
have created a directory named 'valid\n' on Linux. Doesn't escape
<HERMES_HOME>/profiles/ via Path joining, but unintended.
NIT 2 (test_issue798.py): R19j coverage gaps — added trailing-
newline tests, length-boundary tests (64-char valid, 65-char
rejected), single-char minimum, and non-ASCII / Unicode-trick tests.
New regression test (tests/test_pr1375_partial_tool_calls_sanitize.py):
- test_partial_tool_calls_field_not_forwarded_to_llm: pins that
sanitize-for-API strips _partial_tool_calls + reasoning + does
NOT have tool_calls on a partial message
- test_legitimate_tool_calls_are_preserved_for_completed_turns:
pins that real OpenAI-shape tool_calls on completed turns survive
sanitize unchanged
Tests: 3486 passing (3484 → 3486, +2 sanitize tests).
2026-04-30 23:43:23 +00:00
const hasPartialTc = Array . isArray ( m . _partial _tool _calls ) && m . _partial _tool _calls . length > 0 ;
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
const hasTu = Array . isArray ( m . content ) && m . content . some ( p => p && p . type === 'tool_use' ) ;
fix(cancel-stream): rename tool_calls to _partial_tool_calls (Opus MUST-FIX)
Opus pass-2 review of v0.50.251 caught a critical regression in PR
#1375:
The cancel-partial message stored captured tool calls under the
'tool_calls' key. That key is whitelisted by _API_SAFE_MSG_KEYS so
_sanitize_messages_for_api forwarded the entries to the next-turn
LLM call. But the captured entries use the WebUI internal shape
({name, args, done, duration, is_error}) — they don't have the
OpenAI/Anthropic id + function: {name, arguments} envelope. Strict
providers (OpenAI, Anthropic, Z.AI/GLM) would 400 on the malformed
entries. Net effect: the very cancel-then-continue scenario PR
#1375 aimed to improve becomes a hard fail.
Fix:
- Rename the persisted key to '_partial_tool_calls' (underscore-
prefixed private key NOT in _API_SAFE_MSG_KEYS, so sanitize
correctly strips it).
- Update static/messages.js hasMessageToolMetadata check to also
recognize _partial_tool_calls for UI rendering.
- Update test_issue1361_cancel_data_loss.py assertion to check
_partial_tool_calls (and tool_calls as legacy fallback).
Plus 2 NIT fixes from the same Opus review:
NIT 1 (api/profiles.py:153): re.match → re.fullmatch for consistency
with other _PROFILE_ID_RE callers in the codebase. The trailing-
newline footgun ($ matches before final \n in re.match) is now
closed. Without #1373's is_dir() guard, a name like 'valid\n' would
have created a directory named 'valid\n' on Linux. Doesn't escape
<HERMES_HOME>/profiles/ via Path joining, but unintended.
NIT 2 (test_issue798.py): R19j coverage gaps — added trailing-
newline tests, length-boundary tests (64-char valid, 65-char
rejected), single-char minimum, and non-ASCII / Unicode-trick tests.
New regression test (tests/test_pr1375_partial_tool_calls_sanitize.py):
- test_partial_tool_calls_field_not_forwarded_to_llm: pins that
sanitize-for-API strips _partial_tool_calls + reasoning + does
NOT have tool_calls on a partial message
- test_legitimate_tool_calls_are_preserved_for_completed_turns:
pins that real OpenAI-shape tool_calls on completed turns survive
sanitize unchanged
Tests: 3486 passing (3484 → 3486, +2 sanitize tests).
2026-04-30 23:43:23 +00:00
return hasTc || hasPartialTc || hasTu ;
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
} ) ;
if ( ! hasMessageToolMetadata && session . tool _calls && session . tool _calls . length ) {
S . toolCalls = ( session . tool _calls || [ ] ) . map ( tc => ( { ... tc , done : true } ) ) ;
} else {
S . toolCalls = [ ] ;
}
2026-04-28 09:23:24 +00:00
if ( isSessionViewed ) _markSessionViewed ( completedSid , session . message _count ? ? S . messages . length ) ;
2026-05-05 16:21:25 +00:00
syncTopbar ( ) ; renderMessages ( { preserveScroll : true } ) ;
2026-04-16 03:25:31 +00:00
}
2026-05-06 21:56:17 +00:00
if ( _isActiveSession ( ) ) _queueDrainSid = activeSid ;
2026-05-06 18:07:11 +00:00
renderSessionList ( ) ;
2026-05-06 21:56:17 +00:00
_setActivePaneIdleIfOwner ( ) ;
2026-04-16 03:25:31 +00:00
return true ;
} catch ( _ ) {
return false ;
}
}
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
function _handleStreamError ( ) {
2026-04-22 01:47:40 +00:00
// Opus review Q1: mirror done/apperror/cancel finalization so any pending rAF
// cannot fire after renderMessages() has settled the DOM with the error message.
2026-04-24 19:33:16 +00:00
if ( _persistTimer ) { clearTimeout ( _persistTimer ) ; _persistTimer = null ; }
2026-04-22 01:47:40 +00:00
_streamFinalized = true ;
2026-04-24 18:57:48 +00:00
if ( _pendingRafHandle !== null ) { cancelAnimationFrame ( _pendingRafHandle ) ; clearTimeout ( _pendingRafHandle ) ; _pendingRafHandle = null ; _renderPending = false ; }
2026-04-22 01:47:40 +00:00
if ( typeof finalizeThinkingCard === 'function' ) finalizeThinkingCard ( ) ;
2026-05-06 21:56:17 +00:00
_clearOwnerInflightState ( ) ;
2026-04-13 23:25:31 +00:00
_closeSource ( ) ;
2026-05-06 21:56:17 +00:00
_clearApprovalForOwner ( ) ;
_clearClarifyForOwner ( 'terminal' ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
if ( S . session && S . session . session _id === activeSid ) {
fix: remove deprecated btnCancel; localise composer tooltips with disabled reason branching
- Drop btnCancel element and all JS show/hide call sites across
boot.js, messages.js, sessions.js, ui.js (superseded by single
primary action button)
- Remove .cancel-btn CSS rules including mobile media-query override
- Route updateSendBtn() title/aria-label through t() with English
fallbacks; add composer_send/queue/interrupt/steer/stop keys to all
7 locales (en, ru, es, de, zh, zh-Hant, ko)
- Branch disabled-state tooltip on reason: clarify lock, compression
running, or idle-empty, each with its own i18n key
- Update test_sprint10 / test_sprint36 to reflect single-button model:
assert btnSend present and id="btnCancel" absent; replace
test_hides_cancel_button with test_clears_composer_status
2026-04-28 06:31:11 +00:00
S . activeStreamId = null ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
clearLiveToolCards ( ) ; if ( ! assistantText ) removeThinking ( ) ;
2026-05-05 16:21:25 +00:00
S . messages . push ( { role : 'assistant' , content : '**Error:** Connection lost' } ) ; renderMessages ( { preserveScroll : true } ) ;
2026-04-23 16:05:57 +00:00
_markSessionViewed ( activeSid , S . messages . length ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
} else {
if ( typeof trackBackgroundError === 'function' ) {
const _errTitle = ( typeof _allSessions !== 'undefined' && _allSessions . find ( s => s . session _id === activeSid ) || { } ) . title || null ;
trackBackgroundError ( activeSid , _errTitle , 'Connection lost' ) ;
2026-03-31 03:40:19 +00:00
}
}
2026-05-06 21:56:17 +00:00
_setActivePaneIdleIfOwner ( ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
}
2026-04-15 14:50:59 +00:00
( async ( ) => {
// Reattach path can carry stale stream ids after server restart; preflight
// status avoids opening a dead SSE URL that will 404 in the console.
if ( reconnecting ) {
try {
const st = await api ( ` /api/chat/stream/status?stream_id= ${ encodeURIComponent ( streamId ) } ` ) ;
if ( ! st . active ) {
2026-05-06 21:56:17 +00:00
_clearOwnerInflightState ( ) ;
_clearApprovalForOwner ( ) ;
_clearClarifyForOwner ( 'terminal' ) ;
2026-04-15 14:50:59 +00:00
if ( S . session && S . session . session _id === activeSid ) {
S . activeStreamId = null ;
clearLiveToolCards ( ) ;
removeThinking ( ) ;
2026-05-06 21:56:17 +00:00
if ( _isActiveSession ( ) ) _queueDrainSid = activeSid ;
_setActivePaneIdleIfOwner ( ) ;
2026-05-05 16:21:25 +00:00
renderMessages ( { preserveScroll : true } ) ;
2026-04-15 14:50:59 +00:00
renderSessionList ( ) ;
}
return ;
}
} catch ( _ ) { }
}
2026-05-01 04:47:36 +00:00
_wireSSE ( new EventSource ( new URL ( ` api/chat/stream?stream_id= ${ encodeURIComponent ( streamId ) } ` , document . baseURI || location . href ) . href , { withCredentials : true } ) ) ;
2026-04-15 14:50:59 +00:00
} ) ( ) ;
Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)
Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export
Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name
Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)
Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
2026-03-31 03:40:19 +00:00
}
function transcript ( ) {
const lines = [ ` # Hermes session ${ S . session ? . session _id || '' } ` , ` ` ,
` Workspace: ${ S . session ? . workspace || '' } ` , ` Model: ${ S . session ? . model || '' } ` , ` ` ] ;
for ( const m of S . messages ) {
if ( ! m || m . role === 'tool' ) continue ;
let c = m . content || '' ;
if ( Array . isArray ( c ) ) c = c . filter ( p => p && p . type === 'text' ) . map ( p => p . text || '' ) . join ( '\n' ) ;
const ct = String ( c ) . trim ( ) ;
if ( ! ct && ! m . attachments ? . length ) continue ;
const attach = m . attachments ? . length ? ` \n \n _Files: ${ m . attachments . join ( ', ' ) } _ ` : '' ;
lines . push ( ` ## ${ m . role } ` , '' , ct + attach , '' ) ;
}
return lines . join ( '\n' ) ;
}
feat: polish send button — hidden until content, icon-circle, pop-in animation
- index.html: btnSend hidden by default (display:none), icon-only (upward
arrow SVG, no text label), title attribute for accessibility
- style.css: new send-btn design — 34px circle, blue fill (#7cb9ff),
subtle glow box-shadow, scale() hover/active for tactile feel,
.send-btn.visible with @keyframes send-pop-in (scale+opacity spring
using cubic-bezier(.34,1.56,.64,1) for a satisfying pop). Mobile
override updated to preserve circle dimensions.
- ui.js: updateSendBtn() — shows button with pop-in animation when
textarea has content OR files are attached and agent is not busy;
hides instantly when content is cleared. Hooked into setBusy() and
renderTray() so button state tracks all content sources correctly.
- boot.js: input event listener calls updateSendBtn() on every keystroke.
- messages.js: autoResize() calls updateSendBtn() so button disappears
immediately after send clears the textarea.
- tests/test_sprint21.py: 33 tests covering HTML structure, CSS design
(circle shape, colors, animations, keyframes), JS logic (updateSendBtn,
setBusy, renderTray, autoResize integration), and regressions
(363 total, all pass).
2026-04-03 14:10:59 +00:00
function autoResize ( ) { const el = $ ( 'msg' ) ; el . style . height = 'auto' ; el . style . height = Math . min ( el . scrollHeight , 200 ) + 'px' ; updateSendBtn ( ) ; }
2026-03-31 03:40:19 +00:00
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
fix+feat: batch v0.50.236 — OAuth providers fix, profile switch UX, YOLO mode (#1211)
Merges PRs #1208, #1209, #1210 (#1152 rebased):
- fix(providers): OAuth provider cards show correct Configured status in Settings.
get_providers() was discarding has_key=True from _provider_has_key() for OAuth
providers, hiding config.yaml tokens. Also fixed filter excluding all OAuth providers
from the Settings panel. Surfaces auth_error string. (closes #1202)
- ux(profiles): profile chip shows spinner and new name immediately on switch.
Optimistic name update + .switching CSS class + chip disabled + finally cleanup.
populateModelDropdown() and loadWorkspaceList() now parallelized via Promise.all.
- feat: YOLO mode toggle — skip all approvals per session.
/yolo slash command, "Skip all this session" button on approval cards,
amber ⚡ pill indicator in composer footer. Session-scoped, in-memory.
Full i18n: en, ru, es, de, zh, ko, zh-Hant. (closes #467)
Original author: @bergeouss (PR #1152)
Tests: 2837 passed (+50 new tests vs previous release)
QA harness: 20/20 passed + all browser API checks passed
2026-04-28 05:56:12 +00:00
// ── YOLO mode state ──
// Session-scoped; stored server-side in memory (tools/approval.py).
// Lifecycle:
// • Page reload: state PERSISTS — _fetchYoloState() re-syncs from backend.
// • Cross-tab: state is SHARED — enabling YOLO in Tab A affects Tab B for
// the same session (both poll the same server-side flag).
// • Server restart: state is LOST — in-memory only, not persisted to disk.
// • Session switch: state resets — loadSession() clears _yoloEnabled and
// fetches the new session's state.
let _yoloEnabled = false ;
async function _fetchYoloState ( sid ) {
try {
const data = await api ( '/api/session/yolo?session_id=' + encodeURIComponent ( sid ) ) ;
_yoloEnabled = ! ! data . yolo _enabled ;
_updateYoloPill ( ) ;
} catch ( _ ) { /* ignore */ }
}
function _updateYoloPill ( ) {
const pill = $ ( 'yoloPill' ) ;
if ( ! pill ) return ;
pill . style . display = _yoloEnabled ? '' : 'none' ;
if ( _yoloEnabled ) {
pill . title = t ( 'yolo_pill_title_active' ) ;
pill . setAttribute ( 'data-i18n-title' , 'yolo_pill_title_active' ) ;
}
if ( typeof applyLocaleToDOM === 'function' ) applyLocaleToDOM ( ) ;
}
async function toggleYoloFromApproval ( ) {
const sid = S . session && S . session . session _id ;
if ( ! sid ) return ;
try {
await api ( '/api/session/yolo' , {
method : 'POST' ,
body : JSON . stringify ( { session _id : sid , enabled : true } ) ,
} ) ;
_yoloEnabled = true ;
_updateYoloPill ( ) ;
hideApprovalCard ( true ) ;
showToast ( t ( 'yolo_enabled' ) ) ;
} catch ( e ) { showToast ( 'YOLO: ' + e . message ) ; }
}
2026-03-31 03:40:19 +00:00
// ── Approval polling ──
let _approvalPollTimer = null ;
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons
* fix: approval pending check broken by stale has_pending import (#228)
api/routes.py imported has_pending/pop_pending from tools.approval, but the
agent module renamed has_pending to has_blocking_approval (checks gateway
queue, not _pending dict) and removed pop_pending. The import fell through
to fallback lambdas that always returned False, making GET /api/approval/pending
always return {pending:null} even after a successful inject_test.
Fix: check _pending directly under _lock — same dict submit_pending writes to.
Stale imports removed.
Before: 554 pass, 1 fail | After: 555 pass, 0 fail
* fix: move login JS into external file, remove inline handlers (#226)
Login page used inline onsubmit/onkeydown handlers and an inline <script>
block — all blocked by strict script-src CSP, causing silent login failure.
Fix: extract doLogin() and Enter key listener into static/login.js (served
from /static/, already a public path). Form uses id='login-form' and
data-* attributes for i18n strings instead of injected JS literals.
Also guards res.json() parse with try/catch so non-JSON error bodies
(e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'.
Fixes #222.
* fix: improve update error messages when pull fails (#227)
_apply_update_inner() ran git pull --ff-only and returned only raw stderr
on failure, making all failure modes indistinguishable.
Fix: explicit git fetch before pull; if fetch fails, returns human-readable
network error. Diverged history and missing upstream tracking branch each
get distinct messages with exact recovery commands. Generic fallback
truncates to 300 chars and shows sentinel when git produces no output.
Also adds tests/test_update_checker.py with 13 tests covering all 4 new
diagnostic code paths (0 tests existed before).
Fixes #223.
* fix: stabilize 30s terminal approval prompt visibility (#225)
Adds minimum 30-second visibility guard for the approval card using
_approvalVisibleSince, _approvalHideTimer, and a signature fingerprint
to deduplicate repeated poll ticks.
Fix: respondApproval() and all stream-end paths (done/cancel/apperror/
error/start-error) now call hideApprovalCard(true) so the card hides
immediately when the user responds or the session ends. The 30s guard
only applies to mid-session poll ticks where the approval is still live
but briefly absent.
Adds 11 structural tests covering the new timer variables, force
parameter, force-on-respond, force-on-stream-end, and poll-loop
no-force behavior.
* feat: replace emoji icons with self-hosted Lucide SVG icons (#221)
Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled
in static/icons.js (no CDN dependency). Adds li(name) function returning
inline SVG geometry from a hardcoded whitelist — unknown keys return '' so
dynamic server-supplied names never inject arbitrary SVG.
Changes:
- static/icons.js: new file with 21 icon paths + li() renderer
- static/index.html: all nav/action buttons now use li() icons
- static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons
- static/messages.js: cancelStream button uses SVG square stop icon
- .gitignore: adds node_modules/ entry
Verified: all 35 onclick= functions exist in JS, all 21 li() calls
reference defined icons, applyBotName() selectors intact, version
label present, no removed IDs referenced by JS.
* docs: v0.44.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
let _approvalHideTimer = null ;
let _approvalVisibleSince = 0 ;
let _approvalSignature = '' ;
const APPROVAL _MIN _VISIBLE _MS = 30000 ;
2026-03-31 03:40:19 +00:00
// showApprovalCard moved above respondApproval
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons
* fix: approval pending check broken by stale has_pending import (#228)
api/routes.py imported has_pending/pop_pending from tools.approval, but the
agent module renamed has_pending to has_blocking_approval (checks gateway
queue, not _pending dict) and removed pop_pending. The import fell through
to fallback lambdas that always returned False, making GET /api/approval/pending
always return {pending:null} even after a successful inject_test.
Fix: check _pending directly under _lock — same dict submit_pending writes to.
Stale imports removed.
Before: 554 pass, 1 fail | After: 555 pass, 0 fail
* fix: move login JS into external file, remove inline handlers (#226)
Login page used inline onsubmit/onkeydown handlers and an inline <script>
block — all blocked by strict script-src CSP, causing silent login failure.
Fix: extract doLogin() and Enter key listener into static/login.js (served
from /static/, already a public path). Form uses id='login-form' and
data-* attributes for i18n strings instead of injected JS literals.
Also guards res.json() parse with try/catch so non-JSON error bodies
(e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'.
Fixes #222.
* fix: improve update error messages when pull fails (#227)
_apply_update_inner() ran git pull --ff-only and returned only raw stderr
on failure, making all failure modes indistinguishable.
Fix: explicit git fetch before pull; if fetch fails, returns human-readable
network error. Diverged history and missing upstream tracking branch each
get distinct messages with exact recovery commands. Generic fallback
truncates to 300 chars and shows sentinel when git produces no output.
Also adds tests/test_update_checker.py with 13 tests covering all 4 new
diagnostic code paths (0 tests existed before).
Fixes #223.
* fix: stabilize 30s terminal approval prompt visibility (#225)
Adds minimum 30-second visibility guard for the approval card using
_approvalVisibleSince, _approvalHideTimer, and a signature fingerprint
to deduplicate repeated poll ticks.
Fix: respondApproval() and all stream-end paths (done/cancel/apperror/
error/start-error) now call hideApprovalCard(true) so the card hides
immediately when the user responds or the session ends. The 30s guard
only applies to mid-session poll ticks where the approval is still live
but briefly absent.
Adds 11 structural tests covering the new timer variables, force
parameter, force-on-respond, force-on-stream-end, and poll-loop
no-force behavior.
* feat: replace emoji icons with self-hosted Lucide SVG icons (#221)
Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled
in static/icons.js (no CDN dependency). Adds li(name) function returning
inline SVG geometry from a hardcoded whitelist — unknown keys return '' so
dynamic server-supplied names never inject arbitrary SVG.
Changes:
- static/icons.js: new file with 21 icon paths + li() renderer
- static/index.html: all nav/action buttons now use li() icons
- static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons
- static/messages.js: cancelStream button uses SVG square stop icon
- .gitignore: adds node_modules/ entry
Verified: all 35 onclick= functions exist in JS, all 21 li() calls
reference defined icons, applyBotName() selectors intact, version
label present, no removed IDs referenced by JS.
* docs: v0.44.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
function _clearApprovalHideTimer ( ) {
if ( _approvalHideTimer ) {
clearTimeout ( _approvalHideTimer ) ;
_approvalHideTimer = null ;
}
}
function _resetApprovalCardState ( ) {
_clearApprovalHideTimer ( ) ;
_approvalVisibleSince = 0 ;
_approvalSignature = '' ;
}
function hideApprovalCard ( force = false ) {
const card = $ ( "approvalCard" ) ;
if ( ! card ) return ;
if ( ! force && _approvalVisibleSince ) {
const remaining = APPROVAL _MIN _VISIBLE _MS - ( Date . now ( ) - _approvalVisibleSince ) ;
if ( remaining > 0 ) {
const scheduledSignature = _approvalSignature ;
_clearApprovalHideTimer ( ) ;
_approvalHideTimer = setTimeout ( ( ) => {
_approvalHideTimer = null ;
if ( _approvalSignature !== scheduledSignature ) return ;
hideApprovalCard ( true ) ;
} , remaining ) ;
return ;
}
}
_approvalSessionId = null ;
_resetApprovalCardState ( ) ;
card . classList . remove ( "visible" ) ;
2026-03-31 03:40:19 +00:00
$ ( "approvalCmd" ) . textContent = "" ;
$ ( "approvalDesc" ) . textContent = "" ;
}
// Track session_id of the active approval so respond goes to the right session
let _approvalSessionId = null ;
2026-04-15 19:16:14 +00:00
let _approvalCurrentId = null ; // approval_id of the card currently shown
2026-05-07 15:14:30 +00:00
let _approvalPendingBySession = new Map ( ) ;
function _promptActiveSessionId ( ) {
return ( S . session && S . session . session _id ) || null ;
}
function _approvalPromptBelongsToActiveSession ( sid ) {
return ! ! ( sid && _promptActiveSessionId ( ) === sid ) ;
}
function _rememberApprovalPending ( pending , pendingCount ) {
if ( ! pending ) return null ;
const sid = pending . _session _id || _promptActiveSessionId ( ) ;
if ( ! sid ) return null ;
const nextPending = { ... pending , _session _id : sid } ;
_approvalPendingBySession . set ( sid , { pending : nextPending , pendingCount : pendingCount || 1 } ) ;
return sid ;
}
function _clearApprovalPendingForSession ( sid ) {
if ( sid ) _approvalPendingBySession . delete ( sid ) ;
}
function _hideApprovalCardIfOwner ( sid , force = false ) {
if ( ! sid || _approvalSessionId === sid ) hideApprovalCard ( force ) ;
}
function _renderPendingApprovalForActiveSession ( ) {
const sid = _promptActiveSessionId ( ) ;
if ( ! sid ) return ;
if ( _approvalSessionId && _approvalSessionId !== sid ) hideApprovalCard ( true ) ;
const entry = _approvalPendingBySession . get ( sid ) ;
if ( entry ) showApprovalCard ( entry . pending , entry . pendingCount ) ;
}
function showApprovalForSession ( sid , pending , pendingCount ) {
if ( ! pending ) return ;
pending . _session _id = sid ;
showApprovalCard ( pending , pendingCount ) ;
}
2026-03-31 03:40:19 +00:00
2026-04-15 19:16:14 +00:00
function showApprovalCard ( pending , pendingCount ) {
2026-05-07 15:14:30 +00:00
const sid = _rememberApprovalPending ( pending , pendingCount ) ;
if ( ! _approvalPromptBelongsToActiveSession ( sid ) ) return ;
2026-03-31 03:40:19 +00:00
const keys = pending . pattern _keys || ( pending . pattern _key ? [ pending . pattern _key ] : [ ] ) ;
fix: surface approval prompt in UI instead of getting stuck in Thinking (#187)
* fix: surface approval prompt in UI instead of getting stuck in Thinking
When a dangerous command was detected during streaming, the approval system
would call submit_pending() but no SSE 'approval' event would be emitted to
the frontend. The agent thread either blocked indefinitely (gateway path) or
returned an approval_required status the UI never saw (EXEC_ASK path). Either
way the chat UI stayed stuck in 'Thinking...' with no prompt shown.
Root cause: streaming.py used HERMES_EXEC_ASK=1 but never registered a
register_gateway_notify() callback. Without it, check_all_command_guards()
fell back to the legacy polling path (submit_pending only), which relies on
on_tool() polling -- but on_tool() fires *before* the tool runs, so by the
time the terminal tool detected the dangerous command and called submit_pending,
the approval event had already missed its window.
Fix (streaming.py):
- Register a gateway-style notify_cb via register_gateway_notify() before the
agent runs. The callback calls put('approval', ...) to emit the SSE event
the moment a dangerous command is detected, regardless of on_tool() timing.
- Unregister via unregister_gateway_notify() in the finally block to unblock
any threads still waiting if the stream ends or is cancelled mid-approval.
- Keep the on_tool() fallback poll for older approval module versions.
Fix (routes.py):
- Import and call resolve_gateway_approval() in _handle_approval_respond().
This unblocks the agent thread parked in entry.event.wait() when the user
clicks Allow or Deny in the UI. Without this call the thread would block
until the 5-minute gateway timeout.
Tests (tests/test_approval_unblock.py):
- 16 new tests covering: resolve_gateway_approval() event signalling, deny/
session/once choices, resolve_all, notify_cb registration/firing/cleanup,
unregister signals blocked entries, full end-to-end streaming simulation,
module symbol exports, and HTTP endpoint regressions.
515 tests pass (499 existing + 16 new).
* feat: full approval UI — i18n buttons, keyboard shortcut, loading state, scoping fix
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 03:16:22 +00:00
const desc = ( pending . description || "" ) + ( keys . length ? " [" + keys . join ( ", " ) + "]" : "" ) ;
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons
* fix: approval pending check broken by stale has_pending import (#228)
api/routes.py imported has_pending/pop_pending from tools.approval, but the
agent module renamed has_pending to has_blocking_approval (checks gateway
queue, not _pending dict) and removed pop_pending. The import fell through
to fallback lambdas that always returned False, making GET /api/approval/pending
always return {pending:null} even after a successful inject_test.
Fix: check _pending directly under _lock — same dict submit_pending writes to.
Stale imports removed.
Before: 554 pass, 1 fail | After: 555 pass, 0 fail
* fix: move login JS into external file, remove inline handlers (#226)
Login page used inline onsubmit/onkeydown handlers and an inline <script>
block — all blocked by strict script-src CSP, causing silent login failure.
Fix: extract doLogin() and Enter key listener into static/login.js (served
from /static/, already a public path). Form uses id='login-form' and
data-* attributes for i18n strings instead of injected JS literals.
Also guards res.json() parse with try/catch so non-JSON error bodies
(e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'.
Fixes #222.
* fix: improve update error messages when pull fails (#227)
_apply_update_inner() ran git pull --ff-only and returned only raw stderr
on failure, making all failure modes indistinguishable.
Fix: explicit git fetch before pull; if fetch fails, returns human-readable
network error. Diverged history and missing upstream tracking branch each
get distinct messages with exact recovery commands. Generic fallback
truncates to 300 chars and shows sentinel when git produces no output.
Also adds tests/test_update_checker.py with 13 tests covering all 4 new
diagnostic code paths (0 tests existed before).
Fixes #223.
* fix: stabilize 30s terminal approval prompt visibility (#225)
Adds minimum 30-second visibility guard for the approval card using
_approvalVisibleSince, _approvalHideTimer, and a signature fingerprint
to deduplicate repeated poll ticks.
Fix: respondApproval() and all stream-end paths (done/cancel/apperror/
error/start-error) now call hideApprovalCard(true) so the card hides
immediately when the user responds or the session ends. The 30s guard
only applies to mid-session poll ticks where the approval is still live
but briefly absent.
Adds 11 structural tests covering the new timer variables, force
parameter, force-on-respond, force-on-stream-end, and poll-loop
no-force behavior.
* feat: replace emoji icons with self-hosted Lucide SVG icons (#221)
Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled
in static/icons.js (no CDN dependency). Adds li(name) function returning
inline SVG geometry from a hardcoded whitelist — unknown keys return '' so
dynamic server-supplied names never inject arbitrary SVG.
Changes:
- static/icons.js: new file with 21 icon paths + li() renderer
- static/index.html: all nav/action buttons now use li() icons
- static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons
- static/messages.js: cancelStream button uses SVG square stop icon
- .gitignore: adds node_modules/ entry
Verified: all 35 onclick= functions exist in JS, all 21 li() calls
reference defined icons, applyBotName() selectors intact, version
label present, no removed IDs referenced by JS.
* docs: v0.44.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
const cmd = pending . command || "" ;
const sig = JSON . stringify ( { desc , cmd , sid : pending . _session _id || ( S . session && S . session . session _id ) || null } ) ;
const card = $ ( "approvalCard" ) ;
const sameApproval = card . classList . contains ( "visible" ) && _approvalSignature === sig ;
fix: surface approval prompt in UI instead of getting stuck in Thinking (#187)
* fix: surface approval prompt in UI instead of getting stuck in Thinking
When a dangerous command was detected during streaming, the approval system
would call submit_pending() but no SSE 'approval' event would be emitted to
the frontend. The agent thread either blocked indefinitely (gateway path) or
returned an approval_required status the UI never saw (EXEC_ASK path). Either
way the chat UI stayed stuck in 'Thinking...' with no prompt shown.
Root cause: streaming.py used HERMES_EXEC_ASK=1 but never registered a
register_gateway_notify() callback. Without it, check_all_command_guards()
fell back to the legacy polling path (submit_pending only), which relies on
on_tool() polling -- but on_tool() fires *before* the tool runs, so by the
time the terminal tool detected the dangerous command and called submit_pending,
the approval event had already missed its window.
Fix (streaming.py):
- Register a gateway-style notify_cb via register_gateway_notify() before the
agent runs. The callback calls put('approval', ...) to emit the SSE event
the moment a dangerous command is detected, regardless of on_tool() timing.
- Unregister via unregister_gateway_notify() in the finally block to unblock
any threads still waiting if the stream ends or is cancelled mid-approval.
- Keep the on_tool() fallback poll for older approval module versions.
Fix (routes.py):
- Import and call resolve_gateway_approval() in _handle_approval_respond().
This unblocks the agent thread parked in entry.event.wait() when the user
clicks Allow or Deny in the UI. Without this call the thread would block
until the 5-minute gateway timeout.
Tests (tests/test_approval_unblock.py):
- 16 new tests covering: resolve_gateway_approval() event signalling, deny/
session/once choices, resolve_all, notify_cb registration/firing/cleanup,
unregister signals blocked entries, full end-to-end streaming simulation,
module symbol exports, and HTTP endpoint regressions.
515 tests pass (499 existing + 16 new).
* feat: full approval UI — i18n buttons, keyboard shortcut, loading state, scoping fix
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 03:16:22 +00:00
$ ( "approvalDesc" ) . textContent = desc ;
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons
* fix: approval pending check broken by stale has_pending import (#228)
api/routes.py imported has_pending/pop_pending from tools.approval, but the
agent module renamed has_pending to has_blocking_approval (checks gateway
queue, not _pending dict) and removed pop_pending. The import fell through
to fallback lambdas that always returned False, making GET /api/approval/pending
always return {pending:null} even after a successful inject_test.
Fix: check _pending directly under _lock — same dict submit_pending writes to.
Stale imports removed.
Before: 554 pass, 1 fail | After: 555 pass, 0 fail
* fix: move login JS into external file, remove inline handlers (#226)
Login page used inline onsubmit/onkeydown handlers and an inline <script>
block — all blocked by strict script-src CSP, causing silent login failure.
Fix: extract doLogin() and Enter key listener into static/login.js (served
from /static/, already a public path). Form uses id='login-form' and
data-* attributes for i18n strings instead of injected JS literals.
Also guards res.json() parse with try/catch so non-JSON error bodies
(e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'.
Fixes #222.
* fix: improve update error messages when pull fails (#227)
_apply_update_inner() ran git pull --ff-only and returned only raw stderr
on failure, making all failure modes indistinguishable.
Fix: explicit git fetch before pull; if fetch fails, returns human-readable
network error. Diverged history and missing upstream tracking branch each
get distinct messages with exact recovery commands. Generic fallback
truncates to 300 chars and shows sentinel when git produces no output.
Also adds tests/test_update_checker.py with 13 tests covering all 4 new
diagnostic code paths (0 tests existed before).
Fixes #223.
* fix: stabilize 30s terminal approval prompt visibility (#225)
Adds minimum 30-second visibility guard for the approval card using
_approvalVisibleSince, _approvalHideTimer, and a signature fingerprint
to deduplicate repeated poll ticks.
Fix: respondApproval() and all stream-end paths (done/cancel/apperror/
error/start-error) now call hideApprovalCard(true) so the card hides
immediately when the user responds or the session ends. The 30s guard
only applies to mid-session poll ticks where the approval is still live
but briefly absent.
Adds 11 structural tests covering the new timer variables, force
parameter, force-on-respond, force-on-stream-end, and poll-loop
no-force behavior.
* feat: replace emoji icons with self-hosted Lucide SVG icons (#221)
Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled
in static/icons.js (no CDN dependency). Adds li(name) function returning
inline SVG geometry from a hardcoded whitelist — unknown keys return '' so
dynamic server-supplied names never inject arbitrary SVG.
Changes:
- static/icons.js: new file with 21 icon paths + li() renderer
- static/index.html: all nav/action buttons now use li() icons
- static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons
- static/messages.js: cancelStream button uses SVG square stop icon
- .gitignore: adds node_modules/ entry
Verified: all 35 onclick= functions exist in JS, all 21 li() calls
reference defined icons, applyBotName() selectors intact, version
label present, no removed IDs referenced by JS.
* docs: v0.44.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
$ ( "approvalCmd" ) . textContent = cmd ;
2026-05-07 15:14:30 +00:00
_approvalSessionId = sid ;
2026-04-15 19:16:14 +00:00
_approvalCurrentId = pending . approval _id || null ;
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons
* fix: approval pending check broken by stale has_pending import (#228)
api/routes.py imported has_pending/pop_pending from tools.approval, but the
agent module renamed has_pending to has_blocking_approval (checks gateway
queue, not _pending dict) and removed pop_pending. The import fell through
to fallback lambdas that always returned False, making GET /api/approval/pending
always return {pending:null} even after a successful inject_test.
Fix: check _pending directly under _lock — same dict submit_pending writes to.
Stale imports removed.
Before: 554 pass, 1 fail | After: 555 pass, 0 fail
* fix: move login JS into external file, remove inline handlers (#226)
Login page used inline onsubmit/onkeydown handlers and an inline <script>
block — all blocked by strict script-src CSP, causing silent login failure.
Fix: extract doLogin() and Enter key listener into static/login.js (served
from /static/, already a public path). Form uses id='login-form' and
data-* attributes for i18n strings instead of injected JS literals.
Also guards res.json() parse with try/catch so non-JSON error bodies
(e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'.
Fixes #222.
* fix: improve update error messages when pull fails (#227)
_apply_update_inner() ran git pull --ff-only and returned only raw stderr
on failure, making all failure modes indistinguishable.
Fix: explicit git fetch before pull; if fetch fails, returns human-readable
network error. Diverged history and missing upstream tracking branch each
get distinct messages with exact recovery commands. Generic fallback
truncates to 300 chars and shows sentinel when git produces no output.
Also adds tests/test_update_checker.py with 13 tests covering all 4 new
diagnostic code paths (0 tests existed before).
Fixes #223.
* fix: stabilize 30s terminal approval prompt visibility (#225)
Adds minimum 30-second visibility guard for the approval card using
_approvalVisibleSince, _approvalHideTimer, and a signature fingerprint
to deduplicate repeated poll ticks.
Fix: respondApproval() and all stream-end paths (done/cancel/apperror/
error/start-error) now call hideApprovalCard(true) so the card hides
immediately when the user responds or the session ends. The 30s guard
only applies to mid-session poll ticks where the approval is still live
but briefly absent.
Adds 11 structural tests covering the new timer variables, force
parameter, force-on-respond, force-on-stream-end, and poll-loop
no-force behavior.
* feat: replace emoji icons with self-hosted Lucide SVG icons (#221)
Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled
in static/icons.js (no CDN dependency). Adds li(name) function returning
inline SVG geometry from a hardcoded whitelist — unknown keys return '' so
dynamic server-supplied names never inject arbitrary SVG.
Changes:
- static/icons.js: new file with 21 icon paths + li() renderer
- static/index.html: all nav/action buttons now use li() icons
- static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons
- static/messages.js: cancelStream button uses SVG square stop icon
- .gitignore: adds node_modules/ entry
Verified: all 35 onclick= functions exist in JS, all 21 li() calls
reference defined icons, applyBotName() selectors intact, version
label present, no removed IDs referenced by JS.
* docs: v0.44.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
_approvalSignature = sig ;
2026-04-15 19:16:14 +00:00
// Show "1 of N" counter when multiple approvals are queued
const counter = $ ( "approvalCounter" ) ;
if ( counter ) {
if ( pendingCount && pendingCount > 1 ) {
counter . textContent = "1 of " + pendingCount + " pending" ;
counter . style . display = "" ;
} else {
counter . style . display = "none" ;
}
}
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons
* fix: approval pending check broken by stale has_pending import (#228)
api/routes.py imported has_pending/pop_pending from tools.approval, but the
agent module renamed has_pending to has_blocking_approval (checks gateway
queue, not _pending dict) and removed pop_pending. The import fell through
to fallback lambdas that always returned False, making GET /api/approval/pending
always return {pending:null} even after a successful inject_test.
Fix: check _pending directly under _lock — same dict submit_pending writes to.
Stale imports removed.
Before: 554 pass, 1 fail | After: 555 pass, 0 fail
* fix: move login JS into external file, remove inline handlers (#226)
Login page used inline onsubmit/onkeydown handlers and an inline <script>
block — all blocked by strict script-src CSP, causing silent login failure.
Fix: extract doLogin() and Enter key listener into static/login.js (served
from /static/, already a public path). Form uses id='login-form' and
data-* attributes for i18n strings instead of injected JS literals.
Also guards res.json() parse with try/catch so non-JSON error bodies
(e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'.
Fixes #222.
* fix: improve update error messages when pull fails (#227)
_apply_update_inner() ran git pull --ff-only and returned only raw stderr
on failure, making all failure modes indistinguishable.
Fix: explicit git fetch before pull; if fetch fails, returns human-readable
network error. Diverged history and missing upstream tracking branch each
get distinct messages with exact recovery commands. Generic fallback
truncates to 300 chars and shows sentinel when git produces no output.
Also adds tests/test_update_checker.py with 13 tests covering all 4 new
diagnostic code paths (0 tests existed before).
Fixes #223.
* fix: stabilize 30s terminal approval prompt visibility (#225)
Adds minimum 30-second visibility guard for the approval card using
_approvalVisibleSince, _approvalHideTimer, and a signature fingerprint
to deduplicate repeated poll ticks.
Fix: respondApproval() and all stream-end paths (done/cancel/apperror/
error/start-error) now call hideApprovalCard(true) so the card hides
immediately when the user responds or the session ends. The 30s guard
only applies to mid-session poll ticks where the approval is still live
but briefly absent.
Adds 11 structural tests covering the new timer variables, force
parameter, force-on-respond, force-on-stream-end, and poll-loop
no-force behavior.
* feat: replace emoji icons with self-hosted Lucide SVG icons (#221)
Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled
in static/icons.js (no CDN dependency). Adds li(name) function returning
inline SVG geometry from a hardcoded whitelist — unknown keys return '' so
dynamic server-supplied names never inject arbitrary SVG.
Changes:
- static/icons.js: new file with 21 icon paths + li() renderer
- static/index.html: all nav/action buttons now use li() icons
- static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons
- static/messages.js: cancelStream button uses SVG square stop icon
- .gitignore: adds node_modules/ entry
Verified: all 35 onclick= functions exist in JS, all 21 li() calls
reference defined icons, applyBotName() selectors intact, version
label present, no removed IDs referenced by JS.
* docs: v0.44.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
if ( ! sameApproval ) {
_approvalVisibleSince = Date . now ( ) ;
_clearApprovalHideTimer ( ) ;
}
fix: surface approval prompt in UI instead of getting stuck in Thinking (#187)
* fix: surface approval prompt in UI instead of getting stuck in Thinking
When a dangerous command was detected during streaming, the approval system
would call submit_pending() but no SSE 'approval' event would be emitted to
the frontend. The agent thread either blocked indefinitely (gateway path) or
returned an approval_required status the UI never saw (EXEC_ASK path). Either
way the chat UI stayed stuck in 'Thinking...' with no prompt shown.
Root cause: streaming.py used HERMES_EXEC_ASK=1 but never registered a
register_gateway_notify() callback. Without it, check_all_command_guards()
fell back to the legacy polling path (submit_pending only), which relies on
on_tool() polling -- but on_tool() fires *before* the tool runs, so by the
time the terminal tool detected the dangerous command and called submit_pending,
the approval event had already missed its window.
Fix (streaming.py):
- Register a gateway-style notify_cb via register_gateway_notify() before the
agent runs. The callback calls put('approval', ...) to emit the SSE event
the moment a dangerous command is detected, regardless of on_tool() timing.
- Unregister via unregister_gateway_notify() in the finally block to unblock
any threads still waiting if the stream ends or is cancelled mid-approval.
- Keep the on_tool() fallback poll for older approval module versions.
Fix (routes.py):
- Import and call resolve_gateway_approval() in _handle_approval_respond().
This unblocks the agent thread parked in entry.event.wait() when the user
clicks Allow or Deny in the UI. Without this call the thread would block
until the 5-minute gateway timeout.
Tests (tests/test_approval_unblock.py):
- 16 new tests covering: resolve_gateway_approval() event signalling, deny/
session/once choices, resolve_all, notify_cb registration/firing/cleanup,
unregister signals blocked entries, full end-to-end streaming simulation,
module symbol exports, and HTTP endpoint regressions.
515 tests pass (499 existing + 16 new).
* feat: full approval UI — i18n buttons, keyboard shortcut, loading state, scoping fix
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 03:16:22 +00:00
// Re-enable buttons in case a previous approval disabled them
[ "approvalBtnOnce" , "approvalBtnSession" , "approvalBtnAlways" , "approvalBtnDeny" ] . forEach ( id => {
const b = $ ( id ) ; if ( b ) { b . disabled = false ; b . classList . remove ( "loading" ) ; }
} ) ;
card . classList . add ( "visible" ) ;
if ( typeof applyLocaleToDOM === "function" ) applyLocaleToDOM ( ) ;
const onceBtn = $ ( "approvalBtnOnce" ) ;
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
if ( onceBtn ) setTimeout ( ( ) => onceBtn . focus ( { preventScroll : true } ) , 50 ) ;
2026-03-31 03:40:19 +00:00
}
async function respondApproval ( choice ) {
const sid = _approvalSessionId || ( S . session && S . session . session _id ) ;
if ( ! sid ) return ;
2026-04-15 19:16:14 +00:00
const approvalId = _approvalCurrentId ;
fix: surface approval prompt in UI instead of getting stuck in Thinking (#187)
* fix: surface approval prompt in UI instead of getting stuck in Thinking
When a dangerous command was detected during streaming, the approval system
would call submit_pending() but no SSE 'approval' event would be emitted to
the frontend. The agent thread either blocked indefinitely (gateway path) or
returned an approval_required status the UI never saw (EXEC_ASK path). Either
way the chat UI stayed stuck in 'Thinking...' with no prompt shown.
Root cause: streaming.py used HERMES_EXEC_ASK=1 but never registered a
register_gateway_notify() callback. Without it, check_all_command_guards()
fell back to the legacy polling path (submit_pending only), which relies on
on_tool() polling -- but on_tool() fires *before* the tool runs, so by the
time the terminal tool detected the dangerous command and called submit_pending,
the approval event had already missed its window.
Fix (streaming.py):
- Register a gateway-style notify_cb via register_gateway_notify() before the
agent runs. The callback calls put('approval', ...) to emit the SSE event
the moment a dangerous command is detected, regardless of on_tool() timing.
- Unregister via unregister_gateway_notify() in the finally block to unblock
any threads still waiting if the stream ends or is cancelled mid-approval.
- Keep the on_tool() fallback poll for older approval module versions.
Fix (routes.py):
- Import and call resolve_gateway_approval() in _handle_approval_respond().
This unblocks the agent thread parked in entry.event.wait() when the user
clicks Allow or Deny in the UI. Without this call the thread would block
until the 5-minute gateway timeout.
Tests (tests/test_approval_unblock.py):
- 16 new tests covering: resolve_gateway_approval() event signalling, deny/
session/once choices, resolve_all, notify_cb registration/firing/cleanup,
unregister signals blocked entries, full end-to-end streaming simulation,
module symbol exports, and HTTP endpoint regressions.
515 tests pass (499 existing + 16 new).
* feat: full approval UI — i18n buttons, keyboard shortcut, loading state, scoping fix
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 03:16:22 +00:00
// Disable all buttons immediately to prevent double-submit
[ "approvalBtnOnce" , "approvalBtnSession" , "approvalBtnAlways" , "approvalBtnDeny" ] . forEach ( id => {
const b = $ ( id ) ;
if ( b ) { b . disabled = true ; if ( b . id === "approvalBtn" + choice . charAt ( 0 ) . toUpperCase ( ) + choice . slice ( 1 ) ) b . classList . add ( "loading" ) ; }
} ) ;
2026-03-31 03:40:19 +00:00
_approvalSessionId = null ;
2026-04-15 19:16:14 +00:00
_approvalCurrentId = null ;
2026-05-07 15:14:30 +00:00
_clearApprovalPendingForSession ( sid ) ;
v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons
* fix: approval pending check broken by stale has_pending import (#228)
api/routes.py imported has_pending/pop_pending from tools.approval, but the
agent module renamed has_pending to has_blocking_approval (checks gateway
queue, not _pending dict) and removed pop_pending. The import fell through
to fallback lambdas that always returned False, making GET /api/approval/pending
always return {pending:null} even after a successful inject_test.
Fix: check _pending directly under _lock — same dict submit_pending writes to.
Stale imports removed.
Before: 554 pass, 1 fail | After: 555 pass, 0 fail
* fix: move login JS into external file, remove inline handlers (#226)
Login page used inline onsubmit/onkeydown handlers and an inline <script>
block — all blocked by strict script-src CSP, causing silent login failure.
Fix: extract doLogin() and Enter key listener into static/login.js (served
from /static/, already a public path). Form uses id='login-form' and
data-* attributes for i18n strings instead of injected JS literals.
Also guards res.json() parse with try/catch so non-JSON error bodies
(e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'.
Fixes #222.
* fix: improve update error messages when pull fails (#227)
_apply_update_inner() ran git pull --ff-only and returned only raw stderr
on failure, making all failure modes indistinguishable.
Fix: explicit git fetch before pull; if fetch fails, returns human-readable
network error. Diverged history and missing upstream tracking branch each
get distinct messages with exact recovery commands. Generic fallback
truncates to 300 chars and shows sentinel when git produces no output.
Also adds tests/test_update_checker.py with 13 tests covering all 4 new
diagnostic code paths (0 tests existed before).
Fixes #223.
* fix: stabilize 30s terminal approval prompt visibility (#225)
Adds minimum 30-second visibility guard for the approval card using
_approvalVisibleSince, _approvalHideTimer, and a signature fingerprint
to deduplicate repeated poll ticks.
Fix: respondApproval() and all stream-end paths (done/cancel/apperror/
error/start-error) now call hideApprovalCard(true) so the card hides
immediately when the user responds or the session ends. The 30s guard
only applies to mid-session poll ticks where the approval is still live
but briefly absent.
Adds 11 structural tests covering the new timer variables, force
parameter, force-on-respond, force-on-stream-end, and poll-loop
no-force behavior.
* feat: replace emoji icons with self-hosted Lucide SVG icons (#221)
Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled
in static/icons.js (no CDN dependency). Adds li(name) function returning
inline SVG geometry from a hardcoded whitelist — unknown keys return '' so
dynamic server-supplied names never inject arbitrary SVG.
Changes:
- static/icons.js: new file with 21 icon paths + li() renderer
- static/index.html: all nav/action buttons now use li() icons
- static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons
- static/messages.js: cancelStream button uses SVG square stop icon
- .gitignore: adds node_modules/ entry
Verified: all 35 onclick= functions exist in JS, all 21 li() calls
reference defined icons, applyBotName() selectors intact, version
label present, no removed IDs referenced by JS.
* docs: v0.44.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-10 17:02:28 +00:00
hideApprovalCard ( true ) ;
2026-03-31 03:40:19 +00:00
try {
await api ( "/api/approval/respond" , {
method : "POST" ,
2026-04-15 19:16:14 +00:00
body : JSON . stringify ( { session _id : sid , choice , approval _id : approvalId } )
2026-03-31 03:40:19 +00:00
} ) ;
fix: surface approval prompt in UI instead of getting stuck in Thinking (#187)
* fix: surface approval prompt in UI instead of getting stuck in Thinking
When a dangerous command was detected during streaming, the approval system
would call submit_pending() but no SSE 'approval' event would be emitted to
the frontend. The agent thread either blocked indefinitely (gateway path) or
returned an approval_required status the UI never saw (EXEC_ASK path). Either
way the chat UI stayed stuck in 'Thinking...' with no prompt shown.
Root cause: streaming.py used HERMES_EXEC_ASK=1 but never registered a
register_gateway_notify() callback. Without it, check_all_command_guards()
fell back to the legacy polling path (submit_pending only), which relies on
on_tool() polling -- but on_tool() fires *before* the tool runs, so by the
time the terminal tool detected the dangerous command and called submit_pending,
the approval event had already missed its window.
Fix (streaming.py):
- Register a gateway-style notify_cb via register_gateway_notify() before the
agent runs. The callback calls put('approval', ...) to emit the SSE event
the moment a dangerous command is detected, regardless of on_tool() timing.
- Unregister via unregister_gateway_notify() in the finally block to unblock
any threads still waiting if the stream ends or is cancelled mid-approval.
- Keep the on_tool() fallback poll for older approval module versions.
Fix (routes.py):
- Import and call resolve_gateway_approval() in _handle_approval_respond().
This unblocks the agent thread parked in entry.event.wait() when the user
clicks Allow or Deny in the UI. Without this call the thread would block
until the 5-minute gateway timeout.
Tests (tests/test_approval_unblock.py):
- 16 new tests covering: resolve_gateway_approval() event signalling, deny/
session/once choices, resolve_all, notify_cb registration/firing/cleanup,
unregister signals blocked entries, full end-to-end streaming simulation,
module symbol exports, and HTTP endpoint regressions.
515 tests pass (499 existing + 16 new).
* feat: full approval UI — i18n buttons, keyboard shortcut, loading state, scoping fix
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-09 03:16:22 +00:00
} catch ( e ) { setStatus ( t ( "approval_responding" ) + " " + e . message ) ; }
2026-03-31 03:40:19 +00:00
}
function startApprovalPolling ( sid ) {
stopApprovalPolling ( ) ;
2026-05-06 18:07:11 +00:00
_approvalPollingSessionId = sid || null ;
2026-04-30 18:31:42 +00:00
// ── SSE (preferred): long-lived connection, server pushes instantly ──
try {
2026-05-04 06:57:52 +00:00
const es = new EventSource ( new URL ( 'api/approval/stream?session_id=' + encodeURIComponent ( sid ) , document . baseURI || location . href ) . href ) ;
2026-04-30 18:31:42 +00:00
let _fallbackActive = false ;
es . addEventListener ( 'initial' , e => {
const d = JSON . parse ( e . data ) ;
2026-05-07 15:14:30 +00:00
if ( d . pending ) { showApprovalForSession ( sid , d . pending , d . pending _count || 1 ) ; }
else { _clearApprovalPendingForSession ( sid ) ; _hideApprovalCardIfOwner ( sid ) ; }
2026-04-30 18:31:42 +00:00
} ) ;
es . addEventListener ( 'approval' , e => {
const d = JSON . parse ( e . data ) ;
2026-05-07 15:14:30 +00:00
if ( d . pending ) { showApprovalForSession ( sid , d . pending , d . pending _count || 1 ) ; }
else { _clearApprovalPendingForSession ( sid ) ; _hideApprovalCardIfOwner ( sid ) ; }
2026-04-30 18:31:42 +00:00
} ) ;
es . onerror = ( ) => {
// SSE failed — fall back to HTTP polling (3s interval)
if ( _fallbackActive ) return ;
_fallbackActive = true ;
try { es . close ( ) ; } catch ( _ ) { }
_startApprovalFallbackPoll ( sid ) ;
} ;
// If the session changes or stops being busy, close the SSE.
// We detect this via a periodic check (cheap — no network request).
_approvalSSEHealthTimer = setInterval ( ( ) => {
if ( ! S . busy || ! S . session || S . session . session _id !== sid ) {
2026-05-07 15:14:30 +00:00
stopApprovalPolling ( ) ; _hideApprovalCardIfOwner ( sid , true ) ;
2026-04-30 18:31:42 +00:00
}
} , 5000 ) ;
_approvalEventSource = es ;
} catch ( _e ) {
// EventSource constructor failed — use polling directly
_startApprovalFallbackPoll ( sid ) ;
}
}
let _approvalEventSource = null ;
let _approvalSSEHealthTimer = null ;
2026-05-06 18:07:11 +00:00
let _approvalPollingSessionId = null ;
2026-04-30 18:31:42 +00:00
function _startApprovalFallbackPoll ( sid ) {
2026-03-31 03:40:19 +00:00
_approvalPollTimer = setInterval ( async ( ) => {
if ( ! S . busy || ! S . session || S . session . session _id !== sid ) {
2026-05-07 15:14:30 +00:00
stopApprovalPolling ( ) ; _hideApprovalCardIfOwner ( sid , true ) ; return ;
2026-03-31 03:40:19 +00:00
}
try {
const data = await api ( "/api/approval/pending?session_id=" + encodeURIComponent ( sid ) ) ;
2026-05-07 15:14:30 +00:00
if ( data . pending ) { showApprovalForSession ( sid , data . pending , data . pending _count || 1 ) ; }
else { _clearApprovalPendingForSession ( sid ) ; _hideApprovalCardIfOwner ( sid ) ; }
2026-03-31 03:40:19 +00:00
} catch ( e ) { /* ignore poll errors */ }
fix(approval): close SSE notify-ordering, head-fidelity, and trailing-approval gaps (Opus MUST-FIX A/C/D)
Pre-release Opus review caught three correctness bugs in the original
PR #1350 SSE wiring beyond the snapshot/subscribe race:
A) **Notify-ordering race (MUST-FIX A):** _approval_sse_notify took _lock
only for the subscriber-list snapshot, then released it before
put_nowait. With two parallel submit_pending calls, T2's notify
could fire before T1's, leaving the UI showing pending_count=1 while
the server actually had 2 queued.
C) **Trailing approval lost (MUST-FIX C):** _handle_approval_respond
never called _approval_sse_notify after popping. With parallel
tool-call approvals (#527), a second approval queued behind the one
being responded to was invisible until the next event ever fired —
in practice, the agent thread parked on it would appear hung.
D) **Payload showed tail not head (MUST-FIX D):** payload built from
the just-appended entry instead of queue[0]. /api/approval/pending
returns the head; SSE returned the tail. Diverging contracts.
Fix:
- Split into _approval_sse_notify_locked (caller holds _lock, no
internal locking) and _approval_sse_notify (convenience wrapper).
- submit_pending: call _locked variant inside the queue-mutation lock,
passing queue_list[0] as head.
- _handle_approval_respond: call _locked variant inside the pop lock,
passing the new head (or None/0 if queue is empty).
- Restore fallback poll to 1500ms (was bumped to 3000ms; degraded-mode
parity with v0.50.247 is more important than save 1.5s of polling).
New regression tests in tests/test_pr1350_sse_notify_correctness.py:
- test_second_submit_pending_sends_head_not_tail (D)
- test_respond_to_first_pushes_second_as_new_head (C)
- test_respond_to_only_pending_pushes_empty_state (C edge)
- test_pending_count_is_monotonic_under_contention (A)
Updated test_approval_sse.py to pin the new contract:
- _approval_sse_notify_locked(session_key, head, total)
- 1500ms fallback interval
Total: 3411 tests passing.
Co-authored-by: jasonjcwu <jasonjcwu@users.noreply.github.com>
2026-04-30 18:45:15 +00:00
} , 1500 ) ; // matches the v0.50.247 polling cadence so degraded-mode users see the same responsiveness
2026-03-31 03:40:19 +00:00
}
2026-05-06 18:07:11 +00:00
function stopApprovalPollingForSession ( sid ) {
if ( sid && _approvalPollingSessionId && _approvalPollingSessionId !== sid ) return ;
stopApprovalPolling ( ) ;
}
2026-03-31 03:40:19 +00:00
function stopApprovalPolling ( ) {
if ( _approvalPollTimer ) { clearInterval ( _approvalPollTimer ) ; _approvalPollTimer = null ; }
2026-04-30 18:31:42 +00:00
if ( _approvalEventSource ) { try { _approvalEventSource . close ( ) ; } catch ( _ ) { } _approvalEventSource = null ; }
if ( _approvalSSEHealthTimer ) { clearInterval ( _approvalSSEHealthTimer ) ; _approvalSSEHealthTimer = null ; }
2026-05-06 18:07:11 +00:00
_approvalPollingSessionId = null ;
2026-03-31 03:40:19 +00:00
}
2026-04-08 16:02:02 +00:00
2026-04-15 05:10:50 +00:00
// ── Clarify polling ──
let _clarifyPollTimer = null ;
let _clarifyHideTimer = null ;
let _clarifyVisibleSince = 0 ;
let _clarifySignature = '' ;
let _clarifySessionId = null ;
let _clarifyMissingEndpointWarned = false ;
2026-04-28 06:57:58 +00:00
let _clarifyCountdownTimer = null ;
let _clarifyExpiresAt = 0 ;
2026-05-07 15:14:30 +00:00
let _clarifyPendingBySession = new Map ( ) ;
2026-04-15 05:10:50 +00:00
const CLARIFY _MIN _VISIBLE _MS = 30000 ;
2026-05-07 15:14:30 +00:00
function _clarifyPromptBelongsToActiveSession ( sid ) {
return ! ! ( sid && _promptActiveSessionId ( ) === sid ) ;
}
function _rememberClarifyPending ( pending ) {
if ( ! pending ) return null ;
const sid = pending . _session _id || _promptActiveSessionId ( ) ;
if ( ! sid ) return null ;
const nextPending = { ... pending , _session _id : sid } ;
_clarifyPendingBySession . set ( sid , { pending : nextPending } ) ;
return sid ;
}
function _clearClarifyPendingForSession ( sid ) {
if ( sid ) _clarifyPendingBySession . delete ( sid ) ;
}
function _hideClarifyCardIfOwner ( sid , force = false , reason = "dismissed" ) {
if ( ! sid || _clarifySessionId === sid ) hideClarifyCard ( force , reason ) ;
}
function _renderPendingClarifyForActiveSession ( ) {
const sid = _promptActiveSessionId ( ) ;
if ( ! sid ) return ;
if ( _clarifySessionId && _clarifySessionId !== sid ) hideClarifyCard ( true , 'session' ) ;
const entry = _clarifyPendingBySession . get ( sid ) ;
if ( entry ) showClarifyCard ( entry . pending ) ;
}
function showClarifyForSession ( sid , pending ) {
if ( ! pending ) return ;
pending . _session _id = sid ;
showClarifyCard ( pending ) ;
}
function _renderPendingPromptsForActiveSession ( ) {
_renderPendingApprovalForActiveSession ( ) ;
_renderPendingClarifyForActiveSession ( ) ;
}
2026-04-15 05:10:50 +00:00
function _ensureClarifyCardDom ( ) {
let card = $ ( "clarifyCard" ) ;
if ( card ) return card ;
const host = $ ( "msgInner" ) || $ ( "messages" ) ;
if ( ! host ) return null ;
card = document . createElement ( "div" ) ;
card . className = "clarify-card" ;
card . id = "clarifyCard" ;
card . setAttribute ( "role" , "dialog" ) ;
card . setAttribute ( "aria-labelledby" , "clarifyHeading" ) ;
card . setAttribute ( "aria-describedby" , "clarifyQuestion clarifyHint" ) ;
card . innerHTML = `
< div class = "clarify-inner" >
< div class = "clarify-header" >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" > < path d = "M12 17h.01" / > < path d = "M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4" / > < circle cx = "12" cy = "12" r = "10" / > < / s v g >
< span id = "clarifyHeading" data - i18n = "clarify_heading" > Clarification needed < / s p a n >
2026-04-28 06:57:58 +00:00
< span class = "clarify-countdown" id = "clarifyCountdown" > < / s p a n >
2026-04-15 05:10:50 +00:00
< / d i v >
< div class = "clarify-question" id = "clarifyQuestion" > < / d i v >
< div class = "clarify-choices" id = "clarifyChoices" > < / d i v >
< div class = "clarify-response" >
< input class = "clarify-input" id = "clarifyInput" type = "text" data - i18n - placeholder = "clarify_input_placeholder" placeholder = "Type your response…" >
< button class = "clarify-submit" id = "clarifySubmit" data - i18n = "clarify_send" > Send < / b u t t o n >
< / d i v >
< div class = "clarify-hint" id = "clarifyHint" data - i18n = "clarify_hint" > Please choose one option , or type your own response below . < / d i v >
< / d i v >
` ;
host . appendChild ( card ) ;
const submit = $ ( "clarifySubmit" ) ;
if ( submit ) submit . onclick = ( ) => respondClarify ( ) ;
if ( typeof applyLocaleToDOM === "function" ) applyLocaleToDOM ( ) ;
return card ;
}
function _clearClarifyHideTimer ( ) {
if ( _clarifyHideTimer ) {
clearTimeout ( _clarifyHideTimer ) ;
_clarifyHideTimer = null ;
}
}
2026-04-28 06:57:58 +00:00
function _clearClarifyCountdownTimer ( ) {
if ( _clarifyCountdownTimer ) {
clearInterval ( _clarifyCountdownTimer ) ;
_clarifyCountdownTimer = null ;
}
_clarifyExpiresAt = 0 ;
const countdown = $ ( "clarifyCountdown" ) ;
if ( countdown ) {
countdown . textContent = "" ;
countdown . classList . remove ( "urgent" ) ;
}
}
function _clarifyExpiryMs ( pending ) {
const expiresAt = Number ( pending && pending . expires _at ) ;
if ( Number . isFinite ( expiresAt ) && expiresAt > 0 ) return expiresAt * 1000 ;
const requestedAt = Number ( pending && pending . requested _at ) ;
const timeoutSeconds = Number ( pending && pending . timeout _seconds ) ;
if ( Number . isFinite ( requestedAt ) && Number . isFinite ( timeoutSeconds ) ) {
return ( requestedAt + timeoutSeconds ) * 1000 ;
}
return 0 ;
}
function _updateClarifyCountdown ( ) {
const countdown = $ ( "clarifyCountdown" ) ;
if ( ! countdown || ! _clarifyExpiresAt ) return ;
const remaining = Math . max ( 0 , Math . ceil ( ( _clarifyExpiresAt - Date . now ( ) ) / 1000 ) ) ;
countdown . textContent = ` ${ remaining } s ` ;
countdown . classList . toggle ( "urgent" , remaining <= 10 ) ;
}
function _startClarifyCountdown ( pending ) {
2026-04-28 10:36:45 +00:00
const expiresAt = _clarifyExpiryMs ( pending ) ;
if ( _clarifyCountdownTimer && _clarifyExpiresAt === expiresAt ) return ;
2026-04-28 06:57:58 +00:00
_clearClarifyCountdownTimer ( ) ;
2026-04-28 10:36:45 +00:00
_clarifyExpiresAt = expiresAt ;
2026-04-28 06:57:58 +00:00
if ( ! _clarifyExpiresAt ) return ;
_updateClarifyCountdown ( ) ;
_clarifyCountdownTimer = setInterval ( _updateClarifyCountdown , 1000 ) ;
}
function _stashClarifyDraft ( reason ) {
if ( reason !== "expired" && reason !== "terminal" ) return false ;
const input = $ ( "clarifyInput" ) ;
const draft = String ( ( input && input . value ) || "" ) . trim ( ) ;
if ( ! draft ) return false ;
const sid = _clarifySessionId || ( S . session && S . session . session _id ) || "unknown" ;
const key = ` hermes-clarify-draft- ${ sid } - ${ _clarifySignature || "unknown" } ` ;
try {
sessionStorage . setItem ( key , JSON . stringify ( {
draft ,
reason ,
saved _at : Date . now ( ) ,
} ) ) ;
} catch ( _ ) { }
const composer = $ ( 'msg' ) ;
if ( composer ) {
const current = String ( composer . value || "" ) ;
composer . value = current . trim ( ) ? ` ${ current . replace ( /\s+$/ , "" ) } \n \n ${ draft } ` : draft ;
if ( typeof autoResize === "function" ) autoResize ( ) ;
if ( typeof updateSendBtn === "function" ) updateSendBtn ( ) ;
}
const notice = reason === "expired"
? "Clarification timed out. Your draft was kept in the composer."
: "Clarification closed. Your draft was kept in the composer." ;
if ( typeof setComposerStatus === "function" ) setComposerStatus ( notice ) ;
else if ( typeof setStatus === "function" ) setStatus ( notice ) ;
if ( typeof showToast === "function" ) showToast ( notice , 5000 ) ;
return true ;
}
2026-04-15 05:10:50 +00:00
function _resetClarifyCardState ( ) {
_clearClarifyHideTimer ( ) ;
2026-04-28 06:57:58 +00:00
_clearClarifyCountdownTimer ( ) ;
2026-04-15 05:10:50 +00:00
_clarifyVisibleSince = 0 ;
_clarifySignature = '' ;
}
2026-04-28 06:57:58 +00:00
function hideClarifyCard ( force = false , reason = "dismissed" ) {
2026-04-15 05:10:50 +00:00
const card = $ ( "clarifyCard" ) ;
if ( ! card ) {
_clarifySessionId = null ;
_resetClarifyCardState ( ) ;
if ( typeof unlockComposerForClarify === "function" ) unlockComposerForClarify ( ) ;
return ;
}
2026-04-28 06:57:58 +00:00
if ( ! force && reason !== "expired" && _clarifyVisibleSince ) {
2026-04-15 05:10:50 +00:00
const remaining = CLARIFY _MIN _VISIBLE _MS - ( Date . now ( ) - _clarifyVisibleSince ) ;
if ( remaining > 0 ) {
const scheduledSignature = _clarifySignature ;
_clearClarifyHideTimer ( ) ;
_clarifyHideTimer = setTimeout ( ( ) => {
_clarifyHideTimer = null ;
if ( _clarifySignature !== scheduledSignature ) return ;
2026-04-28 06:57:58 +00:00
hideClarifyCard ( true , reason ) ;
2026-04-15 05:10:50 +00:00
} , remaining ) ;
return ;
}
}
2026-04-28 06:57:58 +00:00
_stashClarifyDraft ( reason ) ;
2026-04-15 05:10:50 +00:00
_clarifySessionId = null ;
_resetClarifyCardState ( ) ;
card . classList . remove ( "visible" ) ;
if ( typeof unlockComposerForClarify === "function" ) unlockComposerForClarify ( ) ;
$ ( "clarifyQuestion" ) . textContent = "" ;
$ ( "clarifyChoices" ) . innerHTML = "" ;
$ ( "clarifyInput" ) . value = "" ;
$ ( "clarifyInput" ) . disabled = false ;
$ ( "clarifyInput" ) . onkeydown = null ;
const submit = $ ( "clarifySubmit" ) ;
if ( submit ) { submit . disabled = false ; submit . classList . remove ( "loading" ) ; }
}
function _clarifySetControlsDisabled ( disabled , loading = false ) {
const input = $ ( "clarifyInput" ) ;
const submit = $ ( "clarifySubmit" ) ;
if ( input ) input . disabled = disabled ;
if ( submit ) {
submit . disabled = disabled ;
submit . classList . toggle ( "loading" , ! ! loading ) ;
}
const choices = $ ( "clarifyChoices" ) ;
if ( choices ) {
choices . querySelectorAll ( "button" ) . forEach ( btn => {
btn . disabled = disabled ;
if ( loading && btn . dataset && btn . dataset . choice === "other" ) {
btn . classList . toggle ( "loading" , false ) ;
}
} ) ;
}
}
function showClarifyCard ( pending ) {
2026-05-07 15:14:30 +00:00
const sid = _rememberClarifyPending ( pending ) ;
if ( ! _clarifyPromptBelongsToActiveSession ( sid ) ) return ;
2026-04-15 05:10:50 +00:00
const question = pending . question || pending . description || '' ;
const choices = Array . isArray ( pending . choices _offered )
? pending . choices _offered
: ( Array . isArray ( pending . choices ) ? pending . choices : [ ] ) ;
const sig = JSON . stringify ( {
question ,
choices ,
sid : pending . _session _id || ( S . session && S . session . session _id ) || null ,
} ) ;
const card = _ensureClarifyCardDom ( ) ;
if ( ! card ) return ;
const questionEl = $ ( "clarifyQuestion" ) ;
const choicesEl = $ ( "clarifyChoices" ) ;
const input = $ ( "clarifyInput" ) ;
const sameClarify = card . classList . contains ( "visible" ) && _clarifySignature === sig ;
2026-05-07 15:14:30 +00:00
_clarifySessionId = sid ;
2026-04-15 05:10:50 +00:00
_clarifySignature = sig ;
2026-04-28 06:57:58 +00:00
_startClarifyCountdown ( pending ) ;
2026-04-15 05:10:50 +00:00
if ( ! sameClarify ) {
_clarifyVisibleSince = Date . now ( ) ;
_clearClarifyHideTimer ( ) ;
}
if ( questionEl ) questionEl . textContent = question ;
if ( choicesEl ) {
choicesEl . innerHTML = '' ;
choicesEl . style . display = choices . length ? '' : 'none' ;
if ( choices . length ) {
choices . forEach ( ( choice , idx ) => {
const btn = document . createElement ( 'button' ) ;
btn . type = 'button' ;
btn . className = 'clarify-choice' ;
btn . dataset . choice = choice ;
btn . onclick = ( ) => respondClarify ( choice ) ;
const badge = document . createElement ( 'span' ) ;
badge . className = 'clarify-choice-badge' ;
badge . textContent = String ( idx + 1 ) ;
const text = document . createElement ( 'span' ) ;
text . className = 'clarify-choice-text' ;
text . textContent = choice ;
btn . appendChild ( badge ) ;
btn . appendChild ( text ) ;
choicesEl . appendChild ( btn ) ;
} ) ;
const other = document . createElement ( 'button' ) ;
other . type = 'button' ;
other . className = 'clarify-choice other' ;
other . dataset . choice = 'other' ;
other . setAttribute ( 'data-i18n' , 'clarify_other' ) ;
const otherBadge = document . createElement ( 'span' ) ;
otherBadge . className = 'clarify-choice-badge other' ;
otherBadge . textContent = '•' ;
const otherText = document . createElement ( 'span' ) ;
otherText . className = 'clarify-choice-text' ;
otherText . textContent = t ( 'clarify_other' ) || 'Other' ;
other . appendChild ( otherBadge ) ;
other . appendChild ( otherText ) ;
other . onclick = ( ) => {
const el = $ ( "clarifyInput" ) ;
if ( el ) {
el . focus ( ) ;
if ( typeof el . select === 'function' ) el . select ( ) ;
}
} ;
choicesEl . appendChild ( other ) ;
}
}
if ( input ) {
if ( ! sameClarify ) input . value = '' ;
input . disabled = false ;
input . onkeydown = ( e ) => {
if ( e . key === 'Enter' ) {
e . preventDefault ( ) ;
respondClarify ( ) ;
}
} ;
}
if ( typeof lockComposerForClarify === "function" ) {
lockComposerForClarify ( question ? ` Clarification needed: ${ question } ` : "Clarification needed" ) ;
}
_clarifySetControlsDisabled ( false , false ) ;
card . classList . add ( "visible" ) ;
if ( typeof applyLocaleToDOM === "function" ) applyLocaleToDOM ( ) ;
feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70
Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.
Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.
Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).
Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].
Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.
Workspace panel FOUC fix: [data-workspace-panel] set at parse time.
Docs: docs/ui-ux/index.html + two-stage-proposal.html.
Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.
Reviewed and approved by @nesquena (independent review). 1361 tests passing.
2026-04-16 21:04:42 +00:00
if ( input && ! sameClarify ) setTimeout ( ( ) => input . focus ( { preventScroll : true } ) , 50 ) ;
2026-04-15 05:10:50 +00:00
}
async function respondClarify ( response ) {
const sid = _clarifySessionId || ( S . session && S . session . session _id ) ;
if ( ! sid ) return ;
const input = $ ( "clarifyInput" ) ;
let value = typeof response === 'string' ? response : ( input ? input . value : '' ) ;
value = String ( value || '' ) . trim ( ) ;
if ( ! value ) {
if ( input ) input . focus ( ) ;
return ;
}
_clarifySessionId = null ;
2026-05-07 15:14:30 +00:00
_clearClarifyPendingForSession ( sid ) ;
2026-04-15 05:10:50 +00:00
_clarifySetControlsDisabled ( true , true ) ;
2026-04-28 06:57:58 +00:00
hideClarifyCard ( true , 'sent' ) ;
2026-04-15 05:10:50 +00:00
try {
await api ( "/api/clarify/respond" , {
method : "POST" ,
body : JSON . stringify ( { session _id : sid , response : value } )
} ) ;
} catch ( e ) { setStatus ( t ( "clarify_responding" ) + " " + e . message ) ; }
}
2026-04-30 21:32:51 +00:00
var _clarifyEventSource = null ;
var _clarifyFallbackTimer = null ;
var _clarifyHealthTimer = null ;
2026-05-06 18:07:11 +00:00
let _clarifyPollingSessionId = null ;
2026-04-30 21:32:51 +00:00
2026-04-15 05:10:50 +00:00
function startClarifyPolling ( sid ) {
stopClarifyPolling ( ) ;
2026-05-06 18:07:11 +00:00
_clarifyPollingSessionId = sid || null ;
2026-04-15 05:10:50 +00:00
_clarifyMissingEndpointWarned = false ;
2026-04-30 21:32:51 +00:00
// SSE primary path: long-lived connection pushes events instantly.
try {
2026-05-04 06:57:52 +00:00
_clarifyEventSource = new EventSource ( new URL ( 'api/clarify/stream?session_id=' + encodeURIComponent ( sid ) , document . baseURI || location . href ) . href ) ;
2026-04-30 21:32:51 +00:00
} catch ( e ) {
_startClarifyFallbackPoll ( sid ) ;
return ;
}
_clarifyEventSource . addEventListener ( 'initial' , function ( ev ) {
try {
var d = JSON . parse ( ev . data ) ;
2026-05-07 15:14:30 +00:00
if ( d . pending ) { showClarifyForSession ( sid , d . pending ) ; }
else { _clearClarifyPendingForSession ( sid ) ; _hideClarifyCardIfOwner ( sid , false , 'expired' ) ; }
2026-04-30 21:32:51 +00:00
} catch ( e ) { }
} ) ;
_clarifyEventSource . addEventListener ( 'clarify' , function ( ev ) {
try {
var d = JSON . parse ( ev . data ) ;
2026-05-07 15:14:30 +00:00
if ( d . pending ) { showClarifyForSession ( sid , d . pending ) ; }
else { _clearClarifyPendingForSession ( sid ) ; _hideClarifyCardIfOwner ( sid , false , 'expired' ) ; }
2026-04-30 21:32:51 +00:00
} catch ( e ) { }
} ) ;
_clarifyEventSource . onerror = function ( ) {
stopClarifyPolling ( ) ;
_startClarifyFallbackPoll ( sid ) ;
} ;
2026-04-30 22:25:12 +00:00
// Stale-detector: track last event timestamp; only reconnect if no event
// (initial or clarify) has arrived in 60s. The server sends a keepalive
// comment line every 30s but EventSource silently consumes those; we only
// bump lastEventAt on actual application events. With no real events for
// 60s on a long-lived clarify connection the server is effectively silent
// and a reconnect is the safe move.
//
// Without the lastEventAt gate the original PR force-reconnected every 60s
// regardless of activity, which churned one TCP/SSE setup per minute per
// active session. (Opus pre-release review of v0.50.249.)
let _lastClarifyEventAt = Date . now ( ) ;
const _markClarifyEvent = ( ) => { _lastClarifyEventAt = Date . now ( ) ; } ;
_clarifyEventSource . addEventListener ( 'initial' , _markClarifyEvent ) ;
_clarifyEventSource . addEventListener ( 'clarify' , _markClarifyEvent ) ;
2026-04-30 21:32:51 +00:00
_clarifyHealthTimer = setInterval ( function ( ) {
2026-04-30 22:25:12 +00:00
if ( Date . now ( ) - _lastClarifyEventAt < 60000 ) return ;
2026-04-30 21:32:51 +00:00
if ( _clarifyEventSource ) {
try { _clarifyEventSource . close ( ) ; } catch ( _ ) { }
_clarifyEventSource = null ;
}
clearInterval ( _clarifyHealthTimer ) ; _clarifyHealthTimer = null ;
startClarifyPolling ( sid ) ;
} , 60000 ) ;
}
function _startClarifyFallbackPoll ( sid ) {
_clarifyFallbackTimer = setInterval ( async ( ) => {
2026-04-15 05:10:50 +00:00
if ( ! S . session || S . session . session _id !== sid ) {
2026-05-07 15:14:30 +00:00
stopClarifyPolling ( ) ; _hideClarifyCardIfOwner ( sid , true , 'session' ) ; return ;
2026-04-15 05:10:50 +00:00
}
try {
const data = await api ( "/api/clarify/pending?session_id=" + encodeURIComponent ( sid ) ) ;
2026-05-07 15:14:30 +00:00
if ( data . pending ) { showClarifyForSession ( sid , data . pending ) ; }
else { _clearClarifyPendingForSession ( sid ) ; _hideClarifyCardIfOwner ( sid , false , 'expired' ) ; }
2026-04-15 05:10:50 +00:00
} catch ( e ) {
const msg = String ( ( e && e . message ) || "" ) ;
if ( ! _clarifyMissingEndpointWarned && /(^|\b)(404|not found)(\b|$)/i . test ( msg ) ) {
_clarifyMissingEndpointWarned = true ;
setComposerStatus ( "Clarify unavailable on current server build. Restart server." ) ;
if ( typeof showToast === "function" ) {
showToast ( "Clarify endpoint unavailable. Please restart server." , 5000 ) ;
}
stopClarifyPolling ( ) ;
}
}
2026-04-30 21:32:51 +00:00
} , 3000 ) ;
2026-04-15 05:10:50 +00:00
}
2026-05-06 18:07:11 +00:00
function stopClarifyPollingForSession ( sid ) {
if ( sid && _clarifyPollingSessionId && _clarifyPollingSessionId !== sid ) return ;
stopClarifyPolling ( ) ;
}
2026-04-15 05:10:50 +00:00
function stopClarifyPolling ( ) {
2026-04-30 21:32:51 +00:00
if ( _clarifyEventSource ) { try { _clarifyEventSource . close ( ) ; } catch ( _ ) { } _clarifyEventSource = null ; }
if ( _clarifyFallbackTimer ) { clearInterval ( _clarifyFallbackTimer ) ; _clarifyFallbackTimer = null ; }
if ( _clarifyHealthTimer ) { clearInterval ( _clarifyHealthTimer ) ; _clarifyHealthTimer = null ; }
2026-05-06 18:07:11 +00:00
_clarifyPollingSessionId = null ;
2026-04-15 05:10:50 +00:00
}
2026-04-08 16:02:02 +00:00
// ── Notifications and Sound ──────────────────────────────────────────────────
function playNotificationSound ( ) {
if ( ! window . _soundEnabled ) return ;
try {
const ctx = new ( window . AudioContext || window . webkitAudioContext ) ( ) ;
const osc = ctx . createOscillator ( ) ;
const gain = ctx . createGain ( ) ;
osc . connect ( gain ) ; gain . connect ( ctx . destination ) ;
osc . type = 'sine' ; osc . frequency . setValueAtTime ( 660 , ctx . currentTime ) ;
osc . frequency . setValueAtTime ( 880 , ctx . currentTime + 0.1 ) ;
gain . gain . setValueAtTime ( 0.3 , ctx . currentTime ) ;
gain . gain . exponentialRampToValueAtTime ( 0.01 , ctx . currentTime + 0.3 ) ;
osc . start ( ctx . currentTime ) ; osc . stop ( ctx . currentTime + 0.3 ) ;
2026-04-09 01:55:57 +00:00
osc . onended = ( ) => ctx . close ( ) ;
2026-04-08 16:02:02 +00:00
} catch ( e ) { console . warn ( 'Notification sound failed:' , e ) ; }
}
function sendBrowserNotification ( title , body ) {
if ( ! window . _notificationsEnabled || ! document . hidden ) return ;
if ( ! ( 'Notification' in window ) ) return ;
const botName = window . _botName || 'Hermes' ;
if ( Notification . permission === 'granted' ) {
new Notification ( title || botName , { body : body } ) ;
} else if ( Notification . permission !== 'denied' ) {
Notification . requestPermission ( ) . then ( p => {
if ( p === 'granted' ) new Notification ( title || botName , { body : body } ) ;
} ) ;
}
}
2026-04-24 01:24:51 +00:00
// ── /btw ephemeral stream ────────────────────────────────────────────────────
// Connects to the ephemeral SSE stream from /api/btw and renders the answer
// in a visually distinct bubble that is NOT persisted to session history.
function attachBtwStream ( parentSid , streamId , question ) {
if ( ! parentSid || ! streamId ) return ;
2026-05-04 06:57:52 +00:00
const src = new EventSource ( new URL ( 'api/chat/stream?stream_id=' + encodeURIComponent ( streamId ) , document . baseURI || location . href ) . href ) ;
2026-04-24 01:24:51 +00:00
let answer = '' ;
let btwRow = null ;
2026-04-24 02:18:51 +00:00
let _streamDone = false ;
2026-04-24 01:24:51 +00:00
function _ensureBtwRow ( ) {
if ( btwRow && btwRow . isConnected ) return ;
const inner = $ ( 'msgInner' ) ;
if ( ! inner ) return ;
btwRow = document . createElement ( 'div' ) ;
btwRow . className = 'msg-row msg-row-btw' ;
btwRow . dataset . role = 'assistant' ;
btwRow . dataset . btw = '1' ;
const labelEl = document . createElement ( 'div' ) ;
labelEl . className = 'msg-btw-label' ;
labelEl . textContent = t ( 'btw_label' ) ;
const qEl = document . createElement ( 'div' ) ;
qEl . className = 'msg-body' ;
qEl . textContent = question ;
const ansEl = document . createElement ( 'div' ) ;
ansEl . className = 'msg-body msg-btw-answer' ;
ansEl . textContent = '...' ;
btwRow . appendChild ( labelEl ) ;
btwRow . appendChild ( qEl ) ;
btwRow . appendChild ( ansEl ) ;
inner . appendChild ( btwRow ) ;
btwRow . scrollIntoView ( { behavior : 'smooth' , block : 'end' } ) ;
}
src . addEventListener ( 'token' , e => {
try { answer += JSON . parse ( e . data ) . text || '' ; } catch ( _ ) { }
_ensureBtwRow ( ) ;
const ansEl = btwRow && btwRow . querySelector ( '.msg-btw-answer' ) ;
if ( ansEl ) ansEl . innerHTML = renderMd ( answer ) ;
} ) ;
src . addEventListener ( 'done' , e => {
2026-04-24 02:18:51 +00:00
_streamDone = true ;
2026-04-24 06:25:45 +00:00
src . close ( ) ;
2026-04-24 01:24:51 +00:00
try {
const d = JSON . parse ( e . data ) ;
if ( d . answer && ! answer ) answer = d . answer ;
} catch ( _ ) { }
2026-04-24 06:25:45 +00:00
if ( S . session && S . session . session _id === parentSid ) _ensureBtwRow ( ) ;
2026-04-24 01:24:51 +00:00
if ( btwRow && btwRow . isConnected ) {
const ansEl = btwRow . querySelector ( '.msg-btw-answer' ) ;
if ( ansEl ) ansEl . innerHTML = renderMd ( answer || t ( 'btw_no_answer' ) ) ;
}
showToast ( t ( 'btw_done' ) ) ;
} ) ;
src . addEventListener ( 'apperror' , e => {
2026-04-24 02:18:51 +00:00
_streamDone = true ;
2026-04-24 06:25:45 +00:00
src . close ( ) ;
2026-04-24 01:24:51 +00:00
try {
const d = JSON . parse ( e . data ) ;
showToast ( t ( 'btw_failed' ) + ( d . message || '' ) ) ;
} catch ( _ ) { showToast ( t ( 'btw_failed' ) ) ; }
if ( btwRow && btwRow . isConnected ) btwRow . remove ( ) ;
} ) ;
2026-04-24 06:25:45 +00:00
src . addEventListener ( 'stream_end' , ( ) => { _streamDone = true ; src . close ( ) ; } ) ;
2026-04-24 02:18:51 +00:00
src . onerror = ( ) => { src . close ( ) ; if ( ! _streamDone && btwRow && btwRow . isConnected ) btwRow . remove ( ) ; } ;
2026-04-24 01:24:51 +00:00
}
// ── /background task tracking ────────────────────────────────────────────────
let _bgPollTimers = { } ;
let _bgActiveTasks = new Set ( ) ;
function showBackgroundBadge ( taskId ) {
_bgActiveTasks . add ( taskId ) ;
const badge = $ ( 'bgBadge' ) ;
if ( badge ) {
badge . textContent = String ( _bgActiveTasks . size ) ;
badge . style . display = _bgActiveTasks . size ? '' : 'none' ;
}
}
function hideBackgroundBadge ( taskId ) {
_bgActiveTasks . delete ( taskId ) ;
const badge = $ ( 'bgBadge' ) ;
if ( badge ) {
badge . textContent = String ( _bgActiveTasks . size ) ;
badge . style . display = _bgActiveTasks . size ? '' : 'none' ;
}
}
function startBackgroundPolling ( parentSid , taskId , prompt ) {
if ( _bgPollTimers [ taskId ] ) return ;
async function _poll ( ) {
try {
const r = await api ( '/api/background/status?session_id=' + encodeURIComponent ( parentSid ) ) ;
if ( r && r . results ) {
for ( const res of r . results ) {
if ( res . task _id === taskId ) {
hideBackgroundBadge ( taskId ) ;
delete _bgPollTimers [ taskId ] ;
const msg = { role : 'assistant' , content : ` ** ${ t ( 'bg_label' ) } ** ${ prompt . slice ( 0 , 80 ) } \n \n ${ res . answer || t ( 'bg_no_answer' ) } ` , '_background' : true , _ts : Date . now ( ) / 1000 } ;
S . messages . push ( msg ) ;
2026-05-05 16:21:25 +00:00
renderMessages ( { preserveScroll : true } ) ;
2026-04-24 01:24:51 +00:00
showToast ( t ( 'bg_complete' ) ) ;
return ;
}
}
}
} catch ( _ ) { }
_bgPollTimers [ taskId ] = setTimeout ( _poll , 3000 ) ;
}
_poll ( ) ;
}
2026-03-31 03:40:19 +00:00
// ── Panel navigation (Chat / Tasks / Skills / Memory) ──