The assistant message footer (model · duration · tokens · cost · cache% · time · actions)
could overflow its row on mobile/narrow panes, pushing the per-message action buttons off
the right edge where they were unreachable. #4724 proposed ellipsing every stat, which
shrank them into unreadable stubs (D…, 98…). This instead lets .msg-foot flex-wrap to a
second line: every stat stays full-size + readable, the actions/time are pinned flex:0 0
auto so they never shrink and stay on-screen, and on wider screens where it already fits
wrap never triggers. Pure CSS. + source-structure regression test.
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
Opus ship-gate non-blocking observations (no MUST-FIX), applied to lower future-maintainer
risk on the crown-jewel profile-switch surface:
- clarified the belt-and-suspenders _sessionListSkeletonActive=false at the real-paint site
- added a FOUR-GUARD CONTRACT doc-comment at the top of switchToProfile naming the four
interacting state pieces (embargo / invalidation / skeleton-flag / _wsTreeGen + _switchGen)
so a future edit touching one without the others doesn't silently reopen a window.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Two more edge cases from re-gate 4:
(1) SILENT: a mid-switch /api/sessions that FAILS bypassed the embargo (the drop-check
was only on the success path), so its catch cleared the skeleton + rendered old-profile
cache. Added 'if(_profileSwitchListEmbargo) return;' after the gen guard in the catch too.
(2) CORE: the embargo was set BEFORE the try, so a throw in the synchronous setup could
leak it (the guarded finally only covered the try). Moved the invalidate/embargo/skeleton/
ws-gen setup INSIDE the try so the _switchGen-guarded finally always lifts the embargo.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Codex re-gate 3 found a third clobber variant: point-in-time invalidation isn't enough
because a renderSessionList() can START after the skeleton shows but BEFORE
/api/profile/switch returns. Since the new-profile cookie is only set by the switch
response, that GET fetches the OLD profile's rows, passes the (now-current) generation
guard, and clobbers the skeleton.
Fix: a profile-switch session-list EMBARGO. _setProfileSwitchListEmbargo(true) before
showSessionListSkeleton(); _runRenderSessionListRefresh drops ALL payloads (after the
gen guard, before apply) while embargoed; the switch lifts it immediately before its own
authoritative renderSessionList() (both branches), in the failure-restore, and in a
_switchGen-guarded finally as a safety net so it can't freeze the sidebar on an
early-return/throw. + regression test.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Codex re-gate 2 found a remaining CORE clobber variant: a renderSessionList() already
in flight BEFORE the switch (background poll fetching the OLD profile's /api/sessions)
would resolve after the skeleton appears but before the switch's own renderSessionList()
bumped _renderSessionListGen — so it passed the generation guard, cleared the skeleton
flag in _applySessionListPayload, and painted stale rows.
Fix: _invalidateSessionListRenders() bumps _renderSessionListGen + clears
_pendingSessionListPayload / _renderSessionListQueuedRequest, called at switch start
BEFORE showSessionListSkeleton(). Any outstanding pre-switch response is now stale
(its captured _gen < current) and discarded by the existing guard; deferred/queued
applies are dropped. + regression test asserting the helper exists and runs before the
skeleton.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Reworked from the Codex re-gate (rebased onto fresh master so #4730's gateway-poll
work is preserved, not reverted). Two CORE races + a test-window break:
(1) Skeleton-clobber (sessions.js): renderSessionListFromCache() now bails while
_sessionListSkeletonActive — _allSessions still holds the PREVIOUS profile until
/api/sessions resolves, so a gateway-poll/SSE/timer/cache render mid-switch would
repaint the wrong profile's rows over the skeleton. _applySessionListPayload clears
the flag right before painting (fresh data in hand; superseded responses already
discarded by the generation guard); the fetch-failure path and switch-failure catch
clear it too so it can't strand. NO signature change (kept renderSessionListFromCache()
and _applySessionListPayload(sessData,projData) so the ~10 static tests that anchor on
those markers stay green).
(2) Workspace wrong-profile tree (workspace.js + panels.js): an empty-session switch
reuses the same session_id, so loadDir()'s id guard can't reject a stale pre-switch
/api/list. Added _wsTreeGen generation token + bumpWorkspaceTreeGen(), bumped
UNCONDITIONALLY at switch start (even panel-closed, since loadDir('.') still runs),
captured in loadDir() and re-checked after both awaited /api/list points.
(3) test_issue1116 proximity window 5000->6500 (applyBotName now ~char 5618, still fires).
+ regression tests: skeleton-flag bail/clear lifecycle, ws generation token, failure
restore clears flag; updated #1785 stale-session guard test for the compound condition.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
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.
Addresses @rodboev + greptile review of #4671:
- panels.js switchToProfile(): pass timeoutToast:false to the /api/profile/switch
POST so a superseded or transient-but-successful switch no longer pops api()'s
generic 'Request timed out' error toast; the generation-guarded catch handler is
now the single source of truth for switch failure.
- panels.js: re-check _switchGen after the awaited renderSessionList() in the
in-progress-session branch so a rapid second switch can't have its workspace
skeleton cleared / a stale toast popped by a slower earlier switch (mirrors the
no-messages branch guard).
- style.css: group labels now fade in via skeletonFadeInDim (ends at opacity:.5)
instead of the shared skeletonFadeIn (ends at opacity:1 with fill-mode:both),
which was overriding their intended resting 50% opacity.
- 4 new static-source regression assertions locking the three fixes; CHANGELOG
Unreleased entry updated (fixed-shape skeleton note + the three review fixes).
Builds on Nathan's switch-failure workspace-skeleton-clear fix (which already
covered Greptile P1). Remaining two:
- [P2] Removed dead _clearSessionListSkeletonFlag() — the flag is reset inline in
renderSessionListFromCache(); the helper had no call site.
- [stagger] The .skeleton-row:nth-child(N) fade-in delays skipped most rows
because group-label siblings are interleaved with rows in the DOM (positions
shift: rows land at child 2, 4-6, 8-11, so 9/10/11 got no delay). Replaced the
CSS :nth-child rules with an inline per-row animationDelay set in JS (rowIndex
* 0.025s, capped 0.2s) so every row gets its correct stagger regardless of
group structure.
Tests: +1 behavioural (every row carries a real inline stagger delay; last row
not skipped). 26 pass.
The success path clears a stranded workspace-tree skeleton when the new
profile has no bound workspace (both switch branches), but the catch block
only restored the tree `if (S.session && S.session.workspace)` — with no
else. So a profile-switch failure while the workspace panel is open and the
(still-current) previous session has no workspace left the up-front skeleton
shimmering forever: the same skeleton-strand class the Codex/Opus gates
targeted, in the one path they didn't cover. Mirror the success-path
no-workspace handling in the failure path. Adds a static regression
assertion that the catch block clears the workspace skeleton.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On long sidebars over the virtualization threshold, showSessionListSkeleton()
cleared the DOM but left the data-session-virtual-* window state + the queued
scroll RAF active. A pending _scheduleSessionVirtualizedRender() could then
repaint the PREVIOUS profile's cached rows over the skeleton before the new
/api/sessions response landed (SILENT regression Codex caught).
Fix:
- showSessionListSkeleton(): cancelAnimationFrame(_sessionVirtualScrollRaf) and
delete the data-session-virtual-{total,start,end,filter,activeAnchor} markers
so no scroll-driven render targets the skeleton; the real render rebuilds them.
- _scheduleSessionVirtualizedRender(): early-return while
_sessionListSkeletonActive is true.
Tests: +2 static assertions; driver mock gains dataset + RAF stubs. 24 pass.
Codex (CORE regression): the parallelized loadDir('.') started BEFORE the
_switchGen stale-switch guard; empty-session switches reuse the session id, so a
superseded switch's /api/list could paint the wrong profile's tree. Reverted to
guard-before-workspace-render ordering (the up-front skeletons already give the
immediate cross-surface feedback; the parallel gain was marginal).
Opus findings:
- [blocker] Workspace skeleton stranded permanently when the new profile has no
bound workspace (loadDir skipped → nothing clears #fileTree). Added
clearWorkspaceTreeSkeleton() (only empties when a skeleton is present) and call
it on the no-workspace path in BOTH switch branches.
- [low-med] Session-list skeleton stranded if an inline-rename input or row
action menu was open (renderSessionListFromCache early-returns before the DOM
swap). Dismiss _renamingSid + closeSessionActionMenu() before showing the
skeleton.
- [low] No-op self-switch flashed skeleton→restore (activateCurrentProfile
doesn't pre-check). Added an early-return when name === S.activeProfile.
Tests: +4 static assertions for the self-switch guard, rename/menu dismissal,
no-workspace skeleton clear, and the clear-helper definition. 22 pass.
The initial sheen (15% sheen / 9% base, 1.4s) was too subtle to read as
animation. Bump to a 5-stop gradient (22-24% sheen / 8% base, 1.25s), define
explicit light-mode --skeleton-base/--skeleton-sheen tokens (was relying on
fallbacks only), and give the tree glyph the same shimmering gradient so it
animates with the bars. prefers-reduced-motion still disables the sweep.
Show polished loading skeletons the instant a profile switch begins instead of
leaving the previous profile's conversation list and workspace file tree visible
until their fetches resolve (~1s).
- static/style.css: theme-token skeleton system (single-line session rows with
group labels + timestamp bar; tree rows with glyph + name + size bar); subtle
shimmer sweep; dark-mode tokens; prefers-reduced-motion fallback.
- static/sessions.js: showSessionListSkeleton() mirrors real sidebar anatomy
(group headers + single-line rows); _sessionListSkeletonActive flag cleared on
real render.
- static/workspace.js: showWorkspaceTreeSkeleton() mirrors the file-tree row.
- static/panels.js switchToProfile(): clear stale content + show both skeletons
up front; parallelize the independent list + workspace refreshes (Promise.all);
restore real content from cache on switch failure so a skeleton never strands.
Verified live: switch fires -> skeleton instant (0 stale rows) -> resolves to new
profile's real rows. Polished across 1920/1440/1280/390 + dark mode (screenshots
pending Nathan's visual review before gate+PR).
Overlays live pending_started_at/updated_at/last_message_at onto cached /api/sessions rows
(when newer), sorts active/running sessions first then by freshest effective timestamp, applies
the same running-first order in the frontend sidebar renderer, and groups date buckets with the
same runtime-aware timestamp (no stale header on resumed running sessions). routes.py +
sessions.js + 81-line test. Composes with #4680 cache-freeze + #4597 (98 session-list tests pass).
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
1-line CSS: adds touch-action:manipulation + -webkit-tap-highlight-color:transparent to
button/.icon-btn/.send-btn/.approval-btn/[onclick] inside the touch/compact media block
(@media max-width:1399.98px), removing iOS Safari's ~300ms double-tap-zoom delay so taps
fire on first touch. Added a regression test pinning the rule.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
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>
Removes the unreliable _isVirtualKeyboardLikelyOpen() viewport-shrink heuristic (flaked on
iOS Safari + some Android where the keyboard doesn't shrink vv.height >120px). Mobile
Enter=newline now keys solely on matchMedia('(pointer:coarse)') && !_hasFinePointerCoexisting()
— a more reliable software-keyboard-only signal. Hardware keyboards on tablets keep desktop
send behavior; Settings override preserved. Fn fully removed (no dangling refs). 2 new tests.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: neaucode-bot <neaucode-bot@users.noreply.github.com>
* stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master
REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot
diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master
(authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_
state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is
additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none;
does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off.
71 mobile-layout+swipe tests pass.
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
* fix#4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior
* fix#4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener
Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed
interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for
taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller
under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch
listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open.
Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new
non-interactive design. 71 mobile tests pass.
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Re-staged from the contributor's rebased head (0cee92c9) which independently converged on
the same 5-test-file class-tolerance fixups I'd made — shipping the contributor's exact
version, not my private variant. Tags md/markdown/mdx fenced blocks with md-source-block
class in both renderers; CSS wraps them while code/diff/csv keep horizontal-scroll. 90
renderer-family tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: TomBanksAU <TomBanksAU@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>
#4584 (fixes#4295): scroll re-pin requires deliberate downward intent (deltaY<-30 unpin
threshold, re-pin only if not user-unpinned OR bottomDistance<=80, reset counter) so reading
mid-stream isn't hijacked back to bottom. ui.js only + 5 scroll-regression tests (4 existing
assertions adjusted, all green; new test_issue4295_scroll_pin_reentry).
#4583 (fixes#4553): mobile transcript overflow-x:hidden + max-width:100% + word-break so wide
content can't pan sideways. style.css only + own test. Disjoint files. 32 scroll-family tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Converged after deep bounce cycles: Finding A (production regression — skipped the
state.db source correction before the CLI filter, dropping WebUI sessions with stale-CLI
sidecar flags) FIXED via _apply_sidebar_state_db_overrides(result) before the routes.py
filter on both index + full-scan paths; Finding B (flaky lineage test) FIXED via hermetic
_persisted_session_ids_snapshot fixture. 16 own tests deterministic. Code byte-faithful
from PR head e1a3ecf22eca.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: santastabber <santastabber@users.noreply.github.com>
_decorateTransparentEventRow populates the existing .tool-card-preview span from a
new quiet helper _transparentToolSummary(tc) — built from the call's TARGET
(path/command/query/skill via _toolVisibleTargetLabel), NOT the raw result JSON.
Restores the collapsed one-liner the #4411 preview-suppression dropped for the
transparent view (which overrides the row name back to the bare tool name, so it
had neither the action-label target nor a preview). Idempotent across status
re-decoration. Covers live + persisted/reload (shared render path). Code
byte-faithful from PR head ce7e5f394360. 5 own tests + 454 related pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: nesquena-hermes <nesquena-hermes@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>
#4644: close mobile sidebar drawer after a rail-click panel switch (was left open).
#4645: animate drawer open/close with transform:translateX instead of left offset.
Disjoint product files (panels.js vs style.css); both add tests to test_mobile_layout.py
in distinct functions (no conflict). Mobile-only, desktop unaffected. 60 mobile-layout
tests pass. Code byte-faithful from PR heads.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
UI enable/disable of installed manifest extensions via a bounded fail-safe
extension-overrides.json state file. Safe boundary: no manifest edits, no asset
fetches, no sidecar proxy, no extension-owned routes. /api/extensions/toggle is
auth+CSRF gated; anchored _EXTENSION_ID_RE (no path traversal); atomic UTF-8 write
under _EXTENSION_STATE_LOCK; 32KiB/512-id DoS bounds; disabled override suppresses
asset/sidecar injection end-to-end. Nathan greenlit the extensions-phase-2 concept.
Code byte-faithful from PR head 8769b4a3902e. 49 own tests pass.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: santastabber <santastabber@users.noreply.github.com>