## Release v0.51.258 — Release HZ (stage-r6)
Fresh-arrival low-risk pair (both @rodboev, landed in the last sweep window).
### Fixed
| Issue | Fix |
|-------|-----|
| #3597 | The "update available" banner now shows from **any panel** (Settings → System "Check now", etc.), not just the Chat view — it was positioned inside the chat surface so it only rendered there. |
| #3592 | Under Simplified Tool Calling, an assistant turn with **thinking but no tool calls** now renders that thinking inline on settlement instead of burying it in an empty collapsed activity group. |
### Review fix absorbed (Codex)
#3592's inline-render `continue` skipped the activity-group creation that carried the turn's `data-turn-duration`, but the footer still suppressed the "Done in …" duration for any `assistantThinking` turn → thinking-only turns silently lost their duration display. Fixed: footer duration is now suppressed **only** for turns that actually build an activity group (`toolCallAssistantIdxs.has(mi)`), so thinking-only inline turns keep "Done in …". + regression test.
### Gate
- Full pytest suite: **7631 passed, 0 failed**
- ESLint: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES (duration-drop) → fixed → **SAFE TO SHIP**
### Sweep note
#3603 + #3604 (sidebar CLI-session classification, same author/area) were **not** included — they assert contradictory models for a sidecar-less `source='cli'` recovery row; flagged on both PRs for the author to reconcile.
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
## Release v0.51.254 — Release HV (stage-r2)
Phase-2 medium wave 1 — 4 PRs (UI/mobile/cancel fixes + an un-held model dedup).
### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3528 | @franksong2702 | Render partial tool calls after cancel — interrupted turns keep their `_partial_tool_calls` rows in the transcript + fallback tool-cards. (Codex confirmed it stays render-only, not forwarded to the provider API.) |
| #3550 | @lurebat | Android offline recovery soft-reattaches the live stream instead of hard-reloading the page on a transient background/disconnect. |
| #3479 | @mvanhorn | iOS Safari no longer snaps the conversation to the top when a handoff/compression card is inserted mid-stream or on `refreshSession()`. |
| #3478 | @JayC-L | **Un-held:** named custom providers (`@custom:name:model`) dedup against bare model IDs without regressing Ollama multi-colon tags (`qwen2.5:7b-instruct-q4`). Only `@custom:` IDs strip the two-segment prefix. |
### Hold-sweep note
#3478/#3489 was held yesterday for an Ollama multi-colon-tag regression risk (a blanket `lastIndexOf` would lose the model). The author pushed a scoped fix (only `@custom:` IDs use `lastIndexOf`); I verified `_normId` in node against the regression cases — Ollama bare tags are preserved. Un-held + shipped.
### Gate
- Full pytest suite: **7588 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — #3552 partial-tool-calls verified render-only (no `_API_SAFE_MSG_KEYS` leak / no 400-on-strict-provider, the v0.50.251 #1375 trap); #3551 no EventSource double-subscribe; #3541 no regression vs the #3525 scroll-follow shipped in v0.51.253; #3489 no over-dedup.
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Co-authored-by: lurebat <lurebat@users.noreply.github.com>
Co-authored-by: mvanhorn <mvanhorn@users.noreply.github.com>
Co-authored-by: JayC-L <JayC-L@users.noreply.github.com>
## Release v0.51.245 — Release HM (stage-q17)
Small UX bug-fix.
### Fixed
| Issue | Author | Fix |
|-------|--------|-----|
| #3338 | @rodboev (#3502) | **Messaging sessions (Telegram, Discord, WeChat, …) now show their platform source badge in the chat-pane topbar**, not just the sidebar. The topbar badge was gated on `is_cli_session` (intentionally `false` for messaging sources), so it vanished once the session opened. Gate removed; a recovered native session stamped `source_label:"WebUI"` stays un-badged. Reuses the existing `.topbar-source-badge` styling — no new chrome. |
Picked #3502 over the duplicate **#3499** (same issue/files) — #3502 adds the `WebUI` self-source suppression and a stronger regression test. #3499 closed as superseded with credit.
### Gate
- Full pytest suite: **7543 passed, 0 failed** (first run hit the known boot-cascade flake — 376 connection-refused across 29 files; clean on re-run, as expected for a JS-only change)
- ESLint: CLEAN · browser-smoke: CLEAN · 8 source-contract tests pin the fix
- Codex (regression): **SAFE TO SHIP** — both renderers (panels.js + ui.js) fixed consistently, WebUI-suppression correct, read-only suffix intact, `textContent` XSS-safe
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
## Release v0.51.244 — Release HL (stage-q16)
UX-approved direction via Telegram (workspace drag-drop polish you requested). All 4 drag-drop flows verified live in-browser.
### Added
| PR | Author | Feature |
|----|--------|---------|
| #3402 / #3424 | @pamnard | **Drop OS files/folders onto a specific workspace folder row or breadcrumb** to upload into that directory (not just the current dir). OS folder drops are traversed (`webkitGetAsEntry`/`readEntries`) preserving nested structure. Uploads via the existing `/api/workspace/upload` (no new backend). |
### Fixed
- **Composer drop-zone jank**: dragging a workspace file (or OS file) over the composer footer rendered a translucent overlay that let the textarea/chips/icons bleed through and collide with the hint text. Now a clean, fully-opaque box with a single centered **context-aware** label — *"Drop to insert workspace reference"* (workspace file → `@path` insert) vs *"Drop files to attach"* (OS file → message attach).
- **Drag-drop handler coexistence (CORE, caught in review)**: #3424's OS-upload binding assigned `el.ondrop` on folder rows, which **overwrote** the drag-to-move handler from #3422 (also `el.ondrop`) — silently breaking move-to-folder (the ws-path drop fell through to the composer as an `@path` insert). Fixed by binding the OS-upload handlers via `addEventListener` so they compose; each handler gates on its own drag type.
### Drag-drop matrix — all verified LIVE in-browser (real drag→drop, asserted on disk)
| Flow | Result |
|------|--------|
| OS image → composer footer | ✓ attaches |
| workspace file → composer footer | ✓ inserts `@path` |
| workspace file → workspace folder | ✓ moves on disk (report.md → docs/) |
| OS file → workspace folder | ✓ uploads into target folder |
### Scope note
#3424's PR branch carried the OLD pre-hardening `_handle_file_move`. Applied **frontend-only** — master's hardened move backend (v0.51.243, TOCTOU/symlink fixes) is untouched (Codex confirmed no `api/routes.py` diff).
### Gate
- Full pytest suite: **7542 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): CORE handler-clobber → fixed → **SAFE TO SHIP**
Co-authored-by: pamnard <pamnard@users.noreply.github.com>
## Release v0.51.243 — Release HK (stage-q15)
UX-approved direction via Telegram (workspace feature you named). Backend security hardened through 3 Codex rounds.
### Added
| PR | Author | Feature |
|----|--------|---------|
| #3402 / #3422 | @pamnard | **Drag a file or folder in the workspace tree onto another folder row (or breadcrumb segment) to move it** within the workspace. New `POST /api/file/move`. Drop handlers use `stopPropagation` so the composer `@path` drag (#1097) and OS-file upload-drop (#3411) are unchanged. |
### Verified
- Live end-to-end: `notes.txt` → `docs/` confirmed on disk; legit moves return the correct `new_path`.
- 13 tests (incl. folder-into-self/descendant guard, existing-target collision, and 3 security regressions).
### Security hardening absorbed (3 Codex rounds — all fixed + regression-tested)
1. **TOCTOU symlink race on destination**: a path-based `source.rename(dest)` could be raced by swapping `dest_dir` to an external symlink between validation and rename. Now opens both parent dirs via the workspace-anchored `open_anchored_fd` (openat + `O_NOFOLLOW`, same helper as the upload hardening) and uses `os.rename(leaf, leaf, src_dir_fd=…, dst_dir_fd=…)` with an fd-based collision check; path-based fallback only where `dir_fd` is unsupported.
2. **Symlinked workspace root** returned a confusing 400 after a successful move — returned `new_path` now computed against `ws_root.resolve()`.
3. **Symlinked source entry**: `safe_resolve` follows the final symlink, so moving `link.txt` would move its *target* and dangle the link — now rejected via no-follow `lstat` on the lexical path.
### Gate
- Full pytest suite: **7530 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES ×3 → all applied + tested → **SAFE TO SHIP**
Co-authored-by: pamnard <pamnard@users.noreply.github.com>
## Release v0.51.240 — Release HH (stage-q12)
Mobile UX bug-fix — UX-approved via Telegram.
### Fixed
| PR | Author | Fix |
|----|--------|-----|
| #3470 | @cnogrin | On mobile/touch, you can now **swipe up to stop streaming auto-scroll**. Previously the view snapped to the bottom on every token with no way to read earlier content while a response streamed — `_recordNonMessageScrollIntent()` only detected upward intent on the wheel path (`e.deltaY`), and touch events have no `deltaY`. Now tracks `_touchStartY` on `touchstart` and treats a `touchmove` that drags the finger down >8px (= scroll up into history, `scrollTop` decreases) as upward intent, setting the same `_messageUserUnpinned` flag the wheel path + scroll listener use. |
### Why this is safe for existing installs
- **Only ADDS a touch-unpin path** — wheel + desktop behavior completely untouched, no existing branch modified.
- New `touchstart`/`touchend`/`touchcancel` listeners are **passive + capture-only** (they only write `_touchStartY`), so they can't interfere with existing touch handling.
- Net effect for users: mobile users *gain* the ability to scroll up during streaming (which was simply broken before). No one's working flow changes.
### Absorbed on review (Codex CORE MUST-FIX)
The contributor's gesture sign was inverted (`dy<-8` = finger up = *follow* the stream), which would have unpinned in the wrong direction. Corrected to `dy>8` to match the existing scroll listener's `movedUp` semantics; fixed the comment; strengthened the regression test to pin the gesture direction.
### Gate
- Full pytest suite: **7503 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint runtime gate: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES (inverted sign) → fixed → re-reviewed **SAFE TO SHIP**
- Regression test `tests/test_issue3470_touch_unpin_streaming_scroll.py` (verified to fail against master)
UX-approved via Telegram (mobile touch-gesture behavior, no visual/layout delta to screenshot).
Co-authored-by: cnogrin <cnogrin@users.noreply.github.com>
* Release v0.51.229 (stage-p13): /model never silently snaps versioned name to -tier variant (#3368, @nesquena-hermes)
Agent-authored, nesquena-APPROVED. Rebased onto current master. Both _findModelInDropdown
(ui.js) and _bestModelMatch (commands.js) reject a prefix-snap when the typed target ends
in a version digit and the longer option's extra text is a variant/tier suffix (.pro) rather
than a version continuation (.digit). Adds _nearestModelSuggestion + 'did you mean?' toast.
34 tests pass (14 new #3368 + 20 regression: #1188 fuzzy + #3360 collision).
* fix(commands): /model did-you-mean toast renders suggestion + single quotes (#3368 review)
Live-render review of the approved #3437 caught two toast-assembly bugs in cmdModel:
(1) t('model_did_you_mean') was called WITHOUT the suggestion arg — model_did_you_mean
is a (m)=>... template that t() invokes, so it rendered 'did you mean "undefined"?';
fixed to t('model_did_you_mean', suggestion). (2) no_model_match already ends with an
opening quote, so '"${args}"' doubled it ('No model matching ""deepseek-v4""'); fixed
to '${args}"'. +4 source-assertion regression tests. Verified live: toast now reads
'No model matching "deepseek-v4" — did you mean "deepseek/deepseek-v4-pro"?'.
* fix(commands): slash-qualified versioned no-snap falls through to suggestion (#3368 Codex CORE)
Codex review found a 2nd no-snap layer the version guard missed: for a slash-qualified
versioned query (e.g. 'xiaomi/mimo-v2.5') whose only near catalog entry is a rejected
tier variant ('xiaomi/mimo-v2.5-pro'), cmdModel's cross-provider /api/session/update
fallback would silently persist the invalid model + 'Switched to...'. Now gated on
!versionedNoSnap (_looksLikeVersionedModel(bare) && a near suggestion exists) so it falls
through to the 'did you mean?' toast; genuinely off-catalog providers (no near variant)
still direct-update. Verified live: '/model deepseek/deepseek-v4' no longer switches, shows
suggestion toast. +1 regression test.
---------
Co-authored-by: nesquena-hermes <[email protected]>
* feat(composer): replace mobile config-button sliders icon with context-usage ring (#3062, @NottheGuy007)
Squashed net diff of #3062 (the PR branch's tip commits were deletes of files not
present in our tree). Replaces the composerMobileCtxBadge text badge with an SVG
progress ring (ctx-arc + ctx-num) showing real-time context-window usage: ring
fill via stroke-dashoffset, centered percentage, color-coded green<=50%
orange<=85% red>85%. Ring resets to 0%/green on new session.
* feat(activity): add 'expand activity feed by default' appearance setting (#3080, @AJV20)
Squashed net diff of #3080. Adds a Settings -> Appearance checkbox
(activity_feed_expanded_default, default off) to expand new Activity disclosures
by default; preserves manual per-turn collapse/expand (explicit toggle still
wins); live 'Waiting on model' rows explain what the agent is doing before/after
tool calls. i18n keys for all locales.
* docs(changelog): v0.51.226 — context-usage ring (#3062) + activity-feed default-expand setting (#3080)
* test(mobile): update touch-target test for #3062 ring (badge removed)
#3062 replaced the composerMobileCtxBadge text badge with the SVG context-usage
ring (composerMobileCtxRing) but left 3 stale references in tests/test_mobile_layout.py.
The full suite caught test_mobile_composer_primary_controls_keep_touch_friendly_sizing
asserting the removed .composer-mobile-ctx-badge CSS rule + #composerMobileCtxBadge
element. Updated the assertion to the new ring: confirm composerMobileCtxRing exists,
the old badge is fully gone (not dangling), and the ring SVG is aria-hidden so it
stays a decorative overlay that doesn't steal the config button's 44px touch target
(which is still asserted via .composer-mobile-config-btn above). 56/56 mobile-layout
tests pass. Codex: no production JS dereferences the removed badge.
---------
Co-authored-by: nesquena-hermes <[email protected]>
v0.51.218 fixed getModelLabel() (the visible chip) for URI-scheme model IDs but
left the same first-segment-slash-strip bug in the matching/dedup paths:
api/config.py _norm_model_id + _get_label_for_model, and static/ui.js
_normalizeConfiguredModelKey. For gpt://folder/model/latest those treat the
path slashes as provider delimiters, mis-normalizing the model-identity key
(the #3360 collision class, for URI ids). Adds a URI-scheme guard to all three
so the scheme is not stripped. Kept v0.51.218's getModelLabel (more thorough
than #3436's variant — it extracts the model name vs returning the whole id);
took #3436's backend + _normalizeConfiguredModelKey halves with backend/frontend
parity tests.
Co-authored-by: b3nw <b3nw@duck.com>
Codex re-check: degenerate URIs still leaked — gpt://folder123 returned the
authority, gpt://folder123/${MODEL} returned the placeholder. Removed the _all[0]
authority fallback and guarded the literal-last-path fallback against placeholders;
a URI with no usable model segment now falls back to the raw id. Added regression
cases for gpt://folder123 and gpt://folder123/${MODEL}.
Codex gate found two edges in the first cut: (1) the candidate segment list
included the URI authority, so gpt://folder123/v4 and .../latest returned the
folder; (2) _isVersionTail matched any digit-leading segment, dropping a real
model named 2026-model. Fix: build path segments from AFTER the authority only;
tighten the version-tail regex to pure version tokens (latest/stable/v4/1.2),
not mixed names; fall back to last-usable (non-placeholder) path segment so the
authority is never returned. Added edge-case regression tests.
#3366 changed getModelLabel() to strip only the first /-segment (fixing #3360
multi-slash proxy IDs). That regressed URI-scheme IDs like Yandex
gpt://${FOLDER}/deepseek-v4-flash/latest — indexOf('/') lands inside the ://
and leaves /${FOLDER}/... path junk in the composer model chip. Detect a
scheme:// id, drop scheme+authority, and take the last meaningful path segment
(skipping ${...} env-var placeholders and bare version tails like latest).
Non-URI multi-slash IDs keep the #3360 first-segment-strip behavior unchanged.
Node-driven regression test covers the URI case + the #3360 non-regression.
Capture a same-session force-reload hint (loaded renderable/message counts,
known count, truncation flag) BEFORE clearing the in-memory transcript, so the
authoritative reload requests a width that preserves what was loaded instead of
collapsing a long session to the default 30-message tail window mid-read.
Same-session force reloads render with preserveScroll. Resolves conflict with
the shipped #3306 carry-forward snapshot by keeping both (complementary).
Closes#3239.
Co-authored-by: viraatdas <viraatdas@users.noreply.github.com>
When a custom/proxy provider serves models whose IDs share the same base
name across vendor prefixes (e.g. vendor_a/deepseek/deepseek-v4-pro vs
vendor_b/deepseek/deepseek-v4-pro), several normalization functions use
split('/').pop() (or split('/')[-1]) which discards all segments except
the last. This causes three user-facing symptoms: (1) clicking one
model selects a different colliding model, (2) configured-model badges
attach to the wrong dropdown entry, and (3) the model-chip label in the
composer bar is truncated to just the base model name.
Root cause: all three callers take only the last slash-segment instead
of stripping only the first (provider) segment and preserving the
remaining vendor hierarchy.
Fix 1 — _findModelInDropdown (static/ui.js): Move the exact string match
before the provider-aware normalized match. Previously, when all models
share the same provider ID (common with LLM proxy setups), the normalized
match returned whichever colliding option appeared first in DOM order,
even though an exact match existed.
Fix 2 — _normalizeConfiguredModelKey (static/ui.js) and _norm_model_id
(api/config.py): Replace split('/').pop() / split('/')[-1] with a first-
segment-only strip (regex on frontend, split('/',1) on backend), matching
the strategy already used by _findModelInDropdown's norm lambda. This
prevents multi-slash IDs from colliding in badge assignment and the
configured-entry dedup set. Additionally, strip colon-qualified provider
prefixes (e.g. custom:name/) before the slash strip so badge-key variants
like 'custom:llm-proxy/opencode_go/model' merge correctly with the bare
'opencode_go/model' in the configured section dedup.
Fix 3 — getModelLabel (static/ui.js) and _get_label_for_model
(api/config.py): Same split('/').pop() to first-segment-strip change so
the composer-bar model chip and backend label preserve vendor context
(e.g. shows 'opencode_go/deepseek-v4-pro' instead of 'deepseek-v4-pro').
Verification: 9 new regression tests (test_issue3360) covering exact-
match priority, multi-slash normalization, and backend/frontend parity.
Updated 1 existing test (test_norm_model_id_trailing_empty_guard) that
asserted the old split('/').pop() pattern. All 25 related tests pass.
AI Usage: Gemini (gemini-2.5-pro), via Antigravity IDE, pair-programmed.
(cherry picked from commit a454fecd2b3a83f7da34473c883be069171aeac9)
Both advisors (Opus MUST-FIX + Codex SHIP-ONLY-WITH-FIXES) caught that the Edge
TTS playback was broken despite the settings selector working:
1. ui.js _playEdgeTts used new Audio('/api/tts?text=...') — a GET — but /api/tts
is POST-only (405) and registered only in handle_post. The per-message speaker
button + auto-read silently failed in edge mode, and the GET leaked message
text into the query string/access log. Rewritten to POST JSON + blob object
URL (mirrors the working boot.js path), and now surfaces server errors
(503 not-installed, 429 rate-limit) via toast instead of silent dead air.
2. boot.js hands-free Edge audio was a local var never assigned to the shared
_playingEdgeAudio handle, so stopTTS() (from _deactivate) couldn't stop it.
Now registered + cleared on end/error.
3. Test isolation: the _tts_limiter function-attribute singleton persisted across
the whole suite, flaking 2 of my endpoint tests in the full run. Converted the
reset to an autouse fixture (before+after each) + unique per-test client IPs.
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.
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.
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.
#3330 Fix pinned chat scroll after message rebuild
Co-authored-by: jianongHe <jianongHe@users.noreply.github.com>
#3311 fix: reject inline math when $ is followed by a digit (currency)
Co-authored-by: toanalien <toanalien@users.noreply.github.com>
Increase MESSAGE_UPWARD_INTENT_MS from 450ms to 2000ms to fix a race
condition where the user scrolls up during streaming, pauses to read
for >450ms, and then gets snapped back to the bottom.
The root cause: after the 450ms upward-intent window expires, DOM layout
changes from the streaming markdown parser (smd), tool card insertions,
or code re-highlighting can trigger scroll events that the handler no
longer recognizes as user-initiated. When the resulting position lands
inside the 250px near-bottom zone for two consecutive samples, the
hysteresis counter re-pins (_scrollPinned=true) and the next streaming
token's scrollIfPinned() call forces scrollTop to the bottom.
With a 2-second window, the user's upward intent persists through typical
streaming DOM churn. Downward scrolling and the scroll-to-bottom button
are unaffected — movedUp requires top < _lastScrollTop-2 which is false
for downward movement regardless of the intent timeout.
Refs: #1360 (macOS momentum protection), #1731 (direction-aware unpin)
1. routes.py: hoist a single case-folded path helper (_norm/_within_ci/_equal_ci)
used for ALL deny + carve-out comparisons (consistent macOS/Windows safety).
2. routes.py: split the deny into (a) dir-based denies that ALWAYS fire (even
inside the active workspace — so a workspace overlapping a state dir cannot
expose sessions/memories), and (b) filename denies relaxed only by the
carve-out. Fix the over-block: a workspace that is a proper DESCENDANT of a
Hermes root (e.g. STATE_DIR/workspace) is a legit project workspace and keeps
the carve-out; only a root-itself / ancestor / $HOME / profiles / state-subdir
workspace disables it.
3. ui.js: move the bare file:// media-stash pass after the raw-<pre> stash too,
so file:// inside a raw <pre> block stays literal (not just fenced/inline code).
1. api/routes.py: case-fold /api/media deny filename + dir containment checks
(os.path.normcase + casefold) so STATE.DB / Sessions/ cannot bypass the
state/secret deny on case-insensitive filesystems (macOS/Windows).
2. static/ui.js: move the bare file:// media-stash pass to run AFTER fenced-block
and inline-code stashing, so a file:// inside a code block / backtick span
stays literal text instead of becoming an auto-loaded <img>. The MEDIA: stash
keeps its first-position precedent.
Adds behavioral renderer tests (real renderMd via node) for fenced + inline code
file:// staying literal, bare file:// becoming media, and anchors keeping the
link path. Closes the last Codex review items for #3234.
When tc.snippet === tc.preview (common for no-progress tools where
both are set to the same result_snippet), the detail block would show
identical content as the header. Skip the detail block in this case.
This also handles the reload-path where derived entries get snippet
populated but no preview, so displaySnippet falls back to the snippet
content for the header — same deduplication applies.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
#3020: Sync viewed-count in the polling path for actively-viewed sessions
so navigating away doesn't show a stale unread dot. Defensive clear of
completion-unread marker in _setSessionViewedCount.
#2973: Clear elapsed-timer attributes and interval when a live compression
card transitions from running to done, preventing the orphan timer from
overwriting the completed card state. Guarded by active-session check.
Tool card duplication: Route tool_complete result to tc.snippet (detail)
instead of tc.preview (header) to prevent identical content appearing in
both the card header and expanded detail section.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
In multi-step turns (assistant -> tool_call -> assistant -> tool_call ->
final assistant), only the turn-final assistant bubble was rendering the
'jump to question' navigation button because the gate keyed on
isTurnFinalAssistant. Intermediate assistant bubbles that *do* have a
resolvable question raw-index lost the affordance entirely.
Switch the gate to 'show whenever questionRawIdxByAssistantRawIdx has a
target for this rawIdx', which is the actual precondition for the button
being meaningful. Turn-finality was a proxy for 'has a question target'
that under-covered multi-step turns.
No template/CSS change needed; _questionJumpButtonHtml already handles
the rawIdx-or-undefined contract.
- Stop provider-qualified or slash-qualified model inputs from fuzzy-matching a
sibling catalog entry when the exact requested model is missing from the
curated picker list.
- Preserve the raw typed selection so uncatalogued provider-routed models
fall through to a temporary custom option instead of silently snapping to a
nearby curated model.
- Add generalized regression coverage for provider-qualified uncatalogued
picker selections.
_cliToolResultSnippet truncated to 200 chars while the backend's
_tool_result_snippet uses 4000. This caused tool card details to be
more aggressively truncated after session reload than during live
streaming.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>