mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(ui): keep sessions hub tabs stationary (#112039)
This commit is contained in:
committed by
GitHub
parent
7f32b6c984
commit
c66c55abfd
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" })}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user