diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js
index 30682d1dd..0ba789cf9 100644
--- a/packages/webui/src/public/app.js
+++ b/packages/webui/src/public/app.js
@@ -302,8 +302,8 @@
const visible = group.filter(r => !($('hide-offline').checked && r.state === 'offline') && !($('hide-seen').checked && r.seen));
$('session-count').textContent = visible.length === group.length ? String(group.length) : `${visible.length} of ${group.length}`;
$('sessions').innerHTML = table(visible);
- $('footer').textContent = Object.entries(data.counts || {}).map(([s, n]) => `${s} ${n}`).join(' · ') || 'No sessions';
- $('status').textContent = `Scanned ${data.generatedAt || 'unknown'} · ${paused ? 'auto-refresh paused' : 'refresh every 10s'}`;
+ $('footer').textContent = data.sessions.length ? Object.entries(data.counts || {}).map(([s, n]) => `${s} ${n}`).join(' · ') : 'No machine sessions';
+ $('status').textContent = `Scanned ${Number.isFinite(Date.parse(data.generatedAt)) ? new Date(data.generatedAt).toLocaleString() : 'unknown'} · ${paused ? 'auto-refresh paused' : 'refresh every 10s'}`;
fresh(); inspect(); convForm(); if (conv) convStatus(); restore(focus);
}
// Freshness line in the command bar: when the board last scanned, and warning words when the last refresh failed.
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
index 44465d8f7..b0cb33a86 100644
--- a/packages/webui/src/public/bus.js
+++ b/packages/webui/src/public/bus.js
@@ -425,12 +425,13 @@
Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.
`;
}
+ const messageText = m => m.from_role !== 'human' && typeof m.body === 'string' && m.body.startsWith('The turn failed: ') ? `The assistant couldn't reply. Try again, or check Agents.Reading never wakes the assistant. An accepted message is not an answer.
${threadWrites() ? `Projects
';
if (c.error && !(c.kind === 'room' && c.error === ROOM_LINES['room-not-open'] && redesigned)) html += `
${txt(c.error)}
`;
- if (c.archiveUnknown) html += '
Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.
';
+ if (c.archiveUnknown && !c.loading) html += '
Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.
';
else if (c.kind === 'thread' && c.projectArchived) html += '
This project is archived; its conversation stays readable.
';
- if (c.readFailed && (c.projects.length || c.threads.length || c.kind === 'thread' && state(c.key).loaded)) html += '
Showing previously read chat content; it may be out of date.
';
+ if (c.readFailed && (c.kind === 'thread' ? state(c.key).loaded : c.projects.length || c.threads.length)) html += '
Showing previously read chat content; it may be out of date.
';
if (c.kind === 'room') {
html += c.refused ? heading('Room unavailable') : roomConversation();
} else if (c.kind === 'thread') {
const t = c.thread;
if (t && (c.session || c.legacyDefault) && !c.refused) html += conversation(t);
- else html += heading('Thread unavailable');
+ else if (c.loading) html += '
Reading conversation…
';
+ else html += heading('Conversation unavailable') + '
Read again ';
} else if (c.kind === 'project') {
const p = c.projects.find(p => p.id === c.id);
html += p && !c.refused && redesigned ? window.MosaicProjects.header(p, c.section, c.threads) : heading(p?.name ?? 'Project unavailable');
@@ -544,7 +546,7 @@
paint(); return;
}
const old = current;
- current = { container, kind, id, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
+ current = { container, kind, id, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id };
current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived);
if (kind === 'thread' && old?.kind === 'thread' && old.id === id && !navigated && old.archiveUnknown === false && old.projects.some(p => p.id === old.thread?.project)) current.archiveUnknown = false;
if (current.projectArchived) current.archiveUnknown = false;
@@ -554,7 +556,9 @@
for (const el of container.querySelectorAll('#chat-session-form input, #chat-session-form button, #chat-share-form input, #chat-share-form button, [data-unshare], #chat-cancel, [data-revert]')) el.disabled = true;
window.MosaicRequests?.mount?.(container, { archiveUnknown: !!current.archiveUnknown, projectArchived: !!current.projectArchived });
if (!current.archiveUnknown) return;
+ if (container.querySelector('[data-archive-notice]')) return;
const notice = document.createElement('p');
+ notice.dataset.archiveNotice = '';
notice.setAttribute('role', 'status');
notice.textContent = 'Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.';
container.querySelector('.chat-area')?.prepend(notice);
@@ -698,6 +702,7 @@
} catch (err) { if (err.name === 'AbortError') return; }
}
if (g === generation) {
+ current.loading = false;
if ((kind === 'thread' && current.thread || kind === 'project') && !current.refused) {
const projectId = kind === 'thread' ? current.thread.project : id;
const project = current.projects.find(p => p.id === projectId);
@@ -715,6 +720,7 @@
}
} catch (err) {
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
+ current.loading = false;
current.error = readError(err);
current.readFailed = true;
if (err.code === 'channel-disabled') current.channelDisabled = true;
@@ -850,6 +856,7 @@
if (event.target.id === 'chat-cancel') void cancelClear();
if (event.target.id === 'chat-clear') void clearContext();
if (event.target.id === 'chat-next') void refresh(true);
+ if (event.target.closest?.('[data-chat-retry]')) void refresh();
const revertButton = event.target.closest?.('[data-revert]');
if (revertButton) void revert(revertButton.dataset.revert);
if (event.target.id === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); }
diff --git a/packages/webui/src/public/project-archive.js b/packages/webui/src/public/project-archive.js
index 231d6c6ed..b1a8a7166 100644
--- a/packages/webui/src/public/project-archive.js
+++ b/packages/webui/src/public/project-archive.js
@@ -121,19 +121,24 @@
const list = document.createElement('ul');
list.id = 'project-archive-list';
box.append(list);
+ box.hidden = true;
container.append(box);
- if (!shown) return;
toggle.disabled = true;
const controller = new AbortController();
archived(controller.signal).then(rows => {
- if (out() || !shown) return;
- list.replaceChildren(...(rows ?? []).filter(p => p.archived === true).map(p => listRow(p, true)));
+ if (out() || !box.isConnected) return;
+ if (rows === null) throw Error('archive-unavailable');
+ const items = rows.filter(p => p.archived === true);
+ box.hidden = items.length === 0;
+ if (!shown) return;
+ list.replaceChildren(...items.map(p => listRow(p, true)));
if (!list.children.length) {
const li = document.createElement('li'); li.textContent = 'No archived projects.';
list.append(li);
}
}).catch(() => {
- if (out()) return;
+ if (out() || !box.isConnected) return;
+ box.hidden = false;
const li = document.createElement('li');
li.textContent = 'Archived projects unavailable. Refresh to try again.';
list.replaceChildren(li);
diff --git a/packages/webui/src/public/projects.css b/packages/webui/src/public/projects.css
index eded86b3d..e9ac4bf30 100644
--- a/packages/webui/src/public/projects.css
+++ b/packages/webui/src/public/projects.css
@@ -245,3 +245,11 @@ body:has(.project-chat) #bus-view, body:has(.project-chat) .chat-area { flex:1 1
.project-chat .chat-room-picker legend { margin-right:4px; }
.project-chat .room-chip { padding:0 8px; font-size:12px; }
}
+/* Project list rows use the available content width. */
+.chat-area:has(.project-list-head) .chat-projects { grid-template-columns:minmax(0,1fr); }
+.chat-area:has(.project-list-head) .project-card { display:grid; grid-template-columns:52px minmax(0,1fr) auto; gap:2px 20px; align-items:center; }
+.chat-area:has(.project-list-head) .project-card-icon { grid-row:1 / span 3; }
+.chat-area:has(.project-list-head) .project-card > strong,.chat-area:has(.project-list-head) .project-card > span:not(.project-card-icon) { grid-column:2; }
+.chat-area:has(.project-list-head) .project-card::after { content:'›'; grid-column:3; grid-row:1 / span 3; font-size:24px; }
+.chat-area:has(.project-list-head) .project-card [data-project-status] { display:flex; flex-direction:row; flex-wrap:wrap; align-items:center; }
+@media (min-width:700px) { .chat-area:has(.project-list-head) .project-create:not([open]) { position:absolute; top:0; right:0; margin:0; } }
diff --git a/packages/webui/src/public/projects.js b/packages/webui/src/public/projects.js
index 921e9f252..aa56f6a20 100644
--- a/packages/webui/src/public/projects.js
+++ b/packages/webui/src/public/projects.js
@@ -139,9 +139,10 @@
}
const conversationLink=t=>`#/projects/threads/${encodeURIComponent(t.id)}`;
const taskLink=(ref,data)=>{const row=data?.tasks?.find(x=>x.task_ref===ref);const f=row?.fields||{};return `
${clean(ref)}${typeof f.title==='string'?' · '+clean(f.title):''} ${f.done===true?' · Done':''}`;};
+ const requestCode = id => `
${clean(UUID.test(id) ? id.slice(0,8) : id)}`;
function card(thread,request,failed) {
const file=request && delivery(request);
- return `
◌ ${clean(thread.name)} With ${clean(thread.instance)}${request?` · Request ${clean(request.id)}${request.state?.mode?' · '+clean(modeLabel(request.state.mode)):''} · Observed just now`:failed?' · Request status unavailable':' · No active request'}
${request?chip(request.state):''}
${stepper(request?.state)}${request?.state?.wait?.code?`Waiting: ${clean(request.state.wait.code)}
`:''}${file?`${clean(file.name)} · Reviewed delivery Download Open files
`:''}${request?.state?.tasks?.length?`
Tasks: ${request.state.tasks.filter(v=>/^vikunja:[0-9]+\/[0-9]+$/.test(v)).map(taskLink).join(', ')} `:''}
Open conversation `;
+ return `
◌ ${clean(thread.name)} With ${clean(thread.instance)}${request?` · Request ${requestCode(request.id)}${request.state?.mode?' · '+clean(modeLabel(request.state.mode)):''} · Observed just now`:failed?' · Request status unavailable':' · No active request'}
${request?chip(request.state):''}
${stepper(request?.state)}${request?.state?.wait?.code?`Waiting: ${clean(request.state.wait.code)}
`:''}${file?`${clean(file.name)} · Reviewed delivery Download Open files
`:''}${request?.state?.tasks?.length?`
Tasks: ${request.state.tasks.filter(v=>/^vikunja:[0-9]+\/[0-9]+$/.test(v)).map(taskLink).join(', ')} `:''}
Open conversation `;
}
const requestOrder=(a,b)=>(Number(needs(b.state))-Number(needs(a.state))) || (Number(!!b.state && !ended(b.state))-Number(!!a.state && !ended(a.state))) || (Date.parse(b.at)||0)-(Date.parse(a.at)||0);
function docsList(data) { return Array.isArray(data.docs?.files)?data.docs.files.filter(f=>typeof f?.path==='string' && /^(spec\/north-star\.md|docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md)$/.test(f.path)).map(f=>f.path):null; }
@@ -217,7 +218,7 @@
async function enhanceList(container,projects,threads) {
for(const project of projects.filter(p=>!p.archived)) {
const slot=container.querySelector(`[data-project-status="${project.id}"]`); if(!slot) continue;
- void gather(project,threads).then(data=>{if(!slot.isConnected)return;const current=[...data.requests].sort(requestOrder).find(r=>r.state);slot.innerHTML=`${current?chip(current.state):''}
${data.failed?'Status unavailable · ':current?'':'No active requests · '}${ownThreads(project,threads).length} conversations · Updated just now `;}).catch(()=>{if(slot.isConnected)slot.textContent='Status unavailable';});
+ void gather(project,threads).then(data=>{if(!slot.isConnected)return;const current=[...data.requests].sort(requestOrder).find(r=>r.state);slot.innerHTML=`${current?chip(current.state):''}
${data.failed?'Status unavailable · ':current?'':'No active requests · '}${ownThreads(project,threads).length} ${ownThreads(project,threads).length === 1 ? 'conversation' : 'conversations'} · Updated just now `;}).catch(()=>{if(slot.isConnected)slot.textContent='Status unavailable';});
}
}
@@ -238,7 +239,7 @@
}
function enrichRequest(container, projectId) {
const data=surfaceData.get(projectId); if(!data) return;
- for(const intro of container.querySelectorAll('.project-request-intro')) { const id=intro.querySelector('code')?.textContent, r=data.requests.find(r=>r.id===id && r.state?.revision===Number(intro.dataset.requestRevision)); if(!r) continue; const files=filesData(data), prd=intro.querySelector('[data-work-prd]'), counts=intro.querySelector('[data-work-files]'); if(prd) prd.innerHTML=`PRD ${clean(r.draft?.candidate?.prd_version || '–')} ${r.draft ? `
${r.draft.snapshot?.id?'Approved':'Draft'} ` : ''}`; if(counts) counts.textContent=`Files · ${!data.failed ? files.reviewed.length : '–'} reviewed, ${!data.failed && data.requests.every(x=>x.workspace?.available===true && Array.isArray(x.workspace.entries) && !x.workspace.truncated) ? files.drafts.length+' draft' : '– draft (Draft files unavailable)'}`; }
+ for(const intro of container.querySelectorAll('.project-request-intro')) { const id=intro.querySelector('code')?.title, r=data.requests.find(r=>r.id===id && r.state?.revision===Number(intro.dataset.requestRevision)); if(!r) continue; const files=filesData(data), prd=intro.querySelector('[data-work-prd]'), counts=intro.querySelector('[data-work-files]'); if(prd) prd.innerHTML=`PRD ${clean(r.draft?.candidate?.prd_version || '–')} ${r.draft ? `
${r.draft.snapshot?.id?'Approved':'Draft'} ` : ''}`; if(counts) counts.textContent=`Files · ${!data.failed ? files.reviewed.length : '–'} reviewed, ${!data.failed && data.requests.every(x=>x.workspace?.available===true && Array.isArray(x.workspace.entries) && !x.workspace.truncated) ? files.drafts.length+' draft' : '– draft (Draft files unavailable)'}`; }
}
document.addEventListener('click',e=>{
const open=e.target.closest?.('[data-project-dialog]');
@@ -249,7 +250,7 @@
function requestIntro(state, projectId, observedAt) {
if(!UUID.test(projectId || '')) return '';
const observed = typeof observedAt === 'string' && Number.isFinite(Date.parse(observedAt)) ? `
${clean(new Date(observedAt).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'}))} ` : 'time unavailable';
- return `
Request ${clean(state.request)} ${chip(state)}
${clean(modeLabel(state.mode))} · Observed ${observed}
${stepper(state)}
`;
+ return `
Request ${requestCode(state.request)} ${chip(state)}
${clean(modeLabel(state.mode))} · Observed ${observed}
${stepper(state)}
`;
}
const palette=()=>treeRows.filter(p=>!p.archived && UUID.test(p.id)).flatMap(p=>sections.map(([key,label])=>({href:path(p.id,key),label:key==='overview'?p.name:`${p.name}: ${label}`,group:'project'})));
function breadcrumb(box, view) {
diff --git a/packages/webui/src/public/shell.css b/packages/webui/src/public/shell.css
index f47220c35..65163f2ea 100644
--- a/packages/webui/src/public/shell.css
+++ b/packages/webui/src/public/shell.css
@@ -314,3 +314,18 @@ button:disabled,[aria-disabled=true]{cursor:not-allowed}
}
.settings-success:not(:empty)::before{content:"✓ ";}
+/* Keep wide tables reachable and show their scrollbar. */
+@media (min-width:760px) { .routing-panel .table-wrap { overflow-x:scroll; } }
+@media (max-width:759px) {
+ .table-wrap:has(.s1-agents), .table-wrap:has(.s1-authority), .table-wrap:has(.s1-creds),
+ .table-wrap:has(#token-usage-table), .table-wrap:has(#token-limits-table),
+ .table-wrap:has(#proxy-accounts), .table-wrap:has(#proxy-keys) { overflow-x:scroll; max-width:100%; }
+ .s1-agents, .s1-authority, .s1-creds, #token-usage-table, #token-limits-table,
+ .table-wrap:has(#proxy-accounts) table, .table-wrap:has(#proxy-keys) table { min-width:650px; }
+ .s1-authority :is(th,td), .s1-creds :is(th,td) { white-space:nowrap; }
+}
+body:not(:has(.project-chat)) .content { padding-bottom:calc(22px + var(--foot-h)); }
+@media (max-width:759px) {
+ section[aria-label="Proxy-reported usage"] .table-wrap { overflow-x:scroll; }
+ section[aria-label="Proxy-reported usage"] table { min-width:650px; }
+}
diff --git a/packages/webui/tests/bus-browser.test.mjs b/packages/webui/tests/bus-browser.test.mjs
index afa83cf50..fb4ecfad0 100644
--- a/packages/webui/tests/bus-browser.test.mjs
+++ b/packages/webui/tests/bus-browser.test.mjs
@@ -208,7 +208,7 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
]);
assert.match(tasks, /Done 1/); assert.match(tasks, /No longer readable 1/); assert.match(tasks, /missing: moved/);
assert.match(tasks, /Bucket names, bot names and the Vikunja address: not in the Q1 module/);
- assert.match(tasks, /Poller status: from the S3 status read, not built yet/);
+ assert.doesNotMatch(tasks, /Poller status:/);
await inertCheck('tasks'); await noOverflow('tasks');
// Task detail: request, requirement, external change, refused write, decisions, snapshots and trail.
diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs
index d6d5a898d..6512da997 100644
--- a/packages/webui/tests/chat-browser.test.mjs
+++ b/packages/webui/tests/chat-browser.test.mjs
@@ -507,7 +507,7 @@ test('264e chooser: two sessions keep drafts, cursors, deep links and counts ind
assert.deepEqual(await b.evaluate('window.__writes[0].body'), {body:'first session draft',session:A});
assert.equal(await b.evaluate('document.querySelector("#chat-clear").disabled'), true);
await b.evaluate(`location.hash='#/chat/threads/${A}/sessions/${M}',true`);
- await wait('document.querySelector(".chat-area h1")?.textContent === "Thread unavailable"');
+ await wait('document.querySelector(".chat-area h1")?.textContent === "Conversation unavailable"');
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'), false);
assert.equal(await b.evaluate('window.__writes.length'), 1);
});
@@ -583,7 +583,7 @@ test('272 B1: a bad-session deep link preserves the draft and lost clear operati
const op=await b.evaluate('window.__writes[0].body.op');
assert.equal(await b.evaluate('document.querySelector("#chat-operation").value'),op);
await b.evaluate(`location.hash='#/chat/threads/${A}/sessions/${M}',true`);
- await wait('document.querySelector(".chat-area h1")?.textContent === "Thread unavailable"');
+ await wait('document.querySelector(".chat-area h1")?.textContent === "Conversation unavailable"');
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),false);
await b.evaluate(`location.hash='#/chat/threads/${A}/sessions/${A}',true`);
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${A}'`);
diff --git a/packages/webui/tests/projects-slice3-browser.test.mjs b/packages/webui/tests/projects-slice3-browser.test.mjs
index 56d007d9c..32a112935 100644
--- a/packages/webui/tests/projects-slice3-browser.test.mjs
+++ b/packages/webui/tests/projects-slice3-browser.test.mjs
@@ -2,11 +2,11 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { setup } from './helpers/w15-conversations.mjs';
-test('projects list presents responsive, equal-height linked cards', async t => {
+test('projects list presents full-width linked rows at desktop and mobile sizes', async t => {
const { b, render } = await setup(t);
await b.evaluate(`(() => { const old=window.fetch; window.fetch=(url,opts)=>String(url)==='/api/chat/projects' ? Promise.resolve(new Response(JSON.stringify({rows:[1,2,3].map((n)=>({id:'10000000-0000-4000-8000-00000000000'+n,name:n===2?'A much longer project name for testing':'Project '+n,repo:null}))}),{headers:{'content-type':'application/json'}})) : old(url,opts); })()`);
await render('/projects');
- for (const [width, columns] of [[1440,3],[390,1]]) {
+ for (const [width, columns] of [[1440,1],[390,1]]) {
await b.viewport(width,1000);
const result=await b.evaluate(`(() => {const grid=document.querySelector('.chat-projects');const cards=[...grid.querySelectorAll('.project-card')];return {columns:getComputedStyle(grid).gridTemplateColumns.split(' ').length,heights:cards.map(c=>c.getBoundingClientRect().height),links:cards.map(c=>c.matches('a[href]')),overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth};})()`);
assert.equal(result.columns,columns);
diff --git a/packages/webui/tests/room-browser.test.mjs b/packages/webui/tests/room-browser.test.mjs
index 03c8a76ef..c71e4916b 100644
--- a/packages/webui/tests/room-browser.test.mjs
+++ b/packages/webui/tests/room-browser.test.mjs
@@ -113,7 +113,7 @@ test('C2-10c room merges members, shows a broadcast once, reuses cited renderer
await wait('document.querySelector(".chat-cites")');
assert.equal(await b.evaluate('document.querySelectorAll(".chat-room-log > li[data-message]").length'), 5);
assert.equal(await b.evaluate('document.querySelectorAll(".chat-room-log > li[data-message]")[0].dataset.message'), M);
- assert.match(await text('.chat-room-log > li:first-child .chat-message-meta'), /Human.*Bean/);
+ assert.match(await text('.chat-room-log > li:first-child .chat-message-meta'), /You.*Bean/);
assert.equal(await text('.chat-scope'), 'supporting');
assert.equal(await text('.chat-agent-written'), 'agent-written: demo/bean');
assert.equal(await text('.chat-changed'), 'changed since');
diff --git a/packages/webui/tests/s5-polish-browser.test.mjs b/packages/webui/tests/s5-polish-browser.test.mjs
new file mode 100644
index 000000000..b2bbc5a0b
--- /dev/null
+++ b/packages/webui/tests/s5-polish-browser.test.mjs
@@ -0,0 +1,91 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { fixture } from './fixture.mjs';
+import { browser } from './browser.mjs';
+import { P, T, S, stub } from './helpers/projects-review-fixture.mjs';
+
+async function setup(t, route, width=390, extra='', gateRenderer=false) {
+ const f=await fixture(), b=await browser({machine:false});
+ t.after(async()=>{await b.close();await f.close()});
+ await b.call('Fetch.enable',{patterns:[{urlPattern:'*'}]});
+ let rendererRequest;
+ b.on('Fetch.requestPaused',p=>{
+ if(gateRenderer && p.request.url===f.base+'/render.mjs') { rendererRequest=p.requestId; return; }
+ void b.call(p.request.url.startsWith(f.base+'/')?'Fetch.continueRequest':'Fetch.failRequest',p.request.url.startsWith(f.base+'/')?{requestId:p.requestId}:{requestId:p.requestId,errorReason:'BlockedByClient'}).catch(()=>{});
+ });
+ await b.call('Page.addScriptToEvaluateOnNewDocument',{source:stub+extra});
+ await b.viewport(width,900);await b.navigate(f.base+'/#'+route);
+ const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return;await new Promise(r=>setTimeout(r,25))}throw Error('Timed out: '+${JSON.stringify(expr)})})()`);
+ return {b,wait,releaseRenderer:async()=>{for(let i=0;i<200&&!rendererRequest;i++)await new Promise(r=>setTimeout(r,25));assert.ok(rendererRequest,'renderer request was gated');await b.call('Fetch.continueRequest',{requestId:rendererRequest});}};
+}
+test('conversation and room keep a mobile gutter for long human messages',async t=>{
+ const {b,wait}=await setup(t,'/projects/threads/'+T,390,`fixtureMessages[0].body='A very long human message '.repeat(30);fixtureRoom[0].body=fixtureMessages[0].body;`);
+ for(const [route,selector] of [[null,'#chat-send-form'],['/projects/rooms/'+P,'#chat-room-send-form']]) {
+ if(route)await b.evaluate(`location.hash=${JSON.stringify('#'+route)}`);
+ await wait(`document.querySelector('${selector}')`);
+ const result=await b.evaluate(`(()=>{const e=document.querySelector('.chat-message-human .chat-body'),m=document.querySelector('.chat-message-human .chat-message-meta');const a=e.getBoundingClientRect(),b=m.getBoundingClientRect();return {body:a.right,meta:b.right,width:document.documentElement.clientWidth}})()`);
+ assert.ok(result.body<=result.width-10 && result.meta<=result.width-10,JSON.stringify(result));
+ }
+});
+test('a repaint while the initial conversation read is pending stays in the reading state',async t=>{
+ const {b,wait,releaseRenderer}=await setup(t,'/projects/threads/'+T,390,`const prior=window.fetch;let gateSession=true;window.fetch=async(u,o)=>{if(gateSession && String(u)==='/api/chat/threads/${T}/sessions'){gateSession=false;await new Promise(r=>window.releaseSessions=r)}return prior(u,o)};`,true);
+ await wait(`typeof window.releaseSessions==='function'`);
+ await releaseRenderer();
+ await wait(`document.querySelector('.chat-area')`);
+ const body=await b.evaluate(`document.querySelector('#bus-view').textContent`);
+ assert.doesNotMatch(body,/Thread unavailable|Conversation unavailable/);
+ await b.evaluate(`window.releaseSessions()`);
+ await wait(`document.querySelector('#chat-send-form')`);
+});
+test('a failed conversation read offers a retry without duplicate archive notices',async t=>{
+ const {b,wait}=await setup(t,'/projects/threads/'+T,390,`window.reviewFail['/api/chat/threads/${T}/sessions']=true;`);
+ await wait(`document.querySelector('.chat-error')`);
+ const result=await b.evaluate(`({text:document.querySelector('#bus-view').textContent,retry:!!document.querySelector('[data-chat-retry]'),notices:[...document.querySelectorAll('#bus-view [role=status]')].filter(e=>e.textContent.includes('Archive status unavailable')).length})`);
+ assert.match(result.text,/Conversation unavailable/);assert.doesNotMatch(result.text,/Showing previously read chat content/);assert.equal(result.retry,true);assert.ok(result.notices<=1);
+ await b.evaluate(`reviewFail['/api/chat/threads/${T}/sessions']=false;document.querySelector('[data-chat-retry]').click()`);
+ await wait(`document.querySelector('#chat-send-form')`);
+});
+test('a failed initial history read can be retried without losing the draft',async t=>{
+ const extra=`const prior=window.fetch;let failHistory=true;window.fetch=async(u,o)=>{if(failHistory && String(u).startsWith('/api/chat/threads/${T}/messages'))return new Response('{}',{status:503});return prior(u,o)};window.clearHistoryFailure=()=>{failHistory=false};`;
+ const {b,wait}=await setup(t,'/projects/threads/'+T,390,extra);
+ await wait(`document.querySelector('.chat-error') && document.querySelector('#chat-body')`);
+ assert.match(await b.evaluate(`document.querySelector('.chat-log').textContent`),/Messages unavailable/);
+ await b.evaluate(`(()=>{const e=document.querySelector('#chat-body');e.value='A draft to keep';e.dispatchEvent(new Event('input',{bubbles:true}))})()`);
+ assert.equal(await b.evaluate(`!!document.querySelector('[data-chat-retry]')`),true);
+ await b.evaluate(`clearHistoryFailure();document.querySelector('[data-chat-retry]').click()`);
+ await wait(`document.querySelector('.chat-log').textContent.includes('Build a self-contained HTML Pomodoro timer')`);
+ assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`),'A draft to keep');
+});
+test('request identifiers are abbreviated but retain their full value',async t=>{
+ const id='c6946904-a20e-49c3-927f-267a67f14e81';
+ const {b,wait}=await setup(t,'/projects/threads/'+T,1440);
+ await wait(`window.MosaicProjects && document.querySelector('#chat-send-form')`);
+ const result=await b.evaluate(`(()=>{const host=document.createElement('div');host.innerHTML=MosaicProjects.requestIntro({request:${JSON.stringify(id)},revision:1,phase:'implementation'},'${P}');const e=host.querySelector('code');return {text:e.textContent,title:e.title}})()`);
+ assert.deepEqual(result,{text:id.slice(0,8),title:id});
+});
+test('wide routing and data tables expose horizontal scrolling inside their panel',async t=>{
+ const {b,wait}=await setup(t,'/projects',1440);await wait(`document.querySelector('.chat-area')`);
+ for(const width of [1440,390]) {
+ await b.viewport(width,900);
+ const r=await b.evaluate(`(()=>{const host=document.createElement('div');host.className='settings-content';host.style.width='300px';host.innerHTML='
';document.querySelector('#bus-view').append(host);const e=host.querySelector('.table-wrap');const r={overflow:getComputedStyle(e).overflowX,scroll:e.scrollWidth,client:e.clientWidth};host.remove();return r})()`);
+ if(width===1440)assert.ok(['auto','scroll'].includes(r.overflow) && r.scroll>r.client,JSON.stringify(r));
+ }
+});
+test('failed assistant turns explain recovery in conversation and messages',async t=>{
+ const extra=`fixtureMessages[1].body='The turn failed: adapter exited 127';const prior=window.fetch;window.fetch=async(u,o)=>String(u)==='/api/bus/messages'?new Response(JSON.stringify({rows:[{id:'m1',from_role:'assistant',class:'RESULT',at:'2026-11-06T10:25:00Z',body:'The turn failed: adapter exited 127'}],total:1,unread:1}),{headers:{'content-type':'application/json'}}):prior(u,o);`;
+ const {b,wait}=await setup(t,'/projects/threads/'+T,390,extra);await wait(`document.querySelector('.chat-message-agent .chat-body')`);
+ let text=await b.evaluate(`document.querySelector('.chat-message-agent').textContent`);assert.match(text,/The assistant couldn.t reply\. Try again, or check Agents\./);assert.match(text,/adapter exited 127/);
+ await b.evaluate(`location.hash='#/messages'`);await wait(`document.querySelector('.s1-message')`);text=await b.evaluate(`document.querySelector('#message-list').textContent`);assert.match(text,/The assistant couldn.t reply\. Try again, or check Agents\./);assert.match(text,/adapter exited 127/);
+});
+test('queue refusal redacts the placeholder and shows its code only once',async t=>{
+ const extra=`const prior=window.fetch;window.fetch=async(u,o)=>String(u)==='/api/queue'?new Response(JSON.stringify({error:'queue-refused',message:'
is a linked worktree; the queue runs only in the canonical checkout'}),{status:503,headers:{'content-type':'application/json'}}):prior(u,o);`;
+ const {b,wait}=await setup(t,'/queue',390,extra);await wait(`document.querySelector('#bus-view .banner')`);const text=await b.evaluate(`document.querySelector('#bus-view .banner').textContent`);assert.doesNotMatch(text,//);assert.equal((text.match(/queue-refused/g)||[]).length,1);
+});
+test('empty machine session counts do not masquerade as held role counts',async t=>{
+ const extra=`localStorage.setItem('mosaic-console-board',JSON.stringify({showMachine:true}));const prior=window.fetch;window.fetch=async(u,o)=>String(u)==='/api/board'?new Response(JSON.stringify({sessions:[],counts:{working:0,waiting:0,error:0,offline:0,idle:0,unknown:0},generatedAt:'2026-11-06T10:25:00Z'}),{headers:{'content-type':'application/json'}}):prior(u,o);`;
+ const {b,wait}=await setup(t,'/projects',390,extra);await wait(`document.querySelector('#footer')`);await b.evaluate(`document.querySelector('#show-machine').checked=true;document.querySelector('#show-machine').dispatchEvent(new Event('change',{bubbles:true}))`);await wait(`document.querySelector('#status').textContent.includes('Scanned')`);assert.doesNotMatch(await b.evaluate(`document.querySelector('#footer').textContent`),/working 0.*idle 0/);
+});
+test('the project list uses full width rows and hides an empty archive',async t=>{
+ const {b,wait}=await setup(t,'/projects',1440,`fixtureProjects=fixtureProjects.filter(p=>!p.archived);`);await wait(`document.querySelector('.project-card')`);
+ const r=await b.evaluate(`(()=>{const c=document.querySelector('.project-card').getBoundingClientRect(),p=document.querySelector('.chat-projects').getBoundingClientRect(),a=document.querySelector('.project-archived-filter');return {card:c.width,parent:p.width,archive:a && getComputedStyle(a).display,button:document.querySelector('.project-create summary').getBoundingClientRect().right,heading:document.querySelector('.project-list-head').getBoundingClientRect().right,buttonTop:document.querySelector('.project-create summary').getBoundingClientRect().top,headingBottom:document.querySelector('.project-list-head').getBoundingClientRect().bottom}})()`);assert.ok(r.button>r.heading-5 && r.buttonTop=r.parent-1,JSON.stringify(r));assert.ok(!r.archive || r.archive==='none',JSON.stringify(r));
+});
diff --git a/packages/webui/tests/settings-review-browser.test.mjs b/packages/webui/tests/settings-review-browser.test.mjs
index 92a9f663e..cb0bde63c 100644
--- a/packages/webui/tests/settings-review-browser.test.mjs
+++ b/packages/webui/tests/settings-review-browser.test.mjs
@@ -38,3 +38,18 @@ test('F9 assistant saving shows spinner and local refusal',opts,async t=>{const
test('F9 proxy priority disables input and shows saving',opts,async t=>{const {b,wait}=await page(t,'model-proxy');await b.evaluate(`window.__baseFetch=fetch;window.fetch=(u,o)=>o?.method==='POST'?new Promise(resolve=>window.__answer=(status,body)=>resolve(new Response(JSON.stringify(body),{status}))):window.__baseFetch(u,o);document.querySelector('#proxy-accounts form').requestSubmit()`);await wait('!!window.__answer');assert.equal(await b.evaluate('document.querySelector("#proxy-accounts input").disabled'),true);assert.equal(await b.evaluate('!!document.querySelector("#proxy-accounts .settings-spinner")'),true);assert.equal(await b.evaluate('document.querySelector("#proxy-apikey [name=key]").matches(":disabled")'),false);await b.evaluate('__answer(409,{error:"fixture"})');await wait('!!document.querySelector("#proxy-accounts form .banner.err")');await b.evaluate(`window.__answer=null;let f=document.querySelector('#proxy-apikey');f.elements.key.value='fixture-key';f.requestSubmit()`);await wait('!!window.__answer');assert.equal(await b.evaluate('document.querySelector("#proxy-apikey input").disabled'),true);assert.equal(await b.evaluate('!!document.querySelector("#proxy-apikey .settings-spinner")'),true);await b.evaluate('__answer(409,{error:"fixture"})');await wait('!!document.querySelector("#proxy-apikey-message.banner.err")');assert.equal(await b.evaluate('document.querySelector("#proxy-apikey input").value'), '');});
test('F3 routing refusal remains visible and announced in the phone draft bar',opts,async t=>{const {b,wait}=await page(t,'routing',390);await b.evaluate(`__scenario['POST /api/routes']={status:409,body:{message:'Fixture conflict',sent:false}};[...document.querySelectorAll('[data-route]')].find(e=>JSON.parse(e.dataset.route)[1]==='thinking').focus()`);await b.key('ArrowDown');await wait('!!document.querySelector("#settings-disclosure .settings-dot")');await pointer(b,'#routes-form button[type=submit]');await wait('document.querySelector("#routes-receipt")?.textContent.includes("Fixture conflict")');assert.deepEqual(await b.evaluate(`(()=>{const e=document.querySelector('#routes-receipt'),r=e.getBoundingClientRect();return {visible:r.width>0&&r.height>0,role:e.getAttribute('role'),banner:e.classList.contains('err')}})()`),{visible:true,role:'status',banner:true});});
test('F9 simultaneous assistant card refusals stay with their own drafts',opts,async t=>{const {b,wait}=await page(t,'assistant');await b.evaluate(`window.__answers={};window.__baseFetch=fetch;window.fetch=(u,o)=>o?.method==='POST'?new Promise(resolve=>window.__answers[u]=(status,body)=>resolve(new Response(JSON.stringify(body),{status}))):window.__baseFetch(u,o)`);await input(b,'[data-assistant-field=definition]','changed');await b.evaluate('document.querySelector(".assistant-form").requestSubmit()');await wait('!!window.__answers["/api/settings"] && !!document.querySelector(".assistant-form .settings-spinner")');await input(b,'[data-assistant-field=name]','Wren');await b.key('Enter');await wait('!!window.__answers["/api/name"]');await b.evaluate(`__answers['/api/name'](409,{message:'Name refused',sent:false})`);await wait('document.querySelector("[data-assistant-kind=name]")?.textContent.includes("Name refused")');await b.evaluate(`__answers['/api/settings'](409,{message:'Settings refused',sent:false})`);await wait('document.querySelector("[data-assistant-kind=settings]")?.textContent.includes("Settings refused")');assert.match(await b.evaluate('document.querySelector("[data-assistant-kind=name]").textContent'),/Name refused/);assert.equal(await b.evaluate('document.querySelector("[data-assistant-field=name]").value'),'Wren');assert.equal(await b.evaluate('document.querySelector("[data-assistant-field=definition]").value'),'changed');});
+test('S5 routing remains inside its panel and reaches the last fallback control',opts,async t=>{
+ const {b}=await page(t,'routing',1440);
+ const r=await b.evaluate(`(()=>{const p=document.querySelector('.routing-panel').getBoundingClientRect(),w=document.querySelector('#routes-table').parentElement,wr=w.getBoundingClientRect(),last=document.querySelector('#routes-table tr:last-child td:last-child select').getBoundingClientRect();return {panel:p.right,wrap:wr.right,last:last.right,viewport:document.documentElement.clientWidth,scroll:w.scrollWidth,client:w.clientWidth,overflow:getComputedStyle(w).overflowX}})()`);
+ assert.ok(r.panel<=r.viewport && r.wrap<=r.panel && (r.last<=r.wrap || r.scroll>r.client && r.overflow==='scroll'),JSON.stringify(r));
+});
+test('S5 mobile token use, proxy and held roles expose their clipped columns',opts,async t=>{
+ const {b,wait}=await page(t,'model-proxy',390);
+ await b.evaluate(`__scenario['/api/bus/usage']={rows:[{total:{input:1,output:2,cacheWrite:3,cacheRead:4,unknownTurns:5}}]};__scenario['/api/bus/agents']={rows:[{role:'assistant',state:'idle',attention:'none',holder:'fixture',at:'2026-11-06T10:25:00Z'}]}`);
+ for(const [route,selector] of [['/settings/model-proxy','#proxy-accounts'],['/settings/tokens','#token-usage-table'],['/agents','.s1-agents']]){
+ if(route!=='/settings/model-proxy')await b.evaluate(`location.hash=${JSON.stringify('#'+route)}`);
+ await wait(`document.querySelector(${JSON.stringify(selector)})`);
+ const r=await b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)}).closest('.table-wrap');return {overflow:getComputedStyle(e).overflowX,scroll:e.scrollWidth,client:e.clientWidth,right:e.getBoundingClientRect().right,viewport:document.documentElement.clientWidth}})()`);
+ assert.ok(r.right<=r.viewport && r.scroll>r.client && r.overflow==='scroll',route+' '+JSON.stringify(r));
+ }
+});
diff --git a/packages/webui/tests/w15-fixture.mjs b/packages/webui/tests/w15-fixture.mjs
index 11ed20eb4..92fa9ce97 100644
--- a/packages/webui/tests/w15-fixture.mjs
+++ b/packages/webui/tests/w15-fixture.mjs
@@ -37,6 +37,7 @@ export async function w15Fixture(t, { scenario = {}, slice = 'shell' } = {}) {
if(/^\\/api\\/library\\/status\\?project=[^&]+$/.test(path) || path==='/api/processing' || path==='/api/bus-status' || path==='/api/queue') return new Response(JSON.stringify({error:'fixture-unavailable'}),{status:503,headers:{'content-type':'application/json'}});
if(path==='/api/config') return data({send:true,edit:false,decide:false});
if(path==='/api/business') return data({projects:[ids.repository]});
+ if(path==='/api/chat/projects/archived') return data({rows:[]});
if(path==='/api/chat/projects') return data({rows:[{id:ids.project,name:'Active project',repo:ids.repository},{id:ids.empty,name:'Empty project',repo:null},{id:ids.archived,name:'Archived project',archived:true,repo:null}]});
if(path==='/api/chat/instances') return data({rows:[{instance:'assistant',name:'Assistant'}]});
if(/^\\/api\\/chat\\/threads\\/[^/]+\\/shares$/.test(path)) return data({rows:[]});