diff --git a/components/chat.tsx b/components/chat.tsx index 3693de9..50b52cd 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -561,7 +561,8 @@ export function Chat({ }); const [setupOpen, setSetupOpen] = useState(false); /** The last explicitly-sent draft (composer send): the - * refusal flow restores it into the composer and Save replays it. + * refusal flow restores it into the composer, where it waits for the + * learner to re-send (Save never auto-sends, owner 2026-10-09). * Auto-continuations (probe answers) never set it — their refusal * opens the dialog with no replay. */ const draftRef = useRef(null); @@ -573,7 +574,8 @@ export function Chat({ // optimistic message is an orphan that goes HOME: refetch the record, // drop the orphan, put the draft back in the composer, open the // dialog (the prefilled tiers carry their picks, the unpinned ones the - // "needed" marks). Esc keeps the draft; Save replays it. + // "needed" marks). The draft stays in the composer until the learner + // re-sends it (owner ruling 2026-10-09). useEffect(() => { if (!error) return; const missing = parseModelsRefusal(error.message); @@ -1589,7 +1591,7 @@ export function Chat({ type="button" data-testid="setup-cta" onClick={() => setSetupOpen(true)} - className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50" + className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer" > Set up your models @@ -1659,23 +1661,18 @@ export function Chat({ {/* The BYOK setup flow (owner, 2026-09-22): one mount serves the persistent CTA, the refused-send interception, and the job cards' - deep-link. Save replays the preserved draft automatically; Esc - keeps it in the composer (the dialog is closable by design — the - gate is the server, not this modal). */} + deep-link. Save keeps the draft in the composer — it never sends + (owner ruling 2026-10-09); Esc also keeps it (the dialog is + closable by design — the gate is the server, not this modal). */} setSetupOpen(false)} onDone={() => { setSetupOpen(false); setConfigured(true); - // Draft-preserving replay (owner ruling): the draft went home - // into the composer at refusal time; Save sends it onward. - const t = text.trim(); - if (t) { - applyText(""); - draftRef.current = t; - void send({ type: "text", text: t }); - } + // Save only saves (owner ruling 2026-10-09): the draft went + // home into the composer at refusal time and STAYS there — + // the learner sends it themselves. }} /> diff --git a/components/home.tsx b/components/home.tsx index a6fb5ce..1e77872 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -102,21 +102,19 @@ export function Home({ * the run, let the last finals land, THEN read the REF mirror (not the * state closure) and run. Applies before the setup/key guards too, so * an intercepted draft is the complete one. */ - const start = (force = false) => { + const start = () => { if (!dictation.active) { - void begin(force); + void begin(); return; } - void dictation.finish().then(() => begin(force)); + void dictation.finish().then(() => begin()); }; - const begin = async (force = false) => { + const begin = async () => { const t = topicRef.current.trim(); if (!t || busy) return; // Unconfigured: the send INTERCEPTS into the setup flow (the draft - // stays in the textarea; Save completes and this re-runs). `force` - // is the onDone path — the state this closure captured is still - // the pre-save `configured`, the state update hasn't landed yet. - if (!configured && !force) { + // stays in the textarea; the learner sends it after Save). + if (!configured) { setSetupOpen(true); return; } @@ -125,9 +123,9 @@ export function Home({ // refused first send would otherwise leave an orphan error-shell // chat in the sidebar (the 503 lands only after POST /api/chats // already created the record). Same skip-everything-and-open-setup - // treatment; `force` (setup's onDone replay) bypasses, and the - // server's 503 gate remains the belt for /chat/[id] sessions. - if (!force && (zeroKeys || strongKeyless)) { + // treatment; the server's 503 gate remains the belt for /chat/[id] + // sessions. + if (zeroKeys || strongKeyless) { setSetupOpen(true); return; } @@ -260,7 +258,7 @@ export function Home({ type="button" data-testid="setup-cta" onClick={() => setSetupOpen(true)} - className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50" + className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer" > Set up your models — chats start after the three picks @@ -346,15 +344,15 @@ export function Home({ {/* First-run setup (unconfigured): the CTA's target and the send interception's landing surface — stage 1 is the self-contained - first-key screen (zero-keys wave); onDone replays the - intercepted draft. */} + first-key screen (zero-keys wave); Save never sends — the + draft stays for the learner to send (owner 2026-10-09). */} setSetupOpen(false)} onDone={() => { setConfigured(true); setSetupOpen(false); - void start(true); // the queued draft sends + // Save only saves: the draft waits in the composer. }} /> diff --git a/components/new-session.tsx b/components/new-session.tsx index b9051e4..c29ab4b 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -301,7 +301,8 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi // First-run gate (model-slots wave, owner 2026-09-20): the send // INTERCEPTS into the setup flow while unconfigured — no defaults for // 33 providers, chats cannot start. The draft stays in the textarea; - // Save completes and the send replays. + // Save stores the slots and never sends (owner ruling 2026-10-09) — + // the learner sends the draft themselves. const [configured, setConfigured] = useState(initialConfigured); // The zero-keys truth (zero-keys wave): no provider key = the pill // takes precedence over the models CTA (keys come first in the chain). @@ -312,22 +313,19 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi const strongKeyless = useStrongKeyless(); const [setupOpen, setSetupOpen] = useState(false); - const send = useCallback((force = false) => { + const send = useCallback(() => { const draft = textRef.current.trim(); if (!draft || busy) return; - // `force` is the onDone path — the captured `configured` is still - // the pre-save value; the state update hasn't landed yet. - if (!configured && !force) { + if (!configured) { setSetupOpen(true); return; } // The pre-send KEY guards (owner interlude, no-orphan-chats): guard // BEFORE startDraftSession creates the record — a refused first // send would otherwise leave an orphan error-shell chat in the - // sidebar. Same skip-everything-and-open-setup treatment; `force` - // (setup's onDone replay) bypasses, and the server's 503 gate - // remains the belt for /chat/[id] sessions. - if (!force && (zeroKeys || strongKeyless)) { + // sidebar. Same skip-everything-and-open-setup treatment; the + // server's 503 gate remains the belt for /chat/[id] sessions. + if (zeroKeys || strongKeyless) { setSetupOpen(true); return; } @@ -386,7 +384,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi type="button" data-testid="setup-cta" onClick={() => setSetupOpen(true)} - className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50" + className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer" > Set up your models — chats start after the three picks @@ -403,7 +401,8 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi onDone={() => { setConfigured(true); setSetupOpen(false); - send(true); // the queued draft sends + // Save only saves (owner ruling 2026-10-09): the draft + // waits in the composer; the learner sends it. }} /> diff --git a/components/setup-cta.tsx b/components/setup-cta.tsx index dcf32c0..efc174d 100644 --- a/components/setup-cta.tsx +++ b/components/setup-cta.tsx @@ -68,7 +68,7 @@ export function ZeroKeysPill({ onOpen }: { onOpen: () => void }) { type="button" data-testid="setup-cta-keys" onClick={onOpen} - className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50" + className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer" > Add a provider key — chats start after one unlocks the models diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 4d8a489..aeeb615 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -6,8 +6,9 @@ * providers. The graph view's new-session dock (the app's home, where * chats start) shows a "Set up your models" CTA while unconfigured; * submitting a topic — or sending on /chat/new — opens THIS dialog with - * the draft preserved; Save stores the slots and the queued message - * sends. + * the draft preserved; Save stores the slots — the draft WAITS in the + * composer, the learner sends it themselves (owner ruling 2026-10-09: + * saving models must never send the message). * * STAGED BY DEPENDENCY (owner, 2026-09-20: "first keys, then models — * there's no point presenting the model picker before the user has set a @@ -216,8 +217,8 @@ export function SetupKeysBody({ export function SetupDialog({ open, onClose, - /** Saved: the models section landed; the caller marks configured and - * replays the intercepted draft. */ + /** Saved: the models section landed; the caller marks configured (the + * draft stays put — save never sends). */ onDone, }: { open: boolean; @@ -243,14 +244,10 @@ export function SetupDialog({ const [keySaving, setKeySaving] = useState(false); const [keyError, setKeyError] = useState(null); const [keyMenuOpen, setKeyMenuOpen] = useState(false); - /** The open menu's trigger wrapper — the shared shell's anchor (fix - * wave chunk B): one menu open at a time, so one ref suffices, and - * the menu PORTALS out of this dialog's overflow-hidden frame — it - * extends beyond the dialog exactly like the settings menus do. */ - const triggerRef = useRef(null); - // Stage 1's provider-picker trigger wrapper — the shared shell's anchor - // (the slot menus use the same single-ref pattern). Declared with the - // other hooks: the early `if (!open) return null` sits below them all. + // Stage 1's provider-picker trigger wrapper — the shared shell's anchor. + // Declared with the other hooks: the early `if (!open) return null` sits + // below them all. (Stage 2's slot menus anchor by anchorTestid instead — + // see the slot MenuShell below for why a ref cannot work there.) const keyTriggerRef = useRef(null); // The keys signal (zero-keys wave): keys-section saves/deletes AND @@ -469,7 +466,7 @@ export function SetupDialog({ {SLOT_META[slot].roster}
-
+
+ {/* Anchor by TESTID, not a ref (the settings-section pattern): + React attaches a host element's ref AFTER its descendants' + layout effects run, so a ref freshly attached in this same + commit (menu opening = the ref condition turning true) is + still null when MenuShell measures — place() gives up, the + menu stays invisible, and only a window resize (which re-runs + place()) shows it. querySelector at place time has no such + ordering hazard. Live-caught by the owner 2026-10-09: every + setup picker opened dead until a window resize. */} {openMenu?.slot === slot && openMenu.kind === "model" && ( {/* Thinking selector (chunk 2): editable, pre-populated from the ordering per picked model — no model, no levels. */} -
+