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
23 changes: 21 additions & 2 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ import { defaultUrlTransform } from "react-markdown";
import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";
import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";
import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
import { AssistantCitationChip } from "./chat/AssistantCitationChip";
import { AssistantCitationEmbed } from "./chat/AssistantCitationChip";
import { MarkdownThreadLink } from "./chat/MarkdownThreadLink";
import { mathKind } from "@t3tools/shared/markdownMath";
import remarkMath from "remark-math";
Expand Down Expand Up @@ -952,6 +952,15 @@ const ChatMarkdownRendererContext = React.createContext<
// Isolate streamed source text from the renderer context; only offset-based renderers need it.
const ChatMarkdownTextContext = React.createContext("");

// A quote card is a block. These say nothing else shares its paragraph or list item on that side.
const LIST_MARKER = String.raw`(?:[-*+]|\d+[.)])[ \t]+`;
const CITATION_LEADS_BLOCK = new RegExp(
String.raw`(?:(?:^|\n[ \t]*\n)[ \t>]*(?:${LIST_MARKER})?|\n[ \t>]*${LIST_MARKER})$`,
);
const CITATION_ENDS_BLOCK = new RegExp(
String.raw`^[ \t]*(?:$|\n[ \t]*(?:\n|$)|\n[ \t>]*${LIST_MARKER})`,
);

function markdownHeadingRenderer(level: 1 | 2 | 3 | 4 | 5 | 6) {
const Tag = `h${level}` as const;
return function MarkdownHeading({
Expand Down Expand Up @@ -1096,7 +1105,17 @@ const CHAT_MARKDOWN_COMPONENTS = {
} = use(ChatMarkdownRendererContext);
const text = use(ChatMarkdownTextContext);
const citation = href ? parseAssistantCitationHref(href) : null;
if (citation) return <AssistantCitationChip citation={citation} />;
if (citation) {
const before = node?.position?.start.offset;
const after = node?.position?.end.offset;
return (
<AssistantCitationEmbed
citation={citation}
leading={before !== undefined && CITATION_LEADS_BLOCK.test(text.slice(0, before))}
trailing={after !== undefined && CITATION_ENDS_BLOCK.test(text.slice(after))}
/>
);
}
const threadLink = href ? parseThreadLinkHref(href) : null;
if (threadLink) {
const written = hastPlainTextDeep(node);
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/components/ComposerPromptEditorTiptap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,6 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
>
<AssistantCitationChip
citation={citation}
composer
commentEditor={{
open: commentTarget !== null,
sourceAnchor: commentTarget?.sourceAnchor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ function mount(
return (
<AssistantCitationChip
citation={chipCitation}
composer
commentEditor={{ open, sourceAnchor, onOpenChange: setOpen, onSave }}
/>
);
Expand Down
153 changes: 78 additions & 75 deletions apps/web/src/components/chat/AssistantCitationChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
serializeAssistantCitation,
} from "@t3tools/shared/assistantCitations";
import { Link, useNavigate } from "@tanstack/react-router";
import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react";
import { PencilIcon, QuoteIcon } from "lucide-react";
import {
useEffect,
useEffectEvent,
Expand All @@ -24,24 +24,93 @@ import {
import { cn } from "~/lib/utils";
import { useQuietThreadShell } from "~/state/entities";
import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip";
import { ContextChipPopover } from "../contextChipParts";
import { Button } from "../ui/button";
import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover";
import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor";
import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal";
import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";
import { composerFloatingLayerProps } from "./composerEventScope";
import { observeResize } from "~/lib/observeResize";
import { textDirection } from "~/textDirection";

function useCitationSourceLinkProps(citation: AssistantCitation) {
const navigate = useNavigate();
const sourceThread = useQuietThreadShell(
scopeThreadRef(citation.environmentId, citation.threadId),
);
return {
...assistantCitationRoute(citation, sourceThread),
"data-markdown-copy": serializeAssistantCitation(citation),
onClick: (event: ReactMouseEvent<HTMLAnchorElement>) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
event.preventDefault();
void navigate(assistantCitationNavigation(citation, sourceThread));
},
};
}

/**
* A quoted response inside a message. Clicking it goes to the source; its comment follows as
* text. `leading` and `trailing` say it starts or ends its block, which already has spacing.
*/
export function AssistantCitationEmbed({
citation,
leading,
trailing,
}: {
citation: AssistantCitation;
leading: boolean;
trailing: boolean;
}) {
const wholeResponse = citation.prefix === "" && citation.suffix === "";
const flat = (text: string) => text.replace(/\s+/g, " ");
const { comment, ...quoted } = citation;
const linkProps = useCitationSourceLinkProps(citation);
return (
// One copy boundary for the card and its comment, so a copied quote carries the comment once.
<span
data-markdown-copy={linkProps["data-markdown-copy"]}
className={cn("block", !leading && "mt-[0.65rem]", !trailing && "mb-[0.65rem]")}
>
<Link
{...linkProps}
// A selection inside the card's boundary copies the quote here and the comment as text.
data-markdown-copy={serializeAssistantCitation(quoted)}
Comment thread
maria-rcks marked this conversation as resolved.
Resolved
// The quote is findable in its source message; here it is clamped and mostly hidden.
data-thread-find-ignore
aria-label={`Go to quoted response: ${assistantCitationLabel(quoted)}`}
// The message's markdown styles colour and underline links; a quote is neither.
className="block min-w-0 rounded-lg bg-black/10 px-2.5 py-1.5 text-start text-sm text-muted-foreground! no-underline! wrap-break-word hover:bg-black/14 focus-visible:outline-2 focus-visible:outline-ring dark:bg-black/35 dark:hover:bg-black/50"
>
<span className="line-clamp-2" dir={textDirection(citation.text)}>
{wholeResponse ? (
flat(citation.text)
) : (
<>
{citation.prefix ? `…${flat(citation.prefix)}` : null}
<mark className="rounded-sm bg-foreground/12 px-0.5 text-foreground">
{flat(citation.text)}
</mark>
{citation.suffix ? `${flat(citation.suffix)}…` : null}
</>
)}
</span>
</Link>
{comment ? (
<span className="mt-1.5 block whitespace-pre-wrap" dir={textDirection(comment)}>
{comment}
</span>
) : null}
</span>
);
}

/** The citation chip in the composer, with its comment editor. */
export function AssistantCitationChip({
citation,
composer = false,
commentEditor,
}: {
citation: AssistantCitation;
composer?: boolean;
commentEditor?: {
open: boolean;
sourceAnchor?: AssistantCitationSourceAnchor | undefined;
Expand All @@ -53,10 +122,7 @@ export function AssistantCitationChip({
onRestoreFocus?: () => void;
};
}) {
const navigate = useNavigate();
const sourceThread = useQuietThreadShell(
scopeThreadRef(citation.environmentId, citation.threadId),
);
const sourceLinkProps = useCitationSourceLinkProps(citation);
const commentInputRef = useRef<HTMLTextAreaElement>(null);
const commentPopupRef = useRef<HTMLDivElement>(null);
const draftCommentRef = useRef<string | null>(null);
Expand Down Expand Up @@ -116,17 +182,6 @@ export function AssistantCitationChip({
}
: undefined;
const label = assistantCitationLabel(citation);
const sourceLinkProps = {
...assistantCitationRoute(citation, sourceThread),
"data-markdown-copy": serializeAssistantCitation(citation),
onClick: (event: ReactMouseEvent<HTMLAnchorElement>) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
event.preventDefault();
void navigate(assistantCitationNavigation(citation, sourceThread));
},
};
const composerSourceLink = (
<Link
{...sourceLinkProps}
Expand All @@ -139,27 +194,6 @@ export function AssistantCitationChip({
</ContextChipLabel>
</Link>
);
if (!composer) {
return (
<ContextChipPopover
kind="citation"
icon={<QuoteIcon />}
label={label}
accessibleLabel={`Quoted assistant text: ${label}`}
copyMarkdown={serializeAssistantCitation(citation)}
>
<div className="flex max-h-[calc(var(--available-height)_-_1rem_-_2px)] flex-col items-start gap-3 p-1 text-sm">
<AssistantCitationQuote citation={citation} />
<PopoverClose
render={<Button variant="outline" size="sm" render={<Link {...sourceLinkProps} />} />}
>
<ArrowUpRightIcon aria-hidden="true" />
Go to source
</PopoverClose>
</div>
</ContextChipPopover>
);
}
return (
<ContextChip
kind="citation"
Expand Down Expand Up @@ -262,34 +296,3 @@ export function AssistantCitationChip({
</ContextChip>
);
}

function AssistantCitationQuote({ citation }: { citation: AssistantCitation }) {
const [fade, setFade] = useState({ top: false, bottom: false });
const updateFade = (element: HTMLElement) => {
const top = element.scrollTop > 1;
const bottom = element.scrollHeight - element.clientHeight - element.scrollTop > 1;
setFade((current) =>
current.top === top && current.bottom === bottom ? current : { top, bottom },
);
};
const observeFade = (element: HTMLDivElement | null) =>
element ? observeResize(element, () => updateFade(element)) : undefined;
return (
<div
ref={observeFade}
onScroll={(event) => updateFade(event.currentTarget)}
className={cn(
"max-h-64 min-h-0 space-y-3 self-stretch overflow-y-auto whitespace-pre-wrap wrap-break-word",
getVirtualizedScrollFadeClassName(fade),
)}
>
<blockquote
className="border-l-2 border-border pl-3 text-muted-foreground"
dir={textDirection(citation.text)}
>
{citation.text}
</blockquote>
{citation.comment ? <p dir={textDirection(citation.comment)}>{citation.comment}</p> : null}
</div>
);
}
13 changes: 8 additions & 5 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4538,22 +4538,25 @@ describe("subagent continued by a later turn", () => {
});

describe("shouldCollapseUserMessage", () => {
it("measures a quote chip by its label, not its encoded link", () => {
it("measures a quote by its card, not its encoded link", () => {
const quote = "A long assistant paragraph that the user quoted. ".repeat(40);
const citation = serializeAssistantCitation({
version: 1,
const source = {
version: 1 as const,
environmentId: EnvironmentId.make("environment"),
threadId: ThreadId.make("thread"),
messageId: MessageId.make("source"),
text: quote,
comment: "Why does this matter?",
start: 0,
end: quote.length,
prefix: "",
suffix: "",
});
};
const citation = serializeAssistantCitation({ ...source, comment: "Why does this matter?" });
const bare = serializeAssistantCitation(source);

expect(shouldCollapseUserMessage(`${citation} Can you expand on this?`)).toBe(false);
expect(shouldCollapseUserMessage(`${citation} ${"More text. ".repeat(60)}`)).toBe(true);
expect(shouldCollapseUserMessage(bare.repeat(2))).toBe(false);
expect(shouldCollapseUserMessage(bare.repeat(10))).toBe(true);
});
});
9 changes: 4 additions & 5 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@ import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-mes
import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime";
import * as DateTime from "effect/DateTime";
import * as Equal from "effect/Equal";
import {
assistantCitationLabel,
collectAssistantCitations,
} from "@t3tools/shared/assistantCitations";
import { collectAssistantCitations } from "@t3tools/shared/assistantCitations";
import { shallow } from "zustand/vanilla/shallow";
import { claudeResumeCompactionAge } from "@t3tools/shared/claudeCompaction";
import {
Expand Down Expand Up @@ -2654,7 +2651,9 @@ function visibleUserMessageText(text: string): string {
let visible = "";
let cursor = 0;
for (const match of collectAssistantCitations(text)) {
visible += linkLabels(text.slice(cursor, match.start)) + assistantCitationLabel(match.citation);
// A quote shows as a two-line card, counted here as a mark, with its comment under it.
visible += linkLabels(text.slice(cursor, match.start));
visible += "\n\u275D\n" + (match.citation.comment ?? "") + "\n";
cursor = match.end;
}
return visible + linkLabels(text.slice(cursor));
Expand Down
13 changes: 7 additions & 6 deletions packages/shared/src/threadFindText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ it("indexes nested disclosure summaries and bodies in rendered order", () => {
).toEqual(["Outer", "first", "Inner", "second"]);
});

it("indexes the citation chip label instead of its link text", () => {
it("indexes a citation by its comment, not its quote or link text", () => {
const citation = {
version: 1 as const,
environmentId: EnvironmentId.make("environment"),
Expand All @@ -110,10 +110,11 @@ it("indexes the citation chip label instead of its link text", () => {
};
const text = `Please fix ${serializeAssistantCitation(citation)} thanks`;
expect(searchableMessageSegments({ role: "user", text, streaming: false })).toEqual([
"Please fix cited needle thanks",
]);
const commented = serializeAssistantCitation({ ...citation, comment: "my note" });
expect(searchableMessageSegments({ role: "user", text: commented, streaming: false })).toEqual([
"my note",
"Please fix ",
" thanks",
]);
const commented = serializeAssistantCitation({ ...citation, comment: "my\nnote" });
expect(
searchableMessageSegments({ role: "user", text: `fix ${commented} thanks`, streaming: false }),
).toEqual(["fix ", "my note thanks"]);
});
6 changes: 4 additions & 2 deletions packages/shared/src/threadFindText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
isAttachmentOnlyBootstrapPrompt,
parseComposerContextHref,
} from "./composerContextReferences.ts";
import { assistantCitationLabel, parseAssistantCitationHref } from "./assistantCitations.ts";
import { parseAssistantCitationHref } from "./assistantCitations.ts";
import type { OrchestrationV2ConversationMessage } from "@t3tools/contracts";
import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanText.ts";
import { unified } from "unified";
Expand Down Expand Up @@ -141,7 +141,9 @@ function markdownThreadFindText(
const citation =
node.tagName === "a" && typeof href === "string" ? parseAssistantCitationHref(href) : null;
if (citation) {
text += assistantCitationLabel(citation);
// The quote card is not searched, and it splits the text before it from its comment.
flush();
text += (citation.comment ?? "").replace(/\r?\n/g, " ");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return;
}
{
Expand Down
Loading