Restore exclusive provider-first setup recovery
This commit is contained in:
+25
-26
@@ -5,7 +5,7 @@ import { useChatSession } from "@/components/session";
|
||||
import { visibleQueuedSteers } from "@/components/session-core";
|
||||
import { SetupDialog } from "@/components/setup-dialog";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
||||
import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta";
|
||||
import { useStickToBottomContext, type StickToBottomContext } from "use-stick-to-bottom";
|
||||
import {
|
||||
Conversation,
|
||||
@@ -78,8 +78,6 @@ import {
|
||||
} from "@/src/core";
|
||||
import { ReadingThinkingField } from "@/components/reading-thinking";
|
||||
import { currentUserThinkingOpen, setAllUserThinkingOpen } from "@/components/thinking-block";
|
||||
import { slotsConfigured, type ModelsSettings } from "@/src/core";
|
||||
import { useSettingsSaved } from "@/components/settings-signal";
|
||||
|
||||
/** One width for everything: transcript, cards, and the bottom blob share this column. */
|
||||
/**
|
||||
@@ -555,30 +553,18 @@ export function Chat({
|
||||
textRef.current = v;
|
||||
setText(v);
|
||||
}, []);
|
||||
// The BYOK setup flow (owner, 2026-09-22): the persistent CTA and the
|
||||
// refusal interception share one SetupDialog mount. `configured` is
|
||||
// seeded server-side (slotsConfigured on the record) and flips on Save.
|
||||
const [configured, setConfigured] = useState(initialConfigured);
|
||||
// The zero-keys truth (zero-keys wave): no provider key = the pill
|
||||
// takes precedence over the models CTA (keys come first in the chain).
|
||||
// Reads once on mount, re-reads on the "keys" signal.
|
||||
const zeroKeys = useZeroKeys();
|
||||
// Cross-surface freshness (fix wave): another surface's models write —
|
||||
// setup save, composer pick, a settings write — re-reads the record so
|
||||
// the setup CTA appears/disappears live (it used to lag until reload).
|
||||
useSettingsSaved((section) => {
|
||||
if (section !== "models") return;
|
||||
fetch("/api/settings")
|
||||
.then((r) => r.json() as Promise<{ settings?: { models?: ModelsSettings } | null } | null>)
|
||||
.then((body) => setConfigured(slotsConfigured(body?.settings?.models)))
|
||||
.catch(() => {});
|
||||
});
|
||||
// Saved slot completeness is live, but the session's selected route
|
||||
// comes from its own ordinary model control, never today's Strong pin.
|
||||
const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
|
||||
const catalogRef = useRef(catalog);
|
||||
catalogRef.current = catalog;
|
||||
const [selectedModel, setSelectedModel] = useState<string | undefined>();
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
/** The last explicitly-sent draft (composer send): the
|
||||
* refusal flow restores it into the composer, where it waits for the
|
||||
* learner to re-send (Save never auto-sends, owner 2026-10-09).
|
||||
* Auto-continuations (probe answers) never set it — their refusal
|
||||
* opens the dialog with no replay. */
|
||||
* opens recovery with no replay. */
|
||||
const draftRef = useRef<string | null>(null);
|
||||
|
||||
// THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's
|
||||
@@ -594,8 +580,9 @@ export function Chat({
|
||||
if (!error) return;
|
||||
const missing = parseModelsRefusal(error.message);
|
||||
// The key-refusal twin (zero-keys wave): a pinned model whose
|
||||
// provider key is gone gets the SAME treatment — revert-draft and
|
||||
// open setup (the dialog derives to its first-key stage on its own).
|
||||
// provider key is gone also restores the draft. With another provider
|
||||
// connected, repair belongs to the ordinary current-session picker;
|
||||
// otherwise setup derives its exclusive provider/loading stage.
|
||||
const keyRefused = parseProviderKeyRefusal(error.message);
|
||||
if (!missing && !keyRefused) return; // a real error keeps the honest banner
|
||||
// ORDER MATTERS: clearError() flips this effect's own dep (`error`)
|
||||
@@ -615,7 +602,8 @@ export function Chat({
|
||||
draftRef.current = null;
|
||||
}
|
||||
clearError();
|
||||
setSetupOpen(true);
|
||||
if (keyRefused && catalogRef.current?.providers.some((p) => p.keyPresent)) openModelPicker();
|
||||
else setSetupOpen(true);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -1146,6 +1134,14 @@ export function Chat({
|
||||
if (busy || pending || compacting) return;
|
||||
const t = textRef.current.trim();
|
||||
if (!t) return;
|
||||
if (zeroKeys) {
|
||||
setSetupOpen(true);
|
||||
return;
|
||||
}
|
||||
if (selectedModelUnavailable(catalog, selectedModel)) {
|
||||
openModelPicker();
|
||||
return; // No optimistic send and no change to the learner's draft.
|
||||
}
|
||||
draftRef.current = t; // the refusal flow's preserve-and-replay seam
|
||||
applyText("");
|
||||
void send({ type: "text", text: t });
|
||||
@@ -1605,6 +1601,7 @@ export function Chat({
|
||||
compactionRunning={compacting}
|
||||
onSubmitThinking={submitThinking}
|
||||
chatId={chatId}
|
||||
onModelChange={setSelectedModel}
|
||||
plan={hasPlan ? <PlanMarginalia messages={messages} /> : undefined}
|
||||
onOpenPlan={() => setPlanOpen(true)}
|
||||
requiresReveal={requiresReveal}
|
||||
@@ -1708,7 +1705,6 @@ export function Chat({
|
||||
onClose={() => setSetupOpen(false)}
|
||||
onDone={() => {
|
||||
setSetupOpen(false);
|
||||
setConfigured(true);
|
||||
// Save only saves (owner ruling 2026-10-09): the draft went
|
||||
// home into the composer at refusal time and STAYS there —
|
||||
// the learner sends it themselves.
|
||||
@@ -1843,6 +1839,7 @@ function DockOverlay({
|
||||
composer,
|
||||
onSubmitAnswer,
|
||||
chatId,
|
||||
onModelChange,
|
||||
onReserve,
|
||||
plan,
|
||||
onOpenPlan,
|
||||
@@ -1879,6 +1876,7 @@ function DockOverlay({
|
||||
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
||||
/** The model control's pass-through (the stats bar hosts it). */
|
||||
chatId: string;
|
||||
onModelChange: (model: string | undefined) => void;
|
||||
/** Reports the dock's settled height (block + gradient) — ChatView's
|
||||
* dockReserve state feeds the transcript's spacer from it. */
|
||||
onReserve: (h: number) => void;
|
||||
@@ -2331,6 +2329,7 @@ function DockOverlay({
|
||||
pricing={pricing}
|
||||
dictationSlot={dictationSlot}
|
||||
chatId={chatId}
|
||||
onModelChange={onModelChange}
|
||||
/>
|
||||
{/* The scroll-down disc (moved here from the Conversation,
|
||||
owner refinement round 2026-09-24): anchored to the BLOCK's
|
||||
|
||||
+12
-15
@@ -7,7 +7,7 @@ import { GraphView } from "@/components/graph-view";
|
||||
import { MinimalHome } from "@/components/minimal-home";
|
||||
import { SetupDialog } from "@/components/setup-dialog";
|
||||
import { ComposerActions, NodePickerControl } from "@/components/composer-actions";
|
||||
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
||||
import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta";
|
||||
import { DictationBar, joinDictation, useDictation } from "@/components/dictation";
|
||||
import { useFieldCycle } from "@/components/field-cycle";
|
||||
import { SidebarToggle } from "@/components/sidebar";
|
||||
@@ -41,15 +41,8 @@ export function Home({
|
||||
// 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);
|
||||
// The zero-keys truth (zero-keys wave): no provider key = the pill
|
||||
// takes precedence over the models CTA (keys come first in the chain).
|
||||
const zeroKeys = useZeroKeys();
|
||||
// The strong slot's keyless truth (pre-send key guards, owner interlude
|
||||
// 2026-09-25): keys may exist generally while the STRONG slot's pinned
|
||||
// provider has none — the same skip-everything treatment on send.
|
||||
const strongKeyless = useStrongKeyless();
|
||||
// stores slots only; the learner sends the message themselves).
|
||||
const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
// 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.
|
||||
@@ -123,13 +116,18 @@ export function Home({
|
||||
// models case guards BEFORE creation; the key cases must too — a
|
||||
// refused first send would otherwise leave an orphan error-shell
|
||||
// chat in the sidebar (the 503 lands only after POST /api/chats
|
||||
// already created the record). Same skip-everything-and-open-setup
|
||||
// treatment; the server's 503 gate remains the belt for /chat/[id]
|
||||
// sessions.
|
||||
if (zeroKeys || strongKeyless) {
|
||||
// already created the record). Zero providers opens connection setup;
|
||||
// an unavailable selection with another provider opens its ordinary
|
||||
// picker. The server's admission gate remains the belt.
|
||||
if (zeroKeys) {
|
||||
setSetupOpen(true);
|
||||
return;
|
||||
}
|
||||
const strong = catalog?.slots.strong?.model;
|
||||
if (selectedModelUnavailable(catalog, strong ? `${strong.provider}:${strong.id}` : undefined)) {
|
||||
openModelPicker();
|
||||
return; // Preserve the draft and never create an orphan session.
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
// The map context (owner, 2026-10-04): picks in EITHER mode ride
|
||||
@@ -326,7 +324,6 @@ export function Home({
|
||||
open={setupOpen}
|
||||
onClose={() => setSetupOpen(false)}
|
||||
onDone={() => {
|
||||
setConfigured(true);
|
||||
setSetupOpen(false);
|
||||
// Save only saves: the draft waits in the composer.
|
||||
}}
|
||||
|
||||
+12
-14
@@ -8,7 +8,7 @@ import { DictationBar, joinDictation, useDictation } from "@/components/dictatio
|
||||
import { useFieldCycle } from "@/components/field-cycle";
|
||||
import { SidebarToggle } from "@/components/sidebar";
|
||||
import { SetupDialog } from "@/components/setup-dialog";
|
||||
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
|
||||
import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill } from "@/components/setup-cta";
|
||||
import { StatsBar } from "@/components/stats-bar";
|
||||
import { ChainTableOverlay } from "@/components/chain-table.tsx";
|
||||
import { ComposerActions, NodePickerControl } from "@/components/composer-actions";
|
||||
@@ -275,14 +275,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
||||
// 33 providers, chats cannot start. The draft stays in the textarea;
|
||||
// Save stores the slots and never sends (owner ruling 2026-10-09) —
|
||||
// the learner sends the draft themselves.
|
||||
const [configured, setConfigured] = useState(initialConfigured);
|
||||
// The zero-keys truth (zero-keys wave): no provider key = the pill
|
||||
// takes precedence over the models CTA (keys come first in the chain).
|
||||
const zeroKeys = useZeroKeys();
|
||||
// The strong slot's keyless truth (pre-send key guards, owner interlude
|
||||
// 2026-09-25): keys may exist generally while the STRONG slot's pinned
|
||||
// provider has none — the same skip-everything treatment on send.
|
||||
const strongKeyless = useStrongKeyless();
|
||||
const { catalog, configured, zeroKeys } = useModelSetup(initialConfigured);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
|
||||
const send = useCallback(() => {
|
||||
@@ -295,12 +288,18 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
||||
// The pre-send KEY guards (owner interlude, no-orphan-chats): guard
|
||||
// BEFORE startDraftSession creates the record — a refused first
|
||||
// send would otherwise leave an orphan error-shell chat in the
|
||||
// sidebar. Same skip-everything-and-open-setup treatment; the
|
||||
// server's 503 gate remains the belt for /chat/[id] sessions.
|
||||
if (zeroKeys || strongKeyless) {
|
||||
// sidebar. Zero providers opens connection setup; an unavailable
|
||||
// selection with another provider opens the ordinary Strong picker.
|
||||
// The server's admission gate remains the belt.
|
||||
if (zeroKeys) {
|
||||
setSetupOpen(true);
|
||||
return;
|
||||
}
|
||||
const strong = catalog?.slots.strong?.model;
|
||||
if (selectedModelUnavailable(catalog, strong ? `${strong.provider}:${strong.id}` : undefined)) {
|
||||
openModelPicker();
|
||||
return; // The learner replaces it explicitly, then sends again.
|
||||
}
|
||||
setBusy(true);
|
||||
void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined)
|
||||
.then((target) => {
|
||||
@@ -308,7 +307,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
||||
else setBusy(false);
|
||||
})
|
||||
.catch(() => setBusy(false));
|
||||
}, [busy, router, configured, zeroKeys, strongKeyless]);
|
||||
}, [busy, router, configured, zeroKeys, catalog]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -369,7 +368,6 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
||||
open={setupOpen}
|
||||
onClose={() => setSetupOpen(false)}
|
||||
onDone={() => {
|
||||
setConfigured(true);
|
||||
setSetupOpen(false);
|
||||
// Save only saves (owner ruling 2026-10-09): the draft
|
||||
// waits in the composer; the learner sends it.
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
* its own save), the same re-read pattern chat.tsx uses for "models".
|
||||
*/
|
||||
|
||||
import type { AuthKeyStatus } from "@/src/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MODEL_SLOTS, type AuthKeyStatus } from "@/src/core";
|
||||
import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||
import { useModelCatalog } from "@/components/model-picker";
|
||||
import { useModelCatalog } from "@/components/model-catalog";
|
||||
import type { ModelCatalogResponse } from "@/src/core";
|
||||
|
||||
/** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses
|
||||
@@ -32,6 +33,33 @@ export function hasProviderKey(
|
||||
return classifyKeys(statuses, providers).providerRows.some((s) => s.present);
|
||||
}
|
||||
|
||||
/** Completeness is saved identity, not current provider availability. */
|
||||
export function savedSlotsConfigured(slots: ModelCatalogResponse["slots"] | undefined): boolean {
|
||||
return MODEL_SLOTS.every((slot) => !!slots?.[slot]?.model);
|
||||
}
|
||||
|
||||
/** Keep the last known completeness during connection invalidation, but
|
||||
* follow ordinary slot repairs as soon as their shared snapshot arrives. */
|
||||
export function useModelSetup(initialConfigured: boolean) {
|
||||
const { catalog } = useModelCatalog();
|
||||
const [lastConfigured, setLastConfigured] = useState(initialConfigured);
|
||||
const configured = catalog ? savedSlotsConfigured(catalog.slots) : lastConfigured;
|
||||
useEffect(() => { if (catalog) setLastConfigured(savedSlotsConfigured(catalog.slots)); }, [catalog]);
|
||||
return { catalog, configured, zeroKeys: catalog ? !catalog.providers.some((p) => p.keyPresent) : false };
|
||||
}
|
||||
|
||||
/** A known unavailable route needs an explicit replacement, never an
|
||||
* automatic provider/model substitution. Unknown metadata defers to the
|
||||
* server's admission gate instead of declaring a route disconnected. */
|
||||
export function selectedModelUnavailable(catalog: ModelCatalogResponse | undefined, selected: string | undefined): boolean {
|
||||
return !!catalog && !!selected && !catalog.models.some((m) => `${m.provider}:${m.id}` === selected);
|
||||
}
|
||||
|
||||
/** Reuse the ordinary control (also opened by the existing keyboard action). */
|
||||
export function openModelPicker(): void {
|
||||
window.dispatchEvent(new CustomEvent("lh:kbd-open-model-picker"));
|
||||
}
|
||||
|
||||
/** The zero-keys truth for the calling page: TRUE only after a
|
||||
* successful read says no provider key exists (pending and failed
|
||||
* reads render nothing — the pill must never flash on a gap, and a
|
||||
|
||||
+26
-19
@@ -24,8 +24,9 @@
|
||||
* 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).
|
||||
* Saved disconnected selections stay visible; Add provider opens the
|
||||
* connection body when no provider remains. Refreshes preserve edits.
|
||||
* Saved disconnected selections and edits are retained internally in
|
||||
* the exclusive provider stage. Connecting with all slots already saved
|
||||
* closes setup; otherwise the pickers require only the missing slots.
|
||||
* All three required (owner: no skip, no defaults); Save PUTs the
|
||||
* models section and reports onDone(models).
|
||||
*
|
||||
@@ -45,6 +46,7 @@ import { ModelMenuList } from "@/components/settings/model-menu";
|
||||
import { MenuShell } from "@/components/menu-shell";
|
||||
import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal";
|
||||
import { useModelCatalog } from "@/components/model-catalog";
|
||||
import { savedSlotsConfigured } from "@/components/setup-cta";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||
import { ProviderMenuList } from "@/components/settings/model-menu";
|
||||
@@ -247,12 +249,11 @@ export function SetupDialog(props: SetupDialogProps) {
|
||||
function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
||||
const backdropDismiss = useBackdropDismiss(onClose);
|
||||
const { catalog, failed, refresh } = useModelCatalog();
|
||||
const [lastBody, setLastBody] = useState<ModelsBody | undefined>(catalog);
|
||||
const body = catalog ?? lastBody;
|
||||
const providerStageSeen = useRef(false);
|
||||
const connectionCloseRequested = useRef(false);
|
||||
const [picked, setPicked] = useState(() => prefillFromSlots(catalog?.models ?? [], catalog?.slots));
|
||||
const [levels, setLevels] = useState(() => prefillLevels(catalog));
|
||||
const editedSlots = useRef(new Set<ModelSlotName>());
|
||||
const [showKeys, setShowKeys] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null);
|
||||
@@ -273,8 +274,8 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
||||
|
||||
// The keys signal (zero-keys wave): keys-section saves/deletes AND
|
||||
// THIS dialog's own first-key save dispatch it; the fetch effect below
|
||||
// re-runs on it, so saving a key flips the stage to the pickers with
|
||||
// the widened catalog — no restart, no Settings round-trip.
|
||||
// re-runs on it, so saving a key widens the catalog. Incomplete saved
|
||||
// slots advance to pickers; complete saved slots just close setup.
|
||||
const [keysTick, setKeysTick] = useState(0);
|
||||
useSettingsSaved((section) => {
|
||||
if (section === "keys") setKeysTick((t) => t + 1);
|
||||
@@ -291,7 +292,6 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!catalog) return;
|
||||
setLastBody(catalog);
|
||||
const pre = prefillFromSlots(catalog.models, catalog.slots);
|
||||
const preLevels = prefillLevels(catalog);
|
||||
// Fresh metadata is always used, but a response cannot reset an
|
||||
@@ -310,9 +310,18 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (catalog.providers.some((p) => p.keyPresent)) setShowKeys(false);
|
||||
}, [catalog]);
|
||||
|
||||
const stage = setupStage(catalog?.providers ?? null);
|
||||
const closeAfterConnection = stage === "pickers" && providerStageSeen.current && savedSlotsConfigured(catalog?.slots);
|
||||
useEffect(() => {
|
||||
if (stage === "keys") providerStageSeen.current = true;
|
||||
if (closeAfterConnection && !connectionCloseRequested.current) {
|
||||
connectionCloseRequested.current = true;
|
||||
onClose(); // Connection recovery is not a model-settings save.
|
||||
}
|
||||
}, [stage, closeAfterConnection, onClose]);
|
||||
|
||||
// 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
|
||||
@@ -354,14 +363,16 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
||||
};
|
||||
}, [onClose, openMenu, keyMenuOpen]);
|
||||
|
||||
const hasSelections = Object.keys(picked).length > 0 || SLOT_ORDER.some((slot) => body?.slots?.[slot]?.model);
|
||||
const stage = hasSelections ? "pickers" : setupStage(body ? body.providers : null);
|
||||
// Do not briefly paint model pickers between successful connection and
|
||||
// closing an already-configured setup. No saved model/thinking writes.
|
||||
if (closeAfterConnection) return null;
|
||||
|
||||
const allPicked = SLOT_ORDER.every((s) => picked[s]);
|
||||
|
||||
/** Stage 1's save: PUT the pasted key, dispatch the keys signal, and
|
||||
* CLEAN the local screen — the signal re-fetch widens the catalog and
|
||||
* the stage flips to the pickers on its own; the dialog stays mounted
|
||||
* the whole way. Shape-gate 400s keep their user-facing line inline. */
|
||||
* CLEAN the local key field. The fresh catalog decides whether to
|
||||
* close recovery or advance to the missing model slots. Shape-gate
|
||||
* 400s keep their user-facing line inline. */
|
||||
async function saveKey() {
|
||||
if (!keyProvider || !keyValue.trim() || keySaving) return;
|
||||
setKeySaving(true);
|
||||
@@ -465,10 +476,6 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick the model for each tier. All three are required before chats can start.
|
||||
</p>
|
||||
{!body?.providers.some((p) => p.keyPresent) && <>
|
||||
<button type="button" data-testid="setup-add-provider" aria-expanded={showKeys} className="self-start text-xs underline" onClick={() => setShowKeys((v) => !v)}>Add provider</button>
|
||||
{showKeys && keysBody}
|
||||
</>}
|
||||
{SLOT_ORDER.map((slot) => {
|
||||
const entry = picked[slot];
|
||||
const level = levels[slot];
|
||||
@@ -481,7 +488,7 @@ function OpenSetupDialog({ onClose, onDone }: SetupDialogProps) {
|
||||
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="font-ui text-sm text-foreground">{SLOT_META[slot].label}</span>
|
||||
{!body?.slots?.[slot]?.model && !picked[slot] && (
|
||||
{!catalog?.slots?.[slot]?.model && !picked[slot] && (
|
||||
<span
|
||||
data-testid={`setup-needed-${slot}`}
|
||||
className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-normal tracking-wide text-muted-foreground"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core";
|
||||
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
|
||||
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
||||
@@ -34,6 +34,7 @@ export function StatsBar({
|
||||
dictationSlot,
|
||||
chatId,
|
||||
leadingSlot,
|
||||
onModelChange,
|
||||
}: {
|
||||
turns: TurnUsage[];
|
||||
contextTokens?: number | null;
|
||||
@@ -43,6 +44,8 @@ export function StatsBar({
|
||||
* record; absent on the draft surfaces, where it binds the strong
|
||||
* slot (the new-session default). */
|
||||
chatId?: string;
|
||||
/** The same effective identity the visible model control owns. */
|
||||
onModelChange?: (model: string | undefined) => void;
|
||||
/** The dictation strip (STT wave, 2026-09-17): the row's middle is dead
|
||||
* space between the session data and the model control — the mic
|
||||
* button lives there, and while recording the meter unfurls to fill
|
||||
@@ -55,6 +58,10 @@ export function StatsBar({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string | undefined>();
|
||||
const modelChanged = useCallback((model: string | undefined) => {
|
||||
setSelectedModel(model);
|
||||
onModelChange?.(model);
|
||||
}, [onModelChange]);
|
||||
// Same dropdown discipline as the model control (owner, df4433f treatment,
|
||||
// stats round 1): close on any pointerdown outside the bar or on Escape.
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
@@ -192,7 +199,7 @@ export function StatsBar({
|
||||
{/* The model + thinking control — one controller for the conversation
|
||||
agents (prober/teacher); the record's `turns` above still folds
|
||||
the ACTUAL models billed, regardless of what the picker says. */}
|
||||
<ModelControl chatId={chatId} onModelChange={setSelectedModel} />
|
||||
<ModelControl chatId={chatId} onModelChange={modelChanged} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user