diff --git a/ui/src/components/hub-tabs.test.ts b/ui/src/components/hub-tabs.test.ts new file mode 100644 index 000000000000..707f56f787c0 --- /dev/null +++ b/ui/src/components/hub-tabs.test.ts @@ -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("wa-tab-group"); + expect(group?.active).not.toBe(""); + expect(group?.active).not.toBe("first"); + expect(container.querySelector("wa-tab[active]")).toBeNull(); + expect(container.querySelector("#example-tab-first")?.tabIndex).toBe(0); + expect(container.querySelector("#example-tab-second")?.tabIndex).toBe(-1); + }); +}); diff --git a/ui/src/components/hub-tabs.ts b/ui/src/components/hub-tabs.ts index 6764fc60e53c..04bec1bf2ab6 100644 --- a/ui/src/components/hub-tabs.ts +++ b/ui/src/components/hub-tabs.ts @@ -7,15 +7,19 @@ export type HubTabOption = { value: T; label: unknown; badge?: unknown; + count?: number | null; + disabled?: boolean; + testId?: string; }; type HubTabsProps = { id: string; - active: T; + active: T | null; tabs: ReadonlyArray>; ariaLabel: string; panelId: string; className?: string; + variant?: "primary" | "sub"; onSelect: (tab: T) => void; }; @@ -23,6 +27,9 @@ type HubTabsProps = { // 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(props: HubTabsProps): 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` @@ -62,13 +72,22 @@ export function renderHubTabs(props: HubTabsProps): 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(props: HubTabsProps): Templat }} ${selected ? ref((element) => reclaimFocus(props.id, tab.value, element)) : nothing} > - ${tab.label}${tab.badge ?? nothing} + ${tab.label}${tab.count == null + ? nothing + : html`${tab.count}`}${tab.badge == null + ? nothing + : html`${tab.badge}`} `; })} diff --git a/ui/src/components/plugins-hub-tabs.test.ts b/ui/src/components/plugins-hub-tabs.test.ts deleted file mode 100644 index 33d89ca240ed..000000000000 --- a/ui/src/components/plugins-hub-tabs.test.ts +++ /dev/null @@ -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[0]; - -async function mount(props: PluginsHubTabsProps): Promise { - const container = document.createElement("div"); - document.body.append(container); - render(renderPluginsHubTabs(props), container); - const group = container.querySelector }>( - "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("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("#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("#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("#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("#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("#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("#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("#plugins-tab-installed"), - ); - }); - - it("does not steal focus after mouse activation", async () => { - const source = await mount({ active: "installed", onSelect: () => undefined }); - source - .querySelector("#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("#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("#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(); - }); -}); diff --git a/ui/src/components/plugins-hub-tabs.ts b/ui/src/components/plugins-hub-tabs.ts deleted file mode 100644 index ee1079c3a2fb..000000000000 --- a/ui/src/components/plugins-hub-tabs.ts +++ /dev/null @@ -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> { - return [ - { - value: "installed", - label: t("pluginsPage.installedTab"), - badge: - installedCount === null - ? undefined - : html`${installedCount}`, - }, - { 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, - }); -} diff --git a/ui/src/e2e/agent-model-fallback-ownership.e2e.test.ts b/ui/src/e2e/agent-model-fallback-ownership.e2e.test.ts index 6b02f6fc6bcc..1343c05bebab 100644 --- a/ui/src/e2e/agent-model-fallback-ownership.e2e.test.ts +++ b/ui/src/e2e/agent-model-fallback-ownership.e2e.test.ts @@ -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"); diff --git a/ui/src/pages/agents/memory/view.test.ts b/ui/src/pages/agents/memory/view.test.ts index 741efabea012..62a46b84774c 100644 --- a/ui/src/pages/agents/memory/view.test.ts +++ b/ui/src/pages/agents/memory/view.test.ts @@ -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", ); diff --git a/ui/src/pages/agents/memory/view.ts b/ui/src/pages/agents/memory/view.ts index d0aa79c7eebe..9e2428fb456a 100644 --- a/ui/src/pages/agents/memory/view.ts +++ b/ui/src/pages/agents/memory/view.ts @@ -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) {
- + ${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(); + }, + })}
- ${state.activeSubTab === "scene" - ? renderScene(props, idle, dreamText) - : state.activeSubTab === "diary" - ? renderDiarySection(props) - : renderAdvancedSection(props)} +
+ ${state.activeSubTab === "scene" + ? renderScene(props, idle, dreamText) + : state.activeSubTab === "diary" + ? renderDiarySection(props) + : renderAdvancedSection(props)} +
`; } @@ -1524,47 +1519,24 @@ function renderDiarySection(props: DreamingProps) {
${t("dreaming.diary.title")} -
- - - -
+ ${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(); + }, + })} +
-
- ${t("dreaming.wiki.enablePrefix")} - plugins.entries.memory-wiki.enabled = true${t( - "dreaming.wiki.enableSuffix", - )} -
-
- -
- - ` - : activeDiarySubTab === "dreams" - ? renderDreamDiaryEntries(props) - : activeDiarySubTab === "insights" - ? renderDiaryImportsSection(props) - : renderMemoryPalaceSection(props)} + ` + : activeDiarySubTab === "dreams" + ? renderDreamDiaryEntries(props) + : activeDiarySubTab === "insights" + ? renderDiaryImportsSection(props) + : renderMemoryPalaceSection(props)} + ${renderWikiPreviewOverlay(props)} `; diff --git a/ui/src/pages/agents/panels-status-files.ts b/ui/src/pages/agents/panels-status-files.ts index fee8fd590804..c280a263bc4c 100644 --- a/ui/src/pages/agents/panels-status-files.ts +++ b/ui/src/pages/agents/panels-status-files.ts @@ -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`
-
- ${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` - - `; +
+ ${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: { `}
- ${!activeEntry - ? html`
${t("agents.files.selectFile")}
` - : html` -
-
-
${activeEntry.path}
+
+ ${!activeEntry + ? html`
${t("agents.files.selectFile")}
` + : html` +
+
+
${activeEntry.path}
+
+
+ + + +
-
- - - -
-
- ${activeEntry.missing - ? html`
- ${activeEntry.expectedAbsent === true - ? t("agents.files.createHint") - : t("agents.files.missingHint")} -
` - : nothing} - - { - resetAgentFilePreview(e.currentTarget as HTMLElement); - }} - > -
-
-
-
- ${icons.scrollText} - ${getExtensionLabel(activeEntry.name)} -
-
-
- ${activeEntry.name} + ${activeEntry.missing + ? html`
+ ${activeEntry.expectedAbsent === true + ? t("agents.files.createHint") + : t("agents.files.missingHint")} +
` + : nothing} + + { + resetAgentFilePreview(e.currentTarget as HTMLElement); + }} + > +
+
+
+
+ ${icons.scrollText} + ${getExtensionLabel(activeEntry.name)}
-
- ${activePathLabel} -
-
-
-
- - - - -
+
+ ${activePathLabel} +
+
+
+
+ + + + + + + + + +
+
+
+
+ ${previewStatusLabel} +
+
+ ${estimateReadingTimeLabel(draftWordCount)} + ${t("agents.files.words", { + count: String(draftWordCount), + })} - - - - - - +
+
+ ${draftLineCount} + ${t("agents.files.lines")} +
+
+ ${draftByteSize} + ${previewUpdatedLabel} +
+
+
+
-
-
- ${previewStatusLabel} -
-
- ${estimateReadingTimeLabel(draftWordCount)} - ${t("agents.files.words", { count: String(draftWordCount) })} -
-
- ${draftLineCount} - ${t("agents.files.lines")} -
-
- ${draftByteSize} - ${previewUpdatedLabel} -
-
-
- -
-
- - `} + + `} +
`, )} diff --git a/ui/src/pages/agents/view.test.ts b/ui/src/pages/agents/view.test.ts index b20913248fb2..61c41c1e294b 100644 --- a/ui/src/pages/agents/view.test.ts +++ b/ui/src/pages/agents/view.test.ts @@ -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(".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("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(".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( "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(".agent-tab")).map( - (button) => button.textContent?.trim(), - ); + const tabLabels = Array.from( + container.querySelectorAll(".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(".agent-tab")).map( - (tab) => directText(tab), - ); + const tabLabels = Array.from( + container.querySelectorAll(".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(".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(".agent-tab")).map( - (tab) => directText(tab), - ); + const tabLabels = Array.from( + container.querySelectorAll(".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.", ); diff --git a/ui/src/pages/agents/view.ts b/ui/src/pages/agents/view.ts index b59282cb7d83..d8c170679eb1 100644 --- a/ui/src/pages/agents/view.ts +++ b/ui/src/pages/agents/view.ts @@ -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, +
+ ${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` -
- ${renderSettingsNavRow({ - title: t("tabs.memory"), - description: t("subtitles.memory"), - onClick: () => props.onOpenMemorySettings?.(), - })} - ${renderSettingsNavRow({ - title: t("tabs.memoryImport"), - description: t("subtitles.memoryImport"), - onClick: () => props.onOpenMemoryImport?.(), - })} -
- - ` - : 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` +
+ ${renderSettingsNavRow({ + title: t("tabs.memory"), + description: t("subtitles.memory"), + onClick: () => props.onOpenMemorySettings?.(), + })} + ${renderSettingsNavRow({ + title: t("tabs.memoryImport"), + description: t("subtitles.memoryImport"), + onClick: () => props.onOpenMemoryImport?.(), + })} +
+ + ` + : nothing} +
`}
@@ -419,21 +426,16 @@ function renderAgentTabs( { id: "cron", label: t("agents.tabs.cronJobs") }, { id: "memory", label: t("agents.tabs.memory") }, ]; - return html` -
- ${tabs.map( - (tab) => html` - - `, - )} -
- `; + 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, + }); } diff --git a/ui/src/pages/cron/segmented-control.ts b/ui/src/pages/cron/segmented-control.ts index 812a27c5d8bb..34dc5e01b670 100644 --- a/ui/src/pages/cron/segmented-control.ts +++ b/ui/src/pages/cron/segmented-control.ts @@ -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(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` - ) => params.onChange(event.detail.name)} - > - ${params.options.map( - (option) => html` - - ${option.label} - - `, - )} - - `; + 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, diff --git a/ui/src/pages/cron/view.test.ts b/ui/src/pages/cron/view.test.ts index 0fa8e0c2f389..ed361c19095b 100644 --- a/ui/src/pages/cron/view.test.ts +++ b/ui/src/pages/cron/view.test.ts @@ -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"); }); diff --git a/ui/src/pages/cron/view.ts b/ui/src/pages/cron/view.ts index faf4f8c33853..bcd66e568bec 100644 --- a/ui/src/pages/cron/view.ts +++ b/ui/src/pages/cron/view.ts @@ -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, }); } diff --git a/ui/src/pages/plugins/plugins-hub.ts b/ui/src/pages/plugins/plugins-hub.ts new file mode 100644 index 000000000000..68374bbceefb --- /dev/null +++ b/ui/src/pages/plugins/plugins-hub.ts @@ -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> { + 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") }, + ]; +} diff --git a/ui/src/pages/plugins/plugins-page.ts b/ui/src/pages/plugins/plugins-page.ts index f614c54eabe2..860fe5fbef06 100644 --- a/ui/src/pages/plugins/plugins-page.ts +++ b/ui/src/pages/plugins/plugins-page.ts @@ -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 { ${renderSettingsWorkspace(html`
- ${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), })}
diff --git a/ui/src/pages/skill-workshop/header-controls.test.ts b/ui/src/pages/skill-workshop/header-controls.test.ts new file mode 100644 index 000000000000..f9d3a6848010 --- /dev/null +++ b/ui/src/pages/skill-workshop/header-controls.test.ts @@ -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(); + }); +}); diff --git a/ui/src/pages/skill-workshop/header-controls.ts b/ui/src/pages/skill-workshop/header-controls.ts index 6cf011dbb225..f2624abfcc65 100644 --- a/ui/src/pages/skill-workshop/header-controls.ts +++ b/ui/src/pages/skill-workshop/header-controls.ts @@ -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( ${useCurrentChatLabel} - ) => { - if (event.detail.name === "board" || event.detail.name === "today") { - setSkillWorkshopMode(state, event.detail.name, requestUpdate); - } - }} - > - - - ${t("skillWorkshop.header.board")} - - - - ${t("skillWorkshop.header.today")} - - + ${renderHubTabs({ + id: "skill-workshop-mode", + active: state.skillWorkshopMode, + tabs: [ + { + value: "board", + label: html` + + ${t("skillWorkshop.header.board")} + `, + }, + { + value: "today", + label: html` + + ${t("skillWorkshop.header.today")} + `, + }, + ], + ariaLabel: t("skillWorkshop.header.view"), + panelId: "skill-workshop-mode-panel", + variant: "sub", + onSelect: (mode) => setSkillWorkshopMode(state, mode, requestUpdate), + })}
`; } diff --git a/ui/src/pages/skill-workshop/plugins-hub-navigation.ts b/ui/src/pages/skill-workshop/plugins-hub-navigation.ts index 36d2a52dc31c..13250b432667 100644 --- a/ui/src/pages/skill-workshop/plugins-hub-navigation.ts +++ b/ui/src/pages/skill-workshop/plugins-hub-navigation.ts @@ -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, diff --git a/ui/src/pages/skill-workshop/skill-workshop-page.ts b/ui/src/pages/skill-workshop/skill-workshop-page.ts index f626cff1e680..2cc6ca707abb 100644 --- a/ui/src/pages/skill-workshop/skill-workshop-page.ts +++ b/ui/src/pages/skill-workshop/skill-workshop-page.ts @@ -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(
- ${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), })}
${renderSettingsWorkspace(html`
- ${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), + })}
{ 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('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."); }); diff --git a/ui/src/pages/skills/view.ts b/ui/src/pages/skills/view.ts index 895e1b8ffbc9..967fd770e6b7 100644 --- a/ui/src/pages/skills/view.ts +++ b/ui/src/pages/skills/view.ts @@ -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` -
- - ${skill.skillCard?.present - ? html`` - : nothing} -
+ ${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)} +
+ ${detailTab === "overview" + ? renderInstalledClawHubOverview(skill, props, verdict) + : renderInstalledSkillCard(skill, props)} +
${missing.length > 0 ? html`