diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index 69c2ac1fd4d59..c3ea363147a6e 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -1,13 +1,25 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { serializeAssistantCitation, withAssistantCitationComment, } from "@t3tools/shared/assistantCitations"; -import { createRef, useState } from "react"; -import { expect, it } from "vite-plus/test"; +import { createRef, use, useState } from "react"; +import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; +const navigation = vi.hoisted(() => ({ + navigate: vi.fn(), + params: { environmentId: "environment", threadId: "thread" }, +})); +vi.mock(import("@tanstack/react-router"), async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => navigation.navigate, + useParams: (() => navigation.params) as typeof import("@tanstack/react-router").useParams, +})); + +import { ComposerContextActionsContext } from "./composerContextActions"; import { findAssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { act, render } from "../../test/render"; import { @@ -31,10 +43,14 @@ const promptBefore = `See ${serializeAssistantCitation(citation)} `; function Composer({ question, editorRef, + sourceVisible = true, + onCommandKeyDown, disabled = false, send = "clears", }: { question: boolean; + sourceVisible?: boolean; + onCommandKeyDown?: (key: string, event: KeyboardEvent) => boolean; editorRef: React.RefObject; disabled?: boolean; send?: "clears" | "blocked" | "refusedLater"; @@ -43,15 +59,17 @@ function Composer({ const [answer, setAnswer] = useState(""); return ( <> -
-

- hello world -

-
+ {sourceVisible ? ( +
+

+ hello world +

+
+ ) : null} { if (replacedValue === prompt) setPrompt(savedValue); }} + {...(onCommandKeyDown ? { onCommandKeyDown } : {})} onCitationSubmitAndSend={() => { if (send === "blocked") return false; if (send === "clears") setPrompt(""); @@ -104,9 +123,129 @@ it("keeps an unsaved citation comment when a question takes the composer", async expect(drafts()?.prompt).toBe( `See ${serializeAssistantCitation(withAssistantCitationComment(citation, "check this"))} `, ); - await expect.element(page.getByLabelText("Edit citation comment")).toBeInTheDocument(); + await expect + .element(page.getByRole("link", { name: "View cited assistant text: check this" })) + .toBeInTheDocument(); }); +it.each(["click", "Enter", " "])( + "opens the citation comment editor with %s on the chip", + async (activation) => { + const editorRef = createRef(); + const onCommandKeyDown = vi.fn(() => true); + await using screen = await render( + , + ); + const textbox = screen.getByRole("textbox"); + await textbox.click(); + await userEvent.keyboard("{Enter}"); + expect(onCommandKeyDown).toHaveBeenCalledWith("Enter", expect.any(KeyboardEvent), false); + onCommandKeyDown.mockClear(); + const chip = screen.getByRole("link", { name: "View cited assistant text: hello" }); + chip.element().focus(); + await userEvent.keyboard("{Control>}{Enter}{/Control}"); + expect(onCommandKeyDown).not.toHaveBeenCalled(); + for (const init of [ + { ctrlKey: true }, + { metaKey: true }, + { shiftKey: true }, + { altKey: true }, + { button: 1 }, + ]) { + const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...init }); + // Keep the browser on the test page while exercising the link's modifier handling. + chip.element().addEventListener("click", (event) => event.preventDefault(), { once: true }); + await act(() => { + chip.element().dispatchEvent(event); + }); + await expect.element(page.getByLabelText("Comment on selected text")).not.toBeInTheDocument(); + } + if (activation === "click") { + await chip.click(); + } else { + screen.container.querySelector("[data-citation-comment-trigger]")?.focus(); + await userEvent.keyboard(activation === "Enter" ? "{Enter}" : " "); + } + await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible(); + expect(chip.element().getAttribute("aria-controls")).toBe( + page.getByRole("dialog").element().id, + ); + chip.element().dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + chip.element().dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible(); + expect(onCommandKeyDown).not.toHaveBeenCalled(); + expect(screen.container.querySelector("output")?.dataset.prompt).toBe(promptBefore); + }, +); + +it("reanchors the comment when the cited source mounts after activation", async () => { + const editorRef = createRef(); + await using screen = await render( + , + ); + await screen.getByRole("link", { name: "View cited assistant text: hello" }).click(); + await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible(); + const viewport = document.createElement("div"); + viewport.dataset.assistantCitationViewport = ""; + const source = document.createElement("p"); + source.style.position = "fixed"; + source.style.left = "500px"; + source.style.top = "300px"; + source.dataset.assistantCitationSource = citation.messageId; + source.dataset.assistantCitationEnvironment = citation.environmentId; + source.dataset.assistantCitationThread = citation.threadId; + source.textContent = "hello world"; + viewport.append(source); + await act(() => { + screen.container.append(viewport); + }); + await vi.waitFor(() => { + const highlight = CSS.highlights.get("t3-assistant-citation-comment"); + expect(highlight?.size).toBe(1); + expect(highlight && [...highlight][0]?.startContainer.parentElement).toBe(source); + const popup = page.getByRole("dialog").element().getBoundingClientRect(); + const sourceRect = source.getBoundingClientRect(); + const chipRect = screen + .getByRole("link", { name: "View cited assistant text: hello" }) + .element() + .getBoundingClientRect(); + const distance = (rect: DOMRect) => Math.hypot(popup.left - rect.left, popup.top - rect.bottom); + expect(distance(sourceRect)).toBeLessThan(distance(chipRect)); + }); +}); + +it.each([ + ["environment", "other-thread", "click"], + ["environment", "other-thread", "Enter"], + ["environment", "other-thread", " "], + ["other-environment", "thread", "click"], + ["other-environment", "thread", "Enter"], + ["other-environment", "thread", " "], +])( + "edits a citation from draft %s/%s with %s without navigating", + async (environmentId, threadId, activation) => { + navigation.navigate.mockClear(); + navigation.params = { environmentId, threadId }; + try { + const editorRef = createRef(); + await using screen = await render( + , + ); + const chip = screen.getByRole("link", { name: "View cited assistant text: hello" }); + if (activation === "click") { + await chip.click(); + } else { + chip.element().focus(); + await userEvent.keyboard(activation === "Enter" ? "{Enter}" : " "); + } + await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible(); + expect(navigation.navigate).not.toHaveBeenCalled(); + } finally { + navigation.params = { environmentId: "environment", threadId: "thread" }; + } + }, +); + it("clears the sent prompt when an empty citation comment sends it", async () => { const editorRef = createRef(); await using screen = await render(); @@ -162,6 +301,30 @@ it.each(["blocked", "refusedLater"] as const)( }, ); +it("reveals a side-chat citation using the composer scope on its parent route", async () => { + navigation.navigate.mockClear(); + navigation.params = { environmentId: "environment", threadId: "parent-thread" }; + const editorRef = createRef(); + function SideChatComposer() { + const actions = use(ComposerContextActionsContext); + return ( + + + + ); + } + try { + await using screen = await render(); + await screen.getByRole("link", { name: "View cited assistant text: hello" }).click(); + await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible(); + expect(navigation.navigate).toHaveBeenCalledOnce(); + } finally { + navigation.params = { environmentId: "environment", threadId: "thread" }; + } +}); + it("cancels a new citation while dictation makes the composer read-only", async () => { const editorRef = createRef(); await using screen = await render(); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index a974c9308844f..189d8b30e1fdb 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -128,7 +128,7 @@ import { ComposerContextReferenceChip, ComposerContextRecordsContext, } from "./composerContextPresentation"; -import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; +import { type AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; import { resolveAssistantCitationCommentDismissal } from "./chat/assistantCitationCommentDismissal"; @@ -504,7 +504,10 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { return ( ["kin interface ContextChipProps extends useRender.ComponentProps<"span"> { kind?: ContextChipKind; state?: "unresolved" | "invalid"; - shape?: "inline" | "bubble"; + shape?: "inline" | "pill" | "bubble"; } function ContextChip({ className, kind, state, shape, render, ...props }: ContextChipProps) { diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 5a86f65399fcd..f3c3448da2cad 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -4,9 +4,10 @@ import { assistantCitationLabel, serializeAssistantCitation, } from "@t3tools/shared/assistantCitations"; -import { Link, useNavigate } from "@tanstack/react-router"; -import { PencilIcon, QuoteIcon } from "lucide-react"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; +import { QuoteIcon } from "lucide-react"; import { + use, useEffect, useEffectEvent, useRef, @@ -24,16 +25,19 @@ import { } from "../../lib/assistantCitationNavigation"; import { cn } from "~/lib/utils"; import { useQuietThreadShell } from "~/state/entities"; -import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip"; +import { ContextChip, ContextChipLabel } from "../ContextChip"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; import { composerFloatingLayerProps } from "./composerEventScope"; import { textDirection } from "~/textDirection"; +import { ComposerContextActionsContext } from "../composerContextActions"; -function useCitationSourceLinkProps(citation: AssistantCitation) { +function useCitationSourceLinkProps(citation: AssistantCitation, editComment = false) { const navigate = useNavigate(); + const route = useParams({ strict: false }); + const composerScope = use(ComposerContextActionsContext).threadRef ?? route; const sourceThread = useQuietThreadShell( scopeThreadRef(citation.environmentId, citation.threadId), ); @@ -45,6 +49,13 @@ function useCitationSourceLinkProps(citation: AssistantCitation) { return; } event.preventDefault(); + // Keep the draft editor mounted when editing a quote from another scope. + if ( + editComment && + (citation.environmentId !== composerScope.environmentId || + citation.threadId !== composerScope.threadId) + ) + return; void navigate(assistantCitationNavigation(citation, sourceThread)); }, }; @@ -192,7 +203,8 @@ export function AssistantCitationChip({ onRestoreFocus?: () => void; }; }) { - const sourceLinkProps = useCitationSourceLinkProps(citation); + const sourceLinkProps = useCitationSourceLinkProps(citation, Boolean(commentEditor)); + const chipRef = useRef(null); const commentInputRef = useRef(null); const commentPopupRef = useRef(null); const draftCommentRef = useRef(null); @@ -200,7 +212,14 @@ export function AssistantCitationChip({ const [unavailableSourceAnchor, setUnavailableSourceAnchor] = useState(null); const commentOpen = commentEditor?.open ?? false; - const sourceAnchor = commentEditor?.sourceAnchor; + const [resolvedSource, setResolvedSource] = useState<{ + citation: AssistantCitation; + anchor: AssistantCitationSourceAnchor; + } | null>(null); + if (!commentOpen && resolvedSource !== null) setResolvedSource(null); + const sourceAnchor = + commentEditor?.sourceAnchor ?? + (resolvedSource?.citation === citation ? resolvedSource.anchor : undefined); const activeSourceAnchor = sourceAnchor === unavailableSourceAnchor ? undefined : sourceAnchor; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- drops the uncommitted comment draft held in a ref once the parent closes the comment editor useEffect(() => { @@ -223,17 +242,30 @@ export function AssistantCitationChip({ setUnavailableSourceAnchor(sourceAnchor); } }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes the cited DOM source while the comment editor is open and reacts when it disappears + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- waits for the navigated source to mount, then observes its lifetime while editing useEffect(() => { - if (!commentOpen || sourceAnchor === unavailableSourceAnchor) return; - const anchor = sourceAnchor ?? findAssistantCitationSourceAnchor(document, citation); - if (!anchor) return; - return observeAssistantCitationCommentSource({ - anchor, - citation, - onUnavailable: onSourceUnavailable, - }); - }, [citation, commentOpen, sourceAnchor, unavailableSourceAnchor]); + if (!commentOpen) return; + let stopObservingSource: (() => void) | undefined; + const observer = new MutationObserver(resolveSource); + function resolveSource() { + const anchor = + commentEditor?.sourceAnchor ?? findAssistantCitationSourceAnchor(document, citation); + if (!anchor || anchor === unavailableSourceAnchor) return; + observer.disconnect(); + setResolvedSource({ citation, anchor }); + stopObservingSource = observeAssistantCitationCommentSource({ + anchor, + citation, + onUnavailable: onSourceUnavailable, + }); + } + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + resolveSource(); + return () => { + observer.disconnect(); + stopObservingSource?.(); + }; + }, [citation, commentOpen, commentEditor?.sourceAnchor, unavailableSourceAnchor]); const popupAnchor = activeSourceAnchor ? { contextElement: activeSourceAnchor.source, @@ -253,29 +285,48 @@ export function AssistantCitationChip({ : undefined; const label = assistantCitationLabel(citation); const composerSourceLink = ( - } + data-citation-comment-trigger={commentEditor ? "true" : undefined} + onKeyDownCapture={(event) => { + if (event.key === "Enter") event.stopPropagation(); + if ( + (event.key !== " " && event.key !== "Enter") || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.click(); + }} aria-label={`View cited assistant text: ${label}`} >