Skip to content
Closed
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
19 changes: 18 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,14 @@ import ReactMarkdown from "react-markdown";
import { toHtml } from "hast-util-to-html";
import { createIncrementalMarkdownPlugin } from "../markdown-incremental";
import { defaultUrlTransform } from "react-markdown";
import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations";
import {
parseAssistantCitationHref,
parseAssistantCitationRefHref,
} from "@t3tools/shared/assistantCitations";
import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences";
import { parseThreadLinkHref } from "@t3tools/shared/threadLinks";
import { AssistantCitationChip } from "./chat/AssistantCitationChip";
import { AssistantCitationNumber } from "./chat/AssistantCitationCards";
import { MarkdownThreadLink } from "./chat/MarkdownThreadLink";
import { isWindowsAbsolutePath } from "@t3tools/shared/path";
import {
Expand Down Expand Up @@ -2484,6 +2488,7 @@ function useChatMarkdownState({
}, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]);
const markdownUrlTransform = useCallback((href: string) => {
if (parseAssistantCitationHref(href)) return href;
if (parseAssistantCitationRefHref(href) !== null) return href;
if (parseComposerContextHref(href)) return href;
if (parseThreadLinkHref(href)) return href;
if (isWindowsDrivePathHref(href)) return href;
Expand Down Expand Up @@ -2948,6 +2953,18 @@ const CHAT_MARKDOWN_COMPONENTS = {
} = use(ChatMarkdownRendererContext);
const citation = href ? parseAssistantCitationHref(href) : null;
if (citation) return <AssistantCitationChip citation={citation} />;
// `#n` points at quote card n of the same message.
const quoteNumber = href ? parseAssistantCitationRefHref(href) : null;
if (quoteNumber !== null) {
return (
<AssistantCitationNumber
number={quoteNumber}
aria-label={`Quote ${quoteNumber}`}
data-thread-find-ignore
data-markdown-copy={`#${quoteNumber}`}
/>
);
}
// A thread link names a thread in this message's environment and opens it in the app.
const linkedThreadId = href ? parseThreadLinkHref(href) : null;
if (linkedThreadId) {
Expand Down
5 changes: 2 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,6 @@ import { flushSync } from "react-dom";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation";
import { isMacPlatform } from "../lib/utils";
import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
import { useShallow } from "zustand/react/shallow";
import {
isAtomCommandInterrupted,
Expand Down Expand Up @@ -1857,8 +1856,8 @@ export default function ChatView(props: ChatViewProps) {
useState<HTMLDivElement | null>(null);
const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false);
const citeAssistantText = useCallback(
(citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => {
const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false;
(citation: AssistantCitation) => {
const inserted = composerRef.current?.citeAssistantText(citation) ?? false;
if (!inserted) {
toastManager.add({
type: "warning",
Expand Down
198 changes: 190 additions & 8 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,199 @@
import { ComposerPromptEditorTiptap } from "./ComposerPromptEditorTiptap";
import type { ComposerPromptEditorProps } from "./ComposerPromptEditorTiptap";
import { removeLeadingAssistantCitation } from "@t3tools/shared/assistantCitations";
import {
useCallback,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
type RefObject,
} from "react";

export type {
ComposerCitationCommentRequest,
ComposerPromptEditorHandle,
ComposerPromptEditorProps,
import {
collapseExpandedComposerCursor,
expandCollapsedComposerCursor,
joinComposerQuoteCards,
setLeadingAssistantCitationComment,
splitComposerQuoteCards,
} from "~/composer-logic";
import { ComposerQuoteCardTray, quoteCardKeys } from "./chat/AssistantCitationCards";
import {
ComposerPromptEditorTiptap,
type ComposerPromptEditorHandle as TextEditorHandle,
type ComposerPromptEditorProps as TextEditorProps,
} from "./ComposerPromptEditorTiptap";

export interface ComposerPromptEditorHandle extends TextEditorHandle {
/** Focuses the comment of quote card `number` (1-based), once it has rendered. */
focusQuoteCard: (number: number) => void;
}

export interface ComposerPromptEditorProps extends Omit<TextEditorProps, "editorRef"> {
/** Keeps quote cards out of a collapsed one-line composer. */
quoteCardsHidden?: boolean;
editorRef: RefObject<ComposerPromptEditorHandle | null>;
}

/**
* The composer editor. Tiptap in both modes: the `richTextEnabled` setting
* toggles Markdown styling, never the engine. Plain mode keeps Markdown
* markers literal; `literalText` also preserves context-token sources as text.
*
* The prompt's leading citations show as quote cards above the text editor, which edits only
* the text below them. Values and cursors cross this component in full-prompt terms, so the
* rest of the composer never sees the split.
*/
export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
return <ComposerPromptEditorTiptap {...props} />;
export function ComposerPromptEditor({
value,
cursor,
onChange,
editorRef,
quoteCardsHidden = false,
...rest
}: ComposerPromptEditorProps) {
const literalText = rest.literalText ?? false;
const split = useMemo(() => splitPrompt(value, literalText), [literalText, value]);
// Card edits report a new prompt before it comes back as `value`; text edits in between must
// join with the newest cards, not the rendered ones.
const latestRef = useRef(split);
const onChangeRef = useRef(onChange);
useLayoutEffect(() => {
latestRef.current = split;
}, [split]);
useLayoutEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
const textEditorRef = useRef<TextEditorHandle>(null);
const [focusRequest, setFocusRequest] = useState<number | null>(null);

const sources = () => latestRef.current.citations.map(({ source }) => source);
const toPrompt = (snapshot: ReturnType<TextEditorHandle["readSnapshot"]>) => {
const next = joinComposerQuoteCards(sources(), snapshot.value);
return {
value: next.prompt,
cursor: snapshot.cursor + next.collapsedOffset,
expandedCursor: snapshot.expandedCursor + next.expandedOffset,
contextIds: snapshot.contextIds,
};
};
const textSnapshot = () =>
textEditorRef.current?.readSnapshot() ?? {
value: latestRef.current.body,
cursor: Math.max(0, cursor - latestRef.current.collapsedOffset),
expandedCursor: latestRef.current.body.length,
contextIds: [],
};

const onTextChange = useCallback<TextEditorProps["onChange"]>(
(body, bodyCursor, bodyExpandedCursor, cursorAdjacentToMention, contextIds) => {
const next = joinComposerQuoteCards(
latestRef.current.citations.map(({ source }) => source),
body,
);
onChangeRef.current(
next.prompt,
bodyCursor + next.collapsedOffset,
bodyExpandedCursor + next.expandedOffset,
cursorAdjacentToMention,
contextIds,
);
},
[],
);

/** Reports a prompt whose cards changed; the caret stays where it was in the text. */
const commitCards = (edit: (prompt: string) => string) => {
const snapshot = textSnapshot();
const prompt = edit(toPrompt(snapshot).value);
const next = splitPrompt(prompt, literalText);
latestRef.current = next;
// Removing a card also removes its `#n` references from the text.
const bodyCursor =
next.body === snapshot.value
? snapshot.cursor
: collapseExpandedComposerCursor(next.body, next.body.length);
onChangeRef.current(
prompt,
bodyCursor + next.collapsedOffset,
expandCollapsedComposerCursor(next.body, bodyCursor) + next.expandedOffset,
// Card edits never open a mention or command menu.
true,
snapshot.contextIds,
);
};

const focusText = () => {
window.requestAnimationFrame(() => textEditorRef.current?.focus());
};

useImperativeHandle(editorRef, () => ({
focus: () => textEditorRef.current?.focus(),
focusAt: (nextCursor) =>
textEditorRef.current?.focusAt(Math.max(0, nextCursor - latestRef.current.collapsedOffset)),
focusAtEnd: () => textEditorRef.current?.focusAtEnd(),
readSelectionRange: () => {
const range = textEditorRef.current?.readSelectionRange() ?? { start: 0, end: 0 };
const offset = latestRef.current.expandedOffset;
return { start: range.start + offset, end: range.end + offset };
},
readSnapshot: () => toPrompt(textSnapshot()),
isCaretOnVisualEdge: (edge) => textEditorRef.current?.isCaretOnVisualEdge(edge) ?? true,
focusQuoteCard: setFocusRequest,
}));

const cards = useMemo(() => {
const keys = quoteCardKeys(split.citations);
return split.citations.map(({ citation }, index) => ({ key: keys[index]!, citation }));
}, [split]);
const clearFocusRequest = useCallback(() => setFocusRequest(null), []);

return (
<>
{quoteCardsHidden ? null : (
<ComposerQuoteCardTray
cards={cards}
disabled={rest.disabled}
focusRequest={focusRequest}
onFocusHandled={clearFocusRequest}
onCommentChange={(number, comment) =>
commitCards((prompt) => setLeadingAssistantCitationComment(prompt, number, comment))
}
onRemove={(number, { fromKeyboard }) => {
const trayHadFocus =
document.activeElement?.closest("[data-composer-quote-cards]") != null;
commitCards((prompt) => removeLeadingAssistantCitation(prompt, number));
if (fromKeyboard && number > 1) setFocusRequest(number - 1);
else if (trayHadFocus) focusText();
}}
onExitToText={focusText}
onSend={() => rest.onCitationSubmitAndSend?.()}
/>
)}
<ComposerPromptEditorTiptap
{...rest}
value={split.body}
cursor={Math.max(0, cursor - split.collapsedOffset)}
onChange={onTextChange}
{...(rest.onCitationDraftSave
? {
// Runs before this component sees the new value, so the cards are still the
// ones that sat above the replaced text.
onCitationDraftSave: (replacedBody: string, savedBody: string) =>
rest.onCitationDraftSave?.(
joinComposerQuoteCards(sources(), replacedBody).prompt,
joinComposerQuoteCards(sources(), savedBody).prompt,
),
}
: {})}
editorRef={textEditorRef}
/>
</>
);
}

function splitPrompt(prompt: string, literalText: boolean) {
// A literal answer is verbatim text; it has no cards.
return literalText
? { citations: [], body: prompt, collapsedOffset: 0, expandedOffset: 0 }
: splitComposerQuoteCards(prompt);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,36 @@ import { act, createRef, useState, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";

import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";

vi.mock("./chat/AssistantCitationSource", () => ({
observeAssistantCitationCommentSource: () => () => {},
observePendingAssistantCitationSource: () => () => {},
}));
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => () => {},
Link: ({ children }: { children: ReactNode }) => <a>{children}</a>,
}));
// Keep the real chip and comment editor while replacing floating layers.
vi.mock("./ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverTrigger: ({ "aria-label": label }: { "aria-label": string }) => (
<button aria-label={label} />
),
PopoverPopup: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverClose: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
// Keep the real chip and comment editor while replacing floating layers. The trigger
// opens the popover the way a press would.
vi.mock("./ui/popover", async () => {
const { createContext, use } = await import("react");
type OnOpenChange = (open: boolean, details: { reason: string; cancel: () => void }) => void;
const OpenContext = createContext<OnOpenChange>(() => {});
return {
Popover: ({ children, onOpenChange }: { children: ReactNode; onOpenChange: OnOpenChange }) => (
<OpenContext value={onOpenChange}>{children}</OpenContext>
),
PopoverTrigger: function PopoverTrigger({ "aria-label": label }: { "aria-label": string }) {
const onOpenChange = use(OpenContext);
return (
<button
aria-label={label}
onClick={() => onOpenChange(true, { reason: "trigger-press", cancel: () => {} })}
/>
);
},
PopoverPopup: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverClose: ({ children }: { children: ReactNode }) => <>{children}</>,
};
});

import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";

Expand Down Expand Up @@ -63,11 +75,13 @@ afterEach(async () => {
function Composer({
question,
editorRef,
initialPrompt = promptBefore,
}: {
question: boolean;
editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
initialPrompt?: string;
}) {
const [prompt, setPrompt] = useState(promptBefore);
const [prompt, setPrompt] = useState(initialPrompt);
const [answer, setAnswer] = useState("");
return (
<>
Expand All @@ -90,34 +104,42 @@ function Composer({
);
}

it("keeps an unsaved citation comment when a question takes the composer", async () => {
/** Types an unsaved comment on the inline chip, then lets a question take the editor and leave. */
async function commentThenAsk(initialPrompt: string) {
const editorRef = createRef<ComposerPromptEditorHandle>();
const drafts = () => container.querySelector("output")?.dataset;

await act(async () => root.render(<Composer question={false} editorRef={editorRef} />));
const render = (question: boolean) =>
act(async () =>
root.render(
<Composer question={question} editorRef={editorRef} initialPrompt={initialPrompt} />,
),
);
await render(false);
await act(async () =>
editorRef.current?.requestCitationComment({
previousValue: "See ",
value: promptBefore,
citationStart: "See ".length,
sourceAnchor: {} as AssistantCitationSourceAnchor,
insertedSpaces: { before: false, after: true },
}),
container.querySelector<HTMLButtonElement>('[aria-label="Add comment to citation"]')?.click(),
);
const textarea = container.querySelector<HTMLTextAreaElement>(
'textarea[aria-label="Comment on selected text"]',
);
const textarea = container.querySelector("textarea");
if (!textarea) throw new Error("Comment editor did not open");
await act(async () => {
const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
setValue?.call(textarea, "check this");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
await render(true);
expect(container.querySelector("output")?.dataset.answer).toBe("");
await render(false);
return container.querySelector("output")?.dataset.prompt;
}

await act(async () => root.render(<Composer question editorRef={editorRef} />));
expect(drafts()?.answer).toBe("");
await act(async () => root.render(<Composer question={false} editorRef={editorRef} />));
const commented = `See ${serializeAssistantCitation(withAssistantCitationComment(citation, "check this"))} `;

expect(drafts()?.prompt).toBe(
`See ${serializeAssistantCitation(withAssistantCitationComment(citation, "check this"))} `,
);
it("keeps an unsaved citation comment when a question takes the composer", async () => {
expect(await commentThenAsk(promptBefore)).toBe(commented);
expect(container.querySelector('[aria-label="Edit citation comment"]')).not.toBeNull();
});

it("keeps the quote cards above a chip whose unsaved comment is saved", async () => {
const card = serializeAssistantCitation({ ...citation, text: "card", start: 10, end: 14 });
expect(await commentThenAsk(`${card}\n\n${promptBefore}`)).toBe(`${card}\n\n${commented}`);
});
Loading
Loading