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 {
121121 skills: ReadonlyArray<ServerProviderSkill>;
122122 disabled: boolean;
123123 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;
124129 containerClassName?: string;
125130 className?: string;
126131 placeholderClassName?: string;
......@@ -592,6 +597,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
592597 skills,
593598 disabled,
594599 placeholder,
600 ariaLabel,
601 suggestionListId,
602 activeSuggestionId,
595603 containerClassName,
596604 className,
597605 placeholderClassName,
......@@ -752,9 +760,25 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
752760 ),
753761 "data-testid": "composer-editor",
754762 "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 : {}),
755779 "aria-placeholder": placeholder,
756780 }),
757 [className, placeholder, richText],
781 [activeSuggestionId, ariaLabel, className, disabled, placeholder, richText, suggestionListId],
758782 );
759783
760784 const editor = useEditor(
apps/web/src/components/chat/ChatComposer.tsx+33-3
......@@ -58,6 +58,7 @@ import {
5858 type ReactNode,
5959 useCallback,
6060 useEffect,
61 useId,
6162 useImperativeHandle,
6263 useLayoutEffect,
6364 useMemo,
......@@ -245,7 +246,11 @@ import { useEnvironmentQuery } from "~/state/query";
245246import { useDebouncedValue } from "~/state/queries";
246247import { ProviderModelPicker } from "./ProviderModelPicker";
247248import { resolveModelPickerSelectedModel } from "./ModelPickerContent";
248import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
249import {
250 type ComposerCommandItem,
251 ComposerCommandMenu,
252 composerSuggestionOptionId,
253} from "./ComposerCommandMenu";
249254import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
250255import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
251256import { ComposerImageThumbnail } from "./ComposerImageThumbnail";
......@@ -2108,6 +2113,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
21082113 resetTrigger: resetComposerTrigger,
21092114 } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length));
21102115 const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
2116 const composerSuggestionId = useId();
2117 const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`;
21112118 // Active ArrowUp recall. Cleared on edit and on thread switch.
21122119 const promptHistoryPositionRef = useRef<ComposerPromptHistoryPosition | null>(null);
21132120 const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>(
......@@ -2502,7 +2509,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25022509
25032510 const composerMenuOpen = Boolean(composerTrigger);
25042511 const composerMenuSearchKey = composerTrigger
2505 ? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`
2512 ? `${composerSuggestionListId}:${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`
25062513 : null;
25072514 const activeComposerMenuItem = useMemo(() => {
25082515 const activeItemId = resolveComposerMenuActiveItemId({
......@@ -2529,6 +2536,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25292536 );
25302537
25312538 const isComposerApprovalState = activePendingApproval !== null;
2539 const composerSuggestionsVisible = composerMenuOpen && !isComposerApprovalState;
2540 const composerSuggestionListVisible = composerSuggestionsVisible && composerMenuItems.length > 0;
25322541 const activePendingUserInput = pendingUserInputs[0] ?? null;
25332542 const isChoiceOnlyPendingQuestion =
25342543 activePendingProgress?.activeQuestion?.allowCustomAnswer === false;
......@@ -3176,6 +3185,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
31763185 // ------------------------------------------------------------------
31773186 useEffect(() => {
31783187 setComposerHighlightedItemId(null);
3188 setComposerHighlightedSearchKey(null);
31793189 setComposerSubmissionError(null);
31803190 setProviderInputSubmissionError(null);
31813191 setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
......@@ -6454,9 +6464,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
64546464 </ComposerCommandMenuLayer>
64556465 )}
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 && (
64586477 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
64596478 <ComposerCommandMenu
6479 listId={composerSuggestionListId}
64606480 items={composerMenuItems}
64616481 resolvedTheme={resolvedTheme}
64626482 isLoading={isComposerMenuLoading}
......@@ -6845,6 +6865,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
68456865 ) : null}
68466866 <ComposerContextActionsContext value={composerContextActions}>
68476867 <ComposerPromptEditor
6868 ariaLabel="Message"
6869 suggestionListId={composerSuggestionListId}
6870 activeSuggestionId={
6871 composerSuggestionListVisible && activeComposerMenuItem
6872 ? composerSuggestionOptionId(
6873 composerSuggestionListId,
6874 activeComposerMenuItem.id,
6875 )
6876 : undefined
6877 }
68486878 editorRef={composerEditorRef}
68496879 richTextEnabled={settings.composerRichTextEnabled}
68506880 value={
apps/web/src/components/chat/ComposerCommandMenu.test.tsx+28-1
......@@ -2,12 +2,37 @@ import { renderToStaticMarkup } from "react-dom/server";
22import { ProviderDriverKind } from "@t3tools/contracts";
33import { 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
731describe("ComposerCommandMenu", () => {
832 it("renders slash commands with their descriptions", () => {
933 const markup = renderToStaticMarkup(
1034 <ComposerCommandMenu
35 listId="test-suggestions"
1136 items={[
1237 {
1338 id: "slash:model",
......@@ -33,6 +58,7 @@ describe("ComposerCommandMenu", () => {
3358 it("shows the app source for an app skill", () => {
3459 const markup = renderToStaticMarkup(
3560 <ComposerCommandMenu
61 listId="test-suggestions"
3662 items={[
3763 {
3864 id: "skill:codex:browser",
......@@ -67,6 +93,7 @@ describe("ComposerCommandMenu", () => {
6793 it("shows the repo source for a slash skill", () => {
6894 const markup = renderToStaticMarkup(
6995 <ComposerCommandMenu
96 listId="test-suggestions"
7097 items={[
7198 {
7299 id: "skill:codex:ask-matt",
apps/web/src/components/chat/ComposerCommandMenu.tsx+22-1
......@@ -69,6 +69,7 @@ export type ComposerCommandItem =
6969 };
7070
7171export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
72 listId: string;
7273 items: ComposerCommandItem[];
7374 resolvedTheme: "light" | "dark";
7475 isLoading: boolean;
......@@ -104,11 +105,16 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
104105 data-composer-command-drawer="true"
105106 >
106107 {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 >
108113 <CommandGroup>
109114 {props.items.map((item) => (
110115 <ComposerCommandMenuItem
111116 key={item.id}
117 optionId={composerSuggestionOptionId(props.listId, item.id)}
112118 item={item}
113119 triggerKind={props.triggerKind}
114120 resolvedTheme={props.resolvedTheme}
......@@ -143,6 +149,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
143149});
144150
145151const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
152 optionId: string;
146153 item: ComposerCommandItem;
147154 triggerKind: ComposerTriggerKind | null;
148155 resolvedTheme: "light" | "dark";
......@@ -159,6 +166,8 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
159166
160167 return (
161168 <CommandItem
169 render={<div id={props.optionId} />}
170 aria-selected={props.isActive}
162171 value={props.item.id}
163172 data-composer-item-id={props.item.id}
164173 active={props.isActive}
......@@ -211,6 +220,18 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
211220 );
212221});
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
214235const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {
215236 app: BlocksIcon,
216237 repo: FolderIcon,
apps/web/src/components/chat/composerMenuHighlight.test.ts+17
......@@ -48,4 +48,21 @@ describe("resolveComposerMenuActiveItemId", () => {
4848 }),
4949 ).toBe("top");
5050 });
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 });
5168});
apps/web/src/components/settings/SettingsFontPreviews.tsx+1
......@@ -39,6 +39,7 @@ export function PromptFontPreview() {
3939 return (
4040 <div className="mt-1 mb-2 rounded-lg border border-border bg-background px-3 py-2">
4141 <ComposerPromptEditor
42 ariaLabel="Prompt font preview"
4243 editorRef={editorRef}
4344 value={prompt}
4445 cursor={cursor}