Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,21 @@ stay hidden here: they'd sit over folder rows, which don't move apart to make ro
the drag labels hidden there.
- Tests: `apps/web/src/components/sidebar/boringPinDrag.fork.test.ts`.

### Mod+L cites selected assistant text (`fork:`)

Selecting text in an assistant reply shows upstream's floating Cite button. Cmd+L on a Mac, or Ctrl+L elsewhere,
now presses it while it shows, the way Cursor's Cmd+L adds a selection to chat, and the button shows the shortcut.
The shortcut lives in the toolbar rather than in the keybindings config: a new default would be written into
`keybindings.json`, where stock T3 reports the unknown command as an invalid entry. So it can't be rebound, and it
takes Mod+L only while the button shows; the preview's Mod+L (`preview.focusUrl`) is unchanged. Unlike Tab, it
also works in a side chat, whose button cites into that chat as a click does.

- `apps/web/src/components/chat/citeSelectionShortcut.ts`: the shortcut, its label and its `aria-keyshortcuts`.
- `apps/web/src/components/chat/AssistantSelectionToolbar.tsx`: a capture-phase keydown listener clicks Cite, so
a disabled (too long) selection ignores it too, and a `Kbd` shows the shortcut on the button.
- Tests: `apps/web/src/components/chat/citeSelectionShortcut.fork.test.ts` and
`apps/web/src/components/chat/AssistantSelectionToolbar.fork.browser.test.tsx`.

### Fork CI, release and sync (`fork:`)

- Only the fork's own workflows run here: `fork-ci.yml`, `fork-release.yml`, `fork-windows-tests.yml`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {
ASSISTANT_CITATION_MAX_TEXT_LENGTH,
EnvironmentId,
ThreadId,
type AssistantCitation,
} from "@t3tools/contracts";
import { isMacPlatform } from "@t3tools/shared/keybindings";
import { useState } from "react";
import { describe, expect, it, vi } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";

import "~/index.css";
import { render } from "../../../test/render";
import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";

const threadRef = {
environmentId: EnvironmentId.make("environment"),
threadId: ThreadId.make("thread"),
};
const mod = isMacPlatform(navigator.platform) ? "Meta" : "Control";
const citeButton = () => page.getByRole("button", { name: "Cite selection in composer" });

function Timeline({
text,
onCite,
}: {
text: string;
onCite: (citation: AssistantCitation) => boolean;
}) {
const [viewport, setViewport] = useState<HTMLElement | null>(null);
return (
<div ref={setViewport} data-assistant-citation-viewport="true">
<AssistantSelectionToolbar viewport={viewport} threadRef={threadRef} onCite={onCite} />
<p data-assistant-citation-source="source">{text}</p>
</div>
);
}

function selectSourceText(end: number) {
const text = document.querySelector("[data-assistant-citation-source]")!.firstChild!;
const range = document.createRange();
range.setStart(text, 0);
range.setEnd(text, end);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
}

describe("AssistantSelectionToolbar Mod+L (fork)", () => {
it("cites the selection, as clicking Cite does", async () => {
const onCite = vi.fn((_citation: AssistantCitation) => true);
await using _screen = await render(<Timeline text="hello world" onCite={onCite} />);

selectSourceText(5);
await expect.element(citeButton()).toBeVisible();
await expect
.element(citeButton())
.toHaveAttribute("aria-keyshortcuts", mod === "Meta" ? "Meta+L" : "Control+L");
await userEvent.keyboard(`{${mod}>}l{/${mod}}`);

expect(onCite).toHaveBeenCalledOnce();
expect(onCite.mock.calls[0]![0]).toMatchObject({ messageId: "source", text: "hello" });
await expect.element(citeButton()).not.toBeInTheDocument();
expect(window.getSelection()!.isCollapsed).toBe(true);
});

it("does nothing without a selection", async () => {
const onCite = vi.fn(() => true);
await using _screen = await render(<Timeline text="hello world" onCite={onCite} />);
let prevented: boolean | null = null;
const record = (event: KeyboardEvent) => {
if (event.key === "l") prevented = event.defaultPrevented;
};
window.addEventListener("keydown", record);
try {
await userEvent.keyboard(`{${mod}>}l{/${mod}}`);
} finally {
window.removeEventListener("keydown", record);
}

expect(prevented).toBe(false);
expect(onCite).not.toHaveBeenCalled();
});

it("does nothing when the selection is too long to cite", async () => {
const onCite = vi.fn(() => true);
const text = "a".repeat(ASSISTANT_CITATION_MAX_TEXT_LENGTH + 1);
await using _screen = await render(<Timeline text={text} onCite={onCite} />);

selectSourceText(text.length);
const tooLong = page.getByRole("button", { name: "Selection is too long to cite" });
await expect.element(tooLong).toBeVisible();
await userEvent.keyboard(`{${mod}>}l{/${mod}}`);

expect(onCite).not.toHaveBeenCalled();
await expect.element(tooLong).toBeVisible();
});
});
19 changes: 19 additions & 0 deletions apps/web/src/components/chat/AssistantSelectionToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@ import {
type SelectionActionPoint,
} from "~/lib/selectionActions";
import { Button } from "../ui/button";
import { Kbd } from "../ui/kbd";
import { BackgroundThreadContext } from "./backgroundThread";
// fork: Mod+L presses Cite. See FORK.md.
import {
citeSelectionAriaKeyShortcuts,
citeSelectionShortcutLabel,
isCiteSelectionShortcut,
} from "./citeSelectionShortcut";

export function AssistantSelectionToolbar({
viewport,
Expand Down Expand Up @@ -165,11 +172,21 @@ export function AssistantSelectionToolbar({
event.stopPropagation();
toolbar.focus({ preventScroll: true });
};
// fork: Mod+L clicks Cite while it shows, so a disabled button ignores it too. See FORK.md.
const citeOnShortcut = (event: KeyboardEvent) => {
const toolbar = toolbarRef.current;
if (!toolbar || event.defaultPrevented || !isCiteSelectionShortcut(event)) return;
event.preventDefault();
event.stopPropagation();
toolbar.click();
};
// Tab belongs to the foreground chat; a background timeline only follows its selection.
if (!backgroundThread) document.addEventListener("keydown", focusActions, true);
document.addEventListener("keydown", citeOnShortcut, true);
document.addEventListener("selectionchange", actions.selectionChanged);
return () => {
document.removeEventListener("keydown", focusActions, true);
document.removeEventListener("keydown", citeOnShortcut, true);
document.removeEventListener("selectionchange", actions.selectionChanged);
actions.dispose();
actionsRef.current = null;
Expand All @@ -196,6 +213,7 @@ export function AssistantSelectionToolbar({
variant="glass"
disabled={tooLong}
aria-label={tooLong ? "Selection is too long to cite" : "Cite selection in composer"}
aria-keyshortcuts={tooLong ? undefined : citeSelectionAriaKeyShortcuts()}
className="fixed z-50 max-w-[calc(100vw-1rem)]"
style={{ left: selection.position.x, top: selection.position.y }}
onPointerDown={(event) => event.preventDefault()}
Expand All @@ -210,6 +228,7 @@ export function AssistantSelectionToolbar({
>
<QuoteIcon aria-hidden="true" className="size-3.5" />
{tooLong ? "Shorten selection" : "Cite"}
{tooLong ? null : <Kbd className="-me-0.5">{citeSelectionShortcutLabel()}</Kbd>}
</Button>,
document.body,
);
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/components/chat/citeSelectionShortcut.fork.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vite-plus/test";

import {
citeSelectionAriaKeyShortcuts,
citeSelectionShortcutLabel,
isCiteSelectionShortcut,
} from "./citeSelectionShortcut";

const key = (overrides: Partial<Parameters<typeof isCiteSelectionShortcut>[0]> = {}) => ({
type: "keydown",
key: "l",
code: "KeyL",
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
...overrides,
});

describe("isCiteSelectionShortcut", () => {
it("matches Cmd+L on a Mac and Ctrl+L elsewhere", () => {
expect(isCiteSelectionShortcut(key({ metaKey: true }), "MacIntel")).toBe(true);
expect(isCiteSelectionShortcut(key({ ctrlKey: true }), "Win32")).toBe(true);
expect(isCiteSelectionShortcut(key({ ctrlKey: true }), "Linux x86_64")).toBe(true);
});

it("leaves the other platform's modifier alone", () => {
expect(isCiteSelectionShortcut(key({ ctrlKey: true }), "MacIntel")).toBe(false);
expect(isCiteSelectionShortcut(key({ metaKey: true }), "Win32")).toBe(false);
});

it("leaves Mod+Shift+L, the previous-worktree shortcut, alone", () => {
expect(isCiteSelectionShortcut(key({ metaKey: true, shiftKey: true }), "MacIntel")).toBe(false);
expect(isCiteSelectionShortcut(key({ ctrlKey: true, shiftKey: true }), "Win32")).toBe(false);
});

it("follows the L key on other layouts", () => {
expect(isCiteSelectionShortcut(key({ key: "д", ctrlKey: true }), "Win32")).toBe(true);
});

it("ignores key-ups, a bare L and IME composition", () => {
expect(isCiteSelectionShortcut(key({ type: "keyup", ctrlKey: true }), "Win32")).toBe(false);
expect(isCiteSelectionShortcut(key(), "Win32")).toBe(false);
expect(isCiteSelectionShortcut(key({ ctrlKey: true, isComposing: true }), "Win32")).toBe(false);
});
});

describe("cite shortcut labels", () => {
it("reads as each platform writes it", () => {
expect(citeSelectionShortcutLabel("MacIntel")).toBe("⌘L");
expect(citeSelectionShortcutLabel("Win32")).toBe("Ctrl+L");
expect(citeSelectionAriaKeyShortcuts("MacIntel")).toBe("Meta+L");
expect(citeSelectionAriaKeyShortcuts("Win32")).toBe("Control+L");
});
});
28 changes: 28 additions & 0 deletions apps/web/src/components/chat/citeSelectionShortcut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// fork: Mod+L cites selected assistant text, as clicking Cite does. See FORK.md.
import {
isMacPlatform,
matchesKeybindingShortcut,
parseKeybindingShortcut,
} from "@t3tools/shared/keybindings";

import { formatShortcutLabel, type ShortcutEventLike } from "~/keybindings";

const CITE_SELECTION_SHORTCUT = parseKeybindingShortcut("mod+l")!;

export function isCiteSelectionShortcut(
event: ShortcutEventLike & { isComposing?: boolean },
platform = navigator.platform,
): boolean {
if (event.type !== undefined && event.type !== "keydown") return false;
if (event.isComposing) return false;
return matchesKeybindingShortcut(event, CITE_SELECTION_SHORTCUT, platform);
}

export function citeSelectionShortcutLabel(platform = navigator.platform): string {
return formatShortcutLabel(CITE_SELECTION_SHORTCUT, platform);
}

/** The shortcut in `aria-keyshortcuts` syntax. */
export function citeSelectionAriaKeyShortcuts(platform = navigator.platform): string {
return isMacPlatform(platform) ? "Meta+L" : "Control+L";
}
Loading