From e9271841f3af23bf2a72aa17d9d67a1ec5abe017 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 4 Nov 2026 17:27:22 -0600 Subject: [PATCH] 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. --- BUILD-LOG.md | 10 ++ docs/SESSIONS.md | 6 + docs/guides/case1-business.mosaic-stack.json | 1 + .../tests/case1-business-file.test.mjs | 2 + packages/webui/src/public/chat.js | 71 ++++---- packages/webui/tests/chat-browser.test.mjs | 153 +++++++++++++++++- packages/webui/tests/room-browser.test.mjs | 32 ++++ 7 files changed, 244 insertions(+), 31 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index cd8dcdc15..08ea00d5e 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -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. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 59cc4010b..28b6d8bab 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -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. diff --git a/docs/guides/case1-business.mosaic-stack.json b/docs/guides/case1-business.mosaic-stack.json index fa8dc7b71..372fc7022 100644 --- a/docs/guides/case1-business.mosaic-stack.json +++ b/docs/guides/case1-business.mosaic-stack.json @@ -15,6 +15,7 @@ "bus.tester": true }, "plugins": { + "channel": ["cli", "webui"], "harness": ["claude-code"] }, "roles": { diff --git a/packages/business/tests/case1-business-file.test.mjs b/packages/business/tests/case1-business-file.test.mjs index 0d5853236..0886d6ef2 100644 --- a/packages/business/tests/case1-business-file.test.mjs +++ b/packages/business/tests/case1-business-file.test.mjs @@ -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" }); diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index 3d6b7686b..d358afe05 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -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 ? '' : ``; 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 `

New ${kind}

${repo}${picker}

${txt(status)}

`; + return `

New ${kind}

${repo}${picker}

${txt(status)}

`; } function threads(rows) { - return rows.length ? `` : '

No threads yet.

'; + return rows.length ? `` : current.readFailed ? '

Threads unavailable.

' : '

No threads yet.

'; } 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 `

Compactions: ${txt(countLabel(selected?.compactions))}

${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · Open fresh successor` : ''}

${txt(f.receipt)}

${reason ? `

${txt(reason)}

` : ''}

${txt(clear.receipt)}

`; + return `

Compactions: ${txt(countLabel(selected?.compactions))}

${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · Open fresh successor` : ''}

${txt(f.receipt)}

${reason ? `

${txt(reason)}

` : ''}

${txt(clear.receipt)}

`; } // 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 => `
  • ${txt(m.from_role)} ${stamp(m.at)}
    ${messageBody(m)}${withRequest.has(m.id) ? `
    ` : ''}
  • `).join(''); const receipt = s.receipt; - return `

    ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

    ${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}
      ${messages || '
    1. No messages yet.
    2. '}
    ${s.more ? '

    Showing earlier history. Read forward to reach the current conversation.

    ' : '

    Caught up to the last read.

    '}
    ${threadWrites() ? `
    ` : ''}

    ${txt(receipt)}

    `; + return `

    ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

    ${heading(t.name)}${libraryLine(libraryOf(current, t))}${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}
      ${messages || (current.readFailed ? '
    1. Messages unavailable.
    2. ' : '
    3. No messages yet.
    4. ')}
    ${current.readFailed ? '' : s.more ? '

    Showing earlier history. Read forward to reach the current conversation.

    ' : '

    Caught up to the last read.

    '}
    ${threadWrites() ? `
    ` : ''}

    ${txt(receipt)}

    `; } // 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 `
    Read room

    ${txt(f.receipt)}

    `; + return `
    Read room

    ${txt(f.receipt)}

    `; } 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 `
  • ${txt(speaker)}${recipients} ${stamp(m.at)}
    ${messageBody({ ...m, from_role: m.from_role === 'human' ? m.from_role : speaker })}
  • `; }).join(''); - const off = !c.roomReady || s.pending || !roomOwner(); - return `

    ${link('projects', c.id, p?.name ?? 'Project')}

    ${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}

    Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}

      ${messages || '
    1. No messages yet.
    2. '}
    ${s.more ? '' : '

    Caught up to the last read.

    '}

    Without leading @tags, your message goes to every member. The broker checks the addressees.

    ${txt(s.receipt)}

    `; + const off = !c.roomReady || c.channelDisabled || s.pending || !roomOwner(); + return `

    ${link('projects', c.id, p?.name ?? 'Project')}

    ${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}

    Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}

      ${messages || (c.readFailed ? '
    1. Messages unavailable.
    2. ' : '
    3. No messages yet.
    4. ')}
    ${c.readFailed ? '' : s.more ? '' : '

    Caught up to the last read.

    '}

    Without leading @tags, your message goes to every member. The broker checks the addressees.

    ${txt(s.receipt)}

    `; } 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 = '

    Chat projects

    '; if (c.error) html += ``; + if (c.readFailed && (c.projects.length || c.threads.length || c.kind === 'thread' && state(c.key).loaded)) html += '

    Showing previously read chat content; it may be out of date.

    '; 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)) + '

    Shared with me

    ' + threads(c.threads.filter(t => t.shared)) + creation('thread', c.id); } else { html += heading('Chat') + '

    Business projects and your named assistant threads.

    ' + - c.projects.map(p => `

    ${link('projects', p.id, p.name)}

    ${p.repo ? `

    Repository: ${txt(p.repo)}

    ` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}
    `).join('') + (c.projects.length ? '' : '

    No projects yet.

    ') + '

    Shared with me

    ' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects'); + c.projects.map(p => `

    ${link('projects', p.id, p.name)}

    ${p.repo ? `

    Repository: ${txt(p.repo)}

    ` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}
    `).join('') + (c.projects.length ? '' : c.readFailed ? '

    Projects unavailable.

    ' : '

    No projects yet.

    ') + '

    Shared with me

    ' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects'); } if (!c.canSend) html += '

    Creating and sending require signed-in send mode. This Console only reads.

    '; c.container.innerHTML = html + ''; @@ -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 = '

    Reading chat…

    '; 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; diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs index b020fc29f..f1eabe21f 100644 --- a/packages/webui/tests/chat-browser.test.mjs +++ b/packages/webui/tests/chat-browser.test.mjs @@ -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); diff --git a/packages/webui/tests/room-browser.test.mjs b/packages/webui/tests/room-browser.test.mjs index 1e375c235..03c8a76ef 100644 --- a/packages/webui/tests/room-browser.test.mjs +++ b/packages/webui/tests/room-browser.test.mjs @@ -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")');