Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Hermes Web UI -- Changelog

## [v0.51.13] — 2026-05-06 — single-PR composer UX

### Added

- **PR #1758** — Click pasted/attached image thumbnails in the composer to lightbox-zoom them. When pasting/dropping screenshots into the composer, the 56×56 thumbnail in each chip now opens the existing image lightbox on click — same modal that's been wired for message-attached images since v0.50.x. Cursor changes to `zoom-in` (was `default`, actively misleading) and a subtle hover emphasis (4% scale + 5% brightness, 120ms ease, hover-capable devices only via `@media (hover: hover)`) gives instant visual feedback. Audio/video chips are unaffected — they keep their inline native controls and never render an `.attach-thumb` IMG. Refs #1733. Pairs with the companion Mac PR `hermes-webui/hermes-swift-mac#74` for sequential-paste filename uniqueness — paste, paste, paste, click any to verify, send.

### Tests

4637 → **4642 collected** (+5 regression tests across composer chip wiring + cursor affordance). 4630 passed, 9 skipped (test-isolation prong-2 noise), 3 xpassed, 0 failed in 145s.

### Pre-release verification

- @nesquena independently APPROVED with exhaustive headless-Chrome behavioural harness verifying all 4 click paths (thumb-image, ×-on-image, ×-on-audio, audio-element). Pre-fix verification confirmed 4/5 of the new tests catch regressions to the previous state.
- Stage-307: clean rebase + clean merge (no conflicts).
- All JS files syntax-clean (`node -c static/ui.js`).
- pytest: 4630 passed, 0 failed (single clean run).
- `scripts/run-browser-tests.sh`: all 11 endpoints PASS on isolated port 8789.
- Pre-stamp re-fetch: PR head still matches local rebase — no late commits.
- Opus advisor: SHIP, all 6 verification questions clean, 0 MUST-FIX. One non-blocking nit (wrap `:hover` in `@media (hover: hover)` for iPad sticky-hover hygiene) absorbed in-release as a defensive 3-LOC cleanup.

## [v0.51.12] — 2026-05-06 — 3-PR full-sweep batch

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
>
> Last updated: v0.51.12 (May 6, 2026) — 4632 tests collected — 3-PR full-sweep batch (#1746, #1752, #1753)
> Last updated: v0.51.13 (May 6, 2026) — 4642 tests collected — single-PR composer UX (#1758)
> Test source: `pytest tests/ --collect-only -q`
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)

Expand Down
4 changes: 2 additions & 2 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -1835,8 +1835,8 @@ Bridged CLI sessions:

---

*Last updated: v0.51.12, May 6, 2026*
*Total automated tests collected: 4632*
*Last updated: v0.51.13, May 6, 2026*
*Total automated tests collected: 4642*
*Regression gate: tests/test_regressions.py*
*Run: pytest tests/ -v --timeout=60*
*Source: <repo>/*
5 changes: 4 additions & 1 deletion static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -894,7 +894,10 @@
.attach-chip--audio,.attach-chip--video{max-width:260px;}
.attach-media-icon{display:inline-flex;align-items:center;color:var(--accent-text);}
.attach-chip-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.attach-thumb{width:56px;height:56px;object-fit:cover;border-radius:4px;display:block;cursor:default;}
.attach-thumb{width:56px;height:56px;object-fit:cover;border-radius:4px;display:block;cursor:zoom-in;transition:filter .12s ease, transform .12s ease;}
@media (hover: hover) {
.attach-thumb:hover{filter:brightness(1.05);transform:scale(1.04);}
}
textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:16px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;font-family:inherit;}
textarea#msg::placeholder{color:var(--muted);}
.composer-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 10px 10px;position:relative;container-type:inline-size;container-name:composer-footer;}
Expand Down
16 changes: 13 additions & 3 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -296,9 +296,19 @@ function _closeImgLightbox(lb) {
}

document.addEventListener('click', e => {
const img = e.target && e.target.closest ? e.target.closest('.msg-media-img') : null;
if(!img) return;
_openImgLightbox(img.src, img.alt);
if(!e.target || !e.target.closest) return;
// Message-attached images (already wired since v0.50.x).
let img = e.target.closest('.msg-media-img');
if(img){ _openImgLightbox(img.src, img.alt); return; }
// Composer attach-tray image thumbnails — click any pasted/dropped image
// chip to lightbox-zoom it before sending. Excludes audio/video chips,
// which keep their inline media controls. SVG thumbnails (.attach-thumb--svg)
// are still images visually, so they qualify.
img = e.target.closest('.attach-thumb');
if(img && img.tagName === 'IMG'){
_openImgLightbox(img.src, img.alt || img.title || 'Attached image');
return;
}
});

const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
Expand Down
100 changes: 100 additions & 0 deletions tests/test_composer_chip_lightbox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Regression tests for composer attach-thumb lightbox click behaviour.

User pasted/dropped/picked an image and wants to verify the right one
attached before sending. Clicking the thumbnail in the composer's
attach-tray should open the existing image lightbox (the same one
that's wired to message-attached images).

This file pins the wiring at the source level — the document-level
delegated click handler must:
- Continue handling .msg-media-img (existing v0.50.x behaviour).
- Also handle .attach-thumb on IMG elements (new in this PR).
- NOT trigger on the chip's × remove button (sibling element).
- NOT trigger on audio/video chips (those have native controls).

It also pins the CSS cursor affordance so users discover the feature.
"""
from pathlib import Path


ROOT = Path(__file__).resolve().parent.parent
UI = ROOT / "static" / "ui.js"
STYLE = ROOT / "static" / "style.css"


class TestComposerChipLightboxDelegate:
def test_delegate_handles_attach_thumb_clicks(self):
"""The document click handler must pick up clicks on .attach-thumb
(composer image chips) and route them to _openImgLightbox().

Previously the handler only looked for .msg-media-img.
"""
src = UI.read_text(encoding="utf-8")
assert "e.target.closest('.attach-thumb')" in src, (
"Document click delegate must also match .attach-thumb"
)
# And it must call _openImgLightbox in that path.
# Use a tighter anchor block to ensure both branches are wired.
anchor = (
"img = e.target.closest('.attach-thumb');\n"
" if(img && img.tagName === 'IMG'){\n"
)
assert anchor in src

def test_delegate_still_handles_message_attached_images(self):
"""Existing .msg-media-img wiring must not regress."""
src = UI.read_text(encoding="utf-8")
# The message-image branch must come first (so _openImgLightbox
# fires for them without falling through to the .attach-thumb check).
msg_branch = "let img = e.target.closest('.msg-media-img');\n if(img){ _openImgLightbox(img.src, img.alt); return; }"
assert msg_branch in src

def test_delegate_excludes_audio_video_chips(self):
"""Audio/video chips have their own inline controls (native <audio>
/ <video>) — they don't get a thumbnail .attach-thumb at all, so
the handler can't possibly trigger on them. Pin that the chip
renderer uses .attach-chip--audio / .attach-chip--video sibling
classes (no IMG with class attach-thumb in those branches).
"""
src = UI.read_text(encoding="utf-8")
# Audio chip block — uses <audio>, no .attach-thumb img
assert "<audio controls preload=\"metadata\"" in src
# Video chip block — uses <video>, no .attach-thumb img
assert "<video controls preload=\"metadata\"" in src
# The .attach-thumb img tag is only generated in the image / svg branches.
# Quick structural check: every chip-rendering line that emits
# `class="attach-thumb"` has either `<img class="attach-thumb"` or
# `attach-thumb attach-thumb--svg`. Both are images.
for line in src.splitlines():
if 'class="attach-thumb' in line:
assert "<img " in line, (
"Every .attach-thumb emission should be an <img> tag, "
f"got: {line.strip()[:120]}"
)


class TestComposerChipCursorAffordance:
def test_attach_thumb_cursor_is_zoom_in(self):
"""`cursor: zoom-in` signals to the user that the thumbnail is
clickable for zoom — the most discoverable affordance for this UX.
Previously it was `cursor: default` which silently advertised
non-interactivity.
"""
src = STYLE.read_text(encoding="utf-8")
# The .attach-thumb rule must declare cursor:zoom-in
# Use a substring search resilient to other property additions.
for line in src.splitlines():
if line.strip().startswith(".attach-thumb{"):
assert "cursor:zoom-in" in line, (
f".attach-thumb cursor must be 'zoom-in', got: {line.strip()[:120]}"
)
break
else:
raise AssertionError(".attach-thumb selector not found in style.css")

def test_attach_thumb_has_hover_emphasis(self):
"""Subtle hover emphasis (brightness + scale) reinforces the
zoom-in cursor by giving instant visual feedback before click.
"""
src = STYLE.read_text(encoding="utf-8")
assert ".attach-thumb:hover{" in src or ".attach-thumb:hover {" in src
Loading