diff --git a/cmd/odek/ui/js/input.js b/cmd/odek/ui/js/input.js index 58b8fa68..07df315d 100644 --- a/cmd/odek/ui/js/input.js +++ b/cmd/odek/ui/js/input.js @@ -89,7 +89,7 @@ S.pauseQueue = () => { S.queuePaused = true; renderQueueStrip(); }; // ── Send ── export function send() { - if (S.uploading) { showToast('Wait for attachments to finish uploading'); return; } + if (S.uploading) { showToast('Wait for attachments to finish processing'); return; } // F-B2: dead socket still rejects BEFORE touching attachments. if (!S.ws || S.ws.readyState !== WebSocket.OPEN) { showToast('connection lost — reconnecting'); @@ -285,8 +285,22 @@ function readFileAsText(file) { }); } -let uploadSequence=Promise.resolve(); -function handleFiles(fileList) { const files=Array.from(fileList);const owner=S.sessionId;uploadSequence=uploadSequence.then(()=>processFiles(files,owner));return uploadSequence; } +let uploadSequence = Promise.resolve(); +let pendingFileBatches = 0; +function handleFiles(fileList) { + const files = Array.from(fileList); + if (!files.length) return uploadSequence; + const owner = S.sessionId; + pendingFileBatches++; + S.uploading = true; + sendBtn.disabled = true; + uploadSequence = uploadSequence.then(() => processFiles(files, owner)).finally(() => { + pendingFileBatches--; + S.uploading = pendingFileBatches > 0; + sendBtn.disabled = S.busy || S.uploading || !S.ws || S.ws.readyState !== WebSocket.OPEN; + }); + return uploadSequence; +} async function processFiles(fileList, owner) { // Serialize uploads so files attached to a new conversation share one session. for (const file of fileList) { @@ -297,14 +311,14 @@ async function processFiles(fileList, owner) { if (/^(image\/|audio\/|application\/pdf$)/.test(file.type)) { if(S.busy){addErrorChip(file.name,'Wait for the current turn before uploading media');continue;} const sid=S.sessionId; - S.uploading=true;sendBtn.disabled=true; const progress=document.createElement('span');progress.className='file-chip';progress.textContent='Uploading '+file.name+'…';fileChips.appendChild(progress); - let data;try{data=await uploadMedia(file,sid,getSessionToken(sid));}finally{progress.remove();S.uploading=false;sendBtn.disabled=S.busy || !S.ws || S.ws.readyState!==WebSocket.OPEN;} + let data;try{data=await uploadMedia(file,sid,getSessionToken(sid));}finally{progress.remove();} if(S.sessionId!==sid){addErrorChip(file.name,'Session changed during upload; attach again');continue;} S.sessionId=data.session_id;owner=data.session_id;setSessionToken(data.session_id,data.auth_token); addAttachedFile({name:file.name,size:file.size,upload_id:data.upload_id,content:''}); } else { - const content=await readFileAsText(file); + const progress=document.createElement('span');progress.className='file-chip';progress.textContent='Reading '+file.name+'…';fileChips.appendChild(progress); + let content;try{content=await readFileAsText(file);}finally{progress.remove();} if (S.sessionId !== owner) { showToast('Session changed while reading the attachment.'); return; } if(content.includes('\u0000'))throw new Error('Unsupported binary attachment'); addAttachedFile({name:file.name,size:file.size,content}); diff --git a/cmd/odek/ui/js/lifecycle.test.js b/cmd/odek/ui/js/lifecycle.test.js index a28d880f..2649c059 100644 --- a/cmd/odek/ui/js/lifecycle.test.js +++ b/cmd/odek/ui/js/lifecycle.test.js @@ -1001,3 +1001,89 @@ test('management ignores pre-reset responses and reloads when reopened', async ( await new Promise(resolve => setTimeout(resolve, 0)); } finally { globalThis.fetch = oldFetch; inspector.togglePanels(false); } }); + +test('session search ignores an older response that arrives last', async () => { + const oldFetch = globalThis.fetch; + const requests = []; + try { + globalThis.fetch = (path) => new Promise(resolve => requests.push({ path, resolve })); + S.sessionSearch = 'old'; + const oldLoad = sessions.loadSessions(); + S.sessionSearch = 'new'; + const newLoad = sessions.loadSessions(); + assert.equal(requests.length, 2); + + const reply = (id) => ({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ sessions: [{ id, task: id, turns: 1 }] }), + }); + requests[1].resolve(reply('new-id')); + await newLoad; + requests[0].resolve(reply('old-id')); + await oldLoad; + assert.equal(S.sessionSearch, 'new'); + assert.equal(S.sessionPages[0].id, 'new-id'); + } finally { + globalThis.fetch = oldFetch; + S.sessionSearch = ''; + } +}); + +test('concurrent More clicks fetch one page and advance the offset once', async () => { + const oldFetch = globalThis.fetch; + const requests = []; + try { + S.sessionPages = Array.from({ length: 50 }, (_, i) => ({ id: 'first-' + i, task: 'first' })); + S.sessionOffset = 50; + S.sessionsExhausted = false; + globalThis.fetch = (path) => new Promise(resolve => requests.push({ path, resolve })); + const first = sessions.loadSessionsMore(); + const second = sessions.loadSessionsMore(); + assert.equal(requests.length, 1); + assert.match(requests[0].path, /offset=50/); + assert.equal(byId['sessions-more'].disabled, true); + requests[0].resolve({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ sessions: Array.from({ length: 50 }, (_, i) => ({ id: 'second-' + i, task: 'second' })) }), + }); + await Promise.all([first, second]); + assert.equal(S.sessionPages.length, 100); + assert.equal(S.sessionOffset, 100); + assert.equal(byId['sessions-more'].disabled, false); + } finally { globalThis.fetch = oldFetch; } +}); + +test('send waits for a text attachment read and includes it afterward', async () => { + const oldReader = globalThis.FileReader; + let reader; + try { + globalThis.FileReader = class { + constructor() { reader = this; } + readAsText() {} + }; + byId['file-input'].files = [{ name: 'note.txt', size: 5, type: 'text/plain' }]; + byId['file-input'].dispatch('change'); + await Promise.resolve(); + await Promise.resolve(); + assert.ok(reader, 'file reading has started'); + assert.equal(S.uploading, true); + assert.equal(byId['send-btn'].disabled, true); + + byId.prompt.value = 'summarize attachment'; + input.send(); + assert.equal(S.ws.sent.length, 0); + assert.equal(byId.prompt.value, 'summarize attachment'); + + reader.result = 'hello'; + reader.onload(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(S.uploading, false); + assert.equal(S.attachedFiles.length, 1); + input.send(); + const sent = JSON.parse(S.ws.sent.at(-1)); + assert.equal(sent.type, 'prompt'); + assert.deepEqual(sent.attachments, [{ name: 'note.txt', content: 'hello' }]); + } finally { globalThis.FileReader = oldReader; } +}); diff --git a/cmd/odek/ui/js/render.js b/cmd/odek/ui/js/render.js index 999e716b..d441ff4c 100644 --- a/cmd/odek/ui/js/render.js +++ b/cmd/odek/ui/js/render.js @@ -641,7 +641,7 @@ export function endStream(reason = "interrupted") { S.busy = false; hideLoading(); hideCancel(); - sendBtn.disabled = !S.ws || S.ws.readyState !== WebSocket.OPEN; + sendBtn.disabled = S.uploading || !S.ws || S.ws.readyState !== WebSocket.OPEN; promptEl.disabled = false; promptEl.focus(); } diff --git a/cmd/odek/ui/js/sessions.js b/cmd/odek/ui/js/sessions.js index 08f67f17..c61b8aa7 100644 --- a/cmd/odek/ui/js/sessions.js +++ b/cmd/odek/ui/js/sessions.js @@ -14,6 +14,8 @@ import { resetPlanPanel } from './plan.js'; const PAGE_SIZE = 50; const moreBtn = document.getElementById('sessions-more'); +let sessionListVersion = 0; +let moreLoading = false; // syncSidebarCount updates the header badge with the visible session count. function syncSidebarCount() { @@ -34,31 +36,47 @@ function updateActiveSessionItem() { // loadSessions fetches the first page for the current search query and // replaces the list. Called on init, refresh, and after turns complete. export async function loadSessions() { + const version = ++sessionListVersion; + moreLoading = false; S.sessionOffset = 0; S.sessionPages = []; S.sessionsExhausted = false; - await loadSessionsPage(true); + syncMoreButton(); + await loadSessionsPage(true, version); } S.refreshSessions = loadSessions; // loadSessionsMore appends the next page (if any). export async function loadSessionsMore() { - if (S.sessionsExhausted) return; - await loadSessionsPage(false); + if (S.sessionsExhausted || !S.sessionPages.length || moreLoading) return; + const version = sessionListVersion; + moreLoading = true; + syncMoreButton(); + try { + await loadSessionsPage(false, version); + } finally { + if (version === sessionListVersion) { + moreLoading = false; + syncMoreButton(); + } + } } -async function loadSessionsPage(replace) { +async function loadSessionsPage(replace, version) { // Skeleton rows only on cold start (empty list); refreshes keep the // existing items so scroll position and hover state survive. if (replace && !sessionListEl.querySelector('.session-item')) { sessionListEl.innerHTML = '
'.repeat(6); } + const query = S.sessionSearch; + const offset = S.sessionOffset; try { const data = await listSessions({ - q: S.sessionSearch, + q: query, limit: PAGE_SIZE, - offset: S.sessionOffset, + offset, }); + if (version !== sessionListVersion || query !== S.sessionSearch) return; const sessions = (data && data.sessions) || []; S.allSessions = sessions; @@ -71,11 +89,12 @@ async function loadSessionsPage(replace) { const seen = new Set(S.sessionPages.map(s => s.id)); S.sessionPages = S.sessionPages.concat(sessions.filter(s => !seen.has(s.id))); } - S.sessionOffset += sessions.length; + S.sessionOffset = offset + sessions.length; S.sessionsExhausted = sessions.length < PAGE_SIZE; renderSessionItems(); } catch (err) { + if (version !== sessionListVersion || query !== S.sessionSearch) return; sessionListEl.querySelectorAll('.session-skel').forEach(el => el.remove()); // A silent catch here is how a stale list goes unnoticed — say it. showToast('Session list refresh failed'); @@ -138,6 +157,7 @@ function renderSessionItems() { function syncMoreButton() { if (!moreBtn) return; moreBtn.hidden = S.sessionsExhausted || !S.sessionPages.length; + moreBtn.disabled = moreLoading; } if (moreBtn) moreBtn.addEventListener('click', loadSessionsMore); @@ -231,7 +251,7 @@ export function newSession() { clearClarify(); S.busy = false; hideLoading(); hideCancel(); - sendBtn.disabled = !S.ws || S.ws.readyState !== WebSocket.OPEN; + sendBtn.disabled = S.uploading || !S.ws || S.ws.readyState !== WebSocket.OPEN; promptEl.disabled = false; // Clear messages and restore empty state. @@ -304,7 +324,7 @@ export async function loadAndRenderSession(sid) { clearApprovals(); clearClarify(); S.busy = false; hideLoading(); hideCancel(); - sendBtn.disabled = !S.ws || S.ws.readyState !== WebSocket.OPEN; + sendBtn.disabled = S.uploading || !S.ws || S.ws.readyState !== WebSocket.OPEN; promptEl.disabled = false; messagesEl.innerHTML = ''; diff --git a/cmd/odek/ui/js/ws.js b/cmd/odek/ui/js/ws.js index a5aaec58..3c45a577 100644 --- a/cmd/odek/ui/js/ws.js +++ b/cmd/odek/ui/js/ws.js @@ -120,7 +120,7 @@ export function connect() { lostNotified = false; dotEl.className = 'dot connected'; statusEl.textContent = 'connected'; - sendBtn.disabled = false; + sendBtn.disabled = !!S.uploading; reconnectDelay = 1000; // Hide loading skeleton when connected if (skeletonEl) skeletonEl.classList.remove('visible');