* 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>
* fix(chat): keep response jump position after session load
Cancel pending load-time bottom settling before an explicit response jump takes scroll ownership. Add a regression test covering the first-click race.
* fix: gate response jump ownership by destination
* fix: keep response jumps programmatic through smooth scroll
Native smooth-scroll frames from response jumps could reach the manual scroll listener and claim reader ownership before the final destination was known.
- own response-jump scrolling with a generation- and session-scoped lifecycle
- reconcile sticky ownership from final 80px tail geometry
- preserve current low-delta wheel takeover semantics when integrating with master
- cover visible assistant, user-row, and virtualized paths through the production scroll listener
* fix(scroll): hold reader off-bottom during jump owner + cancel jump on explicit End (#6621 gate fixes)
Codex gate found two defects in the jump-scroll ownership mechanism:
- Jump during an active stream snapped back to bottom on the next token: the
owner suppressed the scroll listener but left the pre-jump pinned state, so
scrollIfPinned() reclaimed the bottom. Now _beginMessageJumpScroll unpins for
the ownership window and scrollIfPinned() no-ops while an owner exists.
- An explicit End click could be undone by the pending jump reconcile restoring
the stale unpinned snapshot. scrollToBottom() now cancels the active jump
owner first; _cancelMessageJumpScroll restores the preserved snapshot so a
non-reconcile cancel doesn't leak the transient unpinned state.
* fix(scroll): drop undefined currentSid global ref in _messageJumpSessionId (#6621 brick-class scope-undef gate)
The PR's _messageJumpSessionId() referenced a bare 'currentSid' global that
does not exist in ui.js (everywhere else it's a local const from
S.session.session_id). test_static_js_scope_undef flagged it brick-class
(#3696). The S.session.session_id fallback already IS the canonical accessor;
removed the dead first line and updated the harness to drive the session-change
case via S.session.session_id.
* fix(scroll): round-2 gate fixes for jump-owner state transitions (#6621)
Re-gate found three more state-transition edge cases from the temp-unpin window:
- wheel-up interrupting an active jump owner after the programmatic latch
expires now explicitly establishes the unpinned reader-owned state (was
cancelling the jump but leaving pinned -> next token snapped to bottom).
- _resetStreamScrollFollow() now cancels the jump owner FIRST, before its
pinned-state assignments, so a stream starting mid-jump can't have its pin
undone by the snapshot restore -> auto-follow stays enabled.
- _finishMessageJumpScroll() flushes a deferred external-session refresh after
reconciliation when the terminal state is pinned to the tail, so a refresh
deferred during the temp-unpin window isn't stranded.
* fix(scroll): make jump-owner guards typeof-safe + widen static test window (#6621)
The two _messageJumpScrollOwner guards (scrollIfPinned, scrollToBottom) are
pulled into other scroll test harnesses (test_issue6414, test_issue4856) that
stub the scroll env without declaring the new global; bare references threw
ReferenceError. Guard with typeof (matches the PR's own jumpScrollOwned check).
Also widen test_low_delta_wheel_intent_is_tracked_separately's source-slice
window 1400->1800 to still contain the (unchanged) _lastMessageWheelIntentMs
line after the +9-line wheel-up-during-jump block was inserted.
* test(scroll): add streaming-frame-hold + wheel-during-jump regression tests (#6621 Fable finding ii)
Fable UX gate flagged that the PR's tests model only the stale load-time settle
callback, not the streaming case. Add two node-harness tests:
- a streaming render frame (scrollIfPinned) fired inside the jump-owner window
must not snap the reader to the bottom (holds at target, 0 bottom-writes).
- a gentle wheel-up during the owner window after the programmatic latch stales
hands ownership to the reader UNPINNED at their position, never pinned
mid-transcript.
* fix(scroll): widen jump-owner takeover to downward + touch input (#6621 Fable S3)
Fable's re-review (on the fixed code) confirmed the streaming blocker + wheel-up
concern resolved, and probe-proved one remaining narrow regression: a gentle
wheel-DOWN or touch scroll during the owner window cancelled the owner and
restored the pinned snapshot but did NOT re-unpin (the takeover was gated on
wheelUp only), so the next streaming token yanked the reader to the bottom.
Widen the takeover to any message-pane scroll input during the owner window.
Add a wheel-down regression test; widen the #4970 static-slice window to 2000
to still contain the (unchanged) _lastMessageWheelIntentMs line.
---------
Co-authored-by: pxxD1998 <214340659+pxxD1998@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.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>
* fix(renderer): exclude CJK full-width punctuation from autolink URLs
* fix(test): widen autolink block search window for CJK punctuation fix (#6792)
The CJK full-width punctuation fix expanded the autolink regex and
comment text, pushing target='_blank' and esc(clean) beyond the 600/
700-char search windows in tests. Widen to 1200 chars.
* fix(test): widen autolink window in test_issue470 to match 1200-char block
The CJK punctuation fix added comment lines that pushed href="${clean}"
beyond the 600-char search window in test_issue470.py::test_autolink_no_esc_on_href,
causing the same failure across 3.11/3.12/3.13 shard-4. test_issue342.py was
already widened in cc52202f; apply the same 1200-char window here.
* fix(test): drive real renderMd() for CJK full-width autolink behaviour
Replace the test-owned FIXED_REGEX copy in test_fullwidth_autolink.js with
a pytest + node harness that extracts and runs the ACTUAL renderMd() from
static/ui.js (same driver pattern as test_renderer_js_behaviour.py).
Coverage per review:
- every CJK trailing mark (),。;:!?、) through both the outer
paragraph pass and the inline pass (list items + blockquotes)
- ASCII trailing marks (regression guard)
- normal URLs with query params (href keeps raw &, text is escaped)
- Markdown links and pre-existing anchors are stashed, never re-wrapped
Reverse-verified: reverting the \uFF09 exclusion + CJK strip makes 27 of
38 assertions fail on the real production code.
* fix(test): drop stray f-prefix from placeholder-free asserts (F541)
* docs(changelog): stamp #6792 (CJK autolink punctuation)
---------
Co-authored-by: happy5318 <happy5318@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Addresses nesquena-hermes Round-2 review on #6979. All three MUST-FIX
items reproduced end-to-end are fixed; three SHOULD-FIX items folded in.
MUST-FIX 1 (data exfiltration) — capture/serve deny parity + source binding:
- The #3234 state/profile deny model is extracted into a single shared
predicate (routes._media_deny_reason) used by BOTH /api/media serving and
snapshot capture, so anything the serve path refuses is never captured in
the first place (STATE_DIR subdirs, webui_state subdirs, named-profile
roots, secret basenames, the snapshot store itself).
- Every captured digest now carries a server-owned canonical source-path
association (<digest>.src.json sidecar, tmp+rename atomic). /api/media
serves a digest only for the exact path it was captured from — replaying
a valid digest through a different allowed path falls back to the live
file instead of leaking stored bytes.
- Snapshot blobs are opened ANCHORED inside the store root (no-follow).
- Committed end-to-end regression: capture a denied state file and prove it
cannot be served through any path.
MUST-FIX 2 (data exfiltration) — configured store root denied regardless of name:
- get_snapshot_dir() is resolved and any bare path= target at or below it is
rejected BEFORE the allow/serve decision, so a custom
HERMES_WEBUI_MEDIA_SNAPSHOT_DIR (e.g. /tmp/custom-store-name) can no
longer be read via a plain path= request.
MUST-FIX 3 (silent corruption) — prefix-safe URL stamping:
- _stampMediaSnapshots now rewrites each COMPLETE path= query value
atomically (boundary-aware regex, decode whole value, exact map lookup)
instead of substring split/join. One path being a prefix of another
(/tmp/a.png vs /tmp/a.png.backup) can no longer corrupt the longer URL or
drop its digest. data-path placeholders use the same value-level rewrite
with HTML-entity unescaping. Prefix-path regression test committed.
SHOULD-FIX folded in:
- A recorded digest is FINAL: quota eviction no longer causes a later settle
to re-capture the current live bytes and rebind the historical message
(immutability restored + no quota-thrash I/O).
- is_valid_digest uses whole-string fullmatch (\Z) — a trailing newline can
no longer pass the gate.
- PDF/HTML fallback, download and open-full-page links now carry snap so
they open/download the frozen bytes.
Deferred (noted in review thread): emission-time capture (streaming.py) and
worklog scene-prose stamping — scene rows do not carry parent-message
snapshot metadata; threading it through the scene reconciler is a separate
data-pipeline change. Main transcript + transparent ordered segments remain
the old/new comparison surface.
Tests: +11 backend/frontend regressions (36 total in
test_media_message_snapshots.py), incl. the exact Round-2 repros.
Reverse-verified: the new tests fail on the round-1 head (8 failed).
- Revert all worklog/scene render-path changes: the scene render chain is
exercised by tests that extract function bodies and eval them in isolation
(_renderAnchorSceneRowsIntoWorklog, _anchorSceneNodeForRow, etc.); adding
helper references breaks those harnesses with ReferenceError.
- Snapshot stamping now covers the main transcript path and transparent
ordered segments only — the comparison surface for old/new previews.
- Transparent segment stamping is inlined in the msg-body template guarded by
typeof m, keeping the source-window string intact for test_issue3397 and
keeping the eval'd ordered-block self-contained for test_status_command_card.
- PDF/HTML lazy loaders keep the exact publicMediaUrl source string the tests
assert; snap is appended to mediaUrl only.
- Keep `const partBodyHtml=_getCachedRender(partDisplayText,false);` line
intact (test_issue3397 asserts the exact source string), apply snapshot
stamping on the following line via partBodyHtmlStamped.
- Drop unused roots_before variable and unused import in
test_media_message_snapshots.py (ruff F841/F401 on new lines).
- Update the source-invariant assertion to the two-line stamp shape.
PR #6922 made /api/media revalidate on every use, so overwriting a file in
place (same filename) also rewrote every historical chat preview that
referenced it — the old/new comparison was lost.
At settle time, snapshot each local-file MEDIA: reference into a
content-addressed store (<STATE_DIR>/media_snapshots/<sha256>.snap) and stamp
the message with _media_snapshots {path: digest}. The frontend appends
&snap=<digest> to historical preview URLs; /api/media serves the frozen bytes
with immutable caching when the digest is valid, and falls back to the live
file otherwise (eviction/never-captured). The existing path allow/deny checks
still gate the request — snap only selects which bytes to serve, it never
widens access.
- api/media_snapshots.py: content-addressed store (sha256 key, dedup, 50MB
per-file / 2GB total caps with FIFO eviction), MEDIA ref resolver, capture
allow-list mirroring the /api/media roots + #3234 deny list
- streaming.py: annotate settled assistant messages (idempotent, best-effort)
- routes.py: snap= serving branch + download_name override + deny direct
store-path access
- models.py: _media_snapshots registered in display-metadata merge keys
- ui.js: _stampMediaSnapshots post-cache stamping (main transcript,
transparent ordered segments, worklog scene prose) + data-snap threading
through the five lazy preview loaders
25 new tests incl. node-executed frontend behavior tests; reverse-verified.
Review round 2: the 1.5s timestamp tolerance over-deduped a legitimate
rapid repeat — two identical-text turns <1.5s apart collapsed into one,
hiding the second turn and copying its attachments onto the earlier row
(the mirror risk of #6649).
_pendingActiveTurnUserMessage now matches only on unambiguous identity:
- the row carries the server-stamped _active_turn_token (stream_id +
started_at, per build_active_turn_token), or
- its timestamp equals pending_started_at within a precision-only
epsilon (1e-6, absorbs float/state.db drift, never a full second).
Anything wider (whole-second truncation, ~1s rapid repeat) returns null
so getPendingSessionMessage() materializes the pending turn — fail
toward the harmless transient duplicate the settle render clears.
Regressions added: two completed identical-text turns ~1s apart → second
pending row returned + first row's attachments untouched; token-identity
row adopted even when its timestamp is outside the epsilon.
The /api/media endpoint served all file types with Cache-Control:
private, max-age=3600. For HTML inline previews (rendered via
loadHtmlInline() → fetch() → iframe srcdoc), this meant the browser
cached the response for 1 hour. When an agent edited and re-rendered
the same HTML file, the inline preview showed stale content — only a
manual full-page refresh revealed the update.
Two changes:
- Backend: serve text/html with no-store instead of max-age=3600
(images and other media keep the 1-hour cache)
- Frontend: loadHtmlInline() fetch with {cache:'no-store'} for
belt-and-suspenders defense against intermediate caches
Note: the /api/file/raw path already used no-store for HTML; this
fixes the /api/media path which was missed.
The frontend re-derived partition rank from the serialized type, so FIFO,
socket, and device entries moved into the regular-file partition on non-default
sorts. Emit workspace_sort_rank from list_dir, consume it in the rank helper,
and keep it out of dir_signature.
Browser race in static/ui.js: _autoScrollFollow can be undefined when
scroll/scrollpin listeners fire before boot.js initializes settings.
Bare global reference (ReferenceError) occurs on the golden path.
Fix: wrap both consumer sites with a typeof guard that defaults to
true (matching the boot.js fallback) when the variable hasn't been set.
Closes#6606
While a turn is live the server can reconcile that turn's user row into the
transcript returned by /api/session (the sidecar/state.db merge picks it up
from state.db, where the agent writes it immediately) while
pending_user_message is still set. The row is then followed by the same
turn's assistant/tool rows.
_pendingCurrentTailUserMessage() scans the tail backwards and returns null as
soon as it meets a completed assistant, so in that state it reports "the
current turn has no user row". getPendingSessionMessage() then materializes
pending_user_message a second time and the pane renders two identical user
bubbles until the settle render replaces the list wholesale (which is why the
duplicate disappears when the answer lands, and why nothing wrong is ever
persisted).
Scanning past assistant/tool rows on text alone would regress #3300: a user
who submits the same text twice in a row legitimately gets two identical user
turns. Use pending_started_at as the discriminator instead — the server stamps
the active turn's user row with it, and no earlier turn can share it. Text
equality is still required, so a false match needs identical text AND a
near-identical timestamp. Absent/invalid pending_started_at disables the
fallback and keeps the previous strict-tail behaviour.
Verified: the new midRunReloadDedupe assertion fails on the unpatched ui.js
and passes with the fix; the existing repeat-prompt, compaction-boundary and
strict-tail assertions still pass.
The mid-stream reconnect path could render the just-sent user message below
the live streaming assistant response it triggered, because
`refreshSession()` appended `pending_user_message` at the end of
`S.messages` unconditionally while `loadSession()` correctly inserted
it before the first live assistant message. When the client went through
its reconnect/recovery path mid-turn (typical on iOS PWA screen
lock/resume) the live assistant tail rendered above the prompt.
Centralize every recovery/reconnect reinsertion through one
identity-aware helper (`_mergePendingSessionMessage`) that:
- inserts the owning user turn before its live assistant/Worklog tail,
- appends only when no live tail exists,
- deduplicates against the current tail user row,
- remains idempotent across repeated resume probes.
`refreshSession()` now calls the same helper instead of pushing at
the end. `loadSession()` continues to use the helper unchanged.
Adds `tests/test_issue6419_pending_user_reconnect_order.py` pinning:
- the helper is defined exactly once at module top level,
- it inserts before live assistant and falls back to append,
- `refreshSession()` uses the helper and does not push unconditionally,
- `loadSession()` still merges before renderMessages in the INFLIGHT
reattach branch.
Renames the inner end-marker in
`tests/test_gateway_sse_reconnect_dedupe.py` to a stable inside-loadSession
boundary (`// Phase 2a:`) since the helper is no longer nested.
Render structured background-process completions and watch matches as accessible collapsed cards while preserving raw fallback, verbatim output, and full watch patterns.
Co-authored-by: b3nw <b3nw@duck.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>
A tool-using Transparent Stream turn renders multiple assistant segments
(activity segment(s) then the final answer). Turn metadata — including
_usedModel — is stamped only on the final assistant message, but the
transparent footer read it via turn.querySelector('.assistant-segment'),
which returns the FIRST segment and thus no metadata. Combined with the
generic footer yielding model ownership to transparent mode, the model
label disappeared entirely on exactly the tool turns where per-turn
attribution matters most.
- Add _transparentTurnMetaMessage(turn): scan .assistant-segment
[data-msg-idx] last→first for the final metadata-bearing assistant
message, falling back to the last assistant segment.
- Use it for the transparent footer's duration/model/TTFT/usage instead
of querySelector's first match.
- Non-blocking UX (per gate): preserve the full uncompacted model id in a
hover title on both the transparent .lf-model span and the settled
.msg-used-model-inline chip.
- Tests: behavioral multi-segment harness asserting exactly one .lf-model
(and the correct final-segment metadata + hover title), plus wiring
checks for the segment-scan helper.
Addresses the three gate-review blockers on #6113:
- Fallback attribution: read the served model from agent.model AFTER the
run (the agent mutates it when a fallback fires) instead of the pre-run
resolved_model, for both the persisted _usedModel and SSE used_model.
- Gateway double-label: suppress the additive chip whenever
_gatewayRouting is present, and pass msg._usedModel to
_formatGatewayModelLabel as its model fallback when routing omits
used_model.
- Transparent double-label: skip the generic .msg-used-model-inline chip
when the transparent turn footer owns the model label (.lf-model).
Also per Greptile: settled footer now orders the model chip after the
duration (elapsed - model - ...) to match the transparent footer, and the
test file gains its trailing newline.