Repository navigation
feat(web): press R to cite selected assistant text #25570
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
chhoumann
wants to merge
4
commits into
pingdotgg:main
Choose a base branch
from
chhoumann:feat/cite-selection-hotkey
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+194
−13
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
0fc225e
feat(web): press R to cite selected assistant text
chhoumann ab6f01f
Merge remote-tracking branch 'github/main' into takeover-25570
maria-rcks ec71712
refactor(web): keep citation shortcut detection with composer input
maria-rcks a5b0cb3
chore: merge main into citation shortcut branch
maria-rcks File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
165 changes: 165 additions & 0 deletions
165
apps/web/src/components/chat/AssistantSelectionToolbar.browser.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof AssistantSelectionToolbar>["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<ChatComposerHandle> as ChatComposerHandle, | ||
| }, | ||
| branchToolbarRef: { current: null }, | ||
| closeThreadFind: () => {}, | ||
| commands: {}, | ||
| fallback: () => undefined, | ||
| }); | ||
| return null; | ||
| } | ||
|
|
||
| function Transcript(props: { onCite: OnCite; composerKeys?: string[] }) { | ||
| const [viewport, setViewport] = useState<HTMLElement | null>(null); | ||
| return ( | ||
| <> | ||
| {props.composerKeys ? <ChatShortcuts composerKeys={props.composerKeys} /> : null} | ||
| <input aria-label="Find in thread" /> | ||
| <div ref={setViewport}> | ||
| <div data-assistant-citation-source="message-1"> | ||
| <p>Render the citation chip</p> | ||
| </div> | ||
| </div> | ||
| <AssistantSelectionToolbar viewport={viewport} threadRef={threadRef} onCite={props.onCite} /> | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| // 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<OnCite>(() => true); | ||
| await using screen = await render(<Transcript onCite={onCite} />, 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<OnCite>(() => true); | ||
| const composerKeys: string[] = []; | ||
| await using screen = await render( | ||
| <Transcript onCite={onCite} composerKeys={composerKeys} />, | ||
| 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<OnCite>(() => true); | ||
| const composerKeys: string[] = []; | ||
| await using _screen = await render( | ||
| <Transcript onCite={onCite} composerKeys={composerKeys} />, | ||
| 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<OnCite>(() => true); | ||
| await using screen = await render(<Transcript onCite={onCite} />, 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<OnCite>(() => true); | ||
| await using screen = await render(<Transcript onCite={onCite} />, 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<OnCite>(() => true); | ||
| await using screen = await render(<Transcript onCite={onCite} />, options); | ||
| await selectAssistantText(screen.container); | ||
|
|
||
| await userEvent.keyboard("{Tab}"); | ||
|
|
||
| await expect.element(citeButton).toHaveFocus(); | ||
| expect(onCite).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: pingdotgg/t3code
Length of output: 13920
Script executed:
Repository: pingdotgg/t3code
Length of output: 41754
Script executed:
Repository: pingdotgg/t3code
Length of output: 41557
Script executed:
Repository: pingdotgg/t3code
Length of output: 36774
Script executed:
Repository: pingdotgg/t3code
Length of output: 15187
Script executed:
Repository: pingdotgg/t3code
Length of output: 11252
Do not consume the cite shortcut from editable fields.
A field can remain focused while a pointer selection is made in the assistant response. The selection observer then restores the toolbar after
mouseup. The document capture handler currently preventsrand clicks Cite before the focused field receives it. Check for input and textarea targets before handling the cite shortcut.Suggested fix
Committable suggestion