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:
@@ -1,5 +1,6 @@
|
|||||||
import { NewSession } from "@/components/new-session";
|
import { NewSession } from "@/components/new-session";
|
||||||
import { readDefaultSettings } from "@/src/server";
|
import { readDefaultSettings, readSettingsRecord } from "@/src/server";
|
||||||
|
import { slotsConfigured } from "@/src/core";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -16,5 +17,10 @@ export const dynamic = "force-dynamic";
|
|||||||
* only before the first-ever pick).
|
* only before the first-ever pick).
|
||||||
*/
|
*/
|
||||||
export default function NewSessionPage() {
|
export default function NewSessionPage() {
|
||||||
return <NewSession initialSettings={readDefaultSettings()} />;
|
return (
|
||||||
|
<NewSession
|
||||||
|
initialSettings={readDefaultSettings()}
|
||||||
|
initialConfigured={slotsConfigured(readSettingsRecord()?.models)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-2
@@ -1,5 +1,6 @@
|
|||||||
import { Home } from "@/components/home";
|
import { Home } from "@/components/home";
|
||||||
import { readDefaultSettings } from "@/src/server";
|
import { readDefaultSettings, readSettingsRecord } from "@/src/server";
|
||||||
|
import { slotsConfigured } from "@/src/core";
|
||||||
|
|
||||||
// Dynamic: the default settings record is server-rendered into the page —
|
// Dynamic: the default settings record is server-rendered into the page —
|
||||||
// the model + thinking + context controls show truth on the FIRST frame
|
// the model + thinking + context controls show truth on the FIRST frame
|
||||||
@@ -8,5 +9,11 @@ import { readDefaultSettings } from "@/src/server";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return <Home initialSettings={readDefaultSettings()} />;
|
// First-run gate (model-slots wave): chats cannot start until the user
|
||||||
|
// has picked all three slots — the dock shows the CTA while the raw
|
||||||
|
// record's models section lacks any slot pin.
|
||||||
|
const record = readSettingsRecord();
|
||||||
|
return (
|
||||||
|
<Home initialSettings={readDefaultSettings()} initialConfigured={slotsConfigured(record?.models)} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-2
@@ -4,6 +4,8 @@ import { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { CornerDownLeftIcon } from "lucide-react";
|
import { CornerDownLeftIcon } from "lucide-react";
|
||||||
import { GraphView } from "@/components/graph-view";
|
import { GraphView } from "@/components/graph-view";
|
||||||
|
import { SetupDialog } from "@/components/setup-dialog";
|
||||||
|
import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx";
|
||||||
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
|
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
|
||||||
import { useDefaultSettings } from "@/components/default-settings";
|
import { useDefaultSettings } from "@/components/default-settings";
|
||||||
import { DictationBar, useDictation } from "@/components/dictation";
|
import { DictationBar, useDictation } from "@/components/dictation";
|
||||||
@@ -17,9 +19,17 @@ import type { ChatSettings, TurnUsage } from "@/src/core";
|
|||||||
* under the header and the new-session dock, both of which float over it
|
* under the header and the new-session dock, both of which float over it
|
||||||
* with no gradient scrims. Sessions live in the toggleable sidebar.
|
* with no gradient scrims. Sessions live in the toggleable sidebar.
|
||||||
*/
|
*/
|
||||||
export function Home({ initialSettings }: { initialSettings: ChatSettings | null }) {
|
export function Home({ initialSettings, initialConfigured }: { initialSettings: ChatSettings | null; initialConfigured: boolean }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [topic, setTopic] = useState("");
|
const [topic, setTopic] = useState("");
|
||||||
|
// First-run gate (model-slots wave, owner 2026-09-20): chats cannot
|
||||||
|
// start until the user has picked all three slots — no defaults for
|
||||||
|
// 33 providers. The dock shows the CTA while unconfigured; submitting
|
||||||
|
// a topic opens the setup dialog WITH THE DRAFT PRESERVED (Save
|
||||||
|
// stores the slots and the queued message sends).
|
||||||
|
const [configured, setConfigured] = useState(initialConfigured);
|
||||||
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
|
const { open: openSettings } = useSettingsDialog();
|
||||||
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
|
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
|
||||||
// topic draft; the strip lives in the stats bar's middle slot.
|
// topic draft; the strip lives in the stats bar's middle slot.
|
||||||
const dictation = useDictation({
|
const dictation = useDictation({
|
||||||
@@ -34,9 +44,17 @@ export function Home({ initialSettings }: { initialSettings: ChatSettings | null
|
|||||||
// defers its save when chatId is empty; see components/model-picker).
|
// defers its save when chatId is empty; see components/model-picker).
|
||||||
const { settings, save: saveDraft } = useDefaultSettings(initialSettings);
|
const { settings, save: saveDraft } = useDefaultSettings(initialSettings);
|
||||||
|
|
||||||
const start = async () => {
|
const start = async (force = false) => {
|
||||||
const t = topic.trim();
|
const t = topic.trim();
|
||||||
if (!t || busy) return;
|
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) {
|
||||||
|
setSetupOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/chats", {
|
const res = await fetch("/api/chats", {
|
||||||
@@ -96,6 +114,18 @@ export function Home({ initialSettings }: { initialSettings: ChatSettings | null
|
|||||||
px-4) so nothing shifts when a session starts (owner). */}
|
px-4) so nothing shifts when a session starts (owner). */}
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 pb-5">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 pb-5">
|
||||||
<div className="pointer-events-auto mx-auto w-full max-w-[46rem] px-4">
|
<div className="pointer-events-auto mx-auto w-full max-w-[46rem] px-4">
|
||||||
|
{!configured && (
|
||||||
|
<div className="mb-2 flex justify-center">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Set up your models — chats start after the three picks
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<form
|
<form
|
||||||
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
|
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -155,6 +185,23 @@ export function Home({ initialSettings }: { initialSettings: ChatSettings | null
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* First-run setup (unconfigured): the CTA's target and the send
|
||||||
|
interception's landing surface. onDone replays the intercepted
|
||||||
|
draft; onOpenKeys deep-links the settings dialog (stage 1). */}
|
||||||
|
<SetupDialog
|
||||||
|
open={setupOpen}
|
||||||
|
onClose={() => setSetupOpen(false)}
|
||||||
|
onDone={() => {
|
||||||
|
setConfigured(true);
|
||||||
|
setSetupOpen(false);
|
||||||
|
void start(true); // the queued draft sends
|
||||||
|
}}
|
||||||
|
onOpenKeys={() => {
|
||||||
|
setSetupOpen(false);
|
||||||
|
openSettings("keys");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-11
@@ -9,6 +9,8 @@ import { GraphContextControl, effectiveContextTokens } from "@/components/graph-
|
|||||||
import { useDefaultSettings } from "@/components/default-settings";
|
import { useDefaultSettings } from "@/components/default-settings";
|
||||||
import { PhaseChip } from "@/components/phase-divider";
|
import { PhaseChip } from "@/components/phase-divider";
|
||||||
import { SidebarToggle } from "@/components/sidebar";
|
import { SidebarToggle } from "@/components/sidebar";
|
||||||
|
import { SetupDialog } from "@/components/setup-dialog";
|
||||||
|
import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx";
|
||||||
import { StatsBar } from "@/components/stats-bar";
|
import { StatsBar } from "@/components/stats-bar";
|
||||||
import type { ChatSettings } from "@/src/core";
|
import type { ChatSettings } from "@/src/core";
|
||||||
|
|
||||||
@@ -148,7 +150,13 @@ export function DraftShell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewSession({ initialSettings }: { initialSettings: ChatSettings | null }) {
|
export function NewSession({
|
||||||
|
initialSettings,
|
||||||
|
initialConfigured,
|
||||||
|
}: {
|
||||||
|
initialSettings: ChatSettings | null;
|
||||||
|
initialConfigured: boolean;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -160,8 +168,22 @@ export function NewSession({ initialSettings }: { initialSettings: ChatSettings
|
|||||||
// hands the choice up; it still rides the session POST then PUT-on-create.
|
// hands the choice up; it still rides the session POST then PUT-on-create.
|
||||||
const { settings, save: saveDraft } = useDefaultSettings(initialSettings);
|
const { settings, save: saveDraft } = useDefaultSettings(initialSettings);
|
||||||
|
|
||||||
const send = useCallback(() => {
|
// 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.
|
||||||
|
const [configured, setConfigured] = useState(initialConfigured);
|
||||||
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
|
const { open: openSettings } = useSettingsDialog();
|
||||||
|
|
||||||
|
const send = useCallback((force = false) => {
|
||||||
if (!text.trim() || busy) return;
|
if (!text.trim() || 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) {
|
||||||
|
setSetupOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
void startDraftSession(text, settings, fetch)
|
void startDraftSession(text, settings, fetch)
|
||||||
.then((target) => {
|
.then((target) => {
|
||||||
@@ -169,16 +191,32 @@ export function NewSession({ initialSettings }: { initialSettings: ChatSettings
|
|||||||
else setBusy(false);
|
else setBusy(false);
|
||||||
})
|
})
|
||||||
.catch(() => setBusy(false));
|
.catch(() => setBusy(false));
|
||||||
}, [text, busy, settings, router]);
|
}, [text, busy, settings, router, configured]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DraftShell
|
<>
|
||||||
busy={busy}
|
<DraftShell
|
||||||
text={text}
|
busy={busy}
|
||||||
onChange={setText}
|
text={text}
|
||||||
onSend={send}
|
onChange={setText}
|
||||||
settings={settings}
|
onSend={send}
|
||||||
onSettingsSaved={saveDraft}
|
settings={settings}
|
||||||
/>
|
onSettingsSaved={saveDraft}
|
||||||
|
/>
|
||||||
|
{/* First-run setup: the send interception's landing surface. */}
|
||||||
|
<SetupDialog
|
||||||
|
open={setupOpen}
|
||||||
|
onClose={() => setSetupOpen(false)}
|
||||||
|
onDone={() => {
|
||||||
|
setConfigured(true);
|
||||||
|
setSetupOpen(false);
|
||||||
|
send(true); // the queued draft sends
|
||||||
|
}}
|
||||||
|
onOpenKeys={() => {
|
||||||
|
setSetupOpen(false);
|
||||||
|
openSettings("keys");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
/**
|
/**
|
||||||
* The Agent section (settings-app wave): background-role model overrides,
|
* The Agent section (settings-app wave): the THREE MODEL SLOTS, the tool
|
||||||
* the tool toggles, and the prompt override switches — three GROUPS
|
* toggles + the search-engine preference, and the prompt override
|
||||||
* (owner, 2026-09-20: clear separation — Models / Tools / System prompts),
|
* switches — three GROUPS (owner, 2026-09-20: clear separation — Models /
|
||||||
* ONE section (PUT /api/settings/agent replaces the whole agent object,
|
* Tools / System prompts).
|
||||||
* so every change composes models+tools+prompts together from the record).
|
|
||||||
*
|
*
|
||||||
* The role selects (owner, 2026-09-20) are the EXACT same dropdown as the
|
* Model slots (model-slots wave, 2026-09-20, owner ruling: SLOTS ONLY —
|
||||||
* chat model selector — the shared ModelMenuList/ThinkingMenuList from
|
* the six per-role rows disappeared; per-role overrides remain in the
|
||||||
* ./model-menu.tsx (fuzzy search, arrow-walk, Enter pick). The collapsed
|
* schema for a future advanced surface): Strong = Prober + Teacher, Cheap
|
||||||
* trigger keeps the thin select look; the opened menu is the model
|
* = the five workhorse roles, Vision = Visual maker + PDF reader. Each
|
||||||
* picker's. Menus open DOWNWARD here (the dialog sits mid-screen); the
|
* slot row carries the shared dropdown (fuzzy search, arrow-walk, Enter
|
||||||
* chat control opens upward — the shell belongs to each mount, the body
|
* pick — ModelMenuList/ThinkingMenuList from ./model-menu.tsx) over the
|
||||||
* is shared.
|
* FEDERATED catalog (/api/models serves only keyed providers, fresh per
|
||||||
|
* call — saving a key in Keys immediately widens this picker). The
|
||||||
|
* vision slot filters to image-capable models (Model.input). The slots
|
||||||
|
* live in the `models` settings section (PUT /api/settings/models — slot
|
||||||
|
* pins are engine-level, not per-chat); the rest of the section writes
|
||||||
|
* the `agent` section as before. Resolution per tier: slot override →
|
||||||
|
* factory pin (GET /api/models' `slots` — resolved, cannot drift from
|
||||||
|
* what runs). The Default row clears the slot override; a stored id stays
|
||||||
|
* VISIBLE even when its provider's key lapsed out of the catalog — a
|
||||||
|
* trigger showing "Default" over a live override would lie about the
|
||||||
|
* record.
|
||||||
|
*
|
||||||
|
* Search engine (search wave, 2026-09-20, owner: no default — an engine
|
||||||
|
* is live iff its key is present; with a key the engine AUTO-activates,
|
||||||
|
* the explicit choice is only a preference between live engines): a row
|
||||||
|
* in the Tools group, Tavily / Brave via the same shared-menu look, with
|
||||||
|
* the live reason line from /api/search. No engine key at all → the row
|
||||||
|
* grays whole-row (a choice without a key would only BLOCK the other
|
||||||
|
* engine's auto-activation).
|
||||||
*
|
*
|
||||||
* Tools — the owner's final list (2026-09-19): research, demo, visual, pdf.
|
* Tools — the owner's final list (2026-09-19): research, demo, visual, pdf.
|
||||||
* Absent key = enabled in the schema; the UI writes explicit booleans for
|
* Absent key = enabled in the schema; the UI writes explicit booleans for
|
||||||
* all four (simpler to reason about, sanitize-equivalent).
|
* all four (simpler to reason about, sanitize-equivalent).
|
||||||
*
|
*
|
||||||
* Models: each of the six background roles defaults to config.ts's pin
|
|
||||||
* (no override). The Default row clears the model override; the stored id
|
|
||||||
* stays VISIBLE even when the catalog no longer contains it — a trigger
|
|
||||||
* showing "Default" over a live override would lie about the record.
|
|
||||||
*
|
|
||||||
* Prompts: GET /api/settings/prompts segments; the switch requires an
|
* Prompts: GET /api/settings/prompts segments; the switch requires an
|
||||||
* override FILE (route-enforced), so a file-less row dims whole-row
|
* override FILE (route-enforced), so a file-less row dims whole-row
|
||||||
* (graph-view graying) until one is written. Switch state lives in this
|
* (graph-view graying) until one is written. Switch state lives in this
|
||||||
@@ -32,22 +44,29 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { BACKGROUND_MODEL_ROLES, type AgentSettings, type BackgroundModelRole, type ModelCatalogEntry } from "@/src/core";
|
import {
|
||||||
|
MODEL_SLOTS,
|
||||||
|
type AgentSettings,
|
||||||
|
type ModelCatalogEntry,
|
||||||
|
type ModelSlotName,
|
||||||
|
type ModelsSettings,
|
||||||
|
} from "@/src/core";
|
||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import { prettyModel } from "@/components/usage-format";
|
import { prettyModel } from "@/components/usage-format";
|
||||||
import { SettingsGroup, SettingsRow, Toggle } from "./controls.tsx";
|
import { SettingsGroup, SettingsRow, Toggle } from "./controls.tsx";
|
||||||
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
||||||
|
|
||||||
/** The role's display label — schema ids are camelCase, the label is human. */
|
/** One slot's display identity — the tier's roster as the row hint rides
|
||||||
const ROLE_LABEL: Record<BackgroundModelRole, string> = {
|
* with the resolved default (owner: the hint ALWAYS names the default). */
|
||||||
researcher: "Researcher",
|
const SLOT_META: Record<ModelSlotName, { label: string; roster: string }> = {
|
||||||
demoBuilder: "Demo builder",
|
strong: { label: "Strong", roster: "Prober + Teacher" },
|
||||||
visualMaker: "Visual maker",
|
cheap: { label: "Cheap", roster: "Researcher, demo builder, extractor, auditor, labeler" },
|
||||||
extractor: "Extractor",
|
vision: { label: "Vision", roster: "Visual maker + PDF reader" },
|
||||||
auditor: "Auditor",
|
|
||||||
labeler: "Labeler",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The search engines this wave ships (src/core/search.ts) + their labels. */
|
||||||
|
const SEARCH_ENGINES = ["tavily", "brave"] as const;
|
||||||
|
|
||||||
/** The owner's final toggleable-tool list (confirm with the user before
|
/** The owner's final toggleable-tool list (confirm with the user before
|
||||||
* changing — the Teacher stream settled this 2026-09-19). */
|
* changing — the Teacher stream settled this 2026-09-19). */
|
||||||
export const TOGGLE_TOOLS = ["research", "demo", "visual", "pdf"] as const;
|
export const TOGGLE_TOOLS = ["research", "demo", "visual", "pdf"] as const;
|
||||||
@@ -63,30 +82,57 @@ interface SegmentInfo {
|
|||||||
shared: boolean;
|
shared: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The GET /api/models response fields this section consumes. */
|
||||||
|
interface ModelsBody {
|
||||||
|
models: ModelCatalogEntry[];
|
||||||
|
/** The three tiers' RESOLVED pins (override → factory) — the honest
|
||||||
|
* "what runs now" the Default rows and hints speak from. */
|
||||||
|
slots: Record<ModelSlotName, { model: { provider: string; id: string }; thinkingLevel?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Compose: one tool toggled, the rest of the section carried verbatim.
|
/** Compose: one tool toggled, the rest of the section carried verbatim.
|
||||||
* Pure — exported for tests. */
|
* Pure — exported for tests. */
|
||||||
export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings {
|
export function composeTool(agent: AgentSettings | undefined, id: string, on: boolean): AgentSettings {
|
||||||
return { ...agent, tools: { ...agent?.tools, [id]: on } };
|
return { ...agent, tools: { ...agent?.tools, [id]: on } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compose: one role's model/thinking override. Empty model = absent
|
/** Compose: one slot's model/thinking override. Empty model = absent
|
||||||
* override (Default); empty thinking = thinking falls back. Pure — tests. */
|
* override (Default — falls to the factory pin); empty thinking = the
|
||||||
export function composeRoleModel(
|
* tier keeps its resolved thinking budget. Per-field independence, same
|
||||||
agent: AgentSettings | undefined,
|
* as the schema's ModelSlot. Pure — exported for tests. */
|
||||||
role: BackgroundModelRole,
|
export function composeSlotModel(
|
||||||
modelId: string,
|
models: ModelsSettings | undefined,
|
||||||
|
slot: ModelSlotName,
|
||||||
|
provider: string,
|
||||||
|
id: string,
|
||||||
thinking: string,
|
thinking: string,
|
||||||
): AgentSettings {
|
): ModelsSettings {
|
||||||
const prev = agent?.models?.[role];
|
const prev = models?.[slot];
|
||||||
const models = { ...agent?.models };
|
const next = { ...models };
|
||||||
if (!modelId && !thinking) delete models[role];
|
if (!provider && !id && !thinking) {
|
||||||
else {
|
delete next[slot];
|
||||||
models[role] = {
|
return next;
|
||||||
...(modelId ? { model: { provider: "openrouter", id: modelId } } : {}),
|
|
||||||
...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return { ...agent, models };
|
next[slot] = {
|
||||||
|
...(provider && id ? { model: { provider, id } } : prev?.model ? { model: prev.model } : {}),
|
||||||
|
...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}),
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compose: the search-engine preference. null = the explicit pin is
|
||||||
|
* REMOVED (auto-activation takes over — the schema's resting state).
|
||||||
|
* Pure — exported for tests. */
|
||||||
|
export function composeSearchEngine(
|
||||||
|
agent: AgentSettings | undefined,
|
||||||
|
engine: (typeof SEARCH_ENGINES)[number] | null,
|
||||||
|
): AgentSettings {
|
||||||
|
if (engine === null) {
|
||||||
|
if (!agent?.search) return { ...agent };
|
||||||
|
const { search: _drop, ...rest } = agent;
|
||||||
|
return { ...rest } as AgentSettings;
|
||||||
|
}
|
||||||
|
return { ...agent, search: { engine } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compose: one prompt switch. on = explicit true; off = key REMOVED
|
/** Compose: one prompt switch. on = explicit true; off = key REMOVED
|
||||||
@@ -109,6 +155,7 @@ function MenuTrigger({
|
|||||||
open,
|
open,
|
||||||
onClick,
|
onClick,
|
||||||
widthClass,
|
widthClass,
|
||||||
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
/** What the trigger shows when there is no override ("Default"). */
|
/** What the trigger shows when there is no override ("Default"). */
|
||||||
@@ -122,6 +169,8 @@ function MenuTrigger({
|
|||||||
* the model trigger takes the same width as a MINIMUM — its values
|
* the model trigger takes the same width as a MINIMUM — its values
|
||||||
* genuinely vary in length. */
|
* genuinely vary in length. */
|
||||||
widthClass: string;
|
widthClass: string;
|
||||||
|
/** The no-key search state grays the whole row; the trigger follows. */
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -129,8 +178,12 @@ function MenuTrigger({
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
data-testid={testid}
|
data-testid={testid}
|
||||||
onClick={onClick}
|
onClick={() => {
|
||||||
className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 ${widthClass}`}
|
if (!disabled) onClick();
|
||||||
|
}}
|
||||||
|
className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${
|
||||||
|
disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50"
|
||||||
|
} ${widthClass}`}
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate">{value || emptyLabel}</span>
|
<span className="min-w-0 flex-1 truncate">{value || emptyLabel}</span>
|
||||||
<span aria-hidden className="text-muted-foreground">
|
<span aria-hidden className="text-muted-foreground">
|
||||||
@@ -191,39 +244,69 @@ function AnchoredMenu({
|
|||||||
|
|
||||||
export function AgentSection({
|
export function AgentSection({
|
||||||
agent,
|
agent,
|
||||||
|
models,
|
||||||
onSectionChange,
|
onSectionChange,
|
||||||
|
onModelsChange,
|
||||||
}: {
|
}: {
|
||||||
agent: AgentSettings | undefined;
|
agent: AgentSettings | undefined;
|
||||||
|
/** The `models` section (slot pins — engine-level, PUT /api/settings/models). */
|
||||||
|
models: ModelsSettings | undefined;
|
||||||
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
|
/** Writes the composed WHOLE agent section (replace-PUT discipline). */
|
||||||
onSectionChange: (next: AgentSettings) => void;
|
onSectionChange: (next: AgentSettings) => void;
|
||||||
|
/** Writes the composed models section (slots — replace-PUT discipline). */
|
||||||
|
onModelsChange: (next: ModelsSettings) => void;
|
||||||
}) {
|
}) {
|
||||||
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
|
||||||
/** The config-pinned model per background role — GET /api/models'
|
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
|
||||||
* rolePins (Platform stream, landing as a standalone commit; master
|
* Default hints and thinking lists resolve from it. */
|
||||||
* routing 2026-09-20). Optional here: until the rebase the rows read
|
const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null);
|
||||||
* "Default: config pin" and light up the moment the field arrives. */
|
/** The live search state (/api/search) — the row's reason line. */
|
||||||
const [rolePins, setRolePins] = useState<Record<string, { model: { provider: string; id: string }; thinkingLevel?: string }> | null>(null);
|
const [search, setSearch] = useState<{ available: boolean; engine: string | null; reason: string } | null>(null);
|
||||||
|
/** Which search engines have a key (/api/keys) — the no-keys-anywhere
|
||||||
|
* state grays the row; per-engine marks ride the menu rows. */
|
||||||
|
const [engineKeys, setEngineKeys] = useState<Partial<Record<(typeof SEARCH_ENGINES)[number], boolean>>>({});
|
||||||
const [prompts, setPrompts] = useState<{ prober: SegmentInfo[]; teacher: SegmentInfo[] } | null>(null);
|
const [prompts, setPrompts] = useState<{ prober: SegmentInfo[]; teacher: SegmentInfo[] } | null>(null);
|
||||||
const [openSegment, setOpenSegment] = useState<string | null>(null);
|
const [openSegment, setOpenSegment] = useState<string | null>(null);
|
||||||
const [segmentDraft, setSegmentDraft] = useState<Record<string, string>>({});
|
const [segmentDraft, setSegmentDraft] = useState<Record<string, string>>({});
|
||||||
const [segmentStatus, setSegmentStatus] = useState<string | null>(null);
|
const [segmentStatus, setSegmentStatus] = useState<string | null>(null);
|
||||||
/** One open dropdown for the whole section: which role, which kind. */
|
/** One open dropdown for the whole section: which slot (or the search
|
||||||
const [openMenu, setOpenMenu] = useState<{ role: BackgroundModelRole; kind: "model" | "thinking" } | null>(null);
|
* row), which kind. */
|
||||||
|
const [openMenu, setOpenMenu] = useState<{ target: ModelSlotName | "search"; kind: "model" | "thinking" | "engine" } | null>(null);
|
||||||
const menuAnchorRef = useRef<HTMLSpanElement>(null);
|
const menuAnchorRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
// The catalog + the prompt segments — one fetch each per section mount.
|
const refreshSearch = () =>
|
||||||
|
fetch("/api/search")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body: { available: boolean; engine: string | null; reason: string }) => setSearch(body))
|
||||||
|
.catch(() => setSearch(null));
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
fetch("/api/models")
|
fetch("/api/models")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((body: { models: ModelCatalogEntry[]; rolePins?: Record<string, { model: { provider: string; id: string }; thinkingLevel?: string }> }) => {
|
.then((body: ModelsBody) => {
|
||||||
setCatalog(body.models);
|
setCatalog(body.models);
|
||||||
if (body.rolePins) setRolePins(body.rolePins);
|
setSlots(body.slots);
|
||||||
})
|
})
|
||||||
.catch(() => setCatalog(null));
|
.catch(() => setCatalog(null));
|
||||||
fetch("/api/settings/prompts")
|
fetch("/api/settings/prompts")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
|
.then((body: { prober: SegmentInfo[]; teacher: SegmentInfo[] }) => setPrompts(body))
|
||||||
.catch(() => setPrompts(null));
|
.catch(() => setPrompts(null));
|
||||||
|
refreshSearch();
|
||||||
|
fetch("/api/keys")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body: { keys: { id: string; present: boolean }[] }) => {
|
||||||
|
const present: Partial<Record<(typeof SEARCH_ENGINES)[number], boolean>> = {};
|
||||||
|
for (const id of SEARCH_ENGINES) {
|
||||||
|
const envVar = id === "tavily" ? "TAVILY_API_KEY" : "BRAVE_API_KEY";
|
||||||
|
present[id] = body.keys.some((k) => k.id === envVar && k.present);
|
||||||
|
}
|
||||||
|
setEngineKeys(present);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The dropdown contract (the chat model selector's, mirrored): any
|
// The dropdown contract (the chat model selector's, mirrored): any
|
||||||
@@ -256,8 +339,7 @@ export function AgentSection({
|
|||||||
};
|
};
|
||||||
}, [openMenu]);
|
}, [openMenu]);
|
||||||
|
|
||||||
async function saveSegment(path: string, text: string) {
|
async function saveSegment(path: string, text: string) { setSegmentStatus(null);
|
||||||
setSegmentStatus(null);
|
|
||||||
const res = await fetch(`/api/settings/prompts/${path}`, {
|
const res = await fetch(`/api/settings/prompts/${path}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
@@ -287,7 +369,7 @@ export function AgentSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The pinned Default row — clears this control's override (absent key
|
/** The pinned Default row — clears this control's override (absent key
|
||||||
* = config pin). Same row language as the shared menu's list rows. */
|
* = the factory pin). Same row language as the shared menu's list rows. */
|
||||||
const defaultRow = (active: boolean, onPick: () => void) => (
|
const defaultRow = (active: boolean, onPick: () => void) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -302,6 +384,29 @@ export function AgentSection({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** The search-engine menu row — label + the quiet live-key mark. */
|
||||||
|
const engineOption = (
|
||||||
|
id: (typeof SEARCH_ENGINES)[number] | "auto",
|
||||||
|
label: string,
|
||||||
|
active: boolean,
|
||||||
|
live: boolean | null,
|
||||||
|
onPick: () => void,
|
||||||
|
) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`engine-option-${id}`}
|
||||||
|
onClick={onPick}
|
||||||
|
className={`flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
|
||||||
|
active ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{live === null ? (active && <span aria-hidden>✓</span>) : live ? <span aria-hidden>✓ key</span> : <span aria-hidden>· no key</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
const segmentRow = (info: SegmentInfo) => {
|
const segmentRow = (info: SegmentInfo) => {
|
||||||
const fileExists = info.overrideText !== null;
|
const fileExists = info.overrideText !== null;
|
||||||
const open = openSegment === info.path;
|
const open = openSegment === info.path;
|
||||||
@@ -378,35 +483,36 @@ export function AgentSection({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsGroup label="Models" testid="group-models">
|
<SettingsGroup label="Models" testid="group-models">
|
||||||
{BACKGROUND_MODEL_ROLES.map((role) => {
|
{MODEL_SLOTS.map((slot) => {
|
||||||
const override = agent?.models?.[role];
|
const override = models?.[slot];
|
||||||
const modelId = override?.model?.id ?? "";
|
const modelId = override?.model?.id ?? "";
|
||||||
|
const provider = override?.model?.provider ?? "";
|
||||||
const thinking = override?.thinkingLevel ?? "";
|
const thinking = override?.thinkingLevel ?? "";
|
||||||
|
// The tier's resolved pin (override → factory): the Default
|
||||||
|
// hint names it; the thinking list resolves from its levels.
|
||||||
|
const pin = slots?.[slot];
|
||||||
|
const defaultName = pin ? prettyModel(pin.model.id) : "config pin";
|
||||||
// The model whose levels govern the thinking list: the
|
// The model whose levels govern the thinking list: the
|
||||||
// override, else the config pin (rolePins makes the
|
// override, else the resolved pin (when it's in the federated
|
||||||
// default's levels resolvable when it's in the catalog);
|
// catalog); the standard ladder as the last fallback.
|
||||||
// the standard ladder as the last fallback.
|
const coord = override?.model ?? pin?.model;
|
||||||
const effectiveId = modelId || rolePins?.[role]?.model.id || "";
|
const chosen = coord ? catalog?.find((m) => m.id === coord.id && m.provider === coord.provider) : undefined;
|
||||||
const chosen = catalog?.find((m) => m.id === effectiveId);
|
|
||||||
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
|
const levels = chosen ? chosen.supportedLevels : THINKING_LEVELS;
|
||||||
// The row ALWAYS names the default (owner, 2026-09-20: the
|
const thisMenuOpen = openMenu?.target === slot ? openMenu.kind : null;
|
||||||
// old "config pin applies" hint said nothing). rolePins
|
// Vision slots pick from image-capable models only.
|
||||||
// carries the resolved default; the fallback reads until
|
const slotCatalog =
|
||||||
// Platform's rolePins lands on main.
|
slot === "vision" ? catalog?.filter((m) => m.input.includes("image")) : catalog;
|
||||||
const defaultName = rolePins?.[role]?.model.id
|
|
||||||
? prettyModel(rolePins[role].model.id)
|
|
||||||
: "config pin";
|
|
||||||
const thisMenuOpen =
|
|
||||||
openMenu?.role === role ? openMenu.kind : null;
|
|
||||||
return (
|
return (
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
key={role}
|
key={slot}
|
||||||
label={ROLE_LABEL[role]}
|
label={SLOT_META[slot].label}
|
||||||
hint={`Default: ${defaultName}`}
|
hint={`${SLOT_META[slot].roster} · default: ${defaultName}`}
|
||||||
testid={`role-row-${role}`}
|
testid={`slot-row-${slot}`}
|
||||||
>
|
>
|
||||||
{/* The anchor for the outside-click contract is the
|
{/* The anchor for the outside-click contract is the
|
||||||
span holding BOTH triggers of the open menu. */}
|
span holding BOTH triggers of the open menu. */}
|
||||||
@@ -417,55 +523,60 @@ export function AgentSection({
|
|||||||
<MenuTrigger
|
<MenuTrigger
|
||||||
value={modelId ? prettyModel(modelId) : ""}
|
value={modelId ? prettyModel(modelId) : ""}
|
||||||
emptyLabel="Default"
|
emptyLabel="Default"
|
||||||
ariaLabel={`${ROLE_LABEL[role]} model`}
|
ariaLabel={`${SLOT_META[slot].label} model`}
|
||||||
testid={`role-model-${role}`}
|
testid={`slot-model-${slot}`}
|
||||||
open={thisMenuOpen === "model"}
|
open={thisMenuOpen === "model"}
|
||||||
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { role, kind: "model" })}
|
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
|
||||||
widthClass="min-w-20"
|
widthClass="min-w-20"
|
||||||
/>
|
/>
|
||||||
<MenuTrigger
|
<MenuTrigger
|
||||||
value={thinking}
|
value={thinking}
|
||||||
emptyLabel="Default"
|
emptyLabel="Default"
|
||||||
ariaLabel={`${ROLE_LABEL[role]} thinking`}
|
ariaLabel={`${SLOT_META[slot].label} thinking`}
|
||||||
testid={`role-thinking-${role}`}
|
testid={`slot-thinking-${slot}`}
|
||||||
open={thisMenuOpen === "thinking"}
|
open={thisMenuOpen === "thinking"}
|
||||||
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { role, kind: "thinking" })}
|
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })}
|
||||||
widthClass="w-20"
|
widthClass="w-20"
|
||||||
/>
|
/>
|
||||||
{thisMenuOpen === "model" && (
|
{thisMenuOpen === "model" && (
|
||||||
<AnchoredMenu
|
<AnchoredMenu
|
||||||
anchorTestid={`role-model-${role}`}
|
anchorTestid={`slot-model-${slot}`}
|
||||||
testid={`role-model-menu-${role}`}
|
testid={`slot-model-menu-${slot}`}
|
||||||
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
||||||
>
|
>
|
||||||
{defaultRow(
|
{defaultRow(
|
||||||
modelId === "",
|
modelId === "",
|
||||||
() => {
|
() => {
|
||||||
onSectionChange(composeRoleModel(agent, role, "", thinking));
|
onModelsChange(composeSlotModel(models, slot, "", "", thinking));
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
<ModelMenuList
|
<ModelMenuList
|
||||||
models={catalog ?? undefined}
|
models={slotCatalog ?? undefined}
|
||||||
effectiveId={modelId || undefined}
|
effectiveId={modelId || pin?.model.id}
|
||||||
|
effectiveProvider={provider || pin?.model.provider}
|
||||||
onPick={(entry) => {
|
onPick={(entry) => {
|
||||||
onSectionChange(composeRoleModel(agent, role, entry.id, thinking));
|
onModelsChange(composeSlotModel(models, slot, entry.provider, entry.id, thinking));
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
}}
|
}}
|
||||||
footer="Applied to every run of this role"
|
footer={
|
||||||
|
catalog && catalog.length === 0
|
||||||
|
? "No models — add a provider key in Keys"
|
||||||
|
: "Runs every agent of this tier"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</AnchoredMenu>
|
</AnchoredMenu>
|
||||||
)}
|
)}
|
||||||
{thisMenuOpen === "thinking" && (
|
{thisMenuOpen === "thinking" && (
|
||||||
<AnchoredMenu
|
<AnchoredMenu
|
||||||
anchorTestid={`role-thinking-${role}`}
|
anchorTestid={`slot-thinking-${slot}`}
|
||||||
testid={`role-thinking-menu-${role}`}
|
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"
|
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
||||||
>
|
>
|
||||||
{defaultRow(
|
{defaultRow(
|
||||||
thinking === "",
|
thinking === "",
|
||||||
() => {
|
() => {
|
||||||
onSectionChange(composeRoleModel(agent, role, modelId, ""));
|
onModelsChange(composeSlotModel(models, slot, provider, modelId, ""));
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
@@ -473,7 +584,7 @@ export function AgentSection({
|
|||||||
levels={levels.map(String)}
|
levels={levels.map(String)}
|
||||||
effectiveLevel={thinking || undefined}
|
effectiveLevel={thinking || undefined}
|
||||||
onPick={(level) => {
|
onPick={(level) => {
|
||||||
onSectionChange(composeRoleModel(agent, role, modelId, level));
|
onModelsChange(composeSlotModel(models, slot, provider, modelId, level));
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -495,6 +606,52 @@ export function AgentSection({
|
|||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
))}
|
))}
|
||||||
|
{(() => {
|
||||||
|
const engine = agent?.search?.engine ?? null;
|
||||||
|
const anyKey = engineKeys.tavily === true || engineKeys.brave === true;
|
||||||
|
const open = openMenu?.target === "search";
|
||||||
|
return (
|
||||||
|
<SettingsRow
|
||||||
|
label="Search engine"
|
||||||
|
hint={search?.reason ?? undefined}
|
||||||
|
testid="search-engine-row"
|
||||||
|
dimmed={!anyKey}
|
||||||
|
>
|
||||||
|
<span ref={open ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
|
||||||
|
<MenuTrigger
|
||||||
|
value={engine === "tavily" ? "Tavily" : engine === "brave" ? "Brave" : ""}
|
||||||
|
emptyLabel="Automatic"
|
||||||
|
ariaLabel="Search engine"
|
||||||
|
testid="search-engine-trigger"
|
||||||
|
open={open}
|
||||||
|
onClick={() => setOpenMenu(open ? null : { target: "search", kind: "engine" })}
|
||||||
|
widthClass="min-w-20"
|
||||||
|
disabled={!anyKey}
|
||||||
|
/>
|
||||||
|
{open && (
|
||||||
|
<AnchoredMenu
|
||||||
|
anchorTestid="search-engine-trigger"
|
||||||
|
testid="search-engine-menu"
|
||||||
|
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
||||||
|
>
|
||||||
|
{engineOption("auto", "Automatic", engine === null, null, () => {
|
||||||
|
onSectionChange(composeSearchEngine(agent, null));
|
||||||
|
setOpenMenu(null);
|
||||||
|
refreshSearch();
|
||||||
|
})}
|
||||||
|
{SEARCH_ENGINES.map((id) =>
|
||||||
|
engineOption(id, id === "tavily" ? "Tavily" : "Brave", engine === id, engineKeys[id] ?? false, () => {
|
||||||
|
onSectionChange(composeSearchEngine(agent, id));
|
||||||
|
setOpenMenu(null);
|
||||||
|
refreshSearch();
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</AnchoredMenu>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</SettingsRow>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsGroup label="System prompts" testid="group-prompts">
|
<SettingsGroup label="System prompts" testid="group-prompts">
|
||||||
@@ -516,4 +673,4 @@ export function AgentSection({
|
|||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function priceLabel(cost: ModelCatalogEntry["cost"]): string {
|
|||||||
export function ModelMenuList({
|
export function ModelMenuList({
|
||||||
models,
|
models,
|
||||||
effectiveId,
|
effectiveId,
|
||||||
|
effectiveProvider,
|
||||||
onPick,
|
onPick,
|
||||||
footer,
|
footer,
|
||||||
}: {
|
}: {
|
||||||
@@ -38,6 +39,11 @@ export function ModelMenuList({
|
|||||||
models: ModelCatalogEntry[] | undefined;
|
models: ModelCatalogEntry[] | undefined;
|
||||||
/** Currently effective id (the row gets the quiet highlight). */
|
/** Currently effective id (the row gets the quiet highlight). */
|
||||||
effectiveId: string | undefined;
|
effectiveId: string | undefined;
|
||||||
|
/** The effective id's provider (model-slots wave: the catalog
|
||||||
|
* federates multiple providers and ids can collide across them —
|
||||||
|
* the highlight must match BOTH coordinates or it lands on the
|
||||||
|
* wrong row). */
|
||||||
|
effectiveProvider?: string;
|
||||||
/** Receives the chosen entry. */
|
/** Receives the chosen entry. */
|
||||||
onPick: (entry: ModelCatalogEntry) => void;
|
onPick: (entry: ModelCatalogEntry) => void;
|
||||||
/** Quiet footer note under the list (context-specific). */
|
/** Quiet footer note under the list (context-specific). */
|
||||||
@@ -111,7 +117,7 @@ export function ModelMenuList({
|
|||||||
onClick={() => onPick(m)}
|
onClick={() => onPick(m)}
|
||||||
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
|
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
|
||||||
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
|
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
|
||||||
}${m.id === effectiveId && i !== menuIdx ? " bg-muted/60" : ""}`}
|
}${m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider) && i !== menuIdx ? " bg-muted/60" : ""}`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center justify-between gap-2">
|
<span className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate text-card-foreground">{m.name}</span>
|
<span className="truncate text-card-foreground">{m.name}</span>
|
||||||
@@ -120,7 +126,7 @@ export function ModelMenuList({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
||||||
<span className="truncate">{m.id}</span>
|
<span className="truncate">{m.provider} · {m.id}</span>
|
||||||
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
|
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
import { useAppearance } from "./appearance.tsx";
|
import { useAppearance } from "./appearance.tsx";
|
||||||
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
|
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
|
||||||
import { AgentSection } from "./agent-section.tsx";
|
import { AgentSection } from "./agent-section.tsx";
|
||||||
|
import type { ModelsSettings } from "@/src/core";
|
||||||
import { KeysSection } from "./keys-section.tsx";
|
import { KeysSection } from "./keys-section.tsx";
|
||||||
import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
|
import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
|
||||||
import { KeyboardSection } from "./keyboard-section.tsx";
|
import { KeyboardSection } from "./keyboard-section.tsx";
|
||||||
@@ -56,20 +57,26 @@ const SECTIONS = [
|
|||||||
] as const;
|
] as const;
|
||||||
type SectionId = (typeof SECTIONS)[number]["id"];
|
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({
|
export function SettingsDialogProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [show, setShow] = useState(false);
|
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 (
|
return (
|
||||||
<DialogContext.Provider value={{ open }}>
|
<DialogContext.Provider value={{ open }}>
|
||||||
{children}
|
{children}
|
||||||
{show && <SettingsDialog onClose={() => setShow(false)} />}
|
{show && <SettingsDialog onClose={() => setShow(false)} initialSection={landOn} />}
|
||||||
</DialogContext.Provider>
|
</DialogContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -78,7 +85,7 @@ export function useSettingsDialog() {
|
|||||||
return useContext(DialogContext);
|
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
|
// Plain dim backdrop (no blur — nothing decorative); the click
|
||||||
// closes, the dialog body stops propagation.
|
// closes, the dialog body stops propagation.
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -87,7 +94,7 @@ function SettingsDialog({ onClose }: { onClose: () => void }) {
|
|||||||
data-testid="settings-backdrop"
|
data-testid="settings-backdrop"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<SettingsDialogBody onClose={onClose} />
|
<SettingsDialogBody onClose={onClose} initialSection={initialSection} />
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
@@ -95,13 +102,19 @@ function SettingsDialog({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
/** The dialog without the portal/backdrop — the SSR-testable frame
|
/** The dialog without the portal/backdrop — the SSR-testable frame
|
||||||
* (portals render nothing under renderToStaticMarkup). */
|
* (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 { onAppearanceChange } = useAppearance();
|
||||||
const [record, setRecord] = useState<UserSettings | null | undefined>(
|
const [record, setRecord] = useState<UserSettings | null | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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
|
// Fetch once per dialog mount. 200 {settings: null} is FIRST BOOT
|
||||||
// (no record = factory everything), not a failure — the error state is
|
// (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). */
|
* schema mistake caught by the validation surface). */
|
||||||
const write = useCallback(
|
const write = useCallback(
|
||||||
async (
|
async (
|
||||||
section: "general" | "agent" | "appearance" | "graph",
|
section: "general" | "agent" | "models" | "appearance" | "graph",
|
||||||
body: object,
|
body: object,
|
||||||
) => {
|
) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -271,6 +284,10 @@ export function SettingsDialogBody({ onClose }: { onClose: () => void }) {
|
|||||||
>
|
>
|
||||||
<AgentSection
|
<AgentSection
|
||||||
agent={record.agent}
|
agent={record.agent}
|
||||||
|
models={record.models}
|
||||||
|
onModelsChange={(next: ModelsSettings) =>
|
||||||
|
void write("models", next)
|
||||||
|
}
|
||||||
onSectionChange={(next: AgentSettings) =>
|
onSectionChange={(next: AgentSettings) =>
|
||||||
void write("agent", next)
|
void write("agent", next)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"use client";
|
||||||
|
/**
|
||||||
|
* The first-run setup flow (model-slots wave, 2026-09-20, owner design
|
||||||
|
* "A — composer CTA + send interception"): chats cannot start until the
|
||||||
|
* user has picked all three model slots — there are no defaults for 33
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* key because there are no models to pick then"):
|
||||||
|
* Stage 1 — no keyed provider: "add a provider key first" + a button
|
||||||
|
* that deep-links the settings dialog on Keys. The draft lives in
|
||||||
|
* the caller's state, untouched; after the key lands the next
|
||||||
|
* submit/CTA click reopens here at stage 2 (the /api/models check is
|
||||||
|
* fresh per open — saving a key immediately widens the pickers).
|
||||||
|
* Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog
|
||||||
|
* (keyed providers only), the vision slot filtered to image-capable
|
||||||
|
* models, in the app's shared dropdown language (ModelMenuList).
|
||||||
|
* All three required (owner: no skip, no defaults); Save PUTs the
|
||||||
|
* models section and reports onDone(models).
|
||||||
|
*
|
||||||
|
* Closable without configuring (Esc/backdrop/✕) — browsing the app stays
|
||||||
|
* possible; the GATE is the send interception, not the dialog. The
|
||||||
|
* settings-dialog visual language: dim backdrop, border rounded-md card,
|
||||||
|
* CMU title, z-50.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import type React from "react";
|
||||||
|
import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings } from "@/src/core";
|
||||||
|
import { prettyModel } from "@/components/usage-format";
|
||||||
|
import { ModelMenuList } from "@/components/settings/model-menu";
|
||||||
|
|
||||||
|
/** The pickers' slot identity — same roster language as the Agent
|
||||||
|
* section's slot rows. */
|
||||||
|
const SLOT_META: Record<ModelSlotName, { label: string; roster: string }> = {
|
||||||
|
strong: { label: "Strong", roster: "Prober + Teacher" },
|
||||||
|
cheap: { label: "Cheap", roster: "Researcher, demo builder, extractor, auditor, labeler" },
|
||||||
|
vision: { label: "Vision", roster: "Visual maker + PDF reader" },
|
||||||
|
};
|
||||||
|
const SLOT_ORDER: readonly ModelSlotName[] = ["strong", "cheap", "vision"];
|
||||||
|
|
||||||
|
/** The GET /api/models response fields the flow consumes. */
|
||||||
|
interface ModelsBody {
|
||||||
|
models: ModelCatalogEntry[];
|
||||||
|
providers: { id: string; keyPresent: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure: which stage the flow opens at — no keyed provider = keys first
|
||||||
|
* (owner's dependency ordering). Exported for tests. */
|
||||||
|
export function setupStage(
|
||||||
|
providers: { id: string; keyPresent: boolean }[] | null,
|
||||||
|
): "keys" | "pickers" {
|
||||||
|
if (providers === null) return "keys"; // fetch failed → the honest fallback
|
||||||
|
return providers.some((p) => p.keyPresent) ? "pickers" : "keys";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SetupDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
/** Saved: the models section landed; the caller marks configured and
|
||||||
|
* replays the intercepted draft. */
|
||||||
|
onDone,
|
||||||
|
/** Deep-link to the Keys section ("add a provider key first"). */
|
||||||
|
onOpenKeys,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: (models: ModelsSettings) => void;
|
||||||
|
onOpenKeys: () => void;
|
||||||
|
}) {
|
||||||
|
const [body, setBody] = useState<ModelsBody | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [picked, setPicked] = useState<Partial<Record<ModelSlotName, ModelCatalogEntry>>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [openSlot, setOpenSlot] = useState<ModelSlotName | null>(null);
|
||||||
|
|
||||||
|
// Fresh /api/models per open — a key saved a moment ago (stage 1 →
|
||||||
|
// Keys → back) widens the pickers immediately, no restart.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setPicked({});
|
||||||
|
setError(null);
|
||||||
|
fetch("/api/models")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((b: ModelsBody) => {
|
||||||
|
setBody(b);
|
||||||
|
setFailed(false);
|
||||||
|
})
|
||||||
|
.catch(() => setFailed(true));
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Esc + outside-click — layered dismissal, closable WITHOUT
|
||||||
|
// configuring (browsing stays possible; the gate is the send
|
||||||
|
// interception, not this dialog). An open slot menu eats Esc first
|
||||||
|
// (and any click outside it); only a bare Esc closes the dialog.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
if (openSlot !== null) setOpenSlot(null);
|
||||||
|
else onClose();
|
||||||
|
};
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
if (openSlot === null) return;
|
||||||
|
const t = e.target as Element;
|
||||||
|
if (t.closest("[data-lh-menu]") || t.closest(`[data-testid="setup-trigger-${openSlot}"]`)) return;
|
||||||
|
setOpenSlot(null);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
document.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
};
|
||||||
|
}, [open, onClose, openSlot]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const stage = failed ? "keys" : setupStage(body ? body.providers : null);
|
||||||
|
const allPicked = SLOT_ORDER.every((s) => picked[s]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!allPicked || saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
const models: ModelsSettings = {};
|
||||||
|
for (const slot of SLOT_ORDER) models[slot] = { model: { provider: picked[slot]!.provider, id: picked[slot]!.id } };
|
||||||
|
const res = await fetch("/api/settings/models", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(models),
|
||||||
|
}).catch(() => null);
|
||||||
|
if (!res || !res.ok) {
|
||||||
|
setSaving(false);
|
||||||
|
setError(res ? `Save failed: ${(await res.json().catch(() => ({}))).error ?? res.status}` : "Save failed — network");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDone(models);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
|
||||||
|
data-testid="setup-backdrop"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Set up your models"
|
||||||
|
data-testid="setup-dialog"
|
||||||
|
className="settings-dialog-pop flex max-h-[85vh] w-[640px] max-w-[90vw] flex-col overflow-hidden rounded-md border border-border bg-card shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5">
|
||||||
|
<h2 className="font-title text-2xl tracking-tight">Set up your models</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close setup"
|
||||||
|
data-testid="setup-close"
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-auto text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{stage === "keys" ? (
|
||||||
|
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
|
||||||
|
<p className="text-sm text-foreground">
|
||||||
|
{failed ? "Model catalog could not be loaded — check the connection and retry." : "First, add a provider key."}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The model pickers list only what your keys unlock — there is nothing to pick until one exists.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="setup-open-keys"
|
||||||
|
onClick={onOpenKeys}
|
||||||
|
className="rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
|
||||||
|
>
|
||||||
|
Open Keys settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid="setup-stage-pickers">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Pick the model for each tier — all three are required before chats can start.
|
||||||
|
</p>
|
||||||
|
{SLOT_ORDER.map((slot) => {
|
||||||
|
const entry = picked[slot];
|
||||||
|
const catalog =
|
||||||
|
slot === "vision" ? body?.models.filter((m) => m.input.includes("image")) : body?.models;
|
||||||
|
return (
|
||||||
|
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
|
||||||
|
<span className="font-ui text-sm text-foreground">{SLOT_META[slot].label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${SLOT_META[slot].label} model`}
|
||||||
|
data-testid={`setup-trigger-${slot}`}
|
||||||
|
aria-expanded={openSlot === slot}
|
||||||
|
onClick={() => setOpenSlot(openSlot === slot ? null : slot)}
|
||||||
|
className="flex h-7 min-w-40 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{entry ? prettyModel(entry.id) : "Pick a model…"}</span>
|
||||||
|
<span aria-hidden className="text-muted-foreground">
|
||||||
|
▾
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{openSlot === slot && (
|
||||||
|
<div
|
||||||
|
data-testid={`setup-menu-${slot}`}
|
||||||
|
data-lh-menu=""
|
||||||
|
className="absolute top-full left-0 z-40 flex max-h-96 w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
|
||||||
|
>
|
||||||
|
<ModelMenuList
|
||||||
|
models={catalog}
|
||||||
|
effectiveId={entry?.id}
|
||||||
|
effectiveProvider={entry?.provider}
|
||||||
|
onPick={(m) => {
|
||||||
|
setPicked((prev) => ({ ...prev, [slot]: m }));
|
||||||
|
setOpenSlot(null);
|
||||||
|
}}
|
||||||
|
footer={catalog && catalog.length === 0 ? "No image-capable models — widen the provider keys" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="setup-save"
|
||||||
|
disabled={!allPicked || saving}
|
||||||
|
onClick={() => void save()}
|
||||||
|
className="rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Save models"}
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -491,7 +491,7 @@ function SessionsSidebar() {
|
|||||||
<div className="px-3 pb-3 pt-1">
|
<div className="px-3 pb-3 pt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openSettings}
|
onClick={() => openSettings()}
|
||||||
data-testid="settings-entry"
|
data-testid="settings-entry"
|
||||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function StatsBar({
|
|||||||
return (
|
return (
|
||||||
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
|
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate text-muted-foreground">
|
<span className="truncate text-muted-foreground">
|
||||||
#{i + 1} · {prettyModel(turn.model)}
|
#{i + 1} · {prettyModel(turn.model, true)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
||||||
<span className="text-muted-foreground">{turn.steps.length} steps</span>
|
<span className="text-muted-foreground">{turn.steps.length} steps</span>
|
||||||
|
|||||||
@@ -15,14 +15,30 @@ export function formatTokens(n: number): string {
|
|||||||
return String(n);
|
return String(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Split a provider-qualified stamp "provider:id" (the usage contracts'
|
||||||
|
* qualification, model-slots wave 2026-09-20) into its two coordinates.
|
||||||
|
* No colon = no provider qualification (a bare catalog id). */
|
||||||
|
export function splitQualified(id: string): { provider: string | null; id: string } {
|
||||||
|
const colon = id.indexOf(":");
|
||||||
|
if (colon === -1) return { provider: null, id };
|
||||||
|
return { provider: id.slice(0, colon), id: id.slice(colon + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "z-ai/glm-5.3-flash" → "Glm 5.3 Flash" — vendor prefix dropped, dashes to
|
* "z-ai/glm-5.3-flash" → "Glm 5.3 Flash" — vendor prefix dropped, dashes to
|
||||||
* spaces, capitalized; a trailing all-digit segment (OpenRouter's date
|
* spaces, capitalized; a trailing all-digit segment (OpenRouter's date
|
||||||
* suffix, "qwen3.8-max-0902") is dropped. Quiet-chip friendly.
|
* suffix, "qwen3.8-max-0902") is dropped. Quiet-chip friendly.
|
||||||
|
*
|
||||||
|
* With `qualified` (usage stamps are "provider:id", model-slots wave):
|
||||||
|
* the provider rides as a quiet suffix — "Glm 5.3 Flash · openrouter" —
|
||||||
|
* because with federation the same model via two providers is genuinely
|
||||||
|
* different bookkeeping. Bare catalog ids are unchanged.
|
||||||
*/
|
*/
|
||||||
export function prettyModel(id: string): string {
|
export function prettyModel(id: string, qualified = false): string {
|
||||||
const short = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id;
|
const { provider, id: bare } = splitQualified(id);
|
||||||
|
const short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare;
|
||||||
const parts = short.split("-");
|
const parts = short.split("-");
|
||||||
const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts;
|
const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts;
|
||||||
return trimmed.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
|
const pretty = trimmed.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
|
||||||
|
return qualified && provider ? `${pretty} · ${provider}` : pretty;
|
||||||
}
|
}
|
||||||
@@ -87,6 +87,15 @@ export interface ModelsSettings {
|
|||||||
vision?: ModelSlot;
|
vision?: ModelSlot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** First-run gate (model-slots wave, owner 2026-09-20): the app refuses
|
||||||
|
* to start chats until the user has actively picked all three slots —
|
||||||
|
* there ARE no defaults for 33 providers; a configured app is one whose
|
||||||
|
* models section carries a model in every slot. Thinking levels stay
|
||||||
|
* optional (absent = the resolved tier's budget). */
|
||||||
|
export function slotsConfigured(models: ModelsSettings | undefined): boolean {
|
||||||
|
return Boolean(models?.strong?.model && models?.cheap?.model && models?.vision?.model);
|
||||||
|
}
|
||||||
|
|
||||||
/** Which slot a role draws from (model slots wave, 2026-09-20). Includes the
|
/** Which slot a role draws from (model slots wave, 2026-09-20). Includes the
|
||||||
* conversation pair and pdfReader (not BackgroundModelRoles — the slot
|
* conversation pair and pdfReader (not BackgroundModelRoles — the slot
|
||||||
* tier spans every role; only background roles surface per-role overrides). */
|
* tier spans every role; only background roles surface per-role overrides). */
|
||||||
|
|||||||
@@ -59,42 +59,60 @@ describe("SettingsDialog (the designed surface, SSR)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AgentSection (thin SSR)", () => {
|
describe("AgentSection (thin SSR)", () => {
|
||||||
it("renders all six background-role rows, Default-triggered dropdowns", () => {
|
it("renders the three model SLOT rows (slots-only, owner ruling 2026-09-20), Default-triggered dropdowns", () => {
|
||||||
const html = renderToStaticMarkup(<AgentSection agent={undefined} onSectionChange={() => {}} />);
|
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
|
||||||
for (const role of ["researcher", "demoBuilder", "visualMaker", "extractor", "auditor", "labeler"]) {
|
for (const slot of ["strong", "cheap", "vision"]) {
|
||||||
assert.ok(html.includes(`data-testid="role-row-${role}"`), role);
|
assert.ok(html.includes(`data-testid="slot-row-${slot}"`), slot);
|
||||||
// The shared model-picker dropdown as the opened menu (owner,
|
// The shared model-picker dropdown as the opened menu (owner,
|
||||||
// 2026-09-20), the thin trigger as the collapsed look.
|
// 2026-09-20), the thin trigger as the collapsed look.
|
||||||
assert.ok(html.includes(`data-testid="role-model-${role}"`), `${role} model trigger`);
|
assert.ok(html.includes(`data-testid="slot-model-${slot}"`), `${slot} model trigger`);
|
||||||
assert.ok(html.includes(`data-testid="role-thinking-${role}"`), `${role} thinking trigger`);
|
assert.ok(html.includes(`data-testid="slot-thinking-${slot}"`), `${slot} thinking trigger`);
|
||||||
|
// the roster hint rides every row
|
||||||
|
assert.ok(html.includes("Prober + Teacher") || slot !== "strong", "strong roster");
|
||||||
|
}
|
||||||
|
assert.equal((html.match(/>Default<\/span>/g) ?? []).length, 6, "three slots × two Default triggers");
|
||||||
|
// the per-role rows are GONE (slots-only)
|
||||||
|
for (const role of ["researcher", "demoBuilder", "visualMaker"]) {
|
||||||
|
assert.ok(!html.includes(`data-testid="role-row-${role}"`), `${role} retired`);
|
||||||
}
|
}
|
||||||
assert.equal((html.match(/>Default<\/span>/g) ?? []).length, 12, "six roles × two Default triggers");
|
|
||||||
assert.ok(html.includes('data-testid="group-models"'), "Models sub-group");
|
assert.ok(html.includes('data-testid="group-models"'), "Models sub-group");
|
||||||
assert.ok(html.includes('data-testid="group-tools"'), "Tools sub-group");
|
assert.ok(html.includes('data-testid="group-tools"'), "Tools sub-group");
|
||||||
assert.ok(html.includes('data-testid="group-prompts"'), "System prompts sub-group");
|
assert.ok(html.includes('data-testid="group-prompts"'), "System prompts sub-group");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("the search-engine row renders with its live reason (search wave 2026-09-20)", () => {
|
||||||
|
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
|
||||||
|
assert.ok(html.includes('data-testid="search-engine-row"'), "the row");
|
||||||
|
assert.ok(html.includes('data-testid="search-engine-trigger"'), "the trigger");
|
||||||
|
// SSR: /api/search never fired → no reason line yet, trigger reads Automatic
|
||||||
|
assert.ok(html.includes("Automatic"), "unset engine reads Automatic");
|
||||||
|
// the explicit preference shows the chosen engine in the trigger
|
||||||
|
const html2 = renderToStaticMarkup(
|
||||||
|
<AgentSection agent={{ search: { engine: "tavily" } }} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />,
|
||||||
|
);
|
||||||
|
assert.ok(html2.includes("Tavily"), "pinned engine visible in the trigger");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the owner's FINAL tool list — research, demo, visual, pdf", () => {
|
it("renders the owner's FINAL tool list — research, demo, visual, pdf", () => {
|
||||||
assert.deepEqual([...TOGGLE_TOOLS], ["research", "demo", "visual", "pdf"]);
|
assert.deepEqual([...TOGGLE_TOOLS], ["research", "demo", "visual", "pdf"]);
|
||||||
const html = renderToStaticMarkup(<AgentSection agent={undefined} onSectionChange={() => {}} />);
|
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
|
||||||
for (const id of TOGGLE_TOOLS) assert.ok(html.includes(`data-testid="tool-row-${id}"`), id);
|
for (const id of TOGGLE_TOOLS) assert.ok(html.includes(`data-testid="tool-row-${id}"`), id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tools default ON (absent key = enabled)", () => {
|
it("tools default ON (absent key = enabled)", () => {
|
||||||
const html = renderToStaticMarkup(<AgentSection agent={undefined} onSectionChange={() => {}} />);
|
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
|
||||||
for (const id of TOGGLE_TOOLS) {
|
for (const id of TOGGLE_TOOLS) {
|
||||||
assert.ok(tagOf(html, `toggle-tool-${id}`).includes('aria-checked="true"'), `${id} defaults on`);
|
assert.ok(tagOf(html, `toggle-tool-${id}`).includes('aria-checked="true"'), `${id} defaults on`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an overridden role shows its model in the select, tools carry the delta", () => {
|
it("an overridden SLOT shows its model in the trigger, tools carry the delta", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<AgentSection
|
<AgentSection
|
||||||
agent={{
|
agent={{ tools: { demo: false } }}
|
||||||
models: { researcher: { model: { provider: "openrouter", id: "z-ai/glm-4.7-flash" } } },
|
models={{ strong: { model: { provider: "zai", id: "glm-4.7-flash" } } }}
|
||||||
tools: { demo: false },
|
|
||||||
}}
|
|
||||||
onSectionChange={() => {}}
|
onSectionChange={() => {}}
|
||||||
|
onModelsChange={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
// The trigger shows the stored id even with no catalog loaded (a
|
// The trigger shows the stored id even with no catalog loaded (a
|
||||||
@@ -105,7 +123,7 @@ describe("AgentSection (thin SSR)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prompts show the loading state under SSR (effects never fire)", () => {
|
it("prompts show the loading state under SSR (effects never fire)", () => {
|
||||||
const html = renderToStaticMarkup(<AgentSection agent={undefined} onSectionChange={() => {}} />);
|
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
|
||||||
assert.ok(html.includes("Loading prompt segments"));
|
assert.ok(html.includes("Loading prompt segments"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,4 +73,15 @@ describe("stats bar", () => {
|
|||||||
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "Glm 5.3 Flash");
|
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "Glm 5.3 Flash");
|
||||||
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "Qwen3.8 Max");
|
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "Qwen3.8 Max");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("provider-qualified stamps (usage contract, model-slots wave)", () => {
|
||||||
|
// qualified: the provider rides as a quiet suffix — federation makes
|
||||||
|
// the same model via two providers different bookkeeping
|
||||||
|
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "Glm 5.3 Flash · openrouter");
|
||||||
|
assert.equal(prettyModel("openai:gpt-5.2", true), "Gpt 5.2 · openai");
|
||||||
|
// unqualified on a qualified stamp: provider silently dropped (old shape)
|
||||||
|
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "Glm 5.3 Flash");
|
||||||
|
// qualified on a bare id: no suffix invented
|
||||||
|
assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "Glm 5.3 Flash");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+78
-12
@@ -8,10 +8,12 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { appearanceVars, derivedPalette, resolveAppearance } from "../components/settings/appearance.tsx";
|
import { appearanceVars, derivedPalette, resolveAppearance } from "../components/settings/appearance.tsx";
|
||||||
import { composePromptSwitch, composeRoleModel, composeTool } from "../components/settings/agent-section.tsx";
|
import { composePromptSwitch, composeSearchEngine, composeSlotModel, composeTool } from "../components/settings/agent-section.tsx";
|
||||||
import { nextOverrides } from "../components/settings/keyboard-section.tsx";
|
import { nextOverrides } from "../components/settings/keyboard-section.tsx";
|
||||||
import { classifyKeys, keyStatusLine, type ProviderKeyInfo } from "../components/settings/keys-section.tsx";
|
import { classifyKeys, keyStatusLine, type ProviderKeyInfo } from "../components/settings/keys-section.tsx";
|
||||||
import type { AuthKeyStatus } from "../src/core";
|
import type { AgentSettings, AuthKeyStatus, ModelsSettings } from "../src/core";
|
||||||
|
import { slotsConfigured } from "../src/core";
|
||||||
|
import { setupStage } from "../components/setup-dialog.tsx";
|
||||||
|
|
||||||
describe("resolveAppearance — delta over factory", () => {
|
describe("resolveAppearance — delta over factory", () => {
|
||||||
it("absent everything = the factory view", () => {
|
it("absent everything = the factory view", () => {
|
||||||
@@ -105,23 +107,50 @@ describe("agent section compose helpers — the replace-whole PUT body", () => {
|
|||||||
assert.deepEqual(after.prompts, { "system-prompt.md": true }, "prompts untouched");
|
assert.deepEqual(after.prompts, { "system-prompt.md": true }, "prompts untouched");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("composeRoleModel: empty+empty REMOVES the role (absent = config pin)", () => {
|
it("composeSlotModel: empty+empty REMOVES the slot (absent = factory pin)", () => {
|
||||||
const after = composeRoleModel({ models: { researcher: { model: { provider: "openrouter", id: "x" } }, auditor: {} } }, "researcher", "", "");
|
const after = composeSlotModel(
|
||||||
assert.equal(after.models?.researcher, undefined, "role dropped");
|
{ strong: { model: { provider: "openrouter", id: "x" } }, cheap: {} },
|
||||||
assert.deepEqual(after.models?.auditor, {}, "sibling untouched");
|
"strong",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
assert.equal(after.strong, undefined, "slot dropped");
|
||||||
|
assert.deepEqual(after.cheap, {}, "sibling untouched");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("composeRoleModel: model alone, thinking alone, and both", () => {
|
it("composeSlotModel: model alone, thinking alone, and both — provider rides the swap (federation)", () => {
|
||||||
assert.deepEqual(composeRoleModel(undefined, "labeler", "z-ai/glm-4.7-flash", "").models?.labeler, {
|
assert.deepEqual(composeSlotModel(undefined, "strong", "zai", "glm-4.7-flash", "").strong, {
|
||||||
model: { provider: "openrouter", id: "z-ai/glm-4.7-flash" },
|
model: { provider: "zai", id: "glm-4.7-flash" },
|
||||||
});
|
});
|
||||||
assert.deepEqual(composeRoleModel(undefined, "labeler", "", "low").models?.labeler, { thinkingLevel: "low" });
|
assert.deepEqual(composeSlotModel(undefined, "cheap", "", "", "low").cheap, { thinkingLevel: "low" });
|
||||||
assert.deepEqual(composeRoleModel(undefined, "labeler", "m1", "high").models?.labeler, {
|
assert.deepEqual(composeSlotModel(undefined, "vision", "openai", "gpt-5.2", "high").vision, {
|
||||||
model: { provider: "openrouter", id: "m1" },
|
model: { provider: "openai", id: "gpt-5.2" },
|
||||||
thinkingLevel: "high",
|
thinkingLevel: "high",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("composeSlotModel: per-field independence — clearing the model keeps the thinking pin", () => {
|
||||||
|
const prev: ModelsSettings = { strong: { model: { provider: "zai", id: "glm-4.7-flash" }, thinkingLevel: "high" } };
|
||||||
|
const after = composeSlotModel(prev, "strong", "", "", "high");
|
||||||
|
assert.deepEqual(after.strong, { model: { provider: "zai", id: "glm-4.7-flash" }, thinkingLevel: "high" }, "model carried, thinking kept");
|
||||||
|
// clearing thinking keeps the model
|
||||||
|
const after2 = composeSlotModel(prev, "strong", "zai", "glm-4.7-flash", "");
|
||||||
|
assert.deepEqual(after2.strong, { model: { provider: "zai", id: "glm-4.7-flash" } }, "thinking dropped, model kept");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composeSearchEngine: null REMOVES the pin (auto-activation's resting state)", () => {
|
||||||
|
const withPin: AgentSettings = { search: { engine: "tavily" } };
|
||||||
|
const cleared = composeSearchEngine(withPin, null);
|
||||||
|
assert.equal(cleared.search, undefined);
|
||||||
|
// no pin to begin with → carried verbatim
|
||||||
|
assert.deepEqual(composeSearchEngine({ tools: { demo: true } }, null).tools, { demo: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composeSearchEngine: the explicit preference writes agent.search.engine", () => {
|
||||||
|
assert.deepEqual(composeSearchEngine(undefined, "brave").search, { engine: "brave" });
|
||||||
|
});
|
||||||
|
|
||||||
it("composePromptSwitch: on = explicit true, off = key REMOVED (absent = off)", () => {
|
it("composePromptSwitch: on = explicit true, off = key REMOVED (absent = off)", () => {
|
||||||
assert.deepEqual(composePromptSwitch({ prompts: { a: true } }, "b", true).prompts, { a: true, b: true });
|
assert.deepEqual(composePromptSwitch({ prompts: { a: true } }, "b", true).prompts, { a: true, b: true });
|
||||||
assert.deepEqual(composePromptSwitch({ prompts: { a: true, b: true } }, "b", false).prompts, { a: true });
|
assert.deepEqual(composePromptSwitch({ prompts: { a: true, b: true } }, "b", false).prompts, { a: true });
|
||||||
@@ -216,3 +245,40 @@ describe("keyStatusLine — the minimal at-a-glance state", () => {
|
|||||||
assert.equal(keyStatusLine({ id: "X", info: null, present: false, masked: null, source: "none", shadowedByEnv: false }), "not set");
|
assert.equal(keyStatusLine({ id: "X", info: null, present: false, masked: null, source: "none", shadowedByEnv: false }), "not set");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("first-run gate — slotsConfigured + setupStage (model-slots wave)", () => {
|
||||||
|
it("slotsConfigured: all three slot models required — thinking levels are optional", () => {
|
||||||
|
assert.equal(slotsConfigured(undefined), false, "no record = unconfigured");
|
||||||
|
assert.equal(slotsConfigured({}), false, "empty models section");
|
||||||
|
assert.equal(
|
||||||
|
slotsConfigured({
|
||||||
|
strong: { model: { provider: "zai", id: "glm-5.3-flash" } },
|
||||||
|
cheap: { model: { provider: "zai", id: "glm-4.7-flash" } },
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
"vision missing",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
slotsConfigured({
|
||||||
|
strong: { model: { provider: "zai", id: "glm-5.3-flash" } },
|
||||||
|
cheap: { model: { provider: "zai", id: "glm-4.7-flash" } },
|
||||||
|
vision: { model: { provider: "openai", id: "gpt-5.2" } },
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
"all three picked",
|
||||||
|
);
|
||||||
|
// a thinking-only pin does NOT configure the slot
|
||||||
|
assert.equal(
|
||||||
|
slotsConfigured({ strong: { thinkingLevel: "low" }, cheap: { thinkingLevel: "low" }, vision: { thinkingLevel: "low" } }),
|
||||||
|
false,
|
||||||
|
"thinking pins are not models",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setupStage: keys before models (owner's dependency ordering)", () => {
|
||||||
|
assert.equal(setupStage(null), "keys", "fetch failed → the honest fallback");
|
||||||
|
assert.equal(setupStage([]), "keys", "no providers = keys");
|
||||||
|
assert.equal(setupStage([{ id: "zai", keyPresent: false }]), "keys", "provider present but keyless");
|
||||||
|
assert.equal(setupStage([{ id: "zai", keyPresent: false }, { id: "openai", keyPresent: true }]), "pickers", "one keyed provider widens the pickers");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user