authorgravatar for andrew@johnson5.netAndrew Johnson <andrew@johnson5.net> 2026-09-30 20:54:00-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-30 17:54:00-07:00
log0cf482b08bca0249ec94fbcbd0eb04fff28d9b3d
tree9f9a402b19c4ada6aafd3e49d552f81b1cb09d43
parent1905846e03226582f64fec16e1500ddffa151fdf
signature Signed by PGP key B5690EEEBB952194

fix(web): make composer suggestions usable with screen readers (#10154)

Co-authored-by: Khai Shern, Toh <khaishern8235@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

6 files changed, 126 insertions(+), 6 deletions(-)

apps/web/src/components/ComposerPromptEditorTiptap.tsx+25-1
...@@ -121,6 +121,11 @@ export interface ComposerPromptEditorProps {...@@ -121,6 +121,11 @@ export interface ComposerPromptEditorProps {
121 skills: ReadonlyArray<ServerProviderSkill>;121 skills: ReadonlyArray<ServerProviderSkill>;
122 disabled: boolean;122 disabled: boolean;
123 placeholder: string;123 placeholder: string;
124 ariaLabel?: string | undefined;
125 /** Identifies an editor with suggestions, even while its list is closed. */
126 suggestionListId?: string | undefined;
127 /** References the highlighted option only while its list is rendered. */
128 activeSuggestionId?: string | undefined;
124 containerClassName?: string;129 containerClassName?: string;
125 className?: string;130 className?: string;
126 placeholderClassName?: string;131 placeholderClassName?: string;
...@@ -592,6 +597,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -592,6 +597,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
592 skills,597 skills,
593 disabled,598 disabled,
594 placeholder,599 placeholder,
600 ariaLabel,
601 suggestionListId,
602 activeSuggestionId,
595 containerClassName,603 containerClassName,
596 className,604 className,
597 placeholderClassName,605 placeholderClassName,
...@@ -752,9 +760,25 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {...@@ -752,9 +760,25 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
752 ),760 ),
753 "data-testid": "composer-editor",761 "data-testid": "composer-editor",
754 "data-composer-rich-text": richText ? "true" : "false",762 "data-composer-rich-text": richText ? "true" : "false",
763 role: "textbox",
764 "aria-multiline": "true",
765 ...(ariaLabel ? { "aria-label": ariaLabel } : {}),
766 ...(disabled ? { "aria-readonly": "true" } : {}),
767 ...(!disabled && suggestionListId
768 ? {
769 "aria-autocomplete": "list",
770 "aria-haspopup": "listbox",
771 ...(activeSuggestionId
772 ? {
773 "aria-controls": suggestionListId,
774 "aria-activedescendant": activeSuggestionId,
775 }
776 : {}),
777 }
778 : {}),
755 "aria-placeholder": placeholder,779 "aria-placeholder": placeholder,
756 }),780 }),
757 [className, placeholder, richText],781 [activeSuggestionId, ariaLabel, className, disabled, placeholder, richText, suggestionListId],
758 );782 );
759783
760 const editor = useEditor(784 const editor = useEditor(
apps/web/src/components/chat/ChatComposer.tsx+33-3
...@@ -58,6 +58,7 @@ import {...@@ -58,6 +58,7 @@ import {
58 type ReactNode,58 type ReactNode,
59 useCallback,59 useCallback,
60 useEffect,60 useEffect,
61 useId,
61 useImperativeHandle,62 useImperativeHandle,
62 useLayoutEffect,63 useLayoutEffect,
63 useMemo,64 useMemo,
...@@ -245,7 +246,11 @@ import { useEnvironmentQuery } from "~/state/query";...@@ -245,7 +246,11 @@ import { useEnvironmentQuery } from "~/state/query";
245import { useDebouncedValue } from "~/state/queries";246import { useDebouncedValue } from "~/state/queries";
246import { ProviderModelPicker } from "./ProviderModelPicker";247import { ProviderModelPicker } from "./ProviderModelPicker";
247import { resolveModelPickerSelectedModel } from "./ModelPickerContent";248import { resolveModelPickerSelectedModel } from "./ModelPickerContent";
248import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";249import {
250 type ComposerCommandItem,
251 ComposerCommandMenu,
252 composerSuggestionOptionId,
253} from "./ComposerCommandMenu";
249import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";254import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
250import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";255import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
251import { ComposerImageThumbnail } from "./ComposerImageThumbnail";256import { ComposerImageThumbnail } from "./ComposerImageThumbnail";
...@@ -2108,6 +2113,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2108,6 +2113,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2108 resetTrigger: resetComposerTrigger,2113 resetTrigger: resetComposerTrigger,
2109 } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length));2114 } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length));
2110 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);2115 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
2116 const composerSuggestionId = useId();
2117 const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`;
2111 // Active ArrowUp recall. Cleared on edit and on thread switch.2118 // Active ArrowUp recall. Cleared on edit and on thread switch.
2112 const promptHistoryPositionRef = useRef<ComposerPromptHistoryPosition | null>(null);2119 const promptHistoryPositionRef = useRef<ComposerPromptHistoryPosition | null>(null);
2113 const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>(2120 const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>(
...@@ -2502,7 +2509,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2502,7 +2509,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25022509
2503 const composerMenuOpen = Boolean(composerTrigger);2510 const composerMenuOpen = Boolean(composerTrigger);
2504 const composerMenuSearchKey = composerTrigger2511 const composerMenuSearchKey = composerTrigger
2505 ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`2512 ? `${composerSuggestionListId}:${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`
2506 : null;2513 : null;
2507 const activeComposerMenuItem = useMemo(() => {2514 const activeComposerMenuItem = useMemo(() => {
2508 const activeItemId = resolveComposerMenuActiveItemId({2515 const activeItemId = resolveComposerMenuActiveItemId({
...@@ -2529,6 +2536,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2529,6 +2536,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2529 );2536 );
25302537
2531 const isComposerApprovalState = activePendingApproval !== null;2538 const isComposerApprovalState = activePendingApproval !== null;
2539 const composerSuggestionsVisible = composerMenuOpen && !isComposerApprovalState;
2540 const composerSuggestionListVisible = composerSuggestionsVisible && composerMenuItems.length > 0;
2532 const activePendingUserInput = pendingUserInputs[0] ?? null;2541 const activePendingUserInput = pendingUserInputs[0] ?? null;
2533 const isChoiceOnlyPendingQuestion =2542 const isChoiceOnlyPendingQuestion =
2534 activePendingProgress?.activeQuestion?.allowCustomAnswer === false;2543 activePendingProgress?.activeQuestion?.allowCustomAnswer === false;
...@@ -3176,6 +3185,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3176,6 +3185,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3176 // ------------------------------------------------------------------3185 // ------------------------------------------------------------------
3177 useEffect(() => {3186 useEffect(() => {
3178 setComposerHighlightedItemId(null);3187 setComposerHighlightedItemId(null);
3188 setComposerHighlightedSearchKey(null);
3179 setComposerSubmissionError(null);3189 setComposerSubmissionError(null);
3180 setProviderInputSubmissionError(null);3190 setProviderInputSubmissionError(null);
3181 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));3191 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
...@@ -6454,9 +6464,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6454,9 +6464,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6454 </ComposerCommandMenuLayer>6464 </ComposerCommandMenuLayer>
6455 )}6465 )}
64566466
6457 {composerMenuOpen && !isComposerApprovalState && (6467 <div role="status" aria-atomic="true" className="sr-only">
6468 {composerSuggestionsVisible && composerMenuItems.length === 0
6469 ? isComposerMenuLoading
6470 ? composerTriggerKind === "pull-request"
6471 ? "Finding pull request..."
6472 : "Searching workspace files..."
6473 : composerMenuEmptyState
6474 : ""}
6475 </div>
6476 {composerSuggestionsVisible && (
6458 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>6477 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
6459 <ComposerCommandMenu6478 <ComposerCommandMenu
6479 listId={composerSuggestionListId}
6460 items={composerMenuItems}6480 items={composerMenuItems}
6461 resolvedTheme={resolvedTheme}6481 resolvedTheme={resolvedTheme}
6462 isLoading={isComposerMenuLoading}6482 isLoading={isComposerMenuLoading}
...@@ -6845,6 +6865,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -6845,6 +6865,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
6845 ) : null}6865 ) : null}
6846 <ComposerContextActionsContext value={composerContextActions}>6866 <ComposerContextActionsContext value={composerContextActions}>
6847 <ComposerPromptEditor6867 <ComposerPromptEditor
6868 ariaLabel="Message"
6869 suggestionListId={composerSuggestionListId}
6870 activeSuggestionId={
6871 composerSuggestionListVisible && activeComposerMenuItem
6872 ? composerSuggestionOptionId(
6873 composerSuggestionListId,
6874 activeComposerMenuItem.id,
6875 )
6876 : undefined
6877 }
6848 editorRef={composerEditorRef}6878 editorRef={composerEditorRef}
6849 richTextEnabled={settings.composerRichTextEnabled}6879 richTextEnabled={settings.composerRichTextEnabled}
6850 value={6880 value={
apps/web/src/components/chat/ComposerCommandMenu.test.tsx+28-1
...@@ -2,12 +2,37 @@ import { renderToStaticMarkup } from "react-dom/server";...@@ -2,12 +2,37 @@ import { renderToStaticMarkup } from "react-dom/server";
2import { ProviderDriverKind } from "@t3tools/contracts";2import { ProviderDriverKind } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
44
5import { ComposerCommandMenu } from "./ComposerCommandMenu";5import { ComposerCommandMenu, composerSuggestionOptionId } from "./ComposerCommandMenu";
6
7describe("composerSuggestionOptionId", () => {
8 it("keeps whitespace, escape-like paths, and malformed UTF-16 distinct", () => {
9 const paths = [
10 "docs/my file.md",
11 "docs/my_file.md",
12 "docs/my%20file.md",
13 "docs/my\tfile.md",
14 "docs/\ud800.md",
15 "docs/\ud801.md",
16 "docs/\udc00.md",
17 "docs/\ufffd.md",
18 "docs/\\ud800.md",
19 "docs/\ud83d\ude80.md",
20 ];
21 const ids = paths.map((path) => composerSuggestionOptionId("suggestions", `path:file:${path}`));
22
23 expect(new Set(ids).size).toBe(paths.length);
24 for (const id of ids) expect(id).not.toMatch(/\s|[\ud800-\udfff]/u);
25 expect(composerSuggestionOptionId("other-composer", paths[0]!)).not.toBe(
26 composerSuggestionOptionId("suggestions", paths[0]!),
27 );
28 });
29});
630
7describe("ComposerCommandMenu", () => {31describe("ComposerCommandMenu", () => {
8 it("renders slash commands with their descriptions", () => {32 it("renders slash commands with their descriptions", () => {
9 const markup = renderToStaticMarkup(33 const markup = renderToStaticMarkup(
10 <ComposerCommandMenu34 <ComposerCommandMenu
35 listId="test-suggestions"
11 items={[36 items={[
12 {37 {
13 id: "slash:model",38 id: "slash:model",
...@@ -33,6 +58,7 @@ describe("ComposerCommandMenu", () => {...@@ -33,6 +58,7 @@ describe("ComposerCommandMenu", () => {
33 it("shows the app source for an app skill", () => {58 it("shows the app source for an app skill", () => {
34 const markup = renderToStaticMarkup(59 const markup = renderToStaticMarkup(
35 <ComposerCommandMenu60 <ComposerCommandMenu
61 listId="test-suggestions"
36 items={[62 items={[
37 {63 {
38 id: "skill:codex:browser",64 id: "skill:codex:browser",
...@@ -67,6 +93,7 @@ describe("ComposerCommandMenu", () => {...@@ -67,6 +93,7 @@ describe("ComposerCommandMenu", () => {
67 it("shows the repo source for a slash skill", () => {93 it("shows the repo source for a slash skill", () => {
68 const markup = renderToStaticMarkup(94 const markup = renderToStaticMarkup(
69 <ComposerCommandMenu95 <ComposerCommandMenu
96 listId="test-suggestions"
70 items={[97 items={[
71 {98 {
72 id: "skill:codex:ask-matt",99 id: "skill:codex:ask-matt",
apps/web/src/components/chat/ComposerCommandMenu.tsx+22-1
...@@ -69,6 +69,7 @@ export type ComposerCommandItem =...@@ -69,6 +69,7 @@ export type ComposerCommandItem =
69 };69 };
7070
71export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {71export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
72 listId: string;
72 items: ComposerCommandItem[];73 items: ComposerCommandItem[];
73 resolvedTheme: "light" | "dark";74 resolvedTheme: "light" | "dark";
74 isLoading: boolean;75 isLoading: boolean;
...@@ -104,11 +105,16 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {...@@ -104,11 +105,16 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
104 data-composer-command-drawer="true"105 data-composer-command-drawer="true"
105 >106 >
106 {props.items.length > 0 ? (107 {props.items.length > 0 ? (
107 <CommandList className="max-h-72 min-h-0 scroll-pb-6">108 <CommandList
109 id={props.listId}
110 aria-label={props.triggerKind ? LISTBOX_LABEL_BY_TRIGGER[props.triggerKind] : undefined}
111 className="max-h-72 min-h-0 scroll-pb-6"
112 >
108 <CommandGroup>113 <CommandGroup>
109 {props.items.map((item) => (114 {props.items.map((item) => (
110 <ComposerCommandMenuItem115 <ComposerCommandMenuItem
111 key={item.id}116 key={item.id}
117 optionId={composerSuggestionOptionId(props.listId, item.id)}
112 item={item}118 item={item}
113 triggerKind={props.triggerKind}119 triggerKind={props.triggerKind}
114 resolvedTheme={props.resolvedTheme}120 resolvedTheme={props.resolvedTheme}
...@@ -143,6 +149,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {...@@ -143,6 +149,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
143});149});
144150
145const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {151const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
152 optionId: string;
146 item: ComposerCommandItem;153 item: ComposerCommandItem;
147 triggerKind: ComposerTriggerKind | null;154 triggerKind: ComposerTriggerKind | null;
148 resolvedTheme: "light" | "dark";155 resolvedTheme: "light" | "dark";
...@@ -159,6 +166,8 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -159,6 +166,8 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
159166
160 return (167 return (
161 <CommandItem168 <CommandItem
169 render={<div id={props.optionId} />}
170 aria-selected={props.isActive}
162 value={props.item.id}171 value={props.item.id}
163 data-composer-item-id={props.item.id}172 data-composer-item-id={props.item.id}
164 active={props.isActive}173 active={props.isActive}
...@@ -211,6 +220,18 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -211,6 +220,18 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
211 );220 );
212});221});
213222
223export function composerSuggestionOptionId(listId: string, itemId: string): string {
224 // JSON escapes lone UTF-16 surrogates before URI encoding without losing identity.
225 return `${listId}-${encodeURIComponent(JSON.stringify(itemId))}`;
226}
227
228const LISTBOX_LABEL_BY_TRIGGER: Record<ComposerTriggerKind, string> = {
229 path: "Files and folders",
230 "pull-request": "Pull requests",
231 "slash-command": "Commands",
232 skill: "Skills",
233};
234
214const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {235const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {
215 app: BlocksIcon,236 app: BlocksIcon,
216 repo: FolderIcon,237 repo: FolderIcon,
apps/web/src/components/chat/composerMenuHighlight.test.ts+17
...@@ -48,4 +48,21 @@ describe("resolveComposerMenuActiveItemId", () => {...@@ -48,4 +48,21 @@ describe("resolveComposerMenuActiveItemId", () => {
48 }),48 }),
49 ).toBe("top");49 ).toBe("top");
50 });50 });
51
52 it("clears the active result while async results are empty and resolves against restored results", () => {
53 const search = {
54 highlightedItemId: "second",
55 currentSearchKey: "path:src",
56 highlightedSearchKey: "path:src",
57 };
58 const cleared = resolveComposerMenuActiveItemId({ ...search, items: [] });
59 expect(cleared).toBeNull();
60 expect(
61 resolveComposerMenuActiveItemId({
62 ...search,
63 highlightedItemId: cleared,
64 items: [{ id: "new-result" }, { id: "second" }],
65 }),
66 ).toBe("new-result");
67 });
51});68});
apps/web/src/components/settings/SettingsFontPreviews.tsx+1
...@@ -39,6 +39,7 @@ export function PromptFontPreview() {...@@ -39,6 +39,7 @@ export function PromptFontPreview() {
39 return (39 return (
40 <div className="mt-1 mb-2 rounded-lg border border-border bg-background px-3 py-2">40 <div className="mt-1 mb-2 rounded-lg border border-border bg-background px-3 py-2">
41 <ComposerPromptEditor41 <ComposerPromptEditor
42 ariaLabel="Prompt font preview"
42 editorRef={editorRef}43 editorRef={editorRef}
43 value={prompt}44 value={prompt}
44 cursor={cursor}45 cursor={cursor}