From 5915c99809564bcca2b3e9422aa046147f7f4d74 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 6 Nov 2026 19:33:00 -0600 Subject: [PATCH 1/2] style(webui): polish round from the live audit (ui slice 5) --- packages/webui/src/public/app.js | 4 +- packages/webui/src/public/bus.js | 15 ++-- packages/webui/src/public/chat.js | 19 +++-- packages/webui/src/public/project-archive.js | 13 +++- packages/webui/src/public/projects.css | 8 ++ packages/webui/src/public/projects.js | 9 ++- packages/webui/src/public/shell.css | 15 ++++ packages/webui/tests/bus-browser.test.mjs | 2 +- packages/webui/tests/chat-browser.test.mjs | 4 +- .../tests/projects-slice3-browser.test.mjs | 4 +- packages/webui/tests/room-browser.test.mjs | 2 +- .../webui/tests/s5-polish-browser.test.mjs | 74 +++++++++++++++++++ .../tests/settings-review-browser.test.mjs | 15 ++++ packages/webui/tests/w15-fixture.mjs | 1 + 14 files changed, 156 insertions(+), 29 deletions(-) create mode 100644 packages/webui/tests/s5-polish-browser.test.mjs 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 d51757b53..fd12f38b7 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -425,12 +425,13 @@

Open decisions ${rows.length}

${body}

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.Details: ${txt(m.body.slice('The turn failed: '.length))}` : txt(m.body); async function messagesView(get, q) { const after = q.get('after'); if (after !== null && !SAFE.test(after)) return notFound('The message cursor is not a message id.'); const page = await get('messages', after === null ? undefined : after); const body = page.rows.length ? `` + `
  • ${txt(m.from_role)} · ${txt(m.class)} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · trail

    ${messageText(m)}

  • `).join('')}` : empty('No messages to you.', 'Messages appear here as the bus is read.'); return `${head('Messages', `${readNote(get)} Every message to you, newest first. Reading here marks nothing read.`)}

    ${page.total} messages · ${page.unread} unread

    ${body} @@ -501,7 +502,7 @@ const taskTable = (rows, label) => `
    ${['Task', 'Requirement', 'Assignees', 'Due', 'Priority', 'Done', 'Changed', 'Markers'].map(h => ``).join('')}${rows.map(taskRow).join('')}
    ${h}
    `; function freshness(rows) { const reads = rows.filter(r => r.source === 'poll' && r.read_at).map(r => r.read_at).sort(); - return `Newest Vikunja read among these tasks: ${reads.length ? when(reads.at(-1)) : 'none'}. Poller status: from the S3 status read, not built yet`; + return `Newest Vikunja read among these tasks: ${reads.length ? when(reads.at(-1)) : 'none'}.`; } const byChanged = (x, y) => String(y.changedAt).localeCompare(String(x.changedAt)); async function tasksView(get) { @@ -1082,7 +1083,7 @@ try { files = (await get.api(`project-files?project=${enc(p)}`)).files; } catch (err) { if (err.code === 'signed-out' || refusal(err)) throw err; code = err.code; } files = Array.isArray(files) ? files.filter(f => typeof f?.path === 'string' && DOC_PATH.test(f.path)) : []; - blocks.push(`

    ${txt(p)} ${files.length}

    ${code ? `

    Not read: ${txt(code)}.

    ` : files.length ? `` : '

    No spec or docs files.

    '}
    `); + blocks.push(`

    ${txt(p)} ${files.length}

    ${code ? `

    These files could not be read. Read again to retry. ${txt(code)}

    ${retry}` : files.length ? `` : '

    No spec or docs files.

    '}
    `); } return `${head('Docs', `${readLine()} The spec and the files under docs/ of each project. ${canEdit ? 'Open one to edit it; a save makes one commit in the project with you as the author.' : 'Saving needs a Console started with --send behind the sign-in proxy; this one only reads.'}`)} ${blocks.join('') || empty('This business declares no projects.', 'A project shows here when the business file names it.')}`; @@ -1180,7 +1181,7 @@ const loadingView = (source = null) => `${head('Reading…')}
    ${source ? `Reading the ${esc(source.toLowerCase())}.` : 'Reading from the bus.'}
    `; const retry = '
    '; // Why a read failed, in words, with the bus code as given. Never a path or a guess. - function why(err) { + function why(err, omitCode = false) { if (err.code === 'tester-off' || err.code === 'tester-required') return 'The tester is off or this request did not bind the tester. Nothing was changed.'; const code = `${txt(err.code)}`; // The queue, business and settings reads: the module's own text, redacted by the server. @@ -1193,7 +1194,7 @@ 'invalid-request': 'The Console server refused this address.', unreachable: 'The Console server did not answer.', }[err.code] || 'The read answered with something Console cannot use.'; - return `${said} (${code})${err.code !== 'unreachable' && err.message ? `
    ${txt(err.message)}` : ''}`; + return `${said}${omitCode ? '' : ` (${code})`}${err.code !== 'unreachable' && err.message ? `
    ${txt(err.code === 'queue-refused' ? err.message.replace(//g, 'This checkout') : err.message)}` : ''}`; } const say = { 'human-required': 'The bus answers the Console only when the human started it from their own shell. This Console was started inside an agent session, so the bus refused.', @@ -1206,14 +1207,14 @@ 'invalid-request': 'The bus refused this address as a request.', unreachable: 'The Console server did not answer.', }[err.code] || 'The bus answered with something Console cannot use.'; - return `${say} (${code})`; + return `${say}${omitCode ? '' : ` (${code})`}`; } const failTitle = err => err.code === 'queue-refused' ? 'Queue refused the read' : err.status === 403 ? 'Bus refused the read' : ['not-configured', 'no-bus-host'].includes(err.code) ? (err.module ? 'Nothing to read' : 'No bus to read') : 'The read failed'; // A refusal fails closed: no kept data, the code in mono. Any other failure keeps the last read, labelled with its time. const refusal = err => err.status === 403 || ['not-configured', 'no-bus-host', 'queue-refused'].includes(err.code); // After a refusal nothing read before is shown anywhere: not in a view, the palette or the Inbox count. function forget() { window.MosaicChat?.forget(); last.clear(); inboxCount = null; messageCount = null; } - const failedView = err => `${head(failTitle(err))}${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : err.module ? 'Read again.' : 'Read again, or come back when the bus answers.')}`; + const failedView = err => `${head(failTitle(err))}${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : err.module ? 'Read again.' : 'Read again, or come back when the bus answers.')}`; const staleBanner = err => ``; // Freshness line in the command bar for bus pages. const stamp = iso => { const t = Date.parse(iso); return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 19)} UTC` : 'unknown'; }; diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index 316ded793..a171d383c 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -121,6 +121,7 @@ return ``; } function messageBody(m) { + if (m.from_role !== 'human' && typeof m.body === 'string' && m.body.startsWith('The turn failed: ')) return `
    The assistant couldn't reply. Try again, or check Agents.
    Details: ${txt(m.body.slice('The turn failed: '.length))}
    `; const r = renderReply?.(m.body, { check: m.check ?? null, name: m.from_role, changed: changedBy(m.check) }); if (r?.kind === 'reply') return `
    ${txt(r.text)}
    ${r.citations.length ? cites(r.citations) : ''}`; if (r?.kind === 'notice') { @@ -221,7 +222,7 @@ const list = mountsAfter.get(index) || []; list.push(m); mountsAfter.set(index, list); }); const mount = m => `
    `; - const messages = s.rows.map((m, i) => `
  • ${txt(m.from_role)} ${stamp(m.at)}
    ${messageBody(m)}${!redesigned && withRequest.has(m.id) ? mount(m) : ''}
  • ${redesigned ? (mountsAfter.get(i) || []).map(origin => `
  • ${mount(origin)}
  • `).join('') : ''}`).join(''); + const messages = s.rows.map((m, i) => `
  • ${txt(m.from_role === 'human' ? 'You' : m.from_role)} ${stamp(m.at)}
    ${messageBody(m)}${!redesigned && withRequest.has(m.id) ? mount(m) : ''}
  • ${redesigned ? (mountsAfter.get(i) || []).map(origin => `
  • ${mount(origin)}
  • `).join('') : ''}`).join(''); const frame = redesigned ? window.MosaicProjects.header(project, 'conversations', current.threads, t.name, true) : heading(t.name); const details = redesigned ? `

    Conversation details

    ${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}
    ` : `
    Conversation details${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}`; return `

    ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

    ${frame}${libraryLine(libraryOf(current, t))}${details}
      ${messages || (current.readFailed ? '
    1. Messages unavailable.
    2. ' : '
    3. No messages yet.
    4. ')}
    ${current.readFailed ? '' : s.more ? '

    Showing earlier history. Read forward to reach the current conversation.

    ' : '

    Caught up to the last read.

    '}
    ${threadWrites() ? `

    To: ${txt(t.instance)} · Private conversation

    ${redesigned ? '

    Enter adds a new line.

    ' : ''}
    ` : ''}

    ${txt(receipt)}

    ${redesigned ? '' : '

    Files

    Files unavailable.
    '}
    `; @@ -259,7 +260,7 @@ const messages = s.rows.map(m => { const speaker = m.from_role === 'human' ? 'Human' : roomName(m.from_role); const recipients = m.from_role === 'human' ? ` → ${(m.to ?? []).map(r => txt(roomName(r))).join(', ')}` : ''; - return `
  • ${txt(speaker)}${recipients} ${stamp(m.at)}
    ${messageBody({ ...m, from_role: m.from_role === 'human' ? m.from_role : speaker })}${m.from_role !== 'human' && c.threads.find(t => t.instance === m.from_role && t.name === t.instance) ? `Open ${txt(speaker)} conversation` : ''}
  • `; + return `
  • ${txt(m.from_role === 'human' ? 'You' : speaker)}${recipients} ${stamp(m.at)}
    ${messageBody({ ...m, from_role: m.from_role === 'human' ? m.from_role : speaker })}${m.from_role !== 'human' && c.threads.find(t => t.instance === m.from_role && t.name === t.instance) ? `Open ${txt(speaker)} conversation` : ''}
  • `; }).join(''); const off = !c.roomReady || c.channelDisabled || s.pending || !roomOwner(); const redesigned = p && window.MosaicProjects && roomOwner() && (!legacyRoute() || current.path?.startsWith('/projects')); @@ -427,15 +428,16 @@ const redesigned = (!legacyRoute() || c.path?.startsWith('/projects')) && !!window.MosaicProjects && !c.user && !c.tester; let html = '

    Projects

    '; if (c.error && !(c.kind === 'room' && c.error === ROOM_LINES['room-not-open'] && redesigned)) html += ``; - 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') + ''; } 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)) ? `` : 'time unavailable'; - return `
    Request ${clean(state.request)}${chip(state)}

    ${clean(modeLabel(state.mode))} · Observed ${observed}

    ${stepper(state)}
    Files · – reviewed, – draftOpen files
    `; + return `
    Request ${requestCode(state.request)}${chip(state)}

    ${clean(modeLabel(state.mode))} · Observed ${observed}

    ${stepper(state)}
    Files · – reviewed, – draftOpen files
    `; } 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..790f4b94c --- /dev/null +++ b/packages/webui/tests/s5-polish-browser.test.mjs @@ -0,0 +1,74 @@ +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='') { + 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:'*'}]}); + b.on('Fetch.requestPaused',p=>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}; +} +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}=await setup(t,'/projects/threads/'+T,390,`window.reviewDelay['/api/chat/threads/${T}/sessions']=1500;`); + await wait(`reviewReads.includes('/api/chat/threads/${T}/sessions')`); + await b.evaluate(`MosaicRequests.mount(document.querySelector('#bus-view'))`); + const body=await b.evaluate(`document.querySelector('#bus-view').textContent`); + assert.doesNotMatch(body,/Thread unavailable|Conversation unavailable/); + 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('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='
    wide
    ';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:[]}); From c2f655ff90a2ab146d8170a682746f1ac55e859c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 6 Nov 2026 20:02:54 -0600 Subject: [PATCH 2/2] fix(webui): read again after a failed history read (ui slice 5 review) --- packages/webui/src/public/chat.js | 2 +- .../webui/tests/s5-polish-browser.test.mjs | 29 +++++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index a171d383c..592b41098 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -225,7 +225,7 @@ const messages = s.rows.map((m, i) => `
  • ${txt(m.from_role === 'human' ? 'You' : m.from_role)} ${stamp(m.at)}
    ${messageBody(m)}${!redesigned && withRequest.has(m.id) ? mount(m) : ''}
  • ${redesigned ? (mountsAfter.get(i) || []).map(origin => `
  • ${mount(origin)}
  • `).join('') : ''}`).join(''); const frame = redesigned ? window.MosaicProjects.header(project, 'conversations', current.threads, t.name, true) : heading(t.name); const details = redesigned ? `

    Conversation details

    ${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}
    ` : `
    Conversation details${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}`; - return `

    ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

    ${frame}${libraryLine(libraryOf(current, t))}${details}
      ${messages || (current.readFailed ? '
    1. Messages unavailable.
    2. ' : '
    3. No messages yet.
    4. ')}
    ${current.readFailed ? '' : s.more ? '

    Showing earlier history. Read forward to reach the current conversation.

    ' : '

    Caught up to the last read.

    '}
    ${threadWrites() ? `

    To: ${txt(t.instance)} · Private conversation

    ${redesigned ? '

    Enter adds a new line.

    ' : ''}
    ` : ''}

    ${txt(receipt)}

    ${redesigned ? '' : '

    Files

    Files unavailable.
    '}
    `; + return `

    ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

    ${frame}${libraryLine(libraryOf(current, t))}${details}
      ${messages || (current.readFailed ? '
    1. Messages unavailable.
    2. ' : '
    3. No messages yet.
    4. ')}
    ${current.readFailed ? '' : s.more ? '

    Showing earlier history. Read forward to reach the current conversation.

    ' : '

    Caught up to the last read.

    '}
    ${threadWrites() ? `

    To: ${txt(t.instance)} · Private conversation

    ${redesigned ? '

    Enter adds a new line.

    ' : ''}
    ` : ''}

    ${txt(receipt)}

    ${redesigned ? '' : '

    Files

    Files unavailable.
    '}
    `; } // C2-10c: rooms reuse the same state, renderer and generation fences as threads. const ROOM_LINES = Object.freeze({ diff --git a/packages/webui/tests/s5-polish-browser.test.mjs b/packages/webui/tests/s5-polish-browser.test.mjs index 790f4b94c..b2bbc5a0b 100644 --- a/packages/webui/tests/s5-polish-browser.test.mjs +++ b/packages/webui/tests/s5-polish-browser.test.mjs @@ -4,15 +4,19 @@ 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='') { +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:'*'}]}); - b.on('Fetch.requestPaused',p=>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(()=>{})); + 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}; + 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;`); @@ -24,11 +28,13 @@ test('conversation and room keep a mobile gutter for long human messages',async } }); test('a repaint while the initial conversation read is pending stays in the reading state',async t=>{ - const {b,wait}=await setup(t,'/projects/threads/'+T,390,`window.reviewDelay['/api/chat/threads/${T}/sessions']=1500;`); - await wait(`reviewReads.includes('/api/chat/threads/${T}/sessions')`); - await b.evaluate(`MosaicRequests.mount(document.querySelector('#bus-view'))`); + 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=>{ @@ -39,6 +45,17 @@ test('a failed conversation read offers a retry without duplicate archive notice 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);