From 299fab56315fa2d9171307cb86dddf18412fbe49 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 18 Jun 2026 04:46:50 +0000 Subject: [PATCH] feat(#4372): attach large composer text pastes as .md files Pastes over 4000 chars or 100 lines attach as a timestamped pasted-text-*.md in the upload tray instead of flooding the composer. Image-paste path unchanged; an oversize paste that exceeds MAX_UPLOAD_BYTES falls through to a normal inline paste so nothing is lost. Co-authored-by: santastabber <204424800+santastabber@users.noreply.github.com> --- CHANGELOG.md | 6 ++ static/boot.js | 70 +++++++++++++++----- static/i18n.js | 13 ++++ tests/test_1620_paste_text_with_image.py | 15 +++-- tests/test_large_text_paste_attachment.py | 79 +++++++++++++++++++++++ 5 files changed, 163 insertions(+), 20 deletions(-) create mode 100644 tests/test_large_text_paste_attachment.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 001dc093..1cf0a43f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ - **The conversation-outline toggle button no longer overlaps the scroll-to-bottom control (#2124).** The outline FAB was `position:fixed` at a high z-index and could stack on top of the scroll-to-bottom button in the bottom-right corner. It's now anchored inside the messages shell (`position:absolute`, lower z-index) so the two controls sit cleanly without colliding. Thanks @Habib1001-m. +## [v0.51.490] — 2026-06-18 — Release QZ (large plain-text pastes become .md attachments) + +### Added + +- **Large plain-text pastes in the composer now become `.md` attachments instead of flooding the input (#4372).** Pasting text over 4000 characters or 100 lines now attaches it as a timestamped `pasted-text-*.md` file in the upload tray rather than dumping it inline. Image-paste (with or without accompanying text) is unchanged, and a paste that would exceed the upload size limit falls through to a normal inline paste so nothing is lost. Thanks @santastabber. + ## [v0.51.488] — 2026-06-18 — Release QW (rescue state.db user prompts that fall before a newer sidecar tail) ### Fixed diff --git a/static/boot.js b/static/boot.js index 6a1ae32c..d6d54acc 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1569,26 +1569,66 @@ document.addEventListener('keydown',async e=>{ } } }); +const LARGE_TEXT_PASTE_CHAR_THRESHOLD=4000; +const LARGE_TEXT_PASTE_LINE_THRESHOLD=100; +function _largeTextPasteLineCount(text){ + const value=String(text||''); + const lines=value.split('\n'); + return value.endsWith('\n')?lines.length-1:lines.length; +} +function _shouldAttachLargePastedText(text){ + const value=String(text||''); + if(!value.trim())return false; + return value.length>=LARGE_TEXT_PASTE_CHAR_THRESHOLD || _largeTextPasteLineCount(value)>=LARGE_TEXT_PASTE_LINE_THRESHOLD; +} +function _largeTextPasteFileName(now){ + const d=new Date(now||Date.now()); + const stamp=d.toISOString().replace(/[:.]/g,'-').replace('T','_').replace('Z',''); + const existing=new Set((S.pendingFiles||[]).map(f=>f&&f.name).filter(Boolean)); + let name=`pasted-text-${stamp}.md`; + for(let i=2;existing.has(name);i++)name=`pasted-text-${stamp}-${i}.md`; + return name; +} +function _largeTextPasteFile(text,now){ + const name=_largeTextPasteFileName(now||Date.now()); + return new File([String(text||'')],name,{type:'text/markdown;charset=utf-8'}); +} +function _largeTextPasteFitsUploadLimit(file){ + return !(file&&typeof MAX_UPLOAD_BYTES==='number'&&file.size>MAX_UPLOAD_BYTES); +} +function _attachLargePastedText(file){ + addFiles([file]); + if(typeof setStatus==='function')setStatus(t('text_pasted')+file.name); + return file; +} $('msg').addEventListener('paste',e=>{ const items=Array.from(e.clipboardData?.items||[]); // Extract image items (kind==='file' filter avoids misclassifying text/html // with embedded data URIs as images). const imageItems=items.filter(i=>i.kind==='file'&&i.type.startsWith('image/')); - if(!imageItems.length)return; - // If text is also present (common when copying images from browsers, Notes, - // Slack, etc.), let the browser paste the text normally AND attach the image. - // Only preventDefault when the clipboard is image-only (true screenshot paste). - const hasText=items.some(i=>i.kind==='string'&&(i.type==='text/plain'||i.type==='text/html')); - if(!hasText)e.preventDefault(); - const pasteTs=Date.now(); - const files=imageItems.map((i,idx)=>{ - const blob=i.getAsFile(); - const ext=i.type.split('/')[1]||'png'; - const suffix=imageItems.length>1?`-${idx+1}`:''; - return new File([blob],`screenshot-${pasteTs}${suffix}.${ext}`,{type:i.type}); - }); - addFiles(files); - setStatus(t('image_pasted')+files.map(f=>f.name).join(', ')); + if(imageItems.length){ + // If text is also present (common when copying images from browsers, Notes, + // Slack, etc.), let the browser paste the text normally AND attach the image. + // Only preventDefault when the clipboard is image-only (true screenshot paste). + const hasText=items.some(i=>i.kind==='string'&&(i.type==='text/plain'||i.type==='text/html')); + if(!hasText)e.preventDefault(); + const pasteTs=Date.now(); + const files=imageItems.map((i,idx)=>{ + const blob=i.getAsFile(); + const ext=i.type.split('/')[1]||'png'; + const suffix=imageItems.length>1?`-${idx+1}`:''; + return new File([blob],`screenshot-${pasteTs}${suffix}.${ext}`,{type:i.type}); + }); + addFiles(files); + setStatus(t('image_pasted')+files.map(f=>f.name).join(', ')); + return; + } + const plainText=e.clipboardData?.getData('text/plain')||''; + if(!_shouldAttachLargePastedText(plainText))return; + const pastedTextFile=_largeTextPasteFile(plainText); + if(!_largeTextPasteFitsUploadLimit(pastedTextFile))return; + e.preventDefault(); + _attachLargePastedText(pastedTextFile); }); document.querySelectorAll('.suggestion').forEach(btn=>{ btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();}; diff --git a/static/i18n.js b/static/i18n.js index 47b4bf0f..d646c6cc 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -39,6 +39,7 @@ const LOCALES = { import_failed: 'Import failed: ', import_invalid_json: 'Invalid JSON', image_pasted: 'Image pasted: ', + text_pasted: 'Pasted text attached as ', // messages.js edit_message: 'Edit message', regenerate: 'Regenerate response', @@ -1550,6 +1551,7 @@ const LOCALES = { import_failed: 'Importazione fallita: ', import_invalid_json: 'JSON non valido', image_pasted: 'Immagine incollata: ', + text_pasted: 'Testo incollato allegato come ', // messages.js edit_message: 'Modifica messaggio', regenerate: 'Rigenera risposta', @@ -3052,6 +3054,7 @@ const LOCALES = { import_failed: 'インポート失敗: ', import_invalid_json: '無効な JSON', image_pasted: '画像を貼り付けました: ', + text_pasted: '貼り付けたテキストを添付しました: ', // messages.js edit_message: 'メッセージを編集', regenerate: '応答を再生成', @@ -4557,6 +4560,7 @@ const LOCALES = { import_failed: 'Не удалось импортировать: ', import_invalid_json: 'Неверный JSON', image_pasted: 'Изображение вставлено: ', + text_pasted: 'Вставленный текст прикреплён как ', edit_message: 'Редактировать сообщение', regenerate: 'Сгенерировать ответ заново', copy: 'Копировать', @@ -5990,6 +5994,7 @@ const LOCALES = { import_failed: 'Error al importar: ', import_invalid_json: 'JSON inválido', image_pasted: 'Imagen pegada: ', + text_pasted: 'Texto pegado adjuntado como ', // messages.js edit_message: 'Editar mensaje', regenerate: 'Regenerar respuesta', @@ -7428,6 +7433,7 @@ const LOCALES = { import_failed: 'Import fehlgeschlagen: ', import_invalid_json: 'Ungültiges JSON', image_pasted: 'Bild eingefügt: ', + text_pasted: 'Eingefügter Text angehängt als ', // messages.js edit_message: 'Nachricht bearbeiten', regenerate: 'Antwort regenerieren', @@ -8870,6 +8876,7 @@ const LOCALES = { import_failed: '导入失败:', import_invalid_json: 'JSON 无效', image_pasted: '已粘贴图片:', + text_pasted: '已将粘贴文本附加为:', // messages.js edit_message: '编辑消息', regenerate: '重新生成回复', @@ -10320,6 +10327,7 @@ const LOCALES = { import_failed: '匯入失敗:', import_invalid_json: '無效的 JSON', image_pasted: '已貼上圖片:', + text_pasted: '已將貼上文字附加為:', // messages.js edit_message: '編輯訊息', regenerate: '重新產生回覆', @@ -11811,6 +11819,7 @@ const LOCALES = { import_failed: 'Falha na importação: ', import_invalid_json: 'JSON inválido', image_pasted: 'Imagem colada: ', + text_pasted: 'Texto colado anexado como ', // messages.js edit_message: 'Editar mensagem', regenerate: 'Regenerar resposta', @@ -13133,6 +13142,7 @@ const LOCALES = { import_failed: '가져오기 실패: ', import_invalid_json: '잘못된 JSON입니다', image_pasted: '붙여넣은 이미지: ', + text_pasted: '붙여넣은 텍스트를 첨부했습니다: ', // messages.js edit_message: '메시지 편집', regenerate: '응답 다시 생성', @@ -14635,6 +14645,7 @@ const LOCALES = { import_failed: 'Échec de l\'importation :', import_invalid_json: 'JSON invalide', image_pasted: 'Image collée :', + text_pasted: 'Texte collé joint sous ', edit_message: 'Modifier le message', regenerate: 'Régénérer la réponse', copy: 'Copie', @@ -16077,6 +16088,7 @@ const LOCALES = { import_failed: 'İçe aktarma başarısız oldu:', import_invalid_json: 'Geçersiz JSON', image_pasted: 'Yapıştırılan resim:', + text_pasted: 'Yapıştırılan metin şu adla eklendi: ', // messages.js edit_message: 'Mesajı düzenle', regenerate: 'Yanıtı yeniden oluştur', @@ -17572,6 +17584,7 @@ const LOCALES = { import_failed: 'Import nie powiódł się: ', import_invalid_json: 'Nieprawidłowy JSON', image_pasted: 'Wklejono obraz: ', + text_pasted: 'Wklejony tekst załączono jako ', // messages.js edit_message: 'Edytuj wiadomość', regenerate: 'Wygeneruj ponownie odpowiedź', diff --git a/tests/test_1620_paste_text_with_image.py b/tests/test_1620_paste_text_with_image.py index f1b1200a..900f566e 100644 --- a/tests/test_1620_paste_text_with_image.py +++ b/tests/test_1620_paste_text_with_image.py @@ -75,11 +75,16 @@ class TestPasteHandlerTextWithImage: Additionally, text-only clipboards (no image items) must early-return before reaching the attach path.""" body = _paste_handler_body() - # Text-only early-out: if no image items, return before any attach logic. - assert re.search( - r"if\s*\(\s*!\s*imageItems\.length\s*\)\s*return\s*;", - body, - ), "handler must early-return when there are no image files (text-only paste)" + # Text-only small pastes should return before preventDefault/attach; + # large text-only pastes may continue into the markdown-attachment path. + assert "if(!_shouldAttachLargePastedText(plainText))return;" in body, ( + "handler must leave normal text-only paste to the browser before any preventDefault" + ) + # The new text-only branch must run after the image path has returned, + # so image+text paste still uses the #1620 path below. + assert body.index("return;\n }\n const plainText") > body.index("addFiles(files)"), ( + "text-only large-paste handling must not intercept the image+text attach path" + ) # preventDefault must be gated on !hasText so text+image pastes let the # browser insert text natively. assert re.search( diff --git a/tests/test_large_text_paste_attachment.py b/tests/test_large_text_paste_attachment.py new file mode 100644 index 00000000..5086855c --- /dev/null +++ b/tests/test_large_text_paste_attachment.py @@ -0,0 +1,79 @@ +"""Regression tests for large composer text paste attachment behavior.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") +CHANGELOG = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") +I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") + + +def test_large_text_paste_threshold_helpers_are_defined(): + assert "const LARGE_TEXT_PASTE_CHAR_THRESHOLD=4000" in BOOT_JS + assert "const LARGE_TEXT_PASTE_LINE_THRESHOLD=100" in BOOT_JS + assert "function _shouldAttachLargePastedText(text)" in BOOT_JS + assert "value.length>=LARGE_TEXT_PASTE_CHAR_THRESHOLD" in BOOT_JS + assert "_largeTextPasteLineCount(value)>=LARGE_TEXT_PASTE_LINE_THRESHOLD" in BOOT_JS + + +def test_large_text_line_count_does_not_overcount_trailing_newline(): + assert "const lines=value.split('\\n');" in BOOT_JS + assert "return value.endsWith('\\n')?lines.length-1:lines.length;" in BOOT_JS + + +def test_line_threshold_is_one_hundred_lines(): + assert "const LARGE_TEXT_PASTE_LINE_THRESHOLD=100" in BOOT_JS + assert "_largeTextPasteLineCount(value)>=LARGE_TEXT_PASTE_LINE_THRESHOLD" in BOOT_JS + + +def test_large_text_paste_creates_markdown_file_and_uses_existing_tray(): + assert "function _largeTextPasteFile(text,now)" in BOOT_JS + assert "function _attachLargePastedText(file)" in BOOT_JS + assert "pasted-text-${stamp}.md" in BOOT_JS + assert "const existing=new Set((S.pendingFiles||[]).map(f=>f&&f.name).filter(Boolean))" in BOOT_JS + assert "for(let i=2;existing.has(name);i++)name=`pasted-text-${stamp}-${i}.md`;" in BOOT_JS + assert "new File([String(text||'')],name,{type:'text/markdown;charset=utf-8'})" in BOOT_JS + assert "addFiles([file])" in BOOT_JS + assert "setStatus(t('text_pasted')+file.name)" in BOOT_JS + + +def test_large_text_status_uses_i18n_key_available_to_all_locales(): + assert "text_pasted: 'Pasted text attached as '," in I18N_JS + assert I18N_JS.count("text_pasted:") == I18N_JS.count("image_pasted:") + + +def test_paste_handler_keeps_image_paste_path_before_large_text_path(): + paste_idx = BOOT_JS.index("$('msg').addEventListener('paste',e=>{") + image_idx = BOOT_JS.index("if(imageItems.length){", paste_idx) + return_idx = BOOT_JS.index("return;", image_idx) + text_idx = BOOT_JS.index("const plainText=e.clipboardData?.getData('text/plain')||'';", paste_idx) + attach_idx = BOOT_JS.index("_attachLargePastedText(pastedTextFile);", text_idx) + + assert image_idx < return_idx < text_idx < attach_idx + assert "if(!hasText)e.preventDefault();" in BOOT_JS[image_idx:return_idx] + + +def test_large_text_paste_prevents_default_textarea_insert_only_for_large_plain_text(): + text_idx = BOOT_JS.index("const plainText=e.clipboardData?.getData('text/plain')||'';") + block = BOOT_JS[text_idx : BOOT_JS.index("});", text_idx)] + assert "if(!_shouldAttachLargePastedText(plainText))return;" in block + assert "const pastedTextFile=_largeTextPasteFile(plainText);" in block + assert "if(!_largeTextPasteFitsUploadLimit(pastedTextFile))return;" in block + assert "e.preventDefault();" in block + assert "_attachLargePastedText(pastedTextFile);" in block + assert block.index("if(!_shouldAttachLargePastedText(plainText))return;") < block.index("const pastedTextFile=_largeTextPasteFile(plainText);") + assert block.index("if(!_largeTextPasteFitsUploadLimit(pastedTextFile))return;") < block.index("e.preventDefault();") + + +def test_oversize_large_text_paste_falls_back_to_native_paste_instead_of_being_dropped(): + assert "function _largeTextPasteFitsUploadLimit(file)" in BOOT_JS + assert "typeof MAX_UPLOAD_BYTES==='number'&&file.size>MAX_UPLOAD_BYTES" in BOOT_JS + text_idx = BOOT_JS.index("const plainText=e.clipboardData?.getData('text/plain')||'';") + block = BOOT_JS[text_idx : BOOT_JS.index("});", text_idx)] + fit_idx = block.index("if(!_largeTextPasteFitsUploadLimit(pastedTextFile))return;") + prevent_idx = block.index("e.preventDefault();") + attach_idx = block.index("_attachLargePastedText(pastedTextFile);") + assert fit_idx < prevent_idx < attach_idx + + +def test_changelog_mentions_large_text_paste_attachment(): + assert "Large plain-text pastes in the composer now become `.md` attachments" in CHANGELOG