diff --git a/CHANGELOG.md b/CHANGELOG.md index e72a38556..df4ea6ede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,6 +156,11 @@ Compatibility is documented in release notes, not encoded in the version string. ### Changed +- **Log out** moved into the web terminal's display menu, alongside + **Settings** — the two now sit side by side under a line naming the signed-in + user. The separate user chip in the header is gone, leaving search and the + display menu there. Single-user terminals are unchanged apart from + **System Settings** being relabelled **Settings**. - Pairing a virtual accelerator with the mock archiver is refused — at build, at deploy, and at MCP server startup — because the VA moves channels for modelled reasons while the mock archiver invents history at read time, and diff --git a/src/osprey/interfaces/web_terminal/static/css/terminal.css b/src/osprey/interfaces/web_terminal/static/css/terminal.css index 7dc751033..7cb063ca3 100644 --- a/src/osprey/interfaces/web_terminal/static/css/terminal.css +++ b/src/osprey/interfaces/web_terminal/static/css/terminal.css @@ -234,150 +234,6 @@ background: var(--bg-elevated); } -/* ---- Identity chip (who you are + the way out) ---- - - One chip replaces the old user-name badge and the standalone logout door - button. The trigger states the fact (avatar initial + username); the popover - it opens holds the action (Log out) plus the deployment line, so an operator - on several terminals can confirm WHICH one they are about to leave before - they leave it. Popover grammar — capture-phase outside-click, Escape, - aria-expanded mirrored on the trigger — matches display-menu.js and - panel-add-menu.js, the shell's other two header popovers. */ -.identity-menu { - position: relative; - display: flex; - flex-shrink: 0; -} - -.identity-trigger { - display: flex; - align-items: center; - gap: var(--space-2); - height: 28px; - max-width: 200px; - padding: 0 8px 0 4px; - color: var(--text-primary); - background: var(--bg-panel); - border: 1px solid var(--border-default); - border-radius: var(--radius-full); - cursor: pointer; - transition: color var(--wt-transition-fast), border-color var(--wt-transition-fast), - background var(--wt-transition-fast); -} - -.identity-trigger:hover, -.identity-trigger[aria-expanded="true"] { - border-color: var(--border-accent); - background: var(--bg-elevated); -} - -/* The initial disc. Carries the accent so the chip reads as "you" at a glance - without needing an avatar image the framework has no way to source. */ -.identity-avatar { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 20px; - height: 20px; - font-family: var(--font-display); - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - line-height: var(--leading-none); - color: var(--color-on-accent); - background: var(--color-accent); - border-radius: var(--radius-full); -} - -.identity-name { - font-family: var(--font-display); - font-size: var(--text-sm); - font-weight: var(--weight-regular); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.identity-caret { - display: block; - flex-shrink: 0; - color: var(--text-muted); -} - -/* The popover: drops below the chip, right-aligned to it — same geometry as - the display menu's card. */ -.identity-card { - display: none; - position: absolute; - top: calc(100% + var(--space-2)); - right: 0; - min-width: 200px; - flex-direction: column; - background: var(--bg-secondary); - border: 1px solid var(--border-default); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-dropdown); - overflow: hidden; - z-index: var(--z-dropdown); -} - -.identity-card.open { - display: flex; -} - -.identity-card-identity { - display: flex; - flex-direction: column; - gap: 2px; - padding: var(--space-3); - border-bottom: 1px solid var(--border-default); -} - -.identity-card-name { - font-family: var(--font-display); - font-size: var(--text-md); - font-weight: var(--weight-semibold); - color: var(--text-primary); - word-break: break-all; -} - -.identity-card-sub { - font-family: var(--font-display); - font-size: var(--text-sm); - color: var(--text-secondary); -} - -.identity-card-logout { - display: flex; - align-items: center; - gap: var(--space-2); - width: 100%; - padding: var(--space-3); - font-family: var(--font-display); - font-size: var(--text-sm); - text-align: left; - color: var(--text-secondary); - background: none; - border: none; - cursor: pointer; - transition: color var(--wt-transition-fast), background var(--wt-transition-fast); -} - -.identity-card-logout:hover { - color: var(--color-error); - background: var(--bg-elevated); -} - -.identity-card-logout[aria-busy="true"] { - cursor: progress; - opacity: 0.6; -} - -.identity-card-logout-icon { - font-size: var(--text-md); - line-height: var(--leading-none); -} - /* ---- Display menu (the header sliders button) ---- Every display preference — Appearance (light/dark), View (Expert/Simple), @@ -569,15 +425,96 @@ html[data-ui-mode="simple"] .mode-segment[data-mode="simple"], background: var(--accent-tint-06); } -/* System Settings: the card's last row, separated from the preference rows by - a hairline so it reads as an action rather than a fourth preference. Expert - only — the simple-mode gate lives with the other density deltas below. */ -.display-menu-settings { +/* ---- Session footer (identity + the two actions) ---- + + The card's last block: who you are, then Settings and Log out side by side. + A hairline splits it from the preference rows so it reads as action rather + than a fourth preference. Both actions are present in BOTH ui modes — the + settings drawer's own warning gate, not the ui mode, is what guards expert + configuration. */ +.display-menu-identity { + display: flex; + align-items: baseline; + gap: var(--space-2); + min-width: 0; + padding-top: var(--space-2); + border-top: 1px solid var(--border-default); +} + +/* The initial disc. Carries the accent so the line reads as "you" at a glance + without needing an avatar image the framework has no way to source. */ +.display-menu-identity-avatar { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + align-self: center; + width: 20px; + height: 20px; + font-family: var(--font-display); + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + line-height: var(--leading-none); + color: var(--color-on-accent); + background: var(--color-accent); + border-radius: var(--radius-full); +} + +/* The name wins the row when both it and the deployment are too long for it: + `alice` is what identifies the session, the deployment only qualifies it. */ +.display-menu-identity-name { + flex-shrink: 0; + max-width: 60%; + font-family: var(--font-display); + font-size: var(--text-base); + font-weight: var(--weight-semibold); + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.display-menu-identity-sub { + min-width: 0; + font-family: var(--font-display); + font-size: var(--text-sm); + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.display-menu-identity-sub::before { + content: '·'; + margin-right: var(--space-1); +} + +/* The hairline opens the footer, so it belongs to whichever element starts it: + the identity line on a multi-user terminal, the action row on a single-user + one (which renders no identity and no logout). */ +.display-menu-actions { display: flex; + gap: var(--space-2); + padding-top: var(--space-2); + border-top: 1px solid var(--border-default); +} + +.display-menu-identity + .display-menu-actions { + padding-top: 0; + border-top: none; +} + +/* `flex: 1` rather than a 2-column grid: with logout absent — a single-user + deployment, or one with no landing_url to return to — Settings fills the row + by itself instead of sitting in half of it. */ +.display-menu-settings, +.display-menu-logout { + display: flex; + flex: 1; align-items: center; justify-content: center; gap: var(--space-2); - margin-top: calc(var(--space-1) * -1); + min-width: 0; padding: 7px 10px; font-family: var(--font-display); font-size: var(--text-base); @@ -586,6 +523,7 @@ html[data-ui-mode="simple"] .mode-segment[data-mode="simple"], border: 1px solid var(--border-default); border-radius: var(--radius-lg); cursor: pointer; + white-space: nowrap; transition: color var(--wt-transition-fast), border-color var(--wt-transition-fast), background var(--wt-transition-fast); } @@ -597,7 +535,23 @@ html[data-ui-mode="simple"] .mode-segment[data-mode="simple"], background: var(--bg-elevated); } -.display-menu-settings-icon { +/* Logout hovers to the error colour, not the accent: it is the one control in + this card that ends the session rather than adjusting it. */ +.display-menu-logout:hover { + color: var(--color-error); + border-color: var(--color-error); + background: var(--bg-elevated); +} + +/* app.js locks the button (`disabled` + `aria-busy`) for the whole in-flight + logout and never resets it — every path out of that handler navigates away. */ +.display-menu-logout[aria-busy="true"] { + cursor: progress; + opacity: 0.6; +} + +.display-menu-settings-icon, +.display-menu-logout-icon { font-size: var(--text-md); line-height: var(--leading-none); } diff --git a/src/osprey/interfaces/web_terminal/static/index.html b/src/osprey/interfaces/web_terminal/static/index.html index 57176c7a1..7aa69b4ec 100644 --- a/src/osprey/interfaces/web_terminal/static/index.html +++ b/src/osprey/interfaces/web_terminal/static/index.html @@ -113,55 +113,16 @@ Search - - {% if terminal_user %} -
- -
-
- {{ terminal_user }} - {% if app_name %}{{ app_name }}{% endif %} -
- {% if landing_url %} - - {% endif %} -
-
- {% endif %} + card so the header stays quiet, and — where the deployment knows a + user — closes with the session footer (who you are, System Settings, + Log out). display-menu.js owns open/close and the two theme rows; + the View row keeps the #mode-toggle / .mode-segment contract, so + app.js's initModeToggle() still owns the mode flip and the active + segment stays CSS-driven off html[data-ui-mode]. The family pills + are populated by display-menu.js from the tokens.js THEMES + registry. -->
- - + + {% if terminal_user %} +
+ + {{ terminal_user }} + {% if app_name %}{{ app_name }}{% endif %} +
+ {% endif %} + +
+ + {% if terminal_user and landing_url %} + + {% endif %} +
diff --git a/src/osprey/interfaces/web_terminal/static/js/app.js b/src/osprey/interfaces/web_terminal/static/js/app.js index 48983b061..3806763ad 100644 --- a/src/osprey/interfaces/web_terminal/static/js/app.js +++ b/src/osprey/interfaces/web_terminal/static/js/app.js @@ -15,7 +15,6 @@ import { initChat } from './chat.js'; import { initDockWorkspace, applyDockMode } from './dock-workspace.js'; import { initHeaderContrib } from './tile-header-contrib.js'; import { initDisplayMenu } from './display-menu.js'; -import { initIdentityMenu } from './identity-menu.js'; import { followThemeFamily, getRailPosition, setRailPosition } from './rail-position.js'; document.addEventListener('DOMContentLoaded', () => { @@ -61,7 +60,6 @@ document.addEventListener('DOMContentLoaded', () => { initLogoutButton(); initModeToggle(); initDisplayMenu(); - initIdentityMenu(); initRailPosition(); initDrawerTriggerHighlight(); initSettings(); @@ -148,10 +146,11 @@ export function initLogoutButton() { * prefix (`window.__OSPREY_PREFIX__`, which `compute_url_prefix()` sets to * exactly `/u/` for a multi-user container and to `""` otherwise). * - * Read from the prefix rather than from the identity chip's text because the - * prefix is the copy the app already routes every one of its own requests - * through — the chip is display markup, and taking a name from rendered text - * to put it back in a URL is how a display change becomes a wiring bug. + * Read from the prefix rather than from the display menu's identity line + * because the prefix is the copy the app already routes every one of its own + * requests through — that line is display markup, and taking a name from + * rendered text to put it back in a URL is how a display change becomes a + * wiring bug. * Returns `""` for a plain `osprey web`, which has no per-user prefix. */ function terminalUserFromPrefix() { diff --git a/src/osprey/interfaces/web_terminal/static/js/display-menu.js b/src/osprey/interfaces/web_terminal/static/js/display-menu.js index 57026df70..10b5b13a7 100644 --- a/src/osprey/interfaces/web_terminal/static/js/display-menu.js +++ b/src/osprey/interfaces/web_terminal/static/js/display-menu.js @@ -3,11 +3,13 @@ * * One quiet faders button in the header collapses every display preference * behind a popover card: Appearance (light/dark within the active theme family), View - * (Expert/Simple), Theme (the family pills), and — last — the System Settings - * row that opens the settings drawer. It replaces the hub header's - * always-visible segmented mode toggle + `` pair and the - * standalone settings gear beside them; standalone fleet pages (session.html, - * the panels) keep the shared switcher component; this menu is hub chrome only. + * (Expert/Simple), Theme (the family pills), and — last — a session footer + * naming the signed-in user above the Settings / Log out pair. It replaces the + * hub header's always-visible segmented mode toggle + `` + * pair, the standalone settings gear beside them, and the identity chip that + * used to carry logout in a popover of its own; standalone fleet pages + * (session.html, the panels) keep the shared switcher component; this menu is + * hub chrome only. * * Division of labour: * - This module owns the popover (open/close, outside-click, Escape) and @@ -24,12 +26,18 @@ * card renders identically in both modes, so the card an operator is * looking at survives the flip and they can compare the two shells (or go * straight back) without re-opening the menu. - * - The SYSTEM SETTINGS row is not this module's to open: it keeps the + * - The SETTINGS button is not this module's to open: it keeps the * `[data-drawer-trigger="settings-drawer"]` contract, so settings.js's * first-time warning gate stays the sole open path and app.js still * mirrors the drawer's open state onto it as `.active`. This is the ONE * row that closes the card, because opening the drawer moves the operator * to a different surface entirely. + * - LOG OUT is likewise app.js's, by `#logout-btn` + `data-landing-url` + * (palette-boot.js's "Log out" command also finds it by id). Deliberately + * NOT closed by this module: every path out of that handler navigates + * away, and closing the card first would hide the button's own aria-busy + * state while the POST is still in flight — from the operator and from + * assistive tech — at exactly the moment it matters. * * The popover grammar (open renders nothing lazily except the family pills' * one-time build; capture-phase outside-click + Escape close; aria-expanded diff --git a/src/osprey/interfaces/web_terminal/static/js/identity-menu.js b/src/osprey/interfaces/web_terminal/static/js/identity-menu.js deleted file mode 100644 index 73c5e322d..000000000 --- a/src/osprey/interfaces/web_terminal/static/js/identity-menu.js +++ /dev/null @@ -1,81 +0,0 @@ -// @ts-check -/* OSPREY Web Terminal — Identity Menu (the header user chip) - * - * One chip in the header states who you are; clicking it opens a popover - * holding the deployment line and the Log out control. It replaces the old - * pair — a bare username badge beside a standalone door button — which read - * as two unrelated controls despite being one fact and one action on it. - * - * Division of labour: - * - This module owns ONLY the popover (open/close, outside-click, Escape). - * - The logout BEHAVIOUR is not this module's: the button keeps its - * `#logout-btn` id and `data-landing-url` attribute, so app.js's - * initLogoutButton() still owns the safe-URL check, the POST, the - * disabled/aria-busy lock and the navigation, and palette-boot.js's - * "Log out" command still finds the button by id. Moving the control - * into a popover changed its placement, not its contract. - * - * Deliberately no close-on-logout-click: every path out of that handler - * navigates away, and closing the card first would flash the chip back to its - * resting state while the POST is still in flight, hiding the button's own - * aria-busy state from the operator (and from assistive tech) at exactly the - * moment it matters. - * - * The popover grammar (capture-phase outside-click + Escape close; - * aria-expanded mirrored on the trigger) matches display-menu.js and - * panel-add-menu.js, the shell's other header popovers. - */ - -/** - * Wire the header identity chip's popover. - * - * No-op when the chip is absent — a single-user deployment renders no - * `OSPREY_TERMINAL_USER`, so there is no identity to show and the header - * carries search + display menu alone. - */ -export function initIdentityMenu() { - const root = document.getElementById('identity-menu'); - const button = /** @type {HTMLButtonElement | null} */ ( - document.getElementById('identity-menu-btn') - ); - const card = document.getElementById('identity-menu-card'); - if (!root || !button || !card) return; - - const isOpen = () => card.classList.contains('open'); - - /** @param {MouseEvent} e */ - function onDocClick(e) { - if (e.target instanceof Node && root && !root.contains(e.target)) closeMenu(); - } - - /** @param {KeyboardEvent} e */ - function onKeydown(e) { - if (e.key === 'Escape') { - closeMenu(); - button?.focus(); - } - } - - function openMenu() { - if (!card || !button) return; - card.classList.add('open'); - button.setAttribute('aria-expanded', 'true'); - // Capture-phase so an outside click closes before it does anything else. - document.addEventListener('click', onDocClick, true); - document.addEventListener('keydown', onKeydown, true); - } - - function closeMenu() { - if (!card || !button) return; - card.classList.remove('open'); - button.setAttribute('aria-expanded', 'false'); - document.removeEventListener('click', onDocClick, true); - document.removeEventListener('keydown', onKeydown, true); - } - - button.addEventListener('click', (e) => { - e.stopPropagation(); - if (isOpen()) closeMenu(); - else openMenu(); - }); -} diff --git a/tests/e2e/test_query_write_refused_e2e.py b/tests/e2e/test_query_write_refused_e2e.py index 5dd12b156..38b525410 100644 --- a/tests/e2e/test_query_write_refused_e2e.py +++ b/tests/e2e/test_query_write_refused_e2e.py @@ -1,9 +1,19 @@ """E2E safety test: ``osprey query`` refuses hardware writes. Proves the read-only guarantee at the SDK layer: ``mcp__controls__channel_write`` -never appears in a query run's tool trace, regardless of the +never *executes* in a query run, regardless of the ``control_system.writes_enabled`` kill-switch setting. +The assertion is on execution, not emission: ``disallowed_tools`` strips the +tool from the model's toolset, but a model told the exact tool name (as the +directive prompt here does) can still hallucinate a ``tool_use`` block naming +it. The SDK rejects that call with ``No such tool available`` and it never +reaches the MCP server — the trace records the doomed emission with +``is_error=True``. A trace entry is therefore a leak only if it is anything +other than that rejection: a success result, or a hook-level denial (which +would mean the tool was still registered — the actual regression this test +guards against). + The load-bearing case is ``writes_enabled=True``: the application-level kill-switch hook would *permit* the write, yet the SDK-level ``disallowed_tools`` list (sourced from ``hook_config.json`` via @@ -61,7 +71,8 @@ def test_query_refuses_write(tmp_path: Path, writes_enabled: bool) -> None: (structural: passes even before a live run). Guard 2 — a directive prompt explicitly naming ``mcp__controls__channel_write`` - produces no tool-trace entry for that tool (behavioural: the SDK-level block + never produces an *executed* call: any trace entry for the tool must be the + SDK's ``No such tool available`` rejection (behavioural: the SDK-level block holds against a model that has been told exactly what to call). """ project = init_project( @@ -95,10 +106,14 @@ def test_query_refuses_write(tmp_path: Path, writes_enabled: bool) -> None: json_start = output.find("{") assert json_start >= 0, f"No JSON found in command output:\n{output}" payload, _ = json.JSONDecoder().raw_decode(output[json_start:].strip()) - names = [t["name"] for t in payload["tool_traces"]] - assert "mcp__controls__channel_write" not in names, ( - f"WRITE LEAKED (writes_enabled={writes_enabled}): {names}" - ) + write_traces = [ + t for t in payload["tool_traces"] if t["name"] == "mcp__controls__channel_write" + ] + for t in write_traces: + assert t["is_error"] and "No such tool available" in (t["result"] or ""), ( + f"WRITE LEAKED (writes_enabled={writes_enabled}): a channel_write trace " + f"is not the SDK's no-such-tool rejection — the tool was reachable: {t}" + ) # Directive prompt naming the built-in Bash tool — the non-MCP escape hatch. @@ -118,8 +133,10 @@ def test_query_refuses_builtin_bash(tmp_path: Path) -> None: and Bash is arbitrary shell (a hardware-write / file-mutation vector). Guard 1 — Bash is in the SDK-level disallowed set (structural). - Guard 2 — a directive prompt naming Bash produces no Bash tool trace - (behavioural: the SDK-level block holds against a model told to use it). + Guard 2 — a directive prompt naming Bash never produces an *executed* Bash + call: any Bash trace must be the SDK's ``No such tool available`` rejection + (behavioural: the SDK-level block holds against a model told to use it; see + the module docstring for why emission alone is not a leak). """ project = init_project( tmp_path, @@ -144,5 +161,9 @@ def test_query_refuses_builtin_bash(tmp_path: Path) -> None: json_start = output.find("{") assert json_start >= 0, f"No JSON found in command output:\n{output}" payload, _ = json.JSONDecoder().raw_decode(output[json_start:].strip()) - names = [t["name"] for t in payload["tool_traces"]] - assert "Bash" not in names, f"BASH LEAKED: {names}" + bash_traces = [t for t in payload["tool_traces"] if t["name"] == "Bash"] + for t in bash_traces: + assert t["is_error"] and "No such tool available" in (t["result"] or ""), ( + f"BASH LEAKED: a Bash trace is not the SDK's no-such-tool rejection " + f"— the tool was reachable: {t}" + ) diff --git a/tests/interfaces/web_terminal/display-menu.test.mjs b/tests/interfaces/web_terminal/display-menu.test.mjs index ccfb6efd2..ff6fb48cb 100644 --- a/tests/interfaces/web_terminal/display-menu.test.mjs +++ b/tests/interfaces/web_terminal/display-menu.test.mjs @@ -17,10 +17,11 @@ * pill click drives setFamily() * - a View-row segment click closes the card (the mode flip itself is * app.js's initModeToggle(), out of scope here) - * - the System Settings row: it is the card's last child, closes the card on - * click, and keeps the `[data-drawer-trigger]`-not-`[data-drawer]` - * contract settings.js's warning gate binds to (the open itself is - * settings.js's, out of scope here) + * - the session footer: the Settings / Log out row is the card's last child, + * Settings keeps the `[data-drawer-trigger]`-not-`[data-drawer]` contract + * settings.js's warning gate binds to and closes the card on click (the + * open itself is settings.js's, out of scope here), and Log out + * deliberately does not close it (the logout itself is app.js's) * * Module-identity note (same as theme-switcher.test.mjs): this file imports * theme-manager.js and display-menu.js exactly once, at the top, and resets @@ -51,8 +52,16 @@ const FIXTURE = `
- +
+ + alice +
+
+ + +
@@ -142,16 +151,27 @@ describe('display-menu', () => { expect(qs('#display-menu-card').classList.contains('open')).toBe(true); }); - test('a System Settings click closes the card', () => { + test('a Settings click closes the card', () => { mountAndInit(); qs('#display-menu-btn').click(); qs('#display-menu-settings').click(); expect(qs('#display-menu-card').classList.contains('open')).toBe(false); }); + + test('a Log out click leaves the card OPEN', () => { + // Deliberate: the logout handler (app.js) navigates away on every path, + // and closing the card first would hide the button's own aria-busy state + // while the POST is still in flight. + mountAndInit(); + qs('#display-menu-btn').click(); + + qs('#logout-btn').click(); + expect(qs('#display-menu-card').classList.contains('open')).toBe(true); + }); }); - describe('System Settings row', () => { + describe('Session footer', () => { test('keeps the data-drawer-trigger contract settings.js binds its gate to', () => { mountAndInit(); // The row must remain the [data-drawer-trigger="settings-drawer"] the @@ -163,12 +183,15 @@ describe('display-menu', () => { expect(trigger?.hasAttribute('data-drawer')).toBe(false); }); - test('lives inside the card, after every preference row', () => { + test('the action row closes the card, after every preference row', () => { mountAndInit(); const card = qs('#display-menu-card'); - const settings = qs('#display-menu-settings'); - expect(card.contains(settings)).toBe(true); - expect(card.lastElementChild).toBe(settings); + const actions = qs('.display-menu-actions'); + expect(card.lastElementChild).toBe(actions); + // Settings leads, Log out follows: the destructive control is not the + // one under the cursor when the card opens. + expect(actions.firstElementChild).toBe(qs('#display-menu-settings')); + expect(actions.lastElementChild).toBe(qs('#logout-btn')); }); test('init still no-ops when the row is absent', () => { diff --git a/tests/interfaces/web_terminal/identity-menu.test.mjs b/tests/interfaces/web_terminal/identity-menu.test.mjs deleted file mode 100644 index 3ea46484e..000000000 --- a/tests/interfaces/web_terminal/identity-menu.test.mjs +++ /dev/null @@ -1,161 +0,0 @@ -// @ts-check -/** - * Unit tests for the header identity chip's popover (`initIdentityMenu`): - * npx vitest run tests/interfaces/web_terminal/identity-menu.test.mjs - * - * The chip collapses the old username badge + standalone logout button into - * one control. This module owns ONLY the popover; the logout behaviour stays - * app.js's `initLogoutButton` (covered by app-logout.test.mjs), which finds the - * button by `#logout-btn` wherever it lives. The tests below therefore pin two - * things: the popover grammar shared with the shell's other header popovers, - * and the fact that this module does not touch the logout contract. - * - * Imported by RELATIVE path — this module lives under web_terminal, so the - * /design-system/js/* alias does not apply. - */ - -import { test, expect, describe, beforeEach } from 'vitest'; - -import { initIdentityMenu } from '../../../src/osprey/interfaces/web_terminal/static/js/identity-menu.js'; - -/** Render the chip the way the server does when `terminal_user` is set. */ -function mountChip({ landingUrl = 'https://landing.example.org/' } = {}) { - document.body.innerHTML = ` -
- -
-
- alice - Control Assistant -
- -
-
-
elsewhere
`; - return { - root: /** @type {HTMLElement} */ (document.getElementById('identity-menu')), - button: /** @type {HTMLButtonElement} */ (document.getElementById('identity-menu-btn')), - card: /** @type {HTMLElement} */ (document.getElementById('identity-menu-card')), - logout: /** @type {HTMLButtonElement} */ (document.getElementById('logout-btn')), - outside: /** @type {HTMLElement} */ (document.getElementById('outside')), - }; -} - -beforeEach(() => { - document.body.innerHTML = ''; -}); - -describe('initIdentityMenu', () => { - test('starts closed', () => { - const { card, button } = mountChip(); - - initIdentityMenu(); - - expect(card.classList.contains('open')).toBe(false); - expect(button.getAttribute('aria-expanded')).toBe('false'); - }); - - test('clicking the chip opens the popover and mirrors aria-expanded', () => { - const { button, card } = mountChip(); - initIdentityMenu(); - - button.click(); - - expect(card.classList.contains('open')).toBe(true); - expect(button.getAttribute('aria-expanded')).toBe('true'); - }); - - test('clicking the chip again closes it', () => { - const { button, card } = mountChip(); - initIdentityMenu(); - - button.click(); - button.click(); - - expect(card.classList.contains('open')).toBe(false); - expect(button.getAttribute('aria-expanded')).toBe('false'); - }); - - test('a click outside closes it', () => { - const { button, card, outside } = mountChip(); - initIdentityMenu(); - button.click(); - - outside.click(); - - expect(card.classList.contains('open')).toBe(false); - expect(button.getAttribute('aria-expanded')).toBe('false'); - }); - - test('a click inside the popover leaves it open', () => { - // Logging out navigates away; closing the card first would hide the - // button's own in-flight aria-busy state at the moment it matters. - const { button, card, logout } = mountChip(); - initIdentityMenu(); - button.click(); - - logout.click(); - - expect(card.classList.contains('open')).toBe(true); - }); - - test('Escape closes it and returns focus to the chip', () => { - const { button, card } = mountChip(); - initIdentityMenu(); - button.click(); - - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); - - expect(card.classList.contains('open')).toBe(false); - expect(document.activeElement).toBe(button); - }); - - test('a non-Escape key leaves it open', () => { - const { button, card } = mountChip(); - initIdentityMenu(); - button.click(); - - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); - - expect(card.classList.contains('open')).toBe(true); - }); - - test('stops listening once closed', () => { - // The outside-click/Escape listeners are document-level and capture-phase; - // leaving them attached after a close would let a later stray Escape steal - // focus back to a chip the operator is no longer using. - const { button, card, outside } = mountChip(); - initIdentityMenu(); - button.click(); - button.click(); - - outside.click(); - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); - - expect(card.classList.contains('open')).toBe(false); - expect(document.activeElement).not.toBe(button); - }); - - test('leaves the logout button contract untouched', () => { - // app.js's initLogoutButton() and palette-boot.js's "Log out" command both - // find this button by id and read data-landing-url off it. Moving it into - // the popover must not have changed either. - const { button, logout } = mountChip(); - initIdentityMenu(); - button.click(); - - expect(logout.id).toBe('logout-btn'); - expect(logout.dataset.landingUrl).toBe('https://landing.example.org/'); - expect(logout.disabled).toBe(false); - }); - - test('is a no-op when the chip is absent (single-user deployment)', () => { - document.body.innerHTML = '
'; - - expect(() => initIdentityMenu()).not.toThrow(); - }); -}); diff --git a/tests/interfaces/web_terminal/test_logout_resume_browser.py b/tests/interfaces/web_terminal/test_logout_resume_browser.py index 47171a00e..cc1def188 100644 --- a/tests/interfaces/web_terminal/test_logout_resume_browser.py +++ b/tests/interfaces/web_terminal/test_logout_resume_browser.py @@ -4,9 +4,9 @@ page — the part a FastAPI TestClient can't see because it neither runs the frontend JS nor persists ``localStorage`` across navigations: - * the header identity chip (``#identity-menu``, naming the user) and the - logout control inside its popover (``#logout-btn`` carrying - ``data-landing-url``) render only when the server emitted a non-empty + * the display menu's session footer — the identity line naming the user and + the logout control beside System Settings (``#logout-btn`` carrying + ``data-landing-url``) — renders only when the server emitted a non-empty ``terminal_user`` / ``landing_url`` (multi-user); * clicking logout POSTs the real server logout route (``logout_terminal``, routes/websocket.py — empties the PTY and operator registries), clears the @@ -198,12 +198,11 @@ def test_logout_and_return_starts_fresh_session(tmp_path, monkeypatch, chromium_ ) assert "session_id=" not in opening_url - # Header shows the configured user; logout control carries the landing url. - expect(page.locator("#identity-menu-btn .identity-name")).to_have_text(user) + # The logout control carries the landing url. logout = page.locator("#logout-btn") expect(logout).to_have_count(1) assert logout.get_attribute("data-landing-url") == landing_url - # It lives inside the identity popover, so it starts hidden. + # It lives inside the display menu's popover, so it starts hidden. expect(logout).to_be_hidden() # --- Represent an established (warm) PTY session (see module docstring) --- @@ -232,8 +231,11 @@ def _record(request) -> None: page.on("request", _record) # --- Logout: clears the stored pointer, THEN navigates to landing --- - # Logout lives in the identity chip's popover; open it first. - page.click("#identity-menu-btn") + # Logout lives in the display menu's session footer; open the menu first. + page.click("#display-menu-btn") + # The footer names the operator, so they can confirm WHICH terminal they + # are leaving before they leave it. + expect(page.locator("#display-menu-card .display-menu-identity-name")).to_have_text(user) expect(page.locator("#logout-btn")).to_be_visible() page.click("#logout-btn") page.wait_for_url(lambda u: u.startswith(landing_url)) @@ -275,8 +277,9 @@ def test_standalone_has_no_logout_control(tmp_path, monkeypatch, chromium_browse """Plain ``osprey web`` (no landing_url env) omits the logout control. With neither ``OSPREY_TERMINAL_USER`` nor ``OSPREY_TERMINAL_LANDING_URL`` set, - the whole identity chip — and with it the logout button it contains — must be - absent from the DOM; the single-user experience is unchanged. + both halves of the session footer — the identity line and the logout button + beside System Settings — must be absent from the DOM; the single-user + experience is unchanged. """ with _launch_terminal(tmp_path, monkeypatch, terminal_user="", landing_url="") as base_url: page = chromium_browser.new_page() @@ -286,6 +289,8 @@ def test_standalone_has_no_logout_control(tmp_path, monkeypatch, chromium_browse page.wait_for_selector(".header-actions", timeout=10_000) expect(page.locator("#logout-btn")).to_have_count(0) - expect(page.locator("#identity-menu")).to_have_count(0) + expect(page.locator(".display-menu-identity")).to_have_count(0) + # ...and Settings, alone in the footer, is still there. + expect(page.locator("#display-menu-settings")).to_have_count(1) page.close() diff --git a/tests/interfaces/web_terminal/test_terminal_user.py b/tests/interfaces/web_terminal/test_terminal_user.py index 167266957..102de0a30 100644 --- a/tests/interfaces/web_terminal/test_terminal_user.py +++ b/tests/interfaces/web_terminal/test_terminal_user.py @@ -134,8 +134,8 @@ def _capture(request, name, context=None, *args, **kwargs): assert captured["landing_url"] == "" -class TestIdentityChip: - """The header identity chip: who you are, and the logout control it holds.""" +class TestSessionFooter: + """The display menu's session footer: who you are, and the way out.""" def _body(self, workspace_dir, env): cfg = {"watch_dir": str(workspace_dir)} @@ -146,7 +146,7 @@ def _body(self, workspace_dir, env): with TestClient(create_app(shell_command="echo")) as c: return c.get("/").text - def test_chip_renders_the_user_and_holds_the_logout_control(self, workspace_dir): + def test_footer_names_the_user_and_holds_the_logout_control(self, workspace_dir): body = self._body( workspace_dir, { @@ -155,34 +155,37 @@ def test_chip_renders_the_user_and_holds_the_logout_control(self, workspace_dir) }, ) - assert 'id="identity-menu"' in body - assert 'id="identity-menu-btn"' in body - assert 'class="identity-name">alice<' in body + assert 'class="display-menu-identity"' in body + assert 'class="display-menu-identity-name">alice<' in body # The avatar shows the initial, upper-cased. - assert 'class="identity-avatar" aria-hidden="true">A<' in body + assert 'class="display-menu-identity-avatar" aria-hidden="true">A<' in body # The logout control keeps its id + data-landing-url contract; app.js's # initLogoutButton() and the command palette both find it by id. assert 'id="logout-btn"' in body assert 'data-landing-url="https://facility.example/portal"' in body + # And it is no longer a header chip of its own. + assert 'id="identity-menu"' not in body - def test_chip_is_absent_for_a_single_user_deployment(self, client): - """No OSPREY_TERMINAL_USER: no chip, and with it no logout control.""" + def test_footer_is_identity_free_for_a_single_user_deployment(self, client): + """No OSPREY_TERMINAL_USER: no identity line, and no logout control — + the footer is the Settings button alone.""" body = client.get("/").text - assert 'id="identity-menu"' not in body + assert 'class="display-menu-identity"' not in body assert 'id="logout-btn"' not in body + assert 'id="display-menu-settings"' in body - def test_chip_renders_without_a_landing_url_but_offers_no_logout(self, workspace_dir): - """A user with nowhere to log out TO still gets identified — the chip + def test_user_without_a_landing_url_is_named_but_offered_no_logout(self, workspace_dir): + """A user with nowhere to log out TO still gets identified — the line states a fact, and only the action depends on landing_url.""" body = self._body(workspace_dir, {"OSPREY_TERMINAL_USER": "alice"}) - assert 'id="identity-menu"' in body + assert 'class="display-menu-identity-name">alice<' in body assert 'id="logout-btn"' not in body def test_deployment_name_moved_out_of_the_action_cluster(self, workspace_dir): """app_name renders once, on the left beside the product name, and once - more as the popover's context line — never as a chip in the right-hand + more as the footer's context line — never as a chip in the right-hand action cluster where it read as a second user badge.""" cfg = {"watch_dir": str(workspace_dir)} with ( @@ -197,5 +200,5 @@ def test_deployment_name_moved_out_of_the_action_cluster(self, workspace_dir): body = c.get("/").text assert 'class="header-deployment"' in body - assert 'class="identity-card-sub">Control Assistant<' in body + assert 'class="display-menu-identity-sub">Control Assistant<' in body assert "header-app-name" not in body