
























@@ -19,7 +19,7 @@ import { renderChatSessionSelect } from "../chat/session-controls.ts";
1919import type { GatewayBrowserClient } from "../gateway.ts";
2020import type { ModelCatalogEntry } from "../types.ts";
2121import type { ChatQueueItem } from "../ui-types.ts";
22-import { renderChat } from "./chat.ts";
22+import { renderChat, resetChatViewState } from "./chat.ts";
23232424const refreshVisibleToolsEffectiveForCurrentSessionMock = vi.hoisted(() =>
2525vi.fn(async (state: AppViewState) => {
@@ -392,6 +392,7 @@ function renderChatView(overrides: Partial<Parameters<typeof renderChat>[0]> = {
392392afterEach(() => {
393393loadSessionsMock.mockClear();
394394refreshVisibleToolsEffectiveForCurrentSessionMock.mockClear();
395+resetChatViewState();
395396resetChatAttachmentPayloadStoreForTest();
396397vi.unstubAllGlobals();
397398});
@@ -452,6 +453,134 @@ describe("chat voice controls", () => {
452453});
453454});
454455456+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+455584describe("chat attachment picker", () => {
456585it("accepts and previews non-video file attachments", async () => {
457586const onAttachmentsChange = vi.fn();
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。