From edcf197483810e6eab2618a6bb7b83d13d4c0a7e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 21:32:51 -0700 Subject: [PATCH 01/23] improve: one channel picker and one model picker across the Control UI (#122964) * 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. * refactor(ui): unify model pickers Share provider-aware model selection across chat, new sessions, Automations, Agents, model defaults, Session Observer, Talk, and Memory while preserving owner-specific values and locks. * fix(ui): keep picker change callbacks value-only Keep the Web Awesome element private to model-picker custom-mode handling while preserving one-argument callbacks for every picker consumer. * fix(ui): satisfy model picker types Keep custom sentinel options within the shared model option shape and guard optional Memory placeholder translation keys. * refactor(ui): remove obsolete model option template import Drop the Lit import after Agents model options became pure shared-picker data. * fix(ui): satisfy picker lint gates Use explicit model option mapping, remove stale E2E locals, and keep the Cron behavior suite within its enforced line budget. * test(ui): target unified session model picker --- ui/src/components/channel-icon.ts | 30 + ui/src/components/channel-picker.test.ts | 64 ++ ui/src/components/channel-picker.ts | 17 + ui/src/components/model-picker.test.ts | 78 ++ ui/src/components/model-picker.ts | 99 +++ ui/src/components/select-picker.ts | 82 ++ .../web-awesome-migration.node.test.ts | 6 +- ui/src/components/wizard-step-controls.ts | 71 +- .../e2e/channels-whatsapp-logout.e2e.test.ts | 12 +- .../chat-composer-accessory-focus.e2e.test.ts | 11 +- ui/src/e2e/chat-composer-redesign.e2e.test.ts | 70 +- ui/src/e2e/chat-flow.follow-ups.e2e.test.ts | 8 +- .../chat-flow.models-reasoning.e2e.test.ts | 124 ++- ui/src/e2e/chat-only-model.e2e.test.ts | 45 +- ui/src/e2e/cron-filters.e2e.test.ts | 14 +- ui/src/e2e/cron-select-values.e2e.test.ts | 24 +- ui/src/e2e/model-alias-display.e2e.test.ts | 16 +- ui/src/e2e/model-providers.e2e.test.ts | 21 +- ...session-page.catalog-reconnect.e2e.test.ts | 31 +- .../e2e/new-session-page.places.e2e.test.ts | 4 +- ...-session-page.workspace-memory.e2e.test.ts | 145 ++-- ui/src/e2e/session-suggestions.e2e.test.ts | 10 +- ui/src/i18n/.i18n/raw-copy-baseline.json | 7 + ui/src/i18n/locales/en.ts | 1 + ui/src/lib/agents/display.ts | 49 +- ui/src/pages/agents/panels-overview.ts | 49 +- ui/src/pages/agents/view.test.ts | 54 +- ui/src/pages/channels/hub-meta.ts | 26 - ui/src/pages/channels/view.detail.ts | 5 +- ui/src/pages/channels/view.pairing.test.ts | 39 +- ui/src/pages/channels/view.pairing.ts | 50 +- ui/src/pages/channels/view.ts | 6 +- .../pages/channels/wizard-view.busy.test.ts | 12 +- ui/src/pages/channels/wizard-view.ts | 8 +- ui/src/pages/chat/chat-pane-lifecycle.ts | 2 - .../chat/chat-responsive.browser.test.ts | 25 +- ui/src/pages/chat/chat-view.test.ts | 349 +++------ .../chat/components/chat-model-controls.ts | 255 +++++-- .../chat/components/chat-model-picker.ts | 719 ------------------ ui/src/pages/config/memory-dreaming.test.ts | 8 + ui/src/pages/config/memory-dreaming.ts | 30 + .../config/session-observer-settings.test.ts | 16 +- .../pages/config/session-observer-settings.ts | 70 +- ui/src/pages/config/talk.test.ts | 58 +- ui/src/pages/config/talk.ts | 14 +- ui/src/pages/cron/view.test.ts | 91 ++- ui/src/pages/cron/view.ts | 101 +-- .../model-providers/default-models-view.ts | 149 ++-- ui/src/pages/model-providers/view.test.ts | 42 +- .../pages/new-session/model-control.test.ts | 153 ++-- ui/src/pages/new-session/model-control.ts | 48 +- ui/src/pages/new-session/new-session-page.ts | 4 +- ui/src/pages/workboard/view.test.ts | 20 +- ui/src/pages/workboard/workboard-select.ts | 86 +-- ui/src/styles/channels.css | 6 +- ui/src/styles/chat/layout.css | 348 ++------- ui/src/styles/layout.css | 7 +- ui/src/styles/settings.css | 53 ++ ui/src/styles/workboard.css | 33 - 59 files changed, 1727 insertions(+), 2248 deletions(-) create mode 100644 ui/src/components/channel-icon.ts create mode 100644 ui/src/components/channel-picker.test.ts create mode 100644 ui/src/components/channel-picker.ts create mode 100644 ui/src/components/model-picker.test.ts create mode 100644 ui/src/components/model-picker.ts create mode 100644 ui/src/components/select-picker.ts delete mode 100644 ui/src/pages/chat/components/chat-model-picker.ts 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``; +} 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/model-picker.test.ts b/ui/src/components/model-picker.test.ts new file mode 100644 index 000000000000..38340f8ca361 --- /dev/null +++ b/ui/src/components/model-picker.test.ts @@ -0,0 +1,78 @@ +/* @vitest-environment jsdom */ +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderModelPicker } from "./model-picker.ts"; + +describe("renderModelPicker", () => { + it("renders provider details and caller sentinels while preserving an unknown current model", () => { + const container = document.createElement("div"); + render( + renderModelPicker({ + label: "Model", + value: "legacy/model", + options: [ + { value: "", label: "Automatic" }, + { + value: "openai/gpt-5.6-luna", + label: "GPT-5.6 Luna", + provider: "openai", + detail: "Fast · 128k", + disabled: true, + }, + ], + onChange: vi.fn(), + }), + container, + ); + + expect(container.querySelector('wa-option[value=""] [slot="start"]')).toBeNull(); + const openai = container.querySelector('wa-option[value="openai/gpt-5.6-luna"]'); + expect(openai?.querySelector('[data-provider-icon="codex"]')).not.toBeNull(); + expect(openai?.textContent).toContain("Fast · 128k"); + expect(openai?.hasAttribute("disabled")).toBe(true); + expect(container.querySelector('wa-option[value="legacy/model"]')?.textContent).toContain( + "legacy/model", + ); + }); + + it("reveals free-form entry without leaking its internal option value", () => { + const container = document.createElement("div"); + const onChange = vi.fn(); + render( + renderModelPicker({ + label: "Model", + value: "openai/gpt-5.6-luna", + options: [ + { value: "", label: "Default" }, + { value: "openai/gpt-5.6-luna", label: "GPT-5.6 Luna", provider: "openai" }, + ], + custom: { label: "Custom model…", placeholder: "provider/model" }, + onChange, + }), + container, + ); + + const customOption = Array.from(container.querySelectorAll("wa-option")).find( + (option) => option.textContent?.trim() === "Custom model…", + ); + const picker = container.querySelector("wa-select"); + const input = container.querySelector("input"); + expect(customOption).not.toBeNull(); + expect(input?.hidden).toBe(true); + if (!customOption || !picker || !input) { + return; + } + Object.defineProperty(picker, "value", { + configurable: true, + value: customOption.getAttribute("value"), + }); + picker.dispatchEvent(new Event("change", { bubbles: true })); + Reflect.deleteProperty(picker, "value"); + expect(input.hidden).toBe(false); + + input.value = "vendor/model with spaces"; + input.dispatchEvent(new Event("input", { bubbles: true })); + expect(onChange).toHaveBeenCalledWith("vendor/model with spaces"); + expect(onChange).not.toHaveBeenCalledWith(customOption.getAttribute("value")); + }); +}); diff --git a/ui/src/components/model-picker.ts b/ui/src/components/model-picker.ts new file mode 100644 index 000000000000..56205b8b5b26 --- /dev/null +++ b/ui/src/components/model-picker.ts @@ -0,0 +1,99 @@ +import { html, nothing } from "lit"; +import { renderProviderBrandIcon } from "./provider-icon.ts"; +import { renderPicker } from "./select-picker.ts"; + +export type ModelPickerOption = { + value: string; + label: string; + provider?: string; + detail?: string; + disabled?: boolean; +}; + +type ModelPickerParams = { + id?: string; + label: string; + value: string; + options: readonly ModelPickerOption[]; + disabled?: boolean; + title?: string; + className?: string; + placement?: "top" | "bottom"; + custom?: { + label: string; + placeholder?: string; + commit?: "input" | "change"; + id?: string; + invalid?: boolean; + describedBy?: string; + }; + onChange: (value: string) => void; +}; + +export function renderModelPicker(params: ModelPickerParams) { + let customValue = "__openclaw_custom_model__"; + const values = new Set([params.value, ...params.options.map((option) => option.value)]); + while (values.has(customValue)) { + customValue += "_"; + } + const currentIsKnown = params.options.some((option) => option.value === params.value); + const options: Array = [ + ...params.options.map((option) => ({ ...option, description: option.detail })), + ...(params.custom ? [{ value: customValue, label: params.custom.label }] : []), + ]; + return html` +
+ ${renderPicker({ + id: params.id, + label: params.label, + value: params.value, + options, + disabled: params.disabled, + title: params.title, + placement: params.placement, + className: `model-picker__select ${params.className ?? ""}`, + renderLeading: (option) => + option.provider + ? renderProviderBrandIcon(option.provider, { className: "model-picker__provider-icon" }) + : nothing, + onChange: params.onChange, + onChangeTarget: (value, select) => { + const wrapper = select.closest(".model-picker"); + const input = wrapper?.querySelector(".model-picker__custom"); + if (value === customValue && input) { + input.hidden = false; + queueMicrotask(() => input.focus()); + return; + } + if (input) { + input.hidden = true; + } + params.onChange(value); + }, + })} + ${params.custom + ? html` { + if (params.custom?.commit !== "change") { + params.onChange((event.currentTarget as HTMLInputElement).value); + } + }} + @change=${(event: Event) => { + if (params.custom?.commit === "change") { + params.onChange((event.currentTarget as HTMLInputElement).value); + } + }} + />` + : nothing} +
+ `; +} diff --git a/ui/src/components/select-picker.ts b/ui/src/components/select-picker.ts new file mode 100644 index 000000000000..61853d6769de --- /dev/null +++ b/ui/src/components/select-picker.ts @@ -0,0 +1,82 @@ +import { html, nothing } from "lit"; +import "./web-awesome-select.ts"; + +export type PickerOption = { + value: string; + label: string; + description?: string; + disabled?: boolean; +}; + +export type PickerParams