fix(ui): keep sessions hub tabs stationary (#112039)

This commit is contained in:
Peter Steinberger
2026-07-21 00:57:24 -07:00
committed by GitHub
parent 7f32b6c984
commit c66c55abfd
10 changed files with 440 additions and 237 deletions
+90
View File
@@ -0,0 +1,90 @@
import { html, nothing, type TemplateResult } from "lit";
import { ref } from "lit/directives/ref.js";
import "../styles/hub-tabs.css";
import "./web-awesome-tabs.ts";
export type HubTabOption<T extends string> = {
value: T;
label: unknown;
badge?: unknown;
};
type HubTabsProps<T extends string> = {
id: string;
active: T;
tabs: ReadonlyArray<HubTabOption<T>>;
ariaLabel: string;
panelId: string;
className?: string;
onSelect: (tab: T) => void;
};
// Keyboard activation unmounts a route-owned strip, so the destination strip
// reclaims focus on first render. The timeout prevents an aborted navigation
// from stealing focus later.
const PENDING_FOCUS_WINDOW_MS = 2000;
let pendingFocus: { hubId: string; tab: string; at: number } | null = null;
let pointerActivation: { hubId: string; tab: string } | null = null;
function selectHubTab<T extends string>(tab: T, props: HubTabsProps<T>) {
const activatedByPointer = pointerActivation?.hubId === props.id && pointerActivation.tab === tab;
pointerActivation = null;
if (!activatedByPointer && tab !== props.active) {
pendingFocus = { hubId: props.id, tab, at: Date.now() };
}
props.onSelect(tab);
}
function reclaimFocus(hubId: string, tab: string, element: Element | undefined) {
if (!element || pendingFocus?.hubId !== hubId || pendingFocus.tab !== tab) {
return;
}
const pending = pendingFocus;
pendingFocus = null;
if (Date.now() - pending.at > PENDING_FOCUS_WINDOW_MS) {
return;
}
// The ref fires while the strip is still inside Lit's template fragment.
// A task lets both Lit and Web Awesome finish connecting before focus moves.
window.setTimeout(() => {
if (element.isConnected) {
(element as HTMLElement).focus();
}
}, 0);
}
export function renderHubTabs<T extends string>(props: HubTabsProps<T>): TemplateResult {
const className = `hub-tabs ${props.id}-hub-tabs${props.className ? ` ${props.className}` : ""}`;
return html`
<wa-tab-group
class=${className}
aria-label=${props.ariaLabel}
.active=${props.active}
activation="manual"
without-scroll-controls
@wa-tab-show=${(event: CustomEvent<{ name: T }>) => selectHubTab(event.detail.name, props)}
>
${props.tabs.map((tab) => {
const selected = props.active === tab.value;
return html`
<wa-tab
id=${`${props.id}-tab-${tab.value}`}
panel=${tab.value}
aria-controls=${props.panelId}
class="hub-tab"
?active=${selected}
@click=${(event: MouseEvent) => {
pointerActivation = event.detail > 0 ? { hubId: props.id, tab: tab.value } : null;
}}
@keydown=${() => {
pointerActivation = null;
}}
${selected ? ref((element) => reclaimFocus(props.id, tab.value, element)) : nothing}
>
${tab.label}${tab.badge ?? nothing}
</wa-tab>
`;
})}
</wa-tab-group>
`;
}
+26 -104
View File
@@ -1,22 +1,9 @@
// Shared tab strip for the Plugins hub: the plugins, skills, and skill-workshop
// routes render it under one "Plugins" header so the three surfaces read as tabs
// of a single page even though each tab keeps its own route and loader.
import { html, nothing } from "lit";
import { ref } from "lit/directives/ref.js";
import { html } from "lit";
import { t } from "../i18n/index.ts";
import "./web-awesome-tabs.ts";
import { renderHubTabs, type HubTabOption } from "./hub-tabs.ts";
export type PluginsHubTab = "installed" | "discover" | "skills" | "workshop";
const HUB_TABS: readonly PluginsHubTab[] = ["installed", "discover", "skills", "workshop"];
// Keyboard activation of a cross-route tab unmounts the strip that had focus,
// so the destination strip reclaims focus for its active tab on first render.
// Time-bounded so an aborted navigation cannot steal focus much later.
const PENDING_FOCUS_WINDOW_MS = 2000;
let pendingFocus: { tab: PluginsHubTab; at: number } | null = null;
let pointerActivation = false;
type PluginsHubTabsProps = {
active: PluginsHubTab;
/** Installed-plugin count badge; omit on pages without catalog data. */
@@ -24,96 +11,31 @@ type PluginsHubTabsProps = {
onSelect: (tab: PluginsHubTab) => void;
};
function hubTabLabel(tab: PluginsHubTab): string {
switch (tab) {
case "installed":
return t("pluginsPage.installedTab");
case "discover":
return t("pluginsPage.discoverTab");
case "skills":
return t("tabs.skills");
case "workshop":
return t("pluginsPage.workshopTab");
default:
return tab satisfies never;
}
function hubTabs(installedCount: number | null): ReadonlyArray<HubTabOption<PluginsHubTab>> {
return [
{
value: "installed",
label: t("pluginsPage.installedTab"),
badge:
installedCount === null
? undefined
: html`<span class="settings-count">${installedCount}</span>`,
},
{ value: "discover", label: t("pluginsPage.discoverTab") },
{ value: "skills", label: t("tabs.skills") },
{ value: "workshop", label: t("pluginsPage.workshopTab") },
];
}
function selectHubTab(tab: PluginsHubTab, props: PluginsHubTabsProps) {
// Keyboard activation unmounts the focused strip; only then should the
// destination strip pull focus after the route swap. Skip
// same-tab activation: it does not navigate, and a lingering entry would
// let a later re-render steal focus from whatever the user moved on to.
if (!pointerActivation && tab !== props.active) {
pendingFocus = { tab, at: Date.now() };
}
pointerActivation = false;
props.onSelect(tab);
}
function reclaimFocus(tab: PluginsHubTab, element: Element | undefined) {
if (!element || pendingFocus?.tab !== tab) {
return;
}
const pending = pendingFocus;
pendingFocus = null;
if (Date.now() - pending.at > PENDING_FOCUS_WINDOW_MS) {
return;
}
// The ref fires while the strip is still inside lit's template fragment.
// A task lets both Lit and Web Awesome finish connecting before focus moves.
window.setTimeout(() => {
if (element.isConnected) {
(element as HTMLElement).focus();
}
}, 0);
}
/**
* Every hub page marks its main content container with
* id="plugins-hub-panel" so aria-controls stays valid on each route.
* Styled as page-level navigation (.hub-tabs in ui/src/styles/plugins.css),
* deliberately distinct from segmented filter pills, keeping tablist semantics.
*/
/** Every route marks its main content with id="plugins-hub-panel". */
export function renderPluginsHubTabs(props: PluginsHubTabsProps) {
return html`
<wa-tab-group
class="hub-tabs plugins-hub-tabs plugins-tabs"
aria-label=${t("pluginsPage.hubTablistLabel")}
.active=${props.active}
activation="manual"
without-scroll-controls
@wa-tab-show=${(event: CustomEvent<{ name: PluginsHubTab }>) =>
selectHubTab(event.detail.name, props)}
>
${HUB_TABS.map((tab) => {
const selected = props.active === tab;
const count = tab === "installed" ? (props.installedCount ?? null) : null;
return html`
<wa-tab
id=${`plugins-tab-${tab}`}
panel=${tab}
aria-controls="plugins-hub-panel"
class="hub-tab"
?active=${selected}
@click=${(event: MouseEvent) => {
// Trusted pointer clicks carry a click count. Keyboard and AT
// synthesized clicks use detail=0 and need focus recovery.
pointerActivation = event.detail > 0;
}}
@keydown=${() => {
// Any keyboard interaction supersedes a prior pointer click.
// This also clears clicks on the already-active tab, which do
// not emit wa-tab-show and would otherwise leave stale state.
pointerActivation = false;
}}
${selected ? ref((element) => reclaimFocus(tab, element)) : nothing}
>
${hubTabLabel(tab)}
${count === null ? nothing : html`<span class="settings-count">${count}</span>`}
</wa-tab>
`;
})}
</wa-tab-group>
`;
return renderHubTabs({
id: "plugins",
active: props.active,
tabs: hubTabs(props.installedCount ?? null),
ariaLabel: t("pluginsPage.hubTablistLabel"),
panelId: "plugins-hub-panel",
className: "plugins-tabs",
onSelect: props.onSelect,
});
}
@@ -0,0 +1,88 @@
import { html, render } from "lit";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { i18n } from "../i18n/index.ts";
import "../styles.css";
import { renderSessionsHubHeader } from "./sessions-hub-header.ts";
const hasBrowserLayout = !navigator.userAgent.toLowerCase().includes("jsdom");
async function useViewport(width: number, height = 800) {
const { page } = await import("vitest/browser");
await page.viewport(width, height);
}
async function mount(active: "sessions" | "worktrees", withActions: boolean) {
const container = document.createElement("div");
container.style.width = "calc(100vw - 32px)";
container.style.maxWidth = "1120px";
document.body.append(container);
render(
renderSessionsHubHeader({
active,
title: "Threads",
actions: withActions ? html`<div style="width: 240px">Agent selector</div>` : undefined,
onSelect: () => undefined,
}),
container,
);
const group = container.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
"wa-tab-group",
);
await group?.updateComplete;
return container;
}
function overlaps(left: DOMRect, right: DOMRect): boolean {
return !(
left.right <= right.left ||
left.left >= right.right ||
left.bottom <= right.top ||
left.top >= right.bottom
);
}
describe.skipIf(!hasBrowserLayout)("Sessions hub header browser layout", () => {
beforeEach(async () => {
await i18n.setLocale("en");
});
afterEach(() => {
document.body.replaceChildren();
});
it.each([1280, 820])(
"keeps the tab strip fixed without overlap at a %dpx viewport",
async (width) => {
await useViewport(width);
const sessions = await mount("sessions", true);
const sessionsTitle = sessions.querySelector<HTMLElement>(".sessions-hub-header__title");
const sessionsTabs = sessions.querySelector<HTMLElement>(".sessions-hub-tabs");
const sessionsActions = sessions.querySelector<HTMLElement>(".sessions-hub-header__actions");
const sessionsLeft = sessionsTabs?.getBoundingClientRect().left;
expect(sessionsLeft).toBeTypeOf("number");
expect(sessionsTabs?.getBoundingClientRect().width).toBeGreaterThan(0);
expect(sessionsActions?.childElementCount).toBe(1);
expect(
overlaps(sessionsTitle!.getBoundingClientRect(), sessionsTabs!.getBoundingClientRect()),
).toBe(false);
expect(
overlaps(sessionsActions!.getBoundingClientRect(), sessionsTabs!.getBoundingClientRect()),
).toBe(false);
sessions.remove();
const worktrees = await mount("worktrees", false);
const worktreesTabs = worktrees.querySelector<HTMLElement>(".sessions-hub-tabs");
const worktreesLeft = worktreesTabs?.getBoundingClientRect().left;
expect(worktreesLeft).toBeTypeOf("number");
expect(worktrees.querySelector(".sessions-hub-header__actions")?.childElementCount).toBe(0);
expect(Math.abs((sessionsLeft ?? 0) - (worktreesLeft ?? 0))).toBeLessThanOrEqual(1);
},
);
it("keeps the page header hidden on mobile", async () => {
await useViewport(414, 800);
const sessions = await mount("sessions", true);
const header = sessions.querySelector<HTMLElement>(".sessions-hub-header");
expect(getComputedStyle(header!).display).toBe("none");
});
});
+21
View File
@@ -0,0 +1,21 @@
import { html, nothing, type TemplateResult } from "lit";
import { renderSessionsHubTabs, type SessionsHubTab } from "./sessions-hub-tabs.ts";
type SessionsHubHeaderProps = {
active: SessionsHubTab;
title: unknown;
actions?: unknown;
onSelect: (tab: SessionsHubTab) => void;
};
export function renderSessionsHubHeader(props: SessionsHubHeaderProps): TemplateResult {
return html`
<section class="content-header content-header--page sessions-hub-header">
<div class="sessions-hub-header__title">
<div class="page-title">${props.title}</div>
</div>
${renderSessionsHubTabs({ active: props.active, onSelect: props.onSelect })}
<div class="sessions-hub-header__actions">${props.actions ?? nothing}</div>
</section>
`;
}
@@ -0,0 +1,82 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "../i18n/index.ts";
import { renderSessionsHubTabs } from "./sessions-hub-tabs.ts";
type SessionsHubTabsProps = Parameters<typeof renderSessionsHubTabs>[0];
async function mount(props: SessionsHubTabsProps): Promise<HTMLDivElement> {
const container = document.createElement("div");
document.body.append(container);
render(renderSessionsHubTabs(props), container);
const group = container.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
"wa-tab-group",
);
await group?.updateComplete;
return container;
}
describe("renderSessionsHubTabs", () => {
beforeEach(async () => {
await i18n.setLocale("en");
});
afterEach(() => {
document.body.innerHTML = "";
});
it("renders the route hub with manual activation and a shared panel target", async () => {
const container = await mount({ active: "worktrees", onSelect: () => undefined });
const group = container.querySelector("wa-tab-group");
const tabs = [...container.querySelectorAll<HTMLElement>("wa-tab")];
expect(group?.getAttribute("activation")).toBe("manual");
expect(tabs.map((tab) => tab.id)).toEqual(["sessions-tab-sessions", "sessions-tab-worktrees"]);
expect(tabs.map((tab) => tab.getAttribute("aria-controls"))).toEqual([
"sessions-hub-panel",
"sessions-hub-panel",
]);
expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual(["false", "true"]);
});
it("delegates cross-route selection", async () => {
const onSelect = vi.fn();
const container = await mount({ active: "sessions", onSelect });
container.querySelector("wa-tab-group")?.dispatchEvent(
new CustomEvent("wa-tab-show", {
bubbles: true,
composed: true,
detail: { name: "worktrees" },
}),
);
expect(onSelect).toHaveBeenLastCalledWith("worktrees");
});
it("hands focus to the destination strip after keyboard navigation", async () => {
const source = await mount({ active: "sessions", onSelect: () => undefined });
source
.querySelector<HTMLElement>("#sessions-tab-worktrees")
?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, composed: true }),
);
source.querySelector("wa-tab-group")?.dispatchEvent(
new CustomEvent("wa-tab-show", {
bubbles: true,
composed: true,
detail: { name: "worktrees" },
}),
);
source.remove();
const destination = await mount({ active: "worktrees", onSelect: () => undefined });
await vi.waitFor(() => {
expect(document.activeElement).toBe(
destination.querySelector<HTMLElement>("#sessions-tab-worktrees"),
);
});
});
});
+16 -49
View File
@@ -1,61 +1,28 @@
// Shared tab strip for the Sessions hub: the sessions and worktrees routes
// render it under one header so the two surfaces read as tabs of a single
// page even though each tab keeps its own route and loader (same pattern as
// plugins-hub-tabs.ts).
import { html } from "lit";
import { t } from "../i18n/index.ts";
import "./web-awesome-tabs.ts";
import { renderHubTabs, type HubTabOption } from "./hub-tabs.ts";
type SessionsHubTab = "sessions" | "worktrees";
const HUB_TABS: readonly SessionsHubTab[] = ["sessions", "worktrees"];
export type SessionsHubTab = "sessions" | "worktrees";
type SessionsHubTabsProps = {
active: SessionsHubTab;
onSelect: (tab: SessionsHubTab) => void;
};
function hubTabLabel(tab: SessionsHubTab): string {
switch (tab) {
case "sessions":
return t("tabs.sessions");
case "worktrees":
return t("tabs.worktrees");
default:
return tab satisfies never;
}
function hubTabs(): ReadonlyArray<HubTabOption<SessionsHubTab>> {
return [
{ value: "sessions", label: t("tabs.sessions") },
{ value: "worktrees", label: t("tabs.worktrees") },
];
}
/**
* Every hub page marks its main content container with
* id="sessions-hub-panel" so aria-controls stays valid on each route.
* Styled as page-level navigation (.hub-tabs in ui/src/styles/plugins.css).
*/
/** Every route marks its main content with id="sessions-hub-panel". */
export function renderSessionsHubTabs(props: SessionsHubTabsProps) {
return html`
<wa-tab-group
class="hub-tabs plugins-hub-tabs sessions-hub-tabs"
aria-label=${t("sessionsPage.hubTablistLabel")}
.active=${props.active}
activation="manual"
without-scroll-controls
@wa-tab-show=${(event: CustomEvent<{ name: SessionsHubTab }>) =>
props.onSelect(event.detail.name)}
>
${HUB_TABS.map((tab) => {
const selected = props.active === tab;
return html`
<wa-tab
id=${`sessions-tab-${tab}`}
panel=${tab}
aria-controls="sessions-hub-panel"
class="hub-tab"
?active=${selected}
>
${hubTabLabel(tab)}
</wa-tab>
`;
})}
</wa-tab-group>
`;
return renderHubTabs({
id: "sessions",
active: props.active,
tabs: hubTabs(),
ariaLabel: t("sessionsPage.hubTablistLabel"),
panelId: "sessions-hub-panel",
onSelect: props.onSelect,
});
}
+12 -16
View File
@@ -15,7 +15,7 @@ import { fetchSessionMenuWork } from "../../components/session-menu-work.ts";
import type { SessionMenuAction, SessionMenuWork } from "../../components/session-menu.ts";
import "../../components/session-menu.ts";
import { isStoppableCloudWorkerPlacement } from "../../components/session-row-badges.ts";
import { renderSessionsHubTabs } from "../../components/sessions-hub-tabs.ts";
import { renderSessionsHubHeader } from "../../components/sessions-hub-header.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import { openEditor } from "../../lib/editor-links.ts";
@@ -1267,23 +1267,19 @@ class SessionsPage extends OpenClawLightDomElement {
return html``;
}
return html`
<section class="content-header content-header--page">
<div>
<div class="page-title">${titleForRoute("sessions")}</div>
</div>
${renderSessionsHubTabs({
active: "sessions",
onSelect: (tab) => {
if (tab !== "sessions") {
context.navigate(tab);
}
},
})}
${renderAgentScopeControl({
${renderSessionsHubHeader({
active: "sessions",
title: titleForRoute("sessions"),
actions: renderAgentScopeControl({
agents: context.agents.state.agentsList?.agents ?? [],
selection: context.agentSelection,
})}
</section>
}),
onSelect: (tab) => {
if (tab !== "sessions") {
context.navigate(tab);
}
},
})}
${renderSettingsWorkspace(
renderSessions({
loading: this.loading,
+10 -14
View File
@@ -6,7 +6,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
import { titleForRoute } from "../../app-navigation.ts";
import { pathForRoute } from "../../app-route-paths.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import { renderSessionsHubTabs } from "../../components/sessions-hub-tabs.ts";
import { renderSessionsHubHeader } from "../../components/sessions-hub-header.ts";
import {
renderSettingsEmpty,
renderSettingsPage,
@@ -472,19 +472,15 @@ class WorktreesPage extends OpenClawLightDomElement {
{ wide: true },
);
return html`
<section class="content-header">
<div>
<div class="page-title">${titleForRoute("sessions")}</div>
</div>
${renderSessionsHubTabs({
active: "worktrees",
onSelect: (tab) => {
if (tab !== "worktrees") {
this.context?.navigate(tab);
}
},
})}
</section>
${renderSessionsHubHeader({
active: "worktrees",
title: titleForRoute("sessions"),
onSelect: (tab) => {
if (tab !== "worktrees") {
this.context?.navigate(tab);
}
},
})}
${renderSettingsWorkspace(body, { id: "sessions-hub-panel" })}
`;
}
+95
View File
@@ -0,0 +1,95 @@
/* Page hubs use quiet text tabs on a hairline track so route navigation stays
distinct from segmented filters inside each page. */
wa-tab-group.hub-tabs {
--track-color: color-mix(in srgb, var(--border) 60%, transparent);
--indicator-color: var(--accent);
}
wa-tab-group.hub-tabs::part(body) {
display: none;
}
wa-tab-group.hub-tabs::part(tabs) {
gap: var(--space-2);
}
wa-tab.hub-tab {
font-size: var(--control-ui-text-sm);
font-weight: 550;
color: var(--muted);
transition: color var(--duration-normal) var(--ease-out);
}
/* Neutralize wa-tab's stock 1em padding; keep a slim nav-tab hit area. */
wa-tab.hub-tab::part(base) {
padding: var(--space-1) var(--space-2) var(--space-2);
gap: 6px;
font: inherit;
color: inherit;
}
wa-tab.hub-tab:hover {
color: var(--text);
}
wa-tab.hub-tab[active] {
color: var(--text-strong);
}
wa-tab.hub-tab:focus-visible {
outline: none;
border-radius: var(--radius-sm);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent);
}
/* Web Awesome also draws its stock focus ring on the inner base part;
without this reset both rings render at once. */
wa-tab.hub-tab:focus-visible::part(base) {
outline: none;
}
/* Equal outer columns keep the Sessions hub tabs centered when the Threads
route adds its trailing agent selector and Worktrees leaves that slot empty. */
.content-header.sessions-hub-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
}
.sessions-hub-header__title,
.sessions-hub-header__actions {
min-width: 0;
}
.sessions-hub-header__actions {
justify-self: end;
}
@media (min-width: 769px) and (max-width: 1024px) {
.content-header.sessions-hub-header {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
"title actions"
"tabs tabs";
row-gap: var(--space-2);
max-height: none;
}
.sessions-hub-header__title {
grid-area: title;
}
.sessions-hub-tabs {
grid-area: tabs;
justify-self: center;
}
.sessions-hub-header__actions {
grid-area: actions;
}
}
@media (max-width: 768px), (max-width: 932px) and (max-height: 500px) and (orientation: landscape) {
.content-header.sessions-hub-header {
display: none;
}
}
-54
View File
@@ -15,60 +15,6 @@
padding: var(--space-3) var(--space-4) 0;
}
/* ---------- hub tabs ---------- */
/* Hub tab strips (plugins + sessions hubs) are page-level navigation, not
filters: quiet text tabs on a hairline track with an accent indicator, so
they read differently from the segmented filter pills inside the pages.
Track/indicator come from wa-tab-group's shadow machinery. */
wa-tab-group.hub-tabs {
--track-color: color-mix(in srgb, var(--border) 60%, transparent);
--indicator-color: var(--accent);
}
wa-tab-group.hub-tabs::part(body) {
display: none;
}
wa-tab-group.hub-tabs::part(tabs) {
gap: var(--space-2);
}
wa-tab.hub-tab {
font-size: var(--control-ui-text-sm);
font-weight: 550;
color: var(--muted);
transition: color var(--duration-normal) var(--ease-out);
}
/* Neutralize wa-tab's stock 1em padding; keep a slim nav-tab hit area. */
wa-tab.hub-tab::part(base) {
padding: var(--space-1) var(--space-2) var(--space-2);
gap: 6px;
font: inherit;
color: inherit;
}
wa-tab.hub-tab:hover {
color: var(--text);
}
wa-tab.hub-tab[active] {
color: var(--text-strong);
}
wa-tab.hub-tab:focus-visible {
outline: none;
border-radius: var(--radius-sm);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent);
}
/* Web Awesome also draws its stock focus ring on the inner base part;
without this reset both rings render at once. */
wa-tab.hub-tab:focus-visible::part(base) {
outline: none;
}
/* ---------- toolbar ---------- */
.plugins-toolbar {