From d26ff65c5849229001d740cf031dcc82252f3292 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 13:18:56 +0000 Subject: [PATCH] fix(ui): harden large-paste toast for mobile and stale state Scope toast width overrides to sm+ so Sonner keeps full-width mobile toasts, resolve ask actions from live composer/attachment state, and extract offer-id invalidation into a unit-tested helper. Co-authored-by: Serhii Dziupin --- packages/ui/src/components/chat/ChatInput.tsx | 43 +++++++++----- .../components/chat/composer/DOCUMENTATION.md | 10 ++-- .../__tests__/largeTextPasteOffer.test.ts | 56 +++++++++++++++++++ .../chat/composer/largeTextPasteOffer.ts | 33 +++++++++++ 4 files changed, 124 insertions(+), 18 deletions(-) create mode 100644 packages/ui/src/components/chat/composer/__tests__/largeTextPasteOffer.test.ts create mode 100644 packages/ui/src/components/chat/composer/largeTextPasteOffer.ts diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index b5dee769..4f4cde5c 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -88,6 +88,11 @@ import { createPastedContextFile, isLargePlainTextPaste, } from './composer/largeTextPaste'; +import { + LARGE_TEXT_PASTE_TOAST_CLASSNAME, + beginLargeTextPasteOffer, + resolveLargeTextPasteOffer, +} from './composer/largeTextPasteOffer'; import type { LargeTextPasteBehavior } from '@/stores/useUIStore'; import type { FileMentionAutocompleteInputSource } from './fileMentionAutocompleteState'; import { @@ -1591,21 +1596,24 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (!editor) { // No mounted editor (collapsed mobile pill): append to the state // the editor will be seeded from. - const nextValue = message + text; + const nextValue = messageRef.current + text; setMessage(nextValue); updateAutocompleteState(nextValue, nextValue.length, inputSource, text); return; } const { start, end } = editor.getSelection(); - const nextValue = `${message.substring(0, start)}${text}${message.substring(end)}`; + // Read the live document — delayed toast actions must not use a + // paste-time React `message` closure. + const currentMessage = editor.getValue(); + const nextValue = `${currentMessage.substring(0, start)}${text}${currentMessage.substring(end)}`; const cursorPosition = start + text.length; // One dispatch places both the text and the caret, so there is no // frame where the caret sits at a stale offset. editor.insertText(text); updateAutocompleteState(nextValue, cursorPosition, inputSource, text); - }, [message, updateAutocompleteState]); + }, [updateAutocompleteState]); const clearDropTextSuppression = React.useCallback(() => { suppressNextFileDropTextInsertRef.current = false; @@ -1762,18 +1770,22 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo }; const attachAsFile = async () => { + // Read live attachment + composer state at action time — the ask + // toast can outlive the paste while the user types or attaches more. + const liveAttachedFiles = useInputStore.getState().attachedFiles; const filename = nextPastedContextFilename([ - ...attachedFiles.map((file) => file.filename), + ...liveAttachedFiles.map((file) => file.filename), ...pendingPastedAttachmentFilenamesRef.current, ]); const citationText = buildAttachmentCitationText([filename]); - const textarea = composerRef.current; - const selectionStart = textarea?.getSelection().start ?? message.length; - const selectionEnd = textarea?.getSelection().end ?? message.length; + const editor = composerRef.current; + const currentMessage = editor?.getValue() ?? messageRef.current; + const selectionStart = editor?.getSelection().start ?? currentMessage.length; + const selectionEnd = editor?.getSelection().end ?? currentMessage.length; const insertionText = withInlineInsertionBoundaries( citationText, - message.slice(0, selectionStart), - message.slice(selectionEnd), + currentMessage.slice(0, selectionStart), + currentMessage.slice(selectionEnd), ); insertTextAtSelection( @@ -1802,7 +1814,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return; } - const offerId = largeTextPasteOfferIdRef.current + 1; + const offerId = beginLargeTextPasteOffer(largeTextPasteOfferIdRef.current); largeTextPasteOfferIdRef.current = offerId; if (largeTextPasteToastIdRef.current !== null) { @@ -1813,11 +1825,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo } const resolveLargePaste = (action: 'attach' | 'inline') => { - if (offerId !== largeTextPasteOfferIdRef.current) { + const resolution = resolveLargeTextPasteOffer( + largeTextPasteOfferIdRef.current, + offerId, + ); + largeTextPasteOfferIdRef.current = resolution.nextOfferId; + if (!resolution.accepted) { return; } - // Invalidate this offer so a later onDismiss cannot double-apply. - largeTextPasteOfferIdRef.current += 1; largeTextPasteToastIdRef.current = null; if (action === 'attach') { void attachAsFile(); @@ -1830,7 +1845,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo t('chat.chatInput.toast.largeTextPaste.title'), { duration: Infinity, - className: '!min-w-[22rem] !w-auto [&_[data-icon]]:!hidden', + className: LARGE_TEXT_PASTE_TOAST_CLASSNAME, action: { label: t('chat.chatInput.toast.largeTextPaste.attach'), onClick: () => resolveLargePaste('attach'), diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index db521edb..759067ee 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -19,6 +19,7 @@ belongs to one of them. | `ui/` | Presentation | | `text.ts` | How inserted text meets the text already there | | `largeTextPaste.ts` | Detect large plain-text pastes and build virtual `.txt` files | +| `largeTextPasteOffer.ts` | Ask-toast offer id begin/resolve (supersede + double-apply guards) | `ChatInput.handlePaste` owns paste orchestration: URL-over-selection markdown links, clipboard images (attach + citation), and large plain-text pastes. @@ -26,8 +27,9 @@ Large pastes (about 2,000 characters or 25 lines) follow the composer setting `largeTextPasteBehavior` (`ask` / `attach` / `inline`). Attaching creates an in-memory `text/plain` file named `pasted-context-N.txt`, inserts a bracket citation, and sends it through the same attachment pipeline as a manually -picked `.txt` file. Short text, images, and URL wraps keep their existing -paths. +picked `.txt` file. Ask-toast actions read live composer/attachment state so +typing or other attaches between paste and choice stay consistent. Short text, +images, and URL wraps keep their existing paths. ## The prompt language @@ -129,8 +131,8 @@ hardware. The package has no DOM test environment, so coverage stops at the state and logic layers: the language, the submit assembly, path and drop handling, text -splicing, large-paste detection, message history, and the CodeMirror language -extension at the `EditorState` level. +splicing, large-paste detection, paste-offer invalidation, message history, and +the CodeMirror language extension at the `EditorState` level. Rendering, focus, keyboard behavior, IME and WKWebView are **not covered by tests** and are verified by hand. Do not report a change to them as validated diff --git a/packages/ui/src/components/chat/composer/__tests__/largeTextPasteOffer.test.ts b/packages/ui/src/components/chat/composer/__tests__/largeTextPasteOffer.test.ts new file mode 100644 index 00000000..2b453a31 --- /dev/null +++ b/packages/ui/src/components/chat/composer/__tests__/largeTextPasteOffer.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test'; + +import { + LARGE_TEXT_PASTE_TOAST_CLASSNAME, + beginLargeTextPasteOffer, + resolveLargeTextPasteOffer, +} from '../largeTextPasteOffer'; + +describe('large text paste offer state', () => { + test('begin allocates the next offer id', () => { + expect(beginLargeTextPasteOffer(0)).toBe(1); + expect(beginLargeTextPasteOffer(3)).toBe(4); + }); + + test('resolve accepts a matching active offer and invalidates it', () => { + expect(resolveLargeTextPasteOffer(2, 2)).toEqual({ + accepted: true, + nextOfferId: 3, + }); + }); + + test('resolve rejects a superseded offer without advancing', () => { + expect(resolveLargeTextPasteOffer(5, 4)).toEqual({ + accepted: false, + nextOfferId: 5, + }); + }); + + test('second resolve after accept is rejected (double-apply guard)', () => { + const first = resolveLargeTextPasteOffer(1, 1); + expect(first.accepted).toBe(true); + expect(resolveLargeTextPasteOffer(first.nextOfferId, 1)).toEqual({ + accepted: false, + nextOfferId: first.nextOfferId, + }); + }); + + test('begin then resolve of the old id is rejected', () => { + const previous = 2; + const next = beginLargeTextPasteOffer(previous); + expect(resolveLargeTextPasteOffer(next, previous)).toEqual({ + accepted: false, + nextOfferId: next, + }); + expect(resolveLargeTextPasteOffer(next, next).accepted).toBe(true); + }); + + test('toast class widens only from the sm breakpoint', () => { + const classes = LARGE_TEXT_PASTE_TOAST_CLASSNAME.split(/\s+/); + expect(classes).toContain('sm:!min-w-[22rem]'); + expect(classes).toContain('sm:!w-auto'); + expect(classes).toContain('[&_[data-icon]]:!hidden'); + expect(classes.includes('!min-w-[22rem]')).toBe(false); + expect(classes.includes('!w-auto')).toBe(false); + }); +}); diff --git a/packages/ui/src/components/chat/composer/largeTextPasteOffer.ts b/packages/ui/src/components/chat/composer/largeTextPasteOffer.ts new file mode 100644 index 00000000..976a9d41 --- /dev/null +++ b/packages/ui/src/components/chat/composer/largeTextPasteOffer.ts @@ -0,0 +1,33 @@ +/** + * Offer-id state for the large-text paste ask toast. + * + * The toast can outlive the paste event (duration Infinity), and a second + * large paste can supersede an unanswered offer. These helpers keep that + * invalidation pure so ChatInput only wires toast UI to attach/inline actions. + */ + +export type LargeTextPasteOfferAction = 'attach' | 'inline'; + +/** Allocate a new offer id, superseding any unanswered previous offer. */ +export const beginLargeTextPasteOffer = (activeOfferId: number): number => ( + activeOfferId + 1 +); + +/** + * Attempt to resolve an offer. Returns whether this call won the race, and the + * next active id. A superseded or already-resolved offer is rejected so + * dismiss/action cannot double-apply. + */ +export const resolveLargeTextPasteOffer = ( + activeOfferId: number, + offerId: number, +): { accepted: boolean; nextOfferId: number } => { + if (offerId !== activeOfferId) { + return { accepted: false, nextOfferId: activeOfferId }; + } + return { accepted: true, nextOfferId: activeOfferId + 1 }; +}; + +/** Toast chrome: widen on desktop only; leave mobile full-width to Sonner. */ +export const LARGE_TEXT_PASTE_TOAST_CLASSNAME = + '[&_[data-icon]]:!hidden sm:!min-w-[22rem] sm:!w-auto';