Files
learnharness/components/setup-cta.tsx
T
amosblomqvist 66090ed769 Pre-send key guards: no orphan error-shell chats on first send
The owner's asymmetry find (interlude over the zero-keys wave): the
models-unconfigured case guards BEFORE send — no request, no chat
created, setup opens — but the key cases only reacted to the 503 after
POST /api/chats had already created the record, leaving orphan error-shell
chats in the sidebar. The guards now twin the models mechanism on BOTH
creation paths (home start(), /chat/new send()):

- Zero keys (useZeroKeys reuse): no provider key present -> skip creation
  and send entirely, open SetupDialog (stage 1 first-key screen).
- Strong-provider key missing (owner-approved extension): keys exist
  generally but the strong slot's pinned provider has none — the original
  deleted-key scenario. New pure strongKeyless(catalog) in setup-cta.tsx:
  slots.strong resolved pin + providers[] (present-key only, the catalog's
  own federation surface), false when unconfigured (the models guard owns
  that case) or catalog not loaded (the server 503 gate remains the belt).
  useStrongKeyless rides useModelCatalog, re-reading on the 'keys' AND
  'models' signals (a deleted key or a re-pinned slot flips it live).

Both guards run after the configured check, same
skip-everything-and-open-setup treatment; 'force' (setup's onDone replay)
bypasses both — completing setup implies a keyed provider. /chat/[id]
sessions keep the 503 revert-draft interception unchanged: those chats
legitimately exist.

Pins: strongKeyless fires on a keyless strong pin (keys elsewhere or
none), never fires with the pin's key present (incl. the keyPresent=false
belt), false on unset pin / unloaded catalog. Normal-path pins ride the
existing startDraftSession contract suite. Live-verified on :3001 under
LH_IGNORE_ENV_KEYS=1 with a file-key anthropic + openrouter-pinned strong:
both guards on both pages (setup opens stage-1 / stage-2 respectively,
draft preserved, 154 chats unchanged, zero sidebar entries), normal path
with a keyed strong provider still creates and lands the prompt redirect.
929/929, tsc/lint/build green.
2026-09-25 00:46:10 +03:00

103 lines
4.5 KiB
TypeScript

"use client";
/**
* The zero-keys surfaces (zero-keys wave, 2026-09-25, owner-locked over
* Platform's 66dbea9 key-presence gate): when NO model-provider key
* exists, chat / home / /chat/new hoist a composer pill — "Add a
* provider key" — that takes PRECEDENCE over the models setup CTA
* (keys come first in the chain: the model pickers list only what
* keys unlock). Click opens the SetupDialog, whose stage derives to
* the first-key screen on its own.
*
* Truth: GET /api/keys — a PROVIDER key present (env or file; tool keys
* like Deepgram do not count — they unlock no models). Live updates ride
* the lh:settings-saved "keys" signal (keys-section dispatches after
* save AND delete; the setup dialog's first-key screen dispatches on
* its own save), the same re-read pattern chat.tsx uses for "models".
*/
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
* classifyKeys (the keys section's own classification — no drift):
* a status whose id is a provider env-var (or the pre-interlude
* fallback shape) with present=true counts, present or file-sourced,
* env-owned included (env wins but it EXISTS). Exported for pins. */
export function hasProviderKey(
statuses: readonly AuthKeyStatus[],
providers: readonly ProviderKeyInfo[] | undefined,
): boolean {
return classifyKeys(statuses, providers).providerRows.some((s) => s.present);
}
/** 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
* failed read claiming "zero keys" would be a lie the CTA flow then
* leans on). Re-reads on the keys signal. */
export function useZeroKeys(): boolean {
const [zeroKeys, setZeroKeys] = useState(false);
const read = useCallback(() => {
fetch("/api/keys")
.then((r) => r.json())
.then((body: { keys?: AuthKeyStatus[]; providers?: ProviderKeyInfo[] }) => {
setZeroKeys(!hasProviderKey(body.keys ?? [], body.providers));
})
.catch(() => {});
}, []);
useEffect(() => {
read();
}, [read]);
useSettingsSaved((section) => {
if (section === "keys") read();
});
return zeroKeys;
}
/** The pill itself — the models CTA's chrome and placement, the keys
* chain's copy. testid "setup-cta-keys" (the models CTA keeps
* "setup-cta"; probes can tell them apart). */
export function ZeroKeysPill({ onOpen }: { onOpen: () => void }) {
return (
<div className="pointer-events-auto mb-2 flex justify-center">
<button
type="button"
data-testid="setup-cta-keys"
onClick={onOpen}
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"
>
Add a provider key — chats start after one unlocks the models
</button>
</div>
);
}
/** 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);
}