diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 2a5fe2ba0588..c8866e7fc413 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -121,6 +121,11 @@ export interface ComposerPromptEditorProps { skills: ReadonlyArray; 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; @@ -592,6 +597,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { skills, disabled, placeholder, + ariaLabel, + suggestionListId, + activeSuggestionId, containerClassName, className, placeholderClassName, @@ -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( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d168c6db4e5f..f0383dbaf17b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -58,6 +58,7 @@ import { type ReactNode, useCallback, useEffect, + useId, useImperativeHandle, useLayoutEffect, useMemo, @@ -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"; @@ -2108,6 +2113,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) resetTrigger: resetComposerTrigger, } = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length)); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(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(null); const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState( @@ -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({ @@ -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; @@ -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)); @@ -6454,9 +6464,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} - {composerMenuOpen && !isComposerApprovalState && ( +
+ {composerSuggestionsVisible && composerMenuItems.length === 0 + ? isComposerMenuLoading + ? composerTriggerKind === "pull-request" + ? "Finding pull request..." + : "Searching workspace files..." + : composerMenuEmptyState + : ""} +
+ {composerSuggestionsVisible && ( { + 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( { 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} active={props.isActive} @@ -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 = { + path: "Files and folders", + "pull-request": "Pull requests", + "slash-command": "Commands", + skill: "Skills", +}; + 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"); + }); }); diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 6f392598b9e7..4715583e4005 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -39,6 +39,7 @@ export function PromptFontPreview() { return (