feat: no baked-in default model — the new-session controls ARE the default settings page

Owner reframe (2026-09-18): the model + thinking + context controls on
the home graph view and /chat/new are not a draft overlay on a baked-in
default — they ARE the settings page for the default model, and the
user's last choice IS the default. BYOK first boot has no keys and
possibly no OpenRouter, so there is NO default to show: the controls
render 'no model' ('—') until the first pick.

The flash was the deeper symptom: the surfaces rendered from client
state that started empty and fetched truth afterward, so every visit
flashed the config pin (GLM/low — a dev artifact) for a few frames.
Fix at the root: both pages are now force-dynamic server components
that read the record (readDefaultSettings → initialSettings) and hand
it to the client — the first frame shows truth. Dynamic routes aren't
cached client-side (staleTimes.dynamic = 0), so every navigation
re-reads the record; the useDefaultSettings hook keeps only save-side
state (optimistic set + PUT). The GET-on-mount and its touch-guard are
gone.

ModelControl gains noDefaultModel (the default surfaces only): unset
settings render no model/level, never the catalog pin; the thinking
menu no-ops until a model exists. Chat surfaces keep the catalog
fallback — the engine runs the config pin when a session record has no
override, so the control keeps showing what will actually run.

Renamed draft→default throughout: data/default-settings.json,
GET/PUT /api/settings/defaults, useDefaultSettings. New sessions
still inherit the default on creation exactly as before.

Live-verified: first-boot 'no model' with zero flash, picker +
Shift+Tab cycle, values held across session→/chat/new→home roundtrips
and a reload (10×100ms samples, never the old default), zero console
errors.
This commit is contained in:
amosblomqvist
2026-09-18 09:37:20 +03:00
parent a94a108e74
commit bd3086194f
16 changed files with 188 additions and 168 deletions
+44
View File
@@ -0,0 +1,44 @@
"use client";
/**
* THE default settings (owner, 2026-09-18): the new-session surfaces'
* model + thinking + knowledge-context controls are the settings page for
* the default model. There is NO baked-in default — BYOK first boot shows
* "no model" until the user picks one, and their last choice IS the
* default from then on. Both surfaces (the home graph view's topic dock
* and /chat/new) share ONE server-side record (GET/PUT
* /api/settings/defaults → data/default-settings.json).
*
* No loading state, no flash: the PAGES server-render the record
* (readDefaultSettings → initialSettings), so the controls show truth on
* the first frame — dynamic routes aren't cached client-side (staleTimes
* .dynamic = 0), so every navigation gets fresh server truth. This hook
* only holds the state between saves: initialSettings seeds it, and every
* change applies locally (optimistic — the controls only send valid
* values: the picker offers catalog models, the slider sends its stops)
* and persists fire-and-forget; a failed write logs, and the next
* navigation re-reads the persisted truth.
*/
import { useCallback, useState } from "react";
import type { ChatSettings } from "@/src/core";
export function useDefaultSettings(initialSettings: ChatSettings | null): {
settings: ChatSettings | undefined;
save: (settings: ChatSettings) => void;
} {
const [settings, setSettings] = useState<ChatSettings | undefined>(initialSettings ?? undefined);
const save = useCallback((next: ChatSettings) => {
setSettings(next);
void fetch("/api/settings/defaults", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(next),
}).catch((err: unknown) => {
console.error("default settings write failed:", err);
});
}, []);
return { settings, save };
}
-62
View File
@@ -1,62 +0,0 @@
"use client";
/**
* The DRAFT (pre-session) settings hook — the shared persistence behind
* the two new-session surfaces (the home graph view's topic dock and
* /chat/new). The model + thinking + knowledge-context choices live in
* ONE server-side record (GET/PUT /api/settings/draft →
* data/draft-settings.json), so whatever the learner last left the
* controls on is what every later visit shows (owner, draft-settings wave
* 2026-09-18): navigating away and back keeps the feel. Per-session
* settings are a different record (the chat's own) — untouched here.
*
* Contract: settings === undefined until the first GET resolves — the
* controls render config-pinned defaults in that window, same as today.
* The GET is touch-guarded: if the learner changes something before the
* load lands, the loaded copy is dropped, never clobbering the choice.
* Saves are optimistic (the controls only ever send valid values — the
* picker offers catalog models, the slider sends its stops) and
* fire-and-forget; a failed write logs, and the next mount falls back to
* the persisted truth.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { ChatSettings } from "@/src/core";
export function useDraftSettings(): {
settings: ChatSettings | undefined;
save: (settings: ChatSettings) => void;
} {
const [settings, setSettings] = useState<ChatSettings | undefined>(undefined);
const touchedRef = useRef(false);
useEffect(() => {
let cancelled = false;
fetch("/api/settings/draft")
.then((res) => (res.ok ? (res.json() as Promise<ChatSettings | null>) : null))
.then((loaded) => {
// Never clobber a choice made while the load was in flight.
if (!cancelled && loaded !== null && !touchedRef.current) setSettings(loaded);
})
.catch(() => {
/* transient — defaults stand, the next change rewrites the record */
});
return () => {
cancelled = true;
};
}, []);
const save = useCallback((next: ChatSettings) => {
touchedRef.current = true;
setSettings(next);
void fetch("/api/settings/draft", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(next),
}).catch((err: unknown) => {
console.error("draft settings write failed:", err);
});
}, []);
return { settings, save };
}
+8 -7
View File
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { CornerDownLeftIcon } from "lucide-react";
import { GraphView } from "@/components/graph-view";
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
import { useDraftSettings } from "@/components/draft-settings";
import { useDefaultSettings } from "@/components/default-settings";
import { DictationBar, useDictation } from "@/components/dictation";
import { SidebarToggle } from "@/components/sidebar";
import { StatsBar } from "@/components/stats-bar";
@@ -17,7 +17,7 @@ import type { ChatSettings, TurnUsage } from "@/src/core";
* under the header and the new-session dock, both of which float over it
* with no gradient scrims. Sessions live in the toggleable sidebar.
*/
export function Home() {
export function Home({ initialSettings }: { initialSettings: ChatSettings | null }) {
const router = useRouter();
const [topic, setTopic] = useState("");
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
@@ -26,13 +26,13 @@ export function Home() {
append: (t) => setTopic((v) => (v ? v.replace(/\s+$/, "") + " " + t : t)),
});
const [busy, setBusy] = useState(false);
// The model+thinking+context selection made pre-chat — the SHARED draft
// record (draft-settings wave, 2026-09-18): both new-session surfaces
// (this topic dock and /chat/new) read and write ONE persisted choice,
// so what the learner last left the controls on is what shows on return.
// THE default settings (owner, 2026-09-18): this topic dock IS the
// settings page for the default model — no baked-in default exists; the
// user's last choice (server-rendered in via initialSettings, shared
// with /chat/new through data/default-settings.json) IS the default.
// Still carried to the chat POST then PUT-on-create (the model picker
// defers its save when chatId is empty; see components/model-picker).
const { settings, save: saveDraft } = useDraftSettings();
const { settings, save: saveDraft } = useDefaultSettings(initialSettings);
const start = async () => {
const t = topic.trim();
@@ -143,6 +143,7 @@ export function Home() {
dictationSlot={<DictationBar dictation={dictation} />}
turns={[] as TurnUsage[]}
chatId=""
noDefaultModel
settings={settings}
onSettingsSaved={saveDraft}
leftSlot={
+15 -3
View File
@@ -86,6 +86,7 @@ export function ModelControl({
settings,
onSaved,
fallbackModelId,
noDefaultModel,
}: {
chatId: string;
/** The record's current settings (undefined = config pin). */
@@ -95,6 +96,11 @@ export function ModelControl({
/** The last turn's actual model (from the turns log) — the label until
* the catalog (with config's defaults) finishes loading. */
fallbackModelId?: string;
/** Default-surfaces only (owner, 2026-09-18): NO baked-in default —
* unset settings render "no model"/"—" (BYOK first boot), never the
* config pin. Chat surfaces keep the catalog-default fallback (the
* engine runs the pin, so the control shows what will run). */
noDefaultModel?: boolean;
}) {
const catalog = useModelCatalog();
const [open, setOpen] = useState<"model" | "thinking" | null>(null);
@@ -109,9 +115,12 @@ export function ModelControl({
const listRef = useRef<HTMLUListElement>(null);
const defaults = catalog?.defaults;
const effectiveId = settings?.model?.id ?? defaults?.model.id ?? fallbackModelId;
const effectiveLevel =
settings?.thinkingLevel ?? defaults?.thinkingLevel;
const effectiveId = noDefaultModel
? settings?.model?.id
: (settings?.model?.id ?? defaults?.model.id ?? fallbackModelId);
const effectiveLevel = noDefaultModel
? settings?.thinkingLevel
: (settings?.thinkingLevel ?? defaults?.thinkingLevel);
const entry = catalog?.models.find((m) => m.id === effectiveId);
const levels = entry?.supportedLevels ?? [];
@@ -250,6 +259,9 @@ export function ModelControl({
type="button"
data-testid="stats-thinking"
onClick={() => {
// No model yet (noDefaultModel first boot) → nothing to set a
// level FOR: the click is a no-op, never an empty menu.
if (entry === undefined) return;
setOpen((o) => (o === "thinking" ? null : "thinking"));
setError(null);
}}
+10 -9
View File
@@ -6,7 +6,7 @@ import { Conversation, ConversationContent, ConversationEmptyState } from "@/com
import { Composer } from "@/components/composer";
import { DictationBar, useDictation } from "@/components/dictation";
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
import { useDraftSettings } from "@/components/draft-settings";
import { useDefaultSettings } from "@/components/default-settings";
import { PhaseChip } from "@/components/phase-divider";
import { SidebarToggle } from "@/components/sidebar";
import { StatsBar } from "@/components/stats-bar";
@@ -124,6 +124,7 @@ export function DraftShell({
dictationSlot={<DictationBar dictation={dictation} />}
turns={[]}
chatId=""
noDefaultModel
settings={settings}
onSettingsSaved={onSettingsSaved}
// The session-data slot (owner, 2026-09-15): pre-chat the left
@@ -147,17 +148,17 @@ export function DraftShell({
);
}
export function NewSession() {
export function NewSession({ initialSettings }: { initialSettings: ChatSettings | null }) {
const router = useRouter();
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
// The pre-chat model+thinking+context selection — the SHARED draft
// record (draft-settings wave, 2026-09-18): /chat/new and the home
// topic dock read and write ONE persisted choice, so what the learner
// last left the controls on is what shows on return. ModelControl with
// an empty chatId never PUTs — it hands the choice up; it still rides
// the session POST then PUT-on-create.
const { settings, save: saveDraft } = useDraftSettings();
// THE default settings (owner, 2026-09-18): this surface and the home
// topic dock ARE the settings page for the default model — no baked-in
// default exists; the user's last choice (server-rendered in via
// initialSettings, shared with home through data/default-settings.json)
// IS the default. ModelControl with an empty chatId never PUTs — it
// hands the choice up; it still rides the session POST then PUT-on-create.
const { settings, save: saveDraft } = useDefaultSettings(initialSettings);
const send = useCallback(() => {
if (!text.trim() || busy) return;
+9
View File
@@ -34,11 +34,19 @@ export function StatsBar({
onSettingsSaved,
leftSlot,
dictationSlot,
noDefaultModel,
}: {
turns: TurnUsage[];
chatId: string;
settings?: ChatSettings;
onSettingsSaved: (settings: ChatSettings) => void;
/** Default-surfaces only (owner, 2026-09-18): there is NO baked-in
* default model — BYOK first boot shows "no model" until the user
* picks one, and that pick IS the default from then on. Chat surfaces
* keep the catalog-default fallback (the engine runs the config pin
* when a session record has no override — the control shows what will
* actually run). */
noDefaultModel?: boolean;
/** Pre-chat only: replaces the (empty) session-data summary — home and
* the /chat/new draft mount the knowledge-context slider here. */
leftSlot?: ReactNode;
@@ -175,6 +183,7 @@ export function StatsBar({
settings={settings}
onSaved={onSettingsSaved}
fallbackModelId={lastModel}
noDefaultModel={noDefaultModel}
/>
</div>
);