Files
learnharness/components/setup-cta.tsx
T
amosblomqvist 1f777f770c Zero-keys wave: provider-key gate renders honestly everywhere
Over Platform's 66dbea9 key-presence seam, the five owner-locked tasks:

1. Zero-keys pill — components/setup-cta.tsx: pure hasProviderKey
   (classifyKeys reuse — tool keys like Deepgram never count), useZeroKeys
   (GET /api/keys, re-read on the new lh:settings-saved 'keys' signal),
   ZeroKeysPill (testid setup-cta-keys) taking PRECEDENCE over the models
   CTA on chat/home/new-session. SettingsSection gains 'keys'; keys-section
   dispatches after save AND delete.

2. Setup stage 1 is self-contained: provider picker (MenuShell +
   ProviderMenuList) + key paste + Save via PUT /api/keys/:id — the dialog
   STAYS MOUNTED; its own save dispatches the keys signal, the fetch effect
   re-runs on it, and the stage flips to the pickers on its own.
   onOpenKeys + setup-open-keys die. SetupKeysBody exported for SSR pins.

3. parseProviderKeyRefusal (chat.tsx, envelope-verbatim twin of
   parseModelsRefusal) joins the revert-draft-and-open-setup interception;
   activity-dock reads 'needs a provider key'; dispatch-card shows
   'Add a key' — both deep-link setup, which lands on stage 1 by itself.

4. Dictation trio: the token route's only 503 (missing Deepgram key)
   becomes the client-authored DICTATION_KEYLESS short line; the errored
   idle click clears the error and deep-links Settings -> Keys ->
   DEEPGRAM_API_KEY via the new open(section, row) seam (dialog landOnRow
   -> SettingsDialogBody initialRow -> KeysSection openRow follow-effect).

Live-verified on :3001 under LH_IGNORE_ENV_KEYS=1 (the fresh-user switch):
pill on all three surfaces + precedence over the CTA, stage-1 save ->
auto-advance with dialog mounted, real 503 send refusal reverting the
draft and opening setup, dictation 503 -> short line -> Settings lands on
the unfolded Deepgram row with the error cleared, keys-section save and
delete both flipping the pill live, and a fabricated refused research job
rendering the 'Add a key' dispatch card. 926/926, tsc/lint/build green.
2026-09-24 23:20:59 +03:00

75 lines
3.1 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 { 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>
);
}