diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 54174262fd70f..1590396e92b94 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -17,6 +17,7 @@ import { import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { deriveProviderInstanceEntries } from "../providerInstances"; import { ensureInlineContextReferences } from "../lib/composerContextReferences"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; import { responseContextRecord, responseContextReference, @@ -68,6 +69,17 @@ import { const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); +const trayCitation = { + version: 1 as const, + environmentId, + threadId, + messageId: MessageId.make("source"), + text: "Source", + start: 0, + end: 6, + prefix: "", + suffix: "", +}; const now = "2026-03-29T00:00:00.000Z"; function makeThread(overrides: Partial = {}): Thread { @@ -133,6 +145,12 @@ describe("deriveComposerSendState", () => { ], }); + for (const citationCount of [0, 1]) { + expect( + deriveComposerSendState({ prompt: "", imageCount: 0, terminalContexts: [], citationCount }) + .hasSendableContent, + ).toBe(citationCount > 0); + } expect(state.trimmedPrompt).toBe("yoo waddup"); expect(state.expiredTerminalContextCount).toBe(1); expect(state.hasSendableContent).toBe(true); @@ -1154,10 +1172,18 @@ describe("snapshotComposerSend", () => { it("drops expired terminal contexts from the contexts and from the prompt", () => { const snapshot = snapshotComposerSend({ - prompt: ensureInlineContextReferences("run", [ + draftPrompt: ensureInlineContextReferences("run", [ terminalContextReference(expired), terminalContextReference(live), ]), + trayCitations: [trayCitation], + prompt: + serializeAssistantCitation(trayCitation) + + "\n" + + ensureInlineContextReferences("run", [ + terminalContextReference(expired), + terminalContextReference(live), + ]), images: [], files: [], terminalContexts: [expired, live], @@ -1169,9 +1195,15 @@ describe("snapshotComposerSend", () => { replyTo: null, }); + expect(snapshot.draftPrompt).toBe( + ensureInlineContextReferences("run", [terminalContextReference(live)]).trim(), + ); + expect(snapshot.trayCitations).toEqual([trayCitation]); expect(snapshot.terminalContexts).toEqual([live]); expect(snapshot.messageText).toBe( - ensureInlineContextReferences("run", [terminalContextReference(live)]).trim(), + serializeAssistantCitation(trayCitation) + + "\n" + + ensureInlineContextReferences("run", [terminalContextReference(live)]).trim(), ); }); @@ -1193,7 +1225,13 @@ describe("snapshotComposerSend", () => { }); const reference = responseContextReference(replyTo); const snapshot = snapshotComposerSend({ - prompt: ensureInlineContextReferences("", [reference]) + " why there?", + prompt: + serializeAssistantCitation(replyTo.citation) + + "\n" + + ensureInlineContextReferences("", [reference]) + + " why there?", + draftPrompt: "why there?", + trayCitations: [replyTo.citation], images: [], files: [], terminalContexts: [], @@ -1206,8 +1244,9 @@ describe("snapshotComposerSend", () => { }); expect(snapshot.messageText).toContain(`t3-context://v1/response/${replyTo.contextId}`); - expect(snapshot.draftText).toBe("why there?"); + expect(snapshot.draftPrompt).toBe("why there?"); expect(snapshot.replyTo).toBe(replyTo); + expect(snapshot.trayCitations).toEqual([replyTo.citation]); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 927a253ead2b8..3aee3ec4f1439 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,5 +1,9 @@ import { toServerClock } from "@t3tools/client-runtime/clock"; -import { type OrchestrationV2ProviderTurn, type RunAttemptId } from "@t3tools/contracts"; +import { + type AssistantCitation, + type OrchestrationV2ProviderTurn, + type RunAttemptId, +} from "@t3tools/contracts"; import { deriveLatestThreadRun, deriveThreadRuntime, @@ -723,6 +727,7 @@ export function cloneComposerImageForRetry( export function deriveComposerSendState(options: { prompt: string; imageCount: number; + citationCount?: number; existingAttachmentCount?: number; terminalContexts: ReadonlyArray; elementContextCount?: number; @@ -744,6 +749,7 @@ export function deriveComposerSendState(options: { hasSendableContent: trimmedPrompt.length > 0 || options.imageCount > 0 || + (options.citationCount ?? 0) > 0 || (options.existingAttachmentCount ?? 0) > 0 || sendableTerminalContexts.length > 0 || elementContextCount > 0, @@ -1263,6 +1269,8 @@ export function makeModelDisabledReason( /** What the composer held when the user pressed send, after a direct annotation merged in. */ export interface ComposerSendContents { + readonly draftPrompt?: string; + readonly trayCitations?: ReadonlyArray; readonly prompt: string; readonly images: ReadonlyArray; readonly files: ReadonlyArray; @@ -1285,15 +1293,18 @@ export function snapshotComposerSend(contents: ComposerSendContents) { const images = [...contents.images]; const files = [...contents.files]; const terminalContexts = [...contents.sendableTerminalContexts]; - const messageText = contents.terminalContexts - .filter((context) => !terminalContexts.includes(context)) - .reduce( - (text, context) => - removeInlineContextReference(text, terminalContextReference(context).contextId).prompt, - contents.prompt, - ) - .trim(); + const cleanPrompt = (prompt: string) => + contents.terminalContexts + .filter((context) => !terminalContexts.includes(context)) + .reduce( + (text, context) => + removeInlineContextReference(text, terminalContextReference(context).contextId).prompt, + prompt, + ) + .trim(); return { + draftPrompt: cleanPrompt(contents.draftPrompt ?? contents.prompt), + trayCitations: [...(contents.trayCitations ?? [])], images, files, attachments: [...images, ...files], @@ -1303,11 +1314,7 @@ export function snapshotComposerSend(contents: ComposerSendContents) { threadContexts: [...contents.threadContexts], environmentContexts: [...contents.environmentContexts], replyTo: contents.replyTo, - messageText, - /** What a failed send puts back in the composer: the reply returns above it, not in the text. */ - draftText: contents.replyTo - ? removeInlineContextReference(messageText, contents.replyTo.contextId).prompt.trimStart() - : messageText, + messageText: cleanPrompt(contents.prompt), }; } diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index 69c2ac1fd4d59..f2e4c6217ea37 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -1,9 +1,10 @@ -import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { AssistantCitationTray } from "./chat/AssistantCitationTray"; +import { type AssistantCitation, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { serializeAssistantCitation, withAssistantCitationComment, } from "@t3tools/shared/assistantCitations"; -import { createRef, useState } from "react"; +import { createRef, useRef, useState } from "react"; import { expect, it } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; @@ -107,6 +108,137 @@ it("keeps an unsaved citation comment when a question takes the composer", async await expect.element(page.getByLabelText("Edit citation comment")).toBeInTheDocument(); }); +function TrayComposer({ question = false }: { question?: boolean }) { + const editorRef = useRef(null); + const [prompt, setPrompt] = useState(promptBefore); + const [answer, setAnswer] = useState(""); + const [tray, setTray] = useState>([]); + return ( + <> +
+

+ hello world +

+
+ + + { + setPrompt((text) => `${text}${serializeAssistantCitation(item)}`); + return true; + } + } + /> + {}} + onMoveCitationToTray={question ? undefined : (item) => setTray((items) => [...items, item])} + /> + + ); +} + +it("opens a source comment for tray citations and preserves saved and cancelled citations", async () => { + await using screen = await render(); + await page.getByRole("button", { name: "Cite to tray", exact: true }).click(); + await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible(); + await page.getByLabelText("Comment on selected text").fill("Keep this comment"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + const state = () => screen.container.querySelector("output")?.dataset; + expect(state()?.trayCount).toBe("1"); + expect(state()?.prompt).not.toContain("t3-citation"); + await page.getByRole("button", { name: "1 annotation", exact: true }).click(); + await page.getByRole("button", { name: "Edit annotation 1", exact: true }).click(); + await page.getByLabelText("Comment on selected text").fill("Keep the dismissed edit"); + await page.getByText("hello world", { exact: true }).click(); + await page.getByRole("button", { name: "1 annotation", exact: true }).click(); + await expect.element(page.getByText("User comment: Keep the dismissed edit")).toBeVisible(); + await page.getByRole("button", { name: "Edit annotation 1", exact: true }).click(); + await page.getByLabelText("Comment on selected text").fill("Discard the cancelled edit"); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect.element(page.getByText("User comment: Keep the dismissed edit")).toBeVisible(); + await page.getByRole("button", { name: "Cite to tray", exact: true }).click(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await page.getByRole("button", { name: "2 annotations", exact: true }).click(); + await page.getByRole("button", { name: "Edit annotation 1", exact: true }).click(); + await page.getByLabelText("Comment on selected text").fill("Keep the edit after deletion"); + await page.getByRole("button", { name: "Delete annotation 2", exact: true }).click(); + await expect.element(page.getByText("User comment: Keep the edit after deletion")).toBeVisible(); + await screen.rerender(); + await expect + .element(page.getByRole("button", { name: "Insert annotation 1 inline", exact: true })) + .not.toBeInTheDocument(); + expect(state()?.trayCount).toBe("1"); + await screen.rerender(); + await page.getByRole("button", { name: "Insert annotation 1 inline", exact: true }).click(); + expect(state()?.trayCount).toBe("0"); + expect(state()?.prompt).toContain( + serializeAssistantCitation( + withAssistantCitationComment(citation, "Keep the edit after deletion"), + ), + ); + await page.getByRole("button", { name: "Cite to tray", exact: true }).click(); + await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible(); + await page.getByLabelText("Comment on selected text").fill("Discard this comment"); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + expect(state()?.trayCount).toBe("1"); + expect(state()?.prompt).toBe("See "); + await page.getByRole("button", { name: "1 annotation", exact: true }).click(); + await page.getByRole("button", { name: "Insert annotation 1 inline", exact: true }).click(); + expect(state()?.prompt).toContain(serializeAssistantCitation(citation)); + expect(state()?.prompt).not.toContain("Discard this comment"); +}); + +it.each(["Save", "dismiss"])( + "keeps question citations inline on %s when tray transfer is unavailable", + async (action) => { + await using screen = await render(); + await page.getByRole("button", { name: "Cite to tray", exact: true }).click(); + await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible(); + await page.getByLabelText("Comment on selected text").fill("Question comment"); + if (action === "Save") await page.getByRole("button", { name: "Save", exact: true }).click(); + else await page.getByText("hello world", { exact: true }).click(); + const state = screen.container.querySelector("output")?.dataset; + expect(state?.trayCount).toBe("0"); + expect(state?.answer).toContain( + serializeAssistantCitation(withAssistantCitationComment(citation, "Question comment")), + ); + }, +); + 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 d89f57068e9cf..d751d72898858 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -197,6 +197,7 @@ export interface ComposerPromptEditorProps { onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; onPageScrollKeyUp?: (key: string) => void; onPageScrollRelease?: () => void; + onMoveCitationToTray?: ((citation: AssistantCitation) => void) | undefined; onCitationSubmitAndSend?: () => boolean; /** Saves unsaved citation text into replacedValue when a controlled rewrite replaces the document. */ onCitationDraftSave?: (replacedValue: string, savedValue: string) => void; @@ -205,6 +206,7 @@ export interface ComposerPromptEditorProps { } export type ComposerCitationCommentRequest = { + destination?: "inline" | "tray" | undefined; previousValue: string; value: string; citationStart: number; @@ -216,6 +218,7 @@ type CitationInsertedSpaces = { before: boolean; after: boolean }; type OpenCitationComment = { key: string; + destination?: "inline" | "tray" | undefined; sourceAnchor?: AssistantCitationSourceAnchor; removeOnCancel?: CitationInsertedSpaces; }; @@ -224,6 +227,7 @@ const ComposerCitationCommentContext = createContext<{ openComment: OpenCitationComment | null; onOpenChange: (citeKey: string, open: boolean) => void; onDraftChange: (citeKey: string, comment: string) => void; + onMoveToTray?: ((citation: AssistantCitation) => void) | undefined; onSubmitAndSend: () => boolean; }>({ openComment: null, @@ -457,6 +461,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { return typeof pos === "number" ? pos : null; }; + const destination = commentContext.onMoveToTray ? commentTarget?.destination : "inline"; const onSaveComment = (comment: string): boolean => { if (!editor.isEditable) return false; const pos = nodePos(); @@ -465,6 +470,11 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { if (!current || current.type.name !== "composer-citation") return false; const currentCitation = current.attrs.citation as AssistantCitation; const next = withAssistantCitationComment(currentCitation, comment); + if (destination === "tray" && commentContext.onMoveToTray) { + commentContext.onMoveToTray(next); + onRemove(); + return true; + } const tr = editor.state.tr.setNodeMarkup(pos, undefined, { ...current.attrs, citation: next, @@ -540,9 +550,36 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { sourceAnchor: commentTarget?.sourceAnchor, onOpenChange: (open) => { if (open && !editor.isEditable) return; + if (!open && destination === "tray" && commentContext.onMoveToTray) { + const pos = nodePos(); + const current = pos === null ? null : editor.state.doc.nodeAt(pos); + if (current?.type.name === "composer-citation" && current.attrs.citeKey === citeKey) { + commentContext.onMoveToTray(current.attrs.citation as AssistantCitation); + onRemove(); + } + } commentContext.onOpenChange(citeKey, open); }, - ...(removeOnCancel ? { onCancel: onRemove } : {}), + ...(removeOnCancel + ? { + onCancel: () => { + if (destination === "tray") commentContext.onMoveToTray?.(citation); + onRemove(); + }, + } + : {}), + onMoveToTray: commentContext.onMoveToTray + ? (comment) => { + const pos = nodePos(); + if (pos === null || !editor.isEditable) return; + commentContext.onMoveToTray?.(withAssistantCitationComment(citation, comment)); + editor + .chain() + .focus() + .deleteRange({ from: pos, to: pos + node.nodeSize }) + .run(); + } + : undefined, onSave: onSaveComment, onDraftChange: (comment) => commentContext.onDraftChange(citeKey, comment), onSaveAndSend: (comment) => { @@ -952,6 +989,7 @@ function ComposerPromptEditorTiptapInner( onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, + onMoveCitationToTray, onCitationSubmitAndSend, onCitationDraftSave, onPaste, @@ -1005,6 +1043,7 @@ function ComposerPromptEditorTiptapInner( const citationCommentActions = { openComment: openCitation, + onMoveToTray: onMoveCitationToTray, onOpenChange: (nodeKey: string, open: boolean) => { if (!open && citationDraftRef.current?.key === nodeKey) citationDraftRef.current = null; setOpenCitation((current) => { @@ -1653,6 +1692,7 @@ function ComposerPromptEditorTiptapInner( key: citeKey, sourceAnchor: pendingCitation.sourceAnchor, removeOnCancel: pendingCitation.insertedSpaces, + destination: pendingCitation.destination, }); } } @@ -1774,6 +1814,7 @@ function ComposerPromptEditorTiptapInner( key: citeKey, sourceAnchor: request.sourceAnchor, removeOnCancel: request.insertedSpaces, + destination: request.destination, }); } }, diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 5a86f65399fcd..b29c08aacb33b 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -23,6 +23,8 @@ import { assistantCitationRoute, } from "../../lib/assistantCitationNavigation"; import { cn } from "~/lib/utils"; +import { observeResize } from "~/lib/observeResize"; +import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import { useQuietThreadShell } from "~/state/entities"; import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -186,6 +188,7 @@ export function AssistantCitationChip({ sourceAnchor?: AssistantCitationSourceAnchor | undefined; onOpenChange: (open: boolean) => void; onCancel?: () => void; + onMoveToTray?: ((comment: string) => void) | undefined; onSave: (comment: string) => boolean; onSaveAndSend?: (comment: string) => boolean; onDraftChange?: (comment: string) => void; @@ -331,6 +334,8 @@ export function AssistantCitationChip({ { @@ -366,3 +371,34 @@ export function AssistantCitationChip({ ); } + +export 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 ( +
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), + )} + > +
+ {citation.text} +
+ {citation.comment ?

{citation.comment}

: null} +
+ ); +} diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index cdd8e1249cfc7..dde788ecaffc0 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -1,4 +1,5 @@ import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; +import { ArchiveIcon, TextCursorInputIcon } from "lucide-react"; import { use, useEffect, useRef, useState, type RefObject } from "react"; import { TextFieldSpeechControls, useTextFieldSpeech } from "~/speech/useTextFieldSpeech"; @@ -6,12 +7,16 @@ import { textDirection } from "~/textDirection"; import { ComposerContextActionsContext } from "../composerContextActions"; import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export function AssistantCitationCommentEditor({ citation, inputRef, onSubmit, onSubmitAndSend, + onMove, + moveLabel, + moveAriaLabel, onCancel, onDraftChange, onVoiceSetupOpenChange, @@ -19,6 +24,9 @@ export function AssistantCitationCommentEditor({ citation: AssistantCitation; inputRef?: RefObject; onSubmit: (comment: string) => boolean; + onMove?: ((comment: string) => void) | undefined; + moveLabel?: string; + moveAriaLabel?: string; onSubmitAndSend?: (comment: string) => boolean; onCancel: () => void; onDraftChange?: (comment: string) => void; @@ -114,6 +122,24 @@ export function AssistantCitationCommentEditor({ ) : null}
+ {onMove ? ( + + onMove(comment)} + /> + } + > + {moveLabel === "Insert inline" ? : } + + {moveLabel} + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 199758cab4e20..6f6ef08040ba7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,3 +1,6 @@ +import { AssistantCitationTray } from "./AssistantCitationTray"; +import { useCitationDestination } from "./citationDestination"; +import { serializeComposerTray } from "./composerSubmission"; import { VoiceInputSetup } from "./VoiceInputSetup"; import { useComposerTypingGuard } from "./useComposerTypingGuard"; import { insertAttachmentReferencesForDraft } from "./composerAttachmentReferences"; @@ -671,6 +674,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, []); const questionPreparations = useQuestionAttachmentPreparation((state) => state.counts); const prompt = composerDraft.prompt; + const composerImages = attachmentDraft.images; const composerFiles = attachmentDraft.files; const inlineFileIdSet = (() => { @@ -811,6 +815,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (store) => store.syncPersistedAttachments, ); const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); + const [citationDestination] = useCitationDestination(); + const setTrayCitations = useStableCallback((citations: ReadonlyArray) => + useComposerDraftStore.getState().setTrayCitations(composerDraftTarget, citations), + ); + const appendTrayCitation = useStableCallback((citation: AssistantCitation) => + setTrayCitations([...(getComposerDraft(composerDraftTarget)?.trayCitations ?? []), citation]), + ); + const submissionPrompt = useStableCallback(() => + serializeComposerTray(promptRef.current, getComposerDraft(composerDraftTarget)?.trayCitations), + ); const setComposerDraftReplyTo = useComposerDraftStore((store) => store.setReplyTo); const navigate = useNavigate(); @@ -1093,6 +1107,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerSendState = deriveComposerSendState({ prompt, + citationCount: composerDraft.trayCitations?.length ?? 0, imageCount: composerImages.length + composerFiles.length, existingAttachmentCount: editingQueuedAttachments?.length ?? 0, terminalContexts: composerTerminalContexts, @@ -1387,7 +1402,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } if (composerSubmissionError !== null) { - const nextError = getComposerPromptLengthValidationMessage(prompt); + const nextError = getComposerPromptLengthValidationMessage(prompt, composerDraft.trayCitations); if (nextError !== composerSubmissionError) { setComposerSubmissionError(nextError); } @@ -1718,6 +1733,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) start: number; sourceAnchor: AssistantCitationSourceAnchor; insertedSpaces: ComposerCitationCommentRequest["insertedSpaces"]; + destination?: "inline" | "tray" | undefined; }; }, ): boolean => { @@ -1754,6 +1770,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) citationStart: options.citationComment.start, sourceAnchor: options.citationComment.sourceAnchor, insertedSpaces: options.citationComment.insertedSpaces, + destination: options.citationComment.destination, }); } promptRef.current = next.text; @@ -2160,7 +2177,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; } const submission = submitComposerDraft({ - prompt: promptRef.current, + prompt: submissionPrompt(), submissionTarget, event, onSend: (sendEvent) => { @@ -2550,6 +2567,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) options?: { ensureLeadingBoundary?: boolean; citationCommentAnchor?: AssistantCitationSourceAnchor; + citationDestination?: "inline" | "tray" | undefined; clipboardData?: DataTransfer; }, ): boolean => { @@ -2581,6 +2599,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) citationComment: { start: cursor + (needsLeadingSpace ? 1 : 0), sourceAnchor: options.citationCommentAnchor, + destination: options.citationDestination, insertedSpaces: { before: needsLeadingSpace, after: rangeEnd === cursor }, }, focusEditorAfterReplace: false, @@ -3029,14 +3048,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusComposer(); return true; }, - citeAssistantText: (citation, sourceAnchor) => - insertComposerTextStable( + citeAssistantText: (citation, sourceAnchor) => { + return insertComposerTextStable( formatAssistantCitationForComposer(citation, citation.comment), "cursor", - { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor }, - ), + { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor, citationDestination }, + ); + }, replyToResponse: (response) => { - // An open question owns the composer, and its answer would not carry the reply. if (pendingUserInputs.length > 0 || projectSelectionRequired) return false; setComposerDraftReplyTo(composerDraftTarget, response); focusComposer(); @@ -3179,7 +3198,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) shownPendingApprovalIds: pendingApprovals .map((approval) => approval.requestId) .filter((requestId) => !heldRequestIds.has(requestId)), - prompt: promptRef.current, + prompt: submissionPrompt(), images: composerImagesRef.current, files: composerFilesRef.current, terminalContexts: composerTerminalContextsRef.current, @@ -3233,6 +3252,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) insertComposerDraftTerminalContext, setComposerDraftReplyTo, insertComposerTextStable, + citationDestination, + appendTrayCitation, + submissionPrompt, insertComposerTextAtEndStable, promptRef, composerImagesRef, @@ -3585,7 +3607,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> ) : null} + + insertComposerTextStable( + formatAssistantCitationForComposer(citation, citation.comment), + "cursor", + { ensureLeadingBoundary: true }, + ) + } + /> { expect(approvalRedirectNotice(settled)).toBeNull(); }); }); + +it("detects tray edits while uploads are pending", () => { + const citation = { + version: 1 as const, + environmentId: local, + threadId: ThreadId.make("thread"), + messageId: MessageId.make("message"), + text: "quote", + start: 0, + end: 5, + prefix: "", + suffix: "", + }; + const input = { + prompt: "draft", + promptAtSend: "draft", + promptForSend: "draft", + attachmentIds: ["upload"], + attachmentIdsAtSend: ["upload"], + attachmentIdsBeforeAnnotation: ["upload"], + annotationImageId: undefined, + trayCitations: [citation], + liveTrayCitations: [citation], + replyTo: null, + liveReplyTo: null, + }; + expect(composerDraftChangedSinceSend(input)).toBe(false); + expect( + composerDraftChangedSinceSend({ + ...input, + replyTo: responseContextRecord({ citation, itemId: TurnItemId.make("item"), subagents: [] }), + }), + ).toBe(true); + expect( + composerDraftChangedSinceSend({ + ...input, + liveTrayCitations: [{ ...citation, comment: "edited" }], + }), + ).toBe(true); + expect(composerDraftChangedSinceSend({ ...input, liveTrayCitations: [] })).toBe(true); +}); diff --git a/apps/web/src/components/chat/composerSend.logic.ts b/apps/web/src/components/chat/composerSend.logic.ts index e19512e6c90d4..5dbe03255051c 100644 --- a/apps/web/src/components/chat/composerSend.logic.ts +++ b/apps/web/src/components/chat/composerSend.logic.ts @@ -1,4 +1,10 @@ -import type { EnvironmentId, ThreadContextRecord } from "@t3tools/contracts"; +import type { + AssistantCitation, + EnvironmentId, + ResponseContextRecord, + ThreadContextRecord, +} from "@t3tools/contracts"; +import { serializeComposerTray } from "./composerSubmission"; import { collectInlineContextIds } from "../../lib/composerContextReferences"; /** Attached threads the prompt still links to; replacing linked text leaves stale records. */ @@ -79,3 +85,33 @@ export function approvalRedirectNotice(input: { } return null; } + +export function composerDraftChangedSinceSend(input: { + prompt: string; + promptAtSend: string; + promptForSend: string; + attachmentIds: ReadonlyArray; + attachmentIdsAtSend: ReadonlyArray; + attachmentIdsBeforeAnnotation: ReadonlyArray; + annotationImageId: string | undefined; + trayCitations: ReadonlyArray; + liveTrayCitations: ReadonlyArray; + replyTo: ResponseContextRecord | null; + liveReplyTo: ResponseContextRecord | null; +}) { + const sameIds = (left: ReadonlyArray, right: ReadonlyArray) => + left.length === right.length && left.every((id, index) => id === right[index]); + const promptUnchanged = + input.prompt === input.promptAtSend || input.prompt === input.promptForSend; + const attachmentsUnchanged = + sameIds(input.attachmentIds, input.attachmentIdsAtSend) || + (input.annotationImageId !== undefined && + sameIds(input.attachmentIds, input.attachmentIdsBeforeAnnotation)); + return ( + !promptUnchanged || + !attachmentsUnchanged || + input.liveReplyTo !== input.replyTo || + serializeComposerTray("", input.trayCitations) !== + serializeComposerTray("", input.liveTrayCitations) + ); +} diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts index 23e85c9c644b6..d138729404dd7 100644 --- a/apps/web/src/components/chat/composerSubmission.test.ts +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -11,7 +11,12 @@ import { } from "@t3tools/shared/assistantCitations"; import { describe, expect, it, vi } from "vite-plus/test"; -import { isComposerSubmissionBlocked, submitComposerDraft } from "./composerSubmission"; +import { + isComposerSubmissionBlocked, + getComposerPromptLengthValidationMessage, + serializeComposerTray, + submitComposerDraft, +} from "./composerSubmission"; const assistantCitation = { version: 1 as const, @@ -209,6 +214,34 @@ describe("submitComposerDraft", () => { }); }); +it("serializes ordered tray citations before inline citations and text", () => { + const second = { ...assistantCitation, comment: "Second" }; + const inline = serializeAssistantCitation({ ...assistantCitation, comment: "Inline" }); + expect(serializeComposerTray(`Reply ${inline}`, [assistantCitation, second])).toBe( + `${serializeAssistantCitation(assistantCitation)}\n${serializeAssistantCitation(second)}\nReply ${inline}`, + ); + expect(serializeComposerTray("Draft")).toBe("Draft"); + expect(serializeComposerTray("", [assistantCitation])).toBe( + serializeAssistantCitation(assistantCitation), + ); +}); + +it("retains tray-inclusive limit errors until the tray is shortened", () => { + const prompt = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS - 1); + const combined = serializeComposerTray(prompt, [assistantCitation]); + const result = submitComposerDraft({ + prompt: combined, + submissionTarget: "provider-turn", + event: undefined, + onSend: vi.fn(), + }); + expect(result.validationMessage).not.toBeNull(); + expect(getComposerPromptLengthValidationMessage(prompt, [assistantCitation])).toBe( + result.validationMessage, + ); + expect(getComposerPromptLengthValidationMessage(prompt, [])).toBeNull(); +}); + describe("isComposerSubmissionBlocked", () => { const sendBlocked = { noProviderAvailable: true, diff --git a/apps/web/src/components/chat/composerSubmission.ts b/apps/web/src/components/chat/composerSubmission.ts index 2f9ffe0ddbd26..8b0542f8d379d 100644 --- a/apps/web/src/components/chat/composerSubmission.ts +++ b/apps/web/src/components/chat/composerSubmission.ts @@ -1,3 +1,5 @@ +import type { AssistantCitation } from "@t3tools/contracts"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; @@ -9,8 +11,11 @@ type ComposerSubmissionInput = { submissionTarget: "provider-turn" | "pending-user-input"; }; -export function getComposerPromptLengthValidationMessage(prompt: string): string | null { - const normalizedPrompt = prompt.trim(); +export function getComposerPromptLengthValidationMessage( + prompt: string, + citations: ReadonlyArray = [], +): string | null { + const normalizedPrompt = serializeComposerTray(prompt, citations).trim(); const inputLength = Math.max( normalizedPrompt.length, expandAssistantCitationsForProvider(normalizedPrompt).length, @@ -62,3 +67,10 @@ export function submitComposerDraft( } return { validationMessage: null, didDispatch: true }; } + +export function serializeComposerTray( + prompt: string, + citations: ReadonlyArray = [], +): string { + return [...citations.map(serializeAssistantCitation), prompt].filter(Boolean).join("\n"); +} diff --git a/apps/web/src/components/chat/queuedMessageEdit.test.ts b/apps/web/src/components/chat/queuedMessageEdit.test.ts index f67d1843c38be..f2446dac3abcd 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.test.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.test.ts @@ -77,7 +77,18 @@ describe("queued message file edits", () => { ).rejects.toThrow("Retry or remove"); }); - it("keeps file-only edits when another client starts the queued run", () => { + const citation = { + version: 1 as const, + environmentId, + threadId: threadTarget.threadId, + messageId: MessageId.make("message"), + text: "Quote", + start: 0, + end: 5, + prefix: "", + suffix: "", + }; + it.each(["file", "citation"])("keeps %s-only edits when the queued run leaves", (kind) => { const store = useComposerDraftStore.getState(); store.setPrompt(editTarget, "Original message"); const elsewhere: ComposerFileAttachment = { @@ -88,14 +99,20 @@ describe("queued message file edits", () => { uploadedAttachmentId: "upload:elsewhere", uploadEnvironmentId: EnvironmentId.make("other-environment"), }; - store.addFiles(editTarget, [file, elsewhere]); + if (kind === "file") store.addFiles(editTarget, [file, elsewhere]); + else store.setTrayCitations(editTarget, [citation]); expect( recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "Original message" }), ).toBe("kept"); expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Original message"); // An upload this environment cannot reach comes back as a file to reattach, not a dropped chip. const { uploadedAttachmentId: _id, uploadEnvironmentId: _env, ...reattach } = elsewhere; - expect(store.getComposerDraft(threadTarget)?.files).toEqual([file, reattach]); + expect(store.getComposerDraft(threadTarget)?.files).toEqual( + kind === "file" ? [file, reattach] : [], + ); + expect(store.getComposerDraft(threadTarget)?.trayCitations ?? []).toEqual( + kind === "citation" ? [citation] : [], + ); expect(store.getComposerDraft(editTarget)).toBeNull(); }); diff --git a/apps/web/src/components/chat/queuedMessageEdit.ts b/apps/web/src/components/chat/queuedMessageEdit.ts index 48f7ee5d1ae10..54ad9c47ec8cd 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.ts @@ -34,7 +34,10 @@ export function recoverQueuedMessageEdit(input: { editChanged: input.removedExistingAttachment === true || (draft !== null && - (draft.prompt !== input.originalText || draft.images.length > 0 || draft.files.length > 0)), + (draft.prompt !== input.originalText || + draft.images.length > 0 || + draft.files.length > 0 || + (draft.trayCitations?.length ?? 0) > 0)), // A cleared edit has nothing to put back, so it can only be discarded. threadDraftEmpty: composerDraftHasUserContent(draft) && diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 80555864284a6..5bb61144eb39a 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -1,3 +1,4 @@ +import { serializeComposerTray } from "./composerSubmission"; import { isClearComposerCommand, parseSideChatCommand } from "@t3tools/shared/composerTrigger"; import { armFreshSession, disarmFreshSession, readFreshSessionArm } from "./freshSessionArms"; import { @@ -98,6 +99,7 @@ import { useClientSettingsHydrated } from "../../hooks/useSettings"; import { useStableCallback } from "../../hooks/useStableCallback"; import { environmentPresentations } from "../../state/presentation"; import { + composerDraftChangedSinceSend, approvalRedirectNotice, loadBalancingSendWarning, multipleModelSendBlock, @@ -486,6 +488,10 @@ export function useComposerSend(input: ComposerSendInput) { }); return; } + const trayCitations = [ + ...(useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.trayCitations ?? + []), + ]; // Where side chats are unavailable, `/side` text can still be a literal answer. if (sideChatDisabledReason !== null && sideChatCommand && !answeringQuestion) { toastManager.add( @@ -499,6 +505,7 @@ export function useComposerSend(input: ComposerSendInput) { } if (onSideChat && sendCtx && sideChatCommand && sideChatDisabledReason === null) { if ( + trayCitations.length > 0 || sendCtx.images.length > 0 || sendCtx.files.length > 0 || filterTerminalContextsWithText(sendCtx.terminalContexts).length > 0 || @@ -597,19 +604,19 @@ export function useComposerSend(input: ComposerSendInput) { }); // A direct "send annotation" writes the draft and sends in the same tick; the reference // must be in the text now, not after the next render. - const draftPrompt = directAnnotation - ? ensureInlineContextReferences(promptRef.current, [ + const serializedPrompt = directAnnotation + ? ensureInlineContextReferences(sendCtx.prompt, [ previewAnnotationContextReference(directAnnotation.annotation), ]) - : promptRef.current; + : sendCtx.prompt; const replyTo = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.replyTo ?? null; // The reply leads the message, so its card sits above the text it is about. Text that // already points at that response, from a pasted card, keeps its one reference. const promptForSend = - replyTo && !collectInlineContextIds(draftPrompt).includes(replyTo.contextId) - ? `${formatInlineContextReference(responseContextReference(replyTo))} ${draftPrompt}` - : draftPrompt; + replyTo && !collectInlineContextIds(serializedPrompt).includes(replyTo.contextId) + ? `${formatInlineContextReference(responseContextReference(replyTo))} ${serializedPrompt}` + : serializedPrompt; const strandedThread = threadContextsOutsideEnvironment({ text: promptForSend, records: composerThreadContexts, @@ -663,8 +670,27 @@ export function useComposerSend(input: ComposerSendInput) { composerThreadContexts.length + composerEnvironmentContexts.length, }); + const commandText = directAnnotation ? trimmed : promptAtSend.trim(); + if ( + trayCitations.length > 0 && + ((freshSessionAvailable && isClearComposerCommand(commandText)) || + (ctxSelectedProvider === "codex" && + multipleModelSelections === null && + parseCodexFeedbackCommand(commandText) !== null)) + ) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "This command takes text only", + description: "Remove the tray citations, then send the command again.", + }), + ); + return; + } const feedbackCommand = ctxSelectedProvider === "codex" && + trayCitations.length === 0 && + replyTo === null && composerImages.length === 0 && composerFiles.length === 0 && sendableComposerTerminalContexts.length === 0 && @@ -672,7 +698,7 @@ export function useComposerSend(input: ComposerSendInput) { composerReviewComments.length === 0 && composerThreadContexts.length === 0 && composerEnvironmentContexts.length === 0 - ? parseCodexFeedbackCommand(trimmed) + ? parseCodexFeedbackCommand(commandText) : null; if (feedbackCommand && multipleModelSelections === null) { if (!isServerThread || activeThread.activeProviderThreadId === null) { @@ -689,7 +715,7 @@ export function useComposerSend(input: ComposerSendInput) { await submitCodexFeedback({ submission: { id: newMessageId(), - command: trimmed, + command: commandText, createdAt: new Date().toISOString(), }, clearDraft: () => { @@ -721,6 +747,7 @@ export function useComposerSend(input: ComposerSendInput) { return; } const composerHoldsOnlyText = + trayCitations.length === 0 && composerImages.length === 0 && composerFiles.length === 0 && sendableComposerTerminalContexts.length === 0 && @@ -729,7 +756,7 @@ export function useComposerSend(input: ComposerSendInput) { composerThreadContexts.length === 0 && composerEnvironmentContexts.length === 0 && replyTo === null; - if (freshSessionAvailable && composerHoldsOnlyText && isClearComposerCommand(trimmed)) { + if (freshSessionAvailable && composerHoldsOnlyText && isClearComposerCommand(commandText)) { armFreshSession(routeThreadKey); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); @@ -766,6 +793,12 @@ export function useComposerSend(input: ComposerSendInput) { } const threadIdForSend = activeThread.id; const sendSnapshot = snapshotComposerSend({ + draftPrompt: directAnnotation + ? ensureInlineContextReferences(promptAtSend, [ + previewAnnotationContextReference(directAnnotation.annotation), + ]) + : promptAtSend, + trayCitations, prompt: promptForSend, images: composerImages, files: composerFiles, @@ -955,6 +988,44 @@ export function useComposerSend(input: ComposerSendInput) { return; } } + // A draft edited since send, or a paste still landing in it, is no longer the message + // the user sent. + const attachmentIdsAtSend = composerAttachmentsSnapshot.map((attachment) => attachment.id); + // A direct annotation send writes its image and reference into the draft as it sends, + // so the live draft may still lack them; anything else differing is a real edit. + const annotationImageId = directAnnotation?.image?.id; + const attachmentIdsBeforeAnnotation = attachmentIdsAtSend.filter( + (id) => id !== annotationImageId, + ); + const draftChangedSinceSend = () => { + if (composerRef.current?.hasPendingAttachments() === true) return true; + const sendContext = composerRef.current?.getSendContext(); + const attachmentIds = sendContext + ? [...sendContext.images, ...sendContext.files].map((attachment) => attachment.id) + : attachmentIdsAtSend; + return composerDraftChangedSinceSend({ + prompt: promptRef.current, + promptAtSend, + promptForSend, + attachmentIds, + attachmentIdsAtSend, + attachmentIdsBeforeAnnotation, + annotationImageId, + trayCitations, + replyTo, + liveReplyTo: + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.replyTo ?? null, + liveTrayCitations: + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.trayCitations ?? + [], + }); + }; + if (currentRouteThreadKeyRef.current === routeThreadKey && draftChangedSinceSend()) { + sendInFlightRef.current = false; + setThreadError(threadIdForSend, "Your message changed, so it wasn't sent."); + return; + } + // A message sent while approvals are pending redirects the agent. They are declined only // here, after everything above has accepted the send, so local commands and rejected // drafts leave them alone. After navigating away during an upload, the shown approvals @@ -966,34 +1037,6 @@ export function useComposerSend(input: ComposerSendInput) { ) : []; if (approvalsToDecline.length > 0 || approvalFeedbackTarget !== undefined) { - // A draft edited since send, or a paste still landing in it, is no longer the message - // the user sent. - const attachmentIdsAtSend = composerAttachmentsSnapshot.map((attachment) => attachment.id); - // A direct annotation send writes its image and reference into the draft as it sends, - // so the live draft may still lack them; anything else differing is a real edit. - const annotationImageId = directAnnotation?.image?.id; - const attachmentIdsBeforeAnnotation = attachmentIdsAtSend.filter( - (id) => id !== annotationImageId, - ); - const sameIds = (left: ReadonlyArray, right: ReadonlyArray) => - left.length === right.length && left.every((id, index) => id === right[index]); - const draftChangedSinceSend = () => { - if (composerRef.current?.hasPendingAttachments() === true) return true; - const sendContext = composerRef.current?.getSendContext(); - const attachmentIds = sendContext - ? [...sendContext.images, ...sendContext.files].map((attachment) => attachment.id) - : attachmentIdsAtSend; - const promptUnchanged = - promptRef.current === promptAtSend || promptRef.current === promptForSend; - const attachmentsUnchanged = - sameIds(attachmentIds, attachmentIdsAtSend) || - (annotationImageId !== undefined && - sameIds(attachmentIds, attachmentIdsBeforeAnnotation)); - // The reply is not in the prompt, so cancelling or changing it is checked on its own. - const liveReply = - useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.replyTo ?? null; - return !promptUnchanged || !attachmentsUnchanged || liveReply !== replyTo; - }; const draftBeforeDecline = useComposerDraftStore .getState() .getComposerDraft(composerDraftTarget); @@ -1409,7 +1452,10 @@ export function useComposerSend(input: ComposerSendInput) { ) ) { // A draft typed meanwhile keeps the composer, so the failed text stays reachable here. - const failedText = sendSnapshot.draftText; + const failedText = serializeComposerTray( + sendSnapshot.draftPrompt, + sendSnapshot.trayCitations, + ); const hasText = failedText.trim().length > 0; // An image-only message has nothing to copy, but its failure still needs a notice. toastManager.add( @@ -1441,16 +1487,16 @@ export function useComposerSend(input: ComposerSendInput) { } else { const retryComposerImages = restoreComposerDraft(composerDraftTarget, sendSnapshot); if (currentRouteThreadKeyRef.current === routeThreadKey) { - promptRef.current = sendSnapshot.draftText; + promptRef.current = sendSnapshot.draftPrompt; composerImagesRef.current = retryComposerImages; composerFilesRef.current = composerFilesSnapshot; composerTerminalContextsRef.current = composerTerminalContextsSnapshot; composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor( - sendSnapshot.draftText, - sendSnapshot.draftText.length, + sendSnapshot.draftPrompt, + sendSnapshot.draftPrompt.length, ), - prompt: sendSnapshot.draftText, + prompt: sendSnapshot.draftPrompt, detectTrigger: true, }); } diff --git a/apps/web/src/components/chat/useComposerStash.ts b/apps/web/src/components/chat/useComposerStash.ts index 65b635acc1df6..ca567a8450db3 100644 --- a/apps/web/src/components/chat/useComposerStash.ts +++ b/apps/web/src/components/chat/useComposerStash.ts @@ -255,6 +255,13 @@ export function useComposerStash(input: { }) : reference.source; }); + const currentDraft = getComposerDraft(composerDraftTarget); + useComposerDraftStore + .getState() + .setTrayCitations(composerDraftTarget, [ + ...(currentDraft?.trayCitations ?? []), + ...(entry.trayCitations ?? []), + ]); const currentPrompt = promptRef.current; const nextPrompt = restoredPrompt.length === 0 @@ -517,7 +524,12 @@ export function useComposerStash(input: { }), ), ]; - if (prompt.length === 0 && images.length === 0 && files.length === 0) { + if ( + prompt.length === 0 && + !getComposerDraft(composerDraftTarget)?.trayCitations?.length && + images.length === 0 && + files.length === 0 + ) { const entries = usePromptStashStore.getState().entries; const entry = entries.length === 1 ? entries[0] : undefined; if (entry && !entry.pendingImageCount) { @@ -585,6 +597,7 @@ export function useComposerStash(input: { id: entryId, createdAt: new Date().toISOString(), prompt, + trayCitations: getComposerDraft(composerDraftTarget)?.trayCitations ?? [], attachments: [], ...(stashedFiles.length > 0 ? { files: stashedFiles } : {}), droppedImageNames: [], diff --git a/apps/web/src/components/chat/useMultipleModelSend.ts b/apps/web/src/components/chat/useMultipleModelSend.ts index ccea2378968f5..648b83c955993 100644 --- a/apps/web/src/components/chat/useMultipleModelSend.ts +++ b/apps/web/src/components/chat/useMultipleModelSend.ts @@ -235,13 +235,13 @@ export function useMultipleModelSend(input: MultipleModelSendInput) { if (clearedDraft) { restoreComposerDraft(composerDraftTarget, sendSnapshot); if (composerRef.current && currentRouteThreadKeyRef.current === routeThreadKey) { - promptRef.current = sendSnapshot.draftText; + promptRef.current = sendSnapshot.draftPrompt; composerRef.current.resetCursorState({ cursor: collapseExpandedComposerCursor( - sendSnapshot.draftText, - sendSnapshot.draftText.length, + sendSnapshot.draftPrompt, + sendSnapshot.draftPrompt.length, ), - prompt: sendSnapshot.draftText, + prompt: sendSnapshot.draftPrompt, detectTrigger: true, }); } diff --git a/apps/web/src/components/promptLinks/copyPromptLink.test.ts b/apps/web/src/components/promptLinks/copyPromptLink.test.ts new file mode 100644 index 0000000000000..3a7993c87e256 --- /dev/null +++ b/apps/web/src/components/promptLinks/copyPromptLink.test.ts @@ -0,0 +1,43 @@ +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { expect, it } from "vite-plus/test"; +import { useComposerDraftStore } from "../../composerDraftStore"; +import { composerPromptLink, promptLinkFor } from "./copyPromptLink"; + +it.each([false, true])("exports tray quotes and comments with context=%s", (withContext) => { + const target = scopeThreadRef(EnvironmentId.make("link-test"), ThreadId.make("tray")); + const citation = { + version: 1 as const, + environmentId: target.environmentId, + threadId: target.threadId, + messageId: MessageId.make("source"), + text: "quote", + start: 0, + end: 5, + prefix: "", + suffix: "", + comment: "Keep this", + }; + const store = useComposerDraftStore.getState(); + store.setTrayCitations(target, [citation]); + for (const prompt of ["", "Reply"]) { + store.setPrompt(target, prompt); + expect( + composerPromptLink({ + target, + project: undefined, + providers: [], + threadModelSelection: null, + threadStarted: false, + withContext, + }), + ).toBe( + promptLinkFor( + [serializeAssistantCitation(citation), prompt].filter(Boolean).join("\n"), + withContext ? { repository: null, provider: null, model: null } : undefined, + ), + ); + } + store.clearComposerContent(target); +}); diff --git a/apps/web/src/components/promptLinks/copyPromptLink.ts b/apps/web/src/components/promptLinks/copyPromptLink.ts index 80bb453d67840..3600dea546df2 100644 --- a/apps/web/src/components/promptLinks/copyPromptLink.ts +++ b/apps/web/src/components/promptLinks/copyPromptLink.ts @@ -17,6 +17,8 @@ import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { configuredHostedAppUrl } from "~/hostedPairing"; import { draftModelSelectionApplies } from "~/lib/chatThreadActions"; +import { serializeComposerTray } from "../chat/composerSubmission"; + import { stackedThreadToast, toastManager } from "../ui/toast"; /** A shareable link to a prompt on the hosted app, or null when the prompt cannot fit in one. */ @@ -54,7 +56,8 @@ export function composerPromptLink(input: { }): string | null { const draft = useComposerDraftStore.getState().getComposerDraft(input.target); if (!draft) return null; - if (!input.withContext) return promptLinkFor(draft.prompt); + const prompt = serializeComposerTray(draft.prompt, draft.trayCitations); + if (!input.withContext) return promptLinkFor(prompt); const selection = (draft.activeProvider && draftModelSelectionApplies(draft, input.threadStarted) ? draft.modelSelectionByProvider[draft.activeProvider] @@ -63,7 +66,7 @@ export function composerPromptLink(input: { ? (input.providers.find((candidate) => candidate.instanceId === selection.instanceId)?.driver ?? null) : null; - return promptLinkFor(draft.prompt, { + return promptLinkFor(prompt, { repository: promptLinkRepositoryOf(input.project), provider, model: provider === null ? null : (selection?.model ?? null), diff --git a/apps/web/src/components/settings/CitationDestinationSetting.tsx b/apps/web/src/components/settings/CitationDestinationSetting.tsx new file mode 100644 index 0000000000000..0c173d81d4290 --- /dev/null +++ b/apps/web/src/components/settings/CitationDestinationSetting.tsx @@ -0,0 +1,21 @@ +import { useCitationDestination } from "../chat/citationDestination"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +export function CitationDestinationSetting() { + const [destination, setDestination] = useCitationDestination(); + return ( + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 633e18a567b2f..34875a8b2720c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,3 +1,4 @@ +import { CitationDestinationSetting } from "./CitationDestinationSetting"; import { useAtomValue } from "@effect/atom-react"; import { NotificationSettings } from "./NotificationSettings"; import { PRIVACY_POLICY_URL } from "../../legalLinks"; @@ -1163,6 +1164,11 @@ export function ChatSettingsPanel() { return ( + } + /> { }); }); +describe("composer citation tray", () => { + beforeEach(resetComposerDraftStore); + it("persists a citation-only draft, moves drafts, and clears citations with sent content", () => { + const source = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("tray-source")); + const target = scopeThreadRef(TEST_ENVIRONMENT_ID, ThreadId.make("tray-target")); + const citation = { + version: 1 as const, + environmentId: TEST_ENVIRONMENT_ID, + threadId: source.threadId, + messageId: MessageId.make("message"), + text: "Quote", + start: 0, + end: 5, + prefix: "", + suffix: "", + comment: "Comment", + }; + const store = useComposerDraftStore.getState(); + store.setTrayCitations(source, [citation]); + const persisted = JSON.parse( + JSON.stringify(partializeComposerDraftStoreState(useComposerDraftStore.getState())), + ); + const merge = useComposerDraftStore.persist.getOptions().merge!; + const restored = merge(persisted, useComposerDraftStore.getInitialState()); + expect(restored.draftsByThreadKey[scopedThreadKey(source)]?.trayCitations).toEqual([citation]); + persisted.draftsByThreadKey[scopedThreadKey(source)].prompt = "Legacy draft"; + delete persisted.draftsByThreadKey[scopedThreadKey(source)].trayCitations; + const legacy = merge(persisted, useComposerDraftStore.getInitialState()); + expect(legacy.draftsByThreadKey[scopedThreadKey(source)]?.prompt).toBe("Legacy draft"); + expect(legacy.draftsByThreadKey[scopedThreadKey(source)]?.trayCitations ?? []).toEqual([]); + store.setTrayCitations(target, [citation]); + store.setTrayCitations(target, []); + expect(store.getComposerDraft(target)).toBeNull(); + store.moveComposerContent(source, target); + expect(store.getComposerDraft(target)?.trayCitations).toEqual([citation]); + expect(store.getComposerDraft(source)?.trayCitations ?? []).toEqual([]); + store.clearComposerContent(target); + expect(store.getComposerDraft(target)?.trayCitations ?? []).toEqual([]); + }); +}); + describe("composerDraftStore checkout pull request", () => { const projectId = ProjectId.make("project-checkout"); const projectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, projectId); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 0bf2b690b4696..d144c9476b9fd 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,3 +1,4 @@ +import { AssistantCitation } from "@t3tools/contracts"; import { composerFileAttachmentMimeType, composerImageDedupKey, @@ -337,6 +338,7 @@ type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type; const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, + trayCitations: Schema.optionalKey(Schema.Array(AssistantCitation)), attachments: Schema.Array(PersistedComposerImageAttachment), files: Schema.optionalKey(Schema.Array(PersistedComposerDraftFileAttachment)), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), @@ -463,6 +465,7 @@ const contextInsertionHandlers = new Map; images: ComposerImageAttachment[]; files: ComposerFileAttachment[]; nonPersistedImageIds: string[]; @@ -488,6 +491,7 @@ export function composerDraftHasUserContent( return false; } return ( + (draft.trayCitations?.length ?? 0) > 0 || draft.prompt.trim().length > 0 || draft.images.length > 0 || draft.files.length > 0 || @@ -634,6 +638,10 @@ interface ComposerDraftStoreState { finalizePromotedDraftThread: (threadRef: ComposerThreadTarget) => void; clearDraftThread: (threadRef: ComposerThreadTarget) => void; setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; + setTrayCitations: ( + threadRef: ComposerThreadTarget, + citations: ReadonlyArray, + ) => void; setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void; setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; setModelSelection: ( @@ -895,6 +903,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ function createEmptyThreadDraft(): ComposerThreadDraftState { return { + trayCitations: [], prompt: "", images: [], files: [], @@ -1010,6 +1019,7 @@ function normalizeTerminalContextsForThread( function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { return ( + (draft.trayCitations?.length ?? 0) === 0 && draft.prompt.length === 0 && draft.images.length === 0 && draft.files.length === 0 && @@ -2195,6 +2205,7 @@ function normalizePersistedDraftsByThreadId( const hasModelData = Object.keys(modelSelectionByProvider).length > 0 || activeProvider !== null; if ( + !draftCandidate.trayCitations?.length && promptCandidate.length === 0 && attachments.length === 0 && files.length === 0 && @@ -2224,6 +2235,9 @@ function normalizePersistedDraftsByThreadId( })(); nextDraftsByThreadKey[normalizedThreadKey] = { prompt, + trayCitations: Array.isArray(draftCandidate.trayCitations) + ? draftCandidate.trayCitations.filter(Schema.is(AssistantCitation)) + : [], attachments, ...(files.length > 0 ? { files } : {}), ...(terminalContexts.length > 0 ? { terminalContexts } : {}), @@ -2249,6 +2263,7 @@ function normalizePersistedDraftsByThreadId( function persistedComposerDraftHasUserContent(draft: PersistedComposerThreadDraftState): boolean { return ( + (draft.trayCitations?.length ?? 0) > 0 || draft.prompt.trim().length > 0 || draft.attachments.length > 0 || (draft.files?.length ?? 0) > 0 || @@ -2329,6 +2344,7 @@ export function partializeComposerDraftStoreState( const hasModelData = Object.keys(draft.modelSelectionByProvider).length > 0 || draft.activeProvider !== null; if ( + (draft.trayCitations?.length ?? 0) === 0 && draft.prompt.length === 0 && draft.persistedAttachments.length === 0 && draft.files.length === 0 && @@ -2346,6 +2362,7 @@ export function partializeComposerDraftStoreState( } const persistedDraft: DeepMutable = { prompt: draft.prompt, + ...(draft.trayCitations?.length ? { trayCitations: [...draft.trayCitations] } : {}), attachments: draft.persistedAttachments, ...(draft.files.length > 0 ? { @@ -2653,6 +2670,7 @@ function toHydratedThreadDraft( })) ?? []; return { + trayCitations: persistedDraft.trayCitations ?? [], prompt: ensureInlineContextReferences(persistedDraft.prompt, [ ...(persistedDraft.reviewComments ?? []).map(reviewCommentContextReference), ...(persistedDraft.previewAnnotations ?? []).map(previewAnnotationContextReference), @@ -3199,6 +3217,20 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + setTrayCitations: (threadRef, citations) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) return; + set((state) => { + const nextDraft = { + ...(state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft()), + trayCitations: citations, + }; + const draftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) delete draftsByThreadKey[threadKey]; + else draftsByThreadKey[threadKey] = nextDraft; + return { draftsByThreadKey }; + }); + }, setPrompt: (threadRef, prompt) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -4344,6 +4376,7 @@ const composerDraftStore = create()( } const nextDraft: ComposerThreadDraftState = { ...current, + trayCitations: [], prompt: ensureInlineContextReferences("", [ ...current.terminalContexts.map(terminalContextReference), ...current.reviewComments.map(reviewCommentContextReference), diff --git a/apps/web/src/promptStashStore.test.ts b/apps/web/src/promptStashStore.test.ts index 1e40dfc884bfd..6902897c75e09 100644 --- a/apps/web/src/promptStashStore.test.ts +++ b/apps/web/src/promptStashStore.test.ts @@ -104,11 +104,19 @@ describe("promptStashStore", () => { writePromptStashStorageForTest( JSON.stringify({ version: 2, - state: { entries: [makeEntry({ id: "citation-stash", prompt })] }, + state: { + entries: [ + { + ...makeEntry({ id: "citation-stash", prompt }), + trayCitations: [citation, { version: 999 }], + }, + ], + }, }), ); const restored = usePromptStashStore.getState().takeEntry("citation-stash").entry; expect(restored?.prompt).toBe(prompt); + expect(restored?.trayCitations).toEqual([citation]); expect( collectAssistantCitations(restored?.prompt ?? "").map((entry) => entry.citation), ).toEqual([citation]); diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 0afb52eb01838..393c6dce42370 100644 --- a/apps/web/src/promptStashStore.ts +++ b/apps/web/src/promptStashStore.ts @@ -1,4 +1,8 @@ -import { ComposerContextRecord, ForwardCompatibleArray } from "@t3tools/contracts"; +import { + AssistantCitation, + ComposerContextRecord, + ForwardCompatibleArray, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { create } from "zustand"; @@ -20,6 +24,7 @@ const StashEntrySchema = Schema.Struct({ id: Schema.String, createdAt: Schema.String, prompt: Schema.String, + trayCitations: Schema.optionalKey(ForwardCompatibleArray(AssistantCitation)), attachments: Schema.Array(PersistedComposerImageAttachment), files: Schema.optionalKey(Schema.Array(PersistedComposerFileAttachment)), droppedImageNames: Schema.Array(Schema.String), diff --git a/docs/user/composer.md b/docs/user/composer.md index 89a96f120d4d0..c0920234ee69b 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -142,6 +142,10 @@ 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. +On web and desktop, set **New citations go** to **In the tray** in **Settings → Chat** +to collect quotes above the input. The default is **Inline**. Tray quotes are sent +before your message in their listed order and stay with saved drafts and stashed prompts. + Mobile displays saved quotes and comments, but does not create citations or navigate to their sources.