Backend:
- Track running cron jobs in thread-safe dict (job_id → start_time)
- Wrapper _run_cron_tracked() marks done on completion
- New GET /api/crons/status?job_id=... returns {running, elapsed}
- New GET /api/crons/status returns all running jobs
Frontend:
- After 'Run Now', enters watch mode with 3s polling
- Shows running indicator (spinner + elapsed timer) in detail card
- Auto-detects running jobs when opening detail view
- Stops watch and refreshes output on job completion
- Cleanup on detail view switch
Note: True SSE streaming is not possible because the hermes-agent
scheduler writes output files only on completion. This polling
approach provides real-time status feedback within that constraint.
- 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
Provider card improvements:
- Show model name tags when a provider card is expanded (panels.js)
- Add .provider-card-model-tag styling (style.css)
Custom providers in providers panel:
- Scan config.yaml custom_providers (e.g. glmcode, timicc) and list
them as providers with their configured models (api/providers.py)
- Detect API key status from env var references (${ENV_VAR})
When context usage reaches 50% (yellow), a subtle hint button appears
in the context ring tooltip suggesting /compress. At 75%+ (red), the
hint intensifies with a warning style.
Clicking the button pre-fills /compress into the composer and focuses
it, so the user can add a focus topic or just hit send. No auto-fire
— the user stays in control.
- static/ui.js: conditional visibility + click handler in _syncCtxIndicator
- static/index.html: ctxCompressBtn element inside ctxTooltip
- static/style.css: muted button style, red variant for ctx-high
- static/i18n.js: ctx_compress_hint / ctx_compress_action in all 7 locales
Closes#524
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
Merged as v0.50.226.
Integration branch absorbed @aronprins's original PR #1141 with one reviewer fix from @nesquena (`1d11646`: queue hide tooltip updated to reference the queue pill, not the removed titlebar badge).
**Full gate results:**
- 2595 tests passing ✅
- Browser QA 21/21 (desktop 1440×900 + mobile iPhone 14) ✅
- Independent review: APPROVED by @nesquena ✅
Thank you @aronprins for the clean PR — the titlebar is properly restored.
* feat: attention state for broken cron jobs + Korean i18n (#1133, @franksong2702)
* fix: pytest state isolation for direct session saves (#1136, @franksong2702)
* fix(#1095): image thumbnails in composer + lightbox in chat (#1135)
* fix(css): restore cron attention + detail-alert rules overwritten by style.css merge (absorb)
* docs: v0.50.225 release notes and version bump
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(#1096): copy buttons fall back to execCommand on HTTP contexts
- Add _copyText() helper: tries navigator.clipboard first, falls back to
document.execCommand('copy') with hidden textarea when not in secure context
- Update copyMsg() and addCopyButtons() to use helper instead of direct
navigator.clipboard.writeText()
- Code block copy button now has .catch() handler (was silently failing)
- Error messages use t('copy_failed') for i18n instead of hardcoded string
- Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant)
- Add 10 regression tests
* fix(#1095): render pasted/dragged images as inline preview instead of paperclip badge
- User message attachments with image extensions now render as <img> via
api/media endpoint, with click-to-fullscreen support
- Non-image attachments still show paperclip + filename badge
- Extracts filename from full path for display
- Add 5 regression tests
* fix: hoist _IMAGE_EXTS to module scope, add avif (absorb fix)
* fix: improve mobile touch responsiveness for session list items
iPad Safari has known issues with the click/dblclick pattern on touch:
- :hover-triggered padding-right layout shift causes the first tap click
to target the wrong element (actions button that just appeared)
- No touch-action:manipulation means iOS still delays taps for
double-tap zoom detection
- The old onclick+ondblclick pattern is designed for mouse, not touch
Changes:
- CSS: Remove :hover from padding-right rule to prevent layout shift
- CSS: Add touch-action:manipulation and -webkit-tap-highlight-color
to .session-item for immediate tap response
- JS: Replace onclick/ondblclick with onpointerup + manual 350ms
double-tap detection — works consistently on mouse and touch
* fix(#1106): iterate custom_providers[].models dict keys for dropdown population
- After reading singular 'model' field, also iterate 'models' dict keys
- Deduplicate: model field value not repeated if also in models dict
- Skip non-string keys gracefully
- Works for both named and unnamed custom_providers entries
- Add 7 regression tests
* fix(#1105): allow custom_providers hostnames through SSRF check
- Build trusted hostname set from custom_providers[].base_url in config.yaml
- These are user-explicitly configured endpoints — not SSRF risks
- Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active
- Unknown private IPs still blocked
- Add 7 tests (5 source analysis + 2 functional with mocked socket)
* fix(tests): update hover padding assertions for #1110 touch fix (absorb)
* fix(css): restore hover padding via @media (hover:hover) for mouse devices (absorb)
* fix: filter right/middle-click from pointerup handler (absorb)
* docs: v0.50.221 release notes and version bump
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: sheng <378978764@qq.com>
* fix(ui): workspace panel collapse priority + visible project color dot
Two related sidebar UI bugs from project-ui-bugs.md:
1. Workspace panel header had no collapse priority. As the right panel
narrowed, all three header children (Workspace label, git badge,
icon buttons) compressed at the same rate because `.panel-header`
used `justify-content:space-between` with no flex-shrink ratios.
The icon buttons -- the actual primary controls -- could disappear
before the git badge (which is least-essential metadata).
Fix: declare `.rightpanel` as a `container-type:inline-size` container.
Replace `justify-content:space-between` with `gap:6px` plus
`margin-left:auto` on `.panel-actions`. Set flex-shrink:0 on
`.panel-actions` (icons never shrink), flex-shrink:2 on the label,
flex-shrink:3 on `.git-badge` (shrinks fastest), and
`min-width:0;text-overflow:ellipsis` for graceful intermediate
shrink. Add @container queries that crisply set `display:none` on
the git badge below 220px and on the label below 160px.
2. Project color dot was appended INSIDE the `.session-title` span,
which is `overflow:hidden;text-overflow:ellipsis`. Long titles
clipped the dot off entirely -- hiding the project marker exactly
when it was most needed. The timestamp was also `position:absolute`,
so the title's `flex:1` ran underneath it and there was nowhere
coherent to anchor the dot.
Fix: in sessions.js, append the dot to `titleRow` between title and
timestamp (a flex sibling, not inside the truncating title span).
In style.css, move `.session-time` from absolute positioning to
`margin-left:auto` in the flex row. Drop the
`margin-left:4px/vertical-align:middle` from
`.session-project-dot` (gap:6px on the row handles spacing).
Reduce `.session-item` padding-right at rest from 86px (which was
reserving space for the absolute timestamp) to 8px; expand to 40px
on hover/streaming/unread/menu-open/focus-within so the absolute
action button + attention indicator still have room.
Tests:
- tests/test_workspace_panel_session_list.py (14 new tests)
- tests/test_issue856_pinned_indicator_layout.py updated to reflect
the new flex-flow timestamp + reduced rest-padding
Full suite: 2433 passed, 47 skipped, 0 PR-related failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui): remove duplicate margin-left:auto from .git-badge
With .panel-actions already carrying margin-left:auto, both .git-badge
and .panel-actions having auto margins split the free space equally,
centering the badge instead of keeping it adjacent to the label.
Remove margin-left:auto and margin-right:4px from .git-badge. The
panel-header gap:6px handles label→badge spacing; panel-actions
margin-left:auto owns the right-push. Layout: [label][badge][→][actions].
* fix(ui): mobile session-item padding 86px → 40px + git-badge margin fix
Two fixes from Opus independent review of #1089:
1. Mobile padding regression: .session-item mobile override had
padding:10px 86px 10px 12px — the 86px was reserving space for the
old position:absolute timestamp. Since the timestamp now lives in the
flex flow of .session-title-row (margin-left:auto), that 86px
reservation is wasted and pushes the timestamp ~76px from the right
edge, leaving dead space between it and the always-visible action
button. Fixed: 86px → 40px (matching desktop hover/attention rule,
only enough for the absolute action button at right:6px + 26px wide).
2. Duplicate margin-left:auto on .git-badge: the old rule from master
had margin-left:auto on .git-badge (for the old space-between layout).
With .panel-actions also having margin-left:auto, the two auto margins
split free space equally, floating the badge to the middle of the header
instead of keeping it flush against the label. Removed margin-left:auto
and margin-right:4px from .git-badge; gap:6px on .panel-header handles
label→badge spacing; .panel-actions margin-left:auto owns the right-push.
Updated tests:
- test_workspace_panel_session_list.py: assert 40px mobile padding
- test_issue856_pinned_indicator_layout.py: assert 40px mobile padding
Verified by Playwright visual QA:
- Desktop 250px: badge hidden, Workspace label visible, icons visible ✓
- Desktop 150px: badge hidden, label hidden, icons only ✓
- Project dots visible on long-title sessions (outside truncating title span) ✓
- Mobile: padding-right=40px, no layout overflow ✓
* docs: v0.50.220 release notes, test count 2481, roadmap
---------
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>
* fix(projects): opaque context menu + auto-sizing rename/create input
Two project chip UI bugs reported in project-ui-bugs.md:
1. Right-click context menu was transparent and the session list bled
through it. Root cause: _showProjectContextMenu set
background: var(--panel), but --panel is not defined anywhere in
style.css, so the menu fell back to transparent. Fix: use
var(--surface) -- the same opaque variable used by
.session-action-menu and other floating popovers.
2. The rename and new-project input field was hard-coded to 100px
regardless of the project name being edited (a 3-letter name got
the same field size as a 20-letter name). Fix: drop width:100px
from .project-create-input, replace with
min-width:40px / max-width:180px / width:auto. Add a
_resizeProjectInput() helper that measures the current value with
a hidden span and sets pixel width inside those bounds. Wired into
both _startProjectRename (called once on focus, again on every
input event) and _startProjectCreate (same pattern).
Tests: 9 new static-source tests in tests/test_project_chip_ui.py
that pin (a) var(--panel) is undefined in style.css so the fallback
trap doesn't return; (b) menu uses var(--surface); (c) the fixed
width:100px is gone and min/max bounds are present; (d) the
_resizeProjectInput helper is defined and called from both flows.
Full suite: 2419 passed, 47 skipped, 0 PR-related failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(projects): use getComputedStyle in _resizeProjectInput sizer span
Switch the hidden sizer span from hardcoded font-size:10px / font-family:inherit
to reading the live values from getComputedStyle(inp). This keeps the sizer
calibrated if the CSS rule ever changes, rather than silently drifting.
Also update test_resize_helper_uses_hidden_span to assert getComputedStyle
is used rather than the old literal font-size check.
Suggested by Opus independent review of #1086.
* docs: v0.50.219 release notes, test count 2467, roadmap update
---------
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>
* fix(css): add overflow-wrap:anywhere to chat bubbles — prevents long URL overflow (#1080)
* fix(projects): rename now works via dblclick timer guard + right-click color picker (#1078)
* fix(renderer): block-level constructs inside blockquotes now render
Fenced code blocks, headings, horizontal rules, and ordered lists inside
blockquotes now render correctly. Six related bugs documented in
blockquote-rendering-bugs.md were collapsed into one architectural fix
in renderMd().
Bugs fixed (all 6):
1. Fenced code blocks inside blockquotes -- > prefixes leaked into the
<pre> body and the blockquote got fragmented around the rendered
code, sometimes leaving raw <pre>/<div class="pre-header"> as
visible text.
2. Blank > continuation lines fragmented multi-paragraph blockquotes
into separate <blockquote> elements with literal > between them.
3. ## headings inside blockquotes rendered as literal "##" text.
4. Numbered lists inside blockquotes rendered as plain prose.
5. Complex blockquote (mixed headings + code + list + inline code)
collapsed into a monospace blob with raw markdown syntax leaking
everywhere.
6. Horizontal rules (---) inside blockquotes rendered as literal text.
Root cause:
The per-line passes for fenced code, headings, hr, ordered lists all ran
BEFORE the blockquote handler and could not match lines that started
with >, so by the time blockquote stripping ran those constructs had
already been mishandled.
Fix:
A new blockquote pre-pass at the top of renderMd():
- Walks lines fence-aware so > -prefixed lines inside non-blockquote
code fences (e.g. shell prompts in bash code blocks) are not
miscaptured as a blockquote.
- Groups consecutive > -prefixed lines, strips the > prefix, and
recursively calls renderMd() on the stripped content. The recursive
call handles all block-level constructs (fenced code, headings, hr,
ordered/unordered lists, nested blockquotes) using the same pipeline.
- Wraps the rendered HTML in <blockquote> and stashes it with a \x00Q
token. Restored at the very end of renderMd() so no later pass can
mangle the inner HTML.
The old _applyBlockquotes regex-replace is removed entirely along with
its limited inline branches for nested blockquotes and unordered lists.
Behaviour change:
Blockquotes now produce CommonMark-compliant <p> wrapping for text
content (was: bare text directly inside <blockquote>). The visual
output is the same in browsers but the HTML structure is now standard.
Tests:
- 14 new behavioural tests in tests/test_renderer_js_behaviour.py
drive the actual renderMd() via node and lock all 6 bug fixes.
- .local-review/test_blockquote_bugs.js -- node harness covering the
same scenarios, runnable manually for fast iteration.
- 2407/2408 tests pass (1 pre-existing macOS-only failure deselected).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(renderer): entity decode before blockquote pre-pass + CSS margin fix
- Move the >/</& entity-decode to run at the very top of
renderMd(), before the blockquote pre-pass. Previously decode() ran
at line 756 (after the pre-pass at line 697), so LLM output containing
>-encoded blockquotes was never matched by the pre-pass.
- Add .msg-body blockquote p{margin:0} and .preview-md blockquote p{margin:0}
so the new CommonMark-compliant <p> wrapping inside blockquotes doesn't
add extra vertical spacing. Prior shape (bare text) had no default p-margins.
- Add Node-driven tests: TestBlockquoteEntityEncodedInput covers > prefix
and >-encoded fenced code inside blockquotes.
- Add struct test: TestBlockquotePrePassOrdering::test_entity_decode_runs_before_blockquote_pre_pass
locks decode < _bq_stash ordering in ui.js.
Fixes found during Opus independent review of #1083.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: v0.50.218 release notes, test count 2458, roadmap update
---------
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>
* 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>
* fix(models): disk cache now used on restart, cold path locked, 24h TTL
Root causes fixed:
- reload_config() was deleting disk cache on every server start (cfg_mtime 0.0 vs real mtime).
Now saves old mtime before update and skips cache deletion on first-ever load.
- Cold path was running outside the lock causing thundering herd on startup.
Now extracted to _build_available_models_uncached() helper running inside RLock.
- Disk cache was never being checked before lock acquisition.
Now loads from disk BEFORE acquiring lock; cache hit returns without lock contention.
- Credential pool load_pool() was called per-provider per-request (~10s for zai).
Now cached in _CREDENTIAL_POOL_CACHE with 24h TTL.
Result: /api/models returns in ~1ms on restart instead of ~30s.
* fix(ui): block stale SSE events, cancel old stream on switch, clear pending files after send, focus textarea after switch, instant click for inactive sessions, rename session via titlebar dblclick
Key UX improvements:
- Block stale SSE responses from old sessions reaching new session DOM after switch
- Cancel in-flight streaming when switching sessions
- Clear pending files after send (prevents ghost attachments in tray)
- Auto-focus message textarea after session switch
- Instant click for inactive sessions (no loading spinner blocking)
- Double-click app titlebar to rename active session
- Persist/restore composer draft across session switches
* style: add user-select:none to session titles to prevent accidental text selection
* fix(models): prevent concurrent cold path runs with _cache_build_in_progress guard
Thread 2 was re-entering the cold path (via RLock) while Thread 1 was
still inside it, causing duplicate 10s zai load_pool() calls. The RLock
allows re-entry from the same thread, defeating the 'only one cold path'
guarantee. Now threads wait on _cache_build_cv instead of re-entering.
* fix(models): add missing global declarations, move mtime check to outer scope for test
* fix(models): attach _cache_build_cv to the RLock so notify_all() is safe
* fix(models): evict _CREDENTIAL_POOL_CACHE entries when provider cache is invalidated
Without this, invalidate_provider_models_cache(provider_id) cleared the
models cache but left stale CredentialPool objects in _CREDENTIAL_POOL_CACHE
for up to 24h. The next get_available_models() cold path would re-use the
stale pool instead of re-loading, meaning new credentials added by the user
wouldn't show up until the pool TTL expired.
Now evicts both provider_id and its canonical alias from the pool cache
so the next cold path re-loads from disk.
* fix(merge): restore #1024/#1025 work in static/sessions.js after rebase
The merge of master (commit 05d1ba9) resolved the static/sessions.js
conflict by keeping the contributor's version, which silently dropped
several pieces of work that had landed via PR #1024 and #1025:
PR #1024 (session attention indicators):
- _renderOneSession(s, isPinnedGroup=false) signature
- body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))
- pinned-group dedup: if(s.pinned&&!isPinnedGroup) ...
- last_message_at preference in _sessionTimestampMs
- Right-slot attention indicator + hide-timestamp-when-attentive
PR #1025 (session restore speed):
- &resolve_model=0 on the loadSession metadata fetch
- S.session._modelResolutionDeferred=true after assignment
- _resolveSessionModelForDisplaySoon(sid) helper + invocation
- &resolve_model=0 on the lazy full-message fetch
Restoration approach: reset sessions.js to current master, then layer
the contributor's #1060 additions on top:
- _loadingSessionId global for stale-response discard
- composer draft persistence on session switch (via S.composerDrafts)
- _loadingSessionId !== sid bail-outs at every async await point
- Cleanup _loadingSessionId = null at all exit paths
Test outcome:
- tests/test_issue856_pinned_indicator_layout.py: 5/5 (was 5/5 fail)
- tests/test_session_metadata_fast_path.py: 5/5 (was 3/5 fail)
- tests/test_session_sidebar_relative_time.py: 5/5 (was 1/5 fail)
- Full suite: 2233 passed, 0 failed
fix(models): clear _CREDENTIAL_POOL_CACHE in invalidate_models_cache
The 24h-TTL credential pool cache introduced in this PR was keyed by
provider_id only, so when a user added/changed credentials, or when
tests called invalidate_models_cache() between cases with different
auth payloads, the cached CredentialPool from the prior payload leaked
into the new run.
Two complementary fixes:
1. invalidate_models_cache() now also clears _CREDENTIAL_POOL_CACHE
2. invalidate_provider_models_cache(provider_id) pops just that
provider's entry — surgical eviction for live key edits
Pinned by tests/test_credential_pool_providers.py — 23/23 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: invalidate disk cache in invalidate_models_cache(); reset _cache_build_in_progress on exception
1. invalidate_models_cache() now calls _delete_models_cache_on_disk() so that the
on-disk snapshot at /dev/shm is removed alongside the memory cache. Without this,
_load_models_cache_from_disk() serves a stale prior-test result immediately after
invalidation, breaking all test_credential_pool_providers and test_model_resolver
tests that rely on get_available_models() returning fresh mocked data.
2. Wrap _build_available_models_uncached() in try/except so _cache_build_in_progress
is always reset (+ notify_all) even if the rebuild raises unexpectedly, preventing
waiting threads from being stuck at wait_for() for the full 60s timeout.
3. Fix misleading comment: "avoid deadlock" → "file I/O outside the lock".
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* docs: v0.50.212 release notes and version bump
Model cache perf, session switch UX improvements, cache isolation fixes.
---------
Co-authored-by: Josh <josh@fyul.link>
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>
Co-authored-by: JKJameson <JKJameson@users.noreply.github.com>
* Shorten session sidebar relative time labels
* feat: adaptive session title refresh based on conversation evolution
Addresses #869 — the 'Optional' part: adapt session names to current
conversation context instead of only generating once from the first exchange.
Backend (api/streaming.py):
- Add _latest_exchange_snippets() to extract last user+assistant pair
- Add _count_exchanges() to count user messages
- Add _get_title_refresh_interval() to read the setting
- Add _run_background_title_refresh() — refreshes title from latest exchange
with LLM, skips if title is unchanged or user manually renamed
- Add _maybe_schedule_title_refresh() — checks exchange count and schedules
refresh after stream_end (non-blocking)
Config (api/config.py):
- Add auto_title_refresh_every setting (default '0' = off)
- Enum validation: {'0', '5', '10', '20'}
Frontend:
- Settings UI dropdown (static/index.html)
- Wire up load/save in panels.js
- i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant)
Default: off. Opt-in via Settings > Conversation > Adaptive title refresh.
* test: add 37 tests for adaptive title refresh helpers
Covers all five new functions introduced in this PR:
_count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval,
_run_background_title_refresh, _maybe_schedule_title_refresh
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
* fix(settings): show selected state on theme/skin/font-size picker cards
The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was
overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and
siblings — `!important` beats inline styles. Active cards showed no visual highlight.
Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active
rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the
sync functions. 5 regression tests added.
Closes#1057
* fix: rename test file to match PR number, fix stale issue reference
* docs: v0.50.211 release notes and version bump
Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix.
* docs(changelog): correct settings tab for adaptive title refresh
The v0.50.211 entry for #1058 said "Settings → Appearance" but the
toggle is actually rendered inside settingsPanePreferences (the
Preferences tab) per static/index.html:604+. The commit message also
had the wrong tab ("Conversation"). Updated CHANGELOG to match the
actual UI surface so users can find the toggle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: create state dir before writing settings file
save_settings() called SETTINGS_FILE.write_text() without ensuring the
parent directory exists. In fresh environments (CI, first run without
HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError.
Add mkdir(parents=True, exist_ok=True) before the write.
---------
Co-authored-by: Pavol Biely <biely@webtec.sk>
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
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>
* feat: add manual 'Check for Updates' button in System settings (#785)
Add a 'Check now' button next to the version badge in the System
settings section, allowing users to manually trigger an update check
at any time without waiting for the automatic periodic check.
Changes:
- index.html: add button with spinner and status text inline with version badge
- panels.js: add checkUpdatesNow() calling /api/updates/check?force=1
with immediate feedback (checking... / up to date / X updates available)
- style.css: style the button block and spinner
- i18n.js: add 5 new keys (settings_check_now, settings_checking,
settings_up_to_date, settings_updates_available, settings_updates_disabled)
in all 6 locales (en, ru, es, de, zh, zh-Hant)
* fix: sanitize error message in checkUpdatesNow to avoid exposing paths
Review feedback: strip filesystem paths from error messages and cap
length to prevent internal details leaking into the UI.
* fix: fully sanitize error in update check — never expose raw e.message in UI
Previous partial fix (80cdaee) stripped filesystem paths from e.message but
still displayed the JS exception message to users. Per reviewer feedback and
project convention (NEVER expose raw e.message in UI), replace with:
- A generic user-facing i18n key (settings_update_check_failed) as default
- Fallback to API response body error if available (structured, not raw)
- Full error logged via console.warn for debugging
- Button disable-during-check already confirmed working (try/finally pattern)
- settings_update_check_failed key added in all 6 locales
* fix(#785): align HTML selectors with CSS and add regression tests
- Wrap update button in div#checkUpdatesBlock so CSS selectors apply
- Change button class from sm-btn to btn-tiny (matching stylesheet)
- Remove inline styles now handled by CSS (#checkUpdatesBlock, .btn-tiny)
- Move spinner sizing to CSS class .spinner-xs
- Add 4 static tests in test_update_banner_fixes.py:
checkUpdatesNow defined, btnCheckUpdatesNow in HTML, CSS selectors exist, i18n key in all locales
* feat: 'Keep workspace panel open' toggle in Appearance settings (#999)
* feat: categorize providers in setup wizard (#603)
- Add 6 new providers: Google Gemini, DeepSeek, Mistral, xAI (Grok),
Ollama, LM Studio to the onboarding quick-setup catalog
- Group providers into 3 categories: Easy start, Open/self-hosted,
Specialized — rendered as <optgroup> in the provider dropdown
- Generic base_url save logic (requires_base_url + default_base_url)
instead of hardcoded provider checks
- i18n keys for category labels in en, ru, es, zh, zh-Hant
* ci: re-run tests
* fix(tests): prevent reload_config() from overwriting in-memory mock in test_issue644
The test helper _available_models_with_cfg patches cfg in-memory but
get_available_models() calls reload_config() when the config file's
mtime doesn't match _cfg_mtime. On CI, config.yaml exists so mtime > 0
and _cfg_mtime starts at 0.0, triggering a reload that overwrites the
test's mock with on-disk content.
Fix: freeze _cfg_mtime to the current config file mtime inside the
helper, so reload_config() is not triggered during the test.
* fix: correct default model IDs for gemini, xai, deepseek; add specialized provider tests
- gemini: gemini-3.1-pro-preview → gemini-2.5-pro-preview
- x-ai: grok-4.20 → grok-3
- deepseek: deepseek-chat-v3-0324 → deepseek-chat
- Add TestApplyBaseURLSpecialized: 4 tests verifying base_url written for
gemini, deepseek, mistral, and x-ai through apply_onboarding_setup
* test: add TestApplyBaseURLSpecialized — verify base_url written for gemini, deepseek, mistralai, x-ai
* fix(onboarding): correct stale model defaults for specialized providers
Three issues in the new specialized provider catalog (#1027 hold reason):
1. gemini default_model was `gemini-2.5-pro-preview` — agent's catalog
has the 3.1 family. Updated to `gemini-3.1-pro-preview`.
2. x-ai default_model was `grok-3` — agent's catalog has `grok-4.20`.
Updated.
3. gemini `models` list was sourcing from `_PROVIDER_MODELS.get("gemini")`
which returns []. The catalog in api/config.py is keyed under "google"
(even though the agent's alias map normalizes google -> gemini).
Switched to `_PROVIDER_MODELS.get("google")` so the wizard surfaces
the actual 5-model list. Also forward-compatible lookup for x-ai
(xai or x-ai key).
Without these fixes, users picking gemini or x-ai in the wizard would
see no model dropdown and the default_model written to config.yaml
would 404 on first chat.
deepseek default_model bumped from `deepseek-chat` to
`deepseek-chat-v3-0324` to match the test fixture's expectation and
the agent catalog's pinned version.
Added two regression tests:
- test_gemini_model_list_is_populated: pins the catalog-key correctness
- test_specialized_default_models_match_catalog: pins the version
prefixes (3.x for gemini, 4.x for grok)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: inline HTML preview in workspace panel (#779)
Render .html/.htm files as live previews in a sandboxed iframe instead
of showing raw source code. Adds an 'Open in browser' button to open
the file in a new tab.
Changes:
- workspace.js: add HTML_EXTS set, 'html' preview mode, iframe routing
in openFile(), and openInBrowser() function
- index.html: add sandboxed iframe element and 'Open in browser' button
in preview toolbar (visible only for HTML files)
- i18n.js: add 'open_in_browser' key in all 6 locales
The iframe uses sandbox='allow-scripts' for security. Download button
remains available alongside the new preview.
* docs: document sandbox security tradeoff for HTML preview
Review feedback: fileExt() already lowercases extensions so .HTML/.HTM work.
Added code comment explaining the deliberate sandbox=allow-scripts choice:
scripts are needed for most HTML documents but the iframe is still origin-
isolated and cannot access parent cookies/data.
* fix: pass ?inline=1 to file/raw so HTML preview iframe renders instead of downloading
routes.py: add inline_preview param — bypasses Content-Disposition:attachment for
text/html when ?inline=1 is set, serving the file inline for the sandboxed iframe.
workspace.js: add &inline=1 to the iframe src URL.
test: add 5 static regression tests for the inline HTML preview.
* fix(security): CSP sandbox header for inline HTML preview
The iframe sandbox="allow-scripts" attribute on previewHtmlIframe only
applies when HTML is loaded INSIDE that iframe. A user tricked into
opening /api/file/raw?path=evil.html&inline=1 directly in a top-level
tab (e.g. via a chat link) would render the HTML in the WebUI's origin
without any sandbox, giving the page full access to cookies and
localStorage.
Server-side Content-Security-Policy: sandbox allow-scripts mirrors the
iframe sandbox exactly: scripts run, but the document is treated as a
unique opaque origin (no allow-same-origin) and cannot read WebUI
cookies, localStorage, or postMessage to the parent regardless of how
the URL is accessed.
Added test_inline_html_response_sets_csp_sandbox to pin the header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.50.209 release notes — 4 PRs, 2212 tests (+43)
* docs(changelog): document #1040 queue flyout and Cloudflare CSP in v0.50.209
The stage commit ed2bd18 listed v0.50.209 as a 4-PR release but the
stage actually bundles 5 PRs — #1040 (queue flyout) was cherry-picked in
without a corresponding CHANGELOG entry. Without this fix, the queue
feature ships silently and the bundled Cloudflare CSP relaxation in
api/helpers.py is also undocumented.
Adds two entries:
- Added: queue flyout (#1040) under v0.50.209
- Changed: CSP allowlist for Cloudflare Access deployments
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
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>
* chore: apply pending #965 queue flyout patches on local master
Queue flyout implementation (PR #965 — pending merge) applied on top of
upstream v0.50.205. Features:
- Queue card slides up from behind composer (approval-card pattern)
- Lucide icons via li(), CSS class system, no inline SVG dumps
- Drag-to-reorder by _queued_at timestamp (survives re-renders)
- Inline contenteditable edit with focus guard and blur-commit
- Combine preserves first item files, merge immediate (no 200ms race)
- Files/model compact badges per item
- Hide/expand via header chevron + composer pill + titlebar chip
- All 3 expand paths sync correctly
- border-bottom CSS order fixed, fingerprint improved, _dragTs guards
CF CSP domains also applied (deployment-specific, not in upstream PR).
* fix(queue): harden merge closure, toggleQueue sid, and drain flash
- mergeBtn _doMerge now reads live queue (_getSessionQueue) instead of stale closure q
- toggleQueue reads activeSid from S.session at call time, not captured param
- updateQueueBadge defers chips.innerHTML='' by 360ms so slide-out transition completes before content clears
* style(queue): contain:paint on inner, pill fade-in animation
* feat(queue): pill outside composer, compact collapsed state matching card width
- Move #queuePill out of .composer-box to between .composer-flyout and .composer-box
- Pill styled as compact queue-card-inner (same border, radius:14px 14px 0 0, no border-bottom)
- Pill width matches card inner: max-width:calc(var(--msg-max)-40px), centered
- Pill stays visible until user re-expands or queue drains (updateQueueBadge no longer
hides pill when card is manually collapsed)
- Remove all queue-active/queue-pill-active composer modifications — composer untouched
- Fix: mergeBtn reads live queue not stale closure
- Fix: toggleQueue uses S.session.session_id at call time not captured param
- Fix: chips.innerHTML deferred 360ms on drain to avoid empty-card flash
* fix(queue): collapsed state persists + cross-session DOM isolation
- Add _queueCollapsed[sid] flag: set by hideBtn, cleared by pill expand / queue drain
- _renderQueueChips respects flag — no longer reopens card when new message queued while collapsed
- updateQueueBadge else-branch: DOM mutations now gated on sid===active session
- _syncQueueTitlebar only fires for active session in else-branch
- Fixes Opus/Codex-identified bugs: pill auto-reopen and cross-session DOM corruption
* fix(queue): proper pill wrapper matching queue-card structure
- Add .queue-pill-outer div wrapper (max-width:var(--msg-max); padding:0 20px)
identical to .queue-card outer — positions pill button at exact card-inner width
- .queue-pill button fills slot with width:100%
- Removes hardcoded 740px — width is derived correctly from the same CSS variables
the card uses, scales with --msg-max across all viewports
- JS toggles .show on pillOuter (parentElement), not on pill button directly
---------
Co-authored-by: Basit Mustafa <basit.mustafa@gmail.com>
* 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>
At 641-767px the sidebar was in a no-mans-land: hamburger hidden (<=640 only)
and rail also hidden (>=768 only). Users could still navigate via the sidebar-nav
tabs inside the sidebar, but the rail was absent unnecessarily.
Changing the rail breakpoint from min-width:768px to min-width:641px closes the
gap. The sidebar slide-in behavior (position:fixed, hamburger toggle) stays at
<=640px only, so the mobile UX is unchanged. At 641-767px the rail now appears
alongside the persistent sidebar.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
refactor(ui): three-column layout with left rail + main-view migration (#899)
Unifies the shell into a three-column layout (rail + sidebar + main) matching the
hermes-desktop reference, and migrates every per-item detail/edit surface into a
shared main-view canvas with consistent headers, empty states, and action buttons.
Changes:
- New desktop-only left rail (48px) with 8 nav tabs (chat/tasks/skills/memory/workspaces/profiles/todos/settings)
- Persistent app titlebar (replaces per-chat topbar), active conversation title shown
- All panel detail/create/edit views migrated to #mainSkills, #mainTasks, #mainSettings, #mainWorkspaces, #mainProfiles, #mainMemory
- Settings moved out of modal into main-view page; ESC closes it
- YAML frontmatter rendered in collapsible <details> block in skill detail
- Toasts repositioned from bottom-center to top-right with theme-aware success/error/warning/info variants
- Composer workspace chip split into two-button group: files-icon toggles file panel, label opens workspace picker
- .settings-menu → .side-menu / .side-menu-item (generalised, shared by memory and settings panels)
- i18n: ~25 new keys across en/ru/es/de/zh/zh-Hant for all new form labels, placeholders, and empty states
- Mobile: hamburger in titlebar, slide-in sidebar; box-shadow removed from sidebar
- New regression test: tests/test_settings_navigation_and_detail_refresh.py (9 tests)
Co-authored-by: Aron Prins <pwf.aron@gmail.com>
* fix: reasoning chip dropdown visible + SVG icon + /btw answer no longer wiped (closes#933)
* fix(ui): resize handler symmetry + lock regressions for PR #934 fixes
Two small additions on top of the core PR:
1. Resize handler now re-positions the reasoning dropdown when the window
resizes while it's open, matching the existing model-dropdown branch.
Without this, resizing while the dropdown is open leaves it aligned to
the pre-resize chip position — fine in practice (most resizes close the
dropdown via the global click handler) but inconsistent with the
model-dropdown sibling.
2. Regression test file tests/test_reasoning_chip_btw_fixes.py with 10
tests locking all four fixes in place so they can't silently regress:
- Dropdown sits OUTSIDE .composer-left (so overflow-y: hidden can't clip it)
- Dropdown is grouped with the other composer-level dropdowns
- Chip button contains stroke="currentColor" SVG (not a 🧠 emoji)
- _applyReasoningChip() body doesn't include 🧠
- cmdReasoning calls _applyReasoningChip(eff) directly with the
server-confirmed effort, not syncReasoningChip() (stale cache)
- _streamDone flag declared, set in done handler, checked in onerror
- _ensureBtwRow() called in done handler (creates bubble when no tokens arrive)
- resize handler re-positions composerReasoningDropdown
Full suite: 2056 passed, 0 failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
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>
Rebased onto master after #931 (aux title routing) to resolve streaming.py conflict.
All changes from both PRs are cleanly integrated.
2088 tests passing (2065 master + 23 from #931).
Co-authored-by: bergeouss <bergeouss@gmail.com>
* fix: correct interleaved streaming order (Text → Thinking → Tool → Text)
During live streaming, tool cards were inserted before their associated
thinking cards instead of after them. The root cause was that
appendLiveToolCard's anchor selector didn't include .thinking-card-row,
so finalized thinking cards were skipped when finding the insertion point.
Changes:
- messages.js: Add segment splitting (segmentStart/_freshSegment) so each
text segment after a tool call renders only its own slice, not the full
accumulated text. Sync thinking card render in reasoning handler to
avoid rAF race with tool events. Guard removeThinking() to preserve
finalized cards when reasoningText is active.
- ui.js: Add .thinking-card-row to appendLiveToolCard anchor selector so
tool cards land after finalized thinking. Add anchor-based positioning
to appendThinking for correct interleaved placement. Clean up empty
spinner-only thinking rows in finalizeThinkingCard. Add 3-dot waiting
indicator (toolRunningRow) after tool cards for visual feedback.
- style.css: Scope blinking cursor to last live-assistant segment only.
Add spacing for toolRunningRow.
* chore: CHANGELOG for v0.50.174
---------
Co-authored-by: bsgdigital <bsgdigital@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Closes#856. Co-authored-by: Frank Song <138988108+franksong2702@users.noreply.github.com>
Reviewed-by: nesquena (709bd37 — test isolation fix also included)
Adds GET /api/workspaces/suggest endpoint and autocomplete dropdown in the Spaces panel. Suggestions limited to trusted roots (home, saved workspaces, boot default). Keyboard nav, Tab completion, hidden dir support. Symlink-escape and dotdot-escape invariants locked by regression tests.
User bubble selection contrast fixed via scoped ::selection CSS (closes#877). Also adds missing provider i18n keys to es/de/zh/ru/zh-Hant locales, fixing 3 CI failures that crept in from PR #867.
* fix(appearance): font size setting now visibly scales UI text
Root cause: the original CSS override only changed :root{font-size} which
has no effect on the 232+ hardcoded px values throughout style.css. Only
the ~49 em/rem values were affected, which are not the main visible text.
Fix: add explicit px overrides for the key UI surfaces under each
data-font-size attribute selector:
- .msg-body (chat messages) + headings, code, tables
- .session-item, .session-meta (sidebar session list)
- #msg (composer textarea)
- .file-item (workspace file tree)
The :root override is kept so em/rem cascade correctly, but the targeted
element overrides are what actually make the text visibly larger/smaller.
Also: 8 new regression tests lock in the targeted CSS rules so this
cannot silently regress again.
* fix: composer large font was no-op — bump to 18px (default is 16px)
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* feat(appearance): font size setting with Small/Default/Large toggle
Add a font size preference to the Appearance settings pane.
Three options (12px/14px/16px) follow the same three-button visual
pattern as the Theme picker. Closes#833.
- static/style.css: :root[data-font-size=small|large] CSS overrides
- static/index.html: boot script applies from localStorage before CSS
renders (no FOUC); fontSizePickerGrid HTML in Appearance pane
- static/boot.js: _applyFontSize(), _pickFontSize(), _syncFontSizePicker()
- static/panels.js: loadSettingsPanel syncs picker on open;
_revertSettingsPreview restores on discard
- static/i18n.js: settings_label_font_size + font_size_{small,default,large}
keys in all 6 locales (en, ru, es, de, zh, zh-Hant)
- tests/test_font_size_setting.py: 14 new tests
* fix(ui): remove duplicate font-size picker + correct CHANGELOG issue ref
Two small fixes on the font size feature:
1. Duplicate HTML IDs — the picker block was injected into BOTH
settingsPaneAppearance (correct, next to Theme/Skin) AND
settingsPanePreferences (accidental copy-paste). Duplicate IDs
#fontSizePickerGrid and #settingsFontSize violate HTML spec and
break the _syncFontSizePicker visual sync which reads via
document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn')
— only the first grid would update its highlight, leaving the second
stale. $('settingsFontSize') via getElementById also always returns
the first match, so the second hidden input never reflected the
user's choice.
Removed the Preferences-pane copy. The Appearance-pane copy is the
one the PR description describes and is the correct home for it
(next to Theme and Skin).
2. CHANGELOG trailer said `Closes #830.` but #830 is the session-search
autocomplete PR — this feature closes#833. Fixed.
Added two regression tests:
- test_font_size_picker_not_duplicated: asserts each ID appears exactly
once in index.html.
- test_font_size_picker_lives_in_appearance_pane: asserts the picker
sits inside settingsPaneAppearance and not any other pane.
Full suite: 1754 passed, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
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>
At the @media(max-width:900px) breakpoint both .close-preview and .mobile-close-btn were visible simultaneously. Since boot.js wires both to handleWorkspaceClose(), only the mobile-close-btn needs to show at that width. Adds .close-preview{display:none} to the 900px media block.
Fixes#781
Removes the bubble_layout toggle from Settings, all persistence, CSS, i18n strings, and the UI docs demo. The CSS was already effectively dead. Users with a saved bubble_layout value in settings.json get a clean migration via _SETTINGS_LEGACY_DROP_KEYS.
Credit: @aronprins (PR #760 / #777)
Co-authored-by: aronprins <aronprins@users.noreply.github.com>
Adds sub-argument suggestions for /model, /personality, /reasoning slash commands. /reasoning is now discoverable from the first slash. Keyboard navigation pre-selects the first item. Fixes bug where no-arg commands (/clear, /new, /stop, etc.) would loop the dropdown on selection.
Fixes#632
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Adds compact/detailed toggle for the session list sidebar. Compact is the default (no behavior change for existing users). Detailed mode shows message count and model; profile names only appear when mixing sessions across profiles.
Fixes#673
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
## Summary
Rebased on behalf of @aronprins from fork branch `codex/dark-user-bubbles`. Two asset-only commits (PR screenshot add/remove) were dropped; the two code commits are applied cleanly on top of current master (v0.50.110).
### What changed
**Dark-mode user bubbles** (`static/style.css`):
- `:root.dark` now overrides `--user-bubble-bg`/`--user-bubble-border` to `var(--accent-bg-strong)` (a 15% opacity tint) — keeps the bubble visually subdued in dark skins instead of a glaring bright accent fill
- Removes 6 per-skin `--user-bubble-text` hacks (ares, mono, slate, poseidon, sisyphus, charizard); text falls back to `var(--text)` which is already correct in dark mode
- Adds `--user-bubble-placeholder` token; edit-area box-shadow now uses `--focus-ring` instead of hardcoded `rgba(255,255,255,.15)`
**Thinking card collapsibility** (`static/ui.js` + `static/style.css`):
- `_thinkingMarkup()` now includes `onclick` toggle and chevron affordance, matching the compression reference card pattern
- `.thinking-card-header` gets `display:flex; gap:8px` for proper icon/label/chevron alignment
**Tests**: 2 new in `test_bugbatch_apr2026.py` (dark bubble token contract + no-per-skin-hack assertion), 2 updated in `test_ui_card_animation.py` (flex header layout + onclick pattern).
1520 passed. QA 20/20. Browser verified: dark mode bubble uses subtle tint, thinking card toggles correctly.
(credit: @aronprins)
Squash merge of PR #717 — rebased on behalf of @franksong2702.
## What it does
Fixes#680. Footer chrome (timestamps, copy, edit, regenerate) is now hover-only for both user and assistant message rows, consistent throughout the conversation. The last assistant turn keeps cumulative usage visible at rest; timestamp and actions are revealed inline on hover in the same row.
Key changes:
- `static/ui.js`: new `_formatMessageFooterTimestamp()` (local timezone, cross-day fuller format); `timeHtml` no longer gated to user-only; last assistant usage moved from separate `.msg-usage` div to inline `.msg-usage-inline` span in the footer
- `static/style.css`: `.msg-foot-with-usage` class + rules; assistant footer opacity changed from 0.45 to 0 (hover-only); `:focus-within` alongside `:hover` for keyboard users
- `api/streaming.py`: `_restore_reasoning_metadata()` now preserves `_ts`/`timestamp` for unchanged historical messages
- `tests/test_sprint49.py`: 8 new tests covering rendering contract, hover CSS, timestamp preservation
Tests: 1518 passed. QA: 20/20. Browser verified. Reviewed and approved by @nesquena and @aronprins.
## Summary
Rebased-on-behalf of @franksong2702 (originally PR #728 — had CHANGELOG conflict after #747 merged).
Moves `#cmdDropdown` from outside `composer-box` to inside it, so the `position:absolute` anchor is scoped to the composer width rather than the full chat panel. CSS updated to use `bottom:calc(100% + 4px)` and `width:auto;max-width:100%` for clean upward positioning.
Closes#633
## Changes
- `static/index.html` — moved `cmd-dropdown` div inside `composer-box`
- `static/style.css` — updated `.cmd-dropdown` positioning (remove `margin-bottom`, use `bottom:calc(100% + 4px)`, add `width:auto;max-width:100%`)
- `tests/test_sprint50.py` — 2 new structural tests verifying DOM position and CSS rules
## Tests
1493 passed, 1 warning (2 new tests added)
**Original author:** @franksong2702
- Live search input in model dropdown (filter by name or ID)
- Provider group headers preserved in filtered view
- Clear button, Escape-to-close, No models found empty state
- i18n EN/ES/zh-CN strings
- CSS uses var(--accent) consistent with current theme system
- zh-CN double-escape fix included
- Provider headers regression fix included
- 1423 tests pass
Co-authored-by: mmartial <mmartial@users.noreply.github.com>
Four self-contained CSS/JS fixes: skill click auto-opens workspace panel (ensureWorkspacePreviewVisible before api call), thinking card body scrolls when open (overflow-y:auto), nav tab icons properly centered (display:flex), Safari iOS zoom prevented (textarea 14px->16px). Independent review by @nesquena confirmed all four correct.
Independent review by @nesquena confirmed all blockers resolved. Theme×skin two-axis system replaces old monolithic color schemes. Closes#627. Co-Authored-By: aronprins <aronprins@users.noreply.github.com>
Fixes four bugs + locks in one existing fix with regression tests.
Closes#594 (light theme dialogs), #576 (workspace panel snap), #585 (stale model list after CLI change), #567 (docker-compose macOS UID docs). Confirms and tests #590 (transcribing spinner already present).
Reviewed and approved by @nesquena. 1340 tests passing.
Squash-merges PR #584 by @aronprins.
Drops the meta row (message count, model slug, source-tag badge) from every sidebar session item. Each session now renders as a single title line — visible session count roughly doubles at typical viewport height.
Changes merged verbatim from contributor branch, plus maintainer additions:
- CHANGELOG entry for v0.50.64
- Version badge bump to v0.50.64
- New test: test_relative_time_today_bucket (closes minor coverage gap from code review)
Co-authored-by: aronprins <aronprins@users.noreply.github.com>
* fix: restore mobile chat scrolling and drawer close (#397)
- static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll)
- static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages
- static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut
- tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites
Original PR by @Jordan-SkyLF
* fix: preserve imported session timestamps (#395)
- api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False
- api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save
- tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block
Original PR by @Jordan-SkyLF
* fix(profiles): block path traversal in profile switch and delete flows (#399)
Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile
names directly into filesystem paths with no validation. An attacker could send
'../../etc/passwd' as a profile name to traverse outside the profiles directory.
- api/profiles.py: add _resolve_named_profile_home(name) — validates name with
^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via
candidate.resolve().relative_to(profiles_root); use in switch_profile()
- api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry
- api/routes.py: add _validate_profile_name() call at HTTP handler level for
both /api/profile/switch and /api/profile/delete (fail-fast at API boundary)
- tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes
Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master)
* feat: add desktop microphone transcription fallback (#396)
Mic button now works in browsers that support getUserMedia/MediaRecorder but
lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds).
- static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder);
keep mic button enabled when either SpeechRecognition or MediaRecorder is available;
MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript
into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither)
- api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls
transcription_tools.transcribe_audio(), always cleans up temp file
- api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit,
returns {text:...} or {error:...}
- api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to
allow getUserMedia in the same origin)
- tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription
- tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy)
- tests/test_sprint20.py: 3 updated tests for new fallback-capability checks
Original PR by @Jordan-SkyLF
* docs: v0.50.25 release — version badge and CHANGELOG
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* feat(ui): opt-in chat bubble layout
Closes#336.
Adds a settings toggle that right-aligns user messages and left-aligns
assistant replies. Off by default - the current full-width layout is
friendlier to code blocks and tool output, so bubbles are strictly
opt-in per the maintainer note on the issue.
Wiring follows the existing token-usage / cli-sessions pattern:
- api/config.py: new bubble_layout bool in _SETTINGS_DEFAULTS and
_SETTINGS_BOOL_KEYS, validated + persisted like the rest.
- static/style.css: .bubble-layout gated selectors using :has() to
tag msg-rows by .msg-role.user / .msg-role.assistant without any JS
changes to message creation. User rows get align-self: flex-end,
max-width: 75%, and a row-reverse header; assistant rows flex-start.
A 700px media query widens the max to 92% on narrow screens.
- static/index.html: new checkbox with i18n keys next to the existing
token-usage toggle.
- static/panels.js: loads the setting into the checkbox, saves it
back, and toggles body.bubble-layout immediately on save.
- static/boot.js: applies the class on initial load so refreshed
tabs honor the persisted setting without a flash.
- static/i18n.js: English label + description.
Test suite errors are environmental (test server fails to start on
port 8788 on main as well).
* i18n(es): add Spanish translations for bubble_layout setting
* fix+test: boot.js bubble-layout reset on failure; add 22 tests for issue #336
* docs: v0.50.24 release — version badge and CHANGELOG
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* feat: KaTeX math rendering for $..$ and $$..$$ in chat and previews (fixes#347)
- Stash math delimiters before markdown pipeline, restore as .katex-block/.katex-inline elements
- KaTeX JS lazy-loaded from CDN on first math block (mirrors mermaid pattern)
- KaTeX CSS loaded eagerly in <head> to prevent layout shift
- SRI hashes on both CDN tags
- throwOnError:false — bad LaTeX degrades to code span
- Supports $$, $, \\(...\\), \\[...\\] delimiters
- 18 new tests, 831/831 passing
* fix: remove invalid \' escape sequences in math stash lines
Lines 311, 314, 316, 317 had \' (backslash-quote) instead of plain '
in the arrow function bodies. This is a JS syntax error — node --check
fails with 'Invalid or unexpected token'. Likely caused by a
serialization artifact during code generation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: swap stash order (fence before math) to protect code spans; add renderKatexBlocks to workspace preview
- static/ui.js: fence_stash now runs BEFORE math_stash so dollar signs
inside backtick code spans are not extracted as math. Previously
`$x$` would render as KaTeX inside a <code> tag instead of
showing the literal string $x$.
- static/workspace.js: add requestAnimationFrame(renderKatexBlocks)
after markdown preview renders so math works in workspace file
previews, not only in chat messages.
* feat: KaTeX math rendering + stash order fix + workspace wiring (#352)
- tests/test_issue347.py: 11 new tests (29 total) covering fence-before-math
ordering, workspace.js renderKatexBlocks call, stash token distinctness,
false-positive prevention, safe-tags boundary check
- CHANGELOG.md: v0.50.15 entry; 870 tests total (up from 841)
* fix: use literal null byte (\x00M) in math stash token — matches restore regex
The original PR's second commit (fix: remove invalid \' escapes) accidentally
doubled the backslash in the math stash tokens: '\\x00M' is a 5-char string
(backslash + x + 0 + 0 + M) but the restore regex /\x00M/ expects a null byte.
Result: $...$ in messages produced visible \x00M0\x00 tokens instead of
KaTeX spans.
Changed all 4 math stash return statements to use '\x00M' (single backslash =
null byte, same convention as fence_stash's '\x00F').
Also updates test_stash_tokens_distinct to check for the correct pattern.
* fix: add null-byte token test; update CHANGELOG to v0.50.15 with fixes
- tests/test_issue347.py: add test_math_stash_token_uses_single_backslash_null_byte
to catch the \\x00M double-backslash regression; 30 tests total (up from 29)
- CHANGELOG.md: v0.50.15 entry documents all fixes including the token bug
and workspace preview wiring; 871 tests total
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* 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>
PR #301 changes:
- api/streaming.py: guard title_from() with s.title == 'Untitled' check
- api/routes.py: same guard in sync/non-streaming path
PR #302 changes (cleaned — restores accidentally-removed features):
- static/boot.js: PANEL_MAX 500 -> 1200
- static/boot.js: clearPreview() calls renderBreadcrumb() to restore dir view
- static/style.css: responsive .messages-inner breakpoints (1400px/1800px)
- static/workspace.js: renderFileBreadcrumb() function with clickable segments
- static/workspace.js: openFile() calls renderFileBreadcrumb(path)
12 new tests in tests/test_sprint35.py
Note: PR #302 branch contained several accidental regressions (removed app-dialog
system, onboarding CSS, _checkProviderMismatch, closeMobileFiles, etc.) that were
not part of its stated scope. This clean branch applies only the three intended
features on top of current master.
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Adds a bootstrap launcher and a blocking first-run onboarding wizard that guides
new users through minimum Hermes setup from the browser UI.
Supported provider flows: OpenRouter, Anthropic, OpenAI, custom OpenAI-compatible.
OAuth/terminal-first flows remain via 'hermes model'.
Security hardening applied during review:
- /api/onboarding/setup restricted to loopback when auth disabled
- Newline injection guard in _write_env_file
- esc() on setup.unsupported_note in onboarding.js
- Test isolation fix (send_key instead of bot_name in contamination test)
- Skip markers for PyYAML-dependent tests in agent-less environments
Tests: 693 passed (up from 679)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: gabogabucho <gabogabucho@gmail.com>
* feat: add real-time gateway session sync (Phase 1)
- Add gateway_watcher.py: background daemon polling state.db every 5s
for gateway session changes (telegram, discord, slack, etc.)
- Extend get_cli_sessions() to include all non-webui sources
- Add SSE endpoint /api/sessions/gateway/stream for real-time push
- Add dynamic source badges (telegram=blue, discord=purple, slack=dark purple)
- Rename 'Show CLI sessions' to 'Show agent sessions'
- Wire watcher lifecycle into server start/stop
- 10 tests covering metadata, filtering, SSE, and watcher lifecycle
- Activated via the same checkbox as CLI session import
Addresses GitHub issue #272
* fix: SSE event name mismatch, TLS attribute, remove PLAN.md
- Fix critical SSE bug: frontend listened for 'gateway_session_update'
but backend sends 'sessions_changed' -- events were silently dropped
- Fix frontend field check: data.changed -> data.sessions (matches
the actual payload structure from gateway_watcher)
- Fix TLS: ssl.TLSv1_2 -> ssl.TLSVersion.TLSv1_2 (the bare attribute
does not exist, would crash TLS setup and silently fall back to HTTP)
- Remove PLAN.md: implementation plan should not be committed to repo
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: test isolation and slow-consumer sentinel in gateway sync
tests/test_gateway_sync.py:
- Fix _get_test_state_dir() path mismatch: the function was computing
HERMES_HOME/webui-mvp-test but conftest.py sets HERMES_HOME=TEST_STATE_DIR,
so state.db was written to a double-nested path the server never read.
Now uses HERMES_WEBUI_STATE_DIR first (which conftest sets directly to
TEST_STATE_DIR), fixing the 7/10 test failures in full-suite ordering.
- Fix conn cleanup: removed conn.close() from inside try blocks so the
connection stays valid for _remove_test_sessions() in the finally block.
Previously the closed conn caused ProgrammingError in finally (swallowed
by bare except), leaving ghost sessions in state.db on test failure.
api/gateway_watcher.py:
- Fix slow-consumer queue eviction: when a subscriber queue fills (>10 events)
and is removed from _subscribers, now puts a None sentinel into it so the
SSE handler unblocks and closes the connection, letting EventSource
auto-reconnect. Without this the connection stayed open but received no
further events.
* fix: test isolation — set HERMES_WEBUI_TEST_STATE_DIR in conftest
The gateway sync tests write directly to state.db and must use the same
path the test server reads from. Previously they computed the path
independently, which broke when test_auth_sessions.py set a different
HERMES_WEBUI_STATE_DIR in the test-process environment at import time.
tests/conftest.py:
- Set HERMES_WEBUI_TEST_STATE_DIR=TEST_STATE_DIR in the test process's
os.environ (via setdefault) so gateway tests can read it reliably.
Using setdefault preserves any explicit override the caller may pass.
tests/test_gateway_sync.py:
- Simplify _get_test_state_dir(): check HERMES_WEBUI_TEST_STATE_DIR first
(now reliably set by conftest), fall back to HERMES_HOME/webui-mvp-test.
Remove the workaround that tried to snapshot HERMES_HOME at import time.
Result: 658/658 tests pass in full-suite ordering (was 651 pass / 7 fail).
---------
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The .ws-opt base class uses flex-direction:column which was causing the
SVG icon to stack above the label+meta text in each session action menu
item. Added row-flex layout to .session-action-opt .ws-opt-action,
removed inherited padding from .session-action-opt (moved to the inner
action span), and gave .ws-opt-icon a fixed width:16px + flex-shrink:0
so icons stay left-aligned regardless of text length.
Each menu item now shows: [icon] | Title\nSubtitle — one row per option.
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)
When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.
Fixes#230
* test: mobile layout regression suite — 14 tests for every QA run (#254)
Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.
* feat: /skills slash command lists and filters available Hermes skills (#257)
Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.
Fixes#248
* feat: shared app dialogs replace native confirm()/prompt() calls (#251)
Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.
Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.
Extracted from PR #242.
* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)
Fix#247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.
Fix#246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.
Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.
Fixes#247Fixes#246
* feat: session ⋯ action dropdown replaces per-row buttons (#252)
Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.
5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.
Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.
Extracted from PR #242.
* docs: v0.47.0 release notes, bump version, update test counts (645)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* 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>
- Added --input-bg and --hover-bg CSS variables to OLED theme
- Added OLED row to built-in themes table in THEMES.md
- Updated theme count from six to seven
True black background with subtle borders for OLED displays.
Pure #000 backgrounds, low-opacity borders, and warm accent colors
to minimize burn-in risk and maximize contrast.
Shows a blue banner when the webui or hermes-agent git repos are behind
their upstream branches. One-click 'Update Now' button does stash, pull
--ff-only, stash pop, then reloads the page.
Backend (api/updates.py):
- _check_repo(): git fetch + rev-list count with 15s timeout
- check_for_updates(): 30-min server-side cache, thread-safe, skips
Docker (no .git dir)
- apply_update(): stash (if dirty), pull --ff-only, pop, invalidate cache
Routes:
- GET /api/updates/check -- returns cached {webui, agent} with behind count
- POST /api/updates/apply -- {target: 'webui'|'agent'}
Frontend:
- Blue banner (matches reconnect-banner pattern) with 'Later' / 'Update Now'
- Non-blocking boot check via fire-and-forget .then(), once per tab session
- sessionStorage guards prevent re-checking and re-showing after dismiss
Settings:
- 'Check for updates' checkbox (default: on) -- when off, no git operations
- Removed 'Default Workspace' dropdown to keep settings panel compact
Performance:
- Server cache: git fetch at most 2x/hour regardless of client count
- sessionStorage: one check per browser tab session
- _check_in_progress flag prevents concurrent fetch storms
- Fire-and-forget: does NOT block the boot sequence
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: light theme sidebar, roles, chips, active states -- full polish
Comprehensive light theme overrides for every remaining hardcoded
dark-theme element:
Sidebar:
- Session items: warm dark text instead of faint muted gray
- Active session: blue accent (matching --blue) instead of washed-out gold
- Pin stars/headers: deep gold #996b15 instead of bright yellow #f5c542
- Session actions gradient: light bg instead of dark overlay
- Search input: dark borders, proper focus ring
Role labels:
- You: solid #2d6fa3 blue instead of faint rgba(124,185,255,0.65)
- Hermes: solid #8a6520 gold instead of faint rgba(201,168,76,0.6)
- Role icons: proper bg/border contrast for light backgrounds
Chips and interactive elements:
- Project chips: dark borders, dark hover states
- Model chip: blue accent matching theme
- New chat button: blue accent borders
- All hover states: rgba(0,0,0,.XX) instead of rgba(255,255,255,.XX)
Other surfaces:
- Composer box borders and focus ring
- Tool cards, cron items, suggestions
- File tree hover, preview badges
- Profile/workspace dropdown hovers
- Settings, nav tooltips
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: update THEMES.md with all current CSS variables
Added typography variables (--strong, --em, --code-text, --code-inline-bg,
--pre-text) to the custom theme guide. Added note about light theme
selector overrides needed for hover/border contrast.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added 5 new CSS variables to every theme block:
--strong, --em, --code-text, --code-inline-bg, --pre-text
Light theme: dark brown text, warm gray italics, saddle brown code on
subtle bg. All previously invisible text is now readable.
All themes get palette-appropriate values matching their design language
(Solarized orange, Monokai yellow, Nord green, etc).
Also fixed: remaining white borders to var(--border), light scrollbar,
code-bg contrast, settings overlay, approval card text.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: topbar, dropdowns, toast, approval card, tooltips, main area,
inputs, and hover states all used hardcoded rgba(22,33,62), #1a2535, etc.
These only looked correct on the Dark theme — all other themes showed
jarring dark-navy elements on non-navy backgrounds.
New CSS variables added to every theme block:
- --surface: dropdowns, popups, toast, approval card
- --topbar-bg: topbar background
- --main-bg: main chat area background
- --input-bg: subtle input/button backgrounds
- --hover-bg: hover state backgrounds
- --focus-ring / --focus-glow: focus border and box-shadow
Light theme now has proper light-colored surfaces, inputs, and hover
states instead of invisible white-on-white.
THEMES.md updated with all new variables documented.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Unsaved-changes guard:
- _closeSettingsPanel() intercepts all three close paths (X button, overlay
click, Escape key) and checks _settingsDirty before closing
- If dirty: shows inline 'Unsaved changes' bar with Save & Close / Discard
- Discard reverts the live theme preview to what it was when panel opened
- _markSettingsDirty() wired to all inputs via addEventListener in loadSettingsPanel()
- saveSettings() now resets dirty flag and hides the bar on successful save
Theme improvements:
- Add 'Slate' theme: warm charcoal (#2b2d30 bg), a softer/lighter dark option
that sits between Dark and the full light themes
- Rework 'Light' theme: replace pure white (#f5f5f7) with warm off-white
(#f0ede8) -- warmer, lower contrast, less harsh on most displays
- Update /theme command to include 'slate' in valid list
- Add test_settings_set_theme_slate() to test_sprint26.py
- routes.py /api/git-info: get_session raises KeyError on miss, does not
return None -- wrap in try/except KeyError to correctly return 404
(PR #82, api/routes.py line 222)
- style.css ctx-bar used undefined --teal CSS variable -- replaced with
--blue which is defined in :root and fits the existing color palette
(PR #83, static/style.css)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Shows a compact bar + label in the composer footer after the first
response, displaying input/output token counts, context window fill
percentage, and estimated cost. Bar turns yellow >50% and red >75%.
Updates on every response completion via the existing usage data from
the done SSE event. Hidden until first response (no usage data yet).
Inspired by PR #75 (@MartinNielsenDev).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the workspace root is a git repo, a badge in the panel header
shows the current branch name, dirty file count, and ahead/behind
status. Updates on every root directory load.
Backend:
- git_info_for_workspace() in api/workspace.py runs lightweight git
commands (rev-parse, status --porcelain, rev-list) with 3s timeout
- New GET /api/git-info endpoint returns branch, dirty count, modified,
untracked, ahead, behind
Frontend:
- _refreshGitBadge() in workspace.js fetches git info on root load
- Git badge element in panel header shows branch + status
- Badge turns gold when workspace has uncommitted changes
Inspired by PR #75 (@MartinNielsenDev).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Date group headers (Pinned, Today, Yesterday, Earlier) are now clickable
to collapse/expand their session lists. Collapsed state persists to
localStorage across page reloads.
- Refactored renderSessionListFromCache to group sessions first, then
render groups with collapsible wrappers
- Extracted _renderOneSession() helper for reuse within group bodies
- Chevron indicator rotates -90deg when collapsed
- Pinned group header keeps its gold color
Inspired by PR #75 (@MartinNielsenDev).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix five stacking/overflow bugs in static/style.css (no JS changes):
1. Profile dropdown overlaps chat messages
.topbar lacked a stacking context -- added position:relative;z-index:10
so the dropdown (z-index:200 child) always paints above .messages (z-index:0)
2. Workspace dropdown clipped by sidebar overflow:hidden
.sidebar overflow:hidden was swallowing the upward-opening ws-dropdown.
Changed to overflow:visible -- scroll is already on .session-list, not .sidebar.
Added position:relative;z-index:10;overflow:visible to .sidebar-bottom.
3. Slash-command dropdown could render behind tool cards
.composer-wrap had position:relative but no z-index.
Added z-index:10 so cmd-dropdown always sits above .messages (z-index:0).
4. Skill picker dropdown clipped inside Settings modal
.settings-panel had overflow-y:auto which clipped the absolute-positioned
skill picker. Changed to overflow:visible + display:flex;flex-direction:column,
moved overflow-y:auto to .settings-body, raised skill-picker-dropdown to z-index:1100.
5. CLI session badge blocks action buttons on hover
Added .session-item.cli-session:hover::after { display:none } so the gold
'cli' label hides on hover, making archive/delete/pin fully reachable.
6. Workspace dropdown name+path crowded on same line
.ws-opt was a plain block with inline spans. Added flex-direction:column;gap:4px
and display:block to each child so name and path stack cleanly on separate lines.
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
1. Image preview onerror fires on clearPreview (#68)
clearPreview() set previewImg.src='' which triggered the stale onerror
handler, showing 'Could not load image' on every refresh/message.
Fix: null out onerror before clearing src.
2. CLI session badge covers delete button (#69)
The ::after 'cli' label occupied the same space as the hover-revealed
.session-actions overlay, making delete unreachable.
Fix: add padding-right to .cli-session, use margin-left:auto to push
badge right, add pointer-events:none so clicks pass through.
3. Tool cards visible through profile dropdown
The .messages container had no stacking context, so tool cards could
render above the profile dropdown (z-index:200).
Fix: add position:relative;z-index:0 to .messages to establish a
stacking context that keeps all children below overlays.
Closes#68, closes#69
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Read CLI sessions from the agent's state.db and surface them in the
WebUI sidebar alongside local sessions, with read-only display and
import-on-click to avoid data duplication.
Key changes:
- get_cli_sessions(): reads sessions list via parameterized SQL,
wrapped in sqlite3 context manager (no connection leaks)
- get_cli_session_messages(): reads messages for a CLI session
via parameterized SQL, also context-managed
- GET /api/sessions: merges WebUI + CLI sessions with dedup
(WebUI takes priority on same session_id)
- GET /api/session: falls back to CLI store if not a WebUI session
- POST /api/session/import_cli: imports a CLI session into the
WebUI store (idempotent, no duplicates on re-import)
- Imported sessions use get_last_workspace() for the workspace field
(not a hardcoded string) and carry the active profile tag
- CSS: .cli-session with ::after 'cli' indicator (no theme changes)
Fixes review feedback:
- SQLite connections use 'with' context managers (no leaks)
- Workspace uses real path via get_last_workspace()
- Profile awareness via api.profiles.get_active_profile_name()
- Parameterized SQL queries throughout (no injection risk)
- Graceful fallback when sqlite3 or state.db is missing
Track A: Token/cost display
- Read agent usage attrs (session_prompt_tokens, session_completion_tokens,
session_estimated_cost_usd) after run_conversation in streaming.py
- Add input_tokens, output_tokens, estimated_cost fields to Session model
- Include usage in done SSE event payload
- Store usage on S.lastUsage in messages.js done handler
- Render usage badge below last assistant message (input/output/cost)
Track B: Subagent delegation cards
- Add subagent_progress to toolIcon map with shuffle emoji
- Special-case subagent_progress in buildToolCard: "Subagent" label,
strip double emoji from preview, add tool-card-subagent CSS class
- Indented border-left styling for subagent cards
- Clean delegate_task display name
Track C: Skill picker in cron create form
- Add skill search input + tag chips to cron create form HTML
- Skill picker JS in panels.js: search/filter, click-to-add tags,
remove tag chips, pre-fetch skill list on form open
- submitCronCreate sends skills array in POST body
- Skill picker dropdown + tag CSS
Track D: Skill linked files viewer
- Add file query param to /api/skills/content endpoint
- Serve linked files from skill directory with path traversal protection
- Ensure linked_files key always present in skill content response
- Render linked files section below SKILL.md content in preview panel
- openSkillFile function for viewing individual linked files
Track E: Bug fixes and code quality
- Expand Session.__init__ and compact() to readable multi-line format
- Remove inline import json as _j2 inside loop in streaming.py
- Fix tool_calls: capture args from assistant messages, skip unresolved names
- Store args snapshot in persisted tool_calls for reload display
6 new tests. Total: 421 (409 passing).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. _profile_default_workspace() now checks terminal.cwd
Profile config.yaml files don't have a 'workspace' or 'default_workspace' key
— they store the working directory as terminal.cwd (the hermes-agent CLI
setting). Added it as the third fallback after 'workspace' and
'default_workspace', so switching to camanji correctly resolves
~/Camanji, webui resolves ~/webui-mvp, etc.
2. Workspace dropdown opens upward (bottom: calc(100% + 4px))
The dropdown is now anchored at the bottom of the sidebar. Opening it
downward (top: 100%) caused it to clip off screen. Flipped to open upward
with an upward shadow so it expands into the session list area instead.
Tests: 426 passed, 0 failed.
Two changes:
1. Workspace updates correctly on profile switch
switchToProfile() now applies data.default_workspace from the switch
response to the current session via /api/session/update, updates
S.session.workspace in-memory, and stores S._profileDefaultWorkspace
so the next new session also inherits the profile's workspace.
newSession() in sessions.js picks up S._profileDefaultWorkspace when
creating a new session after a profile switch.
2. Workspace chip removed from topbar
The workspace was shown in two places: the topbar chip (wsChip) AND
the sidebar bottom display (sidebarWsDisplay with name + full path).
The topbar chip was redundant, cluttered the topbar, and pushed other
chips (profile, model, clear, settings) off screen.
Removed wsChip from the topbar entirely. The sidebar display is now
the sole workspace UI, consistent and unambiguous.
Moved wsDropdown to live inside the sidebar position:relative wrapper
so it opens downward from sidebarWsDisplay. Updated the click-outside
listener to close on clicks outside sidebarWsDisplay/wsDropdown.
Removed stale wsChip update code from syncTopbar() in ui.js.
Tests: 426 passed, 0 failed.
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.
Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.
Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BUG-1 (critical): CSS cascade — .sidebar{position:relative} and
.rightpanel{position:relative} at line 528/530 appeared after the
@media(max-width:640px) block and silently overrode the position:fixed
overlay behavior needed for the mobile slide-in. Wrapped both in
@media(min-width:641px) so they only apply on desktop.
BUG-2 (medium): mobileSwitchPanel() in boot.js always reopened the
sidebar overlay after closing it, with a stale comment saying 'close
after a moment' but no actual auto-close. For the 'chat' panel, the
content lives in the main area — reopening the sidebar obstructs it.
Fixed: only open sidebar for non-chat panels; chat tap closes sidebar.
BUG-3 (medium): Dockerfile was missing 'pip install -r requirements.txt'.
pyyaml (required by api/config.py) is not in the python:3.12-slim base
image — the container would fail at startup with ImportError.
SEC-2 (medium): No .dockerignore — COPY . /app included .git/, tests/,
and .env* in every image. Added .dockerignore excluding these.
NIT-3: docker-compose.yml used ${HERMES_HOME:-~/.hermes} but Docker
Compose does not shell-expand ~ in default values. Changed to
${HERMES_HOME:-${HOME}/.hermes}.
Tests: 415 passed, 0 failed (same as pre-fix).
Mobile responsive (Issue #21):
- Hamburger sidebar: slide-in overlay on mobile (<640px) with backdrop.
Tap hamburger in topbar to open, tap outside to close. Full session
list, project chips, all panel content accessible.
- Bottom navigation bar: 5-tab fixed bar (Chat, Tasks, Skills, Memory,
Spaces) replaces sidebar nav tabs on mobile. iOS-style layout.
Tapping a tab opens the sidebar overlay with that panel active.
- Right panel slide-over: Files button in topbar chips opens workspace
panel as a slide-over from the right on mobile/tablet.
- Touch targets: all interactive elements get min 44x44px touch areas.
Session items, approval buttons, composer buttons all sized for fingers.
- Composer positioned above bottom nav bar with proper spacing.
- Sidebar nav tabs and bottom section hidden on mobile (replaced by
bottom nav + topbar chips).
- Clicking a session auto-closes the sidebar overlay.
- Desktop layout completely unchanged — all mobile elements are
display:none by default, only shown inside @media(max-width:640px).
Docker (Issue #7):
- Dockerfile: python:3.12-slim, HERMES_WEBUI_HOST=0.0.0.0, port 8787.
- docker-compose.yml: named volume for state persistence, optional
~/.hermes mount for agent features, password env var documented.
- README: Docker quick start section with compose and manual commands.
Tests: 392 passed, 23 pre-existing failures, 0 regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- 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).
- index.html: add #btnMic (hidden by default, shown if browser supports
SpeechRecognition) and #micStatus listening indicator inside .composer-box
- boot.js: IIFE-scoped mic handler wired to Web Speech API
* recognition.continuous=false (auto-stops after ~2s silence)
* recognition.interimResults=true (live transcript preview in textarea)
* Toggles .recording class + shows #micStatus while active
* Handles 'not-allowed', 'no-speech', 'network' errors via showToast()
* btnSend.onclick stops active recognition before sending
* Entire feature disabled/hidden gracefully when API unavailable
- style.css: .mic-btn, .mic-btn.recording (red pulse animation),
.mic-status, .mic-dot, @keyframes mic-pulse
- tests/test_sprint20.py: 46 tests covering HTML structure, CSS rules,
JS logic, error handling, and regression checks (376 total, all pass)
No API keys, no external libraries, no server changes. Browser-only.
Works in Chrome, Edge, Safari (partial). Firefox unsupported (hides button).
- File preview auto-close: clearPreview() extracted as named function
and called from loadDir(). Navigating directories (breadcrumbs, up
button, folder clicks) now automatically closes the right panel
file preview instead of leaving stale content visible.
- Thinking/reasoning display: assistant messages with structured content
arrays containing type=thinking or type=reasoning blocks now render
as collapsible gold-themed cards above the response text. Collapsed
by default, click header to expand. Works with Claude extended thinking
and o3 reasoning tokens when preserved in the message array.
- Workspace tree view (Issue #22): directories expand/collapse in-place
with toggle arrows. Single-click toggles, double-click navigates
(breadcrumb view). Subdirectory contents fetched lazily and cached.
Indentation shows nesting depth. Empty directories show "(empty)".
S._expandedDirs tracks open state, S._dirCache caches fetched entries.
Tests: 295 passed, 23 pre-existing failures, 0 regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory
Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally
Track C: Send key setting (closes#26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json
5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
renderMd() now correctly renders safe inline HTML tags that AI models
emit in their responses:
Pre-pass (ui.js):
Converts <strong>, <b>, <em>, <i>, <code>, <br> to their markdown
equivalents (**text**, *text*, `text`, newline) before the pipeline
runs. Code blocks and backtick spans are stashed first so their content
is never modified.
inlineMd() helper (ui.js):
New helper for processing inline formatting inside list items,
blockquotes, and headings. Previously these used esc() directly, which
escaped <strong>/<code> tags that had already been converted from HTML
by the pre-pass — causing them to appear as literal <strong> text
instead of rendering as bold. inlineMd() applies bold/italic/code
processing and then escapes only unknown tags.
Safety net (ui.js):
After the full pipeline, any HTML tags NOT emitted by our own renderer
(i.e. <img>, <script>, <iframe>, <svg>, <object>, etc.) are escaped
via esc(). The SAFE_TAGS allowlist covers every tag the pipeline itself
produces. XSS is fully blocked.
Active session gold style (sessions.js, style.css):
Active session item now uses gold/amber (#e8a030) instead of blue,
matching the logo gradient color for better visual hierarchy.
Project color border-left is skipped when the session is active
(gold always wins). Session items get border-radius: 0 8px 8px 0
to complement the left border indicator.
Tests (tests/test_sprint16.py — 74 tests):
- Static analysis: pre-pass, SAFE_TAGS, SAFE_INLINE, inlineMd present
- Behavioural: all safe tags render in paragraphs, list items (ul+ol),
blockquotes, headings (h1/h2/h3)
- Exact screenshot regression: the 4-item list with <strong> labels
and <code> values that was showing as literal text
- XSS: 7 attack vectors blocked (<img>, <script>, <iframe>, <svg>,
<object>, XSS inside bold, XSS nested inside <strong>)
- Edge cases: code block protection, double-escaping guards, br tag,
mixed markdown+HTML, inlineMd called in list/blockquote handlers
Tests: 312 passed, 0 failed.
- Action buttons overlay: wrap pin/move/archive/dup/trash in a
.session-actions container with position:absolute. Titles now use
full available width. Actions appear on hover with gradient fade
from the right edge. Overlay auto-hides during inline rename.
- SVG line icons: replace all emoji HTML entities with monochrome
SVGs that inherit currentColor. Consistent across all platforms.
- Pin indicator: small gold star rendered inline only when pinned.
Unpinned sessions get full title width (zero space reservation).
- Project border: sessions assigned to a project show a colored
left border matching the project color, replacing the old
always-visible blue folder button.
Fixes both BUGS.md items (title truncation + sticky folder button).
Tests: 214 passed, 23 pre-existing failures, 0 regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Five fixes to the Sprint 15 Move to Project picker:
1. CRITICAL: Picker was invisible (overflow:hidden clipping)
Appended to document.body + positioned with fixed/getBoundingClientRect
instead of inside .session-item (overflow:hidden). Flips above button
when near bottom of viewport.
2. CRITICAL: Picker stretched full screen width
position:fixed removed the containing block width constraint. Added
max-width:220px; width:max-content to .project-picker.
3. UX: No way to create a project from the picker
Added '+ New project': creates project and moves session in one click.
4. UX: Feature was undiscoverable
Folder button shows persistently (blue, 60% opacity) when session
has a project.
5. Minor: Event listener leak
removeEventListener was missing from picker item onclick handlers.
Tests: 237 passed (7 pre-existing failures from unrelated logger bug).
- Picker dropdown: append to document.body with fixed positioning instead
of inside the session-item (which has overflow:hidden). Flips above
when near bottom of viewport.
- Add "+ New project" item at bottom of picker so users can create a
project and assign in one flow.
- Folder button stays visible (blue, 60% opacity) when session belongs
to a project, instead of only appearing on hover.
- Clean up document click listener in all picker item onclick handlers
to prevent stale listener accumulation.
Tests: 214 passed, 23 pre-existing failures, 0 regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Session projects: named groups for organizing sessions. Project filter
bar with chips between search and session list. Create/rename/delete
projects, assign sessions via folder icon dropdown. Stored in
projects.json, project_id on Session model. 5 new API endpoints.
Code block copy button: every code block gets a Copy button in the
language header (or top-right for plain blocks). Clipboard API with
"Copied!" feedback.
Tool card expand/collapse: messages with 2+ tool cards get an
"Expand all / Collapse all" toggle above the card group.
13 new tests (237 total), all passing. No regressions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolved path was not checked against the static/ directory, allowing
GET /static/../../../../etc/passwd to serve arbitrary files.
Fix: resolve the path and call relative_to(static_root) before serving.
Returns 404 for any path that escapes the static/ directory.
fix(css): add !important to three dead mobile overrides in @media(640px)
Three @media(max-width:640px) rules added by the mobile responsive PR
were silently overridden by later bare rules in the same stylesheet:
.composer-wrap padding (overridden by line 347)
.suggestion-grid max-width (overridden by line 364)
.tool-card margin-left (overridden by line 460)
Fix: add !important to these three declarations so the mobile overrides
actually fire on narrow screens.
Tests: 224 passed, 0 failed.
- Use 100dvh with 100vh fallback to fix composer being cut off on
mobile browsers where the address bar affects viewport height
- Add comprehensive @media(max-width:640px) rules: topbar wrapping,
compact messages, full-width msg-body, smaller chips and buttons,
responsive composer, approval cards, tool cards, settings modal
- Use font-size:16px on textarea to prevent iOS/Android auto-zoom
on input focus (browsers zoom when font-size < 16px)
- Add .topbar-left class on title wrapper for responsive stacking