fix(webui): explain disabled chat channel

This commit is contained in:
jason.woltje committed 2026-11-04 16:58:56 -06:00
1 parent 3572bf9b32
commit 5558fe0204
7 files changed
+184 -30

No files matched your search

+3
View File
@@ -6144,3 +6144,6 @@ Task 8b final whole-package gates, all zero failures/cancellations/skips: bus 15
Lead-only live window checks, not executed here: (1) explicitly authorize and select a named isolated development instance on .53 with its own data root/marker, installed source/board, scoped credentials, real PM/CTO/chief/coder/reviewer/assistant routes and signed-in owner Console; retain preflight and refuse any missing resource rather than provision it. (2) Run packages/cli/live/request-lifecycle.mjs with explicit --instance, --config, --business, --owner, --request-file and --evidence plus --authorize-isolated-run, from a genuine owner shell so the existing process proof applies; use --submit with an existing owned --thread, or observe the owner's existing --request. The owner selects/confirms the exact displayed autonomous plan once in Console. No user relay, manual task creation or worker shell command is part of the lifecycle. (3) Retain original request/message bytes, exact plan confirmation, approved snapshot/decision, tracker create/assignment, working commit, independent reviewer fetch/test/record/verdict, matching closure, delivery and chief/assistant notification; check identical project/snapshot/task/commit/review/verdict IDs across the chain. (4) Download through the owner-only Console URL, rerun observation with --request and --artifact, and require byte length/SHA to equal the reviewed delivery. Open that downloaded HTML with network disabled and verify the approved durations, start, pause/resume/reset, both transitions and the phase-end cue after Start, with no external requests/assets. Inspect actual independent model/session/test receipts before declaring live acceptance. (5) If live crash checks are authorized, kill only the owned isolated broker at the eight named checkpoints; preserve DB/WAL/SHM and repositories. Follow the existing operator rule: establish the exact writer PID is dead, retain its lock identity, and explicitly remove only the stale lock/socket before supported restart. There is no production automatic stale-lock reclaim and this task does not add one. Require unchanged historical rows, identical known IDs/commits and no duplicate project/task/artifact; an unprovable remote outcome must remain a visible wait. Full invocation and qualifications are in packages/cli/live/README.md. Stage 4 live acceptance remains with the lead.
2026-11-04 | Lead Codex, #815 / Gitea #1843 chat-channel recovery before | Owner /chat screenshots show failed read plus false empty state and Create project channel-disabled. Read-only .53 checks: plugins.channel absent; existing usage-source transcript and harness claude-code retained; Console journal has matching refusal; existing typed tester plugins API is authorized. Failure precedes project dispatch. Plan: typed tester channel-only correction with private config backup/readback and no restart; Codex source fix for factory channel declaration and accurate failure UI, tests/review before integration. No signed-owner workflow or schema/role/secret change.
2026-11-04 | Codex implementation sub-agent | #815 / Gitea #1843 Task 2, before (recorded during implementation) | Started in T3-prepared branch t3/channel-disabled-chat-recovery at controller base 3572bf9b32f414283686e828155ae859a45c537e, source baseline 51525a31d. Scope: explicit Case 1 CLI/WebUI opt-in and Console channel-refusal/failed-read recovery; preserve global defaults, enforcement and no-tracker starter. Controller owns coordination and live repair; no live config, role, queue or transport changes.
2026-11-04 | Codex implementation sub-agent | #815 / Gitea #1843 Task 2, after | Case 1 explicitly opts into cli and webui; no-tracker starter and global defaults remain unchanged. Console renders a fixed disabled-channel explanation, retains in-memory drafts and distinguishes definite refusal from unknown outcome, disables known-refused creation/send actions, marks cached reads stale and does not claim empty on failed reads. TDD red logs and green gates under /mnt/storage/scratch/tmp/mosaic-815-logs/: final targeted chat/room HTTP and Chromium 100/100, full WebUI with MOSAIC_REQUIRE_EDITOR_BUILD=1 574/574, business 307/307, zero skips/cancellations. First full WebUI after root npm ci: 569 passed, 1 missing WebUI esbuild failure, 1 editor-build skip; WebUI lockfile install restored the pinned dependency. Concurrent run: 571/572 with one unrelated decide-browser I1 wait timeout; isolated decide-browser 9/9, subsequent full gate green. Superseded full gates were stopped when later regression corrections changed the source. No bus.mjs/proof pin, role, policy, auth, live host/config, queue, push or deployment change. Draft recovery tested through the Console Refresh action; full browser reload does not persist in-memory drafts.
+1
View File
@@ -2029,3 +2029,4 @@ are never rewritten or removed; corrections are new entries.
2026-11-04 | Codex implementation sub-agent, t3/request-lifecycle-task-8-76f8d365 | #797 / Gitea #1839 Task 8b repository acceptance | Three paths, eight separate-process crashes, tracker uncertainty, real typed tracker/Git/review/delivery, offline Chromium and guarded live runner; assistant-origin owner controls and exact PRD cancellation fixed red-to-green. Five older launcher fixture protocols forwarded to real request-host methods. Final gates 4300 tests across bus/CLI/tasks/project/harness/business/seat/WebUI, zero failures/skips/cancellations; full CLI includes opt-in R7 in isolated fake HOME. Earlier CLI 52 failures/1 skip and isolated reproductions retained. Logs/evidence index /mnt/storage/scratch/tmp/tupyBK/. No migration, role/ROUTED/security expansion, install, canonical edit, live acceptance or push. Second local commit; lead-only live checks in BUILD-LOG and packages/cli/live/README.md.
2026-11-04 | GPT-6 Luna implementation sub-agent, t3/lifecycle-task-7b-r2 | Gitea #1839 lifecycle Task 7b rebase | Stopped before branch creation: packages/webui/src/bus.mjs differs between requested base 34e9af616 and source 1904c9775 (blob hashes 2d19a538b809a808a53307f049363e62e854ea96 vs 593d12b1582a4a3a285e0b89b32d9ee6e3ab726d); pinned-hash guard requires reporting. No cherry-pick, tests, or commit.
2026-11-04 | Lead Codex GPT-6.1 Sol, provider recovery | #815 / Gitea #1843 live MVP 3 /chat failure | Recovered through .53 Window D at 51525a31d/schema 3s; canonical fast-forwarded from b5a58b48b, prior Luna uncommitted append preserved. Diagnosed absent plugins.channel and channel-disabled before project dispatch; typed tester plugins read succeeded. Repair limited to validated channel setting and source error/template regressions; no owner acceptance or secret access.
2026-11-04 | Codex implementation sub-agent, t3/channel-disabled-chat-recovery | #815 / Gitea #1843 Task 2 source fix | Explicit cli/webui Case 1 opt-in and fixed disabled-channel/error/empty-state UI, draft retention and safe retry distinction; targeted chat/room 100, full WebUI 574 and business 307 passed with zero skips. Earlier dependency and concurrent browser timeout results and logs recorded in BUILD-LOG; Console Refresh preserves in-memory drafts, full browser reload does not. Local commit only; controller owns independent review and integration.
@@ -15,6 +15,7 @@
"bus.tester": true
},
"plugins": {
"channel": ["cli", "webui"],
"harness": ["claude-code"]
},
"roles": {
@@ -70,6 +70,8 @@ test("row 323: committed Case 1 document loads unchanged through the business lo
assert.deepEqual(b.projects, { "stack": { root: "/srv/mosaic/src/mosaic-stack" } });
assert.deepEqual(Object.keys(b.roles), ["chief", "assistant"]);
assert.deepEqual(b.plugins.harness, ["claude-code"]);
assert.deepEqual(document().plugins.channel, ["cli", "webui"]);
assert.deepEqual(b.plugins.channel, ["cli", "webui"]);
assert.equal(b.launch.by, "chief");
assert.deepEqual(b.launch.instances, ["assistant"]);
assert.deepEqual(b.arbiters, { delivery: "chief", technical: "chief" });
+39 -28
View File
@@ -31,6 +31,7 @@
class ChatError extends Error {
constructor(code, status = 0, sent) { super(code); this.code = code; this.status = status; this.sent = sent; }
}
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();
window.MosaicRequests?.forget();
@@ -87,10 +88,10 @@
const picker = project ? '' : `<label>Thread with<select id="chat-instance" data-chat-field="instance" required${disabled(f.pending || !!current.instanceError || !choices.length)}><option value="">Choose who this thread is with</option>${choices.map(row => `<option value="${esc(row.instance)}"${row.instance === f.instance ? ' selected' : ''}>${txt(row.name === row.instance ? row.instance : `${row.name} (${row.instance})`)}</option>`).join('')}</select></label>`;
const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance));
const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : '');
return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
return `<form id="chat-${kind}-form" class="chat-create" data-key="${esc(key)}"><h2>New ${kind}</h2><label>Name<input id="chat-${kind}-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label>${repo}${picker}<button class="btn" type="submit"${disabled(f.pending || unavailable || current.channelDisabled)}>${f.pending ? 'Creating…' : `Create ${kind}`}</button><p role="status">${txt(status)}</p></form>`;
}
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>` : '<p>No threads yet.</p>';
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 countLabel = value => !value ? 'unavailable (count collector not installed)' :
@@ -102,7 +103,7 @@
const c = current, selected = c.sessions.find(r => r.id === c.session), f = form('session:' + t.id);
const clear = form('clear:' + c.key);
const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : '';
return `<section class="chat-session-controls" aria-label="Sessions"><label>Session<select id="chat-session">${c.sessions.map(r => `<option value="${esc(r.id)}"${r.id === c.session ? ' selected' : ''}>${txt(r.name)}${r.default ? ' (default)' : ''} — ${txt(r.state ?? 'status unavailable')}</option>`).join('')}</select></label><p id="chat-compactions">Compactions: ${txt(countLabel(selected?.compactions))}</p><p>${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · <a href="${sessionPath(t.id, selected.successor)}">Open fresh successor</a>` : ''}</p><form id="chat-session-form" class="chat-create" data-key="session:${esc(t.id)}"><label>New session name<input id="chat-session-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label><button class="btn" type="submit"${disabled(f.pending)}>New session</button><p role="status">${txt(f.receipt)}</p></form><button class="btn" type="button" id="chat-clear"${disabled(clear.pending || !!reason || selected?.state && selected.state !== 'active')}>Clear context</button>${reason ? `<p>${txt(reason)}</p>` : ''}<p id="chat-clear-receipt" role="status">${txt(clear.receipt)}</p><form id="chat-operation-form"><label>Check clear operation<input id="chat-operation" value="${esc(clear.op ?? '')}" maxlength="160"${clear.pending ? ' disabled' : ''}></label><button class="btn" type="submit">Check status</button><button class="btn" type="button" id="chat-cancel"${disabled(clear.pending || !!reason || !clear.op || ['cancelled', 'failed'].includes(clear.record?.state) || clear.record?.start === 'started')}>Cancel clear</button></form></section>`;
return `<section class="chat-session-controls" aria-label="Sessions"><label>Session<select id="chat-session">${c.sessions.map(r => `<option value="${esc(r.id)}"${r.id === c.session ? ' selected' : ''}>${txt(r.name)}${r.default ? ' (default)' : ''} — ${txt(r.state ?? 'status unavailable')}</option>`).join('')}</select></label><p id="chat-compactions">Compactions: ${txt(countLabel(selected?.compactions))}</p><p>${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · <a href="${sessionPath(t.id, selected.successor)}">Open fresh successor</a>` : ''}</p><form id="chat-session-form" class="chat-create" data-key="session:${esc(t.id)}"><label>New session name<input id="chat-session-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label><button class="btn" type="submit"${disabled(f.pending || current.channelDisabled)}>New session</button><p role="status">${txt(f.receipt)}</p></form><button class="btn" type="button" id="chat-clear"${disabled(clear.pending || !!reason || selected?.state && selected.state !== 'active')}>Clear context</button>${reason ? `<p>${txt(reason)}</p>` : ''}<p id="chat-clear-receipt" role="status">${txt(clear.receipt)}</p><form id="chat-operation-form"><label>Check clear operation<input id="chat-operation" value="${esc(clear.op ?? '')}" maxlength="160"${clear.pending ? ' disabled' : ''}></label><button class="btn" type="submit">Check status</button><button class="btn" type="button" id="chat-cancel"${disabled(clear.pending || !!reason || !clear.op || ['cancelled', 'failed'].includes(clear.record?.state) || clear.record?.start === 'started')}>Cancel clear</button></form></section>`;
}
// A cite is changed when the file's last commit at the served tip (`last`, read with the page) is not
// its last commit at the cited one (`file`, stored with the check). Either one missing marks nothing.
@@ -204,7 +205,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)}>Resume assistant</button>` : ''}<ol class="chat-log" tabindex="0" aria-label="Thread conversation">${messages || '<li class="muted">No messages yet.</li>'}</ol><div class="chat-paging">${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)}>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))}${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>`;
}
// C2-10c: rooms reuse the same state, renderer and generation fences as threads.
const ROOM_LINES = Object.freeze({
@@ -213,7 +214,7 @@
'room-thread-name-taken': 'A room thread name is already in use. Resolve the conflicting thread before opening the room.',
'room-not-open': 'This room is not open. Open the room before sending.',
});
const roomError = err => ROOM_LINES[err.code] ?? (err.sent === false || err.status >= 400 && err.status < 500
const roomError = err => err.code === 'channel-disabled' ? channelMessage : ROOM_LINES[err.code] ?? (err.sent === false || err.status >= 400 && err.status < 500
? `Room operation refused (${inert(err.code)}).`
: 'Room unavailable. Refresh to read it again; nothing was retried.');
const roomOwner = () => current && !current.user && !current.tester;
@@ -231,7 +232,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)}>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="#/chat/rooms/${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);
@@ -240,8 +241,8 @@
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>`;
}).join('');
const off = !c.roomReady || 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 || '<li>No messages yet.</li>'}</ol><div class="chat-paging">${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>`;
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>`;
}
function drawRoomCompletion() {
const list = document.getElementById('chat-room-completion'), field = document.getElementById('chat-room-body');
@@ -308,13 +309,15 @@
} catch (err) {
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
current.error = roomError(err);
current.readFailed = true;
if (err.code === 'channel-disabled') current.channelDisabled = true;
if (err.status === 403 || err.status === 404) { s.rows = []; current.members = []; }
}
if (g === generation && !out()) { paint(navigated); void loadLibrary(libraryOf(current), g, signal); }
}
async function openRoom() {
const c = current, id = c?.id;
if (!roomOwner() || !c.canSend || out() || !UUID.test(id ?? '')) return;
if (!roomOwner() || !c.canSend || c.channelDisabled || out() || !UUID.test(id ?? '')) return;
const f = form('room-open:' + id);
if (f.pending) return;
f.pending = true; f.receipt = ''; paint();
@@ -329,12 +332,13 @@
}
} catch (err) {
if (out() || err.code === 'signed-out') return;
f.receipt = err.sent === false ? roomError(err) : 'Outcome unknown. Read the room before opening again; nothing was retried.';
f.receipt = err.sent === false ? roomError(err) : `${err.code === 'channel-disabled' ? channelMessage + ' ' : ''}Outcome unknown. Read the room before opening again; nothing was retried.`;
if (err.code === 'channel-disabled' && current?.id === id) current.channelDisabled = true;
} finally { f.pending = false; if (current?.id === id && !out()) paint(); }
}
async function sendRoom() {
const c = current;
if (!roomOwner() || !c.canSend || !c.roomReady || out()) return;
if (!roomOwner() || !c.canSend || !c.roomReady || c.channelDisabled || out()) return;
const s = state(c.key), body = s.draft;
if (s.pending) return;
if (!body.trim() || new TextEncoder().encode(JSON.stringify({ body })).length > 16384) {
@@ -350,7 +354,8 @@
s.follow = true; s.tailRequested = true;
} catch (err) {
if (out() || err.code === 'signed-out') return;
s.receipt = err.sent === false ? roomError(err) : 'Outcome unknown. Read the room before trying again. Nothing was retried; your draft is kept.';
s.receipt = err.sent === false ? roomError(err) : `${err.code === 'channel-disabled' ? channelMessage + ' ' : ''}Outcome unknown. Read the room before trying again. Nothing was retried; your draft is kept.`;
if (err.code === 'channel-disabled' && current?.key === c.key) current.channelDisabled = true;
} finally { s.pending = false; if (current?.key === c.key && !out()) paint(); }
}
document.addEventListener('keydown', event => {
@@ -373,6 +378,7 @@
const keep = capture(), c = current;
let html = '<div class="chat-area"><p><a href="#/chat">Chat 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') {
html += c.refused ? heading('Room unavailable') : roomConversation();
} else if (c.kind === 'thread') {
@@ -385,7 +391,7 @@
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);
} else {
html += heading('Chat') + '<p>Business projects and your named assistant threads.</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 ? '' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', '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>';
@@ -408,7 +414,7 @@
updateRoomCompletion();
}
function readError(err) {
return err.code === 'thread-not-owned' ? 'This thread is not available to this user.' :
return err.code === 'channel-disabled' ? channelMessage : err.code === 'thread-not-owned' ? 'This thread is not available to this user.' :
err.code === 'thread-not-found' ? 'Thread not found.' :
err.code === 'session-not-found' ? 'Session not found in this thread.' : 'Chat could not be read. Refresh to try again; your draft is kept.';
}
@@ -425,7 +431,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: '', refused: false, 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, 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) {
@@ -443,11 +449,12 @@
request('projects', undefined, signal),
request('threads' + (kind === 'project' ? '?project=' + id : ''), undefined, signal),
tester ? { rows: [] } : request('threads?shared=true' + (kind === 'project' ? '&project=' + id : ''), undefined, signal).catch(err => {
if (['signed-out', 'access-revoked'].includes(err.code)) throw err;
return { rows: [] };
if (err.code !== 'access-revoked') err.sharedRead = true;
throw err;
}),
]);
if (g !== generation || out()) return;
current.channelDisabled = false;
current.projects = projects.rows;
const own = new Set(listed.rows.map(t => t.id));
current.threads = [...listed.rows, ...shared.rows.filter(t => !own.has(t.id)).map(t => ({ ...t, shared: true }))];
@@ -493,7 +500,7 @@
clear.record = receipt; clear.receipt = clearText(receipt);
} catch (err) {
if (g !== generation || out() || err.name === 'AbortError') return;
clear.receipt = `Status unavailable (${inert(err.code)}). Operation ${clear.op}; no write was retried.`;
clear.receipt = err.code === 'channel-disabled' ? `${channelMessage} Operation ${clear.op}; no write was retried.` : `Status unavailable (${inert(err.code)}). Operation ${clear.op}; no write was retried.`;
}
}
}
@@ -522,7 +529,8 @@
} catch (err) {
if (err.name === 'AbortError' || out() || g !== generation) return;
current.instances = [];
current.instanceError = "Couldn't read the thread instances; reload to try again";
current.instanceError = err.code === 'channel-disabled' ? channelMessage : "Couldn't read the thread instances; reload to try again";
if (err.code === 'channel-disabled') current.channelDisabled = true;
}
}
// Repository choices are optional metadata; an unavailable read must not block unlinked chat.
@@ -545,13 +553,15 @@
} catch (err) {
if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;
current.error = readError(err);
current.readFailed = true;
if (err.code === 'channel-disabled') current.channelDisabled = true;
// A bad session selector is local to that read. Only a thread-level refusal
// invalidates all sibling drafts, receipts and recovery operation keys.
if (['thread-not-owned', 'thread-not-found', 'access-refused'].includes(err.code)) {
if (!err.sharedRead && ['thread-not-owned', 'thread-not-found', 'access-refused'].includes(err.code)) {
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);
}
if (err.status === 403 || err.status === 404) {
if (!err.sharedRead && (err.status === 403 || err.status === 404)) {
current.projects = []; current.threads = []; current.thread = null; current.shared = false; current.refused = true;
}
paint(navigated);
@@ -583,7 +593,7 @@
}
}, true);
async function write(action, key, value, path) {
if (!current || !current.canSend || out()) return;
if (!current || !current.canSend || out() || current.channelDisabled) return;
const isThread = action === 'send' || action === 'open';
if ((isThread || action === 'createSession') && !threadWrites()) return;
if (action === 'createProject' && current.user || action === 'createThread' && !scopeWrites(value.project, value.instance)) return;
@@ -611,8 +621,9 @@
}
} catch (err) {
if (out() || err.code === 'signed-out') return;
s.receipt = err.sent === false ? `Not sent (${inert(err.code)}). Your draft is kept.` :
'Outcome unknown. Read the current list or conversation before trying again. Nothing was retried; your draft is kept.';
s.receipt = err.sent === false ? (err.code === 'channel-disabled' ? `Not sent. ${channelMessage} Your draft is kept.` : `Not sent (${inert(err.code)}). Your draft is kept.`) :
`${err.code === 'channel-disabled' ? channelMessage + ' ' : ''}Outcome unknown. Read the current list or conversation before trying again. Nothing was retried; your draft is kept.`;
if (err.code === 'channel-disabled' && current) current.channelDisabled = true;
if (action === 'createSession' && err.sent === false) { s.op = null; s.payload = null; }
if (err.status === 403 && ['thread-not-owned', 'access-refused'].includes(err.code)) {
states.delete(key);
@@ -680,7 +691,7 @@
f.receipt = op === 'share' ? 'Shared read-only.' : 'Share ended.';
if (op === 'share') f.to = '';
} catch (e) {
f.receipt = e.sent === false ? `Share refused (${inert(e.code)}).` : 'Outcome unknown. Read shares before trying again; nothing was retried.';
f.receipt = e.sent === false ? (e.code === 'channel-disabled' ? `Share refused. ${channelMessage}` : `Share refused (${inert(e.code)}).`) : `${e.code === 'channel-disabled' ? channelMessage + ' ' : ''}Outcome unknown. Read shares before trying again; nothing was retried.`;
} finally {
f.pending = false;
if (!out() && current?.id === id) { await refresh(); paint(); }
@@ -695,7 +706,7 @@
if (out() || f.op !== op) return;
f.record = r; f.receipt = clearText(r);
if (current?.key === key) await refresh();
} catch (e) { if (!out()) f.receipt = `Status unavailable (${inert(e.code)}). Operation ${f.op}; no write was retried.`; }
} catch (e) { if (!out()) f.receipt = e.code === 'channel-disabled' ? `${channelMessage} Operation ${f.op}; no write was retried.` : `Status unavailable (${inert(e.code)}). Operation ${f.op}; no write was retried.`; }
if (current?.key === key) paint();
}
async function cancelClear() {
@@ -710,7 +721,7 @@
f.record = r; f.receipt = clearText(r);
} catch (e) {
if (out()) return;
f.receipt = e.sent === false ? `Cancel refused (${inert(e.code)}).` : `Outcome unknown. Check operation ${op}; cancel was not retried.`;
f.receipt = e.sent === false ? (e.code === 'channel-disabled' ? `Cancel refused. ${channelMessage}` : `Cancel refused (${inert(e.code)}).`) : `${e.code === 'channel-disabled' ? channelMessage + ' ' : ''}Outcome unknown. Check operation ${op}; cancel was not retried.`;
} finally {
f.pending = false;
if (!out() && current?.key === key) { await refresh(); paint(); }
@@ -729,7 +740,7 @@
f.record = r; f.receipt = clearText(r);
} catch (e) {
if (out()) return;
f.receipt = e.sent === false ? `Clear refused (${inert(e.code)}).` : `Outcome unknown. Check operation ${f.op}; nothing was retried.`;
f.receipt = e.sent === false ? (e.code === 'channel-disabled' ? `Clear refused. ${channelMessage}` : `Clear refused (${inert(e.code)}).`) : `${e.code === 'channel-disabled' ? channelMessage + ' ' : ''}Outcome unknown. Check operation ${f.op}; nothing was retried.`;
if (e.sent === false) f.op = null;
} finally {
f.pending = false;
+130 -2
View File
@@ -35,7 +35,7 @@ const stub = `(() => {
}
window.__reads.push(path);
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
if(window.__readFailure)return new Response(JSON.stringify({error:window.__readFailure}),{status:window.__readFailure==='thread-not-owned'?403:503});
if(window.__readFailure && (!window.__failurePath || path.includes(window.__failurePath)))return new Response(JSON.stringify({error:window.__readFailure}),{status:['thread-not-owned','access-refused'].includes(window.__readFailure)?403:503});
if(path==='/api/chat/instances')return window.__instanceFailure ? new Response(JSON.stringify({error:'not-configured'}),{status:503}) : data({rows:window.__instances});
if(path==='/api/chat/projects')return data({rows:window.__projects});
if(path.endsWith('/sessions')){const t=path.split('/')[4];return data({rows:window.__sessions[t]||[{id:t,name:'default',default:true}]});}
@@ -45,11 +45,12 @@ const stub = `(() => {
return data({rows:[],after:null,hasMore:false});
};
})();`;
async function setup(t, suffix = '') {
async function setup(t, suffix = '', initial = '') {
const f = await fixture(); t.after(() => f.close());
const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
const b = await browser(); t.after(() => b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
if (initial) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: initial });
await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat`);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expression)});})()`);
const set = (selector, text) => b.evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(text)};el.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
@@ -63,6 +64,133 @@ async function setup(t, suffix = '') {
};
return { b, wait, set, click, route, refresh };
}
test('chat browser: initial disabled channel reports unavailable lists and does not offer creation', async t => {
const { b } = await setup(t, '', 'window.__readFailure="channel-disabled"');
const text = await b.evaluate('document.querySelector(".chat-area").textContent');
assert.match(text, /chat.*channel.*disabled/i);
assert.doesNotMatch(text, /No projects yet|No threads yet|mosaic plugins|SENTINEL/i);
assert.equal(await b.evaluate('document.querySelector("#chat-project-form button")?.disabled ?? true'), true);
assert.equal(await b.evaluate('window.__writes.length'), 0);
});
test('chat browser: refused creation keeps the draft, disables creation and recovers after refresh', async t => {
const { b, wait, set, click, refresh } = await setup(t);
await set('#chat-project-name', 'Pomodoro Timer');
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",sent:false,message:"SENTINEL private transport"}}),true');
await click('#chat-project-form button[type=submit]');
await wait('document.querySelector("#chat-project-form [role=status]")?.textContent.includes("Not sent")');
assert.match(await b.evaluate('document.querySelector("#chat-project-form [role=status]").textContent'), /channel.*disabled/i);
assert.doesNotMatch(await b.evaluate('document.querySelector(".chat-area").textContent'), /SENTINEL/);
assert.equal(await b.evaluate('document.querySelector("#chat-project-name").value'), 'Pomodoro Timer');
assert.equal(await b.evaluate('document.querySelector("#chat-project-form button").disabled'), true);
await refresh();
await wait('!document.querySelector("#chat-project-form button").disabled');
assert.equal(await b.evaluate('document.querySelector("#chat-project-name").value'), 'Pomodoro Timer');
assert.equal(await b.evaluate('window.__writes.length'), 1);
await b.evaluate(`window.__answers.push({body:{id:'${P}'}}),true`);
await click('#chat-project-form button[type=submit]');
await wait('document.querySelector("#chat-thread-form")');
assert.equal(await b.evaluate('window.__writes[1].body.name'), 'Pomodoro Timer');
});
test('chat browser: failed refresh marks cached lists stale; successful empty reads retain creation', async t => {
const { b, wait, refresh } = await setup(t);
await b.evaluate('window.__readFailure="channel-disabled";true');
await refresh();
await wait('document.querySelector(".chat-error")');
const failed = await b.evaluate('document.querySelector(".chat-area").textContent');
assert.match(failed, /channel.*disabled/i);
assert.match(failed, /previous|cached|stale/i);
assert.doesNotMatch(failed, /No projects yet|No threads yet/);
assert.equal(await b.evaluate('document.querySelector("#chat-project-form button").disabled'), true);
await b.evaluate('window.__readFailure=null;window.__projects=[];window.__threads=[];true');
await refresh();
await wait('document.querySelector(".chat-area").textContent.includes("No projects yet")');
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /No threads yet/);
assert.equal(await b.evaluate('document.querySelector("#chat-project-form button").disabled'), false);
assert.equal(await b.evaluate('window.__writes.length'), 0);
});
test('chat browser: disabled thread creation retains its draft and refresh restores the action', async t => {
const { b, wait, set, click, refresh } = await setup(t);
await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
await wait('document.querySelector("#chat-thread-form button") && !document.querySelector("#chat-thread-form button").disabled');
await set('#chat-thread-name', 'planning');
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",sent:false}}),true');
await click('#chat-thread-form button');
await wait('document.querySelector("#chat-thread-form [role=status]").textContent.includes("Not sent")');
assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /channel.*disabled/i);
assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'planning');
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
await refresh();
await wait('!document.querySelector("#chat-thread-form button").disabled');
assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'planning');
assert.equal(await b.evaluate('window.__writes.length'), 1);
});
test('chat browser: uncertain disabled send remains unknown, preserves draft and does not retry', async t => {
const { b, wait, set, click, route, refresh } = await setup(t);
await route(A); await set('#chat-body', 'unsure draft');
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",message:"SENTINEL"}}),true');
await click('#chat-send-form button');
await wait('document.querySelector("#chat-receipt").textContent.includes("Outcome unknown")');
assert.doesNotMatch(await b.evaluate('document.querySelector("#chat-receipt").textContent'), /Not sent|SENTINEL/);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'unsure draft');
assert.equal(await b.evaluate('document.querySelector("#chat-send-form button").disabled'), true);
assert.equal(await b.evaluate('document.querySelector("#chat-session-form button").disabled'), true);
await refresh();
await wait('!document.querySelector("#chat-send-form button").disabled');
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'unsure draft');
assert.equal(await b.evaluate('window.__writes.length'), 1);
});
test('chat browser: a refused send explains the disabled channel and retains the message', async t => {
const { b, wait, set, click, route } = await setup(t);
await route(A); await set('#chat-body', 'keep my message');
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",sent:false,message:"SENTINEL"}}),true');
await click('#chat-send-form button');
await wait('document.querySelector("#chat-receipt").textContent.includes("Not sent")');
const receipt = await b.evaluate('document.querySelector("#chat-receipt").textContent');
assert.match(receipt, /channel.*disabled/i);
assert.doesNotMatch(receipt, /Outcome unknown|SENTINEL/);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'keep my message');
assert.equal(await b.evaluate('document.querySelector("#chat-send-form button").disabled'), true);
assert.equal(await b.evaluate('window.__writes.length'), 1);
});
test('chat browser: failed shared-thread read does not claim an empty shared list', async t => {
const { b, wait, refresh } = await setup(t);
await b.evaluate('window.__failurePath="shared=true";window.__readFailure="channel-disabled";true');
await refresh();
await wait('document.querySelector(".chat-error")');
const text = await b.evaluate('document.querySelector(".chat-area").textContent');
assert.match(text, /channel.*disabled/i);
assert.doesNotMatch(text, /No threads yet/);
});
test('chat browser: a shared-list refusal does not discard an owned thread draft', async t => {
const { b, wait, set, route, refresh } = await setup(t);
await route(A); await set('#chat-body', 'owned thread draft');
await b.evaluate('window.__failurePath="shared=true";window.__readFailure="access-refused";true');
await refresh(); await wait('document.querySelector(".chat-error")');
await b.evaluate('window.__readFailure=null;true');
await refresh(); await wait('document.querySelector("#chat-body")');
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'owned thread draft');
});
test('chat browser: a refused share explains the disabled channel without exposing transport text', async t => {
const { b, wait, set, click, route } = await setup(t);
await route(A); await set('#chat-share-target', 'user:reader');
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",sent:false,message:"SENTINEL"}}),true');
await click('#chat-share-form button');
await wait('document.querySelector("#chat-share-form + [role=status]")?.textContent.includes("refused")');
const receipt = await b.evaluate('document.querySelector("#chat-share-form + [role=status]").textContent');
assert.match(receipt, /chat channel.*disabled/i);
assert.doesNotMatch(receipt, /SENTINEL/);
assert.equal(await b.evaluate('document.querySelector("#chat-share-target").value'), 'user:reader');
assert.equal(await b.evaluate('window.__writes.length'), 1);
});
test('chat browser: list/create named projects and threads, hostile labels inert and no implicit writes', async t => {
const { b, wait, set, click } = await setup(t);
assert.equal(await b.evaluate('window.__writes.length'), 0);
@@ -76,6 +76,14 @@ async function setup(t, { path = `/chat/rooms/${P}`, before = '', suffix = '' }
await wait(path.includes('/rooms/') ? 'document.querySelector(".chat-room")' : 'document.querySelector("#chat-thread-form")');
return { b, wait, text, set };
}
test('disabled room read shows unavailable history and disables opening', async t => {
const { b, text } = await setup(t, { before: 'window.__readError="channel-disabled";' });
assert.match(await text('.chat-error'), /chat channel.*disabled/i);
assert.match(await text('.chat-room-log'), /unavailable/i);
assert.doesNotMatch(await text('.chat-room'), /No messages yet|Caught up to the last read/);
assert.equal(await b.evaluate('document.querySelector("#chat-room-open").disabled'), true);
assert.equal(await b.evaluate('window.__writes.length'), 0);
});
test('C2-10c room merges members, shows a broadcast once, reuses cited renderer and suppresses checked notice forgery', async t => {
const { b, wait, text } = await setup(t);
await wait('document.querySelector(".chat-cites")');