* 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>
fix: inject full workspace path into agent context for uploaded files (#997)
Uploaded files (drag-and-drop or paperclip) were saved correctly to the workspace
but the agent message only contained the bare filename — `photo.jpg` instead of the
full path. The agent couldn't call `read_file` or `vision_analyze` without a full path.
`uploadPendingFiles()` now returns `{name, path}` objects from `/api/upload`
(`data.path` was always returned, just never threaded through). The agent message
gets the full absolute path; all display surfaces (badges, session history, INFLIGHT
state, POST body) continue showing only the bare filename.
Three fixes absorbed during review:
- Second `saveInflightState()` call was passing raw `{name,path}` objects instead
of the `uploadedNames` string array (INFLIGHT localStorage corruption on page reload)
- `attachLiveStream()` was being called with the raw object array; changed to pass
`uploadedNames` so the `done` handler receives strings, not objects
- `attachLiveStream` `done` handler referenced `uploadedNames` which is out of scope
there (ReferenceError on every upload success); fixed to use the `uploaded` param
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Closes#996
When a session finishes streaming while the user has switched to a different
session, setBusy(false) was draining S.session.session_id (the currently
*viewed* session) instead of the session that actually finished. Queued
follow-up messages were silently dropped.
Root cause: setBusy() has no context about which session triggered it.
The activeSid closure variable inside attachLiveStream() knew the right
session but was not propagated.
Fix: add _queueDrainSid module global (null by default). Stream done and
error handlers set it to activeSid immediately before calling setBusy(false).
setBusy(false) reads and clears _queueDrainSid, falling back to S.session if
it is unset (the common case where the user hasn't switched away).
Handlers patched: done event, start-call error handler, stream_end/stream_stop
reconnection fallback, and max-retry error exit.
Co-authored with Claude Sonnet 4.6 / Anthropic.
saveInflightState() is called from syncInflightAssistantMessage() on every
token. It does localStorage.getItem + JSON.parse + mutate + JSON.stringify +
localStorage.setItem on the full inflight state map. For a 5000-token response
with a 10KB messages array this produces ~36MB of JSON churn per second.
This O(response_length) work per token is the primary source of GC pressure
that causes the renderer to crash (Chrome error codes 4/5). The 13.6-second
RunTask we observed in perf traces is a direct consequence: accumulated rAF
callbacks execute all at once after each multi-second GC pause.
Fix: add _throttledPersist() which writes at most once every 2 seconds during
token streaming. State transitions that matter for crash recovery (tool events,
done, start) still call persistInflightState() directly, so at most 2s of
in-flight progress is lost if the tab crashes mid-stream.
The _persistTimer is cleared on 'done' so the final state is always flushed.
Co-authored with Claude Sonnet 4.6 / Anthropic.
_scheduleRender() now uses setTimeout(→rAF) when within the 66ms throttle
window, meaning _pendingRafHandle can hold a setTimeout ID (not a rAF ID).
All 4 cleanup sites only called cancelAnimationFrame(), which is a no-op for
timeout handles, leaving stale callbacks that could fire after stream end.
Fix: call both clearTimeout() and cancelAnimationFrame() at each site.
(clearTimeout is a no-op when called with a rAF handle, and vice versa.)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
_scheduleRender() uses requestAnimationFrame to update the live assistant
message during streaming. rAF fires at up to 60fps, but each DOM update
takes 50-150ms on sessions with long histories — far exceeding the 16ms
rAF budget.
During GC pauses (which can run for hundreds of milliseconds), rAF
callbacks accumulate. When the GC yields, the browser executes all
queued callbacks sequentially in a single RunTask. A Chrome performance
trace shows a 13.6-second RunTask containing 1,240 accumulated render
callbacks — which causes the renderer to crash (Chrome error codes 4/5,
ERR_EMPTY_RESPONSE / ERR_CONNECTION_RESET).
Fix: track the last render timestamp and delay scheduling the next rAF
until at least 66ms (15fps) have elapsed since the previous render.
If within the 66ms window, use setTimeout to defer the rAF rather than
skipping it — this batches token updates without dropping any content.
The 66ms interval is conservative enough to prevent runaway accumulation
while fast enough that streaming text still feels immediate. The _renderPending
flag continues to prevent double-scheduling within each interval.
Co-authored with Claude Sonnet 4.6 / Anthropic.
Detect prefix desync between current display text and already-streamed text, then rebuild the streaming-markdown parser from full content to avoid character loss during live rendering. Add regression assertions for the new desync guard.
Made-with: Cursor
Co-authored-by: bsgdigital <bsg@bsgdigital.com>
Handle DeepSeek DSML variants including truncated and spaced tag forms, and sanitize thinking-card text so leaked XML fragments never render. Add regression tests for DSML edge cases and thinking-card sanitization.
Made-with: Cursor
Co-authored-by: bsgdigital <bsg@bsgdigital.com>
The /btw command was completely non-functional because attachBtwStream()
connected to /api/stream which doesn't exist — the server SSE handler
lives at /api/chat/stream. This caused an immediate 404 on every /btw
request.
Closes#945
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.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>
streaming-markdown@0.2.15 preserves arbitrary URL schemes in href/src.
Verified with a Node + jsdom harness:
IN : [click](javascript:alert(1))
OUT: <p><a href="javascript:alert(1">click</a>)</p> ← XSS vector
Confirmed unsafe for: javascript:, vbscript:, data:text/html, file://.
The library uses only safe DOM primitives (createElement/appendChild/
createTextNode — no innerHTML/eval), so <script> tags are escaped as
text, but URL-scheme filtering is absent. The existing renderMd() path
implicitly filtered to http(s) via its regex, so this is a regression
the moment streaming markdown is enabled.
Attack path: agent echoes prompt-injection content containing a
markdown link with javascript: href → smd renders it live → user clicks
during the streaming window → JS executes in webui origin → session
cookie, API calls, etc.
Fix: walk the live DOM after each parser_write (and again after
parser_end) and remove href/src attributes whose scheme isn't on the
safe allowlist (http, https, mailto, tel, and relative/anchor paths).
Blocked anchors keep their text content but lose href; blocked images
lose src and get data-blocked-scheme="1" for debugging.
Harness confirms all 10 tested cases behave correctly — javascript:,
vbscript:, data:text/html, file:// all stripped; https://, /path,
#anchor, mailto:, tel: all preserved.
Added 5 regression tests in TestSmdUrlSchemeSanitization that lock:
- the sanitize helper exists
- the allowlist regex permits https? and forbids javascript/vbscript/data:
- _smdWrite invokes sanitize after parser_write
- _smdEndParser invokes sanitize after parser_end
- the sanitizer covers both <a href> and <img src>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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)
fix: correct message ordering after task cancellation — v0.50.163 (#883)
Fixes the message-ordering glitch from #882: clicking Cancel while the
agent is responding could cause a subsequent response to render above
the "*Task cancelled.*" marker.
Root cause: the cancel handler pushed the marker only to local S.messages
without persisting to the server. When the done event fired shortly after
and replaced S.messages from server state, the marker disappeared from
client state while the next response anchored to the server-authoritative
position.
Fix has three parts:
- Server (cancel_stream): append *Task cancelled.* to session.messages
with _error:True + timestamp, then save. _error ensures
_sanitize_messages_for_api() strips it from conversation_history on
the next agent turn, so the LLM never sees it as a prior assistant
turn. Precedent: same flag used for the apperror marker at line 1343.
- Client (SSE cancel handler): fetch /api/session instead of pushing
locally (same pattern as the done handler). Falls back to local push
if the fetch fails.
- Tests: fix test window width for cancel handler (1200→dynamic); add
two regression tests pinning _error flag and _sanitize invariant.
1941 tests passing.
Co-authored-by: piliang <piliang1@jd.com>
* fix(ui): echo slash command input as user message in chat (#840)
Slash commands like /skills, /help, /status previously showed only the
assistant response with no user message above it — the conversation
appeared to start from nowhere.
Fix: executeCommand() now returns {noEcho:bool} instead of true/false
(returns null when no command matched). send() in messages.js pushes a
user message bubble before returning when noEcho is false.
Commands with noEcho:true are action-only and don't get echoed:
/clear, /new, /stop, /retry, /undo, /voice, /model, /workspace,
/theme, /usage, /reasoning.
Commands without noEcho (get echoed):
/help, /skills, /status, /title, /compress, /compact, /personality.
16 new tests in test_issue840_slash_echo.py.
* fix(ui): push user message BEFORE running slash handler (ordering bug)
The PR as originally written pushed the user message AFTER the slash
command handler ran. That works correctly for async handlers (the
assistant response lands later, after the user push) but breaks for
sync handlers like cmdHelp which push their assistant response
synchronously:
S.messages = [assistant response, user "/help"] ← reverse order
The chat would render the help content ABOVE the user's own "/help"
input — not what the issue asked for.
Fix: look up the command inline, push the user message first (for
echo-worthy commands), then run the handler. If the handler opts out
(returns false — e.g. /reasoning <level>), pop the user message back
off so the normal send path can add it cleanly when forwarding to the
agent.
Renamed the flow so it's clear we're not calling executeCommand twice
(my first attempt did that by accident). executeCommand() stays as a
public API returning null or {noEcho:bool} — just isn't the only path
send() uses now.
Added 2 regression tests:
- test_send_pushes_user_message_before_running_handler: asserts
the user push appears before the handler invocation in source order.
- test_send_rolls_back_user_push_on_handler_optout: asserts the
S.messages.pop() for the opt-out case.
Also tightened the existing `test_send_checks_noecho_flag` and
`test_send_pushes_user_message_for_echo_commands` tests to look at
the new `_cmd.noEcho` pattern inline (vs the original
`cmdResult.noEcho`). Removed `test_send_uses_null_check_not_truthy`
(obsoleted — the control flow no longer stores the executeCommand
return in a variable).
Full suite: 1767 passed, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ui): compress/compact noEcho + title/personality confirmation messages
Applied Opus mentor review fixes:
- compress and compact: add noEcho:true (S.messages reset internally causes
user bubble to flicker/disappear without noEcho)
- /title <name>: push assistant confirmation message after rename succeeds
- /personality <name>: push assistant confirmation message after set succeeds
- 4 new regression tests covering the above invariants
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#631. Closes#804.
Bug A (thinking card below answer / double render / stuck cursor): trailing rAF after 'done'
inserted a duplicate live-turn wrapper into already-settled DOM. Fixed via _streamFinalized flag
+ cancelAnimationFrame in all terminal handlers (done/apperror/cancel/_handleStreamError) +
_scheduleRender guard. All three reported symptoms were the same root cause.
Bug B (accumulator reset): original fix reset assistantText/reasoningText inside _wireSSE on reconnect.
Reverted — server uses one-shot queue.Queue(), no replay on reconnect, reset would wipe valid
pre-drop content causing data loss. Bug A fix alone resolves all symptoms.
#804 (blank page workspace): syncWorkspaceDisplays uses S._profileDefaultWorkspace as fallback;
workspace chip enabled when hasWorkspace (not hasSession); promptNewFile/promptNewFolder/
switchToWorkspace/promptWorkspacePath auto-create session on blank page; boot.js hydrates
_profileDefaultWorkspace from /api/settings before any session exists.
Opus max-effort review + Nathan independent review + full browser QA. 1765/1765 tests.
Closes#461
Adds full /reasoning CLI parity to the WebUI slash command system:
- /reasoning show|on → window._showThinking = true; writes display.show_reasoning to config.yaml (same key as CLI); mirrors to settings.json for boot.js
- /reasoning hide|off → same in reverse; re-renders immediately
- /reasoning none|minimal|low|medium|high|xhigh → POST /api/reasoning → writes agent.reasoning_effort to config.yaml; takes effect next turn (matching CLI semantics)
- /reasoning (no args) → GET /api/reasoning → live status toast from config.yaml
- Autocomplete shows all 8 options: show|hide|none|minimal|low|medium|high|xhigh
- Profile-isolated: _get_config_path() is thread-local so per-profile settings never bleed across
- Boot hydration: window._showThinking initialised from settings.json show_thinking on page load
- Inspect.signature guard in streaming.py so older hermes-agent builds don't TypeError
28 new tests, 1708/1708 total passing. Full browser QA on port 8789 with isolated state. CLI/config.yaml sync verified with hermes_constants.parse_reasoning_effort().
- quota_exhausted error type: distinguishes credit exhaustion from rate limits
- Streaming errors persisted to session file so they survive page reload
- _error flag excludes persisted errors from subsequent LLM API calls
- stream_end and title SSE events use original session_id (not s.session_id which rotates during context compaction)
Fixes#739, #652, #653
## Summary
Rebased-on-behalf of @likawa3b (originally PR #748 — stale base).
Sessions can outlive provider changes. When an old session still points to a model from a previous provider (e.g. `gemini-3.1-pro-preview` after switching the agent to OpenAI Codex), starting a chat hits the wrong backend and fails silently.
This PR adds a lightweight normalization pass:
- `_normalize_provider_id()` maps common prefixes to canonical provider IDs
- `_resolve_compatible_session_model()` checks the session model's provider against `active_provider` and returns the default model if they differ
- `_normalize_session_model_in_place()` is called at GET `/api/session` — corrects and persists stale models once
- Chat start also normalizes via `_resolve_compatible_session_model()` and returns `effective_model` in the response
- `messages.js` applies `effective_model` back to the UI/localStorage/dropdown if set
Closes#748
## Tests
1498 passed (2 pre-existing ordering failures unrelated to this PR; 5 new tests added in `test_provider_mismatch.py`).
**Original author:** @likawa3b
Strips <function_calls> XML from assistant messages before rendering, adds workspace file panel empty-state messages, and changes notification description from 'tab' to 'app'. 16 new tests. Fixes#702, #703, #704.
Fixes <|turn|>thinking delimiter (was wrong as <|turn>thinking) in api/streaming.py, static/messages.js, and static/ui.js. Adds 13 regression tests. Independent review by @nesquena.
Squash-merges feature from PR #588 by @vcavichini. Dynamic <base href> injection + api() helper slash-stripping enables deploying hermes-webui behind a reverse proxy at any subpath without configuration. Also fixes pre-existing bug: api/upload was using location.origin instead of location.href (closes#596). Co-authored-by: vcavichini <vcavichini@users.noreply.github.com>
BUG-1 (CRITICAL): messages.js line 522 — mismatched quote in
setComposerStatus('Reconnecting…') caused JS syntax error on the
reconnect path.
BUG-2 (HIGH): messages.js line 491 — broken template literal
'\\n\\n*{d.hint}*' restored to '\n\n*${d.hint}*'. Error hint
text was non-functional (missing $ prefix and escaped newlines).
BUG-3 (HIGH): messages.js — showApprovalCard(pending, pendingCount),
_approvalCurrentId, and approval_id in respondApproval() were removed,
regressing the simultaneous approval queue fix from PR #546. Restored
all three, including the '1 of N pending' counter and poll passthrough.
BUG-4 (LOW): api/streaming.py — MiniMax thinking delimiter regex
missing closing pipe: <|channel> -> <|channel|> in both
_strip_thinking_markup() and _looks_invalid_generated_title().
ALSO: test_issue487b.py docstring changed to raw string to fix
DeprecationWarning for invalid escape sequence '\s'.
Changes _pending from a single overwriting dict value to a list,
so parallel tool calls each get their own approval slot.
api/routes.py:
- Wraps submit_pending() to append to a list and assign a stable
approval_id (uuid4) to each entry.
- _handle_approval_pending() returns the first queued entry plus
pending_count so the UI can show '1 of N'.
- _handle_approval_respond() pops by approval_id (falls back to
oldest entry for backward-compat with old clients).
- Backward-compat: legacy single-dict values in _pending are
handled without crashing.
static/messages.js:
- respondApproval() sends approval_id in the POST body.
- showApprovalCard() accepts pendingCount, shows '1 of N pending'
counter when multiple approvals are queued.
- _approvalCurrentId tracks the approval_id of the displayed card.
- Poll loop passes pending_count to showApprovalCard.
static/index.html:
- Adds approvalCounter element for the '1 of N' display.
tests/test_approval_queue.py:
- 14 tests: static-analysis checks (Python + JS + HTML),
functional tests that inject two simultaneous approvals and
verify both are surfaced and independently resolvable.
* fix: persist durable inflight reload snapshots
* fix: remove duplicate loadInflightState stub, update CHANGELOG test count
The stub added in the previous review branch is superseded by the author's
real localStorage-backed implementation in the cherry-picked commit 36051c0.
Remove the duplicate. Update CHANGELOG to 961 tests and document the durable
inflight state feature.
---------
Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* fix: preserve live session output across chat switches
(cherry picked from commit 401e3b643d25e8dad8c06883b478b3c3073f07a5)
* fix: preserve todo state after session reload
(cherry picked from commit 7ee093ba19978af23b79148df2f2347e2f1e5bde)
* fix: preserve live assistant anchor across rerenders
* fix: stream live reasoning and tool progress
* fix: recover inflight session state after reload
* fix: add loadInflightState stub + CHANGELOG v0.50.21
- static/ui.js: add loadInflightState() function (currently returns null —
the typeof guard in sessions.js means reload recovery works via the
else-path attachLiveStream call; this stub satisfies the guard cleanly
and documents the extension point for future localStorage-backed state)
- CHANGELOG.md: v0.50.21 entry; 960 tests (up from 949)
---------
Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* fix: silent errors, stale models, live model fetching (#373, #374, #375)
- api/streaming.py: detect empty agent response (_assistant_added check),
emit apperror(type='no_response' or 'auth_mismatch') instead of silent done
- api/streaming.py: add _token_sent flag so guard works for streaming agents
- static/messages.js: done handler belt-and-suspenders guard for zero replies
- static/messages.js: apperror handler labels 'no_response' type distinctly
- api/config.py: remove gpt-4o and o3 from _FALLBACK_MODELS and
_PROVIDER_MODELS['openai'] (superseded by gpt-5.4-mini and o4-mini)
- api/routes.py: new /api/models/live?provider= endpoint, fetches /v1/models
from provider API with B310 scheme check + SSRF guard
- static/ui.js: _fetchLiveModels() background fetch after static list loads,
appends new models to dropdown, caches per session, skips unsupported providers
Other:
- tests/test_issues_373_374_375.py: 25 new structural tests
- tests/test_regressions.py: extend done-handler window 1500->2500 chars
- CHANGELOG.md: v0.50.19 entry; 947 tests (up from 922)
* fix: SSRF hostname bypass + auth detection operator precedence
1. routes.py: SSRF guard used substring matching (any(k in hostname))
which allows bypass via hostnames like evil-ollama.attacker.com.
Changed to exact hostname matching against a fixed set of known
local hostnames (localhost, 127.0.0.1, 0.0.0.0, ::1).
2. streaming.py: _is_auth detection had a Python operator precedence
bug on the ternary expression. The line:
'AuthenticationError' in type(...).__name__ if _last_err else False
parsed as the ternary absorbing the rest of the or-chain when
_last_err was falsy. Fixed to: (_last_err and 'AuthenticationError' in ...)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: fix v0.50.20 CHANGELOG version number and test count (949 tests)
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove ^ anchor from think/Gemma regexes in ui.js; trimStart() before startsWith checks in messages.js streaming path. Fixes MiniMax M2.7 and any model emitting leading newlines before <think>. 10 new tests, 768 total.
* 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>
* fix: warn on provider/model mismatch, surface auth errors (#266)
Fixes#266 — WebUI silently ignores provider/model selection mismatch.
The problem: selecting an OpenRouter (or Anthropic/OpenAI) model while
Hermes is configured for a different provider (e.g. local Ollama) sends
the request to the wrong endpoint, which returns a 401 Unauthorized error
with no UI indication of why.
Three-layer fix:
1. api/streaming.py — detect 401/auth errors explicitly
Added is_auth_error detection covering '401', 'AuthenticationError',
'authentication', 'unauthorized', 'invalid api key', and the specific
Ollama error string 'no cookie auth credentials'. Auth errors emit
apperror with type='auth_mismatch' and a hint pointing to 'hermes model'.
2. static/ui.js — expose active_provider and warn on selection
- populateModelDropdown() stores data.active_provider from /api/models
as window._activeProvider (the field was already in the response but
the frontend never used it)
- New _checkProviderMismatch(modelId) helper: compares the selected
model's slash-prefix (e.g. 'openai/' from 'openai/gpt-4o') against
the active provider. Skips the check for 'openrouter' and 'custom'
to avoid false positives on configs that legitimately route any model.
3. static/boot.js — warn on model dropdown change
modelSelect.onchange calls _checkProviderMismatch() and shows a toast
when the selected model looks incompatible with the configured provider.
4. static/messages.js — distinct UI label for auth errors
apperror handler now distinguishes type='auth_mismatch' and shows
'Provider mismatch' as the error label instead of 'Error'.
5. static/i18n.js — provider_mismatch_warning and provider_mismatch_label
keys added to all 5 locales (en, es, de, zh-Hans, zh-Hant).
Tests: 21 new tests in tests/test_provider_mismatch.py covering all
five change areas. 679/679 total pass (658 baseline + 21 new).
* fix: t() call args spread + use i18n label for auth mismatch
1. ui.js: _checkProviderMismatch passed [modelId, ap] as a single
array arg to t(). Since t(key, ...args) spreads, the function
received the array as m and undefined as p. Fixed to pass as
separate args: t('provider_mismatch_warning', modelId, ap).
2. messages.js: 'Provider mismatch' label was hardcoded instead of
using t('provider_mismatch_label'). Now uses the i18n key with
fallback for when t() isn't available.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: decode HTML entities before markdown processing + zh/zh-Hant translations (#239)
Adds decode() helper in renderMd() to fix double-escaping of HTML entities
from LLM output (e.g. <code> becoming &lt;code&gt; instead
of rendering). XSS-safe: decode runs before esc(), only 5 entity patterns.
Also adds 40+ missing zh (Simplified Chinese) translation keys and a new
zh-Hant (Traditional Chinese) locale with 163 keys.
Fix applied: removed duplicate settings_label_notifications key in both
zh and zh-Hant locales.
Fixes#240
* fix: restore custom model list discovery with config api key (#238)
get_available_models() now reads api_key from config.yaml before env vars:
1. model.api_key
2. providers.<active>.api_key / providers.custom.api_key
3. env var fallbacks (HERMES_API_KEY, OPENAI_API_KEY, etc.)
Also adds OpenAI/Python User-Agent header and a regression test covering
authenticated /v1/models discovery.
Fixes users with LM Studio / Ollama custom endpoints configured in
config.yaml whose model picker silently collapsed to the default model.
* feat: Docker UID/GID matching to avoid root-owned .hermes files (#237)
Adds docker_init.bash with hermeswebuitoo/hermeswebui user pattern so
container files match the host user UID/GID. Prevents .hermes volume
mounts from being owned by root when using a non-root host user.
Configure via WANTED_UID and WANTED_GID env vars (default 1000/1000).
Readme updated with setup instructions.
Fix applied: removed duplicate WANTED_GID=1000 line in docker-compose.yml
that was overriding the ${GID:-1000} variable expansion.
* security: redact credentials from API responses and fix credential file permissions (#243)
Adds response-layer credential redaction to three endpoints:
- GET /api/session — messages[], tool_calls[], and title
- GET /api/session/export — download also redacted
- SSE done event — session payload in stream
- GET /api/memory — MEMORY.md and USER.md content
Adds api/startup.py with fix_credential_permissions() at server startup.
Adds 13 tests in tests/test_security_redaction.py.
Merged with #237 container detection changes in server.py.
* fix: cancel button now interrupts agent and cleans up UI state (#244)
Wires agent.interrupt() into cancel_stream() so the backend actually
stops tool execution when the user clicks Cancel, rather than only
stopping the SSE stream while the agent keeps running.
Changes:
- api/config.py: adds AGENT_INSTANCES dict (stream_id -> AIAgent)
- api/streaming.py: stores agent in AGENT_INSTANCES after creation,
checks CANCEL_FLAGS immediately after store (race condition fix),
calls agent.interrupt() in cancel_stream(), cleans up in finally block
- static/boot.js: removes stale setStatus(cancelling) call
- static/messages.js: setBusy(false)/setStatus('') unconditionally on cancel
Race condition fix: after storing agent in AGENT_INSTANCES, immediately
checks if CANCEL_FLAGS[stream_id] is already set (cancel arrived during
agent init) and interrupts before starting. Check is inside the same
STREAMS_LOCK acquisition, making it atomic.
New test file: tests/test_cancel_interrupt.py with 6 unit tests.
* docs: v0.46.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* fix: approval pending check broken by stale has_pending import (#228)
api/routes.py imported has_pending/pop_pending from tools.approval, but the
agent module renamed has_pending to has_blocking_approval (checks gateway
queue, not _pending dict) and removed pop_pending. The import fell through
to fallback lambdas that always returned False, making GET /api/approval/pending
always return {pending:null} even after a successful inject_test.
Fix: check _pending directly under _lock — same dict submit_pending writes to.
Stale imports removed.
Before: 554 pass, 1 fail | After: 555 pass, 0 fail
* fix: move login JS into external file, remove inline handlers (#226)
Login page used inline onsubmit/onkeydown handlers and an inline <script>
block — all blocked by strict script-src CSP, causing silent login failure.
Fix: extract doLogin() and Enter key listener into static/login.js (served
from /static/, already a public path). Form uses id='login-form' and
data-* attributes for i18n strings instead of injected JS literals.
Also guards res.json() parse with try/catch so non-JSON error bodies
(e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'.
Fixes#222.
* fix: improve update error messages when pull fails (#227)
_apply_update_inner() ran git pull --ff-only and returned only raw stderr
on failure, making all failure modes indistinguishable.
Fix: explicit git fetch before pull; if fetch fails, returns human-readable
network error. Diverged history and missing upstream tracking branch each
get distinct messages with exact recovery commands. Generic fallback
truncates to 300 chars and shows sentinel when git produces no output.
Also adds tests/test_update_checker.py with 13 tests covering all 4 new
diagnostic code paths (0 tests existed before).
Fixes#223.
* fix: stabilize 30s terminal approval prompt visibility (#225)
Adds minimum 30-second visibility guard for the approval card using
_approvalVisibleSince, _approvalHideTimer, and a signature fingerprint
to deduplicate repeated poll ticks.
Fix: respondApproval() and all stream-end paths (done/cancel/apperror/
error/start-error) now call hideApprovalCard(true) so the card hides
immediately when the user responds or the session ends. The 30s guard
only applies to mid-session poll ticks where the approval is still live
but briefly absent.
Adds 11 structural tests covering the new timer variables, force
parameter, force-on-respond, force-on-stream-end, and poll-loop
no-force behavior.
* feat: replace emoji icons with self-hosted Lucide SVG icons (#221)
Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled
in static/icons.js (no CDN dependency). Adds li(name) function returning
inline SVG geometry from a hardcoded whitelist — unknown keys return '' so
dynamic server-supplied names never inject arbitrary SVG.
Changes:
- static/icons.js: new file with 21 icon paths + li() renderer
- static/index.html: all nav/action buttons now use li() icons
- static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons
- static/messages.js: cancelStream button uses SVG square stop icon
- .gitignore: adds node_modules/ entry
Verified: all 35 onclick= functions exist in JS, all 21 li() calls
reference defined icons, applyBotName() selectors intact, version
label present, no removed IDs referenced by JS.
* docs: v0.44.0 release notes, bump version, update test counts
---------
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
* 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>
Agent review findings:
- _soundEnabled/_notificationsEnabled not updated in the password-save
early-return branch of saveSettings() — fixed
- AudioContext never closed after oscillator finishes — added osc.onended
callback to ctx.close() preventing resource accumulation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>