diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b28f39b889..544c8c31b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 0ab920ec8fc..79813602a16 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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); } diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 0abf3d54a1a..eca9f273619 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -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[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("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("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(".agent-chat__composer-combobox"); + const textarea = container.querySelector("textarea"); + const listbox = container.querySelector("#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("textarea") + ?.getAttribute("aria-activedescendant"); + + keydownComposer(container, "ArrowDown"); + container = renderChatView({ draft, onDraftChange }); + + const textarea = container.querySelector("textarea"); + const nextActiveId = textarea?.getAttribute("aria-activedescendant"); + const activeOption = nextActiveId + ? container.querySelector(`#${nextActiveId}`) + : null; + const status = container.querySelector("#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("textarea"); + const listbox = container.querySelector("#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("textarea") + ?.getAttribute("aria-activedescendant"), + ).toBeTruthy(); + + inputDraft(container, "plain message"); + container = renderChatView({ draft, onDraftChange }); + + expect(container.querySelector(".slash-menu")).toBeNull(); + expect( + container.querySelector("textarea")?.hasAttribute("aria-expanded"), + ).toBe(false); + expect( + container + .querySelector(".agent-chat__composer-combobox") + ?.hasAttribute("aria-expanded"), + ).toBe(false); + expect( + container + .querySelector("textarea") + ?.hasAttribute("aria-activedescendant"), + ).toBe(false); + }); +}); + describe("chat attachment picker", () => { it("accepts and previews non-video file attachments", async () => { const onAttachmentsChange = vi.fn(); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index d2084571c37..1553bc5b1b0 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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(); const deletedMessagesMap = new Map(); +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` -
+
/${vs.slashMenuCommand.name} ${vs.slashMenuCommand.description} @@ -613,6 +678,7 @@ function renderSlashMenu( ${vs.slashMenuArgItems.map( (arg, i) => html`
html`
+
${sections} ${hiddenCount > 0 ? html`