From 0fc225e592e29769206a3951c29848da1abf2825 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 5 Oct 2026 18:24:15 +0000 Subject: [PATCH 1/2] feat(web): press R to cite selected assistant text While the Cite action is shown for a selection in an assistant response, pressing R cites it and focuses the comment editor, same as clicking Cite. Type-to-focus leaves R for the Cite action so it is not typed into the composer. Cmd, Ctrl and Alt+R still reach the browser. --- ...AssistantSelectionToolbar.browser.test.tsx | 165 ++++++++++++++++++ .../chat/AssistantSelectionToolbar.tsx | 42 +++-- .../components/chat/composerFocusRedirect.ts | 3 + docs/user/composer.md | 2 +- 4 files changed, 199 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/chat/AssistantSelectionToolbar.browser.test.tsx 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 ea27de728e11d..1621f5bcffdcf 100644 --- a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx +++ b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx @@ -18,6 +18,17 @@ import { type SelectionActionPoint, } from "~/lib/selectionActions"; import { Button } from "../ui/button"; +import { Kbd } from "../ui/kbd"; + +const CITE_ACTION_SELECTOR = "button[data-cite-selection-action]:enabled"; + +/** + * Whether this key press is R while the Cite action is shown. Type-to-focus + * checks it so the key cites instead of being typed into the composer. + */ +export function isCiteSelectionShortcut(event: KeyboardEvent): boolean { + return event.key.toLowerCase() === "r" && document.querySelector(CITE_ACTION_SELECTOR) !== null; +} export function AssistantSelectionToolbar({ viewport, @@ -141,30 +152,34 @@ export function AssistantSelectionToolbar({ onDismiss: clear, }); actionsRef.current = actions; - const focusActions = (event: KeyboardEvent) => { + // While the action is shown, Tab focuses it and R cites straight into the comment editor. + 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; @@ -191,6 +206,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()} @@ -205,6 +222,7 @@ export function AssistantSelectionToolbar({ >