Skip to content

Commit 959cd5f

Browse files
author
IM.codes
committed
fix: upload oversized pasted chat text as attachments
1 parent 1de1f19 commit 959cd5f

9 files changed

Lines changed: 167 additions & 18 deletions

File tree

web/src/components/SessionControls.tsx

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ interface Props {
118118

119119
type MenuAction = 'restart' | 'new' | 'stop';
120120
type ModelChoice = 'opus[1M]' | 'sonnet' | 'haiku';
121+
122+
const INLINE_PASTE_TEXT_CHAR_LIMIT = 12000;
123+
124+
function buildPastedTextFileName(now = new Date()): string {
125+
const compact = now.toISOString().replace(/[:.]/g, '-');
126+
return `pasted-text-${compact}.txt`;
127+
}
121128
type CodexModelChoice = 'gpt-5.4' | 'gpt-5.4-mini' | 'gpt-5.2';
122129
type QwenModelChoice = string;
123130
type P2pMode = string; // 'solo' | single modes | combo pipelines like 'brainstorm>discuss>plan' | typeof P2P_CONFIG_MODE
@@ -1743,30 +1750,17 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on
17431750
if (document.body.scrollTop !== 0) document.body.scrollTop = 0;
17441751
};
17451752

1746-
// Paste: upload files from clipboard, or insert plain text
1747-
const handlePaste = (e: Event) => {
1748-
const ce = e as ClipboardEvent;
1749-
const files = ce.clipboardData?.files;
1750-
if (files && files.length > 0) {
1751-
e.preventDefault();
1752-
void handleFileUpload(files);
1753-
return;
1754-
}
1755-
e.preventDefault();
1756-
const text = ce.clipboardData?.getData('text/plain') ?? '';
1757-
document.execCommand('insertText', false, text);
1758-
setHasText(!!(divRef.current?.textContent?.trim()));
1759-
};
1760-
1761-
const handleFileUpload = useCallback(async (files: FileList | null) => {
1762-
if (!files || files.length === 0 || !serverId) return;
1753+
const uploadAttachmentFiles = useCallback(async (files: readonly File[]): Promise<boolean> => {
1754+
if (files.length === 0 || !serverId) return false;
17631755
setUploading(true);
17641756
setUploadProgress(0);
17651757
setUploadError(null);
1766-
for (const file of Array.from(files)) {
1758+
let uploadedAny = false;
1759+
for (const file of files) {
17671760
try {
17681761
const result = await uploadFile(serverId, file, (pct) => setUploadProgress(pct));
17691762
if (result.attachment?.daemonPath) {
1763+
uploadedAny = true;
17701764
setAttachments((prev) => [...prev, { path: result.attachment!.daemonPath, name: file.name }]);
17711765
}
17721766
} catch (err) {
@@ -1783,8 +1777,46 @@ export function SessionControls({ ws, activeSession, inputRef, onAfterAction, on
17831777
}
17841778
}
17851779
setUploading(false);
1780+
return uploadedAny;
17861781
}, [serverId, t]);
17871782

1783+
const handleFileUpload = useCallback(async (files: FileList | null) => {
1784+
if (!files || files.length === 0) return;
1785+
await uploadAttachmentFiles(Array.from(files));
1786+
}, [uploadAttachmentFiles]);
1787+
1788+
// Paste: upload files from clipboard, or insert plain text
1789+
const handlePaste = (e: Event) => {
1790+
const ce = e as ClipboardEvent;
1791+
const files = ce.clipboardData?.files;
1792+
if (files && files.length > 0) {
1793+
e.preventDefault();
1794+
void handleFileUpload(files);
1795+
return;
1796+
}
1797+
e.preventDefault();
1798+
const text = ce.clipboardData?.getData('text/plain') ?? '';
1799+
if (!text) return;
1800+
if (text.length > INLINE_PASTE_TEXT_CHAR_LIMIT) {
1801+
if (!serverId) {
1802+
showSendWarning(t('upload.long_text_requires_attachment'));
1803+
return;
1804+
}
1805+
const fileName = buildPastedTextFileName();
1806+
const textFile = new File([text], fileName, { type: 'text/plain' });
1807+
void (async () => {
1808+
const uploaded = await uploadAttachmentFiles([textFile]);
1809+
if (uploaded) {
1810+
showSendWarning(t('upload.long_text_attached', { name: fileName }));
1811+
divRef.current?.focus();
1812+
}
1813+
})();
1814+
return;
1815+
}
1816+
document.execCommand('insertText', false, text);
1817+
setHasText(!!(divRef.current?.textContent?.trim()));
1818+
};
1819+
17881820
const handleShortcut = (data: string) => {
17891821
if (!ws || !activeSession) return;
17901822
ws.sendInput(activeSession.name, data);

web/src/i18n/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,8 @@
635635
"uploading": "Uploading...",
636636
"upload_failed": "Upload failed",
637637
"file_too_large": "File too large (max {{max}}MB)",
638+
"long_text_attached": "Large pasted text attached as {{name}}",
639+
"long_text_requires_attachment": "Paste is too large for inline input here. Upload it as a file instead.",
638640
"download_file": "Download",
639641
"downloading": "Downloading...",
640642
"preview_unavailable": "Preview unavailable",

web/src/i18n/locales/es.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,8 @@
634634
"uploading": "Subiendo...",
635635
"upload_failed": "Error al subir",
636636
"file_too_large": "Archivo demasiado grande (máx. {{max}}MB)",
637+
"long_text_attached": "El texto pegado largo se adjuntó como {{name}}",
638+
"long_text_requires_attachment": "Este pegado es demasiado grande para el cuadro de texto. Súbelo como archivo.",
637639
"download_file": "Descargar",
638640
"downloading": "Descargando...",
639641
"preview_unavailable": "Vista previa no disponible",

web/src/i18n/locales/ja.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,8 @@
634634
"uploading": "アップロード中...",
635635
"upload_failed": "アップロード失敗",
636636
"file_too_large": "ファイルが大きすぎます(最大 {{max}}MB)",
637+
"long_text_attached": "長い貼り付けテキストを {{name}} として添付しました",
638+
"long_text_requires_attachment": "この貼り付け内容は入力欄に直接入れるには長すぎます。ファイルとしてアップロードしてください。",
637639
"download_file": "ダウンロード",
638640
"downloading": "ダウンロード中...",
639641
"preview_unavailable": "プレビューできません",

web/src/i18n/locales/ko.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,8 @@
634634
"uploading": "업로드 중...",
635635
"upload_failed": "업로드 실패",
636636
"file_too_large": "파일이 너무 큽니다 (최대 {{max}}MB)",
637+
"long_text_attached": "긴 붙여넣기 텍스트가 {{name}} 첨부파일로 추가되었습니다",
638+
"long_text_requires_attachment": "이 붙여넣기 내용은 입력창에 직접 넣기엔 너무 깁니다. 파일로 업로드하세요.",
637639
"download_file": "다운로드",
638640
"downloading": "다운로드 중...",
639641
"preview_unavailable": "미리보기 불가",

web/src/i18n/locales/ru.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,8 @@
634634
"uploading": "Загрузка...",
635635
"upload_failed": "Ошибка загрузки",
636636
"file_too_large": "Файл слишком большой (макс. {{max}}МБ)",
637+
"long_text_attached": "Большой вставленный текст прикреплен как {{name}}",
638+
"long_text_requires_attachment": "Эта вставка слишком большая для поля ввода. Загрузите ее как файл.",
637639
"download_file": "Скачать",
638640
"downloading": "Скачивание...",
639641
"preview_unavailable": "Предпросмотр недоступен",

web/src/i18n/locales/zh-CN.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,8 @@
635635
"uploading": "上传中...",
636636
"upload_failed": "上传失败",
637637
"file_too_large": "文件过大(最大 {{max}}MB)",
638+
"long_text_attached": "超长粘贴内容已作为 {{name}} 附件添加",
639+
"long_text_requires_attachment": "这段粘贴内容太长,不能直接放进输入框,请改为文件上传。",
638640
"download_file": "下载",
639641
"downloading": "下载中...",
640642
"preview_unavailable": "无法预览",

web/src/i18n/locales/zh-TW.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,8 @@
635635
"uploading": "上傳中...",
636636
"upload_failed": "上傳失敗",
637637
"file_too_large": "檔案過大(最大 {{max}}MB)",
638+
"long_text_attached": "過長貼上內容已作為 {{name}} 附件加入",
639+
"long_text_requires_attachment": "這段貼上內容太長,不能直接放進輸入框,請改成檔案上傳。",
638640
"download_file": "下載",
639641
"downloading": "下載中...",
640642
"preview_unavailable": "無法預覽",

web/test/components/SessionControls.test.tsx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ vi.mock('react-i18next', () => ({
5656
if (key === 'session.send_placeholder_desktop_upload') {
5757
return `${String(opts?.placeholder ?? '')} Supports fast multi-file paste upload`;
5858
}
59+
if (key === 'upload.long_text_attached') {
60+
return `Large pasted text attached as ${String(opts?.name ?? '')}`;
61+
}
62+
if (key === 'upload.long_text_requires_attachment') {
63+
return 'Paste is too large for inline input here. Upload it as a file instead.';
64+
}
5965
if (key === 'session.stop_plain') return 'Stop';
6066
if (key === 'session.supervision.quickLabel') return 'Auto';
6167
if (key === 'session.supervision.quickTitle') return 'Auto mode';
@@ -139,6 +145,7 @@ vi.mock('../../src/components/AtPicker.js', () => ({
139145
}));
140146

141147
const uploadFileMock = vi.fn();
148+
const execCommandMock = vi.fn(() => true);
142149
const getUserPrefMock = vi.fn().mockResolvedValue(null);
143150
const saveUserPrefMock = vi.fn().mockResolvedValue(undefined);
144151
const fetchSupervisorDefaultsMock = vi.fn().mockResolvedValue(null);
@@ -275,6 +282,17 @@ afterEach(() => {
275282

276283
beforeEach(() => {
277284
vi.clearAllMocks();
285+
execCommandMock.mockImplementation((_command: string, _ui?: boolean, value?: string) => {
286+
const active = document.activeElement as HTMLDivElement | null;
287+
if (active && typeof active.textContent === 'string') {
288+
active.textContent = `${active.textContent}${String(value ?? '')}`;
289+
}
290+
return true;
291+
});
292+
Object.defineProperty(document, 'execCommand', {
293+
configurable: true,
294+
value: execCommandMock,
295+
});
278296
sessionStorage.clear();
279297
localStorage.clear();
280298
fetchSupervisorDefaultsMock.mockResolvedValue(null);
@@ -2407,6 +2425,91 @@ afterEach(() => {
24072425
expect(document.querySelector('.controls-input')?.getAttribute('data-placeholder')).toBe('Send to my-project…');
24082426
});
24092427

2428+
it('keeps normal plain-text paste inline for short clipboard content', () => {
2429+
render(
2430+
<SessionControls
2431+
ws={makeWs() as any}
2432+
activeSession={makeSession()}
2433+
quickData={makeQuickData() as any}
2434+
serverId="srv-1"
2435+
/>,
2436+
);
2437+
2438+
const input = screen.getByRole('textbox') as HTMLDivElement;
2439+
input.focus();
2440+
fireEvent.paste(input, {
2441+
clipboardData: {
2442+
getData: (type: string) => type === 'text/plain' ? 'short inline paste' : '',
2443+
},
2444+
});
2445+
2446+
expect(execCommandMock).toHaveBeenCalledWith('insertText', false, 'short inline paste');
2447+
expect(input.textContent).toBe('short inline paste');
2448+
expect(uploadFileMock).not.toHaveBeenCalled();
2449+
});
2450+
2451+
it('converts oversized plain-text paste into an attachment upload', async () => {
2452+
uploadFileMock.mockResolvedValue({ attachment: { daemonPath: '/tmp/pasted-text.txt' } });
2453+
const ws = makeWs();
2454+
render(
2455+
<SessionControls
2456+
ws={ws as any}
2457+
activeSession={makeSession({ name: 'my-session' })}
2458+
quickData={makeQuickData() as any}
2459+
serverId="srv-1"
2460+
/>,
2461+
);
2462+
2463+
const input = screen.getByRole('textbox') as HTMLDivElement;
2464+
input.focus();
2465+
const longText = 'x'.repeat(13000);
2466+
fireEvent.paste(input, {
2467+
clipboardData: {
2468+
getData: (type: string) => type === 'text/plain' ? longText : '',
2469+
},
2470+
});
2471+
2472+
await waitFor(() => expect(uploadFileMock).toHaveBeenCalledTimes(1));
2473+
const uploadedFile = uploadFileMock.mock.calls[0]?.[1] as File;
2474+
expect(uploadedFile).toBeInstanceOf(File);
2475+
expect(uploadedFile.name).toMatch(/^pasted-text-.*\.txt$/);
2476+
expect(await uploadedFile.text()).toBe(longText);
2477+
expect(execCommandMock).not.toHaveBeenCalled();
2478+
expect(input.textContent).toBe('');
2479+
await waitFor(() => {
2480+
expect(document.querySelector('.attachment-badge-name')?.textContent).toMatch(/^pasted-text-.*\.txt$/);
2481+
});
2482+
2483+
fireEvent.click(screen.getByRole('button', { name: /send/i }));
2484+
expectSendPayload(ws, {
2485+
sessionName: 'my-session',
2486+
text: '@/tmp/pasted-text.txt',
2487+
});
2488+
});
2489+
2490+
it('blocks oversized plain-text paste when upload context is unavailable', async () => {
2491+
render(
2492+
<SessionControls
2493+
ws={makeWs() as any}
2494+
activeSession={makeSession()}
2495+
quickData={makeQuickData() as any}
2496+
/>,
2497+
);
2498+
2499+
const input = screen.getByRole('textbox') as HTMLDivElement;
2500+
input.focus();
2501+
fireEvent.paste(input, {
2502+
clipboardData: {
2503+
getData: (type: string) => type === 'text/plain' ? 'y'.repeat(13000) : '',
2504+
},
2505+
});
2506+
2507+
expect(uploadFileMock).not.toHaveBeenCalled();
2508+
expect(execCommandMock).not.toHaveBeenCalled();
2509+
expect(input.textContent).toBe('');
2510+
expect(await screen.findByText('Paste is too large for inline input here. Upload it as a file instead.')).toBeDefined();
2511+
});
2512+
24102513
// TODO: fix — file upload mock doesn't trigger state update in jsdom
24112514
describe.skip('attachment badges', () => {
24122515
it('shows badge after file upload', async () => {

0 commit comments

Comments
 (0)