feat(webui): add project conversation surfaces (#797)
This commit is contained in:
1 parent
05fdfef9ea
commit
cecfcdd201
5 files changed
+259
-20
No files matched your search
@@ -65,3 +65,13 @@
|
||||
.request-prd { margin-block: .75rem; border-block: 1px solid var(--border); padding-block: .5rem; }
|
||||
.request-prd pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); }
|
||||
.request-requirement { margin-block: .75rem; }
|
||||
|
||||
/* Project conversation layout, scoped to this surface. */
|
||||
.chat-area { overflow-wrap: anywhere; }
|
||||
.chat-area h1 { font-size: clamp(1.375rem, 2vw, 2rem); }
|
||||
.chat-area .btn, .chat-area summary { min-height: 44px; }
|
||||
.chat-details, .chat-files, .chat-room-picker { margin-block: 1rem; padding: .8rem; border: 1px solid var(--line); border-radius: 4px; }
|
||||
.chat-details summary { cursor: pointer; }
|
||||
.chat-room-picker label { display: block; padding-block: .45rem; }
|
||||
.chat-audience { margin: .25rem 0; }
|
||||
.chat-projects .panel { border-radius: 4px; }
|
||||
@@ -6,7 +6,7 @@
|
||||
const inert = value => String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g,
|
||||
c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
|
||||
const txt = value => esc(inert(value));
|
||||
const states = new Map(), forms = new Map();
|
||||
const states = new Map(), forms = new Map(), knownRequests = new Map();
|
||||
let current = null, controller = null, generation = 0, signedOut = false;
|
||||
const out = () => signedOut || document.body.classList.contains('signed-out');
|
||||
const state = id => {
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
const channelMessage = 'The chat channel is disabled for this business. Ask the person who manages it to enable WebUI chat, then refresh.';
|
||||
function forget() {
|
||||
generation++; controller?.abort(); states.clear(); forms.clear(); libraries.clear(); reverts.clear();
|
||||
generation++; controller?.abort(); states.clear(); forms.clear(); knownRequests.clear(); libraries.clear(); reverts.clear();
|
||||
window.MosaicRequests?.forget();
|
||||
if (current) current.container.replaceChildren();
|
||||
current = null;
|
||||
@@ -64,7 +64,11 @@
|
||||
}
|
||||
return data;
|
||||
}
|
||||
const link = (type, id, label) => UUID.test(id) ? `<a href="#/chat/${type}/${id}">${txt(label)}</a>` : txt(label);
|
||||
const legacyRoute = () => location.hash.startsWith('#/chat');
|
||||
const projectPath = id => legacyRoute() ? `#/chat/projects/${id}` : `#/projects/${id}`;
|
||||
const threadPath = id => legacyRoute() ? `#/chat/threads/${id}` : `#/projects/threads/${id}`;
|
||||
const roomPath = id => legacyRoute() ? `#/chat/rooms/${id}` : `#/projects/rooms/${id}`;
|
||||
const link = (type, id, label) => UUID.test(id) ? `<a href="${type === 'projects' ? projectPath(id) : threadPath(id)}">${txt(label)}</a>` : txt(label);
|
||||
const heading = text => `<h1 tabindex="-1">${txt(text)}</h1>`;
|
||||
const selectedInactive = () => { const s = current?.sessions?.find(r => r.id === current.session); return !!s?.state && s.state !== 'active'; };
|
||||
const disabledSession = pending => disabled(pending || selectedInactive());
|
||||
@@ -93,7 +97,7 @@
|
||||
function threads(rows) {
|
||||
return rows.length ? `<ul class="chat-thread-list">${rows.map(t => `<li>${link('threads', t.id, t.name)} <span class="muted">${txt(t.instance)}${t.shared ? ` · Shared by ${txt(t.owner)}` : ''}</span></li>`).join('')}</ul>` : current.readFailed ? '<p>Threads unavailable.</p>' : '<p>No threads yet.</p>';
|
||||
}
|
||||
const sessionPath = (thread, session) => `#/chat/threads/${thread}/sessions/${session}`;
|
||||
const sessionPath = (thread, session) => `${threadPath(thread)}/sessions/${session}`;
|
||||
const countLabel = value => !value ? 'unavailable (count collector not installed)' :
|
||||
value.known === true && Number.isSafeInteger(value.count) && value.count >= 0 ? String(value.count) :
|
||||
`unknown (${value.reason ?? 'not-collected'})`;
|
||||
@@ -205,7 +209,7 @@
|
||||
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 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))}${sessionControls(t)}${sharing(t)}<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><button class="btn primary" type="submit"${disabledSession(s.pending || current.channelDisabled)}>Send</button></form>` : ''}<p id="chat-receipt" role="status">${txt(receipt)}</p></section>`;
|
||||
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>`;
|
||||
}
|
||||
// C2-10c: rooms reuse the same state, renderer and generation fences as threads.
|
||||
const ROOM_LINES = Object.freeze({
|
||||
@@ -232,7 +236,7 @@
|
||||
function roomOpenControl(project) {
|
||||
if (!roomOwner()) return '';
|
||||
const f = form('room-open:' + project);
|
||||
return `<section class="chat-room-entry" aria-label="Project room"><button class="btn" type="button" id="chat-room-open"${disabled(f.pending || current.channelDisabled)}>Open room</button> <a href="#/chat/rooms/${esc(project)}">Read room</a><p id="chat-room-open-receipt" role="status">${txt(f.receipt)}</p></section>`;
|
||||
return `<section class="chat-room-entry" aria-label="Project room"><button class="btn" type="button" id="chat-room-open"${disabled(f.pending || current.channelDisabled)}>Open room</button> <a href="${roomPath(esc(project))}">Read room</a><p id="chat-room-open-receipt" role="status">${txt(f.receipt)}</p></section>`;
|
||||
}
|
||||
function roomConversation() {
|
||||
const c = current, s = state(c.key), p = c.projects.find(p => p.id === c.id);
|
||||
@@ -242,7 +246,7 @@
|
||||
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>`;
|
||||
}).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>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><form id="chat-room-send-form"><label for="chat-room-body">Message the room</label><p id="chat-room-help">Without leading @tags, your message goes to every member. The broker checks the addressees.</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-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>`;
|
||||
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><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>`;
|
||||
}
|
||||
function drawRoomCompletion() {
|
||||
const list = document.getElementById('chat-room-completion'), field = document.getElementById('chat-room-body');
|
||||
@@ -257,8 +261,12 @@
|
||||
if (current?.kind !== 'room') return;
|
||||
const field = document.getElementById('chat-room-body'), preview = document.getElementById('chat-room-tags');
|
||||
if (!field || !preview) return;
|
||||
const { tags } = roomAddress(field.value);
|
||||
preview.innerHTML = tags.length ? 'Typed addressees: ' + tags.map(tag => `<span data-room-tag="${esc(tag)}">@${txt(tag)}</span>`).join(' ') : 'All room members';
|
||||
const { tags, body } = roomAddress(field.value);
|
||||
const offer = document.getElementById('chat-room-offer');
|
||||
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;
|
||||
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()) {
|
||||
@@ -328,8 +336,8 @@
|
||||
if (data.project !== id || !Array.isArray(data.threads) || !data.threads.length || data.threads.some(t => !UUID.test(t?.id) || typeof t.instance !== 'string' || t.name !== t.instance)) throw new ChatError('outcome-unknown');
|
||||
f.receipt = 'Room opened.';
|
||||
if (current?.id === id) {
|
||||
if (location.hash === '#/chat/rooms/' + id) void refresh();
|
||||
else location.hash = '#/chat/rooms/' + id;
|
||||
if (current?.kind === 'room' && current.id === id) void refresh();
|
||||
else location.hash = roomPath(id);
|
||||
}
|
||||
} catch (err) {
|
||||
if (out() || err.code === 'signed-out') return;
|
||||
@@ -372,12 +380,12 @@
|
||||
if (!current) return null;
|
||||
const active = document.activeElement, log = current.container.querySelector('.chat-log');
|
||||
const field = active?.id?.startsWith('chat-') ? { id: active.id, start: active.selectionStart, end: active.selectionEnd } : null;
|
||||
return { field, top: log?.scrollTop, tail: log ? log.scrollHeight - log.scrollTop - log.clientHeight < 40 : true };
|
||||
return { field, detailsOpen: current.container.querySelector('.chat-details')?.open, top: log?.scrollTop, tail: log ? log.scrollHeight - log.scrollTop - log.clientHeight < 40 : true };
|
||||
}
|
||||
function paint(navigated = false) {
|
||||
if (!current || out()) return;
|
||||
const keep = capture(), c = current;
|
||||
let html = '<div class="chat-area"><p><a href="#/chat">Chat projects</a></p>';
|
||||
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.readFailed && (c.projects.length || c.threads.length || c.kind === 'thread' && state(c.key).loaded)) html += '<p role="status">Showing previously read chat content; it may be out of date.</p>';
|
||||
if (c.kind === 'room') {
|
||||
@@ -389,15 +397,35 @@
|
||||
} else if (c.kind === 'project') {
|
||||
const p = c.projects.find(p => p.id === c.id);
|
||||
html += heading(p?.name ?? 'Project unavailable');
|
||||
if (p && !c.refused) html += roomOpenControl(c.id) + threads(c.threads.filter(t => !t.shared)) + '<h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('thread', c.id);
|
||||
if (p && !c.refused) {
|
||||
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 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)) + creation('thread', c.id);
|
||||
}
|
||||
} else {
|
||||
html += heading('Chat') + '<p>Business projects and your named assistant threads.</p><div class="chat-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>';
|
||||
window.MosaicRequests?.mount(c.container);
|
||||
window.MosaicRequests?.mount?.(c.container);
|
||||
const t = c.kind === 'thread' ? c.thread : null;
|
||||
const repo = c.kind === 'project' ? c.projects.find(p => p.id === c.id)?.repo : libraryOf(c, t);
|
||||
const cached = knownRequests.get(c.id);
|
||||
const remembered = c.kind === 'project' && cached && cached.repo === repo ? cached.ids : [];
|
||||
const ids = !roomOwner() ? [] : t ? [...new Set(state(c.key).rows.filter(m => m.from_role === 'human').map(m => m.request).filter(id => typeof id === 'string' && REQUEST_ID.test(id)))] : [...remembered];
|
||||
const files = c.container.querySelector('[data-chat-files]');
|
||||
if (files && typeof window.MosaicFiles?.mount === 'function') { files.replaceChildren(); window.MosaicFiles.mount({ container: files, requestIds: ids }); }
|
||||
const summary = c.container.querySelector('[data-chat-summary]');
|
||||
if (summary && repo && ids.length) {
|
||||
if (typeof window.MosaicRequests?.projectSummary === 'function') window.MosaicRequests.projectSummary({ container: summary, requestIds: ids, project: repo });
|
||||
else summary.textContent = 'Request summary unavailable.';
|
||||
}
|
||||
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 }); }
|
||||
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
|
||||
const details = c.container.querySelector('.chat-details');
|
||||
if (details && keep?.detailsOpen) details.open = true;
|
||||
const log = c.container.querySelector('.chat-log');
|
||||
if (log) {
|
||||
const s = state(c.key ?? c.id);
|
||||
@@ -421,6 +449,12 @@
|
||||
}
|
||||
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];
|
||||
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 });
|
||||
const match = /^\/chat(?:\/(projects|threads)\/([0-9a-f-]+)(?:\/sessions\/([0-9a-f-]+))?)?$/.exec(path);
|
||||
controller?.abort(); controller = new AbortController();
|
||||
@@ -432,7 +466,7 @@
|
||||
paint(); return;
|
||||
}
|
||||
const old = current;
|
||||
current = { container, kind, id, 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, 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 };
|
||||
if (navigated || !old) container.innerHTML = '<p class="chat-loading" role="status">Reading chat…</p>';
|
||||
try {
|
||||
if (user) {
|
||||
@@ -548,6 +582,17 @@
|
||||
} catch (err) { if (err.name === 'AbortError') return; }
|
||||
}
|
||||
if (g === generation) {
|
||||
if ((kind === 'thread' && current.thread || kind === 'project') && !current.refused) {
|
||||
const projectId = kind === 'thread' ? current.thread.project : id;
|
||||
const project = current.projects.find(p => p.id === projectId);
|
||||
if (project && kind === 'thread' && roomOwner() && typeof project.repo === 'string') {
|
||||
const prior = knownRequests.get(projectId);
|
||||
const ids = prior?.repo === project.repo ? prior.ids : new Set();
|
||||
for (const row of state(current.key).rows) if (row.from_role === 'human' && typeof row.request === 'string' && REQUEST_ID.test(row.request)) ids.add(row.request);
|
||||
knownRequests.set(projectId, { repo: project.repo, ids });
|
||||
}
|
||||
if (project) document.dispatchEvent(new CustomEvent('mosaic-project-context', { detail: { projectId, repositoryKey: project.repo ?? null, thread: kind === 'thread' ? id : null, session: kind === 'thread' ? current.session : null } }));
|
||||
}
|
||||
paint(navigated);
|
||||
const t = kind === 'thread' ? current.threads.find(t => t.id === id) : null;
|
||||
if (t) void loadLibrary(libraryOf(current, t), g, signal);
|
||||
@@ -560,6 +605,7 @@
|
||||
// A bad session selector is local to that read. Only a thread-level refusal
|
||||
// invalidates all sibling drafts, receipts and recovery operation keys.
|
||||
if (!err.sharedRead && ['thread-not-owned', 'thread-not-found', 'access-refused'].includes(err.code)) {
|
||||
knownRequests.clear();
|
||||
for (const key of states.keys()) if (key === id || key.startsWith(id + '/')) states.delete(key);
|
||||
for (const key of forms.keys()) if (key.includes(id)) forms.delete(key);
|
||||
}
|
||||
@@ -573,7 +619,7 @@
|
||||
if (current) return render({ ...current, path: location.hash.slice(1).split('?')[0], next });
|
||||
}
|
||||
function leave(path) {
|
||||
if (!path.startsWith('/chat')) { generation++; controller?.abort(); current = null; }
|
||||
if (!path.startsWith('/chat') && !path.startsWith('/projects')) { generation++; controller?.abort(); current = null; }
|
||||
}
|
||||
document.addEventListener('input', event => {
|
||||
if (!current || out()) return;
|
||||
@@ -619,7 +665,7 @@
|
||||
} else if (action === 'open') s.receipt = 'Assistant resume requested.';
|
||||
else {
|
||||
s.name = ''; s.repo = ''; s.op = null; s.payload = null;
|
||||
if (location.hash === startedRoute) location.hash = action === 'createSession' ? sessionPath(targetThread, result.id) : '#/chat/' + (action === 'createProject' ? 'projects/' : 'threads/') + result.id;
|
||||
if (location.hash === startedRoute) location.hash = action === 'createSession' ? sessionPath(targetThread, result.id) : (action === 'createProject' ? projectPath(result.id) : threadPath(result.id));
|
||||
}
|
||||
} catch (err) {
|
||||
if (out() || err.code === 'signed-out') return;
|
||||
@@ -666,6 +712,14 @@
|
||||
document.addEventListener('click', event => {
|
||||
if (!current || out()) return;
|
||||
if (event.target.id === 'chat-room-open') void openRoom();
|
||||
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;
|
||||
if (parsed.tags.length && current.members.some(m => m.instance === name) && !parsed.tags.includes(name)) {
|
||||
state(current.key).draft = [...parsed.tags, name].map(tag => '@' + tag).join(' ') + ' ' + parsed.body;
|
||||
paint();
|
||||
}
|
||||
}
|
||||
const option = event.target.closest?.('[data-room-complete]');
|
||||
if (option) completeRoom(option.dataset.roomComplete);
|
||||
if (event.target.dataset.unshare) void changeShare('unshare', event.target.dataset.unshare);
|
||||
@@ -678,6 +732,17 @@
|
||||
if (event.target.id === 'chat-new') { state(current.key ?? current.id).tailRequested = true; state(current.key ?? current.id).follow = true; paint(); }
|
||||
});
|
||||
document.addEventListener('change', event => {
|
||||
if (event.target.matches?.('[data-room-member]') && current?.kind === 'room') {
|
||||
const field = document.getElementById('chat-room-body'), s = state(current.key);
|
||||
const parsed = roomAddress(field.value);
|
||||
if (parsed.tags.some(tag => !current.members.some(m => m.instance === tag))) {
|
||||
s.receipt = 'Repair the unresolved address before changing recipients. Your draft is kept.'; paint(); return;
|
||||
}
|
||||
const chosen = [...document.querySelectorAll('[data-room-member]:checked')].map(el => el.dataset.roomMember);
|
||||
if (!chosen.length) { s.receipt = 'Choose at least one recipient. Your draft is kept.'; paint(); return; }
|
||||
s.draft = chosen.map(name => '@' + name).join(' ') + (parsed.body ? ' ' + parsed.body : ' ');
|
||||
s.receipt = ''; paint(); return;
|
||||
}
|
||||
if (event.target.id === 'chat-session' && current && UUID.test(event.target.value)) {
|
||||
location.hash = sessionPath(current.id, event.target.value);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
let canSend = false, ready = false;
|
||||
const route = () => {
|
||||
const path = location.hash.slice(1).split('?')[0];
|
||||
return path.startsWith('/chat') ? path : '/chat';
|
||||
return path.startsWith('/chat') || path.startsWith('/projects') ? path : '/projects';
|
||||
};
|
||||
function show(navigated) {
|
||||
if (ready && window.MosaicChat) void window.MosaicChat.render({ path: route(), container: $('chat'), canSend, user: true, navigated });
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { writeFile, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fixture } from '../fixture.mjs';
|
||||
import { browser } from '../browser.mjs';
|
||||
export const P = '10000000-0000-4000-8000-000000000001';
|
||||
export const T = '20000000-0000-4000-8000-000000000001';
|
||||
export const S = '30000000-0000-4000-8000-000000000001';
|
||||
export const S2 = '30000000-0000-4000-8000-000000000002';
|
||||
const stub = `(() => {
|
||||
const real = fetch.bind(window), P=${JSON.stringify(P)}, T=${JSON.stringify(T)}, S=${JSON.stringify(S)}, S2=${JSON.stringify(S2)};
|
||||
const json=(v,status=200)=>new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}});
|
||||
window.__writes=[]; window.__unexpected=[];window.__roomRows=[];window.confirm=()=>true; window.__mounts=[]; window.__contexts=[];
|
||||
window.MosaicFiles={mount:o=>{window.__mounts.push(['files',o.requestIds]);o.container.textContent='Fixture files';}};
|
||||
window.MosaicProjectRemote={mount:o=>{window.__mounts.push(['remote',o.projectId,o.repositoryKey]);o.container.textContent='Fixture remote';}};
|
||||
document.addEventListener('mosaic-project-context',e=>window.__contexts.push(e.detail));
|
||||
window.fetch=async (url,init={})=>{
|
||||
const path=String(url), method=init.method||'GET'; if(!path.startsWith('/api/')) return real(url,init);
|
||||
if(method!=='GET'){window.__writes.push({path,body:JSON.parse(init.body||'{}')}); if(window.__holdClear && path.endsWith('/clear'))return new Promise(r=>window.__releaseClear=()=>r(json({error:'fixture-refusal',sent:false},409))); if(window.__roomAccept && path.includes('/rooms/') && path.endsWith('/messages')){const id='50000000-0000-4000-8000-000000000001';window.__roomRows.push({id,ids:[id],from_role:'human',to:['alice'],body:JSON.parse(init.body).body,at:'2026-11-05T12:00:00Z'});return json({ids:[id],to:['alice']});} return json({error:'fixture-refusal',sent:false},409);}
|
||||
if(path==='/api/config')return json({send:true,chatGrants:[]});
|
||||
if(path.startsWith('/api/bus/'))return json({rows:[],unread:0,at:'2026-11-05T12:00:00Z'});
|
||||
if(path==='/api/business')return json({projects:['repo-key']});
|
||||
if(path.startsWith('/api/library/status'))return json({error:'not-configured'},404);
|
||||
if(path==='/api/chat/projects')return json({rows:[{id:P,name:'Active project',repo:'repo-key'},{id:'10000000-0000-4000-8000-000000000002',name:'Empty project',repo:null},{id:'10000000-0000-4000-8000-000000000003',name:'Archived project',repo:null,archived:true}]});
|
||||
if(path==='/api/chat/instances')return json({rows:[{instance:'alice',name:'Alice'},{instance:'bob',name:'Bob'}]});
|
||||
if(path.includes('/shares'))return json({rows:[]});
|
||||
if(path.endsWith('/sessions'))return json({rows:[{id:S,name:'Planning',default:true,state:'active',revision:1},{id:S2,name:'Review',state:'active',revision:1}]});
|
||||
if(path.includes('/rooms/') && path.includes('/messages'))return json({rows:window.__roomRows,after:null,hasMore:false});
|
||||
if(path.includes('/messages')) { if(window.__failRead)return json({error:'offline'},503); return json({rows:[{id:'40000000-0000-4000-8000-000000000001',from_role:'human',body:'A fixture message',request:'request-1',at:'2026-11-05T12:00:00Z'}],after:null,hasMore:false}); }
|
||||
if(path.startsWith('/api/chat/threads'))return json({rows:path.includes('shared=true')?(window.__shared?[{id:T,project:P,name:'Planning conversation',instance:'alice',owner:'Owner'}]:[]):window.__shared?[]:[{id:T,project:P,name:'Planning conversation',instance:'alice'},{id:'20000000-0000-4000-8000-000000000002',project:P,name:'alice',instance:'alice'},{id:'20000000-0000-4000-8000-000000000003',project:P,name:'bob',instance:'bob'}]});
|
||||
window.__unexpected.push(path);return json({error:'unexpected-fixture-api'},403);
|
||||
};
|
||||
})();`;
|
||||
export async function setup(t) {
|
||||
const f=await fixture(); t.after(()=>f.close());
|
||||
const b=await browser({machine:false}); t.after(()=>b.close());
|
||||
const errors=[];
|
||||
b.on('Runtime.exceptionThrown', p=>errors.push(p.exceptionDetails.text));
|
||||
await b.call('Fetch.enable',{patterns:[{urlPattern:'*'}]});
|
||||
b.on('Fetch.requestPaused', p=>{void b.call(p.request.url.startsWith(f.base+'/')?'Fetch.continueRequest':'Fetch.failRequest',p.request.url.startsWith(f.base+'/')?{requestId:p.requestId}:{requestId:p.requestId,errorReason:'BlockedByClient'}).catch(()=>{});});
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:stub});
|
||||
await b.viewport(1440,1000); await b.navigate(f.base+'/#/chat');
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expr)});})()`);
|
||||
await wait('document.querySelector(".chat-projects")');
|
||||
// The shell owns the canonical route dispatcher and arrives at integration. Exercise the actual
|
||||
// conversation renderer with canonical paths without changing the unowned baseline shell.
|
||||
const render=async path=>{await b.evaluate(`MosaicChat.render({path:${JSON.stringify(path)},container:document.getElementById('bus-view'),canSend:true,navigated:false})`);};
|
||||
const shot=async (name,width)=>{
|
||||
if(!process.env.MOSAIC_UX_SHOTS)return;
|
||||
await mkdir(process.env.MOSAIC_UX_SHOTS,{recursive:true});await b.viewport(width,1000);await b.evaluate('document.fonts.ready');
|
||||
if (await b.evaluate('document.documentElement.scrollWidth > document.documentElement.clientWidth')) throw new Error('Document overflows at '+width);
|
||||
const file=join(process.env.MOSAIC_UX_SHOTS,`conversations-${name}-${width}.png`);await b.screenshot(file);
|
||||
const {cssContentSize:s}=await b.call('Page.getLayoutMetrics');
|
||||
const {data}=await b.call('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:Math.ceil(s.width),height:Math.ceil(s.height),scale:1}});
|
||||
await writeFile(file.replace('.png','-full.png'),Buffer.from(data,'base64'));
|
||||
};
|
||||
return {f,b,wait,render,shot,errors};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import test from 'node:test';
|
||||
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);
|
||||
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/);
|
||||
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']);
|
||||
await render(`/projects/threads/${T}/sessions/${S}`);
|
||||
assert.equal(await b.evaluate('document.querySelector(".chat-conversation").dataset.session'),S);
|
||||
assert.deepEqual(await b.evaluate('window.__contexts.at(-1)'),{projectId:P,repositoryKey:'repo-key',thread:T,session:S});
|
||||
assert.ok(await b.evaluate(`window.__mounts.some(x=>x[0]==='files' && x[1][0]==='request-1')`));
|
||||
assert.deepEqual(await b.evaluate('window.__mounts.at(-1)'),['summary',['request-1'],'repo-key']);
|
||||
await b.evaluate('document.querySelector("#chat-body").value="first draft";document.querySelector("#chat-body").dispatchEvent(new Event("input",{bubbles:true}))');
|
||||
for(const w of [1440,390]) await shot('conversation-progress',w);
|
||||
await b.evaluate('document.querySelector(".chat-details").open=true');
|
||||
for(const w of [1440,390]) await shot('conversation-details',w);
|
||||
await render(`/projects/threads/${T}/sessions/${S2}`);
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'),'');
|
||||
await b.evaluate('document.querySelector("#chat-body").value="second draft";document.querySelector("#chat-body").dispatchEvent(new Event("input",{bubbles:true}))');
|
||||
for(const w of [1440,390]) await shot('two-named-sessions',w);
|
||||
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']);
|
||||
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 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');
|
||||
for(const w of [1440,390]) await shot('read-failed-draft-kept',w);
|
||||
assert.equal(await b.evaluate('window.__writes.length'),0);
|
||||
});
|
||||
|
||||
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/);
|
||||
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 /);
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/To:.*alice/);
|
||||
await b.evaluate('document.querySelector("#chat-room-body").value="@alice Please ask @bob too";document.querySelector("#chat-room-body").dispatchEvent(new Event("input",{bubbles:true}))');
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/To:.*alice/);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-room-offer=bob]") !== null'),true);
|
||||
await b.evaluate('document.querySelector("[data-room-offer=bob]").click()');
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/alice.*bob/);
|
||||
await b.evaluate('document.querySelector("#chat-room-body").value="@unknown message";document.querySelector("#chat-room-body").dispatchEvent(new Event("input",{bubbles:true}));document.querySelector("[data-room-member=bob]").click()');
|
||||
assert.equal(await b.evaluate('document.querySelector("#chat-room-body").value'),'@unknown message');
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-room-receipt").textContent'),/Repair/);
|
||||
for(const w of [1440,390]) await shot('room-invalid',w);
|
||||
assert.equal(await b.evaluate('window.__writes.length'),0);
|
||||
});
|
||||
|
||||
|
||||
test('granted routes do not expose owner room or remote controls', async t=>{
|
||||
const {b}=await setup(t);
|
||||
await b.evaluate(`MosaicChat.render({path:'/projects/rooms/${P}',container:document.getElementById('bus-view'),canSend:true,user:true})`);
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'),/Rooms require the business owner/);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#chat-room-send-form")'),false);
|
||||
await b.evaluate(`MosaicChat.render({path:'/projects/${P}/settings',container:document.getElementById('bus-view'),canSend:true,user:true})`);
|
||||
assert.equal(await b.evaluate('window.__mounts.some(x=>x[0]==="remote")'),false);
|
||||
assert.equal(await b.evaluate('window.__writes.length'),0);
|
||||
});
|
||||
|
||||
|
||||
test('pending clear, room receipt and shared history retain truthful state', async t=>{
|
||||
const {b,render,wait,shot}=await setup(t);
|
||||
await render(`/projects/threads/${T}/sessions/${S}`);
|
||||
await b.evaluate('document.querySelector(".chat-details").open=true;window.__holdClear=true;document.querySelector("#chat-clear").click()');
|
||||
await wait('window.__writes.some(w=>w.path.endsWith("/clear"))');
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'),/fixture message/);
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-clear-receipt").textContent'),/requesting clear/);
|
||||
for(const w of [1440,390]) await shot('clear-pending',w);
|
||||
await b.evaluate('window.__releaseClear()');
|
||||
await render(`/projects/rooms/${P}`);
|
||||
await b.evaluate('window.__roomAccept=true;document.querySelector("[data-room-member=bob]").click();const e=document.querySelector("#chat-room-body");e.value="@alice hello";e.dispatchEvent(new Event("input",{bubbles:true}));document.querySelector("#chat-room-send-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}))');
|
||||
await wait('document.querySelector("#chat-room-receipt").textContent.includes("Accepted")');
|
||||
for(const w of [1440,390]) await shot('room-sent',w);
|
||||
await b.evaluate('window.__shared=true');
|
||||
await render(`/projects/threads/${T}`);
|
||||
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'),/Read-only thread/);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),false);
|
||||
for(const w of [1440,390]) await shot('shared-readonly',w);
|
||||
});
|
||||
|
||||
test('room recipient controls accept keyboard and pointer at both widths', async t=>{
|
||||
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.key(' ');
|
||||
assert.match(await b.evaluate('document.querySelector("#chat-room-tags").textContent'),/alice/);
|
||||
assert.equal(await b.evaluate('document.activeElement.dataset.roomMember'),'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}})()');
|
||||
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/);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user