refactor(ui): unify tab navigation on the shared hub tabs (#115934)

* refactor(ui): unify tab navigation on shared hub tabs

* refactor(ui): move cron navigation to hub tabs

* test(ui): update agent overview tab locator
This commit is contained in:
Peter Steinberger
2026-07-29 11:24:44 -04:00
committed by GitHub
parent e0c384b771
commit df3674f577
26 changed files with 859 additions and 960 deletions
+93
View File
@@ -0,0 +1,93 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderHubTabs } from "./hub-tabs.ts";
describe("renderHubTabs", () => {
it("renders counts, badges, and the reduced sub variant", () => {
const container = document.createElement("div");
render(
renderHubTabs({
id: "example",
active: "files",
tabs: [
{ value: "files", label: "Files", count: 3 },
{ value: "memory", label: "Memory", badge: "New" },
],
ariaLabel: "Example sections",
panelId: "example-panel",
variant: "sub",
onSelect: () => undefined,
}),
container,
);
expect(container.querySelector("wa-tab-group")?.classList).toContain("hub-tabs--sub");
expect(container.querySelector("#example-tab-files")?.hasAttribute("active")).toBe(true);
expect(container.querySelector(".hub-tab__badge--count")?.textContent).toBe("3");
expect(container.querySelector("#example-tab-memory .hub-tab__badge")?.textContent).toBe("New");
});
it("selects only enabled tabs from direct user activation", () => {
const onSelect = vi.fn();
const container = document.createElement("div");
render(
renderHubTabs({
id: "example",
active: "first",
tabs: [
{ value: "first", label: "First" },
{ value: "second", label: "Second" },
{ value: "disabled", label: "Disabled", disabled: true },
],
ariaLabel: "Example sections",
panelId: "example-panel",
onSelect,
}),
container,
);
container
.querySelector("#example-tab-second")
?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
container
.querySelector("#example-tab-disabled")
?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
container.querySelector("wa-tab-group")?.dispatchEvent(
new CustomEvent("wa-tab-show", {
bubbles: true,
composed: true,
detail: { name: "disabled" },
}),
);
expect(onSelect).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith("second");
expect(container.querySelector("wa-tab-group")?.getAttribute("activation")).toBe("manual");
});
it("preserves an intentional no-selection state", () => {
const container = document.createElement("div");
render(
renderHubTabs({
id: "example",
active: null,
tabs: [
{ value: "first", label: "First" },
{ value: "second", label: "Second" },
],
ariaLabel: "Example sections",
panelId: "example-panel",
onSelect: () => undefined,
}),
container,
);
const group = container.querySelector<HTMLElement & { active: string }>("wa-tab-group");
expect(group?.active).not.toBe("");
expect(group?.active).not.toBe("first");
expect(container.querySelector("wa-tab[active]")).toBeNull();
expect(container.querySelector<HTMLElement>("#example-tab-first")?.tabIndex).toBe(0);
expect(container.querySelector<HTMLElement>("#example-tab-second")?.tabIndex).toBe(-1);
});
});
+30 -5
View File
@@ -7,15 +7,19 @@ export type HubTabOption<T extends string> = {
value: T;
label: unknown;
badge?: unknown;
count?: number | null;
disabled?: boolean;
testId?: string;
};
type HubTabsProps<T extends string> = {
id: string;
active: T;
active: T | null;
tabs: ReadonlyArray<HubTabOption<T>>;
ariaLabel: string;
panelId: string;
className?: string;
variant?: "primary" | "sub";
onSelect: (tab: T) => void;
};
@@ -23,6 +27,9 @@ type HubTabsProps<T extends string> = {
// reclaims focus on first render. The timeout prevents an aborted navigation
// from stealing focus later.
const PENDING_FOCUS_WINDOW_MS = 2000;
// Web Awesome selects its first tab when `active` is empty. A truthy value that
// matches no panel preserves an intentional no-selection state.
const NO_ACTIVE_TAB = "__openclaw-hub-tabs-no-active__";
let pendingFocus: { hubId: string; tab: string; at: number } | null = null;
function reclaimFocus(hubId: string, tab: string, element: Element | undefined) {
@@ -44,12 +51,15 @@ function reclaimFocus(hubId: string, tab: string, element: Element | undefined)
}
export function renderHubTabs<T extends string>(props: HubTabsProps<T>): TemplateResult {
const className = `hub-tabs ${props.id}-hub-tabs${props.className ? ` ${props.className}` : ""}`;
const variant = props.variant ?? "primary";
const className = `hub-tabs hub-tabs--${variant} ${props.id}-hub-tabs${props.className ? ` ${props.className}` : ""}`;
const fallbackFocusValue =
props.active === null ? props.tabs.find((tab) => !tab.disabled)?.value : null;
return html`
<wa-tab-group
class=${className}
aria-label=${props.ariaLabel}
.active=${props.active}
.active=${props.active ?? NO_ACTIVE_TAB}
activation="manual"
without-scroll-controls
>
@@ -62,13 +72,22 @@ export function renderHubTabs<T extends string>(props: HubTabsProps<T>): Templat
aria-controls=${props.panelId}
class="hub-tab"
?active=${selected}
?disabled=${tab.disabled}
.tabIndex=${selected || tab.value === fallbackFocusValue ? 0 : -1}
aria-selected=${selected ? "true" : "false"}
data-test-id=${tab.testId ?? nothing}
@click=${(event: MouseEvent) => {
if ((event.detail > 0 || event.isTrusted) && tab.value !== props.active) {
if (
!tab.disabled &&
(event.detail > 0 || event.isTrusted) &&
tab.value !== props.active
) {
props.onSelect(tab.value);
}
}}
@keydown=${(event: KeyboardEvent) => {
if (
!tab.disabled &&
!event.repeat &&
(event.key === "Enter" || event.key === " ") &&
tab.value !== props.active
@@ -80,7 +99,13 @@ export function renderHubTabs<T extends string>(props: HubTabsProps<T>): Templat
}}
${selected ? ref((element) => reclaimFocus(props.id, tab.value, element)) : nothing}
>
${tab.label}${tab.badge ?? nothing}
${tab.label}${tab.count == null
? nothing
: html`<span class="hub-tab__badge hub-tab__badge--count"
>${tab.count}</span
>`}${tab.badge == null
? nothing
: html`<span class="hub-tab__badge">${tab.badge}</span>`}
</wa-tab>
`;
})}
-163
View File
@@ -1,163 +0,0 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "../i18n/index.ts";
import { renderPluginsHubTabs } from "./plugins-hub-tabs.ts";
type PluginsHubTabsProps = Parameters<typeof renderPluginsHubTabs>[0];
async function mount(props: PluginsHubTabsProps): Promise<HTMLDivElement> {
const container = document.createElement("div");
document.body.append(container);
render(renderPluginsHubTabs(props), container);
const group = container.querySelector<HTMLElement & { updateComplete: Promise<boolean> }>(
"wa-tab-group",
);
await group?.updateComplete;
return container;
}
describe("renderPluginsHubTabs", () => {
beforeEach(async () => {
await i18n.setLocale("en");
});
afterEach(() => {
document.body.innerHTML = "";
});
it("renders all hub tabs with the active tab selected", async () => {
const container = await mount({
active: "skills",
installedCount: 4,
onSelect: () => undefined,
});
const tabs = [...container.querySelectorAll<HTMLElement>("wa-tab")];
expect(tabs.map((tab) => tab.id)).toEqual([
"plugins-tab-installed",
"plugins-tab-discover",
"plugins-tab-skills",
"plugins-tab-workshop",
]);
expect(tabs.map((tab) => tab.getAttribute("aria-selected"))).toEqual([
"false",
"false",
"true",
"false",
]);
expect(container.querySelector("#plugins-tab-installed")?.textContent).toContain("4");
});
it("omits the installed count badge when no catalog data is provided", async () => {
const container = await mount({ active: "workshop", onSelect: () => undefined });
expect(container.querySelector("#plugins-tab-installed span")).toBeNull();
});
it("selects tabs on click", async () => {
const onSelect = vi.fn();
const container = await mount({ active: "installed", onSelect });
container
.querySelector<HTMLButtonElement>("#plugins-tab-workshop")
?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
expect(onSelect).toHaveBeenLastCalledWith("workshop");
});
it("uses manual Web Awesome activation for cross-route tabs", async () => {
const onSelect = vi.fn();
const container = await mount({ active: "installed", onSelect });
const group = container.querySelector("wa-tab-group");
const installed = container.querySelector<HTMLElement>("#plugins-tab-installed");
expect(group?.getAttribute("activation")).toBe("manual");
installed?.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, composed: true }),
);
expect(onSelect).not.toHaveBeenCalled();
});
it("hands focus to the destination strip after keyboard activation", async () => {
const onSelect = vi.fn();
const source = await mount({ active: "installed", onSelect });
const target = source.querySelector<HTMLElement>("#plugins-tab-workshop");
target?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, composed: true }),
);
expect(onSelect).toHaveBeenLastCalledWith("workshop");
source.remove();
const destination = await mount({ active: "workshop", onSelect: () => undefined });
await vi.waitFor(() => {
expect(document.activeElement).toBe(
destination.querySelector<HTMLElement>("#plugins-tab-workshop"),
);
});
});
it("ignores setup-time tab-show events", async () => {
const onSelect = vi.fn();
const source = await mount({ active: "installed", onSelect });
source.querySelector("wa-tab-group")?.dispatchEvent(
new CustomEvent("wa-tab-show", {
bubbles: true,
composed: true,
detail: { name: "discover" },
}),
);
expect(onSelect).not.toHaveBeenCalled();
source.remove();
const destination = await mount({ active: "discover", onSelect: () => undefined });
await Promise.resolve();
expect(document.activeElement).not.toBe(
destination.querySelector<HTMLElement>("#plugins-tab-discover"),
);
});
it("does not queue focus recovery for same-tab keyboard activation", async () => {
const container = await mount({ active: "installed", onSelect: () => undefined });
const installed = container.querySelector<HTMLElement>("#plugins-tab-installed");
installed?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, composed: true }),
);
// A later re-render of the strip must not reclaim focus from whatever
// control the user moved on to.
container.remove();
const rerendered = await mount({ active: "installed", onSelect: () => undefined });
await Promise.resolve();
expect(document.activeElement).not.toBe(
rerendered.querySelector<HTMLElement>("#plugins-tab-installed"),
);
});
it("does not steal focus after mouse activation", async () => {
const source = await mount({ active: "installed", onSelect: () => undefined });
source
.querySelector<HTMLElement>("#plugins-tab-skills")
?.dispatchEvent(new MouseEvent("click", { detail: 1 }));
source.remove();
const destination = await mount({ active: "skills", onSelect: () => undefined });
await Promise.resolve();
expect(document.activeElement).not.toBe(
destination.querySelector<HTMLElement>("#plugins-tab-skills"),
);
});
it("does not turn arrow navigation into route selection", async () => {
const onSelect = vi.fn();
const source = await mount({ active: "installed", onSelect });
const active = source.querySelector<HTMLElement>("#plugins-tab-installed");
active?.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, composed: true }),
);
source.querySelector("wa-tab-group")?.dispatchEvent(
new CustomEvent("wa-tab-show", {
bubbles: true,
composed: true,
detail: { name: "discover" },
}),
);
expect(onSelect).not.toHaveBeenCalled();
});
});
-41
View File
@@ -1,41 +0,0 @@
import { html } from "lit";
import { t } from "../i18n/index.ts";
import { renderHubTabs, type HubTabOption } from "./hub-tabs.ts";
export type PluginsHubTab = "installed" | "discover" | "skills" | "workshop";
type PluginsHubTabsProps = {
active: PluginsHubTab;
/** Installed-plugin count badge; omit on pages without catalog data. */
installedCount?: number | null;
onSelect: (tab: PluginsHubTab) => void;
};
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") },
];
}
/** Every route marks its main content with id="plugins-hub-panel". */
export function renderPluginsHubTabs(props: PluginsHubTabsProps) {
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,
});
}
@@ -114,7 +114,7 @@ describeControlUiE2e("Control UI agent model fallback ownership", () => {
)
.toBe("writer");
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/agents/writer/tools");
await page.getByRole("button", { name: "Overview", exact: true }).click();
await page.getByRole("tab", { name: "Overview", exact: true }).click();
await expect
.poll(() => new URL(page.url()).pathname)
.toBe("/settings/agents/writer/overview");
+26 -13
View File
@@ -320,8 +320,11 @@ describe("dreaming view", () => {
viewState = createDreamingViewState();
});
it("renders the active dream scene chrome and status", () => {
const container = renderInto(buildProps({ dreamingOf: "reindexing old chats\u2026" }));
it("renders the active dream scene chrome and selects another view", () => {
const onViewStateChange = vi.fn();
const container = renderInto(
buildProps({ dreamingOf: "reindexing old chats\u2026", onViewStateChange }),
);
expectElement(container, ".dreams__lobster svg");
@@ -371,10 +374,14 @@ describe("dreaming view", () => {
"off",
);
const buttons = [...container.querySelectorAll("button")].map((node) =>
node.textContent?.trim(),
);
expect(buttons).toEqual(["Scene", "Diary", "Advanced"]);
const tabs = [...container.querySelectorAll(".dreams-hub-tabs .hub-tab")];
expect(tabs.map((node) => node.textContent?.trim())).toEqual(["Scene", "Diary", "Advanced"]);
expect(container.querySelector("#dreams-tab-scene")?.hasAttribute("active")).toBe(true);
container
.querySelector("#dreams-tab-diary")
?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
expect(viewState.activeSubTab).toBe("diary");
expect(onViewStateChange).toHaveBeenCalledOnce();
expectElement(container, ".dreams__bubble");
const text = container.querySelector(".dreams__bubble-text");
expect(text?.textContent).toBe("reindexing old chats\u2026");
@@ -384,8 +391,6 @@ describe("dreaming view", () => {
expect(detail?.textContent?.trim().replace(/\s+/g, " ")).toBe(
"12 promoted · next sweep 4:00 AM · America/Los_Angeles",
);
const tabs = container.querySelectorAll(".dreams__tab");
expect([...tabs].map((tab) => tab.textContent?.trim())).toEqual(["Scene", "Diary", "Advanced"]);
});
it("renders idle and unavailable scene states", () => {
@@ -414,16 +419,24 @@ describe("dreaming view", () => {
it("renders imported memory topics inside the diary tab", () => {
setDreamSubTab("diary");
setDreamDiarySubTab("insights");
const container = renderInto(buildProps());
const subtabs = [...container.querySelectorAll(".dreams-diary__subtab")].map((tab) => ({
label: tab.textContent?.trim(),
active: tab.classList.contains("dreams-diary__subtab--active"),
}));
const onViewStateChange = vi.fn();
const container = renderInto(buildProps({ onViewStateChange }));
const subtabs = [...container.querySelectorAll(".dream-diary-hub-tabs .hub-tab")].map(
(tab) => ({
label: tab.textContent?.trim(),
active: tab.hasAttribute("active"),
}),
);
expect(subtabs).toEqual([
{ label: "Dreams", active: false },
{ label: "Imported Insights", active: true },
{ label: "Memory Palace", active: false },
]);
container
.querySelector("#dream-diary-tab-palace")
?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
expect(viewState.activeDiarySubTab).toBe("palace");
expect(onViewStateChange).toHaveBeenCalledOnce();
expect(compactText(container.querySelector(".dreams-diary__date"))).toBe(
"Travel · 1 chats · 1 signals",
);
+78 -100
View File
@@ -3,6 +3,7 @@ import "../../../styles/lobster-pet.css";
import { expectDefined } from "@openclaw/normalization-core";
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { renderHubTabs } from "../../../components/hub-tabs.ts";
import {
createLobsterPetLook,
lobsterPetSeed,
@@ -281,42 +282,36 @@ export function renderDreaming(props: DreamingProps) {
<div class="dreams-page">
<!-- ── Sub-tab bar ── -->
<div class="dreams__topbar">
<nav class="dreams__tabs">
<button
class="dreams__tab ${state.activeSubTab === "scene" ? "dreams__tab--active" : ""}"
@click=${() => {
state.activeSubTab = "scene";
props.onViewStateChange();
}}
>
${t("dreaming.tabs.scene")}
</button>
<button
class="dreams__tab ${state.activeSubTab === "diary" ? "dreams__tab--active" : ""}"
@click=${() => {
state.activeSubTab = "diary";
props.onViewStateChange();
}}
>
${t("dreaming.tabs.diary")}
</button>
<button
class="dreams__tab ${state.activeSubTab === "advanced" ? "dreams__tab--active" : ""}"
@click=${() => {
state.activeSubTab = "advanced";
props.onViewStateChange();
}}
>
${t("dreaming.tabs.advanced")}
</button>
</nav>
${renderHubTabs({
id: "dreams",
active: state.activeSubTab,
tabs: [
{ value: "scene", label: t("dreaming.tabs.scene") },
{ value: "diary", label: t("dreaming.tabs.diary") },
{ value: "advanced", label: t("dreaming.tabs.advanced") },
],
ariaLabel: t("memoryPage.tabs.dreams"),
panelId: "dreams-panel",
variant: "sub",
onSelect: (tab) => {
state.activeSubTab = tab;
props.onViewStateChange();
},
})}
</div>
${state.activeSubTab === "scene"
? renderScene(props, idle, dreamText)
: state.activeSubTab === "diary"
? renderDiarySection(props)
: renderAdvancedSection(props)}
<div
id="dreams-panel"
class="dreams__panel"
role="tabpanel"
aria-labelledby=${`dreams-tab-${state.activeSubTab}`}
>
${state.activeSubTab === "scene"
? renderScene(props, idle, dreamText)
: state.activeSubTab === "diary"
? renderDiarySection(props)
: renderAdvancedSection(props)}
</div>
</div>
`;
}
@@ -1524,47 +1519,24 @@ function renderDiarySection(props: DreamingProps) {
<div class="dreams-diary__chrome">
<div class="dreams-diary__header">
<span class="dreams-diary__title">${t("dreaming.diary.title")}</span>
<div class="dreams-diary__subtabs">
<button
class="dreams-diary__subtab ${activeDiarySubTab === "dreams"
? "dreams-diary__subtab--active"
: ""}"
@click=${() => {
resetWikiPreview(state);
state.activeDiarySubTab = "dreams";
state.diaryPage = 0;
props.onViewStateChange();
}}
>
${t("dreaming.wiki.dreamsTab")}
</button>
<button
class="dreams-diary__subtab ${activeDiarySubTab === "insights"
? "dreams-diary__subtab--active"
: ""}"
@click=${() => {
resetWikiPreview(state);
state.activeDiarySubTab = "insights";
state.diaryPage = 0;
props.onViewStateChange();
}}
>
${t("dreaming.wiki.insightsTab")}
</button>
<button
class="dreams-diary__subtab ${activeDiarySubTab === "palace"
? "dreams-diary__subtab--active"
: ""}"
@click=${() => {
resetWikiPreview(state);
state.activeDiarySubTab = "palace";
state.diaryPage = 0;
props.onViewStateChange();
}}
>
${t("dreaming.wiki.palaceTab")}
</button>
</div>
${renderHubTabs({
id: "dream-diary",
active: activeDiarySubTab,
tabs: [
{ value: "dreams", label: t("dreaming.wiki.dreamsTab") },
{ value: "insights", label: t("dreaming.wiki.insightsTab") },
{ value: "palace", label: t("dreaming.wiki.palaceTab") },
],
ariaLabel: t("dreaming.diary.title"),
panelId: "dream-diary-panel",
variant: "sub",
onSelect: (tab) => {
resetWikiPreview(state);
state.activeDiarySubTab = tab;
state.diaryPage = 0;
props.onViewStateChange();
},
})}
<button
class="btn btn--subtle btn--sm"
?disabled=${memoryWikiUnavailable
@@ -1606,32 +1578,38 @@ function renderDiarySection(props: DreamingProps) {
${renderDiarySubtabExplainer(activeDiarySubTab)}
</div>
${memoryWikiUnavailable
? html`
<div class="dreams-diary__empty">
<div class="dreams-diary__empty-text">${t("dreaming.wiki.unavailable")}</div>
<div class="dreams-diary__empty-hint">
${t("dreaming.wiki.unavailablePluginPrefix")}
<code>memory-wiki</code> ${t("dreaming.wiki.unavailablePluginSuffix")}
<div
id="dream-diary-panel"
role="tabpanel"
aria-labelledby=${`dream-diary-tab-${activeDiarySubTab}`}
>
${memoryWikiUnavailable
? html`
<div class="dreams-diary__empty">
<div class="dreams-diary__empty-text">${t("dreaming.wiki.unavailable")}</div>
<div class="dreams-diary__empty-hint">
${t("dreaming.wiki.unavailablePluginPrefix")}
<code>memory-wiki</code> ${t("dreaming.wiki.unavailablePluginSuffix")}
</div>
<div class="dreams-diary__empty-hint">
${t("dreaming.wiki.enablePrefix")}
<code>plugins.entries.memory-wiki.enabled = true</code>${t(
"dreaming.wiki.enableSuffix",
)}
</div>
<div class="dreams-diary__empty-actions">
<button class="btn btn--subtle btn--sm" @click=${() => props.onOpenConfig()}>
${t("dreaming.wiki.openConfig")}
</button>
</div>
</div>
<div class="dreams-diary__empty-hint">
${t("dreaming.wiki.enablePrefix")}
<code>plugins.entries.memory-wiki.enabled = true</code>${t(
"dreaming.wiki.enableSuffix",
)}
</div>
<div class="dreams-diary__empty-actions">
<button class="btn btn--subtle btn--sm" @click=${() => props.onOpenConfig()}>
${t("dreaming.wiki.openConfig")}
</button>
</div>
</div>
`
: activeDiarySubTab === "dreams"
? renderDreamDiaryEntries(props)
: activeDiarySubTab === "insights"
? renderDiaryImportsSection(props)
: renderMemoryPalaceSection(props)}
`
: activeDiarySubTab === "dreams"
? renderDreamDiaryEntries(props)
: activeDiarySubTab === "insights"
? renderDiaryImportsSection(props)
: renderMemoryPalaceSection(props)}
</div>
${renderWikiPreviewOverlay(props)}
</section>
`;
+200 -194
View File
@@ -12,6 +12,7 @@ import type {
CronJob,
CronStatus,
} from "../../api/types.ts";
import { renderHubTabs } from "../../components/hub-tabs.ts";
import { icons } from "../../components/icons.ts";
import "../../components/modal-dialog.ts";
import type { OpenClawModalDialog } from "../../components/modal-dialog.ts";
@@ -461,28 +462,25 @@ export function renderAgentFiles(params: {
? renderSettingsEmpty(t("agents.files.empty"))
: html`
<div class="agents-panel-body">
<div class="agent-tabs">
${tabFiles.map((file) => {
const isActive = active === file.name;
const label = file.name.replace(/\.md$/i, "");
const isFault = file.missing && file.expectedAbsent !== true;
// File reads are serialized; changing the active tab mid-read would
// expose an editor whose content request was never accepted.
return html`
<button
class="agent-tab ${isActive ? "active" : ""} ${isFault
? "agent-tab--missing"
: ""}"
?disabled=${params.agentFilesLoading}
@click=${() => params.onSelectFile(file.name)}
>
${label}${isFault
? html`
<span class="agent-tab-badge">${t("agents.files.missing")}</span>
`
: nothing}
</button>
`;
<div class="agent-file-tabs">
${renderHubTabs({
id: "agent-files",
active,
tabs: tabFiles.map((file) => ({
value: file.name,
label: file.name.replace(/\.md$/i, ""),
badge:
file.missing && file.expectedAbsent !== true
? t("agents.files.missing")
: undefined,
// File reads are serialized; changing the active tab mid-read would
// expose an editor whose content request was never accepted.
disabled: params.agentFilesLoading,
})),
ariaLabel: t("agents.files.coreFilesTitle"),
panelId: "agent-file-panel",
variant: "sub",
onSelect: params.onSelectFile,
})}
${creatableFiles.length === 0
? nothing
@@ -511,184 +509,192 @@ export function renderAgentFiles(params: {
</select>
`}
</div>
${!activeEntry
? html`<div class="muted">${t("agents.files.selectFile")}</div>`
: html`
<div class="agent-file-header">
<div>
<div class="agent-file-sub mono">${activeEntry.path}</div>
<div
id="agent-file-panel"
role="tabpanel"
aria-labelledby=${active ? `agent-files-tab-${active}` : nothing}
>
${!activeEntry
? html`<div class="muted">${t("agents.files.selectFile")}</div>`
: html`
<div class="agent-file-header">
<div>
<div class="agent-file-sub mono">${activeEntry.path}</div>
</div>
<div class="agent-file-actions">
<button
class="btn btn--sm"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
btn
.closest(".settings-group")
?.querySelector<OpenClawModalDialog>("openclaw-modal-dialog")
?.show();
}}
>
${icons.eye} ${t("agents.files.preview")}
</button>
<button
class="btn btn--sm"
?disabled=${!isDirty}
@click=${() => params.onFileReset(activeEntry.name)}
>
${t("common.reset")}
</button>
<button
class="btn btn--sm primary"
?disabled=${params.agentFileSaving || !isDirty}
@click=${() => params.onFileSave(activeEntry.name)}
>
${params.agentFileSaving ? t("common.saving") : t("common.save")}
</button>
</div>
</div>
<div class="agent-file-actions">
<button
class="btn btn--sm"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
btn
.closest(".settings-group")
?.querySelector<OpenClawModalDialog>("openclaw-modal-dialog")
?.show();
}}
>
${icons.eye} ${t("agents.files.preview")}
</button>
<button
class="btn btn--sm"
?disabled=${!isDirty}
@click=${() => params.onFileReset(activeEntry.name)}
>
${t("common.reset")}
</button>
<button
class="btn btn--sm primary"
?disabled=${params.agentFileSaving || !isDirty}
@click=${() => params.onFileSave(activeEntry.name)}
>
${params.agentFileSaving ? t("common.saving") : t("common.save")}
</button>
</div>
</div>
${activeEntry.missing
? html`<div class="callout info">
${activeEntry.expectedAbsent === true
? t("agents.files.createHint")
: t("agents.files.missingHint")}
</div>`
: nothing}
<label class="field agent-file-field">
<span>${t("agents.files.content")}</span>
<textarea
class="agent-file-textarea"
.value=${draft}
@input=${(e: Event) =>
params.onFileDraftChange(
activeEntry.name,
(e.target as HTMLTextAreaElement).value,
)}
></textarea>
</label>
<openclaw-modal-dialog
manual
label=${activeEntry.name}
style="--openclaw-modal-width: min(1040px, calc(100vw - 32px));"
@modal-cancel=${(e: Event) => {
resetAgentFilePreview(e.currentTarget as HTMLElement);
}}
>
<div class="md-preview-dialog__panel">
<div class="md-preview-dialog__header">
<div class="md-preview-dialog__header-main">
<div class="md-preview-dialog__eyebrow">
${icons.scrollText}
<span>${getExtensionLabel(activeEntry.name)}</span>
</div>
<div class="md-preview-dialog__title-wrap">
<div
id=${previewTitleId}
class="md-preview-dialog__title"
translate="no"
>
${activeEntry.name}
${activeEntry.missing
? html`<div class="callout info">
${activeEntry.expectedAbsent === true
? t("agents.files.createHint")
: t("agents.files.missingHint")}
</div>`
: nothing}
<label class="field agent-file-field">
<span>${t("agents.files.content")}</span>
<textarea
class="agent-file-textarea"
.value=${draft}
@input=${(e: Event) =>
params.onFileDraftChange(
activeEntry.name,
(e.target as HTMLTextAreaElement).value,
)}
></textarea>
</label>
<openclaw-modal-dialog
manual
label=${activeEntry.name}
style="--openclaw-modal-width: min(1040px, calc(100vw - 32px));"
@modal-cancel=${(e: Event) => {
resetAgentFilePreview(e.currentTarget as HTMLElement);
}}
>
<div class="md-preview-dialog__panel">
<div class="md-preview-dialog__header">
<div class="md-preview-dialog__header-main">
<div class="md-preview-dialog__eyebrow">
${icons.scrollText}
<span>${getExtensionLabel(activeEntry.name)}</span>
</div>
<div class="md-preview-dialog__path mono" translate="no">
${activePathLabel}
</div>
</div>
</div>
<div class="md-preview-dialog__actions">
<openclaw-tooltip .content=${t("agents.files.expandPreview")}>
<button
type="button"
class="btn btn--sm md-preview-icon-btn md-preview-expand-btn"
aria-label=${t("agents.files.expandPreview")}
aria-pressed="false"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
const panel = btn.closest(".md-preview-dialog__panel");
if (!panel) {
return;
}
const isFullscreen = panel.classList.toggle("fullscreen");
btn
.closest("openclaw-modal-dialog")
?.classList.toggle("fullscreen", isFullscreen);
setPreviewExpandButtonState(btn, isFullscreen);
}}
>
<span class="when-normal" aria-hidden="true"
>${icons.maximize}</span
><span class="when-fullscreen" aria-hidden="true"
>${icons.minimize}</span
<div class="md-preview-dialog__title-wrap">
<div
id=${previewTitleId}
class="md-preview-dialog__title"
translate="no"
>
</button>
</openclaw-tooltip>
<openclaw-tooltip .content=${t("agents.files.editFile")}>
<button
type="button"
class="btn btn--sm md-preview-icon-btn"
aria-label=${t("agents.files.editFile")}
@click=${(e: Event) => {
const modal = (e.currentTarget as HTMLElement).closest(
"openclaw-modal-dialog",
) as OpenClawModalDialog | null;
modal?.hide();
if (modal) {
resetAgentFilePreview(modal);
}
const textarea =
document.querySelector<HTMLElement>(".agent-file-textarea");
textarea?.focus();
}}
${activeEntry.name}
</div>
<div class="md-preview-dialog__path mono" translate="no">
${activePathLabel}
</div>
</div>
</div>
<div class="md-preview-dialog__actions">
<openclaw-tooltip .content=${t("agents.files.expandPreview")}>
<button
type="button"
class="btn btn--sm md-preview-icon-btn md-preview-expand-btn"
aria-label=${t("agents.files.expandPreview")}
aria-pressed="false"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
const panel = btn.closest(".md-preview-dialog__panel");
if (!panel) {
return;
}
const isFullscreen = panel.classList.toggle("fullscreen");
btn
.closest("openclaw-modal-dialog")
?.classList.toggle("fullscreen", isFullscreen);
setPreviewExpandButtonState(btn, isFullscreen);
}}
>
<span class="when-normal" aria-hidden="true"
>${icons.maximize}</span
><span class="when-fullscreen" aria-hidden="true"
>${icons.minimize}</span
>
</button>
</openclaw-tooltip>
<openclaw-tooltip .content=${t("agents.files.editFile")}>
<button
type="button"
class="btn btn--sm md-preview-icon-btn"
aria-label=${t("agents.files.editFile")}
@click=${(e: Event) => {
const modal = (e.currentTarget as HTMLElement).closest(
"openclaw-modal-dialog",
) as OpenClawModalDialog | null;
modal?.hide();
if (modal) {
resetAgentFilePreview(modal);
}
const textarea =
document.querySelector<HTMLElement>(".agent-file-textarea");
textarea?.focus();
}}
>
<span aria-hidden="true">${icons.edit}</span>
</button>
</openclaw-tooltip>
<openclaw-tooltip .content=${t("agents.files.closePreview")}>
<button
type="button"
class="btn btn--sm md-preview-icon-btn"
aria-label=${t("agents.files.closePreview")}
@click=${(e: Event) => {
const modal = (e.currentTarget as HTMLElement).closest(
"openclaw-modal-dialog",
) as OpenClawModalDialog | null;
modal?.hide();
if (modal) {
resetAgentFilePreview(modal);
}
}}
>
<span aria-hidden="true">${icons.x}</span>
</button>
</openclaw-tooltip>
</div>
</div>
<div class="md-preview-dialog__meta">
<div class="md-preview-dialog__chip ${previewStatusClass}">
<strong>${previewStatusLabel}</strong>
</div>
<div class="md-preview-dialog__chip">
<strong>${estimateReadingTimeLabel(draftWordCount)}</strong>
<span
>${t("agents.files.words", {
count: String(draftWordCount),
})}</span
>
<span aria-hidden="true">${icons.edit}</span>
</button>
</openclaw-tooltip>
<openclaw-tooltip .content=${t("agents.files.closePreview")}>
<button
type="button"
class="btn btn--sm md-preview-icon-btn"
aria-label=${t("agents.files.closePreview")}
@click=${(e: Event) => {
const modal = (e.currentTarget as HTMLElement).closest(
"openclaw-modal-dialog",
) as OpenClawModalDialog | null;
modal?.hide();
if (modal) {
resetAgentFilePreview(modal);
}
}}
>
<span aria-hidden="true">${icons.x}</span>
</button>
</openclaw-tooltip>
</div>
<div class="md-preview-dialog__chip">
<strong>${draftLineCount}</strong>
<span>${t("agents.files.lines")}</span>
</div>
<div class="md-preview-dialog__chip">
<strong>${draftByteSize}</strong>
<span>${previewUpdatedLabel}</span>
</div>
</div>
<div class="md-preview-dialog__body">
<article class="md-preview-dialog__reader sidebar-markdown">
${unsafeHTML(previewHtml)}
</article>
</div>
</div>
<div class="md-preview-dialog__meta">
<div class="md-preview-dialog__chip ${previewStatusClass}">
<strong>${previewStatusLabel}</strong>
</div>
<div class="md-preview-dialog__chip">
<strong>${estimateReadingTimeLabel(draftWordCount)}</strong>
<span
>${t("agents.files.words", { count: String(draftWordCount) })}</span
>
</div>
<div class="md-preview-dialog__chip">
<strong>${draftLineCount}</strong>
<span>${t("agents.files.lines")}</span>
</div>
<div class="md-preview-dialog__chip">
<strong>${draftByteSize}</strong>
<span>${previewUpdatedLabel}</span>
</div>
</div>
<div class="md-preview-dialog__body">
<article class="md-preview-dialog__reader sidebar-markdown">
${unsafeHTML(previewHtml)}
</article>
</div>
</div>
</openclaw-modal-dialog>
`}
</openclaw-modal-dialog>
`}
</div>
</div>
`,
)}
+44 -20
View File
@@ -48,11 +48,11 @@ function directText(element: Element | null | undefined): string | undefined {
.trim();
}
function expectAgentTab(container: Element, text: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll<HTMLButtonElement>(".agent-tab")).find(
(candidate) => directText(candidate) === text,
);
if (!(button instanceof HTMLButtonElement)) {
function expectAgentTab(container: Element, text: string): HTMLElement & { disabled: boolean } {
const button = Array.from(
container.querySelectorAll<HTMLElement & { disabled: boolean }>("wa-tab.hub-tab"),
).find((candidate) => directText(candidate) === text);
if (!(button instanceof HTMLElement)) {
throw new Error(`Expected agent tab "${text}"`);
}
return button;
@@ -166,7 +166,7 @@ describe("renderAgents", () => {
render(renderAgents(createProps({ onOpenAgentDefaults })), container);
const defaultsRow = container.querySelector<HTMLButtonElement>(".settings-row--nav");
const tabs = container.querySelector(".agent-tabs");
const tabs = container.querySelector(".agents-hub-tabs");
expect(defaultsRow?.textContent).toContain("Agent defaults");
expect(defaultsRow?.textContent).toContain("Defaults every agent inherits unless overridden.");
expect(defaultsRow?.compareDocumentPosition(tabs!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
@@ -175,6 +175,18 @@ describe("renderAgents", () => {
expect(onOpenAgentDefaults).toHaveBeenCalledOnce();
});
it("renders the active agent tab and selects a different panel", () => {
const container = document.createElement("div");
const onSelectPanel = vi.fn();
render(renderAgents(createProps({ activePanel: "files", onSelectPanel })), container);
expect(container.querySelector("#agents-tab-files")?.hasAttribute("active")).toBe(true);
expectAgentTab(container, "Tools").dispatchEvent(
new MouseEvent("click", { detail: 1, bubbles: true }),
);
expect(onSelectPanel).toHaveBeenCalledWith("tools");
});
it("prefills the identity editor from the fetched agent identity", () => {
const container = document.createElement("div");
render(
@@ -197,7 +209,9 @@ describe("renderAgents", () => {
const container = document.createElement("div");
render(renderAgents(createProps({ activePanel: "memory" })), container);
const tabs = [...container.querySelectorAll(".agent-tab")].map((tab) => directText(tab));
const tabs = [...container.querySelectorAll(".agents-hub-tabs .hub-tab")].map((tab) =>
directText(tab),
);
expect(tabs.slice(-2)).toEqual([t("agents.tabs.cronJobs"), t("agents.tabs.memory")]);
const panel = container.querySelector<HTMLElement & { agentId: string }>(
"openclaw-agent-memory-panel",
@@ -536,7 +550,7 @@ describe("renderAgents", () => {
skillsTab = expectAgentTab(container, "Skills");
expect(directText(skillsTab)).toBe("Skills");
expect(skillsTab.querySelector(".agent-tab-count")?.textContent).toBe("1");
expect(skillsTab.querySelector(".hub-tab__badge--count")?.textContent).toBe("1");
});
it("localizes agent tabs and the channel refresh never state", async () => {
@@ -561,9 +575,9 @@ describe("renderAgents", () => {
);
await Promise.resolve();
const tabLabels = Array.from(container.querySelectorAll<HTMLButtonElement>(".agent-tab")).map(
(button) => button.textContent?.trim(),
);
const tabLabels = Array.from(
container.querySelectorAll<HTMLElement>(".agents-hub-tabs .hub-tab"),
).map((button) => button.textContent?.trim());
expect(tabLabels).toEqual([
"概览",
@@ -743,11 +757,13 @@ describe("renderAgentFiles", () => {
container,
);
const tabLabels = Array.from(container.querySelectorAll<HTMLButtonElement>(".agent-tab")).map(
(tab) => directText(tab),
);
const tabLabels = Array.from(
container.querySelectorAll<HTMLElement>(".agent-files-hub-tabs .hub-tab"),
).map((tab) => directText(tab));
expect(tabLabels).toStrictEqual(["AGENTS"]);
expect(container.querySelectorAll(".agent-tab--missing")).toHaveLength(1);
expect(container.querySelector(".agent-files-hub-tabs .hub-tab__badge")?.textContent).toBe(
"missing",
);
const picker = container.querySelector<HTMLSelectElement>(".agent-tab-add");
expect(picker).not.toBeNull();
@@ -770,6 +786,7 @@ describe("renderAgentFiles", () => {
it("shows the picked file as a tab with a create hint", () => {
const container = document.createElement("div");
const onSelectFile = vi.fn();
render(
renderAgentFiles({
@@ -794,7 +811,7 @@ describe("renderAgentFiles", () => {
agentFileDrafts: { "SOUL.md": "" },
agentFileSaving: false,
onLoadFiles: () => undefined,
onSelectFile: () => undefined,
onSelectFile,
onFileDraftChange: () => undefined,
onFileReset: () => undefined,
onFileSave: () => undefined,
@@ -802,12 +819,19 @@ describe("renderAgentFiles", () => {
container,
);
const tabLabels = Array.from(container.querySelectorAll<HTMLButtonElement>(".agent-tab")).map(
(tab) => directText(tab),
);
const tabLabels = Array.from(
container.querySelectorAll<HTMLElement>(".agent-files-hub-tabs .hub-tab"),
).map((tab) => directText(tab));
expect(tabLabels).toStrictEqual(["AGENTS", "SOUL"]);
expect(container.querySelector(".agent-tab-add")).toBeNull();
expect(container.querySelectorAll(".agent-tab--missing")).toHaveLength(0);
expect(container.querySelectorAll(".agent-files-hub-tabs .hub-tab__badge")).toHaveLength(0);
expect(container.querySelector('[id="agent-files-tab-SOUL.md"]')?.hasAttribute("active")).toBe(
true,
);
container
.querySelector('[id="agent-files-tab-AGENTS.md"]')
?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
expect(onSelectFile).toHaveBeenCalledWith("AGENTS.md");
expect(container.querySelector(".callout.info")?.textContent?.trim()).toBe(
"This file does not exist yet. Saving will create it in the agent workspace.",
);
+157 -155
View File
@@ -14,6 +14,7 @@ import type {
ToolsCatalogResult,
ToolsEffectiveResult,
} from "../../api/types.ts";
import { renderHubTabs } from "../../components/hub-tabs.ts";
import {
renderSettingsEmpty,
renderSettingsNavRow,
@@ -254,151 +255,157 @@ export function renderAgents(props: AgentsProps) {
(panel) => props.onSelectPanel(panel),
tabCounts,
)}
${props.activePanel === "overview"
? keyed(
selectedAgent.id,
renderAgentOverview({
agent: selectedAgent,
basePath: props.basePath,
defaultId,
configForm: props.config.form,
<div
id="agent-panel"
role="tabpanel"
aria-labelledby=${`agents-tab-${props.activePanel}`}
>
${props.activePanel === "overview"
? keyed(
selectedAgent.id,
renderAgentOverview({
agent: selectedAgent,
basePath: props.basePath,
defaultId,
configForm: props.config.form,
agentFilesList: props.agentFiles.list,
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
agentIdentityError: props.agentIdentityError,
agentIdentityLoading: props.agentIdentityLoading,
identityDraft: props.identityDraft,
identitySaving: props.identitySaving,
identityError: props.identityError,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
modelCatalog: props.modelCatalog,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
onIdentityFieldChange: props.onIdentityFieldChange,
onIdentityAvatarSelect: props.onIdentityAvatarSelect,
onIdentitySave: props.onIdentitySave,
onModelChange: props.onModelChange,
onModelFallbacksChange: props.onModelFallbacksChange,
onSelectPanel: props.onSelectPanel,
}),
)
: nothing}
${props.activePanel === "files"
? renderAgentFiles({
agentId: selectedAgent.id,
agentFilesList: props.agentFiles.list,
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
agentIdentityError: props.agentIdentityError,
agentIdentityLoading: props.agentIdentityLoading,
identityDraft: props.identityDraft,
identitySaving: props.identitySaving,
identityError: props.identityError,
agentFilesLoading: props.agentFiles.loading,
agentFilesError: props.agentFiles.error,
agentFileActive: props.agentFiles.active,
agentFileContents: props.agentFiles.contents,
agentFileDrafts: props.agentFiles.drafts,
agentFileSaving: props.agentFiles.saving,
onLoadFiles: props.onLoadFiles,
onSelectFile: props.onSelectFile,
onFileDraftChange: props.onFileDraftChange,
onFileReset: props.onFileReset,
onFileSave: props.onFileSave,
})
: nothing}
${props.activePanel === "tools"
? renderAgentTools({
agentId: selectedAgent.id,
configForm: props.config.form,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
modelCatalog: props.modelCatalog,
toolsCatalogLoading: props.toolsCatalog.loading,
toolsCatalogError: props.toolsCatalog.error,
toolsCatalogResult: props.toolsCatalog.result,
toolsEffectiveLoading: props.toolsEffective.loading,
toolsEffectiveError: props.toolsEffective.error,
toolsEffectiveResult: props.toolsEffective.result,
runtimeSessionKey: props.runtimeSessionKey,
runtimeSessionMatchesSelectedAgent: props.runtimeSessionMatchesSelectedAgent,
onProfileChange: props.onToolsProfileChange,
onOverridesChange: props.onToolsOverridesChange,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
onIdentityFieldChange: props.onIdentityFieldChange,
onIdentityAvatarSelect: props.onIdentityAvatarSelect,
onIdentitySave: props.onIdentitySave,
onModelChange: props.onModelChange,
onModelFallbacksChange: props.onModelFallbacksChange,
})
: nothing}
${props.activePanel === "skills"
? renderAgentSkills({
agentId: selectedAgent.id,
report: props.agentSkills.report,
loading: props.agentSkills.loading,
error: props.agentSkills.error,
activeAgentId: props.agentSkills.agentId,
configForm: props.config.form,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
filter: props.agentSkills.filter,
onFilterChange: props.onSkillsFilterChange,
onRefresh: props.onSkillsRefresh,
onToggle: props.onAgentSkillToggle,
onClear: props.onAgentSkillsClear,
onDisableAll: props.onAgentSkillsDisableAll,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
})
: nothing}
${props.activePanel === "channels"
? renderAgentChannels({
context: buildAgentContext(
selectedAgent,
props.config.form,
props.agentFiles.list,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
),
configForm: props.config.form,
snapshot: props.channels.snapshot,
loading: props.channels.loading,
error: props.channels.error,
lastSuccess: props.channels.lastSuccess,
onRefresh: props.onChannelsRefresh,
onSelectPanel: props.onSelectPanel,
}),
)
: nothing}
${props.activePanel === "files"
? renderAgentFiles({
agentId: selectedAgent.id,
agentFilesList: props.agentFiles.list,
agentFilesLoading: props.agentFiles.loading,
agentFilesError: props.agentFiles.error,
agentFileActive: props.agentFiles.active,
agentFileContents: props.agentFiles.contents,
agentFileDrafts: props.agentFiles.drafts,
agentFileSaving: props.agentFiles.saving,
onLoadFiles: props.onLoadFiles,
onSelectFile: props.onSelectFile,
onFileDraftChange: props.onFileDraftChange,
onFileReset: props.onFileReset,
onFileSave: props.onFileSave,
})
: nothing}
${props.activePanel === "tools"
? renderAgentTools({
agentId: selectedAgent.id,
configForm: props.config.form,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
toolsCatalogLoading: props.toolsCatalog.loading,
toolsCatalogError: props.toolsCatalog.error,
toolsCatalogResult: props.toolsCatalog.result,
toolsEffectiveLoading: props.toolsEffective.loading,
toolsEffectiveError: props.toolsEffective.error,
toolsEffectiveResult: props.toolsEffective.result,
runtimeSessionKey: props.runtimeSessionKey,
runtimeSessionMatchesSelectedAgent: props.runtimeSessionMatchesSelectedAgent,
onProfileChange: props.onToolsProfileChange,
onOverridesChange: props.onToolsOverridesChange,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
})
: nothing}
${props.activePanel === "skills"
? renderAgentSkills({
agentId: selectedAgent.id,
report: props.agentSkills.report,
loading: props.agentSkills.loading,
error: props.agentSkills.error,
activeAgentId: props.agentSkills.agentId,
configForm: props.config.form,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
filter: props.agentSkills.filter,
onFilterChange: props.onSkillsFilterChange,
onRefresh: props.onSkillsRefresh,
onToggle: props.onAgentSkillToggle,
onClear: props.onAgentSkillsClear,
onDisableAll: props.onAgentSkillsDisableAll,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
})
: nothing}
${props.activePanel === "channels"
? renderAgentChannels({
context: buildAgentContext(
selectedAgent,
props.config.form,
props.agentFiles.list,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
),
configForm: props.config.form,
snapshot: props.channels.snapshot,
loading: props.channels.loading,
error: props.channels.error,
lastSuccess: props.channels.lastSuccess,
onRefresh: props.onChannelsRefresh,
onSelectPanel: props.onSelectPanel,
})
: nothing}
${props.activePanel === "cron"
? renderAgentCron({
context: buildAgentContext(
selectedAgent,
props.config.form,
props.agentFiles.list,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
),
agentId: selectedAgent.id,
jobs: props.cron.jobs,
status: props.cron.status,
loading: props.cron.loading,
error: props.cron.error,
onRefresh: props.onCronRefresh,
onRunNow: props.onCronRunNow,
onSelectPanel: props.onSelectPanel,
})
: nothing}
${props.activePanel === "memory"
? html`
<div class="settings-group agent-memory-import-row">
${renderSettingsNavRow({
title: t("tabs.memory"),
description: t("subtitles.memory"),
onClick: () => props.onOpenMemorySettings?.(),
})}
${renderSettingsNavRow({
title: t("tabs.memoryImport"),
description: t("subtitles.memoryImport"),
onClick: () => props.onOpenMemoryImport?.(),
})}
</div>
<openclaw-agent-memory-panel
.agentId=${selectedAgent.id}
></openclaw-agent-memory-panel>
`
: nothing}
})
: nothing}
${props.activePanel === "cron"
? renderAgentCron({
context: buildAgentContext(
selectedAgent,
props.config.form,
props.agentFiles.list,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
),
agentId: selectedAgent.id,
jobs: props.cron.jobs,
status: props.cron.status,
loading: props.cron.loading,
error: props.cron.error,
onRefresh: props.onCronRefresh,
onRunNow: props.onCronRunNow,
onSelectPanel: props.onSelectPanel,
})
: nothing}
${props.activePanel === "memory"
? html`
<div class="settings-group agent-memory-import-row">
${renderSettingsNavRow({
title: t("tabs.memory"),
description: t("subtitles.memory"),
onClick: () => props.onOpenMemorySettings?.(),
})}
${renderSettingsNavRow({
title: t("tabs.memoryImport"),
description: t("subtitles.memoryImport"),
onClick: () => props.onOpenMemoryImport?.(),
})}
</div>
<openclaw-agent-memory-panel
.agentId=${selectedAgent.id}
></openclaw-agent-memory-panel>
`
: nothing}
</div>
`}
</section>
</div>
@@ -419,21 +426,16 @@ function renderAgentTabs(
{ id: "cron", label: t("agents.tabs.cronJobs") },
{ id: "memory", label: t("agents.tabs.memory") },
];
return html`
<div class="agent-tabs">
${tabs.map(
(tab) => html`
<button
class="agent-tab ${active === tab.id ? "active" : ""}"
type="button"
@click=${() => onSelect(tab.id)}
>
${tab.label}${counts[tab.id] != null
? html`<span class="agent-tab-count">${counts[tab.id]}</span>`
: nothing}
</button>
`,
)}
</div>
`;
return renderHubTabs({
id: "agents",
active,
tabs: tabs.map((tab) => ({
value: tab.id,
label: tab.label,
count: counts[tab.id],
})),
ariaLabel: t("tabs.agents"),
panelId: "agent-panel",
onSelect,
});
}
+17 -30
View File
@@ -1,43 +1,30 @@
import { html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { renderHubTabs } from "../../components/hub-tabs.ts";
import { renderSettingsSegmented } from "../../components/settings-ui.ts";
import "../../components/web-awesome-tabs.ts";
export function renderSegmented<T extends string>(params: {
value: T;
options: ReadonlyArray<{ value: T; label: string; testId?: string }>;
ariaLabel?: string;
onChange: (value: T) => void;
/** Render as a tablist controlling `panelId`; option ids are `idPrefix` + value. */
tabs?: { idPrefix: string; panelId: string };
/** Render navigation as shared hub tabs instead of a value picker. */
tabs?: { id: string; panelId: string; variant?: "primary" | "sub" };
}) {
const tabs = params.tabs;
if (tabs) {
return html`
<wa-tab-group
class="settings-segmented cron-tabs"
activation="manual"
.active=${params.value}
aria-label=${ifDefined(params.ariaLabel)}
@wa-tab-show=${(event: CustomEvent<{ name: T }>) => params.onChange(event.detail.name)}
>
${params.options.map(
(option) => html`
<wa-tab
slot="nav"
id=${`${tabs.idPrefix}${option.value}`}
class="settings-segmented__btn cron-tab"
panel=${option.value}
.active=${option.value === params.value}
aria-controls=${tabs.panelId}
data-test-id=${ifDefined(option.testId)}
>
${option.label}
</wa-tab>
`,
)}
</wa-tab-group>
`;
return renderHubTabs({
id: tabs.id,
active: params.value,
tabs: params.options.map((option) => ({
value: option.value,
label: option.label,
testId: option.testId,
})),
ariaLabel: params.ariaLabel ?? "",
panelId: tabs.panelId,
className: "cron-tabs",
variant: tabs.variant,
onSelect: params.onChange,
});
}
return renderSettingsSegmented({
value: params.value,
+13 -15
View File
@@ -383,10 +383,8 @@ describe("cron view list pane", () => {
expect(tasks.querySelector(".cron-table")).not.toBeNull();
expect(tasks.querySelector(".cron-activity")).toBeNull();
tasks
.querySelector("wa-tab-group")
?.dispatchEvent(
new CustomEvent("wa-tab-show", { detail: { name: "activity" }, bubbles: true }),
);
.querySelector('[data-test-id="cron-list-tab-activity"]')
?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
expect(onListTabChange).toHaveBeenCalledWith("activity");
const activity = renderView({ listTab: "activity" });
@@ -394,20 +392,18 @@ describe("cron view list pane", () => {
expect(activity.querySelector(".cron-activity")).not.toBeNull();
});
it("configures manual Web Awesome list tabs", () => {
it("renders shared manual list tabs with active state and selection", () => {
const onListTabChange = vi.fn();
const container = renderView({ onListTabChange });
document.body.append(container);
const group = getElement(container, ".cron-toolbar > wa-tab-group", HTMLElement);
const group = getElement(container, ".cron-list-hub-tabs", HTMLElement);
const tasks = getElement(container, '[data-test-id="cron-list-tab-tasks"]', HTMLElement);
const activity = getElement(container, '[data-test-id="cron-list-tab-activity"]', HTMLElement);
expect(group.getAttribute("activation")).toBe("manual");
expect((tasks as HTMLElement & { active: boolean }).active).toBe(true);
expect((activity as HTMLElement & { active: boolean }).active).toBe(false);
group.dispatchEvent(
new CustomEvent("wa-tab-show", { detail: { name: "activity" }, bubbles: true }),
);
expect(tasks.getAttribute("aria-selected")).toBe("true");
expect(activity.getAttribute("aria-selected")).toBe("false");
activity.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
expect(onListTabChange).toHaveBeenCalledWith("activity");
expect(activity.getAttribute("aria-controls")).toBe("cron-list-panel");
@@ -983,12 +979,14 @@ describe("cron view editor", () => {
}
expect(onRemove).toHaveBeenCalledWith(job);
expect(
container
.querySelector('[data-test-id="cron-detail-tab-settings"]')
?.getAttribute("aria-selected"),
).toBe("true");
container
.querySelector('[data-test-id="cron-detail-tab-history"]')
?.closest("wa-tab-group")
?.dispatchEvent(
new CustomEvent("wa-tab-show", { detail: { name: "history" }, bubbles: true }),
);
?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
expect(onDetailTabChange).toHaveBeenCalledWith("history");
});
+2 -2
View File
@@ -447,7 +447,7 @@ function renderListTabs(props: CronProps) {
{ value: "activity", label: t("cron.list.activityTab"), testId: "cron-list-tab-activity" },
],
ariaLabel: t("cron.list.viewLabel"),
tabs: { idPrefix: "cron-list-tab-", panelId: "cron-list-panel" },
tabs: { id: "cron-list", panelId: "cron-list-panel" },
onChange: props.onListTabChange,
});
}
@@ -972,7 +972,7 @@ function renderDetailTabs(props: CronProps) {
{ value: "history", label: t("cron.detail.historyTitle"), testId: "cron-detail-tab-history" },
],
ariaLabel: t("cron.detail.tabsLabel"),
tabs: { idPrefix: "cron-detail-tab-", panelId: "cron-detail-panel" },
tabs: { id: "cron-detail", panelId: "cron-detail-panel", variant: "sub" },
onChange: props.onDetailTabChange,
});
}
+17
View File
@@ -0,0 +1,17 @@
import type { HubTabOption } from "../../components/hub-tabs.ts";
import { t } from "../../i18n/index.ts";
export type PluginsHubTab = "installed" | "discover" | "skills" | "workshop";
export const PLUGINS_HUB_PANEL_ID = "plugins-hub-panel";
export function pluginsHubTabs(
installedCount: number | null = null,
): ReadonlyArray<HubTabOption<PluginsHubTab>> {
return [
{ value: "installed", label: t("pluginsPage.installedTab"), count: installedCount },
{ value: "discover", label: t("pluginsPage.discoverTab") },
{ value: "skills", label: t("tabs.skills") },
{ value: "workshop", label: t("pluginsPage.workshopTab") },
];
}
+10 -3
View File
@@ -13,8 +13,8 @@ import {
} from "../../app/context.ts";
import { resolveControlUiAuthCandidates } from "../../app/control-ui-auth.ts";
import { hasOperatorAdminAccess } from "../../app/operator-access.ts";
import { renderHubTabs } from "../../components/hub-tabs.ts";
import type { McpServerForm } from "../../components/mcp-server-form.ts";
import { renderPluginsHubTabs, type PluginsHubTab } from "../../components/plugins-hub-tabs.ts";
import { renderDocsLink } from "../../components/settings-ui.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
@@ -45,6 +45,7 @@ import {
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { fetchPluginIconBlobUrl } from "./icon-loader.ts";
import { PLUGINS_HUB_PANEL_ID, pluginsHubTabs, type PluginsHubTab } from "./plugins-hub.ts";
import type { ConnectorSuggestion } from "./presentation.ts";
import { pluginArtPath } from "./presentation.ts";
import { canonicalPluginsRouteLocation, pluginsHubTabForRoute } from "./route-data.ts";
@@ -980,9 +981,15 @@ class PluginsPage extends OpenClawLightDomElement {
</section>
${renderSettingsWorkspace(html`
<div class="plugins-hub-tabs-row">
${renderPluginsHubTabs({
${renderHubTabs({
id: "plugins",
active: this.activeTab,
installedCount: this.result?.plugins.filter((plugin) => plugin.installed).length ?? 0,
tabs: pluginsHubTabs(
this.result?.plugins.filter((plugin) => plugin.installed).length ?? 0,
),
ariaLabel: t("pluginsPage.hubTablistLabel"),
panelId: PLUGINS_HUB_PANEL_ID,
className: "plugins-tabs",
onSelect: (tab) => this.selectHubTab(tab),
})}
</div>
@@ -0,0 +1,32 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderSkillWorkshopHeaderControls } from "./header-controls.ts";
import { createSkillWorkshopState } from "./proposals.ts";
describe("skill workshop header tabs", () => {
it("renders the active mode and selects a different view", () => {
const state = createSkillWorkshopState();
const requestUpdate = vi.fn();
const container = document.createElement("div");
render(
renderSkillWorkshopHeaderControls(
state,
{ selfLearning: null, onSelfLearningToggle: () => undefined },
requestUpdate,
),
container,
);
expect(container.querySelector("#skill-workshop-mode-tab-today")?.hasAttribute("active")).toBe(
true,
);
container
.querySelector("#skill-workshop-mode-tab-board")
?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
expect(state.skillWorkshopMode).toBe("board");
expect(requestUpdate).toHaveBeenCalledOnce();
});
});
+34 -42
View File
@@ -1,7 +1,7 @@
// Workshop page header: self-learning toggle, revision-session toggle, and
// the board/today view switch.
import { html } from "lit";
import "../../components/web-awesome-tabs.ts";
import { renderHubTabs } from "../../components/hub-tabs.ts";
import { t } from "../../i18n/index.ts";
import type { SkillWorkshopState } from "./proposals.ts";
import { renderSelfLearningToggle, type SkillWorkshopSelfLearning } from "./self-learning.ts";
@@ -65,47 +65,39 @@ export function renderSkillWorkshopHeaderControls(
<span class="sw-revision-session-toggle__track" aria-hidden="true"></span>
<span class="sw-revision-session-toggle__label">${useCurrentChatLabel}</span>
</label>
<wa-tab-group
class="sw-mode-switch"
aria-label=${t("skillWorkshop.header.view")}
data-mode=${state.skillWorkshopMode}
.active=${state.skillWorkshopMode}
activation="auto"
without-scroll-controls
@wa-tab-show=${(event: CustomEvent<{ name: string }>) => {
if (event.detail.name === "board" || event.detail.name === "today") {
setSkillWorkshopMode(state, event.detail.name, requestUpdate);
}
}}
>
<wa-tab
id="skill-workshop-mode-tab-board"
class="sw-mode-switch__opt"
panel="board"
aria-controls="skill-workshop-mode-panel"
>
<svg viewBox="0 0 24 24" class="sw-mode-switch__icon" aria-hidden="true">
<rect x="3" y="4" width="7" height="16" rx="1.5" />
<rect x="14" y="4" width="7" height="9" rx="1.5" />
<rect x="14" y="15" width="7" height="5" rx="1.5" />
</svg>
<span>${t("skillWorkshop.header.board")}</span>
</wa-tab>
<wa-tab
id="skill-workshop-mode-tab-today"
class="sw-mode-switch__opt"
panel="today"
aria-controls="skill-workshop-mode-panel"
>
<svg viewBox="0 0 24 24" class="sw-mode-switch__icon" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4"
/>
</svg>
<span>${t("skillWorkshop.header.today")}</span>
</wa-tab>
</wa-tab-group>
${renderHubTabs({
id: "skill-workshop-mode",
active: state.skillWorkshopMode,
tabs: [
{
value: "board",
label: html`
<svg viewBox="0 0 24 24" class="sw-mode-tabs__icon" aria-hidden="true">
<rect x="3" y="4" width="7" height="16" rx="1.5" />
<rect x="14" y="4" width="7" height="9" rx="1.5" />
<rect x="14" y="15" width="7" height="5" rx="1.5" />
</svg>
<span>${t("skillWorkshop.header.board")}</span>
`,
},
{
value: "today",
label: html`
<svg viewBox="0 0 24 24" class="sw-mode-tabs__icon" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4"
/>
</svg>
<span>${t("skillWorkshop.header.today")}</span>
`,
},
],
ariaLabel: t("skillWorkshop.header.view"),
panelId: "skill-workshop-mode-panel",
variant: "sub",
onSelect: (mode) => setSkillWorkshopMode(state, mode, requestUpdate),
})}
</div>
`;
}
@@ -1,6 +1,6 @@
import { pathForPluginsHubTab } from "../../app-route-paths.ts";
import type { ApplicationContext } from "../../app/context.ts";
import type { PluginsHubTab } from "../../components/plugins-hub-tabs.ts";
import type { PluginsHubTab } from "../plugins/plugins-hub.ts";
export function selectPluginsHubTab(
context: Pick<ApplicationContext, "basePath" | "navigate">,
@@ -6,7 +6,7 @@ import { property } from "lit/decorators.js";
import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
import { applicationContext, type ApplicationGatewaySnapshot } from "../../app/context.ts";
import { loadSettings } from "../../app/settings.ts";
import { renderPluginsHubTabs } from "../../components/plugins-hub-tabs.ts";
import { renderHubTabs } from "../../components/hub-tabs.ts";
import "../../components/tooltip.ts";
import { t } from "../../i18n/index.ts";
import { resolveSessionKey } from "../../lib/sessions/index.ts";
@@ -15,6 +15,7 @@ import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
import { filterSkillWorkshopProposals } from "../../lib/skill-workshop/index.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { PLUGINS_HUB_PANEL_ID, pluginsHubTabs } from "../plugins/plugins-hub.ts";
import { renderSkillWorkshopHeaderControls, setSkillWorkshopMode } from "./header-controls.ts";
import {
loadSkillWorkshopPageData,
@@ -128,13 +129,18 @@ function renderSkillWorkshopPage(
</div>
</section>
<div class="plugins-hub-tabs-row">
${renderPluginsHubTabs({
${renderHubTabs({
id: "plugins",
active: "workshop",
tabs: pluginsHubTabs(),
ariaLabel: t("pluginsPage.hubTablistLabel"),
panelId: PLUGINS_HUB_PANEL_ID,
className: "plugins-tabs",
onSelect: (tab) => selectPluginsHubTab(context, tab),
})}
</div>
<wa-tab-panel
id="plugins-hub-panel"
id=${PLUGINS_HUB_PANEL_ID}
class="sw-hub-panel"
name="workshop"
active
+17 -3
View File
@@ -11,8 +11,9 @@ import {
type ApplicationContext,
type ApplicationGatewaySnapshot,
} from "../../app/context.ts";
import { renderPluginsHubTabs, type PluginsHubTab } from "../../components/plugins-hub-tabs.ts";
import { renderHubTabs } from "../../components/hub-tabs.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { t } from "../../i18n/index.ts";
import {
closeClawHubDetail,
installFromClawHub,
@@ -35,6 +36,11 @@ import {
} from "../../lib/skills/index.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import {
PLUGINS_HUB_PANEL_ID,
pluginsHubTabs,
type PluginsHubTab,
} from "../plugins/plugins-hub.ts";
import { renderSkills, type SkillDetailTab, type SkillsStatusFilter } from "./view.ts";
export type SkillsRouteData = {
@@ -394,10 +400,18 @@ class SkillsPage extends OpenClawLightDomElement {
</section>
${renderSettingsWorkspace(html`
<div class="plugins-hub-tabs-row">
${renderPluginsHubTabs({ active: "skills", onSelect: (tab) => this.selectHubTab(tab) })}
${renderHubTabs({
id: "plugins",
active: "skills",
tabs: pluginsHubTabs(),
ariaLabel: t("pluginsPage.hubTablistLabel"),
panelId: PLUGINS_HUB_PANEL_ID,
className: "plugins-tabs",
onSelect: (tab) => this.selectHubTab(tab),
})}
</div>
<wa-tab-panel
id="plugins-hub-panel"
id=${PLUGINS_HUB_PANEL_ID}
name="skills"
active
aria-labelledby="plugins-tab-skills"
+10
View File
@@ -782,12 +782,14 @@ describe("renderSkills", () => {
skills: [linkedSkill],
};
const verdictKey = "https://clawhub.ai\u0000agentreceipt\u00001.2.3";
const onDetailTabChange = vi.fn();
render(
renderSkills(
createProps({
report,
detailKey: "agentreceipt",
onDetailTabChange,
clawhubVerdicts: {
[verdictKey]: {
registry: "https://clawhub.ai",
@@ -815,6 +817,13 @@ describe("renderSkills", () => {
expect(
container.querySelector<HTMLAnchorElement>('a[href*="security-audit"]')?.textContent?.trim(),
).toBe("Full security report");
expect(container.querySelector("#skill-detail-tab-overview")?.hasAttribute("active")).toBe(
true,
);
container
.querySelector("#skill-detail-tab-card")
?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true }));
expect(onDetailTabChange).toHaveBeenCalledWith("card");
render(
renderSkills(
@@ -845,6 +854,7 @@ describe("renderSkills", () => {
);
await Promise.resolve();
expect(container.querySelector("#skill-detail-tab-card")?.hasAttribute("active")).toBe(true);
expect(container.querySelector(".sidebar-markdown strong")?.textContent).toBe("trust");
expect(normalizeText(container)).toContain("AgentReceipt Local trust card.");
});
+26 -19
View File
@@ -8,6 +8,7 @@ import { repeat } from "lit/directives/repeat.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { AgentsListResult, SkillStatusEntry, SkillStatusReport } from "../../api/types.ts";
import "../../components/agent-select-registration.ts";
import { renderHubTabs } from "../../components/hub-tabs.ts";
import { icons } from "../../components/icons.ts";
import { toSanitizedMarkdownHtml } from "../../components/markdown.ts";
import "../../components/modal-dialog.ts";
@@ -649,27 +650,33 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
${skill.clawhub || skill.skillCard?.present
? html`
<div class="agent-tabs">
<button
class="agent-tab ${detailTab === "overview" ? "active" : ""}"
@click=${() => props.onDetailTabChange("overview")}
>
${t("skillsPage.overview")}
</button>
${skill.skillCard?.present
? html`<button
class="agent-tab ${detailTab === "card" ? "active" : ""}"
@click=${() => props.onDetailTabChange("card")}
>
${t("skillsPage.skillCard")}
</button>`
: nothing}
</div>
${renderHubTabs({
id: "skill-detail",
active: detailTab,
tabs: [
{ value: "overview", label: t("skillsPage.overview") },
...(skill.skillCard?.present
? [{ value: "card" as const, label: t("skillsPage.skillCard") }]
: []),
],
ariaLabel: skill.name,
panelId: "skill-detail-panel",
variant: "sub",
onSelect: props.onDetailTabChange,
})}
`
: nothing}
${detailTab === "overview"
? renderInstalledClawHubOverview(skill, props, verdict)
: renderInstalledSkillCard(skill, props)}
<div
id="skill-detail-panel"
role=${skill.clawhub || skill.skillCard?.present ? "tabpanel" : nothing}
aria-labelledby=${skill.clawhub || skill.skillCard?.present
? `skill-detail-tab-${detailTab}`
: nothing}
>
${detailTab === "overview"
? renderInstalledClawHubOverview(skill, props, verdict)
: renderInstalledSkillCard(skill, props)}
</div>
${missing.length > 0
? html`
<div
+3 -50
View File
@@ -3685,48 +3685,10 @@ td.data-table-key-col {
color: var(--muted);
}
.agent-tabs {
.agent-file-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
padding-bottom: 2px;
border-bottom: 1px solid var(--border);
}
.agent-tab {
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: var(--muted);
background: transparent;
transition:
border-color var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.agent-tab:hover {
color: var(--text);
background: var(--bg-hover);
}
.agent-tab.active {
background: var(--accent-subtle);
border-color: color-mix(in srgb, var(--accent) 25%, transparent);
color: var(--accent);
}
.agent-tab-count {
margin-left: 4px;
font-size: 12px; /* was 10px */
font-weight: 700;
opacity: 0.7;
}
.agent-tab--missing {
opacity: 0.5;
align-items: flex-end;
gap: var(--space-2);
}
.agent-tab-add {
@@ -3744,15 +3706,6 @@ td.data-table-key-col {
border-color: var(--accent);
}
.agent-tab-badge {
margin-left: 4px;
font-size: 12px; /* was 9px */
font-weight: 700;
text-transform: uppercase;
color: var(--warn);
opacity: 0.8;
}
.agent-identity-editor {
display: flex;
align-items: flex-start;
+7 -52
View File
@@ -14,39 +14,17 @@
justify-content: flex-end;
}
/* ---- Sub-tab bar ---- */
.dreams__tabs {
display: flex;
align-items: center;
gap: 2px;
.dreams__topbar {
padding: 6px 8px;
flex-shrink: 0;
}
.dreams__tab {
padding: 4px 14px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--muted);
font-family: inherit;
font-size: 12px; /* was 11px */
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
transition:
color 140ms ease,
background 140ms ease;
}
.dreams__tab:hover {
color: var(--text);
}
.dreams__tab--active {
color: var(--text);
background: color-mix(in oklab, var(--panel) 80%, transparent);
.dreams__panel {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.dreams {
@@ -758,29 +736,6 @@
flex: 1;
}
.dreams-diary__subtabs {
display: inline-flex;
gap: 6px;
padding: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--panel) 82%, transparent);
border: 1px solid color-mix(in oklab, var(--border) 72%, transparent);
}
.dreams-diary__subtab {
border: 0;
background: transparent;
color: var(--muted);
border-radius: 999px;
padding: 5px 10px;
font-size: 12px; /* was 11px */
}
.dreams-diary__subtab--active {
color: var(--text);
background: color-mix(in oklab, var(--accent-subtle) 88%, transparent);
}
.dreams-diary__explainer {
width: min(100%, 920px);
margin: 0 0 16px;
+31
View File
@@ -13,6 +13,10 @@ wa-tab-group.hub-tabs::part(tabs) {
gap: var(--space-2);
}
wa-tab-group.hub-tabs--sub::part(tabs) {
gap: var(--space-1);
}
wa-tab.hub-tab {
font-size: var(--control-ui-text-sm);
font-weight: 550;
@@ -28,6 +32,33 @@ wa-tab.hub-tab::part(base) {
color: inherit;
}
.hub-tabs--sub wa-tab.hub-tab {
font-size: var(--control-ui-text-xs);
font-weight: 550;
}
.hub-tabs--sub wa-tab.hub-tab::part(base) {
padding: 3px var(--space-2) var(--space-1);
}
.hub-tab__badge {
display: inline-flex;
align-items: center;
min-height: 16px;
padding: 0 5px;
border-radius: var(--radius-full);
background: var(--bg-hover);
color: var(--muted);
font-size: 10px;
font-weight: 700;
line-height: 1;
}
wa-tab.hub-tab[active] .hub-tab__badge {
background: var(--accent-subtle);
color: var(--accent);
}
wa-tab.hub-tab:hover {
color: var(--text);
}
+1 -48
View File
@@ -1165,54 +1165,7 @@
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
.sw-mode-switch {
--track-width: 0;
--indicator-color: var(--accent);
display: inline-block;
}
.sw-mode-switch::part(nav) {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px;
}
.sw-mode-switch::part(body) {
display: none;
}
.sw-mode-switch__opt::part(base) {
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: none;
padding: 6px 14px;
border-radius: 999px;
font: inherit;
font-size: 12.5px;
font-weight: 500;
color: var(--muted);
transition: color 160ms ease;
white-space: nowrap;
}
.sw-mode-switch__opt:hover::part(base) {
color: var(--text);
}
.sw-mode-switch__opt[active]::part(base) {
color: var(--text-strong);
font-weight: 600;
}
.sw-mode-switch__opt:focus-visible::part(base) {
outline: none;
box-shadow: none;
}
.sw-mode-switch__icon {
.sw-mode-tabs__icon {
width: 14px;
height: 14px;
stroke: currentColor;