From 1f777f770c277b0c80bb94fd1462f8a5e8343783 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Thu, 24 Sep 2026 23:20:59 +0300 Subject: [PATCH] Zero-keys wave: provider-key gate renders honestly everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/activity-dock.tsx | 13 +- components/artifacts/dispatch-card.tsx | 16 +- components/chat.tsx | 60 +++++-- components/dictation.tsx | 45 ++++- components/home.tsx | 43 ++--- components/new-session.tsx | 21 ++- components/settings-signal.ts | 5 +- components/settings/keys-section.tsx | 28 ++- components/settings/settings-dialog.tsx | 34 +++- components/setup-cta.tsx | 75 ++++++++ components/setup-dialog.tsx | 230 ++++++++++++++++++++---- tests/byok-refusal.test.ts | 17 +- tests/render-activity-dock.test.tsx | 11 ++ tests/render-cards.test.tsx | 11 ++ tests/zero-keys.test.tsx | 118 ++++++++++++ 15 files changed, 625 insertions(+), 102 deletions(-) create mode 100644 components/setup-cta.tsx create mode 100644 tests/zero-keys.test.tsx diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index 8eea8c6..7f89678 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -4,7 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import type { CallUsage, JobKind, JobRecord } from "@/src/core"; -import { isModelsRefusal, jobCost } from "@/src/core"; +import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core"; import { formatTokens, formatUsd } from "@/components/usage-format"; /** @@ -204,8 +204,15 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] { context, // BYOK (owner, 2026-09-22): a refusal is info, not failure — // the honest line the runner left ("failed") reads as setup - // needed, matching the dispatch card's quiet tone. - activity: isModelsRefusal(job.error) ? "needs model setup" : (job.activity ?? ""), + // needed, matching the dispatch card's quiet tone. The + // provider-key refusal twin (zero-keys wave, Platform's + // 66dbea9): a pinned model whose key is gone reads as the key + // being needed, same quiet tone. + activity: isModelsRefusal(job.error) + ? "needs model setup" + : isProviderKeyRefusal(job.error) + ? "needs a provider key" + : (job.activity ?? ""), model: usage?.model, steps: usage?.steps, }; diff --git a/components/artifacts/dispatch-card.tsx b/components/artifacts/dispatch-card.tsx index 9f64f5d..0bcb83e 100644 --- a/components/artifacts/dispatch-card.tsx +++ b/components/artifacts/dispatch-card.tsx @@ -4,7 +4,7 @@ import { useContext } from "react"; import { CheckIcon, SettingsIcon, XIcon } from "lucide-react"; import { JobsContext } from "@/components/jobs-context"; import { ChatActionsContext } from "@/components/chat"; -import { isModelsRefusal, type JobKind, type JobRecord } from "@/src/core"; +import { isModelsRefusal, isProviderKeyRefusal, type JobKind, type JobRecord } from "@/src/core"; /** * The dispatch card (contracts.ts "THE ASYNC PROTOCOL" §4): the tool-call @@ -36,8 +36,14 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J // BYOK (owner, 2026-09-22): a job refused for missing models is INFO, // not scary-error — gray glyph + "needs model setup" + the deep-link // into the SetupDialog (the same flow the send interception uses). + // The provider-key refusal twin (zero-keys wave): a pinned model + // whose key is gone reads as the key being needed — the deep-link + // lands on the SetupDialog's first-key stage on its own. if (isModelsRefusal(job.error)) { - return ; + return ; + } + if (isProviderKeyRefusal(job.error)) { + return ; } const failed = job.status === "error"; return ( @@ -53,7 +59,7 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J } /** The refused card: quiet info tone — nothing red, nothing "failed". */ -function DispatchRefusedCard({ label }: { label: string }) { +function DispatchRefusedCard({ label, line, button }: { label: string; line: string; button: string }) { const ctx = useContext(ChatActionsContext); return (
{label} - needs model setup + {line} {ctx && ( )}
diff --git a/components/chat.tsx b/components/chat.tsx index 00ee7d1..e82e063 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -5,7 +5,7 @@ import { useChat } from "@ai-sdk/react"; import { lastAssistantMessageIsCompleteWithToolCalls } from "ai"; import { createChatTransport } from "@/components/chat-transport"; import { SetupDialog } from "@/components/setup-dialog"; -import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx"; +import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { useStickToBottomContext } from "use-stick-to-bottom"; import { Conversation, @@ -25,7 +25,7 @@ import { DictationBar, useDictation } from "@/components/dictation"; import { JobsContext } from "@/components/jobs-context"; import { StatsBar } from "@/components/stats-bar"; import { useActivity } from "@/components/activity-dock"; -import { mergeAdopted, type ChatSettings, type ModelSlotName } from "@/src/core"; +import { isProviderKeyRefusal, mergeAdopted, type ChatSettings, type ModelSlotName } from "@/src/core"; import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block"; import { PlanMarginalia } from "@/components/plan-window"; import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay"; @@ -106,6 +106,21 @@ export function parseModelsRefusal(message: string | undefined): ModelSlotName[] } } +/** The provider-key refusal's transport seam (zero-keys wave, Platform's + * 66dbea9): same discipline as parseModelsRefusal — the 503 body rides + * the thrown error message verbatim, so the envelope's own matcher + * (core, invariant-tail) decides; the wording can never drift client- + * side. Exported pure for tests. */ +export function parseProviderKeyRefusal(message: string | undefined): boolean { + if (!message) return false; + try { + const body = JSON.parse(message) as { code?: unknown; error?: unknown }; + return body.code === "provider_key_missing" && typeof body.error === "string" && isProviderKeyRefusal(body.error); + } catch { + return false; + } +} + /** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; @@ -315,6 +330,10 @@ export function Chat({ // 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). @@ -326,7 +345,6 @@ export function Chat({ .catch(() => {}); }); const [setupOpen, setSetupOpen] = useState(false); - const { open: openSettings } = useSettingsDialog(); /** The last explicitly-sent draft (composer send, verdict note): the * refusal flow restores it into the composer and Save replays it. * Auto-continuations (probe answers) never set it — their refusal @@ -344,7 +362,11 @@ export function Chat({ useEffect(() => { if (!error) return; const missing = parseModelsRefusal(error.message); - if (!missing) return; // a real error keeps the honest banner + // 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). + 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`) // and would cancel the in-flight work below — so the banner dismiss, // the draft restore, and the dialog open all land TOGETHER after the @@ -1087,17 +1109,21 @@ export function Chat({ dictationSlot={} dictationCancel={dictation.cancel} setupCta={ - !configured && ( -
- -
+ zeroKeys ? ( + setSetupOpen(true)} /> + ) : ( + !configured && ( +
+ +
+ ) ) } composer={ @@ -1165,10 +1191,6 @@ export function Chat({ void sendMessage({ text: t }); } }} - onOpenKeys={() => { - setSetupOpen(false); - openSettings("keys"); - }} /> ); diff --git a/components/dictation.tsx b/components/dictation.tsx index c050eb8..6f1cdda 100644 --- a/components/dictation.tsx +++ b/components/dictation.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Loader2Icon, MicIcon } from "lucide-react"; +import { useSettingsDialog } from "@/components/settings/settings-dialog"; /** * Voice dictation — the cloud engine (STT wave 1, 2026-09-17). @@ -80,6 +81,17 @@ export const METER_TICK_MS = 60; /** Rolling history: 48 cells × 60ms ≈ 2.9s of recent loudness. */ export const METER_CELLS = 48; +/** The Deepgram key's id in the keys store (the env-var name — the Keys + * section's openRow state and the PUT /api/keys/:id seam). */ +export const DEEPGRAM_KEY_ID = "DEEPGRAM_API_KEY"; + +/** The client-authored KEYLESS line (zero-keys wave, owner 2026-09-25): + * the token route's raw 503 message is dev-speak ("add it to auth.json + * (desktop) or .env.local (dev)") — the surface says the short truth + * instead. Exported for pins; the errored idle button's click recognizes + * it and deep-links Settings → Keys → the Deepgram row. */ +export const DICTATION_KEYLESS = "Dictation needs a Deepgram key"; + /** RMS (0..1) → normalized 0..1 across the audible dB window. */ export function rmsToMeter(rms: number): number { if (rms <= 0) return 0; @@ -146,6 +158,10 @@ export type Dictation = { * semantics (owner, 2026-09-17 round 3): Esc is a cancel, not a stop — * before, it flushed exactly like the toggle did. */ cancel: () => void; + /** Clear the terminal error without starting a run (zero-keys wave): + * the keyless click leaves for Settings and the surface must come + * back clean. */ + clearError: () => void; }; /** @@ -208,11 +224,17 @@ export function useDictation({ append }: { append: (text: string) => void }): Di setPhase("idle"); }, []); - /** Mint a short-lived token (the route holds the real key server-side). */ + /** Mint a short-lived token (the route holds the real key server-side). + * The keyless 503 (no DEEPGRAM_API_KEY) is the route's ONLY 503 — the + * raw message is dev-speak, so the error becomes the short + * client-authored line (zero-keys wave, owner 2026-09-25). */ const mintToken = useCallback(async () => { const res = await fetch("/api/stt/token"); const data = (await res.json()) as { token?: string; message?: string }; - if (!res.ok || !data.token) throw new Error(data.message ?? `token route ${res.status}`); + if (!res.ok || !data.token) { + if (res.status === 503) throw new Error(DICTATION_KEYLESS); + throw new Error(data.message ?? `token route ${res.status}`); + } return data.token; }, []); @@ -392,7 +414,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di return () => window.removeEventListener("lh:kbd-cancel-dictation", cancel); }, [cancel]); - return { phase, error, levelRef, active: phase !== "idle", toggle, stop, cancel }; + return { phase, error, levelRef, active: phase !== "idle", toggle, stop, cancel, clearError: () => setError(null) }; } /** @@ -408,7 +430,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di * (full text in the tooltip), cleared on the next click. */ export function DictationBar({ dictation }: { dictation: Dictation }) { - const { phase, error, levelRef, toggle, active } = dictation; + const { phase, error, levelRef, toggle, active, clearError } = dictation; + const { open: openSettings } = useSettingsDialog(); + /** The keyless click (zero-keys wave, owner dictation trio): the errored + * idle button deep-links Settings → Keys → the Deepgram row and clears + * the error — the surface is clean when the user returns. Any other + * error keeps the old behavior (click = retry, which clears). */ + const keyless = error === DICTATION_KEYLESS; const [levels, setLevels] = useState(() => new Array(METER_CELLS).fill(0)); const recording = phase === "recording" || phase === "stopping"; /** Adaptive cell count (owner round 2): derived from the meter's @@ -455,7 +483,14 @@ export function DictationBar({ dictation }: { dictation: Dictation }) { return ( - + {zeroKeys ? ( + setSetupOpen(true)} /> + ) : ( + !configured && ( +
+ +
+ ) )}
{/* First-run setup (unconfigured): the CTA's target and the send - interception's landing surface. onDone replays the intercepted - draft; onOpenKeys deep-links the settings dialog (stage 1). */} + interception's landing surface — stage 1 is the self-contained + first-key screen (zero-keys wave); onDone replays the + intercepted draft. */} setSetupOpen(false)} @@ -186,11 +193,7 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings: setSetupOpen(false); void start(true); // the queued draft sends }} - onOpenKeys={() => { - setSetupOpen(false); - openSettings("keys"); - }} - /> + /> ); } diff --git a/components/new-session.tsx b/components/new-session.tsx index 1606883..4285bfe 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 { useSettingsDialog } from "@/components/settings/settings-dialog.tsx"; +import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { StatsBar } from "@/components/stats-bar"; import type { ChatSettings } from "@/src/core"; @@ -165,8 +165,10 @@ export function NewSession({ // 33 providers, chats cannot start. The draft stays in the textarea; // Save completes and the send replays. 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(); const [setupOpen, setSetupOpen] = useState(false); - const { open: openSettings } = useSettingsDialog(); const send = useCallback((force = false) => { if (!text.trim() || busy) return; @@ -195,7 +197,10 @@ export function NewSession({ settings={settings} onSettingsSaved={saveDraft} setupCta={ - !configured && ( + zeroKeys ? ( + setSetupOpen(true)} /> + ) : ( + !configured && (
+ ) ) } /> - {/* First-run setup: the send interception's landing surface. */} + {/* First-run setup: the send interception's landing surface — stage + 1 is the self-contained first-key screen (zero-keys wave). */} setSetupOpen(false)} @@ -218,11 +225,7 @@ export function NewSession({ setSetupOpen(false); send(true); // the queued draft sends }} - onOpenKeys={() => { - setSetupOpen(false); - openSettings("keys"); - }} - /> + /> ); } \ No newline at end of file diff --git a/components/settings-signal.ts b/components/settings-signal.ts index 0d8a5ad..05d427a 100644 --- a/components/settings-signal.ts +++ b/components/settings-signal.ts @@ -17,7 +17,10 @@ import { useEffect, useRef } from "react"; export const SETTINGS_SAVED_EVENT = "lh:settings-saved"; /** The settings sections a writer can land (the write seam's union). */ -export type SettingsSection = "general" | "agent" | "models" | "appearance" | "graph"; +// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section +// dispatches it after save AND delete — deletions used to be invisible to +// the zero-keys pill and the setup dialog's auto-advance. +export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph"; /** Writers call this after the PUT resolves ok — never before (a failed * write notifies nothing; the record is unchanged). */ diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 6ee7cc3..3e1c21a 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -28,6 +28,7 @@ import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core"; import { SettingsGroup } from "./controls.tsx"; import { ProviderMenuList } from "./model-menu.tsx"; import { MenuShell } from "@/components/menu-shell"; +import { notifySettingsSaved } from "@/components/settings-signal"; /** One pi-ai provider with plain API-key auth (the Platform seam — * derived from pi-ai's own env-key table; no drift possible). */ @@ -313,12 +314,21 @@ function AddProviderRow({ ); } -export function KeysSection() { +export function KeysSection({ + /** A deep-linked row to unfold on arrival (zero-keys wave): the + * dictation error's click lands here on DEEPGRAM_API_KEY. Followed + * while mounted too — the settings dialog's while-open retarget + * pattern. undefined = nothing seeded. */ + initialRow, +}: { + initialRow?: string; +}) { const [statuses, setStatuses] = useState(null); const [providers, setProviders] = useState(undefined); const [loading, setLoading] = useState(true); - /** The unfolded row (its id), null = all folded. */ - const [openRow, setOpenRow] = useState(null); + /** The unfolded row (its id), null = all folded. Seeded by the + * deep-link (initialRow) and re-followed when it retargets. */ + const [openRow, setOpenRow] = useState(initialRow ?? null); /** The paste editor for a freshly chosen Add-provider target. */ const [adding, setAdding] = useState(null); const [addError, setAddError] = useState(null); @@ -354,6 +364,13 @@ export function KeysSection() { void reload(); }, []); + // The deep-link follow: a retarget while the section is open (the + // same while-open pattern the dialog's section nav uses) unfolds the + // named row; a fresh mount seeds it via the useState initializer. + useEffect(() => { + if (initialRow !== undefined) setOpenRow(initialRow); + }, [initialRow]); + async function saveKey(id: string, value: string): Promise { const res = await fetch(`/api/keys/${id}`, { method: "PUT", @@ -365,12 +382,17 @@ export function KeysSection() { return body.error ?? `Save failed (${res.status})`; } await reload(); + // The cross-surface keys signal (zero-keys wave): the zero-keys pill, + // the setup dialog's stage advance, and any other keys reader hear + // that a key landed — deletions included (see deleteKey below). + notifySettingsSaved("keys"); return null; } async function deleteKey(id: string): Promise { await fetch(`/api/keys/${id}`, { method: "DELETE" }).catch(() => undefined); await reload(); + notifySettingsSaved("keys"); } if (loading || statuses === null) { diff --git a/components/settings/settings-dialog.tsx b/components/settings/settings-dialog.tsx index 9659720..7609049 100644 --- a/components/settings/settings-dialog.tsx +++ b/components/settings/settings-dialog.tsx @@ -60,7 +60,12 @@ const SECTIONS = [ ] as const; type SectionId = (typeof SECTIONS)[number]["id"]; -const DialogContext = createContext<{ open: (section?: string) => void }>({ open: () => {} }); +/** `open(section?, row?)` — the optional ROW deep-links into the Keys + * section with that row unfolded (zero-keys wave: the dictation error's + * click lands on the Deepgram row). `row` is the KEY ID (the env-var + * name, e.g. DEEPGRAM_API_KEY — KeysSection's openRow state), not the + * row's testid. */ +const DialogContext = createContext<{ open: (section?: string, row?: string) => void }>({ open: () => {} }); /** AppShell-level mount: the sidebar row and the mod+, chord call this. * `open(section)` lands the dialog on a specific section (the first-run @@ -72,14 +77,18 @@ export function SettingsDialogProvider({ }) { const [show, setShow] = useState(false); const [landOn, setLandOn] = useState("general"); - const open = useCallback((section?: string) => { + const [landOnRow, setLandOnRow] = useState(null); + const open = useCallback((section?: string, row?: string) => { if (section) setLandOn(section as SectionId); + setLandOnRow(row ?? null); setShow(true); }, []); return ( {children} - {show && setShow(false)} initialSection={landOn} />} + {show && ( + setShow(false)} initialSection={landOn} initialRow={landOnRow} /> + )} ); } @@ -88,7 +97,16 @@ export function useSettingsDialog() { return useContext(DialogContext); } -function SettingsDialog({ onClose, initialSection }: { onClose: () => void; initialSection: SectionId }) { +function SettingsDialog({ + onClose, + initialSection, + initialRow, +}: { + onClose: () => void; + initialSection: SectionId; + /** The keys-section row to unfold (see DialogContext above). */ + initialRow: string | null; +}) { // Plain dim backdrop (no blur — nothing decorative); the click // closes, the dialog body stops propagation. return createPortal( @@ -97,7 +115,7 @@ function SettingsDialog({ onClose, initialSection }: { onClose: () => void; init data-testid="settings-backdrop" onClick={onClose} > - + , document.body, ); @@ -108,9 +126,13 @@ function SettingsDialog({ onClose, initialSection }: { onClose: () => void; init export function SettingsDialogBody({ onClose, initialSection = "general", + initialRow = null, }: { onClose: () => void; initialSection?: SectionId; + /** The keys row to unfold on arrival (null = none). While-open + * retargets follow it exactly like initialSection below. */ + initialRow?: string | null; }) { const { onAppearanceChange } = useAppearance(); const [record, setRecord] = useState( @@ -333,7 +355,7 @@ export function SettingsDialogBody({ )} {active === "keys" && ( - + )} {active === "agent" && ( diff --git a/components/setup-cta.tsx b/components/setup-cta.tsx new file mode 100644 index 0000000..246399f --- /dev/null +++ b/components/setup-cta.tsx @@ -0,0 +1,75 @@ +"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 ( +
+ +
+ ); +} \ No newline at end of file diff --git a/components/setup-dialog.tsx b/components/setup-dialog.tsx index 9f0b0a9..9cf817a 100644 --- a/components/setup-dialog.tsx +++ b/components/setup-dialog.tsx @@ -12,11 +12,13 @@ * STAGED BY DEPENDENCY (owner, 2026-09-20: "first keys, then models — * there's no point presenting the model picker before the user has set a * key because there are no models to pick then"): - * Stage 1 — no keyed provider: "add a provider key first" + a button - * that deep-links the settings dialog on Keys. The draft lives in - * the caller's state, untouched; after the key lands the next - * submit/CTA click reopens here at stage 2 (the /api/models check is - * fresh per open — saving a key immediately widens the pickers). + * Stage 1 — no keyed provider: a SELF-CONTAINED first-key screen + * (zero-keys wave, owner 2026-09-25): provider picker + one key + * input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED — + * saving dispatches the "keys" signal, the fetch effect re-runs on + * it, and the stage flips to the pickers on its own. No Settings + * round-trip anymore; the draft lives in the caller's state, + * untouched throughout. * 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). @@ -36,7 +38,9 @@ import { type ModelCatalogEntry, type ModelSlotName, type ModelsSettings } from import { prettyModel } from "@/components/usage-format"; import { ModelMenuList } from "@/components/settings/model-menu"; import { MenuShell } from "@/components/menu-shell"; -import { notifySettingsSaved } from "@/components/settings-signal"; +import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-signal"; +import { type ProviderKeyInfo } from "@/components/settings/keys-section"; +import { ProviderMenuList } from "@/components/settings/model-menu"; import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default"; import { ThinkingMenuList } from "@/components/settings/model-menu"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; @@ -101,19 +105,123 @@ export function prefillLevelFromPin( return populateLevel(pin?.thinkingLevel, entry.supportedLevels); } +/** Stage 1's pure body — exported for SSR pins (the delete/settings + * body pattern). The wrapper owns state + effects + the portal. + * + * The first-key screen (owner, zero-keys wave): provider picker (the + * app's shared dropdown language, ProviderMenuList), one paste input, + * Save. A save that 400s keeps the line inline (the keys section's + * shape-gates write user-facing error text). */ +export function SetupKeysBody({ + providers, + provider, + value, + saving, + error, + menuOpen, + onToggleMenu, + onPick, + onValue, + onSave, + menuAnchor, +}: { + /** The full provider roster (GET /api/keys providers) — undefined + * while it loads; the picker renders an honest empty state then. */ + providers: ProviderKeyInfo[] | undefined; + /** The chosen provider; null = the picker trigger reads "Pick a + * provider…". */ + provider: ProviderKeyInfo | null; + value: string; + saving: boolean; + error: string | null; + menuOpen: boolean; + onToggleMenu: () => void; + onPick: (p: ProviderKeyInfo) => void; + onValue: (v: string) => void; + onSave: () => void; + /** The MenuShell anchor — wired by the wrapper (its triggerRef). */ + menuAnchor: React.RefObject; +}) { + return ( +
+

First, add a provider key.

+

+ The model pickers list only what your keys unlock — there is nothing to pick until one exists. +

+
+
+ + {menuOpen && ( + + + + )} +
+ {provider && ( +
+ onValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && value.trim()) onSave(); + }} + placeholder="Paste key…" + aria-label={`${provider.label} key`} + data-testid="setup-key-input" + disabled={saving} + autoFocus + className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50" + /> + +
+ )} + {error && ( +

+ {error} +

+ )} +
+
+ ); +} + export function SetupDialog({ open, onClose, /** Saved: the models section landed; the caller marks configured and * replays the intercepted draft. */ onDone, - /** Deep-link to the Keys section ("add a provider key first"). */ - onOpenKeys, }: { open: boolean; onClose: () => void; onDone: (models: ModelsSettings) => void; - onOpenKeys: () => void; }) { const [body, setBody] = useState(null); const [failed, setFailed] = useState(false); @@ -124,19 +232,46 @@ export function SetupDialog({ const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(null); + // Stage 1's first-key screen (zero-keys wave): the provider roster, + // the chosen provider + pasted value, its busy/error pair, and its own + // menu-open bit (the slot menus' dismissal machinery covers it too — + // see the trigger testid branch below). + const [keyProviders, setKeyProviders] = useState(undefined); + const [keyProvider, setKeyProvider] = useState(null); + const [keyValue, setKeyValue] = useState(""); + const [keySaving, setKeySaving] = useState(false); + const [keyError, setKeyError] = useState(null); + const [keyMenuOpen, setKeyMenuOpen] = useState(false); /** The open menu's trigger wrapper — the shared shell's anchor (fix * wave chunk B): one menu open at a time, so one ref suffices, and * the menu PORTALS out of this dialog's overflow-hidden frame — it * extends beyond the dialog exactly like the settings menus do. */ const triggerRef = useRef(null); + // Stage 1's provider-picker trigger wrapper — the shared shell's anchor + // (the slot menus use the same single-ref pattern). Declared with the + // other hooks: the early `if (!open) return null` sits below them all. + const keyTriggerRef = useRef(null); - // Fresh /api/models per open — a key saved a moment ago (stage 1 → - // Keys → back) widens the pickers immediately, no restart. + // 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. + const [keysTick, setKeysTick] = useState(0); + useSettingsSaved((section) => { + if (section === "keys") setKeysTick((t) => t + 1); + }); + + // Fresh /api/models per open AND per keys change — a key saved a + // moment ago (stage 1's own screen) widens the pickers immediately. useEffect(() => { if (!open) return; setPicked({}); setLevels({}); setError(null); + fetch("/api/keys") + .then((r) => r.json()) + .then((b: { providers?: ProviderKeyInfo[] }) => setKeyProviders(b.providers ?? [])) + .catch(() => setKeyProviders([])); fetch("/api/models") .then((r) => r.json()) .then((b: ModelsBody) => { @@ -158,7 +293,7 @@ export function SetupDialog({ setLevels(preLevels); }) .catch(() => setFailed(true)); - }, [open]); + }, [open, keysTick]); // Esc + outside-click — layered dismissal, closable WITHOUT // configuring (browsing stays possible; the gate is the send @@ -168,6 +303,10 @@ export function SetupDialog({ if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; + if (keyMenuOpen) { + setKeyMenuOpen(false); + return; + } if (openMenu !== null) setOpenMenu(null); else onClose(); }; @@ -185,13 +324,37 @@ export function SetupDialog({ window.removeEventListener("keydown", onKey); document.removeEventListener("pointerdown", onPointerDown); }; - }, [open, onClose, openMenu]); + }, [open, onClose, openMenu, keyMenuOpen]); if (!open) return null; const stage = failed ? "keys" : setupStage(body ? body.providers : 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. */ + async function saveKey() { + if (!keyProvider || !keyValue.trim() || keySaving) return; + setKeySaving(true); + setKeyError(null); + const res = await fetch(`/api/keys/${encodeURIComponent(keyProvider.envVar)}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ value: keyValue }), + }).catch(() => null); + setKeySaving(false); + if (!res || !res.ok) { + const body = (await res?.json().catch(() => ({}))) as { error?: string } | undefined; + setKeyError(res ? (body?.error ?? `Save failed (${res.status})`) : "Save failed — network"); + return; + } + setKeyProvider(null); + setKeyValue(""); + notifySettingsSaved("keys"); + } + async function save() { if (!allPicked || saving) return; setSaving(true); @@ -246,23 +409,30 @@ export function SetupDialog({ {stage === "keys" ? ( -
-

- {failed ? "Model catalog could not be loaded — check the connection and retry." : "First, add a provider key."} -

-

- The model pickers list only what your keys unlock — there is nothing to pick until one exists. -

-
- -
+
+ {failed ? ( +

+ Model catalog could not be loaded — check the connection and retry. +

+ ) : ( + setKeyMenuOpen((v) => !v)} + onPick={(p) => { + setKeyProvider(p); + setKeyMenuOpen(false); + setKeyError(null); + }} + onValue={setKeyValue} + onSave={() => void saveKey()} + /> + )}
) : (
diff --git a/tests/byok-refusal.test.ts b/tests/byok-refusal.test.ts index e772666..fab7db2 100644 --- a/tests/byok-refusal.test.ts +++ b/tests/byok-refusal.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { parseModelsRefusal } from "../components/chat.tsx"; +import { parseModelsRefusal, parseProviderKeyRefusal } from "../components/chat.tsx"; import { prefillFromSlots, prefillLevelFromPin, setupStage } from "../components/setup-dialog.tsx"; import { isModelsRefusal, @@ -66,6 +66,21 @@ describe("providerKeyMissing — the envelope factory (provider-key interlude, 2 }); }); +describe("parseProviderKeyRefusal — the send refusal detector's twin (zero-keys wave)", () => { + it("reads the envelope's own code + invariant tail — the wording can never drift client-side", () => { + const REFUSED = JSON.stringify(providerKeyMissing("openrouter", ["strong"])); + assert.equal(parseProviderKeyRefusal(REFUSED), true); + assert.equal(parseProviderKeyRefusal(JSON.stringify(providerKeyMissing("anthropic", ["strong", "vision"]))), true); + }); + + it("anything else is a real error — false, the honest banner stays", () => { + assert.equal(parseProviderKeyRefusal(REFUSAL), false); // the models refusal is NOT this one + assert.equal(parseProviderKeyRefusal("FAILED: headless crashed"), false); + assert.equal(parseProviderKeyRefusal(undefined), false); + assert.equal(parseProviderKeyRefusal("{not json"), false); + }); +}); + describe("isProviderKeyRefusal — the job-record matcher (provider-key interlude)", () => { it("matches the envelope wording regardless of provider label, not real failures", () => { assert.equal(isProviderKeyRefusal(providerKeyMissing("together", ["cheap"]).error), true); diff --git a/tests/render-activity-dock.test.tsx b/tests/render-activity-dock.test.tsx index 2189c36..6baec57 100644 --- a/tests/render-activity-dock.test.tsx +++ b/tests/render-activity-dock.test.tsx @@ -66,6 +66,17 @@ describe("activity dock pure layer", () => { assert.equal(absent.cost, null); }); + it("a key-refused job reads as info — 'needs a provider key' (zero-keys wave, 66dbea9)", () => { + const refused = job({ + status: "error", + activity: "failed", + // the runner composes the envelope's error verbatim (invariant tail) + error: "No OpenRouter key is set — add one in Settings → Keys", + }); + const blob = toBlobs({ jobs: [refused], extractions: [] })[0]!; + assert.equal(blob.activity, "needs a provider key"); + }); + it("a models-refused job reads as info — 'needs model setup', not 'failed' (BYOK, 2026-09-22)", () => { const refused = job({ status: "error", diff --git a/tests/render-cards.test.tsx b/tests/render-cards.test.tsx index 19acb27..b7e5c5d 100644 --- a/tests/render-cards.test.tsx +++ b/tests/render-cards.test.tsx @@ -101,6 +101,17 @@ describe("DispatchCard — the BYOK refusal tone (owner, 2026-09-22)", () => { assert.ok(!html.includes("text-red-500")); }); + it("a key-refused job is INFO: 'needs a provider key', 'Add a key' (zero-keys wave, 66dbea9)", () => { + const html = renderToStaticMarkup( + inJobs([job({ error: "No OpenRouter key is set — add one in Settings → Keys" })]), + ); + assert.ok(html.includes('data-testid="dispatch-refused"')); + assert.ok(html.includes("needs a provider key")); + assert.ok(html.includes("Add a key")); + assert.ok(html.includes('data-testid="dispatch-setup-link"')); + assert.ok(!html.includes("failed")); + }); + it("a real failure keeps the honest red failure", () => { const html = renderToStaticMarkup(inJobs([job({ error: "FAILED: headless crashed" })])); assert.ok(html.includes('data-testid="dispatch-settled"')); diff --git a/tests/zero-keys.test.tsx b/tests/zero-keys.test.tsx new file mode 100644 index 0000000..c1fbf33 --- /dev/null +++ b/tests/zero-keys.test.tsx @@ -0,0 +1,118 @@ +/** + * The zero-keys wave's pure seams (owner-locked over Platform's 66dbea9 + * key-presence gate, 2026-09-25): the provider-key truth (hasProviderKey + * — tool keys don't count, they unlock no models), the pill's SSR shape, + * and the SetupDialog's self-contained first-key screen (the dialog STAYS + * MOUNTED after a save; these pins hold the screen's structure). + */ + +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 { 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"; +import type { ProviderKeyInfo } from "../components/settings/keys-section.tsx"; + +const status = (id: string, present: boolean, source: AuthKeyStatus["source"] = present ? "file" : "none"): AuthKeyStatus => ({ + id, + info: null, + present, + masked: present ? "…ab12 (64 chars)" : null, + source, + shadowedByEnv: false, +}); + +const PROVIDERS: ProviderKeyInfo[] = [ + { id: "openrouter", envVar: "OPENROUTER_API_KEY", label: "OpenRouter" }, + { id: "anthropic", envVar: "ANTHROPIC_API_KEY", label: "Anthropic" }, +]; + +describe("hasProviderKey — the zero-keys pill's truth", () => { + it("a present provider key (env or file) is a key", () => { + assert.equal(hasProviderKey([status("OPENROUTER_API_KEY", true, "env")], PROVIDERS), true); + assert.equal(hasProviderKey([status("ANTHROPIC_API_KEY", true, "file")], PROVIDERS), true); + }); + + it("a TOOL key alone does not count — Deepgram unlocks no models", () => { + // Deepgram is not in the providers roster: it must never suppress the pill. + assert.equal(hasProviderKey([status("DEEPGRAM_API_KEY", true)], PROVIDERS), false); + }); + + it("absent provider keys = zero keys (the pill shows)", () => { + assert.equal(hasProviderKey([status("OPENROUTER_API_KEY", false)], PROVIDERS), false); + assert.equal(hasProviderKey([], PROVIDERS), false); + }); +}); + +describe("ZeroKeysPill — the composer-hoisted notice", () => { + it("the pill takes the CTA's chrome with its own testid and copy", () => { + const html = renderToStaticMarkup(createElement(ZeroKeysPill, { onOpen: () => {} })); + assert.ok(html.includes('data-testid="setup-cta-keys"')); + assert.ok(html.includes("Add a provider key")); + // the models CTA's testid must not appear on the keys pill + assert.ok(!html.includes('data-testid="setup-cta"')); + }); +}); + +describe("SetupKeysBody — stage 1's self-contained first-key screen", () => { + const base = { + value: "", + saving: false, + error: null, + menuOpen: false, + onToggleMenu: () => {}, + onPick: () => {}, + onValue: () => {}, + onSave: () => {}, + menuAnchor: { current: null }, + }; + + it("before a pick: the provider trigger, no key input yet", () => { + const html = renderToStaticMarkup( + createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: null }), + ); + assert.match(html, /First, add a provider key\./); + assert.ok(html.includes("The model pickers list only what your keys unlock")); + assert.ok(html.includes('data-testid="setup-key-provider"')); + assert.ok(html.includes("Pick a provider…")); + assert.ok(!html.includes('data-testid="setup-key-input"'), "no paste field before a provider is picked"); + }); + + it("after a pick: the paste input + Save join", () => { + const html = renderToStaticMarkup( + createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: PROVIDERS[0]! }), + ); + assert.ok(html.includes('data-testid="setup-key-input"')); + assert.ok(html.includes('data-testid="setup-key-save"')); + assert.ok(html.includes("Save key")); + assert.ok(html.includes("OpenRouter"), "the chosen provider's label rides the trigger"); + }); + + it("a shape-gate 400 keeps its line inline — no Settings round-trip chrome", () => { + const html = renderToStaticMarkup( + createElement(SetupKeysBody, { + ...base, + providers: PROVIDERS, + provider: PROVIDERS[0]!!, + error: "Expected 20+ characters", + }), + ); + assert.ok(html.includes('data-testid="setup-key-error"')); + assert.ok(html.includes("Expected 20+ characters")); + // the old "Open Keys settings" round-trip died with the self-contained screen + assert.ok(!html.includes("setup-open-keys")); + }); +}); + +describe("the dictation keyless seam (owner dictation trio)", () => { + it("the client-authored short line — not the route's dev-speak", () => { + assert.equal(DICTATION_KEYLESS, "Dictation needs a Deepgram key"); + }); + + 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