From ed5cd4768086dd6ae672c7a95d6b46a3db17dd6a Mon Sep 17 00:00:00 2001 From: Andrew Date: Sat, 5 Sep 2026 12:02:01 -0400 Subject: [PATCH 1/6] fix(web): announce composer suggestions to screen readers --- .../src/components/ComposerPromptEditor.tsx | 27 ++++++- apps/web/src/components/chat/ChatComposer.tsx | 76 ++++++++++++++++--- .../chat/ComposerCommandMenu.test.tsx | 3 + .../components/chat/ComposerCommandMenu.tsx | 15 +++- .../chat/composerMenuHighlight.test.ts | 17 +++++ 5 files changed, 122 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 6b49ef884734..5ca8df8e4813 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -26,6 +26,7 @@ import { KEY_ARROW_UP_COMMAND, KEY_DOWN_COMMAND, KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, KEY_TAB_COMMAND, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, @@ -918,6 +919,8 @@ interface ComposerPromptEditorProps { skills: ReadonlyArray; disabled: boolean; placeholder: string; + suggestionListId?: string | undefined; + activeSuggestionId?: string | undefined; containerClassName?: string; className?: string; placeholderClassName?: string; @@ -931,7 +934,7 @@ interface ComposerPromptEditorProps { ) => void; onVisibleSelectionChange?: () => void; onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape", event: KeyboardEvent, ) => boolean; onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; @@ -1004,7 +1007,7 @@ function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { function ComposerCommandKeyPlugin(props: { onCommandKeyDown?: ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape", event: KeyboardEvent, ) => boolean; }) { @@ -1012,7 +1015,7 @@ function ComposerCommandKeyPlugin(props: { useEffect(() => { const handleCommand = ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape", event: KeyboardEvent | null, ): boolean => { if (!props.onCommandKeyDown || !event) { @@ -1052,12 +1055,18 @@ function ComposerCommandKeyPlugin(props: { (event) => handleCommand("Tab", event), COMMAND_PRIORITY_HIGH, ); + const unregisterEscape = editor.registerCommand( + KEY_ESCAPE_COMMAND, + (event) => handleCommand("Escape", event), + COMMAND_PRIORITY_HIGH, + ); return () => { unregisterArrowDown(); unregisterArrowUp(); unregisterEnter(); unregisterTab(); + unregisterEscape(); }; }, [editor, props]); @@ -1635,6 +1644,8 @@ function ComposerPromptEditorInner({ skills, disabled, placeholder, + suggestionListId, + activeSuggestionId, containerClassName, className, placeholderClassName, @@ -1983,6 +1994,12 @@ function ComposerPromptEditorInner({ className, )} data-testid="composer-editor" + ariaLabel="Message" + ariaMultiline={true} + ariaAutoComplete="list" + ariaControls={suggestionListId} + ariaActiveDescendant={activeSuggestionId} + aria-haspopup="listbox" aria-placeholder={placeholder} placeholder={} onKeyDown={(event) => { @@ -2065,6 +2082,8 @@ export function ComposerPromptEditor({ skills, disabled, placeholder, + suggestionListId, + activeSuggestionId, containerClassName, className, placeholderClassName, @@ -2115,6 +2134,8 @@ export function ComposerPromptEditor({ skills={skills} disabled={disabled} placeholder={placeholder} + suggestionListId={suggestionListId} + activeSuggestionId={activeSuggestionId} {...(containerClassName ? { containerClassName } : {})} onRemoveTerminalContext={onRemoveTerminalContext} onChange={onChange} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 30acb44dd423..b10b9e8c0af3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -28,6 +28,7 @@ import { type ReactNode, useCallback, useEffect, + useId, useImperativeHandle, useLayoutEffect, useMemo, @@ -163,7 +164,11 @@ import { measureRestingComposerControls } from "./restingComposerControlsMeasure import { observeResponsiveBreakpointFade, usePanelAnimationSettings } from "../../panelAnimations"; import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; import { ProviderModelPicker } from "./ProviderModelPicker"; -import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; +import { + type ComposerCommandItem, + ComposerCommandMenu, + composerSuggestionOptionId, +} from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; @@ -1792,6 +1797,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) detectComposerTrigger(prompt, prompt.length), ); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const composerSuggestionId = useId(); + const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`; + const dismissedComposerSnapshotRef = useRef<{ value: string; expandedCursor: number } | null>( + null, + ); // Active ArrowUp recall. Cleared on edit and on thread switch. const promptHistoryPositionRef = useRef(null); const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState( @@ -2015,7 +2025,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger - ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` + ? `${composerSuggestionListId}:${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}` : null; const activeComposerMenuItem = useMemo(() => { const activeItemId = resolveComposerMenuActiveItemId({ @@ -2042,6 +2052,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const isComposerApprovalState = activePendingApproval !== null; + const composerSuggestionsVisible = composerMenuOpen && !isComposerApprovalState; + const composerSuggestionListVisible = composerSuggestionsVisible && composerMenuItems.length > 0; const activePendingUserInput = pendingUserInputs[0] ?? null; const isChoiceOnlyPendingQuestion = activePendingProgress?.activeQuestion?.allowCustomAnswer === false; @@ -2363,7 +2375,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Reset compositor state on thread/draft change // ------------------------------------------------------------------ useEffect(() => { + dismissedComposerSnapshotRef.current = null; setComposerHighlightedItemId(null); + setComposerHighlightedSearchKey(null); setComposerSubmissionError(null); setProviderInputSubmissionError(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); @@ -2532,12 +2546,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) terminalContextIds: string[], ) => { expandComposerForEditorChange(); + const dismissed = dismissedComposerSnapshotRef.current; + const isDismissed = + dismissed?.value === nextPrompt && dismissed.expandedCursor === expandedCursor; + if (!isDismissed) dismissedComposerSnapshotRef.current = null; + const nextTrigger = + cursorAdjacentToMention || isDismissed + ? null + : detectComposerTrigger(nextPrompt, expandedCursor); if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; setComposerCursor(nextCursor); - setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), - ); + setComposerTrigger(nextTrigger); onChangeActivePendingUserInputCustomAnswer( activePendingProgress.activeQuestion.id, nextPrompt, @@ -2562,9 +2582,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); } setComposerCursor(nextCursor); - setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), - ); + setComposerTrigger(nextTrigger); }, [ activePendingProgress?.activeQuestion, @@ -2678,9 +2696,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) trigger: ComposerTrigger | null; } => { const snapshot = readComposerSnapshot(); + const dismissed = dismissedComposerSnapshotRef.current; return { snapshot, - trigger: detectComposerTrigger(snapshot.value, snapshot.expandedCursor), + trigger: + dismissed?.value === snapshot.value && dismissed.expandedCursor === snapshot.expandedCursor + ? null + : detectComposerTrigger(snapshot.value, snapshot.expandedCursor), }; }, [readComposerSnapshot]); @@ -3059,7 +3081,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Callbacks: command key // ------------------------------------------------------------------ const onComposerCommandKey = ( - key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", + key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab" | "Escape", event: KeyboardEvent, ) => { if (key === "Tab" && event.shiftKey) { @@ -3067,8 +3089,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toggleInteractionMode(); return true; } - const { trigger } = resolveActiveComposerTrigger(); + const { trigger, snapshot } = resolveActiveComposerTrigger(); const menuIsActive = composerMenuOpenRef.current || trigger !== null; + if (key === "Escape") { + if (!menuIsActive || event.isComposing) return false; + dismissedComposerSnapshotRef.current = snapshot; + composerMenuOpenRef.current = false; + setComposerTrigger(null); + setComposerHighlightedItemId(null); + setComposerHighlightedSearchKey(null); + return true; + } if (menuIsActive) { const currentItems = composerMenuItemsRef.current; const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; @@ -5098,9 +5129,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} - {composerMenuOpen && !isComposerApprovalState && ( +
+ {composerSuggestionsVisible + ? isComposerMenuLoading + ? "Searching workspace files..." + : composerMenuItems.length === 0 + ? composerMenuEmptyState + : "" + : ""} +
+ {composerSuggestionsVisible && ( { it("renders slash commands with their descriptions", () => { const markup = renderToStaticMarkup( { it("shows the app source for an app skill", () => { const markup = renderToStaticMarkup( { it("shows the repo source for a slash skill", () => { const markup = renderToStaticMarkup( {props.items.length > 0 ? ( - + {props.items.map((item) => ( } + aria-selected={props.isActive} value={props.item.id} data-composer-item-id={props.item.id} className={cn( @@ -194,6 +203,10 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { ); }); +export function composerSuggestionOptionId(listId: string, itemId: string): string { + return `${listId}-${encodeURIComponent(itemId)}`; +} + const SKILL_SOURCE_ICON_BY_KIND: Record = { app: BlocksIcon, repo: FolderIcon, diff --git a/apps/web/src/components/chat/composerMenuHighlight.test.ts b/apps/web/src/components/chat/composerMenuHighlight.test.ts index 549ad6bb09e8..74768d906878 100644 --- a/apps/web/src/components/chat/composerMenuHighlight.test.ts +++ b/apps/web/src/components/chat/composerMenuHighlight.test.ts @@ -48,4 +48,21 @@ describe("resolveComposerMenuActiveItemId", () => { }), ).toBe("top"); }); + + it("clears the active result while async results are empty and resolves against restored results", () => { + const search = { + highlightedItemId: "second", + currentSearchKey: "path:src", + highlightedSearchKey: "path:src", + }; + const cleared = resolveComposerMenuActiveItemId({ ...search, items: [] }); + expect(cleared).toBeNull(); + expect( + resolveComposerMenuActiveItemId({ + ...search, + highlightedItemId: cleared, + items: [{ id: "new-result" }, { id: "second" }], + }), + ).toBe("new-result"); + }); }); From 5ddfc53642e9e1b452aeccbea6bff49f5e1e449f Mon Sep 17 00:00:00 2001 From: Andrew Date: Sat, 5 Sep 2026 12:10:13 -0400 Subject: [PATCH 2/6] fix(web): identify skill chips to screen readers --- apps/web/src/components/ComposerPromptEditor.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 5ca8df8e4813..fc09b53eb922 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -272,6 +272,8 @@ function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: s contentEditable={false} spellCheck={false} data-composer-skill-chip="true" + role="img" + aria-label={`Skill: ${props.skillLabel}`} >