From f9bafcc240fe53d31c35cdac92a579236fc619cf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 9 Jul 2026 14:55:25 +0100 Subject: [PATCH] feat(ui): show agent avatars in the agents page selector (#102817) Replace the native type-ahead: + // repeating one letter cycles its matches, mixed letters accumulate a prefix. + private typeaheadQuery = ""; + private typeaheadResetTimer: ReturnType | undefined; + + private focusTypeaheadOption(key: string) { + clearTimeout(this.typeaheadResetTimer); + const normalizedKey = key.toLocaleLowerCase(); + const accumulated = `${this.typeaheadQuery}${normalizedKey}`; + this.typeaheadQuery = + accumulated === normalizedKey.repeat(accumulated.length) ? normalizedKey : accumulated; + this.typeaheadResetTimer = setTimeout(() => { + this.typeaheadQuery = ""; + }, 500); + + const options = this.options(); + const activeIndex = options.indexOf(document.activeElement as HTMLButtonElement); + const ordered = [...options.slice(activeIndex + 1), ...options.slice(0, activeIndex + 1)]; + const match = ordered.find((option) => + option + .querySelector(".agent-select__option-label") + ?.textContent?.trim() + .toLocaleLowerCase() + .startsWith(this.typeaheadQuery), + ); + match?.focus(); + } + + private options() { + return Array.from(this.querySelectorAll(".agent-select__option")); + } + + private trigger() { + return this.querySelector(".agent-select__trigger"); + } + + private focusSelectedOption() { + const selected = this.querySelector( + '.agent-select__option[aria-selected="true"]', + ); + (selected ?? this.querySelector(".agent-select__option"))?.focus(); + } + + private readonly toggle = () => { + if (this.disabled || this.agents.length === 0) { + return; + } + this.setOpen(!this.open); + }; + + private choose(agentId: string) { + this.setOpen(false); + this.trigger()?.focus(); + if (agentId !== this.selectedId) { + this.onSelect(agentId); + } + } + + private renderAvatar(agent: GatewayAgentRow) { + const identity = this.identityById[agent.id] ?? null; + const url = resolveAgentAvatarUrl(agent, identity); + const imageUrl = url ? this.resolveRenderableAvatarUrl(url) : null; + if (imageUrl) { + return html``; + } + const text = resolveAgentTextAvatar(agent, identity); + const fallback = (normalizeAgentLabel(agent)[0] ?? "?").toUpperCase(); + return html` + + `; + } + + private resolveRenderableAvatarUrl(url: string): string | null { + if (!this.authToken || !url.startsWith("/")) { + return url; + } + const cached = this.avatarBlobUrlByRoute.get(url); + if (cached !== undefined) { + return cached || null; + } + this.ensureLocalAvatar(url, this.authToken); + return null; + } + + override render() { + const selectedAgent = + this.agents.find((agent) => agent.id === this.selectedId) ?? + this.agents.find((agent) => agent.id === this.defaultId) ?? + this.agents[0]; + const selectedBadge = selectedAgent ? agentBadgeText(selectedAgent.id, this.defaultId) : null; + + return html` +
+ + ${this.open + ? html` +
+ ${this.agents.map((agent) => { + const badge = agentBadgeText(agent.id, this.defaultId); + const selected = agent.id === this.selectedId; + return html` + + `; + })} +
+ ` + : nothing} +
+ `; + } +} + +if (!customElements.get("openclaw-agent-select")) { + customElements.define("openclaw-agent-select", AgentSelect); +} diff --git a/ui/src/e2e/agents-set-default-persistence.e2e.test.ts b/ui/src/e2e/agents-set-default-persistence.e2e.test.ts index 7ae82c25e12b..1312ca6065ea 100644 --- a/ui/src/e2e/agents-set-default-persistence.e2e.test.ts +++ b/ui/src/e2e/agents-set-default-persistence.e2e.test.ts @@ -86,10 +86,11 @@ describeControlUiE2e("Control UI agents Set Default mocked Gateway E2E", () => { const response = await page.goto(`${server.baseUrl}agents`); expect(response?.status()).toBe(200); - // selectOption / click auto-wait for the element to be actionable (enabled), so + // Click auto-waits for the elements to be actionable (enabled), so // these implicitly assert the dropdown loaded and Set Default is clickable for a // non-default agent. - await page.locator("select.agents-select").selectOption("kimi"); + await page.locator(".agent-select__trigger").click(); + await page.getByRole("option", { name: "Kimi agent" }).click(); await page.getByRole("button", { name: "Set Default", exact: true }).click(); // The fix routes Set Default through the canonical save path; without it the click diff --git a/ui/src/lib/agents/display.ts b/ui/src/lib/agents/display.ts index 135cb900b3c3..38fb4cbb5361 100644 --- a/ui/src/lib/agents/display.ts +++ b/ui/src/lib/agents/display.ts @@ -208,7 +208,7 @@ export function assistantAvatarFallbackUrl(basePath: string): string { return controlUiPublicAssetPath("apple-touch-icon.png", basePath); } -function resolveAgentTextAvatar( +export function resolveAgentTextAvatar( agent: { identity?: { emoji?: string; avatar?: string } }, agentIdentity?: AgentIdentityResult | null, ): string | null { diff --git a/ui/src/pages/agents/agents-page.ts b/ui/src/pages/agents/agents-page.ts index 66504ce6b8d7..e04720d03b0a 100644 --- a/ui/src/pages/agents/agents-page.ts +++ b/ui/src/pages/agents/agents-page.ts @@ -17,6 +17,7 @@ import { type ApplicationContext, type ApplicationGatewaySnapshot, } from "../../app/context.ts"; +import { resolveControlUiAuthToken } from "../../app/control-ui-auth.ts"; import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; import { resolveAgentConfig, @@ -396,6 +397,17 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { ); } + // Local /avatar/ images need a bearer credential when gateway auth is + // active; the agent select uses this to decide whether URLs can load. + private controlUiAuthToken(): string | null { + const { snapshot, connection } = this.context.gateway; + return resolveControlUiAuthToken({ + hello: snapshot.hello, + settings: connection, + password: connection.password, + }); + } + private ensureInitialData() { if (!this.connected || !this.client || !this.routeDataInitialized) { return; @@ -711,6 +723,7 @@ class AgentsPage extends OpenClawLightDomElement implements AgentsState { ${renderSettingsWorkspace( renderAgents({ basePath: this.context.basePath, + authToken: this.controlUiAuthToken(), loading: this.agentsLoading, error: this.agentsError, agentsList: this.agentsList, diff --git a/ui/src/pages/agents/view.test.ts b/ui/src/pages/agents/view.test.ts index 55f8ba33c3d0..bff77120dd8b 100644 --- a/ui/src/pages/agents/view.test.ts +++ b/ui/src/pages/agents/view.test.ts @@ -58,6 +58,7 @@ function expectAgentTab(container: Element, text: string): HTMLButtonElement { function createProps(overrides: Partial = {}): AgentsProps { return { basePath: "", + authToken: null, loading: false, error: null, agentsList: { @@ -146,6 +147,28 @@ function createProps(overrides: Partial = {}): AgentsProps { } describe("renderAgents", () => { + it("renders the custom agent select with the provided agents and selected label", async () => { + const container = document.createElement("div"); + document.body.append(container); + + try { + render(renderAgents(createProps()), container); + const select = container.querySelector("openclaw-agent-select") as + | (HTMLElement & { + agents: Array<{ id: string }>; + updateComplete: Promise; + }) + | null; + expect(select).not.toBeNull(); + await select?.updateComplete; + + expect(select?.agents).toHaveLength(2); + expect(select?.querySelector(".agent-select__label")?.textContent?.trim()).toBe("Beta"); + } finally { + container.remove(); + } + }); + it("selects the configured primary model on initial render", async () => { const container = document.createElement("div"); const configForm = { diff --git a/ui/src/pages/agents/view.ts b/ui/src/pages/agents/view.ts index f451db6a547a..782c87a62817 100644 --- a/ui/src/pages/agents/view.ts +++ b/ui/src/pages/agents/view.ts @@ -1,6 +1,7 @@ // Control UI view renders agents screen content. import { html, nothing } from "lit"; import { keyed } from "lit/directives/keyed.js"; +import "../../components/agent-select.ts"; import type { AgentIdentityResult, AgentsFilesListResult, @@ -14,11 +15,7 @@ import type { ToolsEffectiveResult, } from "../../api/types.ts"; import { t } from "../../i18n/index.ts"; -import { - agentBadgeText, - buildAgentContext, - normalizeAgentLabel, -} from "../../lib/agents/display.ts"; +import { buildAgentContext } from "../../lib/agents/display.ts"; import type { AgentsPanel } from "../../lib/agents/index.ts"; import { renderAgentOverview } from "./panels-overview.ts"; import { renderAgentFiles, renderAgentChannels, renderAgentCron } from "./panels-status-files.ts"; @@ -77,6 +74,7 @@ type ToolsEffectiveState = { type AgentsProps = { basePath: string; + authToken: string | null; loading: boolean; error: string | null; agentsList: AgentsListResult | null; @@ -150,24 +148,15 @@ export function renderAgents(props: AgentsProps) {
- +
${selectedAgent diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 2a5e8b1f4be3..e3650bded621 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -3792,35 +3792,157 @@ td.data-table-key-col { max-width: 280px; } -.agents-select { +.agent-select { + position: relative; width: 100%; - padding: 7px 32px 7px 10px; +} + +.agent-select__trigger { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; border: 1px solid var(--border-strong); border-radius: var(--radius-md); - background-color: var(--bg-accent); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 8px center; + background: var(--bg-accent); + color: var(--text); + font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; outline: none; - appearance: none; + text-align: left; transition: border-color var(--duration-fast) ease, box-shadow var(--duration-fast) ease; } -:root[data-theme-mode="light"] .agents-select { - background-color: white; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23444' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); +:root[data-theme-mode="light"] .agent-select__trigger { + background: white; } -.agents-select:focus-visible { +.agent-select__trigger:focus-visible { border-color: var(--accent); box-shadow: var(--focus-ring); } +.agent-select__trigger:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.agent-select__label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-select__chevron { + display: inline-flex; + flex: 0 0 auto; + color: var(--muted); +} + +.agent-select__chevron svg, +.agent-select__check svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 1.7px; + stroke-linecap: round; + stroke-linejoin: round; +} + +.agent-select__avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + flex: 0 0 auto; + object-fit: cover; +} + +.agent-select__avatar--text { + background: var(--secondary); + font-size: 11px; + font-weight: 600; +} + +.agent-select__badge { + flex: 0 0 auto; + padding: 1px 6px; + border: 1px solid var(--border); + border-radius: var(--radius-full); + color: var(--muted); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.agent-select__list { + position: absolute; + z-index: 121; + top: calc(100% + 4px); + right: 0; + left: 0; + max-height: 320px; + overflow-y: auto; + padding: 6px; + border: 1px solid color-mix(in srgb, var(--border-strong) 78%, transparent); + border-radius: var(--radius-lg); + background: var(--bg-elevated); + box-shadow: 0 18px 40px color-mix(in srgb, black 26%, transparent); +} + +.agent-select__option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 32px; + padding: 4px 8px; + border: none; + border-radius: var(--radius-md); + background: transparent; + color: var(--text); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.agent-select__option:hover, +.agent-select__option:focus-visible { + background: color-mix(in srgb, var(--bg-hover) 84%, transparent); + outline: none; +} + +.agent-select__option-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.agent-select__option .agent-select__avatar { + width: 24px; + height: 24px; +} + +.agent-select__check { + display: inline-flex; + flex: 0 0 auto; + color: var(--accent); +} + .agents-toolbar-actions { display: flex; align-items: center;