diff --git a/ui/src/components/agent-select.test.ts b/ui/src/components/agent-select.test.ts new file mode 100644 index 000000000000..c99594d67a74 --- /dev/null +++ b/ui/src/components/agent-select.test.ts @@ -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; + authToken: string | null; + disabled: boolean; + onSelect: (agentId: string) => void; + updateComplete: Promise; +}; + +const agents: GatewayAgentRow[] = [ + { id: "alpha", name: "Alpha agent" }, + { id: "beta", name: "Beta agent" }, +]; + +function createIdentity( + agentId: string, + overrides: Partial, +): AgentIdentityResult { + return { + agentId, + name: "", + avatar: "", + ...overrides, + }; +} + +async function createAgentSelect( + overrides: Partial> = {}, +): Promise { + 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("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("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("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("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(".agent-select__trigger")?.click(); + await element.updateComplete; + + const listbox = element.querySelector('[role="listbox"]'); + const options = Array.from( + element.querySelectorAll('.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(".agent-select__trigger"); + trigger?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + await element.updateComplete; + + const options = Array.from( + element.querySelectorAll(".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(".agent-select__trigger")?.click(); + await element.updateComplete; + const options = Array.from( + element.querySelectorAll(".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(".agent-select__trigger"); + trigger?.click(); + await element.updateComplete; + element.querySelector('[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('[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(".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(".agent-select__trigger"); + expect(trigger?.disabled).toBe(true); + expect(element.querySelector(".agent-select__label")?.textContent?.trim()).toBe("No agents"); + } finally { + element.remove(); + } +}); diff --git a/ui/src/components/agent-select.ts b/ui/src/components/agent-select.ts new file mode 100644 index 000000000000..207aa6dc821b --- /dev/null +++ b/ui/src/components/agent-select.ts @@ -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 = {}; + @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/ routes require the bearer credential when gateway auth + // is active and 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(); + private readonly avatarRoutesPending = new Set(); + + protected override willUpdate(changed: PropertyValues) { + // 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 . + 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 props.onSelectAgent((e.target as HTMLSelectElement).value)} - > - ${agents.length === 0 - ? html` ` - : agents.map( - (agent) => html` - - `, - )} - +
${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;