merge: ui slice 5 polish and review fix into ui-dev
This commit is contained in:
commit
43686b1451
14 files changed
+174
-30
No files matched your search
@@ -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.
|
||||
|
||||
@@ -425,12 +425,13 @@
|
||||
<h2 id="b-for-you">Open decisions <span class="count">${rows.length}</span></h2>${body}
|
||||
<p class="small muted">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.</p>`;
|
||||
}
|
||||
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.<span class="source">Details: ${txt(m.body.slice('The turn failed: '.length))}</span>` : 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 ? `<ul id="message-list" class="waiting s1-replies">${page.rows.map(m =>
|
||||
`<li class="wait-item"><p class="small muted">${txt(m.from_role)} · ${txt(m.class)} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-message">${txt(m.body)}</p></li>`).join('')}</ul>`
|
||||
`<li class="wait-item"><p class="small muted">${txt(m.from_role)} · ${txt(m.class)} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-message">${messageText(m)}</p></li>`).join('')}</ul>`
|
||||
: 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.`)}
|
||||
<p class="small muted">${page.total} messages · ${page.unread} unread</p>${body}
|
||||
@@ -501,7 +502,7 @@
|
||||
const taskTable = (rows, label) => `<div class="table-wrap" tabindex="0" role="region" aria-label="${esc(label)}, scroll for all columns"><table class="s1-table s1-tasks"><thead><tr>${['Task', 'Requirement', 'Assignees', 'Due', 'Priority', 'Done', 'Changed', 'Markers'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(taskRow).join('')}</tbody></table></div>`;
|
||||
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'}. <span class="feat">Poller status: from the S3 status read, not built yet</span>`;
|
||||
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) {
|
||||
@@ -1099,7 +1100,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(`<section class="panel" aria-labelledby="doc-p-${esc(p)}"><h2 id="doc-p-${esc(p)}">${txt(p)} <span class="count">${files.length}</span></h2>${code ? `<p class="small warn">Not read: <code>${txt(code)}</code>.</p>` : files.length ? `<ul class="s1-plain">${files.map(f => `<li><a href="#/docs/${enc(p)}/${enc(f.path)}">${txt(f.path)}</a></li>`).join('')}</ul>` : '<p class="muted">No spec or docs files.</p>'}</section>`);
|
||||
blocks.push(`<section class="panel" aria-labelledby="doc-p-${esc(p)}"><h2 id="doc-p-${esc(p)}">${txt(p)} <span class="count">${files.length}</span></h2>${code ? `<p class="small warn">These files could not be read. Read again to retry. <code>${txt(code)}</code></p>${retry}` : files.length ? `<ul class="s1-plain">${files.map(f => `<li><a href="#/docs/${enc(p)}/${enc(f.path)}">${txt(f.path)}</a></li>`).join('')}</ul>` : '<p class="muted">No spec or docs files.</p>'}</section>`);
|
||||
}
|
||||
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 <code>--send</code> 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.')}`;
|
||||
@@ -1197,7 +1198,7 @@
|
||||
const loadingView = (source = null) => `${head('Reading…')}<div class="skel-rows" aria-busy="true"><span class="sr-only">${source ? `Reading the ${esc(source.toLowerCase())}.` : 'Reading from the bus.'}</span><span class="skel"></span><span class="skel"></span><span class="skel"></span><span class="skel"></span></div>`;
|
||||
const retry = '<div class="actions"><button type="button" class="btn" data-retry>Read again</button></div>';
|
||||
// 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 = `<code>${txt(err.code)}</code>`;
|
||||
// The queue, business and settings reads: the module's own text, redacted by the server.
|
||||
@@ -1210,7 +1211,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 ? `<br><span class="source">${txt(err.message)}</span>` : ''}`;
|
||||
return `${said}${omitCode ? '' : ` (${code})`}${err.code !== 'unreachable' && err.message ? `<br><span class="source">${txt(err.code === 'queue-refused' ? err.message.replace(/<path>/g, 'This checkout') : err.message)}</span>` : ''}`;
|
||||
}
|
||||
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.',
|
||||
@@ -1223,14 +1224,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))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${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))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err, err.code === 'queue-refused')} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${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 => `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
|
||||
// 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'; };
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
return `<ul class="chat-cites">${list.map(c => `<li>${c.badge ? `<span class="chat-scope chat-scope-${c.badge}">${txt(c.badge)}</span> ` : ''}${c.link ? `<a href="${esc(c.link)}" rel="noopener noreferrer" target="_blank">${txt(c.ref)}</a>` : `<code>${txt(c.ref)}</code>`} <q>${txt(c.quote)}</q>${c.agent ? ` <span class="chat-label chat-agent-written">agent-written: ${txt(c.agent)}</span>` : ''}${c.changed ? ' <span class="chat-label chat-changed">changed since</span>' : ''}</li>`).join('')}</ul>`;
|
||||
}
|
||||
function messageBody(m) {
|
||||
if (m.from_role !== 'human' && typeof m.body === 'string' && m.body.startsWith('The turn failed: ')) return `<div class="chat-body">The assistant couldn't reply. Try again, or check Agents.<div class="small">Details: ${txt(m.body.slice('The turn failed: '.length))}</div></div>`;
|
||||
const r = renderReply?.(m.body, { check: m.check ?? null, name: m.from_role, changed: changedBy(m.check) });
|
||||
if (r?.kind === 'reply') return `<div class="chat-body">${txt(r.text)}</div>${r.citations.length ? cites(r.citations) : ''}`;
|
||||
if (r?.kind === 'notice') {
|
||||
@@ -221,10 +222,10 @@
|
||||
const list = mountsAfter.get(index) || []; list.push(m); mountsAfter.set(index, list);
|
||||
});
|
||||
const mount = m => `<div class="request-mount" data-request="${esc(m.request)}" data-origin-message="${esc(m.id)}"></div>`;
|
||||
const messages = s.rows.map((m, i) => `<li class="${m.from_role === 'human' ? 'chat-message-human' : 'chat-message-agent'}" data-message="${esc(m.id)}"><div class="chat-message-meta"><span class="chat-avatar" aria-hidden="true">${txt(String(m.from_role || '?').slice(0, 1).toUpperCase())}</span><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div>${messageBody(m)}${!redesigned && withRequest.has(m.id) ? mount(m) : ''}</li>${redesigned ? (mountsAfter.get(i) || []).map(origin => `<li class="project-request-item">${mount(origin)}</li>`).join('') : ''}`).join('');
|
||||
const messages = s.rows.map((m, i) => `<li class="${m.from_role === 'human' ? 'chat-message-human' : 'chat-message-agent'}" data-message="${esc(m.id)}"><div class="chat-message-meta"><span class="chat-avatar" aria-hidden="true">${txt(String(m.from_role || '?').slice(0, 1).toUpperCase())}</span><strong>${txt(m.from_role === 'human' ? 'You' : m.from_role)}</strong> ${stamp(m.at)}</div>${messageBody(m)}${!redesigned && withRequest.has(m.id) ? mount(m) : ''}</li>${redesigned ? (mountsAfter.get(i) || []).map(origin => `<li class="project-request-item">${mount(origin)}</li>`).join('') : ''}`).join('');
|
||||
const frame = redesigned ? window.MosaicProjects.header(project, 'conversations', current.threads, t.name, true) : heading(t.name);
|
||||
const details = redesigned ? `<dialog class="chat-details project-dialog" id="project-conversation-dialog" aria-labelledby="project-conversation-title"><h2 id="project-conversation-title" tabindex="-1">Conversation details</h2><button class="btn" type="button" data-dialog-close>Close</button>${sessionControls(t)}${sharing(t)}<p>Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}</dialog>` : `<details class="chat-details"><summary id="chat-details-toggle">Conversation details</summary>${sessionControls(t)}${sharing(t)}</details><p class="muted">Reading never wakes the assistant. An accepted message is not an answer.</p>${threadWrites() ? `<button class="btn" type="button" id="chat-resume"${disabledSession(s.pending || current.channelDisabled)}>Resume assistant</button>` : ''}`;
|
||||
return `<section class="chat-conversation${redesigned ? ' project-chat' : ''}" data-project="${esc(t.project)}" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${frame}${libraryLine(libraryOf(current, t))}${details}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '' : s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message ${txt(t.instance)}</label><textarea id="chat-body" rows="4" placeholder="Write a message…"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><p class="chat-audience">To: ${txt(t.instance)} · Private conversation</p>${redesigned ? '<p class="project-composer-hint">Enter adds a new line.</p>' : ''}<button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p>${redesigned ? '' : '<section class="chat-files"><h2>Files</h2><div data-chat-files>Files unavailable.</div></section><div data-chat-summary></div>'}</section>`;
|
||||
return `<section class="chat-conversation${redesigned ? ' project-chat' : ''}" data-project="${esc(t.project)}" data-thread="${esc(t.id)}" data-session="${esc(current.session)}"><p>${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}</p>${frame}${libraryLine(libraryOf(current, t))}${details}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || (current.readFailed ? '<li class="muted">Messages unavailable.</li>' : '<li class="muted">No messages yet.</li>')}</ol><div class="chat-paging">${current.readFailed ? '<button type="button" class="btn" data-chat-retry>Read again</button>' : s.more ? '<p>Showing earlier history. Read forward to reach the current conversation.</p><button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${threadWrites() ? `<form id="chat-send-form"><label for="chat-body">Message ${txt(t.instance)}</label><textarea id="chat-body" rows="4" placeholder="Write a message…"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><p class="chat-audience">To: ${txt(t.instance)} · Private conversation</p>${redesigned ? '<p class="project-composer-hint">Enter adds a new line.</p>' : ''}<button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p>${redesigned ? '' : '<section class="chat-files"><h2>Files</h2><div data-chat-files>Files unavailable.</div></section><div data-chat-summary></div>'}</section>`;
|
||||
}
|
||||
// C2-10c: rooms reuse the same state, renderer and generation fences as threads.
|
||||
const ROOM_LINES = Object.freeze({
|
||||
@@ -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 `<li class="${m.from_role === 'human' ? 'chat-message-human' : 'chat-message-agent'}" data-message="${esc(m.id)}"><div class="chat-message-meta"><span class="chat-avatar" aria-hidden="true">${txt(String(speaker || '?').slice(0, 1).toUpperCase())}</span><strong>${txt(speaker)}</strong>${recipients} ${stamp(m.at)}</div>${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) ? `<a href="${threadPath(esc(c.threads.find(t => t.instance === m.from_role && t.name === t.instance).id))}">Open ${txt(speaker)} conversation</a>` : ''}</li>`;
|
||||
return `<li class="${m.from_role === 'human' ? 'chat-message-human' : 'chat-message-agent'}" data-message="${esc(m.id)}"><div class="chat-message-meta"><span class="chat-avatar" aria-hidden="true">${txt(String(speaker || '?').slice(0, 1).toUpperCase())}</span><strong>${txt(m.from_role === 'human' ? 'You' : speaker)}</strong>${recipients} ${stamp(m.at)}</div>${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) ? `<a href="${threadPath(esc(c.threads.find(t => t.instance === m.from_role && t.name === t.instance).id))}">Open ${txt(speaker)} conversation</a>` : ''}</li>`;
|
||||
}).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 = '<div class="chat-area"><p><a href="#/projects">Projects</a></p>';
|
||||
if (c.error && !(c.kind === 'room' && c.error === ROOM_LINES['room-not-open'] && redesigned)) html += `<p class="chat-error banner err" role="alert">${txt(c.error)}</p>`;
|
||||
if (c.archiveUnknown) html += '<p role="status">Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.</p>';
|
||||
if (c.archiveUnknown && !c.loading) html += '<p role="status" data-archive-notice>Archive status unavailable. Some changes are unavailable; sending may be refused; refresh to check.</p>';
|
||||
else if (c.kind === 'thread' && c.projectArchived) html += '<p role="status">This project is archived; its conversation stays readable.</p>';
|
||||
if (c.readFailed && (c.projects.length || c.threads.length || c.kind === 'thread' && state(c.key).loaded)) html += '<p role="status">Showing previously read chat content; it may be out of date.</p>';
|
||||
if (c.readFailed && (c.kind === 'thread' ? state(c.key).loaded : c.projects.length || c.threads.length)) html += '<p role="status">Showing previously read chat content; it may be out of date.</p>';
|
||||
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 += '<p role="status">Reading conversation…</p>';
|
||||
else html += heading('Conversation unavailable') + '<button type="button" class="btn" data-chat-retry>Read again</button>';
|
||||
} 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(); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; } }
|
||||
@@ -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 `<a href="#/tasks/${encodeURIComponent(ref)}">${clean(ref)}${typeof f.title==='string'?' · '+clean(f.title):''}</a>${f.done===true?' · Done':''}`;};
|
||||
const requestCode = id => `<code title="${clean(id)}">${clean(UUID.test(id) ? id.slice(0,8) : id)}</code>`;
|
||||
function card(thread,request,failed) {
|
||||
const file=request && delivery(request);
|
||||
return `<article class="project-work"><div class="project-work-head"><div><span class="project-conversation-icon" aria-hidden="true">◌</span><a href="${conversationLink(thread)}"><strong>${clean(thread.name)}</strong></a><p>With ${clean(thread.instance)}${request?` · Request <code>${clean(request.id)}</code>${request.state?.mode?' · '+clean(modeLabel(request.state.mode)):''} · Observed just now`:failed?' · Request status unavailable':' · No active request'}</p></div>${request?chip(request.state):''}</div>${stepper(request?.state)}${request?.state?.wait?.code?`<p>Waiting: ${clean(request.state.wait.code)}</p>`:''}${file?`<p><code>${clean(file.name)}</code> · Reviewed delivery <a download href="/api/requests/${encodeURIComponent(request.id)}/artifact/${encodeURIComponent(file.id)}">Download</a> <a href="${path(request.thread.project,'files')}">Open files</a></p>`:''}<div class="project-work-bottom">${request?.state?.tasks?.length?`<span>Tasks: ${request.state.tasks.filter(v=>/^vikunja:[0-9]+\/[0-9]+$/.test(v)).map(taskLink).join(', ')}</span>`:''}<a class="btn primary" href="${conversationLink(thread)}">Open conversation</a></div></article>`;
|
||||
return `<article class="project-work"><div class="project-work-head"><div><span class="project-conversation-icon" aria-hidden="true">◌</span><a href="${conversationLink(thread)}"><strong>${clean(thread.name)}</strong></a><p>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'}</p></div>${request?chip(request.state):''}</div>${stepper(request?.state)}${request?.state?.wait?.code?`<p>Waiting: ${clean(request.state.wait.code)}</p>`:''}${file?`<p><code>${clean(file.name)}</code> · Reviewed delivery <a download href="/api/requests/${encodeURIComponent(request.id)}/artifact/${encodeURIComponent(file.id)}">Download</a> <a href="${path(request.thread.project,'files')}">Open files</a></p>`:''}<div class="project-work-bottom">${request?.state?.tasks?.length?`<span>Tasks: ${request.state.tasks.filter(v=>/^vikunja:[0-9]+\/[0-9]+$/.test(v)).map(taskLink).join(', ')}</span>`:''}<a class="btn primary" href="${conversationLink(thread)}">Open conversation</a></div></article>`;
|
||||
}
|
||||
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):''}<span class="project-card-status-line">${data.failed?'Status unavailable · ':current?'':'No active requests · '}${ownThreads(project,threads).length} conversations · Updated just now</span>`;}).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):''}<span class="project-card-status-line">${data.failed?'Status unavailable · ':current?'':'No active requests · '}${ownThreads(project,threads).length} ${ownThreads(project,threads).length === 1 ? 'conversation' : 'conversations'} · Updated just now</span>`;}).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 ? `<span class="project-chip ${r.draft.snapshot?.id?'success':''}">${r.draft.snapshot?.id?'Approved':'Draft'}</span>` : ''}`; 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 ? `<span class="project-chip ${r.draft.snapshot?.id?'success':''}">${r.draft.snapshot?.id?'Approved':'Draft'}</span>` : ''}`; 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 datetime="${clean(observedAt)}">${clean(new Date(observedAt).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'}))}</time>` : 'time unavailable';
|
||||
return `<div class="project-request-intro" data-request-revision="${clean(state.revision)}"><div class="project-work-head"><strong>Request <code>${clean(state.request)}</code></strong>${chip(state)}</div><p>${clean(modeLabel(state.mode))} · Observed ${observed}</p>${stepper(state)}<div class="project-work-row"><span data-work-prd>PRD –</span><a href="${path(projectId,'requirements')}">Open requirements</a></div><div class="project-work-row"><span data-work-files>Files · – reviewed, – draft</span><a href="${path(projectId,'files')}">Open files</a></div></div>`;
|
||||
return `<div class="project-request-intro" data-request-revision="${clean(state.revision)}"><div class="project-work-head"><strong>Request ${requestCode(state.request)}</strong>${chip(state)}</div><p>${clean(modeLabel(state.mode))} · Observed ${observed}</p>${stepper(state)}<div class="project-work-row"><span data-work-prd>PRD –</span><a href="${path(projectId,'requirements')}">Open requirements</a></div><div class="project-work-row"><span data-work-files>Files · – reviewed, – draft</span><a href="${path(projectId,'files')}">Open files</a></div></div>`;
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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}'`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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='<div class="panel routing-panel"><div class="table-wrap"><table id="routes-table"><tr><td style="min-width:900px">wide</td></tr></table></div></div>';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:'<path> 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,/<path>/);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.headingBottom,JSON.stringify(r));assert.ok(r.card>=r.parent-1,JSON.stringify(r));assert.ok(!r.archive || r.archive==='none',JSON.stringify(r));
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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:[]});
|
||||
|
||||
Reference in new issue
Block a user