Two gate findings on the worklog-event-timestamp feature (#5700/#5739):
1. Thinking-row timestamps disappeared after settlement. Reasoning source events
carry no server timestamp; the live DOM showed a fallback time but the settled
scene row rebuilt with created_at:null. Now stamp created_at on the source event
the first time it's seen (messages.js apply path) and carry row.created_at
through snapshot hydration, so the timestamp survives live->settled.
2. A garbage huge numeric timestamp (e.g. 1e20) passed the finite/>0 checks and
rendered literal 'Invalid Date'. _timestampSeconds now rejects epochs outside
JavaScript's valid Date range. Added a regression test.
Co-authored-by: rodboev. Gate findings from adversarial Codex re-gate.
Two visible render PRs, gate-passed (deep-review round), Nathan-approved to ship.
- #5757 (rodboev): mark active model with a Selected badge + open its provider group (#5748).
Screenshot-verified (badge clear + correctly placed on active row).
- #5758 (rodboev): suppress the live-token prose row that is a strict prefix of the final
answer (#5749). Provenance-gated (live-prose:* token rows after last tool row only) so it
can't eat legitimate narration; reproduction test proves the dupe is gone.
Codex SAFE + full regression green (1576 render tests). 18 own tests pass together.
Co-authored-by: t <a@b>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
_steerClearCurrentOwnerDeadRun cleared busy/activeStreamId/inflight but left
the status line, elapsed timer, and optimistic streaming badge lingering after
a recovered dead run. Add the rest of the app's stale-busy cleanup idiom
(setStatus/setComposerStatus/_clearActivityElapsedTimer/clearOptimisticSessionStreaming),
still bypassing setBusy(false) so its queue-drain can't clobber the restored draft.
Steering (submitting mid-stream) dropped any pending composer attachments. Now the
pending files are uploaded before the /steer payload is sent, the uploaded file
paths are appended to the steer text so the active agent can inspect them with
tools, file chips stay staged if the steer fails (so nothing is lost), and
file-only busy submissions are handled.
Contributor stage; gate-pass. Co-authored-by: ruizanthony <ruizanthony@users.noreply.github.com>
The live-anchor scene renderer hardcoded mode:'compact_worklog' in
_renderAnchorLiveScene / _projectLiveAnchorActivityScene / the settle projection,
regardless of the user's actual active display mode. In Transparent Stream mode
the final reply was therefore projected with compact-worklog display hints, so on
turn-settle it folded into the collapsed worklog disclosure group (a refresh fixed
it because the stored transcript was correct). Now the renderer reads the real
active mode via _anchorSceneActiveMode() (window.chatActivityMode() ->
_chatActivityDisplayMode -> _transparentStream, default compact_worklog) and
applies per-mode display hints via _anchorSceneRowDisplayHintForMode(), so the
final answer stays a visible chronological reply in Transparent Stream.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Two mobile reliability fixes on the crown-jewel chat streaming path:
(1) content-visibility:auto on off-screen .msg-row under @media(pointer:coarse)
so WKWebView skips layout/paint for off-screen rows during streaming (kills the
long-chat freeze). The LIVE turn is kept content-visibility:visible via the
STABLE #liveAssistantTurn id (covers ALL render modes — Compact Worklog,
Transparent Stream, restored-live — not just the Transparent-Stream path that
stamps data-live-assistant-turn), so a normal live turn on touch never blanks
mid-stream and its height keeps growing so the new-message cue still fires.
contain-intrinsic-size:auto 1px preserves flick-scroll momentum. Scoped to
touch — desktop find-in-page untouched. Deliberately does NOT set
overflow-anchor:none (inert on iOS WebKit; re-opens #4856/#5338).
(2) SSE reconnect ladder extended 4->6 steps + a last-ditch
_restoreSettledSession full-session poll (8s watchdog) after retries exhaust, so
a response completed during an iOS Tailscale/VPN reconnect is recovered without
an error banner.
Co-authored-by: luperrypf <luperrypf@users.noreply.github.com>
Addresses Greptile P2 nit: autoResize() now only calls the re-pin when the
composer actually grew (offsetHeight before/after compare), skipping the DOM
read on a no-height-change keystroke. The #composerWrap ResizeObserver remains
the safety net for growth paths that bypass autoResize. Test tightened to assert
the grow-guard. CHANGELOG entry added (self-built, release owns changelog).
#5514 (deterministic): with the chat pinned to the bottom, growing the composer
past one row (multi-line typing, Shift+Enter, a multi-line / WisprFlow paste)
shrinks the flex:1 .messages viewport by the same delta. scrollTop is unchanged
but max-scrollTop rose by delta, so the reader is stranded delta-px above the
bottom — the transcript 'scrolls up one row per composer row.' autoResize()
resized the textarea and called updateSendBtn() but never re-pinned the transcript.
Reproduced live: composer 176px -> viewport shrank 745->613 -> transcript stranded
132px above bottom while _scrollPinned stayed true.
#5515 (intermittent 'random scroll up'): the same viewport-shrink from composer
growth via paths that don't route through input->autoResize (paste, draft restore,
programmatic value set, reflow) reads to the user as a random upward jump. Covered
by a ResizeObserver seam (below).
Fix: new _repinMessagesAfterComposerResize() (static/ui.js) re-pins to the bottom
ONLY when the reader is genuinely still pinned (honors _messageUserUnpinned /
_scrollPinned — never yanks a reader who scrolled away; verified live). Called from:
(a) autoResize() after the height write, and
(b) a ResizeObserver on the #msg textarea that re-pins on GROW only, catching
every composer height-change path (not just the typed-input seam).
Verified live on a 40-message seeded session: pinned reader stays at bottomDist=0
after a 4-line composer grow; an unpinned (scrolled-up) reader is NOT moved.
Tests: tests/test_issue5514_composer_grow_scroll_pin.py (3 static wiring + 3 node-vm
behavioral). 524 scroll/composer regression tests across 39 files still green.
NOTE on #5515: this resolves the composer-growth cause (which the reporter's
WisprFlow-paste symptom points at). If a purely non-composer intermittent jump
persists, the remaining suspect is touch-device overflow-anchor:auto on content-
above height change — that path has no repro yet and is tracked as needinfo on
#5515.
* fix(#5472): harden failed-send draft restore — order persist after clear, honest test
Two NIT-level follow-ups from the Opus review of #5484 (both on already-shipped
v0.51.842 code; neither is a functional break):
1. Draft clear/save ordering. send() fires an unawaited _clearComposerDraft POST
(text:'') at send time, and the failed-send restore fired a separate
_saveComposerDraftNow POST (text:<draft>). Under HTTP/2 multiplexing those two
same-origin writes can be reordered, leaving the SERVER draft empty after a
reload (the in-memory composer restore was always fine). send() now captures
the clear POST's promise and passes it to the restore helper, which chains the
re-persist after the clear resolves (falls back to immediate persist if the
promise is absent/rejects).
2. Honest happy-path test. test_send_still_clears_composer_on_the_happy_path
asserted a bare $('msg').value='' string that matches ~13 sites, so it would
pass even if the MAIN-path clear were removed. It now anchors to the unique
main-path _clearComposerDraft line and window-checks the composer wipe
immediately precedes it.
Tests: +2 (promise-ordering behavioral via node-vm microtask ordering; absent-promise
fallback), signature/call assertions updated. 14 in the #5472 file, 93 across the
related composer/send/draft/upload surface — all green.
* fix(#5472): make deferred draft persist stale-aware (Codex #5488 catch)
The promise-ordering fix introduced a regression: the deferred _persist saved the
CAPTURED snapshot and its _saveComposerDraftNow clearTimeout cancelled any newer
debounced save. So if the user edited the restored composer before the clear POST
settled, the delayed persist overwrote their edit with the stale original text
(lost on reload).
Restructure the helper so the deferred persist is stale-aware at FIRE time:
- If the failed session is still visible, re-read the LIVE composer (.value
+ current S.pendingFiles) instead of the captured snapshot — a post-restore edit
is captured, not clobbered.
- If we restored the visible session but the user has since switched away, SKIP the
stale write entirely (the session-switch save path already saved that session).
- Background failure (sid never visible): persist the captured snapshot, since
there's no live composer to read — it's the only copy.
The visible-composer restore + session guard are unchanged in behavior (moved above
the persist). Helper now returns whether it restored the VISIBLE composer.
Tests: +3 regression cases (post-restore edit captured; switch-away skip; background
snapshot persist), signature/guard assertions updated. 17 in the #5472 file, 87
across the related surface — all green.
* fix(#5472): preserve composer draft when a send fails
When a provider/background error aborts a send, send() has already cleared
the composer and the persisted draft before /api/chat/start durably accepts
the turn. On a start-time throw the turn is never persisted, so the user's
typed message is lost and must be retyped.
Add _restoreComposerDraftAfterFailedSend(text, sid): restores the typed text
into the composer, keeps staged files in S.pendingFiles, and re-persists the
draft (via _saveComposerDraftNow) so it also survives a reload. Guarded to
no-op when there's nothing to restore and to never clobber a new message the
user began typing during the async send window. Wired into the /api/chat/start
throw handler's general-error branch. Mirrors the existing _trySteer /
_stashClarifyDraft draft-restore idioms.
Tests: tests/test_issue5472_preserve_draft_on_failed_send.py — 3 static wiring
assertions + 4 node-vm behavioral tests of the helper's branching logic.
* fix(#5472): address gate findings — original-payload snapshot, re-stage files, session-guard
Codex + Opus gate on the first commit converged on three real edges:
1. /moa and bundle sends rewrite `text` before /api/chat/start, so restoring
`text` replayed the transformed payload, not the user's typed command.
Fix: capture an immutable _failedSendDraftText snapshot right after the
post-flush trim, before any slash rewrite, and restore that.
2. uploadPendingFiles() clears S.pendingFiles=[] before the start call, so the
original 'keep staged files' assumption was false — a resend silently
dropped attachments. Fix: snapshot S.pendingFiles before the upload drain
(_failedSendFilesSnapshot) and re-stage from it (+ renderTray()) on failure.
3. A start failure after a session switch could write the failed old session's
draft into the currently-visible composer. Fix: the helper now only mutates
the visible composer/tray when sid === the visible session; the sid-keyed
_saveComposerDraftNow persist stays unconditional so the draft survives a
switch-back/reload.
Helper signature is now (draftText, filesSnapshot, sid). Tests expanded to 11
(5 static + 6 node-vm behavioral) covering all three edges: original-vs-mutated
payload, re-stage drained attachments, and cross-session no-pollution.
* test(#5472): bound send() body slice to LIVE_STREAMS instead of fixed char window
The #5472 snapshot-capture lines added ~400 chars near the top of send(), which
pushed 'cancelStream' (in the busy-interrupt branch) past the arbitrary
[send_idx:send_idx+5000] window that test_1062's structural assertions used —
a real CI failure (shard 1, all 3 Pythons) caused by a brittle test, not by the
fix logic. Bound both send()-body slices in test_1062 to the actual function
extent (up to the 'const LIVE_STREAMS=' decl that immediately follows send()) so
future additions near the top of send() don't false-fail these assertions.
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Codex gate findings (2 SILENT, both fixed):
- ui.js: incremental prose node now flows through the shared row-decoration
block (data-anchor-scene-row/-row-id/-row-role/-source-event-type) instead of
returning early, so live incremental rows keep the identity attrs the scene
reconciler matches on.
- messages.js: _cancelThrottledSnapshotTimer() + _clearAnchorProseIncrementalNode()
now run on apperror, cancel, _handleStreamError, and _restoreSettledSession
terminal paths (previously only fallback/done/stream_end) — closes the
snapshot-timer/anchor-cache/window-global teardown gap.
- test: extend test_messages_js_stream_perf_cleanup_lifecycle to pin teardown on
all four additional terminal paths.
CORE equivalence (Codex flagged smd != renderMd): reconciled — Opus's 1498-frame
harness against real smd.min.js proves incremental-smd == whole-smd byte-identical;
live-smd-vs-renderMd difference is the already-shipped fidelity model (main live
body streams smd, settles renderMd), and the incremental path is gated on !settled
so settled DOM still comes from renderMd. Not a regression.
Two O(n^2) costs on the per-token/per-frame streaming path, both over the
growing answer:
WS2.2 — snapshotLiveTurn() serialized the whole live turn via turn.outerHTML on
every render frame. That snapshot only backs mid-stream session-switch restore,
and the switch path (sessions.js) plus tool/done event boundaries already
capture synchronously, so the per-frame call is throttled to a coarse trailing
snapshot instead of serializing the entire growing DOM each frame.
WS2.3 — the token handler ran _parseStreamState() (strip tool-call XML + extract
inline thinking over the FULL accumulated text) on every token, but _doRender
re-parses once per throttled frame anyway and the only per-token use is the
assistant-row creation gate. Skip the parse once the row exists (the common
case); keep it only until the row is created. Also drop the full-string
toLowerCase() allocation in _stripXmlToolCalls for a non-allocating regex test.
Output-neutral: same rendered DOM; the row-creation gate and snapshot semantics
are preserved (row appears within one 66ms frame; switch/settle snapshots stay
synchronous). node --check + ESLint runtime guard clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In compact-worklog mode the anchor activity scene re-renders every frame, and
its live prose row was rebuilt with renderMd(fullText) each time — re-parsing
the entire growing answer per frame, which is O(n^2) over a long streamed
response (the incremental smd parser that the main body already uses was
bypassed and its segment hidden).
Keep a persistent per-segment smd parser + DOM node (keyed by the prose row's
local_id, using the same safe renderer as the live body) and feed it only the
delta each frame; hand that persistent node back to the scene builder instead
of re-parsing. Turns the per-frame prose cost from O(n) into O(delta), i.e. the
whole answer from O(n^2) to O(n).
Safe by construction: settled rows and any failure (no smd, no stable key,
parser error, text divergence) fall through to the existing renderMd path, so
worst case is identical to today. Cache is bounded (<=32 segments) and self-heals
on non-append text changes, mirroring _smdWrite.
Verified: node --check + ESLint runtime guard clean. Streaming render profiled
in-browser via a per-frame timing probe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebase PR #5162 (rename busy_input_mode -> default_message_mode; flip the
default from 'queue' to 'steer') onto current origin/master WITHOUT dropping
the shipped #5170 localStorage persistence mirror.
Rename the mirror machinery to the new setting name for consistency:
_BUSY_INPUT_MODES -> _DEFAULT_MESSAGE_MODES (values unchanged)
_normalizeBusyInputMode -> _normalizeDefaultMessageMode (fallback now 'steer')
_persistBusyInputMode -> _persistDefaultMessageMode
_readPersistedBusyInputMode -> _readPersistedDefaultMessageMode
window._busyInputMode -> window._defaultMessageMode (+ renamed exports)
localStorage: write the new 'hermes-default-message-mode' key; read it with a
fallback to the legacy 'hermes-busy-input-mode' key so an existing user's
persisted preference survives the rename.
Preserve #5170 behavior at every mirror site under the new names:
- boot success -> window._defaultMessageMode=_persistDefaultMessageMode(...)
- boot FAILURE -> window._defaultMessageMode=_readPersistedDefaultMessageMode()
(NOT a hardcoded 'steer' — a saved 'interrupt'/'queue' must still apply when
the server is unreachable; do not regress #5167/#5132)
- preferences autosave, settings-panel load, and _applySavedSettingsUi all
persist through _persistDefaultMessageMode(...)
Tests updated for the rename while keeping the persistence-behavior assertions
(test_1062, test_5145, test_5167); test_5167 gains explicit guards that the
load-failure path reads the persisted pref and never hardcodes a literal mode,
plus autosave/panel-load mirror-write coverage.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
/api/clarify/pending always returns HTTP 200 when present (returns
{"pending": null} for an unknown session — it never 404s). The front-end
clarify poller warned "Clarify endpoint unavailable. Please restart
server." on ANY caught error whose message merely contained "404" or
"not found", so an unrelated stale-session 404 ("Session not found", e.g.
an old-profile session polling briefly after a profile switch) or a
transient error produced a misleading missing-endpoint toast that pointed
operators at the wrong layer.
Clarify polling now branches on the structured HTTP status that api()
attaches to the thrown Error (err.status):
- 404 "Session not found" -> handled as a stale-session poll (stop + hide
card silently), no toast;
- restart-server warning fires only on a genuine route-not-found 404 whose
body is NOT session-scoped;
- poll failures are logged with path, status, polling session id, and
current session id for diagnosis.
Interrupt provenance: cancelStream()/cancelSessionStream() now log a
'[stream] cancel requested' line with the trigger reason
(composer-stop / slash-stop / slash-interrupt / busy-interrupt /
sidebar-stop). Passive UI lifecycle events (session switch, tab hide, page
unload) already tear down only the local SSE transport via
closeLiveStream() and never call /api/chat/cancel — only explicit
Stop/interrupt paths interrupt the backend agent/tool run. This is
confirmed by test_clarify_pending_never_404s locking the handler shape.
Supersedes #5343 (which handled only the profile-switch sub-case and kept
the broad message-scrape). Adds tests/test_issue5345_*.py (9 tests).
Co-authored-by: claw-io <claw-io@users.noreply.github.com>
Co-authored-by: ruizanthony <ruizanthony@users.noreply.github.com>
A visible tab can permanently miss a server-initiated turn (self-wake /
cron / restart hook) when its per-session SSE is momentarily down at the
emit instant. server_turn_started is a fire-and-forget broadcast with no
replay buffer; if the turn also finishes before the tab reconnects, the
on-subscribe self-heal finds no live run in ACTIVE_RUNS and replays
nothing, so the transcript stays stale until a hard refresh.
Extend the self-heal: the (re)subscribing tab reports its last-known
message_count (?known_count); when there is no live run to replay, the
handler compares the persisted count and, if the server is ahead, emits a
lightweight session-updated frame. The frontend syncs incrementally via
the #5189 keepStaleUntilLoaded swap-in-place loadSession path (no
clear+refetch, so the #5177/#5189 blank-gap jump is not reintroduced).
Idle-only and pane-scoped; emits only when the server is strictly ahead.
The RED gate-cert (Codex-verified) found the keep-open was NOT the one-frame
transient the PR claimed: _disarmKeepSettledWorklogOpen() only cleared the token
with no follow-up render, so the just-settled worklog stayed force-open and could
be cached into _sessionHtmlCache and persist across session restores.
Two coordinated fixes:
1. Don't cache the forced-open DOM. Add _isKeepSettledWorklogOpenArmed() and gate
the _sessionHtmlCache.set() population on a typeof-safe !_keepOpenArmed local, so
the armed settle render is never cached. The typeof guard also keeps standalone
renderMessages() node harnesses (which don't define the helper) working.
2. Collapse pass after disarm for BOTH pin states. The STREAM_DONE handler disarms,
then runs _renderMessagesWithScrollSnapshot() UNCONDITIONALLY (a scroll-preserving
re-render that collapses the worklog back to its copied live/user disclosure
state), and only THEN scrollToBottom() for followers to re-settle at the tail.
The second RED gate-cert caught that gating the collapse pass to the non-following
path left a pinned follower forced-open, because scrollToBottom() only settles
scroll position and does NOT re-render. Running the collapse render before the
follower re-settle fixes both pin states. The same-frame JS restore absorbs the
collapse height change instead of leaving it to native scroll-anchoring (which
mobile suppresses mid-render), so there is no jump, and this unarmed render is
the one that populates the cache.
Net: keep-open is genuinely one settle render for pinned AND unpinned readers —
neither persists nor caches — and a user-collapsed worklog is preserved. Verified
e2e on an isolated debug instance for both pin states (worklog open-while-armed ->
collapsed-after-pass; pinned follower stays pinned at tail). Regression tests lock
the cache-skip and the collapse-before-scrollToBottom ordering; the scrollToBottom
adjacency keeps the #1360 done-follow test green.
#5172 (render server-initiated turns in a hidden tab, shipped v0.51.743 via
#5218) has a non-blocking multi-pane edge flagged in review: when a
server-initiated turn arrives for a session that is NOT the active pane, the
hidden-tab status poll called _attachServerInitiatedStream() and could not tell
whether the attach actually happened. _attachServerInitiatedStream returned
undefined in all paths, so the poll had no signal — a stop-on-call would cancel
polling while the turn was never rendered, leaving it invisible until the next
user interaction.
Fix: _attachServerInitiatedStream now returns a bool — true when this tab is
responsible for rendering the stream (attached, or already attached to the same
sid/streamId), false when the attach was not consummated (sid not the current
pane, invalid input, or a thrown error). _startHiddenActiveStreamPoll captures
the bool and only calls _stopHiddenActiveStreamPoll() when true; on false it
keeps polling and re-tries on a later tick (e.g. once the user switches the pane
back to sid, or the turn finishes and status returns active_stream_id=null).
Tests in tests/test_hidden_tab_server_initiated_turn.py: assert all return paths
are explicit bool (no bare return), the non-current-pane path returns false, and
the poll gates _stopHiddenActiveStreamPoll on the attach result.