Removes the unreliable _isVirtualKeyboardLikelyOpen() viewport-shrink heuristic (flaked on
iOS Safari + some Android where the keyboard doesn't shrink vv.height >120px). Mobile
Enter=newline now keys solely on matchMedia('(pointer:coarse)') && !_hasFinePointerCoexisting()
— a more reliable software-keyboard-only signal. Hardware keyboards on tablets keep desktop
send behavior; Settings override preserved. Fn fully removed (no dangling refs). 2 new tests.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: neaucode-bot <neaucode-bot@users.noreply.github.com>
* stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master
REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot
diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master
(authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_
state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is
additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none;
does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off.
71 mobile-layout+swipe tests pass.
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
* fix#4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior
* fix#4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener
Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed
interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for
taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller
under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch
listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open.
Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new
non-interactive design. 71 mobile tests pass.
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
rodboev. Default-off; OFF path byte-identical to master (cache key 'u' + _renderUserFencedBlocks
unchanged when window._renderUserMarkdown falsy). ON routes user text through the same renderMd
SAFE_TAGS/_isSafeUrl sanitizer as assistant msgs. Nathan override-approved (likes the idea, opt-in).
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Pastes over 4000 chars or 100 lines attach as a timestamped pasted-text-*.md in
the upload tray instead of flooding the composer. Image-paste path unchanged; an
oversize paste that exceeds MAX_UPLOAD_BYTES falls through to a normal inline
paste so nothing is lost.
Co-authored-by: santastabber <204424800+santastabber@users.noreply.github.com>
- Hamburger button: 32x32 -> 44x44 tap target (iOS HIG minimum), SVG icon
20 -> 24px. flex-shrink:0 preserved; spacer keeps the title centered.
- Edge-swipe-to-open-sidebar: edge zone 28 -> 80px, trigger 72 -> 64px, max
vertical 48 -> 56px; removes the _isPwaStandalone() gate in
_onPwaSidebarSwipeStart so the gesture works in a normal mobile browser, not
only the installed PWA. Still _isDesktopWidth()-blocked, touch/pen-only,
interactive-target-excluded, and requires horizontal intent (dx>=64 &&
dx>|dy|*1.5), so scroll/tap won't trigger it.
Known tradeoff (Nathan-approved ship-as-is): in mobile BROWSER the 80px left
edge zone overlaps iOS Safari's back-swipe; degrades gracefully (PWA, the
primary mobile target, has no back gesture).
Verified live at 390x844: hamburger renders 44x44 + tap opens sidebar; a
left-edge swipe opens the sidebar in a non-PWA context. 66 existing mobile/
swipe/titlebar tests pass.
Co-authored-by: kaishi00 <kaishi00@users.noreply.github.com>
The paste handler (boot.js) bailed entirely when the clipboard contained
both text and image items, preventing image attachment in the most common
real-world scenario: right-click → Copy Image from browsers, Notes, Slack,
etc. — these almost always carry a text/html or text/plain representation
alongside the image.
Previous behavior (from #1620 fix):
- clipboard has text only → normal text paste (correct)
- clipboard has image only → attach image (correct)
- clipboard has BOTH → text pasted, image silently dropped (wrong)
New behavior:
- clipboard has text only → normal text paste (unchanged)
- clipboard has image only → preventDefault + attach image (unchanged)
- clipboard has BOTH → let text paste normally AND attach image
The key change: preventDefault() is now only called when no text is
present (true screenshot paste), so the browser handles text insertion
naturally while addFiles() runs regardless.
Fixes the regression introduced by the #1620 fix. Closes#1620.
Virtualization (#500/#4214) caused a scroll-up flicker on long sessions with
tall tool-call rows (variable-height anchor oscillation), distinct from the
stream-completion jump fixed in #4325. Per maintainer decision, flip the
'Virtualize long transcripts' preference to experimental/opt-IN (default OFF)
for everyone until the scroll behavior is reliable.
- api/config.py: virtualize_transcript default True->False; add
virtualize_transcript_optin marker (both bool-allowlisted); load_settings
force-off migration — a stored true WITHOUT the post-flip opt-in marker is
reset to off, so 100% of existing users land on off; an explicit opt-in
(marker present) is honored.
- boot.js / panels.js: default-off semantics (===true), persist the opt-in
marker on enable, checkbox not pre-checked.
- index.html / i18n.js (en): relabel experimental + flicker warning.
- Tests: rewrite the #4325 toggle assertions for the opt-in contract + 4 new
migration tests (unset/stale-true/explicit-optin/marker-without-true).
Phase B (root-cause scroll-stability) tracked separately. Thanks @b3nw.
Adds a Preferences setting 'Virtualize long transcripts' (default ON, opt-out).
When disabled, _currentMessageVirtualWindow returns a full non-virtualized window
so the entire transcript renders at once (also short-circuits the virtual
re-measure loop). Server-persisted (virtualize_transcript bool, default True),
hot-applies via renderMessages on toggle. i18n in all 13 locales.
Pairs with @rodboev's #4328 stream-end anchor scroll fix (cherry-picked into
this branch) to fully resolve#4325.
* stage-3955: blur composer on Escape for j/k nav + IME guard (Opus nit)
* stage-3955: update test to match IME-guarded blur branch
* Release OX: blur composer on Escape for j/k message nav (#3952)
Self-rebased TomBanksAU's #3955 onto v0.51.436; full Codex+Opus+suite gate.
Escape now blurs the focused composer so j/k session-nav works, after higher-
priority Escape actions run; dropdown-Escape stops propagation; IME-composition
guarded (Opus nit). Also restored the v0.51.434/435 CHANGELOG headings that an
earlier batch left folded under v0.51.436 (hygiene fix).
Co-authored-by: TomBanksAU <TomBanksAU@users.noreply.github.com>
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: TomBanksAU <TomBanksAU@users.noreply.github.com>
* fix(#3988): default show_cli_sessions to true for new installs, grandfather existing OFF
Combines the best of #3997 (rodboev) and #4222 (Sanjays2402):
- config.py default flipped True so NEW installs surface CLI/TUI/Telegram/
Discord/Hermes One sessions in the sidebar without hunting for the toggle.
- load_settings() backfill grandfathers ESTABLISHED installs OFF: when the saved
settings predate the flip (onboarding_completed True + key absent), pin False so
an existing user's sidebar doesn't silently change. Fresh installs get True.
- Default-true hydration at every read site (config.py / boot.js / panels.js
checkbox) via the !==false idiom — no config-ON-but-UI-OFF mismatch (#4006 class).
- 10 tests: config default, bool-key round-trip, grandfather matrix
(fresh/mid-onboarding/established-absent/explicit-true/explicit-false/
save-preserves-grandfather), and both frontend hydration sites.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
* fix(#4217): guard /api/file/delete and /api/file/rename against symlink targets
* chore(release): stamp v0.51.427 (Release ON) for #3988 + #4217
* chore: drop unused imports (ruff F401) in absorbed test files
* fix(#4217,#3988): address review — dangling-symlink ordering + boot.js catch fallback
Greptile review on #4230 + the related-PR reviews surfaced two fixes on the
shipped code:
1. (#4217) _handle_file_delete/_handle_file_rename checked target.exists()
BEFORE is_symlink(), so a DANGLING workspace symlink resolved to a missing
target -> misclassified as 404 'File not found' and left permanently
undeletable/unrenameable. Moved the no-follow is_symlink() guard before the
follow-based exists() check in both handlers. +2 dangling-symlink tests.
2. (#3988) boot.js settings-load-FAILURE catch block hardcoded
_showCliSessions=false, inconsistent with the new True default (the #4006
catch-block-fallback class). Now defaults true. +1 test pinning the fallback.
Both confirmed by the new tests (20/20 in the two files).
* fix(#3988,#4217): address Opus review — broaden grandfather signal + move-handler parity
Opus #4230 ship-review surfaced 2 LOW findings, both now fixed in-line:
1. (#3988) The grandfather keyed only on onboarding_completed, so a CLI-configured
user who tweaked a WebUI setting but never ran the wizard would get flipped ON
(and onboarding.py later auto-persisting onboarding_completed=True would make it
permanent). Now: established = show_cli_sessions absent AND (onboarding_completed
OR any persisted setting other than a falsy onboarding flag). A genuinely new /
still-mid-onboarding file still falls through to True. +1 test.
2. (#4217) _handle_file_move still checked exists() before is_symlink() — the same
dangling-symlink-404 bug just fixed in delete/rename. Reordered for parity so a
dangling symlinked source is rejected 400, not 404.
22 tests across the two files pass; ruff clean.
---------
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: Sanjays2402 <Sanjays2402@users.noreply.github.com>
* feat(workspace): add Todos tab to workspace panel behind a setting (#2942)
* fix(workspace): use DOM attribute to check active tab across script scope
* Prefer the live todos snapshot in the workspace tab
* Fill the missing workspace Todos locale keys
* feat(workspace): add optional Todos tab to workspace panel (#3564)
Rebased rodboev's #3564 onto current master + CHANGELOG entry.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(#3564): apply workspace Todos tab visibility on cold boot
Codex gate: boot.js set window._workspaceTodosTab from saved settings but
never called _applyWorkspaceTodosTabVisibility(), so a previously-enabled
Todos tab stayed hidden on a normal page reload (only re-appeared after
opening Settings). Call the visibility helper after assigning the flag in
both the settings-apply and the fallback/default paths.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* chore(release): stamp v0.51.422 (Release OI) for #3564
---------
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Two scroll-state bugs cause the viewport to snap to the bottom after
streaming completes, even when the user has scrolled up to read:
1. _scrollAfterMessageRender (ui.js): when preserveScroll=true and
_scrollPinned=true (re-set by the 250px near-bottom hysteresis),
_followMessagesAfterDomReplace() calls scrollToBottom(), overriding
the user's position. Added !_messageUserUnpinned guard so explicitly
scrolled-up users get their snapshot restored instead.
2. _finishDone (messages.js): the explicit scrollToBottom() at L3302
fires when shouldFollowOnDone is true, but that flag only checks a
120px near-bottom threshold. Added _isMessagePaneNearBottom(250)
gate so users reading 250px+ above bottom keep their position.
Also adds a new 'auto_scroll_follow' setting (default: off) that gates
scrollIfPinned() and _shouldFollowMessagesOnDomReplace(). When disabled,
the viewport never auto-scrolls during streaming — the user controls
scroll position manually and uses the ↓ button to jump to bottom.
7-file setting-add pattern applied:
- api/config.py: _SETTINGS_DEFAULTS + _SETTINGS_BOOL_KEYS
- static/boot.js: both init paths
- static/ui.js: scrollIfPinned() + _shouldFollowMessagesOnDomReplace()
- static/index.html: checkbox in appearance settings
- static/i18n.js: en + zh-CN translations
- static/panels.js: payload, autosave, populate, full save
- messages.js: no additional changes (gated via _shouldFollowMessagesOnDomReplace)
Tests updated: test_tars_scroll_reset_regressions, test_issue1690,
test_issue3545. All 116 scroll-related tests pass.
Adds an opt-in, default-off, desktop-only floating panel that lists the user's
questions in a conversation as a numbered jump-list (click to scroll + flash the
message). Gated behind a Settings → Preferences toggle (show_conversation_outline)
wired through the existing show_* preference boot/load/autosave/save paths.
Review follow-ups applied on absorb:
- Outline is now strictly chat-only: leaving the chat view (Settings, Tasks,
Insights, …) hides the toggle button AND closes the panel; returning to chat
restores the toggle (panel stays closed until re-opened). Implemented by gating
_outlineAllowed() on the active panel and re-evaluating via a MutationObserver on
the <main> showing-<panel> class (switchPanel is a global fn declaration that
can't be reliably wrapped from this script).
- Fixed a latent visual bug: #outlinePanelWrapper{display:flex} (id selector)
outranked the UA [hidden]{display:none}, so wrapper.hidden=true never actually
hid the panel — the × close button and auto-close had no visual effect. Added
#outlinePanelWrapper[hidden]{display:none;}.
- Regression tests for both.
Closes#2124.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
* fix(security): ignore spoofable forwarded IPs in onboarding gate + make update-check CSRF-safe (#3758, partial)
Ships the two unambiguous slices of #3758's security review. The two slices with
breakage risk for existing installs — the Docker-default public-bind-requires-auth
gate and removing /tmp from the /api/media allowed roots — are held for separate
review/decision.
Onboarding forwarded-IP spoof hardening (+ release-gate CORE fix):
- The unauthenticated first-run onboarding local-network gate now IGNORES
X-Forwarded-For / X-Real-IP by default (a direct client can spoof them to a
private/loopback address to bypass the gate), trusting them only when
HERMES_WEBUI_TRUST_FORWARDED_FOR=1 is set behind a trusted proxy (rightmost
proxy-appended hop).
- Release-gate (Codex) CORE catch + refinement: when forwarded headers are
present but untrusted, the header is ignored and locality is judged by the raw
socket — but a PRIVATE/LAN raw socket (a separate proxy box that could forward
an arbitrary public client) is no longer treated as local; only a LOOPBACK raw
socket is (genuine same-host; a remote attacker can't forge a 127.0.0.1 TCP
source). This closes the new fail-open the initial refactor introduced (public
client behind a LAN proxy read as local) while preserving genuine same-host
onboarding. LAN-proxy operators must set HERMES_WEBUI_TRUST_FORWARDED_FOR=1.
Regression tests lock the full matrix (spoof-block, LAN-proxy-deny,
loopback-allow, trusted-proxy-rightmost-hop, direct-public-deny).
- Three duplicated inline gate blocks unified into _onboarding_gate_allows /
_onboarding_request_is_local; ONBOARDING_OPEN normalized to canonical truthy
values via _truthy_env.
Update-check CSRF hardening:
- GET /api/updates/check is cache-only (cached_update_status(): no network/git
mutation); forced refresh moves to POST /api/updates/check {force:true}; both
frontend call sites updated and the test_api_timeout contract assertion updated.
- cached_update_status() preserves cached agent info when include_agent re-enabled.
Docker log masking: ENV_OBFUSCATE_PART also masks PASSWORD/SECRET/CREDENTIAL/COOKIE/SESSION.
Held for separate review (NOT in this PR): public-bind-requires-auth startup gate
(server.py + Dockerfile default) and the /api/media /tmp-root removal.
Co-authored-by: fantasticsquirrel <[email protected]>
* docs(changelog): stamp v0.51.307 — Release JW (stage-a3 #3758 partial)
---------
Co-authored-by: nesquena-hermes <[email protected]>
* fix: honor explicit model pick, suppress silent revert on cross-family selection (#3737)
When a user changes the model in the composer dropdown and sends,
_resolve_compatible_session_model_state previously had no way to
distinguish an explicit user pick from stale session state. The
profile-aware branch (v0.51.290, PR #3448) and the legacy block
both rewrote bare cross-family models to the profile default, and
the client unconditionally applied effective_model — silently
discarding the user's choice.
Backend: accept explicit_model_pick flag (default False) on
_resolve_compatible_session_model_state. Guard both the
profile-aware branch (routes.py:2024) and the legacy block
(routes.py:2124) to skip cross-provider normalization when set.
_handle_chat_start extracts the flag and passes it through.
Frontend: consult _readPendingSessionModel (sessionStorage, 10-min
window) to detect explicit picks and include the flag. Add a toast
as defense-in-depth when the server still returns effective_model.
Closes#3737
* fix: tighten explicit-pick detection and add regression tests (#3737)
Greptile P2-1: compare model_provider in pending pick detection,
not just model name, to avoid false-positive flag when the
session provider changes between pick and send.
Greptile P2-2: only show the defense-in-depth toast when an
explicit pick was actually overridden — stale-session
normalizations are expected behavior and should be silent.
Add two regression tests for the profile-branch guard:
- explicit_model_pick=True → cross-family model survives
- explicit_model_pick=False → existing normalization preserved
* revert(sidebar): remove manual session status labels (#3570)
The manual per-session status labels (Todo / In Progress / Done) added in
v0.51.284 (#3570) stored state only in browser localStorage keyed by session
id, with no server-side backing — so labels did not persist across browsers
or devices (a user who labeled sessions on one machine saw none after moving
to a laptop). They also rendered as three flat top-level entries in the
session context menu, crowding the root menu.
Per maintainer decision, remove the feature entirely for now. It can be
reintroduced later with proper server-side persistence and a less intrusive
menu treatment.
Removes:
- JS state/cycle helpers + SESSION_MANUAL_STATUS_KEY (static/sessions.js)
- context-menu status entries + sidebar status badge render
- .session-manual-status* CSS (static/style.css)
- session_status_* locale strings across all locales (static/i18n.js)
Full suite: 8084 passed, 0 failed. ESLint runtime gate: clean.
reverts #3570
* fix(#3737): keep explicit-pick marker until send consumes it (Codex catch)
Codex found the explicit_model_pick flag never engaged in the normal flow: boot.js
modelSelect.onchange cleared the pending-pick marker right after /api/session/update,
so by the time send() ran _readPendingSessionModel returned null, _explicitPick was
false, and the server's profile-provider branch still reverted the cross-family pick
(the exact #3737 bug). The flag only worked in the rare race where send beat the
session-update round-trip.
Fix (Codex prescription): do NOT clear the marker in onchange; clear it in send()
immediately after reading a matching pending pick, so it's consumed for that send only.
onchange still RECORDS the pick (_rememberPendingSessionModel) — only the premature
clear is removed.
* test(#3737): lock client clear-timing wiring (onchange records, send consumes)
Static source guards for the Codex clear-timing fix: onchange must record the
pending pick and NOT clear it post-session-update; send() must consume (clear) it
only after reading a matching _explicitPick, and send the flag only when truthy.
Complements the author's resolver-level tests in test_provider_mismatch.py.
* test(#3737): realign refresh-persistence test to the moved pending-pick clear
The Codex clear-timing fix moved the pending-pick clear out of modelSelect.onchange
into send() (consume-on-send). test_model_selection_records_pending_state_before_async_session_update
asserted the OLD onchange-clears behavior (assert _clearPendingSessionModel in body).
Updated to assert the NEW correct behavior (onchange must NOT clear it — it survives to
send). The test's core refresh-survives invariant (marker recorded before the async
session-update; reapplied on load) is unchanged and still passes; only the stale
clear-location assertion is flipped. Not a regression-blessing: the refresh-survives
feature is intact, the marker lifecycle is more correct.
---------
Co-authored-by: John Doe <johndoe@example.com>
Co-authored-by: nesquena-hermes <[email protected]>
Adds the Verdigris dark-only appearance skin (emerald/forest-green + bronze-gold),
renamed from the contributor's 'Hermes Agent' to a descriptive material name per
maintainer naming convention. Registered across all 5 skin sites (config allowlist,
boot.js swatch, index.html FOUC map, i18n in 12 locales, scoped CSS palette) + test.
Also fixes the zeus i18n test (zeus is no longer the trailing skin token).
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
## Release v0.51.266 — Release IH (stage-r16)
One agent-authored APPROVED fix + two un-held streaming/SSE fixes.
### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3635 (#3637) | @nesquena-hermes (nesquena APPROVED) | Composer profile chip reads `S.activeProfile` again — a #3331 regression keyed it on the loaded session's profile, so opening a cross-profile session made the chip disagree with the dropdown checkmark and misrepresent where the next message routes. #3331's project/session-op scoping is unaffected. |
| #3587 (#3605) | @rodboev | Reasoning persists to the correct intermediate assistant message in multi-turn tool flows. The index only advanced in `on_interim_assistant` (suppressed for contentless tool-call messages) → post-tool reasoning was mis-attributed; it now also advances at the `on_tool` boundary, guarded against over-increment. **(un-held — finding resolved)** |
| #2660 (#3558) | @franksong2702 | Session-event SSE no longer wakes every tab across profiles and never drops a relevant refresh — profile attached when known, root/`default` aliases stay unscoped, and the `maxsize=1` queue falls back to unscoped refresh-all on a profile-mismatch coalesce. **(un-held — both findings resolved)** |
### Gate
- Full pytest suite: **7770 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — chip matches dropdown/routing (no #3331 scoping regression), reasoning-index advance composes with the agent's tool/interim callback ordering, session-events coalesce safely with no dropped refresh and no profile data leak (`/api/sessions` still server-side filtered).
Co-authored-by: nesquena <nesquena@users.noreply.github.com>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
## Release v0.51.265 — Release IG (stage-r15)
Un-held: owner-aware `cancelStream()` (#3344) — author addressed the active-session SSE-settle gap.
### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3344 | @franksong2702 | Stop/Cancel no longer leaves the UI falsely idle, drops the cancellation transcript, or leaks the old stream's tokens. `cancelStream()` is owner- + terminal-settle-aware: **active session** → leave the SSE open so the backend terminal `cancel` event clears INFLIGHT / renders "Task cancelled" / refreshes sidebar; **stale owner** (`activeStreamId!==streamId`) → tear down the SSE; local clear only on exact ownership (no null-window clobber of a turn started mid-cancel). |
### Un-hold note
Held twice earlier: (1) a null-window clobber (cleared busy on `!S.activeStreamId`, hitting a queued new turn), (2) the blocker — it called `closeLiveStream()` on the active session, killing the SSE before the terminal cancel event could settle. The author's rework fixes both (clear requires `activeStreamId===streamId`; SSE closed only for the stale-owner path). Codex confirmed the backend reliably emits the terminal cancel SSE frame the new design relies on (no busy-hang risk).
### Gate
- Full pytest suite: **7742 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — verified active cancel keeps the SSE for settle, the terminal event reliably fires, cancelled:false clears only on exact ownership, stale-owner teardown + network-error paths consistent.
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
## Release v0.51.249 — Release HQ (stage-q21)
Small opt-in feature. UX-approved (screenshot of the toggle in Settings → Preferences).
### Added
| Issue | Author | Feature |
|-------|--------|---------|
| #2974 | @rodboev | **"Auto-expand terminal on output"** preference (Settings → Preferences, **off by default**). When enabled, the collapsed embedded terminal panel expands automatically the first time a running command emits output. Fires once per stream (guarded on open && collapsed — not per chunk), and uses `expandComposerTerminal({focus:false})` so it doesn't steal focus from the composer. Backend-persisted boolean mirroring the `simplified_tool_calling` pattern; default-off = no behavior change on upgrade. |
### Gate
- Full pytest suite: **7557 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN · screenshot vision-verified (toggle renders cleanly under "Compact tool activity", unchecked by default)
- Codex (regression): **SAFE TO SHIP** (clean first pass) — no-arg `expandComposerTerminal` callers unchanged, default-off incl. settings-load-failure path, all 5 plumb sites mirror the existing pattern
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
## Release v0.51.242 — Release HJ (stage-q14)
UX-approved via Telegram (Nathan — dark/light/mobile screenshots).
### Added
| PR | Author | Feature |
|----|--------|---------|
| #3440 | @t3chn0pr13st | **Graphite appearance skin** — a quiet, neutral-gray "workbench" alternative to the default gold/cream. Selectable from Settings → Appearance and `/theme skin graphite`. Both light + dark palettes on the existing CSS-variable token system; tightened typography, shadows, active-sidebar spacing, code-block framing. |
### Why it's safe
- **Fully scoped + additive**: every new CSS rule (and every `!important`) is under `[data-skin="graphite"]` — Codex verified zero bleed into the default appearance or other skins. `api/config.py` keeps the default skin as `default` and only *adds* `graphite` to the allowed set. No i18n keys dropped (only the `/theme` help string gains `graphite`).
- Opt-in; a user has to select it. Default experience unchanged.
### Test-robustness fix (absorbed)
The new graphite scoped selectors (e.g. `:root[data-skin="graphite"] .session-item.active .session-time{…}`) appear in `style.css` *before* the canonical unscoped rules, which broke 3 naive first-occurrence CSS-contract tests (`test_issue677` scroll-btn-overlay, `test_issue856_pinned_indicator_layout`, `test_workspace_panel_session_list`). Fixed those 3 to anchor on the canonical **unscoped** rule (start-of-line regex) instead of the first `.selector` match — robust against this and future skins. Verified they still pass on clean master CSS (invariant preserved, not weakened).
### Gate
- Full pytest suite: **7517 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint runtime gate: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** (verified all CSS scoped, default skin unchanged, no i18n key loss)
- Vision-verified dark + light + mobile; UX-approved by Nathan via Telegram
Co-authored-by: t3chn0pr13st <t3chn0pr13st@users.noreply.github.com>
## Release v0.51.241 — Release HI (stage-q13)
UX-flow bug-fix — approved via Telegram.
### Fixed
| PR | Author | Fix |
|----|--------|-----|
| #3471 (#3333) | @starGazerK | **New Chat keeps your unsent draft after peeking at history.** Start a New Chat draft → open a previous conversation → click New Chat: the draft is no longer lost. Empty New-Chat sessions are hidden from the sidebar, so there was no way back to the session holding the draft — New Chat just created another fresh empty session. The entrypoint now remembers the candidate empty draft session (one `localStorage` pointer) and, before creating a fresh session, re-validates it via `/api/session`, routing back only if it is still a safe empty draft (zero messages, no active stream, no pending message, not worktree-backed, matching profile, non-empty server-side `composer_draft`). |
### Why it's safe for existing installs
- When there is no remembered draft, it's a **pure no-op fall-through** to the existing `newSession()` path — no behavior change.
- Preserves the "zero-message sessions stay hidden from the sidebar" contract.
- Conservative multi-guard validation; the pointer is cleared on draft-clear (after send) so an emptied draft never traps you on New Chat.
### Verified live (end-to-end on a test server)
- **Positive**: typed a draft → visited a 2-message history session → clicked New Chat → landed back on the draft session with the text restored.
- **Negative**: emptied the draft → New Chat created a fresh session (no accidental trap).
### Absorbed on review (Codex CORE MUST-FIX)
The PR added `await _saveComposerDraftNow(...)` before the session-switch, which opened a rapid-switch race: clicking session B then quickly C could let B's stale continuation blank C's freshly-loaded state. Added `if (_loadingSessionId !== sid) return;` immediately after the awaited save and before the destructive state-clear (mirrors the existing #1060 stale-guard) + a regression test pinning the guard's position.
### Gate
- Full pytest suite: **7510 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint runtime gate: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES (rapid-switch race) → fixed → re-reviewed **SAFE TO SHIP**
- `tests/test_issue_new_chat_draft_restore.py` (7 assertions incl. the race-guard; live-verified behavior)
UX-flow change, no visual/layout delta — approved via Telegram.
Co-authored-by: starGazerK <starGazerK@users.noreply.github.com>
* 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]>
The PR silently removed 'console.error([hermes] boot failed, e)' from the
top-level boot .catch() that exists on master, so caught boot-path failures
(session restore / inflight recovery / gateway startup) would no longer surface
in the console or the browser-smoke gate. Restored to match master.
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.
Codex round-2: _stopMic and mediaRecorder.onstop read the CURRENT _rawAudioMode
to choose backend/dispatch, but the recording was started on the OLD mode — so
toggling Settings→Sound mid-recording could stop the wrong backend (orphaning
the other) or dispatch raw-vs-transcribe wrongly. Pin _activeCaptureMode
(speech | media-raw | media-transcribe) at start; _stopMic + onstop use it.
Adds front-end source-invariant regression tests.
Co-authored-by: lucasrc <lrclucas@gmail.com>
Codex pre-release gate: clicking Send while raw-audio recording with text in the
composer attached the audio but never sent — btnSend sets _micPendingSend=true
before _stopMic, but _sendRawAudio only called send() when the textarea was empty.
Mirror the transcribe path: if _micPendingSend is set, clear it and send()
regardless of composer text; otherwise keep the empty-composer auto-send + toast.
Co-authored-by: lucasrc <lrclucas@gmail.com>