From 0f8b1377138d5e86798a607deb4c59ceaf3a315a Mon Sep 17 00:00:00 2001 From: red person Date: Wed, 28 Oct 2026 07:26:05 +0300 Subject: [PATCH 1/4] fix(email): retain paperclip files when resetting the input --- static/app.js | 2 +- static/index.html | 2 +- static/js/chat.js | 2 +- static/js/chatRenderer.js | 4 +- static/js/chatStream.js | 2 +- static/js/document.js | 2 +- static/js/emailLibrary/attachments.js | 2 +- static/js/slashCommands.js | 2 +- static/sw.js | 4 +- tests/test_email_paperclip_files_browser.py | 55 +++++++++++++++++++++ 10 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 tests/test_email_paperclip_files_browser.py diff --git a/static/app.js b/static/app.js index e14121adb..8454c3c65 100644 --- a/static/app.js +++ b/static/app.js @@ -12,7 +12,7 @@ import presetsModule from './js/presets.js?v=20260908personaname1'; import searchModule from './js/search.js'; import chatModule from './js/chat.js?v=20260917toolttft1'; import compareModule from './js/compare/index.js?v=20260909mobilepaneaddscroll1'; -import documentModule from './js/document.js?v=20260916docctx2'; +import documentModule from './js/document.js?v=20261028emailpickerfiles1'; import searchChatModule from './js/search-chat.js'; import { makeWindowDraggable } from './js/windowDrag.js'; import { diff --git a/static/index.html b/static/index.html index 901b42510..a8a5d1f03 100644 --- a/static/index.html +++ b/static/index.html @@ -2682,7 +2682,7 @@ - + diff --git a/static/js/chat.js b/static/js/chat.js index b02eb743c..91ce0d886 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -16,7 +16,7 @@ import spinnerModule from './spinner.js'; import presetsModule from './presets.js?v=20260908personaname1'; import fileHandlerModule from './fileHandler.js?v=20260909mobileattachmentedit1'; import searchModule from './search.js'; -import documentModule from './document.js?v=20260916docctx2'; +import documentModule from './document.js?v=20261028emailpickerfiles1'; import * as emailInbox from './emailInbox.js?v=20260914aireply4'; import codeRunnerModule from './codeRunner.js?v=20260831richtexttools91'; import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260921chatgptusage1'; diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index dceec9b24..cb2f266b6 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -1889,7 +1889,7 @@ function _activateEntityAnchor(e, forcedAnchor = null) { } catch {} }); } else if (kind === 'document') { - import('./document.js?v=20260916docctx2').then(mod => { + import('./document.js?v=20261028emailpickerfiles1').then(mod => { const open = mod.loadDocument || mod.openDocument || (mod.default && (mod.default.loadDocument || mod.default.openDocument)); @@ -3656,7 +3656,7 @@ export function addMessage(role, content, modelName, metadata) { b.querySelectorAll('[data-doc-edit-ref]').forEach(button => { button.addEventListener('click', () => { - import('./document.js?v=20260916docctx2').then(mod => { + import('./document.js?v=20261028emailpickerfiles1').then(mod => { const restore = mod.restoreSelectionReference || mod.default?.restoreSelectionReference; return restore?.(button.dataset.docEditRef || '', { diff --git a/static/js/chatStream.js b/static/js/chatStream.js index 4981ac34d..c048905d7 100644 --- a/static/js/chatStream.js +++ b/static/js/chatStream.js @@ -7,7 +7,7 @@ import Storage from './storage.js'; import themeModule from './theme.js?v=20260911organsrain1'; import markdownModule from './markdown.js'; import sessionModule from './sessions.js'; -import documentModule from './document.js?v=20260916docctx2'; +import documentModule from './document.js?v=20261028emailpickerfiles1'; // Tool approvals are control-plane submits for the current chat. chat.js // deliberately leaves the composer untouched, then programmatically clicks the diff --git a/static/js/document.js b/static/js/document.js index a3e01dc45..0aa6bad98 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -5197,7 +5197,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; } async function _handleAttachUpload(e) { - const files = e.target.files; + const files = Array.from(e.target.files || []); e.target.value = ''; // reset for next upload await _uploadComposeFiles(files); } diff --git a/static/js/emailLibrary/attachments.js b/static/js/emailLibrary/attachments.js index 1af364e15..792a881c1 100644 --- a/static/js/emailLibrary/attachments.js +++ b/static/js/emailLibrary/attachments.js @@ -187,7 +187,7 @@ function _wireAttachmentHandlers(reader, folder) { ownerModal.classList.add('hidden'); } } - const docMod = await import('../document.js?v=20260916docctx2'); + const docMod = await import('../document.js?v=20261028emailpickerfiles1'); const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument); if (typeof load === 'function') { await load(json.doc_id); diff --git a/static/js/slashCommands.js b/static/js/slashCommands.js index d288cb3a1..a3c793487 100644 --- a/static/js/slashCommands.js +++ b/static/js/slashCommands.js @@ -16,7 +16,7 @@ import modelsModule from './models.js'; import chatRenderer from './chatRenderer.js?v=20260914metricssummary1'; import spinnerModule from './spinner.js'; import themeModule from './theme.js?v=20260911organsrain1'; -import documentModule from './document.js?v=20260916docctx2'; +import documentModule from './document.js?v=20261028emailpickerfiles1'; import workspaceModule from './workspace.js'; import settingsModule from './settings.js?v=20260912writingstyle3'; import cookbookModule from './cookbook.js'; diff --git a/static/sw.js b/static/sw.js index 44cf2e2a1..51f98564e 100644 --- a/static/sw.js +++ b/static/sw.js @@ -7,7 +7,7 @@ // - Other static assets (images/fonts/libs): cache-first with bg refresh. // - API / non-GET: never cached. // Bump CACHE_NAME whenever the precache list or SW logic changes. -const CACHE_NAME = 'odysseus-v659-notes-reference'; +const CACHE_NAME = 'odysseus-v660-email-picker-files'; // KaTeX resolves these from its own stylesheet, so caching the CSS without them // gives offline math fallback glyphs instead of proper typesetting. @@ -80,7 +80,7 @@ const PRECACHE = [ '/static/js/search.js', '/static/js/spinner.js', '/static/js/tts-ai.js', - '/static/js/document.js?v=20260916docctx2', + '/static/js/document.js?v=20261028emailpickerfiles1', '/static/js/gallery.js?v=20260910promptcopy1', '/static/js/chatRenderer.js?v=20260914metricssummary1', '/static/js/codeRunner.js?v=20260831richtexttools91', diff --git a/tests/test_email_paperclip_files_browser.py b/tests/test_email_paperclip_files_browser.py new file mode 100644 index 000000000..c0f753e3e --- /dev/null +++ b/tests/test_email_paperclip_files_browser.py @@ -0,0 +1,55 @@ +"""The native FileList must survive resetting the compose file input.""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.helpers.document_source import function_body + + +@pytest.mark.parametrize("selections", [[["one.txt"]], [["one.txt", "two.csv"]], [["one.txt"], ["one.txt"]], [[]]]) +def test_compose_file_selection_survives_reset(tmp_path, selections): + if not shutil.which("node"): + pytest.skip("node not installed") + for name, content in [("one.txt", "Synthetic attachment one\n"), ("two.csv", 'name,value\n"fixture",2\n')]: + (tmp_path / name).write_text(content, encoding="utf-8") + script = r""" +const fs=require('node:fs'),assert=require('node:assert/strict'); +let chromium;try{({chromium}=require('playwright'));}catch(e){if(e.code==='MODULE_NOT_FOUND')process.exit(77);throw e;} +(async()=>{ + const browser=await chromium.launch({headless:true}); + try{ + const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message)); + await page.setContent(''); + await page.addScriptTag({content:` + window.uploads=[];window.finished=0; + async function _uploadComposeFiles(files){ + for(const file of files)window.uploads.push({name:file.name,text:await file.text()}); + } + __HANDLER__ + document.querySelector('input').addEventListener('change',async event=>{ + await _handleAttachUpload(event);window.finished++; + }); + `}); + const selections=JSON.parse(process.argv[1]),root=process.argv[2],expected=[]; + for(const [index,names] of selections.entries()){ + await page.getByLabel('Compose attachments').setInputFiles(names.map(name=>root+'/'+name)); + await page.waitForFunction(n=>window.finished===n,index+1); + expected.push(...names.map(name=>({name,text:fs.readFileSync(root+'/'+name,'utf8')}))); + assert.deepEqual(await page.evaluate(()=>window.uploads),expected); + assert.equal(await page.getByLabel('Compose attachments').inputValue(),''); + assert.equal(await page.getByLabel('Compose attachments').evaluate(el=>el.files.length),0); + } + assert.deepEqual(errors,[]);console.log(JSON.stringify({files:expected.length})); + }finally{await browser.close();} +})().catch(e=>{console.error(e);process.exitCode=1;}); +""".replace("__HANDLER__", function_body("_handleAttachUpload")) + result = subprocess.run(["node", "-e", script, json.dumps(selections), str(tmp_path)], + cwd=Path(__file__).resolve().parents[1], capture_output=True, + text=True, timeout=30) + if result.returncode == 77: + pytest.skip("playwright not installed") + assert result.returncode == 0, result.stdout + result.stderr + assert json.loads(result.stdout)["files"] == sum(map(len, selections)) From 1c5853003e9c8d864c8eb337454c4bcb9f966798 Mon Sep 17 00:00:00 2001 From: red person Date: Wed, 28 Oct 2026 07:34:10 +0300 Subject: [PATCH 2/4] fix(email): retain inline image selections before input reset --- static/app.js | 2 +- static/index.html | 2 +- static/js/chat.js | 2 +- static/js/chatRenderer.js | 4 ++-- static/js/chatStream.js | 2 +- static/js/document.js | 2 +- static/js/emailLibrary/attachments.js | 2 +- static/js/slashCommands.js | 2 +- static/sw.js | 4 ++-- tests/test_email_paperclip_files_browser.py | 26 +++++++++++++-------- 10 files changed, 27 insertions(+), 21 deletions(-) diff --git a/static/app.js b/static/app.js index 8454c3c65..b248e41ef 100644 --- a/static/app.js +++ b/static/app.js @@ -12,7 +12,7 @@ import presetsModule from './js/presets.js?v=20260908personaname1'; import searchModule from './js/search.js'; import chatModule from './js/chat.js?v=20260917toolttft1'; import compareModule from './js/compare/index.js?v=20260909mobilepaneaddscroll1'; -import documentModule from './js/document.js?v=20261028emailpickerfiles1'; +import documentModule from './js/document.js?v=20261028emailpickerfiles2'; import searchChatModule from './js/search-chat.js'; import { makeWindowDraggable } from './js/windowDrag.js'; import { diff --git a/static/index.html b/static/index.html index a8a5d1f03..ddd68e2fd 100644 --- a/static/index.html +++ b/static/index.html @@ -2682,7 +2682,7 @@ - + diff --git a/static/js/chat.js b/static/js/chat.js index 91ce0d886..1170ff8dd 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -16,7 +16,7 @@ import spinnerModule from './spinner.js'; import presetsModule from './presets.js?v=20260908personaname1'; import fileHandlerModule from './fileHandler.js?v=20260909mobileattachmentedit1'; import searchModule from './search.js'; -import documentModule from './document.js?v=20261028emailpickerfiles1'; +import documentModule from './document.js?v=20261028emailpickerfiles2'; import * as emailInbox from './emailInbox.js?v=20260914aireply4'; import codeRunnerModule from './codeRunner.js?v=20260831richtexttools91'; import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260921chatgptusage1'; diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index cb2f266b6..8799b1672 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -1889,7 +1889,7 @@ function _activateEntityAnchor(e, forcedAnchor = null) { } catch {} }); } else if (kind === 'document') { - import('./document.js?v=20261028emailpickerfiles1').then(mod => { + import('./document.js?v=20261028emailpickerfiles2').then(mod => { const open = mod.loadDocument || mod.openDocument || (mod.default && (mod.default.loadDocument || mod.default.openDocument)); @@ -3656,7 +3656,7 @@ export function addMessage(role, content, modelName, metadata) { b.querySelectorAll('[data-doc-edit-ref]').forEach(button => { button.addEventListener('click', () => { - import('./document.js?v=20261028emailpickerfiles1').then(mod => { + import('./document.js?v=20261028emailpickerfiles2').then(mod => { const restore = mod.restoreSelectionReference || mod.default?.restoreSelectionReference; return restore?.(button.dataset.docEditRef || '', { diff --git a/static/js/chatStream.js b/static/js/chatStream.js index c048905d7..7b2d9aff5 100644 --- a/static/js/chatStream.js +++ b/static/js/chatStream.js @@ -7,7 +7,7 @@ import Storage from './storage.js'; import themeModule from './theme.js?v=20260911organsrain1'; import markdownModule from './markdown.js'; import sessionModule from './sessions.js'; -import documentModule from './document.js?v=20261028emailpickerfiles1'; +import documentModule from './document.js?v=20261028emailpickerfiles2'; // Tool approvals are control-plane submits for the current chat. chat.js // deliberately leaves the composer untouched, then programmatically clicks the diff --git a/static/js/document.js b/static/js/document.js index 0aa6bad98..06e377a01 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -5928,7 +5928,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1'; } async function _handleMarkdownImageUpload(e) { - const files = e.target.files; + const files = Array.from(e.target.files || []); e.target.value = ''; await _uploadMarkdownImages(files); } diff --git a/static/js/emailLibrary/attachments.js b/static/js/emailLibrary/attachments.js index 792a881c1..7c53eca4c 100644 --- a/static/js/emailLibrary/attachments.js +++ b/static/js/emailLibrary/attachments.js @@ -187,7 +187,7 @@ function _wireAttachmentHandlers(reader, folder) { ownerModal.classList.add('hidden'); } } - const docMod = await import('../document.js?v=20261028emailpickerfiles1'); + const docMod = await import('../document.js?v=20261028emailpickerfiles2'); const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument); if (typeof load === 'function') { await load(json.doc_id); diff --git a/static/js/slashCommands.js b/static/js/slashCommands.js index a3c793487..931a6e80a 100644 --- a/static/js/slashCommands.js +++ b/static/js/slashCommands.js @@ -16,7 +16,7 @@ import modelsModule from './models.js'; import chatRenderer from './chatRenderer.js?v=20260914metricssummary1'; import spinnerModule from './spinner.js'; import themeModule from './theme.js?v=20260911organsrain1'; -import documentModule from './document.js?v=20261028emailpickerfiles1'; +import documentModule from './document.js?v=20261028emailpickerfiles2'; import workspaceModule from './workspace.js'; import settingsModule from './settings.js?v=20260912writingstyle3'; import cookbookModule from './cookbook.js'; diff --git a/static/sw.js b/static/sw.js index 51f98564e..f91cd0981 100644 --- a/static/sw.js +++ b/static/sw.js @@ -7,7 +7,7 @@ // - Other static assets (images/fonts/libs): cache-first with bg refresh. // - API / non-GET: never cached. // Bump CACHE_NAME whenever the precache list or SW logic changes. -const CACHE_NAME = 'odysseus-v660-email-picker-files'; +const CACHE_NAME = 'odysseus-v661-email-picker-files'; // KaTeX resolves these from its own stylesheet, so caching the CSS without them // gives offline math fallback glyphs instead of proper typesetting. @@ -80,7 +80,7 @@ const PRECACHE = [ '/static/js/search.js', '/static/js/spinner.js', '/static/js/tts-ai.js', - '/static/js/document.js?v=20261028emailpickerfiles1', + '/static/js/document.js?v=20261028emailpickerfiles2', '/static/js/gallery.js?v=20260910promptcopy1', '/static/js/chatRenderer.js?v=20260914metricssummary1', '/static/js/codeRunner.js?v=20260831richtexttools91', diff --git a/tests/test_email_paperclip_files_browser.py b/tests/test_email_paperclip_files_browser.py index c0f753e3e..9582c8602 100644 --- a/tests/test_email_paperclip_files_browser.py +++ b/tests/test_email_paperclip_files_browser.py @@ -9,35 +9,41 @@ import pytest from tests.helpers.document_source import function_body -@pytest.mark.parametrize("selections", [[["one.txt"]], [["one.txt", "two.csv"]], [["one.txt"], ["one.txt"]], [[]]]) -def test_compose_file_selection_survives_reset(tmp_path, selections): +@pytest.mark.parametrize("handler,uploader", [("_handleAttachUpload", "_uploadComposeFiles"), ("_handleMarkdownImageUpload", "_uploadMarkdownImages")]) +@pytest.mark.parametrize("selections", [[["one.png"]], [["one.png", "two.png"]], [["one.png"], ["one.png"]], [[]]]) +def test_compose_file_selection_survives_reset(tmp_path, selections, handler, uploader): if not shutil.which("node"): pytest.skip("node not installed") - for name, content in [("one.txt", "Synthetic attachment one\n"), ("two.csv", 'name,value\n"fixture",2\n')]: - (tmp_path / name).write_text(content, encoding="utf-8") + repo = Path(__file__).resolve().parents[1] + for name, icon in [("one.png", "icon-192.png"), ("two.png", "icon-512.png")]: + shutil.copyfile(repo / "static/icons" / icon, tmp_path / name) script = r""" -const fs=require('node:fs'),assert=require('node:assert/strict'); +const fs=require('node:fs'),assert=require('node:assert/strict'),crypto=require('node:crypto'); let chromium;try{({chromium}=require('playwright'));}catch(e){if(e.code==='MODULE_NOT_FOUND')process.exit(77);throw e;} (async()=>{ const browser=await chromium.launch({headless:true}); try{ const page=await browser.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message)); + await page.goto(process.env.ODYSSEUS_TEST_STATIC_ORIGIN+'/static/js/storage.js'); await page.setContent(''); await page.addScriptTag({content:` window.uploads=[];window.finished=0; - async function _uploadComposeFiles(files){ - for(const file of files)window.uploads.push({name:file.name,text:await file.text()}); + async function __UPLOADER__(files){ + for(const file of files){ + const hash=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',await file.arrayBuffer()))).map(x=>x.toString(16).padStart(2,'0')).join(''); + window.uploads.push({name:file.name,hash}); + } } __HANDLER__ document.querySelector('input').addEventListener('change',async event=>{ - await _handleAttachUpload(event);window.finished++; + await __HANDLER_NAME__(event);window.finished++; }); `}); const selections=JSON.parse(process.argv[1]),root=process.argv[2],expected=[]; for(const [index,names] of selections.entries()){ await page.getByLabel('Compose attachments').setInputFiles(names.map(name=>root+'/'+name)); await page.waitForFunction(n=>window.finished===n,index+1); - expected.push(...names.map(name=>({name,text:fs.readFileSync(root+'/'+name,'utf8')}))); + expected.push(...names.map(name=>({name,hash:crypto.createHash('sha256').update(fs.readFileSync(root+'/'+name)).digest('hex')}))); assert.deepEqual(await page.evaluate(()=>window.uploads),expected); assert.equal(await page.getByLabel('Compose attachments').inputValue(),''); assert.equal(await page.getByLabel('Compose attachments').evaluate(el=>el.files.length),0); @@ -45,7 +51,7 @@ let chromium;try{({chromium}=require('playwright'));}catch(e){if(e.code==='MODUL assert.deepEqual(errors,[]);console.log(JSON.stringify({files:expected.length})); }finally{await browser.close();} })().catch(e=>{console.error(e);process.exitCode=1;}); -""".replace("__HANDLER__", function_body("_handleAttachUpload")) +""".replace("__HANDLER__", function_body(handler)).replace("__HANDLER_NAME__", handler).replace("__UPLOADER__", uploader) result = subprocess.run(["node", "-e", script, json.dumps(selections), str(tmp_path)], cwd=Path(__file__).resolve().parents[1], capture_output=True, text=True, timeout=30) From f780f1dce919d5a277895272268dfe1eda296ff5 Mon Sep 17 00:00:00 2001 From: red person Date: Wed, 28 Oct 2026 10:04:55 +0300 Subject: [PATCH 3/4] test(email): align document identity guards with picker cache --- tests/test_active_document_visibility_contract.py | 2 +- tests/test_document_edit_reference_js.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_active_document_visibility_contract.py b/tests/test_active_document_visibility_contract.py index 1423c1670..fe3bb4f09 100644 --- a/tests/test_active_document_visibility_contract.py +++ b/tests/test_active_document_visibility_contract.py @@ -55,7 +55,7 @@ def test_all_runtime_document_imports_share_one_module_url(): for path in runtime_files for match in re.findall(r"document\.js\?v=([A-Za-z0-9_-]+)", path.read_text(encoding="utf-8")) } - assert versions == {"20260916docctx2"} + assert versions == {"20261028emailpickerfiles2"} def test_server_fallback_is_legacy_only_when_ui_state_is_absent(): diff --git a/tests/test_document_edit_reference_js.py b/tests/test_document_edit_reference_js.py index e214a6b06..3c8bdcb6a 100644 --- a/tests/test_document_edit_reference_js.py +++ b/tests/test_document_edit_reference_js.py @@ -42,8 +42,8 @@ def test_document_reference_looks_clickable_and_has_keyboard_focus_feedback(): def test_document_module_has_one_browser_identity_for_restore_and_chat_send(): """Different query strings create separate JS module selection stores.""" - assert "/static/js/document.js?v=20260916docctx2" in INDEX - assert "./js/document.js?v=20260916docctx2" in APP + assert "/static/js/document.js?v=20261028emailpickerfiles2" in INDEX + assert "./js/document.js?v=20261028emailpickerfiles2" in APP assert "document.js?v=20260913dirtysaveicon1" not in INDEX From 2cace277237653a8d62b2145659a5910d69dd268 Mon Sep 17 00:00:00 2001 From: red person Date: Thu, 29 Oct 2026 23:37:24 +0300 Subject: [PATCH 4/4] test: isolate photo routing from external MCP registry state --- tests/test_photo_editor_family.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_photo_editor_family.py b/tests/test_photo_editor_family.py index ce3caa456..56bac0ab0 100644 --- a/tests/test_photo_editor_family.py +++ b/tests/test_photo_editor_family.py @@ -144,10 +144,12 @@ def test_editor_agent_endpoint_reports_scoped_tools(): async def _route_contract(monkeypatch, message, *, attachment_meta=(), form=None, messages_out=None): from routes import chat_routes - from src import tool_security + from src import tool_security, tool_utils from tests.test_foreground_model_routing import _RouteRequest, _chat_stream_endpoint with monkeypatch.context() as route_patch: + # This built-in catalog fixture has no configured external MCP servers. + route_patch.setattr(tool_utils, "get_mcp_manager", lambda: None) endpoint = _chat_stream_endpoint( route_patch, "agent", {}, session_model="odysseus-qwen3.5-tools-pre-heretic",