2026-05-03 00:38:02 +00:00
|
|
|
"""Regression coverage for issue #1466 sidebar cancel ownership.
|
|
|
|
|
|
|
|
|
|
The active pane is only a projection; running state belongs to the session that
|
|
|
|
|
owns the stream. Cancelling a running session from the sidebar context menu must
|
|
|
|
|
address that session's stream id and must only clear approval/clarify UI owned by
|
|
|
|
|
that session.
|
|
|
|
|
"""
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).parent.parent
|
|
|
|
|
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
|
|
|
|
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _function_body(src: str, name: str, window: int = 1800) -> str:
|
|
|
|
|
idx = src.find(f"function {name}(")
|
|
|
|
|
assert idx >= 0, f"{name} not found"
|
|
|
|
|
return src[idx : idx + window]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSidebarCancelAction:
|
|
|
|
|
def test_running_sidebar_sessions_get_stop_action(self):
|
|
|
|
|
"""Running sessions need a context-menu cancel action even when not active pane."""
|
feat(ux): three high-leverage context-menu essentials from #1764
Issue #1764 asked for a much larger surface (Reveal + Copy-path on
every UI surface that references a file path, plus Rename in session
menus). Per Nathan's curation we ship only the three highest-leverage
pieces in this PR — they cover the three concrete user-visible
frictions Cygnus reported, and leave the broader sweep for follow-up.
## 1. Copy file path in workspace tree right-click menu
The tree's right-click already had Rename and Reveal in File Manager.
Reveal is slow when the user just wants the path string for a
terminal/editor — and there was no Copy-path action anywhere.
Added "Copy file path" between Reveal and Delete. It POSTs to a new
`/api/file/path` endpoint that resolves the relative tree-rooted path
into the absolute on-disk path (the frontend can't compute it because
only the server knows the workspace root) and writes the result to
the OS clipboard via `navigator.clipboard.writeText()`. Falls back to
the legacy execCommand pattern on browsers where the modern Clipboard
API is gated.
The new endpoint deliberately does NOT require the target to exist:
copy-path on a recently-deleted file is still useful (paste into a
terminal to investigate). `safe_resolve` continues to gate path
traversal — the test suite pins this with a `../../../../../etc/passwd`
attempt that 400s.
## 2. Rename in session three-dot menu
Cygnus's specific ask: double-click rename in the sidebar is timing-
sensitive — the first click frequently registers as "open the chat"
before the second click arrives, so users open the conversation when
they meant to rename it. Putting Rename in the menu eliminates the
timing entirely.
Added Rename as the FIRST item in `_openSessionActionMenu` (above
Pin). It reuses the existing `startRename` closure attached to each
session row — no duplicated state, no second API call out of band
with the double-click path. Mechanism: the row builder now stores
`el._startRename = startRename` and `el.dataset.sid = s.session_id`,
so the menu can find the row by data-sid and call its closure
directly. This keeps all the `_renamingSid`/`oldTitle`/`applyTitle`
bookkeeping single-sourced.
Read-only imported sessions skip the menu item via the same
`_isReadOnlySession` gate the closure already uses.
## 3. Reveal-failed toast includes the resolved server-side path
Cygnus posted a screenshot of a "Failed to reveal: not found" toast
that dropped the path entirely. Without it the user can't tell which
file the system expected — useful when a stale session row still
references a deleted file.
Server-side fix in `_handle_file_reveal`: instead of returning
`bad(handler, "File not found", 404)`, return
`bad(handler, f"File not found: {target}", 404)` where target is the
resolved absolute path. Frontend toast also defends against err with
no .message: `(err.message||err)` instead of `err.message` alone.
Verified live: a missing-file reveal now produces:
Failed to reveal: File not found: /home/hermes/workspace/missing-xyz.txt
Cygnus's exact diagnostic-friction is gone.
## Tests
* tests/test_1764_context_menu_essentials.py (new)
- 13 source-level pinning tests
- 6 live HTTP behaviour tests against the conftest test server
* tests/test_1466_sidebar_cancel_clarify.py
- Two assertion-window bumps (3200→4400, 3600→4800) to accommodate
the new Rename action prepended to _openSessionActionMenu. The
test relied on a fixed-byte-window function-body slice — comments
added explaining why the bumps were needed.
* All 9 locales got translations for the 5 new keys
(copy_file_path, path_copied, path_copy_failed, session_rename,
session_rename_desc) — locale parity tests pass.
## Verification
Full pytest suite: 4671 passed, 2 skipped, 3 xpassed (matches
pre-change baseline).
Live browser verification on port 8789:
- Right-click .git folder in workspace tree → menu shows
Rename / Reveal in File Manager / Copy file path / Delete (red).
- Click Copy file path → clipboard gets "/home/hermes/workspace/.git",
toast confirms "File path copied to clipboard".
- Open session three-dot menu → Rename conversation appears first
with pencil icon, followed by Pin / Move / Archive / Duplicate /
Delete in the same order as before.
- Trigger reveal on a non-existent file → toast reads
"Failed to reveal: File not found: /home/hermes/workspace/<filename>".
The resolved server-side path is now visible in the failure.
Refs nesquena/hermes-webui#1764.
2026-05-07 00:47:35 +00:00
|
|
|
# Window bumped from 3200 → 4400 in #1764 to accommodate the new
|
2026-05-12 02:13:25 +00:00
|
|
|
# Rename action item, then to 5200 in #2111 for response-aware archive
|
|
|
|
|
# toast handling inside _openSessionActionMenu before the stop/delete
|
|
|
|
|
# actions.
|
feat(ux): three high-leverage context-menu essentials from #1764
Issue #1764 asked for a much larger surface (Reveal + Copy-path on
every UI surface that references a file path, plus Rename in session
menus). Per Nathan's curation we ship only the three highest-leverage
pieces in this PR — they cover the three concrete user-visible
frictions Cygnus reported, and leave the broader sweep for follow-up.
## 1. Copy file path in workspace tree right-click menu
The tree's right-click already had Rename and Reveal in File Manager.
Reveal is slow when the user just wants the path string for a
terminal/editor — and there was no Copy-path action anywhere.
Added "Copy file path" between Reveal and Delete. It POSTs to a new
`/api/file/path` endpoint that resolves the relative tree-rooted path
into the absolute on-disk path (the frontend can't compute it because
only the server knows the workspace root) and writes the result to
the OS clipboard via `navigator.clipboard.writeText()`. Falls back to
the legacy execCommand pattern on browsers where the modern Clipboard
API is gated.
The new endpoint deliberately does NOT require the target to exist:
copy-path on a recently-deleted file is still useful (paste into a
terminal to investigate). `safe_resolve` continues to gate path
traversal — the test suite pins this with a `../../../../../etc/passwd`
attempt that 400s.
## 2. Rename in session three-dot menu
Cygnus's specific ask: double-click rename in the sidebar is timing-
sensitive — the first click frequently registers as "open the chat"
before the second click arrives, so users open the conversation when
they meant to rename it. Putting Rename in the menu eliminates the
timing entirely.
Added Rename as the FIRST item in `_openSessionActionMenu` (above
Pin). It reuses the existing `startRename` closure attached to each
session row — no duplicated state, no second API call out of band
with the double-click path. Mechanism: the row builder now stores
`el._startRename = startRename` and `el.dataset.sid = s.session_id`,
so the menu can find the row by data-sid and call its closure
directly. This keeps all the `_renamingSid`/`oldTitle`/`applyTitle`
bookkeeping single-sourced.
Read-only imported sessions skip the menu item via the same
`_isReadOnlySession` gate the closure already uses.
## 3. Reveal-failed toast includes the resolved server-side path
Cygnus posted a screenshot of a "Failed to reveal: not found" toast
that dropped the path entirely. Without it the user can't tell which
file the system expected — useful when a stale session row still
references a deleted file.
Server-side fix in `_handle_file_reveal`: instead of returning
`bad(handler, "File not found", 404)`, return
`bad(handler, f"File not found: {target}", 404)` where target is the
resolved absolute path. Frontend toast also defends against err with
no .message: `(err.message||err)` instead of `err.message` alone.
Verified live: a missing-file reveal now produces:
Failed to reveal: File not found: /home/hermes/workspace/missing-xyz.txt
Cygnus's exact diagnostic-friction is gone.
## Tests
* tests/test_1764_context_menu_essentials.py (new)
- 13 source-level pinning tests
- 6 live HTTP behaviour tests against the conftest test server
* tests/test_1466_sidebar_cancel_clarify.py
- Two assertion-window bumps (3200→4400, 3600→4800) to accommodate
the new Rename action prepended to _openSessionActionMenu. The
test relied on a fixed-byte-window function-body slice — comments
added explaining why the bumps were needed.
* All 9 locales got translations for the 5 new keys
(copy_file_path, path_copied, path_copy_failed, session_rename,
session_rename_desc) — locale parity tests pass.
## Verification
Full pytest suite: 4671 passed, 2 skipped, 3 xpassed (matches
pre-change baseline).
Live browser verification on port 8789:
- Right-click .git folder in workspace tree → menu shows
Rename / Reveal in File Manager / Copy file path / Delete (red).
- Click Copy file path → clipboard gets "/home/hermes/workspace/.git",
toast confirms "File path copied to clipboard".
- Open session three-dot menu → Rename conversation appears first
with pencil icon, followed by Pin / Move / Archive / Duplicate /
Delete in the same order as before.
- Trigger reveal on a non-existent file → toast reads
"Failed to reveal: File not found: /home/hermes/workspace/<filename>".
The resolved server-side path is now visible in the failure.
Refs nesquena/hermes-webui#1764.
2026-05-07 00:47:35 +00:00
|
|
|
# The `session.active_stream_id` / cancelSessionStream / delete checks
|
|
|
|
|
# are positional further down in the function, so growing the prefix
|
|
|
|
|
# required growing this read window.
|
2026-05-12 02:13:25 +00:00
|
|
|
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 5200)
|
2026-05-03 00:38:02 +00:00
|
|
|
assert "session.active_stream_id" in body, (
|
|
|
|
|
"sidebar action menu must detect per-session active_stream_id instead of S.activeStreamId"
|
|
|
|
|
)
|
|
|
|
|
assert "cancelSessionStream(session)" in body, (
|
|
|
|
|
"running sidebar sessions must expose a stop action that cancels that session"
|
|
|
|
|
)
|
|
|
|
|
assert body.find("cancelSessionStream(session)") < body.find("deleteSession(session.session_id)"), (
|
|
|
|
|
"stop action should appear before destructive delete action"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_cancel_session_stream_uses_session_owned_stream_id(self):
|
|
|
|
|
"""Cancel-from-sidebar must call /api/chat/cancel with the row's stream id."""
|
|
|
|
|
body = _function_body(BOOT_JS, "cancelSessionStream")
|
|
|
|
|
assert "session&&session.active_stream_id" in body or "session && session.active_stream_id" in body
|
|
|
|
|
assert "stream_id=${encodeURIComponent(streamId)}" in body
|
|
|
|
|
assert "S.activeStreamId" not in body.split("const streamId", 1)[1].split("fetch", 1)[0], (
|
|
|
|
|
"sidebar cancel must not derive the stream id from the active pane global"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_cancel_session_stream_clears_only_owned_clarify_and_approval_cards(self):
|
|
|
|
|
"""Cancelling A from sidebar must not blanket-clear B's clarify/approval cards."""
|
|
|
|
|
body = _function_body(BOOT_JS, "cancelSessionStream")
|
|
|
|
|
assert "_clarifySessionId===sid" in body, (
|
|
|
|
|
"clarify card cleanup must be gated to the cancelled session id"
|
|
|
|
|
)
|
|
|
|
|
assert "_approvalSessionId===sid" in body, (
|
|
|
|
|
"approval card cleanup must be gated to the cancelled session id"
|
|
|
|
|
)
|
|
|
|
|
assert "hideClarifyCard(true" in body
|
|
|
|
|
assert "hideApprovalCard(true" in body
|
2026-05-04 01:44:11 +00:00
|
|
|
|
|
|
|
|
def test_cli_session_helper_identifies_cli_origin(self):
|
|
|
|
|
"""CLI sessions should be treated as external-only for destructive action gating."""
|
|
|
|
|
body = _function_body(SESSIONS_JS, "_isCliSession", 900)
|
|
|
|
|
assert "function _isCliSession(session) {" in body
|
|
|
|
|
assert "session.session_source === 'cli'" in body
|
|
|
|
|
assert "session.raw_source" in body
|
|
|
|
|
assert "session.source_tag" in body
|
|
|
|
|
assert "session.source" in body
|
|
|
|
|
assert "session.source_label" in body
|
|
|
|
|
assert "if (_isMessagingSession(session)) return false;" in body
|
|
|
|
|
assert "return session.is_cli_session === true;" in body
|
|
|
|
|
|
|
|
|
|
def test_cli_sessions_hide_duplicate_and_delete_in_action_menu(self):
|
|
|
|
|
"""Session action menu should hide duplicate/delete for CLI-origin sessions."""
|
2026-05-12 02:13:25 +00:00
|
|
|
# Window bumped 3600 → 4800 in #1764 (Rename action prepended), then
|
|
|
|
|
# to 5200 in #2111 for response-aware archive toast handling.
|
|
|
|
|
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 5200)
|
2026-05-04 01:44:11 +00:00
|
|
|
assert "const isCliSession = _isCliSession(session);" in body
|
|
|
|
|
assert "const isExternalSession = isMessagingSession || isCliSession;" in body
|
|
|
|
|
assert "if(!isExternalSession)" in body
|
|
|
|
|
# duplicate/delete should both be gated by the same external-session check
|
|
|
|
|
first = body.find("_appendSessionDuplicateAction")
|
|
|
|
|
second = body.find("t('session_delete')")
|
|
|
|
|
assert first > 0 and second > 0, "menu actions should still include duplicate/delete nodes"
|
|
|
|
|
assert first < second, "duplicate action should render before delete action"
|