mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
2d5dd6c035
commit
f9bafcc240
8 changed files with 839 additions and 37 deletions
325
ui/src/components/agent-select.test.ts
Normal file
325
ui/src/components/agent-select.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
329
ui/src/components/agent-select.ts
Normal file
329
ui/src/components/agent-select.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue