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.
84 lines
4.5 KiB
Python
84 lines
4.5 KiB
Python
"""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."""
|
|
# Window bumped from 3200 → 4400 in #1764 to accommodate the new
|
|
# Rename action item that lands at the top of _openSessionActionMenu.
|
|
# The `session.active_stream_id` / cancelSessionStream / delete checks
|
|
# are positional further down in the function, so growing the prefix
|
|
# required growing this read window.
|
|
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 4400)
|
|
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
|
|
|
|
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."""
|
|
# Window bumped 3600 → 4800 in #1764 (Rename action prepended).
|
|
body = _function_body(SESSIONS_JS, "_openSessionActionMenu", 4800)
|
|
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"
|