From 801d0e17028ee5d7470dc061bd39924803d94f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=92=89=E8=90=81?= Date: Fri, 31 Jul 2026 20:47:22 +0800 Subject: [PATCH] fix(web-shell): keep pasted text visible --- .../client/e2e/web-shell.smoke.spec.ts | 8 +- .../client/hooks/useComposerCore.dom.test.tsx | 20 +++ .../client/hooks/useComposerCore.test.ts | 106 ------------- .../web-shell/client/hooks/useComposerCore.ts | 144 +----------------- 4 files changed, 29 insertions(+), 249 deletions(-) diff --git a/packages/web-shell/client/e2e/web-shell.smoke.spec.ts b/packages/web-shell/client/e2e/web-shell.smoke.spec.ts index 342a5ed26bc..c564179642a 100644 --- a/packages/web-shell/client/e2e/web-shell.smoke.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.smoke.spec.ts @@ -69,23 +69,23 @@ test('submits a prompt and renders a streamed assistant response @smoke', async ); }); -test('pastes long plain text as a placeholder and expands it on submit @smoke', async ({ +test('pastes long plain text as editable composer content @smoke', async ({ page, }, testInfo) => { const scenario = createWebShellDaemonScenario(); const daemon = await installScenario(page, scenario, testInfo); const pasted = `${'original '.repeat(151)}end`; - const placeholder = `[Pasted Content ${pasted.length} chars]`; const edited = `${pasted} edited`; await gotoSession(page, scenario, daemon); await pasteComposerText(page, pasted); const editor = page.locator('[data-web-shell-composer-editor] .cm-content'); - await expect(editor).toHaveText(placeholder); + await expect(editor).toHaveText(pasted); + await expect(editor).not.toContainText('Pasted Content'); await page.keyboard.type(' edited'); - await expect(editor).toHaveText(`${placeholder} edited`); + await expect(editor).toHaveText(edited); await page.locator('[data-web-shell-composer-submit]').click(); await expect.poll(() => daemon.promptRequests().length).toBe(1); diff --git a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx index 3636340f92b..e93e2939b3a 100644 --- a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx +++ b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx @@ -743,6 +743,26 @@ describe('useComposerCore history and drafts', () => { }); }); +describe('useComposerCore paste', () => { + it('lets long plain text paste directly into the editor', async () => { + await mount(); + const event = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { + value: { + items: [{ type: 'text/plain', getAsFile: () => null }], + getData: () => 'line\n'.repeat(200), + }, + }); + + act(() => { + container!.querySelector('.cm-content')!.dispatchEvent(event); + }); + + expect(latest!.getText()).toBe('line\n'.repeat(200)); + expect(latest!.getText()).not.toContain('Pasted Content'); + }); +}); + describe('useComposerCore tags', () => { it('keeps the composer API stable across tag updates', async () => { await mount(); diff --git a/packages/web-shell/client/hooks/useComposerCore.test.ts b/packages/web-shell/client/hooks/useComposerCore.test.ts index b747277d61e..86bef3316ba 100644 --- a/packages/web-shell/client/hooks/useComposerCore.test.ts +++ b/packages/web-shell/client/hooks/useComposerCore.test.ts @@ -2,15 +2,10 @@ import { describe, expect, it } from 'vitest'; import { buildComposerPrompt, buildComposerPromptWithInlineTagPlacements, - createLargePastePlaceholder, - expandLargePastePlaceholders, getComposerTagDisplay, getComposerTagLabel, getComposerTagValue, getFollowupCompletion, - isLargePaste, - normalizePastedText, - prunePendingPastes, replaceInlineTagPlacements, serializeComposerTag, } from './useComposerCore'; @@ -122,104 +117,3 @@ describe('composer tag serialization', () => { ).toBe('a and '); }); }); - -describe('large paste helpers', () => { - it('normalizes CRLF and CR line endings to LF', () => { - expect(normalizePastedText('a\r\nb\rc\n')).toBe('a\nb\nc\n'); - }); - - it('treats pastes over 1000 chars or 10 lines as large', () => { - expect(isLargePaste('a'.repeat(1000))).toBe(false); - expect(isLargePaste('a'.repeat(1001))).toBe(true); - // 10 lines => 9 newlines => split length 10, not large. - expect(isLargePaste('a\n'.repeat(9) + 'a')).toBe(false); - // 11 lines => split length 11, large. - expect(isLargePaste('a\n'.repeat(10) + 'a')).toBe(true); - }); - - it('counts code points, not UTF-16 units, for the char threshold', () => { - // 1001 emoji are 1001 code points but 2002 UTF-16 units. - expect(isLargePaste('😀'.repeat(1001))).toBe(true); - expect(isLargePaste('😀'.repeat(1000))).toBe(false); - }); - - it('creates incrementing placeholders keyed by code-point count', () => { - const pending = new Map(); - const first = createLargePastePlaceholder(pending, 1, 'hello'); - expect(first.placeholderText).toBe('[Pasted Content 5 chars]'); - expect(first.nextPasteId).toBe(2); - const second = createLargePastePlaceholder( - pending, - first.nextPasteId, - 'hi', - ); - expect(second.placeholderText).toBe('[Pasted Content 2 chars] #2'); - expect(second.nextPasteId).toBe(3); - expect(pending.get('[Pasted Content 5 chars]')).toBe('hello'); - expect(pending.get('[Pasted Content 2 chars] #2')).toBe('hi'); - }); - - it('prunes placeholders absent from the doc and resets the id when empty', () => { - const pending = new Map([ - ['[Pasted Content 5 chars]', 'hello'], - ['[Pasted Content 2 chars] #2', 'hi'], - ]); - // Only the first placeholder remains in the doc. - expect( - prunePendingPastes(pending, 'x [Pasted Content 5 chars] y'), - ).toBeNull(); - expect(pending.size).toBe(1); - expect(pending.has('[Pasted Content 2 chars] #2')).toBe(false); - // Removing the last placeholder resets the next id to 1. - expect(prunePendingPastes(pending, 'no placeholders here')).toBe(1); - expect(pending.size).toBe(0); - }); - - it('prunes by exact placeholder match, not substring', () => { - const pending = new Map([ - ['[Pasted Content 5 chars]', 'aaaaa'], - ['[Pasted Content 5 chars] #2', 'bbbbb'], - ]); - // Only the longer placeholder is in the doc; the shorter one must be - // pruned even though it is a substring of the longer one. - expect( - prunePendingPastes(pending, '[Pasted Content 5 chars] #2'), - ).toBeNull(); - expect(pending.size).toBe(1); - expect(pending.has('[Pasted Content 5 chars]')).toBe(false); - expect(pending.has('[Pasted Content 5 chars] #2')).toBe(true); - }); - - it('expands placeholders back to their pasted content', () => { - const pending = new Map([ - ['[Pasted Content 5 chars]', 'hello'], - ]); - expect( - expandLargePastePlaceholders(pending, 'a [Pasted Content 5 chars] b'), - ).toBe('a hello b'); - expect(expandLargePastePlaceholders(pending, 'no placeholder')).toBe( - 'no placeholder', - ); - expect( - expandLargePastePlaceholders(new Map(), '[Pasted Content 5 chars]'), - ).toBe('[Pasted Content 5 chars]'); - }); - - it('replaces a placeholder that is a substring of another', () => { - // "[Pasted Content 5 chars]" is a prefix of "[Pasted Content 5 chars] #2"; - // the longer placeholder must win regardless of map insertion order. - const pending = new Map([ - ['[Pasted Content 5 chars]', 'aaaaa'], - ['[Pasted Content 5 chars] #2', 'bbbbb'], - ]); - expect( - expandLargePastePlaceholders(pending, '[Pasted Content 5 chars] #2'), - ).toBe('bbbbb'); - expect( - expandLargePastePlaceholders( - pending, - '[Pasted Content 5 chars] and [Pasted Content 5 chars] #2', - ), - ).toBe('aaaaa and bbbbb'); - }); -}); diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index dd1f9a9a6d1..6890d8cb849 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -98,10 +98,6 @@ import type { } from '../customization'; import { useWebShellPortalRoot } from '../portalRoot'; -// ---- Large paste handling (shared utilities) ---- - -const LARGE_PASTE_CHAR_THRESHOLD = 1000; -const LARGE_PASTE_LINE_THRESHOLD = 10; const TOOLTIP_STYLE_ID = 'web-shell-tooltip-styles'; const TOOLTIP_STYLES = ` [data-web-shell-tooltip-portal] { @@ -459,72 +455,6 @@ function renderCompletionHoverInfo(completion: Completion): HTMLElement | null { return anchor; } -export function normalizePastedText(text: string): string { - return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); -} - -export function isLargePaste(text: string): boolean { - return ( - [...text].length > LARGE_PASTE_CHAR_THRESHOLD || - text.split('\n').length > LARGE_PASTE_LINE_THRESHOLD - ); -} - -function escapeRegExp(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -export interface LargePastePlaceholderResult { - placeholderText: string; - nextPasteId: number; -} - -export function createLargePastePlaceholder( - pendingPastes: Map, - nextPasteId: number, - pasted: string, -): LargePastePlaceholderResult { - const charCount = [...pasted].length; - const base = `[Pasted Content ${charCount} chars]`; - const placeholderText = nextPasteId === 1 ? base : `${base} #${nextPasteId}`; - pendingPastes.set(placeholderText, pasted); - return { placeholderText, nextPasteId: nextPasteId + 1 }; -} - -export function prunePendingPastes( - pendingPastes: Map, - docText: string, -): number | null { - if (pendingPastes.size === 0) return null; - const placeholders = [...pendingPastes.keys()].sort( - (a, b) => b.length - a.length, - ); - const pattern = new RegExp(placeholders.map(escapeRegExp).join('|'), 'g'); - const found = new Set(); - for (const match of docText.matchAll(pattern)) { - found.add(match[0]); - } - for (const key of pendingPastes.keys()) { - if (!found.has(key)) pendingPastes.delete(key); - } - return pendingPastes.size === 0 ? 1 : null; -} - -export function expandLargePastePlaceholders( - pendingPastes: Map, - text: string, -): string { - if (pendingPastes.size === 0) return text; - const placeholders = [...pendingPastes.keys()].sort( - (a, b) => b.length - a.length, - ); - const pattern = new RegExp(placeholders.map(escapeRegExp).join('|'), 'g'); - return text.replace( - pattern, - (placeholderText) => pendingPastes.get(placeholderText) ?? placeholderText, - ); -} - // ---- Tag serialization (shared) ---- export function serializeComposerTag(tag: WebShellComposerTag): string { @@ -1646,8 +1576,6 @@ export function useComposerCore( const searchDraftRef = useRef(''); const [pastedImages, setPastedImages] = useState([]); const pastedImagesRef = useRef([]); - const pendingPastesRef = useRef>(new Map()); - const nextPasteIdRef = useRef(1); const [composerTags, setComposerTags] = useState([]); const composerTagsRef = useRef([]); composerTagsRef.current = composerTags; @@ -1794,10 +1722,7 @@ export function useComposerCore( } const currentView = viewRef.current; const text = currentView - ? expandLargePastePlaceholders( - pendingPastesRef.current, - currentView.state.doc.toString(), - ) + ? currentView.state.doc.toString() : mobileTextRef.current; saveComposerDraft(draftIdentityRef.current.storageKey, text); return true; @@ -2253,10 +2178,7 @@ export function useComposerCore( tagsOverride === undefined ? replaceInlineTagPlacements(rawText, normalizedInlineTags) : rawText; - const text = expandLargePastePlaceholders( - pendingPastesRef.current, - textWithInlineTags, - ); + const text = textWithInlineTags; const prompt = buildComposerPrompt(text, tags); const images = pastedImagesRef.current; const isShellMode = shellModeRef.current; @@ -2266,10 +2188,7 @@ export function useComposerCore( [...tags, ...normalizedInlineTags.map((placement) => placement.tag)], ); const submissionIdentity = { ...composerIdentityRef.current }; - const draftTextAtSubmit = expandLargePastePlaceholders( - pendingPastesRef.current, - editorText, - ); + const draftTextAtSubmit = editorText; const editorDocAtSubmit = view?.state.doc; const mobileTextVersionAtSubmit = mobileTextVersionRef.current; const composerTagsAtSubmit = composerTagsRef.current; @@ -2330,8 +2249,6 @@ export function useComposerCore( onAcceptFollowupRef.current?.('enter', { skipOnAccept: true }); } onDismissFollowupRef.current?.(); - pendingPastesRef.current.clear(); - nextPasteIdRef.current = 1; clearPromptHistoryDraftTags(); setComposerTags([]); setPastedImages([]); @@ -2771,21 +2688,6 @@ export function useComposerCore( const userEdited = update.transactions.some( (tr) => tr.isUserEvent('input') || tr.isUserEvent('delete'), ); - // Prune only on input events (not delete): deleting a placeholder - // removes the mapping, but Ctrl+Z restores the text without restoring - // the React ref, so the mapping would be permanently lost. - const userInput = update.transactions.some((tr) => - tr.isUserEvent('input'), - ); - if (update.docChanged && userInput && pendingPastesRef.current.size > 0) { - const nextPasteId = prunePendingPastes( - pendingPastesRef.current, - getDocText(update.state), - ); - if (nextPasteId !== null) { - nextPasteIdRef.current = nextPasteId; - } - } if (userEdited) { historyBrowseActiveRef.current = false; } @@ -2992,36 +2894,7 @@ export function useComposerCore( event.preventDefault(); return true; } - const pasted = normalizePastedText( - event.clipboardData?.getData('text/plain') ?? '', - ); - if (!pasted || !isLargePaste(pasted)) return false; - - event.preventDefault(); - if ( - view.state.doc.toString() === '' && - followupStateRef.current?.isVisible - ) { - onDismissFollowupRef.current?.(); - } - const { placeholderText: pt, nextPasteId } = - createLargePastePlaceholder( - pendingPastesRef.current, - nextPasteIdRef.current, - pasted, - ); - nextPasteIdRef.current = nextPasteId; - const selection = view.state.selection.main; - view.dispatch({ - changes: { - from: selection.from, - to: selection.to, - insert: pt, - }, - selection: { anchor: selection.from + pt.length }, - scrollIntoView: true, - }); - return true; + return false; }, }), EditorView.theme(editorTheme), @@ -3092,10 +2965,7 @@ export function useComposerCore( setSearchActiveIndex(0); const currentText = view - ? expandLargePastePlaceholders( - pendingPastesRef.current, - view.state.doc.toString(), - ) + ? view.state.doc.toString() : mobileTextRef.current; if (draftStorageChanged && !wasBrowsingHistory && !wasSearchingHistory) { saveComposerDraft(previousDraftIdentity.storageKey, currentText); @@ -3126,8 +2996,6 @@ export function useComposerCore( saveComposerDraft(composerDraftStorageKey, currentText); } - pendingPastesRef.current.clear(); - nextPasteIdRef.current = 1; setComposerTags([]); setPastedImages([]); if (view) { @@ -3550,8 +3418,6 @@ export function useComposerCore( } if (clearTextOpt) { setPastedImages([]); - pendingPastesRef.current.clear(); - nextPasteIdRef.current = 1; } if (clearTags) { setComposerTags([]);