(1) Send-button tooltip clipping fix:
The send button (btnSend) sits at the right edge of the composer area.
Its side-positioned tooltip extended 'Send message' (~95px wide) past
the viewport edge, leaving only 'Se' visible in some viewports —
confirmed by maintainer screenshot review.
Added a new `.has-tooltip--left` variant that flips the tooltip to
the LEFT side of the trigger via `right: calc(100% + 8px)` instead
of `left: calc(100% + 8px)`. Applied to btnSend in index.html.
Browser-verified: full 'Send message' text now readable to the left
of the gold Send button, no clipping.
(2) Test compatibility for the tooltip coverage expansion:
5 pre-existing tests hardcoded specific class strings or 'title='
attributes that no longer apply after we added has-tooltip + replaced
title= with data-tooltip= on 11 high-traffic icon buttons.
- tests/test_issue1488_composer_voice_buttons.py:
- test_dictation_button_has_dictate_i18n_key: accept either
title='Dictate' or data-tooltip='Dictate' as the static fallback.
- test_buttons_have_distinct_static_titles: extracted helper
_static_tooltip() that prefers data-tooltip over title.
- tests/test_sprint20.py::test_mic_button_has_mic_btn_class:
regex tolerant to additional utility classes between icon-btn and
mic-btn (now 'icon-btn mic-btn has-tooltip').
- tests/test_sprint20b.py::test_send_button_has_title_attribute:
accept title= OR data-tooltip= per #1775.
- tests/test_sprint20b.py::test_send_button_still_has_send_btn_class:
regex tolerant to additional utility classes.
- tests/test_workspace_panel_session_list.py::TestWorkspacePanelCollapsePriority::test_panel_header_no_longer_uses_space_between:
panel-header was changed from overflow:hidden to overflow:visible
so its tooltips can escape the header bar. The title-text ellipsis
moved to the inner span (.panel-header > span:first-child) which
already had its own overflow:hidden + text-overflow:ellipsis.
Test now accepts either parent-level or inner-span overflow handling.
All 192 of the previously-failing or impacted tests now pass.
Browser-verified two issues with stage-311 tooltip rendering:
(1) Workspace panel header tooltips (NewFile, NewFolder, Refresh, etc.)
were being clipped because .panel-header had overflow:hidden. The
title span at `.panel-header > span:first-child` already has its own
overflow:hidden + text-overflow:ellipsis for the workspace name
truncation, so the parent doesn't need it. Changed .panel-header to
overflow:visible — verified tooltip now floats correctly below the
icon row, ellipsis on the title still works because the inner span
handles it locally.
(2) Strengthened tooltip body styling per browser screenshot review:
- Border: var(--border) (#2A2A45 dark slate) → var(--accent-bg-strong)
(gold-tinted at 15% alpha). Subtle brand-tied edge that's slightly
more visible against the very dark page background.
- Shadow: 6px/20px / 0.55 alpha + 1px ring at 0.25 → 8px/24px / 0.65
alpha + 1px ring at 0.35 + 1px inset highlight at 0.04 alpha. Gives
the tooltip more elevation against the dark theme so it reads as a
floating element rather than painted onto the background.
All 19 tooltip pytest checks still pass. Browser-verified on rail
(Tasks, Settings), composer (Attach files, Send message), and workspace
panel header (New folder) — screenshots delivered to maintainer for
visual sign-off.
Browser verification of the rail tooltip showed the 5px arrow ::before
pseudo-element was rendering as a tiny rectangle slice (not a triangle)
because the global `*, ::before, ::after { box-sizing: border-box }`
reset makes the colored border eat inward from a 10×10 box rather than
projecting outward from a 0×0 box. Adding `box-sizing: content-box`
inline to the pseudo fixes the geometry but at 11px text size and 5px
border-width the resulting triangle reads as visual noise rather than
a clear connector — multiple AI vision passes consistently couldn't
identify the arrow even when it was rendering correctly.
VS Code, Slack, and Linear's rail/icon-button tooltips all skip the
arrow for the same reason: spatial proximity at small sizes (an 8px gap
between trigger and tooltip body) is sufficient association without
the visual clutter of a tiny triangle.
Removes both ::before pseudo-rules. Tooltip body unchanged. Side
tooltip moved 12px → 8px gap (closer to trigger now that the arrow is
gone), bottom tooltip 10px → 8px for the same reason.
Browser-verified: rail Tasks tooltip rendering at 8/10 polish per
vision-AI assessment of the standalone tooltip body (solid surface bg,
solid border, warm-white text, 6px shadow + 1px ring, z-index 1500).
Co-authored-by: Jason Wu <jasonjcwu@users.noreply.github.com>
Stage 311 maintainer-side enhancements on top of @jasonjcwu's PR #1782,
addressing browser-verified issues + extending coverage to high-traffic
icon buttons:
(1) Clear native title when custom data-tooltip is present (the core bug fix):
- static/i18n.js: when data-i18n-title runs against an element that has
data-tooltip, sync data-tooltip AND removeAttribute('title'). Without
this, the slow ~1.5s native browser tooltip co-fires alongside the
fast custom CSS tooltip — exactly the bug #1775 reports.
- static/ui.js _applyDashboardStatus: same treatment for the dashboard
rail/mobile buttons (was setting btn.title=warning unconditionally).
- static/boot.js: added _setButtonTooltip() helper, replaced 6 direct
.title assignments (workspace toggle/collapse/clear, voice dictate,
voice mode active/inactive) with calls through the helper.
(2) Extend coverage to high-traffic icon buttons in static/index.html:
- Composer area (side tooltip): btnAttach, btnMic, btnVoiceMode,
btnWorkspacePanelToggle, btnSend.
- Workspace panel header (bottom tooltip): btnCollapseWorkspacePanel,
btnUpDir, btnNewFile, btnNewFolder, btnRefreshPanel, btnClearPreview.
- All 11 buttons gain has-tooltip[--bottom] class and data-tooltip,
lose their native title=. Total covered surfaces: rail (12), sidebar
nav-tabs (12), panel-head (31), composer/workspace icons (11) = 66.
(3) CSS polish (browser-verified visible improvement):
- z-index 60 → 1500/1501 so the tooltip clears all sidebar/panel
stacking contexts. Earlier verification showed the tooltip overlapping
the Filter conversations search input.
- background: var(--bg-strong, ...) → var(--surface) (solid #1A1A2E
instead of falling back via undefined cascade).
- color: var(--text, var(--accent-text)) → var(--text) (solid warm white
#FFF8DC instead of gold which clashed at body-text size).
- border: var(--accent-bg-strong) → var(--border) (#2A2A45 solid
instead of gold at 0.15 alpha — the old border was barely visible
and the arrow ::before triangle was invisible).
- shadow: 4px/0.45 alpha → 6px/0.55 alpha + 0 0 0 1px ring fallback.
- Added 150ms hover-onset delay (matches Cygnus's spec in #1775); 0s
dismissal-delay so quick mouse-aways don't leave the tooltip behind.
- Fixed has-tooltip--bottom arrow direction: was pointing down (wrong),
now points up at the trigger (border-color order corrected).
- Bumped offsets: side tooltip 10px → 12px (clearance from icon edge),
bottom tooltip 8px → 10px.
(4) Test fixes (the 2 CI failures):
- tests/test_cron_refresh_button_835.py: assertion accepts either
title= or data-tooltip= per #1775 (was hardcoded title=).
- tests/test_mobile_layout.py::test_profiles_sidebar_tab_present:
regex tolerant to additional utility classes (has-tooltip).
(5) Regression tests added to tests/test_css_tooltips.py:
- test_native_title_cleared_when_custom_tooltip_present: pins the
removeAttribute('title') call so we don't regress to dual tooltips.
- test_native_title_path_preserved_for_non_tooltip_elements: pins the
el.title fallback for elements without data-tooltip.
Browser-verified: all 72 has-tooltip elements have zero native title at
runtime (was 94 with native, 2 stuck via dashboard JS path).
Co-authored-by: Jason Wu <jasonjcwu@users.noreply.github.com>
- Add .has-tooltip CSS utility class with 300ms delay (vs ~1500ms native)
- Position-aware: right side for rail buttons, bottom for nav/panel buttons
- Arrow indicator pointing back at trigger element
- :focus-visible support for keyboard accessibility
- prefers-reduced-motion: no animation for users who opt out
- Replace native title="" with data-tooltip="" on all rail-btn, sidebar
nav-tab, and panel-head-btn elements in index.html
- Sync data-tooltip via data-i18n-title handler for locale switching
- 17 tests covering HTML coverage, CSS class definitions, and i18n sync
Closes#1775
When pasting screenshots into the composer (especially multiple in
sequence, now possible end-to-end with hermes-webui/hermes-swift-mac
PR #74) the user has no way to verify the right image attached. The
56x56 thumbnail in the chip is fine as a UI affordance but offers no
detail at all. Quote from the request:
When I hit Cmd+C and save an image to the clipboard and then paste
the clipboard out, I want to be able to click on any one of those
uploaded images that's inside the composer bar and have it zoom up
like a lightbox so I can see the image in full once it's been
pasted in to the composer input.
The lightbox infrastructure already exists for message-attached
images (static/ui.js:269 _openImgLightbox + the doc-level click
delegate at :298 for .msg-media-img). This PR extends the same
delegate to also fire on .attach-thumb composer chips:
- Clicking the thumbnail opens the existing image lightbox with the
blob URL as src and the file name as alt text.
- Audio/video chips are excluded (they have their own native
<audio> / <video> controls and don't render an .attach-thumb
img).
- SVG thumbnails (.attach-thumb attach-thumb--svg) qualify — they
are images visually.
- The chip's x remove button is a sibling, not an ancestor, of the
thumb — closest('.attach-thumb') from the button returns null,
so removing still works without lightbox interference.
Also updates static/style.css:
- cursor: zoom-in on .attach-thumb (was cursor: default — actively
misleading).
- Subtle :hover emphasis (brightness 1.05 + scale 1.04, 120ms ease)
so users discover the affordance before clicking.
5 regression tests in tests/test_composer_chip_lightbox.py pinning:
- delegate handles .attach-thumb on IMG elements
- delegate still handles .msg-media-img (no regression)
- audio/video chips do NOT render an .attach-thumb img
- cursor:zoom-in declared on the .attach-thumb selector
- hover emphasis rule present
Browser-verified live on port 8789:
- addFiles three distinct screenshot files (mimicking three Mac
sequential pastes) -> 3 chips, 3 thumbs, all distinct.
- Click thumb #2 -> lightbox opens with the right image, alt text
matches filename.
- Click x on chip #2 -> removes that chip, no lightbox.
- Escape key closes lightbox.
Companion PR on the Mac side:
hermes-webui/hermes-swift-mac#74 (unique filename per paste so
sequential pastes actually appear as distinct chips).
Refs nesquena/hermes-webui#1733.
- Add .model-opt-provider chip (right-aligned, muted) on every model row
that belongs to a provider group, making same-name models across
providers visually distinguishable at a glance.
- Add per-group model count to group headings: 'OpenRouter (47)'.
- Add subtle border-top divider between provider groups for visual
separation during scroll.
Scope: Shape A from #1425 — smallest change, ~15 LOC, no API churn.
Note: Settings model picker is a native <select> and already has optgroup
labels; this targets the custom dropdown used in the composer.
Closes#1425
Closes the remaining gaps to first-party Hermes Agent dashboard parity:
multi-board CRUD on /api/kanban/boards and a real-time event stream over
Server-Sent Events. Builds on top of #1660 (review-feedback hardening).
== Multi-board ==
Five new endpoints mirror the agent dashboard plugin contract verbatim
(plugins/kanban/dashboard/plugin_api.py) so a single CLI / gateway slash
command / dashboard / WebUI all share the same active-board pointer:
GET /api/kanban/boards
POST /api/kanban/boards
PATCH /api/kanban/boards/<slug>
DELETE /api/kanban/boards/<slug>
POST /api/kanban/boards/<slug>/switch
All existing endpoints accept ?board=<slug> (and writes also accept
'board' in the JSON body) — query takes precedence over body. The slug
travels through the kanban_db library which already had multi-board
support; the bridge is mostly thin wrappers around create_board /
remove_board / list_boards / set_current_board / get_current_board.
The default board is protected from deletion. Slugs are normalised
through kb._normalize_board_slug() with path-traversal rejection.
Archive is the default for DELETE; ?delete=1 hard-deletes.
Frontend gets a 'Default ▾' switcher pill in the panel header. The menu
lists every board (current first), per-status total badges, plus three
actions (New / Rename / Archive). Create + rename use the same modal
with a slug auto-derived from the name. Archive routes through the
existing showConfirmDialog with a clear 'tasks remain on disk and the
board can be restored from kanban/boards/_archived/' message.
Active-board state is persisted to localStorage so a refresh stays put.
The on-disk pointer in kanban/current is the cross-process source of
truth, kept in sync via POST /boards/<slug>/switch.
== SSE event stream ==
GET /api/kanban/events/stream is a long-lived Server-Sent Events feed
that mirrors the agent dashboard's WebSocket /events contract. The
WebUI uses SSE rather than WebSocket because (1) the existing transport
is BaseHTTPServer, not async — WS would require a significant refactor
or a hijack-the-socket hack; (2) SSE is the right tool for unidirectional
server-pushed event streams; (3) browsers auto-reconnect on drop;
(4) the existing /api/approval/stream and /api/clarify/stream patterns
are proven and easy to copy.
The handler polls task_events at 300ms (matching the agent dashboard's
WebSocket poll cadence) so write-to-receive latency is identical.
Heartbeats every 15s prevent proxy/CDN reaping. Hard cap of 200 events
per batch.
Frontend uses EventSource by default and falls back to 30s HTTP polling
after 3 SSE failures. A 250ms debounce coalesces bursts of N events
into a single board re-fetch. Stream is torn down when the user leaves
the Kanban panel.
== Bugs fixed during build ==
(1) read_only=True legacy lie. _board_payload, _events_payload,
_task_log_payload, and the no-change short-circuit all hardcoded
read_only=True from the read-only-bridge era of #1645. Bridge has
been writable since #1649 — flag now matches reality.
(2) Modal + dropdown menu transparent backgrounds. The PR stack used
var(--panel) which is undefined in the WebUI design system (uses
--surface, --bg, gradient panels). Replaced with the same gradient
+ accent border pattern used by the .app-dialog overlay.
(3) Archive race. kb.connect(board=<slug>) auto-materialises the
directory + sqlite on first call, so any in-flight SSE poll on a
board mid-archive would silently un-archive it by re-creating the
directory. Two-layer fix: (a) frontend stops the SSE stream BEFORE
the DELETE call, restarts on failure; (b) bridge's _kanban_sse_fetch_new
checks kb.board_exists() before connect(), returning empty results
when the board is gone.
(4) Save vs. Cancel button visual hierarchy. Both rendered as identical
secondary buttons in the modal. Save now uses the .primary class
with accent-tinted gold styling.
(5) Mobile viewport gaps. Added 9 rules under @media (max-width: 640px)
covering the switcher button (smaller padding/font), name truncation
(max-width:140px), menu sizing (min(280px, 100vw - 24px)), modal
padding, and inline-row stacking.
== Tests ==
+45 new tests across two files. Bridge tests: 18 covering board CRUD
endpoints, slug validation, default-board protection, dispatcher routing,
board isolation (verified via connect() spy), and 3 SSE tests including
a worker-thread integration test with threading.Event watchdog. UI static
tests: 11 covering switcher markup, modal markup, JS handler presence,
REST verb usage, board-param plumbing, localStorage persistence,
showConfirmDialog usage, EventSource subscription, polling fallback,
panel-switch teardown, and 250ms debouncing.
Bridge tests: 18 → 36 (+18 multi-board, +3 SSE)
UI static tests: 15 → 26 (+11)
Total kanban: 33 → 63
Full repo test suite: 4351 passed, 0 regressions.
== Live verification ==
End-to-end browser walkthrough on port 8789:
- Create Sprint 12 + Backlog via modal: switcher updates ✓
- Switch between boards: count isolation correct ✓
- Add task on Sprint 12 via API: SSE delivers in 400ms ✓
- 5-task burst: 250ms debounce coalesces to single render ✓
- Rename board via modal: switcher label updates ✓
- Archive board: confirm dialog → board moved to _archived/, no zombie
directory (race fix verified) ✓
- Zero JS errors throughout 11-step flow
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
Four follow-up issues found in the combined-stack live verification:
(1) handle_kanban_get had no exception handler; ImportError (webui-only deploy
without hermes_cli), ValueError, LookupError, RuntimeError would bubble
as 500. Wrapped in same exception cascade as POST/PATCH/DELETE.
(2) ImportError on any verb now returns 503 "kanban unavailable: <reason>"
instead of 500. Frontend's existing try/catch surfaces a clean toast.
(3) The 'Read-only view' banner (legacy of read-only PR #1645) was always
visible regardless of actual board state. Default-hidden in HTML;
loadKanban() toggles based on _kanbanBoard.read_only.
(4) .btn / .btn.secondary class names were referenced in 4 places (Bulk
action / Nudge dispatcher / New task / Back to board) but no matching
CSS shipped — buttons rendered as browser-default beveled controls
that clashed with the dark theme. Added scoped CSS rules under the
kanban-* parent containers.
+4 behavioral + static UI tests covering the contracts.
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
Two related dropdown bugs in one PR — same root shape (model-picker
endpoints disagreeing about which Nous Portal models exist) plus the
preemptive UX guard against the picker becoming unusable on large-tier
Nous accounts.
#1567 — Endpoint disagreement
=============================
Reporter (Deor, Discord, May 03 2026) saw Settings → Providers card
showing "Nous Portal — 396 models · OAuth" while the in-conversation
picker dropdown listed only the four hardcoded curated entries.
Two structural causes:
1. ``api/providers.py:get_providers`` iterates ALL OAuth providers
regardless of authentication state and unconditionally live-fetches
the catalog.
2. ``api/config.py:_build_available_models_uncached`` only iterates
providers in ``detected_providers``, gated on
``hermes_cli.models.list_available_providers().authenticated``.
That flag can disagree with ``get_auth_status(<id>).logged_in`` on
some hermes_cli versions.
When the disagreement happens for Nous, the picker silently falls
through to the curated 4-entry static list while the providers card
keeps showing the live catalog — exactly the asymmetry users report.
Plus: the Nous live-fetch branch in `_build_available_models_uncached`
fell back to the same curated 4-entry list when `provider_model_ids`
returned an empty list (transient failure / OAuth refresh in flight),
which doubles down on the disagreement instead of healing it.
UX cap (the design concern Nathan flagged on triage)
====================================================
Even with the disagreement fixed, dumping a 397-model catalog into a
flat dropdown is unusable. We trim the visible picker to a curated
~15-entry featured set when the catalog exceeds 25 models, and surface
the rest under a new ``extra_models`` field so:
- ``/model`` slash autocomplete (commands.js) covers the full catalog
- ``_dynamicModelLabels`` (ui.js) hydrates from both lists, so a model
selected from outside the featured slice still gets a proper label
- The optgroup label gets ``" (15 of 397)"`` appended so the user
understands the dropdown is intentionally trimmed, not broken
- The providers card surfaces ``models_total`` separately so the
header still reads "397 models · OAuth"
- A small "+N more" disclosure pill appears at the end of the rendered
pill list (only fires for non-OAuth providers — OAuth cards never
render pills) with a tooltip pointing at the slash command
Featured selection rules
------------------------
Deterministic; same algorithm runs in both `/api/models` and
`/api/models/live` so background enrichment doesn't undo the trim:
1. Always include the user's currently-selected model (sticky — no
orphan IDs in the dropdown after a refresh)
2. Always include every entry from the curated static
``_PROVIDER_MODELS["nous"]`` list whose id maps onto a live id
3. Top up to 15 by walking ``_NOUS_VENDOR_PRIORITY`` round-robin
(one model per vendor each pass) so no vendor monopolises the slots
Changes by file
===============
api/config.py
- New `_format_nous_label` neighbour: `_NOUS_FEATURED_THRESHOLD = 25`,
`_NOUS_FEATURED_TARGET = 15`, `_NOUS_VENDOR_PRIORITY` tuple,
`_build_nous_featured_set()` helper (~80 LOC)
- `_build_available_models_uncached` Nous branch:
- Apply featured-set cap with sticky-selection signal
- Return `extra_models` alongside `models` for the catalog tail
- Decorate optgroup label with truncation count
- Drop stale-4 fallback when authenticated but live-fetch empty
(omit the group entirely; truth lives in the providers card and
the next cache rebuild will heal it)
- Keep stale-4 fallback when hermes_cli is unavailable (test envs,
package mismatches) — that's a different failure mode
- Detection symmetry: explicit `get_auth_status("nous").logged_in`
check after the existing `list_available_providers()` loop, so the
picker matches the providers card on hermes_cli versions where the
two signals disagree
api/providers.py:get_providers
- Apply same featured-set cap so card body doesn't render 397 pills
- Add `models_total` field reporting full catalog size (used by
frontend for the "N models · OAuth" header text)
api/routes.py:_handle_live_models
- Apply same featured-set cap for `/api/models/live` so background
enrichment via `_fetchLiveModels()` doesn't undo the dropdown trim
- Use sticky-selection from `cfg["model"]["model"]` matching the main
endpoint's logic
static/ui.js:populateModelDropdown
- Hydrate `_dynamicModelLabels` from `g.extra_models` so a selection
outside the visible dropdown still renders with its proper label
static/commands.js:_loadSlashModelSubArgs
- Iterate `group.extra_models` so `/model` autocomplete covers the
full catalog (not just the trimmed featured slice)
static/panels.js:_buildProviderCard
- Header count uses `p.models_total` (full catalog size) instead of
`p.models.length` (trimmed slice)
- Render trailing "+N more" disclosure pill when `models.length <
models_total` with a tooltip pointing at the slash command
static/style.css
- New `.provider-card-model-tag-more` rule (italic, dashed border,
cursor:help, no select) — visually distinct from real model pills
Tests
=====
`tests/test_issue1567_nous_picker_capacity_and_symmetry.py` (20 tests):
- TestBuildNousFeaturedSet (8): unit tests on the helper —
small-catalog no-op, large-catalog cap to target, disjoint+complete
invariants, priority-vendor round-robin guarantee, sticky selection
with and without `@nous:` prefix, curated-flagship preservation,
empty-catalog handling, determinism
- TestApiModelsLargeCatalog (2): /api/models cap behavior end-to-end
on a synthetic 397-model catalog vs a 20-model catalog
- TestNousDetectionSymmetry (2): picker includes Nous when
`get_auth_status` agrees but `list_available_providers` disagrees;
picker omits Nous when both disagree
- TestNousLiveFetchEmpty (2): authenticated + empty-fetch omits group;
hermes_cli unavailable still falls back to static-4
- TestProvidersCardPickerSymmetry (1): both endpoints agree on
exactly the same featured-set IDs + total catalog count
- TestFrontendExtrasContract (4): static-source assertions pinning
the JS contract for `extra_models`, `models_total`, and the "+N more"
disclosure
Verified live on port 8789 (30-model catalog):
- /api/models Nous group: provider="Nous Portal (15 of 30)", 15 models,
15 extra_models
- /api/models/live?provider=nous: 15 entries (matches main path)
- /api/providers Nous card: models_total=30, models=15
- Browser dropdown after backfill: 15 options, 30 entries in
_dynamicModelLabels
- Sticky selection: Claude Opus 4.7 (the active model) in the featured
slice as expected
4073 pytest passed (was 4053 → 4073, +20 from this PR).
3 CI test runs (3.11/3.12/3.13) green.
QA harness 11/11 passed.
Reporter: Deor (Discord #report-bugs, May 03 2026 14:15 PT)
Relayed by: AvidFuturist