setup flow: the dead dropdowns, save-never-sends, the pill hover
- The setup pickers opened invisible: React attaches a host element's ref AFTER descendants' layout effects, so MenuShell's mount-time place() measured a null anchor and gave up — the menu stayed hidden until a window resize re-ran place(). The stage-2 slot and thinking menus now anchor by anchorTestid (querySelector at place time, the settings-menu pattern) instead of the conditionally-attached triggerRef. - Saving models NEVER auto-sends the queued message (owner ruling, 2026-10-09): the draft waits in the composer and the learner sends it. The replay/draft-send calls removed from onDone in new-session, home, and chat; the dead force machinery removed from send/start/begin. - The setup CTA pills get the app-wide hover language (hover:bg-accent + cursor-pointer) — hover:bg-muted was invisible because --muted and --card are the same #1c1c1c.
This commit is contained in:
+11
-14
@@ -561,7 +561,8 @@ export function Chat({
|
|||||||
});
|
});
|
||||||
const [setupOpen, setSetupOpen] = useState(false);
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
/** The last explicitly-sent draft (composer send): the
|
/** 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
|
* Auto-continuations (probe answers) never set it — their refusal
|
||||||
* opens the dialog with no replay. */
|
* opens the dialog with no replay. */
|
||||||
const draftRef = useRef<string | null>(null);
|
const draftRef = useRef<string | null>(null);
|
||||||
@@ -573,7 +574,8 @@ export function Chat({
|
|||||||
// optimistic message is an orphan that goes HOME: refetch the record,
|
// optimistic message is an orphan that goes HOME: refetch the record,
|
||||||
// drop the orphan, put the draft back in the composer, open the
|
// drop the orphan, put the draft back in the composer, open the
|
||||||
// dialog (the prefilled tiers carry their picks, the unpinned ones 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(() => {
|
useEffect(() => {
|
||||||
if (!error) return;
|
if (!error) return;
|
||||||
const missing = parseModelsRefusal(error.message);
|
const missing = parseModelsRefusal(error.message);
|
||||||
@@ -1589,7 +1591,7 @@ export function Chat({
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="setup-cta"
|
data-testid="setup-cta"
|
||||||
onClick={() => setSetupOpen(true)}
|
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
|
Set up your models
|
||||||
</button>
|
</button>
|
||||||
@@ -1659,23 +1661,18 @@ export function Chat({
|
|||||||
</SessionMermaidContext.Provider>
|
</SessionMermaidContext.Provider>
|
||||||
{/* The BYOK setup flow (owner, 2026-09-22): one mount serves the
|
{/* The BYOK setup flow (owner, 2026-09-22): one mount serves the
|
||||||
persistent CTA, the refused-send interception, and the job cards'
|
persistent CTA, the refused-send interception, and the job cards'
|
||||||
deep-link. Save replays the preserved draft automatically; Esc
|
deep-link. Save keeps the draft in the composer — it never sends
|
||||||
keeps it in the composer (the dialog is closable by design — the
|
(owner ruling 2026-10-09); Esc also keeps it (the dialog is
|
||||||
gate is the server, not this modal). */}
|
closable by design — the gate is the server, not this modal). */}
|
||||||
<SetupDialog
|
<SetupDialog
|
||||||
open={setupOpen}
|
open={setupOpen}
|
||||||
onClose={() => setSetupOpen(false)}
|
onClose={() => setSetupOpen(false)}
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
setSetupOpen(false);
|
setSetupOpen(false);
|
||||||
setConfigured(true);
|
setConfigured(true);
|
||||||
// Draft-preserving replay (owner ruling): the draft went home
|
// Save only saves (owner ruling 2026-10-09): the draft went
|
||||||
// into the composer at refusal time; Save sends it onward.
|
// home into the composer at refusal time and STAYS there —
|
||||||
const t = text.trim();
|
// the learner sends it themselves.
|
||||||
if (t) {
|
|
||||||
applyText("");
|
|
||||||
draftRef.current = t;
|
|
||||||
void send({ type: "text", text: t });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ChatActionsContext.Provider>
|
</ChatActionsContext.Provider>
|
||||||
|
|||||||
+13
-15
@@ -102,21 +102,19 @@ export function Home({
|
|||||||
* the run, let the last finals land, THEN read the REF mirror (not the
|
* 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
|
* state closure) and run. Applies before the setup/key guards too, so
|
||||||
* an intercepted draft is the complete one. */
|
* an intercepted draft is the complete one. */
|
||||||
const start = (force = false) => {
|
const start = () => {
|
||||||
if (!dictation.active) {
|
if (!dictation.active) {
|
||||||
void begin(force);
|
void begin();
|
||||||
return;
|
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();
|
const t = topicRef.current.trim();
|
||||||
if (!t || busy) return;
|
if (!t || busy) return;
|
||||||
// Unconfigured: the send INTERCEPTS into the setup flow (the draft
|
// Unconfigured: the send INTERCEPTS into the setup flow (the draft
|
||||||
// stays in the textarea; Save completes and this re-runs). `force`
|
// stays in the textarea; the learner sends it after Save).
|
||||||
// is the onDone path — the state this closure captured is still
|
if (!configured) {
|
||||||
// the pre-save `configured`, the state update hasn't landed yet.
|
|
||||||
if (!configured && !force) {
|
|
||||||
setSetupOpen(true);
|
setSetupOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -125,9 +123,9 @@ export function Home({
|
|||||||
// refused first send would otherwise leave an orphan error-shell
|
// refused first send would otherwise leave an orphan error-shell
|
||||||
// chat in the sidebar (the 503 lands only after POST /api/chats
|
// chat in the sidebar (the 503 lands only after POST /api/chats
|
||||||
// already created the record). Same skip-everything-and-open-setup
|
// already created the record). Same skip-everything-and-open-setup
|
||||||
// treatment; `force` (setup's onDone replay) bypasses, and the
|
// treatment; the server's 503 gate remains the belt for /chat/[id]
|
||||||
// server's 503 gate remains the belt for /chat/[id] sessions.
|
// sessions.
|
||||||
if (!force && (zeroKeys || strongKeyless)) {
|
if (zeroKeys || strongKeyless) {
|
||||||
setSetupOpen(true);
|
setSetupOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -260,7 +258,7 @@ export function Home({
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="setup-cta"
|
data-testid="setup-cta"
|
||||||
onClick={() => setSetupOpen(true)}
|
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
|
Set up your models — chats start after the three picks
|
||||||
</button>
|
</button>
|
||||||
@@ -346,15 +344,15 @@ export function Home({
|
|||||||
|
|
||||||
{/* First-run setup (unconfigured): the CTA's target and the send
|
{/* First-run setup (unconfigured): the CTA's target and the send
|
||||||
interception's landing surface — stage 1 is the self-contained
|
interception's landing surface — stage 1 is the self-contained
|
||||||
first-key screen (zero-keys wave); onDone replays the
|
first-key screen (zero-keys wave); Save never sends — the
|
||||||
intercepted draft. */}
|
draft stays for the learner to send (owner 2026-10-09). */}
|
||||||
<SetupDialog
|
<SetupDialog
|
||||||
open={setupOpen}
|
open={setupOpen}
|
||||||
onClose={() => setSetupOpen(false)}
|
onClose={() => setSetupOpen(false)}
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
setConfigured(true);
|
setConfigured(true);
|
||||||
setSetupOpen(false);
|
setSetupOpen(false);
|
||||||
void start(true); // the queued draft sends
|
// Save only saves: the draft waits in the composer.
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+10
-11
@@ -301,7 +301,8 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
|||||||
// First-run gate (model-slots wave, owner 2026-09-20): the send
|
// First-run gate (model-slots wave, owner 2026-09-20): the send
|
||||||
// INTERCEPTS into the setup flow while unconfigured — no defaults for
|
// INTERCEPTS into the setup flow while unconfigured — no defaults for
|
||||||
// 33 providers, chats cannot start. The draft stays in the textarea;
|
// 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);
|
const [configured, setConfigured] = useState(initialConfigured);
|
||||||
// The zero-keys truth (zero-keys wave): no provider key = the pill
|
// The zero-keys truth (zero-keys wave): no provider key = the pill
|
||||||
// takes precedence over the models CTA (keys come first in the chain).
|
// 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 strongKeyless = useStrongKeyless();
|
||||||
const [setupOpen, setSetupOpen] = useState(false);
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
|
|
||||||
const send = useCallback((force = false) => {
|
const send = useCallback(() => {
|
||||||
const draft = textRef.current.trim();
|
const draft = textRef.current.trim();
|
||||||
if (!draft || busy) return;
|
if (!draft || busy) return;
|
||||||
// `force` is the onDone path — the captured `configured` is still
|
if (!configured) {
|
||||||
// the pre-save value; the state update hasn't landed yet.
|
|
||||||
if (!configured && !force) {
|
|
||||||
setSetupOpen(true);
|
setSetupOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The pre-send KEY guards (owner interlude, no-orphan-chats): guard
|
// The pre-send KEY guards (owner interlude, no-orphan-chats): guard
|
||||||
// BEFORE startDraftSession creates the record — a refused first
|
// BEFORE startDraftSession creates the record — a refused first
|
||||||
// send would otherwise leave an orphan error-shell chat in the
|
// send would otherwise leave an orphan error-shell chat in the
|
||||||
// sidebar. Same skip-everything-and-open-setup treatment; `force`
|
// sidebar. Same skip-everything-and-open-setup treatment; the
|
||||||
// (setup's onDone replay) bypasses, and the server's 503 gate
|
// server's 503 gate remains the belt for /chat/[id] sessions.
|
||||||
// remains the belt for /chat/[id] sessions.
|
if (zeroKeys || strongKeyless) {
|
||||||
if (!force && (zeroKeys || strongKeyless)) {
|
|
||||||
setSetupOpen(true);
|
setSetupOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -386,7 +384,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="setup-cta"
|
data-testid="setup-cta"
|
||||||
onClick={() => setSetupOpen(true)}
|
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
|
Set up your models — chats start after the three picks
|
||||||
</button>
|
</button>
|
||||||
@@ -403,7 +401,8 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
|||||||
onDone={() => {
|
onDone={() => {
|
||||||
setConfigured(true);
|
setConfigured(true);
|
||||||
setSetupOpen(false);
|
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.
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function ZeroKeysPill({ onOpen }: { onOpen: () => void }) {
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="setup-cta-keys"
|
data-testid="setup-cta-keys"
|
||||||
onClick={onOpen}
|
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
|
Add a provider key — chats start after one unlocks the models
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+22
-19
@@ -6,8 +6,9 @@
|
|||||||
* providers. The graph view's new-session dock (the app's home, where
|
* providers. The graph view's new-session dock (the app's home, where
|
||||||
* chats start) shows a "Set up your models" CTA while unconfigured;
|
* chats start) shows a "Set up your models" CTA while unconfigured;
|
||||||
* submitting a topic — or sending on /chat/new — opens THIS dialog with
|
* submitting a topic — or sending on /chat/new — opens THIS dialog with
|
||||||
* the draft preserved; Save stores the slots and the queued message
|
* the draft preserved; Save stores the slots — the draft WAITS in the
|
||||||
* sends.
|
* 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 —
|
* 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
|
* there's no point presenting the model picker before the user has set a
|
||||||
@@ -216,8 +217,8 @@ export function SetupKeysBody({
|
|||||||
export function SetupDialog({
|
export function SetupDialog({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
/** Saved: the models section landed; the caller marks configured and
|
/** Saved: the models section landed; the caller marks configured (the
|
||||||
* replays the intercepted draft. */
|
* draft stays put — save never sends). */
|
||||||
onDone,
|
onDone,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -243,14 +244,10 @@ export function SetupDialog({
|
|||||||
const [keySaving, setKeySaving] = useState(false);
|
const [keySaving, setKeySaving] = useState(false);
|
||||||
const [keyError, setKeyError] = useState<string | null>(null);
|
const [keyError, setKeyError] = useState<string | null>(null);
|
||||||
const [keyMenuOpen, setKeyMenuOpen] = useState(false);
|
const [keyMenuOpen, setKeyMenuOpen] = useState(false);
|
||||||
/** The open menu's trigger wrapper — the shared shell's anchor (fix
|
// Stage 1's provider-picker trigger wrapper — the shared shell's anchor.
|
||||||
* wave chunk B): one menu open at a time, so one ref suffices, and
|
// Declared with the other hooks: the early `if (!open) return null` sits
|
||||||
* the menu PORTALS out of this dialog's overflow-hidden frame — it
|
// below them all. (Stage 2's slot menus anchor by anchorTestid instead —
|
||||||
* extends beyond the dialog exactly like the settings menus do. */
|
// see the slot MenuShell below for why a ref cannot work there.)
|
||||||
const triggerRef = useRef<HTMLDivElement>(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.
|
|
||||||
const keyTriggerRef = useRef<HTMLDivElement>(null);
|
const keyTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// The keys signal (zero-keys wave): keys-section saves/deletes AND
|
// The keys signal (zero-keys wave): keys-section saves/deletes AND
|
||||||
@@ -469,7 +466,7 @@ export function SetupDialog({
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
|
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div ref={openMenu?.slot === slot && openMenu.kind === "model" ? triggerRef : undefined} className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`${SLOT_META[slot].label} model`}
|
aria-label={`${SLOT_META[slot].label} model`}
|
||||||
@@ -483,9 +480,18 @@ export function SetupDialog({
|
|||||||
▾
|
▾
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/* 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" && (
|
{openMenu?.slot === slot && openMenu.kind === "model" && (
|
||||||
<MenuShell
|
<MenuShell
|
||||||
anchorRef={triggerRef}
|
anchorTestid={`setup-trigger-${slot}`}
|
||||||
testid={`setup-menu-${slot}`}
|
testid={`setup-menu-${slot}`}
|
||||||
preferredSide="down"
|
preferredSide="down"
|
||||||
align="left"
|
align="left"
|
||||||
@@ -514,10 +520,7 @@ export function SetupDialog({
|
|||||||
</div>
|
</div>
|
||||||
{/* Thinking selector (chunk 2): editable, pre-populated from
|
{/* Thinking selector (chunk 2): editable, pre-populated from
|
||||||
the ordering per picked model — no model, no levels. */}
|
the ordering per picked model — no model, no levels. */}
|
||||||
<div
|
<div className="relative">
|
||||||
ref={openMenu?.slot === slot && openMenu.kind === "thinking" ? triggerRef : undefined}
|
|
||||||
className="relative"
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`${SLOT_META[slot].label} thinking`}
|
aria-label={`${SLOT_META[slot].label} thinking`}
|
||||||
@@ -536,7 +539,7 @@ export function SetupDialog({
|
|||||||
</button>
|
</button>
|
||||||
{openMenu?.slot === slot && openMenu.kind === "thinking" && (
|
{openMenu?.slot === slot && openMenu.kind === "thinking" && (
|
||||||
<MenuShell
|
<MenuShell
|
||||||
anchorRef={triggerRef}
|
anchorTestid={`setup-thinking-${slot}`}
|
||||||
testid={`setup-thinking-menu-${slot}`}
|
testid={`setup-thinking-menu-${slot}`}
|
||||||
preferredSide="down"
|
preferredSide="down"
|
||||||
align="left"
|
align="left"
|
||||||
|
|||||||
Reference in New Issue
Block a user