HERMES-GIELIE/tests/test_mobile_layout.py

1711 lines
86 KiB
Python
Raw Normal View History

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>
2026-04-11 19:19:12 +00:00
"""
Mobile layout regression tests — run on every QA pass.
These tests check that the CSS and HTML structure required for correct
mobile rendering (375px–640px 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 sidebar navigation stays available on phones
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>
2026-04-11 19:19:12 +00:00
- No full-viewport overflow that would break scroll
Run as part of the standard test suite:
./scripts/test.sh tests/test_mobile_layout.py -v
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>
2026-04-11 19:19:12 +00:00
"""
import pathlib
import re
from html.parser import HTMLParser
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>
2026-04-11 19:19:12 +00:00
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")
def _max_width_media_blocks(width_px):
"""Return all @media(max-width:Npx) bodies using balanced braces."""
pattern = re.compile(rf'@media\s*\(\s*max-width\s*:\s*{width_px}px\s*\)\s*\{{')
blocks = []
for match in pattern.finditer(CSS):
open_brace = match.end() - 1
depth = 0
for idx in range(open_brace, len(CSS)):
if CSS[idx] == "{":
depth += 1
elif CSS[idx] == "}":
depth -= 1
if depth == 0:
blocks.append(CSS[open_brace + 1:idx])
break
return blocks
def _composer_phone_media_block():
for block in _max_width_media_blocks(640):
if ".composer-footer" in block:
return block
raise AssertionError("Missing composer rules in @media(max-width:640px)")
def _strip_css_comments(css):
return re.sub(r'/\*.*?\*/', '', css, flags=re.DOTALL)
def _rule_body(css, selector):
for match in re.finditer(r'([^{}]+)\{([^{}]*)\}', _strip_css_comments(css)):
selectors = {part.strip() for part in match.group(1).split(",")}
if selector in selectors:
return match.group(2)
raise AssertionError(f"Missing CSS rule for {selector}")
def _declarations(rule_body):
declarations = {}
for item in rule_body.split(";"):
if ":" not in item:
continue
prop, value = item.split(":", 1)
declarations[prop.strip()] = re.sub(r'\s+', ' ', value.strip())
return declarations
def _js_function_body(source, function_name):
"""Return a JavaScript function body using balanced braces."""
match = re.search(rf'function\s+{re.escape(function_name)}\s*\([^)]*\)\s*\{{', source)
if not match:
raise AssertionError(f"Missing JavaScript function {function_name}()")
open_brace = match.end() - 1
depth = 0
for idx in range(open_brace, len(source)):
if source[idx] == "{":
depth += 1
elif source[idx] == "}":
depth -= 1
if depth == 0:
return source[open_brace + 1:idx]
raise AssertionError(f"Could not parse JavaScript function {function_name}()")
def _optional_declarations(css, selector):
try:
return _declarations(_rule_body(css, selector))
except AssertionError:
return {}
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
def _js_function_body(src, name):
signature = f"function {name}("
start = src.find(signature)
if start == -1:
raise AssertionError(f"Missing JS function {name}()")
brace = src.find("{", start)
if brace == -1:
raise AssertionError(f"Missing function body for {name}()")
depth = 0
for idx in range(brace, len(src)):
char = src[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return src[brace + 1:idx]
raise AssertionError(f"Unterminated JS function {name}()")
def _display_hidden(declarations):
return declarations.get("display", "").replace(" ", "") in {"none", "none!important"}
def _display_inline_flex(declarations):
return declarations.get("display", "").replace(" ", "") in {"inline-flex", "inline-flex!important"}
class _ComposerLeftDropdownParser(HTMLParser):
_VOID_TAGS = {
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
}
def __init__(self):
super().__init__()
self.stack = []
self.violations = []
def handle_starttag(self, tag, attrs):
self._handle_element(tag, attrs, push=True)
def handle_startendtag(self, tag, attrs):
self._handle_element(tag, attrs, push=False)
def handle_endtag(self, tag):
tag = tag.lower()
for idx in range(len(self.stack) - 1, -1, -1):
if self.stack[idx]["tag"] == tag:
del self.stack[idx:]
break
def _handle_element(self, tag, attrs, push):
tag = tag.lower()
attrs = dict(attrs)
classes = set((attrs.get("class") or "").split())
element_id = attrs.get("id") or ""
inside_composer_left = any(
"composer-left" in item["classes"] for item in self.stack
)
is_dropdown = (
element_id.endswith("Dropdown") or
any("dropdown" in class_name for class_name in classes)
)
if inside_composer_left and is_dropdown:
label = f"#{element_id}" if element_id else "." + ".".join(sorted(classes))
self.violations.append(label)
if push and tag not in self._VOID_TAGS:
self.stack.append({"tag": tag, "classes": classes})
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>
2026-04-11 19:19:12 +00:00
# ── 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_settings_system_version_controls_wrap_on_phone_widths():
"""Settings -> System version badges must wrap instead of overflowing phones."""
mobile_css = "\n".join(_max_width_media_blocks(768))
assert ".settings-section-head" in mobile_css, (
"Settings section header needs a mobile rule so title and update controls stack."
)
assert "flex-direction:column" in mobile_css.replace(" ", ""), (
"Settings section header should stack vertically on mobile."
)
assert "#checkUpdatesBlock" in mobile_css, (
"Settings update/version controls need a mobile rule."
)
assert "flex-wrap:wrap" in mobile_css.replace(" ", ""), (
"Version badges and Check now button must wrap instead of overflowing."
)
assert "width:100%" in mobile_css.replace(" ", ""), (
"The update controls row should take the available mobile width."
)
assert ".settings-version-badge" in mobile_css and "white-space:nowrap" in mobile_css.replace(" ", ""), (
"Individual version badges should stay intact while the group wraps."
)
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>
2026-04-11 19:19:12 +00:00
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 "min(300px, 100vw)" in CSS or "min(300px,100vw)" in CSS, \
"rightpanel mobile width should be capped defensively with 100vw"
assert "var(--mobile-rightpanel-width)" in CSS, \
"mobile rightpanel width variable should be used in compact mode rules"
assert "calc(-1 * var(--mobile-rightpanel-width))" in CSS, \
"closed mobile rightpanel should be off-canvas using a width-based negative offset"
mobile_640 = re.search(r'@media\(max-width:640px\)\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}', CSS, re.DOTALL)
assert mobile_640, "@media(max-width:640px) block missing from style.css"
rightpanel_block = mobile_640.group(1)
assert re.search(r'\.rightpanel\{[^}]*width:\s*var\(--mobile-rightpanel-width\)\s*!important',
rightpanel_block, re.DOTALL), \
".rightpanel width must use var(--mobile-rightpanel-width) with !important in mobile block"
assert re.search(r'\.rightpanel\.mobile-open\{[^}]*right:\s*0\s*!important',
rightpanel_block, re.DOTALL), \
"mobile-open mobile rightpanel must force right:0 with !important"
assert re.search(r'\.rightpanel\{[^}]*box-shadow:\s*none\s*!important',
rightpanel_block, re.DOTALL), \
"closed mobile rightpanel should have no shadow to avoid right-edge bleed"
assert re.search(r'\.rightpanel\{[^}]*padding-top:\s*var\(--app-titlebar-safe-top\)',
rightpanel_block, re.DOTALL), \
"mobile rightpanel should reserve the same PWA top inset as the titlebar"
assert re.search(r'\.rightpanel\{[^}]*box-sizing:\s*border-box',
rightpanel_block, re.DOTALL), \
"mobile rightpanel safe-area padding must stay inside its fixed height"
assert re.search(r'\.rightpanel\.mobile-open\{[^}]*box-shadow:\s*-4px 0 24px rgba\(0,\s*0,\s*0,\s*\.?4\)',
rightpanel_block, re.DOTALL), \
"open mobile rightpanel should keep the edge shadow"
2026-05-15 14:58:24 +00:00
assert re.search(r'\.rightpanel\s+\.panel-header\{[^}]*row-gap:\s*8px', rightpanel_block), \
"mobile workspace header should keep comfortable row spacing"
def test_mobile_sidebar_drawer_uses_transform_instead_of_left():
"""Mobile sidebar drawer open/close must animate with transform not left offsets."""
mobile_640 = "\n".join(_max_width_media_blocks(640))
assert mobile_640, "Missing @media(max-width:640px) block in style.css"
sidebar_rule = _declarations(_rule_body(mobile_640, ".sidebar"))
sidebar_open_rule = _declarations(_rule_body(mobile_640, ".sidebar.mobile-open"))
assert sidebar_rule.get("left") == "0", \
"Mobile .sidebar should keep left:0 in the drawer rules"
assert sidebar_rule.get("transform") == "translateX(-100%)", \
"Closed mobile .sidebar should use transform:translateX(-100%)"
assert sidebar_rule.get("transition") == "transform .25s ease", \
"Mobile .sidebar should transition transform for drawer animation"
assert sidebar_rule.get("will-change") == "transform", \
"Mobile .sidebar should promote the transform layer before drawer animation"
assert sidebar_open_rule.get("transform") == "translateX(0)", \
"Open mobile .sidebar should use transform:translateX(0)"
def test_workspace_panel_inline_width_is_desktop_only():
"""Persisted rightpanel width must only be restored above compact/mobile breakpoints."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "function _syncWorkspacePanelInlineWidth()" in boot_js, \
"_syncWorkspacePanelInlineWidth() must exist to keep panel width mobile-safe"
assert "_syncWorkspacePanelInlineWidth();" in boot_js, \
"_syncWorkspacePanelInlineWidth() must be called when viewport changes"
assert "localStorage.getItem('hermes-panel-w')" in boot_js, \
"Panel width helper must source hermes-panel-w from localStorage"
assert "_workspacePanelEls();" in boot_js and "style.removeProperty('width')" in boot_js, \
"Panel helper must clear inline width while in compact/mobile viewport"
def test_workspace_panel_boot_restore_is_desktop_only():
"""Persisted workspace panels should not auto-cover compact/mobile launch."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "if(_ephPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';" in boot_js
assert "if(S.session&&S.session.workspace&&panelPref&&!_isCompactWorkspaceViewport()){" in boot_js
assert "if(_freshPanelPref&&!_isCompactWorkspaceViewport()) _workspacePanelMode='browse';" in boot_js
def _container_query_block(css: str, container_query: str):
query_pattern = re.compile(
rf'@container\s+{re.escape(container_query)}\s*\{{',
re.DOTALL,
)
for match in query_pattern.finditer(css):
start = match.end() - 1
end = css.find("@container", start + 1)
if end == -1:
end = css.find("@media", start + 1)
if end == -1:
end = len(css)
block = css[start + 1:end]
return block
return ""
def _container_media_block(css: str, media_query: str):
query_pattern = re.compile(
rf'@media\s*\(\s*max-width:\s*{re.escape(media_query)}\s*\)\s*\{{',
re.DOTALL,
)
def _media_block_end(css_text: str, open_brace_idx: int) -> int:
depth = 0
for idx in range(open_brace_idx, len(css_text)):
if css_text[idx] == "{":
depth += 1
elif css_text[idx] == "}":
depth -= 1
if depth == 0:
return idx
return -1
def _strip_nested_media(block: str) -> str:
parts = []
cursor = 0
while True:
nested = block.find("@media", cursor)
if nested == -1:
parts.append(block[cursor:])
break
parts.append(block[cursor:nested])
nested_open = block.find("{", nested)
if nested_open == -1:
break
nested_close = _media_block_end(block, nested_open)
if nested_close == -1:
break
cursor = nested_close + 1
return "".join(parts)
for match in query_pattern.finditer(css):
start = match.end() - 1
end = _media_block_end(css, start)
if end == -1:
continue
block = css[start + 1:end]
block = _strip_nested_media(block)
if ".composer-profile-label" in block or ".composer-profile-chip" in block:
return block
return ""
def test_composer_controls_switch_to_fit_stage_classes():
"""Composer controls should progressively compact based on measured overflow."""
assert re.search(r'\.composer-footer\s*\{[^}]*container-type:inline-size[^}]*container-name:composer-footer[^}]*\}', CSS), \
".composer-footer should define container-type:inline-size and container-name:composer-footer"
assert "@container composer-footer (max-width: 700px)" not in CSS
assert "@container composer-footer (max-width: 520px)" not in CSS
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
fit_body = _js_function_body(ui_js, "_fitComposerFooter")
assert "scrollWidth" in fit_body and "clientWidth" in fit_body, \
"_fitComposerFooter() should measure actual left-cluster overflow"
assert "cf-icons" in fit_body and "cf-burger" in fit_body, \
"_fitComposerFooter() should toggle the compact stage classes"
for selector in (
".composer-footer.cf-icons .composer-profile-label",
".composer-footer.cf-icons .composer-profile-chevron",
".composer-footer.cf-icons #composerProfileLabel",
".composer-footer.cf-icons .composer-workspace-label",
".composer-footer.cf-icons #composerWorkspaceLabel",
".composer-footer.cf-icons .composer-model-label",
".composer-footer.cf-icons #composerModelLabel",
".composer-footer.cf-icons .composer-profile-chip",
".composer-footer.cf-icons .composer-model-chip",
".composer-footer.cf-icons .composer-divider",
):
assert selector in CSS, f"{selector} should be present in the .cf-icons rules"
assert ".composer-footer.cf-icons .composer-profile-chip{box-sizing:border-box;width:44px" in CSS, \
".cf-icons should collapse the profile chip to an icon-sized control"
assert ".composer-footer.cf-icons .composer-workspace-chip{display:none!important" not in CSS.replace(" ", ""), \
".cf-icons should keep the workspace switch visible rather than blanking it"
for selector in (
".composer-footer.cf-burger .composer-workspace-group",
".composer-footer.cf-burger .composer-workspace-files-btn",
".composer-footer.cf-burger .composer-workspace-chip",
".composer-footer.cf-burger .composer-left > .composer-model-wrap",
".composer-footer.cf-burger .provider-quota-chip",
".composer-footer.cf-burger .composer-left > .composer-reasoning-wrap",
".composer-footer.cf-burger .composer-left > .composer-toolsets-wrap",
".composer-footer.cf-burger .composer-mobile-config-btn",
".composer-footer.cf-burger .composer-mobile-config-panel.open",
".composer-footer.cf-burger .composer-mobile-context-action",
".composer-footer.cf-burger .ctx-indicator-wrap",
):
assert selector in CSS, f"{selector} should be present in the .cf-burger rules"
assert ".composer-footer.cf-burger .composer-workspace-chip{display:none!important".replace(" ", "") in CSS.replace(" ", ""), \
".cf-burger must remove the blank workspace switch slot"
assert ".composer-footer.cf-burger .provider-quota-chip" in CSS and ".composer-footer.cf-burger .composer-left > .composer-toolsets-wrap{display:none!important" in CSS, \
".cf-burger must fold the inline quota chip into the shared config menu"
assert ".composer-footer.cf-burger .composer-mobile-config-btn{box-sizing:border-box;position:relative;display:inline-flex!important" in CSS, \
".cf-burger must expose the config button even on wider viewports"
# Regression intent:
# - this measured rule should not depend on right-panel open/closed state.
# - left-sidebar-only constriction must still collapse composer controls together.
assert ".layout:not(.workspace-panel-collapsed)" not in CSS, \
"composer-footer compact stages should be state-agnostic"
def test_composer_icon_stage_workspace_switch_does_not_become_blank_chip():
"""The icon stage may hide workspace text, but keeps a visible switch affordance."""
css_ns = CSS.replace(" ", "")
assert ".composer-footer.cf-icons .composer-workspace-label" in CSS
assert ".composer-footer.cf-icons #composerWorkspaceLabel" in CSS
assert ".composer-footer.cf-icons .composer-workspace-chip{display:none!important" not in css_ns, \
".cf-icons must not hide the visible workspace switch chip"
assert ".composer-footer.cf-icons .composer-workspace-chevron{display:none" not in css_ns, \
".cf-icons must not leave the visible workspace switch chip without its chevron"
def test_composer_compact_switch_is_not_viewport_only():
"""Compact controls should be content-triggered, not bound to viewport width alone."""
assert ".composer-footer.cf-icons" in CSS, \
"Icon-chip stage should be expressed as the .cf-icons class"
assert ".composer-footer.cf-burger" in CSS, \
"Hamburger stage should be expressed as the .cf-burger class"
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
assert "_fitComposerFooter" in ui_js and "scrollWidth" in ui_js, \
"Composer compacting should be driven by measured overflow"
assert "ResizeObserver" in ui_js and "MutationObserver" in ui_js, \
"Composer fitting should rerun when footer size or chip content changes"
assert "composer-footer (max-width: 700px)" not in CSS
assert "composer-footer (max-width: 520px)" not in CSS
assert re.search(r'@container\s+composer-footer\s*\(max-width:\s*860px\)', CSS) is None, \
"Full icon-only should not be tied to a 860px threshold any more"
assert re.search(r'@container\s+composer-footer\s*\(max-width:\s*1000px\)', CSS) is None, \
"Full icon-only/first-stage container gate should not be tied to 1000px"
media_860 = _container_media_block(CSS, "860px")
assert media_860 == "", \
"Composer compact breakpoint should not be a dedicated 860px viewport media query"
media_900 = _container_media_block(CSS, "900px")
assert media_900 == "", \
"Composer compact breakpoint should use container queries, not viewport media at 900px"
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>
2026-04-11 19:19:12 +00:00
def test_mobile_overlay_present():
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
"""Legacy mobile overlay stays hidden because the phone sidebar is full-screen."""
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>
2026-04-11 19:19:12 +00:00
assert 'id="mobileOverlay"' in HTML, \
"#mobileOverlay element missing from index.html"
assert "mobile-overlay" in CSS, \
".mobile-overlay CSS rule missing from style.css"
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
mobile_css = "\n".join(_max_width_media_blocks(640))
assert re.search(r'\.mobile-overlay\.visible\{[^}]*display:\s*none', mobile_css), (
"Full-screen mobile sidebar must not dim the PWA status/safe-area with a backdrop"
)
def test_mobile_sidebar_edge_guard_claims_body_edge_only():
"""A narrow body-only edge guard helps iOS hand left swipes to WebUI."""
assert 'id="pwaSidebarEdgeGuard"' in HTML, (
"mobile sidebar edge guard missing from index.html"
)
assert ".pwa-sidebar-edge-guard{display:none;}" in CSS.replace(" ", ""), (
"edge guard should be hidden outside the phone layout"
)
mobile_css = "\n".join(_max_width_media_blocks(640))
guard = _declarations(_rule_body(mobile_css, ".pwa-sidebar-edge-guard"))
assert guard.get("display") == "block"
assert guard.get("position") == "fixed"
assert guard.get("left") == "0"
assert guard.get("top") == "calc(52px + var(--app-titlebar-safe-top))", (
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
"edge guard should start below the PWA titlebar so it does not block hamburger"
)
assert guard.get("width") == "24px"
assert guard.get("pointer-events") == "none", (
"edge guard must be pointer-events:none so taps/vertical scrolls starting in the "
"strip fall through to the .messages scroller; the edge-swipe gesture is handled by "
"window-level capture listeners, not by the guard intercepting hit-testing (#4660 review)"
)
assert guard.get("z-index") == "198", (
"edge guard should sit below the full-screen sidebar but above the page body"
)
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>
2026-04-11 19:19:12 +00:00
def test_sidebar_nav_present():
"""Sidebar top navigation tabs must be present."""
assert 'class="sidebar-nav"' in HTML, \
".sidebar-nav missing from index.html"
assert ".sidebar-nav{" in CSS or ".sidebar-nav {" in CSS, \
".sidebar-nav CSS rule missing from style.css"
def test_mobile_keeps_panel_navigation_available():
"""Phone breakpoint must keep panel navigation available inside the drawer."""
mobile_css = "\n".join(_max_width_media_blocks(640))
assert mobile_css, "Missing @media(max-width:640px) block in style.css"
assert ".sidebar-nav" in mobile_css, \
"Phone panel navigation must remain available in the hamburger drawer"
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
def test_mobile_sidebar_opens_as_full_screen_surface_with_panel_rail():
"""Phone sidebar should open full-screen while keeping the panel rail visible."""
mobile_css = "\n".join(_max_width_media_blocks(640))
assert re.search(r'\.app-titlebar-hamburger,\s*\.app-titlebar-spacer\{[^}]*display:\s*flex', mobile_css), (
"Phone titlebar hamburger must stay visible"
)
assert not re.search(r'\.rail\{[^}]*display:\s*flex[^}]*position:\s*fixed', mobile_css), (
"Phone must not use a persistent left rail that consumes chat width"
)
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
sidebar_rule = _declarations(_rule_body(mobile_css, ".sidebar"))
sidebar_open_rule = _declarations(_rule_body(mobile_css, ".sidebar.mobile-open"))
assert sidebar_rule.get("left") == "0", (
"Mobile sidebar should stay at left:0 and move with transform"
)
assert sidebar_rule.get("width") == "100vw", (
"Mobile sidebar should fill the viewport like a session page"
)
assert sidebar_rule.get("max-width") == "none", (
"Mobile sidebar must not retain desktop/drawer max width"
)
assert sidebar_rule.get("transform") == "translateX(-100%)", (
"Closed mobile sidebar should sit fully offscreen"
)
assert sidebar_rule.get("transition") == "transform .25s ease", (
"Mobile sidebar should animate with transform"
)
assert sidebar_rule.get("will-change") == "transform", (
"Mobile sidebar should promote the transform layer before opening"
)
assert sidebar_open_rule.get("transform") == "translateX(0)", (
"Open mobile sidebar should slide the full session page into view"
)
assert not re.search(r'\.sidebar\s+\.sidebar-nav\{[^}]*display:\s*none', mobile_css), (
"Full-screen mobile sidebar should keep the panel rail visible"
)
assert re.search(r'\.sidebar-nav\{[^}]*position:\s*absolute', mobile_css), (
"Phone drawer panel tabs should be laid out as an internal side strip"
)
assert re.search(r'\.sidebar-nav\{[^}]*width:\s*52px', mobile_css), (
"Phone drawer panel strip should reserve stable space for 44px targets"
)
assert re.search(r'\.sidebar-nav\{[^}]*flex-direction:\s*column', mobile_css), (
"Phone drawer panel tabs must be vertical, not horizontal"
)
assert re.search(r'\.sidebar-nav\s+\.nav-tab\{[^}]*min-width:\s*44px', mobile_css), (
"Phone drawer panel tabs must be at least 44px wide"
)
assert re.search(r'\.sidebar-nav\s+\.nav-tab\{[^}]*min-height:\s*44px', mobile_css), (
"Phone drawer panel tabs must be at least 44px tall"
)
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
assert re.search(r'\.sidebar\s+\.panel-view\{[^}]*height:\s*100%[^}]*margin-left:\s*52px', mobile_css), (
"Full-screen mobile sidebar content should start beside the vertical icon strip"
)
assert re.search(r'\.mobile-sidebar-close\{[^}]*display:\s*inline-flex\s*!important', mobile_css), (
"Full-screen mobile session page needs a visible close affordance"
)
assert re.search(r'\.sidebar\s+\.panel-icon-btn\{[^}]*min-width:\s*44px', mobile_css), (
"Sidebar panel buttons must min-width:44px on phone"
)
assert re.search(r'\.sidebar\s+\.panel-icon-btn\{[^}]*min-height:\s*44px', mobile_css), (
"Sidebar panel buttons must min-height:44px on phone"
)
assert re.search(r'\.sidebar\s+\.panel-icon-btn\{[^}]*width:\s*auto', mobile_css), (
"Sidebar panel buttons must override their base 24px width on phone"
)
assert re.search(r'\.sidebar\s+\.panel-icon-btn\{[^}]*height:\s*auto', mobile_css), (
"Sidebar panel buttons must override their base 24px height on phone"
)
assert not re.search(r'(?<!sidebar\s)\.panel-icon-btn\{[^}]*min-width:\s*44px', mobile_css), (
"Workspace-panel header buttons must not inherit sidebar-only 44px sizing"
)
def test_compact_titlebar_keeps_hamburger_available():
"""Compact app chrome must keep the titlebar menu reachable."""
compact_css = "\n".join(_max_width_media_blocks(900))
assert re.search(r'\.app-titlebar-hamburger,\s*\.app-titlebar-spacer\{[^}]*display:\s*flex', compact_css), (
"Compact titlebar should expose the hamburger before true phone width"
)
assert ".rightpanel{display:none}" in compact_css.replace(" ", ""), (
"The compact titlebar breakpoint should match the hidden workspace-panel breakpoint"
)
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
def test_mobile_rail_click_opens_full_screen_panel_drawer():
"""Rail clicks on phone should keep the full-screen drawer open for panel switching."""
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
assert "opts.fromRailClick" in panels_js, (
"switchPanel() should distinguish rail clicks from programmatic switches"
)
assert "!_isDesktopWidth()" in panels_js, (
"Mobile rail-click sidebar handling must be limited to mobile widths"
)
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
switch_panel = panels_js.split("async function switchPanel", 1)[1].split("\n}\n\n// ── Cron panel", 1)[0]
assert "if (opts.fromRailClick && typeof _isDesktopWidth === 'function' && !_isDesktopWidth())" in switch_panel, (
"Missing mobile rail-click sidebar handler"
)
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
assert "sidebar.classList.remove('mobile-session-page')" in switch_panel, (
"Phone rail clicks should leave the full-screen session page mode"
)
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
assert "sidebar.classList.add('mobile-panel-drawer', 'mobile-open')" in switch_panel, (
"Phone rail clicks should open the panel drawer mode"
)
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
rail_handler_idx = switch_panel.index("if (opts.fromRailClick && typeof _isDesktopWidth === 'function' && !_isDesktopWidth())")
close_sidebar_idx = switch_panel.find("closeMobileSidebar();", rail_handler_idx)
assert close_sidebar_idx == -1, "Phone rail clicks should keep the full-screen drawer open for panel switching"
assert "overlay.classList.add('visible')" not in switch_panel[rail_handler_idx:], (
"Full-screen phone rail clicks should not show a backdrop that dims the PWA status bar"
)
def test_mobile_switch_panel_non_chat_opens_sidebar():
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
"""mobileSwitchPanel() non-chat path should open the full-screen panel drawer."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
fn_body = _js_function_body(boot_js, "mobileSwitchPanel")
assert "if(name==='chat')" in fn_body, (
"mobileSwitchPanel must close sidebar only on chat target"
)
assert "closeMobileSidebar();" in fn_body, (
"mobileSwitchPanel should still close sidebar on chat"
)
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
assert "sidebar.classList.add('mobile-panel-drawer','mobile-open')" in fn_body, (
"mobileSwitchPanel non-chat branch should keep adding mobile-open"
)
Release v0.51.581 — full-screen PWA mobile sidebar drawer (#4660) (#4683) * stage #4660 v2 (franksong2702): full-screen PWA mobile sidebar drawer — REBUILT on current master REBUILD: the prior stage was cut before tonight's #4597/#4584/#4583 ships, so its 3-dot diff falsely appeared to revert them (Codex CORE/SILENT flags). Rebuilt on current master (authoritative head a035b1343): clean apply, tonight's fixes confirmed INTACT (_apply_sidebar_ state_db_overrides, bottomDistance, overflow-x:hidden all present). #4660's own change is additive — full-screen drawer width, edge-guard, mobile close button, overlay->display:none; does NOT touch the scroll/perf/mobile-pan lines. Nathan concept-greenlit + visual signed off. 71 mobile-layout+swipe tests pass. Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com> * fix #4660 edge-guard regression: defer preventDefault until horizontal intent + touch-action:pan-y; update 2 tests to assert corrected behavior * fix #4660 edge-guard hit-test interception (Codex CORE): pointer-events:none + drop guard listener Deeper fix than the prior preventDefault change: the #pwaSidebarEdgeGuard was a fixed interactive element (z-index 198, no pointer-events:none) that intercepted hit-testing for taps/vertical-scrolls starting in the left 24px strip (since .messages is the real scroller under body:overflow:hidden). Now guard is pointer-events:none with NO dedicated touch listener; the existing window-level CAPTURE handlers still catch the edge-swipe-to-open. Removed the orphaned _onPwaSidebarEdgeGuardStart fn. Updated 2 tests to assert the new non-interactive design. 71 mobile tests pass. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
2026-06-22 08:10:42 +00:00
assert "overlay.classList.add('visible')" not in fn_body, (
"mobileSwitchPanel non-chat branch should not show a backdrop over the PWA status bar"
)
def test_pwa_edge_swipe_opens_current_mobile_panel():
"""Left-edge swipe should open the current sidebar panel, matching hamburger."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
body = _js_function_body(boot_js, "_openMobileSidebarFromGesture")
assert "switchPanel('chat',{bypassSettingsGuard:true})" not in body, (
"Left-edge gesture should not force Chat; it should preserve the active panel"
)
assert "sidebar.classList.remove('mobile-session-page')" in body, (
"Left-edge gesture should leave the rail-hiding session page mode"
)
assert "sidebar.classList.add('mobile-panel-drawer')" in body, (
"Left-edge gesture should open the full-screen sidebar with panel rail"
)
assert "sidebar.classList.add('mobile-open')" in body
assert "overlay.classList.add('visible')" not in body
assert "_syncMobileSidebarPanelFromMainView()" in body, (
"Left-edge gesture should sync sidebar panel from the visible detail view before opening"
)
def test_mobile_sidebar_open_syncs_panel_from_visible_detail_view():
"""The mobile sidebar should not fall back to Chat when a module detail is visible."""
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
assert "const MAIN_VIEW_PANELS =" in panels_js
main_view_panels = panels_js.split("const MAIN_VIEW_PANELS =", 1)[1].split("];", 1)[0]
assert "'todos'" not in main_view_panels, (
"Todos is a sidebar-only panel; adding showing-todos makes main-view sync ambiguous"
)
assert "const MAIN_VIEW_SIDEBAR_PANEL_FALLBACKS = { plugin: 'settings' }" in panels_js
for panel_id in [
"panelSettings",
"panelSkills",
"panelMemory",
"panelTasks",
"panelKanban",
"panelWorkspaces",
"panelProfiles",
"panelTodos",
"panelInsights",
"panelLogs",
]:
assert f'id="{panel_id}"' in HTML, f"{panel_id} should exist for mobile sidebar sync"
assert 'id="panelPlugin"' not in HTML, (
"Plugin pages are main-view only and should sync back to the Settings sidebar list"
)
assert "MAIN_VIEW_PANELS.forEach" in panels_js
panel_from_view = _js_function_body(panels_js, "_panelFromCurrentMainView")
assert "mainEl.classList.contains('showing-'+panel)" in panel_from_view
assert "MAIN_VIEW_SIDEBAR_PANEL_FALLBACKS[panel]||panel" in panel_from_view
assert "$('panel'+_currentPanel.charAt(0).toUpperCase()+_currentPanel.slice(1))" in panel_from_view
assert "return 'chat'" in panel_from_view
sync_body = _js_function_body(panels_js, "_syncMobileSidebarPanelFromMainView")
assert "if(!panelEl)return _currentPanel||'chat'" in sync_body
assert "_currentPanel=panel" in sync_body
assert "document.querySelectorAll('[data-panel]')" in sync_body
assert "document.querySelectorAll('.panel-view')" in sync_body
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
toggle_body = _js_function_body(boot_js, "toggleMobileSidebar")
assert "_syncMobileSidebarPanelFromMainView()" in toggle_body, (
"Hamburger-opened mobile sidebar should also sync from the visible detail view"
)
def test_mobile_skill_selection_closes_sidebar_after_detail_load():
"""Selecting a skill on phone should reveal the newly-loaded detail view."""
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
helper = _js_function_body(panels_js, "_closeMobileSidebarAfterPanelSelection")
assert "if(typeof closeMobileSidebar!=='function')return" in helper
assert "if(typeof _isDesktopWidth==='function'&&_isDesktopWidth())return" in helper
assert "closeMobileSidebar()" in helper
body = _js_function_body(panels_js, "openSkill")
assert "_renderSkillDetail(name, data.content || '', data.linked_files || {})" in body
assert "_closeMobileSidebarAfterPanelSelection()" in body
def test_mobile_sidebar_detail_selections_share_close_helper():
"""Sidebar list commits should consistently reveal their main detail on phone."""
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
for name in [
"openCronDetail",
"loadKanbanTask",
"openMemorySection",
"openWorkspaceDetail",
"openProfileDetail",
]:
body = _js_function_body(panels_js, name)
assert "_closeMobileSidebarAfterPanelSelection()" in body, (
f"{name} should close the full-screen mobile sidebar after opening detail"
)
settings_body = _js_function_body(panels_js, "switchSettingsSection")
assert "if(opts&&opts.fromSidebarItem)_closeMobileSidebarAfterPanelSelection()" in settings_body
assert "switchSettingsSection(_currentSettingsSection);" in panels_js
assert "mobile-panel-drawer', 'mobile-open'" in panels_js, (
"Opening Settings from the rail should keep the mobile drawer available"
)
for section in ["conversation", "appearance", "preferences", "providers", "plugins", "extensions", "system", "help"]:
assert f"switchSettingsSection('{section}',{{fromSidebarItem:true}})" in HTML, (
f"Settings sidebar item {section} should close after selecting its detail"
)
def test_mobile_session_page_close_button_is_mobile_scoped():
"""The full-screen session page close button should not appear on desktop."""
assert 'class="panel-head-btn mobile-sidebar-close' in HTML, (
"Sidebar needs a close button for the full-screen mobile session page"
)
assert 'onclick="closeMobileSidebar()"' in HTML, (
"Mobile session page close button should close the sidebar"
)
assert "mobile-sidebar-close{display:none" in CSS.replace(" ", ""), (
"Mobile sidebar close button should be hidden by default"
)
mobile_css = "\n".join(_max_width_media_blocks(640))
assert ".mobile-sidebar-close{display:inline-flex!important" in mobile_css.replace(" ", ""), (
"Mobile sidebar close button should be visible in phone layout"
)
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>
2026-04-11 19:19:12 +00:00
def test_mobile_files_button_present():
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242) * Polish workspace panel behavior and app dialogs * Replace remaining emoji UI glyphs with Lucide icons * Redesign composer footer around model and context controls Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill. Design credit and inspiration: Theo / T3 Code. Reference implementation: https://github.com/pingdotgg/t3code/ * Remove obsolete activity bar Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts. This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status. * Move workspace and model switching into composer footer * Move profile switching into composer footer * Refactor Hermes control center UI * Redesign control center settings modal layout Widen the modal to 860px, simplify the tab list to icon+label rows, stretch the tab column's divider to full height, lock the panel to a fixed height so switching tabs no longer resizes the outer shell, and always open on the Conversation tab. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Put session item actions in a dropdown * Use Hermes mark in sidebar control button * Reset control center section on close * Drop session-item left border indicator Remove the left-border accent used for active, CLI, and project rows — each state already has a dedicated cue (gold fill, cli badge, project dot), so the border was redundant. Fully round the row, add 2px bottom spacing between rows, and strip the matching JS/CSS overrides. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Increase session search input vertical padding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Normalise odd pixel values across UI Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid across composer chips, sidebar panels, cron list, settings, approval buttons, dropdowns, and inline message edit — eliminating the 7/9/11px drift that was making sibling elements feel subtly misaligned. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite) The mobile layout regression suite (test_mobile_layout.py) requires: - #btnMobileFiles onclick=toggleMobileFiles() in topbar chips - .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports. * Improve composer footer mobile responsiveness and UX - Collapse composer chips to icon-only at <=400px viewports - Add model chip icon (CPU) so it remains tappable when labels are hidden - Show send button always (disabled state when empty, hidden during streaming) - Show context usage indicator on session load, not just after streaming - Add cancel status fallback timeout to prevent stale "Cancelling..." text - Update tests to match new send button and busy state behavior * Fix duplicate files button and broken workspace close on mobile Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle in the mobile topbar. Fix workspace panel close button calling undefined closeMobileFiles() — now calls closeWorkspacePanel(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix model chip icon vertical alignment in composer footer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix workspace toggle button hidden on desktop by conflicting CSS class Remove mobile-files-btn class from #btnWorkspacePanelToggle — its display:none!important rule was overriding workspace-toggle-btn visibility on non-mobile viewports. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix session actions dots button inaccessible on mobile sidebar Always show the session actions trigger on mobile (no hover state on touch devices) and restore right padding so text truncates with ellipsis before the dots icon. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix composer footer manage links not opening sidebar panel The "Manage profiles" and "Manage workspaces" links in the composer footer dropdowns called switchPanel() which only changes the active panel content but doesn't open the sidebar. Replaced with mobileSwitchPanel() which also opens the sidebar so the panel is actually visible. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Widen icon-only composer chips breakpoint from 400px to 768px Move the icon-only chip styling up into the existing max-width:768px media query so chips collapse to icon-only on tablets too, preventing composer footer overflow on mid-size screens. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix composer-left vertical scrollbar by setting overflow-y:hidden When overflow-x is set to auto, the CSS spec implicitly changes overflow-y from visible to auto, allowing a vertical scrollbar to appear from slight chip padding/border overflow. Explicitly set overflow-y:hidden to prevent this. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve rebase conflicts and fix control center test assertions - Resolved 4 conflicts during rebase onto master (workspace.js, boot.js, index.html, test_sprint34.py) - Fixed test_sprint34.py: _controlSection -> _settingsSection, cc-tab -> settings-tabs (matching actual implementation) - Fixed quoting syntax error in test assertion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update version badge in System tab to v0.49.4 * docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge --------- Co-authored-by: Aron Prins <pwf.aron@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
"""Mobile files toggle button (#btnWorkspacePanelToggle.workspace-toggle-btn) must be in HTML and CSS."""
assert 'id="btnWorkspacePanelToggle"' in HTML, \
"#btnWorkspacePanelToggle missing from index.html"
assert "workspace-toggle-btn" in CSS, \
".workspace-toggle-btn CSS missing from style.css"
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>
2026-04-11 19:19:12 +00:00
# ── 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_composer_dropdowns_are_not_nested_inside_left_control_row():
"""Composer dropdown surfaces should remain outside .composer-left.
The left row can wrap/scroll on phones; dropdowns need to be siblings so
that overflow rules on the control row cannot clip them.
"""
parser = _ComposerLeftDropdownParser()
parser.feed(HTML)
assert not parser.violations, (
"Composer dropdowns must not be nested inside .composer-left: "
+ ", ".join(parser.violations)
)
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>
2026-04-11 19:19:12 +00:00
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."""
fix: workspace panel close button — no duplicate X on desktop, mobile X respects file preview (#414) * fix: workspace panel close button — no duplicate X on desktop, mobile X respects file preview Two bugs fixed in the workspace right panel: 1. Duplicate X on desktop (bug): #btnClearPreview (the X icon) was always visible alongside #btnCollapseWorkspacePanel (the chevron), producing two close controls at once. Fixed in syncWorkspacePanelUI() — on desktop, the X is now hidden when no file preview is open (display:none), and only shown when the user is viewing a file. The chevron remains as the sole close control in browse mode. 2. Mobile X collapses panel instead of dismissing file (bug): .mobile-close-btn was calling closeWorkspacePanel() directly, which collapsed the whole panel even when a file was open. Changed to handleWorkspaceClose(), which already has the correct two-step logic: clear preview first, close panel only if no preview is visible. Files changed: - static/boot.js: syncWorkspacePanelUI() hides btnClearPreview on desktop when hasPreview is false, guarded by !isCompact so mobile is unaffected - static/index.html: mobile-close-btn onclick changed from closeWorkspacePanel() to handleWorkspaceClose() - tests/test_sprint44.py: 10 new regression tests - tests/test_mobile_layout.py: updated test_workspace_close_button_present() to accept handleWorkspaceClose() as the valid onclick target * fix: widen test_server_delete_invalidates_index window to 1200 chars The test extracted a 600-char window starting from the session/delete handler to check for SESSION_INDEX_FILE. Commit 3cc5839 added session_id character validation and path traversal guards before the unlink call, pushing SESSION_INDEX_FILE to ~764 chars from the match — beyond the 600-char limit, causing the test to fail on CI. Widened the window to 1200 chars, which accommodates any reasonable amount of guard code before the SESSION_INDEX_FILE.unlink() call. * docs: v0.50.33 release — version badge and CHANGELOG --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 06:25:26 +00:00
# Accept handleWorkspaceClose() (two-step close: file→browse→closed), or the
# lower-level functions directly. handleWorkspaceClose is preferred because
# it dismisses a file preview first before closing the panel.
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>
2026-04-11 19:19:12 +00:00
has_close = (
fix: workspace panel close button — no duplicate X on desktop, mobile X respects file preview (#414) * fix: workspace panel close button — no duplicate X on desktop, mobile X respects file preview Two bugs fixed in the workspace right panel: 1. Duplicate X on desktop (bug): #btnClearPreview (the X icon) was always visible alongside #btnCollapseWorkspacePanel (the chevron), producing two close controls at once. Fixed in syncWorkspacePanelUI() — on desktop, the X is now hidden when no file preview is open (display:none), and only shown when the user is viewing a file. The chevron remains as the sole close control in browse mode. 2. Mobile X collapses panel instead of dismissing file (bug): .mobile-close-btn was calling closeWorkspacePanel() directly, which collapsed the whole panel even when a file was open. Changed to handleWorkspaceClose(), which already has the correct two-step logic: clear preview first, close panel only if no preview is visible. Files changed: - static/boot.js: syncWorkspacePanelUI() hides btnClearPreview on desktop when hasPreview is false, guarded by !isCompact so mobile is unaffected - static/index.html: mobile-close-btn onclick changed from closeWorkspacePanel() to handleWorkspaceClose() - tests/test_sprint44.py: 10 new regression tests - tests/test_mobile_layout.py: updated test_workspace_close_button_present() to accept handleWorkspaceClose() as the valid onclick target * fix: widen test_server_delete_invalidates_index window to 1200 chars The test extracted a 600-char window starting from the session/delete handler to check for SESSION_INDEX_FILE. Commit 3cc5839 added session_id character validation and path traversal guards before the unlink call, pushing SESSION_INDEX_FILE to ~764 chars from the match — beyond the 600-char limit, causing the test to fail on CI. Widened the window to 1200 chars, which accommodates any reasonable amount of guard code before the SESSION_INDEX_FILE.unlink() call. * docs: v0.50.33 release — version badge and CHANGELOG --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 06:25:26 +00:00
'onclick="handleWorkspaceClose()"' in HTML or
feat: composer-centric UI refresh + Hermes Control Center (v0.50.0, closes #242) * Polish workspace panel behavior and app dialogs * Replace remaining emoji UI glyphs with Lucide icons * Redesign composer footer around model and context controls Move the model selector into the composer footer, replace the linear context pill with a compact circular badge plus tooltip, and remove the redundant topbar model pill. Design credit and inspiration: Theo / T3 Code. Reference implementation: https://github.com/pingdotgg/t3code/ * Remove obsolete activity bar Drop the old activity bar, keep turn-scoped state in the composer footer, and route remaining non-chat status messages through toasts. This leaves live tool cards and the message timeline as the primary progress UI, with the composer owning stop/cancel and brief turn status. * Move workspace and model switching into composer footer * Move profile switching into composer footer * Refactor Hermes control center UI * Redesign control center settings modal layout Widen the modal to 860px, simplify the tab list to icon+label rows, stretch the tab column's divider to full height, lock the panel to a fixed height so switching tabs no longer resizes the outer shell, and always open on the Conversation tab. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Put session item actions in a dropdown * Use Hermes mark in sidebar control button * Reset control center section on close * Drop session-item left border indicator Remove the left-border accent used for active, CLI, and project rows — each state already has a dedicated cue (gold fill, cli badge, project dot), so the border was redundant. Fully round the row, add 2px bottom spacing between rows, and strip the matching JS/CSS overrides. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Increase session search input vertical padding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Normalise odd pixel values across UI Snap padding, gap, and border-radius values to the 2/4/6/8/10/12 grid across composer chips, sidebar panels, cron list, settings, approval buttons, dropdowns, and inline message edit — eliminating the 7/9/11px drift that was making sibling elements feel subtly misaligned. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add missing #btnMobileFiles button and .mobile-files-btn CSS (for mobile QA suite) The mobile layout regression suite (test_mobile_layout.py) requires: - #btnMobileFiles onclick=toggleMobileFiles() in topbar chips - .mobile-files-btn CSS rules for responsive show/hide at 640/900px breakpoints Also adds max-width guard to .profile-dropdown to prevent clipping at narrow viewports. * Improve composer footer mobile responsiveness and UX - Collapse composer chips to icon-only at <=400px viewports - Add model chip icon (CPU) so it remains tappable when labels are hidden - Show send button always (disabled state when empty, hidden during streaming) - Show context usage indicator on session load, not just after streaming - Add cancel status fallback timeout to prevent stale "Cancelling..." text - Update tests to match new send button and busy state behavior * Fix duplicate files button and broken workspace close on mobile Remove redundant #btnMobileFiles button that duplicated #btnWorkspacePanelToggle in the mobile topbar. Fix workspace panel close button calling undefined closeMobileFiles() — now calls closeWorkspacePanel(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix model chip icon vertical alignment in composer footer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix workspace toggle button hidden on desktop by conflicting CSS class Remove mobile-files-btn class from #btnWorkspacePanelToggle — its display:none!important rule was overriding workspace-toggle-btn visibility on non-mobile viewports. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix session actions dots button inaccessible on mobile sidebar Always show the session actions trigger on mobile (no hover state on touch devices) and restore right padding so text truncates with ellipsis before the dots icon. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix composer footer manage links not opening sidebar panel The "Manage profiles" and "Manage workspaces" links in the composer footer dropdowns called switchPanel() which only changes the active panel content but doesn't open the sidebar. Replaced with mobileSwitchPanel() which also opens the sidebar so the panel is actually visible. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Widen icon-only composer chips breakpoint from 400px to 768px Move the icon-only chip styling up into the existing max-width:768px media query so chips collapse to icon-only on tablets too, preventing composer footer overflow on mid-size screens. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix composer-left vertical scrollbar by setting overflow-y:hidden When overflow-x is set to auto, the CSS spec implicitly changes overflow-y from visible to auto, allowing a vertical scrollbar to appear from slight chip padding/border overflow. Explicitly set overflow-y:hidden to prevent this. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve rebase conflicts and fix control center test assertions - Resolved 4 conflicts during rebase onto master (workspace.js, boot.js, index.html, test_sprint34.py) - Fixed test_sprint34.py: _controlSection -> _settingsSection, cc-tab -> settings-tabs (matching actual implementation) - Fixed quoting syntax error in test assertion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update version badge in System tab to v0.49.4 * docs: update README and CHANGELOG for v0.50.0 UI refresh, bump version badge --------- Co-authored-by: Aron Prins <pwf.aron@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 18:55:40 +00:00
'onclick="closeWorkspacePanel()"' in HTML or
'onclick="toggleWorkspacePanel()"' in HTML
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>
2026-04-11 19:19:12 +00:00
)
assert has_close, \
fix: workspace panel close button — no duplicate X on desktop, mobile X respects file preview (#414) * fix: workspace panel close button — no duplicate X on desktop, mobile X respects file preview Two bugs fixed in the workspace right panel: 1. Duplicate X on desktop (bug): #btnClearPreview (the X icon) was always visible alongside #btnCollapseWorkspacePanel (the chevron), producing two close controls at once. Fixed in syncWorkspacePanelUI() — on desktop, the X is now hidden when no file preview is open (display:none), and only shown when the user is viewing a file. The chevron remains as the sole close control in browse mode. 2. Mobile X collapses panel instead of dismissing file (bug): .mobile-close-btn was calling closeWorkspacePanel() directly, which collapsed the whole panel even when a file was open. Changed to handleWorkspaceClose(), which already has the correct two-step logic: clear preview first, close panel only if no preview is visible. Files changed: - static/boot.js: syncWorkspacePanelUI() hides btnClearPreview on desktop when hasPreview is false, guarded by !isCompact so mobile is unaffected - static/index.html: mobile-close-btn onclick changed from closeWorkspacePanel() to handleWorkspaceClose() - tests/test_sprint44.py: 10 new regression tests - tests/test_mobile_layout.py: updated test_workspace_close_button_present() to accept handleWorkspaceClose() as the valid onclick target * fix: widen test_server_delete_invalidates_index window to 1200 chars The test extracted a 600-char window starting from the session/delete handler to check for SESSION_INDEX_FILE. Commit 3cc5839 added session_id character validation and path traversal guards before the unlink call, pushing SESSION_INDEX_FILE to ~764 chars from the match — beyond the 600-char limit, causing the test to fail on CI. Widened the window to 1200 chars, which accommodates any reasonable amount of guard code before the SESSION_INDEX_FILE.unlink() call. * docs: v0.50.33 release — version badge and CHANGELOG --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 06:25:26 +00:00
"handleWorkspaceClose() or closeWorkspacePanel() must be wired to a button to close the workspace panel on mobile"
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>
2026-04-11 19:19:12 +00:00
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"
assert "function closeMobileWorkspacePanelFromChat(e)" in boot_js
assert "$('mainChat')?.addEventListener('pointerdown', closeMobileWorkspacePanelFromChat);" in boot_js
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>
2026-04-11 19:19:12 +00:00
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404) * fix: restore mobile chat scrolling and drawer close (#397) - static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll) - static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages - static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut - tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites Original PR by @Jordan-SkyLF * fix: preserve imported session timestamps (#395) - api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False - api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save - tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block Original PR by @Jordan-SkyLF * fix(profiles): block path traversal in profile switch and delete flows (#399) Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile names directly into filesystem paths with no validation. An attacker could send '../../etc/passwd' as a profile name to traverse outside the profiles directory. - api/profiles.py: add _resolve_named_profile_home(name) — validates name with ^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via candidate.resolve().relative_to(profiles_root); use in switch_profile() - api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry - api/routes.py: add _validate_profile_name() call at HTTP handler level for both /api/profile/switch and /api/profile/delete (fail-fast at API boundary) - tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master) * feat: add desktop microphone transcription fallback (#396) Mic button now works in browsers that support getUserMedia/MediaRecorder but lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds). - static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder); keep mic button enabled when either SpeechRecognition or MediaRecorder is available; MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither) - api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls transcription_tools.transcribe_audio(), always cleans up temp file - api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit, returns {text:...} or {error:...} - api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to allow getUserMedia in the same origin) - tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription - tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy) - tests/test_sprint20.py: 3 updated tests for new fallback-capability checks Original PR by @Jordan-SkyLF * docs: v0.50.25 release — version badge and CHANGELOG --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
def test_new_conversation_closes_mobile_sidebar():
"""New conversation must close the mobile drawer so the chat pane is visible immediately."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
# Handler is now multi-line — search for the full block rather than a single line.
assert "$('btnNewChat').onclick" in boot_js, "btnNewChat onclick handler missing from static/boot.js"
# Find the handler block and verify closeMobileSidebar appears in it.
fix: new-chat guard ignores in-flight streams (#1432) + profile form auto-capitalizes typed values (#1423) Two unrelated UX bugs, both small surgical fixes with regression tests. Issue #1432 — "+" button doesn't open new chat during streaming ================================================================ Reported by @Olyno: clicking "+" after sending a first message keeps redirecting to the same chat instead of opening a new blank conversation, making parallel chats impossible until the first response finishes. Root cause: static/boot.js:691 (and the Cmd/Ctrl+K branch at :844) had an empty-session guard from #1171 that skipped newSession() when message_count===0: if(S.session && (S.session.message_count||0)===0){ $('msg').focus(); closeMobileSidebar(); return; } But during the first user turn of a brand-new session, message_count is still 0 server-side because the user message hasn't been merged into s.messages yet. The guard treated that as "empty" and silently dropped the click, blocking parallel chats for the entire stream duration. Fix: Tighten the predicate to also exclude in-flight state: if(S.session && (S.session.message_count||0)===0 && !S.busy && !S.session.active_stream_id && !S.session.pending_user_message){ $('msg').focus(); closeMobileSidebar(); return; } Same predicate applied to the Cmd/Ctrl+K handler at :844. The in-flight signal (active_stream_id || pending_user_message) is the same one _restoreSettledSession() in messages.js:1081 already uses to decide whether a session is "settled" — keeping both call sites aligned. Verified end-to-end: with S.busy=true and pending_user_message set, the old guard returned `block=true` (= the bug), the new guard returns `block=false` (= fixed). With a truly empty session (no busy, no pending), both old and new guards still block — preserving #1171 behavior. Issue #1423 — Profile name field auto-capitalizes typed values ============================================================== Self-reported (Mac app, May 1 2026): typing `hello` into the New Profile "Name" field shows `Hello` after blur/autofill, contradicting the "Lowercase letters, numbers, hyphens, underscores only" hint right next to it. The form lowercases on submit so stored data is correct, but the displayed value during typing is misleading. Root cause: static/panels.js:2532 had only autocomplete="off": <input type="text" id="profileFormName" placeholder="..." autocomplete="off" required> Missing three attributes that actually prevent the misbehavior: - autocapitalize="none" — mobile keyboards (iOS Safari, Android Chrome, WKWebView in the Mac app) auto-capitalize the first letter without it - autocorrect="off" — Safari runs autocorrect on blur, can rewrite hello→Hello - spellcheck="false" — desktop browsers may run spellcheck on blur Fix: Add the three attributes to profileFormName. Also added to profileFormBaseUrl since URLs are similarly bad targets for autocapitalize/autocorrect. profileFormApiKey is type="password" and already has correct browser behavior. Verified end-to-end against the live DOM: openProfileCreate() → getElementById('profileFormName').getAttribute(...) returns the new attributes correctly, with required preserved. Tests ----- 3648 passed, 2 skipped, 3 xpassed (was 3640 — added 8 new regression tests in test_1432_newchat_and_1423_profile_input.py). One pre-existing test had to be widened: tests/test_mobile_layout.py test_new_conversation_closes_mobile_sidebar grabbed only the first 500 chars of the btnNewChat handler block to scan for closeMobileSidebar. The new comment block pushed closeMobileSidebar past that window even though both calls are still present. Bumped the window to 1500 chars and the shortcut-block lines from 12 to 24 to match the multi-line guard. Closes #1432 Closes #1423 Reported by @Olyno (#1432, GitHub)
2026-05-02 00:52:41 +00:00
# The handler grew comments after #1432 (in-flight guard refactor), so use a
# generous window to cover the full handler body.
idx = boot_js.find("$('btnNewChat').onclick")
fix: new-chat guard ignores in-flight streams (#1432) + profile form auto-capitalizes typed values (#1423) Two unrelated UX bugs, both small surgical fixes with regression tests. Issue #1432 — "+" button doesn't open new chat during streaming ================================================================ Reported by @Olyno: clicking "+" after sending a first message keeps redirecting to the same chat instead of opening a new blank conversation, making parallel chats impossible until the first response finishes. Root cause: static/boot.js:691 (and the Cmd/Ctrl+K branch at :844) had an empty-session guard from #1171 that skipped newSession() when message_count===0: if(S.session && (S.session.message_count||0)===0){ $('msg').focus(); closeMobileSidebar(); return; } But during the first user turn of a brand-new session, message_count is still 0 server-side because the user message hasn't been merged into s.messages yet. The guard treated that as "empty" and silently dropped the click, blocking parallel chats for the entire stream duration. Fix: Tighten the predicate to also exclude in-flight state: if(S.session && (S.session.message_count||0)===0 && !S.busy && !S.session.active_stream_id && !S.session.pending_user_message){ $('msg').focus(); closeMobileSidebar(); return; } Same predicate applied to the Cmd/Ctrl+K handler at :844. The in-flight signal (active_stream_id || pending_user_message) is the same one _restoreSettledSession() in messages.js:1081 already uses to decide whether a session is "settled" — keeping both call sites aligned. Verified end-to-end: with S.busy=true and pending_user_message set, the old guard returned `block=true` (= the bug), the new guard returns `block=false` (= fixed). With a truly empty session (no busy, no pending), both old and new guards still block — preserving #1171 behavior. Issue #1423 — Profile name field auto-capitalizes typed values ============================================================== Self-reported (Mac app, May 1 2026): typing `hello` into the New Profile "Name" field shows `Hello` after blur/autofill, contradicting the "Lowercase letters, numbers, hyphens, underscores only" hint right next to it. The form lowercases on submit so stored data is correct, but the displayed value during typing is misleading. Root cause: static/panels.js:2532 had only autocomplete="off": <input type="text" id="profileFormName" placeholder="..." autocomplete="off" required> Missing three attributes that actually prevent the misbehavior: - autocapitalize="none" — mobile keyboards (iOS Safari, Android Chrome, WKWebView in the Mac app) auto-capitalize the first letter without it - autocorrect="off" — Safari runs autocorrect on blur, can rewrite hello→Hello - spellcheck="false" — desktop browsers may run spellcheck on blur Fix: Add the three attributes to profileFormName. Also added to profileFormBaseUrl since URLs are similarly bad targets for autocapitalize/autocorrect. profileFormApiKey is type="password" and already has correct browser behavior. Verified end-to-end against the live DOM: openProfileCreate() → getElementById('profileFormName').getAttribute(...) returns the new attributes correctly, with required preserved. Tests ----- 3648 passed, 2 skipped, 3 xpassed (was 3640 — added 8 new regression tests in test_1432_newchat_and_1423_profile_input.py). One pre-existing test had to be widened: tests/test_mobile_layout.py test_new_conversation_closes_mobile_sidebar grabbed only the first 500 chars of the btnNewChat handler block to scan for closeMobileSidebar. The new comment block pushed closeMobileSidebar past that window even though both calls are still present. Bumped the window to 1500 chars and the shortcut-block lines from 12 to 24 to match the multi-line guard. Closes #1432 Closes #1423 Reported by @Olyno (#1432, GitHub)
2026-05-02 00:52:41 +00:00
handler_block = boot_js[idx:idx+1500]
assert "closeMobileSidebar" in handler_block, \
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404) * fix: restore mobile chat scrolling and drawer close (#397) - static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll) - static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages - static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut - tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites Original PR by @Jordan-SkyLF * fix: preserve imported session timestamps (#395) - api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False - api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save - tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block Original PR by @Jordan-SkyLF * fix(profiles): block path traversal in profile switch and delete flows (#399) Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile names directly into filesystem paths with no validation. An attacker could send '../../etc/passwd' as a profile name to traverse outside the profiles directory. - api/profiles.py: add _resolve_named_profile_home(name) — validates name with ^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via candidate.resolve().relative_to(profiles_root); use in switch_profile() - api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry - api/routes.py: add _validate_profile_name() call at HTTP handler level for both /api/profile/switch and /api/profile/delete (fail-fast at API boundary) - tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master) * feat: add desktop microphone transcription fallback (#396) Mic button now works in browsers that support getUserMedia/MediaRecorder but lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds). - static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder); keep mic button enabled when either SpeechRecognition or MediaRecorder is available; MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither) - api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls transcription_tools.transcribe_audio(), always cleans up temp file - api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit, returns {text:...} or {error:...} - api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to allow getUserMedia in the same origin) - tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription - tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy) - tests/test_sprint20.py: 3 updated tests for new fallback-capability checks Original PR by @Jordan-SkyLF * docs: v0.50.25 release — version badge and CHANGELOG --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
"btnNewChat handler must closeMobileSidebar() after creating the new session"
shortcut_line = next((ln for ln in boot_js.splitlines() if "e.key==='k'" in ln or "e.key === 'k'" in ln), "")
assert shortcut_line, "Cmd/Ctrl+K new chat shortcut missing from static/boot.js"
fix: new-chat guard ignores in-flight streams (#1432) + profile form auto-capitalizes typed values (#1423) Two unrelated UX bugs, both small surgical fixes with regression tests. Issue #1432 — "+" button doesn't open new chat during streaming ================================================================ Reported by @Olyno: clicking "+" after sending a first message keeps redirecting to the same chat instead of opening a new blank conversation, making parallel chats impossible until the first response finishes. Root cause: static/boot.js:691 (and the Cmd/Ctrl+K branch at :844) had an empty-session guard from #1171 that skipped newSession() when message_count===0: if(S.session && (S.session.message_count||0)===0){ $('msg').focus(); closeMobileSidebar(); return; } But during the first user turn of a brand-new session, message_count is still 0 server-side because the user message hasn't been merged into s.messages yet. The guard treated that as "empty" and silently dropped the click, blocking parallel chats for the entire stream duration. Fix: Tighten the predicate to also exclude in-flight state: if(S.session && (S.session.message_count||0)===0 && !S.busy && !S.session.active_stream_id && !S.session.pending_user_message){ $('msg').focus(); closeMobileSidebar(); return; } Same predicate applied to the Cmd/Ctrl+K handler at :844. The in-flight signal (active_stream_id || pending_user_message) is the same one _restoreSettledSession() in messages.js:1081 already uses to decide whether a session is "settled" — keeping both call sites aligned. Verified end-to-end: with S.busy=true and pending_user_message set, the old guard returned `block=true` (= the bug), the new guard returns `block=false` (= fixed). With a truly empty session (no busy, no pending), both old and new guards still block — preserving #1171 behavior. Issue #1423 — Profile name field auto-capitalizes typed values ============================================================== Self-reported (Mac app, May 1 2026): typing `hello` into the New Profile "Name" field shows `Hello` after blur/autofill, contradicting the "Lowercase letters, numbers, hyphens, underscores only" hint right next to it. The form lowercases on submit so stored data is correct, but the displayed value during typing is misleading. Root cause: static/panels.js:2532 had only autocomplete="off": <input type="text" id="profileFormName" placeholder="..." autocomplete="off" required> Missing three attributes that actually prevent the misbehavior: - autocapitalize="none" — mobile keyboards (iOS Safari, Android Chrome, WKWebView in the Mac app) auto-capitalize the first letter without it - autocorrect="off" — Safari runs autocorrect on blur, can rewrite hello→Hello - spellcheck="false" — desktop browsers may run spellcheck on blur Fix: Add the three attributes to profileFormName. Also added to profileFormBaseUrl since URLs are similarly bad targets for autocapitalize/autocorrect. profileFormApiKey is type="password" and already has correct browser behavior. Verified end-to-end against the live DOM: openProfileCreate() → getElementById('profileFormName').getAttribute(...) returns the new attributes correctly, with required preserved. Tests ----- 3648 passed, 2 skipped, 3 xpassed (was 3640 — added 8 new regression tests in test_1432_newchat_and_1423_profile_input.py). One pre-existing test had to be widened: tests/test_mobile_layout.py test_new_conversation_closes_mobile_sidebar grabbed only the first 500 chars of the btnNewChat handler block to scan for closeMobileSidebar. The new comment block pushed closeMobileSidebar past that window even though both calls are still present. Bumped the window to 1500 chars and the shortcut-block lines from 12 to 24 to match the multi-line guard. Closes #1432 Closes #1423 Reported by @Olyno (#1432, GitHub)
2026-05-02 00:52:41 +00:00
shortcut_block = "\n".join(boot_js.splitlines()[boot_js.splitlines().index(shortcut_line):boot_js.splitlines().index(shortcut_line)+24])
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404) * fix: restore mobile chat scrolling and drawer close (#397) - static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll) - static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages - static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut - tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites Original PR by @Jordan-SkyLF * fix: preserve imported session timestamps (#395) - api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False - api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save - tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block Original PR by @Jordan-SkyLF * fix(profiles): block path traversal in profile switch and delete flows (#399) Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile names directly into filesystem paths with no validation. An attacker could send '../../etc/passwd' as a profile name to traverse outside the profiles directory. - api/profiles.py: add _resolve_named_profile_home(name) — validates name with ^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via candidate.resolve().relative_to(profiles_root); use in switch_profile() - api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry - api/routes.py: add _validate_profile_name() call at HTTP handler level for both /api/profile/switch and /api/profile/delete (fail-fast at API boundary) - tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master) * feat: add desktop microphone transcription fallback (#396) Mic button now works in browsers that support getUserMedia/MediaRecorder but lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds). - static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder); keep mic button enabled when either SpeechRecognition or MediaRecorder is available; MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither) - api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls transcription_tools.transcribe_audio(), always cleans up temp file - api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit, returns {text:...} or {error:...} - api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to allow getUserMedia in the same origin) - tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription - tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy) - tests/test_sprint20.py: 3 updated tests for new fallback-capability checks Original PR by @Jordan-SkyLF * docs: v0.50.25 release — version badge and CHANGELOG --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
assert "closeMobileSidebar" in shortcut_block, \
"Cmd/Ctrl+K new chat shortcut must closeMobileSidebar() after creating the new session"
def test_new_conversation_shortcut_works_while_busy():
"""Cmd/Ctrl+K should still create a new conversation while the current one is busy.
The previous behavior gated the shortcut on !S.busy, which meant users had
to wait for a long generation to finish before they could start something
new — the exact moment they want to switch context.
"""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
shortcut_line = next((ln for ln in boot_js.splitlines() if "e.key==='k'" in ln or "e.key === 'k'" in ln), "")
assert shortcut_line, "Cmd/Ctrl+K new chat shortcut missing from static/boot.js"
# Inspect the next 10 lines after the keybinding match — the gating block
# would live there if it had been kept.
idx = boot_js.splitlines().index(shortcut_line)
shortcut_block = "\n".join(boot_js.splitlines()[idx:idx + 10])
# Strip the existing message-count guard (which is unrelated and stays) so
# we only check for an S.busy gate on the newSession() call itself.
assert "if(!S.busy)" not in shortcut_block, (
"Cmd/Ctrl+K must not be blocked by the current session's busy state"
)
assert "if (!S.busy)" not in shortcut_block, (
"Cmd/Ctrl+K must not be blocked by the current session's busy state"
)
def test_mobile_titlebar_has_new_conversation_button():
"""Mobile titlebar shows the New Conversation action and keeps it next to reload."""
header_match = re.search(
r'<header class="app-titlebar"[^>]*>(?P<body>.*?)</header>',
HTML,
re.S,
)
assert header_match, "app-titlebar header block missing"
header_html = header_match.group("body")
idx_btn = header_html.find('id="btnTitlebarNewChat"')
idx_reload = header_html.find('id="btnReload"')
idx_spacer = header_html.find('class="app-titlebar-spacer"')
assert idx_btn != -1, "titlebar mobile new chat button should exist"
assert idx_reload != -1, "titlebar reload button should remain present"
assert idx_spacer != -1, "titlebar spacer should remain present"
assert idx_spacer < idx_btn < idx_reload, (
"titlebar new chat button must sit left of the reload button on mobile"
)
assert "btnTitlebarNewChat" in header_html
assert "data-i18n-title=\"new_conversation\"" in header_html
assert "data-i18n-aria-label=\"new_conversation\"" in header_html
assert "aria-label=\"New conversation\"" in header_html
assert "title=\"New conversation\"" in header_html
assert "$('btnNewChat').click()" in header_html
def test_titlebar_new_chat_button_mobile_visibility_css():
"""Keep the titlebar new-chat control mobile-only and reuse reload button styling."""
base_rule = _declarations(_rule_body(CSS, ".app-titlebar-new-chat"))
assert base_rule.get("display") == "none", "app-titlebar new chat button must be hidden by default"
mobile_blocks = "".join(_max_width_media_blocks(640))
mobile_rule = _declarations(_rule_body(mobile_blocks, ".app-titlebar-new-chat"))
assert mobile_rule.get("display") == "inline-flex", (
"app-titlebar new chat button must be visible in mobile layout rules"
)
desktop_css = re.sub(
r"@media\(max-width:640px\).*",
"",
CSS,
flags=re.S,
)
assert ".app-titlebar-new-chat{display:inline-flex;}" not in desktop_css, (
"titlebar new chat button must not be exposed by desktop PWA/fullscreen rules"
)
def test_titlebar_reload_button_visibility_css_contract():
"""Keep reload hidden by default, keep standalone visibility, and expose it on mobile width."""
base_rule = _declarations(_rule_body(CSS, ".app-titlebar-reload"))
assert _display_hidden(base_rule), "app-titlebar reload button should stay hidden by default"
standalone_mode_pattern = re.compile(
r"@media\s*\(\s*display-mode:\s*standalone\s*\)\s*,\s*"
r"\(\s*display-mode:\s*fullscreen\s*\)\s*\{"
)
standalone_rule_body = None
for match in standalone_mode_pattern.finditer(CSS):
open_brace = match.end() - 1
depth = 0
for idx in range(open_brace, len(CSS)):
if CSS[idx] == "{":
depth += 1
elif CSS[idx] == "}":
depth -= 1
if depth == 0:
block = CSS[open_brace + 1 : idx]
if ".app-titlebar-reload" in block:
standalone_rule_body = block
break
if standalone_rule_body is not None:
break
assert standalone_rule_body is not None, (
"standalone/fullscreen media block for titlebar reload could not be parsed"
)
standalone_rule = _declarations(_rule_body(standalone_rule_body, ".app-titlebar-reload"))
assert standalone_rule.get("display") == "inline-flex", (
"titlebar reload should remain inline-flex in standalone/fullscreen"
)
mobile_blocks = "".join(_max_width_media_blocks(640))
mobile_rule = _declarations(_rule_body(mobile_blocks, ".app-titlebar-reload"))
assert _display_inline_flex(mobile_rule), (
"app-titlebar reload button should be visible in phone-width titlebar rules"
)
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>
2026-04-11 19:19:12 +00:00
# ── 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"
v0.50.25: mobile scroll, import timestamps, profile security, mic fallback (#404) * fix: restore mobile chat scrolling and drawer close (#397) - static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll) - static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages - static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut - tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites Original PR by @Jordan-SkyLF * fix: preserve imported session timestamps (#395) - api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False - api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save - tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block Original PR by @Jordan-SkyLF * fix(profiles): block path traversal in profile switch and delete flows (#399) Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile names directly into filesystem paths with no validation. An attacker could send '../../etc/passwd' as a profile name to traverse outside the profiles directory. - api/profiles.py: add _resolve_named_profile_home(name) — validates name with ^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via candidate.resolve().relative_to(profiles_root); use in switch_profile() - api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry - api/routes.py: add _validate_profile_name() call at HTTP handler level for both /api/profile/switch and /api/profile/delete (fail-fast at API boundary) - tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master) * feat: add desktop microphone transcription fallback (#396) Mic button now works in browsers that support getUserMedia/MediaRecorder but lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds). - static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder); keep mic button enabled when either SpeechRecognition or MediaRecorder is available; MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither) - api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls transcription_tools.transcribe_audio(), always cleans up temp file - api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit, returns {text:...} or {error:...} - api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to allow getUserMedia in the same origin) - tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription - tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy) - tests/test_sprint20.py: 3 updated tests for new fallback-capability checks Original PR by @Jordan-SkyLF * docs: v0.50.25 release — version badge and CHANGELOG --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-14 05:11:45 +00:00
def test_flex_parents_allow_message_scroller_to_shrink():
"""The top-level flex containers must opt into min-height:0 so .messages can scroll on mobile.
Mobile Safari/Chrome can trap scroll when a flex child with overflow:auto sits inside
parents whose min-height remains auto. Both .layout and .main need min-height:0.
"""
assert re.search(r'\.layout\{[^}]*min-height:0', CSS), \
".layout must set min-height:0 so the chat column can shrink and scroll"
assert re.search(r'\.main\{[^}]*min-height:0', CSS), \
".main must set min-height:0 so .messages remains scrollable while busy"
def test_messages_touch_scrolling_hints_present():
"""The messages scroller must advertise touch-friendly scrolling behavior.
On mobile browsers, momentum scrolling and explicit pan-y/overscroll behavior help
prevent the chat area from feeling locked while the app body itself stays overflow:hidden.
"""
assert re.search(r'\.messages\{[^}]*-webkit-overflow-scrolling:\s*touch', CSS), \
".messages must enable -webkit-overflow-scrolling:touch for mobile momentum scroll"
assert re.search(r'\.messages\{[^}]*touch-action:\s*pan-y', CSS), \
".messages must set touch-action:pan-y so vertical swipe gestures scroll the transcript"
assert re.search(r'\.messages\{[^}]*overscroll-behavior-y:\s*contain', CSS), \
".messages must contain vertical overscroll so the transcript keeps the gesture"
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>
2026-04-11 19:19:12 +00:00
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_viewport_disables_page_zoom_for_native_pwa_shell():
"""Installed PWA launches should not rubber-band into browser-style page zoom."""
assert 'name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"' in HTML
def test_pwa_safe_area_top_stays_scoped_to_installed_modes():
"""The PWA shell should not opt into cover-mode geometry for every browser surface."""
assert 'viewport-fit=cover' not in HTML
assert 'apple-mobile-web-app-status-bar-style" content="black-translucent"' in HTML
assert "@media (display-mode: standalone), (display-mode: fullscreen)" in CSS
assert "--app-titlebar-safe-top:env(safe-area-inset-top" in CSS
assert "--app-safe-bottom:" not in CSS
def test_titlebar_safe_area_top_uses_scoped_variable():
"""The titlebar must use the safe-area variable instead of direct env()."""
# Match the GLOBAL `.app-titlebar{...}` rule, not skin-scoped variants like
# `:root.dark[data-skin="neon"] .app-titlebar{...}` (#3164) which can appear
# earlier in the file. Require the selector to start the line (optionally
# indented) with no `[data-skin=` scope prefix.
m = re.search(r'(?m)^\s*\.app-titlebar\{(?P<body>[^}]*)\}', CSS)
assert m, ".app-titlebar rule missing from style.css"
rule = m.group("body")
assert "padding-top:var(--app-titlebar-safe-top)" in rule, (
".app-titlebar must use the scoped safe-area variable for top padding"
)
assert "padding-top:env(safe-area-inset-top" not in rule, (
".app-titlebar must not apply env(safe-area-inset-top) directly in "
"the base browser/webview layout"
)
def test_safe_area_variables_available_for_pwa_shell():
"""Top safe-area variable should be available to installed PWA shell CSS."""
assert "--app-titlebar-safe-top:0px" in CSS, (
"titlebar top safe-area variable must default to 0px"
)
assert "--app-titlebar-safe-top:env(safe-area-inset-top" in CSS, (
"CSS must expose env(safe-area-inset-top) through --app-titlebar-safe-top"
)
assert "padding:8px 10px calc(12px + var(--keyboard-bottom-inset, 0px))!important" in CSS, (
"Phone composer should keep the proven pre-cover-mode padding contract"
)
def test_pwa_startup_classes_have_native_shell_affordances():
"""The JS-startup fallback classes should mirror browser display-mode CSS.
iOS and embedded webviews do not always evaluate display-mode media queries
the same way as Chromium. pwa-startup.js adds classes early, so CSS should
provide the same native-feel affordances through those classes.
"""
assert ".pwa-standalone" in CSS
assert ".pwa-standalone .app-titlebar-reload" in CSS
assert "overscroll-behavior:none" in CSS
assert ".pwa-offline .app-titlebar::after" in CSS
assert "pwa-title-resume" in CSS
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>
2026-04-11 19:19:12 +00:00
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)"
def test_mobile_composer_footer_stays_single_row():
"""Phone composer controls should stay in one footer row."""
mobile_css = _composer_phone_media_block()
footer = _declarations(_rule_body(mobile_css, ".composer-footer"))
assert footer.get("flex-wrap") == "nowrap", \
"mobile composer footer must stay visually single-row"
left = _declarations(_rule_body(mobile_css, ".composer-left"))
assert left.get("flex") != "1 1 100%", \
"mobile composer-left controls must not take their own full-width row"
assert left.get("width") != "100%", \
"mobile composer-left controls must not span a separate row"
assert left.get("flex-wrap") == "nowrap", \
"mobile composer-left controls must remain in one row"
right = _declarations(_rule_body(mobile_css, ".composer-right"))
assert right.get("flex") != "1 1 100%", \
"mobile composer-right actions must not take their own full-width row"
assert right.get("width") != "100%", \
"mobile composer-right actions must not span a separate row"
assert right.get("justify-content") == "flex-end", \
"mobile composer-right actions must stay end-aligned"
def test_mobile_composer_left_scrolls_horizontally_without_wrapping():
"""If many primary controls are visible, the single control row should scroll."""
left = _declarations(_rule_body(_composer_phone_media_block(), ".composer-left"))
assert left.get("overflow-x") == "auto", \
"mobile composer-left must allow horizontal overflow in the single row"
assert left.get("overflow-y") == "hidden", \
"mobile composer-left must not create a second vertical control row"
assert left.get("max-height") == "none", \
"mobile composer-left must not preserve the old bounded two-row height"
def test_mobile_composer_left_children_do_not_shrink_into_each_other():
"""Phone composer controls must scroll or compact, never shrink/overlap siblings."""
mobile_css = _composer_phone_media_block()
left = _declarations(_rule_body(mobile_css, ".composer-left"))
assert left.get("gap") == "10px", \
"mobile composer-left needs explicit spacing between 44px touch targets"
children = _declarations(_rule_body(mobile_css, ".composer-left > *"))
assert children.get("flex-shrink") == "0", \
"mobile composer-left children must not shrink and visually overlap"
for selector in (
".composer-profile-wrap",
".composer-ws-wrap",
):
declarations = _declarations(_rule_body(mobile_css, selector))
assert declarations.get("flex") == "0 0 auto", \
f"{selector} must opt out of flex shrinking on phones"
workspace_group = _declarations(_rule_body(mobile_css, ".composer-workspace-group"))
assert workspace_group.get("flex") == "0 0 44px", \
".composer-workspace-group must reserve exactly one 44px slot on phones"
def test_legacy_320px_composer_tightens_spacing_without_shrinking_targets():
"""At 320px, keep 44px controls but use smaller gutters so config stays visible."""
narrow_blocks = [block for block in _max_width_media_blocks(340) if ".composer-left" in block]
assert narrow_blocks, "Missing 320px/legacy-phone composer spacing override"
narrow_css = narrow_blocks[0]
footer = _declarations(_rule_body(narrow_css, ".composer-footer"))
left = _declarations(_rule_body(narrow_css, ".composer-left"))
wrap = _declarations(_rule_body(narrow_css, ".composer-wrap"))
assert footer.get("gap") == "4px", \
"320px footer should tighten only the gutter between left controls and send"
assert left.get("gap") == "2px", \
"320px left controls need compact gutters to fit config before the fixed send button"
assert wrap.get("padding-left") == "8px!important", \
"320px composer should reclaim a little side padding without shrinking touch targets"
assert ".send-btn{width:44px;height:44px;" in _composer_phone_media_block(), \
"narrow spacing override must not shrink the 44px send button"
assert ".composer-mobile-config-btn{box-sizing:border-box;position:relative;display:inline-flex!important;width:44px;height:44px" in _composer_phone_media_block(), \
"narrow spacing override must not shrink the 44px mobile config button"
def test_mobile_composer_workspace_switch_does_not_leave_empty_icon_slot():
"""The phone footer should keep only the useful workspace files button inline."""
mobile_css = _composer_phone_media_block()
workspace_group = _declarations(_rule_body(mobile_css, ".composer-workspace-group"))
workspace_files = _declarations(_rule_body(mobile_css, ".composer-workspace-files-btn"))
workspace_chip = _declarations(_rule_body(mobile_css, ".composer-workspace-chip"))
assert workspace_group.get("max-width") == "44px", \
"workspace group should collapse to one 44px files button on phones"
assert workspace_group.get("width") == "44px", \
"workspace group should have an exact border-box phone width"
assert workspace_group.get("box-sizing") == "border-box", \
"workspace group must use border-box for its 44px phone slot"
assert workspace_group.get("border") == "none", \
"workspace files shortcut should not keep the desktop pill/circle border on phones"
assert workspace_group.get("background") == "transparent", \
"workspace files shortcut should visually match other transparent mobile icon buttons"
assert workspace_files.get("max-width") == "44px", \
"workspace files button should be the only visible workspace footer target on phones"
assert workspace_files.get("width") == "44px", \
"workspace files button should have an exact border-box phone width"
assert workspace_files.get("box-sizing") == "border-box", \
"workspace files button must not grow beyond its 44px phone slot due to padding"
assert workspace_chip.get("display") == "none!important", \
"workspace switch chip has no visible mobile label/icon and must not consume a blank slot"
def test_mobile_composer_overflow_control_present():
"""Phone composer must expose a compact overflow/settings control."""
assert 'id="composerMobileConfigBtn"' in HTML, \
"#composerMobileConfigBtn missing from index.html"
assert 'id="composerMobileConfigPanel"' in HTML, \
"#composerMobileConfigPanel missing from index.html"
assert 'aria-controls="composerMobileConfigPanel"' in HTML, \
"mobile config button must be associated with its panel"
left_start = HTML.index('<div class="composer-left">')
left_end = HTML.index('<div class="composer-right">', left_start)
assert 'id="composerMobileConfigPanel"' not in HTML[left_start:left_end], \
"mobile overflow panel must not be nested inside .composer-left where overflow can clip it"
assert "function toggleMobileComposerConfig()" in (REPO / "static" / "ui.js").read_text(encoding="utf-8"), \
"toggleMobileComposerConfig() must be defined in static/ui.js"
mobile_css = _composer_phone_media_block()
btn = _declarations(_rule_body(mobile_css, ".composer-mobile-config-btn"))
panel = _declarations(_rule_body(CSS, ".composer-mobile-config-panel"))
panel_open = _declarations(_rule_body(mobile_css, ".composer-mobile-config-panel.open"))
assert btn.get("display") == "inline-flex!important", \
"mobile overflow button must be visible at phone width"
assert panel.get("display") == "none", \
"mobile overflow panel should be closed by default"
assert panel.get("position") == "absolute", \
"mobile overflow panel should open above the composer footer"
assert panel.get("flex-wrap") == "wrap", \
"mobile overflow panel must allow the context details row to span below primary actions"
assert panel_open.get("display") == "flex", \
"mobile overflow panel must become visible when opened"
def test_model_and_reasoning_controls_live_in_mobile_overflow_panel():
"""Model and reasoning controls must remain reachable through the phone overflow."""
panel_start = HTML.index('id="composerMobileConfigPanel"')
panel_end = HTML.index('<div class="profile-dropdown"', panel_start)
panel_html = HTML[panel_start:panel_end]
assert 'id="composerMobileModelAction"' in panel_html, \
"mobile model action must be inside the overflow panel"
assert 'id="composerMobileQuotaAction"' in panel_html, \
"mobile quota action must be inside the overflow panel"
assert 'id="composerMobileReasoningAction"' in panel_html, \
"mobile reasoning action must be inside the overflow panel"
assert 'onclick="toggleModelDropdown()"' in panel_html, \
"mobile model action must reuse the existing model dropdown"
assert 'onclick="toggleReasoningDropdown()"' in panel_html, \
"mobile reasoning action must reuse the existing reasoning dropdown"
assert 'id="composerMobileModelLabel"' in panel_html, \
"mobile model action must expose the selected model label"
assert 'id="composerMobileQuotaLabel"' in panel_html, \
"mobile quota action must expose the selected quota label"
assert 'id="composerMobileReasoningLabel"' in panel_html, \
"mobile reasoning action must expose the selected reasoning label"
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
assert "composerMobileModelAction" in ui_js, \
"model dropdown positioning/click handling must know the mobile model action"
assert "composerMobileQuotaAction" in ui_js, \
"quota sync must know the mobile quota action"
assert "composerMobileReasoningAction" in ui_js, \
"reasoning dropdown positioning/click handling must know the mobile reasoning action"
mobile_css = _composer_phone_media_block()
assert ".composer-left > .composer-model-wrap" in mobile_css, \
"phone width must hide the footer model chip behind overflow"
assert ".composer-left > .composer-reasoning-wrap" in mobile_css, \
"phone width must hide the footer reasoning chip behind overflow"
assert ".composer-mobile-config-action" in mobile_css, \
"mobile overflow panel must size the model/reasoning actions"
def test_mobile_overflow_panel_quota_order_matches_desktop_sequence():
"""The mobile overflow panel should keep the same shared control order as desktop."""
panel_start = HTML.index('id="composerMobileConfigPanel"')
panel_end = HTML.index('<div class="profile-dropdown"', panel_start)
panel_html = HTML[panel_start:panel_end]
workspace_idx = panel_html.index('id="composerMobileWorkspaceAction"')
model_idx = panel_html.index('id="composerMobileModelAction"')
quota_idx = panel_html.index('id="composerMobileQuotaAction"')
reasoning_idx = panel_html.index('id="composerMobileReasoningAction"')
context_idx = panel_html.index('id="composerMobileContextAction"')
assert workspace_idx < model_idx < quota_idx < reasoning_idx < context_idx, \
"mobile control order should mirror the desktop/shared control sequence"
def test_model_and_reasoning_dropdowns_use_mobile_panel_anchors():
"""Model/reasoning dropdowns must anchor to mobile actions while the overflow is open."""
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
model_start = ui_js.index("function _positionModelDropdown()")
model_end = ui_js.index("function renderModelDropdown()", model_start)
model_body = ui_js[model_start:model_end]
for expected in (
"composerMobileConfigPanel",
"composerMobileModelAction",
"classList.contains('open')",
):
assert expected in model_body, \
f"_positionModelDropdown must keep mobile-panel anchor logic ({expected})"
reasoning_start = ui_js.index("function _positionReasoningDropdown()")
reasoning_end = ui_js.index("function closeReasoningDropdown()", reasoning_start)
reasoning_body = ui_js[reasoning_start:reasoning_end]
for expected in (
"composerMobileConfigPanel",
"composerMobileReasoningAction",
"classList.contains('open')",
):
assert expected in reasoning_body, \
f"_positionReasoningDropdown must keep mobile-panel anchor logic ({expected})"
def test_context_details_live_in_mobile_overflow_panel():
"""Context details should be reachable in overflow without adding a composer slot."""
panel_start = HTML.index('id="composerMobileConfigPanel"')
panel_end = HTML.index('<div class="profile-dropdown"', panel_start)
panel_html = HTML[panel_start:panel_end]
for element_id in (
"composerMobileContextAction",
"composerMobileContextUsage",
"composerMobileContextTokens",
"composerMobileContextThreshold",
"composerMobileContextCost",
"composerMobileCtxCompressBtn",
):
assert f'id="{element_id}"' in panel_html, \
f"#{element_id} must be inside the mobile overflow panel"
right_start = HTML.index('<div class="composer-right">', HTML.index('<div class="composer-footer">'))
right_end = HTML.index('<div class="composer-mobile-config-panel"', right_start)
right_html = HTML[right_start:right_end]
assert 'id="composerMobileContextAction"' not in right_html, \
"mobile context details must not live in composer-right as another phone slot"
assert 'id="composerMobileCtxBadge"' not in right_html, \
"mobile context badge must stay on the config button, not composer-right"
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
sync_start = ui_js.index("function _syncMobileCtxDisplay(state)")
sync_end = ui_js.index("// ── Touch support", sync_start)
sync_body = ui_js[sync_start:sync_end]
for expected in (
"DEFAULT_CTX=128*1024",
"hasExplicitCtx",
"hasPromptTok",
"rawPct",
"overflowed",
"composerMobileContextUsage",
"composerMobileContextTokens",
"composerMobileCtxCompressBtn",
):
assert expected in sync_body, \
f"_syncCtxIndicator must preserve upstream context logic while updating mobile context UI ({expected})"
mobile_css = _composer_phone_media_block()
ctx_wrap = _declarations(_rule_body(mobile_css, ".ctx-indicator-wrap"))
assert ctx_wrap.get("display") == "none!important", \
"standalone context indicator must remain hidden from the phone composer row"
context_row = _declarations(_rule_body(CSS, ".composer-mobile-context-action"))
assert context_row.get("flex") == "1 0 100%", \
"mobile context details should span the overflow panel instead of crowding the action row"
context_button = _declarations(_rule_body(CSS, ".composer-mobile-context-compress"))
assert context_button.get("width") == "auto", \
"mobile compress affordance should be compact inside the context row"
def test_context_indicator_click_opens_shared_mobile_config_menu():
"""The desktop context ring should open the same menu used by phone mode."""
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
assert "function openMobileComposerConfig()" in ui_js, \
"mobile config open path should be reusable outside the phone button"
assert "function openComposerContextMenu(e)" in ui_js, \
"context indicator needs a named click path into the shared config menu"
context_menu_body = _js_function_body(ui_js, "openComposerContextMenu")
for expected in (
"e.preventDefault()",
"e.stopPropagation()",
"ctxTooltip",
"openMobileComposerConfig()",
):
assert expected in context_menu_body, \
f"context click should open the shared menu without leaving tooltip state behind ({expected})"
assert "btn.addEventListener('click',openComposerContextMenu)" in ui_js, \
"context indicator click must open the shared composer config menu"
panel_open = _declarations(_rule_body(CSS, ".composer-mobile-config-panel.open"))
assert panel_open.get("display") == "flex", \
"the shared composer config panel must be displayable when opened outside phone CSS"
def test_workspace_control_lives_in_mobile_overflow_panel():
"""Workspace switching must stay reachable even when the inline switch chip is hidden."""
panel_start = HTML.index('id="composerMobileConfigPanel"')
panel_end = HTML.index('<div class="profile-dropdown"', panel_start)
panel_html = HTML[panel_start:panel_end]
assert 'id="composerMobileWorkspaceAction"' in panel_html, \
"mobile workspace action must be inside the overflow panel"
assert 'onclick="toggleComposerWsDropdown()"' in panel_html, \
"mobile workspace action must reuse the existing workspace dropdown"
assert 'id="composerMobileWorkspaceLabel"' in panel_html, \
"mobile workspace action must expose the current workspace label"
mobile_css = _composer_phone_media_block()
workspace_chip = _declarations(_rule_body(mobile_css, ".composer-workspace-chip"))
assert workspace_chip.get("display") == "none!important", \
"inline workspace switch chip must remain hidden on phones"
panels_js = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
pos_start = panels_js.index("function _positionComposerWsDropdown()")
pos_end = panels_js.index("function _positionProfileDropdown()", pos_start)
position_body = panels_js[pos_start:pos_end]
assert "composerMobileWorkspaceAction" in position_body, \
"workspace dropdown positioning must know the mobile workspace action"
assert "composerMobileConfigPanel" in position_body, \
"workspace dropdown positioning must anchor to the mobile panel action while open"
assert "anchor to #composerMobileWorkspaceAction" in position_body, \
"workspace dropdown positioning should document the mobile-panel anchor choice"
toggle_start = panels_js.index("function toggleComposerWsDropdown()")
toggle_end = panels_js.index("function closeWsDropdown()", toggle_start)
toggle_body = panels_js[toggle_start:toggle_end]
assert "usingMobileAction" in toggle_body and "chip.disabled" in toggle_body, \
"mobile workspace action must bypass only the hidden/disabled desktop chip guard"
assert "!e.target.closest('#composerMobileWorkspaceAction')" in panels_js, \
"workspace dropdown click-away handling must include the mobile workspace action"
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
assert "e.target.closest('#composerWsDropdown')" in ui_js, \
"mobile overflow click-away handling must allow interaction with the workspace dropdown"
def test_mobile_config_panel_escape_closes_panel_and_dropdowns():
"""Escape should close mobile overflow state without touching desktop-only dropdowns."""
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
keydown_start = ui_js.index("document.addEventListener('keydown',function(e){", ui_js.index("function toggleMobileComposerConfig()"))
keydown_end = ui_js.index("\n});", keydown_start)
keydown_body = ui_js[keydown_start:keydown_end]
assert "e.key!=='Escape'" in keydown_body, \
"mobile config Escape handler must only handle Escape"
assert "composerMobileConfigPanel" in keydown_body, \
"mobile config Escape handler must look up the mobile config panel"
assert "classList.contains('open')" in keydown_body, \
"mobile config Escape handler must be gated on the open mobile panel"
for expected in (
"closeMobileComposerConfig()",
"closeWsDropdown",
"closeModelDropdown()",
"closeReasoningDropdown()",
):
assert expected in keydown_body, \
f"mobile config Escape handler must close related state ({expected})"
def test_reasoning_chip_updates_desktop_and_mobile_controls():
"""Reasoning chip sync should keep both footer and mobile overflow labels current."""
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
chip_start = ui_js.index("function _applyReasoningChip(eff)")
chip_end = ui_js.index("function fetchReasoningChip()", chip_start)
chip_body = ui_js[chip_start:chip_end]
for expected in (
"composerReasoningWrap",
"composerMobileReasoningAction",
"composerReasoningLabel",
"composerMobileReasoningLabel",
"label.textContent=text",
"mobileLabel.textContent=text",
):
assert expected in chip_body, \
f"_applyReasoningChip must update desktop and mobile reasoning UI ({expected})"
def test_mobile_config_kickers_have_i18n_fallbacks():
"""Mobile overflow kicker labels should be localizable without losing HTML fallback text."""
panel_start = HTML.index('id="composerMobileConfigPanel"')
panel_end = HTML.index('<div class="profile-dropdown"', panel_start)
panel_html = HTML[panel_start:panel_end]
i18n_js = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
en_start = i18n_js.index(" en: {")
en_end = i18n_js.index("\n ru: {", en_start)
english = i18n_js[en_start:en_end]
for key, label in (
("composer_mobile_workspace", "Workspace"),
("composer_mobile_model", "Model"),
("composer_mobile_quota", "Quota"),
("composer_mobile_reasoning", "Reasoning"),
("composer_mobile_context", "Context"),
):
assert f'data-i18n="{key}">{label}</span>' in panel_html, \
f"mobile panel kicker {label} must keep data-i18n and fallback text"
assert f"{key}: '{label}'" in english, \
f"English locale must define {key}"
def test_mobile_composer_primary_controls_keep_touch_friendly_sizing():
"""Visible phone composer controls and overflow controls must keep 44px targets."""
mobile_css = _composer_phone_media_block()
for selector in (
".composer-mobile-config-btn",
".composer-profile-chip",
".composer-mobile-config-action",
):
declarations = _declarations(_rule_body(mobile_css, selector))
assert declarations.get("box-sizing") == "border-box", \
f"{selector} must use border-box so padding/border cannot exceed 44px"
assert declarations.get("min-height") == "44px", \
f"{selector} must keep a 44px minimum height on phones"
if selector != ".composer-mobile-config-action":
assert declarations.get("min-width") == "44px", \
f"{selector} must keep a 44px minimum width on phones"
send = _declarations(_rule_body(mobile_css, ".send-btn"))
assert send.get("width") == "44px", ".send-btn must keep 44px width on phones"
assert send.get("height") == "44px", ".send-btn must keep 44px height on phones"
ctx_wrap = _declarations(_rule_body(mobile_css, ".ctx-indicator-wrap"))
assert ctx_wrap.get("display") == "none!important", \
"context indicator must not add a late-appearing composer-right slot on phones"
Release v0.51.226 — Release GT (stage-p9 — mobile composer context-usage ring #3062 + activity-feed default-expand setting #3080) (#3459) * feat(composer): replace mobile config-button sliders icon with context-usage ring (#3062, @NottheGuy007) Squashed net diff of #3062 (the PR branch's tip commits were deletes of files not present in our tree). Replaces the composerMobileCtxBadge text badge with an SVG progress ring (ctx-arc + ctx-num) showing real-time context-window usage: ring fill via stroke-dashoffset, centered percentage, color-coded green<=50% orange<=85% red>85%. Ring resets to 0%/green on new session. * feat(activity): add 'expand activity feed by default' appearance setting (#3080, @AJV20) Squashed net diff of #3080. Adds a Settings -> Appearance checkbox (activity_feed_expanded_default, default off) to expand new Activity disclosures by default; preserves manual per-turn collapse/expand (explicit toggle still wins); live 'Waiting on model' rows explain what the agent is doing before/after tool calls. i18n keys for all locales. * docs(changelog): v0.51.226 — context-usage ring (#3062) + activity-feed default-expand setting (#3080) * test(mobile): update touch-target test for #3062 ring (badge removed) #3062 replaced the composerMobileCtxBadge text badge with the SVG context-usage ring (composerMobileCtxRing) but left 3 stale references in tests/test_mobile_layout.py. The full suite caught test_mobile_composer_primary_controls_keep_touch_friendly_sizing asserting the removed .composer-mobile-ctx-badge CSS rule + #composerMobileCtxBadge element. Updated the assertion to the new ring: confirm composerMobileCtxRing exists, the old badge is fully gone (not dangling), and the ring SVG is aria-hidden so it stays a decorative overlay that doesn't steal the config button's 44px touch target (which is still asserted via .composer-mobile-config-btn above). 56/56 mobile-layout tests pass. Codex: no production JS dereferences the removed badge. --------- Co-authored-by: nesquena-hermes <[email protected]>
2026-06-03 02:48:01 +00:00
# #3062 replaced the old text badge (composerMobileCtxBadge / .composer-mobile-ctx-badge)
# with an SVG context-usage ring overlaid on the config button. The invariant is the
# same: the ring is a visual indicator hosted ON the 44px config button (whose sizing is
# asserted above), and it must not steal/shrink that touch target. The ring SVG is
# aria-hidden and uses currentColor; it carries no pointer events of its own.
assert 'id="composerMobileCtxRing"' in HTML, \
"mobile context-usage ring element must exist in the composer config button"
assert 'id="composerMobileCtxBadge"' not in HTML, \
"old text badge should be fully replaced by the ring, not left dangling"
# Locate the ring's markup and confirm it does not become an interactive/sized control
# that would compete with the config button's 44px target.
_ring_idx = HTML.find('id="composerMobileCtxRing"')
_ring_tag = HTML[HTML.rfind("<", 0, _ring_idx):HTML.find(">", _ring_idx) + 1]
assert "aria-hidden" in _ring_tag, \
"the context ring is decorative overlay — it must be aria-hidden so it doesn't steal the config button's role/touch target"
icon_btn = _declarations(_rule_body(mobile_css, ".icon-btn"))
assert icon_btn.get("min-width") == "44px", \
".icon-btn controls such as attach/mic must keep 44px minimum width on phones"
assert icon_btn.get("min-height") == "44px", \
".icon-btn controls such as attach/mic must keep 44px minimum height on phones"
if ".composer-workspace-files-btn" in mobile_css:
files_btn = _declarations(_rule_body(mobile_css, ".composer-workspace-files-btn"))
workspace_group = _declarations(_rule_body(mobile_css, ".composer-workspace-group"))
assert files_btn.get("min-width") == "44px", \
".composer-workspace-files-btn must keep a 44px minimum width on phones"
assert workspace_group.get("min-height") == "44px", \
".composer-workspace-group must preserve 44px touch height on phones"
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>
2026-04-11 19:19:12 +00:00
# ── 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"
def test_touch_device_inputs_meet_zoom_threshold():
"""All input/textarea/select must clear iOS Safari's 16px zoom threshold
on touch-primary devices, not just the composer textarea (#1167).
This locks the global media-query floor so future per-element font-size
tweaks (sidebar search 13px, settings selects 12px, dialog inputs 14px,
onboarding fields 13px) cannot accidentally re-introduce auto-zoom.
"""
# The hover:none + pointer:coarse pair is the canonical touch-primary
# detection (won't match desktop with mouse, won't match touch laptops
# that report hover:hover).
pattern = re.compile(
r'@media\s*\(hover:none\)\s*and\s*\(pointer:coarse\)\s*\{[^}]*'
r'input\s*,\s*textarea\s*,\s*select\s*\{[^}]*'
r'font-size:\s*max\(\s*16px',
re.DOTALL,
)
assert pattern.search(CSS), (
"style.css must contain a (hover:none) and (pointer:coarse) media "
"query that bumps input/textarea/select to font-size:max(16px,…) "
"so iOS Safari does not auto-zoom on focus (#1167)"
)
def test_touch_keyboard_inset_uses_touch_primary_media_query():
"""The keyboard inset path must key off touch-primary media queries."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "function _isTouchKeyboardViewport()" in boot_js, \
"boot.js must define a touch-keyboard viewport predicate"
assert "matchMedia('(hover:none) and (pointer:coarse)')" in boot_js or \
'matchMedia("(hover:none) and (pointer:coarse)")' in boot_js, \
"touch keyboard inset eligibility must use the hover:none + pointer:coarse media query"
assert "any-pointer:fine" in boot_js, \
"touch keyboard inset eligibility must exclude co-existing fine-pointer setups"
assert "!_hasFinePointerCoexisting()" in boot_js, \
"touch keyboard inset eligibility must skip hardware-keyboard/trackpad devices"
def test_touch_keyboard_inset_writes_and_clears_css_variable():
"""visualViewport geometry must write and clear the keyboard inset variable."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "setProperty('--keyboard-bottom-inset'" in boot_js, \
"boot.js must write --keyboard-bottom-inset on touch keyboard viewport changes"
assert "removeProperty('--keyboard-bottom-inset')" in boot_js, \
"boot.js must clear --keyboard-bottom-inset when the inset is zero or ineligible"
assert "Math.max(0,Math.ceil(window.innerHeight-(vv.height+vv.offsetTop)))" in boot_js, \
"boot.js must compute the bottom inset from innerHeight and visualViewport geometry"
def test_touch_keyboard_inset_ignores_pinch_zoom_scale():
"""A pinch-zoomed viewport (vv.scale != 1) must not be read as keyboard
occlusion — otherwise Chromium 'force enable zoom' produces a large spurious
inset that jitters on pan (#5738 UX-gate hardening)."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "vv.scale" in boot_js, \
"boot.js must consult visualViewport.scale before treating shrinkage as keyboard occlusion"
assert "Math.abs((vv.scale||1)-1)>0.05" in boot_js, \
"boot.js must bail out of the inset when the viewport is pinch-zoomed"
def test_touch_keyboard_inset_primes_during_visual_viewport_setup():
"""The existing visualViewport setup path must prime the inset immediately."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
setup_start = boot_js.index("if(window.visualViewport){")
setup_end = boot_js.index("window.visualViewport.addEventListener('resize'", setup_start)
setup_block = boot_js[setup_start:setup_end]
assert "_syncKeyboardBottomInset();" in setup_block, \
"boot.js must sync the keyboard inset once during visualViewport setup"
def test_touch_keyboard_inset_primes_on_pageshow_restore():
"""BFCache restore must resync the inset before restore work continues."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
pageshow_start = boot_js.index("window.addEventListener('pageshow'")
pageshow_end = boot_js.index("const _srch = document.getElementById('sessionSearch');", pageshow_start)
pageshow_block = boot_js[pageshow_start:pageshow_end]
assert "_syncKeyboardBottomInset();" in pageshow_block, \
"boot.js must sync the keyboard inset during pageshow restore"
def test_touch_keyboard_inset_applies_to_composer_padding():
"""Touch/coarse composer padding must consume the keyboard inset variable."""
css_ns = re.sub(r'\s+', '', CSS)
assert "@media(hover:none)and(pointer:coarse)" in css_ns, \
"style.css must scope the keyboard inset padding to touch-primary viewports"
assert ".composer-wrap{padding-bottom:calc(14px+var(--keyboard-bottom-inset,0px));}" in css_ns, \
"style.css must add the keyboard inset to composer bottom padding"
# ── Sidebar tabs on mobile ───────────────────────────────────────────────────
def test_profiles_sidebar_tab_present():
"""Sidebar tab strip must include Profiles."""
fix(ux): polish CSS tooltips + clear native title + extend coverage 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>
2026-05-07 04:00:40 +00:00
# Tolerate additional utility classes (e.g. `has-tooltip` from #1775).
# We just need a nav-tab classed button targeting the profiles panel.
import re
pattern = r'class="[^"]*\bnav-tab\b[^"]*"[^>]*data-panel="profiles"'
assert re.search(pattern, HTML), \
"Sidebar nav must have a nav-tab button with data-panel=\"profiles\""
def test_mobile_bottom_nav_removed():
"""The old fixed mobile bottom nav should not be present anymore."""
assert "mobile-bottom-nav" not in HTML, \
"mobile-bottom-nav markup should be removed from index.html"
assert "mobile-bottom-nav" not in CSS, \
"mobile-bottom-nav CSS should be removed from style.css"
# ── Mobile Enter key inserts newline (PR #315, fixes #269) ───────────────────
def test_mobile_enter_newline_condition_present():
"""boot.js keydown handler must detect touch-primary devices via pointer:coarse."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "pointer:coarse" in boot_js, \
"boot.js must use pointer:coarse media query for mobile Enter detection"
def test_mobile_enter_newline_uses_match_media():
"""boot.js must call matchMedia for pointer detection, not a hardcoded flag."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "matchMedia('(pointer:coarse)')" in boot_js or 'matchMedia("(pointer:coarse)")' in boot_js, \
"boot.js must use matchMedia('(pointer:coarse)') for mobile detection"
def test_mobile_enter_newline_does_not_depend_on_viewport_heuristic():
"""The viewport-shrink heuristic was unreliable on iOS/Android and must be gone."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "function _isVirtualKeyboardLikelyOpen()" not in boot_js, \
"the unreliable visualViewport keyboard heuristic function must be removed"
assert "&&_isVirtualKeyboardLikelyOpen()" not in boot_js, \
"the mobile Enter override must no longer call the viewport heuristic"
assert "window.innerHeight-vv.height>120" not in boot_js, \
"the viewport height-delta probe must no longer gate the mobile Enter override"
def test_mobile_enter_newline_respects_hardware_keyboard_on_touch_devices():
"""Touch devices with a co-existing fine pointer (hardware keyboard) keep desktop Enter=send."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "any-pointer:fine" in boot_js, \
"boot.js must use any-pointer:fine to detect a co-existing hardware keyboard/trackpad"
assert "!_hasFinePointerCoexisting()" in boot_js, \
"mobile Enter newline override must skip touch devices that also expose a fine pointer"
def test_mobile_enter_newline_only_overrides_enter_default():
"""Mobile newline override must only apply when _sendKey is the default 'enter'."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
# The _mobileDefault check must gate on _sendKey==='enter' so ctrl+enter users aren't affected
assert "_sendKey===" in boot_js and "'enter'" in boot_js, \
"Mobile newline fallback must check window._sendKey==='enter' to avoid overriding user preference"
def test_mobile_enter_does_not_affect_desktop_logic():
"""The mobile Enter override must not alter the existing else branch for desktop users."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
# The else branch (desktop, sends on Enter without Shift) must still be present
assert "if(!e.shiftKey){e.preventDefault();send();" in boot_js, \
"Desktop Enter-to-send logic (else branch) must still be present in boot.js"