Codex SHIP-WITH-FIXES: non-done terminal paths (apperror/cancel/stream-error
finalize) didn't clear _STREAM_WAS_HIDDEN, so a stale wasHidden=true could be
inherited by a later same-sid reconnect -> spurious notification. Make each
entry stream-owned ({streamId, wasHidden}) so a stale entry from a prior stream
is never trusted (reconnect keeps state only when streamId matches; done reads
via the owned entry), and add _clearStreamHidden(activeSid, streamId) on the
apperror/cancel/finalize-fallback terminal paths (belt-and-suspenders).
A background tab's SSE is throttled by Chromium, so the stream 'done' event
arrives late (after the user returns, document.hidden=false) and the
response-complete notification was suppressed by the live visibility check.
Track per-stream whether the tab was hidden at ANY point during the stream
(_STREAM_WAS_HIDDEN, set at attach, OR'd by one idempotent visibilitychange
listener, read+cleared at done) and pass forceHidden accordingly.
sendBrowserNotification bypasses ONLY the live visibility gate on forceHidden,
still honoring the user's notifications-enabled setting — so a watched stream
stays silent (matches Slack/Discord/Gmail/Claude) but a backgrounded one
notifies. Correct fix for the #4416 root cause; supersedes #4421's always-fire
approach (which would also fire when the tab was never hidden).
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Gate-found (Opus SHIP-WITH-FIXES; Codex SAFE — split resolved by reading the
contract: no dropped-turn, but a real transient start-race flicker). #4424
removed the _sendInProgress guards entirely; that re-opened the #2689 start-race
SYMPTOM for the one session mid-send (a /api/sessions poll landing during the
/api/chat/start round-trip would blank the just-sent bubble + queue a spurious
force-reload). Re-add the guard SCOPED to sid===_sendInProgressSid only, in both
_reconcileActiveSessionIdleStateFromList and _purgeStaleInflightEntries — a
long-hung session has _sendInProgress===false so it still gets unstuck (#4354),
only the in-flight start window is protected. Also fixes Opus's test-masking
finding: test_inflight_send_start_race now strips JS comments before asserting +
requires a real , so a comment can't green-light the #2689 guard, and
adds coverage for the reconcile path.
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
The pre-session toolset comment pushed the #2518 provenance comment past the
test_issue2518 4000-char window assertion. Trim the comment to one line and
widen the test window to 5000 (the fallback block it actually guards is well
within range; newSession() has legitimately grown).
Gate-found: staged _pendingSessionToolsets could leak into a later New Chat
started from an existing session (newSession forwarded it whenever it was an
array; loadSession never cleared it). Gate the forward on !S.session and clear
the staged value when a real session loads. +2 regression tests.
Co-authored-by: claw-io <claw-io@users.noreply.github.com>
cmdModel() had the only two root-absolute fetch('/api/...') calls left in the
frontend. Under a reverse proxy that mounts the app at a subpath (e.g.
jupyter-server-proxy at /proxy/<port>/), a root-absolute path escapes the mount
and 404s, so /model can't load the catalog or update the session. Resolve both
against document.baseURI (falling back to location.href) like every other fetch
in the frontend already does.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add readyState !== CLOSED (2) guards to all EventSource.close() calls across
messages.js, sessions.js, panels.js, and terminal.js. Chromium reports
net::ERR_ABORTED when close() is called on an already-closed EventSource,
polluting the console during normal lifecycle transitions (session switch,
panel close, stream teardown).
Key hotspots:
- closeLiveStream() double-close via onerror → _closeSource chain
- _closeSessionEventsSSE, _gatewaySSE.onerror missing try/catch
- _kanbanStopPolling, terminal close handlers
20 close() calls guarded across 4 files, no structural changes to stream
lifecycle.
Pastes over 4000 chars or 100 lines attach as a timestamped pasted-text-*.md in
the upload tray instead of flooding the composer. Image-paste path unchanged; an
oversize paste that exceeds MAX_UPLOAD_BYTES falls through to a normal inline
paste so nothing is lost.
Co-authored-by: santastabber <204424800+santastabber@users.noreply.github.com>
The outline FAB was position:fixed at z-index 9999 and could stack on top of the
scroll-to-bottom button. Anchor it inside the messages shell (position:absolute,
z-index 12) so the two controls don't overlap.
Co-authored-by: Mohamed Habib <Habib1001-m@users.noreply.github.com>
- Hamburger button: 32x32 -> 44x44 tap target (iOS HIG minimum), SVG icon
20 -> 24px. flex-shrink:0 preserved; spacer keeps the title centered.
- Edge-swipe-to-open-sidebar: edge zone 28 -> 80px, trigger 72 -> 64px, max
vertical 48 -> 56px; removes the _isPwaStandalone() gate in
_onPwaSidebarSwipeStart so the gesture works in a normal mobile browser, not
only the installed PWA. Still _isDesktopWidth()-blocked, touch/pen-only,
interactive-target-excluded, and requires horizontal intent (dx>=64 &&
dx>|dy|*1.5), so scroll/tap won't trigger it.
Known tradeoff (Nathan-approved ship-as-is): in mobile BROWSER the 80px left
edge zone overlaps iOS Safari's back-swipe; degrades gracefully (PWA, the
primary mobile target, has no back gesture).
Verified live at 390x844: hamburger renders 44x44 + tap opens sidebar; a
left-edge swipe opens the sidebar in a non-PWA context. 66 existing mobile/
swipe/titlebar tests pass.
Co-authored-by: kaishi00 <kaishi00@users.noreply.github.com>
Paused/disabled cron jobs are partitioned out of the active list into a
collapsible 'Paused (N)' <details> section so they don't drown the active
jobs. _cronList stays the single source of truth — only the render is split,
so openCronDetail / _cronNewJobIds / detail-refresh are untouched. Reuses the
already-translated cron_status_paused label (zero new i18n keys). Collapsed
state persisted in localStorage.
Maintainer polish on absorb: wrapped both localStorage read/write in try/catch
(repo convention — a storage throw in private mode must not dump the cron
sidebar into the error banner) + added the CHANGELOG entry.
Co-authored-by: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com>
Two coherent fixes for the #4346 transcript-virtualization scroll-jump
cluster, shipped together (contributor-approved) because both touch
static/ui.js + the same virtualization test file and interact (the
cache-reset path uses the per-role default from #4367). Both only run when
the default-OFF experimental "Virtualize long transcripts" setting is on.
#4367 — per-role virtual scroll height estimates:
Replaces the flat 140px unmeasured-row estimate with per-role fallbacks
(user 120 / assistant 160 / tool_call 400 / default 140) via
_messageVirtualDefaultHeightForRole + _messageVirtualRoleForEntry, so
tool-call rows (which measure 500-2000px) aren't under-counted before
measurement. Additive + backward-compat (opts.roleForIdx optional → flat
default when absent). The two sibling offset calculators route through the
per-role defaults too.
#4368 — anchor-based scroll compensation during measurement:
Adds _compensateScrollForMeasurementDelta (capture anchor + scrollTop →
render → re-pin scrollTop by the anchor row's measured delta; null/missing/
<2px guards; programmatic-scroll flag managed) wrapping
renderMessages({preserveScroll:true}) in _scheduleMessageVirtualizedRender,
plus a 150ms scroll-active guard deferring measurement refresh while the user
scrolls, and session-switch cleanup of the three scroll-settle globals.
Maintainer note: combining the two required the test harness for
test_clear_height_cache_resets_scroll_settle_globals (#4368) to also eval
_messageVirtualDefaultHeightForRole + define MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS
(#4367), since the combined _clearMessageVirtualHeightCache resets the estimate
via the per-role helper. Both PRs' test sets retained.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Adds WebUI controls to manage the messaging gateway lifecycle without a
terminal. _handle_gateway_lifecycle routes start/stop/restart to the
existing platform-aware `hermes gateway {action}` CLI via a list-argv
subprocess (no shell), scoped to the active profile, with bounded
timeout/error handling and a status-card refresh. Auth + CSRF gated;
profile name charset-validated; stdout/stderr never leaked to the client.
Maintainer hardening (warm-up SHOULD-FIX): added _GATEWAY_ACTION_LOCK, a
server-side single-flight guard. The client disables its button in-flight,
but a scripted authed client could otherwise fire overlapping
start/stop/restart calls and spawn concurrent gateway subprocesses. A
non-blocking acquire now returns 409 on contention (mirrors the self-update
_apply_lock pattern), released in finally. Added a regression test
(toothless-checked: without the lock the 2nd action spawns a subprocess +
returns 200).
Cross-platform: delegates to the mature hermes_cli gateway subcommand
(systemctl/launchctl), pathlib paths, PYTHONUTF8=1 + BROWSER=echo in env.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
The inline PDF preview rendered only page 1, so multi-page documents
looked clipped. loadPdfInline now renders every page (capped at MAX_PAGES=20
for memory) sequentially into a scrollable .pdf-preview-body, shows the page
count in the header, and appends a "Showing the first N of M pages" notice
(pdf_truncated, 13 locales) when the PDF exceeds the cap.
Robustness: renderPage(i) attaches each canvas only AFTER its render
promise resolves, and a per-page failure is caught and skipped
(`.then(()=>renderPage(i+1)).catch(()=>renderPage(i+1))`) so one corrupt
page can't halt the preview or surface an unhandled rejection. XSS-safe
(esc(fname), t() with numeric args).
Co-authored-by: Gouranga satapathy <29521030+gourangasatapathyvit@users.noreply.github.com>
"Reply with selection" no longer appends the selected text straight into
the composer. It now adds a named chip ("Context N") to a row above the
message box (composerSelectionChips). Chips can be renamed (double-click,
esc()-escaped) or removed (x); multiple stack. On send(),
_flushSelectionBlocksToComposer() materializes pending blocks into the
composer as **Name:** + markdown-quoted text, then the normal send path
runs unchanged on composer.value.
Regression-safety:
- _flushSelectionBlocksToComposer early-returns when no blocks are pending,
so send() is byte-identical for anyone who never uses the feature.
- loadSession() AND newSession() both clear pending blocks before replacing
S.session, so chips never leak across a sidebar switch OR a New Chat
(the New Chat clear + its regression test were added per Codex review).
- chip name/text rendered via esc(); XSS-safe.
Verified live (headless CDP drive): add/render/remove/rename, flush
produces correct markdown, empty-flush no-op, session-switch clears,
injected <img onerror=...> chip-name escaped (not executed).
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Surfaces the Hermes agent's LLM Wiki as a read-only knowledge-base
observability section in Insights (status counts + browsable read-only
pages). Unconfigured shows a clear "Unavailable" empty state.
Strictly read-only, auth-gated. The wiki file-serving path is hardened
against the full traversal/symlink/TOCTOU class (Codex + Opus review, each
vector regression-tested + toothless-checked):
- _llm_wiki_page_files resolves the wiki root once as the trust base;
skips a section whose resolved path escapes the root; requires each *.md
target's RESOLVED path under both real root and real section; rejects
dot-prefixed segments on lexical AND resolved-relative paths (closes
symlink-to-hidden/out-of-tree + symlinked-section escapes). Browse + read
share this allowlist.
- /api/wiki/page rejects abs paths + a real '..' SEGMENT (legit v1..v2.md
still opens); requires the resolved target in the allowlist; reads the
resolved path with O_NOFOLLOW; and fstat-verifies the open fd's
(st_dev, st_ino) against the identity captured at allowlist time — closing
final-component AND parent-directory symlink-swap TOCTOU races. Any
mismatch/vanish -> clean 404, never a 500.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Docker images and pip installs have no .git directory, so _check_repo
returned None and the frontend interpreted both update-target parts being
falsy as "Up to date" — a stale Docker image would misleadingly look
current. _check_repo now returns a minimal sentinel dict ({name, behind:
None, no_git: True}) instead of None so the Settings panel can distinguish
"can't check" from "up to date", and _formatUpdateTargetStatus /
checkUpdatesNow surface a localized "Can't check for updates" state
(settings_update_no_git, added across all 13 locale blocks). In mixed
deployments where one component has a git checkout and the other does not,
the valid git-side update info is still shown. The sentinel's behind: None
flows safely through the existing notification/summary builders, which all
gate on int(info.get('behind') or 0) > 0.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
When the model dropdown rebuilds (panel switch, provider refresh, or a
live-model fetch that times out and serves a fallback catalog), a
cross-provider selection could vanish from the refreshed catalog and the
browser would snap the <select> back to its first <option> (the default
model). _reconcileModelDropdownSelection now routes each selection branch
through an _applyOrEnsure helper that delegates to
_ensureModelOptionInDropdown, re-injecting the chosen model as a custom
option when it is missing from the rebuilt catalog instead of returning
null. Branch order and per-branch model/provider arguments are unchanged.
Maintainer note: simplified the contributor's helper to delegate straight
to _ensureModelOptionInDropdown (which already applies-first-then-injects),
removing a redundant double scan; re-pointed the
test_populate_model_dropdown_reconciles_selection_after_rebuild string
assertions to the new form (the behavioral node-driver tests for boot-default
precedence and in-page selection preservation are unchanged and still pass).
Co-authored-by: Ed <edou@edoudoux.com>