From 4cca24b4de3e1d8fa64bf9688c1539a01e8e22a9 Mon Sep 17 00:00:00 2001 From: freeqaz Date: Mon, 19 Oct 2026 21:51:44 +0000 Subject: [PATCH 1/9] fix(console): header layout and compact Namespace selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collection pages show the Namespace selector as one 32 px control with an inline Namespace prefix beside Refresh. An Agent's page and Create Agent lead with a ghost ← Agents, then the title with Refresh (and Create new version) at its right, then a meta row with the Agent ID, a copy button and the compact selector. Settings no longer renders the selector. (UX spec 3.2, plan D3) --- apps/controller/src/console/agents/create.mjs | 3 - apps/controller/src/console/agents/detail.mjs | 38 +--- apps/controller/src/console/console.css | 191 +++++++++++++++--- apps/controller/src/console/console.mjs | 69 ++++++- apps/controller/src/console/shell.mjs | 150 ++++++++++++-- 5 files changed, 361 insertions(+), 90 deletions(-) diff --git a/apps/controller/src/console/agents/create.mjs b/apps/controller/src/console/agents/create.mjs index 09fb47d18c..0dc1c2c375 100644 --- a/apps/controller/src/console/agents/create.mjs +++ b/apps/controller/src/console/agents/create.mjs @@ -403,7 +403,6 @@ export function renderCreateAgent(context, draft) { : null; if (unavailable) { context.view.replaceChildren( - link("← Agents", "agents", context), element( "section", { className: "state-panel" }, @@ -429,7 +428,6 @@ export function renderCreateAgent(context, draft) { }; const presets = createPresetFields(context, startForm); context.view.replaceChildren( - link("← Agents", "agents", context), // A provisioning Namespace accepts drafts; its status shows before any start (finding 1895). // replaceChildren would print a null child as the text "null". ...(notReady ? [namespaceNote(notReady)] : []), @@ -2823,7 +2821,6 @@ function renderAgentForm(context, rendered, presetOptions = {}, draft = {}) { } }); view.replaceChildren( - link("← Agents", "agents", context), ...(notReadyNote ? [notReadyNote] : []), element( "p", diff --git a/apps/controller/src/console/agents/detail.mjs b/apps/controller/src/console/agents/detail.mjs index 14be56da9a..bb97393e76 100644 --- a/apps/controller/src/console/agents/detail.mjs +++ b/apps/controller/src/console/agents/detail.mjs @@ -35,7 +35,6 @@ import { namespacePath, refusedSave, AGENT_UPDATE_DENIED, - link, message, rejectionMessage, assertReadableConfiguration, @@ -1252,9 +1251,12 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null }; const headerNewVersion = selected !== "draft" - ? button("Create new version", () => change("draft", "configuration")) + ? button("Create new version", () => change("draft", "configuration"), { size: "sm" }) : null; + // Create new version sits in the page header beside Refresh (UX spec 3.2); the shell's + // ← Agents and Namespace selector ask refuseLeave before they leave. const headerActions = element("div", { className: "agent-toolbar-actions" }, headerNewVersion); + context.setPageActions?.(headerActions); // The note that holds the tabs also holds ← Agents and the Namespace selector (finding 1902). const refuseLeave = ({ pendingOnly = false } = {}) => { if (!(pendingOnly ? draftWriteInFlight() : draftEditorBlocksNavigation())) { @@ -1263,36 +1265,7 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null showLeaveBlock(); return true; }; - const agentsLink = link("← Agents", "agents", context); - agentsLink.addEventListener( - "click", - (event) => { - if ( - !event.button && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - !event.altKey && - refuseLeave() - ) { - event.preventDefault(); - event.stopImmediatePropagation(); - } - }, - { capture: true }, - ); context.setLeaveGuard?.(refuseLeave); - const header = element( - "div", - { className: "agent-toolbar agent-overview-toolbar" }, - element( - "div", - { className: "agent-overview-identity" }, - agentsLink, - element("span", { className: "resource-id", title: "Agent ID" }, agent.id), - ), - headerActions, - ); const currentVersionValue = element("strong"); const currentVersionNote = element("p", { className: "muted" }); // The summary's loading text follows the 250 ms rule: a read that settles sooner never shows @@ -1419,7 +1392,7 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null element("span", { className: "badge", "data-tone": "warning" }, "Deleting"), ); view.classList.remove("agent-detail-pending", "agent-logs-opening"); - view.replaceChildren(header, deletion); + view.replaceChildren(deletion); context.revealed?.(); } if (deleting) { @@ -1668,7 +1641,6 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null deploymentStatus, ); view.replaceChildren( - header, recovery, nativeAdmin.section, overviewStatus, diff --git a/apps/controller/src/console/console.css b/apps/controller/src/console/console.css index 995183ba24..75dd855836 100644 --- a/apps/controller/src/console/console.css +++ b/apps/controller/src/console/console.css @@ -703,21 +703,127 @@ p { max-width: 1300px; width: 100%; } +/* Page header (UX spec 3.2). Collections: the title and its scope line at the left, the actions + (compact Namespace control, Refresh) at the right on the title block's last line. */ .page-header { display: flex; flex-wrap: wrap; justify-content: space-between; - align-items: start; - gap: 20px; + align-items: end; + gap: var(--space-4) var(--space-6); margin-bottom: 28px; } +.page-title { + min-width: 0; +} +.page-header h1 { + margin-bottom: var(--space-1); + font-size: var(--text-2xl); + line-height: var(--text-2xl--line-height); + letter-spacing: var(--tracking-tight); + overflow-wrap: anywhere; +} .page-actions { display: flex; - align-items: end; - gap: 12px; + align-items: center; + gap: var(--space-3); flex-wrap: wrap; max-width: 100%; } +/* An Agent's page and Create Agent: ← Agents, the title with the actions at its right, then the + meta row (ID, Namespace) across the width. The Agent's name is the page's largest type. */ +.page-header--subpage { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "back back" + "title actions" + "meta meta"; + align-items: start; + gap: var(--space-2) var(--space-6); +} +.page-back { + grid-area: back; + justify-self: start; + /* The arrow lines up with the title, not the ghost button's padding. */ + margin-left: calc(-1 * var(--space-3)); + color: var(--color-text-muted); +} +.page-header--subpage h1 { + grid-area: title; + justify-self: start; +} +.page-header--subpage .page-actions { + grid-area: actions; + justify-content: end; +} +.page-header--agent h1 { + margin-bottom: 0; + font-size: var(--text-display); + line-height: var(--text-display--line-height); +} +.page-header--create h1 { + margin-bottom: 0; +} +.page-meta { + grid-area: meta; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); + color: var(--color-text-muted); + font-size: var(--text-sm); + line-height: var(--text-sm--line-height); +} +.page-meta-id { + display: inline-flex; + align-items: center; + gap: var(--space-1); + min-width: 0; +} +.page-meta-id .resource-id { + font-size: var(--text-xs); +} +/* The separator before the Namespace control; none when the control is hidden. */ +.page-meta-id::after { + content: "·" / ""; + margin-left: var(--space-1); +} +.page-meta:not(:has(> .namespace-selector:not([hidden]))) .page-meta-id::after { + content: none; +} +/* Below 1100 px the row can wrap, and a wrapped separator would trail the ID. */ +@media (max-width: 1100px) { + .page-meta-id::after { + content: none; + } +} +.page-meta-copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--control-h-sm); + padding: 0; + color: var(--color-text-muted); +} +.copy-icon { + width: 16px; + height: 16px; +} +.page-meta-copied:empty { + display: none; +} +@media (max-width: 760px) { + /* Phone: ← Agents shares its row with the actions, so the header is the back row, the title + and the meta row (spec 3.2 and 4: header bottom <= 240 px at 390). */ + .page-header--subpage { + grid-template-areas: + "back actions" + "title title" + "meta meta"; + align-items: center; + } +} .checking-status { margin: 0; padding-block: 10px; @@ -741,6 +847,59 @@ p { border-radius: 8px; text-overflow: ellipsis; } +/* The header's compact control: one 32 px box, "Namespace" as a muted prefix inside it, as wide + as the selection up to 240 px. The box draws the focus ring for its borderless select. */ +.namespace-selector--compact { + display: inline-flex; + align-items: center; + width: max-content; + max-width: min(240px, 100%); + height: var(--control-h-sm); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + background: var(--color-surface); + font-size: var(--text-md); + line-height: var(--text-md--line-height); +} +.namespace-selector--compact label { + flex: none; + padding: 0 var(--space-2) 0 var(--space-3); + color: var(--color-text-muted); + font-weight: var(--weight-regular); + white-space: nowrap; +} +.namespace-selector--compact select:not([multiple]):not([size]) { + flex: 1 1 auto; + width: auto; + min-width: 0; + height: 100%; + min-height: 0; + padding: 0 var(--space-8) 0 0; + border: 0; + border-radius: var(--radius-sm); + background-color: transparent; + background-position: + right 18px center, + right 12px center; + color: var(--color-text-strong); + font-weight: var(--weight-medium); +} +.namespace-selector--compact select:focus-visible { + outline: none; +} +.namespace-selector--compact:has(select:focus-visible) { + outline: var(--focus-ring); + outline-offset: var(--focus-offset); +} +.namespace-selector--compact:has(select:disabled) { + opacity: 0.6; +} +@media (max-width: 760px) { + .page-meta .namespace-selector--compact { + width: 100%; + max-width: 100%; + } +} .scope { margin: 0; font-size: 14px; @@ -1005,33 +1164,9 @@ select:not([multiple])[size="1"] { gap: 12px; flex-wrap: wrap; } -.page-header:has(+ div .agent-overview-toolbar) h1 { - font-size: clamp(27px, 3vw, 36px); - letter-spacing: -0.045em; - line-height: 1.12; -} -.agent-overview-toolbar { - border-bottom: 1px solid var(--line); - gap: 10px; - padding-bottom: 14px; - margin: 0 0 22px; -} -.agent-overview-identity { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 4px 14px; - min-width: 0; -} -.agent-overview-identity a, .overview-text-button:not(:hover) { color: var(--muted); } -.agent-overview-identity .resource-id { - font-size: 11px; - margin: 0; - overflow-wrap: anywhere; -} .agent-overview-status, .native-admin-access, .deployment-recovery { diff --git a/apps/controller/src/console/console.mjs b/apps/controller/src/console/console.mjs index e7c9cf132c..fca785964a 100644 --- a/apps/controller/src/console/console.mjs +++ b/apps/controller/src/console/console.mjs @@ -476,6 +476,8 @@ const stillCheckingDelayMs = 1000; let mountedRouteKey = null; // The page whose shell is on screen. A held page keeps its own navigation and header. let shownFeature = null; +// The shown header's page: an Agent's ({ agentId }), Create Agent ({ creating: true }) or null. +let shownPage = null; let mountedAgent = null; let mountedViewState = null; let resumePending = null; @@ -635,17 +637,23 @@ function holdShownView(current, { busyCue = true, keepFocus = false } = {}) { return null; } const focused = document.activeElement; - const focusHeld = - keepFocus && - (view.contains(focused) || - focused === app.querySelector(".content h1") || - app.querySelector(".sidebar")?.contains(focused) === true); const title = app.querySelector(".content h1")?.textContent; const scope = app.querySelector(".content .scope")?.textContent; const diagnostics = app.querySelector(".runtime-debug"); const scrollY = window.scrollY; const recovery = app.querySelector(".content > .namespace-recovery, .content > p.scope") !== null; - const shell = renderShell(shownFeature ?? current.feature, true, { recovery }); + // An Agent page's own header actions (Create new version) stay with it, inert like the page. + const pageActions = app.querySelector(".content .page-header .page-view-actions"); + const focusHeld = + keepFocus && + (view.contains(focused) || + pageActions?.contains(focused) === true || + focused === app.querySelector(".content h1") || + app.querySelector(".sidebar")?.contains(focused) === true); + const shell = renderShell(shownFeature ?? current.feature, true, { + recovery, + page: shownFeature ? shownPage : headerPage(current), + }); if (title) { app.querySelector(".content h1").textContent = title; } @@ -654,6 +662,10 @@ function holdShownView(current, { busyCue = true, keepFocus = false } = {}) { } view.inert = true; view.setAttribute("aria-busy", "true"); + if (pageActions) { + pageActions.inert = true; + shell.actions.append(pageActions); + } // Navigation dims the held page after a moment (finding 1447); a refocus recheck keeps its // look. The class lives on this load's shell, so the next shell drops it. app.querySelector(".content").classList.toggle("holding", busyCue); @@ -718,6 +730,10 @@ function mountRetainedView(current, retained) { retained.snapshot.inert = false; shell.view.replaceWith(retained.snapshot); shell.view = retained.snapshot; + if (retained.state?.pageActions) { + retained.state.pageActions.inert = false; + shell.actions.append(retained.state.pageActions); + } if (shell.diagnostics && retained.state?.diagnostics) { retained.state.diagnostics.inert = false; shell.diagnostics.replaceWith(retained.state.diagnostics); @@ -752,8 +768,25 @@ function resetReads({ retainView = false, returnLink = null } = {}) { return lifetime.reset(); } -function renderShell(feature, namespaceAdmissionPending = false, { recovery = true } = {}) { +// The header shape for a route: an Agent's page and Create Agent lead with ← Agents and carry a +// meta row (the ID, the Namespace) under the title; collections do not (UX spec 3.2). +function headerPage(current) { + if (current.feature !== "agents") { + return null; + } + if (current.creating) { + return { creating: true }; + } + return current.agentId ? { agentId: current.agentId } : null; +} + +function renderShell( + feature, + namespaceAdmissionPending = false, + { recovery = true, page = headerPage(route()) } = {}, +) { shownFeature = feature; + shownPage = page; const shell = shellUI.renderShell(feature, { session, namespaces, @@ -761,6 +794,7 @@ function renderShell(feature, namespaceAdmissionPending = false, { recovery = tr observabilityUrl, namespaceAdmissionPending, recovery, + page, }); if (feature === "backends" && backendsDenied && shell.scopeSelector) { shell.scopeSelector.hidden = true; @@ -1794,6 +1828,15 @@ async function loadPage({ navigateAgentTab = navigateTab; } }, + // The page's own header actions (an Agent's Create new version) sit beside Refresh. A + // page rendering behind a stand-in gets them when its shell reveals. + setPageActions(node) { + node.classList.add("page-view-actions"); + viewState.pageActions = node; + if (!shownStandIn && shell.actions?.isConnected) { + shell.actions.append(node); + } + }, setLeaveGuard(guard) { if (lifetime.isCurrent(viewState.active)) { const refuse = (options) => shownStandIn === null && guard(options); @@ -1906,6 +1949,9 @@ async function loadPage({ shell.view.replaceWith(detailView); shell.view = detailView; detailView.hidden = false; + if (viewState.pageActions) { + shell.actions.append(viewState.pageActions); + } if (shell.diagnostics) { attachDiagnostics(shell.diagnostics); } @@ -2206,6 +2252,12 @@ async function revalidateMountedAgent(current) { let checking = "session"; // Block stale controls during admission without losing an editor's caret on return. view.inert = true; + const pageActions = mountedViewState?.pageActions?.isConnected + ? mountedViewState.pageActions + : null; + if (pageActions) { + pageActions.inert = true; + } let clearStillChecking = null; const stillChecking = setTimeout(() => { if (isCurrent()) { @@ -2317,6 +2369,9 @@ async function revalidateMountedAgent(current) { // A newer load may hold this same node on screen; only this check's lifetime releases it. if (view.isConnected && lifetime.isCurrent(active)) { view.inert = false; + if (pageActions?.isConnected) { + pageActions.inert = false; + } if ( isCurrent() && focused?.isConnected && diff --git a/apps/controller/src/console/shell.mjs b/apps/controller/src/console/shell.mjs index 77393c7d4a..4bb0e2430d 100644 --- a/apps/controller/src/console/shell.mjs +++ b/apps/controller/src/console/shell.mjs @@ -221,7 +221,9 @@ export function createShell({ app, pages, route, pageUrl, navigate, loadPage, lo } } - function namespaceSelector(label = "Namespace") { + // The page header's selector is compact (UX spec 3.2): one 32 px control whose label sits + // inside it as a muted prefix. The Namespace recovery panel keeps the full-size field. + function namespaceSelector(label = "Namespace", { compact = false } = {}) { namespaceSelect = element("select", { id: "namespace-selector" }); updateNamespaceSelector(); namespaceSelect.addEventListener("change", (event) => { @@ -241,12 +243,97 @@ export function createShell({ app, pages, route, pageUrl, navigate, loadPage, lo }); return element( "div", - { className: "namespace-selector" }, + { + className: compact + ? "namespace-selector namespace-selector--compact" + : "namespace-selector", + }, element("label", { for: "namespace-selector" }, label), namespaceSelect, ); } + function copyIcon() { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("class", "copy-icon"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.setAttribute("aria-hidden", "true"); + const back = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + for (const [name, value] of Object.entries({ x: 9, y: 9, width: 12, height: 12, rx: 2 })) { + back.setAttribute(name, String(value)); + } + const front = document.createElementNS("http://www.w3.org/2000/svg", "path"); + front.setAttribute("d", "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"); + svg.append(back, front); + return svg; + } + + // The Agent's ID under its name, with a copy button. Copying falls back to selecting the ID + // where the clipboard is unavailable (an http origin, say), so it can be copied by hand. + function agentIdentity(agentId) { + const id = element("span", { className: "resource-id", title: "Agent ID" }, agentId); + const copied = element("span", { className: "page-meta-copied", role: "status" }); + let clearCopied = null; + const copy = button( + "", + () => { + const done = () => { + copied.textContent = "Copied"; + clearTimeout(clearCopied); + clearCopied = setTimeout(() => { + copied.textContent = ""; + }, 2000); + }; + const select = () => { + const range = document.createRange(); + range.selectNodeContents(id); + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(range); + }; + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(agentId).then(done, select); + } else { + select(); + } + }, + { + tone: "ghost", + size: "sm", + className: "page-meta-copy", + "aria-label": "Copy Agent ID", + title: "Copy Agent ID", + }, + ); + copy.append(copyIcon()); + return element("span", { className: "page-meta-id" }, id, copy, copied); + } + + // ← Agents on an Agent's page and on Create Agent. A page that cannot be left yet (an open + // draft editor, a write in flight) keeps the reader and says why (findings 1902, 1937). + function backToAgents() { + const back = element( + "a", + { href: pageUrl("agents"), className: "button button--ghost button--sm page-back" }, + "← Agents", + ); + back.addEventListener("click", (event) => { + if (event.button || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + event.preventDefault(); + if (refuseLeave()) { + return; + } + navigate("agents"); + }); + return back; + } + function updateNamespaces(readable, { preservePageScope = false } = {}) { if (!namespaceSelect?.isConnected) { return; @@ -280,6 +367,9 @@ export function createShell({ app, pages, route, pageUrl, navigate, loadPage, lo observabilityUrl, namespaceAdmissionPending = true, } = state); + // An Agent's page ({ agentId }) or Create Agent ({ creating: true }): ← Agents above the + // title, and the ID and Namespace in a meta row under it (UX spec 3.2). + const subpage = feature === "agents" && state.page ? state.page : null; // A page held during a navigation shows Namespace recovery only if it already did. const recovery = state.recovery ?? true; namespaceSelect = null; @@ -407,28 +497,50 @@ export function createShell({ app, pages, route, pageUrl, navigate, loadPage, lo : feature === "backends" ? "Installation-wide · Experimental" : "Installation-wide"; - const scopeSelector = feature === "namespaces" ? null : namespaceSelector(); + // Settings is about the account, and Namespaces lists them: neither has a Namespace scope. + const scopeSelector = + feature === "namespaces" || feature === "settings" + ? null + : namespaceSelector("Namespace", { compact: true }); const refresh = button("Refresh", () => void loadPage({ fromRefresh: true }), { tone: "ghost", size: "sm", }); refresh.disabled = true; - const header = element( - "header", - { className: "page-header" }, - element( - "div", - {}, - element("h1", { tabindex: "-1" }, pages[feature]), - element("p", { className: "scope" }, scope), - ), - element( - "div", - { className: "page-actions" }, - scopeSelector, - feature === "settings" ? null : refresh, - ), + const heading = element("h1", { tabindex: "-1" }, pages[feature]); + const actions = element( + "div", + { className: "page-actions" }, + subpage ? null : scopeSelector, + feature === "settings" ? null : refresh, ); + const header = subpage + ? element( + "header", + { + className: `page-header page-header--subpage ${subpage.agentId ? "page-header--agent" : "page-header--create"}`, + }, + backToAgents(), + heading, + actions, + element( + "div", + { className: "page-meta" }, + subpage.agentId ? agentIdentity(subpage.agentId) : null, + scopeSelector, + ), + ) + : element( + "header", + { className: "page-header" }, + element( + "div", + { className: "page-title" }, + heading, + element("p", { className: "scope" }, scope), + ), + actions, + ); const view = element("div", { "aria-live": "polite", "aria-busy": "true" }); main.append(header); if (recovery && session && feature !== "agents" && namespaceId !== null && !selected) { @@ -506,7 +618,7 @@ export function createShell({ app, pages, route, pageUrl, navigate, loadPage, lo menuControls?.close(); } } - return { view, refresh, diagnostics, scopeSelector }; + return { view, refresh, diagnostics, scopeSelector, actions }; } function sameControl(node, root) { From 74858455031618da23515f442f06473bfda2a304 Mon Sep 17 00:00:00 2001 From: freeqaz Date: Mon, 19 Oct 2026 22:00:16 +0000 Subject: [PATCH 2/9] =?UTF-8?q?test(console):=20cover=20the=20page=20heade?= =?UTF-8?q?r=20layout=20and=20follow=20=E2=86=90=20Agents=20into=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit console-page-header checks the compact selector on collections, the Agent page's back link, meta row and actions at 1440, 1024 and 390 with a 40-character name, the phone header bottom, the ID copy button and Create Agent's header. Tests that found ← Agents, Create new version or the Deleting badge inside the page view now look in the page header; Settings frames expect no selector. --- scripts/ci/test-suites/checks-browser-2.json | 6 +- tests/browser/console-agent-state.test.mjs | 6 +- .../browser/console-button-hierarchy.test.mjs | 4 +- tests/browser/console-focus-ring.test.mjs | 2 +- .../console-namespace-not-ready.test.mjs | 5 +- tests/browser/console-page-header.test.mjs | 253 ++++++++++++++++++ .../console-retained-revalidation.test.mjs | 9 +- 7 files changed, 273 insertions(+), 12 deletions(-) create mode 100644 tests/browser/console-page-header.test.mjs diff --git a/scripts/ci/test-suites/checks-browser-2.json b/scripts/ci/test-suites/checks-browser-2.json index 1f2a55b88e..3090a75a8e 100644 --- a/scripts/ci/test-suites/checks-browser-2.json +++ b/scripts/ci/test-suites/checks-browser-2.json @@ -26,7 +26,8 @@ "tests/browser/console-focus-ring.test.mjs", "tests/browser/console-drafts-kept.test.mjs", "tests/browser/console-button-hierarchy.test.mjs", - "tests/browser/console-status-block.test.mjs" + "tests/browser/console-status-block.test.mjs", + "tests/browser/console-page-header.test.mjs" ], "files": [ { @@ -97,6 +98,9 @@ }, { "path": "tests/browser/console-status-block.test.mjs" + }, + { + "path": "tests/browser/console-page-header.test.mjs" } ] } diff --git a/tests/browser/console-agent-state.test.mjs b/tests/browser/console-agent-state.test.mjs index 4441e0564c..a930a4c618 100644 --- a/tests/browser/console-agent-state.test.mjs +++ b/tests/browser/console-agent-state.test.mjs @@ -1263,10 +1263,8 @@ test("Agent detail does not move shown sections when Deployment activity first s // A full load, so no recent input excuses a shift. await page.goto(detailUrl(fixture, namespace.id, agent.id, revision.id, "configuration").href); // The Agent page renders hidden behind the startup status until its overview reveals - // (finding 1537). - await page - .getByRole("link", { name: "← Agents", exact: true, includeHidden: true }) - .waitFor({ state: "attached" }); + // (finding 1537). Its header (← Agents among it) comes with the reveal, so wait on the page. + await page.locator(".agent-detail-pending").waitFor({ state: "attached" }); // The version's own reads finish while Deployment activity's first read is still held. await page .locator(".agent-version-heading", { hasText: "Version v1" }) diff --git a/tests/browser/console-button-hierarchy.test.mjs b/tests/browser/console-button-hierarchy.test.mjs index 6e8ded4e27..07f28cc7ac 100644 --- a/tests/browser/console-button-hierarchy.test.mjs +++ b/tests/browser/console-button-hierarchy.test.mjs @@ -764,8 +764,10 @@ test("Deleting and unreadable configuration badges take their tones on the list } await page.goto(detailUrl(fixture, namespace.id, deleting.id).href); + await page.locator(settledView).waitFor(); + // The badge sits in the page header's actions, where Create new version would be. const header = page - .locator(settledView) + .locator(".content .page-header .page-actions") .locator(".badge") .filter({ hasText: /^Deleting$/ }); await header.waitFor(); diff --git a/tests/browser/console-focus-ring.test.mjs b/tests/browser/console-focus-ring.test.mjs index 82d1f3c128..499a5e5bc9 100644 --- a/tests/browser/console-focus-ring.test.mjs +++ b/tests/browser/console-focus-ring.test.mjs @@ -278,7 +278,7 @@ test("every keyboard stop on Agents, Agent detail, its logs and Create Agent sho await login(page, fixture, path); await page.getByRole(role, { name: landmark, exact: true }).waitFor(); await page.locator(settledView).waitFor(); - await page.locator(settledView).locator(tag).first().waitFor(); + await page.locator(settledView).locator(`${tag}:visible`).first().waitFor(); const stops = await tabStops(page); assert.ok( stops.some(({ name }) => name === landmark), diff --git a/tests/browser/console-namespace-not-ready.test.mjs b/tests/browser/console-namespace-not-ready.test.mjs index 46b18a0ace..aba00adae7 100644 --- a/tests/browser/console-namespace-not-ready.test.mjs +++ b/tests/browser/console-namespace-not-ready.test.mjs @@ -267,7 +267,10 @@ test("a return to the Agents list and Create Agent shows a Namespace that became // Both views are kept for a return while the Namespace is provisioning. await login(page, fixture, `/console/agents/new?namespace=${namespace.id}`); await view.getByRole("heading", { name: "Creating Agents is not available" }).waitFor(); - await view.getByRole("link", { name: "← Agents", exact: true }).click(); + await page + .locator(".content .page-header") + .getByRole("link", { name: "← Agents", exact: true }) + .click(); await view.getByRole("status").filter({ hasText: NOT_READY }).waitFor(); assert.equal(await view.getByRole("button", { name: "Create Agent" }).isDisabled(), true); diff --git a/tests/browser/console-page-header.test.mjs b/tests/browser/console-page-header.test.mjs new file mode 100644 index 0000000000..e044c6c201 --- /dev/null +++ b/tests/browser/console-page-header.test.mjs @@ -0,0 +1,253 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + detailUrl, + fileBrowser, + login, + nativeValues, + newPage, + settledView, +} from "./console-agents-browser-helpers.mjs"; +import { createReadyConsoleNamespace } from "./console-ready-namespace.mjs"; + +// The page header (UX plan D3, spec 3.2): collections show the Namespace selector as one compact +// 32 px control beside Refresh; an Agent's page and Create Agent lead with ← Agents and carry the +// Agent ID and the selector in a meta row under the title, so a long Agent name never runs into +// the header's controls and the phone header ends by 240 px. + +// One Chromium for the file; each case still gets its own context. +const browser = fileBrowser(); + +// A 40-character Agent name, the length the plan row measures at 1024 px. +const LONG_NAME = "Quarterly release review and signoff bot"; + +// The header's layout: its bottom on the page, the title, every visible control in it (with the +// region it sits in), and the Namespace selector's box. +const headerLayout = (page) => + page.locator(".content .page-header").evaluate((header) => { + const box = (node) => { + const rect = node.getBoundingClientRect(); + return { + top: rect.top, + bottom: rect.bottom, + left: rect.left, + right: rect.right, + height: rect.height, + }; + }; + const select = header.querySelector("#namespace-selector"); + const selector = select?.closest(".namespace-selector"); + const label = selector?.querySelector("label"); + const title = header.querySelector("h1"); + // The title's text, not the block: an h1 is as wide as its column. + const range = globalThis.document.createRange(); + range.selectNodeContents(title); + return { + bottom: header.getBoundingClientRect().bottom + globalThis.scrollY, + title: { text: title.textContent, ...box(title), lines: range.getClientRects().length }, + titleText: box(range), + controls: [...header.querySelectorAll("a, button, select")] + .filter((node) => node.getClientRects().length) + .map((node) => ({ + name: + node.getAttribute("aria-label") || node.labels?.[0]?.textContent || node.textContent, + region: node.closest(".page-meta") + ? "meta" + : node.closest(".page-actions") + ? "actions" + : "header", + ...box(node), + })), + selector: selector + ? { + region: selector.closest(".page-meta") + ? "meta" + : selector.closest(".page-actions") + ? "actions" + : "header", + ...box(selector), + labelInside: + label.getBoundingClientRect().left >= selector.getBoundingClientRect().left && + label.getBoundingClientRect().right <= select.getBoundingClientRect().left + 1 && + Math.abs(label.getBoundingClientRect().top - select.getBoundingClientRect().top) < + selector.getBoundingClientRect().height, + } + : null, + }; + }); + +const overlaps = (a, b) => + a.left < b.right - 0.5 && + b.left < a.right - 0.5 && + a.top < b.bottom - 0.5 && + b.top < a.bottom - 0.5; + +test("collections show a compact Namespace selector beside Refresh and Settings shows none", async (t) => { + const { fixture, namespace } = await createReadyConsoleNamespace(t, "Header scope"); + await fixture.createAgent(namespace.id, "Header Agent", nativeValues("collection")); + for (const viewport of [ + { width: 1440, height: 900 }, + { width: 390, height: 844 }, + ]) { + const size = `${viewport.width}x${viewport.height}`; + const { page } = await newPage(t, fixture, { browser, context: { viewport } }); + await login(page, fixture, `/console/agents?namespace=${namespace.id}`); + await page.locator(settledView).getByRole("link", { name: "Header Agent" }).waitFor(); + const selector = page.getByRole("combobox", { name: "Namespace", exact: true }); + await page.locator("#namespace-selector:not(:disabled)").waitFor(); + assert.equal(await selector.inputValue(), namespace.id, size); + + const layout = await headerLayout(page); + t.diagnostic(`${size} ${JSON.stringify(layout.selector)}`); + assert.equal(layout.selector.region, "actions", size); + assert.equal(layout.selector.height, 32, `${size}: the selector is one 32 px control`); + assert.ok(layout.selector.right - layout.selector.left <= 240, size); + assert.equal(layout.selector.labelInside, true, `${size}: the label is the control's prefix`); + const refresh = layout.controls.find((control) => control.name === "Refresh"); + assert.equal(refresh.region, "actions", size); + assert.ok( + Math.abs( + (refresh.top + refresh.bottom) / 2 - (layout.selector.top + layout.selector.bottom) / 2, + ) < 1, + `${size}: Refresh and the selector share a line`, + ); + + await page.goto(new URL(`/console/settings?namespace=${namespace.id}`, fixture.origin).href); + await page.getByRole("heading", { name: "Settings", exact: true }).waitFor(); + await page.locator(settledView).waitFor(); + assert.equal(await page.locator("#namespace-selector").count(), 0, `${size}: Settings`); + } +}); + +test("an Agent's page leads with ← Agents and keeps its ID and Namespace under a long name", async (t) => { + const { fixture, namespace } = await createReadyConsoleNamespace(t, "Header detail"); + const agent = await fixture.createAgent(namespace.id, LONG_NAME, nativeValues("detail")); + const active = await fixture.seedActiveAgentRevision(namespace.id, agent.id); + for (const viewport of [ + { width: 1440, height: 900 }, + { width: 1024, height: 768 }, + { width: 390, height: 844 }, + ]) { + const size = `${viewport.width}x${viewport.height}`; + const { page } = await newPage(t, fixture, { + browser, + context: { viewport, permissions: ["clipboard-read", "clipboard-write"] }, + }); + await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, active.revision.id).href); + await page.getByRole("heading", { name: LONG_NAME, level: 1 }).waitFor(); + await page + .locator(".content .page-header") + .getByRole("button", { name: "Create new version" }) + .waitFor(); + await page.locator("#namespace-selector:not(:disabled)").waitFor(); + + const header = page.locator(".content .page-header"); + // One way back, in the header: the page view no longer repeats it. + assert.equal(await page.getByRole("link", { name: "← Agents", exact: true }).count(), 1); + assert.equal(await header.getByRole("link", { name: "← Agents", exact: true }).count(), 1); + assert.equal( + await header.locator(".page-meta").getByText(agent.id, { exact: true }).count(), + 1, + `${size}: the ID is in the meta row`, + ); + + const layout = await headerLayout(page); + t.diagnostic(`${size} ${JSON.stringify(layout)}`); + const regions = Object.fromEntries( + layout.controls.map((control) => [control.name, control.region]), + ); + assert.deepEqual( + regions, + { + "← Agents": "header", + Refresh: "actions", + "Create new version": "actions", + "Copy Agent ID": "meta", + Namespace: "meta", + }, + size, + ); + assert.equal(layout.selector.height, 32, size); + // ← Agents comes first, the title's text runs into no control, and the meta row is under it. + const back = layout.controls.find((control) => control.name === "← Agents"); + assert.ok(back.bottom <= layout.title.top, `${size}: ← Agents is above the title`); + for (const control of layout.controls) { + assert.equal( + overlaps(layout.titleText, control), + false, + `${size}: title over ${control.name}`, + ); + } + assert.ok( + layout.selector.top >= layout.title.bottom, + `${size}: the selector is under the title`, + ); + if (viewport.width === 390) { + // Phone: back row (with the actions), title, meta. A name this long takes two lines; the + // header still ends well before the old 8-row header did. + assert.ok(layout.bottom <= 240 + 34 * (layout.title.lines - 1), `${size}: ${layout.bottom}`); + } else { + const actions = layout.controls.filter((control) => control.region === "actions"); + for (const action of actions) { + assert.ok( + action.left >= layout.title.right, + `${size}: ${action.name} is right of the title`, + ); + } + } + + // The copy button puts the ID on the clipboard and says so. + await header.getByRole("button", { name: "Copy Agent ID" }).click(); + await header.getByRole("status").filter({ hasText: "Copied" }).waitFor(); + assert.equal(await page.evaluate(() => globalThis.navigator.clipboard.readText()), agent.id); + } +}); + +test("a phone shows a short-named Agent's header in four rows, ending by 240 px", async (t) => { + const { fixture, namespace } = await createReadyConsoleNamespace(t, "Header phone"); + const agent = await fixture.createAgent( + namespace.id, + "Research assistant", + nativeValues("phone"), + ); + const { page } = await newPage(t, fixture, { + browser, + context: { viewport: { width: 390, height: 844 } }, + }); + await login(page, fixture, detailUrl(fixture, namespace.id, agent.id).href); + await page.getByRole("button", { name: "Deploy new version" }).waitFor(); + await page.locator("#namespace-selector:not(:disabled)").waitFor(); + const layout = await headerLayout(page); + t.diagnostic(JSON.stringify(layout)); + assert.equal(layout.title.lines, 1); + assert.ok(layout.bottom <= 240, `header bottom ${layout.bottom}`); + // The selector spans the meta row on a phone. + const content = await page + .locator(".content .page-header") + .evaluate((node) => node.getBoundingClientRect().width); + assert.ok(layout.selector.right - layout.selector.left >= content - 1); +}); + +test("Create Agent leads with ← Agents and puts the Namespace under its title", async (t) => { + const { fixture, namespace } = await createReadyConsoleNamespace(t, "Header create"); + const { page } = await newPage(t, fixture, { + browser, + context: { viewport: { width: 1440, height: 900 } }, + }); + await login(page, fixture, `/console/agents/new?namespace=${namespace.id}`); + await page.getByRole("heading", { name: "Create Agent", level: 1 }).waitFor(); + await page.getByRole("button", { name: "Start without Preset", exact: true }).waitFor(); + await page.locator("#namespace-selector:not(:disabled)").waitFor(); + const layout = await headerLayout(page); + t.diagnostic(JSON.stringify(layout)); + assert.deepEqual( + Object.fromEntries(layout.controls.map((control) => [control.name, control.region])), + { "← Agents": "header", Refresh: "actions", Namespace: "meta" }, + ); + assert.equal(await page.getByRole("link", { name: "← Agents", exact: true }).count(), 1); + + await page.getByRole("link", { name: "← Agents", exact: true }).click(); + await page.getByRole("heading", { name: "Agents", exact: true, level: 1 }).waitFor(); + assert.equal(new URL(page.url()).pathname, "/console/agents"); +}); diff --git a/tests/browser/console-retained-revalidation.test.mjs b/tests/browser/console-retained-revalidation.test.mjs index 5535735bfb..7739d3b32f 100644 --- a/tests/browser/console-retained-revalidation.test.mjs +++ b/tests/browser/console-retained-revalidation.test.mjs @@ -415,10 +415,11 @@ test("A page held for Agents keeps its own navigation and header until Agents re await returnRead.requested; returnRead.release(); await listShown(page, "Shell Agent"); - // The first entry is the state the recorder re-reads after the previous splice. + // The first entry is the state the recorder re-reads after the previous splice. Settings has + // no Namespace scope, so it shows no selector, held or not. assert.deepEqual(await shownFrames(page), [ - "none | Settings | selector | Settings", - "none | Settings | selector | Settings (held)", + "none | Settings | no selector | Settings", + "none | Settings | no selector | Settings (held)", agentsShown, ]); }); @@ -538,7 +539,7 @@ for (const c of [ prepare: (page) => openAgent(page, "Draft Agent"), enter: (page) => page - .locator(".agent-overview-toolbar") + .locator(".content .page-header") .getByRole("button", { name: "Create new version" }) .click(), revision: /revision=draft/, From a052e0466c4b465edd92062ece5441dbeb2cb823 Mon Sep 17 00:00:00 2001 From: freeqaz Date: Mon, 19 Oct 2026 22:11:48 +0000 Subject: [PATCH 3/9] docs(console): name the Agent page's ID copy button and meta-row Namespace --- docs/guides/console/agent-details.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guides/console/agent-details.md b/docs/guides/console/agent-details.md index d0c737c9db..1db6843614 100644 --- a/docs/guides/console/agent-details.md +++ b/docs/guides/console/agent-details.md @@ -17,13 +17,13 @@ doesn’t require Agent update permission; saving and deploying have separate ac | **Namespaces** | Lists the Namespaces you can read. | | **Observability** | Opens the Installation's configured observability dashboard in a new tab. Shown only to Installation administrators, and only when one is configured. | | Agent name | Human-readable name of this Agent. | -| **Namespace · name** | Namespace containing the Agent. | +| **Namespace** | Namespace containing the Agent, in the row under its name. | | **Refresh** | Reloads the Agent page. It does not retry or restart deployment. | | **Selected version** | Version in the Agent's `activeRevisionId`. It may differ from the latest or viewed version. | | **Latest visible deployment** | Newest readable version and its recorded deployment status. **Newer version hidden** means the current version is one you cannot read; ask for read access to new versions. | | **Live serving** | Unverified by this page. **May be offline** means a newer dedicated deployment failed, and the current version was probably stopped for it, or the selected version's own deployment failed (an embedded redeploy selects its version before its gateway is ready). | | **Deployment activity** | Most recent visible version and its persisted deployment status. | -| `agt_…` | Stable Agent identifier for API calls and support. | +| `agt_…` | Stable Agent identifier for API calls and support, under the name; **Copy Agent ID** copies it. | The **Namespace** selector at the top of the page changes your scope; from Agent detail it returns to the new Namespace's Agents list. The bottom **OpenClaw From 12c3084ea26938bb552db0cc7610f1b42ccc148b Mon Sep 17 00:00:00 2001 From: freeqaz Date: Mon, 19 Oct 2026 22:12:11 +0000 Subject: [PATCH 4/9] docs(console): keep the Agent details guide at its word count --- docs/guides/console/agent-details.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guides/console/agent-details.md b/docs/guides/console/agent-details.md index 1db6843614..03861986ee 100644 --- a/docs/guides/console/agent-details.md +++ b/docs/guides/console/agent-details.md @@ -17,13 +17,13 @@ doesn’t require Agent update permission; saving and deploying have separate ac | **Namespaces** | Lists the Namespaces you can read. | | **Observability** | Opens the Installation's configured observability dashboard in a new tab. Shown only to Installation administrators, and only when one is configured. | | Agent name | Human-readable name of this Agent. | -| **Namespace** | Namespace containing the Agent, in the row under its name. | +| **Namespace** | Namespace containing the Agent. | | **Refresh** | Reloads the Agent page. It does not retry or restart deployment. | | **Selected version** | Version in the Agent's `activeRevisionId`. It may differ from the latest or viewed version. | | **Latest visible deployment** | Newest readable version and its recorded deployment status. **Newer version hidden** means the current version is one you cannot read; ask for read access to new versions. | | **Live serving** | Unverified by this page. **May be offline** means a newer dedicated deployment failed, and the current version was probably stopped for it, or the selected version's own deployment failed (an embedded redeploy selects its version before its gateway is ready). | | **Deployment activity** | Most recent visible version and its persisted deployment status. | -| `agt_…` | Stable Agent identifier for API calls and support, under the name; **Copy Agent ID** copies it. | +| `agt_…` | Agent identifier for API calls and support (**Copy Agent ID**). | The **Namespace** selector at the top of the page changes your scope; from Agent detail it returns to the new Namespace's Agents list. The bottom **OpenClaw From e19343cb27437e7adb12e8614a99994f7bb0809c Mon Sep 17 00:00:00 2001 From: freeqaz Date: Mon, 19 Oct 2026 22:12:27 +0000 Subject: [PATCH 5/9] fix(console): an Agent page's header actions replace, never stack --- apps/controller/src/console/console.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/controller/src/console/console.mjs b/apps/controller/src/console/console.mjs index fca785964a..288cb56b9e 100644 --- a/apps/controller/src/console/console.mjs +++ b/apps/controller/src/console/console.mjs @@ -1834,6 +1834,7 @@ async function loadPage({ node.classList.add("page-view-actions"); viewState.pageActions = node; if (!shownStandIn && shell.actions?.isConnected) { + shell.actions.querySelector(":scope > .page-view-actions")?.remove(); shell.actions.append(node); } }, From 97ef3a13cf965aec762e48f32e882b9cc7cd383a Mon Sep 17 00:00:00 2001 From: freeqaz Date: Mon, 19 Oct 2026 22:57:14 +0000 Subject: [PATCH 6/9] fix(console): a refocus recheck gives focus back to the header's Create new version revalidateMountedAgent captured the focused control only inside the page view, so focus on Create new version, now in the header, fell to the body after the recheck. It now captures focus in the header actions too. Adds a regression test and Storybook stories for the header (long name, 390 px, Copy Agent ID without clipboard access) with a workflow checklist. --- apps/controller/src/console/console.mjs | 12 +++-- .../console-storybook/page-header-workflow.md | 32 +++++++++++++ .../pages-agent-detail.stories.mjs | 3 ++ scripts/console-storybook/public/frame.mjs | 4 ++ .../console-storybook/public/scenarios.mjs | 45 ++++++++++++++++++- tests/browser/console-page-header.test.mjs | 41 +++++++++++++++++ 6 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 scripts/console-storybook/page-header-workflow.md diff --git a/apps/controller/src/console/console.mjs b/apps/controller/src/console/console.mjs index 288cb56b9e..dc84ddb1c3 100644 --- a/apps/controller/src/console/console.mjs +++ b/apps/controller/src/console/console.mjs @@ -2245,7 +2245,14 @@ async function revalidateMountedAgent(current) { routeKey(route()) === key && view.isConnected; const path = `/namespaces/${encodeURIComponent(selectedNamespace)}/agents/${encodeURIComponent(agent.id)}`; - const focused = view.contains(document.activeElement) ? document.activeElement : null; + // The page's own header actions (Create new version) hold with it, and give focus back too. + const pageActions = mountedViewState?.pageActions?.isConnected + ? mountedViewState.pageActions + : null; + const focused = + view.contains(document.activeElement) || pageActions?.contains(document.activeElement) + ? document.activeElement + : null; const selection = typeof focused?.selectionStart === "number" ? [focused.selectionStart, focused.selectionEnd] @@ -2253,9 +2260,6 @@ async function revalidateMountedAgent(current) { let checking = "session"; // Block stale controls during admission without losing an editor's caret on return. view.inert = true; - const pageActions = mountedViewState?.pageActions?.isConnected - ? mountedViewState.pageActions - : null; if (pageActions) { pageActions.inert = true; } diff --git a/scripts/console-storybook/page-header-workflow.md b/scripts/console-storybook/page-header-workflow.md new file mode 100644 index 0000000000..aaf96dd4bb --- /dev/null +++ b/scripts/console-storybook/page-header-workflow.md @@ -0,0 +1,32 @@ +# Check the page header + +The page header (UX spec 3.2) has two shapes. Collections (Agents, Backends) show +the title and its scope line at the left. At the right are the compact Namespace +control and Refresh: one 32 px box with "Namespace" as a muted prefix inside. +An Agent's page and Create Agent lead with **← Agents**. Next comes the title, +with the actions at its right. Under it is a meta row: the Agent ID, **Copy +Agent ID** and the compact Namespace control. Settings and Namespaces show no +Namespace control. Check each story at 1440, 1024 and 390. + +1. Open **Pages / Agents / Populated**. The Namespace control is 32 px tall, with + its label inside the box, and shares a line with Refresh. +2. Open **Pages / Agent detail / Page header with a long name**. At 1024 the + name wraps to two lines on the left. Refresh and Create new version stay at + its right, and nothing overlaps. Select **Copy Agent ID**: "Copied" shows + beside it. +3. In the same story, select **Create new version**. The draft's header shows + Refresh only. Select **Edit Configuration**, change a value, and select + **← Agents**. The page stays and says why. +4. Open **Pages / Agent detail / Page header at 390px**. The header is four + rows: ← Agents with the actions, the name, the ID row, then the Namespace + control at full width. It ends by 240 px. Switch to another window and back. + While the access check runs, Create new version is held with the page, and + focus returns to it. +5. Open **Pages / Agent detail / Copy Agent ID without clipboard access**. The + ID is selected and no "Copied" status shows. +6. Open **Pages / Create Agent / Choose a starting point** and **Pages / Settings / Account**. + Create Agent has ← Agents and the Namespace control under its title. + Settings has no Namespace control. + +Storybook fixtures are simulated UI proof. They do not verify a live +deployment or clipboard permissions in a real browser profile. diff --git a/scripts/console-storybook/pages-agent-detail.stories.mjs b/scripts/console-storybook/pages-agent-detail.stories.mjs index 13beffb848..fee3c8d150 100644 --- a/scripts/console-storybook/pages-agent-detail.stories.mjs +++ b/scripts/console-storybook/pages-agent-detail.stories.mjs @@ -48,6 +48,9 @@ export const DeploymentRunning = story("deploymentRunning"); export const DeploymentActivityLoading = story("deploymentActivityLoading"); export const CurrentVersionDuringDeployment = story("currentVersionDuringDeployment"); export const DeploymentFailed = story("deploymentFailed"); +export const AgentHeader = story("agentHeader"); +export const AgentHeaderMobile = story("agentHeaderMobile"); +export const AgentHeaderNoClipboard = story("agentHeaderNoClipboard"); export const DeploymentFailedLogsFromDraft = story("deploymentFailedLogsFromDraft"); export const DeploymentModelProbeFailed = story("deploymentModelProbeFailed"); export const DeploymentFailsWhileWatching = story("deploymentFailsWhileWatching"); diff --git a/scripts/console-storybook/public/frame.mjs b/scripts/console-storybook/public/frame.mjs index 6a302fabc8..c664c2497e 100644 --- a/scripts/console-storybook/public/frame.mjs +++ b/scripts/console-storybook/public/frame.mjs @@ -26,6 +26,10 @@ history.replaceState( "", scenario.path ?? "/console/agents?namespace=ns_00000000-0000-4000-8000-000000000001", ); +// Simulates a browser without clipboard access (an http origin), where copy buttons fall back. +if (scenario.clipboard === "unavailable") { + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); +} // Focus the isolated preview before the console registers its focus-refresh handler. // Otherwise the first interaction can reset a preselected Preset before it becomes a form. window.focus(); diff --git a/scripts/console-storybook/public/scenarios.mjs b/scripts/console-storybook/public/scenarios.mjs index 60776d82f8..a72edd1218 100644 --- a/scripts/console-storybook/public/scenarios.mjs +++ b/scripts/console-storybook/public/scenarios.mjs @@ -4082,6 +4082,46 @@ export const scenarios = { ], gap: "Runtime status and output are simulated UI evidence; this story does not verify a live deployment or log read.", }, + agentHeader: { + group: "Pages/Agent detail", + name: "Page header with a long name", + path: currentVersion, + deployed: true, + agentName: "Quarterly release review and signoff bot", + description: + "The Agent page header: ← Agents, the name with Refresh and Create new version at its right, then the Agent ID, Copy Agent ID and the compact Namespace control in the row under it.", + steps: [ + "At 1440 and 1024 the 40-character name wraps on the left; Refresh and Create new version stay on its first line and nothing overlaps.", + "Select Copy Agent ID. The ID is on the clipboard and Copied shows beside the button for two seconds.", + "Choose Research in the Namespace control; the console opens Research's Agents list, as the header selector always has. Go Back.", + "Select Create new version; the draft opens and its header shows Refresh only.", + "On the draft, select Edit Configuration and change a value, then select ← Agents. The page stays and says why, as the sidebar does.", + ], + }, + agentHeaderMobile: { + group: "Pages/Agent detail", + name: "Page header at 390px", + path: currentVersion, + deployed: true, + mobile: true, + description: + "At 390px the header is four rows: ← Agents with Refresh and Create new version, the name, the Agent ID with Copy Agent ID, then the Namespace control at full width. It ends by 240px.", + steps: [ + "Check that the ID row has no trailing separator and the Namespace control spans the row.", + "Leave the window and come back while the access recheck runs: Create new version is held with the page, and focus returns to it afterwards.", + ], + }, + agentHeaderNoClipboard: { + group: "Pages/Agent detail", + name: "Copy Agent ID without clipboard access", + path: currentVersion, + deployed: true, + clipboard: "unavailable", + actions: [{ selector: ".page-meta-copy", click: true }], + description: + "Where the browser offers no clipboard (an http origin, say), Copy Agent ID selects the ID instead, so it can be copied by hand.", + steps: ["Check that the Agent ID is selected and no Copied status appears."], + }, deploymentFailedLogsFromDraft: { group: "Pages/Agent detail", name: "Failed deployment logs from draft", @@ -4900,7 +4940,8 @@ export const scenarios = { namespaceMenu: { group: "Components/Navigation", name: "Namespace switcher", - description: "The header selector shows the current Namespace and readable alternatives.", + description: + "The header's compact Namespace control (its label is the muted prefix inside the box) shows the current Namespace and readable alternatives.", steps: [ "Choose Research in the Namespace selector; the URL changes and its empty Agents collection appears.", "Choose Engineering to return to its Agents, then use browser Back to restore Research.", @@ -4915,7 +4956,7 @@ export const scenarios = { description: "Choose a Namespace directly from the header at 390px, without opening navigation.", steps: [ - "Check that the long Namespace name truncates before the inset chevron.", + "Check that the compact control stays 32px tall and the long Namespace name truncates before the inset chevron.", "Choose Research, then return to the long Namespace and check the selection.", ], }, diff --git a/tests/browser/console-page-header.test.mjs b/tests/browser/console-page-header.test.mjs index e044c6c201..ca387c5091 100644 --- a/tests/browser/console-page-header.test.mjs +++ b/tests/browser/console-page-header.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { detailUrl, fileBrowser, + holdRequests, login, nativeValues, newPage, @@ -251,3 +252,43 @@ test("Create Agent leads with ← Agents and puts the Namespace under its title" await page.getByRole("heading", { name: "Agents", exact: true, level: 1 }).waitFor(); assert.equal(new URL(page.url()).pathname, "/console/agents"); }); + +// Create new version lives in the header, outside the page view, so the Agent page's refocus +// recheck holds it inert with the page and gives its focus back afterwards, unless the reader +// moved elsewhere meanwhile (finding 1654's rule for the view's own controls). +test("a refocus recheck holds the header's Create new version with the page and keeps its focus", async (t) => { + const { fixture, namespace } = await createReadyConsoleNamespace(t, "Header refocus"); + const agent = await fixture.createAgent(namespace.id, "Refocus Agent", nativeValues("refocus")); + const active = await fixture.seedActiveAgentRevision(namespace.id, agent.id); + const { page } = await newPage(t, fixture, { browser }); + await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, active.revision.id).href); + const header = page.locator(".content .page-header"); + const create = header.getByRole("button", { name: "Create new version", exact: true }); + await create.waitFor(); + await page.locator(settledView).waitFor(); + const isFocused = (control) => + control.evaluate((node) => node === globalThis.document.activeElement); + const navigation = page.getByRole("link", { name: "Namespaces", exact: true }); + for (const expectedFocus of [create, navigation]) { + await create.focus(); + assert.equal(await isFocused(create), true); + const read = await holdRequests(t, page, "**/api/auth/session", { once: true }); + await page.evaluate(() => globalThis.dispatchEvent(new Event("focus"))); + await read.requested; + // The page and its header action are held together. + await header.locator(".page-view-actions[inert]").waitFor({ state: "attached" }); + await page.locator('.content [aria-live="polite"][inert]').waitFor({ state: "attached" }); + if (expectedFocus === navigation) { + await navigation.focus(); + } + read.release(); + await header.locator(".page-view-actions:not([inert])").waitFor({ state: "attached" }); + await page.locator(settledView).waitFor(); + await page.waitForFunction( + () => globalThis.document.activeElement !== globalThis.document.body, + null, + { timeout: 5_000 }, + ); + assert.equal(await isFocused(expectedFocus), true, "the reader's place after the check"); + } +}); From 360b5067c5995f384a821e242826d4fffad924ab Mon Sep 17 00:00:00 2001 From: Free Wortley Date: Tue, 20 Oct 2026 00:13:13 +0000 Subject: [PATCH 7/9] Restore Agent header action focus after revalidation --- apps/controller/src/console/console.mjs | 12 +++++---- .../focus-and-disabled-workflow.md | 4 +++ .../pages-agent-detail.stories.mjs | 1 + .../console-storybook/public/scenarios.mjs | 15 +++++++++++ .../console-retained-revalidation.test.mjs | 26 +++++++++++++++++++ 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/apps/controller/src/console/console.mjs b/apps/controller/src/console/console.mjs index 288cb56b9e..9a033433b2 100644 --- a/apps/controller/src/console/console.mjs +++ b/apps/controller/src/console/console.mjs @@ -2245,17 +2245,19 @@ async function revalidateMountedAgent(current) { routeKey(route()) === key && view.isConnected; const path = `/namespaces/${encodeURIComponent(selectedNamespace)}/agents/${encodeURIComponent(agent.id)}`; - const focused = view.contains(document.activeElement) ? document.activeElement : null; + const pageActions = mountedViewState?.pageActions?.isConnected + ? mountedViewState.pageActions + : null; + const activeElement = document.activeElement; + const focused = + view.contains(activeElement) || pageActions?.contains(activeElement) ? activeElement : null; const selection = typeof focused?.selectionStart === "number" ? [focused.selectionStart, focused.selectionEnd] : null; let checking = "session"; - // Block stale controls during admission without losing an editor's caret on return. + // Block stale controls during admission without losing focus or an editor's caret on return. view.inert = true; - const pageActions = mountedViewState?.pageActions?.isConnected - ? mountedViewState.pageActions - : null; if (pageActions) { pageActions.inert = true; } diff --git a/scripts/console-storybook/focus-and-disabled-workflow.md b/scripts/console-storybook/focus-and-disabled-workflow.md index 0e77a1ff68..0a04c94f83 100644 --- a/scripts/console-storybook/focus-and-disabled-workflow.md +++ b/scripts/console-storybook/focus-and-disabled-workflow.md @@ -41,6 +41,10 @@ follows `:focus-visible`. No part of any ring may be cut off. 11. On **Pages / Agent detail / Draft** and **New version failed, deploy again**, only one button is filled with the accent. Secondary buttons have a dark edge, and Refresh buttons have none. +12. Open **Pages / Agent detail / Header actions**. Focus **Create new + version**, switch to another window, and return. The action stays visible + but inactive during the access check, then regains focus. Repeat while + moving focus to the sidebar during the check; focus stays where you moved it. Check steps 1 to 5 again at a narrow viewport (390 px; use **Plugins (phone)** for step 5). diff --git a/scripts/console-storybook/pages-agent-detail.stories.mjs b/scripts/console-storybook/pages-agent-detail.stories.mjs index 13beffb848..cc2810d09a 100644 --- a/scripts/console-storybook/pages-agent-detail.stories.mjs +++ b/scripts/console-storybook/pages-agent-detail.stories.mjs @@ -6,6 +6,7 @@ export default { title: "Pages/Agent detail" }; export const Draft = story("draft"); export const NewVersion = story("newVersion"); +export const HeaderActions = story("agentHeaderActions"); export const LongVersionHistory = story("longVersionHistory"); export const FirstDeployment = story("draftAutomaticCredentials"); export const ConfigurationEditor = { ...story("configurationEditor"), name: "Edit Configuration" }; diff --git a/scripts/console-storybook/public/scenarios.mjs b/scripts/console-storybook/public/scenarios.mjs index 60776d82f8..8c029d8433 100644 --- a/scripts/console-storybook/public/scenarios.mjs +++ b/scripts/console-storybook/public/scenarios.mjs @@ -3233,6 +3233,21 @@ export const scenarios = { ], gap: "The fixture simulates admission and worker completion; it does not verify a live Agent.", }, + agentHeaderActions: { + group: "Pages/Agent detail", + name: "Header actions", + path: revision, + deployed: true, + rules: [{ path: "/api/auth/session", skip: 1, delayMs: 1500 }], + description: + "The Agent header keeps its identity and actions visible while access is rechecked. The delay and Agent data are simulated.", + steps: [ + "Choose Copy Agent ID. If clipboard access is available, Copied appears briefly; otherwise, the ID is selected for manual copying.", + "Focus Create new version, switch to another window, then return. During the 1.5-second access check, the action stays visible but cannot be used; focus returns to it after the check.", + "Repeat the window switch and focus Namespaces while the check is pending. Focus stays in the sidebar when the check finishes.", + "Choose Create new version, open Edit Configuration, and make an unsaved edit. Use the Agents back link or Namespace selector: navigation stays on the Agent until the edit is resolved.", + ], + }, longVersionHistory: { group: "Pages/Agent detail", name: "Long version history", diff --git a/tests/browser/console-retained-revalidation.test.mjs b/tests/browser/console-retained-revalidation.test.mjs index 7739d3b32f..3e75fad5a6 100644 --- a/tests/browser/console-retained-revalidation.test.mjs +++ b/tests/browser/console-retained-revalidation.test.mjs @@ -243,6 +243,32 @@ test("Refocusing a loaded page keeps it steady while access is rechecked", async await page.waitForURL("**/console/agents/new**"); }); +for (const moveToSidebar of [false, true]) { + test(`Agent revalidation ${moveToSidebar ? "preserves newer sidebar focus" : "restores header action focus"}`, async (t) => { + const { page } = await openList(t, "Header focus", "Header Agent", { + deploy: true, + running: true, + }); + await openAgent(page, "Header Agent"); + const action = page.locator(".page-header").getByRole("button", { name: "Create new version" }); + const sidebar = page.getByRole("link", NAMESPACES); + await action.focus(); + assert.equal(await isFocused(action), true); + + const session = await holdOnce(t, page); + await refocus(page); + await session.requested; + await page.locator(heldView).waitFor(); + assert.equal(await action.evaluate((node) => node.closest("[inert]") !== null), true); + if (moveToSidebar) { + await sidebar.focus(); + } + session.release(); + await page.locator(settledView).waitFor(); + assert.equal(await isFocused(moveToSidebar ? sidebar : action), true); + }); +} + test("Back after switching Namespace keeps the shown page until the return is admitted", async (t) => { const { fixture, page } = await signedOutPage(t); const first = await fixture.createNamespace("First scope", { ready: true }); From a332cd32fe71c1347e8e807de23a23d0f7050bb8 Mon Sep 17 00:00:00 2001 From: Free Wortley Date: Tue, 20 Oct 2026 00:16:54 +0000 Subject: [PATCH 8/9] Show the navigation guard while a save is in progress --- scripts/console-storybook/public/scenarios.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/console-storybook/public/scenarios.mjs b/scripts/console-storybook/public/scenarios.mjs index 8c029d8433..2888efaec1 100644 --- a/scripts/console-storybook/public/scenarios.mjs +++ b/scripts/console-storybook/public/scenarios.mjs @@ -3245,8 +3245,9 @@ export const scenarios = { "Choose Copy Agent ID. If clipboard access is available, Copied appears briefly; otherwise, the ID is selected for manual copying.", "Focus Create new version, switch to another window, then return. During the 1.5-second access check, the action stays visible but cannot be used; focus returns to it after the check.", "Repeat the window switch and focus Namespaces while the check is pending. Focus stays in the sidebar when the check finishes.", - "Choose Create new version, open Edit Configuration, and make an unsaved edit. Use the Agents back link or Namespace selector: navigation stays on the Agent until the edit is resolved.", + "Choose Create new version to open the draft Configuration without deploying. In the next story, try the Agents back link and Namespace selector while a save is in progress.", ], + nextStory: "authSaveHoldsNavigation", }, longVersionHistory: { group: "Pages/Agent detail", @@ -5803,7 +5804,7 @@ export const scenarios = { description: "The story selects Save authentication source, then Agents in the sidebar. The page stays, and a note in the authentication form says why until the simulated save answers 3.6 s later.", steps: [ - "While the note “This page stays open until the authentication save finishes.” shows, select ← Agents or Agents again: the page stays. Select Reset story to replay the hold.", + "While the note “This page stays open until the authentication save finishes.” shows, select ← Agents or choose another Namespace. The page and selected Namespace stay the same. Reset the story to try again.", "When the save answers, the note goes; Agents in the sidebar now leaves.", ], }, From e0200d96d24ef2af0456f9dc64ea8931e6f28213 Mon Sep 17 00:00:00 2001 From: freeqaz Date: Tue, 20 Oct 2026 00:57:50 +0000 Subject: [PATCH 9/9] fix(console): Settings offers no Namespace recovery without a selector Settings dropped its Namespace selector, but an unreadable Namespace kept in its URL still showed "Namespace unavailable. Switch Namespace", and the button focused nothing. Recovery now shows only on pages that render the header selector it focuses. A browser case covers both sides: Backends' Switch Namespace focuses the compact selector, Settings shows no recovery. Drops this branch's refocus test for the header's Create new version: the same behaviour is covered by the retained-revalidation cases already on the branch. --- apps/controller/src/console/shell.mjs | 3 +- tests/browser/console-page-header.test.mjs | 70 ++++++++++------------ 2 files changed, 35 insertions(+), 38 deletions(-) diff --git a/apps/controller/src/console/shell.mjs b/apps/controller/src/console/shell.mjs index 4bb0e2430d..d91e8da03e 100644 --- a/apps/controller/src/console/shell.mjs +++ b/apps/controller/src/console/shell.mjs @@ -553,7 +553,8 @@ export function createShell({ app, pages, route, pageUrl, navigate, loadPage, lo namespaceSelector("Choose a valid Namespace"), ), ); - } else if (feature !== "namespaces") { + } else if (scopeSelector) { + // Switch Namespace focuses the header's selector: Settings has none, so it offers none. main.append( element( "p", diff --git a/tests/browser/console-page-header.test.mjs b/tests/browser/console-page-header.test.mjs index ca387c5091..0ca779e4a1 100644 --- a/tests/browser/console-page-header.test.mjs +++ b/tests/browser/console-page-header.test.mjs @@ -4,7 +4,6 @@ import test from "node:test"; import { detailUrl, fileBrowser, - holdRequests, login, nativeValues, newPage, @@ -253,42 +252,39 @@ test("Create Agent leads with ← Agents and puts the Namespace under its title" assert.equal(new URL(page.url()).pathname, "/console/agents"); }); -// Create new version lives in the header, outside the page view, so the Agent page's refocus -// recheck holds it inert with the page and gives its focus back afterwards, unless the reader -// moved elsewhere meanwhile (finding 1654's rule for the view's own controls). -test("a refocus recheck holds the header's Create new version with the page and keeps its focus", async (t) => { - const { fixture, namespace } = await createReadyConsoleNamespace(t, "Header refocus"); - const agent = await fixture.createAgent(namespace.id, "Refocus Agent", nativeValues("refocus")); - const active = await fixture.seedActiveAgentRevision(namespace.id, agent.id); +// Settings has no Namespace control, so a Namespace that turned unreadable while it sat in the URL +// gets no recovery there: "Switch Namespace" would have nothing to focus. Backends still has the +// compact control, so its recovery keeps working. +test("Settings offers no Namespace recovery, which needs the selector it no longer has", async (t) => { + const { fixture } = await createReadyConsoleNamespace(t, "Readable scope"); + const revoked = await fixture.createNamespace("Revoked scope", { ready: true }); const { page } = await newPage(t, fixture, { browser }); - await login(page, fixture, detailUrl(fixture, namespace.id, agent.id, active.revision.id).href); - const header = page.locator(".content .page-header"); - const create = header.getByRole("button", { name: "Create new version", exact: true }); - await create.waitFor(); + await login(page, fixture, `/console/backends?namespace=${revoked.id}`); + await page.locator("#namespace-selector:not(:disabled)").waitFor(); + fixture.policy.restrictions.push({ + id: "deny-revoked-read", + namespaceId: revoked.id, + resourceKind: "namespace", + action: "read", + effect: "deny", + }); + + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + const recover = page.getByRole("button", { name: "Switch Namespace", exact: true }); + await recover.click(); + assert.equal( + await page + .locator(".page-header #namespace-selector") + .evaluate((node) => node === node.ownerDocument.activeElement), + true, + "Backends: Switch Namespace focuses the compact selector", + ); + + await page.goto(new URL(`/console/settings?namespace=${revoked.id}`, fixture.origin).href); + await page.getByRole("heading", { name: "Settings", exact: true }).waitFor(); await page.locator(settledView).waitFor(); - const isFocused = (control) => - control.evaluate((node) => node === globalThis.document.activeElement); - const navigation = page.getByRole("link", { name: "Namespaces", exact: true }); - for (const expectedFocus of [create, navigation]) { - await create.focus(); - assert.equal(await isFocused(create), true); - const read = await holdRequests(t, page, "**/api/auth/session", { once: true }); - await page.evaluate(() => globalThis.dispatchEvent(new Event("focus"))); - await read.requested; - // The page and its header action are held together. - await header.locator(".page-view-actions[inert]").waitFor({ state: "attached" }); - await page.locator('.content [aria-live="polite"][inert]').waitFor({ state: "attached" }); - if (expectedFocus === navigation) { - await navigation.focus(); - } - read.release(); - await header.locator(".page-view-actions:not([inert])").waitFor({ state: "attached" }); - await page.locator(settledView).waitFor(); - await page.waitForFunction( - () => globalThis.document.activeElement !== globalThis.document.body, - null, - { timeout: 5_000 }, - ); - assert.equal(await isFocused(expectedFocus), true, "the reader's place after the check"); - } + assert.equal(new URL(page.url()).searchParams.get("namespace"), revoked.id); + assert.equal(await page.locator("#namespace-selector").count(), 0); + assert.equal(await recover.count(), 0, "Settings: no recovery without a selector"); + assert.equal(await page.getByText("Namespace unavailable", { exact: false }).count(), 0); });