Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ed5cd47
fix(web): announce composer suggestions to screen readers
akj Sep 5, 2026
5ddfc53
fix(web): identify skill chips to screen readers
akj Sep 5, 2026
8cc85c5
chore: merge main into fix/composer-screen-reader-suggestions
akj Sep 11, 2026
6526104
chore: merge latest main into fix/composer-screen-reader-suggestions
akj Sep 11, 2026
b428164
chore: sync composer accessibility fix with main
akj Sep 11, 2026
8c54eeb
chore: merge main into fix/composer-screen-reader-suggestions
akj Sep 13, 2026
b14d1b3
chore: merge main into fix/composer-screen-reader-suggestions
akj Sep 14, 2026
17d6954
fix(web): label composer suggestion lists by trigger
Leos-Khai Sep 14, 2026
fee7a7d
fix(web): keep suggestion ARIA off editors without menus
Leos-Khai Sep 14, 2026
2c5b68b
Merge latest main and adapt composer accessibility to Tiptap
akj Sep 18, 2026
a8228ef
fix(web): encode suggestion IDs without rejecting lone surrogates
akj Sep 18, 2026
25c20ea
Merge latest main into composer accessibility fix
akj Sep 18, 2026
2cc0075
Merge branch 'main' into fix/composer-screen-reader-suggestions
akj Sep 18, 2026
a66ae11
chore: merge main into fix/composer-screen-reader-suggestions
akj Sep 24, 2026
b76fbe6
chore: merge main into fix/composer-screen-reader-suggestions
akj Sep 25, 2026
5a71b00
chore: merge main into fix/composer-screen-reader-suggestions
akj Sep 26, 2026
12de523
fix(web): announce suggestion loading only when the list is empty
akj Sep 26, 2026
0b470e8
chore: merge main into fix/composer-screen-reader-suggestions
akj Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ export interface ComposerPromptEditorProps {
skills: ReadonlyArray<ServerProviderSkill>;
disabled: boolean;
placeholder: string;
ariaLabel?: string | undefined;
/** Identifies an editor with suggestions, even while its list is closed. */
suggestionListId?: string | undefined;
/** References the highlighted option only while its list is rendered. */
activeSuggestionId?: string | undefined;
containerClassName?: string;
className?: string;
placeholderClassName?: string;
Expand Down Expand Up @@ -592,6 +597,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
skills,
disabled,
placeholder,
ariaLabel,
suggestionListId,
activeSuggestionId,
containerClassName,
className,
placeholderClassName,
Expand Down Expand Up @@ -752,9 +760,25 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
),
"data-testid": "composer-editor",
"data-composer-rich-text": richText ? "true" : "false",
role: "textbox",
"aria-multiline": "true",
...(ariaLabel ? { "aria-label": ariaLabel } : {}),
...(disabled ? { "aria-readonly": "true" } : {}),
...(!disabled && suggestionListId
? {
"aria-autocomplete": "list",
"aria-haspopup": "listbox",
...(activeSuggestionId
? {
"aria-controls": suggestionListId,
"aria-activedescendant": activeSuggestionId,
}
: {}),
}
: {}),
"aria-placeholder": placeholder,
}),
[className, placeholder, richText],
[activeSuggestionId, ariaLabel, className, disabled, placeholder, richText, suggestionListId],
);

const editor = useEditor(
Expand Down
36 changes: 33 additions & 3 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
type ReactNode,
useCallback,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useMemo,
Expand Down Expand Up @@ -245,7 +246,11 @@ import { useEnvironmentQuery } from "~/state/query";
import { useDebouncedValue } from "~/state/queries";
import { ProviderModelPicker } from "./ProviderModelPicker";
import { resolveModelPickerSelectedModel } from "./ModelPickerContent";
import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
import {
type ComposerCommandItem,
ComposerCommandMenu,
composerSuggestionOptionId,
} from "./ComposerCommandMenu";
import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
import { ComposerImageThumbnail } from "./ComposerImageThumbnail";
Expand Down Expand Up @@ -2108,6 +2113,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
resetTrigger: resetComposerTrigger,
} = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length));
const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
const composerSuggestionId = useId();
const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`;
// Active ArrowUp recall. Cleared on edit and on thread switch.
const promptHistoryPositionRef = useRef<ComposerPromptHistoryPosition | null>(null);
const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>(
Expand Down Expand Up @@ -2502,7 +2509,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({
Expand All @@ -2529,6 +2536,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;
Expand Down Expand Up @@ -3176,6 +3185,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// ------------------------------------------------------------------
useEffect(() => {
setComposerHighlightedItemId(null);
setComposerHighlightedSearchKey(null);
setComposerSubmissionError(null);
setProviderInputSubmissionError(null);
setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
Expand Down Expand Up @@ -6454,9 +6464,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
</ComposerCommandMenuLayer>
)}

{composerMenuOpen && !isComposerApprovalState && (
<div role="status" aria-atomic="true" className="sr-only">
{composerSuggestionsVisible && composerMenuItems.length === 0
? isComposerMenuLoading
? composerTriggerKind === "pull-request"
? "Finding pull request..."
: "Searching workspace files..."
: composerMenuEmptyState
: ""}
</div>
{composerSuggestionsVisible && (
<ComposerCommandMenuLayer anchor={composerMenuAnchor}>
<ComposerCommandMenu
listId={composerSuggestionListId}
items={composerMenuItems}
resolvedTheme={resolvedTheme}
isLoading={isComposerMenuLoading}
Expand Down Expand Up @@ -6845,6 +6865,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
) : null}
<ComposerContextActionsContext value={composerContextActions}>
<ComposerPromptEditor
ariaLabel="Message"
suggestionListId={composerSuggestionListId}
activeSuggestionId={
composerSuggestionListVisible && activeComposerMenuItem
? composerSuggestionOptionId(
composerSuggestionListId,
activeComposerMenuItem.id,
)
: undefined
}
editorRef={composerEditorRef}
richTextEnabled={settings.composerRichTextEnabled}
value={
Expand Down
29 changes: 28 additions & 1 deletion apps/web/src/components/chat/ComposerCommandMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,37 @@ import { renderToStaticMarkup } from "react-dom/server";
import { ProviderDriverKind } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { ComposerCommandMenu } from "./ComposerCommandMenu";
import { ComposerCommandMenu, composerSuggestionOptionId } from "./ComposerCommandMenu";

describe("composerSuggestionOptionId", () => {
it("keeps whitespace, escape-like paths, and malformed UTF-16 distinct", () => {
const paths = [
"docs/my file.md",
"docs/my_file.md",
"docs/my%20file.md",
"docs/my\tfile.md",
"docs/\ud800.md",
"docs/\ud801.md",
"docs/\udc00.md",
"docs/\ufffd.md",
"docs/\\ud800.md",
"docs/\ud83d\ude80.md",
];
const ids = paths.map((path) => composerSuggestionOptionId("suggestions", `path:file:${path}`));

expect(new Set(ids).size).toBe(paths.length);
for (const id of ids) expect(id).not.toMatch(/\s|[\ud800-\udfff]/u);
expect(composerSuggestionOptionId("other-composer", paths[0]!)).not.toBe(
composerSuggestionOptionId("suggestions", paths[0]!),
);
});
});

describe("ComposerCommandMenu", () => {
it("renders slash commands with their descriptions", () => {
const markup = renderToStaticMarkup(
<ComposerCommandMenu
listId="test-suggestions"
items={[
{
id: "slash:model",
Expand All @@ -33,6 +58,7 @@ describe("ComposerCommandMenu", () => {
it("shows the app source for an app skill", () => {
const markup = renderToStaticMarkup(
<ComposerCommandMenu
listId="test-suggestions"
items={[
{
id: "skill:codex:browser",
Expand Down Expand Up @@ -67,6 +93,7 @@ describe("ComposerCommandMenu", () => {
it("shows the repo source for a slash skill", () => {
const markup = renderToStaticMarkup(
<ComposerCommandMenu
listId="test-suggestions"
items={[
{
id: "skill:codex:ask-matt",
Expand Down
23 changes: 22 additions & 1 deletion apps/web/src/components/chat/ComposerCommandMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export type ComposerCommandItem =
};

export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
listId: string;
items: ComposerCommandItem[];
resolvedTheme: "light" | "dark";
isLoading: boolean;
Expand Down Expand Up @@ -104,11 +105,16 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
data-composer-command-drawer="true"
>
{props.items.length > 0 ? (
<CommandList className="max-h-72 min-h-0 scroll-pb-6">
<CommandList
id={props.listId}
aria-label={props.triggerKind ? LISTBOX_LABEL_BY_TRIGGER[props.triggerKind] : undefined}
className="max-h-72 min-h-0 scroll-pb-6"
>
<CommandGroup>
{props.items.map((item) => (
<ComposerCommandMenuItem
key={item.id}
optionId={composerSuggestionOptionId(props.listId, item.id)}
item={item}
triggerKind={props.triggerKind}
resolvedTheme={props.resolvedTheme}
Expand Down Expand Up @@ -143,6 +149,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
});

const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
optionId: string;
item: ComposerCommandItem;
triggerKind: ComposerTriggerKind | null;
resolvedTheme: "light" | "dark";
Expand All @@ -159,6 +166,8 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {

return (
<CommandItem
render={<div id={props.optionId} />}
aria-selected={props.isActive}
value={props.item.id}
data-composer-item-id={props.item.id}
active={props.isActive}
Expand Down Expand Up @@ -211,6 +220,18 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
);
});

export function composerSuggestionOptionId(listId: string, itemId: string): string {
// JSON escapes lone UTF-16 surrogates before URI encoding without losing identity.
return `${listId}-${encodeURIComponent(JSON.stringify(itemId))}`;
}

const LISTBOX_LABEL_BY_TRIGGER: Record<ComposerTriggerKind, string> = {
path: "Files and folders",
"pull-request": "Pull requests",
"slash-command": "Commands",
skill: "Skills",
};

const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {
app: BlocksIcon,
repo: FolderIcon,
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/components/chat/composerMenuHighlight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
1 change: 1 addition & 0 deletions apps/web/src/components/settings/SettingsFontPreviews.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function PromptFontPreview() {
return (
<div className="mt-1 mb-2 rounded-lg border border-border bg-background px-3 py-2">
<ComposerPromptEditor
ariaLabel="Prompt font preview"
editorRef={editorRef}
value={prompt}
cursor={cursor}
Expand Down
Loading