From d46f16db71aced5eb147d2f583eaa4cf57877330 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 10:46:16 -0500 Subject: [PATCH 01/13] feat(web): add an optional citation tray --- ...ditorTiptap.citationDraft.browser.test.tsx | 52 +++++++- .../components/ComposerPromptEditorTiptap.tsx | 16 +++ .../components/chat/AssistantCitationChip.tsx | 5 +- .../chat/AssistantCitationCommentEditor.tsx | 14 +++ .../components/chat/AssistantCitationTray.tsx | 115 ++++++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 53 ++++++-- .../components/chat/citationDestination.ts | 6 + .../chat/composerSubmission.test.ts | 14 ++- .../src/components/chat/composerSubmission.ts | 9 ++ .../src/components/chat/useComposerSend.ts | 6 +- .../src/components/chat/useComposerStash.ts | 15 ++- .../settings/CitationDestinationSetting.tsx | 21 ++++ .../components/settings/SettingsPanels.tsx | 6 + apps/web/src/composerDraftStore.test.ts | 34 ++++++ apps/web/src/composerDraftStore.ts | 37 ++++++ apps/web/src/promptStashStore.ts | 7 +- docs/user/composer.md | 4 + 17 files changed, 398 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/chat/AssistantCitationTray.tsx create mode 100644 apps/web/src/components/chat/citationDestination.ts create mode 100644 apps/web/src/components/settings/CitationDestinationSetting.tsx diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index a1a69a7968ee9..62157b2add7b6 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"; @@ -97,3 +98,50 @@ it("keeps an unsaved citation comment when a question takes the composer", async ); await expect.element(page.getByLabelText("Edit citation comment")).toBeInTheDocument(); }); + +function TrayComposer() { + const editorRef = useRef(null); + const [prompt, setPrompt] = useState(promptBefore); + const [tray, setTray] = useState>([]); + return ( + <> + + { + setPrompt((text) => `${text}${serializeAssistantCitation(item)}`); + return true; + }} + /> + {}} + onMoveCitationToTray={(item) => setTray((items) => [...items, item])} + /> + + ); +} + +it("moves a citation and its edited comment from inline to tray and back", async () => { + await using screen = await render(); + await page.getByLabelText("Add comment to citation").click(); + await page.getByLabelText("Comment on selected text").fill("Keep this comment"); + await page.getByRole("button", { name: "Move to tray", 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: "Insert inline", exact: true }).click(); + expect(state()?.trayCount).toBe("0"); + expect(state()?.prompt).toContain( + serializeAssistantCitation(withAssistantCitationComment(citation, "Keep this comment")), + ); +}); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 8c4698210b1d5..a714f8056929e 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -180,6 +180,7 @@ export interface ComposerPromptEditorProps { onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; onPageScrollKeyUp?: (key: string) => void; onPageScrollRelease?: () => void; + onMoveCitationToTray?: (citation: AssistantCitation) => void; onCitationSubmitAndSend?: () => void; /** Saves unsaved citation text into replacedValue when a controlled rewrite replaces the document. */ onCitationDraftSave?: (replacedValue: string, savedValue: string) => void; @@ -207,6 +208,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: () => void; }>({ openComment: null, @@ -476,6 +478,18 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { commentContext.onOpenChange(citeKey, open); }, ...(removeOnCancel ? { onCancel: 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) => { @@ -882,6 +896,7 @@ function ComposerPromptEditorTiptapInner( onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, + onMoveCitationToTray, onCitationSubmitAndSend, onCitationDraftSave, onPaste, @@ -930,6 +945,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) => { diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index cf62358069701..7c703cc0f2472 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -45,6 +45,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; @@ -219,6 +220,8 @@ export function AssistantCitationChip({ { @@ -255,7 +258,7 @@ export function AssistantCitationChip({ ); } -function AssistantCitationQuote({ citation }: { citation: AssistantCitation }) { +export function AssistantCitationQuote({ citation }: { citation: AssistantCitation }) { const [fade, setFade] = useState({ top: false, bottom: false }); const updateFade = (element: HTMLElement) => { const top = element.scrollTop > 1; diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index f97c66c7f07e1..422d755e275ca 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -12,6 +12,8 @@ export function AssistantCitationCommentEditor({ inputRef, onSubmit, onSubmitAndSend, + onMove, + moveLabel, onCancel, onDraftChange, onVoiceSetupOpenChange, @@ -19,6 +21,8 @@ export function AssistantCitationCommentEditor({ citation: AssistantCitation; inputRef?: RefObject; onSubmit: (comment: string) => boolean; + onMove?: ((comment: string) => void) | undefined; + moveLabel?: string; onSubmitAndSend?: (comment: string) => boolean; onCancel: () => void; onDraftChange?: (comment: string) => void; @@ -122,6 +126,16 @@ export function AssistantCitationCommentEditor({ > + {onMove ? ( + + ) : null} + + + + )} + + ))} + + + + + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index be106a251592e..c0dba286d8322 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"; @@ -697,6 +700,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 = (() => { @@ -831,6 +835,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), + ); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts or releases server attachment uploads as upload capability, limits and draft attachments change useEffect(() => { @@ -1128,7 +1142,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [backgroundThread]); const composerSendState = deriveComposerSendState({ - prompt, + prompt: serializeComposerTray(prompt, composerDraft.trayCitations), imageCount: composerImages.length + composerFiles.length, existingAttachmentCount: editingQueuedAttachments?.length ?? 0, terminalContexts: composerTerminalContexts, @@ -2176,7 +2190,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } const submission = submitComposerDraft({ - prompt: promptRef.current, + prompt: submissionPrompt(), submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", event, onSend: (sendEvent) => { @@ -2966,12 +2980,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusComposer(); return true; }, - citeAssistantText: (citation, sourceAnchor) => - insertComposerTextStable( + citeAssistantText: (citation, sourceAnchor) => { + if (citationDestination === "tray") { + appendTrayCitation(citation); + return true; + } + return insertComposerTextStable( formatAssistantCitationForComposer(citation, citation.comment), "cursor", { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor }, - ), + ); + }, openModelPicker: openModelPickerStable, stepReasoningEffort: (direction) => { if (noProviderAvailable || pendingUserInputs.length > 0 || isEditingQueuedMessage) { @@ -3099,7 +3118,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, @@ -3154,6 +3173,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContexts, insertComposerDraftTerminalContext, insertComposerTextStable, + citationDestination, + appendTrayCitation, + submissionPrompt, insertComposerTextAtEndStable, promptRef, composerImagesRef, @@ -3489,7 +3511,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> ) : null} + + insertComposerTextStable( + formatAssistantCitationForComposer(citation, citation.comment), + "cursor", + { ensureLeadingBoundary: true }, + ) + } + /> 0 || voiceInputActive} + promptHasText={ + prompt.trim().length > 0 || + !!composerDraft.trayCitations?.length || + voiceInputActive + } voiceInputActive={voiceInputActive} isSendBusy={isSendBusy} sendDisabledReason={voiceSendDisabledReason} diff --git a/apps/web/src/components/chat/citationDestination.ts b/apps/web/src/components/chat/citationDestination.ts new file mode 100644 index 0000000000000..92767b74947be --- /dev/null +++ b/apps/web/src/components/chat/citationDestination.ts @@ -0,0 +1,6 @@ +import * as Schema from "effect/Schema"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +const citationDestinationSchema = Schema.Literals(["inline", "tray"]); +export function useCitationDestination() { + return useLocalStorage("t3code:citation-destination", "inline", citationDestinationSchema); +} diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts index 6be1cd0733f2b..23fba24c4a7af 100644 --- a/apps/web/src/components/chat/composerSubmission.test.ts +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -11,7 +11,7 @@ import { } from "@t3tools/shared/assistantCitations"; import { describe, expect, it, vi } from "vite-plus/test"; -import { submitComposerDraft } from "./composerSubmission"; +import { serializeComposerTray, submitComposerDraft } from "./composerSubmission"; const assistantCitation = { version: 1 as const, @@ -208,3 +208,15 @@ describe("submitComposerDraft", () => { expect(onSend).toHaveBeenCalledOnce(); }); }); + +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), + ); +}); diff --git a/apps/web/src/components/chat/composerSubmission.ts b/apps/web/src/components/chat/composerSubmission.ts index 9e35aca99cf90..7eb9460e9b946 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"; @@ -48,3 +50,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/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 7e0b410134c5e..5199946b28d31 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -607,10 +607,10 @@ 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 promptForSend = directAnnotation - ? ensureInlineContextReferences(promptRef.current, [ + ? ensureInlineContextReferences(sendCtx.prompt, [ previewAnnotationContextReference(directAnnotation.annotation), ]) - : promptRef.current; + : sendCtx.prompt; const strandedThread = threadContextsOutsideEnvironment({ text: promptForSend, records: composerThreadContexts, @@ -760,7 +760,7 @@ export function useComposerSend(input: ComposerSendInput) { if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) { return; } - const followUpPromptSnapshot = promptRef.current; + const followUpPromptSnapshot = sendCtx.prompt; const followUpTerminalContexts = [...sendableComposerTerminalContexts]; const followUpReviewComments = [...composerReviewComments]; const followUpPreviewAnnotations = [...composerPreviewAnnotations]; diff --git a/apps/web/src/components/chat/useComposerStash.ts b/apps/web/src/components/chat/useComposerStash.ts index 1d480dcf150e1..fea004c1679db 100644 --- a/apps/web/src/components/chat/useComposerStash.ts +++ b/apps/web/src/components/chat/useComposerStash.ts @@ -217,6 +217,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 @@ -471,7 +478,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) { @@ -529,6 +541,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/settings/CitationDestinationSetting.tsx b/apps/web/src/components/settings/CitationDestinationSetting.tsx new file mode 100644 index 0000000000000..d0dd318726882 --- /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 c53ca8a2dc71a..cb1c0d76350f8 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"; @@ -1089,6 +1090,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]); + store.moveComposerPromptAndImages(source, target); + expect(store.getComposerDraft(target)?.trayCitations).toEqual([citation]); + expect(store.getComposerDraft(source)?.trayCitations ?? []).toEqual([]); + store.clearComposerContent(target); + expect(store.getComposerDraft(target)?.trayCitations ?? []).toEqual([]); + }); +}); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index aef9e88a38572..aab9739114c74 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,3 +1,4 @@ +import { AssistantCitation } from "@t3tools/contracts"; import { stripInlineContextReferences } from "./lib/composerContextReferences"; import { composerFileAttachmentMimeType } from "./components/chat/composerAttachmentFiles"; import { elementContextToPreviewAnnotation } from "./lib/elementContext"; @@ -241,6 +242,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)), @@ -363,6 +365,7 @@ const contextInsertionHandlers = new Map; images: ComposerImageAttachment[]; files: ComposerFileAttachment[]; nonPersistedImageIds: string[]; @@ -386,6 +389,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 || @@ -508,6 +512,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: ( @@ -870,6 +878,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 && @@ -1973,6 +1982,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 && @@ -2001,6 +2011,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 } : {}), @@ -2025,6 +2038,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 || @@ -2104,6 +2118,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 && @@ -2120,6 +2135,7 @@ export function partializeComposerDraftStoreState( } const persistedDraft: DeepMutable = { prompt: draft.prompt, + trayCitations: [...(draft.trayCitations ?? [])], attachments: draft.persistedAttachments, ...(draft.files.length > 0 ? { @@ -2424,6 +2440,7 @@ function toHydratedThreadDraft( })) ?? []; return { + trayCitations: persistedDraft.trayCitations ?? [], prompt: ensureInlineContextReferences(persistedDraft.prompt, [ ...(persistedDraft.reviewComments ?? []).map(reviewCommentContextReference), ...(persistedDraft.previewAnnotations ?? []).map(previewAnnotationContextReference), @@ -2956,6 +2973,19 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + setTrayCitations: (threadRef, citations) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) return; + set((state) => ({ + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...(state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft()), + trayCitations: citations, + }, + }, + })); + }, setPrompt: (threadRef, prompt) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -4047,6 +4077,7 @@ const composerDraftStore = create()( } const nextDraft: ComposerThreadDraftState = { ...current, + trayCitations: [], prompt: "", images: [], files: [], @@ -4082,6 +4113,7 @@ const composerDraftStore = create()( } const nextDraft: ComposerThreadDraftState = { ...current, + trayCitations: [], prompt: ensureInlineContextReferences("", [ ...current.terminalContexts.map(terminalContextReference), ...current.reviewComments.map(reviewCommentContextReference), @@ -4160,6 +4192,10 @@ const composerDraftStore = create()( ); const nextDestination: ComposerThreadDraftState = { ...destination, + trayCitations: [ + ...(destination.trayCitations ?? []), + ...(source.trayCitations ?? []), + ], prompt: movedPrompt, images: [...destination.images, ...movedImages], files: [...destination.files, ...movedFiles], @@ -4177,6 +4213,7 @@ const composerDraftStore = create()( // Moved images still use these blob URLs at the destination; do not revoke them. const nextSource: ComposerThreadDraftState = { ...source, + trayCitations: [], prompt: ensureInlineContextReferences( "", source.terminalContexts.map(terminalContextReference), diff --git a/apps/web/src/promptStashStore.ts b/apps/web/src/promptStashStore.ts index 099353cad284e..0944146ab559a 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(Schema.Array(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 22b499ebe7b0c..0c6eebe899cde 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -408,3 +408,7 @@ including filenames or folders containing `#` or `?`. On mobile, select a PDF attachment or link to open it. iOS uses the native viewer; Android opens a compatible installed file viewer. + +### Citation tray + +In Settings ? Composer, set **New citations go** to **In the tray** to collect quotes above the input. The default is **Inline**. Open the annotations pill to review, edit, or delete quotes; use its ? to clear them all. In a citation comment editor, **Move to tray** and **Insert inline** move the quote between the tray and your text. Tray quotes are sent in their listed order before your message and stay with saved drafts and stashed prompts. From 789c0c9a08d652d7aad4d88f8d9b13d53fd96715 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 11:25:16 -0500 Subject: [PATCH 02/13] fix(web): preserve tray citations while answering questions --- ...ditorTiptap.citationDraft.browser.test.tsx | 27 ++++++++---- .../components/ComposerPromptEditorTiptap.tsx | 2 +- .../components/chat/AssistantCitationTray.tsx | 41 +++++++++++-------- apps/web/src/components/chat/ChatComposer.tsx | 17 ++++---- docs/user/composer.md | 2 +- 5 files changed, 55 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index 62157b2add7b6..f5b94c3184f3b 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -99,9 +99,10 @@ it("keeps an unsaved citation comment when a question takes the composer", async await expect.element(page.getByLabelText("Edit citation comment")).toBeInTheDocument(); }); -function TrayComposer() { +function TrayComposer({ question = false }: { question?: boolean }) { const editorRef = useRef(null); const [prompt, setPrompt] = useState(promptBefore); + const [answer, setAnswer] = useState(""); const [tray, setTray] = useState>([]); return ( <> @@ -109,22 +110,26 @@ function TrayComposer() { { - setPrompt((text) => `${text}${serializeAssistantCitation(item)}`); - return true; - }} + onInsertInline={ + question + ? undefined + : (item) => { + setPrompt((text) => `${text}${serializeAssistantCitation(item)}`); + return true; + } + } /> {}} - onMoveCitationToTray={(item) => setTray((items) => [...items, item])} + onMoveCitationToTray={question ? undefined : (item) => setTray((items) => [...items, item])} /> ); @@ -139,6 +144,12 @@ it("moves a citation and its edited comment from inline to tray and back", async expect(state()?.trayCount).toBe("1"); expect(state()?.prompt).not.toContain("t3-citation"); await page.getByRole("button", { name: "1 annotation", exact: true }).click(); + await screen.rerender(); + await expect + .element(page.getByRole("button", { name: "Insert inline", exact: true })) + .not.toBeInTheDocument(); + expect(state()?.trayCount).toBe("1"); + await screen.rerender(); await page.getByRole("button", { name: "Insert inline", exact: true }).click(); expect(state()?.trayCount).toBe("0"); expect(state()?.prompt).toContain( diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index a714f8056929e..b5686d2da33f6 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -180,7 +180,7 @@ export interface ComposerPromptEditorProps { onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; onPageScrollKeyUp?: (key: string) => void; onPageScrollRelease?: () => void; - onMoveCitationToTray?: (citation: AssistantCitation) => void; + onMoveCitationToTray?: ((citation: AssistantCitation) => void) | undefined; onCitationSubmitAndSend?: () => void; /** Saves unsaved citation text into replacedValue when a controlled rewrite replaces the document. */ onCitationDraftSave?: (replacedValue: string, savedValue: string) => void; diff --git a/apps/web/src/components/chat/AssistantCitationTray.tsx b/apps/web/src/components/chat/AssistantCitationTray.tsx index 519fdfed16837..0ca58067b61ab 100644 --- a/apps/web/src/components/chat/AssistantCitationTray.tsx +++ b/apps/web/src/components/chat/AssistantCitationTray.tsx @@ -15,7 +15,7 @@ export function AssistantCitationTray({ }: { citations: ReadonlyArray; onChange: (citations: ReadonlyArray) => void; - onInsertInline: (citation: AssistantCitation) => boolean; + onInsertInline?: ((citation: AssistantCitation) => boolean) | undefined; }) { const [voiceSetupOpen, setVoiceSetupOpen] = useState(false); const [open, setOpen] = useState(false); @@ -29,15 +29,16 @@ export function AssistantCitationTray({
{ + onOpenChange={(next) => { if (voiceSetupOpen) return; - if (!next && details.reason === "trigger-press") return; setOpen(next); }} > } - onMouseEnter={() => setOpen(true)} + openOnHover + delay={150} + closeDelay={150} > {citations.length} {citations.length === 1 ? "annotation" : "annotations"} @@ -70,25 +71,31 @@ export function AssistantCitationTray({ }} onCancel={() => setEditing(null)} moveLabel="Insert inline" - onMove={(comment) => { - if (onInsertInline(withAssistantCitationComment(citation, comment))) - remove(index); - }} + onMove={ + onInsertInline + ? (comment) => { + if (onInsertInline(withAssistantCitationComment(citation, comment))) + remove(index); + } + : undefined + } /> ) : (
- + {onInsertInline ? ( + + ) : null} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c0dba286d8322..737d8f256ad63 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3515,16 +3515,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) key={composerDraftTargetKey} citations={composerDraft.trayCitations ?? []} onChange={setTrayCitations} - onInsertInline={(citation) => - insertComposerTextStable( - formatAssistantCitationForComposer(citation, citation.comment), - "cursor", - { ensureLeadingBoundary: true }, - ) + onInsertInline={ + activePendingProgress + ? undefined + : (citation) => + insertComposerTextStable( + formatAssistantCitationForComposer(citation, citation.comment), + "cursor", + { ensureLeadingBoundary: true }, + ) } /> Date: Sat, 7 Nov 2026 11:56:41 -0500 Subject: [PATCH 03/13] fix(web): preserve citation trays on failed sends --- .../web/src/components/ChatView.logic.test.ts | 34 +++++++++++++++++-- apps/web/src/components/ChatView.logic.ts | 31 ++++++++++++----- apps/web/src/components/chat/chatViewSend.ts | 3 +- .../src/components/chat/useComposerSend.ts | 25 +++++++++++--- .../components/chat/useMultipleModelSend.ts | 9 +++-- 5 files changed, 83 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 2e772cc0998c0..269dd2e6f4e54 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -12,6 +12,7 @@ import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { deriveProviderInstanceEntries } from "../providerInstances"; import { ensureInlineContextReferences } from "../lib/composerContextReferences"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; import { terminalContextReference } from "../lib/composerContextRecords"; import { CommandId, @@ -57,6 +58,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 { @@ -1043,10 +1055,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], @@ -1057,9 +1077,15 @@ describe("snapshotComposerSend", () => { environmentContexts: [], }); + 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(), ); }); }); @@ -1167,8 +1193,11 @@ describe("restorePlanFollowUpComposer", () => { }, ])("$name", ({ before, routeIsCurrent, after }) => { const composer: { draft: string; refs: string; cursor: string | null } = { ...before }; + const writeTrayCitations = vi.fn(); restorePlanFollowUpComposer({ + writeTrayCitations, snapshot: { + trayCitations: [trayCitation], prompt: "Tighten step 2", environmentContexts: [], terminalContexts: [], @@ -1194,6 +1223,7 @@ describe("restorePlanFollowUpComposer", () => { }, }); + expect(writeTrayCitations.mock.calls).toEqual(before.draft ? [] : [[[trayCitation]]]); expect(composer).toEqual(after); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index d4b54a7e5a848..afd9352c36911 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,4 +1,8 @@ -import { type OrchestrationV2ProviderTurn, type RunAttemptId } from "@t3tools/contracts"; +import { + type AssistantCitation, + type OrchestrationV2ProviderTurn, + type RunAttemptId, +} from "@t3tools/contracts"; import { deriveLatestThreadRun, deriveThreadRuntime, @@ -1169,6 +1173,7 @@ export function shouldRefocusComposerOnWindowFocus( } export interface PlanFollowUpComposerSnapshot { + readonly trayCitations: ReadonlyArray; readonly prompt: string; readonly terminalContexts: ReadonlyArray; readonly reviewComments: ReadonlyArray; @@ -1184,6 +1189,7 @@ export function restorePlanFollowUpComposer(input: { readonly draftHasUserContent: boolean; readonly routeIsCurrent: boolean; readonly writeComposerRefs: (snapshot: PlanFollowUpComposerSnapshot) => void; + readonly writeTrayCitations: (citations: ReadonlyArray) => void; readonly writePrompt: (prompt: string) => void; readonly writeTerminalContexts: (contexts: ReadonlyArray) => void; readonly writeReviewComments: (comments: ReadonlyArray) => void; @@ -1199,6 +1205,7 @@ export function restorePlanFollowUpComposer(input: { if (input.draftHasUserContent) return; if (input.routeIsCurrent) input.writeComposerRefs(input.snapshot); input.writePrompt(input.snapshot.prompt); + input.writeTrayCitations(input.snapshot.trayCitations); input.writeTerminalContexts(input.snapshot.terminalContexts); input.writeReviewComments(input.snapshot.reviewComments); input.writePreviewAnnotations(input.snapshot.previewAnnotations); @@ -1356,6 +1363,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; @@ -1376,7 +1385,18 @@ export function snapshotComposerSend(contents: ComposerSendContents) { const images = [...contents.images]; const files = [...contents.files]; const terminalContexts = [...contents.sendableTerminalContexts]; + 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], @@ -1385,14 +1405,7 @@ export function snapshotComposerSend(contents: ComposerSendContents) { reviewComments: [...contents.reviewComments], threadContexts: [...contents.threadContexts], environmentContexts: [...contents.environmentContexts], - messageText: contents.terminalContexts - .filter((context) => !terminalContexts.includes(context)) - .reduce( - (text, context) => - removeInlineContextReference(text, terminalContextReference(context).contextId).prompt, - contents.prompt, - ) - .trim(), + messageText: cleanPrompt(contents.prompt), }; } diff --git a/apps/web/src/components/chat/chatViewSend.ts b/apps/web/src/components/chat/chatViewSend.ts index 81a00823a98be..d9f66f645e4a6 100644 --- a/apps/web/src/components/chat/chatViewSend.ts +++ b/apps/web/src/components/chat/chatViewSend.ts @@ -262,7 +262,8 @@ export function restoreComposerDraft( ): ComposerImageAttachment[] { const store = useComposerDraftStore.getState(); const images = snapshot.images.map(cloneComposerImageForRetry); - store.setPrompt(draftTarget, snapshot.messageText); + store.setPrompt(draftTarget, snapshot.draftPrompt); + store.setTrayCitations(draftTarget, snapshot.trayCitations); store.addImages(draftTarget, images); store.addFiles(draftTarget, snapshot.files); store.setTerminalContexts(draftTarget, snapshot.terminalContexts); diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 5199946b28d31..0070349a6a19a 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -487,6 +487,10 @@ export function useComposerSend(input: ComposerSendInput) { return; } const sendCtx = composerRef.current?.getSendContext(); + const trayCitations = [ + ...(useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.trayCitations ?? + []), + ]; // `/side` beats an open question and a plan follow-up, which would send the text to main. const sideChatCommand = sendCtx && !directAnnotation && !queuedRunEdit.isEditing @@ -507,6 +511,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 || @@ -760,7 +765,7 @@ export function useComposerSend(input: ComposerSendInput) { if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) { return; } - const followUpPromptSnapshot = sendCtx.prompt; + const followUpPromptSnapshot = promptAtSend; const followUpTerminalContexts = [...sendableComposerTerminalContexts]; const followUpReviewComments = [...composerReviewComments]; const followUpPreviewAnnotations = [...composerPreviewAnnotations]; @@ -784,6 +789,7 @@ export function useComposerSend(input: ComposerSendInput) { restorePlanFollowUpComposer({ snapshot: { prompt: followUpPromptSnapshot, + trayCitations, terminalContexts: followUpTerminalContexts, reviewComments: followUpReviewComments, previewAnnotations: followUpPreviewAnnotations, @@ -798,6 +804,8 @@ export function useComposerSend(input: ComposerSendInput) { promptRef.current = snapshot.prompt; composerTerminalContextsRef.current = [...snapshot.terminalContexts]; }, + writeTrayCitations: (citations) => + useComposerDraftStore.getState().setTrayCitations(composerDraftTarget, citations), writePrompt: (prompt) => setComposerDraftPrompt(composerDraftTarget, prompt), writeTerminalContexts: (contexts) => setComposerDraftTerminalContexts(composerDraftTarget, [...contexts]), @@ -858,6 +866,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, @@ -1481,13 +1495,16 @@ export function useComposerSend(input: ComposerSendInput) { optimisticBlobPreviews.release(optimisticMessage); const retryComposerImages = restoreComposerDraft(composerDraftTarget, sendSnapshot); if (currentRouteThreadKeyRef.current === routeThreadKey) { - promptRef.current = messageTextForSend; + promptRef.current = sendSnapshot.draftPrompt; composerImagesRef.current = retryComposerImages; composerFilesRef.current = composerFilesSnapshot; composerTerminalContextsRef.current = composerTerminalContextsSnapshot; composerRef.current?.resetCursorState({ - cursor: collapseExpandedComposerCursor(messageTextForSend, messageTextForSend.length), - prompt: messageTextForSend, + cursor: collapseExpandedComposerCursor( + sendSnapshot.draftPrompt, + sendSnapshot.draftPrompt.length, + ), + prompt: sendSnapshot.draftPrompt, detectTrigger: true, }); } diff --git a/apps/web/src/components/chat/useMultipleModelSend.ts b/apps/web/src/components/chat/useMultipleModelSend.ts index 8a2a1dd409643..e422dd4cc723c 100644 --- a/apps/web/src/components/chat/useMultipleModelSend.ts +++ b/apps/web/src/components/chat/useMultipleModelSend.ts @@ -234,10 +234,13 @@ export function useMultipleModelSend(input: MultipleModelSendInput) { if (clearedDraft) { restoreComposerDraft(composerDraftTarget, sendSnapshot); if (composerRef.current && currentRouteThreadKeyRef.current === routeThreadKey) { - promptRef.current = messageText; + promptRef.current = sendSnapshot.draftPrompt; composerRef.current.resetCursorState({ - cursor: collapseExpandedComposerCursor(messageText, messageText.length), - prompt: messageText, + cursor: collapseExpandedComposerCursor( + sendSnapshot.draftPrompt, + sendSnapshot.draftPrompt.length, + ), + prompt: sendSnapshot.draftPrompt, detectTrigger: true, }); } From c5f65e8f58eb1e4133d24793053a1886d1391165 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 12:11:30 -0500 Subject: [PATCH 04/13] fix(web): preserve citation tray edits and trim closed tray work --- .../web/src/components/ChatView.logic.test.ts | 6 + apps/web/src/components/ChatView.logic.ts | 2 + .../chat/AssistantCitationCommentEditor.tsx | 3 + .../components/chat/AssistantCitationTray.tsx | 121 ++++++++++-------- apps/web/src/components/chat/ChatComposer.tsx | 3 +- .../components/chat/queuedMessageEdit.test.ts | 24 +++- .../src/components/chat/queuedMessageEdit.ts | 5 +- apps/web/src/composerDraftStore.test.ts | 8 ++ apps/web/src/composerDraftStore.ts | 21 +-- apps/web/src/promptStashStore.test.ts | 10 +- apps/web/src/promptStashStore.ts | 2 +- 11 files changed, 138 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 269dd2e6f4e54..71664e6bb44ae 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -134,6 +134,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); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index afd9352c36911..4e00226a68767 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -754,6 +754,7 @@ export function cloneComposerImageForRetry( export function deriveComposerSendState(options: { prompt: string; imageCount: number; + citationCount?: number; existingAttachmentCount?: number; terminalContexts: ReadonlyArray; elementContextCount?: number; @@ -775,6 +776,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, diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index 422d755e275ca..8d50fd10640a8 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -14,6 +14,7 @@ export function AssistantCitationCommentEditor({ onSubmitAndSend, onMove, moveLabel, + moveAriaLabel, onCancel, onDraftChange, onVoiceSetupOpenChange, @@ -23,6 +24,7 @@ export function AssistantCitationCommentEditor({ onSubmit: (comment: string) => boolean; onMove?: ((comment: string) => void) | undefined; moveLabel?: string; + moveAriaLabel?: string; onSubmitAndSend?: (comment: string) => boolean; onCancel: () => void; onDraftChange?: (comment: string) => void; @@ -131,6 +133,7 @@ export function AssistantCitationCommentEditor({ variant="outline" size="xs" disabled={speech.blocksSubmission || commentTooLong} + aria-label={moveAriaLabel} onClick={() => onMove(comment)} > {moveLabel} diff --git a/apps/web/src/components/chat/AssistantCitationTray.tsx b/apps/web/src/components/chat/AssistantCitationTray.tsx index 0ca58067b61ab..8e3e2c9c53333 100644 --- a/apps/web/src/components/chat/AssistantCitationTray.tsx +++ b/apps/web/src/components/chat/AssistantCitationTray.tsx @@ -8,7 +8,11 @@ import { Button } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { composerFloatingLayerProps } from "./composerEventScope"; -export function AssistantCitationTray({ +export function AssistantCitationTray(props: Parameters[0]) { + return props.citations.length ? : null; +} + +function CitationTrayContents({ citations, onChange, onInsertInline, @@ -20,7 +24,6 @@ export function AssistantCitationTray({ const [voiceSetupOpen, setVoiceSetupOpen] = useState(false); const [open, setOpen] = useState(false); const [editing, setEditing] = useState(null); - if (!citations.length) return null; const remove = (index: number) => { onChange(citations.filter((_, i) => i !== index)); setEditing(null); @@ -49,61 +52,75 @@ export function AssistantCitationTray({ width="md" aria-label="Citation tray" > -
- {citations.map((citation, index) => ( - // oxlint-disable-next-line react/no-array-index-key -- identical quotes may appear more than once and the tray preserves their order -
-

{index + 1}. Selected text:

- -

User comment: {citation.comment || "None"}

- {editing === index ? ( - { - onChange( - citations.map((item, i) => - i === index ? withAssistantCitationComment(item, comment) : item, - ), - ); - setEditing(null); - return true; - }} - onCancel={() => setEditing(null)} - moveLabel="Insert inline" - onMove={ - onInsertInline - ? (comment) => { - if (onInsertInline(withAssistantCitationComment(citation, comment))) - remove(index); - } - : undefined - } - /> - ) : ( -
- - {onInsertInline ? ( + {open ? ( +
+ {citations.map((citation, index) => ( + // oxlint-disable-next-line react/no-array-index-key -- identical quotes may appear more than once and the tray preserves their order +
+

{index + 1}. Selected text:

+ +

User comment: {citation.comment || "None"}

+ {editing === index ? ( + { + onChange( + citations.map((item, i) => + i === index ? withAssistantCitationComment(item, comment) : item, + ), + ); + setEditing(null); + return true; + }} + onCancel={() => setEditing(null)} + moveLabel="Insert inline" + moveAriaLabel={`Insert annotation ${index + 1} inline`} + onMove={ + onInsertInline + ? (comment) => { + if (onInsertInline(withAssistantCitationComment(citation, comment))) + remove(index); + } + : undefined + } + /> + ) : ( +
+ + {onInsertInline ? ( + + ) : null} - ) : null} - -
- )} -
- ))} -
+
+ )} +
+ ))} +
+ ) : null} { +it("opens a source comment for tray citations and preserves saved and cancelled citations", async () => { await using screen = await render(); - await page.getByLabelText("Add comment to citation").click(); + 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: "Move to tray", exact: true }).click(); + 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"); @@ -155,4 +182,14 @@ it("moves a citation and its edited comment from inline to tray and back", async expect(state()?.prompt).toContain( serializeAssistantCitation(withAssistantCitationComment(citation, "Keep this comment")), ); + 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"); }); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index b5686d2da33f6..83c781d22214f 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -189,6 +189,7 @@ export interface ComposerPromptEditorProps { } export type ComposerCitationCommentRequest = { + destination?: "inline" | "tray" | undefined; previousValue: string; value: string; citationStart: number; @@ -200,6 +201,7 @@ type CitationInsertedSpaces = { before: boolean; after: boolean }; type OpenCitationComment = { key: string; + destination?: "inline" | "tray" | undefined; sourceAnchor?: AssistantCitationSourceAnchor; removeOnCancel?: CitationInsertedSpaces; }; @@ -411,6 +413,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 (commentTarget?.destination === "tray" && commentContext.onMoveToTray) { + commentContext.onMoveToTray(next); + onRemove(); + return true; + } const tr = editor.state.tr.setNodeMarkup(pos, undefined, { ...current.attrs, citation: next, @@ -475,9 +482,25 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { sourceAnchor: commentTarget?.sourceAnchor, onOpenChange: (open) => { if (open && !editor.isEditable) return; + if (!open && commentTarget?.destination === "tray") { + 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 (commentTarget?.destination === "tray") + commentContext.onMoveToTray?.(citation); + onRemove(); + }, + } + : {}), onMoveToTray: commentContext.onMoveToTray ? (comment) => { const pos = nodePos(); @@ -1559,6 +1582,7 @@ function ComposerPromptEditorTiptapInner( key: citeKey, sourceAnchor: pendingCitation.sourceAnchor, removeOnCancel: pendingCitation.insertedSpaces, + destination: pendingCitation.destination, }); } } @@ -1675,6 +1699,7 @@ function ComposerPromptEditorTiptapInner( key: citeKey, sourceAnchor: request.sourceAnchor, removeOnCancel: request.insertedSpaces, + destination: request.destination, }); } }, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ae68587966ae7..d2511ce1b467a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1768,6 +1768,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) start: number; sourceAnchor: AssistantCitationSourceAnchor; insertedSpaces: ComposerCitationCommentRequest["insertedSpaces"]; + destination?: "inline" | "tray" | undefined; }; }, ): boolean => { @@ -1804,6 +1805,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; @@ -2528,6 +2530,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) options?: { ensureLeadingBoundary?: boolean; citationCommentAnchor?: AssistantCitationSourceAnchor; + citationDestination?: "inline" | "tray" | undefined; clipboardData?: DataTransfer; }, ): boolean => { @@ -2559,6 +2562,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, @@ -2982,14 +2986,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; }, citeAssistantText: (citation, sourceAnchor) => { - if (citationDestination === "tray") { - appendTrayCitation(citation); - return true; - } return insertComposerTextStable( formatAssistantCitationForComposer(citation, citation.comment), "cursor", - { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor }, + { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor, citationDestination }, ); }, openModelPicker: openModelPickerStable, diff --git a/apps/web/src/components/settings/CitationDestinationSetting.tsx b/apps/web/src/components/settings/CitationDestinationSetting.tsx index d0dd318726882..0c173d81d4290 100644 --- a/apps/web/src/components/settings/CitationDestinationSetting.tsx +++ b/apps/web/src/components/settings/CitationDestinationSetting.tsx @@ -10,7 +10,7 @@ export function CitationDestinationSetting() { }} > - + {destination === "inline" ? "Inline (default)" : "In the tray"} Inline (default) From b621458dfe642ab5ca825563ecb97914942f0ca7 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 15:24:28 -0500 Subject: [PATCH 08/13] fix(web): tray citations survive questions, uploads, limits, and prompt links Co-Authored-By: GPT-6.1 Sol via T3 Code --- ...ditorTiptap.citationDraft.browser.test.tsx | 22 ++++++- .../components/ComposerPromptEditorTiptap.tsx | 10 +-- apps/web/src/components/chat/ChatComposer.tsx | 2 +- .../chat/composerSend.logic.test.ts | 36 ++++++++++- .../src/components/chat/composerSend.logic.ts | 30 ++++++++- .../chat/composerSubmission.test.ts | 22 ++++++- .../src/components/chat/composerSubmission.ts | 7 ++- .../src/components/chat/useComposerSend.ts | 61 +++++++++++-------- .../promptLinks/copyPromptLink.test.ts | 42 +++++++++++++ .../components/promptLinks/copyPromptLink.ts | 7 ++- docs/user/composer.md | 8 +-- 11 files changed, 203 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/components/promptLinks/copyPromptLink.test.ts diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index 4d554d48e9057..d9242aea02374 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -119,7 +119,8 @@ function TrayComposer({ question = false }: { question?: boolean }) { onClick={() => { const sourceAnchor = findAssistantCitationSourceAnchor(document, citation); if (!sourceAnchor) throw new Error("Citation source did not resolve"); - setPrompt(promptBefore); + if (question) setAnswer(promptBefore); + else setPrompt(promptBefore); editorRef.current?.requestCitationComment({ previousValue: "See ", value: promptBefore, @@ -132,7 +133,7 @@ function TrayComposer({ question = false }: { question?: boolean }) { > Cite to tray - + { + 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")), + ); + }, +); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 83c781d22214f..e6d4b27c90ad8 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -405,6 +405,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(); @@ -413,7 +414,7 @@ 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 (commentTarget?.destination === "tray" && commentContext.onMoveToTray) { + if (destination === "tray" && commentContext.onMoveToTray) { commentContext.onMoveToTray(next); onRemove(); return true; @@ -482,11 +483,11 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { sourceAnchor: commentTarget?.sourceAnchor, onOpenChange: (open) => { if (open && !editor.isEditable) return; - if (!open && commentTarget?.destination === "tray") { + 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); + commentContext.onMoveToTray(current.attrs.citation as AssistantCitation); onRemove(); } } @@ -495,8 +496,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { ...(removeOnCancel ? { onCancel: () => { - if (commentTarget?.destination === "tray") - commentContext.onMoveToTray?.(citation); + if (destination === "tray") commentContext.onMoveToTray?.(citation); onRemove(); }, } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d2511ce1b467a..f4ac9f3d8b21a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1436,7 +1436,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); } diff --git a/apps/web/src/components/chat/composerSend.logic.test.ts b/apps/web/src/components/chat/composerSend.logic.test.ts index 13e36ea5659c9..6b5981ef3fb98 100644 --- a/apps/web/src/components/chat/composerSend.logic.test.ts +++ b/apps/web/src/components/chat/composerSend.logic.test.ts @@ -1,10 +1,11 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { describe, expect, it } from "vite-plus/test"; import { formatInlineContextReference } from "../../lib/composerContextReferences"; import { threadContextReference } from "../../lib/composerContextRecords"; import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { + composerDraftChangedSinceSend, approvalRedirectNotice, loadBalancingSendWarning, multipleModelSendBlock, @@ -124,3 +125,36 @@ describe("approvalRedirectNotice", () => { 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], + }; + expect(composerDraftChangedSinceSend(input)).toBe(false); + 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..735a3a039b508 100644 --- a/apps/web/src/components/chat/composerSend.logic.ts +++ b/apps/web/src/components/chat/composerSend.logic.ts @@ -1,4 +1,5 @@ -import type { EnvironmentId, ThreadContextRecord } from "@t3tools/contracts"; +import type { AssistantCitation, EnvironmentId, 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 +80,30 @@ 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; +}) { + 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 || + 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 23fba24c4a7af..77e2936a6b666 100644 --- a/apps/web/src/components/chat/composerSubmission.test.ts +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -11,7 +11,11 @@ import { } from "@t3tools/shared/assistantCitations"; import { describe, expect, it, vi } from "vite-plus/test"; -import { serializeComposerTray, submitComposerDraft } from "./composerSubmission"; +import { + getComposerPromptLengthValidationMessage, + serializeComposerTray, + submitComposerDraft, +} from "./composerSubmission"; const assistantCitation = { version: 1 as const, @@ -220,3 +224,19 @@ it("serializes ordered tray citations before inline citations and text", () => { 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(); +}); diff --git a/apps/web/src/components/chat/composerSubmission.ts b/apps/web/src/components/chat/composerSubmission.ts index 7eb9460e9b946..63fa78bb98f84 100644 --- a/apps/web/src/components/chat/composerSubmission.ts +++ b/apps/web/src/components/chat/composerSubmission.ts @@ -11,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, diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 0070349a6a19a..2f2c155505c8b 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -93,6 +93,7 @@ import { runMobileComposerTransition } from "./draftHeroTransition"; import { useClientSettingsHydrated, useEnvironmentSettings } from "../../hooks/useSettings"; import { environmentPresentations } from "../../state/presentation"; import { + composerDraftChangedSinceSend, approvalRedirectNotice, loadBalancingSendWarning, multipleModelSendBlock, @@ -1058,6 +1059,41 @@ 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, + 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 @@ -1069,31 +1105,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)); - return !promptUnchanged || !attachmentsUnchanged; - }; const draftBeforeDecline = useComposerDraftStore .getState() .getComposerDraft(composerDraftTarget); 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..ae38e81830d74 --- /dev/null +++ b/apps/web/src/components/promptLinks/copyPromptLink.test.ts @@ -0,0 +1,42 @@ +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, + 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 6f2a6762fa4a6..792028d7159e9 100644 --- a/apps/web/src/components/promptLinks/copyPromptLink.ts +++ b/apps/web/src/components/promptLinks/copyPromptLink.ts @@ -16,6 +16,8 @@ import { type ComposerThreadTarget, useComposerDraftStore } from "~/composerDraf import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { configuredHostedAppUrl } from "~/hostedPairing"; +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. */ @@ -52,7 +54,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 ? draft.modelSelectionByProvider[draft.activeProvider] : undefined) ?? input.threadModelSelection; @@ -60,7 +63,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/docs/user/composer.md b/docs/user/composer.md index 75e49599d78a4..1334e2ef3cd11 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -140,6 +140,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. @@ -408,7 +412,3 @@ including filenames or folders containing `#` or `?`. On mobile, select a PDF attachment or link to open it. iOS uses the native viewer; Android opens a compatible installed file viewer. - -### Citation tray - -On web and desktop, in **Settings → Chat** (Composer section), set **New citations go** to **In the tray** to collect quotes above the input. The default is **Inline**. Open the annotations pill to review, edit, or delete quotes; use **Clear all annotations** to clear them all. In a citation comment editor, **Move to tray** and **Insert inline** move the quote between the tray and your text. Tray quotes are sent in their listed order before your message and stay with saved drafts and stashed prompts. From 7c494edc11c7ffbdc687506cc6f35d2a83df8b0b Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Sat, 7 Nov 2026 16:19:05 -0500 Subject: [PATCH 09/13] fix(web): tray actions use icon buttons with tooltips Co-Authored-By: GPT-6.1 Sol via T3 Code --- .../chat/AssistantCitationCommentEditor.tsx | 27 ++++--- .../components/chat/AssistantCitationTray.tsx | 78 ++++++++++++------- 2 files changed, 68 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index 8d50fd10640a8..c63c8ab3b3650 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,6 +7,7 @@ import { textDirection } from "~/textDirection"; import { ComposerContextActionsContext } from "../composerContextActions"; import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export function AssistantCitationCommentEditor({ citation, @@ -129,15 +131,22 @@ export function AssistantCitationCommentEditor({
{onMove ? ( - + + onMove(comment)} + /> + } + > + {moveLabel === "Insert inline" ? : } + + {moveLabel} + ) : null} - {onInsertInline ? ( - + + + Edit comment + + {onInsertInline ? ( + + { + if (onInsertInline(citation)) remove(index); + }} + /> + } + > + + + Insert inline + ) : null} - + + remove(index)} + /> + } + > + + + Delete annotation +
)} From 305676da666ed2dbf24b5db2dcfdfc75a06e94dd Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Mon, 9 Nov 2026 09:11:15 -0500 Subject: [PATCH 10/13] test(web): consolidate queued draft recovery cases Cover file-only and citation-only recovery with one table while preserving both safeguards. Co-Authored-By: GPT-6.1 Sol --- .../components/chat/queuedMessageEdit.test.ts | 44 ++++++++----------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/chat/queuedMessageEdit.test.ts b/apps/web/src/components/chat/queuedMessageEdit.test.ts index baecfb4ec1b78..ebbb772628a14 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.test.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.test.ts @@ -74,37 +74,29 @@ 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"); - store.addFiles(editTarget, [file]); - expect( - recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "Original message" }), - ).toBe("kept"); - expect(store.getComposerDraft(threadTarget)?.prompt).toBe("Original message"); - expect(store.getComposerDraft(threadTarget)?.files).toEqual([file]); - expect(store.getComposerDraft(editTarget)).toBeNull(); - }); - - it("keeps citation-only edits when the queued run leaves", () => { - const store = useComposerDraftStore.getState(); - const citation = { - version: 1 as const, - environmentId, - threadId: threadTarget.threadId, - messageId: MessageId.make("message"), - text: "Quote", - start: 0, - end: 5, - prefix: "", - suffix: "", - }; - store.setPrompt(editTarget, "Original message"); - store.setTrayCitations(editTarget, [citation]); + if (kind === "file") store.addFiles(editTarget, [file]); + else store.setTrayCitations(editTarget, [citation]); expect( recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "Original message" }), ).toBe("kept"); - expect(store.getComposerDraft(threadTarget)?.trayCitations).toEqual([citation]); + const recovered = store.getComposerDraft(threadTarget); + expect(recovered?.prompt).toBe("Original message"); + expect(recovered?.files).toEqual(kind === "file" ? [file] : []); + expect(recovered?.trayCitations ?? []).toEqual(kind === "citation" ? [citation] : []); expect(store.getComposerDraft(editTarget)).toBeNull(); }); From 2400ad40691539f22f11a90d44506bda05efb388 Mon Sep 17 00:00:00 2001 From: Elliot Drel Date: Wed, 11 Nov 2026 01:31:01 -0500 Subject: [PATCH 11/13] fix(web): keep tray citations out of local slash commands --- .../src/components/chat/useComposerSend.ts | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 33e0b62e8fd4f..64b4fd3bc98a1 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -623,8 +623,26 @@ 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 && composerImages.length === 0 && composerFiles.length === 0 && sendableComposerTerminalContexts.length === 0 && @@ -632,7 +650,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) { @@ -649,7 +667,7 @@ export function useComposerSend(input: ComposerSendInput) { await submitCodexFeedback({ submission: { id: newMessageId(), - command: trimmed, + command: commandText, createdAt: new Date().toISOString(), }, clearDraft: () => { @@ -681,6 +699,7 @@ export function useComposerSend(input: ComposerSendInput) { return; } const composerHoldsOnlyText = + trayCitations.length === 0 && composerImages.length === 0 && composerFiles.length === 0 && sendableComposerTerminalContexts.length === 0 && @@ -688,7 +707,7 @@ export function useComposerSend(input: ComposerSendInput) { composerReviewComments.length === 0 && composerThreadContexts.length === 0 && composerEnvironmentContexts.length === 0; - if (freshSessionAvailable && composerHoldsOnlyText && isClearComposerCommand(trimmed)) { + if (freshSessionAvailable && composerHoldsOnlyText && isClearComposerCommand(commandText)) { armFreshSession(routeThreadKey); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); From a90ee566ee0b8e2d614ac1f02caa758287e459e0 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 20:51:33 +0000 Subject: [PATCH 12/13] fix(web): preserve tray comments when dismissed --- ...ditorTiptap.citationDraft.browser.test.tsx | 11 ++++- .../components/chat/AssistantCitationTray.tsx | 48 +++++++++++++++---- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index 73f59e902669e..1b5466fd5f857 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -179,6 +179,15 @@ it("opens a source comment for tray citations and preserves saved and cancelled 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 screen.rerender(); await expect .element(page.getByRole("button", { name: "Insert annotation 1 inline", exact: true })) @@ -188,7 +197,7 @@ it("opens a source comment for tray citations and preserves saved and cancelled 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 this comment")), + serializeAssistantCitation(withAssistantCitationComment(citation, "Keep the dismissed edit")), ); await page.getByRole("button", { name: "Cite to tray", exact: true }).click(); await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible(); diff --git a/apps/web/src/components/chat/AssistantCitationTray.tsx b/apps/web/src/components/chat/AssistantCitationTray.tsx index f497a75f360ce..844a524a19eeb 100644 --- a/apps/web/src/components/chat/AssistantCitationTray.tsx +++ b/apps/web/src/components/chat/AssistantCitationTray.tsx @@ -1,13 +1,14 @@ import type { AssistantCitation } from "@t3tools/contracts"; import { withAssistantCitationComment } from "@t3tools/shared/assistantCitations"; import { PencilIcon, QuoteIcon, TextCursorInputIcon, Trash2Icon, XIcon } from "lucide-react"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { AssistantCitationQuote } from "./AssistantCitationChip"; import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; import { Button } from "../ui/button"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { composerFloatingLayerProps } from "./composerEventScope"; +import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; export function AssistantCitationTray(props: Parameters[0]) { return props.citations.length ? : null; @@ -25,16 +26,43 @@ function CitationTrayContents({ const [voiceSetupOpen, setVoiceSetupOpen] = useState(false); const [open, setOpen] = useState(false); const [editing, setEditing] = useState(null); + const draftCommentRef = useRef(null); + const saveComment = (index: number, comment: string) => { + onChange( + citations.map((item, i) => + i === index ? withAssistantCitationComment(item, comment) : item, + ), + ); + draftCommentRef.current = null; + setEditing(null); + }; + const settleDraft = (reason: string) => { + const dismissal = resolveAssistantCitationCommentDismissal({ + reason, + draft: draftCommentRef.current, + savedComment: editing === null ? undefined : citations[editing]?.comment, + }); + if (dismissal.kind === "keep-open") return false; + if (dismissal.kind === "commit" && editing !== null) saveComment(editing, dismissal.comment); + draftCommentRef.current = null; + setEditing(null); + return true; + }; const remove = (index: number) => { onChange(citations.filter((_, i) => i !== index)); + draftCommentRef.current = null; setEditing(null); }; return (
{ + onOpenChange={(next, details) => { if (voiceSetupOpen) return; + if (!next && !settleDraft(details.reason)) { + details.cancel(); + return; + } setOpen(next); }} > @@ -65,16 +93,14 @@ function CitationTrayContents({ { + draftCommentRef.current = comment; + }} onSubmit={(comment) => { - onChange( - citations.map((item, i) => - i === index ? withAssistantCitationComment(item, comment) : item, - ), - ); - setEditing(null); + saveComment(index, comment); return true; }} - onCancel={() => setEditing(null)} + onCancel={() => settleDraft("escape-key")} moveLabel="Insert inline" moveAriaLabel={`Insert annotation ${index + 1} inline`} onMove={ @@ -95,7 +121,9 @@ function CitationTrayContents({ aria-label={`Edit annotation ${index + 1}`} variant="ghost" size="icon-xs" - onClick={() => setEditing(index)} + onClick={() => { + if (settleDraft("none")) setEditing(index); + }} /> } > From 9a7b62eecd6cdcba7c4fe84a74dd920360532db5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 20:59:02 +0000 Subject: [PATCH 13/13] fix(web): retain edited comments during tray actions --- ...ditorTiptap.citationDraft.browser.test.tsx | 11 +++++++- .../components/chat/AssistantCitationTray.tsx | 27 +++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx index 1b5466fd5f857..bbaaea841873e 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx @@ -188,6 +188,13 @@ it("opens a source comment for tray citations and preserves saved and cancelled 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 })) @@ -197,7 +204,9 @@ it("opens a source comment for tray citations and preserves saved and cancelled 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 dismissed edit")), + 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(); diff --git a/apps/web/src/components/chat/AssistantCitationTray.tsx b/apps/web/src/components/chat/AssistantCitationTray.tsx index 844a524a19eeb..6af82dd44d529 100644 --- a/apps/web/src/components/chat/AssistantCitationTray.tsx +++ b/apps/web/src/components/chat/AssistantCitationTray.tsx @@ -48,8 +48,24 @@ function CitationTrayContents({ setEditing(null); return true; }; - const remove = (index: number) => { - onChange(citations.filter((_, i) => i !== index)); + const remove = (index: number, beforeRemove?: () => boolean) => { + const dismissal = resolveAssistantCitationCommentDismissal({ + reason: editing === index ? "escape-key" : "none", + draft: draftCommentRef.current, + savedComment: editing === null ? undefined : citations[editing]?.comment, + }); + if (dismissal.kind === "keep-open" || (beforeRemove && !beforeRemove())) return; + onChange( + citations.flatMap((item, i) => + i === index + ? [] + : [ + i === editing && dismissal.kind === "commit" + ? withAssistantCitationComment(item, dismissal.comment) + : item, + ], + ), + ); draftCommentRef.current = null; setEditing(null); }; @@ -106,8 +122,9 @@ function CitationTrayContents({ onMove={ onInsertInline ? (comment) => { - if (onInsertInline(withAssistantCitationComment(citation, comment))) - remove(index); + remove(index, () => + onInsertInline(withAssistantCitationComment(citation, comment)), + ); } : undefined } @@ -140,7 +157,7 @@ function CitationTrayContents({ size="icon-xs" aria-label={`Insert annotation ${index + 1} inline`} onClick={() => { - if (onInsertInline(citation)) remove(index); + remove(index, () => onInsertInline(citation)); }} /> }