diff --git a/ui/src/components/channel-icon.ts b/ui/src/components/channel-icon.ts new file mode 100644 index 000000000000..e0b87a0464b2 --- /dev/null +++ b/ui/src/components/channel-icon.ts @@ -0,0 +1,30 @@ +import { html } from "lit"; +import { + pluginArtPath, + pluginFallbackGradient, + pluginMonogram, +} from "../pages/plugins/presentation.ts"; +import "../styles/channels.css"; + +/** Bundled channel art reuses the plugin art set because channel ids match plugin slugs. */ +export function renderChannelIcon( + channelId: string, + label: string, + variant: "tile" | "cover" | "picker", +) { + const artVariant = variant === "picker" ? "tile" : variant; + const art = pluginArtPath(channelId); + const [from, to] = art ? ["", ""] : pluginFallbackGradient(channelId); + const style = `${variant === "picker" ? "--channels-art-size:24px;" : ""}${ + art ? "" : `--channels-art-a:${from};--channels-art-b:${to}` + }`; + return html` + ${art + ? html`` + : html`${pluginMonogram(label)}`} + `; +} diff --git a/ui/src/components/channel-picker.test.ts b/ui/src/components/channel-picker.test.ts new file mode 100644 index 000000000000..55dd805d6e9f --- /dev/null +++ b/ui/src/components/channel-picker.test.ts @@ -0,0 +1,64 @@ +/* @vitest-environment jsdom */ +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderChannelPicker } from "./channel-picker.ts"; + +describe("renderChannelPicker", () => { + it("renders neutral and channel artwork while preserving a missing current channel", () => { + const container = document.createElement("div"); + render( + renderChannelPicker({ + label: "Channel", + value: "retired-channel", + options: [ + { value: "last", label: "last", kind: "neutral" }, + { value: "telegram", label: "Telegram" }, + ], + onChange: vi.fn(), + }), + container, + ); + + expect(container.querySelector('wa-option[value="last"] [slot="start"]')).toBeNull(); + expect(container.querySelector('wa-option[value="telegram"] img')).not.toBeNull(); + expect(container.querySelector('wa-option[value="retired-channel"]')?.textContent).toContain( + "retired-channel", + ); + expect( + container.querySelector('wa-option[value="retired-channel"] .channels-tile--fallback'), + ).not.toBeNull(); + }); + + it("honors disabled choices and reports enabled changes", () => { + const container = document.createElement("div"); + const onChange = vi.fn(); + render( + renderChannelPicker({ + label: "Channel", + value: "telegram", + options: [ + { value: "telegram", label: "Telegram" }, + { value: "disabled", label: "Disabled", disabled: true }, + ], + onChange, + }), + container, + ); + + const picker = container.querySelector("wa-select"); + expect(container.querySelector('wa-option[value="disabled"]')?.hasAttribute("disabled")).toBe( + true, + ); + if (!picker) { + return; + } + Object.defineProperty(picker, "value", { configurable: true, value: "disabled" }); + picker.dispatchEvent(new Event("change", { bubbles: true })); + Reflect.deleteProperty(picker, "value"); + expect(onChange).not.toHaveBeenCalled(); + Object.defineProperty(picker, "value", { configurable: true, value: "telegram" }); + picker.dispatchEvent(new Event("change", { bubbles: true })); + Reflect.deleteProperty(picker, "value"); + expect(onChange).toHaveBeenCalledWith("telegram"); + }); +}); diff --git a/ui/src/components/channel-picker.ts b/ui/src/components/channel-picker.ts new file mode 100644 index 000000000000..21942b21c9f6 --- /dev/null +++ b/ui/src/components/channel-picker.ts @@ -0,0 +1,17 @@ +import { nothing } from "lit"; +import { renderChannelIcon } from "./channel-icon.ts"; +import { renderPicker, type PickerOption, type PickerParams } from "./select-picker.ts"; + +export type ChannelPickerOption = PickerOption & { + /** Neutral choices such as "last" or "all" are routing policy, not transports. */ + kind?: "channel" | "neutral"; +}; + +export function renderChannelPicker(params: PickerParams) { + return renderPicker({ + ...params, + className: "channel-picker", + renderLeading: (option) => + option.kind === "neutral" ? nothing : renderChannelIcon(option.value, option.label, "picker"), + }); +} diff --git a/ui/src/components/select-picker.ts b/ui/src/components/select-picker.ts new file mode 100644 index 000000000000..ee5e666bd59c --- /dev/null +++ b/ui/src/components/select-picker.ts @@ -0,0 +1,71 @@ +import { html, nothing } from "lit"; +import "./web-awesome-select.ts"; + +export type PickerOption = { + value: string; + label: string; + description?: string; + disabled?: boolean; +}; + +export type PickerParams = { + id?: string; + label: string; + value: string | null; + options: readonly Option[]; + disabled?: boolean; + className?: string; + onChange: (value: string) => void; + renderLeading?: (option: Option) => unknown; +}; + +export function renderPicker(params: PickerParams) { + const options = + params.value === null || params.options.some((option) => option.value === params.value) + ? params.options + : [...params.options, { value: params.value, label: params.value } as Option]; + const leading = (option: Option | undefined) => { + const content = option && params.renderLeading?.(option); + return content === undefined || content === null || content === nothing + ? nothing + : html`${content}`; + }; + return html` + { + const value = (event.currentTarget as HTMLElement & { value?: unknown }).value; + const option = typeof value === "string" && options.find((entry) => entry.value === value); + if (option && !option.disabled) { + params.onChange(value); + } + }} + > + ${params.label} + ${leading(options.find((option) => option.value === params.value))} + ${options.map( + (option) => html` + + ${leading(option)} + + ${option.label} + ${option.description + ? html`${option.description}` + : nothing} + + + `, + )} + + `; +} diff --git a/ui/src/components/wizard-step-controls.ts b/ui/src/components/wizard-step-controls.ts index 696c19dc6bb5..73d94a23e981 100644 --- a/ui/src/components/wizard-step-controls.ts +++ b/ui/src/components/wizard-step-controls.ts @@ -1,7 +1,9 @@ import { html, nothing, type TemplateResult } from "lit"; import type { WizardStep } from "../api/types.ts"; import { t } from "../i18n/index.ts"; +import { renderChannelPicker } from "./channel-picker.ts"; import { handleCopyButton } from "./copy-button.ts"; +import { renderPicker } from "./select-picker.ts"; import { renderSensitiveInput } from "./sensitive-input.ts"; import "../styles/wizard-step-controls.css"; @@ -19,6 +21,7 @@ type WizardStepControlsProps = { onValueChange: (value: unknown) => void; onAnswer: (value: unknown) => void; presentation?: "channels"; + channelSelect?: boolean; answerLabel?: string; confirmAffirmativeLabel?: string; leadingAction?: TemplateResult; @@ -112,29 +115,19 @@ function renderAnswerButton( function renderOption( props: WizardStepControlsProps, option: WizardStepOption, - index: number, selected: unknown[], ) { const checked = selected.some((value) => Object.is(value, option.value)); if (props.presentation === "channels") { - return props.step.type === "select" - ? html` - ${renderOptionBody(option, props.presentation)} - ` - : html` props.onValueChange(option.value)} - > - ${renderOptionBody(option, props.presentation, checked)} - `; + return html` props.onValueChange(option.value)} + > + ${renderOptionBody(option, props.presentation, checked)} + `; } return html` Object.is(option.value, props.value)); + const channels = + props.channelSelect && options.every((option) => typeof option.value === "string"); + const picker = channels ? renderChannelPicker : renderPicker; return html` - = 0 ? String(selectedIndex) : null} - ?disabled=${props.busy} - @change=${(event: Event) => { - const index = (event.currentTarget as HTMLElement & { value?: string | number | null }) - .value; - const option = options[Number(index)]; - if (option) { - props.onAnswer(option.value); - } - }} - > - ${options.map((option, index) => renderOption(props, option, index, selected))} - + ${renderMessage(props)} + ${picker({ + label: props.step.message ?? "", + value: + selectedIndex < 0 + ? null + : channels + ? String(options[selectedIndex]?.value) + : String(selectedIndex), + options: options.map((option, index) => ({ + value: channels ? String(option.value) : String(index), + label: option.label, + description: option.hint, + kind: channels ? "channel" : "neutral", + })), + disabled: props.busy, + onChange: (value) => props.onAnswer(channels ? value : options[Number(value)]?.value), + })} `; } const answer = multiple @@ -270,7 +267,7 @@ function renderOptionsStep(props: WizardStepControlsProps) { return html` ${renderMessage(props)} - ${options.map((option, index) => renderOption(props, option, index, selected))} + ${options.map((option) => renderOption(props, option, selected))} ${renderAnswerButton( props, diff --git a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts index 05747ba50946..c55a06349a7f 100644 --- a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts +++ b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts @@ -276,11 +276,15 @@ suite.define(() => { await page.locator(".channels-detail").getByRole("button", { name: "Run setup" }).click(); const wizard = page.locator(".channels-wizard"); await gateway.deferNext("wizard.next"); - await wizard.getByRole("radio", { name: "Work bot" }).click(); + const account = wizard.locator("wa-select"); + await account.evaluate(async (select) => { + const picker = select as HTMLElement & { value: string; updateComplete: Promise }; + picker.value = "1"; + await picker.updateComplete; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); await expect.poll(async () => gateway.getRequests("wizard.next")).toHaveLength(1); - await expect - .poll(() => wizard.locator("wa-radio-group").getAttribute("disabled")) - .not.toBeNull(); + await expect.poll(() => account.getAttribute("disabled")).not.toBeNull(); await gateway.resolveDeferred("wizard.next"); const token = wizard.getByLabel("Telegram bot token"); diff --git a/ui/src/e2e/cron-select-values.e2e.test.ts b/ui/src/e2e/cron-select-values.e2e.test.ts index f415eb8d5692..1374d1f4c674 100644 --- a/ui/src/e2e/cron-select-values.e2e.test.ts +++ b/ui/src/e2e/cron-select-values.e2e.test.ts @@ -1,4 +1,4 @@ -// Control UI tests cover Automations form native-select display state. +// Control UI tests cover Automations form select display state. import { expect, it } from "vitest"; import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; @@ -55,15 +55,27 @@ suite.define(() => { expect(await page.locator("select.cron-run-sort").inputValue()).toBe("asc"); await page.locator('[data-test-id="cron-new-task"]').click(); - const action = page.locator("select#cron-payload-kind"); + const pickerValue = (selector: string) => + page + .locator(selector) + .evaluate((element) => String((element as HTMLElement & { value?: string }).value)); + const action = page.locator("wa-select#cron-payload-kind"); await action.waitFor({ state: "visible" }); // Form defaults are agentTurn / isolated / minutes — none of which is // the first option of its select; the rendered selection must agree. - expect(await action.inputValue()).toBe("agentTurn"); - expect(await page.locator("select#cron-session-target").inputValue()).toBe("isolated"); - expect(await page.locator('select[aria-label="Unit"]').inputValue()).toBe("minutes"); + expect(await pickerValue("wa-select#cron-payload-kind")).toBe("agentTurn"); + expect(await pickerValue("wa-select#cron-session-target")).toBe("isolated"); + const unit = page.locator("wa-select").filter({ + has: page.locator('[slot="label"]', { hasText: "Unit" }), + }); + expect( + await unit.evaluate((element) => + String((element as HTMLElement & { value?: string }).value), + ), + ).toBe("minutes"); // Control: delivery mode's default is also its first option. - expect(await page.locator("select#cron-delivery-mode").inputValue()).toBe("announce"); + expect(await pickerValue("wa-select#cron-delivery-mode")).toBe("announce"); + expect(await pickerValue("wa-select#cron-delivery-channel")).toBe("last"); }, ); }); diff --git a/ui/src/pages/channels/hub-meta.ts b/ui/src/pages/channels/hub-meta.ts index efdefe8752c5..334c56348745 100644 --- a/ui/src/pages/channels/hub-meta.ts +++ b/ui/src/pages/channels/hub-meta.ts @@ -1,9 +1,6 @@ // Channel hub presentation: bundled art reuse plus typed per-channel setup // helper links surfaced in the setup wizard. Labels/order still come from the // gateway channels.status snapshot; this table only decorates known channels. -import { html, type TemplateResult } from "lit"; -import { pluginArtPath, pluginFallbackGradient, pluginMonogram } from "../plugins/presentation.ts"; - type ChannelSetupLink = { label: string; url: string; @@ -46,26 +43,3 @@ export function channelHubMeta(channelId: string): ChannelHubMeta { export function channelDocsUrl(channelId: string): string { return `https://docs.openclaw.ai/channels/${encodeURIComponent(channelId)}`; } - -/** Bundled channel art reuses the plugin art set (channel ids match slugs). */ -export function renderChannelArt( - channelId: string, - label: string, - variant: "tile" | "cover", -): TemplateResult { - const art = pluginArtPath(channelId); - if (art) { - return html` - - `; - } - const [from, to] = pluginFallbackGradient(channelId); - const monogram = pluginMonogram(label); - return html` - ${monogram} - `; -} diff --git a/ui/src/pages/channels/view.detail.ts b/ui/src/pages/channels/view.detail.ts index 84d565f87d69..59d2b9e9bb28 100644 --- a/ui/src/pages/channels/view.detail.ts +++ b/ui/src/pages/channels/view.detail.ts @@ -3,12 +3,13 @@ import { asNullableRecord, readStringField } from "@openclaw/normalization-core/record-coerce"; import { html, nothing, type TemplateResult } from "lit"; import type { NostrProfile } from "../../api/types.ts"; +import { renderChannelIcon } from "../../components/channel-icon.ts"; import { renderSettingsSection } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; import "../../components/modal-dialog.ts"; import { resolveChannelAccounts } from "../../lib/channels/index.ts"; import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { channelDocsUrl, renderChannelArt } from "./hub-meta.ts"; +import { channelDocsUrl } from "./hub-meta.ts"; import { renderChannelConfigSection } from "./view.config.ts"; import { renderNostrCard } from "./view.nostr.ts"; import { renderChannelPairingDetail } from "./view.pairing.ts"; @@ -221,7 +222,7 @@ export function renderChannelDetail(params: { params.onClose()}> - ${renderChannelArt(params.channelId, params.label, "cover")} + ${renderChannelIcon(params.channelId, params.label, "cover")} { expect(errorContainer.querySelector(".callout")).toBeNull(); }); - it("uses settings controls for both pairing filters", () => { - const container = renderInto(renderChannelPairingQueue(createProps())); + it("uses the channel picker and clears the account filter when the channel changes", () => { + const onPairingFilterChange = vi.fn(); + const base = createProps(); + const container = renderInto( + renderChannelPairingQueue( + createProps({ + pairingChannelFilter: "whatsapp", + pairingAccountFilter: "personal", + pairingSnapshot: { + ...base.pairingSnapshot!, + accounts: [ + ...base.pairingSnapshot!.accounts, + { + channel: "telegram", + channelLabel: "Telegram", + accountId: "work", + accountLabel: "Work", + notifySupported: true, + }, + ], + }, + onPairingFilterChange, + }), + ), + ); - expect(container.querySelectorAll("select.settings-select")).toHaveLength(2); + const selects = container.querySelectorAll("wa-select"); + const channel = selects.item(0); + const account = selects.item(1); + expect(channel?.querySelector('wa-option[value="whatsapp"] img')).not.toBeNull(); + expect(selects).toHaveLength(2); + expect(container.querySelectorAll("select.settings-select")).toHaveLength(0); + expect(account.querySelector("wa-option[selected]")?.getAttribute("value")).toBe("personal"); + Object.defineProperty(channel, "value", { configurable: true, value: "telegram" }); + channel.dispatchEvent(new Event("change", { bubbles: true })); + Reflect.deleteProperty(channel, "value"); + expect(onPairingFilterChange).toHaveBeenCalledWith("telegram", null); }); it("disables every request action while one mutation is active", () => { diff --git a/ui/src/pages/channels/view.pairing.ts b/ui/src/pages/channels/view.pairing.ts index c798c409c1c0..456373d9448f 100644 --- a/ui/src/pages/channels/view.pairing.ts +++ b/ui/src/pages/channels/view.pairing.ts @@ -1,7 +1,9 @@ // DM sender access request queue shared by the Channels hub and detail panels. import { html, nothing } from "lit"; import type { ChannelsPairingAccount, ChannelsPairingRequest } from "../../api/types.ts"; +import { renderChannelPicker } from "../../components/channel-picker.ts"; import "../../components/modal-dialog.ts"; +import { renderPicker } from "../../components/select-picker.ts"; import { renderSettingsEmpty, renderSettingsSection, @@ -24,11 +26,6 @@ function formatRequestTime(value: string): string { return Number.isFinite(time) ? formatRelativeTimestamp(time) : value; } -function selectValue(event: Event): string | null { - const value = event.currentTarget instanceof HTMLSelectElement ? event.currentTarget.value : ""; - return value || null; -} - function filteredAccounts(props: ChannelsProps): ChannelsPairingAccount[] { const accounts = props.pairingSnapshot?.accounts ?? []; return props.pairingChannelFilter @@ -58,29 +55,32 @@ function renderFilters(props: ChannelsProps) { ${t("channels.pairing.channelFilter")} - props.onPairingFilterChange(selectValue(event), null)} - > - ${t("channels.pairing.allChannels")} - ${channels.map(([channel, label]) => html`${label}`)} - + ${renderChannelPicker({ + label: t("channels.pairing.channelFilter"), + value: props.pairingChannelFilter ?? "", + options: [ + { value: "", label: t("channels.pairing.allChannels"), kind: "neutral" }, + ...channels.map(([value, label]) => ({ value, label })), + ], + onChange: (value) => props.onPairingFilterChange(value || null, null), + })} ${t("channels.pairing.accountFilter")} - - props.onPairingFilterChange(props.pairingChannelFilter, selectValue(event))} - > - ${t("channels.pairing.allAccounts")} - ${accountsForChannel.map( - (account) => html`${accountName(account)}`, - )} - + ${renderPicker({ + label: t("channels.pairing.accountFilter"), + value: props.pairingAccountFilter ?? "", + options: [ + { value: "", label: t("channels.pairing.allAccounts") }, + ...accountsForChannel.map((account) => ({ + value: account.accountId, + label: accountName(account), + })), + ], + disabled: !props.pairingChannelFilter, + onChange: (value) => + props.onPairingFilterChange(props.pairingChannelFilter, value || null), + })} `; diff --git a/ui/src/pages/channels/view.ts b/ui/src/pages/channels/view.ts index 73ad0c801d25..5c6af148cb26 100644 --- a/ui/src/pages/channels/view.ts +++ b/ui/src/pages/channels/view.ts @@ -13,6 +13,7 @@ import type { TelegramStatus, WhatsAppStatus, } from "../../api/types.ts"; +import { renderChannelIcon } from "../../components/channel-icon.ts"; import { icons } from "../../components/icons.ts"; import "../../components/openclaw-mascot.ts"; import { @@ -24,7 +25,6 @@ import { import { t } from "../../i18n/index.ts"; import { resolveChannelAccounts } from "../../lib/channels/index.ts"; import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelArt } from "./hub-meta.ts"; import { renderChannelDetail } from "./view.detail.ts"; import { renderChannelPairingPrompt, renderChannelPairingQueue } from "./view.pairing.ts"; import { channelEnabled, resolveChannelDisplayState } from "./view.shared.ts"; @@ -232,7 +232,7 @@ function renderConnectedRow(key: ChannelKey, props: ChannelsProps) { class="settings-row settings-row--nav channels-item" @click=${() => props.onShowDetail(key)} > - ${renderChannelArt(key, label, "tile")} + ${renderChannelIcon(key, label, "tile")} ${label} ${description} @@ -257,7 +257,7 @@ function renderAvailableRow(key: ChannelKey, props: ChannelsProps) { title=${t("channels.hub.openDetails")} @click=${() => props.onShowDetail(key)} > - ${renderChannelArt(key, label, "tile")} + ${renderChannelIcon(key, label, "tile")} ${label} ${description} diff --git a/ui/src/pages/channels/wizard-view.busy.test.ts b/ui/src/pages/channels/wizard-view.busy.test.ts index ef77296a2e20..eeb0fec3838e 100644 --- a/ui/src/pages/channels/wizard-view.busy.test.ts +++ b/ui/src/pages/channels/wizard-view.busy.test.ts @@ -118,11 +118,9 @@ describe("renderChannelWizard busy controls", () => { { label: "Beta", value: "beta" }, ], }); - const group = select.container.querySelector( - "wa-radio-group", - ); - expect(group?.disabled).toBe(true); + const group = select.container.querySelector("wa-select"); expect(group?.hasAttribute("disabled")).toBe(true); + expect(group?.querySelector('[slot="label"]')?.textContent).toBe("Pick one"); }); it("disables multiselect choices and submission while a step is running", () => { @@ -182,9 +180,7 @@ describe("renderChannelWizard busy controls", () => { }, false, ); - expect( - select.container.querySelector("wa-radio-group") - ?.disabled, - ).toBe(false); + const picker = select.container.querySelector("wa-select"); + expect(picker?.hasAttribute("disabled")).toBe(false); }); }); diff --git a/ui/src/pages/channels/wizard-view.ts b/ui/src/pages/channels/wizard-view.ts index b8ea238b3de8..90b3fe1eab44 100644 --- a/ui/src/pages/channels/wizard-view.ts +++ b/ui/src/pages/channels/wizard-view.ts @@ -1,13 +1,12 @@ // Channel setup wizard modal: renders gateway wizard steps (note/select/text/ // confirm/multiselect) plus the WhatsApp QR linking phase after config write. -import "@awesome.me/webawesome/dist/components/radio/radio.js"; -import "@awesome.me/webawesome/dist/components/radio-group/radio-group.js"; import { html, nothing, type TemplateResult } from "lit"; +import { renderChannelIcon } from "../../components/channel-icon.ts"; import { handleCopyButton } from "../../components/copy-button.ts"; import { renderWizardStepControls } from "../../components/wizard-step-controls.ts"; import { t } from "../../i18n/index.ts"; import "../../components/modal-dialog.ts"; -import { channelDocsUrl, channelHubMeta, renderChannelArt } from "./hub-meta.ts"; +import { channelDocsUrl, channelHubMeta } from "./hub-meta.ts"; import type { ChannelWizardState, ChannelWizardStep } from "./wizard-controller.ts"; type ChannelWizardViewProps = { @@ -99,6 +98,7 @@ function renderStepBody(step: ChannelWizardStep, props: ChannelWizardViewProps) busy: stepIsBusy(props), inputId: "channel-wizard-text-input", presentation: "channels", + channelSelect: props.wizard.phase === "step" && props.wizard.channel === null, answerLabel: t("channels.setup.continue"), sensitiveRevealed: props.secretVisible, onValueChange: @@ -260,7 +260,7 @@ export function renderChannelWizard( > - ${channel ? renderChannelArt(channel, label, "tile") : nothing} + ${channel ? renderChannelIcon(channel, label, "tile") : nothing} ${t("channels.setup.title", { channel: label })} ${t("channels.setup.subtitle")} diff --git a/ui/src/pages/cron/view.test.ts b/ui/src/pages/cron/view.test.ts index f4e7916b2ce7..accffc500b1e 100644 --- a/ui/src/pages/cron/view.test.ts +++ b/ui/src/pages/cron/view.test.ts @@ -519,7 +519,13 @@ describe("cron view editor", () => { channels: ["telegram"], channelMeta: [{ id: "telegram", label: "", detailLabel: "Telegram" }], channelLabels: { telegram: "Telegram fallback" }, - form: { ...DEFAULT_CRON_FORM, scheduleKind: "cron", failureAlertMode: "custom" }, + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "cron", + deliveryChannel: "telegram", + failureAlertMode: "custom", + failureAlertChannel: "retired-channel", + }, onFormChange, }); @@ -539,10 +545,25 @@ describe("cron view editor", () => { expect(onFormChange).toHaveBeenLastCalledWith({ [field]: field }); } - const channel = getElement(container, "#cron-failure-alert-channel", HTMLSelectElement); - channel.value = "telegram"; - expect(channel.selectedOptions[0]?.textContent).toBe("Telegram fallback"); + const channel = getElement( + container, + "#cron-failure-alert-channel", + HTMLElement, + ) as HTMLElement & { + value: string; + }; + expect( + Array.from(channel.querySelectorAll("wa-option"), (option) => option.getAttribute("value")), + ).toContain("retired-channel"); + expect(channel.localName).toBe("wa-select"); + expect(channel.querySelector('wa-option[value="telegram"] img')).not.toBeNull(); + expect( + (channel.querySelector('wa-option[value="telegram"]') as HTMLElement & { label?: string }) + ?.label, + ).toBe("Telegram fallback"); + Object.defineProperty(channel, "value", { configurable: true, value: "telegram" }); channel.dispatchEvent(new Event("change", { bubbles: true })); + Reflect.deleteProperty(channel, "value"); expect(onFormChange).toHaveBeenLastCalledWith({ failureAlertChannel: "telegram" }); }); @@ -664,8 +685,16 @@ describe("cron view editor", () => { everyUnit: "seconds", }, }); - const unitSelect = getElement(container, 'select[aria-label="Unit"]', HTMLSelectElement); - const values = Array.from(unitSelect.querySelectorAll("option")).map((option) => option.value); + const unitSelect = Array.from(container.querySelectorAll("wa-select")).find( + (select) => select.querySelector('[slot="label"]')?.textContent === "Unit", + ); + expect(unitSelect).toBeInstanceOf(HTMLElement); + if (!unitSelect) { + throw new Error("Expected the interval unit picker"); + } + const values = Array.from(unitSelect.querySelectorAll("wa-option"), (option) => + option.getAttribute("value"), + ); expect(values).toEqual(["seconds", "minutes", "hours", "days"]); }); @@ -727,8 +756,10 @@ describe("cron view editor", () => { deliveryMode: "announce", }, }); - const delivery = getElement(container, "#cron-delivery-mode", HTMLSelectElement); - const values = Array.from(delivery.querySelectorAll("option")).map((option) => option.value); + const delivery = getElement(container, "#cron-delivery-mode", HTMLElement); + const values = Array.from(delivery.querySelectorAll("wa-option"), (option) => + option.getAttribute("value"), + ); expect(values).toEqual(["webhook", "none"]); expect(container.querySelector("#cron-delivery-channel")).toBeNull(); }); @@ -1041,21 +1072,35 @@ describe("cron view editor", () => { }); }); -describe("cron view native selects", () => { +describe("cron view selects", () => { it("shows authoritative form values instead of first options in the create form", () => { const container = renderView({ createOpen: true }); - const action = getElement(container, "select#cron-payload-kind", HTMLSelectElement); - expect(action.value).toBe("agentTurn"); - const runsIn = getElement(container, "select#cron-session-target", HTMLSelectElement); - expect(runsIn.value).toBe("isolated"); - const unit = Array.from(container.querySelectorAll("select")).find( - (entry) => entry.getAttribute("aria-label") === "Unit", - ); - expect(unit?.value).toBe("minutes"); + const action = getElement( + container, + "wa-select#cron-payload-kind", + HTMLElement, + ) as HTMLElement & { + value: string; + }; + expect(action.querySelector("wa-option[selected]")?.getAttribute("value")).toBe("agentTurn"); + const runsIn = getElement( + container, + "wa-select#cron-session-target", + HTMLElement, + ) as HTMLElement & { value: string }; + expect(runsIn.querySelector("wa-option[selected]")?.getAttribute("value")).toBe("isolated"); + const unit = Array.from( + container.querySelectorAll("wa-select"), + ).find((select) => select.querySelector('[slot="label"]')?.textContent === "Unit"); + expect(unit?.querySelector("wa-option[selected]")?.getAttribute("value")).toBe("minutes"); // Negative control: the delivery-mode default is also the first option, so // this passes before and after the fix and proves the harness reads selects. - const delivery = getElement(container, "select#cron-delivery-mode", HTMLSelectElement); - expect(delivery.value).toBe("announce"); + const delivery = getElement( + container, + "wa-select#cron-delivery-mode", + HTMLElement, + ) as HTMLElement & { value: string }; + expect(delivery.querySelector("wa-option[selected]")?.getAttribute("value")).toBe("announce"); }); it("shows persisted non-first values in jobs filters and runs sort", () => { diff --git a/ui/src/pages/cron/view.ts b/ui/src/pages/cron/view.ts index e38f2aa55841..0694a6b998dc 100644 --- a/ui/src/pages/cron/view.ts +++ b/ui/src/pages/cron/view.ts @@ -18,9 +18,11 @@ import type { CronJobsSortBy, CronSortDir, } from "../../api/types.ts"; +import { renderChannelPicker, type ChannelPickerOption } from "../../components/channel-picker.ts"; import { renderCronJobsPagination } from "../../components/cron-jobs-pagination.ts"; import { icon, icons } from "../../components/icons.ts"; import { highlightCodeHtml } from "../../components/markdown-code-blocks.ts"; +import { renderPicker, type PickerOption } from "../../components/select-picker.ts"; import "../../components/tooltip.ts"; import "../../components/web-awesome.ts"; import "../../components/web-awesome-popover.ts"; @@ -135,16 +137,17 @@ type CronProps = { // ── Shared option helpers ── -function buildChannelOptions(props: CronProps): string[] { - const current = props.form.deliveryChannel?.trim(); - return uniqueStrings(["last", ...props.channels.filter(Boolean), ...(current ? [current] : [])]); -} - -function resolveChannelLabel(props: CronProps, channel: string): string { - return channel === "last" - ? channel - : props.channelMeta?.find((entry) => entry.id === channel)?.label || - (props.channelLabels?.[channel] ?? channel); +function buildChannelOptions(props: CronProps): ChannelPickerOption[] { + return [ + { value: "last", label: "last", kind: "neutral" }, + ...uniqueStrings(props.channels.filter(Boolean)).map((value) => ({ + value, + label: + props.channelMeta?.find((entry) => entry.id === value)?.label || + props.channelLabels?.[value] || + value, + })), + ]; } function renderSuggestionList(id: string, options: string[]) { @@ -347,7 +350,7 @@ function renderCronInputField( }); } -type CronSelectOption = { value: string; label: string }; +type CronSelectOption = PickerOption; type CronSelectOptions = { label: string; @@ -356,6 +359,7 @@ type CronSelectOptions = { value?: string; disabled?: boolean; standalone?: boolean; + channel?: boolean; }; function renderCronSelect( @@ -364,24 +368,15 @@ function renderCronSelect( options: CronSelectOptions, ) { const selected = options.value ?? props.form[field]; - return html` - - props.onFormChange({ [field]: (event.currentTarget as HTMLSelectElement).value })} - > - ${options.options.map( - // The .value property commits before these mapped options exist, so the - // browser falls back to the first option; ?selected marks the real one. - ({ value, label }) => - html`${label}`, - )} - - `; + const picker = options.channel ? renderChannelPicker : renderPicker; + return picker({ + id: options.standalone ? undefined : inputIdForField(field), + label: options.label, + value: options.channel ? selected || "last" : selected, + options: options.options, + disabled: options.disabled, + onChange: (value) => props.onFormChange({ [field]: value }), + }); } function renderCronSelectField( @@ -1475,10 +1470,8 @@ function renderDeliverySection( label: t("cron.form.channel"), help: t("cron.form.channelHelp"), value: props.form.deliveryChannel || "last", - options: channelOptions.map((channel) => ({ - value: channel, - label: resolveChannelLabel(props, channel), - })), + options: channelOptions, + channel: true, })} ${renderCronInputField(props, "deliveryTo", { label: t("cron.form.to"), @@ -1645,7 +1638,7 @@ function renderAdvanced( `; } -function renderFailureAlertRows(props: CronProps, channelOptions: string[]) { +function renderFailureAlertRows(props: CronProps, channelOptions: readonly ChannelPickerOption[]) { return html` ${renderCronSelectField(props, "failureAlertMode", { label: t("cron.form.failureAlerts"), @@ -1673,10 +1666,8 @@ function renderFailureAlertRows(props: CronProps, channelOptions: string[]) { ${renderCronSelectField(props, "failureAlertChannel", { label: t("cron.form.failureAlertChannel"), value: props.form.failureAlertChannel || "last", - options: channelOptions.map((channel) => ({ - value: channel, - label: resolveChannelLabel(props, channel), - })), + options: channelOptions, + channel: true, })} ${renderCronInputField(props, "failureAlertTo", { label: t("cron.form.failureAlertTo"), diff --git a/ui/src/pages/workboard/view.test.ts b/ui/src/pages/workboard/view.test.ts index 52b5098c5fd6..cc5d8b6faaf8 100644 --- a/ui/src/pages/workboard/view.test.ts +++ b/ui/src/pages/workboard/view.test.ts @@ -94,6 +94,7 @@ function changeWorkboardSelect(select: Element | null | undefined, value: string } Object.defineProperty(control, "value", { configurable: true, value, writable: true }); control.dispatchEvent(new Event("change", { bubbles: true })); + Reflect.deleteProperty(control, "value"); } function selectWorkboardAgent(select: Element | null | undefined, value: string) { @@ -950,11 +951,13 @@ describe("renderWorkboard", () => { ), ]; expect(selects).toHaveLength(2); - expect(selects.map((select) => select.getAttribute("label"))).toEqual([ + expect(selects.map((select) => select.querySelector('[slot="label"]')?.textContent)).toEqual([ "Workboard view", "All priorities", ]); - expect(selects.map((select) => select.getAttribute("value"))).toEqual(["all", "all"]); + expect( + selects.map((select) => select.querySelector("wa-option[selected]")?.getAttribute("value")), + ).toEqual(["all", "all"]); const agentSelect = container.querySelector< HTMLElement & { accessibleLabel: string; value: string } >(".workboard-agent-select--toolbar"); @@ -2503,7 +2506,9 @@ describe("renderWorkboard", () => { expect(state.draftSessionKey).toBe(testCase.sessionKey); expect( [...container.querySelectorAll(".workboard-draft wa-select")].some( - (select) => select.getAttribute("value") === testCase.sessionKey, + (select) => + select.querySelector("wa-option[selected]")?.getAttribute("value") === + testCase.sessionKey, ), ).toBe(true); @@ -2646,6 +2651,7 @@ describe("renderWorkboard", () => { expect( [...(container.querySelector(".workboard-draft")?.querySelectorAll("wa-select") ?? [])] .at(1) + ?.querySelector("wa-option[selected]") ?.getAttribute("value"), ).toBe("high"); }); @@ -2796,7 +2802,9 @@ describe("renderWorkboard", () => { .querySelector(".workboard-draft") ?.querySelectorAll(".workboard-select") ?? []), ].at(2); - expect(sessionSelect?.getAttribute("value")).toBe("agent:main:archived-session"); + expect(sessionSelect?.querySelector("wa-option[selected]")?.getAttribute("value")).toBe( + "agent:main:archived-session", + ); expect( sessionSelect?.querySelector('wa-option[value="agent:main:archived-session"]'), ).not.toBeNull(); @@ -2834,8 +2842,8 @@ describe("renderWorkboard", () => { .querySelector(".workboard-draft") ?.querySelectorAll(".workboard-select") ?? []), ].at(2); - const labels = [...(sessionOptions?.querySelectorAll(".workboard-select__option") ?? [])].map( - (option) => option.textContent?.trim(), + const labels = [...(sessionOptions?.querySelectorAll("wa-option") ?? [])].map((option) => + option.textContent?.trim(), ); expect(labels).toContain("Dashboard session"); expect(labels).not.toContain("heartbeat"); diff --git a/ui/src/pages/workboard/workboard-select.ts b/ui/src/pages/workboard/workboard-select.ts index 6935f2002dd9..c2982834588a 100644 --- a/ui/src/pages/workboard/workboard-select.ts +++ b/ui/src/pages/workboard/workboard-select.ts @@ -1,11 +1,9 @@ import { html, nothing } from "lit"; +import { renderPicker, type PickerOption } from "../../components/select-picker.ts"; import { renderWorkboardBoardGlyph } from "../../components/workboard-board-glyph.ts"; -import "../../components/web-awesome-select.ts"; -export type WorkboardSelectOption = { +export type WorkboardSelectOption = PickerOption & { value: Value; - label: string; - description?: string; icon?: string; color?: string; boardId?: string; @@ -22,66 +20,26 @@ export function renderWorkboardSelect(params: { showLabel?: boolean; disabled?: boolean; }) { - const selectedOption = params.options.find((option) => option.value === params.value); - const select = html` - { - const value = (event.currentTarget as HTMLElement & { value?: string }).value as - | Value - | undefined; - if ( - value !== undefined && - params.options.some((option) => option.value === value && !option.disabled) - ) { - params.onChange(value); - params.requestUpdate?.(); - } - }} - > - ${selectedOption?.boardId - ? html`${renderWorkboardBoardGlyph({ - id: selectedOption.boardId, - name: selectedOption.label, - icon: selectedOption.icon, - color: selectedOption.color, - })}` - : nothing} - ${params.options.map( - (option) => html` - - ${option.boardId - ? html`${renderWorkboardBoardGlyph({ - id: option.boardId, - name: option.label, - icon: option.icon, - color: option.color, - })}` - : nothing} - - ${option.label} - ${option.description - ? html`${option.description}` - : nothing} - - - `, - )} - - `; + const select = renderPicker({ + value: params.value, + options: params.options, + label: params.label, + className: `workboard-select ${params.className ?? ""}`, + disabled: params.disabled, + renderLeading: (option) => + option.boardId + ? renderWorkboardBoardGlyph({ + id: option.boardId, + name: option.label, + icon: option.icon, + color: option.color, + }) + : nothing, + onChange: (value) => { + params.onChange(value as Value); + params.requestUpdate?.(); + }, + }); if (params.showLabel === false) { return select; } diff --git a/ui/src/styles/channels.css b/ui/src/styles/channels.css index 1c38e130bac9..9d3126acfd2a 100644 --- a/ui/src/styles/channels.css +++ b/ui/src/styles/channels.css @@ -20,9 +20,9 @@ .channels-tile { display: grid; - width: 44px; - height: 44px; - flex: 0 0 44px; + width: var(--channels-art-size, 44px); + height: var(--channels-art-size, 44px); + flex: 0 0 var(--channels-art-size, 44px); place-items: center; overflow: hidden; border: 1px solid var(--border); diff --git a/ui/src/styles/settings.css b/ui/src/styles/settings.css index 46a554fa20f6..e75580439df7 100644 --- a/ui/src/styles/settings.css +++ b/ui/src/styles/settings.css @@ -797,6 +797,39 @@ wa-select.settings-select wa-option:state(current) { color: var(--primary-foreground); } +.picker-select__option { + color: var(--text); +} + +.picker-select [slot="start"] { + display: inline-flex; + align-items: center; +} + +.picker-select__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 650; +} + +.picker-select__copy { + display: grid; + gap: 2px; + min-width: 0; +} + +.picker-select__description { + min-width: 0; + overflow: hidden; + color: var(--muted); + font-size: 0.75rem; + line-height: 1.2; + text-overflow: ellipsis; + white-space: normal; +} + /* Forced colors suppresses the box-shadow ring and accent fill that stand in for the outlines removed above, so restore system-color outlines there. */ @media (forced-colors: active) { diff --git a/ui/src/styles/workboard.css b/ui/src/styles/workboard.css index f1a8be897c7b..48d4fb4c3ab4 100644 --- a/ui/src/styles/workboard.css +++ b/ui/src/styles/workboard.css @@ -428,39 +428,6 @@ inset 0 1px 0 color-mix(in srgb, white 5%, transparent); } -.workboard-select__option { - color: var(--text); -} - -.workboard-select [slot="start"] { - display: inline-flex; - align-items: center; -} - -.workboard-select__label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: 650; -} - -.workboard-select__copy { - display: grid; - gap: 2px; - min-width: 0; -} - -.workboard-select__description { - min-width: 0; - overflow: hidden; - color: var(--muted); - font-size: 0.75rem; - line-height: 1.2; - text-overflow: ellipsis; - white-space: normal; -} - .workboard-field--wide { grid-column: 1 / -1; }