A settled turn that recorded no live duration fell back to
(Date.now()/1000) - session.pending_started_at. But pending_started_at is the
start of an IN-FLIGHT turn, so for a settled turn it is either stale (a session
that sat idle) or about a different pending turn — rendering a bogus
'Processed 15h 32m' on fresh conversations (#4930). Gate the fallback on a turn
actually being in flight (active_stream_id || pending_user_message); otherwise
return undefined (no duration shown) rather than a fabricated one. The recorded
live duration (_turnDuration / base.turn_duration) still takes priority, so this
only removes bogus durations, never a correct one.
Codex gate caught that the enrich only restored when the settled snippet was
fully EMPTY — but the backend persists a 4000-char capped preview
(_TOOL_RESULT_SNIPPET_MAX), so long terminal/tool output settles to a truncated
4000-char prefix, not empty (the actual #4622 symptom). Now also restore when
the settled snippet is a bounded preview of the live one (live longer + settled
is a prefix + settled length >= the 4000 cap), without clobbering a genuinely
short non-preview value. Adds capped-preview restore + short-prefix-not-clobbered
tests (proven non-vacuous).
Opus + Codex both SAFE TO SHIP. Opus flagged two cosmetic redundancies (not
blockers): the snippet guard checked payload.result/payload.output which
_anchorSceneToolRowFromCall never sets, and the tool.result/tool.output writes
are never read by the consumer _anchorSceneToolCallFromRow (it reads
tool.snippet||payload.snippet||payload.result||payload.output). Removed both —
tool.snippet/payload.snippet is what actually restores the visible body.
Behavior-identical; 18 settled/reconnect tests still green.
In the Transparent Stream / Worklog view, terminal tool cards lost their stdout
and patch/edit cards lost their diff AFTER a turn settled (live cards were fine).
Root cause (proven via a node harness driving the real settled chain
_anchorSceneRowsByMessageIndex -> _anchorSceneToolRowFromCall ->
_anchorSceneToolCallFromRow): the settled rebuild builds each tool row from the
persisted messages[].tool_calls (state.db/sidecar), which can carry only a short
preview — or, on a cold/paginated load, nothing — for the result body. The full
output lives on the live S.toolCalls entry at settle time, but the dedup loop in
_anchorSceneRowsByMessageIndex SKIPPED the matching live entry (continue) instead
of restoring the missing body onto the surviving settled row. So buildToolCard
got tc.snippet='' -> empty output region, and the patch/edit diff (detected from
the snippet) never rendered. This also blanks the transparent 'Output' tab and
the 'Full' (detailed-input) tab.
Fix: new _enrichSettledToolRowBodyFromLive() restores snippet/result/output,
command, and args onto a settled row from the matched live call, ONLY when the
settled value is empty (never clobbers a genuinely persisted body). Wired into
the dedup loop: on a tid match that would have been skipped, enrich the existing
pooled row instead of dropping the live data.
The prior #4625 guard only exercised the LIVE buildToolCard path, which is why it
stayed green while the settled path regressed. New
tests/test_issue4622_settled_toolcard_body.py drives the full SETTLED chain and
asserts: terminal stdout restored + full output in data-full + transparent Output
tab non-empty; patch diff body restored + renders as a diff + Full-tab args kept;
genuine persisted body not clobbered; args restored when dropped; no-live-match
leaves the row untouched; no duplicate row after enrich. Verified to FAIL on clean
master (assertions) and pass with the fix. Also registered the new helper in the
and now references the new symbol).
Reusable proof harness saved to ~/WebUI/qa/harnesses/.
Closes#4622.
Codex CORE: the dismissed-set was keyed by bare approval_id, but gateway/run
sources preserve externally-supplied IDs without session namespacing — so two
sessions carrying the same approval_id collide and dismissing in session A hides
session B's still-pending approval. Fix: _approvalDismissKey(sid, approvalId)
builds a session-namespaced composite key, threaded through _isApprovalDismissed,
_markApprovalDismissed, _unmarkApprovalDismissed + all 5 call sites (showApprovalCard,
dismissApprovalCard, respondApproval, no-pending poll cleanup). Updated 4 stale
assertions to the namespaced signatures + added a functional node-driver
cross-session-collision regression (proven non-vacuous).
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Codex gate found a SILENT drop: reconnect scene rows are built with orderIndex=0,
so two tools WITHOUT a tool id at the same message index both got row_id
'...:tool:<idx>:0'; _completeSettledAnchorSceneForTurn dedupes by row_id, silently
dropping the second. Now the emit loop rewrites order_index AND regenerates the
index-derived row_id + seq + identity.seq from the final per-bucket position
(tid-based tool row_ids, already unique, are left untouched). Regression test
added + proven non-vacuous.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
A later streaming parse-cache change dropped the #MOBILESCROLL guard from
the live-stream render tick while leaving the helper and CSS in place.
On iOS PWA, Safari can paint a transient scrollTop=0 frame during streamed
DOM rebuilds, forcing the reader to the first/oldest message.
Re-add _fixMobileScrollJank() before streaming DOM work begins and pin it
with a regression test so the third layer of the mobile scroll fix cannot
be silently dropped again.
The parse-result cache keyed only on assistantText, but _parseStreamState() is a
function of BOTH assistantText AND liveReasoningText. A 'reasoning' SSE event (which
mutates liveReasoningText without calling _scheduleRender) landing between a token's
_scheduleRender(parsed) and the rAF firing made _doRender serve a parsed computed from
the older reasoning -> _renderLiveThinking could show stale thinking text. Keying the
cache on liveReasoningText too forces a fresh _parseStreamState when reasoning changed,
and also makes a canceled-then-rescheduled render miss the stale entry. Co-authored-by: wlknight
Introduce _safeSmdRenderer — a renderer wrapper that hooks smd's set_attr
to validate URL schemes inline as each DOM node is created, eliminating
the per-token _sanitizeSmdLinks(assistantBody) full-DOM scan that caused
progressive freezing on answers of 30,000+ chars (5,000+ DOM nodes).
Also add parse-result caching to _scheduleRender to avoid redundant
_extractInlineThinkingFromContent scans between the token event handler
and the rAF render callback.
Changes:
- New _safeSmdRenderer(el): URL scheme safety inline via set_attr hook
(identical algorithm to proven _streamFadeRenderer.set_attr, no animation)
- _smdNewParser: non-fade path uses _safeSmdRenderer instead of bare
default_renderer
- _smdWrite: removed _sanitizeSmdLinks(assistantBody) call — safety now
handled by renderer's set_attr at node-creation time
- _scheduleRender(parsed): accepts optional cached parse result from
token handler to avoid duplicate O(n) text scans
- Token handler: passes _parseStreamState() result to _scheduleRender
_sanitizeSmdLinks is preserved at _smdEndParser (stream-end safety net)
and in fallback paths where smd is not available.
All 166 existing tests pass.
Codex gate found: when window.smd becomes available after a segment already
rendered through the renderMd() fallback, _smdNewParser resets _smdWrittenText
to '' but does NOT clear the element, so the following _smdWrite(displayText)
appends the full accumulated segment on top of the existing fallback render —
duplicating the live assistant text. Clear assistantBody.innerHTML before re-init.
Co-authored-by: akrhin <akrhin@users.noreply.github.com>
The #4720 done-handler comment (9 lines) pushed the tab-anchor localStorage
sync (`hermes-webui-session` setItem + _setActiveSessionUrl) past the 800-char
proximity window that test_session_rotate_url_sync asserts the #3018 session-id
sync lives within — so the PR's `test (3.x, 2)` shards were failing on master's
own regression guard. The full pytest suite caught it; the PR's gate slice did
not run it.
Fix (behavior unchanged — the _oldestIdx reset stays in the same place, before
the re-filter, guarded):
- Trim the inline comment to one line (the full rationale is in the #4720 commit
+ issue), so the completion block stops overflowing the guard window.
- The reset statement legitimately grew the completion block even bare, so widen
test_session_rotate_url_sync's proximity window 800 -> 1000 (still well within
the single done/settled handler; the guard's intent is preserved).
test_session_rotate_url_sync + test_issue4720_* both pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After a turn completed on a long conversation that loaded truncated, the
viewport jumped to the first message. The done SSE handler replaced S.messages
with the full transcript payload and updated _messagesTruncated, but did NOT
reset _oldestIdx the way the canonical full-load paths do (sessions.js
_ensureMessagesLoaded, ui.js loadSession).
The read-position rework (#4613) keys the scroll anchor on an absolute index
(sessionIdx = _oldestIdx + rawIdx; rows tagged data-session-msg-idx). On done
the handler also expands the render window from the tail window (50) to all
messages, shifting every rawIdx. With _oldestIdx left stale, the captured
anchor resolved to an earlier row and the fallback used the raw scrollTop
against the now-taller full transcript, landing near the first message.
Reset _oldestIdx from d.session._messages_offset||0 alongside _messagesTruncated.
The done payload (_session_payload_with_full_messages) embeds the full transcript
and carries no offset, so this resets to 0 and keeps the absolute anchor aligned
with the rebuilt rows. No-op for non-truncated sessions; pinned-at-bottom readers
still follow to bottom (shouldFollowOnDone path unchanged).
Both gates clean (Codex SAFE TO SHIP, Opus ship-it) — verified the done payload
is always a full transcript with no offset, and every other _oldestIdx consumer
becomes more consistent.
When data-anchor-scene-live-owner=1, the #2403 legacy interim_assistant collapse toggle
('Show N earlier updates') is now cleared in renderLiveAnchorActivityScene cleanup AND the
interim_assistant SSE handler skips creating it. Guards both directions; regression test
locks the invariant. messages.js + ui.js. 289 interim/anchor/worklog tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
A failed /steer (or busy-mode steer) no longer silently falls back to interrupt+queue
(which cancelled the active stream). _trySteer now returns delivered:bool, restores the
draft + leaves the stream running on failure; send() only clears staged files when
_steerDelivered===true. Backend hunk comment-only (failure paths already returned
{accepted:False}, never touched CANCEL_FLAGS). i18n value-only on pre-existing keys
(locale-parity preserved, 173 locale tests pass). 48 own tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: dso2ng <dso2ng@users.noreply.github.com>
Two-layer perf fix for the /api/reasoning storm dropping streaming to ~2 tok/s:
- ui.js: syncReasoningChip() serves the cached chip on routine topbar syncs, only
re-fetching when the model/provider key (_lastReasoningFetchKey) changes — restores
pre-a9ce2889 behavior while keeping its model-switch-refresh intent.
- config.py: memoize _load_yaml_config_file keyed on (path, st_mtime_ns, st_size) under
a lock; cache the RAW parsed dict and re-run cheap _expand_env_vars per call (cache stays
correct vs os.environ and read-modify-save callers get a fresh structure; on-disk edits
picked up via mtime/size). Code byte-faithful from PR head f868f10a1ad7. 9 own tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* stage #4380 (santastabber): render selected context as quote cards + CHANGELOG
Evolves the shipped #2543 named-context-blocks: composer chips -> quote cards
(accent rail, editable label w/ rename, remove, clipped excerpt) + thread render
of labeled selections as <figure> cards. Label+quote esc()'d on every path
(adversarial harness 0 survivors). 3 new i18n keys in all 13 locales (parity green).
Code applied from PR head 2eefc11c02a7 (own 3-dot contribution, CHANGELOG re-stamped).
Co-authored-by: santastabber <santastabber@users.noreply.github.com>
* fix#4380 Codex gate finding: selection-only reply must enable the primary Send button
Codex CORE: #4380 moved selected context out of the textarea into _pendingSelections,
but _composerHasContent() only checked textarea+files -> getComposerPrimaryAction()
returned disabled for a selection-only reply (desktop Enter calls send() directly so
it worked, but click/tap/mobile users were blocked). Fix: expose
window._hasPendingSelections() from messages.js, fold it into _composerHasContent(),
and call updateSendBtn() from _renderSelectionChips() so add/remove/clear refreshes
the button. + regression test.
* Release v0.51.562 — Release TU (selected-context quote cards; #4380)
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: santastabber <santastabber@users.noreply.github.com>
Codex gate (SILENT): the scene-builder/hydrator _anchor_scene_row_looks_like_final_answer
(messages.js + routes.py) used a loose '>=80 + prefix' rule while the renderer
_anchorSceneProseMatchesFinalAnswer uses '>=80 + ratio>=0.9'. A short intermediate-prose row that
was a prefix of the final answer could be dropped from the persisted scene by the looser builder,
leaving the stricter renderer no row to show -> silent prose loss. Aligned both filters to the
renderer's rule (drop fewer -> preserve more intermediate prose). Matrix already GREEN; this closes
the edge the fixture didn't exercise.
franksong2702's activity_scene_v1 anchor-scene model: live-stream/settled/refresh/re-entry
show the same ordered turn. Rebased clean (no conflicts) onto current master.
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
- Remove the redundant 4th OR clause from _catalog_has_provider() — the
existing C1–C3 already handle compound provider IDs correctly (C2 IS
a normalized-vs-raw comparison; the 4th clause was a strict subset).
- Add && _activeProvider guard to _isCrossProviderPick in messages.js
to prevent spurious True when the profile has no configured default
provider (reviewer's optional hardening suggestion).
- Add test_catalog_has_provider_compound_ids.py verifying that compound
provider IDs like 'custom:glm-free-relay' are correctly recognized by
the existing C1–C3 clauses.
Two-part fix for the model selector jumping back to the profile default
after the first message when a cross-provider model (e.g. a custom relay
like custom:glm-free-relay) is selected.
Backend (api/routes.py):
- _catalog_has_provider() now matches compound provider IDs (e.g.
custom:zenmux-relay) whose normalized form (custom) is a raw
catalog provider_id. Previously the three existing checks missed
normalized-vs-raw comparison for compound hints, causing the catalog
lookup to fail and the fallback branch to silently revert the model
to the profile default on every non-explicit turn.
Frontend (static/messages.js):
- The explicit_model_pick marker was consumed after the first send(),
so subsequent sends lost the signal and the server repaired the
model back to the profile default (#3737 regression).
- Now, when the session has a non-default model from a different
provider than the profile's active provider, every send() sends
explicit_model_pick=true so the server honors the user's choice
across the entire conversation.
- The pending onchange marker is still consumed after the first send
(unchanged behavior); only the explicit_pick inference is broadened.
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>
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.
"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>
ai... rodboev's #4133, notification-feature ONLY (dropped the unrelated
'invalidate sidebar cache on WAL' commit — that's a separate good fix to split
out). Button now reflects live Notification.permission instead of silent no-op.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>