fix(webui): enable factory chat and recover accurate states (#1843)

Enable CLI and WebUI channels in the Case 1 starter. Explain channel
refusals, preserve in-memory drafts, and distinguish failed reads from
successful empty data. Keep room refusal state through failed refreshes
and restore session controls after catalog recovery.

Integrates reviewed source commits 5558fe020 and a1ba9cd56. Independent
review R2 approved both recovery fixes; append-only records preserve
controller and implementation evidence.
This commit is contained in:
jason.woltje committed 2026-11-04 17:27:22 -06:00
1 parent a89139e5ea
commit e9271841f3
7 files changed
+244 -31

No files matched your search

+10
View File
@@ -6146,3 +6146,13 @@ Lead-only live window checks, not executed here: (1) explicitly authorize and se
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 | Lead Codex, #815 / Gitea #1843 live configuration outcome | Operator enabled cli+webui through pinned 51525a31d tester plugin setter at 22:43:43Z, transaction e94c4db7-c3e3-4d31-825f-0b204a1ccf76; committed actor tester, only plugins.channel changed, other configuration deeply equal. Private backup/rollback report: /mnt/storage/scratch/mosaic-lead/reports/chat-channel-815.md. Lead independently verified configured channels, committed receipt and real tester WebUI GET /api/chat/projects HTTP 200. Services retained PIDs; no deployment/schema/credential/model/role change or owner workflow. Source regression fix remains active and issue open.
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.
2026-11-04 | Codex implementation sub-agent | #815 / Gitea #1843 source fix round 1, stopped | At base 5558fe020 reproduced room channel state loss and sticky legacy-session controls in real Chromium, then made the scoped state corrections; focused red/green logs and the 47/47 covering-browser pass are under /mnt/storage/scratch/tmp/mosaic-815-r1/. The single authorized full WebUI run with MOSAIC_REQUIRE_EDITOR_BUILD=1 and concurrency 2 finished 575/576, zero skips/cancellations: unrelated thread-claims-browser row278 timed out waiting for its table; that entire file then passed 6/6 alone. The unchanged vendored-editor import warning remained, and the editor rebuild passed. No commit: the full applicable suite is not green, and the brief specified one full run. Controller owns the decision on further verification. Minor cached-room stale-label finding remains unchanged. No live, bus/proof, authentication, policy, role, schema, tracker, push, merge or PR change.
2026-11-04 | #815 / Gitea #1843 source fix round 1 controller verification | Existing authorization covered another verification run after the unchanged row278 browser timeout. Same command and test settings: MOSAIC_REQUIRE_EDITOR_BUILD=1 node --test --test-concurrency=2 packages/webui/tests/; 576 passed, zero failures/cancellations/skips, required editor rebuild passed, duration 168128 ms. Source/test blob hashes unchanged from pre-run snapshot; no test weakening or unrelated source changes. Both earlier full failure and isolated 6/6 evidence retained. Log: /mnt/storage/scratch/tmp/mosaic-815-r1/full-webui-controller-retry.log. Local commit now permitted; independent I1/I2 re-review next.
2026-11-04 | #815 / Gitea #1843 integration phase started | Independent scoped re-review APPROVE at a1ba9cd5669cf084ef69eb663b42234fbce38824: I1/I2 addressed; no new critical/important finding. Applied only the five reviewed source/test/template paths to refactor and appended worker records while preserving all existing controller log bytes. Entire tracked source outside BUILD-LOG/SESSIONS matches the reviewed candidate. Running final canonical WebUI gate and factory regression before commit/push. M1 cached-room stale label and M2 unchanged tooling/audit observations remain deferred/nonblocking. No deployment or owner acceptance.
2026-11-04 | #815 / Gitea #1843 integration phase verified | Canonical full WebUI: MOSAIC_REQUIRE_EDITOR_BUILD=1 node --test --test-concurrency=2 packages/webui/tests/ passed 576/576, zero failures/cancellations/skips, required editor rebuild passed (171530 ms); factory regression passed 8/8, zero failures/cancellations/skips. Logs: /mnt/storage/scratch/tmp/mosaic-815-r1/full-webui-canonical.log and factory-canonical.log. Entire source outside append-only logs still equals independently approved candidate a1ba9cd56. Prior full business 307/307 applies to identical business/template source. All existing controller and worker log bytes preserved. Committing and pushing the reviewed repair under the established plan; source deployment and owner PRD-to-artifact acceptance remain separate. Known in-memory full-reload draft loss, minor cached-room stale-label omission and unchanged tooling observations retained.
+6
View File
@@ -2030,3 +2030,9 @@ are never rewritten or removed; corrections are new entries.
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 Sol operator, verified/recorded by lead Sol | #815 / Gitea #1843 .53 channel correction | Typed tester setter enabled cli+webui with one channel-only receipt; remaining config unchanged, private backup retained, service PIDs unchanged; operator and lead actual WebUI tester reads HTTP 200. No owner project creation or acceptance; source fix ongoing.
2026-11-04 | Lead Codex GPT-6.1 Sol, context continuation | #815 source repair and owner chat/rooms/threads question | Recovered existing fix-round ownership without restarting or polling the implementer; verified PRD thread/session and Case 2 room semantics against Console and broker. Explained conversation-container versus product-project distinction; broader UX change remains a design discussion. Queue next sage returned historical row 7; continued the current owner-directed #815 assignment.
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.
2026-11-04 | Codex implementation sub-agent, t3/channel-disabled-chat-recovery | #815 / Gitea #1843 source fix round 1 | Both browser regressions red then green; covering 47/47. Single full WebUI run 575/576, zero skips, unrelated thread-claims browser timeout; that file passed 6/6 isolated. No local commit because applicable full suite did not pass and brief specified one full run. Logs /mnt/storage/scratch/tmp/mosaic-815-r1/; controller to decide further verification. No live or policy changes.
2026-11-04 | Lead Codex GPT-6.1 Sol, controller verification | #815 / Gitea #1843 fix round 1 | Repeated unchanged full affected WebUI gate in completed implementer checkout: 576/576, zero skips/cancellations, required editor rebuild passed; candidate source/test hashes unchanged. Preserved prior 575/576 and isolated 6/6 results; local commit and independent re-review next. No live change or acceptance claim.
2026-11-04 | Lead Codex GPT-6.1 Sol, reviewed-source integration | #815 / Gitea #1843 | Re-review approved I1/I2 at a1ba9cd56. Integrated reviewed source into refactor with byte-preserving append-only records; canonical verification and authorized push next. Queue next sage named unrelated historical row 7; continued current owner assignment. No live operation or owner acceptance.
2026-11-04 | Lead Codex GPT-6.1 Sol, #815 integration verification | #815 / Gitea #1843 | Canonical WebUI 576/576 and factory 8/8, zero skips/failures/cancellations; independent R2 approved; source exactly matches a1ba9cd56 outside preserved append-only records. Existing plan authorizes commit/push to refactor. No source deployment or owner end-to-end acceptance.
@@ -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" });
+42 -29
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');
@@ -281,7 +282,7 @@
const signal = controller.signal, g = ++generation;
const match = /^\/chat\/rooms\/([0-9a-f-]+)$/i.exec(path), id = match?.[1];
current = { kind: 'room', id, key: 'room:' + id, path, container, canSend, user, tester,
projects: [], threads: [], members: [], roomReady: false, refused: false, error: '' };
projects: [], threads: [], members: [], roomReady: false, channelDisabled: current?.channelDisabled ?? false, refused: false, error: '' };
if (!UUID.test(id ?? '') || !roomOwner()) {
current.error = UUID.test(id ?? '') ? 'Rooms require the business owner.' : 'Invalid room route.';
current.refused = true; paint(navigated); return;
@@ -301,6 +302,7 @@
const page = await request(`rooms/${id}/messages?limit=100${after ? '&after=' + after : ''}`, undefined, signal);
if (g !== generation || out()) return;
if (typeof page.hasMore !== 'boolean' || !(page.after === null || UUID.test(page.after)) || page.rows.some(m => !UUID.test(m?.id) || !Array.isArray(m.ids) || !m.ids.every(id => UUID.test(id)) || !Array.isArray(m.to))) throw new ChatError('invalid-response');
current.channelDisabled = false;
s.rows = [...new Map((after ? [...s.rows, ...page.rows] : page.rows).map(m => [m.id, m])).values()].slice(-200);
s.after = page.after ?? s.after; s.more = page.hasMore; s.loaded = true;
current.roomReady = current.members.length > 0;
@@ -308,13 +310,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 +333,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 +355,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 +379,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 +392,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 +415,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 +432,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 +450,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,12 +501,13 @@
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.`;
}
}
}
if (s) {
if (typeof page.hasMore !== 'boolean' || !(page.after === null || UUID.test(page.after))) throw new ChatError('invalid-response');
if (catalog) current.legacyDefault = false;
const rows = after ? [...s.rows, ...page.rows] : page.rows;
s.rows = [...new Map(rows.map(m => [m.id, m])).values()].slice(-200);
s.after = page.after ?? s.after; s.more = page.hasMore; s.loaded = true;
@@ -522,7 +531,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 +555,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 +595,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 +623,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 +693,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 +708,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 +723,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 +742,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;
+151 -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,154 @@ 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: session catalog recovery restores controls and scopes drafts and messages', async t => {
const { b, wait, set, click, route, refresh } = await setup(t);
await b.evaluate('window.__failurePath="/sessions";window.__readFailure="access-refused";true');
await route(A);
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /sessions.*not.*available/i);
await set('#chat-body', 'legacy draft');
await b.evaluate(`window.__readFailure=null;window.__sessions['${A}']=[{id:'${B}',name:'default',default:true,state:'active',revision:2}];window.__pages.push({rows:[{id:'${M}',body:'session history',from_role:'assistant',at:'2026-10-18T00:00:00Z'}],after:'${M}',hasMore:false});true`);
await refresh();
assert.equal(await b.evaluate('document.querySelector("#chat-session")?.value'), B);
assert.equal(await b.evaluate('!!document.querySelector("#chat-session-form") && !!document.querySelector("#chat-clear")'), true);
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'), /session history/);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), '', 'legacy draft is not copied into another session');
await set('#chat-body', 'session draft');
await b.evaluate(`window.__answers.push({body:{id:'${P}'}}),true`);
await click('#chat-send-form button');
await wait('document.querySelector("#chat-receipt").textContent.includes("Accepted")');
assert.deepEqual(await b.evaluate('window.__writes[0].body'), { body: 'session draft', session: B });
await b.evaluate('window.__readFailure="access-refused";true');
await refresh();
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'legacy draft');
});
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);
@@ -40,6 +40,7 @@ const stub = `(() => {
if(path==='/api/library/revert'){window.__writes.push({path,body:JSON.parse(init.body)});return answer({project:'books',path:'Notes/One.md',reverted:'${SHA}',commit:'${LAST}'});}
if(!path.startsWith('/api/chat/'))return real(url,init);
if(method==='GET')window.__reads.push(path);
if(method==='GET' && window.__chatOffline)throw new TypeError('offline');
if(path==='/api/chat/projects')return answer({rows:window.__projects});
if(path==='/api/chat/instances')return answer({rows:window.__instances});
if(path.includes('/rooms/')){
@@ -76,6 +77,37 @@ 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('room keeps a known disabled channel through a failed refresh and recovers on a successful read', async t => {
const { b, wait, text } = await setup(t);
const refresh = async () => {
await wait('!document.querySelector("#refresh").disabled');
await b.evaluate('document.querySelector(".chat-area").dataset.beforeRefresh="yes";document.querySelector("#refresh").click();true');
await wait('!document.querySelector(".chat-area")?.dataset.beforeRefresh');
};
assert.match(await text('.chat-room-log'), /Broadcast once/);
await b.evaluate('window.__readError="channel-disabled";true');
await refresh();
assert.match(await text('.chat-error'), /channel.*disabled/i);
assert.equal(await b.evaluate('document.querySelector("#chat-room-open").disabled'), true);
await b.evaluate('window.__readError=null;window.__chatOffline=true;true');
await refresh();
assert.equal(await b.evaluate('document.querySelector("#chat-room-open").disabled'), true);
await b.evaluate('document.querySelector("#chat-room-open").dispatchEvent(new MouseEvent("click",{bubbles:true}));true');
assert.equal(await b.evaluate('window.__writes.length'), 0, 'the write guard also refuses a synthetic click');
assert.match(await text('.chat-room-log'), /Broadcast once/);
await b.evaluate('window.__chatOffline=false;true');
await refresh();
assert.equal(await b.evaluate('document.querySelector("#chat-room-open").disabled'), false);
assert.equal(await b.evaluate('window.__writes.length'), 0, 'recovery reads never open a room');
});
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")');