The #7240 fix peeled the leading @custom: segment on first-colon split, but a
plain-lane custom id whose model carries a tag/variant colon
(@custom:ollamacloud/qwen3.5:397b) was still misread as slug=ollamacloud/qwen3.5,
rendering only "397b". Two changes:
- _dynamicModelLabels (the backend-populated catalog) is now consulted BEFORE
the string peel: the backend knows the true provider/model split and ships an
exact m.label per routing id, so catalogued ids — including plain-lane colon
models and collision cases — render verbatim.
- The legacy string fallback now treats a slash-bearing first segment as the
model itself (a provider slug is a config key or host:port authority and
never contains '/'), mirroring the '/' means routable rule api/config.py
applies when building ids.
Tests: add plain-lane @custom:ollamacloud/qwen3.5:397b controls, a
post-catalog dynamic-label control, and a collision case proving labels stay
distinct by full routing id. Driver now accepts an optional pre-hydrated
catalog map.
The settled current-summary card was only created when no compaction
marker was loaded at all. A transcript with older (stale) markers plus a
newer `compression_anchor_summary` that no loaded marker references
therefore lost the current summary, which master still rendered.
Decide the fallback from `_latestCompressionReferenceMessage()`
(`referenceMessageRawIdx < 0`) instead of marker presence, and feed that
decision into `_selectCompactionCardPlacements()` before any card node is
built so the fallback is the single preserved-task owner and marker cards
never attach the task list a second time.
Add a render-level regression that executes the real `renderMessages()`
over 220 messages, three stale markers, one preserved task-list message
and a newer summary: the current summary is visible and exactly one task
owner is rendered, in full, virtualized and server-truncated windows.
Gate follow-up to #7649. _cronScheduleForEdit() consulted sched.expr before
schedule_display, so opening or duplicating a job scheduled as
`every monday 9am` populated the field with the canonical `0 9 * * 1`. The
Agent rebuilds schedule_display from whatever the WebUI submits, so the
rewrite stuck and the user silently lost their phrasing.
Only the `once at ...` label is unparseable; every other schedule_display
form round-trips through parse_schedule(), so prefer it and fall back to
expr/expression.
The existing tests asserted on the SOURCE TEXT of the helper and stayed green
through this defect. Adds four tests that EXECUTE the real helper out of
panels.js via node; the natural-language case is red before this fix.
Co-authored-by: happy5318 <happy5318@users.noreply.github.com>
## Thinking Path
The WebUI populates the editable schedule field from `schedule_display`,
which is the human-readable label `once at 2026-08-28 16:00` for one-shot
jobs. `saveCronForm()` then submits that exact string back to
`POST /api/crons/update`. The Agent's `cron.jobs.parse_schedule()`
rejects the display form with `ValueError` (it only accepts the canonical
ISO timestamp). The route's earlier `try/except ValueError` block
covered input normalization but not the `update_job()` call, so the
parser exception escaped as HTTP 500.
There are two independent failures to fix:
1. **Frontend** ships the unsafe display label as editable input. Both
`openCronEdit()` (`static/panels.js:1593`) and `duplicateCurrentCron()`
(`static/panels.js:1532`) use the same `schedule_display ||
schedule.expression` fallback. The cleanest fix is a shared helper
that returns the canonical Agent-parseable value by schedule kind,
matching the Agent's own schema:
- `once` → `schedule.run_at`
- recurring cron → `schedule.expr` (current schema; `expression`
accepted for legacy payloads)
- interval / legacy → the stored value, with `schedule_display`
kept as a final fallback only when it isn't the `once at …` label.
2. **Backend** converts `ValueError` from `update_job()` into HTTP 400
with the parser message, so the WebUI can show a real validation
toast instead of an opaque Internal Server Error. The 404 path
(job not found) is preserved.
## What Changed
- **`static/panels.js`** — add `_cronScheduleForEdit(job)` helper and
use it in both `openCronEdit()` and `duplicateCurrentCron()`. The
helper returns `schedule.run_at` for one-shot jobs,
`schedule.expr` (or legacy `schedule.expression`) for recurring
cron, and falls back to `schedule_display` only when it is not the
`once at …` presentation form. `saveCronForm()` is unchanged — it
still submits `schEl.value`, which is now the canonical field
instead of the display label.
- **`api/routes.py::_handle_cron_update`** — wrap the `update_job()`
call in `try/except ValueError` and return HTTP 400 with the parser
message. The earlier normalization try block stays untouched; the
404 check (`if not job`) sits outside the new try block so a missing
job still returns 404.
- **`tests/test_cron_edit_round_trip.py`** — new regression module
with seven tests covering the helper contract (helper exists, both
call sites use it, `saveCronForm` still submits the field by name),
the route's 400-on-`ValueError` behavior, the preserved 404 path,
and the happy-path 200.
## Why
- Editing a one-shot cron job's scheduled time has been returning
HTTP 500 since the WebUI's initial public release (per #7352
historical note), because the editable field round-trips the
`once at …` display label straight to the Agent parser.
- The backend regression is independent: even with a correct
frontend, any user-typed invalid schedule text (or a programmatic
caller) used to produce a 500 instead of a validation response.
Both halves are required to deliver a "no more 500" user
experience.
## Verification
```
$ .venv/bin/pytest \
tests/test_cron_edit_round_trip.py \
tests/test_cron_no_agent_edit.py \
tests/test_cron_model_override.py \
tests/test_cron_model_provider_picker.py \
tests/test_issue617_cron_profile_selector.py \
-v --no-header
…
============================== 27 passed in 4.84s ==============================
```
Seven new tests cover both layers; twenty surrounding cron tests
(no-agent edit, model override, model/provider picker, profile
selector) stay green.
## Contract
| Scenario | Before | After |
| --- | --- | --- |
| Open one-shot job for edit, change time, save | 500 (display label round-trips to parser) | 200, persisted with new `run_at` |
| Open recurring cron job for edit | 200 (legacy `schedule.expression` chain) | 200, unchanged canonical field (`schedule.expr` first, `expression` as legacy fallback) |
| Duplicate a one-shot job | 500 on first save | 200, populated with `schedule.run_at` from the source job |
| Submit a `once at …` schedule value (programmatic / pre-fix frontend) | 500 | 400 with the parser message |
| Submit any other unparseable schedule | 500 | 400 with the parser message |
| Update a job that does not exist | 404 | 404 (preserved) |
| Update a job with a valid canonical schedule | 200 | 200 (preserved) |
## Risks
- **Frontend helper change**: I switched the editable field source
for both edit and duplicate in one commit. `saveCronForm()` is
untouched, so anything already rendered with the legacy chain
(rare in practice — the helper is only called from these two
functions) keeps working. The regression `test_save_cron_form_still_submits_schedule_value`
pins the submit field name.
- **New `try/except ValueError` in `_handle_cron_update`**: the
earlier normalization try block (lines 24986–24998) is left
exactly as-is. The new try block sits directly around
`update_job()` only, so a `ValueError` from input parsing still
surfaces before any persistence side effect. The 404 check is
outside the new try, so a missing-job path cannot be misreported
as 400. Both `test_cron_update_preserves_404_when_job_missing` and
`test_cron_update_valid_schedule_still_succeeds` pin those
contracts.
- **Legacy `schedule.expression` payloads**: accepted as a
back-compat fallback in `_cronScheduleForEdit` and in the route
(the Agent schema is `expr`, but the parser still handles
`expression` per the existing contract). If you prefer to drop
the legacy fallback, the helper can be trimmed to `sched.expr`
only.
## Model Used
- Generated with `MiniMax-M3` via `happy5318/hermes-webui` worktree
`/tmp/webui-worktrees/pr7352-cron-edit`, branch
`fix/cron-edit-round-trip` (off `origin/master`).
## Related
- Closes#7352
- Surface code:
- `static/panels.js:1510` `duplicateCurrentCron` (uses helper)
- `static/panels.js:1585` `openCronEdit` (uses helper)
- `static/panels.js:1905` `saveCronForm` (submit path, unchanged)
- `api/routes.py:24979` `_handle_cron_update` (new try/except)
- `cron/jobs.py:785` `parse_schedule` (rejects the display form;
Agent-side, not modified in this PR)
- `cron/jobs.py:2199` `update_job` (the call that raises)
- Test surface: new `tests/test_cron_edit_round_trip.py`
Third gate finding: the table-row predicate accepted ANY pipe-wrapped line, but
the downstream table parser only accepts a run of pipe-lines whose SECOND line
is a separator. So pipe-delimited prose such as '| note<br># heading |' was
treated as a table row and kept its literal <br>, silently losing heading/list
rendering that master renders correctly.
The guard now classifies complete blocks with the parser's own grammar
(row regex + separator check) and converts <br> on every unclassified line.
Verified: all non-table cases now match master byte-for-byte, while genuine
multi-row tables keep <br> in every data cell.
Co-authored-by: JayaMegelar <JayaMegelar@users.noreply.github.com>
The release gate (Codex, adversarial pass) reproduced two SILENT regressions
introduced by the original implementation. Both are fixed here with
revert-sensitive regressions.
1. Collidable sentinel. Table-row <br> was stashed as a fixed \x00BR\x00 token
and that token was unconditionally rewritten back to <br> afterwards, so
user-supplied text containing the literal token was corrupted -- in prose it
became a <br>, and inside a link URL it broke the anchor apart. Replaced with
conditional conversion: <br> becomes a newline only on non-table-row lines,
so no placeholder is ever introduced and nothing user-suppliable is rewritten.
2. Lost HTML emphasis. The stricter italic regex (which correctly stops
'a * b * c' from italicising) also rejected the '* x *' produced by the
<em>/<i> pre-pass, so '<em> italic </em>' degraded to literal asterisks -- and
to a bullet list at line start. Boundary whitespace is now kept OUTSIDE the
generated delimiters.
Verified against the real renderMd(): output for both cases now matches master,
while the table-cell <br> and spaced-asterisk fixes still hold. A revert probe
confirms all five new regressions fail against the defective implementation.
Co-authored-by: JayaMegelar <JayaMegelar@users.noreply.github.com>
Disabled skills are excluded from the backend skill-command map, but the WebUI
picker listed every /api/skills entry regardless of skills.disabled -- so a
disabled skill stayed selectable in both '/use <skill>' sub-args and the
skill-command suggestions (#7509). A single _isSkillDisabled() gate now filters
both surfaces.
Also fences the skill caches across a profile switch: invalidateSlashSkillCaches()
bumps a generation counter that an in-flight /api/skills reply checks before
committing, so a payload generated for the outgoing profile can no longer land
after the switch and keep hiding a skill that is enabled in the new profile.
Maintainer follow-up folded in: loadSkillCommands() published readiness in a
finally block that ran even when /api/skills rejected, leaving
_skillCommandCacheReady=true with an empty cache. Because
ensureSkillCommandsLoadedForAutocomplete() only re-loads while
!ready && !promise, one transient failure PERMANENTLY emptied the slash-command
picker until a page reload. Readiness is now published only after a successful
current-generation commit. This wedge is PRE-EXISTING on master (its finally sets
readiness unconditionally) and is not introduced by this PR; fixed here because
this change already reworks the surrounding generation handling. Added a
reject-once/recover regression plus a __failSkillsOnce() harness knob.
Gate: Codex SAFE TO SHIP on the fixed head — confirmed the wedge is closed for
every failure interleaving, the stale-generation fence is NOT weakened (a stale
rejection landing after a fresh success leaves the fresh cache intact), and
_skillCommandCacheReady=_committed is correct rather than ||-ing with the old
value (which would re-create the wedge after a forced refresh erased the cache).
Verified the API really emits a boolean 'disabled' per skill (api/routes.py:931)
so the predicate matches the true payload. Full suite 15,612 passed with only the
known box-baseline singleton; 9/9 own tests, 34/34 neighboring slash tests;
16/16 required checks green.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
loadSkillCommands() published readiness in a finally block that ran even when
/api/skills rejected, so one transient failure left
_skillCommandCacheReady=true with an empty cache. Because
ensureSkillCommandsLoadedForAutocomplete() only re-loads while
!_skillCommandCacheReady && !_skillCommandLoadPromise, no later picker pass ever
retried: skill commands stayed missing until a page reload even after the API
recovered.
Readiness is now set only after a successful current-generation commit; the
promise is cleared either way so the next pass can retry. The stale-generation
fence is unchanged (verified a reply from the outgoing profile still refuses to
commit).
This wedge is PRE-EXISTING on master (its finally sets ready unconditionally),
not introduced by this PR — fixed here because this change already reworks the
surrounding generation handling. Adds a reject-once/recover regression plus a
__failSkillsOnce() harness knob; reverting the fix turns it red.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
The slash-skill caches (_slashSkillCache, _slashSkillCachePromise,
_skillCommandCache) outlived a profile switch, so the outgoing profile's
/api/skills payload kept hiding skills that the incoming profile enables
(#7509).
- call invalidateSlashSkillCaches() from both switch handlers
(static/panels.js switchToProfile, static/sessions.js
_switchProfileForSessionLoad), after the /api/profile/switch request
- bump a generation counter in invalidateSlashSkillCaches() and capture it
in both loaders: a reply issued for the outgoing profile can no longer
commit once the switch has landed
- tests: in-flight race and cache-refresh regression on both picker
surfaces, plus a check that both switch paths call the invalidation
`skills.disabled` entries are already excluded from the backend skill-command
map, but the composer still offered them as selectable slash commands and as
`/use` sub-args: both picker surfaces read `/api/skills` and ignored the
`disabled` flag the endpoint reports per skill.
Add one `_isSkillDisabled()` gate in static/commands.js and apply it at the two
places that turn `/api/skills` rows into completions:
* `_buildSkillCommandEntry()` returns null for a disabled skill, which keeps
`_skillCommandCache` (and every consumer of it, including the dropdown) clean;
* `_loadSlashSkillSubArgs()` skips disabled skills when building `/use`
sub-args.
Both caches still rebuild from `/api/skills`, so a skill toggled off in the
skills panel disappears from the picker on the next load.
Adds tests/test_issue7509_disabled_skills_slash_picker.py, which runs the real
static/commands.js in a vm context with a mocked `api()` and asserts on
`getSlashAutocompleteMatches()` for both surfaces, over 0 / 1 / many skills,
enabled / disabled / flag-absent entries and a disable-then-refresh cycle.
Fixes#7509
The agent's handle_pending_subcommand() accepts aliases the initial list
missed: approve|apply, reject|deny|drop. Those three were still swallowed by
the local /skills search, so '/skills apply 3' and '/skills deny 3' silently
ran a skill-name search instead of reaching the write-approval handler.
Completes the class rather than the reported instance.
Co-authored-by: totalitarian <totalitarian@users.noreply.github.com>
cmdSkills intercepted every /skills argument as a local search filter,
so /skills pending|approve|reject|diff|approval|mode never reached the
agent's own write-approval handler (hermes_cli/write_approval_commands.py
via gateway/slash_commands.py) — pending memory/skill writes staged by
skills.write_approval could not be reviewed or approved from the WebUI
at all, only via CLI/gateway.
cmdSkills now checks the first argument against the known agent
subcommand names and returns false to opt out of local handling
(matching the existing /reasoning fall-through convention), letting
those subcommands reach the agent like any other unrecognized text.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Extensions settings pane (Gallery / Installed / Diagnostics) was the
last large user-facing surface still rendering hardcoded English:
- static/index.html carried the section meta, both loading placeholders,
the Copy diagnostics button, and the whole Trust-model explainer block
as plain HTML with no data-i18n attributes.
- static/panels.js built the runtime / diagnostics cards from template
literals with 14 untranslated row labels, a separate installed card,
asset and warning card titles, sidecar card fields (Origin / Health
path / Health URL / Proxy / Proxy path), the Enabled/Disabled and
true/false badges, and every empty-state string (None, No warnings,
No extension directory is configured., No manifest extensions are
installed in the configured bundle., No loopback sidecars declared.)
inline in JS.
Every one of those call sites now routes through t() and the plain
strings live in the locale bundles. data-i18n is used for the static
HTML (the same mechanism the other selects already use, per #7582
triage), and esc(t(...)) for the template-literal DOM built in
panels.js so no injected text can introduce markup.
Locale keys are added to all 15 shipped locale bundles (en, ru, it, ja,
es, de, zh, zh-Hant, pt, ko, fr, cs, tr, pl, vi), following the same
key-parity contract that the #7602 triage enforced for the
sessions_source keys: inserted right after each locale's identity
(_label:_speech_) line so the brace-bounded locale extraction still
finds the whole block, matching the same pattern upstream used to fix
the sessions_source parity. No locale falls back to English at runtime
because t() resolves every bundle.
tests/test_extensions_settings_panel.py: assertions that used to match
the inline English strings now match the corresponding locale keys, and
the state_unknown_ids hint asserts both the key is used and the raw
English is gone.
Verified:
- python3 -m pytest tests/ -k "i18n or extension or parity or japanese"
-q -> 496 passed
- node --check on the three touched static assets -> all valid
Fixes#7581. Same surgical slice as #7602 (linked from #7579).
The settings UI writes localStorage immediately but persists the server
engine through a debounced save, so a first-party Edge selection could
fall through the new server default to a stale ElevenLabs/OpenAI
engine. Both WebUI Edge callers now pass engine explicitly; regressions
cover both call sites.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNmJZVV9u6pBhcwVhwkPp6
* perf(composer): make the single-row autoResize fast path reachable
autoResize() skips the height:'auto' -> scrollHeight -> restore round trip only
when the composer is already at its one-row height, but the guard compared
el.offsetHeight against the CSS min-height (44px) while the composer's natural
one-row border box is line-height + vertical padding + borders (~48px at the
stock font). 48 <= ceil(44)+1 is false, so the skip was unreachable in a real
browser: every append keystroke wrote style.height twice, and the scrollHeight
read that follows forces a synchronous layout of the whole document, transcript
included. The cost therefore grows with the rendered transcript, which is the
"typing gets laggy in long sessions" report.
Accept the natural one-row height as the skip ceiling, computed from the same
already-read getComputedStyle. The strict finite-pixel gate is kept for every
value involved, so a percentage / calc / normal line-height, padding or
min-height still fails closed to the full resize (the #6349 re-gate property is
preserved, and test_issue5514_composer_grow_scroll_pin.py still pins it).
Measured on a 758-message / 5 MB session (31k DOM nodes rendered) with the
browser Event Timing API (input -> next paint) at 6x CPU throttle: a keystroke
went from 168ms to ~136ms median echo latency (72ms with the transcript
detached), 96ms to ~72ms in the windowed state, and the style writes per 8
keystrokes from 16 to 2.
The #5514 invariant holds: an oversized composer still takes the full resize
because its offsetHeight is far above the one-row ceiling.
Tests:
- new tests/test_long_session_composer_typing_latency.py runs the real
autoResize() body from messages.js in a node sandbox whose getComputedStyle
returns the composer's computed styles; it is red before this change
(2 failed, 27 passed) and green after (29 passed).
- tests/test_issue5514_composer_grow_scroll_pin.py source pins updated to the
new guard shape.
* test(docs): address #7604 review - behavior-only tests + composer sizing docs
Review feedback on the PR flagged two repo-rule items: tests that pin source
strings, and an undocumented runtime behavior change. Both are addressed here.
- tests/test_long_session_composer_typing_latency.py: delete
test_fix_is_pinned_in_source. It asserted exact JS declarations, which the
repo testing guidance rejects and which the harness in this module already
covers by executing the REAL autoResize() body. In its place add
test_natural_row_skip_survives_every_append_of_a_typed_word, which drives the
actual typing loop ('' -> 'h' -> ... -> 'hello') and asserts zero height
writes on every keystroke. Red on the pre-fix tree, green with the fix.
- tests/test_issue5514_composer_grow_scroll_pin.py: convert
test_single_line_growth_skips_the_height_round_trip from source-string pins to
a behavior fixture whose getComputedStyle returns the composer's real computed
styles. It asserts the observable outcome: a one-row append at the natural
48px row takes no height write, while an oversized 176px composer still
remeasures back to one row. Red before the fix (2 writes), green after.
- docs/UIUX-GUIDE.md: document the composer sizing invariants in the composer
section - the single-row skip is a hot path and must stay reachable, its
ceiling is the natural one-row height or the CSS min-height (whichever is
larger), everything else still remeasures, and non-px computed values fail
closed.
./scripts/test.sh on the two touched modules: 29 passed (3 failed on the
pre-fix tree). Neighbour sweep over the 51 composer/scroll/typing modules:
386 passed. ruff check clean.
* fix(tests,docs): derive composer test dimensions from style.css (#7604 review)
Greptile's re-review is right: the fixtures hardcoded the 18px
`data-font-size=large` composer (line-height 29.7px -> a 47.7px one-row box) as
if it were the stylesheet's 16px default (44.4px). At the default the pre-fix
ceiling (ceil(44)+1 = 45px) DID clear the one-row box, so the regression is
font-size dependent - the 16px default reaches the skip, the 18px and 20px
appearance settings do not - and not a stock-configuration bug. The runtime
change is unaffected and unchanged.
- new `tests/_composer_metrics.py`: parses `static/style.css` (the base
`textarea#msg` rule, the `data-font-size` overrides, the skin overrides) and
exposes each configuration's box metrics - natural one-row height, the integer
offsetHeight a browser reports, and both the pre-fix and post-fix ceilings -
so a fixture cannot encode the wrong font size again.
- `tests/test_long_session_composer_typing_latency.py`: parameterized over every
configuration the stylesheet can produce, with
`test_the_regression_was_font_size_dependent_not_stock` pinning the grounding.
- `tests/test_issue5514_composer_grow_scroll_pin.py`: the converted single-row
test uses the same derived metrics.
- `docs/UIUX-GUIDE.md`: the sizing section now states that the natural row
tracks the appearance font size (44 / 48 / 51px) while `min-height` stays
44px, which is why a min-height-only ceiling silently disables the skip.
Pre-fix tree: `4 failed, 31 passed` - exactly the large-18px and xlarge-20px
parameterizations plus the two tests that assert the reported configuration.
Post-fix tree: `35 passed`. Neighbour sweep over the 51 composer/scroll/typing
modules: `392 passed`. ruff clean.
* fix(i18n): route sidebar source labels through t() (#7580)
_sessionSourceLabel() hardcoded the two visible tab labels as English
template literals and assigned the result straight to textContent, so
non-English locales rendered untranslated sidebar tabs. Route both
filters through t() with the established {0}-interpolation pattern
(same shape as kanban_comments_count) and add explicit en + ru keys
so English remains the fallback for locales without a translation.
- static/sessions.js: return t("sessions_source_cli", n) /
t("sessions_source_webui", n) instead of template literals
- static/i18n.js: add the two keys to the en and ru bundles
- tests/test_issue2351_cli_session_source_filter.py: replace the
hardcoded-English regressions with locale-key assertions and a new
interpolation test; verify the template-literal fallback is gone
so a regression can no longer ro-introduce it silently
* i18n(sessions-source): add source-tab labels to the remaining 13 locales
Codex gate: the runtime fallback is non-regressive, but the repo enforces a
locale key-parity contract — 9 parity tests failed because the two new keys
existed only in EN+RU. Add sessions_source_webui / sessions_source_cli to it,
ja, es, de, zh, zh-Hant, pt, ko, fr, cs, tr, pl, vi, matching each locale's
established translation of 'session' and preserving the {0} count placeholder.
Placed after each locale's _label line so the {0} literal does not fall inside
test_japanese_locale.py's brace-bounded _label regex.
Co-authored-by: Yularzhi <Yularzhi@users.noreply.github.com>
---------
Co-authored-by: nesquena-hermes <agent@hermes.local>
Co-authored-by: Yularzhi <Yularzhi@users.noreply.github.com>
Use native CSS field-sizing for composer auto-grow where supported, keeping the JS path as fallback. Includes a fallback-parity fix so an empty composer holds its resting height instead of measuring the placeholder.
Co-authored-by: starship-s <starship-s@users.noreply.github.com>
Nest delegated subagent sessions under the parent conversation that spawned them instead of forcing them to the sidebar top level.
Co-authored-by: lowlandsheperd <lowlandsheperd@users.noreply.github.com>
Classify Signal gateway sessions as messaging so they group, label, and list like other gateway platforms.
Co-authored-by: webtecnica <webtecnica@gmail.com>
* fix(composer): stop footer fit from jittering the transcript during SSE
_fitComposerFooter() measured overflow by REMOVING the .cf-icons/.cf-burger
stage classes, reading scrollWidth, then adding them back. Between those two
steps the footer is laid out at full width: the composer grows a few px and
#messages loses the same amount of clientHeight, then gets it back on the
next frame.
For a pinned reader that is a visible up/down jitter of the whole transcript,
because the fit pass runs on every context-indicator update — which fires
continuously while an SSE turn is streaming.
Measured with a real Chromium probe instrumenting the scrollTop setter,
scroll events, ResizeObserver and MutationObserver:
before: 4 non-JS backward jumps of exactly -8px per streamed turn,
each one ~30ms after a `ctx-indicator-wrap` -> `composer-footer`
class mutation pair
after: 0 backward jumps over 5 streamed turns on 2 different sessions
Fix: freeze the footer's layout box (explicit height + visibility:hidden)
for the duration of the measurement, so the intermediate expanded geometry
is never committed to the screen, and restore the resolved stage in the same
task. The adaptive behaviour is unchanged — verified across 8 viewport
widths (1600 -> 420px), with the correct full/icons/burger stage at each and
no residual inline style left behind.
Note: the guard added in the tail-jitter work is what keeps the reader pinned
here; before it, the same footer jitter unpinned the reader instead, which
masked the oscillation behind a worse bug.
* test(composer): cover footer fit freeze across full/icons/burger stages
Add a behavioural node driver test for _fitComposerFooter() that runs the
actual function from static/ui.js against a small layout model where the
stage classes dictate the footer's natural height and the left cluster's
content width. Every class mutation, inline-style write and overflow
measurement commits a layout sample, so the recorded footer heights and
messages clientHeights are what a browser would have painted.
The matrix starts from each stage (full, icons, burger), forces each
overflow outcome, and runs with empty and caller-owned prior inline
styles. It asserts the resolved stage classes, that the footer border-box
height and messages clientHeight stay pinned while the ladder is probed
(and do not move at all in the steady state), that no intermediate stage
is ever painted, and that prior inline height/visibility are restored
verbatim. It also covers the zero-height skip and an exception during
measurement, and includes a control run with the freeze made ineffective
to prove the harness reports the original jitter. Against the pre-fix
ui.js the height, jitter, painted-stage and style-restoration assertions
fail.
* docs(changelog): stamp #7275 composer footer-fit jitter fix
* ci: re-trigger (playwright browser install hung on GH runners)
* ci: re-trigger (google chrome apt mirror hash-sum mismatch, external infra)
---------
Co-authored-by: Anthony Ruiz <ruizanthony@users.noreply.github.com>
Co-authored-by: n <a@n>
openItem() received ordered but dropped it from the item constructor, so
it.ordered was always undefined and every item looked unordered at the
task-list check. An ordered item whose text begins with [x] or [ ] (e.g.
'1. [x] shipped') was silently converted to a task icon and lost its
literal prefix. Task-list rendering now applies only to unordered items.
Codex gate finding on #7385: the new fold-matching (#7228) normalizes the search
term by stripping [\s._-], so a separator-only query ('.', '-', '_') folds to '' and
String.includes('') is always true — every model matched. Verified: searching '.'
returned all models, including ids with no dot.
Guard the folded clauses with a non-empty foldTerm, keeping the literal
name/id includes() checks so '-' still matches ids that really contain one.
Real searches ('ox alpha' / 'ox-alpha' / 'sonnet') are unchanged.
Codex gate finding on #7427: moving _scheduleMessageVirtualizedRender() below the
_freshProgrammaticScrollActive() guard (the idle re-render-loop fix) removed the ONLY
thing that mounted the new render window on the jumpToSessionStart() streaming path,
which deliberately skips renderMessages(). Reproduced in Chromium: 160 rows produced a
14,827px spacer and zero visible rows (blank transcript) where master mounted correctly.
Schedule the virtualized render explicitly in the streaming branch, after invalidating
_messageVirtualWindowKey, preserving #7427's listener guard and its idle-loop fix.
routes.py appends a 'live anchor shell' row whenever a stream has events
but no visible rows projected yet:
row_id: lifecycle:<stream>:running
role: lifecycle
status: running
text: Working
source_event_type: runtime_journal_snapshot
_sourceEventTypeForSnapshotAnchorRow skips the source_event_type branch
for exactly that value, falls through to the role check, and the bare
phase==='running' arm then classified the shell as 'compressing'. Every
session that hit the path painted a 'Compressing context' divider while
nothing was compressing - including brand-new sessions on their first
message.
It also persists. The classification is written back into the session's
anchor_activity_scenes as source_event_type: 'compressing', which the
source branch above then returns directly on the next load, so the
phantom divider survives a reload. Measured on this host: 46 such rows
across 24 sessions, every one carrying the text 'Working' and not one a
real compression notice.
Require an explicit cue instead - phase==='compressing' or one of the
compression text markers already listed below it. The function's own
comment asked for this ('do not default every lifecycle row to a running
compress divider'); the bare running arm did the opposite.
tests/test_compression_phantom_barrier.py: 8 passed.