The English-only addition broke the locale-parity tests (es/zh/ja/ru/tr/ko all
enforce full key coverage vs English). Added translated uploading/uploaded to
it/ja/ru/es/de/zh/zh-Hant/pt/ko/fr/tr so every locale covers the new keys.
The PR referenced t('uploading') and t('uploaded') in static/workspace.js with
JS fallbacks but never defined the keys, so test_static_literal_i18n_keys_exist_in_english_locale
(the i18n-key existence gate, also run in CI) went red. Added both to the English
locale (and the Korean block's English-placeholder upload keys for consistency).
Opus review SHOULD-FIX on the upload surface:
- Add _MAX_ARCHIVE_MEMBERS=10000 cap in extract_archive (both zip + tar loops):
a tiny archive with millions of members slips under the byte cap but can
exhaust inodes/fds. Trips before extraction, cleaned up via the existing
rmtree-on-exception. Regression test added.
- Bound the extraction-dir collision-suffix loop (was while-True) to 1000 tries.
Other Opus SHOULD-FIX items (member-count #1 done; #2 done) filed as follow-up
or N/A: same-field multi-file collapse doesn't apply (frontend sends one request
per file); .tar.gz stem cosmetic.
Two issues caught by the PR's own tests against the out-of-process test server:
1. Dedup reporting bug: after a filename collision the file was correctly
written to e.g. report-1.pdf, but the JSON response reported the ORIGINAL
name (safe_name) — now reports dest.name. (Real user-facing bug.)
2. Zip-bomb cap was untestable: the test monkeypatched _MAX_EXTRACTED_BYTES in
the pytest process, which has no effect on the separate server process where
extraction runs. Made the cap env-configurable (HERMES_WEBUI_MAX_EXTRACTED_MB,
read at call time via _max_extracted_bytes(); defaults to 10x upload cap),
set it to 5MB in the conftest server env, and rewrote the test to upload a
compressible archive that genuinely extracts past the cap. Also asserts no
partial extraction dir is left behind.
Plus lint: unused field_name loop var -> _field_name, unused os import in test.
Nathan feedback: the download icon read as 'oddly placed' on dark generated
images. Geometry was already correct (8px inset on the image corner) — the
issue was low contrast: a flat rgba(0,0,0,.55) button blends into a dark image.
Add a subtle 1px white border, soft shadow, and 3px backdrop-blur so the button
reads as a clear chip-on-image regardless of the underlying pixels (the standard
treatment for on-image controls). CSS-only.
The .msg-artifact-image span was stretching wider than the image (inline-block
in a block context filled the line), so the absolutely-positioned download
button (right:8px of the wrapper) floated ~240px to the right of the image.
Add width:fit-content + max-width so the wrapper hugs the image; the button now
correctly overlays the image's top-right corner. line-height:0 removes inline
descender gap.
Nathan feedback round 2:
- #3220: generated images were rendering at the 120x90 upload-thumbnail size
(too small for the subject of the message). Now render at natural aspect
ratio up to 360px (max-height 360, responsive max-width), lightbox preserved.
- #3223: drop the per-item subtitle/description lines from the session action
menu — show only icon + label (VS Code / browser / ChatGPT pattern). The
description is preserved as a hover tooltip (title=). Makes the menu ~40%
shorter, less crowded, and structurally less prone to viewport clipping.
Opus iter2 SHOULD-FIX (both non-blocking, applied for cleanliness):
- esc() the media_download title/aria-label so a future translator's quote
can't break out of the attribute (defense-in-depth).
- Remove the now-dead media_open key from all 11 locales + the ui.js fallback
dict (the Open button was dropped in the clean-image redesign).
#3220: redesign generated-image rendering from a permanent bordered card
(filename + Open/Download buttons) to a clean inline image with click-to-zoom
lightbox + a hover/focus-revealed Download overlay, matching ChatGPT/Claude/
Gemini. Drops redundant Open (lightbox already covers it). Tests updated.
#3337: fix two-tone code background — Prism's prism-tomorrow theme styled the
parent <pre> gray while the <code> was navy var(--code-bg), so dark theme showed
a gray frame around navy code. Override BOTH .preview-code[class*=language-] and
its <code> to var(--code-bg) (mirrors the chat code-block fix at .msg-body pre).
#3223: fix action-menu clipping — the new Regenerate-title row made the 9-item
menu tall enough to overflow the viewport bottom when opened on a top-anchored
row at short viewports (e.g. 1280x720). _positionSessionActionMenu now clamps
the menu within both viewport edges and caps max-height with scroll when the
menu is taller than the viewport.
Maintainer fix on stage (browser-test catch): Prism.highlightElement()
propagates the language-* class onto the parent <pre>, so previewing a
.css file then a .txt file rendered the plain text with CSS grammar.
Strip any stale language-* from #previewCode before each render and only
call highlightElement when a language was assigned. Adds regression test
(tests/test_issue3337_workspace_preview_highlight.py) and bumps the
_openSessionActionMenu scan window in test_1466 to cover the new
Regenerate-title action lines (#3223).
Closes#3106. Adds /api/session/title/regenerate endpoint + session-action
menu item. Preserves chronology (touch_updated_at=False), guards read-only
and imported sessions, syncs to state.db when Insights sync enabled.
Maintainer refinement (Opus SHOULD-FIX): scope the is_imported guard to the
regenerate action only instead of broadening the shared _isReadOnlySession()
helper, which also gates rename/pin/archive/move/fork. Matches the backend
403 guard. Test updated to assert the scoped shape.
Codex+Opus gate findings on the profile-scoping PR:
1. panels.js: retag S.session.profile on ANY profile switch (was inside the
if(data.default_model) block, so model-less profile switches left a stale chip).
2. sessions.js: project-picker filter now mirrors the server's root-alias
tolerance (default <-> renamed-root) so a server-approved 'default' project
isn't hidden for a renamed-root session.
3. routes.py /api/projects/create: validate the optional client-supplied profile
via _PROFILE_ID_RE before stamping (was trusting raw client input -> could
create hidden cross-profile rows). Updated the PR's string-assertion test.
Supersedes the v0.51.199 proximity-re-pin (#3330) and the #3250 upward-intent
timeout with a sticky-unpin model (ChatGPT/Claude/Codex behavior): scroll up =
stay put until you return to the bottom or click the scroll-to-bottom control.
Reconciled against the shipped #3330 code: removed the now-dead
_recentMessageUpwardIntent reference from the #3319 rAF retry, kept the
load-time -Infinity intent-init fix, kept the pinned-only >500 catch-up.
Co-authored-by: pamnard <pamnard@users.noreply.github.com>
Codex+Opus regression-gate finding: _isRecoveryControlMessage /
_streamRecoveryControlMessage fell back to matching
provider_details_label==='interruption details'. But a GENUINE 'Response
interrupted' card (Stop button, real provider crash) carries that exact label,
so the filter would drop a real user-facing interruption from the transcript on
the next render/restore — the inverse of the #3300 data-loss class. Require the
explicit server-set recovery_control marker; keep only the two fully-anchored
synthetic-text matches for pre-marker backward-compat. Adds a node-driven
regression test (revert-verified) asserting a label-only interruption card and a
real user turn stay visible while marker + strict text are filtered.
#3313 applies the ephemeral-field carry-forward at all 5 wholesale S.messages
replace sites (loadSession/_ensureMessagesLoaded/_loadOlderMessages/
_ensureAllMessagesLoaded/startGatewaySSE), renaming the replace RHS from
msgs/next to _msgsToAssign/_nextToAssign. These pre-existing tests pinned the
old literals; updated to match the new RHS (or made LHS-agnostic) while
preserving the invariants they protect (bump-before-replace, session-switch
guard, shorter-transcript guard).
Codex follow-up finding: _lastMessageUpwardIntentMs/_lastNonMessageScrollIntentMs
initialized to 0, so _recentMessageUpwardIntent() returned true for the first 2s
after load (performance.now() < MESSAGE_UPWARD_INTENT_MS=2000) even with no user
scroll — which would disable the new #3319 retry guard during initial load. Sentinel
-Infinity makes 'no event recorded yet' read as no-intent. Also tightens the
pre-existing scrollIfPinned/settle callers that read the same helpers.
Codex regression-gate finding: the new requestAnimationFrame retry in
_setMessageScrollToBottom re-asserted scrollTop=scrollHeight + _scrollPinned=true
on the next layout frame unconditionally, so a user who scrolled up in that ~16ms
window during streaming would be snapped back and re-pinned, bypassing the
scrollIfPinned early-return guards. Re-check _messageUserUnpinned / upward-intent /
non-message-scroll-intent / !_scrollPinned inside the retry; on hit, only release
the programmatic-scroll latch and bail.
Codex regression-gate finding: since Session.save() no longer auto-clears the
truncation_watermark, the unconditional 'timestamp > watermark' skip in
merge_session_messages_append_only became a permanent ceiling — a genuine future
state.db-only row (recovery/compaction, missed by the sidecar) would be silently
dropped from /api/session and model-context reconstruction forever. Only apply the
above-watermark skip while the sidecar has NOT advanced past the watermark. Preserves
the #2914 deleted-tail filtering (revert-verified). Adds 2 regression tests.