diff --git a/apps/web/src/components/chat/AssistantSelectionToolbar.browser.test.tsx b/apps/web/src/components/chat/AssistantSelectionToolbar.browser.test.tsx new file mode 100644 index 0000000000000..0fffcafa4015b --- /dev/null +++ b/apps/web/src/components/chat/AssistantSelectionToolbar.browser.test.tsx @@ -0,0 +1,165 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useState, type ComponentProps } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; +import type { ChatComposerHandle } from "./ChatComposer"; +import { useChatShortcuts } from "./useChatShortcuts"; + +type OnCite = ComponentProps["onCite"]; + +const threadRef = { + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), +}; + +// The chat's window capture listener runs before the toolbar's document listener, so +// type-to-focus sees every key first. The stand-in composer records what it receives. +function ChatShortcuts(props: { composerKeys: string[] }) { + useChatShortcuts({ + enabled: true, + keybindings: [], + scriptKeybindings: [], + getShortcutContext: () => ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + modelPickerOpen: false, + }), + threadFindActive: false, + composerRef: { + current: { + insertTextAtEnd: (key: string) => { + props.composerKeys.push(key); + return true; + }, + } as Partial as ChatComposerHandle, + }, + branchToolbarRef: { current: null }, + closeThreadFind: () => {}, + commands: {}, + fallback: () => undefined, + }); + return null; +} + +function Transcript(props: { onCite: OnCite; composerKeys?: string[] }) { + const [viewport, setViewport] = useState(null); + return ( + <> + {props.composerKeys ? : null} + +
+
+

Render the citation chip

+
+
+ + + ); +} + +// router: false because the transcript never navigates. +const options = { router: false } as const; +const citeButton = page.getByRole("button", { name: "Cite selection in composer" }); + +async function selectAssistantText(container: HTMLElement) { + const text = container.querySelector("p")!.firstChild!; + window.getSelection()!.setBaseAndExtent(text, 7, text, 15); + await expect.element(citeButton).toBeVisible(); +} + +describe("AssistantSelectionToolbar", () => { + it.each(["r", "R"])("cites the selection when %s is pressed", async (key) => { + const onCite = vi.fn(() => true); + await using screen = await render(, options); + await selectAssistantText(screen.container); + + await userEvent.keyboard(key); + + expect(onCite).toHaveBeenCalledOnce(); + expect(onCite.mock.lastCall![0]).toMatchObject({ messageId: "message-1", text: "the cita" }); + expect(window.getSelection()!.isCollapsed).toBe(true); + await expect.element(citeButton).not.toBeInTheDocument(); + }); + + it("cites with R instead of typing it into the composer", async () => { + const onCite = vi.fn(() => true); + const composerKeys: string[] = []; + await using screen = await render( + , + options, + ); + await selectAssistantText(screen.container); + + await userEvent.keyboard("r"); + + expect(onCite).toHaveBeenCalledOnce(); + expect(composerKeys).toEqual([]); + }); + + it("types R into the composer when nothing is selected", async () => { + const onCite = vi.fn(() => true); + const composerKeys: string[] = []; + await using _screen = await render( + , + options, + ); + + await userEvent.keyboard("r"); + + expect(composerKeys).toEqual(["r"]); + expect(onCite).not.toHaveBeenCalled(); + }); + + it("lets a focused text field keep R", async () => { + const onCite = vi.fn(() => true); + await using screen = await render(, options); + await selectAssistantText(screen.container); + const find = screen.getByRole("textbox", { name: "Find in thread" }); + await userEvent.click(find); + + await userEvent.keyboard("r"); + + await expect.element(find).toHaveValue("r"); + expect(onCite).not.toHaveBeenCalled(); + }); + + // Dispatched rather than typed so a real Ctrl+R cannot reload the test page. + it.each([ + ["Cmd+R", { metaKey: true }], + ["Ctrl+R", { ctrlKey: true }], + ["Alt+R", { altKey: true }], + ])("leaves %s to the browser", async (_name, modifier) => { + const onCite = vi.fn(() => true); + await using screen = await render(, options); + await selectAssistantText(screen.container); + + const event = new KeyboardEvent("keydown", { + key: "r", + bubbles: true, + cancelable: true, + ...modifier, + }); + document.body.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + expect(onCite).not.toHaveBeenCalled(); + }); + + it("still focuses the Cite action on Tab", async () => { + const onCite = vi.fn(() => true); + await using screen = await render(, options); + await selectAssistantText(screen.container); + + await userEvent.keyboard("{Tab}"); + + await expect.element(citeButton).toHaveFocus(); + expect(onCite).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx index 9280b9cbc065c..ed0ae27fd7b77 100644 --- a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx +++ b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx @@ -18,6 +18,8 @@ import { type SelectionActionPoint, } from "~/lib/selectionActions"; import { Button } from "../ui/button"; +import { Kbd } from "../ui/kbd"; +import { isCiteSelectionShortcut } from "./composerFocusRedirect"; export function AssistantSelectionToolbar({ viewport, @@ -143,30 +145,33 @@ export function AssistantSelectionToolbar({ onDismiss: clear, }); actionsRef.current = actions; - const focusActions = (event: KeyboardEvent) => { + const handleShortcut = (event: KeyboardEvent) => { const toolbar = toolbarRef.current; if ( - event.key !== "Tab" || - event.shiftKey || + !toolbar || + toolbar.disabled || event.altKey || event.ctrlKey || event.metaKey || event.isComposing || - event.defaultPrevented || - !toolbar || - toolbar.contains(event.target as Node) + event.defaultPrevented ) { return; } - if (toolbar.disabled) return; - event.preventDefault(); - event.stopPropagation(); - toolbar.focus({ preventScroll: true }); + if (event.key === "Tab" && !event.shiftKey && !toolbar.contains(event.target as Node)) { + event.preventDefault(); + event.stopPropagation(); + toolbar.focus({ preventScroll: true }); + } else if (isCiteSelectionShortcut(event)) { + event.preventDefault(); + event.stopPropagation(); + toolbar.click(); + } }; - document.addEventListener("keydown", focusActions, true); + document.addEventListener("keydown", handleShortcut, true); document.addEventListener("selectionchange", actions.selectionChanged); return () => { - document.removeEventListener("keydown", focusActions, true); + document.removeEventListener("keydown", handleShortcut, true); document.removeEventListener("selectionchange", actions.selectionChanged); actions.dispose(); actionsRef.current = null; @@ -193,6 +198,8 @@ export function AssistantSelectionToolbar({ variant="glass" disabled={tooLong} aria-label={tooLong ? "Selection is too long to cite" : "Cite selection in composer"} + aria-keyshortcuts={tooLong ? undefined : "R"} + data-cite-selection-action="" className="fixed z-50 max-w-[calc(100vw-1rem)]" style={{ left: selection.position.x, top: selection.position.y }} onPointerDown={(event) => event.preventDefault()} @@ -207,6 +214,7 @@ export function AssistantSelectionToolbar({ >