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.
75 lines
3.1 KiB
TypeScript
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>
|
|
);
|
|
} |