// Phase 05 · LIVE the room.
//
// Three panes:
// LEFT · private — your coach pre-send pass, your private callouts from the pattern watcher,
// your draft composer.
// CENTER· public — the posted openings (from phase 03), the accepted steel-mans (phase 04),
// and the live message thread.
// RIGHT · public — established truths. Either party can propose; BOTH must confirm.
//
// State (live mode):
// shared.posted = [{ id, from:'A'|'B'|'mediator'|'system', kind:'opening'|'steelman'|'msg'|'truth'|'sys', text, ts, ref? }]
// shared.truths = [{ id, text, proposedBy:'A'|'B', confirmedA:bool, confirmedB:bool, ts }]
//
// Pre-existing shared.posted used a simpler shape. We only append; never rewrite.
// Messages: after the room-coach returns, user clicks send-original OR send-gentler.
// Post-send: pattern watcher runs in the background and may produce a private callout.
(function () {
const { useState, useEffect, useRef } = React;
window.LiveRoomPhase = function LiveRoomPhase({ seat, onPhase }) {
const [, tick] = useState(0);
useEffect(() => CG.session.onState(() => tick(t => t + 1)), []);
const shared = CG.session.shared || {};
const names = (CG.session.getNames && CG.session.getNames()) || {};
const mp = CG.session.isMultiplayer && CG.session.isMultiplayer();
const isHost = !mp || seat === 'A';
const otherSeat = seat === 'A' ? 'B' : 'A';
const myName = names[seat] || (seat === 'A' ? 'host' : 'guest');
const otherName = names[otherSeat] || (otherSeat === 'A' ? 'host' : 'guest');
// Prerequisite guard
const myPosted = shared.openings?.[seat]?.step === 'posted';
const theirPosted = shared.openings?.[otherSeat]?.step === 'posted';
const steel = shared.steelman || {};
const myDone = (steel[`${seat}_attempts`] || []).some(a => a.status === 'accepted');
const theirDone = (steel[`${otherSeat}_attempts`] || []).some(a => a.status === 'accepted');
if (!myPosted || !theirPosted || !myDone || !theirDone) {
return (
);
}
// Seed the room thread once: openings + accepted steelmans (as "you said" cards).
const posted = shared.posted || [];
const hasSeed = posted.some(p => p.kind === 'opening');
useEffect(() => {
if (hasSeed) return;
if (!isHost) return; // host seeds deterministically
const out = [];
const openA = shared.openings?.A;
const openB = shared.openings?.B;
if (openA?.synthesis) {
out.push({ id: 'op-a', from: 'mediator', kind: 'opening', text: openA.synthesis, attribution: 'A', ts: Date.now() });
}
if (openB?.synthesis) {
out.push({ id: 'op-b', from: 'mediator', kind: 'opening', text: openB.synthesis, attribution: 'B', ts: Date.now() + 1 });
}
const accA = (steel.A_attempts || []).find(a => a.status === 'accepted');
const accB = (steel.B_attempts || []).find(a => a.status === 'accepted');
if (accA) out.push({ id: 'st-a', from: 'A', kind: 'steelman', text: accA.text, attribution: 'B', ts: Date.now() + 2 });
if (accB) out.push({ id: 'st-b', from: 'B', kind: 'steelman', text: accB.text, attribution: 'A', ts: Date.now() + 3 });
if (out.length) {
CG.session.setShared({ posted: [...posted, ...out] });
}
}, [hasSeed, isHost]);
// ----- phase 06 · callout state -----
const callout = shared.callout || null;
const [calloutError, setCalloutError] = useState('');
const [calloutLoading, setCalloutLoading] = useState(false);
const [calloutRefusal, setCalloutRefusal] = useState(null); // { reason } — shown to requester only
const requestCallout = async () => {
if (calloutLoading) return;
if (callout && callout.status !== 'committed' && callout.status !== 'rejected') return;
setCalloutError('');
setCalloutRefusal(null);
const id = `co-${Date.now()}-${Math.random().toString(36).slice(2,6)}`;
const requesting = { id, requestedBy: seat, status: 'requesting', ackA: false, ackB: false, ts: Date.now() };
if (isHost) CG.session.setShared({ callout: requesting });
else CG.peer.send('state:propose', { callout: requesting });
// Host runs the mediator call; if guest requested, we still want the host to execute,
// but we can let the requester run it too (routed via session proxy anyway).
setCalloutLoading(true);
try {
const reading = await CG.agents.patternCaller.call({ requestedBy: seat });
if (!reading.found) {
// do NOT post. clear the callout; show a private refusal note to the requester only.
setCalloutRefusal({ reason: reading.not_yet_reason || 'not enough to name yet.' });
if (isHost) CG.session.setShared({ callout: null });
else CG.peer.send('state:propose', { callout: { id, status: 'rejected' } });
return;
}
const proposed = { ...requesting, status: 'proposed', reading };
if (isHost) CG.session.setShared({ callout: proposed });
else {
// Guest-initiated: guest runs the call, but only host's setShared is authoritative for
// writing the full reading. Route a write through a dedicated path.
CG.peer.send('state:propose', { __callout_propose: proposed });
}
} catch (e) {
setCalloutError(String(e.message || e));
if (isHost) CG.session.setShared({ callout: null });
else CG.peer.send('state:propose', { callout: { id, status: 'rejected' } });
} finally {
setCalloutLoading(false);
}
};
const ackCallout = () => {
if (!callout || callout.status !== 'proposed') return;
const field = seat === 'A' ? 'ackA' : 'ackB';
const next = { ...callout, [field]: true };
// If both acked, commit: append to posted and clear callout.
const bothAcked = (field === 'ackA' ? true : callout.ackA) && (field === 'ackB' ? true : callout.ackB);
if (bothAcked) {
const entry = {
id: `pat-${callout.id}`,
from: 'mediator',
kind: 'pattern',
title: callout.reading.title,
text: callout.reading.reading,
anchor: callout.reading.anchor,
requestedBy: callout.requestedBy,
ts: Date.now(),
};
if (isHost) {
CG.session.setShared({ callout: null, posted: [...(shared.posted || []), entry] });
} else {
CG.peer.send('state:propose', { __append_posted: entry, callout: null });
}
} else {
if (isHost) CG.session.setShared({ callout: next });
else CG.peer.send('state:propose', { callout: next });
}
};
const rejectCallout = () => {
if (!callout) return;
const rejected = { ...callout, status: 'rejected' };
if (isHost) CG.session.setShared({ callout: null });
else CG.peer.send('state:propose', { callout: rejected });
};
// ----- left rail state -----
const [draft, setDraft] = useState('');
const [review, setReview] = useState(null); // { note, gentler_version, issues, ok_to_send }
const [reviewing, setReviewing] = useState(false);
const [reviewError, setReviewError] = useState('');
const [sending, setSending] = useState(false);
// private callouts, keyed per seat — stored in component state only (not shared)
const [callouts, setCallouts] = useState([]);
// persist draft locally
useEffect(() => { (async () => {
const s = await CG.store.get(`room_draft_${seat}`);
if (s && typeof s === 'string') setDraft(s);
})(); }, [seat]);
useEffect(() => { CG.store.set(`room_draft_${seat}`, draft); }, [draft, seat]);
const publicThread = posted.filter(p => p.kind === 'msg' || p.kind === 'opening' || p.kind === 'steelman');
// Presence
useEffect(() => {
let activity = 'in the room';
if (sending) activity = 'posting';
else if (reviewing) activity = 'reviewing with coach';
else if ((draft || '').trim().length > 0) activity = 'drafting';
try { CG.session.setCursor && CG.session.setCursor({ activity }); } catch (e) {}
}, [sending, reviewing, draft]);
const runReview = async () => {
const t = draft.trim();
if (!t || reviewing) return;
setReviewing(true);
setReviewError('');
setReview(null);
try {
const r = await CG.agents.roomCoach.review(seat, t, publicThread);
setReview(r);
} catch (e) {
setReviewError(String(e.message || e));
} finally {
setReviewing(false);
}
};
const sendPost = (text) => {
if (!text || sending) return;
setSending(true);
const entry = { id: `m-${Date.now()}-${Math.random().toString(36).slice(2,6)}`, from: seat, kind: 'msg', text, ts: Date.now() };
// posted is append-only; use the __append_posted mechanism on cg-session for guest
if (isHost) {
CG.session.setShared({ posted: [...(shared.posted || []), entry] });
} else {
// guest appends via dedicated protocol (supported by _applyGuestProposal)
CG.peer.send('state:propose', { __append_posted: entry });
}
setDraft('');
setReview(null);
setSending(false);
CG.store.set(`room_draft_${seat}`, '');
// Run the pattern watcher in the background (private)
(async () => {
try {
const c = await CG.agents.patternWatcher.watch(seat, [...publicThread, entry]);
if (c) setCallouts(cs => [...cs, c]);
} catch {}
})();
};
// ----- establish-truth flow (right rail) -----
const [truthDraft, setTruthDraft] = useState('');
const truths = shared.truths || [];
const proposeTruth = () => {
const t = truthDraft.trim();
if (!t) return;
const entry = { id: `t-${Date.now()}-${Math.random().toString(36).slice(2,6)}`, text: t, proposedBy: seat, confirmedA: seat === 'A', confirmedB: seat === 'B', ts: Date.now() };
const nextTruths = [...truths, entry];
if (isHost) CG.session.setShared({ truths: nextTruths });
else CG.peer.send('state:propose', { truths: nextTruths, __truth_append: entry });
setTruthDraft('');
};
const confirmTruth = (id) => {
const nextTruths = truths.map(t => t.id === id ? { ...t, [seat === 'A' ? 'confirmedA' : 'confirmedB']: true } : t);
if (isHost) CG.session.setShared({ truths: nextTruths });
else CG.peer.send('state:propose', { truths: nextTruths, __truth_confirm: { id, seat } });
};
return (
{/* Header */}
phase 05 · the room · live
public record. your coach runs every message before it posts.
{/* Three panes */}
{/* LEFT */}
sendPost(draft.trim())}
onSendGentler={() => sendPost((review?.gentler_version || draft).trim())}
callouts={callouts}
dismissCallout={(i) => setCallouts(cs => cs.filter((_, k) => k !== i))}
otherName={otherName}
callout={callout}
calloutLoading={calloutLoading}
calloutError={calloutError}
calloutRefusal={calloutRefusal}
dismissRefusal={() => setCalloutRefusal(null)}
onRequestCallout={requestCallout}
seatSelf={seat}
/>
{/* CENTER */}
{callout && callout.status === 'proposed' && callout.reading && (
)}
{callout && callout.status === 'requesting' && (
mediator · reading the room…
{callout.requestedBy === seat ? 'you asked for a pattern reading.' : `${callout.requestedBy === 'A' ? (names.A || 'host') : (names.B || 'guest')} asked for a pattern reading.`}
)}
{/* RIGHT */}
onPhase && onPhase('apology')}
/>
);
};
// -------- LEFT · private pane --------
function LeftPrivatePane({ seat, draft, setDraft, review, setReview, reviewing, reviewError, onReview, onSendOriginal, onSendGentler, callouts, dismissCallout, otherName, callout, calloutLoading, calloutError, calloutRefusal, dismissRefusal, onRequestCallout, seatSelf }) {
const canRequest = !calloutLoading && (!callout || callout.status === 'committed' || callout.status === 'rejected');
const calloutBusy = callout && (callout.status === 'requesting' || callout.status === 'proposed');
return (
{/* phase 06 · pattern callout trigger */}
pattern callout · mediator
stuck in a loop? ask the mediator to name the pattern. both of you decide whether to accept it.
{calloutError && (
{calloutError}
)}
{calloutRefusal && (
mediator declined · private to you
{calloutRefusal.reason}
)}
{/* pattern-watcher callouts */}
{callouts.length > 0 && (
{callouts.slice().reverse().map((c, i) => (
pattern · {c.severity}
{c.text}
references: "{c.ledger_ref}"
))}
)}
draft · nothing's posted until you choose to send
);
}
// -------- CENTER · public thread --------
function PublicThread({ posted, names, myseat }) {
const entries = posted.slice().sort((a, b) => a.ts - b.ts);
return (
public record · everyone sees this exactly
{entries.length === 0 && (
thread is empty.
)}
);
}
function PostedEntry({ entry, names, myseat }) {
const fromName = entry.from === 'mediator' ? 'mediator'
: entry.from === 'A' ? (names.A || 'host')
: entry.from === 'B' ? (names.B || 'guest')
: entry.from;
const accent = entry.from === 'A' ? '#b94b4b' : entry.from === 'B' ? '#3a78b8' : 'var(--ink-3)';
const isMine = entry.from === myseat;
if (entry.kind === 'opening') {
const who = entry.attribution === 'A' ? (names.A || 'host') : (names.B || 'guest');
return (
);
}
if (entry.kind === 'steelman') {
const author = entry.from === 'A' ? (names.A || 'host') : (names.B || 'guest');
const about = entry.attribution === 'A' ? (names.A || 'host') : (names.B || 'guest');
return (
{author} restated {about} · accepted
heard
{entry.text}
);
}
if (entry.kind === 'pattern') {
const by = entry.requestedBy === 'A' ? (names.A || 'host') : (names.B || 'guest');
return (
pattern · called by {by} · both accepted
{entry.title || '(untitled)'}
{entry.text}
{entry.anchor && (
→ {entry.anchor}
)}
);
}
// kind === 'msg'
return (
{fromName}
{formatTs(entry.ts)}
{entry.text}
);
}
function formatTs(ts) {
try {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch { return ''; }
}
// -------- callout · proposal overlay --------
function CalloutProposalCard({ callout, seat, names, onAck, onReject }) {
const r = callout.reading || {};
const myAck = seat === 'A' ? callout.ackA : callout.ackB;
const theirAck = seat === 'A' ? callout.ackB : callout.ackA;
const theirName = seat === 'A' ? (names.B || 'guest') : (names.A || 'host');
const requester = callout.requestedBy === 'A' ? (names.A || 'host') : (names.B || 'guest');
return (
mediator · proposed pattern reading
requested by {requester}
{r.title || '(untitled)'}
{r.reading}
{r.anchor && (
→ {r.anchor}
)}
both of you have to accept for this to post. declining makes it disappear.
{!myAck ? (
<>
>
) : (
you accepted · waiting for {theirName}
)}
{callout.ackA ? (names.A || 'host') + ' ✓' : (names.A || 'host') + ' ·'}
{' '}
{callout.ackB ? (names.B || 'guest') + ' ✓' : (names.B || 'guest') + ' ·'}
);
}
// -------- RIGHT · truths pane --------
function RightTruthsPane({ truths, seat, onConfirm, truthDraft, setTruthDraft, onPropose, onContinue }) {
const established = truths.filter(t => t.confirmedA && t.confirmedB);
const pending = truths.filter(t => !(t.confirmedA && t.confirmedB));
return (
established truths
the anchor. both of you have to confirm. the mediator can propose, but cannot impose.
{established.length === 0 && (
nothing yet.
)}
{established.map(t => (
{t.text}
✓ both confirmed
))}
{pending.length > 0 && (
<>
pending · awaiting confirm
{pending.map(t => {
const me = seat === 'A' ? t.confirmedA : t.confirmedB;
const them = seat === 'A' ? t.confirmedB : t.confirmedA;
return (
{t.text}
{me ? 'you ✓' : '·'} {them ? '· partner ✓' : ''}
{!me && (
)}
);
})}
>
)}
propose a new truth
);
}
console.log('[phases-room-live] loaded');
})();