diff --git a/components/home.tsx b/components/home.tsx index 154f054..200e23b 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { CornerDownLeftIcon } from "lucide-react"; import { GraphView } from "@/components/graph-view"; import { SetupDialog } from "@/components/setup-dialog"; -import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; +import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { useDefaultSettings } from "@/components/default-settings"; import { DictationBar, useDictation } from "@/components/dictation"; import { SidebarToggle } from "@/components/sidebar"; @@ -30,6 +30,10 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings: // 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 [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. @@ -56,6 +60,17 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings: setSetupOpen(true); return; } + // The pre-send KEY guards (owner interlude, no-orphan-chats): the + // 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; `force` (setup's onDone replay) bypasses, and the + // server's 503 gate remains the belt for /chat/[id] sessions. + if (!force && (zeroKeys || strongKeyless)) { + setSetupOpen(true); + return; + } setBusy(true); try { const res = await fetch("/api/chats", { diff --git a/components/new-session.tsx b/components/new-session.tsx index 4285bfe..74e99db 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -8,7 +8,7 @@ import { DictationBar, useDictation } from "@/components/dictation"; import { useDefaultSettings } from "@/components/default-settings"; import { SidebarToggle } from "@/components/sidebar"; import { SetupDialog } from "@/components/setup-dialog"; -import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; +import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { StatsBar } from "@/components/stats-bar"; import type { ChatSettings } from "@/src/core"; @@ -168,6 +168,10 @@ export function NewSession({ // 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 [setupOpen, setSetupOpen] = useState(false); const send = useCallback((force = false) => { @@ -178,6 +182,16 @@ export function NewSession({ setSetupOpen(true); return; } + // 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; `force` + // (setup's onDone replay) bypasses, and the server's 503 gate + // remains the belt for /chat/[id] sessions. + if (!force && (zeroKeys || strongKeyless)) { + setSetupOpen(true); + return; + } setBusy(true); void startDraftSession(text, settings, fetch) .then((target) => { @@ -185,7 +199,7 @@ export function NewSession({ else setBusy(false); }) .catch(() => setBusy(false)); - }, [text, busy, settings, router, configured]); + }, [text, busy, settings, router, configured, zeroKeys, strongKeyless]); return ( <> diff --git a/components/setup-cta.tsx b/components/setup-cta.tsx index 246399f..dcf32c0 100644 --- a/components/setup-cta.tsx +++ b/components/setup-cta.tsx @@ -18,6 +18,8 @@ import { useCallback, useEffect, useState } from "react"; import type { AuthKeyStatus } from "@/src/core"; import { classifyKeys, type ProviderKeyInfo } from "@/components/settings/keys-section"; +import { useModelCatalog } from "@/components/model-picker"; +import type { ModelCatalogResponse } from "@/src/core"; import { useSettingsSaved } from "@/components/settings-signal"; /** Pure: does a MODEL-PROVIDER key exist among the statuses? Reuses @@ -72,4 +74,29 @@ export function ZeroKeysPill({ onOpen }: { onOpen: () => void }) { ); -} \ No newline at end of file +} +/** Pure: the strong slot is pinned to a provider whose key is GONE (owner + * interlude, 2026-09-25 — the original deleted-key scenario). The catalog + * already knows both halves: slots.strong is the resolved pin, providers[] + * lists only PRESENT-key providers (keyless ones are absent, the same + * discipline as the search engines). False when the strong slot is unset + * (the models guard owns that case — this check only runs when slots ARE + * configured) or the catalog hasn't loaded (the server gate remains the + * belt). Exported for pins. */ +export function strongKeyless(catalog: ModelCatalogResponse | undefined): boolean { + const strong = catalog?.slots.strong; + if (!strong) return false; + return !catalog.providers.some((p) => p.id === strong.model.provider && p.keyPresent); +} + +/** The strong-slot keyless truth for the PRE-SEND guard (home / /chat/new): + * the catalog seam with re-reads on BOTH signals that can flip it — "keys" + * (a provider key saved or deleted) and "models" (the strong slot + * re-pinned). Live-updates with the page, never mid-send. */ +export function useStrongKeyless(): boolean { + const { catalog, refresh } = useModelCatalog(); + useSettingsSaved((section) => { + if (section === "keys" || section === "models") refresh(); + }); + return strongKeyless(catalog); +} diff --git a/tests/zero-keys.test.tsx b/tests/zero-keys.test.tsx index c1fbf33..4c2907e 100644 --- a/tests/zero-keys.test.tsx +++ b/tests/zero-keys.test.tsx @@ -10,7 +10,8 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { hasProviderKey, ZeroKeysPill } from "../components/setup-cta.tsx"; +import { hasProviderKey, strongKeyless, ZeroKeysPill } from "../components/setup-cta.tsx"; +import type { ModelCatalogResponse } from "../src/core/index.ts"; import { SetupKeysBody } from "../components/setup-dialog.tsx"; import { DICTATION_KEYLESS, DEEPGRAM_KEY_ID } from "../components/dictation.tsx"; import type { AuthKeyStatus } from "../src/core/auth-keys.ts"; @@ -115,4 +116,41 @@ describe("the dictation keyless seam (owner dictation trio)", () => { it("the Deepgram row id matches the keys store (KeysSection openRow)", () => { assert.equal(DEEPGRAM_KEY_ID, "DEEPGRAM_API_KEY"); }); -}); \ No newline at end of file +}); +/** A minimal catalog double — only the two halves the strong-keyless guard + * reads: the strong pin and the present-key providers list. */ +const catalog = (strongProvider: string | null, ...keyedProviders: string[]): ModelCatalogResponse => ({ + models: [], + defaults: null, + rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null }, + slots: { + strong: strongProvider + ? { model: { provider: strongProvider, id: `${strongProvider}/model` }, thinkingLevel: "low" } + : null, + cheap: null, + vision: null, + }, + providers: keyedProviders.map((id) => ({ id, envVar: `${id.toUpperCase()}_API_KEY`, keyPresent: true })), +}); + +describe("strongKeyless — the pre-send guard's truth (owner interlude, no-orphan-chats)", () => { + it("strong pinned to a provider whose key is gone: the guard FIRES", () => { + // keys exist generally (anthropic), but the strong slot pins openrouter + assert.equal(strongKeyless(catalog("openrouter", "anthropic")), true); + // no keys at all with a strong pin: also keyless (guard case 1 fires too) + assert.equal(strongKeyless(catalog("openrouter")), true); + }); + + it("strong provider's key present: the guard does NOT fire — the normal send path stays open", () => { + assert.equal(strongKeyless(catalog("openrouter", "openrouter", "anthropic")), false); + // belt check: a provider row never counts when keyPresent is false + const lying = catalog("openrouter", "openrouter", "anthropic"); + lying.providers[0]!.keyPresent = false; + assert.equal(strongKeyless(lying), true); + }); + + it("no strong pin (the models guard owns that case) or catalog not loaded: false — the server gate remains the belt", () => { + assert.equal(strongKeyless(catalog(null, "openrouter")), false); + assert.equal(strongKeyless(undefined), false); + }); +});