diff --git a/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx b/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx index 3d704b5209305..c3f6433eff9a6 100644 --- a/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerModelEffort.browser.test.tsx @@ -42,6 +42,7 @@ const models: ReadonlyArray = [ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, + { id: "max", label: "Max" }, ], }, { @@ -88,25 +89,48 @@ function Column() { ); } -it("draws an agent's reasoning as bars and keeps its mode out of the levels", async () => { +it("navigates an agent's reasoning by letter and arrow and keeps its mode out of the levels", async () => { await using screen = await render(, { router: false }); // no routes involved const status = screen.getByRole("status"); expect(status.element().querySelector("[data-composer-effort-glyph]")).not.toBeNull(); await expect.element(status).toHaveTextContent(""); const levels = screen.getByRole("radiogroup", { name: "Thinking" }); - await expect.element(levels.getByRole("radio")).toHaveLength(3); + await expect.element(levels.getByRole("radio")).toHaveLength(4); await expect.element(screen.getByRole("radio", { name: "Plan" })).not.toBeInTheDocument(); - // Up and Down walk from the levels through the option rows without opening their menus. await userEvent.tab(); await expect.element(screen.getByRole("radio", { name: "Medium" })).toHaveFocus(); - await userEvent.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}"); + await userEvent.keyboard("{Control>}h{/Control}"); + await expect.element(screen.getByRole("radio", { name: "Medium" })).toHaveFocus(); + await userEvent.keyboard("h"); + await expect.element(screen.getByRole("radio", { name: "High" })).toHaveFocus(); + await userEvent.keyboard("q"); + await expect.element(screen.getByRole("radio", { name: "High" })).toHaveFocus(); + await expect + .element(screen.getByRole("radio", { name: "Medium" })) + .toHaveAttribute("aria-checked", "true"); + await userEvent.keyboard("{Enter}"); + await expect + .element(screen.getByRole("radio", { name: "High" })) + .toHaveAttribute("aria-checked", "true"); + await userEvent.keyboard("l{Enter}{ArrowDown}"); + await expect + .element(screen.getByRole("radio", { name: "Low" })) + .toHaveAttribute("aria-checked", "true"); + await expect.element(screen.getByRole("radio", { name: "Medium" })).toHaveFocus(); + await userEvent.keyboard("m"); + await expect.element(screen.getByRole("radio", { name: "Max" })).toHaveFocus(); + await userEvent.keyboard("M"); + await expect.element(screen.getByRole("radio", { name: "Medium" })).toHaveFocus(); + await userEvent.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}"); const speed = screen.getByRole("button", { name: "Speed: Standard" }); await expect.element(speed).toHaveFocus(); await expect.element(page.getByRole("menuitemradio")).not.toBeInTheDocument(); await userEvent.keyboard("{ArrowRight}"); await expect.element(page.getByRole("menuitemradio", { name: "Standard" })).toHaveFocus(); + await userEvent.keyboard("f"); + await expect.element(page.getByRole("menuitemradio", { name: "Fast" })).toHaveFocus(); await userEvent.keyboard("{ArrowLeft}"); await expect.element(page.getByRole("menuitemradio")).not.toBeInTheDocument(); await expect.element(speed).toHaveFocus(); diff --git a/apps/web/src/components/chat/ComposerModelEffort.tsx b/apps/web/src/components/chat/ComposerModelEffort.tsx index 3a863936101b2..e7b96e7875527 100644 --- a/apps/web/src/components/chat/ComposerModelEffort.tsx +++ b/apps/web/src/components/chat/ComposerModelEffort.tsx @@ -222,15 +222,30 @@ function ColumnRow(props: { } function moveBetweenRows(event: KeyboardEvent) { - if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + const arrow = event.key === "ArrowDown" || event.key === "ArrowUp"; + if ( + (!arrow && (event.key.length !== 1 || event.key === " ")) || + event.altKey || + event.ctrlKey || + event.metaKey || + event.nativeEvent.isComposing + ) + return; // An option's side menu is a React child here, but it moves through its own items. if (!event.currentTarget.contains(event.target as Node)) return; const rows = Array.from( event.currentTarget.querySelectorAll("[data-effort-row]:not(:disabled)"), ); const index = rows.findIndex((row) => row === document.activeElement); - const next = - rows[Math.min(rows.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)))]; + if (index < 0) return; + const next = arrow + ? rows[Math.min(rows.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)))] + : [...rows.slice(index + 1), ...rows.slice(0, index + 1)].find((row) => + (row.getAttribute("aria-label") ?? row.textContent ?? "") + .trim() + .toLocaleLowerCase() + .startsWith(event.key.toLocaleLowerCase()), + ); if (!next) return; event.preventDefault(); event.stopPropagation();