// 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 (
{ if (unlocked && !playing) setStep(s.n); }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 10px', cursor: unlocked && !playing ? 'pointer' : 'default', opacity: unlocked ? 1 : 0.4, background: active ? 'var(--paper-2)' : 'transparent', border: active ? '1px solid var(--ink)' : '1px solid transparent', }} >
{s.n}
{s.label}
{i < stepDef.length - 1 &&
} ); })}
{/* 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.

signed · {nameA}
)} {/* Steps 1-3: only render the authoring UI for host (solo = always host). */} {(!mp || isHost) && !(mp && isHost && sharedTopic && sharedTopic.signedA && !sharedTopic.signedB && step === 3) && step === 1 && (
{nameA}'s draft · private to coach · not yet sent

say it however you need to — raw, half-formed, unfair. this will not be shown to {nameB}.