Commit graph

2477 commits

Author SHA1 Message Date
nesquena-hermes
57a8467266 stage: merge #5499 (aa201b1550) 2026-07-05 21:21:35 +00:00
nesquena-hermes
baddaccca1 stage: merge #5632 (8fc3179a8a) 2026-07-05 21:21:34 +00:00
nesquena-hermes
76639b34f0 release: compression-recovery action — one-click focused continuation on compression_exhausted (#5538)
Folds in gate fixes: map the stale-card 409 to a neutral info toast + retire the card (finally-guard so it stays retired), + consumed-state CSS. Full gate: Codex SAFE, Fable SHIP-UX, suite 12140/0, browser-smoke clean.

Co-authored-by: Frank Song <franksong2702@gmail.com>
2026-07-05 21:14:53 +00:00
Frank Song
d284fcd034 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-06 02:24:07 +08:00
Frank Song
d1ed4a0b49 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-06 01:44:58 +08:00
nesquena-hermes
79e485a038 stage: merge #5535 (c1aa45fceb) 2026-07-05 17:43:11 +00:00
nesquena-hermes
74d807a07c stage: merge #5638 (f657014aa3) 2026-07-05 17:32:22 +00:00
allenliang2022
f657014aa3 fix(webui): stop mobile scroll jump-back — scope content-visibility + hold unpinned position (#5637)
Two linked causes of the mobile scroll jump-back, both confirmed by the
maintainer against origin/master in #5637.

Primary (CSS): the @media (pointer: coarse) block applied a flat
`content-visibility: auto; contain-intrinsic-size: auto 1px` to every .msg-row.
Off-screen assistant rows can be multi-thousand px (tool-result / long answers),
so a 1px reservation collapses their off-screen height; scrollHeight lurches down
by tens of thousands of px on a long transcript, the browser force-clamps
scrollTop, and the viewport is thrown to the top. A flat intrinsic-size cannot
both reserve tall rows and keep scrollHeight stable for iOS flick momentum (why
1px was chosen), so content-visibility is scoped to the short, size-predictable
user rows; assistant rows keep real rendered height. Mirrors the existing
#liveAssistantTurn opt-out pattern.

Secondary (JS): _restoreMessageScrollSnapshotSameFrame fell back to an absolute
snapshot.top when the semantic anchor restore failed. During streaming the live
activity-scene refresh fires this every tick; for a reader scrolled up into
history (unpinned), snapping to a stale absolute top nudges the viewport backward
by an amount that grows with scrollHeight. Now: hold position (no scrollTop write)
for the unpinned/anchor-failed case and let the browser's own scroll anchoring
keep the reader put. Pinned/near-bottom readers keep the tail-relative restore.

Adds tests/test_issue5637_mobile_scroll_clamp_jumpback.py (mutation-checked:
reverting either fix fails the matching assertion).
2026-07-05 22:52:59 +08:00
allenliang2022
ad1843f31b fix(webui): recover virtual-scroll compensation when the anchor row is recycled
_compensateScrollForMeasurementDelta re-found its viewport anchor only by
rawIdx and bailed with a bare 'if(!row) return' when that row was recycled
out of the render window. On long sessions containing multi-thousand-px
turns, a large scroll delta recycles the anchor row, so the estimated->measured
topPad swap (a scrollHeight change of tens of thousands of px) hit scrollTop
uncompensated and threw the viewport to the top -- a mobile scroll jump-back
that did not reproduce on desktop (where the anchor row stays rendered).

Fall back when the rawIdx row is gone: (1) re-find the same row by its stable
data-session-msg-idx and compensate by its measured offset delta; (2) if that
row is also unrendered, compensate by the top-spacer (topPad) height delta
captured before the re-render. _captureMessageViewportAnchor now records
topPadBefore so the fallback has a reference.

Adds tests/test_issue4346_vscroll_recycled_anchor_jumpback.py (Node-harness
behavioral tests). All fail on the pre-fix bare-return and pass on the fix;
existing vscroll suites stay green.

Relates to #4346.
2026-07-05 19:53:47 +08:00
nankingjing
8fc3179a8a fix(#3874): auto-refresh sidebar session list after new session creation
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-05 18:43:20 +08:00
wangyichen
aa201b1550 chore(#5499): drop redundant post-divider _applyVoiceModePref call
The success branch had two adjacent calls:

  if(typeof window._applyVoiceModePref==='function') window._applyVoiceModePref();   // pre-divider (the actual fix)
  _applyComposerFooterVisibilitySettings();
  if(typeof window._applyVoiceModePref==='function') window._applyVoiceModePref();   // post-divider (redundant)

The post-divider one ran after _applyComposerFooterVisibilitySettings,
so it couldn't affect the divider the PR was meant to fix. It looked
like a defensive double-tap, but the function is idempotent and the
pref source (localStorage) doesn't change between the two synchronous
calls — so it was just dead code that made the diff look bigger than
the actual semantic change.

Drop it. Net PR footprint is now strictly: 'use the window-qualified
name and apply once before divider recompute in both IIFE branches.'

Re-tested: 26/26 pass on test_issue4598_composer_control_visibility
+ test_issue1488_composer_voice_buttons.
2026-07-05 15:55:36 +08:00
wangyichen
edce177202 fix(#5499): use window._applyVoiceModePref — bare name is closure-local
Greptile P1: _applyVoiceModePref is declared with 'function' inside the
voice-mode IIFE (line 1205) and exported to window._applyVoiceModePref
at line 1212. The bare identifier _applyVoiceModePref is closure-local
and not visible from the settings-load IIFE branches at line 2997 and
line 3056 — so `typeof _applyVoiceModePref==='function'` was always
'undefined', the guard was permanently false, and the pre-divider call
silently never fired.

This undid the entire boot-order fix: divider was still computed
against the pre-sync #btnVoiceMode visibility, so the round-2 SILENT
race the PR was meant to close remained.

Fix: use window._applyVoiceModePref in both branches, matching the
already-correct call on line 2999 (which worked only by accident —
it ran AFTER _applyComposerFooterVisibilitySettings so it had no
effect on the bug, but at least it ran).

Tests: pytest tests/test_issue4598_composer_control_visibility.py
       tests/test_issue1488_composer_voice_buttons.py — 26/26 pass.
2026-07-05 15:53:56 +08:00
silent-reader-cn
9ff0298b50 fix(#5451): apply voice-mode pref before recomputing composer divider
Clean rebase of silent-reader-cn's #5499 (rebase-first; kept both boot-order
_applyVoiceModePref-before-recompute AND the voice-mode onchange recompute + autosave).

Co-authored-by: silent-reader-cn <silent-reader-cn@users.noreply.github.com>
(cherry picked from commit eeea3868d3b7f069dbf61470d787541b95af9181)
2026-07-05 15:53:56 +08:00
Frank Song
08fc9bd95e Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 15:44:44 +08:00
nesquena-hermes
c78d5083ad fix(#5459): dedup steer upload on retry + remove only delivered files by identity (gate)
Codex gate found two SILENT defects, fixed:
1) Failed-steer RETRY re-uploaded the same File objects (uploadPendingFiles ran
   again with clearPending:false). Added _steerUploadCache keyed by ownerSid +
   file signature so a retry with the same staged set reuses the uploaded paths;
   invalidated when the staged set changes (signature miss) or on accepted steer.
2) Accepted steer cleared ALL S.pendingFiles, dropping files staged during the
   upload/API await. Now removes ONLY the delivered snapshot by object identity,
   preserving newly staged files.

(The owner-session draft is still cleared on accepted steer — clearing the
delivered session's draft is correct even after a mid-upload switch; the existing
test asserts this.) Tests updated for the identity-based removal + new
cache/dedup guard. 69 steer/composer tests pass, node --check clean.
2026-07-05 07:10:23 +00:00
Frank Song
cc816828d2 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 15:03:26 +08:00
nesquena-hermes
ceea2438eb fix: include attachments in steer submissions
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>
2026-07-05 06:50:34 +00:00
nesquena-hermes
7ad9707c4b fix(ui): align mobile sidebar close (X) with the new-conversation (+) button
On the mobile sidebar header the close (X) button rendered an 18px glyph — the
only 18px .panel-head-btn icon — sitting ~6px lower than the adjacent 14px
new-conversation (+) button, an inconsistent pair. Match the X glyph to 14px and
vertically center it on the ~41px panel-head row so both icons share one size and
baseline (top:calc(safe-top - 2px) keeps the existing safe-area offset so the X
still clears the notch, with no extra drawer spacing). 44x44 tap target kept for
mobile touch; mobile @media(max-width:640px) only, and the X is display:none on
desktop, so desktop is unaffected. + CHANGELOG + source-guard tests.
2026-07-05 06:40:26 +00:00
Frank Song
7f3eed0a82 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 14:03:45 +08:00
nesquena-hermes
4559ca85f0 fix(#5409): iOS PWA session-switch — close sidebar instantly + prevent _loadingSessionId race
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>
2026-07-05 05:42:51 +00:00
Frank Song
94305093d8 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 13:24:10 +08:00
nesquena-hermes
f6cc48b834 fix(#5501): clear pending retry state on render invalidation (Codex gate)
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>
2026-07-05 04:48:50 +00:00
Frank Song
1262148fc8 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 12:43:07 +08:00
nesquena-hermes
9be4048c72 fix(#5501): fold in Fable a11y hardening — live region, aria-disabled, focus restore, ellipsis
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>
2026-07-05 04:39:14 +00:00
nesquena-hermes
7349a9434f fix(#5501): improve session-list retry feedback
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>
2026-07-05 04:39:14 +00:00
nesquena-hermes
39a4d67e5c fix(#5385): increase approval card height cap on mobile
The mobile command/tool approval popup rendered too small — options didn't all fit
and required scrolling within the cramped popup to select (Android WebUI wrapper).
Raises the mobile @supports(dvh) .approval-inner max-height cap from
min(52dvh,360px) to min(60dvh,420px) so the options fit without in-popup scrolling;
still overflow-y:auto so very tall content scrolls rather than clipping. Scoped to
the mobile dvh rule; desktop unaffected. Adds a source-level guard test.

Contributor stage; gate-pass. Co-authored-by: nankingjing <nankingjing@users.noreply.github.com>
2026-07-05 04:04:02 +00:00
Frank Song
b3899d64c5 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 10:23:15 +08:00
nesquena-hermes
ef33ad44f2 fix(#5552): guard _messageViewportAnchorKeyForMessage against lone surrogates
A message whose text contained a lone UTF-16 surrogate (e.g. a truncated emoji
from a partial paste/stream) made encodeURIComponent throw URIError inside
_messageViewportAnchorKeyForMessage, crashing the viewport-anchor computation and
breaking scroll-anchor restore for the whole transcript. New _safeEncodeURIComponent
catches the URIError and rebuilds the string via a UTF-16 code-unit walk that keeps
valid high+low surrogate PAIRS intact (emoji survive) and drops only LONE
surrogates. Uses no regex lookbehind/lookahead so it parses on every browser
engine (older WebViews / Safari <16.4 lack lookbehind and would brick ui.js at
parse time).

Contributor stage; gate-pass. Co-authored-by: rumotoshino <rumotoshino@users.noreply.github.com>
2026-07-05 01:49:08 +00:00
Frank Song
440f1e6eb8 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 09:22:00 +08:00
nesquena-hermes
7ee9f78a2e fix(#5525): distinct Mermaid fullscreen toolbar icon
The Mermaid toolbar's "fit" and "fullscreen" icons rendered byte-identical (the
fullscreen path was fit's corner-brackets drawn twice). Replaced the fullscreen
glyph with a distinct corner-frame + diagonal-expand-arrows icon so the two
actions are visually distinguishable at 14px. Still wired to openLightbox; no
dependency on the old path. (The other half of #5525 — mermaid height:0 on
mobile — does not reproduce on current master; verified live at 390x844.)

Self-built (nesquena-hermes); nesquena independent review APPROVED.
2026-07-05 00:53:42 +00:00
Frank Song
a6741f1c6f Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 08:42:15 +08:00
nesquena-hermes
e09e819f08 fix(#5578): stop login next= param from nesting into itself and exploding the URL
An expired-auth 401 on the login page fed the login redirect its own address:
each of the three redirect guards (_safeNextPath in login.js,
_safe_login_redirect_path in routes.py, the api() 401 redirect in workspace.js,
_safe_login_inner_next in auth.py) validated against open-redirect but none
rejected a `next` pointing back at the login page, so every auth bounce wrapped +
re-encoded the previous login URL one level deeper — growing the URL exponentially
until the browser's length limit broke the tab. All guards now collapse/reject a
`next` whose decoded leading PATH resolves to the login route (bounded
percent-decoding up to 8 levels, fail-closed at the cap), while preserving the
existing open-redirect protections AND legitimate non-login paths that merely
carry their own `next=` query key. Length cap as belt-and-suspenders.

Self-built (nesquena-hermes); nesquena independent review APPROVED. Gate (Codex)
found + fixed an over-broad nested-next reject (regressed next-carrying paths) and
a deep-encode decode off-by-one; both root-fixed, login guards verified clean.
2026-07-05 00:12:19 +00:00
Frank Song
81d5e897e9 fix: isolate compression recovery child sessions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 07:46:43 +08:00
Frank Song
8717dcc03b Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 06:42:39 +08:00
nesquena-hermes
b36bd789ce fix(#5551): refresh sidebar recency after missed session events
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>
2026-07-04 22:34:28 +00:00
Frank Song
4deb1a9bfb Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 06:29:08 +08:00
Frank Song
9280c073ba Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 06:22:24 +08:00
nesquena-hermes
ae43091993 fix(#5550): keep Transparent Stream final replies visible after settle
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>
2026-07-04 22:09:50 +00:00
nesquena-hermes
cebfdf41f9 fix(#5541): iOS PWA streaming freeze + SSE reconnect reliability
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>
2026-07-04 21:51:38 +00:00
Frank Song
e3cf9e7099 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 05:42:10 +08:00
nesquena-hermes
4dc87e9069 fix(#5544): re-pin auto-scroll only at true bottom after scroll-to-bottom
A single scroll-up during streaming set _messageUserUnpinned=true and
scrollIfPinned() then permanently stopped auto-follow (only scrollToBottom()
cleared it) — a permanent auto-follow lockout even after the user returned to
the bottom. scrollIfPinned() now re-pins, but ONLY when the reader has genuinely
reached the true-bottom tail (<=80px) AND shows no active scroll intent
(wheel/key/touch/non-message), reusing the listener's _nearBottomCount debounce.
Proximity alone (the ~250px nearBottom band) must never re-pin — that is the
#4295 invariant. Restores the listener's <=80px true-bottom gate too.

Co-authored-by: luperrypf <luperrypf@users.noreply.github.com>
2026-07-04 21:24:17 +00:00
Frank Song
a83ffae4ac Live Stream: skip stale compression recovery fallback
Treat a cleared session-level compression_recovery payload as authoritative so old message metadata cannot intercept a later generic continuation after a successful new turn.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 04:25:22 +08:00
Frank Song
c8b79bcaa7 Merge latest master into compression recovery action
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-05 03:29:59 +08:00
Rod Boev
c85db4f3d7 fix(#5523): show running tools as running in Transparent Stream 2026-07-04 13:37:39 -04:00
Frank Song
1e9d42c053 Live Stream: harden compression recovery action
Make focused compression-recovery continuation creation idempotent by marking child sessions and reusing the existing child on repeated start requests. Also surface malformed successful recovery responses in the UI instead of silently re-enabling the action.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-07-04 22:30:57 +08:00
Frank Song
dfc0686dbd Live Stream: add compression recovery action 2026-07-04 21:42:32 +08:00
mo7al876any
c1aa45fceb i18n: translate all 664 remaining TODO-translate placeholders
Completes it, ja, ru, es, de, pt, ko, tr (zh, zh-Hant, fr, pl, vi already
had none). Terminology and loanword conventions matched to each locale
block's existing neighbors; every placeholder, key name, and the en source
block left untouched (diff is exactly 664 balanced value replacements).
node --check passes; the locale-parity suite is green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:04:23 +03:00
nesquena-hermes
6baf501dce harden #5471: dual-signature suppression (Opus root fix) — covers stale prefix + attachment
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>
2026-07-04 08:51:13 +00:00
nesquena-hermes
5a8dc91bbb harden #5471: canonicalize draft files before persist+sign (attachment round-trip)
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>
2026-07-04 08:37:15 +00:00
Agent Zero
68a692a8e0 fix(composer): scope draft restore suppression to payload 2026-07-04 08:06:33 +00:00