diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 641efacec90..4a2e9dadd01 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -66,7 +66,7 @@ import { type OpenAIReasoningMode, type ThinkingLevel, } from "@/common/types/thinking"; -import { CUSTOM_EVENTS } from "@/common/constants/events"; +import { createCustomEvent, CUSTOM_EVENTS } from "@/common/constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; import { getAgentIdKey, @@ -720,6 +720,14 @@ function AppInner() { }); if (provider == null) { fastModeToggleInFlightRef.current = false; + // The shortcut used to do nothing here, so it looked broken (#5693). Say why instead. + window.dispatchEvent( + createCustomEvent(CUSTOM_EVENTS.ANALYTICS_REBUILD_TOAST, { + type: "error", + title: "Fast mode", + message: `Fast mode is not available for ${model} on its current provider route, for example through a gateway or a custom base URL.`, + }) + ); return; } diff --git a/src/browser/components/SidebarCollapseButton/SidebarCollapseButton.tsx b/src/browser/components/SidebarCollapseButton/SidebarCollapseButton.tsx index 30035b307b1..5a65567da09 100644 --- a/src/browser/components/SidebarCollapseButton/SidebarCollapseButton.tsx +++ b/src/browser/components/SidebarCollapseButton/SidebarCollapseButton.tsx @@ -1,5 +1,6 @@ import React from "react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/browser/components/Tooltip/Tooltip"; +import { cn } from "@/common/lib/utils"; interface SidebarCollapseButtonProps { collapsed: boolean; @@ -32,11 +33,14 @@ export const SidebarCollapseButton: React.FC = ({ diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx index 759e2d37452..3690bc21566 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx @@ -842,8 +842,10 @@ export const WorkspaceMenuBar: React.FC = ({ + {/* Same words as the button's aria-label, so sighted and screen-reader users get one + name for this control (#5681). */} - More actions + Workspace actions diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index f31b0bff91b..8513679496f 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -115,6 +115,8 @@ import { matchesKeybind, formatKeybind, KEYBINDS, + isDesktopViewportFocused, + isDialogOpen, isEditableElement, } from "@/browser/utils/ui/keybinds"; import { @@ -1347,6 +1349,25 @@ const ChatInputInner: React.FC = (props) => { } }, [onReady, focusMessageInput, send]); + // The creation composer shows the focus-chat hint too, but the only handler lived in the + // workspace view (useAIViewKeybinds), so the shortcut did nothing here (#5687). Same rules as + // there: capture phase, works from inputs, yields to remote desktops and open dialogs. + useEffect(() => { + if (variant !== "creation") return; + const handleFocusChat = (event: KeyboardEvent) => { + if (!matchesKeybind(event, KEYBINDS.FOCUS_CHAT) || isDesktopViewportFocused(event.target)) { + return; + } + event.preventDefault(); + // isDialogOpen() misses the command palette (cmdk); focus must not move behind it. + const inCommandPalette = + event.target instanceof Element && event.target.closest("[cmdk-root]") != null; + if (!isDialogOpen() && !inCommandPalette) focusMessageInput(); + }; + window.addEventListener("keydown", handleFocusChat, { capture: true }); + return () => window.removeEventListener("keydown", handleFocusChat, { capture: true }); + }, [variant, focusMessageInput]); + useEffect(() => { const handleGlobalKeyDown = (event: KeyboardEvent) => { if (isEditableElement(event.target)) { diff --git a/tests/bugbash/repros/focusAndLabels.e2e.ts b/tests/bugbash/repros/focusAndLabels.e2e.ts new file mode 100644 index 00000000000..cf976575c74 --- /dev/null +++ b/tests/bugbash/repros/focusAndLabels.e2e.ts @@ -0,0 +1,86 @@ +// #5681, #5687, #5688, #5693: keyboard focus, labels and shortcut feedback. Each test fails on +// the old code. +import { test } from "@e2e-dev/web"; +import { expect } from "e2e"; +import { disableTutorials, openPlayground } from "./helpers"; + +test( + "Ctrl+I focuses the composer on the new-workspace screen", + { tags: ["bugbash", "5687"] }, + async ({ app, screen, browser }) => { + await browser.setViewport({ width: 1440, height: 900 }); + await disableTutorials(browser); + await app.open(); + const composer = screen.getByRole("textbox", "Message"); + await expect(composer).toBeVisible({ timeout: 15_000 }); + // The screen opens with the composer focused, so move focus away first. + await browser.evaluate(() => { + if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); + return null; + }); + await expect(composer).not.toBeFocused(); + + await browser.keyboard.press("Control+i"); + await expect(composer).toBeFocused(); + + // With the command palette open, Ctrl+I leaves focus in the palette. + await browser.keyboard.press("Control+Shift+P"); + const palette = screen.getByRole("combobox", "Command palette"); + await expect(palette).toBeFocused(); + await browser.keyboard.press("Control+i"); + await expect(palette).toBeFocused(); + } +); + +test( + "The right sidebar's collapse button shows a focus ring", + { tags: ["bugbash", "5688"] }, + async ({ app, screen, browser }) => { + await browser.setViewport({ width: 1440, height: 900 }); + await openPlayground(app, screen, browser); + const collapse = screen + .getByRole("complementary", "Workspace insights") + .getByRole("button", "Collapse sidebar"); + await expect(collapse).toBeVisible(); + // A key press first, so the browser treats the next focus as keyboard focus (:focus-visible). + await browser.keyboard.press("Shift"); + await collapse.focus(); + + await expect + .poll(() => + browser.evaluate(() => { + const el = document.activeElement; + if (!(el instanceof HTMLElement) || !el.matches(":focus-visible")) + return "no focus-visible"; + return getComputedStyle(el).boxShadow; + }) + ) + .not.toMatch(/^(none|no focus-visible)$/); + } +); + +test( + "The workspace actions tooltip uses the button's accessible name", + { tags: ["bugbash", "5681"] }, + async ({ app, screen, browser }) => { + await browser.setViewport({ width: 1440, height: 900 }); + await openPlayground(app, screen, browser); + await screen.getByRole("button", "Workspace actions").hover(); + + await expect(screen.getByRole("tooltip")).toHaveText("Workspace actions"); + } +); + +test( + "The fast mode shortcut explains why fast mode is unavailable", + { tags: ["bugbash", "5693"] }, + async ({ app, screen, browser }) => { + await browser.setViewport({ width: 1440, height: 900 }); + // The seeded Anthropic provider uses a loopback base URL, so fast mode is unavailable. + await openPlayground(app, screen, browser); + await screen.getByRole("textbox", "Message").tap(); + + await browser.keyboard.press("Control+Shift+F"); + await expect(screen.getByText(/^Fast mode is not available/)).toBeVisible(); + } +);