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
26 changes: 20 additions & 6 deletions cmd/odek/ui/js/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand All @@ -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});
Expand Down
86 changes: 86 additions & 0 deletions cmd/odek/ui/js/lifecycle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
});
2 changes: 1 addition & 1 deletion cmd/odek/ui/js/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
38 changes: 29 additions & 9 deletions cmd/odek/ui/js/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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 = '<div class="session-skel"></div>'.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;

Expand All @@ -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');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 = '';
Expand Down
2 changes: 1 addition & 1 deletion cmd/odek/ui/js/ws.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading