Files
hermes-webui/tests/test_issue3424_composer_drop_hint_polish.py
nesquena-hermes c8f8ca3428
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.244 — Release HL (stage-q16) (#3509)
## Release v0.51.244 — Release HL (stage-q16)

UX-approved direction via Telegram (workspace drag-drop polish you requested). All 4 drag-drop flows verified live in-browser.

### Added
| PR | Author | Feature |
|----|--------|---------|
| #3402 / #3424 | @pamnard | **Drop OS files/folders onto a specific workspace folder row or breadcrumb** to upload into that directory (not just the current dir). OS folder drops are traversed (`webkitGetAsEntry`/`readEntries`) preserving nested structure. Uploads via the existing `/api/workspace/upload` (no new backend). |

### Fixed
- **Composer drop-zone jank**: dragging a workspace file (or OS file) over the composer footer rendered a translucent overlay that let the textarea/chips/icons bleed through and collide with the hint text. Now a clean, fully-opaque box with a single centered **context-aware** label — *"Drop to insert workspace reference"* (workspace file → `@path` insert) vs *"Drop files to attach"* (OS file → message attach).
- **Drag-drop handler coexistence (CORE, caught in review)**: #3424's OS-upload binding assigned `el.ondrop` on folder rows, which **overwrote** the drag-to-move handler from #3422 (also `el.ondrop`) — silently breaking move-to-folder (the ws-path drop fell through to the composer as an `@path` insert). Fixed by binding the OS-upload handlers via `addEventListener` so they compose; each handler gates on its own drag type.

### Drag-drop matrix — all verified LIVE in-browser (real drag→drop, asserted on disk)
| Flow | Result |
|------|--------|
| OS image → composer footer | ✓ attaches |
| workspace file → composer footer | ✓ inserts `@path` |
| workspace file → workspace folder | ✓ moves on disk (report.md → docs/) |
| OS file → workspace folder | ✓ uploads into target folder |

### Scope note
#3424's PR branch carried the OLD pre-hardening `_handle_file_move`. Applied **frontend-only** — master's hardened move backend (v0.51.243, TOCTOU/symlink fixes) is untouched (Codex confirmed no `api/routes.py` diff).

### Gate
- Full pytest suite: **7542 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): CORE handler-clobber → fixed → **SAFE TO SHIP**

Co-authored-by: pamnard <pamnard@users.noreply.github.com>
2026-06-03 18:19:09 -07:00

98 lines
4.7 KiB
Python

"""Regression: composer drop-zone overlay is clean + context-aware (#3424 polish).
The composer drop hint used to be a translucent overlay with hardcoded text
("Drop files to upload to workspace") — when you dragged a workspace file over
the footer, the composer's own controls (textarea, chips, icons) bled through
and collided with the hint text, looking garbled. The fix:
1. The overlay background is fully opaque (stacked input-bg over --bg), so
nothing behind it shows through.
2. The hint text is context-aware: a workspace-file drag (application/ws-path,
which inserts an @path reference) says "insert workspace reference"; an
OS-file drag (which attaches the file) says "attach".
Source-contract assertions (the project has no JS DOM-test runtime).
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
def test_drop_hint_overlay_is_opaque():
"""The .drop-hint overlay must use an opaque background (stacked over --bg)
so the composer controls behind it cannot bleed through. A bare translucent
token like `background:var(--input-bg)` (which is ~0.04 alpha in dark theme)
is the regression we're guarding against."""
import re
m = re.search(r"\.drop-hint\{[^}]*\}", STYLE_CSS)
assert m, ".drop-hint rule not found"
rule = m.group(0)
assert "linear-gradient(var(--input-bg),var(--input-bg)),var(--bg)" in rule, (
".drop-hint must use an opaque stacked background so composer controls "
"don't bleed through during a drag; got: " + rule
)
def test_drop_hint_has_dedicated_text_span():
"""The hint text lives in its own #dropHintText span so the handler can
swap it per drag type."""
assert 'id="dropHintText"' in INDEX_HTML, (
"index.html must give the drop-hint label its own #dropHintText span"
)
# The stale hardcoded copy must be gone.
assert "Drop files to upload to workspace" not in INDEX_HTML, (
"the old hardcoded drop-hint text should be replaced by the dynamic span"
)
def test_drop_hint_text_is_context_aware():
"""The composer dragenter handler sets the hint text based on drag type:
workspace-file (ws-path) -> insert reference; OS file -> attach."""
# Find the dragenter handler block.
idx = PANELS_JS.find("addEventListener('dragenter'")
assert idx != -1, "composer dragenter handler not found"
block = PANELS_JS[idx:idx + 700]
assert "application/ws-path" in block, "handler must distinguish the ws-path drag"
assert "dropHintText" in block, "handler must update the #dropHintText label"
assert "workspace reference" in block, "ws-path drag hint must mention inserting a reference"
assert "attach" in block, "OS-file drag hint must mention attaching"
def test_os_upload_binder_composes_not_overwrites_move_binder():
"""Regression (Codex CORE catch): _bindWorkspaceOsUploadDropTarget must use
addEventListener, NOT el.on*= property assignment. The move-drop binder
(_bindWorkspaceMoveDropTarget) and the OS-upload binder run on the SAME
folder-row / breadcrumb element; if the OS-upload binder assigned el.ondrop
it would clobber the move handler and a workspace-file drag would fall
through to the composer (@path insert) instead of moving the file."""
WORKSPACE_JS = (ROOT / "static" / "workspace.js").read_text(encoding="utf-8")
start = WORKSPACE_JS.find("function _bindWorkspaceOsUploadDropTarget(")
assert start != -1, "_bindWorkspaceOsUploadDropTarget not found"
# Bound the slice to the end of THIS function: scan brace depth from the
# opening '{' so we don't spill into the following top-level code.
open_brace = WORKSPACE_JS.find("{", start)
depth = 0
end = open_brace
for i in range(open_brace, len(WORKSPACE_JS)):
c = WORKSPACE_JS[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
end = i + 1
break
body = WORKSPACE_JS[start:end]
# Must register via addEventListener for drop (composes with move handler)...
assert "addEventListener('drop'" in body, (
"OS-upload binder must use addEventListener('drop', ...) so it composes "
"with the move-drop handler instead of overwriting el.ondrop"
)
# ...and must NOT use the clobbering property-assignment form.
assert "el.ondrop" not in body and "el.ondragover" not in body, (
"OS-upload binder must not assign el.on* (it would overwrite the move binder)"
)