Files
Kevin Meinon be5ee8f503 perf(composer): make the single-row autoResize fast path reachable (long-session typing lag) (#7604)
* 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.
2026-09-16 07:38:43 -07:00

11 KiB

UI/UX Guide

This document summarizes UI/UX principles that are already visible in the repository. It is a contributor guide, not a new design proposal. Source documents include DESIGN.md, README.md, THEMES.md, docs/ui-ux/index.html, docs/ui-ux/two-stage-proposal.html, and design comments in static/style.css.

Use this guide when a change touches layout, chat rendering, composer chrome, navigation, theme/skin behavior, responsive behavior, or visual hierarchy. For purely backend changes, use the runtime/state contracts instead.

Product shape

Hermes WebUI is a browser workbench for Hermes Agent with near-CLI parity and a simple implementation shape: Python on the server, vanilla JavaScript in the browser, no build step, no bundler, and no frontend framework.

The primary layout is three-panel:

  • left sidebar for sessions and navigation,
  • center panel for chat,
  • right panel for workspace file browsing and previews.

Model, profile, workspace, attachments, voice input, context usage, Stop, and Send controls live in the composer footer. Settings and session-level tools live in the Hermes Control Center. Preserve this shape unless the change explicitly justifies a different interaction model.

Core feeling: calm developer console

The main artifact is the conversation. Tool calls, thinking traces, context compaction records, token usage, runtime status, and other internals are useful, but they are transcript metadata. They should sit below user and assistant prose in visual priority.

Prefer:

  • quiet surfaces,
  • clear spacing,
  • restrained accent use,
  • progressive disclosure for debugging detail,
  • legible text over decorative chrome.

Avoid turning the interface into a demo page of colorful cards. Errors, approvals, and other action-required states may be prominent because the user must notice and respond to them.

Conversation hierarchy

A chat turn should read as one coherent story:

  1. User message: right-aligned, compact bubble.
  2. Assistant content: left-aligned, prose-first, not a heavy bubble.
  3. Tool, thinking, progress, and context traces: quiet disclosure rows inside or adjacent to the assistant turn.
  4. Raw logs and verbose details: hidden until explicitly expanded.

Do not render every internal event as a first-class chat card. A turn that used many tools should summarize the work as inspectable activity, not make the user read a stack of unrelated-looking cards.

Tool, thinking, and activity traces

Tool cards are debug event rows, not chat messages. Show the icon, name, short target or preview, and status first. Arguments, result snippets, and long logs belong behind expansion, with result snippets truncated and full output behind a show-more affordance where needed.

Thinking and context cards should share the quiet metadata visual family. They should not overpower assistant prose. Collapsed activity summaries should be terse, for example Activity: 4 tools, and should not duplicate the thinking area, list every tool name in the summary, or add redundant trailing count badges.

Visible interim assistant progress is part of the live conversation timeline, not raw debug detail. Compact Activity may collapse tool arguments, long tool results, and low-level reasoning detail, but it must not make concise user-visible progress text available only inside a collapsed disclosure.

Automatic compression is a live-only context barrier, not a special branded tool card. Render it as a centered, non-interactive divider with quiet horizontal rules: Compressing context while the compression barrier is active and Context auto-compressed when the agent has continued or the compression completion event arrives. Do not give it a caret, click target, leading status dot, or standalone running badge. In settled final history, remove live-only automatic compression rows unless they explain a visible recovery or error state.

The existing two-stage proposal in docs/ui-ux/two-stage-proposal.html records a compatible direction for long turns: live work can be grouped as a worklog, then settled history can collapse while the final answer reads as the calm conclusion. Treat that page as an existing proposal, not as shipped behavior unless the code and tests prove it is implemented.

Typography and content

Use three explicit font tokens:

  • --font-ui: shell chrome, controls, composer, labels, and ordinary UI text
  • --font-conversation: user/assistant message prose; by default this is var(--font-ui) in static/style.css
  • --font-mono: code, file paths, command lines, tool payloads, technical logs, and terminal output

Use semantic tokens for typography. Keep prose on --font-conversation by default so it tracks --font-ui whenever a skin intentionally retunes UI type. Override --font-conversation only for a skin that intentionally wants a distinct prose face. Conversation prose must remain the system sans by default. Do not introduce a global conversation serif through the --font-conversation token or selector-level overrides without explicit design approval plus code and test evidence; keep distinct editorial prose typography explicitly opt-in or skin-scoped. Avoid hard-coding selector-level font stacks when a token already carries the intent.

Keep scale tight. Avoid introducing near-duplicate one-off font sizes, colors, radius values, or spacing values when an existing token works.

Color, depth, and shape

Use one accent at a time. Semantic colors are for semantic state: success, warning, error, and info. Do not mix many bright colors decoratively in the same viewport.

Use almost no shadows in the transcript. Reserve shadows for popovers, dropdowns, modals, and floating controls. Chat cards should usually use either a subtle border or a subtle tint, not aggressive combinations of both.

Avoid stacks of nested rounded rectangles. Rows and list items should feel compact; panels and cards may be slightly rounder; true pills are reserved for chips and badges.

Composer and controls

The composer is the command surface. Keep it legible, stable, and focused:

  • no theatrical hover scaling for routine controls,
  • no ambient chrome that crowds the model/workspace/profile controls,
  • no new footer buttons on tight layouts without a clear value tradeoff,
  • keep Stop/Send and context feedback easy to find while composing.

When adding a control, consider where users will find it on both wide desktop and mobile. If a setting or quota/control surface does not fit in the composer, route it through the appropriate Control Center panel instead of squeezing the footer.

Composer sizing

The composer grows with its content up to a 200px cap. Where the browser supports field-sizing: content (the stylesheet sets it on textarea#msg, with field-sizing: fixed while the placeholder shows) CSS owns that; everywhere else the JavaScript fallback (autoResize() in static/messages.js) measures, and it runs on every keystroke - so treat it as a hot path and keep these invariants when touching it (regression coverage: tests/test_long_session_composer_typing_latency.py, which derives every dimension from static/style.css, and tests/test_issue5514_composer_grow_scroll_pin.py):

  • A single-row append that already fits its box skips the height round trip. That round trip reads scrollHeight, which forces a synchronous layout of the whole document, so its cost grows with the rendered transcript - this is the long-session typing-lag class. Do not remove the skip.
  • The skip's ceiling is the textarea's natural ONE-ROW height (line-height + vertical padding + borders) or the CSS min-height, whichever is larger. Compare against that natural row, never against min-height alone: the natural row follows the appearance font size (44px at the 16px default, 48px at data-font-size=large, 51px at xlarge), while min-height stays 44px, so a min-height-only ceiling silently disables the skip for the larger sizes.
  • Everything else still fully remeasures: an oversized composer, a replacement, a shrink, a multi-line append, and session/draft restore.
  • Non-pixel computed values (a percentage, calc(), auto) fail closed to the full resize rather than enabling the skip from a bogus pixel parse.

Responsive behavior

Mobile is not an afterthought. The repository documents a responsive layout with a hamburger sidebar, mobile-accessible top tabs, a right-edge file slide-over, full-height chat/composer behavior on phones, and touch-friendly controls.

For UI changes, verify the relevant states:

  • wide desktop,
  • ordinary laptop width,
  • narrow/mobile width,
  • open and closed side panels when relevant,
  • long chat content and live streaming when relevant.

Controls should remain usable at touch sizes, and mobile navigation should not steal chat height unnecessarily.

Themes and skins

Theme and skin work should use the existing variable system. THEMES.md points to the core palette variables in static/style.css; skin comments in the CSS show the expected pattern for full palette rewrites and accent-only changes.

Current implementation has two appearance axes, sourced from static/boot.js: theme is only light, dark, or system and resolves to the .dark class for dark mode; skin is a separate axis applied with data-skin and currently includes default, ares, mono, slate, poseidon, sisyphus, charizard, sienna, catppuccin, nous, and geist-contrast / Geist Contrast. slate is both an active skin and a legacy theme-name migration target; solarized, monokai, nord, and oled are legacy theme names mapped to current theme/skin pairs. Do not follow stale data-theme-only guidance without first proving the current static/boot.js, static/index.html, and static/style.css contracts still support it.

Do not hardcode new colors, radii, shadows, or typography values into isolated components when a token or existing variable can carry the intent. If a token is missing, explain why a new one is needed.

Evidence expected for UI changes

For any interface or interaction change:

  • include before/after images or a short video,
  • mention the tested viewport sizes and responsive states,
  • reference the affected visual inventory or design source when applicable,
  • add or update tests for behavior, state persistence, or regression-prone DOM structure where practical,
  • keep stable class or data hooks when they help future visual regression tests.

Do / don't summary

Do:

  • keep the conversation primary,
  • collapse noisy internals by default when settled,
  • make debugging details accessible without making them visually dominant,
  • use existing tokens, variables, and component patterns,
  • protect action-required states such as errors and approvals.

Don't:

  • make every tool call look like a separate chat message,
  • add decorative color or motion without a user-facing reason,
  • introduce a frontend framework, bundler, or build step for ordinary UI work,
  • hide important recovery, error, or approval state,
  • treat proposal mockups as shipped behavior without code/test evidence.