mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
27b17a8fc8
* fix(#1096): copy buttons fall back to execCommand on HTTP contexts - Add _copyText() helper: tries navigator.clipboard first, falls back to document.execCommand('copy') with hidden textarea when not in secure context - Update copyMsg() and addCopyButtons() to use helper instead of direct navigator.clipboard.writeText() - Code block copy button now has .catch() handler (was silently failing) - Error messages use t('copy_failed') for i18n instead of hardcoded string - Add copy_failed key to all 6 locale blocks (en, ru, es, de, zh, zh-Hant) - Add 10 regression tests * fix(#1095): render pasted/dragged images as inline preview instead of paperclip badge - User message attachments with image extensions now render as <img> via api/media endpoint, with click-to-fullscreen support - Non-image attachments still show paperclip + filename badge - Extracts filename from full path for display - Add 5 regression tests * fix: hoist _IMAGE_EXTS to module scope, add avif (absorb fix) * fix: improve mobile touch responsiveness for session list items iPad Safari has known issues with the click/dblclick pattern on touch: - :hover-triggered padding-right layout shift causes the first tap click to target the wrong element (actions button that just appeared) - No touch-action:manipulation means iOS still delays taps for double-tap zoom detection - The old onclick+ondblclick pattern is designed for mouse, not touch Changes: - CSS: Remove :hover from padding-right rule to prevent layout shift - CSS: Add touch-action:manipulation and -webkit-tap-highlight-color to .session-item for immediate tap response - JS: Replace onclick/ondblclick with onpointerup + manual 350ms double-tap detection — works consistently on mouse and touch * fix(#1106): iterate custom_providers[].models dict keys for dropdown population - After reading singular 'model' field, also iterate 'models' dict keys - Deduplicate: model field value not repeated if also in models dict - Skip non-string keys gracefully - Works for both named and unnamed custom_providers entries - Add 7 regression tests * fix(#1105): allow custom_providers hostnames through SSRF check - Build trusted hostname set from custom_providers[].base_url in config.yaml - These are user-explicitly configured endpoints — not SSRF risks - Hardcoded allowlist (ollama, localhost, 127.0.0.1, lmstudio) still active - Unknown private IPs still blocked - Add 7 tests (5 source analysis + 2 functional with mocked socket) * fix(tests): update hover padding assertions for #1110 touch fix (absorb) * fix(css): restore hover padding via @media (hover:hover) for mouse devices (absorb) * fix: filter right/middle-click from pointerup handler (absorb) * docs: v0.50.221 release notes and version bump --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: sheng <378978764@qq.com>
243 lines
12 KiB
Python
243 lines
12 KiB
Python
"""Regression tests for two related sidebar/panel UI fixes.
|
|
|
|
1. Workspace panel header collapse priority — as the right panel narrows,
|
|
the git-badge must vanish first, the "Workspace" label second, and the
|
|
icon buttons last. Previously all three compressed simultaneously
|
|
because `.panel-header` used `justify-content:space-between` with no
|
|
flex-shrink ratios or container queries.
|
|
|
|
2. Project color dot truncation — the dot used to be appended INSIDE the
|
|
`.session-title` span (which is `overflow:hidden;text-overflow:ellipsis`),
|
|
so the dot got clipped along with long titles. Fix moves the dot to a
|
|
flex sibling in `.session-title-row` between title and timestamp, and
|
|
moves `.session-time` from `position:absolute` to flex flow so the
|
|
title's `flex:1` bound stops at the timestamp's left edge.
|
|
"""
|
|
|
|
import pathlib
|
|
|
|
REPO = pathlib.Path(__file__).parent.parent
|
|
SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
|
|
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
|
|
|
|
|
# ── Bug 1: workspace panel header collapse priority ──────────────────────────
|
|
|
|
|
|
class TestWorkspacePanelCollapsePriority:
|
|
|
|
def test_rightpanel_is_a_size_container(self):
|
|
"""The right panel must declare itself as an inline-size container so
|
|
its descendants can run @container queries against the panel's width."""
|
|
# Look at the .rightpanel rule body
|
|
idx = STYLE_CSS.find(".rightpanel{")
|
|
assert idx >= 0, ".rightpanel rule not found"
|
|
rule = STYLE_CSS[idx: idx + 1200]
|
|
assert "container-type:inline-size" in rule, (
|
|
".rightpanel must declare container-type:inline-size for the "
|
|
"header collapse-priority @container queries to work."
|
|
)
|
|
assert "container-name:rightpanel" in rule, (
|
|
".rightpanel should be named 'rightpanel' so descendants can "
|
|
"scope @container queries explicitly."
|
|
)
|
|
|
|
def test_panel_header_no_longer_uses_space_between(self):
|
|
"""`justify-content:space-between` was the root cause of the
|
|
simultaneous-shrink behaviour. The header now uses `gap` and
|
|
`margin-left:auto` on `.panel-actions` to push them right."""
|
|
idx = STYLE_CSS.find(".panel-header{")
|
|
rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx) + 1]
|
|
assert "justify-content:space-between" not in rule, (
|
|
"panel-header still uses justify-content:space-between — that "
|
|
"compresses all three children simultaneously."
|
|
)
|
|
assert "gap:6px" in rule
|
|
assert "overflow:hidden" in rule
|
|
|
|
def test_panel_actions_pushed_right_and_never_shrinks(self):
|
|
"""`.panel-actions` must have flex-shrink:0 and margin-left:auto so
|
|
the icon buttons stay visible no matter how narrow the panel gets,
|
|
and they sit at the right edge once `space-between` is removed."""
|
|
idx = STYLE_CSS.find(".panel-actions{")
|
|
rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)]
|
|
assert "flex-shrink:0" in rule, (
|
|
".panel-actions must not shrink — icons are the priority."
|
|
)
|
|
assert "margin-left:auto" in rule, (
|
|
".panel-actions must use margin-left:auto to push to the right "
|
|
"now that justify-content:space-between is gone."
|
|
)
|
|
|
|
def test_workspace_label_shrinks_with_ellipsis(self):
|
|
"""The "Workspace" label (`panel-header > span:first-child`) must
|
|
shrink with ellipsis truncation rather than overflow uncontrollably."""
|
|
# Find the rule
|
|
sel = ".panel-header > span:first-child"
|
|
idx = STYLE_CSS.find(sel)
|
|
assert idx >= 0, f"Selector {sel!r} not found in style.css"
|
|
rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)]
|
|
assert "text-overflow:ellipsis" in rule
|
|
assert "min-width:0" in rule
|
|
assert "flex-shrink:2" in rule # shrinks before icons (icons are 0)
|
|
|
|
def test_git_badge_shrinks_first(self):
|
|
"""`.git-badge` must shrink faster than the label so it disappears
|
|
first as the panel narrows."""
|
|
idx = STYLE_CSS.find(".git-badge{")
|
|
rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)]
|
|
assert "flex-shrink:3" in rule, (
|
|
".git-badge must have flex-shrink:3 so it shrinks before the "
|
|
"label (flex-shrink:2) and the icons (flex-shrink:0)."
|
|
)
|
|
|
|
def test_container_query_hides_git_badge_first(self):
|
|
"""At narrow widths the git badge gets `display:none` BEFORE the
|
|
label is hidden — git badge first."""
|
|
# The container query block for hiding git badge
|
|
assert "@container rightpanel (max-width: 220px)" in STYLE_CSS, (
|
|
"Missing @container rule to hide .git-badge at narrow widths"
|
|
)
|
|
# Find the block and check git-badge is targeted
|
|
idx = STYLE_CSS.find("@container rightpanel (max-width: 220px)")
|
|
block = STYLE_CSS[idx: idx + 200]
|
|
assert ".git-badge{display:none" in block
|
|
|
|
def test_container_query_hides_label_at_narrower_width(self):
|
|
"""The label hides at a NARROWER threshold than the git badge —
|
|
confirms collapse priority order."""
|
|
assert "@container rightpanel (max-width: 160px)" in STYLE_CSS
|
|
idx = STYLE_CSS.find("@container rightpanel (max-width: 160px)")
|
|
block = STYLE_CSS[idx: idx + 200]
|
|
assert ".panel-header > span:first-child{display:none" in block
|
|
|
|
def test_breakpoints_in_correct_order(self):
|
|
"""Sanity: the git-badge breakpoint (220px) must be wider than the
|
|
label breakpoint (160px). Otherwise the label would vanish first."""
|
|
# Both queries exist — extract numeric thresholds
|
|
import re
|
|
matches = re.findall(
|
|
r"@container rightpanel \(max-width:\s*(\d+)px\)", STYLE_CSS
|
|
)
|
|
assert len(matches) >= 2
|
|
thresholds = [int(m) for m in matches]
|
|
# First threshold (git badge) must be larger than label threshold
|
|
assert thresholds[0] > thresholds[1], (
|
|
f"Git badge breakpoint ({thresholds[0]}px) must be wider than "
|
|
f"label breakpoint ({thresholds[1]}px) so git-badge hides first."
|
|
)
|
|
|
|
|
|
# ── Bug 2: project color dot placement ───────────────────────────────────────
|
|
|
|
|
|
class TestProjectDotPlacement:
|
|
|
|
def test_dot_appended_to_title_row_not_title(self):
|
|
"""The project dot must be appended to `titleRow` (a flex sibling
|
|
of the title and timestamp), not to the title span (which truncates
|
|
with ellipsis and would clip the dot off long titles)."""
|
|
# Find _renderOneSession body
|
|
idx = SESSIONS_JS.find("function _renderOneSession(")
|
|
assert idx >= 0
|
|
body = SESSIONS_JS[idx: idx + 6000]
|
|
# Must append dot to titleRow
|
|
assert "titleRow.appendChild(dot)" in body, (
|
|
"Project dot must be appended to titleRow as a flex sibling, "
|
|
"not inside the truncating title span"
|
|
)
|
|
# Must NOT append dot to title (the truncating span)
|
|
assert "title.appendChild(dot)" not in body, (
|
|
"Old behaviour — dot inside title span gets clipped by the "
|
|
"ellipsis truncation. Dot must live in titleRow instead."
|
|
)
|
|
|
|
def test_dot_placed_between_title_and_timestamp(self):
|
|
"""The dot is appended AFTER title.appendChild and BEFORE ts append
|
|
— that ordering puts the dot between the title and the timestamp
|
|
in the flex row."""
|
|
idx = SESSIONS_JS.find("function _renderOneSession(")
|
|
body = SESSIONS_JS[idx: idx + 6000]
|
|
title_pos = body.find("titleRow.appendChild(title);")
|
|
dot_pos = body.find("titleRow.appendChild(dot);")
|
|
ts_pos = body.find("titleRow.appendChild(ts);")
|
|
assert title_pos >= 0 and dot_pos >= 0 and ts_pos >= 0
|
|
assert title_pos < dot_pos < ts_pos, (
|
|
f"Order must be title → dot → ts in the title row "
|
|
f"(positions: {title_pos}, {dot_pos}, {ts_pos})"
|
|
)
|
|
|
|
def test_session_time_uses_flex_flow_not_absolute(self):
|
|
"""`.session-time` must use margin-left:auto in flex flow, not
|
|
position:absolute. Without this the title's flex:1 runs underneath
|
|
the absolute-positioned timestamp and the dot has no anchor."""
|
|
# Get the bare .session-time rule (not .session-time.is-hidden, not
|
|
# .session-item:hover .session-time)
|
|
idx = STYLE_CSS.find(".session-time{")
|
|
rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)]
|
|
assert "position:absolute" not in rule, (
|
|
".session-time must not be position:absolute — bug 2 requires "
|
|
"it to live in the flex flow of .session-title-row."
|
|
)
|
|
assert "margin-left:auto" in rule, (
|
|
".session-time must use margin-left:auto to push to the right "
|
|
"edge of the flex row."
|
|
)
|
|
|
|
def test_session_project_dot_no_inline_block_baggage(self):
|
|
"""`.session-project-dot` is now a flex sibling — the row's gap:6px
|
|
handles spacing, so the old `margin-left:4px` and
|
|
`vertical-align:middle` are unnecessary and only confuse layout."""
|
|
idx = STYLE_CSS.find(".session-project-dot{")
|
|
assert idx >= 0
|
|
rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)]
|
|
assert "margin-left:4px" not in rule, (
|
|
"Old margin-left:4px is unnecessary now — gap:6px on "
|
|
".session-title-row handles spacing"
|
|
)
|
|
assert "vertical-align:middle" not in rule, (
|
|
"vertical-align is meaningless inside flex flow"
|
|
)
|
|
assert "flex-shrink:0" in rule, (
|
|
"Dot must not shrink (would disappear at narrow sidebar widths)"
|
|
)
|
|
|
|
def test_session_item_padding_at_rest_no_longer_reserves_86px(self):
|
|
"""At rest (no hover, no streaming, no unread), the session item
|
|
no longer reserves 86px for the absolute timestamp — that space
|
|
was wasted now that the timestamp lives in flex flow."""
|
|
# Find the FIRST .session-item{ rule (the desktop one, not the
|
|
# mobile-touch override).
|
|
idx = STYLE_CSS.find(".session-item{padding:8px")
|
|
assert idx >= 0, "Could not find desktop .session-item padding rule"
|
|
rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)]
|
|
assert "padding:8px 8px" in rule, (
|
|
f"Expected 'padding:8px 8px' for at-rest session items, got: {rule!r}"
|
|
)
|
|
# Mobile also drops from 86px to 40px — the absolute timestamp is
|
|
# gone (now flex-flow), so only the always-visible action button's
|
|
# footprint (26px + 6px gap ≈ 32px, rounded to 40px) needs reservation.
|
|
assert ".session-item{min-height:44px;padding:10px 40px 10px 12px;}" in STYLE_CSS
|
|
|
|
def test_session_item_expands_padding_on_hover_and_attention(self):
|
|
"""PR #1110: Touch layout-shift fix — :hover removed from the COMBINED
|
|
padding-right selector. Touch devices (iPad, phone) see hover:none so
|
|
they skip the @media (hover:hover) block below. Mouse devices see
|
|
hover:hover and get the padding-right on hover.
|
|
streaming/unread/focus-within/menu-open expand to 40px for all devices."""
|
|
# Touch-safe combined rule (no :hover in this one)
|
|
sel = (
|
|
".session-item.streaming,.session-item.unread,"
|
|
".session-item:focus-within,"
|
|
".session-item.menu-open"
|
|
)
|
|
idx = STYLE_CSS.find(sel)
|
|
assert idx >= 0, (
|
|
"Combined streaming/unread/focus-within/menu-open padding rule not found"
|
|
)
|
|
rule = STYLE_CSS[idx: STYLE_CSS.find("}", idx)]
|
|
assert "padding-right:40px" in rule
|
|
# Desktop hover padding restored via @media (hover:hover) — mouse devices only
|
|
assert "@media (hover:hover)" in STYLE_CSS
|
|
assert ".session-item:hover{padding-right:40px;}" in STYLE_CSS
|