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⌘K
-
- {% if terminal_user %}
-
- {% 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 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 = `
-
+
+ A
+ 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