Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5fdc6cb
feat(web): mark draft citations in assistant responses
ElliotDrel Nov 7, 2026
fe30642
fix(web): stabilize draft citation markers and target comments by ide…
ElliotDrel Nov 7, 2026
c666e46
fix(web): match citation markers to the visible composer draft
ElliotDrel Nov 7, 2026
b002e89
fix(web): keep citation marker updates bounded and stable
ElliotDrel Nov 7, 2026
e4a8754
fix(web): spread duplicate citation markers horizontally
ElliotDrel Nov 7, 2026
2a40aa0
fix(web): citation markers sit in the gutter instead of covering text
ElliotDrel Nov 7, 2026
33b1537
feat(web): number composer citation chips to match their markers
ElliotDrel Nov 7, 2026
c03912f
fix(web): cheaper citation chip numbering and review fixes
ElliotDrel Nov 7, 2026
051107b
fix(web): citation markers stay off text and follow moving rows
ElliotDrel Nov 7, 2026
45fd664
fix(web): make the linked citation marker highlight visible
ElliotDrel Nov 7, 2026
f3e7d04
refactor(web): style the marker highlight in feature CSS and share th…
ElliotDrel Nov 7, 2026
0a1f2f6
Merge remote-tracking branch 'origin/main' into t3/cite-response-mark…
ElliotDrel Nov 9, 2026
52c824f
refactor(web): consolidate citation marker observers
ElliotDrel Nov 9, 2026
fadc4dc
fix(web): keep stacked citation markers from overlapping
maria-rcks Nov 10, 2026
2b3f90a
merge: update draft citation markers with main
ElliotDrel Nov 10, 2026
181ba57
merge: retain marker prefix with updated citation limits
ElliotDrel Nov 11, 2026
3c14c3e
merge: retain draft citation numbers with sent quote embeds
ElliotDrel Nov 11, 2026
852e3e3
chore: merge main into draft citation markers
maria-rcks Nov 11, 2026
425972a
merge: keep draft citation markers and reply actions
ElliotDrel Nov 12, 2026
c7e6df4
merge: retain marker helper options with read-only coverage
ElliotDrel Nov 12, 2026
7f57fd8
merge: retain citation markers with attachment insertion
ElliotDrel Nov 14, 2026
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
12 changes: 12 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
type ComposerPromptEditorHandle,
} from "./ComposerPromptEditorTiptap";

import { AssistantCitationMarkers } from "./chat/AssistantCitationMarkers";

const citation = {
version: 1 as const,
environmentId: EnvironmentId.make("environment"),
Expand All @@ -31,30 +33,43 @@ 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<ComposerPromptEditorHandle | null>;
disabled?: boolean;
send?: "clears" | "blocked" | "refusedLater";
}) {
const [prompt, setPrompt] = useState(promptBefore);
const [viewport, setViewport] = useState<HTMLDivElement | null>(null);
const [prompt, setPrompt] = useState(initialPrompt);
const [answer, setAnswer] = useState("");
return (
<>
<div data-assistant-citation-viewport="">
<div ref={setViewport} className="relative" data-assistant-citation-viewport="">
<p
data-assistant-citation-source={citation.messageId}
data-assistant-citation-environment={citation.environmentId}
data-assistant-citation-thread={citation.threadId}
>
hello world
</p>
<AssistantCitationMarkers
viewport={viewport}
prompt={prompt}
richTextEnabled={richTextEnabled}
showingDraft={!question}
onOpen={(key, anchor) => editorRef.current?.openCitation(key, anchor)}
/>
</div>
<output data-prompt={prompt} data-answer={answer} />
<ComposerPromptEditor
editorRef={editorRef}
richTextEnabled={richTextEnabled}
value={question ? answer : prompt}
cursor={0}
contextRecords={new Map()}
Expand Down Expand Up @@ -99,6 +114,9 @@ it("keeps an unsaved citation comment when a question takes the composer", async

await screen.rerender(<Composer question editorRef={editorRef} />);
expect(drafts()?.answer).toBe("");
await expect
.element(page.getByRole("button", { name: "Edit citation 1 comment" }))
.not.toBeInTheDocument();
await screen.rerender(<Composer question={false} editorRef={editorRef} />);

expect(drafts()?.prompt).toBe(
Expand All @@ -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<ComposerPromptEditorHandle>();
await using screen = await render(<Composer question={false} editorRef={editorRef} />);
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(<Composer question={false} editorRef={editorRef} disabled />);
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<ComposerPromptEditorHandle>();
const first = withAssistantCitationComment(citation, "first");
const second = withAssistantCitationComment(citation, "second");
await using screen = await render(
<Composer
question={false}
editorRef={editorRef}
initialPrompt={`${serializeAssistantCitation(first)} ${serializeAssistantCitation(second)}`}
/>,
);
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<ComposerPromptEditorHandle>();
const initialPrompt = "```text\n" + serializeAssistantCitation(citation) + "\n```";
await using screen = await render(
<Composer question={false} editorRef={editorRef} initialPrompt={initialPrompt} />,
);
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(
<Composer
question={false}
editorRef={editorRef}
initialPrompt={initialPrompt}
richTextEnabled
/>,
);
await expect.element(marker).not.toBeInTheDocument();
});
it("clears the sent prompt when an empty citation comment sends it", async () => {
const editorRef = createRef<ComposerPromptEditorHandle>();
await using screen = await render(<Composer question={false} editorRef={editorRef} />);
Expand Down
46 changes: 44 additions & 2 deletions apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: () => {
Expand Down Expand Up @@ -216,6 +222,7 @@ type CitationInsertedSpaces = { before: boolean; after: boolean };

type OpenCitationComment = {
key: string;
markerKey?: string;
sourceAnchor?: AssistantCitationSourceAnchor;
removeOnCancel?: CitationInsertedSpaces;
};
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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<HTMLElement>(
`[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);
Expand All @@ -512,9 +535,14 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
return (
<NodeViewWrapper
as="span"
className={CHIP_NODE_SELECTION_CLASS_NAME}
className={`${CHIP_NODE_SELECTION_CLASS_NAME} data-[citation-highlight]:ring-2 data-[citation-highlight]:ring-ring`}
contentEditable={false}
spellCheck={false}
data-citation-link-key={markerKey}
onPointerEnter={updateCitationHighlight}
onPointerLeave={updateCitationHighlight}
onFocus={updateCitationHighlight}
onBlur={updateCitationHighlight}
data-composer-citation-chip="true"
onKeyDown={(event: ReactKeyboardEvent<HTMLElement>) => {
if (
Expand All @@ -535,6 +563,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
>
<AssistantCitationChip
citation={citation}
citationNumber={markerNumber}
commentEditor={{
open: commentTarget !== null,
sourceAnchor: commentTarget?.sourceAnchor,
Expand Down Expand Up @@ -1746,6 +1775,19 @@ function ComposerPromptEditorTiptapInner(
readSnapshot();
return selectionRangeRef.current;
},
openCitation: (key, sourceAnchor) => {
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) => {
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/AssistantCitationChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -271,6 +274,7 @@ export function AssistantCitationChip({
data-assistant-citation-chip="true"
data-markdown-copy={serializeAssistantCitation(citation)}
>
{citationNumber !== undefined ? <Badge>{citationNumber}</Badge> : null}
{composerSourceLink}
{commentEditor ? (
<Popover
Expand Down
Loading
Loading