Commit graph

450 commits

Author SHA1 Message Date
Jamie (Hermes)
0674a0198c perf(sessions): page archived sidebar rows 2026-06-29 22:24:35 +00:00
Frank Song
47d8ac94d3 Harden connection flapping aftercare
Keep sidebar retries boot-scoped, preserve immediate source-change cache rebuilds, and tighten session stream ownership before closing duplicate SSE sockets.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-29 19:52:02 +00:00
Frank Song
cbb644da69 Reduce connection lost flapping under large session state 2026-06-29 19:52:02 +00:00
allenliang2022
860a3c2458 fix(webui): keep stale transcript mounted while hidden-tab return reloads new messages (#5177)
After a hidden interval that added persisted messages (post-turn bg-review
writes, sibling-tab writes, the just-finished main turn flushing), switching
the tab back caused refreshActiveSessionIfExternallyUpdated('visible'|'focus')
to hit remoteCount !== localCount and call loadSession(sid, {force:true}),
which synchronously did S.messages = [] before awaiting the metadata +
messages fetches. The visible result was the entire conversation transcript
blanking for the round-trip and reappearing — '对话突然消失,重刷才回来'.

sessions.js itself warns about this 'disappear and reappear' tradeoff at the
top of the remoteCount/remoteLast guard, but only short-circuits the
metadata-only branch (#5061). #5122's 4-probe staged reconnect covers a
different path (SSE error with ready_state=2 on a visible tab) and does not
apply when the SSE error arrives while visibility_state='hidden' — that
bottoms out through _deferStreamErrorIfPageHidden before the reconnect block
ever runs.

Fix:

- refreshActiveSessionIfExternallyUpdated maps the visibility/focus recovery
  reasons ({visible, focus}) to a new keepStaleUntilLoaded option, forwarded
  to loadSession. The post-stream idle reconcile and the poll/external paths
  keep their existing behaviour.
- loadSession ANDs opts.keepStaleUntilLoaded with sameSessionForceReload (so
  cross-session switches still clear synchronously — leaving a prior
  session's transcript on screen during a navigation is the original bug
  the clear was written for) and, on that path, skips the synchronous
  S.messages/S.toolCalls/_messagesTruncated/_oldestIdx clear. The new
  transcript is SWAPPED into S.messages by _ensureMessagesLoaded(sid,
  {force:true}), so the user sees old DOM directly replaced by new DOM in a
  single render frame.
- _ensureMessagesLoaded grows an opts.force escape hatch so its 'messages
  already populated' early-return cannot skip the swap when stale messages
  are still in place.

Verified live on a source 8701 build:
- before: minHtmlObserved 65874, minKidsObserved 11 — 372 samples at 16ms,
  DOM html length NEVER dipped below the pre-call value during the reload.
- loadSession was called with force:true keepStale:true reason:'visible',
  outcome 'reloaded', sMsgs 21 → 1037, after kids 11 → 134 (single-frame
  swap).

Tests:

- tests/test_issue5177_hidden_tab_blank_gap.py (new, 7 cases): source-locks
  the keep-stale guard shape, the recovery-reason map, the
  _ensureMessagesLoaded opts.force escape hatch, and that the synchronous
  clear is wrapped in if(!_keepStaleUntilLoaded).
- Updated the two existing source-lock tests whose anchor strings included
  the loadSession call signature
  (tests/test_webui_external_refresh_frontend.py +
  tests/test_tars_scroll_reset_regressions.py) to match the new
  loadSession(... keepStaleUntilLoaded:_keepStaleUntilLoaded) form. No
  behavioural assertions changed.

Local: node --check static/sessions.js OK. 122 passed across
test_issue5177_hidden_tab_blank_gap.py + the full adjacent set
(external-refresh / journal-frontend / scroll-reset / reconnect-chronology /
inflight-stream-reuse / sse-error-multi-probe / issue4295 / issue4856).
2026-06-29 17:02:09 +00:00
nesquena-hermes
a8e921d13a fix(sidebar): scope hidden archived-parent references to active project + source
The sidebar_reference_sessions payload arrives project-unscoped from
/api/sessions. _renderSidebarRowsFromRawSessions appended it to EVERY
render unconditionally, so an archived parent in project B entered a
project-A-filtered render's suppression context and silently hid a
visible project-A child/fork (rendered as []). Add
_scopedSidebarReferenceRows() to filter references to the same project +
webui/cli source bucket as the render they feed, mirroring
_partitionSidebarSessionRows. Regression test locks the cross-project
case; the existing suppression test now exercises the scoped helper.

Co-authored-by: santastabber <santastabber@users.noreply.github.com>
2026-06-29 00:21:04 +00:00
santastabber
5ed0ba6ef6 fix(sidebar): keep hidden archived parents as child references 2026-06-29 00:13:32 +00:00
santastabber
93d8907a3e Defer non-critical session switch refreshes 2026-06-29 00:53:41 +10:00
nesquena-hermes
7ec7b9c942 fix(mobile): PWA reflow guard + avoid redundant same-session refresh (salvage of #3976)
Salvages PR #3976 by ksj5131513-ops, ported fresh onto current master and
re-applied on top of #4195's external-refresh gate (the original PR predated
#3916/#4195's rework of refreshActiveSessionIfExternallyUpdated).

Two parts:

1. Avoid an unconditional same-session force reload the moment streaming
   settles. _scheduleActiveSessionIdleReload previously called
   loadSession(force:true) on every idle reconcile, which on mobile PWA caused
   a visible end-of-turn flash and could briefly restore the pane with stale
   layout geometry. It now reconciles against server metadata first via
   refreshActiveSessionIfExternallyUpdated('idle-reconcile', {ignoreStreamJustFinished:true})
   — reloading ONLY when the message count actually changed — and falls back to
   the original forced reload only if the metadata probe itself fails.

   This COEXISTS with #4195: the poll-only external-session early-return
   (if((reason||'poll')==='poll' && !_isExternalSession(S.session)) return) is
   left untouched. The idle path uses a non-'poll' reason so it never trips that
   gate, and opts (read via arguments[1], same pattern as loadSession) only
   bypasses the post-stream cooldown, not the external gate. The function keeps
   its (reason) signature and now returns a status string
   ('skipped'/'unchanged'/'reloaded'/'failed') for callers that want it.

2. Mobile viewport reflow guard for PWA visualViewport changes. When the
   on-screen keyboard / browser chrome shows or hides, the phone layout can stay
   painted against stale geometry. _forceMobileViewportReflow() toggles a
   one-frame html.viewport-reflow class (cheap GPU-promotion transform) to force
   a repaint, then resyncs the workspace panel + sidebar aria. Wired to
   visualViewport resize/scroll (debounced) where available, falling back to the
   window resize handler otherwise. Adds safe-area-inset padding to topbar,
   messages-inner, and composer-wrap on phones. Uses overflow-x:clip (NOT hidden)
   on .layout per the #4856 lesson to avoid coercing overflow-y into a scroller.

Co-authored-by: ksj5131513-ops <ksj5131513-ops@users.noreply.github.com>
2026-06-28 09:02:10 +00:00
Paladin173
a51e58efc0 fix: skip archived saved session on boot 2026-06-28 06:51:36 +00:00
nesquena-hermes
519c394dfe fix(boot): preserve typed composer input during saved-session boot restore (#5069)
On a hard refresh, boot-restore auto-opens the last session from localStorage,
but that load can finish after the user has started typing into the fresh
composer — and the saved-session draft restore clobbered the typed input.
_restoreComposerDraft already had a preserveActiveInput guard, but the boot
path never set it.

boot.js now calls loadSession(saved, {preserveActiveInput:true}); loadSession
threads it into the draft restore as
preserveActiveInput:!!opts.preserveActiveInput || (currentSid===sid&&forceReload),
preserving the prior same-session-force-reload behavior. A blank composer still
restores the saved draft (the guard only skips when active input is non-empty
and differs). opts is read via arguments[1] so the call is signature-safe.

The 4 one-line test changes refresh exact-string boot snapshots to the new call
signature; test_issue5060_boot_composer_preserve.py adds behavioral coverage
(2 assertions proven RED on master).

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
2026-06-27 20:51:46 +00:00
nesquena-hermes
4f64443f63 fix(sessions): reload active transcript only on message-count change (#5061)
The active-session external-refresh check (refreshActiveSessionIfExternallyUpdated)
force-reloaded the whole transcript whenever last_message_at/updated_at advanced.
The post-turn background skill/memory review bumps those timestamps without adding
any chat messages, so the destructive loadSession(force) (clears S.messages, awaits
a round-trip) made the open conversation visibly disappear and reappear with no new
content.

The refresh now reloads only when the message count actually changed, and treats a
timestamp-only bump as metadata (advance the local last-seen marker + refresh the
lightweight sidebar list, no transcript teardown).

Maintainer fix on top of the contributor's PR (Codex gate): the count check fires
in BOTH directions — remoteCount !== localCount, not just >. A LOWER remote count
means another tab/client truncated/undid/retried/regenerated the transcript
(/api/session/truncate, /retry, /undo all shrink s.messages and persist a lower
message_count); the original >-only gate would have treated that as metadata-only
and silently kept the stale longer transcript forever. Added a regression test for
the count-decrease case. The pending-user-message count-inflation path is excluded
by the existing busy/active-stream guards.

Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com>
2026-06-27 18:49:08 +00:00
Rod Boev
288cbb55bc fix(#5021): keep boot model budget across restore paths 2026-06-27 02:52:33 +00:00
Rod Boev
576834a195 fix(#5021): keep restored-session boot inside the shared 401 budget 2026-06-27 02:52:33 +00:00
Rod Boev
187931be8e perf(#4759): parallelize fresh sidebar boot fetches 2026-06-26 10:08:28 -04:00
Frank Song
24b6e87aea Scope active user turn dedupe to current turn 2026-06-26 11:12:50 +00:00
Frank Song
035a2609f1 Harden active user turn dedupe tests 2026-06-26 11:12:50 +00:00
Frank Song
709f133d76 Fix active user turn replay dedupe 2026-06-26 11:12:50 +00:00
nesquena-hermes
4030dca989 fix(#4927): persist loaded session.tool_calls so the cold-load fallback has a source
Codex gate (the key one): the persistedSnippetByTid fallback read S.session.tool_calls,
but loadSession() keeps the messages=0 session object (tool_calls []), and the
real messages=1 summary arrives only as the sessionToolCalls argument to
_syncToolCallsForLoadedMessages() — never copied onto S.session. So the fallback
source was empty on exactly the cold-load path it repairs. Persist
sessionToolCalls onto S.session.tool_calls in _syncToolCallsForLoadedMessages
(covering _ensureMessagesLoaded, older-message paging, and full-history loaders)
so renderMessages builds a populated persistedSnippetByTid. + guard test.
2026-06-25 19:29:06 +00:00
nesquena-hermes
57a78e9284 fix(#4728): clear stale custom:* base_url + default wins without loaded session (gate findings)
Two gate-found CORE/SILENT edges fixed on-branch:
- api/config.py: switching the main default to a different custom:* provider kept
  the OLD provider's base_url, routing New Chat to the wrong endpoint. The
  base_url-clear on provider-change now covers custom:* too (was exempted).
- static/sessions.js: newSession() skipped the configured default when S.session
  was null but the picker held stale state (e.g. after deleting the last session),
  inheriting the stale model. Default now wins over legacy picker regardless of
  loaded-session state (explicit empty-composer override still takes priority).
- Updated 2 stale source-grep precedence assertions + widened a brittle 6000-char
  newSession() slice window to 7000 (assignment block grew; intent unchanged).
- 2 new non-vacuous regression tests (custom base_url clear; precedence).

Co-authored-by: Rod Boev <rod.boev@gmail.com>
2026-06-24 20:17:29 +00:00
Rod Boev
eba089fd90 fix(#4728): preserve fallback-order guarantees for default-model routing 2026-06-24 20:09:30 +00:00
Rod Boev
dde3845171 fix(#4728): keep default-model requests on the configured provider 2026-06-24 20:09:30 +00:00
Rod Boev
d8b58da018 fix(#4728): use configured default for new conversations 2026-06-24 20:09:30 +00:00
nesquena-hermes
b9f1491de7 Merge branch 'peek-4798-v2' into gate/4798-v2 2026-06-24 06:25:59 +00:00
nesquena-hermes
edbe676b0d Merge branch 'peek-4796' into stage/rodboev-4797-4796 2026-06-23 22:19:13 +00:00
Rod Boev
c3d5d585c2 fix(#4756): keep session refresh compatible with existing picker callers 2026-06-23 17:24:50 -04:00
nesquena-hermes
a7eb7de83f Merge remote-tracking branch 'origin/master' into peek-4748
# Conflicts:
#	CHANGELOG.md
2026-06-23 17:43:38 +00:00
Rod Boev
e5aa548d67 fix(#4756): refresh stale model catalogs on session visit 2026-06-23 12:50:28 -04:00
nesquena-hermes
98e672551a Merge branch 'peek-4782' into gate/4782-sidebar-trim 2026-06-23 16:46:10 +00:00
Rod Boev
2504eb90d7 fix(#4755): honor profile default workspace on fresh sessions 2026-06-23 12:18:44 -04:00
Hermes Agent
ba6349bdcb fix: reset scroll state on session switch to prevent stale unpin blocking scroll-to-bottom 2026-06-23 17:19:50 +02:00
Rod Boev
bd72c785b5 fix(sidebar): reject cached fallback rows from the wrong hidden-filter mode (#4775) 2026-06-23 11:13:33 -04:00
Rod Boev
9296604667 fix(sidebar): keep delete fallback in the visible session scope (#4775) 2026-06-23 11:00:40 -04:00
Rod Boev
df6dba7a94 fix(sidebar): refresh project-chip payload after filter switch (#4775) 2026-06-23 10:40:40 -04:00
Rod Boev
e27b7cf042 perf(sidebar): trim hidden and empty sidebar payload rows (#4775) 2026-06-23 10:29:06 -04:00
Rod Boev
148a1bfab2 Keep source-bucket cleanup tied to real session origin 2026-06-23 07:58:55 -04:00
Rod Boev
df4cdf7f9b Prevent stale cross-bucket rows during source-tab switches 2026-06-23 07:49:46 -04:00
Rod Boev
0f5e0d71b7 Keep sidebar state helpers compatible with isolated JS harnesses 2026-06-23 07:37:42 -04:00
Rod Boev
f5f8f48006 Keep hidden source state through failed source refreshes 2026-06-23 07:06:07 -04:00
Rod Boev
efb784730a Keep sidebar source fallbacks honest under refresh failure 2026-06-23 06:51:12 -04:00
Rod Boev
534008c3fc Preserve hidden source runtime state across tab refreshes 2026-06-23 06:39:34 -04:00
Rod Boev
4ee5ef0d01 fix(#4766): harden source-tab refresh state 2026-06-23 06:21:15 -04:00
Rod Boev
73a209aca7 fix(#4766): close review gaps in sidebar source filtering 2026-06-23 06:10:16 -04:00
Rod Boev
9a5a3eb6d5 fix(#4766): filter /api/sessions by active source bucket 2026-06-23 05:56:00 -04:00
nesquena-hermes
c8ccfa93b1 stage #4451 (santastabber): bubble hidden lineage stream state
# Conflicts:
#	CHANGELOG.md
2026-06-23 06:06:23 +00:00
nesquena-hermes
505465a81e feat(#4717): empty-profile loading skeleton (phase 1.5 of #4662)
The profile-switch skeleton (#4671) always rendered a fixed content shape; for
a brand-new/empty profile that flashed a misleading 'loading conversations'
skeleton that resolved to an empty list. Track each profile's last-seen session
count (localStorage, per profile name, recorded after each non-all-profiles
render) and, when switching into a profile KNOWN to have zero sessions, render a
quiet empty-state placeholder instead of the content skeleton. Unknown profiles
keep the content skeleton (safe default); skipped under an active project/source
filter (the count is an unfiltered total). prefers-reduced-motion + light/dark
aware. Frontend-only. 7 source-structural tests; Phase-1 skeleton tests still
green.
2026-06-23 05:44:57 +00:00
nesquena-hermes
749f95d01c docs(#4671): clarify dead-assignment backstop + add four-guard contract comment (Opus polish)
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>
2026-06-23 01:38:25 +00:00
nesquena-hermes
1b0c64a604 fix(#4671): embargo also guards the fetch-failure path + setup moved inside try (Codex re-gate 4)
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>
2026-06-23 01:18:58 +00:00
nesquena-hermes
ce0ef36ab1 fix(#4671): embargo session-list renders during the switch window (Codex re-gate 3)
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>
2026-06-23 01:11:24 +00:00
nesquena-hermes
0a64d7d7c6 fix(#4671): invalidate in-flight session-list renders at switch start (Codex re-gate 2)
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>
2026-06-23 00:56:30 +00:00
nesquena-hermes
66786143a8 fix(#4671): close 2 profile-switch render races via skeleton-flag + ws generation token
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>
2026-06-23 00:46:14 +00:00