Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/web-shell/client/e2e/web-shell.smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -628,6 +654,20 @@ async function replaceComposerText(page: Page, text: string): Promise<void> {
await page.keyboard.insertText(text);
}

async function pasteComposerText(page: Page, text: string): Promise<void> {
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<void> {
const editor = page.locator('[data-web-shell-composer-editor] .cm-content');
await editor.evaluate((element, imageCount) => {
Expand Down
97 changes: 0 additions & 97 deletions packages/web-shell/client/hooks/useComposerCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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<string, string>();

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<string, string>([
['[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<string, string>([
['[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(
Expand Down Expand Up @@ -191,26 +116,4 @@ describe('composer tag serialization', () => {
]),
).toBe('a <one /> and <two />');
});

it('replaces inline tags before expanding large paste placeholders', () => {
const pending = new Map<string, string>();
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: '<table />' },
},
]);

expect(expandLargePastePlaceholders(pending, withInlineTags)).toBe(
'expanded pasted content that is longer than the placeholder explain <table />',
);
});
});
118 changes: 3 additions & 115 deletions packages/web-shell/client/hooks/useComposerCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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<string, string>,
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<string, string>,
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<string, string>,
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 {
Expand Down Expand Up @@ -1491,8 +1428,6 @@ export function useComposerCore(
const searchDraftRef = useRef('');
const [pastedImages, setPastedImages] = useState<PromptImage[]>([]);
const pastedImagesRef = useRef<PromptImage[]>([]);
const pendingPastesRef = useRef<Map<string, string>>(new Map());
const nextPasteIdRef = useRef(1);
const [composerTags, setComposerTags] = useState<WebShellComposerTag[]>([]);
const composerTagsRef = useRef<WebShellComposerTag[]>([]);
composerTagsRef.current = composerTags;
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -3104,8 +2994,6 @@ export function useComposerCore(
}
if (clearTextOpt) {
setPastedImages([]);
pendingPastesRef.current.clear();
nextPasteIdRef.current = 1;
}
if (clearTags) {
setComposerTags([]);
Expand Down
Loading