fix(control-ui): wire slash menu accessibility

Wire the Control UI chat slash-command menu to the composer with stable listbox and option IDs, active-descendant updates, and a live status announcement. Keep the native textarea role conforming while preserving the menu relationships and tests.
This commit is contained in:
Val Alexander
2026-04-30 04:53:27 -05:00
committed by GitHub
parent 099037cca6
commit 20cbc1f216
4 changed files with 248 additions and 17 deletions

View File

@@ -158,6 +158,7 @@ Docs: https://docs.openclaw.ai
- Channels/Discord: treat bare numeric outbound targets that match the effective Discord DM allowlist as user DMs while preserving account-specific legacy `dm.allowFrom` precedence over inherited root `allowFrom`. (#74303) Thanks @Squirbie.
- Channels/Discord/Slack: share one DM policy/allowlist resolver across runtime, setup, allowlist editing, and doctor repair, so legacy `dm.policy` / `dm.allowFrom` compatibility migrates to canonical `dmPolicy` / `allowFrom` without divergent access checks. Thanks @Squirbie.
- Control UI: make the chat sidebar split divider focusable, keyboard-resizable, ARIA-described, and pointer-event based so sidebar resizing works without a mouse. Thanks @BunsDev.
- Control UI/chat: wire the slash-command autocomplete menu to the composer with stable ARIA relationships so screen readers announce the active command or argument option. Thanks @BunsDev.
- Agents/usage: keep PI embedded-run telemetry attributed to the resolved model provider instead of the PI harness label, so OpenRouter and other provider-backed turns report the right provider in session usage and traces. Thanks @vincentkoc.
- Agents/attribution: send OpenClaw attribution headers on native OpenAI and Codex traffic, including SDK transports, realtime voice and TTS, device-code auth, WHAM usage, and remote embeddings, so PI-origin defaults no longer leak into provider requests. Thanks @vincentkoc.
- Agents/auth: keep OAuth auth profiles inherited from the main agent read-through instead of copying refresh tokens into secondary agents, and refresh Codex app-server tokens against the owning store so multi-agent swarms avoid reused refresh-token failures. Fixes #74055. Thanks @ClarityInvest.

View File

@@ -549,7 +549,24 @@
}
}
.agent-chat__input > textarea {
.agent-chat__composer-combobox {
display: flex;
flex-direction: column;
}
.agent-chat__sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.agent-chat__composer-combobox > textarea {
width: 100%;
min-height: 40px;
max-height: 150px;
@@ -565,11 +582,11 @@
box-sizing: border-box;
}
.agent-chat__input > textarea:focus-visible {
.agent-chat__composer-combobox > textarea:focus-visible {
box-shadow: none;
}
.agent-chat__input > textarea::placeholder {
.agent-chat__composer-combobox > textarea::placeholder {
color: var(--muted);
}

View File

@@ -19,7 +19,7 @@ import { renderChatSessionSelect } from "../chat/session-controls.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ModelCatalogEntry } from "../types.ts";
import type { ChatQueueItem } from "../ui-types.ts";
import { renderChat } from "./chat.ts";
import { renderChat, resetChatViewState } from "./chat.ts";
const refreshVisibleToolsEffectiveForCurrentSessionMock = vi.hoisted(() =>
vi.fn(async (state: AppViewState) => {
@@ -392,6 +392,7 @@ function renderChatView(overrides: Partial<Parameters<typeof renderChat>[0]> = {
afterEach(() => {
loadSessionsMock.mockClear();
refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear();
resetChatViewState();
resetChatAttachmentPayloadStoreForTest();
vi.unstubAllGlobals();
});
@@ -452,6 +453,134 @@ describe("chat voice controls", () => {
});
});
describe("chat slash menu accessibility", () => {
function inputDraft(container: HTMLElement, value: string) {
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
expect(textarea).not.toBeNull();
textarea!.value = value;
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
}
function keydownComposer(container: HTMLElement, key: string) {
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
expect(textarea).not.toBeNull();
textarea!.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
}
it("wires command suggestions to the composer with stable active option ids", () => {
let draft = "";
const onDraftChange = vi.fn((next: string) => {
draft = next;
});
let container = renderChatView({ draft, onDraftChange });
inputDraft(container, "/");
container = renderChatView({ draft, onDraftChange });
const wrapper = container.querySelector<HTMLElement>(".agent-chat__composer-combobox");
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
const listbox = container.querySelector<HTMLElement>("#chat-slash-menu-listbox");
const activeId = textarea?.getAttribute("aria-activedescendant");
expect(wrapper?.hasAttribute("role")).toBe(false);
expect(wrapper?.hasAttribute("aria-expanded")).toBe(false);
expect(wrapper?.hasAttribute("aria-haspopup")).toBe(false);
expect(wrapper?.hasAttribute("aria-controls")).toBe(false);
expect(textarea?.hasAttribute("role")).toBe(false);
expect(textarea?.hasAttribute("aria-expanded")).toBe(false);
expect(textarea?.hasAttribute("aria-haspopup")).toBe(false);
expect(textarea?.getAttribute("aria-controls")).toBe("chat-slash-menu-listbox");
expect(textarea?.getAttribute("aria-autocomplete")).toBe("list");
expect(listbox?.getAttribute("role")).toBe("listbox");
expect(activeId).toMatch(/^chat-slash-option-command-/u);
expect(listbox?.querySelector(`#${activeId}`)?.getAttribute("role")).toBe("option");
});
it("updates the active descendant and live announcement during command navigation", () => {
let draft = "";
const onDraftChange = vi.fn((next: string) => {
draft = next;
});
let container = renderChatView({ draft, onDraftChange });
inputDraft(container, "/");
container = renderChatView({ draft, onDraftChange });
const initialActiveId = container
.querySelector<HTMLTextAreaElement>("textarea")
?.getAttribute("aria-activedescendant");
keydownComposer(container, "ArrowDown");
container = renderChatView({ draft, onDraftChange });
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
const nextActiveId = textarea?.getAttribute("aria-activedescendant");
const activeOption = nextActiveId
? container.querySelector<HTMLElement>(`#${nextActiveId}`)
: null;
const status = container.querySelector<HTMLElement>("#chat-slash-active-announcement");
expect(nextActiveId).toBeTruthy();
expect(nextActiveId).not.toBe(initialActiveId);
expect(activeOption?.getAttribute("aria-selected")).toBe("true");
expect(status?.getAttribute("aria-live")).toBe("polite");
expect(status?.textContent?.trim()).toBeTruthy();
expect(status?.textContent).toContain(activeOption?.textContent?.trim().split(/\s+/u)[0]);
});
it("wires fixed argument suggestions with command-and-argument option ids", () => {
let draft = "";
const onDraftChange = vi.fn((next: string) => {
draft = next;
});
let container = renderChatView({ draft, onDraftChange });
inputDraft(container, "/tools ");
container = renderChatView({ draft, onDraftChange });
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
const listbox = container.querySelector<HTMLElement>("#chat-slash-menu-listbox");
const activeId = textarea?.getAttribute("aria-activedescendant");
expect(listbox?.getAttribute("aria-label")).toBe("Command arguments");
expect(activeId).toBe("chat-slash-option-arg-tools-compact");
expect(listbox?.querySelector(`#${activeId}`)?.getAttribute("aria-selected")).toBe("true");
});
it("clears active descendant when suggestions close", () => {
let draft = "";
const onDraftChange = vi.fn((next: string) => {
draft = next;
});
let container = renderChatView({ draft, onDraftChange });
inputDraft(container, "/");
container = renderChatView({ draft, onDraftChange });
expect(
container
.querySelector<HTMLTextAreaElement>("textarea")
?.getAttribute("aria-activedescendant"),
).toBeTruthy();
inputDraft(container, "plain message");
container = renderChatView({ draft, onDraftChange });
expect(container.querySelector(".slash-menu")).toBeNull();
expect(
container.querySelector<HTMLTextAreaElement>("textarea")?.hasAttribute("aria-expanded"),
).toBe(false);
expect(
container
.querySelector<HTMLElement>(".agent-chat__composer-combobox")
?.hasAttribute("aria-expanded"),
).toBe(false);
expect(
container
.querySelector<HTMLTextAreaElement>("textarea")
?.hasAttribute("aria-activedescendant"),
).toBe(false);
});
});
describe("chat attachment picker", () => {
it("accepts and previews non-video file attachments", async () => {
const onAttachmentsChange = vi.fn();

View File

@@ -1,4 +1,5 @@
import { html, nothing, type TemplateResult } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { ref } from "lit/directives/ref.js";
import { repeat } from "lit/directives/repeat.js";
import { t } from "../../i18n/index.ts";
@@ -132,6 +133,8 @@ export type ChatProps = {
const pinnedMessagesMap = new Map<string, PinnedMessages>();
const deletedMessagesMap = new Map<string, DeletedMessages>();
const SLASH_MENU_LISTBOX_ID = "chat-slash-menu-listbox";
const SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID = "chat-slash-active-announcement";
function getPinnedMessages(sessionKey: string): PinnedMessages {
return getOrCreateSessionCacheValue(
@@ -478,6 +481,63 @@ function selectSlashArg(
}
}
function slashOptionIdSegment(value: string): string {
return (
value
.toLowerCase()
.replace(/[^a-z0-9_-]+/gu, "-")
.replace(/^-+|-+$/gu, "") || "item"
);
}
function getSlashCommandOptionId(cmd: SlashCommandDef): string {
return `chat-slash-option-command-${slashOptionIdSegment(cmd.name)}`;
}
function getSlashArgOptionId(commandName: string, arg: string): string {
return `chat-slash-option-arg-${slashOptionIdSegment(commandName)}-${slashOptionIdSegment(arg)}`;
}
function isSlashMenuVisible(): boolean {
if (!vs.slashMenuOpen) {
return false;
}
if (vs.slashMenuMode === "args") {
return Boolean(vs.slashMenuCommand && vs.slashMenuArgItems.length > 0);
}
return vs.slashMenuItems.length > 0;
}
function getActiveSlashMenuOptionId(): string | null {
if (!isSlashMenuVisible()) {
return null;
}
if (vs.slashMenuMode === "args") {
const commandName = vs.slashMenuCommand?.name;
const arg = vs.slashMenuArgItems[vs.slashMenuIndex];
return commandName && arg ? getSlashArgOptionId(commandName, arg) : null;
}
const cmd = vs.slashMenuItems[vs.slashMenuIndex];
return cmd ? getSlashCommandOptionId(cmd) : null;
}
function getActiveSlashMenuOptionLabel(): string {
if (!isSlashMenuVisible()) {
return "";
}
if (vs.slashMenuMode === "args") {
const commandName = vs.slashMenuCommand?.name;
const arg = vs.slashMenuArgItems[vs.slashMenuIndex];
return commandName && arg ? `/${commandName} ${arg}` : "";
}
const cmd = vs.slashMenuItems[vs.slashMenuIndex];
if (!cmd) {
return "";
}
const command = `/${cmd.name}${cmd.args ? ` ${cmd.args}` : ""}`;
return `${command} ${cmd.description}`;
}
function tokenEstimate(draft: string): string | null {
if (draft.length < 100) {
return null;
@@ -605,7 +665,12 @@ function renderSlashMenu(
// Arg-picker mode: show options for the selected command
if (vs.slashMenuMode === "args" && vs.slashMenuCommand && vs.slashMenuArgItems.length > 0) {
return html`
<div class="slash-menu" role="listbox" aria-label="Command arguments">
<div
id=${SLASH_MENU_LISTBOX_ID}
class="slash-menu"
role="listbox"
aria-label="Command arguments"
>
<div class="slash-menu-group">
<div class="slash-menu-group__label">
/${vs.slashMenuCommand.name} ${vs.slashMenuCommand.description}
@@ -613,6 +678,7 @@ function renderSlashMenu(
${vs.slashMenuArgItems.map(
(arg, i) => html`
<div
id=${getSlashArgOptionId(vs.slashMenuCommand?.name ?? "", arg)}
class="slash-menu-item ${i === vs.slashMenuIndex ? "slash-menu-item--active" : ""}"
role="option"
aria-selected=${i === vs.slashMenuIndex}
@@ -666,6 +732,7 @@ function renderSlashMenu(
${entries.map(
({ cmd, globalIdx }) => html`
<div
id=${getSlashCommandOptionId(cmd)}
class="slash-menu-item ${globalIdx === vs.slashMenuIndex
? "slash-menu-item--active"
: ""}"
@@ -696,7 +763,7 @@ function renderSlashMenu(
const hiddenCount = vs.slashMenuExpanded ? 0 : getHiddenCommandCount();
return html`
<div class="slash-menu" role="listbox" aria-label="Slash commands">
<div id=${SLASH_MENU_LISTBOX_ID} class="slash-menu" role="listbox" aria-label="Slash commands">
${sections}
${hiddenCount > 0
? html`<button
@@ -1032,6 +1099,9 @@ export function renderChat(props: ChatProps) {
updateSlashMenu(target.value, requestUpdate);
props.onDraftChange(target.value);
};
const slashMenuVisible = isSlashMenuVisible();
const activeSlashMenuOptionId = getActiveSlashMenuOptionId();
const activeSlashMenuOptionLabel = getActiveSlashMenuOptionLabel();
return html`
<section
@@ -1142,17 +1212,31 @@ export function renderChat(props: ChatProps) {
`
: nothing}
<textarea
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
.value=${props.draft}
dir=${detectTextDirection(props.draft)}
?disabled=${!props.connected}
@keydown=${handleKeyDown}
@input=${handleInput}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${placeholder}
rows="1"
></textarea>
<div class="agent-chat__composer-combobox">
<textarea
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
.value=${props.draft}
dir=${detectTextDirection(props.draft)}
?disabled=${!props.connected}
aria-autocomplete="list"
aria-controls=${ifDefined(slashMenuVisible ? SLASH_MENU_LISTBOX_ID : undefined)}
aria-activedescendant=${ifDefined(activeSlashMenuOptionId ?? undefined)}
aria-describedby=${SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID}
@keydown=${handleKeyDown}
@input=${handleInput}
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
placeholder=${placeholder}
rows="1"
></textarea>
<span
id=${SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID}
class="agent-chat__sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>${activeSlashMenuOptionLabel}</span
>
</div>
<div class="agent-chat__toolbar">
<div class="agent-chat__toolbar-left">