// Early phases: Topic lock, Intake, Blind opening + Synthesis, Steel-man
// Depends on: SCENARIO, Icons, ScribbleCheck, InkLine, AgentBadge, useTypewriter, SimBar
function TopicLockPhase({ seat, godView, onPhase }) {
const S = window.SCENARIO;
// Detect mode: live (Claude key + fresh) vs canned
const [, setStatusTick] = React.useState(0);
React.useEffect(() => CG.onStatus(() => setStatusTick(t => t + 1)), []);
// re-render on session state changes (so guest sees host's signature arrive)
const [, setSessionTick] = React.useState(0);
React.useEffect(() => {
if (CG.session && CG.session.onState) {
return CG.session.onState(() => setSessionTick(t => t + 1));
}
}, []);
const liveMode = (CG.hasKey() && CG.getMode() === 'fresh') || !!(CG.session && CG.session.isGuest && CG.session.isGuest());
const mp = liveMode && CG.session && CG.session.isMultiplayer && CG.session.isMultiplayer();
const isHost = !mp || seat === 'A';
const isGuest = mp && seat === 'B';
const sharedTopic = (liveMode && CG.session && CG.session.shared && CG.session.shared.topic) || null;
const [step, setStep] = React.useState(liveMode ? 1 : 1);
const [mayaDraft, setMayaDraft] = React.useState("");
const [choice, setChoice] = React.useState(liveMode ? -1 : 1);
const [customEdit, setCustomEdit] = React.useState("");
const [editingOption, setEditingOption] = React.useState(false);
const [jordanAction, setJordanAction] = React.useState(null);
const [jordanAmendment, setJordanAmendment] = React.useState("");
const [signedA, setSignedA] = React.useState(false);
const [signedB, setSignedB] = React.useState(false);
// sim state (only used when !liveMode)
const [playing, setPlaying] = React.useState(!liveMode);
const [resetKey, setResetKey] = React.useState(0);
const [done, setDone] = React.useState(false);
// Live refiner state
const [refinerLoading, setRefinerLoading] = React.useState(false);
const [refinerError, setRefinerError] = React.useState(null);
const [liveFramings, setLiveFramings] = React.useState(null); // [{kind, text, tradeoff}]
const [streamingFramings, setStreamingFramings] = React.useState(null); // in-flight partial
// Canned fallback framings (from original sim)
const cannedFramings = [
{ kind: 'still-characterization', text: "Does my partner respect my time?", tradeoff: "still a character judgment, not a workable topic" },
{ kind: 'neutral-specific', text: "How should we handle last-minute schedule changes, and what do we owe each other's time?", tradeoff: "specific and workable · recommended" },
{ kind: 'structural-reframe', text: "What's a workable way to flex plans without it feeling like one person is being deprioritized?", tradeoff: "broader · focuses on shared rules, not people" },
];
const framings = liveMode
? (liveFramings || streamingFramings || cannedFramings)
: cannedFramings;
const streaming = liveMode && streamingFramings && !liveFramings;
// Always lay out 3 slots so cards don't jump as framings stream in.
const slotOrder = ['still-characterization', 'neutral-specific', 'structural-reframe'];
const displayFramings = streaming
? slotOrder.map(kind => framings.find(f => f.kind === kind) || { kind, text: '', tradeoff: '', pending: true })
: framings;
const pickedTopic = editingOption && customEdit ? customEdit : (choice >= 0 && displayFramings[choice] ? displayFramings[choice].text : "");
const callRefiner = async () => {
setRefinerError(null);
setRefinerLoading(true);
setStreamingFramings([]); // empty array signals "streaming started"
// Advance to step 2 immediately so the user sees skeleton cards populating,
// rather than sitting on step 1 staring at a button for 8-20s.
setStep(2);
try {
const { framings: f } = await CG.agents.refiner.propose(mayaDraft, {
party: 'A',
onStream: (evt) => {
if (evt.kind === 'framings_partial') {
setStreamingFramings(evt.framings);
} else if (evt.kind === 'tool_stop') {
setStreamingFramings(evt.framings || []);
}
},
});
setLiveFramings(f);
setStreamingFramings(null);
setChoice(1); // default to neutral-specific
} catch (e) {
setRefinerError(e.message);
setStreamingFramings(null);
setStep(1); // send user back to the draft so they can retry
} finally {
setRefinerLoading(false);
}
};
const isA = seat === 'A' || seat === 'god';
const isB = seat === 'B' || seat === 'god';
// Live-mode names: read from session, fall back to host/guest. Canned stays maya/jordan.
const sessionNames = (liveMode && CG.session.getNames) ? CG.session.getNames() : null;
const nameA = liveMode ? ((sessionNames && sessionNames.A) || 'host') : 'maya';
const nameB = liveMode ? ((sessionNames && sessionNames.B) || 'guest') : 'jordan';
const stepDef = [
{ n: 1, label: `${nameA} drafts`, who: 'A' },
{ n: 2, label: "refiner · framings", who: 'A' },
{ n: 3, label: `${nameA} signs`, who: 'A' },
{ n: 4, label: `${nameB} · amends`, who: 'B' },
{ n: 5, label: `${nameB} signs`, who: 'B' },
{ n: 6, label: "locked", who: 'both' },
];
// Typewriter for maya's raw draft during step 1 of sim
const mayaTypedDraft = useTypewriter(playing ? S.topic.draft : "", 300, playing, resetKey);
const jordanTypedAmend = useTypewriter(playing ? "…and what counts as 'last-minute'?" : "", 0, playing && step >= 4, resetKey + ':amend');
// Keep mayaDraft in sync with typed while playing
React.useEffect(() => {
if (playing && step === 1 && !liveMode) setMayaDraft(mayaTypedDraft);
}, [mayaTypedDraft, playing, step, liveMode]);
React.useEffect(() => {
if (playing && step >= 4 && jordanAction === 'amend') setJordanAmendment(jordanTypedAmend);
}, [jordanTypedAmend, playing, step, jordanAction]);
// --- Live MP: drive step + sync picked topic from shared.topic ---
// Host runs steps 1→3 locally, then waits for guest signature at step 3.5 (handled in render).
// Guest waits for host signature, then runs steps 4→5. Both land on step 6 when both signed.
React.useEffect(() => {
if (!mp) return;
if (!sharedTopic) return;
// Mirror host's proposed topic into local pickedTopic derived state.
if (sharedTopic.text && choice === -1 && !customEdit) {
// stash as custom edit so pickedTopic resolves; pick choice 1 as sentinel
setCustomEdit(sharedTopic.text);
setEditingOption(true);
setChoice(1);
}
// Mirror amendment if present
if (sharedTopic.amendment && !jordanAmendment) {
setJordanAmendment(sharedTopic.amendment);
if (!jordanAction) setJordanAction('amend');
}
// Advance/clamp the step based on shared signatures
const bothSigned = !!(sharedTopic.signedA && sharedTopic.signedB);
if (bothSigned) {
setSignedA(true); setSignedB(true); setDone(true);
if (step < 6) setStep(6);
return;
}
if (isGuest) {
// Guest flow: if host hasn't signed, park on step 4 (but render waiting screen).
// If host signed, open step 4 for response; if guest already signed but not host's turn, step 5.
if (sharedTopic.signedA && !sharedTopic.signedB) {
if (step < 4) setStep(4);
}
} else if (isHost) {
// Host flow: if A is signed and B isn't, park on step 3 (render waiting).
if (sharedTopic.signedA && !sharedTopic.signedB) {
setSignedA(true);
if (step < 3) setStep(3);
}
}
}, [mp, sharedTopic, isGuest, isHost]);
// Presence: broadcast sub-phase activity
React.useEffect(() => {
if (!mp) return;
let activity = null;
const sA = sharedTopic && sharedTopic.signedA;
const sB = sharedTopic && sharedTopic.signedB;
if (sA && sB) activity = 'reviewing · locked';
else if (isHost) {
if (!sA) activity = step === 2 ? 'picking framing' : 'drafting topic';
else activity = `waiting on ${nameB}`;
} else if (isGuest) {
if (!sA) activity = `waiting on ${nameA}`;
else activity = step === 5 ? 'signing' : 'reviewing proposal';
}
if (activity) { try { CG.session.setCursor && CG.session.setCursor({ activity }); } catch (e) {} }
}, [mp, isHost, isGuest, step, sharedTopic, nameA, nameB]);
// Reset
const reset = () => {
setStep(1);
setMayaDraft("");
setChoice(1);
setCustomEdit("");
setEditingOption(false);
setJordanAction(null);
setJordanAmendment("");
setSignedA(false);
setSignedB(false);
setDone(false);
setResetKey(k => k + 1);
setPlaying(true);
};
// Sim timeline (canned mode only)
React.useEffect(() => {
if (!playing || liveMode) return;
const timers = [];
const at = (ms, fn) => timers.push(setTimeout(fn, ms));
// step 1: type draft (handled by typewriter) — advance after type done
at(2800, () => setStep(2));
// step 2: highlight choice 1, then choice 2
at(3400, () => setChoice(0));
at(4400, () => setChoice(2));
at(5400, () => setChoice(1));
at(6400, () => setStep(3));
// step 3: maya signs
at(7200, () => setSignedA(true));
at(7800, () => setStep(4));
// step 4: jordan arrives, picks amend
at(8600, () => setJordanAction('amend'));
// amendment types during this step via typewriter
at(11200, () => setStep(5));
// step 5: jordan signs
at(12000, () => setSignedB(true));
at(12600, () => { setStep(6); setDone(true); setPlaying(false); });
return () => timers.forEach(clearTimeout);
}, [playing, resetKey]);
return (
phase 01 · pre-room
lock the topic before the room opens.
a vague opener becomes a fight about everything. a locked, co-signed topic becomes the axis everything else is measured against. both of you have to sign.
{!liveMode && (
setPlaying(true)}
onPause={() => setPlaying(false)}
onReset={reset}
done={done}
label={
step === 1 ? "maya is typing her raw draft…" :
step === 2 ? "refiner proposes three framings…" :
step === 3 ? "maya signs and sends…" :
step === 4 ? "jordan amends with 'what counts as last-minute'…" :
step === 5 ? "jordan signs, room unlocks…" :
"topic is locked · both signed."
}
/>
)}
{/* step rail */}
{stepDef.map((s, i) => {
const active = step === s.n;
const doneStep = step > s.n;
let unlocked = (s.who === 'A' && isA) || (s.who === 'B' && isB) || s.who === 'both';
// In live MP, additionally constrain: host can navigate 1-3 & 6; guest can nav 4-5 & 6.
if (mp) {
if (isHost) unlocked = s.n <= 3 || s.n === 6;
else if (isGuest) unlocked = s.n === 4 || s.n === 5 || s.n === 6;
}
return (
{/* Live MP · guest: park on a waiting view until host signs, and again if host reopens. */}
{mp && isGuest && (!sharedTopic || !sharedTopic.signedA) && (
waiting on {nameA}
{nameA} is drafting the topic.
the host writes a rough version of what this is about, then the refiner proposes three neutral framings.
you'll see their signed proposal here the moment it lands — then it's your turn to accept, amend, or counter-propose.
)}
{/* Live MP · host: after signing, park on a waiting view until guest responds. */}
{mp && isHost && sharedTopic && sharedTopic.signedA && !sharedTopic.signedB && step === 3 && (
sent · waiting on {nameB}
your signed proposal
{sharedTopic.text || pickedTopic}
{nameB} can accept as-is, amend with one appended clarifier, or counter-propose. you'll see their response here.
two rooms. one conversation with yourself, not them.
nothing posted. your coach asks open questions and quietly reads for defensiveness, catastrophizing, willingness to self-reflect. the dossier calibrates how they handle you next. it goes nowhere else.
setPlaying(true)}
onPause={() => setPlaying(false)}
onReset={reset}
done={done}
label={
!done ?
(visibleCount < intakeScript.length
? `coach is interviewing ${party.name.toLowerCase()}… (${visibleCount}/${intakeScript.length})`
: goalRevealed ? "surfacing the good-outcome anchor…" : "wrapping up…")
: (!youSubmitted ? "your intake is ready · submit when you're done"
: !partnerDone ? `waiting on ${otherParty.name.toLowerCase()}…`
: "both intakes in · ready for blind openings")
}
/>
submit blind. the mediator speaks you back in neutral.
neither of you sees the other's draft until both submit. then the mediator rewrites each opening in non-inflammatory language. you sign off on your own synthesis before it posts.
setPlaying(true)}
onPause={() => setPlaying(false)}
onReset={reset}
done={done}
label={
stage === 1 ? (
!mayaSubmitted && !jordanSubmitted ? "both drafting in parallel · firewalled from each other…"
: !bothSubmitted ? `waiting on ${!mayaSubmitted ? 'maya' : 'jordan'}…`
: "both submitted · handing to mediator"
) : stage === 2 ? "mediator is rewriting each opening in non-inflammatory language…"
: stage === 3 ? (
!bothApproved ? "syntheses delivered · each party approves their own…"
: "both approved · posting to the room"
) : "openings posted · ready for steel-man gate"
}
/>
{/* Top status strip: both submission states */}
);
}
function SteelmanPhase({ seat, godView }) {
const S = window.SCENARIO;
const [attempt, setAttempt] = React.useState(
"When you change plans last-minute, you're not doing it to hurt me — you're doing it because real things have come up and you'd rather be honest than show up badly. What's been making that worse is my response afterward: a long quiet period that reads to you as a verdict on your character, not space to cool off. You need to be able to flex without me treating every adjustment as evidence you don't value us."
);
const [submitted, setSubmitted] = React.useState(false);
const [rating, setRating] = React.useState(0);
const [note, setNote] = React.useState("");
const isA = seat === 'A' || seat === 'god';
const isB = seat === 'B' || seat === 'god';
return (
phase 04 · steel-man gate
restate the other's position. they grade you. nobody moves until they say yes.
most fights are two people arguing with their imagined version of the other. this mechanic alone resolves a lot of them.
maya restates jordan · attempt {submitted ? 2 : 1}
why this gate is before the room · without steel-manning, the rebuttal round devolves. once each party has confirmed the other got them, disagreement stops feeling like misunderstanding and becomes workable.