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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user