Files
hermes-webui/tests/test_issue856_pinned_indicator_layout.py
T
nesquena-hermes ad8e10304c v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object

Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.

The correct translations already exist in the zh-Hant locale block.

Fixes #1008

* fix: block stale SSE events from polluting new session's DOM

- appendThinking(): guard with !S.session||!S.activeStreamId to drop
  events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
  scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
  streaming if user is watching (scroll pinned)

* Fix empty agent sessions in sidebar

* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status

Fixes #995 — three sub-issues in the Cron Jobs UI:

1. Dual play icons ambiguous: Resume button now shows a distinct
   play+bar icon (play triangle + vertical line) instead of the
   identical triangle used by Run now.

2. Toast notification overlapping header buttons: Added
   position:relative; z-index:10 to .main-view-header so it
   stacks above the fixed toast (z-index:100 within its layer).

3. No running status after trigger: After triggering a job, the
   status badge immediately shows 'running…' with a CSS spinner
   animation, and polls the cron list every 3s (up to 30s) to
   refresh when the job completes.

- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()

* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback

- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback

* feat: create folder and add space directly from UI (#782)

- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales

* fix: validate workspace path before mkdir to prevent orphan directories

Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.

New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir

Also imports _workspace_blocked_roots for the pre-mutation blocklist check.

* fix(#1014): classify model-not-found errors with helpful message

- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n

* feat(ui): add live TPS stat to header

Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.

Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
  window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock

Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
  updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end

Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
  focus/blur was replaced with simple always-1s-when-active approach)

UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)

* feat: session restore speed + title gen reasoning hardening (#1025, #1026)

PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session

PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback

Co-authored-by: Frank Song <franksong2702@gmail.com>

* feat: session attention indicators in right slot + last_message_at timestamps (#1024)

PR #1024 (@franksong2702): Polish session attention indicators

- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
  so metadata-only saves don't make old sessions appear under Today

Co-authored-by: Frank Song <franksong2702@gmail.com>

* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 13:07:35 -07:00

135 lines
7.4 KiB
Python

"""Regression checks for #856 pinned-star layout in the session list."""
from pathlib import Path
SESSIONS_JS = (Path(__file__).resolve().parent.parent / "static" / "sessions.js").read_text()
STYLE_CSS = (Path(__file__).resolve().parent.parent / "static" / "style.css").read_text()
def test_pinned_indicator_renders_inside_title_row():
title_row_idx = SESSIONS_JS.find("titleRow.className='session-title-row';")
assert title_row_idx != -1, "session title row construction not found"
assert "body.appendChild(_renderOneSession(s, Boolean(g.isPinned)))" in SESSIONS_JS
assert "function _renderOneSession(s, isPinnedGroup=false)" in SESSIONS_JS
assert "if(s.pinned&&!isPinnedGroup){" in SESSIONS_JS
pin_idx = SESSIONS_JS.find("pinInd.className='session-pin-indicator';", title_row_idx)
assert pin_idx != -1, "pinned indicator creation not found after title row"
append_to_title_row_idx = SESSIONS_JS.find("titleRow.appendChild(pinInd);", pin_idx)
assert append_to_title_row_idx != -1, "pinned indicator should be appended to titleRow"
append_to_el_idx = SESSIONS_JS.find("el.appendChild(pinInd);", pin_idx)
assert append_to_el_idx == -1, (
"pinned indicator should not be appended to the outer session row; "
"it must align inside the title row with the spinner/unread indicator"
)
def test_pinned_indicator_uses_fixed_indicator_box():
assert ".session-pin-indicator{" in STYLE_CSS, "session pin indicator CSS block missing"
css_block = STYLE_CSS[STYLE_CSS.find(".session-pin-indicator{"):STYLE_CSS.find(".session-pin-indicator svg{")]
assert "width:10px;" in css_block, "pin indicator should reserve a fixed 10px width"
assert "height:10px;" in css_block, "pin indicator should reserve a fixed 10px height"
assert "justify-content:center;" in css_block, "pin indicator should center the star inside its box"
def test_state_indicator_uses_right_actions_slot_to_prevent_title_shift():
"""State span reuses the right-side action slot so the title start position
does not shift when the spinner or unread dot appears/disappears."""
title_row_idx = SESSIONS_JS.find("titleRow.className='session-title-row';")
assert title_row_idx != -1, "title row construction not found"
title_row_append_idx = SESSIONS_JS.find("titleRow.appendChild(state);", title_row_idx)
assert title_row_append_idx == -1, (
"state indicator should not be inserted before the title; it should reuse "
"the right-side actions slot to avoid title shift"
)
state_idx = SESSIONS_JS.find("state.className='session-attention-indicator session-state-indicator'")
assert state_idx != -1, "right-side attention indicator creation not found"
append_to_row_idx = SESSIONS_JS.find("el.appendChild(state);", state_idx)
assert append_to_row_idx != -1, "state indicator should be appended to the outer row"
actions_idx = SESSIONS_JS.find("actions.className='session-actions';", append_to_row_idx)
assert actions_idx != -1, "session actions should still be appended after attention indicator"
assert ".session-attention-indicator{" in STYLE_CSS, "attention indicator CSS rule missing"
css_block = STYLE_CSS[
STYLE_CSS.find(".session-attention-indicator{"):
STYLE_CSS.find(".session-item:hover .session-attention-indicator")
]
assert "position:absolute;" in css_block, "attention indicator should be positioned in the row action slot"
assert "right:6px;" in css_block, "attention indicator should align with the actions trigger"
assert "width:26px;" in css_block, "attention indicator should use the same width as the actions trigger"
assert "height:26px;" in css_block, "attention indicator should use the same height as the actions trigger"
assert ".session-attention-indicator.is-streaming::before{" in STYLE_CSS
inner_spinner_block = STYLE_CSS[
STYLE_CSS.find(".session-attention-indicator.is-streaming::before{"):
STYLE_CSS.find(".session-attention-indicator.is-unread::before{")
]
assert "width:10px;" in inner_spinner_block, "spinner glyph should stay 10px inside the 26px action slot"
assert "height:10px;" in inner_spinner_block, "spinner glyph should stay 10px inside the 26px action slot"
hover_rule = ".session-item:hover .session-attention-indicator"
assert hover_rule in STYLE_CSS, "hover rule should hide attention indicator when actions appear"
def test_timestamp_hidden_when_attention_state_is_present():
assert "+(hasUnread?' unread':'')" in SESSIONS_JS
assert "const hasAttentionState=isStreaming||hasUnread;" in SESSIONS_JS
assert "ts.className='session-time'+(hasAttentionState?' is-hidden':'');" in SESSIONS_JS
assert "ts.textContent=hasAttentionState?'':_formatRelativeSessionTime(tsMs);" in SESSIONS_JS
assert ".session-time.is-hidden{display:none;}" in STYLE_CSS
assert ".session-item{padding:8px 86px 8px 8px;" in STYLE_CSS
assert ".session-item.streaming,.session-item.unread{padding-right:40px;}" in STYLE_CSS
assert ".session-item{min-height:44px;padding:10px 86px 10px 12px;}" in STYLE_CSS
session_time_block = STYLE_CSS[
STYLE_CSS.find(".session-time{"):
STYLE_CSS.find(".session-time.is-hidden")
]
assert "position:absolute;" in session_time_block
assert "right:10px;" in session_time_block
assert ".session-item:hover .session-time" in STYLE_CSS
assert ".session-item.streaming:not(:hover):not(:focus-within):not(.menu-open) .session-actions" in STYLE_CSS
assert ".session-item.unread:not(:hover):not(:focus-within):not(.menu-open) .session-actions" in STYLE_CSS
def test_sidebar_uses_local_inflight_state_for_immediate_spinner():
messages_js = (Path(__file__).resolve().parent.parent / "static" / "messages.js").read_text()
assert "const isLocalStreaming=Boolean(" in SESSIONS_JS
assert "(isActive&&S.busy)" in SESSIONS_JS
assert "INFLIGHT[s.session_id]" in SESSIONS_JS
assert "const isStreaming=Boolean(s.is_streaming||isLocalStreaming);" in SESSIONS_JS
assert "if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();" in messages_js
def test_date_group_caret_expanded_down_collapsed_right():
assert "caret.textContent='\\u25BE';" in SESSIONS_JS
assert ".session-date-caret{" in STYLE_CSS
caret_block = STYLE_CSS[
STYLE_CSS.find(".session-date-caret{"):
STYLE_CSS.find(".session-date-caret.collapsed")
]
assert "transform:rotate(0deg);" in caret_block
assert ".session-date-caret.collapsed{transform:rotate(-90deg);}" in STYLE_CSS
def test_apperror_path_calls_render_session_list():
"""apperror handler must call renderSessionList() to clear the streaming indicator
immediately rather than waiting for the 5s streaming poll interval."""
messages_js = (Path(__file__).resolve().parent.parent / "static" / "messages.js").read_text()
apperror_idx = messages_js.find("source.addEventListener('apperror'")
assert apperror_idx != -1, "apperror handler not found in messages.js"
warning_idx = messages_js.find("source.addEventListener('warning'", apperror_idx)
assert warning_idx != -1, "warning handler not found after apperror handler"
apperror_block = messages_js[apperror_idx:warning_idx]
assert "renderSessionList()" in apperror_block, (
"apperror handler must call renderSessionList() so the streaming indicator "
"clears immediately on server errors, not after a 5s poll delay"
)