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 <makeittech@users.noreply.github.com>
This commit is contained in:
co-authored by
Serhii Dziupin
parent
29420a7e7e
commit
d26ff65c58
@@ -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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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<ChatInputProps> = ({ 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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user