mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:10:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user