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 0c4e3cddf5b..6e3618029f6 100644 --- a/packages/web-shell/client/e2e/web-shell.smoke.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.smoke.spec.ts @@ -69,6 +69,32 @@ test('submits a prompt and renders a streamed assistant response @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 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(pasted); + await expect(editor).not.toContainText('Pasted Content'); + + await page.keyboard.type(' edited'); + await expect(editor).toHaveText(edited); + await page.locator('[data-web-shell-composer-submit]').click(); + + await expect.poll(() => daemon.promptRequests().length).toBe(1); + expectPromptBodyToContainText( + requestBodyRecord(firstRequest(daemon.promptRequests())), + edited, + ); +}); + test('keeps later SSE connections alive when an earlier one is cancelled @smoke', async ({ page, }, testInfo) => { @@ -628,6 +654,20 @@ async function replaceComposerText(page: Page, text: string): Promise { await page.keyboard.insertText(text); } +async function pasteComposerText(page: Page, text: string): Promise { + const origin = new URL(page.url()).origin; + await page + .context() + .grantPermissions(['clipboard-read', 'clipboard-write'], { origin }); + await page.evaluate((clipboardText) => { + return navigator.clipboard.writeText(clipboardText); + }, text); + await page.locator('[data-web-shell-composer-editor] .cm-content').click(); + await page.keyboard.press( + process.platform === 'darwin' ? 'Meta+V' : 'Control+V', + ); +} + async function pasteComposerImages(page: Page, count: number): Promise { const editor = page.locator('[data-web-shell-composer-editor] .cm-content'); await editor.evaluate((element, imageCount) => { diff --git a/packages/web-shell/client/hooks/useComposerCore.test.ts b/packages/web-shell/client/hooks/useComposerCore.test.ts index 6b8f0c88ce3..86bef3316ba 100644 --- a/packages/web-shell/client/hooks/useComposerCore.test.ts +++ b/packages/web-shell/client/hooks/useComposerCore.test.ts @@ -2,39 +2,14 @@ import { describe, expect, it } from 'vitest'; import { buildComposerPrompt, buildComposerPromptWithInlineTagPlacements, - createLargePastePlaceholder, - expandLargePastePlaceholders, getComposerTagDisplay, getComposerTagLabel, getComposerTagValue, getFollowupCompletion, - isLargePaste, - normalizePastedText, - prunePendingPastes, replaceInlineTagPlacements, serializeComposerTag, } from './useComposerCore'; -describe('composer paste helpers', () => { - it('normalizes CRLF and CR newlines', () => { - expect(normalizePastedText('a\r\nb\rc')).toBe('a\nb\nc'); - }); - - it('treats short text as a normal paste', () => { - expect(isLargePaste('hello\nworld')).toBe(false); - }); - - it('treats long text as a large paste', () => { - expect(isLargePaste('x'.repeat(1001))).toBe(true); - }); - - it('treats many lines as a large paste', () => { - expect(isLargePaste(Array.from({ length: 11 }, () => 'x').join('\n'))).toBe( - true, - ); - }); -}); - describe('follow-up completion helpers', () => { it('uses the full suggestion when the editor is empty', () => { expect(getFollowupCompletion('', 'show me tests')).toBe('show me tests'); @@ -51,56 +26,6 @@ describe('follow-up completion helpers', () => { }); }); -describe('large paste placeholders', () => { - it('creates stable placeholders and increments duplicate labels', () => { - const pending = new Map(); - - const first = createLargePastePlaceholder(pending, 1, 'abc'); - const second = createLargePastePlaceholder( - pending, - first.nextPasteId, - 'def', - ); - - expect(first).toEqual({ - placeholderText: '[Pasted Content 3 chars]', - nextPasteId: 2, - }); - expect(second).toEqual({ - placeholderText: '[Pasted Content 3 chars] #2', - nextPasteId: 3, - }); - expect(pending.get(first.placeholderText)).toBe('abc'); - expect(pending.get(second.placeholderText)).toBe('def'); - }); - - it('expands longer placeholder names before shorter prefixes', () => { - const pending = new Map([ - ['[Pasted Content 3 chars]', 'first'], - ['[Pasted Content 3 chars] #2', 'second'], - ]); - - expect( - expandLargePastePlaceholders( - pending, - '[Pasted Content 3 chars] #2\n[Pasted Content 3 chars]', - ), - ).toBe('second\nfirst'); - }); - - it('prunes placeholders missing from the current editor text', () => { - const pending = new Map([ - ['[Pasted Content 3 chars]', 'first'], - ['[Pasted Content 4 chars]', 'next'], - ]); - - expect(prunePendingPastes(pending, '[Pasted Content 4 chars]')).toBeNull(); - expect([...pending.keys()]).toEqual(['[Pasted Content 4 chars]']); - expect(prunePendingPastes(pending, '')).toBe(1); - expect(pending.size).toBe(0); - }); -}); - describe('composer tag serialization', () => { it('prefers value, then label, then id', () => { expect( @@ -191,26 +116,4 @@ describe('composer tag serialization', () => { ]), ).toBe('a and '); }); - - it('replaces inline tags before expanding large paste placeholders', () => { - const pending = new Map(); - const paste = createLargePastePlaceholder( - pending, - 1, - 'expanded pasted content that is longer than the placeholder', - ); - const text = `${paste.placeholderText} explain @orders`; - const tagStart = text.indexOf('@orders'); - const withInlineTags = replaceInlineTagPlacements(text, [ - { - start: tagStart, - end: tagStart + '@orders'.length, - tag: { id: 'table', value: 'orders', serialized: '' }, - }, - ]); - - expect(expandLargePastePlaceholders(pending, withInlineTags)).toBe( - 'expanded pasted content that is longer than the placeholder explain
', - ); - }); }); diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index 3d6d865d947..a1356672086 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -93,10 +93,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] { @@ -454,65 +450,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 { - for (const placeholderText of pendingPastes.keys()) { - if (!docText.includes(placeholderText)) { - pendingPastes.delete(placeholderText); - } - } - 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 { @@ -1491,8 +1428,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; @@ -1935,8 +1870,7 @@ export function useComposerCore( const hasImage = collectClipboardImages(items, (image) => setPastedImages((prev) => [...prev, image]), ); - // Plain text falls through to the native paste. Large-paste - // placeholders are a CodeMirror-only affordance. + // Plain text falls through to the native paste. if (hasImage) { event.preventDefault(); } @@ -1985,14 +1919,10 @@ export function useComposerCore( : []; const tags = tagsOverride ?? composerTagsRef.current; if (!rawText && tags.length === 0 && inlineTags.length === 0) return true; - const textWithInlineTags = + const text = tagsOverride === undefined ? replaceInlineTagPlacements(rawText, normalizedInlineTags) : rawText; - const text = expandLargePastePlaceholders( - pendingPastesRef.current, - textWithInlineTags, - ); const prompt = buildComposerPrompt(text, tags); const images = pastedImagesRef.current; const isShellMode = shellModeRef.current; @@ -2010,8 +1940,6 @@ export function useComposerCore( onAcceptFollowupRef.current?.('enter', { skipOnAccept: true }); } onDismissFollowupRef.current?.(); - pendingPastesRef.current.clear(); - nextPasteIdRef.current = 1; if (isShellMode) { shellHistoryActionsRef.current.push(text); shellHistoryActionsRef.current.reset(); @@ -2439,15 +2367,6 @@ export function useComposerCore( ]); const composerUpdateListener = EditorView.updateListener.of((update) => { - if (update.docChanged && pendingPastesRef.current.size > 0) { - const nextPasteId = prunePendingPastes( - pendingPastesRef.current, - update.state.doc.toString(), - ); - if (nextPasteId !== null) { - nextPasteIdRef.current = nextPasteId; - } - } // A genuine edit (typing/deleting/pasting) ends history-browse mode, so // arrows go back to driving any open menu. Programmatic history recall // dispatches carry no user event, so they do not clear the flag. @@ -2637,36 +2556,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), @@ -3104,8 +2994,6 @@ export function useComposerCore( } if (clearTextOpt) { setPastedImages([]); - pendingPastesRef.current.clear(); - nextPasteIdRef.current = 1; } if (clearTags) { setComposerTags([]);