Salvaged from #4946 by @neaucode-bot. Rebuilt fresh on master (repro-confirmed),
without regressing hidden-tab completion state or lineage-parent unread dots.
Co-authored-by: neaucode-bot <neaucode-bot@users.noreply.github.com>
Adversarial security re-gate on the rebased head found 4 issues; fixed:
1. [CORE] api/shares.py public snapshot could leak credentials/paths/tool
payloads. The sanitizer relied on redact_session_data (gated on the
user-toggleable api_redact_enabled) — disabling it exposed API keys, a dict
assistant content was stringified as raw tool data, and session.workspace
survived in transcript text. Now the per-message sanitizer applies ALWAYS-ON
credential redaction (_redact_fn_cached, force=True, ignores the setting) +
scrubs known session/workspace/home paths, and refuses to stringify non-text
structured content. Title is force-redacted too. Regression test proves the
boundary holds with api_redact_enabled=False.
2. [SILENT] api/models.py: my rebase wrongly kept anchor_activity_scenes in
METADATA_FIELDS, moving 250-480KB scene bodies before messages and undoing
master's metadata fast-path (#5854). Removed; 17 #5854 tests pass again.
3. [SILENT] static/index.html: the Share/StopSharing/ExportHTML button triplet
was double-applied by the rebase (duplicate IDs → only the first wired).
Deduped to one each + a count assertion test.
4. tests: fixed the stale Session.load-after-/api/session/new setup (master
keeps new sessions memory-only until first message).
Also (UX, found in the visual flow sweep): a clipboard-copy failure after a
SUCCESSFUL share showed a red 'Share failed' toast and skipped opening the page.
The copy is now isolated — the share succeeds, the page opens, and the toast
shows the link if the clipboard write fails, never a false failure.
Co-authored-by: MicoRobot.
Rebased 363 commits forward (branch-point 2026-07-05). Applied the PR's code
diff cleanly across 16 files; resolved api/models.py additively (both master's
process_wakeup_pause/anchor_scene_index fields AND the PR's share_token/
share_created_at fields kept in the Session constructor, metadata list, and
to_dict). CI workflow (.github/workflows/tests.yml) intentionally NOT taken from
the stale branch — master owns it (the branch's apparent removal of Playwright/
Office steps was a PAT-workflow-scope revert; rebase restores master's CI).
Co-authored-by: MicoRobot. Concept approved by maintainer (opt-in public share,
2026-07-11). Note: 4 dynamic share tests fail identically on the original
unrebased head (pre-existing test-env Session.load fragility, not rebase-caused);
to be resolved during the security re-gate in a proper sandbox.
Gate review (Codex) reproduced two SILENT regressions in the generation-token
ownership change, both validated against PR head + merge base:
1. sessions.js:1428 — the no-op early-return did NOT fire for a redundant
same-session load (currentSid===sid, !_loadingSessionId was false because a
keep-stale refresh was in flight). The redundant loadSession() then bumped
_loadSessionGeneration and CANCELLED the authoritative in-flight refresh —
stale rows remained while message_count advanced, so later polling could not
detect them. Restore the pre-existing behavior: also return when the in-flight
load is for the SAME sid (!_loadingSessionId || _loadingSessionId===sid).
2. sessions.js:2781 — _ensureMessagesLoaded's finally cleared the
transcript-width reload hint unconditionally, so a stale same-session response
collapsed a long session to the default tail window. Guard the cleanup with
_ownsLoad() so only the owning load clears the hint.
Both fixes retain the PR's cross-session, same-session-rejection, and INFLIGHT
race behavior (47 tests pass incl. the new isolation suite + the 4 edited files).
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
A valid session:// deep link to a session owned by a DIFFERENT Hermes
profile used to look identical to a deleted session (404 -> frontend
self-heals away). Now GET /api/session returns a structured 409
session_profile_mismatch envelope (error/code/session_id/profile ONLY,
no transcript) ONLY when the owning profile is KNOWN, and loadSession()
catches it, switches to the owning profile, and retries once. Truly
missing/deleted or legacy None-profile sessions keep the 404 self-heal.
Gate fixes applied (Codex + Fable):
- Codex CORE: added a post-await stale-load guard after
_switchProfileForSessionLoad so a navigation during the switch can't
hijack the UI back to the old session.
- Fable Finding 1: only emit 409 when _session_profile is truthy;
a None-profile (missing/legacy) session under a non-default active
profile now keeps 404 instead of a useless profile=null 409 (which
skipped self-heal + spun the SSE reconnect against a dead sid). Both
detail branches. + 2 regression tests.
- Fable Finding 2: _switchProfileForSessionLoad now clears the sidebar
skeleton + re-renders from cache on switch-POST failure (mirrors the
#4671 canonical-switch catch), then rethrows, so a failed switch can't
strand the sidebar on the skeleton.
Reconciled tests/test_issue1611_session_profile_filtering.py (4 tests) to
the 409 contract while preserving the no-leak boundary assertion.
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Two iOS-PWA bugs when tapping an old/large session:
1) closeMobileSidebar() ran AFTER the slow `await _openSidebarSession()` (3-15s
for large sessions on WKWebView), so the sidebar stayed open with only a tiny
spinner. Moved closeMobileSidebar() to run synchronously BEFORE the await in
the tap handler + lineage/child open paths for instant feedback.
2) _loadingSessionId race: an SSE-triggered idle reload
(_scheduleActiveSessionIdleReload / refreshActiveSessionIfExternallyUpdated,
fed by idle-reconcile/poll/visibility/focus) could overwrite _loadingSessionId
and silently cancel an in-flight session switch. Both paths now skip while a
different session's loadSession() is in flight.
Contributor stage; gate-pass. Co-authored-by: luperrypf <luperrypf@users.noreply.github.com>
Codex re-gate SILENT: a retry whose fetch is invalidated mid-flight (e.g. a
profile switch) left the button stuck as an inert 'Retrying…' with no request in
flight — the stale catch returns before _showSessionListLoadError and the
.finally() bails when the skeleton removed the old button. _invalidateSessionListRenders()
now clears the pending retry markers (retrying, _retryFailedFocus) so the next
repaint shows an actionable idle Retry. Regression test added (fails without the
fix); test harness extracts _invalidateSessionListRenders + declares its render-gen
globals.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Fable UX gate SHIP-WITH-UX-FIXES (non-blocking). Applied all: (1) role=status +
aria-live=polite on the error note so retry failure/restore is announced to
screen readers; (2) pending state uses aria-disabled (not the disabled property)
so the button retains keyboard focus, with click/keydown guards keeping it inert;
(3) on a failed retry, the fresh rebuilt Retry button reclaims keyboard focus
(_retryFailedFocus flag, rAF-guarded) so keyboard users aren't dropped to <body>;
(4) label uses a true ellipsis 'Retrying…' matching the app's other progress
string. CSS styles [aria-disabled=true] alongside [aria-busy]. Tests updated +
new a11y assertions; 8/8 pass, node --check clean.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
The sidebar's load-error Retry button behaved like a raw idle browser button while
the next slow fetch was pending, so it looked broken/unresponsive. Retry now paints
an immediate pending state (label 'Retrying...', disabled, aria-busy), keeps the
retry state local to the error block via _renderSessionListLoadErrorNote(), and
lets the existing success/failure paths replace the error object when the request
settles (restoring 'Retry' on failure). Also adds proper styling.
Contributor stage; gate-pass. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
A reactivated older conversation wasn't bumped to the top / into Today until a
manual refresh when the viewing tab was backgrounded/unfocused: refreshSessionList
early-returns on document.hidden unless force=true, and the sidebar SSE closes on
blur — but the in-flight/pending coalescing only preserved the refresh REASON
string, dropping the opts (force/refreshActive). So a coalesced 'sessions_changed'
refresh lost its force flag and got skipped on a hidden tab. Now the pending
request preserves merged opts (force/refreshActive OR-merged), and a dedicated
resume-refresh path forces a catch-up when the sidebar regains focus, so
cross-device recency re-sorts land without a manual refresh.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Opus found the exact-match signature fails open in the COMMON case: the server's
last persisted draft is often a PREFIX of the submitted text (Enter-to-send within
400ms cancels the pending debounced save), so matching only the submitted text
misses it and the prefix restores (the literal 'tail of the already-submitted
message' symptom). Root fix (Opus-recommended): capture BOTH signatures at send —
the remembered SERVER-draft signature (_composerDraftPayloadSignatureForSid, read
before the reset) AND the submitted-payload signature — and suppress a restore
matching EITHER. The prefix IS the remembered server draft; the remembered files
went through the same lossy serialization so they self-match; a genuinely new
cross-tab draft matches neither and restores. Subsumes the file-canonicalization
fix (kept as belt-and-suspenders). Suppression stores signatures[] not signature.
Regression tests: stale-prefix suppressed (Opus Finding 1), file round-trip
self-matches (Codex Finding 2), cross-tab-different still restores. Test harness
updated to model send-time server draft vs later restore draft as distinct.
Co-authored-by: ruizanthony <ruizanthony@users.noreply.github.com>
Codex found a SILENT edge in the payload-signature fix: a live browser File
JSON-serializes to {} when the draft is POSTed to the server, so a text+attachment
send's suppression signature (computed from the rich File) never matched the
restored server draft (files:[{}]) — suppression cleared and the just-sent tail
could repopulate for the attachment case. Fix: _composerDraftFilesForPersist()
canonicalizes files to a stable serializable shape (name/path/size/type/lastModified)
used at BOTH _saveComposerDraft/_saveComposerDraftNow (persist) AND
_composerDraftPayloadSignature (sign), so the sent signature matches the
round-tripped server draft. Regression test self-demonstrates lossy-mismatch vs
canon-match.
Co-authored-by: ruizanthony <ruizanthony@users.noreply.github.com>
Codex re-gate found 2 more false-skips (DOM state OUTSIDE the signature path):
- CORE: profile-switch skeleton stays visible if the new payload signature
matches the last one (empty/same-shaped profile) — skip fires over the skeleton.
- SILENT: stale 'Could not load conversations' banner persists when a transient
fetch failure heals with identical rows.
Fix: capture _hadSessionListSkeleton + _hadSessionListLoadError BEFORE clearing
them, and disallow the identical-signature skip when either was set. + regression
test. (messages.js:1462 finding is a base-comparison artifact for #5472/#5479/#5484,
not in #5467's diff.)
Codex gate found the render-skip signature omitted fields the sidebar renders:
- CORE: pending/running (active_stream_id/has_pending_user_message/pending_started_at)
- SILENT: attention dots
- SILENT: source/read-only/worktree/lineage/child/model/profile + _sidebarReferenceSessions
Any omitted field = a false-skip = stale sidebar.
Root-cause fix (most reliable): serialize the FULL applied row objects +
_sidebarReferenceSessions instead of a hand-picked allowlist — inherently
complete, can't rot when a new rendered field is added. Rewrote the
'ignores non-display fields' test into positive full-serialization + no-strip
regression assertions.
Add a dedicated visibilitychange hook for the 60s relative-time refresh poll so skipped hidden-tab ticks repaint immediately when the tab becomes visible, even when no streaming poll is active.
The 30s streaming poll (renderSessionList -> GET /api/sessions + full sidebar
rebuild) and the 60s relative-time refresh (renderSessionListFromCache -> full
DOM rebuild) ran unconditionally, including in hidden background tabs, so a
backgrounded window kept fetching and rebuilding the sidebar it could not show.
Guard both interval callbacks with document.hidden and refresh the list on
visibilitychange when the tab is shown again, mirroring the existing gateway
poll. Visually neutral while the tab is visible; a hidden tab simply stops
burning network and DOM churn and re-syncs the moment it is shown.
Verified: node --check + ESLint runtime guard clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 30s streaming poll and the gateway poll re-applied /api/sessions and ran a
full sidebar DOM rebuild (list.innerHTML='' + one node tree per row) on every
tick, even when the payload was byte-identical to what was already on screen —
the common case on an idle sidebar.
Add a signature over the applied rows + projects + coarse display state in
_applySessionListPayload and skip renderSessionListFromCache() when it matches
the last rendered signature and no entrance animation is pending. A streaming
row's fields advance each poll so its signature changes and it still repaints;
client-only display toggles (search, active, density, select, lineage
expansion) render through their own paths. The per-refresh INFLIGHT cleanup is
still run on the skip path. The 60s relative-time refresh and all other render
triggers call renderSessionListFromCache directly and are unaffected.
Output-neutral: a skip only happens when the render would produce the same DOM.
node --check + ESLint runtime guard clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sidebar session-list GET had 502/503/504 retry logic, but it was gated
to cold boot only. Once `_sessionListHasLoadedOnce` flipped true, every later
refresh (profile switch, focus/visible/reconnect) shipped no retryStatuses, so
a transient 502 during an nginx->backend restart window failed on the first
attempt and left the sidebar stale until a hard reload (Ctrl+F5).
The session-list GET is idempotent, so retrying it is safe unconditionally.
This moves `retries:1` + `retryStatuses:[502,503,504]` into the base request
options so they apply to every refresh, while keeping the larger boot timeout
(`_SESSION_LIST_BOOT_TIMEOUT_MS`) and `retryTimeouts` boot-only. The api()
wrapper in static/workspace.js already retries when the error status is in
retryStatuses, so no other change is needed.
Extends the existing source-string regression test to assert the retry
options are now always present (declared before the boot-only gate) while the
boot path still carries the timeout + timeout retry.
Reported and root-caused by @weidzhou, who traced the boot-only retry gate.
Co-authored-by: weidzhou <weidzhou@users.noreply.github.com>
Follow-up to v0.51.819 which removed the export button from the composer footer
(it tripped the footer overflow-collapse, hiding model/workspace labels). Per
design consult (Fable) + ChatGPT/Open-WebUI convention, export now lives in the
per-conversation sidebar three-dot action menu, right after Duplicate:
- exportSessionHTML(session) parameterized (was active-session-only); Settings
button now wired ()=>exportSessionHTML() and still exports the active session
- new _appendSessionExportHtmlAction() added after Duplicate + in the read-only
early-return branch (export is non-mutating; imported sessions re-exportable)
- exports THAT row's conversation, not just the active one
- download icon added to ICONS; session_export_html[_desc] added to 14 locales
- Settings HTML button retained as secondary data-management entry
#5306 (flicker): while a parent WebUI session is the active/streaming session,
a linked delegate subagent child that transiently reports message_count===0
between /api/sessions polls was dropped by _sidebarRowHasVisibleMessages BEFORE
_attachChildSessionsToSidebarRows could stack it under its parent. It never
entered sessionsRaw, so the row vanished, then reappeared on the next refresh
once its list metadata caught up — the flicker. Extend the visibility predicate
with an active-parent exception (mirroring the existing active-session
exception): a child_session whose parent_session_id is the active sidebar
session stays visible even at message_count 0. Scoped to the active parent so
truly-empty unrelated sessions are still hidden.
#5305 (orphan): a delegated subagent child whose WebUI parent is filtered out of
the current render (project/profile/source scope) was promoted to a contextless
top-level "Subagent Session" orphan. Suppress cross-surface child_session rows
whose parent row is absent from the render instead of orphaning them, mirroring
the existing archived-hidden-parent suppression (#4293). The genuinely-external
parent case (messaging/CLI) still orphans via the parentIsExternal branch.
Tests: tests/test_5306_subagent_sidebar_flicker.py (7 tests) locks both
invariants and the no-regression guards, executing the real sessions.js helper
regions under node like the existing lineage tests.
Add a default-OFF 'project_quick_create_buttons' setting that gates the
per-project '+' quick-create buttons on sidebar project chips. The chip
render site only calls _attachProjectQuickCreateButton when the new
window._projectQuickCreate runtime flag is enabled, so the buttons do
not appear unless the user opts in via Settings.
Mirrors the large_text_paste_as_attachment opt-in plumbing (defaults,
allowlist, settings collect/restore/save, window hydration at both sites,
checkbox row, i18n across all locales).