routes.py appends a 'live anchor shell' row whenever a stream has events
but no visible rows projected yet:
row_id: lifecycle:<stream>:running
role: lifecycle
status: running
text: Working
source_event_type: runtime_journal_snapshot
_sourceEventTypeForSnapshotAnchorRow skips the source_event_type branch
for exactly that value, falls through to the role check, and the bare
phase==='running' arm then classified the shell as 'compressing'. Every
session that hit the path painted a 'Compressing context' divider while
nothing was compressing - including brand-new sessions on their first
message.
It also persists. The classification is written back into the session's
anchor_activity_scenes as source_event_type: 'compressing', which the
source branch above then returns directly on the next load, so the
phantom divider survives a reload. Measured on this host: 46 such rows
across 24 sessions, every one carrying the text 'Working' and not one a
real compression notice.
Require an explicit cue instead - phase==='compressing' or one of the
compression text markers already listed below it. The function's own
comment asked for this ('do not default every lifecycle row to a running
compress divider'); the bare running arm did the opposite.
tests/test_compression_phantom_barrier.py: 8 passed.
Review follow-ups for #7082:
- Maintainer should-fix: the no-cursor rebuild branch of
_refreshTransparentFadeProseRow cleared the body and re-wrapped every
word as .is-new, so the whole visible row dipped to opacity 0 and
faded back (~620ms), and wholesale node replacement invited the
one-time scroll-anchor bounce (#6257). Now the rendered text is
snapshotted before the clear and the messages.js
_streamFadeMuteRenderedPrefix idiom is re-applied after the rebuild,
so only genuinely-new tail words animate. The helper is exported on
window (__streamFadeMuteRenderedPrefix) the same way
__anchorProseIncrementalNode is.
- Greptile P1: the same branch passed raw source text to
_appendTransparentFadeText, flattening live markdown rows to literal
syntax (links/emphasis/headings lost until settlement). The rebuild
now adopts a deep clone of the candidate's parsed .msg-body (the
incremental streaming-markdown output) when one exists, so the parsed
DOM survives; the plain-text rebuild remains only as fallback for
candidates without a parsed body. The resume cursor stays in source
space when rendered text diverges from source (markdown), and shrinks
to the rendered prefix for plain prose so the parser's held-back
character is re-appended on the next delta instead of dropped.
Regression tests: rebuild must not re-animate the already-rendered
prefix (tail-only .is-new), and rebuild must keep parsed <a>/<strong>
DOM with no literal markdown in the visible text. The two existing
tests of this PR are unchanged and still pass.
Replace .trim() with replace(/^\s+/, '') in _stripXmlToolCalls and
_stripXmlToolCallsDisplay to preserve trailing whitespace/characters
that appear before <function_calls> blocks in the streaming text.
Fixes: streaming text losing the last character before a tool call card.
The WebUI clarify bridge resolved the prompt timeout from a local, divergent
copy (legacy clarify.timeout, 120s fallback) and treated 0 as invalid, so
agent.clarify_timeout: 0 (unlimited) could not be honored and there was no
way to make a clarification card wait for the user.
Resolve the timeout with the agent core's canonical resolve_clarify_timeout()
(clarify.timeout -> agent.clarify_timeout -> 3600, <= 0 = unlimited) against
the session's own profile config (the streaming worker thread does not inherit
the request's per-profile context), wait with a null deadline when unlimited,
and stop the frontend rendering an expired countdown for non-positive timeouts.
- api/streaming.py: per-session profile config resolution, canonical resolver
with a compatibility fallback, and an extracted _await_clarify_response()
helper that polls in 1s slices and honours cancellation.
- api/clarify.py: preserve timeout_seconds <= 0 and emit no expires_at.
- static/messages.js: _clarifyExpiryMs() returns 0 for non-positive timeouts
so the card stays up without a countdown.
- tests/test_sprint42.py: resolution matrix, per-session profile divergence,
unlimited resolve-later / cancel-exit, and no-expiry payload.
Closes#7159
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix(ui): prevent OOM on tab focus for long sessions
Coordinate visibility-recovery and session-updated refresh paths so they
never start concurrent full-transcript loads, window the O(N) pre-render
scans to the virtual render window, and bound the render-window expansion.
Closes#6999
* fix(ui): full turn context + collision-free cache hash (#7006 review rework)
Addresses the maintainer's CHANGES_REQUESTED on PR #7006:
- Turn-context maps (_assistantTurnFinalVisibleContentMap /
_assistantTurnVisibleContentMap) receive the FULL visWithIdx again.
The virtualized render window (head+tail around a gap) cut through
assistant runs and merged distinct turns across an omitted user
boundary, which could duplicate the final answer in Worklog/Thinking
or feed later-turn prose into the reasoning echo-strip. Turn context
is input, not output: it must always be complete.
- _addBoundedHash now streams the FULL string form through the FNV-1a
loop (no length+head+tail clip, no slice copies), and object fields
(attachments, tc.args, compression-anchor keys) are walked key-by-key
so no integral JSON.stringify() is allocated. Same-length middle-only
edits of tool args, attachment metadata, tool snippets, or
compression-anchor keys no longer collide in _sessionHtmlCache.
Regression tests:
- tests/test_issue6999_turn_context_virtual_gap.py: multi-assistant turn
with a virtualized gap proves full-context echo-strip suppresses the
exact final-answer echo, and documents the gapped head+tail failure.
- tests/test_issue2613_render_cache_signature.py: Node harness executing
the real _messageRenderCacheSignature() proves same-length middle-only
mutations change the signature for every structured field while the
old clip scheme collides on the same data.
Closes#6999
* fix(ui): coalesce session-updated under refresh guard; insertion-order cache hash
Re-gate round 2 for #6999:
- session-updated frames arriving while the external-refresh guard is held
are latched per SID (max announced count) and drained by the refresh
owner's finally with ONE guarded follow-up, instead of being dropped
(production does not guarantee a second event).
- _hashObjectInto walks keys in insertion order (matching Object.entries
render projection) with an explicit per-key index discriminator, so
opposite-insertion-order argument objects no longer share a cache
signature; recursion depth is now threaded (children increment, not
restart).
* test(jump-scroll): stub _loadingSessionId + _drainSessionUpdatedPendingCount in extraction harness
#7006 adds a reference to the pre-existing module var _loadingSessionId and a new
_drainSessionUpdatedPendingCount() helper inside refreshActiveSessionIfExternallyUpdated.
The jump-to-answer scroll-settle tests extract that function into a node harness, which
didn't declare them -> ReferenceError (19 parametrized failures). Add the missing stubs
to the harness preambles (no-op drain, since these tests exercise scroll ownership, not
the coalesce path -- real drain behavior is covered by test_issue6999_session_updated_coalesce.py).
Test-harness-only; production static/ untouched.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Keep reconnect confirmations transient so they stop consuming mobile composer space, while a single owned timer prevents stale notices from clearing newer status text.
Assisted-by: Hermes Agent:gpt-5.6-sol
Assisted-by: Codex:gpt-5.6-luna
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
When the user switches from a streaming session (A) to another session (B),
A's streaming text could render in B's window because:
1. The token handler guards against rendering when the session is not active,
but the rAF/setTimeout window between _scheduleRender() and _doRender()
can outlive a session switch.
2. _doRender() writes to assistantBody without checking whether the stream's
session is still the active frontend pane.
Fix: add _isActiveSession() guards to:
- _scheduleRender() — drop scheduled renders when the session changed
- _doRender() — bail before any DOM write if the session switched
- _flushPendingSegmentRender() — defense-in-depth for any future callers
These guard at the same level the token/interim_assistant event handlers
already do, closing the rAF gap that could leak streamed text into a
different session's view.
Co-authored-by: webtecnica <contato@webtecnica.com.br>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix(stream): preserve fade nodes while prose is live
* fix(stream): preserve transparent fade nodes like assistant prose
The transparent-activity prose path (transparent_stream mode) kept the
pre-PR fade cleanup: _bindTransparentFadeCleanup() in static/ui.js still
replaced every animated stream-fade-word span with a fresh text node on
animationend, invalidating the native scroll anchor while the row is
still growing.
Mirror the messages.js fix so both live prose paths share one
node-preserving cleanup: remove only the transient is-new class and the
--stream-fade-ms inline property, keeping span identity until final
settlement rebuilds the persisted DOM.
Extend tests/test_smooth_text_fade.py with a sibling regression that
extracts the production _bindTransparentFadeCleanup() from ui.js and
fires animationend, asserting no replacement, is-new removal,
stream-fade-word retention, and timing-override cleanup (plus
source-string guards against replaceWith in the cleanup block). The new
test is red-before-green against the previous ui.js implementation.
* docs(changelog): stamp #6257 (stream fade scroll-bounce fix)
---------
Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Ships PR #6410 with a maintainer UX fix folded in (authorship preserved).
Adds a 'Refine' action to the message text-selection toolbar: select text in a
chat message -> Refine seeds it into the composer as an editable quoted draft
(marker-free, one-way, existing draft + attachments preserved, 44x44 mobile
icon buttons, all 15 locales). ChatGPT/Notion-style quote-to-edit flow.
Maintainer fix (Fable UX gate): French instruction string now uses the
space-before-colon convention (Instruction d'affinage :).
Gate: Codex SAFE TO SHIP (marker-free, no XSS, one-way Range, Reply/drafts
preserved), Fable SHIP-WITH-UX-FIXES (fix applied), tests 19/19, ESLint clean.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
* fix(stream): stop fade-text effect from re-fading whole message on tool-call rewinds
When the fade-text effect is enabled, Hermes long tasks visibly blink the
entire message on every tool call. Root cause: tool-call XML
(<function_calls>) appears inline in the assistant text stream, and the
unclosed-tag stripping in _stripXmlToolCalls temporarily rewinds
displayText to a strict prefix. That rewind zeroed the fade playout
(_streamFadeNextText) and tripped _smdWrite's self-heal, which cleared
assistantBody.innerHTML and re-created every word as a fresh is-new
span — replaying the fade on all visible text at once.
Fix: on a detected rewind, shrink the playout cursor to the common
prefix instead of resetting to zero, and mute the fade renderer for
that common prefix during any rebuild (both the main smd path and the
anchor prose incremental node) so only the post-rewind tail animates.
Adds regression tests: playout rewind keeps common prefix visible,
rebuild mutes the common prefix, and _smdWrite's self-heal sets the
silent-prefix window.
* fix(#6783): compute rewind mute prefix in rendered-text space
Review feedback (maintainer round on 56c9307f): the silent-prefix budget
was computed in SOURCE-text space (charCodeAt over markdown source), but
the fade add_text hook only receives RENDERED prose. Markdown delimiters,
link destinations and MEDIA token bytes never reach add_text, so the
source-space budget over-muted the first genuinely new word after a
rewind (e.g. '**alpha** beta' -> '**alpha** gamma' muted gamma).
Fix: snapshot the OLD rendered text before the rebuild (assistantBody
textContent / anchor .msg-body textContent), then after parser_write
compare it with the NEW rendered text and strip is-new only from spans
inside the rendered common prefix (_streamFadeMuteRenderedPrefix).
- Rendered-space boundary, scoped to the owning node (no stream-global
counter that can survive a rebuild without a fade consumer)
- No state left behind on reduced-motion / safe-renderer / empty-text /
parser-finalization paths: _rewindPrevRendered is a local variable
consumed right after the rebuild
- Tests: plain-word mute, markdown/MEDIA byte regression (the blocker),
per-root scoping (main vs anchor), updated _smdWrite self-heal test
Gate: node -c OK; test_smooth_text_fade + test_smd_media_in_stream +
test_svg_audio_video_rendering + test_insights -> 83 passed, 18 subtests.
* fix(#6783): drop f-prefix from _fade_fake_node JS block (F541)
CI lint failure on a6d37b22: tests/test_smooth_text_fade.py:723 F541
f-string without any placeholders. The f-prefix was only used to escape
JS braces ({{ }}) — a plain string with literal braces is equivalent
and passes the ruff E9+F+B gate.
---------
Co-authored-by: silent-reader-cn <silent-reader-cn@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
* fix: clear stale compression barrier on turn-done when compressed SSE is lost
When a turn completes (done SSE event) but the "compressed" SSE event was
lost or delayed, the compression UI state remained in phase="running".
The done handler unconditionally rebound the stale state sessionId to
the continuation session, surfacing a phantom "Compressing context"
barrier on a session that never actually compressed.
Fix: when the done event session_id differs from the active session AND
the compression UI is still in "running" phase, clear it via
clearCompressionUi() instead of rebinding. Non-running states (e.g.
"done") are still rebound as before.
Added regression tests verifying:
- stale running state is cleared (not rebound) on session change
- non-running states are still rebound
- the old unconditional rebind is gone
* fix: also clear phantom compression on same-session done (A->A case)
Reviewer feedback on PR #6572: the previous fix only cleared stale running
compression when the session ID changed (A->B). But the backend also supports
compression without session rotation (A->A), where the compressed SSE being
lost leaves the same phantom barrier.
Fix: in the done handler, clear any owned automatic compression state that is
still in "running" phase, regardless of whether the session ID changed. The
condition is now phase==='running' alone, not session_id change AND running.
Added behavioral state-transition tests for all four cases:
- running A->B (rotation, compressed lost): cleared
- running A->A (no rotation, compressed lost): cleared
- non-running A->B (rotation, compressed seen): rebound
- non-running A->A (no rotation, compressed seen): rebound
* test: real SSE regression for compression phantom barrier
Rewrite test_compression_phantom_barrier.py to use real browser + SSE
instead of handwritten Python simulation. The new tests:
- Boot real WebUI server with isolated state
- Use deterministic mock gateway (Runs API mode)
- Verify done handler clears stale running compression state
- Cover both A→B (rotation) and A→A (no rotation) cases
Key insight: /v1/capabilities must return run_approval_response=true
to enable the Runs API path in gateway_chat.py.
Fixes reviewer feedback: simulation never called attachLiveStream,
clearCompressionUi, or registered done listeners.
* test: replace Playwright test with hermetic source-grep suite for compression phantom barrier
The previous Playwright-based test failed CI in no-egress sandboxes due to
external resource errors (net::ERR_INTERNET_DISCONNECTED) and was structurally
unsound: the mock window.S shadowed the lexical S, so the compressing handler
short-circuited before the barrier could be established, producing a false-
positive assertion.
New suite uses static source analysis (same convention as test_issue840,
test_issue3040, test_bg_task_complete_focus_drop):
- No browser, no network, no playwright dependency
- Proves the done handler clears phase:'running' without gating on session ID
change (A->A case coverage)
- Asserts gating conditions: automatic && owner && done session exists
- Negative controls: non-owner / manual compression are ignored
- Source-level regression proof: reverting the production hunk breaks tests
13 tests. All pass in <3s on bare Python.
* test: exercise compression cleanup through real SSE handlers
Replace source-grep assertions with a hermetic Playwright harness that loads the shipping classic scripts and dispatches MockEventSource events through attachLiveStream. Cover A-to-A/A-to-B cleanup, non-running rebind, ownership controls, canonical cleanup side effects, and a production-branch mutation proof.
---------
Co-authored-by: happy5318 <happy5318@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena+hermes@gmail.com>
Codex re-gate found the single-row resize fast path used parseFloat on
getComputedStyle(el).minHeight, which reads a bogus pixel value for a
non-px unit (parseFloat('50%')===50) and would wrongly enable the fast
path, leaving an oversized composer stuck tall. Parse with a strict
/^<number>px$/ test so %, auto, empty, and calc() fail closed to the
full resize. Adds red-before-green regression coverage across units.
Co-authored-by: sheldon-im <sheldon-im@users.noreply.github.com>
Fable UX polish pass on the gateway approval counter:
- Move the crude inline style (font-size:0.75em;opacity:0.6;margin-top:4px) into
a proper .approval-counter rule: 11px, color:var(--muted) (skin-safe vs opacity),
margin:0 0 12px (fixes the 0px gap that left it flush against the buttons),
tabular-nums for stable digits.
- i18n the '1 of N pending' string (was the only hardcoded-English string in an
otherwise fully-localized approval card): new approval_pending_count key in en
+ 14 locales, wired via t() with an English fallback.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Anchor-owned process-prose rows kept the newest character as streaming-markdown
lookahead until the turn settled, so Compact Worklog prose immediately before a
tool card appeared one character short mid-stream. Finalize the cached parser
once when the row becomes 'completed' (running the same media/link/table cleanup
as the main body parser), guarded by a 'finalized' flag + self-heal on edit.
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
Issue #6385: when a streaming turn settles, the two-render sequence
(keep-open expanded worklog → collapsed worklog) could displace the
reader's viewport because the second render captured its scroll snapshot
from the intermediate expanded state, not from the original live DOM.
Root cause
----------
The STREAM_DONE handler in messages.js:
1. Arms keep-settled-worklog-open token → renderMessages({preserveScroll:true})
→ worklog rendered EXPANDED (height-stable swap preventing shrink jump)
2. Disarms token → _renderMessagesWithScrollSnapshot()
→ This function called _captureMessageScrollSnapshot() which captured
the scroll anchor from the expanded-worklog DOM (step 1 output),
then called renderMessages with the worklog COLLAPSED (keep-open gone),
then tried to restore from the expanded-state snapshot.
The snapshot's semantic anchor (row key, session idx, top offset) was
captured from a DOM where the worklog was expanded. After the collapse
render the worklog is no longer at that position — anchor keys don't
match, the semantic restore fails, and the viewport jumps to a
unrelated scrollTop.
Fix
---
- Capture the scroll snapshot from the LIVE DOM (before any settlement
renders) and pass it as to the second render.
- Modify _renderMessagesWithScrollSnapshot() to accept a pre-captured
snapshot via options._prescrollSnapshot, using it instead of capturing
a fresh one from the current DOM state.
This ensures the collapse render anchors to the content the reader was
actually viewing, not to a stale intermediate expanded-worklog position.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
* fix: preserve live anchor run identity
* Validate envelope run ids before snapshot cursor use
* docs(changelog): stamp #6201 stable Anchor run identity
---------
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Render data:image URIs as inline images (raster + base64 SVG) instead of raw base64 text, route file:// images through the media pipeline, with a strict allowlist + 2MB cap and img-only data: sanitizer. Thanks @ai-ag2026.
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
* fix(transparent-stream): remove dead rowIndex param from pushRow (#6189 follow-up)
In #6189 / #6217 the final-segment eligibility was migrated from an
index comparison (rowIndex > lastNonTerminalWorkRowIndex) to a WeakSet
lookup (finalSegmentLiveProseRows.has(row)). The rowIndex parameter on
pushRow became dead code — it's declared but never read, and the
call-site still passes idx from forEach. Remove the unused parameter
and simplify the call-site.
This addresses the greptile review feedback on the original PR.
Closes#6189
* CHANGELOG: dead rowIndex param removal (#6258)
---------
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix: intercept /sessions and /resume slash commands in WebUI (#6224)
Add a native-intercept branch in the block of
send() alongside the /pet special-case. When the user types /sessions
or /resume, expand the sidebar and refresh the session list instead of
sending the raw slash text to the agent.
Root cause: the agent command registry exposes sessions/resume as
non-CLI-only commands, so the autocomplete popup shows them, but the
WebUI send-time dispatch had no branch to catch them, causing the
literal text to be sent as a prompt.
* fix(commands): use mobile-aware session-browser opener for /sessions /resume (gate follow-up)
The intercept called expandSidebar() directly, which is a no-op on phone-width
layouts, so /sessions and /resume silently did nothing on mobile (composer
cleared, nothing shown). Use the mobile-aware _openProfileSwitchSessionBrowser()
first, falling back to expandSidebar(). Reproduced + specified by the pre-release
Codex gate.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
* CHANGELOG: intercept /sessions /resume slash commands (#6245)
---------
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>