feat(ui): show agent avatars in the agents page selector (#102817)

Replace the native <select> on the Agents page with an accessible custom
listbox dropdown (openclaw-agent-select) that renders each agent's avatar
image, identity emoji, or initial. Local /avatar/<id> routes are fetched
with the bearer credential and rendered as cached blob URLs when gateway
token auth is active, matching the chat-avatar contract.

Refs #102792, #57067
This commit is contained in:
Peter Steinberger 2026-07-09 14:55:25 +01:00 committed by GitHub
parent 2d5dd6c035
commit f9bafcc240
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 839 additions and 37 deletions

View file

@ -0,0 +1,325 @@
/* @vitest-environment jsdom */
import { expect, it, vi } from "vitest";
import type { AgentIdentityResult, GatewayAgentRow } from "../api/types.ts";
import "./agent-select.ts";
type AgentSelectElement = HTMLElement & {
agents: GatewayAgentRow[];
selectedId: string | null;
defaultId: string | null;
identityById: Record<string, AgentIdentityResult>;
authToken: string | null;
disabled: boolean;
onSelect: (agentId: string) => void;
updateComplete: Promise<boolean>;
};
const agents: GatewayAgentRow[] = [
{ id: "alpha", name: "Alpha agent" },
{ id: "beta", name: "Beta agent" },
];
function createIdentity(
agentId: string,
overrides: Partial<AgentIdentityResult>,
): AgentIdentityResult {
return {
agentId,
name: "",
avatar: "",
...overrides,
};
}
async function createAgentSelect(
overrides: Partial<Omit<AgentSelectElement, keyof HTMLElement>> = {},
): Promise<AgentSelectElement> {
const element = document.createElement("openclaw-agent-select") as AgentSelectElement;
element.agents = agents;
element.selectedId = "alpha";
Object.assign(element, overrides);
document.body.append(element);
await element.updateComplete;
return element;
}
it("renders the selected label and a data URL image avatar", async () => {
const dataUrl = "data:image/png;base64,x";
const element = await createAgentSelect({
identityById: { alpha: createIdentity("alpha", { avatar: dataUrl }) },
});
try {
expect(element.querySelector(".agent-select__label")?.textContent?.trim()).toBe("Alpha agent");
expect(element.querySelector<HTMLImageElement>("img.agent-select__avatar")?.src).toContain(
dataUrl,
);
} finally {
element.remove();
}
});
it("renders an emoji text avatar when no image URL is available", async () => {
const element = await createAgentSelect({
identityById: { alpha: createIdentity("alpha", { emoji: "🦉" }) },
});
try {
expect(element.querySelector(".agent-select__avatar--text")?.textContent?.trim()).toBe("🦉");
expect(element.querySelector("img.agent-select__avatar")).toBeNull();
} finally {
element.remove();
}
});
it("falls back to the uppercase agent initial", async () => {
const element = await createAgentSelect();
try {
expect(element.querySelector(".agent-select__avatar--text")?.textContent?.trim()).toBe("A");
} finally {
element.remove();
}
});
it("fetches local avatars with the bearer credential when token auth is active", async () => {
const createObjectURL = vi.fn(() => "blob:agent-avatar");
const revokeObjectURL = vi.fn();
vi.stubGlobal(
"URL",
class extends URL {
static override createObjectURL = createObjectURL;
static override revokeObjectURL = revokeObjectURL;
},
);
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
blob: async () => new Blob(["avatar"]),
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const element = await createAgentSelect({
authToken: "tok",
identityById: { alpha: createIdentity("alpha", { avatar: "/avatar/alpha" }) },
});
try {
// Text fallback renders while the authenticated fetch is in flight.
expect(element.querySelector(".agent-select__avatar--text")?.textContent?.trim()).toBe("A");
expect(fetchMock).toHaveBeenCalledWith("/avatar/alpha", {
headers: { Authorization: "Bearer tok" },
});
await vi.waitFor(() => {
expect(
element.querySelector<HTMLImageElement>("img.agent-select__avatar")?.getAttribute("src"),
).toBe("blob:agent-avatar");
});
expect(createObjectURL).toHaveBeenCalledTimes(1);
element.remove();
expect(revokeObjectURL).toHaveBeenCalledWith("blob:agent-avatar");
} finally {
element.remove();
vi.unstubAllGlobals();
}
});
it("refetches a failed local avatar after the auth credential rotates", async () => {
vi.stubGlobal(
"URL",
class extends URL {
static override createObjectURL = vi.fn(() => "blob:rotated-avatar");
static override revokeObjectURL = vi.fn();
},
);
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: false })
.mockResolvedValue({ ok: true, blob: async () => new Blob(["avatar"]) });
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const element = await createAgentSelect({
authToken: "tok",
identityById: { alpha: createIdentity("alpha", { avatar: "/avatar/alpha" }) },
});
try {
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expect(element.querySelector("img.agent-select__avatar")).toBeNull();
element.authToken = "tok2";
await element.updateComplete;
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith("/avatar/alpha", {
headers: { Authorization: "Bearer tok2" },
});
expect(
element.querySelector<HTMLImageElement>("img.agent-select__avatar")?.getAttribute("src"),
).toBe("blob:rotated-avatar");
});
} finally {
element.remove();
vi.unstubAllGlobals();
}
});
it("renders a local avatar image when token auth is not active", async () => {
const element = await createAgentSelect({
authToken: null,
identityById: { alpha: createIdentity("alpha", { avatar: "/avatar/alpha" }) },
});
try {
expect(element.querySelector<HTMLImageElement>("img.agent-select__avatar")?.src).toContain(
"/avatar/alpha",
);
} finally {
element.remove();
}
});
it("opens a listbox with selection state and a default badge", async () => {
const element = await createAgentSelect({ defaultId: "beta" });
try {
element.querySelector<HTMLButtonElement>(".agent-select__trigger")?.click();
await element.updateComplete;
const listbox = element.querySelector('[role="listbox"]');
const options = Array.from(
element.querySelectorAll<HTMLButtonElement>('.agent-select__option[role="option"]'),
);
expect(listbox).not.toBeNull();
expect(options).toHaveLength(2);
expect(options[0]?.getAttribute("aria-selected")).toBe("true");
expect(options[1]?.getAttribute("aria-selected")).toBe("false");
expect(options[1]?.querySelector(".agent-select__badge")?.textContent?.trim()).toBe("default");
expect(document.activeElement).toBe(options[0]);
} finally {
element.remove();
}
});
it("supports trigger and listbox keyboard navigation", async () => {
const element = await createAgentSelect();
try {
const trigger = element.querySelector<HTMLButtonElement>(".agent-select__trigger");
trigger?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
await element.updateComplete;
const options = Array.from(
element.querySelectorAll<HTMLButtonElement>(".agent-select__option"),
);
// Options are focused programmatically, never sequential tab stops.
expect(options.every((option) => option.tabIndex === -1)).toBe(true);
expect(document.activeElement).toBe(options[0]);
options[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
expect(document.activeElement).toBe(options[1]);
options[1]?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
expect(document.activeElement).toBe(options[0]);
options[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
expect(document.activeElement).toBe(options[1]);
options[1]?.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
await element.updateComplete;
expect(element.querySelector('[role="listbox"]')).toBeNull();
// Tab hands focus back to the trigger so sequential navigation continues.
expect(document.activeElement).toBe(trigger);
} finally {
element.remove();
}
});
it("jumps focus to a matching agent via printable-key type-ahead", async () => {
const element = await createAgentSelect();
try {
element.querySelector<HTMLButtonElement>(".agent-select__trigger")?.click();
await element.updateComplete;
const options = Array.from(
element.querySelectorAll<HTMLButtonElement>(".agent-select__option"),
);
expect(document.activeElement).toBe(options[0]);
options[0]?.dispatchEvent(new KeyboardEvent("keydown", { key: "b", bubbles: true }));
expect(document.activeElement).toBe(options[1]);
// Accumulated prefix keeps matching the same agent instead of cycling.
options[1]?.dispatchEvent(new KeyboardEvent("keydown", { key: "e", bubbles: true }));
expect(document.activeElement).toBe(options[1]);
} finally {
element.remove();
}
});
it("selects a different agent and ignores the already-selected agent", async () => {
const onSelect = vi.fn<(agentId: string) => void>();
const element = await createAgentSelect({ onSelect });
try {
const trigger = element.querySelector<HTMLButtonElement>(".agent-select__trigger");
trigger?.click();
await element.updateComplete;
element.querySelector<HTMLButtonElement>('[data-agent-id="beta"]')?.click();
await element.updateComplete;
expect(onSelect).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith("beta");
expect(element.querySelector('[role="listbox"]')).toBeNull();
trigger?.click();
await element.updateComplete;
element.querySelector<HTMLButtonElement>('[data-agent-id="alpha"]')?.click();
await element.updateComplete;
expect(onSelect).toHaveBeenCalledOnce();
expect(element.querySelector('[role="listbox"]')).toBeNull();
} finally {
element.remove();
}
});
it("closes on Escape or outside pointerdown and refocuses the trigger on Escape", async () => {
const element = await createAgentSelect();
try {
const trigger = element.querySelector<HTMLButtonElement>(".agent-select__trigger");
trigger?.click();
await element.updateComplete;
element
.querySelector(".agent-select__list")
?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await element.updateComplete;
expect(element.querySelector('[role="listbox"]')).toBeNull();
expect(document.activeElement).toBe(trigger);
trigger?.click();
await element.updateComplete;
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true, composed: true }));
await element.updateComplete;
expect(element.querySelector('[role="listbox"]')).toBeNull();
} finally {
element.remove();
}
});
it("renders a disabled trigger with the empty-state label", async () => {
const element = await createAgentSelect({ agents: [], selectedId: null });
try {
const trigger = element.querySelector<HTMLButtonElement>(".agent-select__trigger");
expect(trigger?.disabled).toBe(true);
expect(element.querySelector(".agent-select__label")?.textContent?.trim()).toBe("No agents");
} finally {
element.remove();
}
});

View file

@ -0,0 +1,329 @@
import { LitElement, html, nothing, type PropertyValues } from "lit";
import { property, state } from "lit/decorators.js";
import type { AgentIdentityResult, GatewayAgentRow } from "../api/types.ts";
import { t } from "../i18n/index.ts";
import {
agentBadgeText,
normalizeAgentLabel,
resolveAgentTextAvatar,
} from "../lib/agents/display.ts";
import { resolveAgentAvatarUrl } from "../lib/avatar.ts";
import { icons } from "./icons.ts";
class AgentSelect extends LitElement {
override createRenderRoot() {
return this;
}
@property({ attribute: false }) agents: GatewayAgentRow[] = [];
@property({ attribute: false }) selectedId: string | null = null;
@property({ attribute: false }) defaultId: string | null = null;
@property({ attribute: false }) identityById: Record<string, AgentIdentityResult> = {};
@property({ attribute: false }) authToken: string | null = null;
@property({ attribute: false }) disabled = false;
@property({ attribute: false }) onSelect: (agentId: string) => void = () => {};
@state() private open = false;
override connectedCallback() {
super.connectedCallback();
document.addEventListener("pointerdown", this.handleDocumentPointerDown, true);
}
override disconnectedCallback() {
document.removeEventListener("pointerdown", this.handleDocumentPointerDown, true);
clearTimeout(this.typeaheadResetTimer);
this.releaseAvatarBlobUrls();
super.disconnectedCallback();
}
// Local /avatar/<id> routes require the bearer credential when gateway auth
// is active and <img> cannot send headers, so fetch them and render blob
// URLs (same single-credential contract as chat-avatar.ts). "" marks a
// failed fetch so we do not retry every render.
private readonly avatarBlobUrlByRoute = new Map<string, string>();
private readonly avatarRoutesPending = new Set<string>();
protected override willUpdate(changed: PropertyValues<this>) {
// Cached blobs and failures belong to the credential that fetched them;
// a rotated token (e.g. device token after reconnect) must refetch.
if (changed.has("authToken")) {
this.releaseAvatarBlobUrls();
}
}
private releaseAvatarBlobUrls() {
for (const blobUrl of this.avatarBlobUrlByRoute.values()) {
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
}
this.avatarBlobUrlByRoute.clear();
this.avatarRoutesPending.clear();
}
private ensureLocalAvatar(url: string, authToken: string) {
if (this.avatarRoutesPending.has(url)) {
return;
}
this.avatarRoutesPending.add(url);
void fetch(url, { headers: { Authorization: `Bearer ${authToken}` } })
.then(async (res) => (res.ok ? URL.createObjectURL(await res.blob()) : ""))
.catch(() => "")
.then((blobUrl) => {
this.avatarRoutesPending.delete(url);
// Drop stale results: the element may be gone or the credential may
// have rotated while this request was in flight.
if (!this.isConnected || this.authToken !== authToken) {
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
return;
}
this.avatarBlobUrlByRoute.set(url, blobUrl);
if (blobUrl) {
this.requestUpdate();
}
});
}
// Owns the open transition: focus must move into the listbox only after the
// options exist in the DOM, so wait for the post-toggle render.
private setOpen(next: boolean) {
if (this.open === next) {
return;
}
this.open = next;
if (next) {
void this.updateComplete.then(() => this.focusSelectedOption());
return;
}
clearTimeout(this.typeaheadResetTimer);
this.typeaheadQuery = "";
}
private readonly handleDocumentPointerDown = (event: PointerEvent) => {
if (!this.open || event.composedPath().includes(this)) {
return;
}
this.setOpen(false);
};
private readonly handleTriggerKeydown = (event: KeyboardEvent) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
return;
}
event.preventDefault();
this.setOpen(true);
};
private readonly handleListboxKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
this.setOpen(false);
this.trigger()?.focus();
return;
}
if (event.key === "Tab") {
// Options are tabindex=-1, so hand focus back to the trigger before the
// default Tab moves on; otherwise the list unmounts under the focused
// option and focus falls to <body>.
this.setOpen(false);
this.trigger()?.focus();
return;
}
if (event.key === " ") {
// Space activates the focused option button natively.
return;
}
if (
event.key.length === 1 &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.isComposing
) {
event.preventDefault();
this.focusTypeaheadOption(event.key);
return;
}
const options = this.options();
if (options.length === 0) {
return;
}
const currentIndex = options.indexOf(document.activeElement as HTMLButtonElement);
let nextIndex: number;
if (event.key === "ArrowDown") {
nextIndex = Math.min(currentIndex + 1, options.length - 1);
} else if (event.key === "ArrowUp") {
nextIndex = Math.max(currentIndex - 1, 0);
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = options.length - 1;
} else {
return;
}
event.preventDefault();
options[nextIndex]?.focus();
};
// Buffered printable-key search, matching the native <select> type-ahead:
// repeating one letter cycles its matches, mixed letters accumulate a prefix.
private typeaheadQuery = "";
private typeaheadResetTimer: ReturnType<typeof setTimeout> | 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<HTMLButtonElement>(".agent-select__option"));
}
private trigger() {
return this.querySelector<HTMLButtonElement>(".agent-select__trigger");
}
private focusSelectedOption() {
const selected = this.querySelector<HTMLButtonElement>(
'.agent-select__option[aria-selected="true"]',
);
(selected ?? this.querySelector<HTMLButtonElement>(".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`<img class="agent-select__avatar" src=${imageUrl} alt="" loading="lazy" />`;
}
const text = resolveAgentTextAvatar(agent, identity);
const fallback = (normalizeAgentLabel(agent)[0] ?? "?").toUpperCase();
return html`
<span class="agent-select__avatar agent-select__avatar--text" aria-hidden="true"
>${text ?? fallback}</span
>
`;
}
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`
<div class="agent-select">
<button
type="button"
class="agent-select__trigger"
aria-haspopup="listbox"
aria-expanded=${String(this.open)}
?disabled=${this.disabled || this.agents.length === 0}
@click=${this.toggle}
@keydown=${this.handleTriggerKeydown}
>
${selectedAgent
? html`
${this.renderAvatar(selectedAgent)}
<span class="agent-select__label">${normalizeAgentLabel(selectedAgent)}</span>
${selectedBadge
? html`<span class="agent-select__badge">${selectedBadge}</span>`
: nothing}
`
: html`<span class="agent-select__label">${t("agents.noAgents")}</span>`}
<span class="agent-select__chevron" aria-hidden="true">${icons.chevronDown}</span>
</button>
${this.open
? html`
<div
class="agent-select__list"
role="listbox"
aria-label=${t("agents.selectTitle")}
@keydown=${this.handleListboxKeydown}
>
${this.agents.map((agent) => {
const badge = agentBadgeText(agent.id, this.defaultId);
const selected = agent.id === this.selectedId;
return html`
<button
type="button"
class="agent-select__option"
role="option"
aria-selected=${String(selected)}
data-agent-id=${agent.id}
tabindex="-1"
@click=${() => this.choose(agent.id)}
>
${this.renderAvatar(agent)}
<span class="agent-select__option-label">${normalizeAgentLabel(agent)}</span>
${badge ? html`<span class="agent-select__badge">${badge}</span>` : nothing}
${selected
? html`<span class="agent-select__check" aria-hidden="true"
>${icons.check}</span
>`
: nothing}
</button>
`;
})}
</div>
`
: nothing}
</div>
`;
}
}
if (!customElements.get("openclaw-agent-select")) {
customElements.define("openclaw-agent-select", AgentSelect);
}

View file

@ -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

View file

@ -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 {

View file

@ -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/<id> images need a bearer credential when gateway auth is
// active; the agent select uses this to decide whether <img> 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,

View file

@ -58,6 +58,7 @@ function expectAgentTab(container: Element, text: string): HTMLButtonElement {
function createProps(overrides: Partial<AgentsProps> = {}): AgentsProps {
return {
basePath: "",
authToken: null,
loading: false,
error: null,
agentsList: {
@ -146,6 +147,28 @@ function createProps(overrides: Partial<AgentsProps> = {}): 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<boolean>;
})
| 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 = {

View file

@ -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) {
<section class="agents-toolbar">
<div class="agents-toolbar-row">
<div class="agents-control-select">
<select
class="agents-select"
.value=${selectedId ?? ""}
?disabled=${props.loading || agents.length === 0}
@change=${(e: Event) => props.onSelectAgent((e.target as HTMLSelectElement).value)}
>
${agents.length === 0
? html` <option value="">${t("agents.noAgents")}</option> `
: agents.map(
(agent) => html`
<option value=${agent.id} ?selected=${agent.id === selectedId}>
${normalizeAgentLabel(agent)}${agentBadgeText(agent.id, defaultId)
? ` (${agentBadgeText(agent.id, defaultId)})`
: ""}
</option>
`,
)}
</select>
<openclaw-agent-select
.agents=${agents}
.selectedId=${selectedId}
.defaultId=${defaultId}
.identityById=${props.agentIdentityById}
.authToken=${props.authToken}
.disabled=${props.loading}
.onSelect=${props.onSelectAgent}
></openclaw-agent-select>
</div>
<div class="agents-toolbar-actions">
${selectedAgent

View file

@ -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;