Wave-5 chunk B: three model slots, search-engine row, first-run setup flow

Chunk B (owner-designed, model-slots wave) on the merged engine surface
(main 1d66a17):

AGENT SECTION — SLOTS ONLY (owner ruling)
- The six per-role model rows are GONE; three slot rows instead:
  Strong (Prober + Teacher), Cheap (five workhorse roles), Vision
  (visual maker + PDF reader), roster + resolved-default in every hint.
- Slot dropdowns share the app's menu language over the FEDERATED
  catalog (/api/models = keyed providers only, fresh per call — saving
  a key in Keys immediately widens the picker); the vision slot
  filters to image-capable models (Model.input); the menu's highlight
  now matches provider+id (ids can collide across providers).
- Slots write the new models section (PUT /api/settings/models) through
  the dialog's write seam; per-role overrides stay schema-only.
- ModelMenuList rows read 'provider · id' — federation makes the
  provider bookkeeping-relevant. Usage stamps render provider-qualified
  (prettyModel(qualified): 'Glm 5.3 Flash · openrouter') in the stats
  bar's turn lines.

SEARCH ENGINE (search wave: no default, owner)
- A row in Tools: Tavily/Brave via the shared-menu look, Automatic =
  the pin removed (auto-activation). The live reason line comes from
  /api/search; no engine key at all grays the whole row + disables the
  trigger (a keyless choice would only block auto-activation).

FIRST-RUN SETUP FLOW (owner design A: composer CTA + send interception)
- Chats cannot start until all three slots are picked — no defaults
  for 33 providers (slotsConfigured: all three slot models required).
- The graph view's new-session dock (home) shows a 'Set up your models'
  CTA while unconfigured; submitting a topic opens the SetupDialog WITH
  THE DRAFT PRESERVED; Save stores the models section and the queued
  message sends. Same interception on /chat/new sends.
- STAGED BY DEPENDENCY (owner: keys first, models after): no keyed
  provider → stage 1 deep-links the settings dialog on Keys (open now
  takes a section); a saved key reopens at stage 2 on the next submit.
- Stage 2: Strong → Cheap → Vision pickers (federated, vision-filtered,
  all three required). Closable without configuring — browsing stays
  possible; the gate is the send, not the dialog. Layered Esc: an open
  slot menu eats Esc before the dialog.
- Two closure bugs caught live: onDone→start/send captured the stale
  pre-save 'configured' (the flow now forces the replayed send); menu-Esc
  used to close the whole dialog.

Settings-dialog provider: open(section?) lands on a section (stage 1
deep-link). Tests: composeSlotModel/composeSearchEngine pure, slot SSR
replacing role rows, slotsConfigured/setupStage first-run gates,
provider-qualified prettyModel. Gates: typecheck, lint, 792/792, build.
This commit is contained in:
amosblomqvist
2026-09-22 13:54:29 +03:00
parent 21d288e9f9
commit 48fbd988f1
15 changed files with 814 additions and 155 deletions
+26 -9
View File
@@ -39,6 +39,7 @@ import {
import { useAppearance } from "./appearance.tsx";
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
import { AgentSection } from "./agent-section.tsx";
import type { ModelsSettings } from "@/src/core";
import { KeysSection } from "./keys-section.tsx";
import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
import { KeyboardSection } from "./keyboard-section.tsx";
@@ -56,20 +57,26 @@ const SECTIONS = [
] as const;
type SectionId = (typeof SECTIONS)[number]["id"];
const DialogContext = createContext<{ open: () => void }>({ open: () => {} });
const DialogContext = createContext<{ open: (section?: string) => void }>({ open: () => {} });
/** AppShell-level mount: the sidebar row and the mod+, chord call this. */
/** AppShell-level mount: the sidebar row and the mod+, chord call this.
* `open(section)` lands the dialog on a specific section (the first-run
* setup flow deep-links to Keys when no provider key exists yet). */
export function SettingsDialogProvider({
children,
}: {
children: React.ReactNode;
}) {
const [show, setShow] = useState(false);
const open = useCallback(() => setShow(true), []);
const [landOn, setLandOn] = useState<SectionId>("general");
const open = useCallback((section?: string) => {
if (section) setLandOn(section as SectionId);
setShow(true);
}, []);
return (
<DialogContext.Provider value={{ open }}>
{children}
{show && <SettingsDialog onClose={() => setShow(false)} />}
{show && <SettingsDialog onClose={() => setShow(false)} initialSection={landOn} />}
</DialogContext.Provider>
);
}
@@ -78,7 +85,7 @@ export function useSettingsDialog() {
return useContext(DialogContext);
}
function SettingsDialog({ onClose }: { onClose: () => void }) {
function SettingsDialog({ onClose, initialSection }: { onClose: () => void; initialSection: SectionId }) {
// Plain dim backdrop (no blur — nothing decorative); the click
// closes, the dialog body stops propagation.
return createPortal(
@@ -87,7 +94,7 @@ function SettingsDialog({ onClose }: { onClose: () => void }) {
data-testid="settings-backdrop"
onClick={onClose}
>
<SettingsDialogBody onClose={onClose} />
<SettingsDialogBody onClose={onClose} initialSection={initialSection} />
</div>,
document.body,
);
@@ -95,13 +102,19 @@ function SettingsDialog({ onClose }: { onClose: () => void }) {
/** The dialog without the portal/backdrop — the SSR-testable frame
* (portals render nothing under renderToStaticMarkup). */
export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
export function SettingsDialogBody({
onClose,
initialSection = "general",
}: {
onClose: () => void;
initialSection?: SectionId;
}) {
const { onAppearanceChange } = useAppearance();
const [record, setRecord] = useState<UserSettings | null | undefined>(
undefined,
);
const [error, setError] = useState<string | null>(null);
const [active, setActive] = useState<SectionId>("general");
const [active, setActive] = useState<SectionId>(initialSection);
// Fetch once per dialog mount. 200 {settings: null} is FIRST BOOT
// (no record = factory everything), not a failure — the error state is
@@ -133,7 +146,7 @@ export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
* schema mistake caught by the validation surface). */
const write = useCallback(
async (
section: "general" | "agent" | "appearance" | "graph",
section: "general" | "agent" | "models" | "appearance" | "graph",
body: object,
) => {
setError(null);
@@ -271,6 +284,10 @@ export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
>
<AgentSection
agent={record.agent}
models={record.models}
onModelsChange={(next: ModelsSettings) =>
void write("models", next)
}
onSectionChange={(next: AgentSettings) =>
void write("agent", next)
}