v0.47.0: dialogs, session menu, /skills, mobile fixes, mobile QA suite

* fix: custom provider with slash model name no longer rerouted to OpenRouter (#255)

When base_url is configured in config.yaml, resolve_model_provider() now
trusts the configured provider/base_url entirely and skips the slash-based
OpenRouter heuristic. Fixes google/gemma-4-26b-a4b with provider:custom
being silently routed to OpenRouter, resulting in 401 errors.

Fixes #230

* test: mobile layout regression suite — 14 tests for every QA run (#254)

Adds tests/test_mobile_layout.py with 14 static regression tests that run
on every QA pass to catch mobile layout breakage before it reaches prod.
Covers: breakpoints at 900px/640px, right panel slide-over CSS, mobile
overlay, bottom nav, files button, profile dropdown z-index, chip overflow,
workspace close, 100dvh, 44px touch targets, 16px font-size on textarea.

* feat: /skills slash command lists and filters available Hermes skills (#257)

Adds /skills [query] command to commands.js. Fetches from /api/skills,
groups by category (alphabetically sorted), displays as a formatted
assistant message. Optional query filters by name, description, or category.
i18n keys added for en, de, zh, zh-Hant. 1 regression test added.

Fixes #248

* feat: shared app dialogs replace native confirm()/prompt() calls (#251)

Adds showConfirmDialog() and showPromptDialog() helpers to ui.js, backed
by a themed #appDialogOverlay. Replaces all 11 native browser confirm/prompt
call sites across panels.js, sessions.js, ui.js, workspace.js.

Supports: danger mode, keyboard focus trap (Tab/Escape/Enter), focus restore,
ARIA roles, mobile-responsive stacked buttons at 640px. i18n for en/de/zh/zh-Hant.
5 new tests in test_sprint33.py verify markup, CSS, helpers, and absence of
native dialog calls.

Extracted from PR #242.

* fix: Android Chrome mobile — workspace panel close + profile dropdown (#256)

Fix #247: toggleMobileFiles() now shows/hides the mobile overlay when
toggling the right workspace panel. New closeMobileFiles() helper closes
the panel with correct overlay state tracking. Overlay onclick calls both
closeMobileSidebar() and closeMobileFiles(). Mobile-only close button (x)
added to workspace panel header.

Fix #246: profile dropdown uses position:fixed;top:56px;right:8px at
max-width:900px, escaping the overflow-x:auto stacking context that was
clipping it on Android Chrome.

Fix applied during review: closeMobileSidebar() now checks if the right
panel is still open before hiding the overlay, preventing the overlay from
disappearing when only the sidebar is closed.

Fixes #247 Fixes #246

* feat: session ⋯ action dropdown replaces per-row buttons (#252)

Replaces the 5 per-row hover action buttons (pin/move/archive/duplicate/trash)
with a single ⋯ trigger that opens a positioned dropdown menu. Menu has full
keyboard (Escape), click-outside, scroll, and resize-reposition handling.
Position:fixed prevents sidebar clipping.

5 actions: Pin/Unpin, Move to project, Archive/Unarchive, Duplicate, Delete
(danger style). Each with icon and descriptive subtitle.

Updated test_sprint16.py: test_sessions_js_uses_action_menu_not_per_row_buttons
asserts the new trigger and menu functions exist, old per-row classes are gone.

Extracted from PR #242.

* docs: v0.47.0 release notes, bump version, update test counts (645)

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
nesquena-hermes
2026-04-11 12:19:12 -07:00
committed by GitHub
parent c357ed9b74
commit b86ace6ce3
18 changed files with 855 additions and 94 deletions

177
tests/test_mobile_layout.py Normal file
View File

@@ -0,0 +1,177 @@
"""
Mobile layout regression tests — run on every QA pass.
These tests check that the CSS and HTML structure required for correct
mobile rendering (375px640px viewport widths) is intact after every change.
They are static checks (no server needed) that catch common regressions:
- Mobile breakpoints present for key layout elements
- Right panel slide-over markup and CSS intact
- Profile dropdown not clipped by overflow on mobile
- Composer footer chips scroll correctly on narrow viewports
- Mobile bottom nav and overlay markup present
- No full-viewport overflow that would break scroll
Run as part of the standard test suite:
pytest tests/test_mobile_layout.py -v
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
# ── Mobile breakpoint rules ───────────────────────────────────────────────────
def test_mobile_breakpoint_900px_present():
"""@media(max-width:900px) must hide the right panel and show mobile-files-btn."""
assert "@media(max-width:900px)" in CSS or "@media (max-width: 900px)" in CSS, \
"Missing @media(max-width:900px) breakpoint in style.css"
# Right panel should be hidden at 900px, replaced by slide-over
assert ".rightpanel{display:none" in CSS or ".rightpanel {display:none" in CSS or \
re.search(r'max-width:900px\).*?\.rightpanel\{display:none', CSS, re.DOTALL), \
".rightpanel must be display:none at max-width:900px (slide-over replaces it)"
def test_mobile_breakpoint_640px_present():
"""@media(max-width:640px) must exist for narrow phone layouts."""
assert "@media(max-width:640px)" in CSS or "@media (max-width: 640px)" in CSS, \
"Missing @media(max-width:640px) breakpoint in style.css"
def test_rightpanel_mobile_slide_over_css():
"""Right panel must have position:fixed slide-over CSS for mobile."""
# At max-width:900px the rightpanel should be position:fixed, off-screen right
assert "position:fixed" in CSS, \
"style.css must have position:fixed for rightpanel mobile slide-over"
assert ".rightpanel.mobile-open{right:0" in CSS or ".rightpanel.mobile-open {right:0" in CSS, \
".rightpanel.mobile-open must set right:0 to slide panel in from right"
assert "right:-320px" in CSS or "right: -320px" in CSS, \
"rightpanel must start off-screen (right:-320px) on mobile"
def test_mobile_overlay_present():
"""Mobile overlay element must exist for tap-to-close sidebar behavior."""
assert 'id="mobileOverlay"' in HTML, \
"#mobileOverlay element missing from index.html"
assert "mobile-overlay" in CSS, \
".mobile-overlay CSS rule missing from style.css"
def test_mobile_bottom_nav_present():
"""Mobile bottom navigation bar must be present."""
assert "mobile-bottom-nav" in HTML or "mobile-nav-btn" in HTML, \
"Mobile bottom nav (.mobile-bottom-nav or .mobile-nav-btn) missing from index.html"
assert "mobile-bottom-nav" in CSS, \
".mobile-bottom-nav CSS rule missing from style.css"
def test_mobile_files_button_present():
"""Mobile files toggle button (#btnMobileFiles) must be in HTML and CSS."""
assert 'id="btnMobileFiles"' in HTML, \
"#btnMobileFiles missing from index.html"
assert "mobile-files-btn" in CSS, \
".mobile-files-btn CSS missing from style.css"
# ── Profile dropdown overflow ─────────────────────────────────────────────────
def test_profile_dropdown_not_clipped_by_overflow():
"""Profile dropdown must not be inside an overflow:hidden or overflow-x:auto ancestor
without a higher z-index escape hatch.
The topbar-chips container uses overflow-x:auto on mobile, which creates a
stacking context that clips absolutely-positioned children. The profile dropdown
must use position:fixed on mobile OR the topbar-chips must not clip it.
"""
# The profile-chip wrapper must have position:relative so the dropdown can escape
assert 'id="profileChipWrap"' in HTML, \
"#profileChipWrap missing from index.html"
# Profile dropdown must have a z-index high enough to clear the topbar
assert ".profile-dropdown{" in CSS or ".profile-dropdown {" in CSS, \
".profile-dropdown CSS rule missing"
# z-index must be at least 200 (topbar is z-index:10)
m = re.search(r'\.profile-dropdown\{[^}]*z-index:(\d+)', CSS)
if m:
assert int(m.group(1)) >= 100, \
f".profile-dropdown z-index {m.group(1)} is too low — must be >= 100 to clear topbar"
def test_topbar_chips_mobile_overflow():
"""topbar-chips must use overflow-x:auto on mobile for chip scrolling.
Chips (profile, workspace, model, files) must scroll horizontally on narrow
viewports rather than wrapping onto a second line which would break the topbar layout.
"""
# At narrow viewport, topbar-chips should scroll
assert "overflow-x:auto" in CSS or "overflow-x: auto" in CSS, \
"topbar-chips must have overflow-x:auto for mobile chip scrolling"
# ── Workspace panel close ─────────────────────────────────────────────────────
def test_workspace_close_button_present():
"""Workspace panel must have a close/hide button accessible on mobile."""
# Either a dedicated mobile close button or the X button that closes the panel
has_close = (
'onclick="toggleMobileFiles()"' in HTML or
'toggleMobileFiles' in HTML
)
assert has_close, \
"toggleMobileFiles() must be wired to a button to close the workspace panel on mobile"
def test_toggle_mobile_files_js_defined():
"""toggleMobileFiles() must be defined in boot.js."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "function toggleMobileFiles()" in boot_js, \
"toggleMobileFiles() missing from static/boot.js"
assert "mobile-open" in boot_js, \
"toggleMobileFiles() must toggle mobile-open class on the right panel"
# ── Viewport and scroll safety ────────────────────────────────────────────────
def test_body_overflow_hidden():
"""body must have overflow:hidden to prevent double scrollbars on mobile."""
assert "body{" in CSS or "body {" in CSS, \
"body rule missing from style.css"
assert re.search(r'body\{[^}]*overflow:hidden', CSS), \
"body must have overflow:hidden to prevent double scrollbars"
def test_100dvh_viewport_height():
"""Layout must use 100dvh (dynamic viewport height) for correct mobile sizing.
On mobile Safari and Chrome, 100vh includes the browser chrome (address bar),
causing content to be hidden. 100dvh accounts for the actual available height.
"""
assert "100dvh" in CSS, \
"style.css must use 100dvh for correct mobile viewport height (100vh hides content under address bar)"
def test_composer_touch_target_size():
"""Send button and composer inputs must have minimum 44px touch targets on mobile.
Apple HIG and Google Material guidelines both require 44px minimum touch targets.
"""
# Check that mobile CSS doesn't make the send button smaller than 44×44
# We check that there's at least a min-height definition for touch targets
assert re.search(r'(min-height|height).*44px', CSS), \
"style.css must define 44px minimum touch targets for mobile (send button, nav buttons)"
# ── Input zoom prevention ─────────────────────────────────────────────────────
def test_composer_textarea_font_size_mobile():
"""Composer textarea must have font-size >= 16px on mobile.
iOS Safari zooms the viewport when an input with font-size < 16px is focused,
which breaks the layout. The composer textarea must be >= 16px at mobile widths.
"""
# Check for 16px font-size on the textarea in a mobile breakpoint
assert re.search(r'font-size:16px', CSS), \
"Composer textarea must have font-size:16px at mobile widths to prevent iOS zoom-on-focus"

View File

@@ -378,3 +378,46 @@ def test_custom_endpoint_uses_model_config_api_key_for_model_discovery(monkeypat
groups = {g['provider']: [m['id'] for m in g['models']] for g in result['groups']}
assert 'Custom' in groups
assert 'gpt-5.2' in groups['Custom']
# -- Issue #230: custom provider with slash model name -----------------------
def test_custom_endpoint_slash_model_routes_to_custom_not_openrouter():
"""Regression test for #230.
When provider=custom (or any non-openrouter provider) and base_url is set,
a model name containing a slash (e.g. google/gemma-4-26b-a4b) must NOT be
rerouted to OpenRouter -- it should stay on the configured custom endpoint.
"""
# --- custom provider with slash model name should NOT go to openrouter ---
model, provider, base_url = _resolve_with_config(
'google/gemma-4-26b-a4b',
provider='custom',
base_url='http://127.0.0.1:1234/v1',
default='google/gemma-4-26b-a4b',
)
assert provider.startswith('custom'), (
"Expected provider starting with 'custom', got '{}'. "
"Slash in model name should NOT trigger OpenRouter rerouting when base_url is set.".format(provider)
)
assert base_url == 'http://127.0.0.1:1234/v1', (
"Expected base_url 'http://127.0.0.1:1234/v1', got '{}'.".format(base_url)
)
assert model == 'google/gemma-4-26b-a4b', (
"Model name should be preserved as-is, got '{}'.".format(model)
)
# --- openrouter with slash model name MUST still route to openrouter -----
model_or, provider_or, _ = _resolve_with_config(
'google/gemma-4-26b-a4b',
provider='openrouter',
base_url='https://openrouter.ai/api/v1',
default='google/gemma-4-26b-a4b',
)
assert provider_or == 'openrouter', (
"Expected provider 'openrouter', got '{}'. "
"Slash model via openrouter provider must still resolve to openrouter.".format(provider_or)
)
assert model_or == 'google/gemma-4-26b-a4b', (
"Model name should be preserved for openrouter, got '{}'.".format(model_or)
)

View File

@@ -472,3 +472,24 @@ def test_upload_error_has_no_trace_field():
assert "trace" not in body, \
"Upload errors must not leak stack traces to clients"
assert "error" in body, "Error responses must include an 'error' key"
# ── #248: /skills slash command ───────────────────────────────────────────────
def test_skills_slash_command_defined():
"""#248: /skills command must be registered in COMMANDS and implemented.
Verifies the command entry, function definition, and i18n key are all present.
"""
src = (REPO_ROOT / "static/commands.js").read_text()
# 1. 'skills' must appear in the COMMANDS array definition
assert "name:'skills'" in src or 'name:"skills"' in src, \
"COMMANDS array must include an entry with name:'skills'"
# 2. cmdSkills function must be defined
assert "function cmdSkills" in src, \
"cmdSkills function must be defined in commands.js"
# 3. i18n key cmd_skills must be referenced (wired to COMMANDS entry)
assert "cmd_skills" in src, \
"cmd_skills i18n key must be referenced in commands.js"

View File

@@ -700,10 +700,20 @@ def test_style_css_active_session_uses_gold(cleanup_test_sessions):
"Active session gold color (#e8a030) not found in style.css"
def test_sessions_js_active_skips_project_border(cleanup_test_sessions):
"""sessions.js must not override active session border-left with project color."""
def test_sessions_js_uses_action_menu_not_per_row_buttons(cleanup_test_sessions):
"""sessions.js must use the single ⋯ action menu instead of per-row buttons.
The per-row button overlay was replaced with a single ⋯ trigger that opens a
positioned dropdown (session-action-menu). This removes the borderLeftColor
project colour override that the old code applied, which was the original
concern this test guarded. The new design uses a dot indicator for project
membership instead.
"""
src = REPO_ROOT / "static" / "sessions.js"
code = src.read_text()
# The fix: only set borderLeftColor if NOT the active session
assert "isActive" in code, "isActive check not found in sessions.js"
assert "borderLeftColor" in code, "borderLeftColor not found in sessions.js"
assert "session-actions-trigger" in code, "session-actions-trigger not found in sessions.js"
assert "_openSessionActionMenu" in code, "_openSessionActionMenu not found in sessions.js"
assert "closeSessionActionMenu" in code, "closeSessionActionMenu not found in sessions.js"
# The old per-row buttons must not be present (they were replaced by the menu)
assert "act-pin" not in code, "old act-pin per-row button still in sessions.js"
assert "act-archive" not in code, "old act-archive per-row button still in sessions.js"

59
tests/test_sprint33.py Normal file
View File

@@ -0,0 +1,59 @@
"""
Sprint 33 Tests: Shared app dialogs replace native confirm/prompt usage.
These tests verify the static assets expose the reusable confirm/input modal
and that browser-native confirm/prompt calls are no longer used in the Web UI.
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
def read(path):
return (REPO / path).read_text(encoding="utf-8")
def test_index_has_shared_app_dialog_markup():
html = read("static/index.html")
assert 'id="appDialogOverlay"' in html
assert 'id="appDialog"' in html
assert 'id="appDialogTitle"' in html
assert 'id="appDialogDesc"' in html
assert 'id="appDialogInput"' in html
assert 'id="appDialogCancel"' in html
assert 'id="appDialogConfirm"' in html
def test_app_dialog_css_rules_exist():
css = read("static/style.css")
for selector in (
".app-dialog-overlay",
".app-dialog",
".app-dialog-input",
".app-dialog-actions",
".app-dialog-btn.confirm",
".app-dialog-btn.confirm.danger",
):
assert selector in css, f"missing CSS selector: {selector}"
def test_ui_js_exposes_shared_dialog_helpers():
src = read("static/ui.js")
assert "function showConfirmDialog(opts={})" in src
assert "function showPromptDialog(opts={})" in src
assert "document.addEventListener('keydown'" in src
def test_no_native_confirm_calls_remain_in_static_js():
for path in (REPO / "static").glob("*.js"):
src = path.read_text(encoding="utf-8")
assert not re.search(r"\bconfirm\s*\(", src), f"native confirm() remains in {path.name}"
def test_no_native_prompt_calls_remain_in_static_js():
for path in (REPO / "static").glob("*.js"):
src = path.read_text(encoding="utf-8")
assert not re.search(r"\bprompt\s*\(", src), f"native prompt() remains in {path.name}"