diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 115c30779a57b..2adfd4f6f03db 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -604,6 +604,10 @@ export default function ChatView(props: ChatViewProps) { ) as AssistantCitationRequest | null; useSideChatCitationReveal(embedded ? null : routeThreadRef, locationCitation, citationActivation); const citationHistory = useCitedHistory(citationRequest); + const openDraftCitation = useStableCallback( + (key: string, anchor: AssistantCitationSourceAnchor) => + composerRef.current?.openCitation(key, anchor), + ); const { resolvedTheme } = useTheme(); const composerRuntimeMode = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.runtimeMode ?? null, @@ -3444,6 +3448,14 @@ export default function ChatView(props: ChatViewProps) { citationRequest={citationRequest} citationHistory={citationHistory} onCiteAssistantText={citeAssistantTextStable} + citationDraftTarget={composerDraftTarget} + citationRichTextEnabled={settings.composerRichTextEnabled} + showingCitationDraft={ + !activePendingProgress || + (pendingUserInputs[0] !== undefined && + heldApprovalRequestIds.has(pendingUserInputs[0].requestId)) + } + onOpenDraftCitation={openDraftCitation} onReplyToResponse={replyToResponseStable} {...(activeProject ? { onRunShellCommand: parentPanel?.runShellCommand ?? runShellCommandStable } diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index 69c2ac1fd4d59..c9e1d7f91fd68 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -15,6 +15,8 @@ import { type ComposerPromptEditorHandle, } from "./ComposerPromptEditorTiptap"; +import { AssistantCitationMarkers } from "./chat/AssistantCitationMarkers"; + const citation = { version: 1 as const, environmentId: EnvironmentId.make("environment"), @@ -31,19 +33,24 @@ const promptBefore = `See ${serializeAssistantCitation(citation)} `; function Composer({ question, editorRef, + initialPrompt = promptBefore, + richTextEnabled = false, disabled = false, send = "clears", }: { question: boolean; + initialPrompt?: string; + richTextEnabled?: boolean; editorRef: React.RefObject; disabled?: boolean; send?: "clears" | "blocked" | "refusedLater"; }) { - const [prompt, setPrompt] = useState(promptBefore); + const [viewport, setViewport] = useState(null); + const [prompt, setPrompt] = useState(initialPrompt); const [answer, setAnswer] = useState(""); return ( <> -
+

hello world

+ editorRef.current?.openCitation(key, anchor)} + />
); expect(drafts()?.answer).toBe(""); + await expect + .element(page.getByRole("button", { name: "Edit citation 1 comment" })) + .not.toBeInTheDocument(); await screen.rerender(); expect(drafts()?.prompt).toBe( @@ -107,6 +125,62 @@ it("keeps an unsaved citation comment when a question takes the composer", async await expect.element(page.getByLabelText("Edit citation comment")).toBeInTheDocument(); }); +it("opens the existing comment at its source from a draft marker", async () => { + const editorRef = createRef(); + await using screen = await render(); + await userEvent.click(screen.getByRole("button", { name: "Edit citation 1 comment" })); + await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible(); + expect(screen.container.querySelector("output")?.dataset.prompt).toBe(promptBefore); + await userEvent.keyboard("{Escape}"); + await screen.rerender(); + await userEvent.click(screen.getByRole("button", { name: "Edit citation 1 comment" })); + await expect.element(page.getByLabelText("Comment on selected text")).not.toBeInTheDocument(); +}); + +it("opens each duplicate marker's own comment and restores marker focus", async () => { + const editorRef = createRef(); + const first = withAssistantCitationComment(citation, "first"); + const second = withAssistantCitationComment(citation, "second"); + await using screen = await render( + , + ); + const one = screen.getByRole("button", { name: "Edit citation 1 comment" }); + const two = screen.getByRole("button", { name: "Edit citation 2 comment" }); + await expect.element(one).toBeVisible(); + await expect.element(two).toBeVisible(); + await userEvent.click(one); + await expect.element(page.getByLabelText("Comment on selected text")).toHaveValue("first"); + await userEvent.keyboard("{Escape}"); + await expect.element(one).toHaveFocus(); + await userEvent.click(two); + await expect.element(page.getByLabelText("Comment on selected text")).toHaveValue("second"); +}); + +it("matches the editor citation nodes when rich text is toggled", async () => { + const editorRef = createRef(); + const initialPrompt = "```text\n" + serializeAssistantCitation(citation) + "\n```"; + await using screen = await render( + , + ); + const marker = page.getByRole("button", { name: "Edit citation 1 comment" }); + await expect.element(marker).toBeVisible(); + await marker.click(); + await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible(); + await userEvent.keyboard("{Escape}"); + await screen.rerender( + , + ); + await expect.element(marker).not.toBeInTheDocument(); +}); it("clears the sent prompt when an empty citation comment sends it", 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 650a8be7b97c0..3359e25fb6af6 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1,4 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; +import { updateCitationHighlight } from "./chat/AssistantCitationMarkers"; +import { + composerCitationMarker, + draftCitationMarkers, +} from "./chat/assistantCitationMarkers.logic"; import { documentChipKindForFileName } from "~/pierre-icons"; import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills"; import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; @@ -51,7 +56,7 @@ import { type ReactNode, type RefObject, } from "react"; -import { EditorContent, useEditor } from "@tiptap/react"; +import { EditorContent, useEditor, useEditorState } from "@tiptap/react"; import { clampCollapsedComposerCursor, @@ -149,6 +154,7 @@ export interface ComposerPromptEditorHandle { focusAt: (cursor: number) => void; focusAtEnd: () => void; readSelectionRange: () => { start: number; end: number }; + openCitation: (key: string, sourceAnchor: AssistantCitationSourceAnchor) => void; captureAttachmentInsertion: () => ComposerAttachmentInsertion | undefined; requestCitationComment: (request: ComposerCitationCommentRequest) => void; readSnapshot: () => { @@ -216,6 +222,7 @@ type CitationInsertedSpaces = { before: boolean; after: boolean }; type OpenCitationComment = { key: string; + markerKey?: string; sourceAnchor?: AssistantCitationSourceAnchor; removeOnCancel?: CitationInsertedSpaces; }; @@ -449,6 +456,13 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { const commentContext = use(ComposerCitationCommentContext); const citation = node.attrs.citation as AssistantCitation; const citeKey = node.attrs.citeKey as string; + const marker = useEditorState({ + editor, + selector: ({ editor }) => composerCitationMarker(editor.state.doc, citeKey), + }); + const separator = marker?.indexOf("|") ?? -1; + const markerNumber = marker ? Number(marker.slice(0, separator)) : undefined; + const markerKey = marker?.slice(separator + 1); const commentTarget = commentContext.openComment?.key === citeKey ? commentContext.openComment : null; @@ -494,6 +508,15 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { const onRestoreFocus = () => { const sent = sentRef.current; sentRef.current = false; + if (commentTarget?.markerKey && commentTarget.sourceAnchor) { + const marker = commentTarget.sourceAnchor.viewport.querySelector( + `[data-citation-marker-key="${CSS.escape(commentTarget.markerKey)}"]`, + ); + if (marker && !marker.hidden) { + marker.focus({ preventScroll: true }); + return; + } + } if (!editor.isEditable) return; const pos = nodePos(); const current = pos === null ? null : editor.state.doc.nodeAt(pos); @@ -512,9 +535,14 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { return ( ) => { if ( @@ -535,6 +563,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { > { + if (!editor?.isEditable) return; + const nodes = findChildren( + editor.state.doc, + (node) => node.type.name === "composer-citation", + ); + const markers = draftCitationMarkers({ + type: "doc", + content: nodes.map(({ node }) => node.toJSON()), + }); + const node = nodes[markers.findIndex((marker) => marker.key === key)]?.node; + if (node) setOpenCitation({ key: node.attrs.citeKey, markerKey: key, sourceAnchor }); + }, captureAttachmentInsertion: () => editor ? captureComposerAttachmentInsertion(editor) : undefined, requestCitationComment: (request) => { diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 5a86f65399fcd..f90570ff3fd98 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -25,6 +25,7 @@ import { import { cn } from "~/lib/utils"; import { useQuietThreadShell } from "~/state/entities"; import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip"; +import { Badge } from "../ui/badge"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; @@ -178,9 +179,11 @@ export function ResponseReplyCard({ /** The citation chip in the composer, with its comment editor. */ export function AssistantCitationChip({ citation, + citationNumber, commentEditor, }: { citation: AssistantCitation; + citationNumber?: number | undefined; commentEditor?: { open: boolean; sourceAnchor?: AssistantCitationSourceAnchor | undefined; @@ -271,6 +274,7 @@ export function AssistantCitationChip({ data-assistant-citation-chip="true" data-markdown-copy={serializeAssistantCitation(citation)} > + {citationNumber !== undefined ? {citationNumber} : null} {composerSourceLink} {commentEditor ? ( ({ findAnchor: vi.fn() })); +vi.mock(import("~/lib/assistantTextSelection"), () => ({ + findAssistantCitationSourceAnchor: mocks.findAnchor, +})); + +it.each(["moving sibling", "queued text", "narrow gutter"])( + "keeps source observations across typing and %s changes", + async (scenario) => { + mocks.findAnchor.mockReset(); + const viewport = document.createElement("div"); + const source = document.createElement("p"); + viewport.style.cssText = "position: fixed; left: 0; top: 0; width: 400px; height: 100px"; + source.style.width = "200px"; + source.textContent = "hello again"; + const sibling = document.createElement("div"); + const content = document.createElement("div"); + content.className = "messages-timeline-scroll"; + content.append(sibling, source); + viewport.append(content); + const canvas = document.createElement("div"); + canvas.setAttribute("data-chat-canvas", ""); + canvas.append(viewport); + document.body.append(canvas); + using _fixture = { [Symbol.dispose]: () => canvas.remove() }; + mocks.findAnchor.mockImplementation(() => { + if (!viewport.contains(source)) return null; + const range = document.createRange(); + range.setStart(source.firstChild!, 0); + range.setEnd(source.firstChild!, 5); + return { source, range }; + }); + const citation = { + version: 1 as const, + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("message"), + text: "hello", + start: 0, + end: 5, + prefix: "", + suffix: "", + }; + const prompt = serializeAssistantCitation(citation); + const onOpen = vi.fn(); + await using screen = await render( + , + ); + const markers = screen.container.querySelectorAll( + "[data-citation-marker-key]", + ); + await vi.waitFor(() => { + expect(markers[0]!.style.left).not.toBe(""); + expect(markers[0]!.style.left).not.toBe(markers[1]!.style.left); + expect(markers[0]!.style.top).toBe(markers[1]!.style.top); + expect( + Number.parseFloat(markers[0]!.style.left) + viewport.getBoundingClientRect().left, + ).toBeGreaterThanOrEqual(source.getBoundingClientRect().right); + }); + const searches = mocks.findAnchor.mock.calls.length; + await screen.rerender( + , + ); + expect(mocks.findAnchor).toHaveBeenCalledTimes(searches); + if (scenario === "moving sibling") { + const previousTop = markers[0]!.style.top; + await act(() => { + sibling.style.height = "30px"; + }); + await vi.waitFor(() => expect(markers[0]!.style.top).not.toBe(previousTop)); + } + if (scenario === "queued text") { + await act(() => { + viewport.append(document.createElement("div")); + source.firstChild!.textContent = "hello updated"; + }); + await vi.waitFor(() => { + expect(mocks.findAnchor.mock.calls.length).toBeGreaterThan(searches); + expect(markers[0]!.hidden).toBe(false); + }); + } + if (scenario === "narrow gutter") { + const nextSource = source.cloneNode(true) as HTMLParagraphElement; + await act(() => { + source.style.lineHeight = "20px"; + nextSource.style.lineHeight = "20px"; + source.style.margin = "0"; + nextSource.style.margin = "0"; + content.style.paddingInlineEnd = "32px"; + content.append(nextSource); + }); + mocks.findAnchor.mockImplementation((_viewport, citation) => { + const target = citation.comment === "next line" ? nextSource : source; + const range = document.createRange(); + range.setStart(target.firstChild!, 0); + range.setEnd(target.firstChild!, 5); + return { source: target, range }; + }); + const nextPrompt = serializeAssistantCitation({ ...citation, comment: "next line" }); + await screen.rerender( + , + ); + await act(() => { + source.style.width = "100%"; + nextSource.style.width = "100%"; + viewport.dispatchEvent(new Event("scroll")); + }); + await vi.waitFor(() => { + for (const marker of markers) { + expect(Number.parseFloat(marker.style.left)).toBeGreaterThanOrEqual( + source.getBoundingClientRect().right - viewport.getBoundingClientRect().left, + ); + expect(Number.parseFloat(marker.style.left) + marker.offsetWidth).toBeLessThanOrEqual( + viewport.clientWidth, + ); + } + const positioned = Array.from( + screen.container.querySelectorAll("[data-citation-marker-key]"), + (marker) => { + expect(marker.style.left).not.toBe(""); + expect(marker.hidden).toBe(false); + return marker.getBoundingClientRect(); + }, + ); + expect(positioned).toHaveLength(3); + for (let i = 0; i < positioned.length; i++) { + for (const other of positioned.slice(i + 1)) { + const rect = positioned[i]!; + expect( + rect.right <= other.left || + other.right <= rect.left || + rect.bottom <= other.top || + other.bottom <= rect.top, + ).toBe(true); + } + } + }); + await act(() => nextSource.remove()); + } + await act(() => source.remove()); + await vi.waitFor(() => { + for (const marker of markers) expect(marker.hidden).toBe(true); + }); + }, +); diff --git a/apps/web/src/components/chat/AssistantCitationMarkers.tsx b/apps/web/src/components/chat/AssistantCitationMarkers.tsx new file mode 100644 index 0000000000000..988b392ee33a7 --- /dev/null +++ b/apps/web/src/components/chat/AssistantCitationMarkers.tsx @@ -0,0 +1,219 @@ +import { ASSISTANT_CITATION_MARKDOWN_PREFIX } from "@t3tools/shared/assistantCitations"; +import { Badge } from "~/components/ui/badge"; +import { useComposerDraftStore, type ComposerThreadTarget } from "~/composerDraftStore"; +import { draftCitationMarkers } from "./assistantCitationMarkers.logic"; +import { useLayoutEffect, useRef } from "react"; +import { observeResize } from "~/lib/observeResize"; +import { buildDocJson } from "~/composer-rich-text-doc"; +import { + findAssistantCitationSourceAnchor, + type AssistantCitationSourceAnchor, +} from "~/lib/assistantTextSelection"; + +export function updateCitationHighlight(event: { currentTarget: HTMLElement }) { + const element = event.currentTarget; + const key = element.dataset.citationLinkKey; + if (!key) return; + const matches = element.ownerDocument.querySelectorAll( + `[data-citation-link-key="${CSS.escape(key)}"]`, + ); + const active = Array.from(matches).some((match) => match.matches(":hover, :focus-within")); + for (const match of matches) match.toggleAttribute("data-citation-highlight", active); +} + +export function AssistantCitationMarkers({ + viewport, + prompt, + richTextEnabled, + showingDraft = true, + onOpen, +}: { + viewport: HTMLElement | null; + prompt: string; + richTextEnabled: boolean; + showingDraft?: boolean; + onOpen: (key: string, anchor: AssistantCitationSourceAnchor) => void; +}) { + const citationJson = JSON.stringify( + showingDraft && prompt.includes(ASSISTANT_CITATION_MARKDOWN_PREFIX) + ? draftCitationMarkers(buildDocJson(prompt, () => null, { styling: richTextEnabled })) + : [], + ); + return ; +} + +function CitationMarkers({ + viewport, + citationJson, + onOpen, +}: { + viewport: HTMLElement | null; + citationJson: string; + onOpen: (key: string, anchor: AssistantCitationSourceAnchor) => void; +}) { + const overlayRef = useRef(null); + const citations = JSON.parse(citationJson) as ReturnType; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- positions markers against rendered source ranges as the timeline scrolls and virtualizes rows + useLayoutEffect(() => { + const overlay = overlayRef.current; + if (!viewport || !overlay || citations.length === 0) return; + let frame: number | null = null; + viewport.setAttribute("data-has-draft-citations", ""); + const anchors = new Map(); + const update = () => { + frame = null; + const bounds = viewport.getBoundingClientRect(); + const occurrences = new Map(); + const placed: { left: number; right: number; top: number; bottom: number }[] = []; + let resolvedSource = false; + for (const [index, marker] of citations.entries()) { + const { citation, key } = marker; + const button = overlay.children[index] as HTMLButtonElement; + if (!anchors.has(key)) { + const anchor = findAssistantCitationSourceAnchor(viewport, citation); + anchors.set(key, anchor); + resolvedSource = true; + } + const anchor = anchors.get(key); + const rect = + anchor && + Array.from(anchor.range.getClientRects()).findLast( + (rect) => + rect.width > 0 && + rect.height > 0 && + rect.bottom > bounds.top && + rect.top < bounds.bottom && + rect.right > bounds.left && + rect.left < bounds.right, + ); + button.hidden = !rect; + if (rect && anchor) { + const sourceRight = anchor.source.getBoundingClientRect().right; + const rectKey = `${sourceRight}:${rect.bottom}`; + const occurrence = occurrences.get(rectKey) ?? 0; + occurrences.set(rectKey, occurrence + 1); + const width = button.offsetWidth; + const height = button.offsetHeight; + const base = Math.max(0, sourceRight - bounds.left + 3); + const columns = Math.max(1, Math.floor((bounds.width - base + 2) / (width + 2))); + const left = base + (occurrence % columns) * (width + 2); + let top = + Math.max(0, rect.bottom - bounds.top - height) + + Math.floor(occurrence / columns) * (height + 2); + let collision; + while ( + (collision = placed.find( + (other) => + left < other.right + 2 && + left + width + 2 > other.left && + top < other.bottom + 2 && + top + height + 2 > other.top, + )) + ) { + top = collision.bottom + 2; + } + placed.push({ left, right: left + width, top, bottom: top + height }); + button.style.left = `${left}px`; + button.style.top = `${top}px`; + } + } + if (resolvedSource) rebuildSourceObservations(); + }; + const schedule = () => { + if (frame === null) frame = requestAnimationFrame(update); + }; + let stopResize = () => {}; + const rebuildSourceObservations = () => { + stopResize(); + const geometry = new Set([viewport]); + for (const anchor of anchors.values()) { + for ( + let node: Element | null = anchor?.source ?? null; + node && node !== viewport; + node = node.parentElement + ) + geometry.add(node); + } + stopResize = observeResize([...geometry], schedule); + }; + const mutations = new MutationObserver((records) => { + const relevant = records.filter((record) => !overlay.contains(record.target)); + if (relevant.length === 0) return; + for (const [key, anchor] of anchors) { + if ( + !anchor || + !viewport.contains(anchor.source) || + relevant.some((record) => anchor.source.contains(record.target)) + ) + anchors.delete(key); + } + rebuildSourceObservations(); + schedule(); + }); + mutations.observe(viewport, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: ["style", "class"], + }); + viewport.addEventListener("scroll", schedule, true); + window.addEventListener("resize", schedule); + schedule(); + return () => { + if (frame !== null) cancelAnimationFrame(frame); + viewport.removeAttribute("data-has-draft-citations"); + stopResize(); + mutations.disconnect(); + viewport.removeEventListener("scroll", schedule, true); + window.removeEventListener("resize", schedule); + }; + }, [viewport, citations]); + return ( +
+ {citations.map(({ citation, key, number }) => ( + } + hidden={!viewport} + className="pointer-events-auto absolute" + aria-label={`Edit citation ${number} comment`} + onPointerDown={(event) => event.preventDefault()} + onClick={() => { + if (!viewport) return; + const anchor = findAssistantCitationSourceAnchor(viewport, citation); + if (!anchor || !viewport.contains(anchor.source)) return; + const selection = viewport.ownerDocument.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(anchor.range); + onOpen(key, anchor); + }} + > + {number} + + ))} +
+ ); +} + +export function ActiveDraftCitationMarkers({ + draftTarget, + ...props +}: Omit[0], "prompt"> & { + draftTarget: ComposerThreadTarget; +}) { + const prompt = useComposerDraftStore( + (store) => store.getComposerDraft(draftTarget)?.prompt ?? "", + ); + return ; +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e25ccc6dd25e8..7e6e6c592ad9b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -301,6 +301,7 @@ export interface ChatComposerHandle { options?: { ensureLeadingBoundary?: boolean; clipboardData?: DataTransfer }, ) => boolean; pasteTextAtEnd: (text: string, options?: { bypassAutoAttachment?: boolean }) => boolean; + openCitation: (key: string, sourceAnchor: AssistantCitationSourceAnchor) => void; citeAssistantText: ( citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor, @@ -3029,6 +3030,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusComposer(); return true; }, + openCitation: (key, sourceAnchor) => + composerEditorRef.current?.openCitation(key, sourceAnchor), citeAssistantText: (citation, sourceAnchor) => insertComposerTextStable( formatAssistantCitationForComposer(citation, citation.comment), diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 83a616a0e8609..28aa6579eecf1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,3 +1,5 @@ +import type { ComposerThreadTarget } from "~/composerDraftStore"; +import { ActiveDraftCitationMarkers } from "./AssistantCitationMarkers"; import { threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import type { ToolCallDiffTarget } from "../../diffPanelStore"; @@ -226,6 +228,10 @@ interface MessagesTimelineProps { onRespondToApproval?: TimelineRowSharedState["onRespondToApproval"]; citationRequest?: AssistantCitationRequest | null; citationHistory?: CitedHistory; + citationDraftTarget?: ComposerThreadTarget; + citationRichTextEnabled?: boolean; + showingCitationDraft?: boolean; + onOpenDraftCitation?: (key: string, anchor: AssistantCitationSourceAnchor) => void; onCiteAssistantText?: ( citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor, @@ -349,6 +355,10 @@ const ConversationTimeline = memo(function ConversationTimeline({ citationRequest = null, citationHistory = CITED_HISTORY_LOADING, onCiteAssistantText, + citationDraftTarget, + citationRichTextEnabled = false, + showingCitationDraft = true, + onOpenDraftCitation, onReplyToResponse, isWorking, runlessWorkActive = false, @@ -1430,6 +1440,15 @@ const ConversationTimeline = memo(function ConversationTimeline({ onCite={onCiteAssistantText} /> ) : null} + {onOpenDraftCitation && citationDraftTarget && ( + + )} ref={setTimelineList} data={rows} diff --git a/apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts b/apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts new file mode 100644 index 0000000000000..168d5a29c781b --- /dev/null +++ b/apps/web/src/components/chat/assistantCitationMarkers.logic.test.ts @@ -0,0 +1,74 @@ +import type { JSONContent } from "@tiptap/core"; +import { Schema } from "@tiptap/pm/model"; +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { expect, it } from "vite-plus/test"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { buildDocJson } from "~/composer-rich-text-doc"; +import { composerCitationMarker, draftCitationMarkers } from "./assistantCitationMarkers.logic"; + +it("numbers only citation nodes in document order, including repeated sources", () => { + const citation = { + version: 1 as const, + environmentId: EnvironmentId.make("env"), + threadId: ThreadId.make("thread"), + messageId: MessageId.make("message"), + text: "hello", + start: 0, + end: 5, + prefix: "", + suffix: "", + }; + const second = { ...citation, text: "world", start: 6, end: 11 }; + const link = serializeAssistantCitation(citation); + const doc = buildDocJson(`${serializeAssistantCitation(second)} ${link} ${link}`, () => null); + expect(draftCitationMarkers(doc).map((marker) => marker.citation)).toEqual([ + second, + citation, + citation, + ]); + const markers = draftCitationMarkers(doc); + const composerDoc = structuredClone(doc); + let nodeIndex = 0; + const assignKeys = (node: JSONContent) => { + if (node.type === "composer-citation") + node.attrs = { ...node.attrs, citeKey: `chip-${nodeIndex++}` }; + for (const child of node.content ?? []) assignKeys(child); + }; + assignKeys(composerDoc); + const schema = new Schema({ + nodes: { + doc: { content: "paragraph+" }, + paragraph: { content: "inline*" }, + text: { group: "inline" }, + "composer-citation": { + inline: true, + group: "inline", + attrs: { citation: {}, citeKey: {}, source: { default: "" } }, + }, + }, + }); + const pmDoc = schema.nodeFromJSON(composerDoc); + for (const index of [0, 1, 2]) { + expect(composerCitationMarker(pmDoc, `chip-${index}`)).toBe( + `${markers[index]?.number}|${markers[index]?.key}`, + ); + } + expect(composerCitationMarker(pmDoc, "missing")).toBeUndefined(); + expect(new Set(markers.map(({ key }) => key)).size).toBe(3); + expect( + draftCitationMarkers( + buildDocJson(`typing ${serializeAssistantCitation(second)} ${link} ${link} more`, () => null), + ), + ).toEqual(markers); + expect( + draftCitationMarkers({ + type: "doc", + content: [ + { type: "composer-citation", attrs: { citation: null } }, + { type: "composer-citation", attrs: { citation: { messageId: "bad" } } }, + ], + }), + ).toEqual([]); + expect(draftCitationMarkers(buildDocJson("plain text", () => null))).toEqual([]); + expect(draftCitationMarkers(buildDocJson(`\`\`\`text\n${link}\n\`\`\``, () => null))).toEqual([]); +}); diff --git a/apps/web/src/components/chat/assistantCitationMarkers.logic.ts b/apps/web/src/components/chat/assistantCitationMarkers.logic.ts new file mode 100644 index 0000000000000..f4f3b88c430e9 --- /dev/null +++ b/apps/web/src/components/chat/assistantCitationMarkers.logic.ts @@ -0,0 +1,65 @@ +import { AssistantCitation } from "@t3tools/contracts"; +import type { JSONContent } from "@tiptap/core"; +import type { Node } from "@tiptap/pm/model"; +import * as Schema from "effect/Schema"; + +const isCitation = Schema.is(AssistantCitation); + +export function draftCitationMarkers(doc: JSONContent) { + const citations: { + citation: AssistantCitation; + key: string; + number: number; + }[] = []; + const occurrences = new Map(); + const visit = (node: JSONContent) => { + const citation: unknown = node.attrs?.citation; + if (node.type === "composer-citation" && isCitation(citation)) { + const key = nextCitationMarkerKey(citation, occurrences); + citations.push({ + citation, + key, + number: citations.length + 1, + }); + } + for (const child of node.content ?? []) visit(child); + }; + visit(doc); + return citations; +} + +function nextCitationMarkerKey(citation: AssistantCitation, occurrences: Map) { + const identity = JSON.stringify([ + citation.environmentId, + citation.threadId, + citation.messageId, + citation.start, + citation.end, + ]); + const occurrence = occurrences.get(identity) ?? 0; + occurrences.set(identity, occurrence + 1); + return `${identity}:${occurrence}`; +} + +export function composerCitationMarker(doc: Node, citeKey: string) { + const occurrences = new Map(); + let number = 0; + let marker: string | undefined; + const visit = (node: Node): boolean => { + const citation: unknown = node.attrs.citation; + if (node.type.name === "composer-citation" && isCitation(citation)) { + const key = nextCitationMarkerKey(citation, occurrences); + number++; + if (node.attrs.citeKey === citeKey) { + marker = `${number}|${key}`; + return true; + } + } + for (let index = 0; index < node.childCount; index++) { + if (visit(node.child(index))) return true; + } + return false; + }; + visit(doc); + return marker; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 0f9942e3295ee..2c1c6c95ea007 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2597,9 +2597,18 @@ code { 0.75rem + var(--chat-lane-inset-start, 0px) - var(--chat-timeline-gutter, 0px) ); padding-inline-end: calc( - 0.75rem + var(--chat-lane-inset-end, 0px) - var(--chat-timeline-gutter, 0px) + max(0.75rem, var(--draft-citation-gutter, 0px)) + var(--chat-lane-inset-end, 0px) - + var(--chat-timeline-gutter, 0px) ); } + [data-has-draft-citations] { + --draft-citation-gutter: 2rem; + } + [data-citation-marker-key][data-citation-highlight] { + box-shadow: + 0 0 0 2px var(--background), + 0 0 0 4px var(--ring); + } .chat-scroll-to-bottom, .chat-banner-lane { inset-inline-start: var(--chat-lane-inset-start, 0px); diff --git a/apps/web/src/lib/assistantTextSelection.ts b/apps/web/src/lib/assistantTextSelection.ts index 9f83cd829eed0..3ba582e76edf6 100644 --- a/apps/web/src/lib/assistantTextSelection.ts +++ b/apps/web/src/lib/assistantTextSelection.ts @@ -17,7 +17,7 @@ export type AssistantCitationSourceAnchor = { }; export function findAssistantCitationSourceAnchor( - document: Document, + document: Document | HTMLElement, citation: AssistantCitation, ): AssistantCitationSourceAnchor | null { const source = [ diff --git a/docs/user/composer.md b/docs/user/composer.md index 89a96f120d4d0..6fc35647ef2b6 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -142,6 +142,8 @@ delete it like other inline context. Copying, reloading, and restoring a with its quote, and sending tells the agent which words were quoted and which comment you wrote. The quoted text and comment count toward the message limit. +Citations in the active draft show numbered markers beside their quoted passages in the conversation. Select a marker to reopen its comment at the source. Numbers follow the order of citations in the draft and match the badges on composer chips. + Mobile displays saved quotes and comments, but does not create citations or navigate to their sources. diff --git a/packages/shared/src/assistantCitations.ts b/packages/shared/src/assistantCitations.ts index bb072e462a45e..195f99e241267 100644 --- a/packages/shared/src/assistantCitations.ts +++ b/packages/shared/src/assistantCitations.ts @@ -10,6 +10,7 @@ import * as Schema from "effect/Schema"; const CITATION_PROTOCOL = "t3-citation:"; const CITATION_HREF_PREFIX = `${CITATION_PROTOCOL}//v1/`; +export const ASSISTANT_CITATION_MARKDOWN_PREFIX = `[Assistant quote](${CITATION_HREF_PREFIX}`; // Percent encoding needs up to nine characters per UTF-16 code unit; 1k covers keys and offsets. const MAX_CITATION_HREF_LENGTH = 9 *