Skip to content

Commit 20cbc1f

Browse files
authored
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.
1 parent 099037c commit 20cbc1f

4 files changed

Lines changed: 248 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ Docs: https://docs.openclaw.ai
158158
- 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.
159159
- 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.
160160
- 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.
161+
- 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.
161162
- 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.
162163
- 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.
163164
- 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.

ui/src/styles/chat/layout.css

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,24 @@
549549
}
550550
}
551551

552-
.agent-chat__input > textarea {
552+
.agent-chat__composer-combobox {
553+
display: flex;
554+
flex-direction: column;
555+
}
556+
557+
.agent-chat__sr-only {
558+
position: absolute;
559+
width: 1px;
560+
height: 1px;
561+
padding: 0;
562+
margin: -1px;
563+
overflow: hidden;
564+
clip: rect(0, 0, 0, 0);
565+
white-space: nowrap;
566+
border: 0;
567+
}
568+
569+
.agent-chat__composer-combobox > textarea {
553570
width: 100%;
554571
min-height: 40px;
555572
max-height: 150px;
@@ -565,11 +582,11 @@
565582
box-sizing: border-box;
566583
}
567584

568-
.agent-chat__input > textarea:focus-visible {
585+
.agent-chat__composer-combobox > textarea:focus-visible {
569586
box-shadow: none;
570587
}
571588

572-
.agent-chat__input > textarea::placeholder {
589+
.agent-chat__composer-combobox > textarea::placeholder {
573590
color: var(--muted);
574591
}
575592

ui/src/ui/views/chat.test.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { renderChatSessionSelect } from "../chat/session-controls.ts";
1919
import type { GatewayBrowserClient } from "../gateway.ts";
2020
import type { ModelCatalogEntry } from "../types.ts";
2121
import type { ChatQueueItem } from "../ui-types.ts";
22-
import { renderChat } from "./chat.ts";
22+
import { renderChat, resetChatViewState } from "./chat.ts";
2323

2424
const refreshVisibleToolsEffectiveForCurrentSessionMock = vi.hoisted(() =>
2525
vi.fn(async (state: AppViewState) => {
@@ -392,6 +392,7 @@ function renderChatView(overrides: Partial<Parameters<typeof renderChat>[0]> = {
392392
afterEach(() => {
393393
loadSessionsMock.mockClear();
394394
refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear();
395+
resetChatViewState();
395396
resetChatAttachmentPayloadStoreForTest();
396397
vi.unstubAllGlobals();
397398
});
@@ -452,6 +453,134 @@ describe("chat voice controls", () => {
452453
});
453454
});
454455

456+
describe("chat slash menu accessibility", () => {
457+
function inputDraft(container: HTMLElement, value: string) {
458+
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
459+
expect(textarea).not.toBeNull();
460+
textarea!.value = value;
461+
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
462+
}
463+
464+
function keydownComposer(container: HTMLElement, key: string) {
465+
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
466+
expect(textarea).not.toBeNull();
467+
textarea!.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
468+
}
469+
470+
it("wires command suggestions to the composer with stable active option ids", () => {
471+
let draft = "";
472+
const onDraftChange = vi.fn((next: string) => {
473+
draft = next;
474+
});
475+
let container = renderChatView({ draft, onDraftChange });
476+
477+
inputDraft(container, "/");
478+
container = renderChatView({ draft, onDraftChange });
479+
480+
const wrapper = container.querySelector<HTMLElement>(".agent-chat__composer-combobox");
481+
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
482+
const listbox = container.querySelector<HTMLElement>("#chat-slash-menu-listbox");
483+
const activeId = textarea?.getAttribute("aria-activedescendant");
484+
485+
expect(wrapper?.hasAttribute("role")).toBe(false);
486+
expect(wrapper?.hasAttribute("aria-expanded")).toBe(false);
487+
expect(wrapper?.hasAttribute("aria-haspopup")).toBe(false);
488+
expect(wrapper?.hasAttribute("aria-controls")).toBe(false);
489+
expect(textarea?.hasAttribute("role")).toBe(false);
490+
expect(textarea?.hasAttribute("aria-expanded")).toBe(false);
491+
expect(textarea?.hasAttribute("aria-haspopup")).toBe(false);
492+
expect(textarea?.getAttribute("aria-controls")).toBe("chat-slash-menu-listbox");
493+
expect(textarea?.getAttribute("aria-autocomplete")).toBe("list");
494+
expect(listbox?.getAttribute("role")).toBe("listbox");
495+
expect(activeId).toMatch(/^chat-slash-option-command-/u);
496+
expect(listbox?.querySelector(`#${activeId}`)?.getAttribute("role")).toBe("option");
497+
});
498+
499+
it("updates the active descendant and live announcement during command navigation", () => {
500+
let draft = "";
501+
const onDraftChange = vi.fn((next: string) => {
502+
draft = next;
503+
});
504+
let container = renderChatView({ draft, onDraftChange });
505+
506+
inputDraft(container, "/");
507+
container = renderChatView({ draft, onDraftChange });
508+
const initialActiveId = container
509+
.querySelector<HTMLTextAreaElement>("textarea")
510+
?.getAttribute("aria-activedescendant");
511+
512+
keydownComposer(container, "ArrowDown");
513+
container = renderChatView({ draft, onDraftChange });
514+
515+
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
516+
const nextActiveId = textarea?.getAttribute("aria-activedescendant");
517+
const activeOption = nextActiveId
518+
? container.querySelector<HTMLElement>(`#${nextActiveId}`)
519+
: null;
520+
const status = container.querySelector<HTMLElement>("#chat-slash-active-announcement");
521+
522+
expect(nextActiveId).toBeTruthy();
523+
expect(nextActiveId).not.toBe(initialActiveId);
524+
expect(activeOption?.getAttribute("aria-selected")).toBe("true");
525+
expect(status?.getAttribute("aria-live")).toBe("polite");
526+
expect(status?.textContent?.trim()).toBeTruthy();
527+
expect(status?.textContent).toContain(activeOption?.textContent?.trim().split(/\s+/u)[0]);
528+
});
529+
530+
it("wires fixed argument suggestions with command-and-argument option ids", () => {
531+
let draft = "";
532+
const onDraftChange = vi.fn((next: string) => {
533+
draft = next;
534+
});
535+
let container = renderChatView({ draft, onDraftChange });
536+
537+
inputDraft(container, "/tools ");
538+
container = renderChatView({ draft, onDraftChange });
539+
540+
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
541+
const listbox = container.querySelector<HTMLElement>("#chat-slash-menu-listbox");
542+
const activeId = textarea?.getAttribute("aria-activedescendant");
543+
544+
expect(listbox?.getAttribute("aria-label")).toBe("Command arguments");
545+
expect(activeId).toBe("chat-slash-option-arg-tools-compact");
546+
expect(listbox?.querySelector(`#${activeId}`)?.getAttribute("aria-selected")).toBe("true");
547+
});
548+
549+
it("clears active descendant when suggestions close", () => {
550+
let draft = "";
551+
const onDraftChange = vi.fn((next: string) => {
552+
draft = next;
553+
});
554+
let container = renderChatView({ draft, onDraftChange });
555+
556+
inputDraft(container, "/");
557+
container = renderChatView({ draft, onDraftChange });
558+
expect(
559+
container
560+
.querySelector<HTMLTextAreaElement>("textarea")
561+
?.getAttribute("aria-activedescendant"),
562+
).toBeTruthy();
563+
564+
inputDraft(container, "plain message");
565+
container = renderChatView({ draft, onDraftChange });
566+
567+
expect(container.querySelector(".slash-menu")).toBeNull();
568+
expect(
569+
container.querySelector<HTMLTextAreaElement>("textarea")?.hasAttribute("aria-expanded"),
570+
).toBe(false);
571+
expect(
572+
container
573+
.querySelector<HTMLElement>(".agent-chat__composer-combobox")
574+
?.hasAttribute("aria-expanded"),
575+
).toBe(false);
576+
expect(
577+
container
578+
.querySelector<HTMLTextAreaElement>("textarea")
579+
?.hasAttribute("aria-activedescendant"),
580+
).toBe(false);
581+
});
582+
});
583+
455584
describe("chat attachment picker", () => {
456585
it("accepts and previews non-video file attachments", async () => {
457586
const onAttachmentsChange = vi.fn();

ui/src/ui/views/chat.ts

Lines changed: 97 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { html, nothing, type TemplateResult } from "lit";
2+
import { ifDefined } from "lit/directives/if-defined.js";
23
import { ref } from "lit/directives/ref.js";
34
import { repeat } from "lit/directives/repeat.js";
45
import { t } from "../../i18n/index.ts";
@@ -132,6 +133,8 @@ export type ChatProps = {
132133

133134
const pinnedMessagesMap = new Map<string, PinnedMessages>();
134135
const deletedMessagesMap = new Map<string, DeletedMessages>();
136+
const SLASH_MENU_LISTBOX_ID = "chat-slash-menu-listbox";
137+
const SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID = "chat-slash-active-announcement";
135138

136139
function getPinnedMessages(sessionKey: string): PinnedMessages {
137140
return getOrCreateSessionCacheValue(
@@ -478,6 +481,63 @@ function selectSlashArg(
478481
}
479482
}
480483

484+
function slashOptionIdSegment(value: string): string {
485+
return (
486+
value
487+
.toLowerCase()
488+
.replace(/[^a-z0-9_-]+/gu, "-")
489+
.replace(/^-+|-+$/gu, "") || "item"
490+
);
491+
}
492+
493+
function getSlashCommandOptionId(cmd: SlashCommandDef): string {
494+
return `chat-slash-option-command-${slashOptionIdSegment(cmd.name)}`;
495+
}
496+
497+
function getSlashArgOptionId(commandName: string, arg: string): string {
498+
return `chat-slash-option-arg-${slashOptionIdSegment(commandName)}-${slashOptionIdSegment(arg)}`;
499+
}
500+
501+
function isSlashMenuVisible(): boolean {
502+
if (!vs.slashMenuOpen) {
503+
return false;
504+
}
505+
if (vs.slashMenuMode === "args") {
506+
return Boolean(vs.slashMenuCommand && vs.slashMenuArgItems.length > 0);
507+
}
508+
return vs.slashMenuItems.length > 0;
509+
}
510+
511+
function getActiveSlashMenuOptionId(): string | null {
512+
if (!isSlashMenuVisible()) {
513+
return null;
514+
}
515+
if (vs.slashMenuMode === "args") {
516+
const commandName = vs.slashMenuCommand?.name;
517+
const arg = vs.slashMenuArgItems[vs.slashMenuIndex];
518+
return commandName && arg ? getSlashArgOptionId(commandName, arg) : null;
519+
}
520+
const cmd = vs.slashMenuItems[vs.slashMenuIndex];
521+
return cmd ? getSlashCommandOptionId(cmd) : null;
522+
}
523+
524+
function getActiveSlashMenuOptionLabel(): string {
525+
if (!isSlashMenuVisible()) {
526+
return "";
527+
}
528+
if (vs.slashMenuMode === "args") {
529+
const commandName = vs.slashMenuCommand?.name;
530+
const arg = vs.slashMenuArgItems[vs.slashMenuIndex];
531+
return commandName && arg ? `/${commandName} ${arg}` : "";
532+
}
533+
const cmd = vs.slashMenuItems[vs.slashMenuIndex];
534+
if (!cmd) {
535+
return "";
536+
}
537+
const command = `/${cmd.name}${cmd.args ? ` ${cmd.args}` : ""}`;
538+
return `${command} ${cmd.description}`;
539+
}
540+
481541
function tokenEstimate(draft: string): string | null {
482542
if (draft.length < 100) {
483543
return null;
@@ -605,14 +665,20 @@ function renderSlashMenu(
605665
// Arg-picker mode: show options for the selected command
606666
if (vs.slashMenuMode === "args" && vs.slashMenuCommand && vs.slashMenuArgItems.length > 0) {
607667
return html`
608-
<div class="slash-menu" role="listbox" aria-label="Command arguments">
668+
<div
669+
id=${SLASH_MENU_LISTBOX_ID}
670+
class="slash-menu"
671+
role="listbox"
672+
aria-label="Command arguments"
673+
>
609674
<div class="slash-menu-group">
610675
<div class="slash-menu-group__label">
611676
/${vs.slashMenuCommand.name} ${vs.slashMenuCommand.description}
612677
</div>
613678
${vs.slashMenuArgItems.map(
614679
(arg, i) => html`
615680
<div
681+
id=${getSlashArgOptionId(vs.slashMenuCommand?.name ?? "", arg)}
616682
class="slash-menu-item ${i === vs.slashMenuIndex ? "slash-menu-item--active" : ""}"
617683
role="option"
618684
aria-selected=${i === vs.slashMenuIndex}
@@ -666,6 +732,7 @@ function renderSlashMenu(
666732
${entries.map(
667733
({ cmd, globalIdx }) => html`
668734
<div
735+
id=${getSlashCommandOptionId(cmd)}
669736
class="slash-menu-item ${globalIdx === vs.slashMenuIndex
670737
? "slash-menu-item--active"
671738
: ""}"
@@ -696,7 +763,7 @@ function renderSlashMenu(
696763
const hiddenCount = vs.slashMenuExpanded ? 0 : getHiddenCommandCount();
697764

698765
return html`
699-
<div class="slash-menu" role="listbox" aria-label="Slash commands">
766+
<div id=${SLASH_MENU_LISTBOX_ID} class="slash-menu" role="listbox" aria-label="Slash commands">
700767
${sections}
701768
${hiddenCount > 0
702769
? html`<button
@@ -1032,6 +1099,9 @@ export function renderChat(props: ChatProps) {
10321099
updateSlashMenu(target.value, requestUpdate);
10331100
props.onDraftChange(target.value);
10341101
};
1102+
const slashMenuVisible = isSlashMenuVisible();
1103+
const activeSlashMenuOptionId = getActiveSlashMenuOptionId();
1104+
const activeSlashMenuOptionLabel = getActiveSlashMenuOptionLabel();
10351105

10361106
return html`
10371107
<section
@@ -1142,17 +1212,31 @@ export function renderChat(props: ChatProps) {
11421212
`
11431213
: nothing}
11441214
1145-
<textarea
1146-
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
1147-
.value=${props.draft}
1148-
dir=${detectTextDirection(props.draft)}
1149-
?disabled=${!props.connected}
1150-
@keydown=${handleKeyDown}
1151-
@input=${handleInput}
1152-
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
1153-
placeholder=${placeholder}
1154-
rows="1"
1155-
></textarea>
1215+
<div class="agent-chat__composer-combobox">
1216+
<textarea
1217+
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
1218+
.value=${props.draft}
1219+
dir=${detectTextDirection(props.draft)}
1220+
?disabled=${!props.connected}
1221+
aria-autocomplete="list"
1222+
aria-controls=${ifDefined(slashMenuVisible ? SLASH_MENU_LISTBOX_ID : undefined)}
1223+
aria-activedescendant=${ifDefined(activeSlashMenuOptionId ?? undefined)}
1224+
aria-describedby=${SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID}
1225+
@keydown=${handleKeyDown}
1226+
@input=${handleInput}
1227+
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
1228+
placeholder=${placeholder}
1229+
rows="1"
1230+
></textarea>
1231+
<span
1232+
id=${SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID}
1233+
class="agent-chat__sr-only"
1234+
role="status"
1235+
aria-live="polite"
1236+
aria-atomic="true"
1237+
>${activeSlashMenuOptionLabel}</span
1238+
>
1239+
</div>
11561240
11571241
<div class="agent-chat__toolbar">
11581242
<div class="agent-chat__toolbar-left">

0 commit comments

Comments
 (0)