mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
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:
committed by
GitHub
parent
e0c384b771
commit
df3674f577
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
})}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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") },
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user