fix(webui): keep remote draft, room context and drawer focus across polls (#797)

This commit is contained in:
jason.woltje committed 2026-11-05 23:18:04 -06:00
1 parent 5a346825b7
commit b64773956e
6 files changed
+113 -15

No files matched your search

+7 -3
View File
@@ -247,11 +247,13 @@
// ---------------------------------------------------------------- views
let inboxCount = null, messageCount = null;
function sections(route) {
const focused = $('sections').contains(document.activeElement) ? document.activeElement.getAttribute('href') : null;
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/projects', 'Projects', '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true"><use href="/icons.svg#i-${icon}"/></svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
}).join('');
if (focused) [...$('sections').querySelectorAll('a[href]')].find(a => a.getAttribute('href') === focused)?.focus({ preventScroll: true });
}
function countInbox(rows) { inboxCount = { open: rows.length, blocking: rows.filter(d => d.blocking).length }; }
@@ -1491,12 +1493,13 @@
const conversation = view.querySelector('.chat-conversation, .chat-room');
const projectLink = conversation?.querySelector('p a[href^="#/chat/projects/"], p a[href^="#/projects/"]');
const overview = /^\/(?:projects\/|chat\/projects\/)([0-9a-f-]+)$/i.exec(path);
const validContext = projectContext && (overview?.[1] === projectContext.projectId || conversation?.dataset.thread === projectContext.thread || path.includes('/rooms/' + projectContext.projectId) || path === '/projects/' + projectContext.projectId + '/settings');
const isRoom = projectContext?.room === true && conversation?.dataset.project === projectContext.projectId && new RegExp('^/(?:projects|chat)/rooms/' + projectContext.projectId + '$', 'i').test(path);
const validContext = projectContext && (overview?.[1] === projectContext.projectId || (projectContext.thread && conversation?.dataset.thread === projectContext.thread) || isRoom || path === '/projects/' + projectContext.projectId + '/settings');
const project = validContext ? (projectLink?.textContent || view.querySelector('.chat-area h1')?.textContent || projectContext.projectId) : '';
const name = conversation?.querySelector('h1')?.textContent || '';
const href = validContext ? '#/projects/' + encodeURIComponent(projectContext.projectId) : '';
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
const threadHref = validContext && uuid(projectContext.thread) ? '#/projects/threads/' + encodeURIComponent(projectContext.thread) + (uuid(projectContext.session) ? '/sessions/' + encodeURIComponent(projectContext.session) : '') : '';
const threadHref = isRoom ? '#/projects/rooms/' + encodeURIComponent(projectContext.projectId) : validContext && uuid(projectContext.thread) ? '#/projects/threads/' + encodeURIComponent(projectContext.thread) + (uuid(projectContext.session) ? '/sessions/' + encodeURIComponent(projectContext.session) : '') : '';
const signature = JSON.stringify([project || '', name, href, threadHref]);
if (context.dataset.signature !== signature) {
context.dataset.signature = signature; context.replaceChildren();
@@ -1548,7 +1551,8 @@
if (e.key !== 'Tab') return;
const controls = [...nav.querySelectorAll('a[href],button:not(:disabled),input:not(:disabled),select:not(:disabled),[tabindex="0"]')].filter(el => el.getClientRects().length);
if (!controls.length) return;
if (e.shiftKey && document.activeElement === controls[0]) { e.preventDefault(); controls.at(-1).focus(); }
if (!nav.contains(document.activeElement)) { e.preventDefault(); (e.shiftKey ? controls.at(-1) : controls[0]).focus(); }
else if (e.shiftKey && document.activeElement === controls[0]) { e.preventDefault(); controls.at(-1).focus(); }
else if (!e.shiftKey && document.activeElement === controls.at(-1)) { e.preventDefault(); controls[0].focus(); }
});
nav.addEventListener('click', e => { if (drawerOpen && e.target.closest('a[href^="#/"]')) drawer(false, false); });
+3 -1
View File
@@ -310,6 +310,8 @@
const page = await request(`rooms/${id}/messages?limit=100${after ? '&after=' + after : ''}`, undefined, signal);
if (g !== generation || out()) return;
if (typeof page.hasMore !== 'boolean' || !(page.after === null || UUID.test(page.after)) || page.rows.some(m => !UUID.test(m?.id) || !Array.isArray(m.ids) || !m.ids.every(id => UUID.test(id)) || !Array.isArray(m.to))) throw new ChatError('invalid-response');
const project = current.projects.find(p => p.id === id);
document.dispatchEvent(new CustomEvent('mosaic-project-context', { detail: { projectId: id, repositoryKey: project.repo ?? null, thread: null, session: null, room: true } }));
current.channelDisabled = false;
s.rows = [...new Map((after ? [...s.rows, ...page.rows] : page.rows).map(m => [m.id, m])).values()].slice(-200);
s.after = page.after ?? s.after; s.more = page.hasMore; s.loaded = true;
@@ -423,7 +425,7 @@
}
const remote = c.container.querySelector('[data-chat-remote]');
const project = c.projects.find(p => p.id === c.id);
if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null }); }
if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, preserve: true }); }
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
const details = c.container.querySelector('.chat-details');
if (details && keep?.detailsOpen) details.open = true;
+3 -3
View File
@@ -14,13 +14,13 @@
}
function status(message) { const el = document.getElementById('display-status'); if (el) el.textContent = message; }
function render(container, navigated) {
container.innerHTML = `<div class="page-head"><div><h1 tabindex="-1">Sidebar display</h1><p>These choices apply in this browser.</p></div></div><section class="panel display-settings"><form id="display-form"><fieldset><legend>Density</legend><label><input type="radio" name="density" value="comfortable"> Comfortable</label><label><input type="radio" name="density" value="compact"> Compact</label></fieldset><fieldset><legend>Sidebar order</legend><label><input type="radio" name="order" value="title-first"> Title first</label><label><input type="radio" name="order" value="contact-first"> Contact first</label></fieldset><p class="small muted">Preview uses the current sidebar destinations.</p><div id="display-preview" class="display-preview"></div><button class="btn primary" type="submit">Save</button><p id="display-status" role="status"></p></form></section>`;
container.innerHTML = `<div class="page-head"><div><h1 tabindex="-1">Sidebar display</h1><p>These choices apply in this browser.</p></div></div><section class="panel display-settings"><form id="display-form"><fieldset><legend>Density</legend><label><input type="radio" name="density" value="comfortable"> Comfortable</label><label><input type="radio" name="density" value="compact"> Compact</label></fieldset><fieldset disabled><legend>Sidebar order</legend><label><input type="radio" name="order" value="title-first"> Title first</label><label><input type="radio" name="order" value="contact-first"> Contact first</label></fieldset><p class="small muted">Sidebar ordering is not available yet.</p><p class="small muted">Preview uses the current sidebar destinations.</p><div id="display-preview" class="display-preview"></div><button class="btn primary" type="submit">Save</button><p id="display-status" role="status"></p></form></section>`;
const form = document.getElementById('display-form');
for (const name of ['density', 'order']) form.elements[name].value = draft[name];
const preview = document.getElementById('display-preview');
preview.textContent = [...document.querySelectorAll('#sections .s1-section')].map(el => el.textContent.trim()).join(' · ');
form.addEventListener('change', () => { draft = { version: 1, density: form.elements.density.value, order: form.elements.order.value }; dirty = JSON.stringify(draft) !== JSON.stringify(saved); status(dirty ? 'Unsaved changes.' : ''); });
form.addEventListener('submit', e => { e.preventDefault(); status(save() ? 'Sidebar display saved.' : 'Could not save to local browser storage. Your choices are kept. Try Save again, edit, or discard when leaving.'); });
form.addEventListener('change', () => { draft = { version: 1, density: form.elements.density.value, order: saved.order }; dirty = JSON.stringify(draft) !== JSON.stringify(saved); status(dirty ? 'Unsaved changes.' : ''); });
form.addEventListener('submit', e => { e.preventDefault(); if (!dirty) { status('No display changes to save.'); return; } status(save() ? 'Sidebar display saved.' : 'Could not save to local browser storage. Your choices are kept. Try Save again, edit, or discard when leaving.'); });
document.title = 'Sidebar display · Mosaic Console';
if (navigated) container.querySelector('h1').focus();
}
+13 -4
View File
@@ -2,15 +2,24 @@
(() => {
'use strict';
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/;
let signedOut = false;
document.addEventListener('mosaic-signed-out', () => { signedOut = true; });
function mount({ container, projectId, repositoryKey }) {
let signedOut = false, active = null;
document.addEventListener('mosaic-signed-out', () => { signedOut = true; active?.dispose(); active = null; });
document.addEventListener('mosaic-project-context', event => {
if (active && event.detail?.projectId !== active.projectId) { active.dispose(); active = null; }
});
function mount({ container, projectId, repositoryKey, preserve = false }) {
if (!(container instanceof Element)) return;
if (preserve && active?.projectId === projectId && active.repositoryKey === repositoryKey) {
container.replaceChildren(active.section); return;
}
if (active) { active.dispose(); active = null; }
let generation = 0, data = null, pending = false, draft = '', error = '', stale = false, canWrite = false;
const section = document.createElement('section');
section.className = 'project-remote';
container.replaceChildren(section);
document.addEventListener('mosaic-signed-out', () => { generation++; draft = ''; data = null; section.replaceChildren(); }, { once: true });
const dispose = () => { generation++; draft = ''; data = null; section.replaceChildren(); };
if (preserve) active = { projectId, repositoryKey, section, dispose };
else document.addEventListener('mosaic-signed-out', dispose, { once: true });
const element = (tag, content, attrs = {}) => {
const node = document.createElement(tag);
if (content !== null) node.textContent = content;
@@ -0,0 +1,83 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { w15Fixture, ids } from './w15-fixture.mjs';
const settings = `#/projects/${ids.project}/settings`;
const thread = `#/projects/threads/${ids.thread}/sessions/${ids.session}`;
const room = `#/projects/rooms/${ids.project}`;
const remoteScenario = {
'/api/config': { send: true, projectRemotes: true, projectRemoteWrites: true },
[`/api/project-remotes/${ids.repository}`]: { project: ids.repository, remote: null, revision: 'a'.repeat(64), firstSaveAllowed: true },
};
const roomScenario = { [`/api/chat/rooms/${ids.project}/messages?limit=100`]: { rows: [], after: null, hasMore: false } };
const input = `const el=document.querySelector('#project-remote-url'); el.value='https://git.example/owner/unsaved'; el.dispatchEvent(new Event('input',{bubbles:true}));`;
const refresh = `document.querySelector('#refresh').click()`;
const replaced = `new Promise((resolve,reject)=>{const deadline=Date.now()+16000;const tick=()=>{if(document.querySelector('#bus-view .chat-area')!==window.__oldPaint && document.querySelector('#bus-view .chat-area'))resolve(true);else if(Date.now()>deadline)reject(Error('No repaint'));else setTimeout(tick,25)};tick()})`;
test('remote draft survives a real polling repaint and resets on project change', { timeout: 40000 }, async t => {
const { f, b, wait } = await w15Fixture(t, { scenario: remoteScenario });
await b.navigate(f.base + '/' + settings); await wait('!!document.querySelector("#project-remote-url")');
await b.evaluate(`${input} window.__oldPaint=document.querySelector('#bus-view .chat-area')`);
await b.evaluate(replaced);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url')?.value`), 'https://git.example/owner/unsaved');
await b.evaluate(`location.hash='#/projects/${ids.empty}/settings'`); await wait(`document.querySelector('.project-remote')?.textContent.includes('no linked repository')`);
await b.evaluate(`location.hash=${JSON.stringify(settings)}`); await wait(`!!document.querySelector('#project-remote-url')`);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), '');
});
test('remote pending save and success survive shell refresh', { timeout: 30000 }, async t => {
const { f, b, wait } = await w15Fixture(t, { scenario: remoteScenario });
await b.navigate(f.base + '/' + settings); await wait('!!document.querySelector("#project-remote-url")');
await b.evaluate(`window.__fetch=fetch; window.fetch=async (url,init={})=>{if(init.method==='POST' && String(url).includes('/api/project-remotes/')) return new Promise(resolve=>window.__finish=()=>resolve(new Response(JSON.stringify({remote:'https://git.example/owner/unsaved'}),{status:200,headers:{'content-type':'application/json'}})));return __fetch(url,init)}; ${input} document.querySelector('.project-remote form').requestSubmit();`);
await wait('!!window.__finish'); await b.evaluate(`window.__oldPaint=document.querySelector('#bus-view .chat-area'); ${refresh}`); await b.evaluate(replaced);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url')?.disabled`), true);
await b.evaluate('__finish()'); await wait(`document.querySelector('.project-remote')?.textContent.includes('Configured, not verified')`);
assert.match(await b.evaluate(`document.querySelector('.project-remote').textContent`), /https:\/\/git.example\/owner\/unsaved/);
});
test('remote unknown outcome and reconciliation survive shell refresh', { timeout: 30000 }, async t => {
const { f, b, wait } = await w15Fixture(t, { scenario: remoteScenario });
await b.navigate(f.base + '/' + settings); await wait('!!document.querySelector("#project-remote-url")');
await b.evaluate(`window.__fetch=fetch; window.fetch=async (url,init={})=>{if(init.method==='POST' && String(url).includes('/api/project-remotes/')) throw Error('lost');return __fetch(url,init)}; ${input} document.querySelector('.project-remote form').requestSubmit();`);
await wait(`document.querySelector('.project-remote')?.textContent.includes('Save outcome is unknown')`);
await b.evaluate(`window.__oldPaint=document.querySelector('#bus-view .chat-area'); ${refresh}`); await b.evaluate(replaced);
assert.match(await b.evaluate(`document.querySelector('.project-remote').textContent`), /Save outcome is unknown/);
assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), true);
await b.evaluate(`document.querySelector('.project-remote button[type=button]').click()`); await wait(`document.querySelector('.project-remote').textContent.includes('Review your entry')`);
assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), 'https://git.example/owner/unsaved');
});
test('room context replaces thread on navigation and on a cold visit', { timeout: 30000 }, async t => {
const { f, b, wait } = await w15Fixture(t, { scenario: roomScenario });
await b.navigate(f.base + '/' + thread); await wait('!!document.querySelector("#project-context .context-conversation")');
await b.evaluate(`location.hash=${JSON.stringify(room)}`); await wait('!!document.querySelector(".chat-room")');
await wait(`document.querySelector('#project-context .context-conversation')?.getAttribute('href')===${JSON.stringify(room)}`);
assert.equal(await b.evaluate(`document.querySelector('#project-context .context-conversation').getAttribute('href')`), room);
await b.navigate(f.base + '/' + room); await wait('!!document.querySelector(".chat-room")');
await wait(`document.querySelector('#project-context .context-conversation')?.getAttribute('href')===${JSON.stringify(room)}`);
assert.equal(await b.evaluate(`document.querySelector('#project-context .context-project').getAttribute('href')`), `#/projects/${ids.project}`);
});
test('drawer destination retains focus and traps Tab across a real poll', { timeout: 40000 }, async t => {
const { f, b, wait } = await w15Fixture(t); await b.viewport(390,1000);
await b.navigate(f.base + '/' + thread); await wait('!!document.querySelector("#chat-body")');
const href = await b.evaluate(`document.querySelector('#nav-open').click();window.__oldLink=document.querySelector('#sections a');__oldLink.focus();__oldLink.getAttribute('href')`);
await b.evaluate(`new Promise((resolve,reject)=>{const deadline=Date.now()+16000;const tick=()=>{if(document.querySelector('#sections a')!==window.__oldLink)resolve(true);else if(Date.now()>deadline)reject(Error('No poll'));else setTimeout(tick,25)};tick()})`);
assert.equal(await b.evaluate(`document.activeElement.getAttribute('href')`), href);
await b.key('Tab'); assert.equal(await b.evaluate(`!!document.activeElement.closest('#console-nav')`), true);
await b.key('Tab','Tab',8); assert.equal(await b.evaluate(`document.activeElement.getAttribute('href')`), href);
await b.evaluate(`document.querySelector('#nav-close').focus()`); await b.key('Tab','Tab',8);
assert.equal(await b.evaluate(`!!document.activeElement.closest('#console-nav')`), true);
await b.key('Tab'); assert.equal(await b.evaluate(`document.activeElement.id`), 'nav-close');
});
test('unsupported sidebar order is disabled while density remains saveable', { timeout: 30000 }, async t => {
const { f, b, wait } = await w15Fixture(t); await b.navigate(f.base + '/#/settings/display'); await wait('!!document.querySelector("#display-form")');
assert.equal(await b.evaluate(`document.querySelector('[name=order][value=contact-first]').matches(':disabled')`), true);
assert.match(await b.evaluate(`document.querySelector('.display-settings').textContent`), /ordering.*not available/i);
await b.evaluate(`document.querySelector('[name=order][value=contact-first]').click();document.querySelector('#display-form').requestSubmit()`);
assert.doesNotMatch(await b.evaluate(`document.querySelector('#display-status').textContent`), /saved/i);
await b.evaluate(`document.querySelector('[name=density][value=compact]').click();document.querySelector('#display-form').requestSubmit()`);
assert.match(await b.evaluate(`document.querySelector('#display-status').textContent`), /saved/i);
assert.equal(await b.evaluate(`document.documentElement.dataset.sidebarDensity`), 'compact');
});
@@ -62,7 +62,7 @@ test('display module on the real fixture server: save, failure and destination',
await b.navigate(f.base+'/#/settings/display');
await wait('!!document.querySelector("#display-form")');
assert.equal(await b.evaluate('document.querySelector("[name=density]:checked").value'), 'compact');
await b.evaluate('document.querySelector("[name=order][value=contact-first]").click();Storage.prototype.setItem=function(){throw new Error("blocked")};location.hash="#/inbox"');
await b.evaluate('document.querySelector("[name=density][value=comfortable]").click();Storage.prototype.setItem=function(){throw new Error("blocked")};location.hash="#/inbox"');
await wait('document.querySelector(".display-dialog").open');
await b.evaluate('document.querySelector("[data-choice=save]").click()');
assert.match(await b.evaluate('document.querySelector("#display-leave-status").textContent'), /local browser storage/);
@@ -135,10 +135,10 @@ test('display screenshots and zoom, dark mode, hostile labels', { timeout: 90000
await wait('!!document.querySelector("#display-form")');
assert.ok(await b.evaluate('document.querySelector("#display-form button[type=submit]").getBoundingClientRect().height >= 44'));
await b.evaluate('document.documentElement.style.zoom="";document.documentElement.removeAttribute("data-mode")');
await b.evaluate('document.querySelector("[name=density][value=compact]").click();document.querySelector("#display-form").requestSubmit()');
await b.evaluate('document.querySelector(`[name=density][value=${document.querySelector("[name=density]:checked").value === "compact" ? "comfortable" : "compact"}]`).click();document.querySelector("#display-form").requestSubmit()');
await wait('document.querySelector("#display-status").textContent.includes("saved")');
captures.push(await capture('display-saved',width,'#/settings/display'));
await b.evaluate('document.querySelector("[name=order][value=contact-first]").click();location.hash="#/projects"');
await b.evaluate('document.querySelector(`[name=density][value=${document.querySelector("[name=density]:checked").value === "compact" ? "comfortable" : "compact"}]`).click();location.hash="#/projects"');
await wait('document.querySelector(".display-dialog").open');
assert.equal(await b.evaluate('document.querySelector("#display-leave-status").textContent'), '');
captures.push(await capture('display-unsaved',width,'#/settings/display'));
@@ -160,7 +160,7 @@ test('optional project tree mount, storage refusal and sign-out clear transient
await b.navigate(f.base+'/#/settings/display');
await wait('!!document.querySelector("#display-form")');
assert.match(await b.evaluate('document.querySelector("#project-tree").textContent'), /Stub tree: \/settings\/display/);
await b.evaluate('document.querySelector("[name=order][value=contact-first]").click(); document.dispatchEvent(new Event("mosaic-signed-out")); location.hash="#/projects"');
await b.evaluate('document.querySelector("[name=density][value=compact]").click(); document.dispatchEvent(new Event("mosaic-signed-out")); location.hash="#/projects"');
await wait('location.hash==="#/projects"');
assert.equal(await b.evaluate('document.querySelector(".display-dialog").open'), false);
assert.equal(await b.evaluate('localStorage.getItem("mosaic-sidebar-display-v1")'), null);