feat(webui): project pages redesign (ui slice 3)

This commit is contained in:
jason.woltje committed 2026-11-06 15:29:46 -06:00
1 parent 700259aa38
commit c907e4d0d0
19 files changed
+520 -45

No files matched your search

+1
View File
@@ -117,6 +117,7 @@
function breadcrumbs() {
const path = location.hash.slice(2).split('?')[0], parts = path.split('/').filter(Boolean), box = $('breadcrumbs');
if (!box || !$('bus-view')) return;
if (window.MosaicProjects?.breadcrumb?.(box, $('bus-view'))) return;
if (!parts.length || parts[0] === 'board') { box.innerHTML = '<span aria-current="page">Dashboard</span>'; return; }
const names = {projects:'Projects',chat:'Projects',inbox:'Inbox',messages:'Messages',tasks:'Tasks',agents:'Agents',queue:'Queue',business:'Business',docs:'Docs',settings:'Settings'};
const name = names[parts[0]] || 'Dashboard', heading = $('bus-view').querySelector('h1')?.textContent?.trim();
+4 -6
View File
@@ -251,8 +251,9 @@
const items = [['/', 'Dashboard', '', 'board'], ['/projects', 'Projects', '', 'inbox'], ['/inbox', 'Inbox', inboxCount?.open > 0 ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount > 0 ? `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>` : '', 'talk'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/docs', 'Docs', '', 'library'], ['/settings', 'Settings', '', 'settings']];
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
const cur = h === '/' ? (route === '/' || route === '/board') : route.startsWith(h) || (h === '/projects' && route.startsWith('/chat')) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true">${h === '/projects' ? '<path d="M2 5h6l2 2h8v11H2z"/>' : `<use href="/icons.svg#i-${icon}"/>`}</svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true">${h === '/projects' ? '<path d="M2 5h6l2 2h8v11H2z"/>' : `<use href="/icons.svg#i-${icon}"/>`}</svg>${l}${extra ? ` ${extra}` : ''}</a>${h === '/projects' ? '<div id="project-tree" aria-label="Project conversations"></div>' : ''}</li>`;
}).join('');
window.MosaicProjectTree?.mount?.({ container: $('project-tree'), path: route });
if (focused) [...$('sections').querySelectorAll('a[href]')].find(a => a.getAttribute('href') === focused)?.focus({ preventScroll: true });
}
function countInbox(rows) { inboxCount = { open: rows.length, blocking: rows.filter(d => d.blocking).length }; }
@@ -1254,16 +1255,13 @@
document.body.classList.toggle('on-bus', !board);
$('bus-view').hidden = board;
sections(path);
// Conversations owns the project tree. The optional mount receives only the route,
// and must obtain authorized project data itself. It may be absent during integration.
window.MosaicProjectTree?.mount?.({ container: $('project-tree'), path });
document.querySelectorAll('.rail a').forEach(a => { const target = a.getAttribute('href').slice(1); const active = target === '/projects' ? /^\/(projects|chat)(\/|$)/.test(path) : target === '/' ? path === '/' : path === target; if (active) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); });
const chatPath = path === '/projects' ? '/chat' : /^\/projects\/(threads|rooms)\//.test(path) ? '/chat' + path.slice('/projects'.length) : /^\/projects\/[^/]+$/.test(path) ? '/chat/projects/' + path.slice('/projects/'.length) : path;
window.MosaicChat?.leave(chatPath);
if (path === '/settings/display' && window.MosaicDisplay) {
window.MosaicDisplay.render($('bus-view'), navigated); shown = route; return;
}
if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/settings$/i.test(chatPath)) {
if (chatPath === '/chat' || chatPath.startsWith('/chat/') || /^\/projects\/[0-9a-f-]+\/(?:settings|conversations|tasks|requirements|files)$/i.test(chatPath)) {
// Row 177 (#1675): the tester Console writes to its own threads.
const focusedHeading = document.activeElement === $('bus-view').querySelector('h1');
await window.MosaicChat.render({ path: chatPath, container: $('bus-view'), canSend: canSend || isTester, tester: isTester, navigated });
@@ -1389,7 +1387,7 @@
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
const tasks = (last.get('tasks')?.rows || []).filter(r => TASK.test(String(r.task_ref))).map(r => { const t = task(r); return { href: `#/tasks/${enc(t.ref)}`, label: `#${t.id} ${t.title ?? '(no title in the current read)'}`, group: 'task' }; });
const rows = (last.get('api:queue')?.rows || []).filter(r => Number.isInteger(r.id)).map(r => ({ href: `#/queue/${r.id}`, label: `${r.id} ${r.piece ?? ''}`, group: `queue row, ${r.state}` }));
pkAll = [...VIEWS.map(([href, label]) => ({ href, label, group: 'view' })), ...decisions, ...tasks, ...rows];
pkAll = [...VIEWS.map(([href, label]) => ({ href, label, group: 'view' })), ...(window.MosaicProjects?.palette?.() || []), ...decisions, ...tasks, ...rows];
}
function pkFill() {
const q = $('cmdk-input').value.trim().toLowerCase();
+42 -16
View File
@@ -209,9 +209,13 @@
function conversation(t) {
const s = state(current.key);
const withRequest = requestRows(s.rows);
const messages = s.rows.map(m => `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(m.from_role)}</strong> ${stamp(m.at)}</div>${messageBody(m)}${withRequest.has(m.id) ? `<div class="request-mount" data-request="${esc(m.request)}"></div>` : ''}</li>`).join('');
const messages = s.rows.map(m => `<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)}${withRequest.has(m.id) ? `<div class="request-mount" data-request="${esc(m.request)}"></div>` : ''}</li>`).join('');
const receipt = s.receipt;
return `<section class="chat-conversation" 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>${heading(t.name)}${libraryLine(libraryOf(current, t))}<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>` : ''}<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 this thread</label><textarea id="chat-body" rows="4"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><p class="chat-audience">To: ${txt(t.instance)} · Private conversation</p><button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p><section class="chat-files"><h2>Files</h2><div data-chat-files>Files unavailable.</div></section><div data-chat-summary></div></section>`;
const project = current.projects.find(p => p.id === t.project);
const redesigned = project && roomOwner() && window.MosaicProjects && (!legacyRoute() || current.path?.startsWith('/projects'));
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" 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"${disabledSession(s.pending)}>${esc(s.draft)}</textarea><p class="chat-audience">To: ${txt(t.instance)} · Private conversation</p><button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p><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({
@@ -246,10 +250,12 @@
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 data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(speaker)}</strong>${recipients} ${stamp(m.at)}</div>${messageBody({ ...m, from_role: m.from_role === 'human' ? m.from_role : speaker })}</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(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();
return `<section class="chat-room" data-project="${esc(c.id)}"><p>${link('projects', c.id, p?.name ?? 'Project')}</p>${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}<p>Messages are copied to selected contacts. Their replies are private.</p><p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p><ol class="chat-log chat-room-log" tabindex="0" aria-label="Room conversation">${messages || (c.readFailed ? '<li>Messages unavailable.</li>' : '<li>No messages yet.</li>')}</ol><div class="chat-paging">${c.readFailed ? '' : s.more ? '<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>${c.projectArchived ? '<p>This project is archived; its room history stays readable.</p>' : `<form id="chat-room-send-form"><fieldset class="chat-room-picker"${off ? ' disabled' : ''}><legend>Choose recipients</legend>${(c.members ?? []).map(m => `<label><input type="checkbox" id="chat-member-${esc(m.instance)}" data-room-member="${esc(m.instance)}"> ${txt(m.name)} (@${txt(m.instance)})</label>`).join('')}</fieldset><label for="chat-room-body">Message the room</label><p id="chat-room-help">Choose contacts above, or use leading @tags. Without tags, the message goes to all room members. Mentions in the message do not add recipients. The broker checks every address.</p><textarea id="chat-room-body" rows="4" aria-describedby="chat-room-help" aria-autocomplete="list" aria-controls="chat-room-completion" aria-expanded="false"${disabled(off)}>${esc(s.draft)}</textarea><div id="chat-room-offer"></div><div id="chat-room-tags"></div><ul id="chat-room-completion" class="chat-room-completion" role="listbox" aria-label="Room members" hidden></ul><button class="btn primary" type="submit"${disabled(off)}>Send to room</button></form>`}<p id="chat-room-receipt" role="status">${txt(s.receipt)}</p></section>`;
const redesigned = p && window.MosaicProjects && roomOwner() && (!legacyRoute() || current.path?.startsWith('/projects'));
const frame = redesigned ? window.MosaicProjects.header(p, 'room', c.threads) + `<div class="project-section-title"><h2>Project room</h2><button class="btn" type="button" data-project-dialog="room">Room details</button></div><dialog class="project-dialog" id="project-room-dialog" aria-labelledby="project-room-title"><h2 id="project-room-title" tabindex="-1">Room details</h2><button class="btn" type="button" data-dialog-close>Close</button>${roomOpenControl(c.id)}<p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p></dialog>` : heading((p?.name ?? 'Project') + ' room') + roomOpenControl(c.id);
return `<section class="chat-room" data-project="${esc(c.id)}"><p>${link('projects', c.id, p?.name ?? 'Project')}</p>${frame}${libraryLine(libraryOf(c))}<p>Messages are copied to the members you choose. Each reply stays in that member's conversation.</p><p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p><ol class="chat-log chat-room-log" tabindex="0" aria-label="Room conversation">${messages || (c.readFailed ? '<li>Messages unavailable.</li>' : '<li>No messages yet.</li>')}</ol><div class="chat-paging">${c.readFailed ? '' : s.more ? '<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>${c.projectArchived ? '<p>This project is archived; its room history stays readable.</p>' : `<form id="chat-room-send-form"><fieldset class="chat-room-picker"${off ? ' disabled' : ''}><legend>To: <span class="sr-only">Recipients</span></legend>${(c.members ?? []).map(m => redesigned ? `<input class="room-chip-input" type="checkbox" id="chat-member-${esc(m.instance)}" data-room-member="${esc(m.instance)}" tabindex="-1" aria-hidden="true"><button type="button" class="room-chip" data-room-chip="${esc(m.instance)}" aria-pressed="true">${txt(m.name)}</button>` : `<label><input type="checkbox" id="chat-member-${esc(m.instance)}" data-room-member="${esc(m.instance)}"> ${txt(m.name)} (@${txt(m.instance)})</label>`).join('')}</fieldset><label for="chat-room-body">Message the room</label><p id="chat-room-help">Choose contacts above, or use leading @tags. Without tags, the message goes to all room members. Mentions in the message do not add recipients. The broker checks every address.</p><textarea id="chat-room-body" rows="4" aria-describedby="chat-room-help" aria-autocomplete="list" aria-controls="chat-room-completion" aria-expanded="false"${disabled(off)}>${esc(s.draft)}</textarea><div id="chat-room-offer"></div><div id="chat-room-tags"></div><ul id="chat-room-completion" class="chat-room-completion" role="listbox" aria-label="Room members" hidden></ul><button class="btn primary" type="submit"${disabled(off)}>Send to room</button></form>`}<p id="chat-room-receipt" role="status">${txt(s.receipt)}</p></section>`;
}
function drawRoomCompletion() {
const list = document.getElementById('chat-room-completion'), field = document.getElementById('chat-room-body');
@@ -269,7 +275,7 @@
const mentioned = tags.length ? [...body.matchAll(/@([A-Za-z0-9][A-Za-z0-9_.-]*)/g)].map(m => m[1]).find(name => !tags.includes(name) && current.members.some(m => m.instance === name)) : null;
if (offer) offer.innerHTML = mentioned ? `<button class="btn" type="button" data-room-offer="${esc(mentioned)}">Add ${txt(roomName(mentioned))} to recipients</button>` : '';
preview.innerHTML = tags.length ? 'To: ' + tags.map(tag => `<span data-room-tag="${esc(tag)}">@${txt(tag)}</span>`).join(' ') : 'To: all room members';
for (const box of document.querySelectorAll('[data-room-member]')) box.checked = tags.length ? tags.includes(box.dataset.roomMember) : true;
for (const box of document.querySelectorAll('[data-room-member]')) { box.checked = tags.length ? tags.includes(box.dataset.roomMember) : true; document.querySelector(`[data-room-chip="${CSS.escape(box.dataset.roomMember)}"]`)?.setAttribute('aria-pressed', String(box.checked)); }
const prefix = field.value.slice(0, field.selectionStart), token = /(?:^|\s)@([^\s@]*)$/u.exec(prefix);
current.completion = null;
if (document.activeElement === field && !field.disabled && token && !roomAddress(prefix.slice(0, token.index)).body.trim()) {
@@ -287,13 +293,13 @@
field.value = value; state(current.key).draft = value; field.focus(); field.setSelectionRange(caret, caret);
updateRoomCompletion();
}
async function renderRoom({ path, container, canSend, user, tester, navigated, next }) {
async function renderRoom({ path, originalPath = path, container, canSend, user, tester, navigated, next }) {
if (out()) return;
controller?.abort(); controller = new AbortController();
const signal = controller.signal, g = ++generation;
const match = /^\/chat\/rooms\/([0-9a-f-]+)$/i.exec(path), id = match?.[1];
const old = current;
current = { kind: 'room', id, key: 'room:' + id, path, container, canSend, user, tester,
current = { kind: 'room', id, key: 'room:' + id, path: originalPath, container, canSend, user, tester,
projects: old?.kind === 'room' && old.id === id ? old.projects : [], threads: [], members: [], roomReady: false, channelDisabled: old?.channelDisabled ?? false, refused: false, error: '' };
current.projectArchived = current.projects.some(p => p.id === id && p.archived);
if (!UUID.test(id ?? '') || !roomOwner()) {
@@ -400,6 +406,7 @@
function paint(navigated = false) {
if (!current || out()) return;
const keep = capture(), c = current;
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) 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>';
@@ -413,14 +420,19 @@
else html += heading('Thread unavailable');
} else if (c.kind === 'project') {
const p = c.projects.find(p => p.id === c.id);
html += heading(p?.name ?? 'Project unavailable');
html += p && !c.refused && redesigned ? window.MosaicProjects.header(p, c.section, c.threads) : heading(p?.name ?? 'Project unavailable');
if (p && !c.refused) {
if (p.archived && redesigned && !c.canSend) html += '<p class="project-panel" role="status">This project is archived. Its saved conversations and records stay readable.</p>';
if (c.settings) html += `<p><a href="#/projects/${esc(c.id)}">Project overview</a></p><h2>Project information</h2><p>Repository: ${txt(p.repo || 'Not linked')}</p><div data-chat-remote>Repository setting unavailable.</div>`;
else if (c.section === 'conversations') html += '<h2>Conversations</h2>' + threads(c.threads.filter(t => !t.shared)) + (p.archived ? '' : creation('thread', c.id));
else if (redesigned) html += '<div data-project-content>Reading…</div>';
else html += `<p><a href="#/projects/${esc(c.id)}/settings">Project information</a></p>${knownRequests.get(c.id)?.repo === p.repo && knownRequests.get(c.id)?.ids.size ? '<p>Request status from conversations opened in this session. Other work may not be shown.</p><div data-chat-summary></div>' : '<p>Request status is not loaded. Open a conversation to see its requests.</p>'}<h2>Conversations</h2>` + roomOpenControl(c.id) + threads(c.threads.filter(t => !t.shared)) + '<h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + (p.archived ? '' : creation('thread', c.id));
}
} else if (redesigned) {
html += '<div class="project-list-head">' + heading('Projects') + '<p>Projects and their conversations.</p></div>' + (c.canSend ? '<details class="project-create"><summary class="btn primary">New project</summary>' + creation('project', 'projects') + '</details>' : '') + '<div class="chat-projects">' +
c.projects.filter(p => !p.archived).map(p => `<a class="project-card" href="${projectPath(esc(p.id))}"><span class="project-card-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M3 6h7l2 2h9v11H3z"/></svg></span><strong>${txt(p.name)}</strong><span>${p.repo ? `Repository: ${txt(p.repo)}` : 'No linked repository'}</span><span data-project-status="${esc(p.id)}">Reading…</span></a>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + (c.threads.some(t => t.shared) ? threads(c.threads.filter(t => t.shared)) : '<p>Nothing is shared with you yet.</p>');
} else {
html += heading('Projects') + '<p>Projects and their conversations.</p><div class="chat-projects">' +
c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
html += heading('Projects') + '<p>Projects and their conversations.</p><div class="chat-projects">' + c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
}
if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
c.container.innerHTML = html + '</div>';
@@ -441,10 +453,19 @@
const remote = c.container.querySelector('[data-chat-remote]');
const project = c.projects.find(p => p.id === c.id);
if (remote && roomOwner() && typeof window.MosaicProjectRemote?.mount === 'function') { remote.replaceChildren(); window.MosaicProjectRemote.mount({ container: remote, projectId: c.id, repositoryKey: project?.repo ?? null, projectArchived: !!c.projectArchived, preserve: true }); }
window.MosaicProjectArchive?.mount(c.container, c);
if (!redesigned || c.kind !== 'project' || c.settings || c.projectArchived) window.MosaicProjectArchive?.mount(c.container, c);
if (redesigned && c.kind === 'project' && c.projectArchived && !c.settings) {
const controls = c.container.querySelector('.project-archive-control');
if (controls) c.container.querySelector('.project-tabs')?.insertAdjacentElement('afterend', controls);
}
if (!c.user && !c.tester && window.MosaicProjects) {
const projectContent = c.container.querySelector('[data-project-content]');
if (projectContent && project) void window.MosaicProjects.mountContent({ container: projectContent, project, threads: c.threads, section: c.section, canSend: c.canSend });
if (c.kind === 'list') window.MosaicProjects.enhanceList(c.container, c.projects, c.threads);
}
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
const details = c.container.querySelector('.chat-details');
if (details && keep?.detailsOpen) details.open = true;
if (details && keep?.detailsOpen && details.tagName === 'DETAILS') details.open = true;
const log = c.container.querySelector('.chat-log');
if (log) {
const s = state(c.key ?? c.id);
@@ -460,6 +481,7 @@
}
} else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true });
updateRoomCompletion();
if (navigated) c.container.querySelector('.project-tabs [aria-current]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
function readError(err) {
return err.code === 'channel-disabled' ? channelMessage : err.code === 'thread-not-owned' ? 'This thread is not available to this user.' :
@@ -469,12 +491,14 @@
async function render({ path, container, canSend, user = false, tester = false, navigated = false, next = false }) {
if (out()) return;
const originalPath = path;
const settings = /^\/projects\/([0-9a-f-]+)\/settings$/i.exec(path);
if (settings) path = '/chat/projects/' + settings[1];
const sectionRoute = /^\/projects\/([0-9a-f-]+)\/(settings|conversations|tasks|requirements|files)$/i.exec(path);
const settings = sectionRoute?.[2] === 'settings';
const section = sectionRoute?.[2] ?? 'overview';
if (sectionRoute) path = '/chat/projects/' + sectionRoute[1];
else if (path === '/projects') path = '/chat';
else if (/^\/projects\/(threads|rooms)\//.test(path)) path = '/chat/' + path.slice('/projects/'.length);
else if (/^\/projects\/[0-9a-f-]+$/i.test(path)) path = '/chat/projects/' + path.slice('/projects/'.length);
if (/^\/chat\/rooms(?:\/|$)/.test(path)) return renderRoom({ path, container, canSend, user, tester, navigated, next });
if (/^\/chat\/rooms(?:\/|$)/.test(path)) return renderRoom({ path, originalPath, container, canSend, user, tester, navigated, next });
const match = /^\/chat(?:\/(projects|threads)\/([0-9a-f-]+)(?:\/sessions\/([0-9a-f-]+))?)?$/.exec(path);
controller?.abort(); controller = new AbortController();
const signal = controller.signal, g = ++generation;
@@ -485,7 +509,7 @@
paint(); return;
}
const old = current;
current = { container, kind, id, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, 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, 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;
@@ -773,6 +797,8 @@
document.addEventListener('click', event => {
if (!current || out()) return;
if (event.target.id === 'chat-room-open') void openRoom();
const chip = event.target.closest?.('[data-room-chip]');
if (chip) { const name = chip.dataset.roomChip; document.querySelector(`[data-room-member="${CSS.escape(name)}"]`)?.click(); document.querySelector(`[data-room-chip="${CSS.escape(name)}"]`)?.focus(); }
const offer = event.target.closest?.('[data-room-offer]');
if (offer && current.kind === 'room') {
const field = document.getElementById('chat-room-body'), parsed = roomAddress(field.value), name = offer.dataset.roomOffer;
+3 -3
View File
@@ -3,9 +3,9 @@
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Dashboard · Mosaic Console</title>
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/inbox.css"><link rel="stylesheet" href="/requests.css"><link rel="stylesheet" href="/files.css"><link rel="stylesheet" href="/project-remote.css"><link rel="stylesheet" href="/shell.css">
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
<link rel="stylesheet" href="/project-archive.css">
<link rel="stylesheet" href="/project-archive.css"><link rel="stylesheet" href="/projects.css">
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="cmdbar"><button type="button" id="nav-open" class="btn" aria-controls="console-nav" aria-expanded="false" aria-label="Open menu" title="Menu"><svg class="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button><a href="#/" class="wordmark" aria-label="Mosaic Stack Dashboard"><svg class="mosaic-logo" viewBox="0 0 36 36" aria-hidden="true"><rect x="3" y="12" width="13" height="10" rx="2"/><rect x="18" y="3" width="13" height="12" rx="2"/><rect x="3" y="24" width="13" height="10" rx="2"/><rect x="18" y="17" width="13" height="12" rx="2"/></svg><span>Mosaic Stack</span></a>
@@ -15,7 +15,7 @@
<div id="processing-banner" class="processing-banner" role="alert" hidden></div>
<div id="processing-mobile"></div>
<div class="frame">
<div id="nav-scrim" hidden></div><nav class="tree" id="console-nav" aria-label="Console"><div class="drawer-head"><strong>Navigation</strong><button type="button" class="btn" id="nav-close">Close</button></div><p id="nav-overflow" class="small muted" hidden>Scroll for more destinations</p><div id="project-context" aria-label="Current project and conversation"></div><!-- Conversations slice owns this mount and its contents. --><div id="project-tree" aria-label="Project conversations"></div>
<div id="nav-scrim" hidden></div><nav class="tree" id="console-nav" aria-label="Console"><div class="drawer-head"><strong>Navigation</strong><button type="button" class="btn" id="nav-close">Close</button></div><p id="nav-overflow" class="small muted" hidden>Scroll for more destinations</p><div id="project-context" aria-label="Current project and conversation"></div>
<ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Board project filter</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><nav class="breadcrumbs" id="breadcrumbs" aria-label="Breadcrumb"><span aria-current="page">Dashboard</span></nav><div class="page-head" id="board-head"><div><h1>Dashboard</h1><p id="dashboard-business" class="muted">Business overview</p><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
<section id="dashboard-summary" aria-label="Dashboard metrics"><div id="dashboard-kpis" class="kpi-grid"></div><p id="dashboard-updated" class="kpi-footer">Not read yet</p></section>
+7 -3
View File
@@ -62,8 +62,12 @@
const li = document.createElement('li');
const a = document.createElement('a');
a.href = window.MosaicChat.projectPath(p.id);
a.textContent = p.name;
li.append(a, ' ');
a.className = 'project-card';
const icon = document.createElement('span'); icon.className = 'project-card-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = '▱';
const title = document.createElement('strong'); title.textContent = p.name;
const repo = document.createElement('span'); repo.textContent = p.repo ? `Repository: ${p.repo}` : 'No linked repository';
const status = document.createElement('span'); status.className = 'project-chip'; status.textContent = 'Archived';
a.append(icon, title, repo, status); li.append(a);
if (unarchive) {
const button = document.createElement('button');
button.type = 'button'; button.className = 'btn'; button.textContent = 'Unarchive';
@@ -89,7 +93,7 @@
const status = document.createElement('p');
status.id = 'project-archive-status'; status.setAttribute('role', 'status');
const line = document.createElement('p');
line.textContent = project.archived ? 'This project is archived. Its saved threads and records stay readable. Unarchive it to start new work.' : 'Archiving hides this project from the list and refuses new work. Nothing is deleted.';
line.textContent = project.archived ? 'This project is archived. Its saved conversations and records stay readable. Unarchive it to start new work.' : 'Archiving hides this project from the list and refuses new work. Nothing is deleted.';
const button = document.createElement('button');
button.type = 'button'; button.id = 'project-archive-button';
button.className = project.archived ? 'btn' : 'btn archive-danger';
+92
View File
@@ -0,0 +1,92 @@
/* Project cards share a readable width as the content area grows. */
.chat-projects { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--project-grid-min)), 1fr)); gap: var(--project-grid-gap); }
.project-card { display:flex; flex-direction:column; gap:8px; min-width:0; padding:20px; border:1px solid var(--line); border-radius:10px; background:var(--surface); color:var(--muted); text-decoration:none; }
.project-card:hover { border-color:var(--border); }
.project-card strong { color:var(--strong); font-size:18px; font-weight:700; }
.project-card-icon { display:grid; place-items:center; width:44px; height:44px; border-radius:8px; background:var(--raised); color:var(--action); font-size:24px; }
.project-header { display:flex; align-items:center; justify-content:space-between; gap:20px; margin:16px 0; }
.project-heading { display:flex; align-items:center; gap:16px; min-width:0; }
.project-heading h1 { margin:0; overflow-wrap:anywhere; }
.project-heading p { color:var(--muted); margin:4px 0 0; }
.project-header-icon { display:grid; place-items:center; width:56px; height:56px; flex:none; border-radius:10px; background:var(--raised); color:var(--action); font-size:28px; }
.project-actions { display:flex; flex-wrap:wrap; gap:12px; }
.project-actions .btn { display:inline-flex; align-items:center; text-decoration:none; }
.project-tabs { display:flex; gap:4px; overflow-x:auto; border-bottom:1px solid var(--line); }
.project-tabs a { display:flex; align-items:center; flex:none; min-height:44px; padding:0 16px; color:var(--muted); font-weight:600; text-decoration:none; }
.project-tabs a[aria-current] { color:var(--strong); background:var(--nav-active); border-bottom:2px solid var(--action); }
@media (max-width:699px) { .project-header { display:block; } .project-header-icon { width:44px; height:44px; } .project-actions { margin-top:12px; } }
.project-tree-list,.project-tree-children { list-style:none; margin:0; padding:0; }
#sections #project-tree { display:block !important; }
.project-tree-row { display:flex; align-items:center; min-width:0; min-height:44px; border-radius:6px; }
.project-tree-row.active,.project-tree-children a[aria-current] { background:var(--nav-active); color:var(--strong); }
.project-tree-row button { flex:none; width:44px; height:44px; color:var(--muted); border:0; background:none; cursor:pointer; font-size:20px; }
.project-tree-row a,.project-tree-children a { display:flex; align-items:center; min-height:44px; min-width:0; color:var(--text); text-decoration:none; overflow:hidden; }
.project-tree-row a span,.project-tree-children a span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.project-tree-children { margin-left:22px; border-left:1px solid var(--line); }
.project-tree-children a { padding:0 10px 0 20px; flex-direction:column; align-items:flex-start; justify-content:center; }
.project-tree-children small { color:var(--muted); }
.project-panel,.project-work,.project-row,.project-metrics { background:var(--surface); border:1px solid var(--line); border-radius:10px; }
.project-panel,.project-work { padding:20px; margin:12px 0; }
.project-decision { border-left:4px solid var(--warning); display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; }
.project-panel h2,.project-panel h3 { margin-top:0; }
.project-panel > h3:not(:first-child) { border-top:1px solid var(--line); padding-top:16px; margin-top:16px; }
.project-panel details { border-bottom:1px solid var(--line); }
.project-panel summary { min-height:44px; display:flex; align-items:center; cursor:pointer; font-weight:600; }
.project-columns { display:grid; grid-template-columns:minmax(0, 1.7fr) minmax(280px,1fr); gap:24px; margin-top:20px; }
.project-columns > * { min-width:0; }
.project-section-title,.project-work-head,.project-work-bottom { display:flex; justify-content:space-between; gap:12px; align-items:center; }
.project-work-head p,.project-work p,.project-panel p { color:var(--muted); }
.project-work-bottom { border-top:1px solid var(--line); padding-top:12px; flex-wrap:wrap; }
.project-row { display:flex; flex-direction:column; padding:16px; margin:12px 0; text-decoration:none; color:var(--strong); }
.project-row small { color:var(--muted); }
.project-stepper { display:flex; justify-content:space-between; gap:8px; margin:20px 0; }
.project-stepper span { padding:6px; color:var(--muted); }
.project-stepper .done { color:var(--success); }.project-stepper .current { color:var(--strong); background:var(--nav-active); border-radius:99px; }
.project-chip,.project-count { display:inline-flex; align-items:center; min-height:24px; border-radius:999px; padding:0 9px; font-size:12px; font-weight:600; background:var(--raised); color:var(--muted); }
.project-chip.success { color:var(--success); background:color-mix(in srgb,var(--success) 16%,var(--surface)); }.project-chip.warning { color:var(--warning); background:color-mix(in srgb,var(--warning) 16%,var(--surface)); }.project-chip.danger { color:var(--danger); background:color-mix(in srgb,var(--danger) 16%,var(--surface)); }
.project-metrics { display:grid; grid-template-columns:repeat(4,1fr); margin-top:20px; padding:16px 0 8px; }
.project-metrics a { display:flex; flex-direction:column; min-width:0; padding:0 20px; text-decoration:none; color:var(--text); border-right:1px solid var(--line); }
.project-metrics a:hover { background:var(--raised); }.project-metrics strong { font-size:30px; color:var(--strong); }.project-metrics small { color:var(--muted); }
.project-metrics p { grid-column:1/-1; border-top:1px solid var(--line); margin:12px 20px 0; padding-top:8px; font-size:12px; color:var(--muted); }
.project-file-list > div,.project-file-row { display:flex; justify-content:space-between; gap:10px; align-items:center; min-height:44px; border-bottom:1px solid var(--line); overflow-wrap:anywhere; }
.project-file-row small { display:block; color:var(--muted); }
@media (max-width:1100px) { .project-columns { grid-template-columns:1fr; }.project-metrics { grid-template-columns:repeat(2,1fr); } }
@media (max-width:699px) { .project-stepper span { flex:1; text-align:center; font-size:12px; overflow-wrap:anywhere; }.project-work-bottom .btn { width:100%; }.project-metrics strong { font-size:26px; } }
.project-list-head p { color:var(--muted); font-size:16px; }
.project-create { margin:12px 0; }.project-create > summary { display:inline-flex; align-items:center; cursor:pointer; }
.chat-area:has(.project-list-head) { position:relative; }
@media(min-width:700px) { .project-create { position:absolute; top:0; right:0; z-index:1; } .project-create[open] { background:var(--surface); padding:12px; border:1px solid var(--line); border-radius:10px; } }
.project-archived-filter ul#project-archive-list { display:grid; grid-template-columns:repeat(auto-fill,minmax(min(100%, var(--project-grid-min)),1fr)); gap:var(--project-grid-gap); }
.project-archived-filter li { display:flex; flex-direction:column; align-items:stretch; background:var(--surface); border:1px solid var(--line); border-radius:10px; padding:0 0 16px; }
.project-archived-filter .project-card { border:0; color:var(--muted); }
.project-archived-filter li > button { align-self:flex-start; margin:0 20px; }
.project-header .btn,.project-work .btn,.project-panel .btn,.project-section-title .btn { display:inline-flex; align-items:center; justify-content:center; min-height:44px; text-decoration:none; }
@media(max-width:699px) { .project-crumb-current { display:none; }.project-create > summary { display:flex; width:100%; justify-content:center; } }
.project-dialog { position:fixed; inset:0 0 0 auto; margin:0; width:min(400px,100vw); max-width:100vw; max-height:100vh; height:100vh; overflow:auto; box-sizing:border-box; background:var(--surface); color:var(--text); border:1px solid var(--line); padding:20px; }
.project-dialog::backdrop { background:rgb(0 0 0 / .55); }
.project-dialog > [data-dialog-close] { position:absolute; right:16px; top:16px; }
.chat-conversation .chat-log,.chat-room .chat-log { border:0; padding:0; max-height:56vh; }
.chat-conversation .chat-log > li,.chat-room .chat-log > li { border:0; display:flex; flex-direction:column; align-items:flex-start; }
.chat-conversation .chat-log > li.chat-message-human,.chat-room .chat-log > li.chat-message-human { align-items:flex-end; }
.chat-log > li > .chat-body { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:12px 16px; max-width:min(720px,100%); }
.chat-log > li.chat-message-human > .chat-body { background:var(--accent-fill); color:var(--on-accent); }
#chat-send-form,#chat-room-send-form { background:var(--surface); border:1px solid var(--line); border-radius:10px; padding:16px; }
@media(max-width:699px) { .project-dialog { width:100vw; } }
.chat-room-picker { display:flex; align-items:center; flex-wrap:wrap; gap:8px; border:0; padding:0; }
.chat-room-picker legend { float:left; margin-right:8px; }.chat-room-picker label { display:inline-flex; align-items:center; min-height:44px; border-radius:999px; padding:0 12px; background:var(--raised); }
.chat-room-picker input { margin-right:6px; }
.project-request-intro { padding:8px; }.request-details > summary { cursor:pointer; color:var(--action); min-height:44px; }
.room-chip-input { position:absolute; width:1px; height:1px; opacity:0; pointer-events:none; }
.room-chip { min-height:44px; padding:0 14px; color:var(--text); background:var(--surface); border:1px solid var(--line); border-radius:999px; }
.room-chip[aria-pressed=true] { background:var(--nav-active); border-color:var(--nav-active-border); color:var(--strong); }
.project-card-icon svg,.project-header-icon svg,.project-tree-row svg { width:55%; height:55%; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linejoin:round; }
.project-tree-row svg { width:16px; height:16px; flex:none; margin-right:6px; }
.breadcrumbs:has(.project-crumb-current) a { display:inline-flex; align-items:center; min-width:44px; min-height:44px; }
.chat-area:has(.project-header),.chat-area:has(.project-list-head) { max-width:none; margin-inline:0; }
.project-tabs { scrollbar-width:none; }.project-tabs::-webkit-scrollbar { display:none; }
.project-chip { white-space:nowrap; flex:none; }
@media(max-width:699px) { .project-stepper span { white-space:nowrap; font-size:10.5px; padding:4px 1px; }.project-work-head { align-items:flex-start; } }
.chat-message-meta .chat-avatar { display:inline-grid; place-items:center; width:36px; height:36px; margin-right:8px; border-radius:50%; background:var(--raised); color:var(--action); font-weight:700; }
.chat-message-human .chat-avatar { background:var(--accent-fill); color:var(--on-accent); }
.project-panel a,.project-work a:not(.btn),.chat-room-log a { display:inline-flex; align-items:center; min-height:44px; }
+213
View File
@@ -0,0 +1,213 @@
/* Project navigation and presentation. Chat retains ownership of reads and writes. */
(() => {
'use strict';
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const clean = v => esc(String(v ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4,'0')}`));
const folder='<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h7l2 2h9v11H3z"/></svg>';
const sections = [['overview','Overview'],['room','Room'],['conversations','Conversations'],['tasks','Tasks'],['requirements','Requirements'],['files','Files']];
const path = (id, key) => key === 'overview' ? `#/projects/${id}` : key === 'room' ? `#/projects/rooms/${id}` : `#/projects/${id}/${key}`;
function header(project, section, threads = [], title = project.name, conversation = false) {
const id=encodeURIComponent(project.id);
const tabs=sections.map(([key,label]) => `<a href="${path(id,key)}"${section === key ? ' aria-current="page"' : ''}>${label}${key==='conversations'?` <span class="project-count">${threads.filter(t=>!t.shared && t.project===project.id).length}</span>`:''}</a>`).join('');
const count=threads.filter(t=>!t.shared && t.project===project.id).length;
return `<header class="project-header" data-project-id="${id}" data-project-name="${clean(project.name)}" data-project-section="${section}" data-project-conversation="${conversation}"><div class="project-heading"><span class="project-header-icon" aria-hidden="true">${folder}</span><div><h1 tabindex="-1">${clean(title)}</h1>${project.archived?'<span class="project-chip">Archived</span>':''}<p>${project.repo ? `Repository: ${clean(project.repo)}` : 'No linked repository'} · ${count} ${count===1?'conversation':'conversations'}</p></div></div><div class="project-actions">${conversation?'<button type="button" class="btn" data-project-dialog="conversation">Details</button>':`<a class="btn" href="${path(id,'requirements')}?section=north-star">North star</a><a class="btn" href="${path(id,'requirements')}?section=prd">PRD</a><a class="btn" href="#/projects/${id}/settings">Settings</a>`}</div></header><nav class="project-tabs" aria-label="Project sections">${tabs}</nav>`;
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const storageKey='mosaic-project-tree';
let treeRows=[], treeThreads=[], treeGeneration=0, treeState, treeRead=null, treeReadAt=0;
try { treeState=JSON.parse(localStorage.getItem(storageKey) || '{}'); if (!treeState || Array.isArray(treeState) || typeof treeState !== 'object') treeState={}; } catch { treeState={}; }
async function read(url) {
const response=await fetch(url,{cache:'no-store',redirect:'manual'});
if(response.status===401 || response.type==='opaqueredirect') { document.dispatchEvent(new CustomEvent('mosaic-signed-out',{detail:{signIn:true}})); throw Error('signed-out'); }
if(!response.ok) throw Error('unavailable');
return response.json();
}
function currentProject(route) {
const id=route.match(/^\/(?:projects|chat)\/(?:projects\/|rooms\/)?([0-9a-f-]{36})/i)?.[1];
if(id) return id;
const thread=route.match(/^\/(?:projects|chat)\/threads\/([0-9a-f-]{36})/i)?.[1];
return treeThreads.find(t=>t.id===thread)?.project;
}
function drawTree(container, route) {
if(!container?.isConnected || document.body.classList.contains('signed-out')) return;
const current=currentProject(route), threadId=route.match(/\/threads\/([0-9a-f-]{36})/i)?.[1], room=/\/rooms\//.test(route);
container.innerHTML=`<ul class="project-tree-list">${treeRows.filter(p=>!p.archived && UUID.test(p.id)).map(p=>{
const expanded=Object.hasOwn(treeState,p.id)?treeState[p.id]:p.id===current;
const own=treeThreads.filter(t=>t.project===p.id && !t.shared && UUID.test(t.id)).slice(-5).reverse();
const active=p.id===current && !room && !threadId;
return `<li><div class="project-tree-row${active?' active':''}"><button type="button" data-project-disclosure="${p.id}" aria-expanded="${!!expanded}" aria-controls="project-children-${p.id}" aria-label="${expanded?'Collapse':'Expand'} ${clean(p.name)}">${expanded?'⌄':'›'}</button><a href="${path(p.id,'overview')}" title="${clean(p.name)}"${active?' aria-current="page"':''}>${folder}<span>${clean(p.name)}</span></a></div><ul id="project-children-${p.id}" class="project-tree-children"${expanded?'':' hidden'}><li><a href="${path(p.id,'room')}"${p.id===current && room?' aria-current="page"':''}>Project room</a></li>${own.map(t=>`<li><a href="#/projects/threads/${t.id}"${t.id===threadId?' aria-current="page"':''} title="${clean(t.name)}"><span>${clean(t.name)}</span><small>${clean(t.instance)}</small></a></li>`).join('')}<li><a href="${path(p.id,'conversations')}">View conversations</a></li></ul></li>`;
}).join('')}</ul>`;
if(current) document.querySelector('#sections > li > a[href="#/projects"]')?.removeAttribute('aria-current');
}
async function mountTree({container,path:route}) {
if(!container || document.body.classList.contains('signed-out')) return;
const generation=++treeGeneration;
try {
if (!treeRead || Date.now()-treeReadAt>10000) {
treeReadAt=Date.now();
treeRead=Promise.all([read('/api/chat/projects'),read('/api/chat/threads')]);
}
const [projects,threads]=await treeRead;
if(generation!==treeGeneration || !container.isConnected || !Array.isArray(projects.rows) || !Array.isArray(threads.rows)) return;
treeRows=projects.rows; treeThreads=threads.rows; drawTree(container,route);
container.onclick=e=>{const button=e.target.closest('[data-project-disclosure]');if(!button)return;const id=button.dataset.projectDisclosure;treeState[id]=button.getAttribute('aria-expanded')!=='true';try{localStorage.setItem(storageKey,JSON.stringify(treeState));}catch{}drawTree(container,route);container.querySelector(`[data-project-disclosure="${id}"]`)?.focus();};
} catch { treeRead=null; if(generation===treeGeneration) container.replaceChildren(); }
}
document.addEventListener('mosaic-signed-out',()=>{treeGeneration++;treeRead=null;treeRows=[];treeThreads=[];document.querySelector('#project-tree')?.replaceChildren();});
const requestId = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$/.test(value);
const cache = new Map();
let epoch=0;
document.addEventListener('click',e=>{if(e.target.closest?.('#refresh, #mobile-refresh')) cache.clear();});
document.addEventListener('mosaic-signed-out',()=>{epoch++;cache.clear();});
const ended=s=>['cancelled','delivered','complete'].includes(s?.phase);
const needs=s=>!!s && ((['discovery','prd-review','planning'].includes(s.phase) && s.question?.answer===null) || s.wait?.kind==='decision' || (s.phase==='selection' && s.route==='existing' && s.wait?.kind==='setup'));
const phaseLabels={selection:'Starting',discovery:'Discovery','prd-review':'PRD review',planning:'Planning',implementation:'Building',review:'In review',delivery:'Delivering',complete:'Delivered',delivered:'Delivered',cancelled:'Cancelled'};
const label=s=>!s?'Status unavailable':needs(s)?'Needs your input':phaseLabels[s.phase]||'Status unavailable';
const chip=s=>`<span class="project-chip ${needs(s)?'warning':ended(s)&&s?.phase!=='cancelled'?'success':s?.phase==='cancelled'?'danger':''}">${clean(label(s))}</span>`;
const taskRefs=data=>[...new Set(data.requests.flatMap(r=>Array.isArray(r.state?.tasks)?r.state.tasks.filter(v=>typeof v==='string' && /^vikunja:[0-9]+\/[0-9]+$/.test(v)):[]))];
const validState=(s,id)=>s && s.version===1 && s.request===id && Number.isSafeInteger(s.revision) && s.revision>0 && typeof s.phase==='string';
const canonical=v=>Array.isArray(v)?v.map(canonical):v && typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,canonical(v[k])])):v;
const same=(a,b)=>JSON.stringify(canonical(a))===JSON.stringify(canonical(b));
const material=(d,s)=>d && d.candidate && s?.candidate && same(d.candidate,s.candidate) && d.candidate.request===s.request && d.candidate.project===s.project && d.candidate.mode_revision===s.mode_revision && typeof d.prd==='string' && Array.isArray(d.requirements) && Array.isArray(d.candidate.requirements) && d.requirements.length===d.candidate.requirements.length && d.requirements.every((r,i)=>r && r.id===d.candidate.requirements[i]?.id && r.title===d.candidate.requirements[i]?.title && typeof r.text==='string') && d.snapshot && Number.isSafeInteger(d.snapshot.revision) && d.snapshot.revision>=0 && d.snapshot.id===s.snapshot && (d.snapshot.id===null || requestId(d.snapshot.id) && d.snapshot.revision>0);
const ownThreads=(project,threads)=>threads.filter(t=>t.project===project.id && !t.shared && UUID.test(t.id));
async function gather(project,threads) {
const own=ownThreads(project,threads), key=project.id+':'+String(project.repo)+':'+own.map(t=>t.id).join(',');
const prior=cache.get(key); if(prior && Date.now()-prior.at<10000) return prior.promise;
const started=epoch;
const promise=(async()=>{
let failed=false;
const config=await read('/api/config').catch(()=>null);
const disabled=!config || config.requests!==true;
if(disabled) failed=true;
const pages=await Promise.all((disabled?[]:own).map(async thread=>{
try {
const sessions=await read(`/api/chat/threads/${thread.id}/sessions`).catch(()=>null);
const selected=Array.isArray(sessions?.rows)?sessions.rows.find(s=>s.default===true && UUID.test(s.id)):null;
const page=await read(`/api/chat/threads/${thread.id}/messages?limit=100${selected?'&session='+selected.id:''}`);
if(!Array.isArray(page.rows)) throw Error('invalid');
return {thread, rows:page.rows};
} catch {failed=true;return {thread,rows:[]};}
}));
const ids=new Map();
for(const {thread,rows} of pages) for(const m of rows) if(requestId(m.request)) ids.set(m.request,{id:m.request,thread,at:m.at});
const requests=await Promise.all([...ids.values()].map(async item=>{
const base='/api/requests/'+encodeURIComponent(item.id);
const [state,draft,workspace]=await Promise.all([read(base).catch(()=>null),read(base+'/draft').catch(()=>null),read(base+'/workspace').catch(()=>null)]);
if(!validState(state,item.id)) {failed=true;return {...item,state:null,draft:null,workspace:null};}
return {...item,state,draft:material(draft,state)?draft:null,materialFailed:!!state.candidate && !material(draft,state),workspace};
}));
const [tasks,catalog]=await Promise.all([read('/api/bus/tasks').catch(()=>null),read('/api/chat/instances').catch(()=>null)]);
const members=Array.isArray(catalog?.rows)?catalog.rows.filter(m=>typeof m?.instance==='string' && own.some(t=>t.instance===m.instance && t.name===t.instance)).length:null;
let docs=null, north=null;
if(project.repo && /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(project.repo)) {
const repo=encodeURIComponent(project.repo);
[docs,north]=await Promise.all([read(`/api/project-files?project=${repo}`).catch(()=>null),read(`/api/project-file?project=${repo}&path=${encodeURIComponent('spec/north-star.md')}`).catch(()=>null)]);
}
if(epoch!==started) throw Error('signed-out');
return {requests,threads:own,failed,disabled,docs,north,tasks: Array.isArray(tasks?.rows) ? tasks.rows : null,members,at:Date.now()};
})();
cache.set(key,{at:Date.now(),promise}); return promise;
}
function workFor(data, thread) {
const rows=data.requests.filter(r=>r.thread.id===thread.id).sort(requestOrder);
return rows.find(r=>r.state && !ended(r.state)) || rows[0];
}
function stepper(s) {
if(!s) return '';
const idx=({selection:0,discovery:0,'prd-review':0,planning:0,implementation:1,review:2,delivery:3,complete:4,delivered:4,cancelled:-1})[s.phase] ?? -1;
return `<div class="project-stepper" aria-label="Request phase">${['Requirements','Build','Review','Deliver'].map((name,i)=>`<span class="${i<idx?'done':i===idx?'current':''}">${i<idx?'✓':i===idx?'◉':'○'} ${name}</span>`).join('')}</div>`;
}
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':''}`;};
function card(thread,request,failed) {
const file=request && delivery(request);
return `<article class="project-work"><div class="project-work-head"><div><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(request.state.mode):''}`: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; }
function delivery(r) {
const s=r.state,d=s?.delivery_evidence,n=d?.artifact?.path?.split('/').at(-1);
return s && requestId(s.delivery) && d?.id===s.delivery && d.request===r.id && d.project===s.project && d.snapshot===s.snapshot && s.phase==='complete' && d.verification && typeof n==='string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(n) ? {name:n,id:d.id} : null;
}
function deliveryPr(r) {
const pr=r.state?.delivery_evidence?.pull_request;
try { const url=new URL(pr?.url); return pr.request===r.id && pr.project===r.state.project && url.protocol==='https:' && !url.username && !url.password ? url.href : null; } catch { return null; }
}
function filesData(data) {
const reviewed=data.requests.map(r=>({r,file:delivery(r)})).filter(x=>x.file);
const drafts=data.requests.flatMap(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries)?r.workspace.entries.slice(0,100).filter(x=>typeof x?.name==='string').map(x=>({r,name:x.name})):[]);
return {reviewed,drafts,docs:docsList(data)};
}
function filesView(data,project) {
const f=filesData(data), docs=f.docs;
return `<h2>Reviewed deliveries <span class="project-count">${f.reviewed.length}</span></h2>${f.reviewed.length?f.reviewed.map(({r,file})=>`<div class="project-panel"><strong>${clean(file.name)}</strong> <span class="project-chip success">Reviewed</span><p>Request ${clean(r.id)} · from ${clean(r.thread.name)} · Reviewed delivery. Not landed or deployed.</p><a class="btn" target="_blank" rel="noopener noreferrer" href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}/text">Open as text</a> <a class="btn" download href="/api/requests/${encodeURIComponent(r.id)}/artifact/${encodeURIComponent(file.id)}">Download</a>${deliveryPr(r)?` <a class="btn" target="_blank" rel="noopener noreferrer" href="${esc(deliveryPr(r))}">Open pull request</a>`:''}<p>Open shows plain text in a new tab. Downloaded HTML can be opened locally after saving.</p></div>`).join(''):`<p>${data.disabled?'Files are unavailable.':data.failed?'Reviewed files are unavailable.':data.requests.length?'No reviewed file is available.':'No request files yet. Files appear when a request in this project produces them.'}</p>`}<h2>Draft workspace <span class="project-count">${f.drafts.length}</span></h2><p>Names only. Draft files may change and are not reviewed deliveries.</p><div class="project-panel project-file-list">${f.drafts.length?f.drafts.map(({r,name})=>`<div><code>${clean(name)}</code><span>Request ${clean(r.id)} · Draft</span></div>`).join(''):data.failed || data.requests.some(r=>r.workspace?.available!==true || !Array.isArray(r.workspace.entries))?'<p>Could not read draft workspace. Try again later.</p>':'<p>No draft files found.</p>'}${data.requests.some(r=>r.workspace?.truncated)?'<p>The draft list was truncated.</p>':''}</div><h2>Project documents ${docs?`<span class="project-count">${docs.length}</span>`:''}</h2><div class="project-panel project-file-list">${!project.repo?'No linked repository.':!docs?'Project documents unavailable. Docs could not read this project’s repository.':docs.length?docs.map(name=>`<div><code>${clean(name)}</code><a href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(name)}">Open in Docs</a></div>`).join(''):'No project documents found.'}</div>`;
}
function requirementView(data,project,selected) {
const options=data.requests.filter(r=>r.draft), r=options.find(x=>x.id===selected)||options[0], d=r?.draft;
const north=typeof data.north?.content==='string'?data.north.content:null;
const select=options.length>1?`<label>Request <select id="project-request-select">${options.map(x=>`<option value="${clean(x.id)}"${x===r?' selected':''}>${clean(x.id)} · ${clean(x.thread.name)}</option>`).join('')}</select></label>`:'';
const candidate=d?.candidate;
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north?`<div data-north-star></div><a href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>Link a repository in Settings to show the north star.</p><a href="#/projects/${project.id}/settings">Settings</a>`}<h3 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel" data-prd></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}</section><aside class="project-panel"><h3>Version and provenance</h3>${candidate?`<p>PRD version: ${clean(candidate.prd_version)}</p><p>Approved snapshot: ${clean(d.snapshot.id || 'None')}</p><p>PRD file: ${clean(candidate.prd_path)}</p><p>PRD blob: ${clean(candidate.prd_blob)}</p><p>Records commit: ${clean(candidate.records_commit)}</p><p>Confirmed intent commit: ${clean(candidate.spec_commit)}</p><p>Mode revision: ${clean(candidate.mode_revision)}</p><p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:'<p>Unavailable until a PRD is read.</p>'}</aside></div>`;
}
function overview(data,project,canSend) {
const active=data.requests.filter(r=>r.state && !ended(r.state)), waiting=active.filter(r=>needs(r.state)), ordered=[...data.requests].sort(requestOrder), first=ordered[0], threads=[...data.threads];
if(first) threads.sort((a,b)=>Number(b.id===first.thread.id)-Number(a.id===first.thread.id));
const f=filesData(data), filesKnown=!!f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries) && r.state), count=filesKnown?f.reviewed.length+f.drafts.length+f.docs.length:'–';
const n=typeof data.north?.content==='string'?data.north.content:null, candidate=ordered.find(r=>r.draft)?.draft;
const metrics=[['Requests in progress',data.failed?'–':active.length,data.failed?'Not available yet':active[0]?label(active[0].state):'No active requests',first?conversationLink(first.thread):path(project.id,'conversations')],['Needs your input',data.failed?'–':waiting.length,data.failed?'Not available yet':waiting.length?label(waiting[0].state):'Nothing waiting',waiting[0]?conversationLink(waiting[0].thread):path(project.id,'conversations')],['Conversations',threads.length,'Project conversations',path(project.id,'conversations')],['Files',count,filesKnown?`${f.reviewed.length} reviewed · ${f.drafts.length} draft · ${f.docs.length} docs`:'Not available yet',path(project.id,'files')]];
return `${waiting.length?`<div class="project-panel project-decision" role="status">${waiting.length} ${waiting.length===1?'decision needs':'decisions need'} your input. <a class="btn primary" href="${conversationLink(waiting[0].thread)}">Review decision</a></div>`:''}<div class="project-metrics">${metrics.map(([title,value,caption,href])=>`<a href="${href}"><span>${title}</span><strong>${value}</strong><small>${clean(caption)}</small></a>`).join('')}<p>Updated just now · Showing requests from recent messages.</p></div><div class="project-columns"><section><div class="project-section-title"><h2>Current work</h2>${canSend&&!project.archived?`<a class="btn" href="${path(project.id,'conversations')}?new=1">New conversation</a>`:''}</div>${threads.length?threads.map((t,i)=>i===0?card(t,workFor(data,t),data.failed):`<a class="project-row" href="${conversationLink(t)}"><strong>${clean(t.name)}</strong><small>With ${clean(t.instance)}</small></a>`).join(''):'<p>No conversations yet. Start one to begin work.</p>'}<h2>Tasks</h2><div class="project-panel">${taskRefs(data).length?taskRefs(data).slice(0,2).map(ref=>`<div>${taskLink(ref,data)}</div>`).join(''):data.failed?'<p>Request tasks unavailable. Refresh to try again.</p>':'<p>No request tasks available.</p>'}<a href="${path(project.id,'tasks')}">View all project tasks</a></div></section><aside class="project-panel"><h2>Project direction</h2><h3>North star</h3><p>${n?clean(n):project.repo?'North star unavailable. Docs could not read this project’s repository.':'Link a repository in Settings to show the north star.'}</p><h3>${candidate?'PRD '+clean(candidate.candidate.prd_version):'PRD'}</h3><p>${candidate?(candidate.snapshot.id?'Approved snapshot '+clean(candidate.snapshot.id):'Draft'):(data.failed?'PRD unavailable.':'No current PRD available.')}</p><a href="${path(project.id,'requirements')}">Open PRD and requirements</a><h3>Files</h3>${[...f.reviewed.map(x=>({name:x.file.name,reviewed:true})),...f.drafts.map(x=>({name:x.name,reviewed:false}))].slice(0,3).map(file=>file.reviewed?`<p><a href="${path(project.id,'files')}"><code>${clean(file.name)}</code> · Reviewed</a></p>`:`<p><code>${clean(file.name)}</code> · Draft</p>`).join('')||(data.failed?'<p>Request files unavailable.</p>':'<p>No request files available.</p>')}<a href="${path(project.id,'files')}">All files</a><h3>Project facts</h3><p>Repository: ${project.repo?clean(project.repo):'Not linked'}</p><p>Room: ${data.members===null || data.members===undefined?'Unavailable':data.members?`Open · ${data.members} ${data.members===1?'member':'members'}`:'Not open'}</p><p>Mode: ${first?.state?.mode?clean(first.state.mode):'Unavailable'}</p></aside></div>`;
}
function tasksView(data) {
const refs=taskRefs(data);
return `<h2>Tasks <span class="project-count">${refs.length}</span></h2><p>Only tasks referenced by requests are shown.</p>${refs.length?`<div class="project-panel">${refs.map(ref=>{
const request=data.requests.find(r=>r.state?.tasks?.includes(ref)), fields=data.tasks?.find(t=>t.task_ref===ref)?.fields;
const assignees=Array.isArray(fields?.assignees)?fields.assignees.filter(v=>typeof v==='string' || typeof v==='number').map(clean).join(', '):'';
return `<div class="project-file-row"><div>${taskLink(ref,data)}${request?`<small>From request ${clean(request.id)} · ${clean(request.thread.name)}</small>`:''}</div><span>${fields?.done===true?'Done':fields?.bucket!==undefined && fields?.bucket!==null?'bucket '+clean(fields.bucket):''}${assignees?' · '+assignees:''}</span></div>`;
}).join('')}</div>`:`<p>${data.failed?'Request tasks unavailable. Refresh to try again.':'No request tasks available.'}</p>`}`;
}
async function mountContent({container,project,threads,section,canSend}) {
if(!container || !project) return;
const generation=epoch; container.textContent='Reading…';
const data=await gather(project,threads).catch(()=>null);
if(!container.isConnected || generation!==epoch || document.body.classList.contains('signed-out')) return;
if(!data){container.textContent='Project data unavailable. Refresh to try again.';return;}
const header=container.closest('.chat-area')?.querySelector('.project-tabs');
if(header){const tasks=header.querySelector(`a[href="${path(project.id,'tasks')}"]`), files=header.querySelector(`a[href="${path(project.id,'files')}"]`);const f=filesData(data);if(tasks && !data.failed)tasks.insertAdjacentHTML('beforeend',` <span class="project-count">${taskRefs(data).length}</span>`);if(files && f.docs && data.requests.every(r=>r.workspace?.available===true && Array.isArray(r.workspace.entries)))files.insertAdjacentHTML('beforeend',` <span class="project-count">${f.reviewed.length+f.drafts.length+f.docs.length}</span>`);}
const draw=selected=>{
container.innerHTML=section==='requirements'?requirementView(data,project,selected):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend);
const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id));
const md=container.querySelector('[data-prd]'); if(md && selectedRequest?.draft) md.replaceChildren(window.MosaicMarkdown?.render(selectedRequest.draft.prd) || document.createTextNode(selectedRequest.draft.prd));
const north=container.querySelector('[data-north-star]'); if(north && data.north?.content) north.replaceChildren(window.MosaicMarkdown?.render(data.north.content) || document.createTextNode(data.north.content));
container.querySelector('#project-request-select')?.addEventListener('change',e=>draw(e.target.value));
}; draw();
const sectionTarget=new URLSearchParams(location.hash.split('?')[1] || '').get('section');
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'});
}
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 && !ended(r.state));slot.innerHTML=`${current?chip(current.state)+' ':''}${data.failed?'Status unavailable · ':''}${ownThreads(project,threads).length} conversations · Updated just now`;}).catch(()=>{if(slot.isConnected)slot.textContent='Status unavailable';});
}
}
document.addEventListener('click',e=>{
const open=e.target.closest?.('[data-project-dialog]');
if(open){const dialog=document.getElementById(`project-${open.dataset.projectDialog}-dialog`);if(dialog && !dialog.open){dialog.showModal();dialog.querySelector('h2')?.focus();}return;}
const close=e.target.closest?.('[data-dialog-close]');if(close)close.closest('dialog')?.close();
});
window.MosaicProjectTree ??= Object.freeze({mount:mountTree});
function requestIntro(state, projectId) {
if(!UUID.test(projectId || '')) return '';
return `<div class="project-request-intro"><div class="project-work-head"><strong>Request <code>${clean(state.request)}</code></strong>${chip(state)}</div><p>${clean(state.mode || '')}</p>${stepper(state)}<div class="project-work-bottom"><a href="${path(projectId,'requirements')}">Open requirements</a><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) {
const h=view.querySelector('.project-header'); if(!h)return false;
const id=h.dataset.projectId, name=h.dataset.projectName, section=h.dataset.projectSection, conversation=h.dataset.projectConversation==='true';
const title=sections.find(([key])=>key===section)?.[1] || (section==='settings'?'Settings':'Overview');
const leaf=conversation?view.querySelector('h1')?.textContent:title;
box.innerHTML=`<a href="#/projects">Projects</a><span aria-hidden="true">/</span><a href="#/projects/${encodeURIComponent(id)}">${clean(name)}</a>${conversation?`<span aria-hidden="true">/</span><a href="${path(id,'conversations')}">Conversations</a>`:''}<span class="project-crumb-current"><span aria-hidden="true">/</span> <span aria-current="page">${clean(leaf)}</span></span>`;
return true;
}
window.MosaicProjects = Object.freeze({ header, path, clean, mountContent, enhanceList, breadcrumb, requestIntro, palette });
})();
+5 -1
View File
@@ -253,7 +253,9 @@
body += `<section class="request-files" aria-label="Files"><h3>Files</h3><div data-request-files><p>Files unavailable.</p></div></section>`;
body += `<p class="request-receipt small" role="status" tabindex="-1">${txt(ui.receipt)}</p>`;
body += `<p class="request-foot small">${p.stale ? 'Could not refresh; showing the last state. ' : ''}${p.reading ? '<span role="status">Refreshing request…</span>' : `<button type="button" class="btn" data-request-reload${busy}>Read again</button>`}</p>`;
return `<section class="request-panel" data-request="${esc(p.id)}" aria-label="Request">${body}</section>`;
const projectId = document.querySelector('.chat-conversation .project-header')?.dataset.projectId;
const intro = window.MosaicProjects?.requestIntro?.(s, projectId) || '';
return `<section class="request-panel" data-request="${esc(p.id)}" aria-label="Request">${intro ? `${intro}<details class="request-details"${openQuestion(s) || s.wait?.kind === 'decision' ? ' open' : ''}><summary>Request details</summary>${body}</details>` : body}</section>`;
}
// Redraw every mount of this request, keeping the keyboard where it was.
@@ -265,7 +267,9 @@
const a = document.activeElement, inside = !!a && a !== document.body && el.contains(a);
const area = inside && a.matches('textarea') ? { id: a.id, start: a.selectionStart, end: a.selectionEnd } : null;
const mark = inside && !area ? [...a.attributes].find(x => x.name.startsWith('data-request-')) : null;
const detailsOpen = el.querySelector('.request-details')?.open;
el.innerHTML = html(p);
if (detailsOpen) el.querySelector('.request-details')?.setAttribute('open', '');
const files = el.querySelector('[data-request-files]');
if (files && typeof window.MosaicFiles?.mount === 'function') window.MosaicFiles.mount({ container: files, requestIds: [p.id] });
if (!inside) continue;
+3
View File
@@ -830,3 +830,6 @@
@media(prefers-color-scheme:dark){:root[data-palette="harbor"]:not([data-mode="light"]):not([data-mode="dim"]){--nav-surface:#10141c;--line-soft:#333b47;--accent-fill:#276bd8;--on-accent:#ffffff;--logo-a:#3691f2;--logo-b:#4a9af5;--logo-c:#3691f2;--logo-d:#1b3e79}}
:root[data-palette="harbor"][data-mode="light"]{--nav-surface:#f7f9fc;--accent-fill:#2266bf;--on-accent:#ffffff}
@media(prefers-color-scheme:light){:root[data-palette="harbor"]:not([data-mode]){--nav-surface:#f7f9fc;--accent-fill:#2266bf;--on-accent:#ffffff}}
/* slice 3: projects */
:root { --project-grid-min: 320px; --project-grid-gap: 16px; }
+2
View File
@@ -38,6 +38,8 @@ const files = new Map([
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
['/files.css', ['files.css', 'text/css; charset=utf-8']],
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
['/display.js', ['display.js', 'text/javascript; charset=utf-8']],
+1 -1
View File
@@ -19,7 +19,7 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false);
+8 -4
View File
@@ -605,15 +605,16 @@ test('archive navigation exposes saved threads and unarchive with shared button
assert.equal(await b.evaluate('document.querySelector("#project-archive-list").children.length'), 0);
await click('#project-archive-toggle');
await wait('document.querySelector("#project-archive-list a")');
assert.equal(await b.evaluate('document.querySelector("#project-archive-list a").textContent'), HOSTILE);
assert.equal(await b.evaluate('document.querySelector("#project-archive-list a strong").textContent'), HOSTILE);
assert.equal(await b.evaluate('window.injected??null'), null);
const hooks = await b.evaluate('Array.from(document.querySelectorAll(".project-archived-filter, .project-archived-filter [class]")).flatMap(el=>Array.from(el.classList))');
assert.ok(hooks.every(c => c === 'project-archived-filter' || c === 'btn'), JSON.stringify(hooks));
assert.ok(hooks.includes('project-card') && hooks.includes('btn'), JSON.stringify(hooks));
assert.equal(await b.evaluate('!!document.querySelector("#project-archive-list a button")'), false);
assert.equal(await b.evaluate('document.querySelector("#project-archive-list a").getAttribute("href")'), `#/projects/${P}`);
await click('#project-archive-list a');
await wait('document.querySelector(".project-archive-control")');
assert.equal(await b.evaluate('document.querySelector(".chat-area h1").textContent'), '<img src=x onerror="window.injected=1">\\u001b[31m\\u202eevil');
assert.equal(await b.evaluate(`document.querySelectorAll('a[href*="${A}"],a[href*="${B}"]').length`), 2);
assert.equal(await b.evaluate(`new Set([...document.querySelectorAll('a[href*="${A}"],a[href*="${B}"]')].map(a=>a.getAttribute("href"))).size`), 2);
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form")'), null);
assert.equal(await b.evaluate('document.querySelector("#chat-room-open")'), null);
assert.equal(await b.evaluate('document.querySelector("#project-archive-button").textContent'), 'Unarchive');
@@ -640,7 +641,10 @@ test('archive navigation exposes saved threads and unarchive with shared button
await wait('document.querySelector("#project-archive-button")');
await b.evaluate(`window.__projects=[{id:'${P}',name:${JSON.stringify(HOSTILE)},repo:null}];window.__archiveRows=[];window.__answers.push({body:{project:'${P}',archived:false}});true`);
await click('#project-archive-button');
await wait('window.__writes.at(-1)?.path.endsWith("/unarchive")');
await b.evaluate(`location.hash='#/projects/${P}/settings'`);
await wait('document.querySelector("#project-archive-button")?.textContent==="Archive project"');
assert.equal(await b.evaluate('window.__writes.at(-1).path'), `/api/chat/projects/${P}/unarchive`);
assert.ok(await b.evaluate('!!document.querySelector("#chat-thread-form")'));
await b.evaluate(`location.hash='#/projects/${P}/conversations'`);
await wait('!!document.querySelector("#chat-thread-form")');
});
@@ -393,3 +393,15 @@ test('R6 G8 userCall spawns user-cli directly with the user nonce, no human nonc
assert.match(message.user.nonce, /^[0-9a-f]{64}$/);
assert.equal(USER_CLI, fileURLToPath(new URL('../../bus/src/user-cli.mjs', import.meta.url)));
});
test('project assets are owner-only while granted-user chat remains available', async t => {
const env = await setup(t);
const { session: owner } = await signIn(env, 'owner-sub');
const { session: user } = await signIn(env, 'alice-sub');
for (const path of ['/projects.js', '/projects.css']) {
assert.equal((await call(env.port, { path, headers: { cookie: owner } })).status, 200, `owner ${path}`);
assert.equal((await call(env.port, { path, headers: { cookie: user } })).status, 403, `user ${path}`);
}
assert.equal((await call(env.port, { path: '/chat.js', headers: { cookie: user } })).status, 200);
assert.equal((await call(env.port, { path: '/api/chat/projects', headers: { cookie: user } })).status, 200);
});
@@ -26,6 +26,7 @@ export async function setup(t) {
if (!u.pathname.startsWith('/api/')) return real(url, init);
if (u.pathname === '/api/processing' || u.pathname === '/api/bus-status') return json({error:'unavailable', message:'Fixture service unavailable'}, 503);
if (u.pathname === '/api/config') return json({sso:true, send:true, decide:true});
if (u.pathname === '/api/chat/projects' || u.pathname === '/api/chat/threads') return json({rows:[]});
if (u.pathname === '/api/bus/inbox') return json({rows, at:'2026-11-06T13:00:00.000Z'});
if (u.pathname === '/api/bus/agents' || u.pathname === '/api/bus/messages') return json({rows:[], unread:0, at:'2026-11-06T13:00:00.000Z'});
if (u.pathname === '/api/bus/trail') { const row = rows.find(x => x.id === u.searchParams.get('subject')); return json({ rows: row ? [{ ...row, table:'decisions' }] : [], at:'2026-11-06T13:00:00.000Z' }); }
@@ -37,6 +37,7 @@ for (const width of [1440, 390]) {
};
const archive = await inspect('#project-archive-button');
const row = await inspect('#project-archive-list button');
assert.equal(await b.evaluate(`document.querySelector('#project-archive-list a button') === null && document.querySelector('#project-archive-list li > a + button') !== null`), true, 'Unarchive is a sibling of the card link');
check(archive, 'Archive'); check(row, 'list Unarchive'); check(await inspect('#project-archive-toggle'), 'toggle');
assert.notEqual(archive.background, row.background, 'Archive has a distinct background');
// The danger tint must preserve readable text in every palette and appearance.
@@ -0,0 +1,113 @@
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 => {
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]]) {
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);
assert.equal(result.links.length,3);
assert.ok(result.links.every(Boolean));
if(width===1440) assert.equal(new Set(result.heights).size,1);
assert.equal(result.overflow,false);
}
});
test('project section routes expose the shared navigation and current section', async t => {
const { b, render } = await setup(t);
const id='10000000-0000-4000-8000-000000000001';
for (const section of ['conversations','tasks','requirements','files']) {
await render(`/projects/${id}/${section}`);
assert.ok((await b.evaluate(`document.querySelector('nav[aria-label="Project sections"] [aria-current="page"]')?.textContent.trim().toLowerCase()`)).startsWith(section));
assert.equal(await b.evaluate(`document.querySelector('.chat-area h1')?.textContent`),'Active project');
}
});
test('project tree remembers disclosure state and links to project sections', async t => {
const { b, wait }=await setup(t);
await b.evaluate(`window.MosaicProjectTree.mount({container:document.querySelector('#project-tree'),path:'/projects'})`);
await wait(`document.querySelector('[data-project-disclosure]')`);
assert.equal(await b.evaluate(`getComputedStyle(document.querySelector('#project-tree')).display`), 'block');
await b.evaluate(`document.querySelector('[data-project-disclosure]').click()`);
assert.equal(await b.evaluate(`document.querySelector('[data-project-disclosure]').getAttribute('aria-expanded')`),'true');
assert.ok(await b.evaluate(`!!document.querySelector('#project-tree a[href$="/conversations"]')`));
await b.evaluate(`window.MosaicProjectTree.mount({container:document.querySelector('#project-tree'),path:'/projects'})`);
await wait(`document.querySelector('[data-project-disclosure]')?.getAttribute('aria-expanded')==='true'`);
});
test('request gathering exposes selectable PRDs and groups files by kind', async t => {
const { b, render, wait }=await setup(t);
await b.evaluate(`(() => { const old=window.fetch; const json=v=>new Response(JSON.stringify(v),{headers:{'content-type':'application/json'}}); window.fetch=(url,opts)=>{const p=String(url); if(p==='/api/config') return Promise.resolve(json({send:true,requests:true})); if(p.includes('/messages')) return Promise.resolve(json({rows:[{id:'40000000-0000-4000-8000-000000000001',from_role:'human',request:'request-1'},{id:'40000000-0000-4000-8000-000000000002',from_role:'human',request:'request-2'}],hasMore:false,after:null})); const m=p.match(/^\\/api\\/requests\\/(request-[12])(\\/draft|\\/workspace)?$/); if(m){ const id=m[1],candidate={request:id,project:'repo-key',mode_revision:1,prd_version:id,records_commit:'a',prd_path:'docs/prd.md',prd_blob:'b',spec_commit:'c',requirements:[{id:'R1',title:'Feature',path:'req',blob:'d'}]}; if(m[2]==='/draft') return Promise.resolve(json({candidate,prd:'A real PRD for '+id,requirements:[{id:'R1',title:'Feature',text:'Details'}],snapshot:{id:null,revision:0}})); if(m[2]==='/workspace') return Promise.resolve(json({available:true,entries:[{name:id+'.js'}]})); return Promise.resolve(json({version:1,request:id,revision:1,phase:'implementation',project:'repo-key',mode:'guided',mode_revision:1,candidate,snapshot:null,tasks:[]})); } return old(url,opts); }; })()`);
const id='10000000-0000-4000-8000-000000000001';
await b.evaluate(`document.querySelector('#refresh').click()`);
await render(`/projects/${id}/requirements`);
await wait(`document.querySelector('#project-request-select')?.options.length===2`);
assert.match(await b.evaluate(`document.querySelector('[data-project-content]').textContent`),/A real PRD for request-1/);
await b.evaluate(`const s=document.querySelector('#project-request-select');s.value='request-2';s.dispatchEvent(new Event('change',{bubbles:true}))`);
assert.match(await b.evaluate(`document.querySelector('[data-project-content]').textContent`),/A real PRD for request-2/);
await render(`/projects/${id}/files`);
await wait(`document.querySelector('[data-project-content]')?.textContent.includes('request-1.js')`);
const text=await b.evaluate(`document.querySelector('[data-project-content]').textContent`);
assert.match(text,/Reviewed deliveries/); assert.match(text,/Draft workspace/); assert.match(text,/request-2.js/); assert.match(text,/Project documents/);
});
test('unavailable request reads do not become empty request claims', async t => {
const {b,render,wait}=await setup(t);
await b.evaluate(`(() => { const old=window.fetch; window.fetch=(url,opts)=>String(url)==='/api/config' ? Promise.resolve(new Response(JSON.stringify({send:true,requests:false}),{headers:{'content-type':'application/json'}})) : old(url,opts); })()`);
await b.evaluate(`document.querySelector('#refresh').click()`);
await render('/projects/10000000-0000-4000-8000-000000000001');
await wait(`document.querySelector('[data-project-content]')?.textContent.includes('Not available yet')`);
const text=await b.evaluate(`document.querySelector('[data-project-content]').textContent`);
assert.doesNotMatch(text,/No active requests|No active request|No request tasks available/);
assert.match(text,/Request status unavailable/);
});
test('overview prioritizes an active decision and Files rejects foreign delivery evidence', async t => {
const {b,render,wait}=await setup(t);
await b.evaluate(`(() => { const old=window.fetch; const json=v=>new Response(JSON.stringify(v),{headers:{'content-type':'application/json'}}); window.fetch=(url,opts)=>{const p=String(url); if(p==='/api/config')return Promise.resolve(json({send:true,requests:true})); if(p.includes('/messages'))return Promise.resolve(json({rows:[{id:'40000000-0000-4000-8000-000000000001',from_role:'human',request:'decision-1',at:'2026-11-06T10:00:00Z'},{id:'40000000-0000-4000-8000-000000000002',from_role:'human',request:'done-1',at:'2026-11-06T11:00:00Z'}],hasMore:false,after:null})); if(p==='/api/requests/decision-1')return Promise.resolve(json({version:1,request:'decision-1',revision:1,phase:'review',project:'repo-key',mode:'guided',wait:{kind:'decision'},tasks:['vikunja:2/12']})); if(p==='/api/requests/done-1')return Promise.resolve(json({version:1,request:'done-1',revision:1,phase:'complete',project:'repo-key',snapshot:'snap',delivery:'delivery-1',delivery_evidence:{id:'delivery-1',request:'done-1',project:'another-project',snapshot:'snap',artifact:{path:'foreign.html'},verification:{}},tasks:[]})); if(p==='/api/bus/tasks')return Promise.resolve(json({rows:[{task_ref:'vikunja:2/12',fields:{title:'A task',bucket:3,done:false,assignees:['coder']}}]})); if(p.startsWith('/api/requests/'))return Promise.resolve(json({available:true,entries:[]})); return old(url,opts); }; })()`);
await b.evaluate(`document.querySelector('#refresh').click()`);
const id='10000000-0000-4000-8000-000000000001';
await render(`/projects/${id}`);
await wait(`document.querySelector('[data-project-content]')?.textContent.includes('decision needs your input')`);
assert.match(await b.evaluate(`document.querySelector('.project-work').textContent`),/decision-1/);
await render(`/projects/${id}/tasks`);
await wait(`document.querySelector('[data-project-content]')?.textContent.includes('A task')`);
assert.match(await b.evaluate(`document.querySelector('[data-project-content]').textContent`),/decision-1.*bucket 3.*coder/s);
await render(`/projects/${id}/files`);
await wait(`document.querySelector('[data-project-content]')?.textContent.includes('Reviewed deliveries')`);
assert.doesNotMatch(await b.evaluate(`document.querySelector('[data-project-content]').textContent`),/foreign\.html/);
});
test('conversation and room details open as keyboard accessible dialogs', async t => {
const {b,render}=await setup(t);
const p='10000000-0000-4000-8000-000000000001', thread='20000000-0000-4000-8000-000000000001';
for(const route of [`/projects/threads/${thread}`,`/projects/rooms/${p}`]) {
await render(route);
await b.evaluate(`document.querySelector('[data-project-dialog]').focus()`);
await b.key('Enter');
assert.equal(await b.evaluate(`document.querySelector('dialog[open]')!==null`),true);
await b.key('Escape');
assert.equal(await b.evaluate(`document.querySelector('dialog[open]')===null`),true);
assert.equal(await b.evaluate(`document.activeElement.hasAttribute('data-project-dialog')`),true);
}
});
test('canonical project breadcrumb and tabs navigate at desktop and mobile widths', async t => {
const {b,wait}=await setup(t), id='10000000-0000-4000-8000-000000000001';
for(const width of [1440,390]) {
await b.viewport(width,900);
await b.evaluate(`location.hash='#/projects/${id}'`);
await wait(`document.querySelector('.project-tabs [aria-current]')?.textContent.includes('Overview')`);
assert.ok(await b.evaluate(`!!document.querySelector('.project-actions a[href*="/requirements"]')`));
assert.ok(await b.evaluate(`!!document.querySelector('.project-tabs a[href$="/files"]')`));
const info=await b.evaluate(`(()=>{const c=document.querySelector('.project-crumb-current'), links=[...document.querySelectorAll('.project-actions a,.project-tabs a')];return {visible:getComputedStyle(c).display!=='none',targets:links.every(a=>a.getBoundingClientRect().height>=44),overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth}})()`);
assert.equal(info.visible,width>699); assert.ok(info.targets); assert.equal(info.overflow,false);
await b.evaluate(`location.hash='#/projects/${id}/files'`);
await wait(`document.querySelector('.project-tabs [aria-current]')?.textContent.includes('Files')`);
}
});
+1 -1
View File
@@ -38,7 +38,7 @@ test('real board fixture passes through WebUI; assets and isolated seen/reply wo
const response = await fetch(f.base + path); assert.equal(response.status, 200, path); assert.equal(response.headers.get('cache-control'), 'no-store');
}
// #1542: the shell's tokens, sprite, stylesheet and mono fonts, each with its type.
for (const [path, type] of [['/tokens.css', 'text/css'], ['/shell.css', 'text/css'], ['/icons.svg', 'image/svg+xml'], ...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2'])]) {
for (const [path, type] of [['/projects.js', 'text/javascript'], ['/projects.css', 'text/css'], ['/tokens.css', 'text/css'], ['/shell.css', 'text/css'], ['/icons.svg', 'image/svg+xml'], ...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2'])]) {
const response = await fetch(f.base + path); assert.equal(response.status, 200, path); assert.ok(response.headers.get('content-type').startsWith(type), path);
}
assert.equal((await fetch(f.base + '/../../AGENTS.md')).status, 404);
@@ -3,10 +3,11 @@ import assert from 'node:assert/strict';
import { setup, P, T, S, S2 } from './helpers/w15-conversations.mjs';
test('canonical project and session routes preserve identity, drafts and optional mounts', async t=>{
const {b,render,shot}=await setup(t);
const {b,render,shot,wait}=await setup(t);
await b.evaluate(`window.MosaicRequests={mount(){},projectSummary(o){window.__mounts.push(['summary',o.requestIds,o.project]);o.container.textContent='Fixture request summary';}}`);
await render(`/projects/${P}`);
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'),/Request status is not loaded/);
await wait('document.querySelector("[data-project-content]")?.textContent.includes("Showing requests from recent messages")');
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'),/Showing requests from recent messages/);
for(const w of [1440,390]) await shot('project-overview-unloaded',w);
await render(`/projects/${P}/settings`);
assert.deepEqual(await b.evaluate('window.__mounts.at(-1)'),['remote',P,'repo-key']);
@@ -26,11 +27,11 @@ test('canonical project and session routes preserve identity, drafts and optiona
await render(`/projects/threads/${T}/sessions/${S}`);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'),'first draft');
await render(`/projects/${P}`);
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'),/Other work may not be shown/);
assert.deepEqual(await b.evaluate('window.__mounts.at(-1)'),['summary',['request-1'],'repo-key']);
await wait('document.querySelector("[data-project-content]")?.textContent.includes("Showing requests from recent messages")');
assert.match(await b.evaluate('document.querySelector("[data-project-content]").textContent'),/Showing requests from recent messages/);
for(const w of [1440,390]) await shot('project-overview-known',w);
await b.evaluate('delete window.MosaicRequests'); await render(`/projects/${P}`);
assert.match(await b.evaluate('document.querySelector("[data-chat-summary]").textContent'),/unavailable/);
await wait('document.querySelector("[data-project-content]")?.textContent.includes("Not available yet")');
await render(`/projects/threads/${T}/sessions/${S}`);
await b.evaluate('window.__failRead=true');await render(`/projects/threads/${T}/sessions/${S}`);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'),'first draft');
@@ -40,7 +41,7 @@ test('canonical project and session routes preserve identity, drafts and optiona
test('room picker serializes selected instance and retains unresolved drafts', async t=>{
const {b,render,shot}=await setup(t);await render(`/projects/rooms/${P}`);
assert.match(await b.evaluate('document.querySelector(".chat-room").textContent'),/replies are private/);
assert.match(await b.evaluate('document.querySelector(".chat-room").textContent'),/Each reply stays in that member/);
for(const w of [1440,390]) await shot('room-picker',w);
await b.evaluate('document.querySelector("[data-room-member=bob]").click()');
assert.match(await b.evaluate('document.querySelector("#chat-room-body").value'),/^@alice /);
@@ -93,13 +94,13 @@ test('room recipient controls accept keyboard and pointer at both widths', async
const {b,render}=await setup(t);
for(const width of [1440,390]) {
await b.viewport(width,1000); await render(`/projects/rooms/${P}`);
await b.evaluate('document.querySelector("[data-room-member=bob]").focus()');
await b.evaluate('document.querySelector("[data-room-chip=bob]").focus()');
await b.key(' ');
assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/alice/);
assert.equal(await b.evaluate('document.activeElement.dataset.roomMember'),'bob');
assert.equal(await b.evaluate('document.activeElement.dataset.roomChip'),'bob');
await b.key('Tab'); await b.key('Tab','Tab',8); await b.key('Escape');
assert.notEqual(await b.evaluate('document.activeElement.tagName'),'BODY');
const box=await b.evaluate('(()=>{const r=document.querySelector("[data-room-member=bob]").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()');
const box=await b.evaluate('(()=>{const r=document.querySelector("[data-room-chip=bob]").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()');
await b.call('Input.dispatchMouseEvent',{type:'mousePressed',x:box.x,y:box.y,button:'left',clickCount:1});
await b.call('Input.dispatchMouseEvent',{type:'mouseReleased',x:box.x,y:box.y,button:'left',clickCount:1});
assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/bob/);
@@ -7,7 +7,7 @@ const captures = [];
after(async () => { if (process.env.MOSAIC_UX_SHOTS) await writeFile(join(process.env.MOSAIC_UX_SHOTS,'manifest.json'),JSON.stringify({candidate:'uncommitted; see report',command:'node --test --test-concurrency=1 packages/webui/tests/w15-shell-browser.test.mjs',result:'see saved test output',captures:captures.filter(Boolean)},null,2)+'\n'); });
test('shell fixture: project navigation and narrow drawer', { timeout: 90000 }, async t => {
const { f,b,wait,capture } = await w15Fixture(t, {scenario:{'/api/processing':{version:1,generation:'12345678-1234-4234-8234-123456789abc',halted:false,quiescent:true,remaining:0,stopped:0}}});
const { f,b,wait,capture } = await w15Fixture(t, {scenario:{'/api/processing':{version:1,generation:'12345678-1234-4234-8234-123456789abc',halted:false,quiescent:true,remaining:0,stopped:0}, 'GET /api/project-files?project=fixture-repository':{status:503,body:{error:'fixture-unavailable'}}, 'GET /api/project-file?project=fixture-repository&path=spec%2Fnorth-star.md':{status:503,body:{error:'fixture-unavailable'}}}});
for (const width of [1440,390]) {
await b.viewport(width,1000); await b.navigate(f.base+'/#/projects');
await wait('document.querySelector(".chat-area h1")?.textContent==="Projects"');