diff --git a/components/chat.tsx b/components/chat.tsx index 531a619..b3fe6a7 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -37,6 +37,8 @@ import { SidebarToggle } from "@/components/sidebar"; import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage, TurnUsage } from "@/src/core"; import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider"; import { findPendingToolCall, phaseAt3, phaseTimeline, streamingToolActivity } from "@/src/core"; +import { slotsConfigured, type ModelsSettings } from "@/src/core"; +import { useSettingsSaved } from "@/components/settings-signal"; /** One width for everything: transcript, cards, and the bottom blob share this column. */ /** @@ -300,6 +302,16 @@ export function Chat({ // refusal interception share one SetupDialog mount. `configured` is // seeded server-side (slotsConfigured on the record) and flips on Save. const [configured, setConfigured] = useState(initialConfigured); + // Cross-surface freshness (fix wave): another surface's models write — + // setup save, composer pick, a settings write — re-reads the record so + // the setup CTA appears/disappears live (it used to lag until reload). + useSettingsSaved((section) => { + if (section !== "models") return; + fetch("/api/settings") + .then((r) => r.json() as Promise<{ settings?: { models?: ModelsSettings } | null } | null>) + .then((body) => setConfigured(slotsConfigured(body?.settings?.models))) + .catch(() => {}); + }); const [setupOpen, setSetupOpen] = useState(false); const { open: openSettings } = useSettingsDialog(); /** The last explicitly-sent draft (composer send, verdict note): the diff --git a/components/menu-shell.tsx b/components/menu-shell.tsx index 5cd7207..e0ab0c9 100644 --- a/components/menu-shell.tsx +++ b/components/menu-shell.tsx @@ -1,6 +1,6 @@ "use client"; -import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react"; import { createPortal } from "react-dom"; /** @@ -43,6 +43,16 @@ const CAP_VH = 0.6; // 60vh — the graph pattern's cap const MIN_USABLE = 96; // floor: a shorter cap is an unusable sliver; the // cap then overflows the edge instead of crushing the menu +/** A MenuShell mounted = a dropdown open (fix wave, owner ruling: + * the settings dialog's content column locks while any menu is down — + * no scrolling under a dropdown). The shell stays placement-only: it + * just ANNOUNCES, surfaces decide what to do with the fact (the + * settings dialog locks; anything un-lockable keeps the re-place-on- + * scroll fallback below). Count-based: overlapping menus open/close in + * any order and the accounting stays balanced. */ +export const MENU_OPEN_EVENT = "lh:menu-open"; +export const MENU_CLOSE_EVENT = "lh:menu-close"; + export type MenuSide = "up" | "down"; export type MenuAlign = "left" | "right"; @@ -116,6 +126,14 @@ export function MenuShell({ const menuRef = useRef(null); const [placed, setPlaced] = useState(null); + // Announce open↔close for lockable surfaces (see MENU_OPEN_EVENT). + useEffect(() => { + window.dispatchEvent(new CustomEvent(MENU_OPEN_EVENT)); + return () => { + window.dispatchEvent(new CustomEvent(MENU_CLOSE_EVENT)); + }; + }, []); + const anchor = () => { if (anchorRef) return anchorRef.current; if (anchorTestid !== undefined) return document.querySelector(`[data-testid="${anchorTestid}"]`); diff --git a/components/model-picker.tsx b/components/model-picker.tsx index dfa182c..e2f6f34 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -7,6 +7,7 @@ import { prettyModel } from "@/components/usage-format"; import { ModelMenuList, ThinkingMenuList } from "@/components/settings/model-menu"; import { composeSlotModel } from "@/components/settings/agent-section"; import { MenuShell } from "@/components/menu-shell"; +import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; /** GET /api/models is cached per page load for instant menu opens, but * the federation is LIVE per call — keys saved mid-session widen the @@ -94,21 +95,28 @@ export function ModelControl() { const [error, setError] = useState(null); const containerRef = useRef(null); - useEffect(() => { - let alive = true; + // The record read — BOTH mount and the cross-surface signal land + // here (fix wave: another surface's models write re-reads into the + // pill live; own writes also notify, and the refetch is idempotent). + const readRecord = useCallback(() => { fetch("/api/settings") .then((r) => r.json() as Promise<{ settings: { models?: ModelsSettings } | null }>) - .then((body) => alive && setModels(body.settings?.models ?? undefined)) + .then((body) => setModels(body.settings?.models ?? undefined)) .catch(() => { /* the record fetch failing leaves the control read-only ("—" triggers, closed menus) — same posture as a catalog failure */ }); - return () => { - alive = false; - }; }, []); + useEffect(() => { + readRecord(); + }, [readRecord]); + + useSettingsSaved((section) => { + if (section === "models") readRecord(); + }); + const override = models?.strong; const effectiveId = override?.model?.id; const effectiveProvider = override?.model?.provider; @@ -119,13 +127,19 @@ export function ModelControl() { const levels = entry?.supportedLevels ?? []; // The intuitive dropdown contract (owner, stats-control round 1): any - // click outside the open menu — or an Esc keypress — closes it. Only the - // menu's own surface keeps it open (the toggle buttons live inside the - // same container and handle their own toggling in onClick). + // click outside the open menu — or an Esc keypress — closes it. The + // menu's own surface keeps it open: the toggle buttons live inside the + // container, and the menu BODY lives in a portal on document.body + // (MenuShell) — never a container descendant, so its rows are carved + // out via [data-lh-menu] (the settings sections' discipline; without + // it a row's pointerdown counts as outside and eats the pick — + // live-caught in the fix wave, broken since the MenuShell migration). useEffect(() => { if (open === null) return; const onPointerDown = (e: PointerEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + const t = e.target as Element; + if (t instanceof Element && t.closest("[data-lh-menu]")) return; + if (containerRef.current && !containerRef.current.contains(t)) { setOpen(null); } }; @@ -164,6 +178,7 @@ export function ModelControl() { } setModels(next); setOpen(null); + notifySettingsSaved("models"); } catch { setError("Could not reach the server — try again."); } diff --git a/components/settings-signal.ts b/components/settings-signal.ts new file mode 100644 index 0000000..0d8a5ad --- /dev/null +++ b/components/settings-signal.ts @@ -0,0 +1,47 @@ +"use client"; + +/** + * THE cross-surface settings signal (fix wave, owner ruling 2026-09-22): + * every WRITER dispatches after a successful PUT, every READER re-reads — + * the composer reflects the strong slot the instant setup saves, the + * settings section reflects the composer's pick on its next mount... and + * so on, by construction. CustomEvent on the window, the lh:chat-renamed + * pattern: writers and readers live in different component subtrees + * (portaled dialogs included), and the app already speaks this dialect. + * + * The event carries the section name so ONE event serves all sections — + * readers filter on what they actually read. + */ +import { useEffect, useRef } from "react"; + +export const SETTINGS_SAVED_EVENT = "lh:settings-saved"; + +/** The settings sections a writer can land (the write seam's union). */ +export type SettingsSection = "general" | "agent" | "models" | "appearance" | "graph"; + +/** Writers call this after the PUT resolves ok — never before (a failed + * write notifies nothing; the record is unchanged). */ +export function notifySettingsSaved(section: SettingsSection): void { + window.dispatchEvent( + new CustomEvent(SETTINGS_SAVED_EVENT, { detail: { section } }), + ); +} + +/** Non-hook listener (imperative seams, tests). Returns an unsubscribe. */ +export function onSettingsSaved(handler: (section: SettingsSection) => void): () => void { + const listener = (e: Event) => { + const section = (e as CustomEvent<{ section?: SettingsSection }>).detail?.section; + if (section !== undefined) handler(section); + }; + window.addEventListener(SETTINGS_SAVED_EVENT, listener); + return () => window.removeEventListener(SETTINGS_SAVED_EVENT, listener); +} + +/** Reader subscription: re-invokes whenever a writer lands, with the + * handler captured through a ref so callers never churn the + * subscription (and never stale-close over it either). */ +export function useSettingsSaved(handler: (section: SettingsSection) => void): void { + const saved = useRef(handler); + saved.current = handler; + useEffect(() => onSettingsSaved((section) => saved.current(section)), []); +} \ No newline at end of file diff --git a/components/settings/agent-section.tsx b/components/settings/agent-section.tsx index 68dd1b9..da5bca0 100644 --- a/components/settings/agent-section.tsx +++ b/components/settings/agent-section.tsx @@ -41,8 +41,9 @@ * section's PUT; text writes through the segment route. */ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { MenuShell } from "@/components/menu-shell"; +import { useSettingsSaved } from "@/components/settings-signal"; import type React from "react"; import { MODEL_SLOTS, @@ -253,8 +254,10 @@ export function AgentSection({ // The catalog (federated: keyed providers only), the resolved slots, // the prompt segments, and the search engines' key presence — one - // fetch each per section mount. - useEffect(() => { + // fetch each per section mount. The models read ALSO re-runs on the + // cross-surface signal (fix wave: another surface's write re-reads + // into the slot rows live). + const readModels = useCallback(() => { fetch("/api/models") .then((r) => r.json()) .then((body: ModelsBody) => { @@ -262,6 +265,14 @@ export function AgentSection({ setSlots(body.slots); }) .catch(() => setCatalog(null)); + }, []); + + useSettingsSaved((section) => { + if (section === "models") readModels(); + }); + + useEffect(() => { + readModels(); fetch("/api/settings/prompts") .then((r) => r.json()) .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)) @@ -346,27 +357,7 @@ export function AgentSection({ .then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body)); } - /** The "—" row — THINKING menus only: clears the thinking pin (the - * slot keeps its model; the tier's resolved budget takes over). Model - * menus carry no such row — unpicking a model would re-arm the - * first-run gate, and a "default" model is not a thing in this app - * (owner, fix wave: no factory fallback is ever presented as a - * choice; an unpicked slot just reads "—"). */ - const noneRow = (active: boolean, onPick: () => void) => ( - - ); - - /** The search-engine menu row — label + the quiet live-key mark. */ + /** The search-engine menu row — label + the quiet live-key mark. */ const engineOption = ( id: (typeof SEARCH_ENGINES)[number] | "auto", label: string, @@ -553,13 +544,6 @@ export function AgentSection({ testid={`slot-thinking-menu-${slot}`} menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl" > - {noneRow( - thinking === "", - () => { - onModelsChange(composeSlotModel(models, slot, provider, modelId, "")); - setOpenMenu(null); - }, - )} (null); + // MenuShell open-count — drives the content column's scroll lock. + const [openMenus, setOpenMenus] = useState(0); + useEffect(() => { + const onOpen = () => setOpenMenus((n) => n + 1); + const onClose = () => setOpenMenus((n) => Math.max(0, n - 1)); + window.addEventListener(MENU_OPEN_EVENT, onOpen); + window.addEventListener(MENU_CLOSE_EVENT, onClose); + return () => { + window.removeEventListener(MENU_OPEN_EVENT, onOpen); + window.removeEventListener(MENU_CLOSE_EVENT, onClose); + }; + }, []); const [active, setActive] = useState(initialSection); // Fetch once per dialog mount. 200 {settings: null} is FIRST BOOT @@ -165,6 +179,7 @@ export function SettingsDialogBody({ setRecord( (prev) => ({ ...(prev ?? {}), [section]: body }) as UserSettings, ); + notifySettingsSaved(section); if (section === "appearance") onAppearanceChange(body as AppearanceSettings); }, @@ -228,9 +243,12 @@ export function SettingsDialogBody({ ))} - {/* Content column — one section at a time, this scrolls. */} + {/* Content column — one section at a time, this scrolls — and + LOCKS while any MenuShell is open (owner ruling: no + scrolling with a dropdown down; MenuShell announces + open/close, the count keeps overlap honest). */}
0 ? "overflow-hidden" : "overflow-y-auto"}`} data-testid="settings-content" > {error && ( diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 546fff8..e1a4f7d 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -36,6 +36,7 @@ import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings } from import { prettyModel } from "@/components/usage-format"; import { ModelMenuList } from "@/components/settings/model-menu"; import { MenuShell } from "@/components/menu-shell"; +import { notifySettingsSaved } from "@/components/settings-signal"; /** The pickers' slot identity — same roster language as the Agent * section's slot rows. */ @@ -177,6 +178,7 @@ export function SetupDialog({ setError(res ? `Save failed: ${(await res.json().catch(() => ({}))).error ?? res.status}` : "Save failed — network"); return; } + notifySettingsSaved("models"); onDone(models); } diff --git a/tests/settings-signal.test.ts b/tests/settings-signal.test.ts new file mode 100644 index 0000000..b92a99d --- /dev/null +++ b/tests/settings-signal.test.ts @@ -0,0 +1,67 @@ +/** + * The cross-surface settings signal (fix wave, owner ruling 2026-09-22): + * writers dispatch lh:settings-saved {section} after a successful PUT, + * readers re-read. These tests pin the SEAM's contract — delivery, + * section detail, unsubscribe, and junk-event tolerance — against a bare + * EventTarget standing in for window (Node has no DOM; the reader WIRING + * is verified live per dev-verification.md). + */ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; + +// A DOM-less window: EventTarget is all the seam touches. The module +// imports react (harmless) and reads window at CALL time, not import time. +const realWindow = (globalThis as { window?: unknown }).window; +(globalThis as { window: unknown }).window = new EventTarget(); + +const { + SETTINGS_SAVED_EVENT, + notifySettingsSaved, + onSettingsSaved, +} = await import("../components/settings-signal.ts"); + +afterEach(() => { + (globalThis as { window: unknown }).window = realWindow ?? new EventTarget(); +}); + +describe("settings-signal — the seam's contract", () => { + it("a writer's notify reaches a reader with the section", () => { + const got: string[] = []; + const off = onSettingsSaved((section) => got.push(section)); + notifySettingsSaved("models"); + assert.deepEqual(got, ["models"]); + off(); + }); + + it("unsubscribe stops delivery (a reader unmounting never leaks)", () => { + const got: string[] = []; + const off = onSettingsSaved((section) => got.push(section)); + off(); + notifySettingsSaved("models"); + assert.deepEqual(got, []); + }); + + it("readers filter their own section — general doesn't reach a models reader", () => { + const got: string[] = []; + const off = onSettingsSaved((s) => { + if (s === "models") got.push(s); + }); + notifySettingsSaved("general"); + notifySettingsSaved("appearance"); + assert.deepEqual(got, []); + notifySettingsSaved("models"); + assert.deepEqual(got, ["models"]); + off(); + }); + + it("a foreign CustomEvent on the same name with no section detail is ignored", () => { + const got: string[] = []; + const off = onSettingsSaved((section) => got.push(section)); + // e.g. some other dispatch path that forgot the detail payload + globalThis.window.dispatchEvent( + new CustomEvent(SETTINGS_SAVED_EVENT, { detail: undefined }), + ); + assert.deepEqual(got, []); + off(); + }); +});