fix(ui): unify channel pickers with icons

Preserve channel values and mutation ownership across Automations, pairing, setup, and Workboard while sharing accessible picker rendering and artwork.
This commit is contained in:
Peter Steinberger
2026-08-12 18:23:41 -07:00
parent 239087d448
commit 610e2eee78
21 changed files with 482 additions and 281 deletions
+30
View File
@@ -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`<span
class=${`channels-${artVariant}${art ? "" : ` channels-${artVariant}--fallback`}`}
style=${style}
aria-hidden="true"
>
${art
? html`<img src=${art} alt="" loading="lazy" decoding="async" />`
: html`<span>${pluginMonogram(label)}</span>`}
</span>`;
}
+64
View File
@@ -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<HTMLElement & { value: string }>("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");
});
});
+17
View File
@@ -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<ChannelPickerOption>) {
return renderPicker({
...params,
className: "channel-picker",
renderLeading: (option) =>
option.kind === "neutral" ? nothing : renderChannelIcon(option.value, option.label, "picker"),
});
}
+71
View File
@@ -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<Option extends PickerOption> = {
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<Option extends PickerOption>(params: PickerParams<Option>) {
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`<span slot="start">${content}</span>`;
};
return html`
<wa-select
id=${params.id ?? nothing}
class=${`settings-select picker-select ${params.className ?? ""}`}
style="width:100%;min-width:0"
.value=${params.value}
?disabled=${params.disabled}
@change=${(event: Event) => {
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);
}
}}
>
<span slot="label" class="settings-control__sr-label">${params.label}</span>
${leading(options.find((option) => option.value === params.value))}
${options.map(
(option) => html`
<wa-option
class="picker-select__option"
value=${option.value}
.label=${option.label}
?selected=${option.value === params.value}
?disabled=${option.disabled}
>
${leading(option)}
<span class="picker-select__copy">
<span class="picker-select__label">${option.label}</span>
${option.description
? html`<span class="picker-select__description">${option.description}</span>`
: nothing}
</span>
</wa-option>
`,
)}
</wa-select>
`;
}
+34 -37
View File
@@ -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`<wa-radio
class="channels-wizard__option"
appearance="button"
value=${String(index)}
.checked=${checked}
>
${renderOptionBody(option, props.presentation)}
</wa-radio>`
: html`<button
type="button"
class="channels-wizard__option"
aria-pressed=${checked ? "true" : "false"}
?disabled=${props.busy}
@click=${() => props.onValueChange(option.value)}
>
${renderOptionBody(option, props.presentation, checked)}
</button>`;
return html`<button
type="button"
class="channels-wizard__option"
aria-pressed=${checked ? "true" : "false"}
?disabled=${props.busy}
@click=${() => props.onValueChange(option.value)}
>
${renderOptionBody(option, props.presentation, checked)}
</button>`;
}
return html`<label class="wizard-step__option">
<input
@@ -242,24 +235,28 @@ function renderOptionsStep(props: WizardStepControlsProps) {
const selected = multiple ? (Array.isArray(props.value) ? props.value : []) : [props.value];
if (props.presentation === "channels" && !multiple) {
const selectedIndex = options.findIndex((option) => Object.is(option.value, props.value));
const channels =
props.channelSelect && options.every((option) => typeof option.value === "string");
const picker = channels ? renderChannelPicker : renderPicker;
return html`
<wa-radio-group
class="channels-wizard__options"
label=${props.step.message ?? ""}
orientation="vertical"
.value=${selectedIndex >= 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))}
</wa-radio-group>
${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)}
<div class=${stepClass(props, "options")} role=${multiple ? nothing : "radiogroup"}>
${options.map((option, index) => renderOption(props, option, index, selected))}
${options.map((option) => renderOption(props, option, selected))}
</div>
${renderAnswerButton(
props,
@@ -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<unknown> };
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");
+18 -6
View File
@@ -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");
},
);
});
-26
View File
@@ -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`<span class="channels-${variant}">
<img src=${art} alt="" loading="lazy" decoding="async" />
</span>`;
}
const [from, to] = pluginFallbackGradient(channelId);
const monogram = pluginMonogram(label);
return html`<span
class="channels-${variant} channels-${variant}--fallback"
style=${`--channels-art-a:${from};--channels-art-b:${to}`}
aria-hidden="true"
>
<span>${monogram}</span>
</span>`;
}
+3 -2
View File
@@ -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: {
<openclaw-modal-dialog label=${params.label} @modal-cancel=${() => params.onClose()}>
<div class="channels-detail">
<div class="channels-detail__header">
${renderChannelArt(params.channelId, params.label, "cover")}
${renderChannelIcon(params.channelId, params.label, "cover")}
<div class="channels-detail__header-actions">
<a
class="btn btn--sm"
+36 -3
View File
@@ -166,10 +166,43 @@ describe("channel DM access request views", () => {
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<HTMLElement & { value: string }>("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", () => {
+25 -25
View File
@@ -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) {
<div class="channels-pairing-filters">
<label>
<span>${t("channels.pairing.channelFilter")}</span>
<select
class="settings-select"
.value=${props.pairingChannelFilter ?? ""}
@change=${(event: Event) => props.onPairingFilterChange(selectValue(event), null)}
>
<option value="">${t("channels.pairing.allChannels")}</option>
${channels.map(([channel, label]) => html`<option value=${channel}>${label}</option>`)}
</select>
${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),
})}
</label>
<label>
<span>${t("channels.pairing.accountFilter")}</span>
<select
class="settings-select"
.value=${props.pairingAccountFilter ?? ""}
?disabled=${!props.pairingChannelFilter}
@change=${(event: Event) =>
props.onPairingFilterChange(props.pairingChannelFilter, selectValue(event))}
>
<option value="">${t("channels.pairing.allAccounts")}</option>
${accountsForChannel.map(
(account) => html`<option value=${account.accountId}>${accountName(account)}</option>`,
)}
</select>
${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),
})}
</label>
</div>
`;
+3 -3
View File
@@ -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")}
<div class="settings-row__text">
<span class="settings-row__title">${label}</span>
<span class="settings-row__desc">${description}</span>
@@ -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")}
<span class="settings-row__text">
<span class="settings-row__title">${label}</span>
<span class="settings-row__desc">${description}</span>
@@ -118,11 +118,9 @@ describe("renderChannelWizard busy controls", () => {
{ label: "Beta", value: "beta" },
],
});
const group = select.container.querySelector<HTMLElement & { disabled: boolean }>(
"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<HTMLElement & { disabled: boolean }>("wa-radio-group")
?.disabled,
).toBe(false);
const picker = select.container.querySelector("wa-select");
expect(picker?.hasAttribute("disabled")).toBe(false);
});
});
+4 -4
View File
@@ -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(
>
<div class="channels-wizard">
<div class="channels-wizard__header">
${channel ? renderChannelArt(channel, label, "tile") : nothing}
${channel ? renderChannelIcon(channel, label, "tile") : nothing}
<div class="channels-wizard__heading">
<h2>${t("channels.setup.title", { channel: label })}</h2>
<div class="muted">${t("channels.setup.subtitle")}</div>
+64 -19
View File
@@ -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<HTMLElement & { value: string }>("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", () => {
+29 -38
View File
@@ -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`
<select
id=${ifDefined(options.standalone ? undefined : inputIdForField(field))}
class="settings-select"
.value=${selected}
aria-label=${ifDefined(options.standalone ? options.label : undefined)}
?disabled=${options.disabled ?? false}
@change=${(event: Event) =>
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`<option value=${value} ?selected=${value === selected}>${label}</option>`,
)}
</select>
`;
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"),
+14 -6
View File
@@ -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<HTMLElement>(".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<HTMLElement>(".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");
+22 -64
View File
@@ -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<Value extends string = string> = {
export type WorkboardSelectOption<Value extends string = string> = PickerOption & {
value: Value;
label: string;
description?: string;
icon?: string;
color?: string;
boardId?: string;
@@ -22,66 +20,26 @@ export function renderWorkboardSelect<Value extends string>(params: {
showLabel?: boolean;
disabled?: boolean;
}) {
const selectedOption = params.options.find((option) => option.value === params.value);
const select = html`
<wa-select
class="workboard-select ${params.className ?? ""}"
label=${params.label}
value=${params.value}
?disabled=${params.disabled}
@change=${(event: Event) => {
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`<span slot="start"
>${renderWorkboardBoardGlyph({
id: selectedOption.boardId,
name: selectedOption.label,
icon: selectedOption.icon,
color: selectedOption.color,
})}</span
>`
: nothing}
${params.options.map(
(option) => html`
<wa-option
class="workboard-select__option"
value=${option.value}
.label=${option.label}
?selected=${option.value === params.value}
?disabled=${option.disabled}
>
${option.boardId
? html`<span slot="start"
>${renderWorkboardBoardGlyph({
id: option.boardId,
name: option.label,
icon: option.icon,
color: option.color,
})}</span
>`
: nothing}
<span class="workboard-select__copy">
<span class="workboard-select__label">${option.label}</span>
${option.description
? html`<span class="workboard-select__description">${option.description}</span>`
: nothing}
</span>
</wa-option>
`,
)}
</wa-select>
`;
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;
}
+3 -3
View File
@@ -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);
+33
View File
@@ -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) {
-33
View File
@@ -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;
}