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.
This commit is contained in:
amosblomqvist
2026-09-24 23:20:59 +03:00
parent 9f0ecccc7a
commit 1f777f770c
15 changed files with 625 additions and 102 deletions
+10 -3
View File
@@ -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 { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CallUsage, JobKind, JobRecord } from "@/src/core"; 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"; import { formatTokens, formatUsd } from "@/components/usage-format";
/** /**
@@ -204,8 +204,15 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
context, context,
// BYOK (owner, 2026-09-22): a refusal is info, not failure — // BYOK (owner, 2026-09-22): a refusal is info, not failure —
// the honest line the runner left ("failed") reads as setup // the honest line the runner left ("failed") reads as setup
// needed, matching the dispatch card's quiet tone. // needed, matching the dispatch card's quiet tone. The
activity: isModelsRefusal(job.error) ? "needs model setup" : (job.activity ?? ""), // 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, model: usage?.model,
steps: usage?.steps, steps: usage?.steps,
}; };
+11 -5
View File
@@ -4,7 +4,7 @@ import { useContext } from "react";
import { CheckIcon, SettingsIcon, XIcon } from "lucide-react"; import { CheckIcon, SettingsIcon, XIcon } from "lucide-react";
import { JobsContext } from "@/components/jobs-context"; import { JobsContext } from "@/components/jobs-context";
import { ChatActionsContext } from "@/components/chat"; 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 * 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, // BYOK (owner, 2026-09-22): a job refused for missing models is INFO,
// not scary-error — gray glyph + "needs model setup" + the deep-link // not scary-error — gray glyph + "needs model setup" + the deep-link
// into the SetupDialog (the same flow the send interception uses). // 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)) { if (isModelsRefusal(job.error)) {
return <DispatchRefusedCard label={label} />; return <DispatchRefusedCard label={label} line="needs model setup" button="Set up models" />;
}
if (isProviderKeyRefusal(job.error)) {
return <DispatchRefusedCard label={label} line="needs a provider key" button="Add a key" />;
} }
const failed = job.status === "error"; const failed = job.status === "error";
return ( return (
@@ -53,7 +59,7 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
} }
/** The refused card: quiet info tone — nothing red, nothing "failed". */ /** 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); const ctx = useContext(ChatActionsContext);
return ( return (
<div <div
@@ -62,7 +68,7 @@ function DispatchRefusedCard({ label }: { label: string }) {
> >
<SettingsIcon className="size-3.5" /> <SettingsIcon className="size-3.5" />
<span className="font-medium uppercase tracking-wide">{label}</span> <span className="font-medium uppercase tracking-wide">{label}</span>
<span>needs model setup</span> <span>{line}</span>
{ctx && ( {ctx && (
<button <button
type="button" type="button"
@@ -70,7 +76,7 @@ function DispatchRefusedCard({ label }: { label: string }) {
onClick={ctx.openSetup} onClick={ctx.openSetup}
className="ml-1 rounded-sm border border-border bg-card px-2 py-0.5 font-ui normal-case tracking-normal text-foreground transition-colors hover:border-muted-foreground/50" className="ml-1 rounded-sm border border-border bg-card px-2 py-0.5 font-ui normal-case tracking-normal text-foreground transition-colors hover:border-muted-foreground/50"
> >
Set up models {button}
</button> </button>
)} )}
</div> </div>
+41 -19
View File
@@ -5,7 +5,7 @@ import { useChat } from "@ai-sdk/react";
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai"; import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { createChatTransport } from "@/components/chat-transport"; import { createChatTransport } from "@/components/chat-transport";
import { SetupDialog } from "@/components/setup-dialog"; 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 { useStickToBottomContext } from "use-stick-to-bottom";
import { import {
Conversation, Conversation,
@@ -25,7 +25,7 @@ import { DictationBar, useDictation } from "@/components/dictation";
import { JobsContext } from "@/components/jobs-context"; import { JobsContext } from "@/components/jobs-context";
import { StatsBar } from "@/components/stats-bar"; import { StatsBar } from "@/components/stats-bar";
import { useActivity } from "@/components/activity-dock"; 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 { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
import { PlanMarginalia } from "@/components/plan-window"; import { PlanMarginalia } from "@/components/plan-window";
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay"; 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). */ /** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
@@ -315,6 +330,10 @@ export function Chat({
// refusal interception share one SetupDialog mount. `configured` is // refusal interception share one SetupDialog mount. `configured` is
// seeded server-side (slotsConfigured on the record) and flips on Save. // seeded server-side (slotsConfigured on the record) and flips on Save.
const [configured, setConfigured] = useState(initialConfigured); 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 — // Cross-surface freshness (fix wave): another surface's models write —
// setup save, composer pick, a settings write — re-reads the record so // setup save, composer pick, a settings write — re-reads the record so
// the setup CTA appears/disappears live (it used to lag until reload). // the setup CTA appears/disappears live (it used to lag until reload).
@@ -326,7 +345,6 @@ export function Chat({
.catch(() => {}); .catch(() => {});
}); });
const [setupOpen, setSetupOpen] = useState(false); const [setupOpen, setSetupOpen] = useState(false);
const { open: openSettings } = useSettingsDialog();
/** The last explicitly-sent draft (composer send, verdict note): the /** The last explicitly-sent draft (composer send, verdict note): the
* refusal flow restores it into the composer and Save replays it. * refusal flow restores it into the composer and Save replays it.
* Auto-continuations (probe answers) never set it — their refusal * Auto-continuations (probe answers) never set it — their refusal
@@ -344,7 +362,11 @@ export function Chat({
useEffect(() => { useEffect(() => {
if (!error) return; if (!error) return;
const missing = parseModelsRefusal(error.message); 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`) // ORDER MATTERS: clearError() flips this effect's own dep (`error`)
// and would cancel the in-flight work below — so the banner dismiss, // and would cancel the in-flight work below — so the banner dismiss,
// the draft restore, and the dialog open all land TOGETHER after the // the draft restore, and the dialog open all land TOGETHER after the
@@ -1087,17 +1109,21 @@ export function Chat({
dictationSlot={<DictationBar dictation={dictation} />} dictationSlot={<DictationBar dictation={dictation} />}
dictationCancel={dictation.cancel} dictationCancel={dictation.cancel}
setupCta={ setupCta={
!configured && ( zeroKeys ? (
<div className="pointer-events-auto mb-2 flex justify-center"> <ZeroKeysPill onOpen={() => setSetupOpen(true)} />
<button ) : (
type="button" !configured && (
data-testid="setup-cta" <div className="pointer-events-auto mb-2 flex justify-center">
onClick={() => setSetupOpen(true)} <button
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" type="button"
> data-testid="setup-cta"
Set up your models onClick={() => setSetupOpen(true)}
</button> 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"
</div> >
Set up your models
</button>
</div>
)
) )
} }
composer={ composer={
@@ -1165,10 +1191,6 @@ export function Chat({
void sendMessage({ text: t }); void sendMessage({ text: t });
} }
}} }}
onOpenKeys={() => {
setSetupOpen(false);
openSettings("keys");
}}
/> />
</ChatActionsContext.Provider> </ChatActionsContext.Provider>
); );
+40 -5
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2Icon, MicIcon } from "lucide-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). * 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. */ /** Rolling history: 48 cells × 60ms ≈ 2.9s of recent loudness. */
export const METER_CELLS = 48; 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. */ /** RMS (0..1) → normalized 0..1 across the audible dB window. */
export function rmsToMeter(rms: number): number { export function rmsToMeter(rms: number): number {
if (rms <= 0) return 0; 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 — * semantics (owner, 2026-09-17 round 3): Esc is a cancel, not a stop —
* before, it flushed exactly like the toggle did. */ * before, it flushed exactly like the toggle did. */
cancel: () => void; 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"); 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 mintToken = useCallback(async () => {
const res = await fetch("/api/stt/token"); const res = await fetch("/api/stt/token");
const data = (await res.json()) as { token?: string; message?: string }; 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; return data.token;
}, []); }, []);
@@ -392,7 +414,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
return () => window.removeEventListener("lh:kbd-cancel-dictation", cancel); return () => window.removeEventListener("lh:kbd-cancel-dictation", cancel);
}, [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. * (full text in the tooltip), cleared on the next click.
*/ */
export function DictationBar({ dictation }: { dictation: Dictation }) { 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<number[]>(() => new Array(METER_CELLS).fill(0)); const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0));
const recording = phase === "recording" || phase === "stopping"; const recording = phase === "recording" || phase === "stopping";
/** Adaptive cell count (owner round 2): derived from the meter's /** Adaptive cell count (owner round 2): derived from the meter's
@@ -455,7 +483,14 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
return ( return (
<button <button
type="button" type="button"
onClick={toggle} onClick={() => {
if (keyless) {
clearError();
openSettings("keys", DEEPGRAM_KEY_ID);
} else {
toggle();
}
}}
title={error ?? "Dictate into the composer"} title={error ?? "Dictate into the composer"}
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"} aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
data-testid="dictation-toggle" data-testid="dictation-toggle"
+23 -20
View File
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { CornerDownLeftIcon } from "lucide-react"; import { CornerDownLeftIcon } from "lucide-react";
import { GraphView } from "@/components/graph-view"; import { GraphView } from "@/components/graph-view";
import { SetupDialog } from "@/components/setup-dialog"; import { SetupDialog } from "@/components/setup-dialog";
import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx"; import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
import { useDefaultSettings } from "@/components/default-settings"; import { useDefaultSettings } from "@/components/default-settings";
import { DictationBar, useDictation } from "@/components/dictation"; import { DictationBar, useDictation } from "@/components/dictation";
import { SidebarToggle } from "@/components/sidebar"; import { SidebarToggle } from "@/components/sidebar";
@@ -27,8 +27,10 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings:
// a topic opens the setup dialog WITH THE DRAFT PRESERVED (Save // a topic opens the setup dialog WITH THE DRAFT PRESERVED (Save
// stores the slots and the queued message sends). // stores the slots and the queued message sends).
const [configured, setConfigured] = useState(initialConfigured); 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 [setupOpen, setSetupOpen] = useState(false);
const { open: openSettings } = useSettingsDialog();
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the // 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. // topic draft; the strip lives in the stats bar's middle slot.
const dictation = useDictation({ const dictation = useDictation({
@@ -113,17 +115,21 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings:
px-4) so nothing shifts when a session starts (owner). */} px-4) so nothing shifts when a session starts (owner). */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 pb-5"> <div className="pointer-events-none absolute inset-x-0 bottom-0 pb-5">
<div className="pointer-events-auto mx-auto w-full max-w-[46rem] px-4"> <div className="pointer-events-auto mx-auto w-full max-w-[46rem] px-4">
{!configured && ( {zeroKeys ? (
<div className="mb-2 flex justify-center"> <ZeroKeysPill onOpen={() => setSetupOpen(true)} />
<button ) : (
type="button" !configured && (
data-testid="setup-cta" <div className="mb-2 flex justify-center">
onClick={() => setSetupOpen(true)} <button
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" type="button"
> data-testid="setup-cta"
Set up your models — chats start after the three picks onClick={() => setSetupOpen(true)}
</button> 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"
</div> >
Set up your models — chats start after the three picks
</button>
</div>
)
)} )}
<form <form
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg" className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
@@ -176,8 +182,9 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings:
</div> </div>
{/* First-run setup (unconfigured): the CTA's target and the send {/* First-run setup (unconfigured): the CTA's target and the send
interception's landing surface. onDone replays the intercepted interception's landing surface — stage 1 is the self-contained
draft; onOpenKeys deep-links the settings dialog (stage 1). */} first-key screen (zero-keys wave); onDone replays the
intercepted draft. */}
<SetupDialog <SetupDialog
open={setupOpen} open={setupOpen}
onClose={() => setSetupOpen(false)} onClose={() => setSetupOpen(false)}
@@ -186,11 +193,7 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings:
setSetupOpen(false); setSetupOpen(false);
void start(true); // the queued draft sends void start(true); // the queued draft sends
}} }}
onOpenKeys={() => { />
setSetupOpen(false);
openSettings("keys");
}}
/>
</div> </div>
); );
} }
+12 -9
View File
@@ -8,7 +8,7 @@ import { DictationBar, useDictation } from "@/components/dictation";
import { useDefaultSettings } from "@/components/default-settings"; import { useDefaultSettings } from "@/components/default-settings";
import { SidebarToggle } from "@/components/sidebar"; import { SidebarToggle } from "@/components/sidebar";
import { SetupDialog } from "@/components/setup-dialog"; 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 { StatsBar } from "@/components/stats-bar";
import type { ChatSettings } from "@/src/core"; import type { ChatSettings } from "@/src/core";
@@ -165,8 +165,10 @@ export function NewSession({
// 33 providers, chats cannot start. The draft stays in the textarea; // 33 providers, chats cannot start. The draft stays in the textarea;
// Save completes and the send replays. // Save completes and the send replays.
const [configured, setConfigured] = useState(initialConfigured); 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 [setupOpen, setSetupOpen] = useState(false);
const { open: openSettings } = useSettingsDialog();
const send = useCallback((force = false) => { const send = useCallback((force = false) => {
if (!text.trim() || busy) return; if (!text.trim() || busy) return;
@@ -195,7 +197,10 @@ export function NewSession({
settings={settings} settings={settings}
onSettingsSaved={saveDraft} onSettingsSaved={saveDraft}
setupCta={ setupCta={
!configured && ( zeroKeys ? (
<ZeroKeysPill onOpen={() => setSetupOpen(true)} />
) : (
!configured && (
<div className="pointer-events-auto mb-2 flex justify-center"> <div className="pointer-events-auto mb-2 flex justify-center">
<button <button
type="button" type="button"
@@ -206,10 +211,12 @@ export function NewSession({
Set up your models — chats start after the three picks Set up your models — chats start after the three picks
</button> </button>
</div> </div>
)
) )
} }
/> />
{/* 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). */}
<SetupDialog <SetupDialog
open={setupOpen} open={setupOpen}
onClose={() => setSetupOpen(false)} onClose={() => setSetupOpen(false)}
@@ -218,11 +225,7 @@ export function NewSession({
setSetupOpen(false); setSetupOpen(false);
send(true); // the queued draft sends send(true); // the queued draft sends
}} }}
onOpenKeys={() => { />
setSetupOpen(false);
openSettings("keys");
}}
/>
</> </>
); );
} }
+4 -1
View File
@@ -17,7 +17,10 @@ import { useEffect, useRef } from "react";
export const SETTINGS_SAVED_EVENT = "lh:settings-saved"; export const SETTINGS_SAVED_EVENT = "lh:settings-saved";
/** The settings sections a writer can land (the write seam's union). */ /** 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 /** Writers call this after the PUT resolves ok — never before (a failed
* write notifies nothing; the record is unchanged). */ * write notifies nothing; the record is unchanged). */
+25 -3
View File
@@ -28,6 +28,7 @@ import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
import { SettingsGroup } from "./controls.tsx"; import { SettingsGroup } from "./controls.tsx";
import { ProviderMenuList } from "./model-menu.tsx"; import { ProviderMenuList } from "./model-menu.tsx";
import { MenuShell } from "@/components/menu-shell"; import { MenuShell } from "@/components/menu-shell";
import { notifySettingsSaved } from "@/components/settings-signal";
/** One pi-ai provider with plain API-key auth (the Platform seam — /** One pi-ai provider with plain API-key auth (the Platform seam —
* derived from pi-ai's own env-key table; no drift possible). */ * 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<AuthKeyStatus[] | null>(null); const [statuses, setStatuses] = useState<AuthKeyStatus[] | null>(null);
const [providers, setProviders] = useState<ProviderKeyInfo[] | undefined>(undefined); const [providers, setProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
/** The unfolded row (its id), null = all folded. */ /** The unfolded row (its id), null = all folded. Seeded by the
const [openRow, setOpenRow] = useState<string | null>(null); * deep-link (initialRow) and re-followed when it retargets. */
const [openRow, setOpenRow] = useState<string | null>(initialRow ?? null);
/** The paste editor for a freshly chosen Add-provider target. */ /** The paste editor for a freshly chosen Add-provider target. */
const [adding, setAdding] = useState<ProviderKeyInfo | null>(null); const [adding, setAdding] = useState<ProviderKeyInfo | null>(null);
const [addError, setAddError] = useState<string | null>(null); const [addError, setAddError] = useState<string | null>(null);
@@ -354,6 +364,13 @@ export function KeysSection() {
void reload(); 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<string | null> { async function saveKey(id: string, value: string): Promise<string | null> {
const res = await fetch(`/api/keys/${id}`, { const res = await fetch(`/api/keys/${id}`, {
method: "PUT", method: "PUT",
@@ -365,12 +382,17 @@ export function KeysSection() {
return body.error ?? `Save failed (${res.status})`; return body.error ?? `Save failed (${res.status})`;
} }
await reload(); 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; return null;
} }
async function deleteKey(id: string): Promise<void> { async function deleteKey(id: string): Promise<void> {
await fetch(`/api/keys/${id}`, { method: "DELETE" }).catch(() => undefined); await fetch(`/api/keys/${id}`, { method: "DELETE" }).catch(() => undefined);
await reload(); await reload();
notifySettingsSaved("keys");
} }
if (loading || statuses === null) { if (loading || statuses === null) {
+28 -6
View File
@@ -60,7 +60,12 @@ const SECTIONS = [
] as const; ] as const;
type SectionId = (typeof SECTIONS)[number]["id"]; 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. /** AppShell-level mount: the sidebar row and the mod+, chord call this.
* `open(section)` lands the dialog on a specific section (the first-run * `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 [show, setShow] = useState(false);
const [landOn, setLandOn] = useState<SectionId>("general"); const [landOn, setLandOn] = useState<SectionId>("general");
const open = useCallback((section?: string) => { const [landOnRow, setLandOnRow] = useState<string | null>(null);
const open = useCallback((section?: string, row?: string) => {
if (section) setLandOn(section as SectionId); if (section) setLandOn(section as SectionId);
setLandOnRow(row ?? null);
setShow(true); setShow(true);
}, []); }, []);
return ( return (
<DialogContext.Provider value={{ open }}> <DialogContext.Provider value={{ open }}>
{children} {children}
{show && <SettingsDialog onClose={() => setShow(false)} initialSection={landOn} />} {show && (
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
)}
</DialogContext.Provider> </DialogContext.Provider>
); );
} }
@@ -88,7 +97,16 @@ export function useSettingsDialog() {
return useContext(DialogContext); 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 // Plain dim backdrop (no blur — nothing decorative); the click
// closes, the dialog body stops propagation. // closes, the dialog body stops propagation.
return createPortal( return createPortal(
@@ -97,7 +115,7 @@ function SettingsDialog({ onClose, initialSection }: { onClose: () => void; init
data-testid="settings-backdrop" data-testid="settings-backdrop"
onClick={onClose} onClick={onClose}
> >
<SettingsDialogBody onClose={onClose} initialSection={initialSection} /> <SettingsDialogBody onClose={onClose} initialSection={initialSection} initialRow={initialRow} />
</div>, </div>,
document.body, document.body,
); );
@@ -108,9 +126,13 @@ function SettingsDialog({ onClose, initialSection }: { onClose: () => void; init
export function SettingsDialogBody({ export function SettingsDialogBody({
onClose, onClose,
initialSection = "general", initialSection = "general",
initialRow = null,
}: { }: {
onClose: () => void; onClose: () => void;
initialSection?: SectionId; 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 { onAppearanceChange } = useAppearance();
const [record, setRecord] = useState<UserSettings | null | undefined>( const [record, setRecord] = useState<UserSettings | null | undefined>(
@@ -333,7 +355,7 @@ export function SettingsDialogBody({
)} )}
{active === "keys" && ( {active === "keys" && (
<SettingsSection title="Keys" testid="section-keys"> <SettingsSection title="Keys" testid="section-keys">
<KeysSection /> <KeysSection initialRow={initialRow ?? undefined} />
</SettingsSection> </SettingsSection>
)} )}
{active === "agent" && ( {active === "agent" && (
+75
View File
@@ -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 (
<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>
);
}
+200 -30
View File
@@ -12,11 +12,13 @@
* STAGED BY DEPENDENCY (owner, 2026-09-20: "first keys, then models — * 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 * there's no point presenting the model picker before the user has set a
* key because there are no models to pick then"): * key because there are no models to pick then"):
* Stage 1 — no keyed provider: "add a provider key first" + a button * Stage 1 — no keyed provider: a SELF-CONTAINED first-key screen
* that deep-links the settings dialog on Keys. The draft lives in * (zero-keys wave, owner 2026-09-25): provider picker + one key
* the caller's state, untouched; after the key lands the next * input + save via PUT /api/keys/:id. The dialog STAYS MOUNTED —
* submit/CTA click reopens here at stage 2 (the /api/models check is * saving dispatches the "keys" signal, the fetch effect re-runs on
* fresh per open — saving a key immediately widens the pickers). * 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 * Stage 2 — Strong → Cheap → Vision pickers over the FEDERATED catalog
* (keyed providers only), the vision slot filtered to image-capable * (keyed providers only), the vision slot filtered to image-capable
* models, in the app's shared dropdown language (ModelMenuList). * 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 { prettyModel } from "@/components/usage-format";
import { ModelMenuList } from "@/components/settings/model-menu"; import { ModelMenuList } from "@/components/settings/model-menu";
import { MenuShell } from "@/components/menu-shell"; 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 { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
import { ThinkingMenuList } from "@/components/settings/model-menu"; import { ThinkingMenuList } from "@/components/settings/model-menu";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
@@ -101,19 +105,123 @@ export function prefillLevelFromPin(
return populateLevel(pin?.thinkingLevel, entry.supportedLevels); 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<HTMLDivElement | null>;
}) {
return (
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
<p className="text-sm text-foreground">First, add a provider key.</p>
<p className="text-xs text-muted-foreground">
The model pickers list only what your keys unlock — there is nothing to pick until one exists.
</p>
<div className="flex flex-col gap-2">
<div ref={menuAnchor} className="relative max-w-96">
<button
type="button"
aria-label="Provider"
data-testid="setup-key-provider"
aria-expanded={menuOpen}
onClick={onToggleMenu}
className="flex h-7 w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
>
<span className="min-w-0 flex-1 truncate text-left">{provider ? provider.label : "Pick a provider…"}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
</button>
{menuOpen && (
<MenuShell
anchorRef={menuAnchor}
testid="setup-key-provider-menu"
preferredSide="down"
align="left"
menuClass="flex w-[22rem] flex-col rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
<ProviderMenuList providers={providers ?? []} onPick={onPick} />
</MenuShell>
)}
</div>
{provider && (
<div className="flex max-w-96 items-center gap-2">
<input
type="text"
value={value}
onChange={(e) => 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"
/>
<button
type="button"
onClick={onSave}
disabled={saving || !value.trim()}
data-testid="setup-key-save"
className="shrink-0 rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
>
{saving ? "Saving…" : "Save key"}
</button>
</div>
)}
{error && (
<p role="alert" data-testid="setup-key-error" className="text-xs text-red-400">
{error}
</p>
)}
</div>
</div>
);
}
export function SetupDialog({ export function SetupDialog({
open, open,
onClose, onClose,
/** Saved: the models section landed; the caller marks configured and /** Saved: the models section landed; the caller marks configured and
* replays the intercepted draft. */ * replays the intercepted draft. */
onDone, onDone,
/** Deep-link to the Keys section ("add a provider key first"). */
onOpenKeys,
}: { }: {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onDone: (models: ModelsSettings) => void; onDone: (models: ModelsSettings) => void;
onOpenKeys: () => void;
}) { }) {
const [body, setBody] = useState<ModelsBody | null>(null); const [body, setBody] = useState<ModelsBody | null>(null);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
@@ -124,19 +232,46 @@ export function SetupDialog({
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [openMenu, setOpenMenu] = useState<{ slot: ModelSlotName; kind: "model" | "thinking" } | null>(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<ProviderKeyInfo[] | undefined>(undefined);
const [keyProvider, setKeyProvider] = useState<ProviderKeyInfo | null>(null);
const [keyValue, setKeyValue] = useState("");
const [keySaving, setKeySaving] = useState(false);
const [keyError, setKeyError] = useState<string | null>(null);
const [keyMenuOpen, setKeyMenuOpen] = useState(false);
/** The open menu's trigger wrapper — the shared shell's anchor (fix /** 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 * 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 * the menu PORTALS out of this dialog's overflow-hidden frame — it
* extends beyond the dialog exactly like the settings menus do. */ * extends beyond the dialog exactly like the settings menus do. */
const triggerRef = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLDivElement>(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<HTMLDivElement>(null);
// Fresh /api/models per open — a key saved a moment ago (stage 1 → // The keys signal (zero-keys wave): keys-section saves/deletes AND
// Keys → back) widens the pickers immediately, no restart. // 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
setPicked({}); setPicked({});
setLevels({}); setLevels({});
setError(null); setError(null);
fetch("/api/keys")
.then((r) => r.json())
.then((b: { providers?: ProviderKeyInfo[] }) => setKeyProviders(b.providers ?? []))
.catch(() => setKeyProviders([]));
fetch("/api/models") fetch("/api/models")
.then((r) => r.json()) .then((r) => r.json())
.then((b: ModelsBody) => { .then((b: ModelsBody) => {
@@ -158,7 +293,7 @@ export function SetupDialog({
setLevels(preLevels); setLevels(preLevels);
}) })
.catch(() => setFailed(true)); .catch(() => setFailed(true));
}, [open]); }, [open, keysTick]);
// Esc + outside-click — layered dismissal, closable WITHOUT // Esc + outside-click — layered dismissal, closable WITHOUT
// configuring (browsing stays possible; the gate is the send // configuring (browsing stays possible; the gate is the send
@@ -168,6 +303,10 @@ export function SetupDialog({
if (!open) return; if (!open) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return; if (e.key !== "Escape") return;
if (keyMenuOpen) {
setKeyMenuOpen(false);
return;
}
if (openMenu !== null) setOpenMenu(null); if (openMenu !== null) setOpenMenu(null);
else onClose(); else onClose();
}; };
@@ -185,13 +324,37 @@ export function SetupDialog({
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("pointerdown", onPointerDown);
}; };
}, [open, onClose, openMenu]); }, [open, onClose, openMenu, keyMenuOpen]);
if (!open) return null; if (!open) return null;
const stage = failed ? "keys" : setupStage(body ? body.providers : null); const stage = failed ? "keys" : setupStage(body ? body.providers : null);
const allPicked = SLOT_ORDER.every((s) => picked[s]); 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() { async function save() {
if (!allPicked || saving) return; if (!allPicked || saving) return;
setSaving(true); setSaving(true);
@@ -246,23 +409,30 @@ export function SetupDialog({
</header> </header>
{stage === "keys" ? ( {stage === "keys" ? (
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys"> <div className="flex flex-col px-6 py-5">
<p className="text-sm text-foreground"> {failed ? (
{failed ? "Model catalog could not be loaded — check the connection and retry." : "First, add a provider key."} <p className="text-sm text-foreground">
</p> Model catalog could not be loaded — check the connection and retry.
<p className="text-xs text-muted-foreground"> </p>
The model pickers list only what your keys unlock — there is nothing to pick until one exists. ) : (
</p> <SetupKeysBody
<div> providers={keyProviders}
<button provider={keyProvider}
type="button" value={keyValue}
data-testid="setup-open-keys" saving={keySaving}
onClick={onOpenKeys} error={keyError}
className="rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50" menuOpen={keyMenuOpen}
> menuAnchor={keyTriggerRef}
Open Keys settings onToggleMenu={() => setKeyMenuOpen((v) => !v)}
</button> onPick={(p) => {
</div> setKeyProvider(p);
setKeyMenuOpen(false);
setKeyError(null);
}}
onValue={setKeyValue}
onSave={() => void saveKey()}
/>
)}
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid="setup-stage-pickers"> <div className="flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid="setup-stage-pickers">
+16 -1
View File
@@ -1,6 +1,6 @@
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; 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 { prefillFromSlots, prefillLevelFromPin, setupStage } from "../components/setup-dialog.tsx";
import { import {
isModelsRefusal, 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)", () => { describe("isProviderKeyRefusal — the job-record matcher (provider-key interlude)", () => {
it("matches the envelope wording regardless of provider label, not real failures", () => { it("matches the envelope wording regardless of provider label, not real failures", () => {
assert.equal(isProviderKeyRefusal(providerKeyMissing("together", ["cheap"]).error), true); assert.equal(isProviderKeyRefusal(providerKeyMissing("together", ["cheap"]).error), true);
+11
View File
@@ -66,6 +66,17 @@ describe("activity dock pure layer", () => {
assert.equal(absent.cost, null); 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)", () => { it("a models-refused job reads as info — 'needs model setup', not 'failed' (BYOK, 2026-09-22)", () => {
const refused = job({ const refused = job({
status: "error", status: "error",
+11
View File
@@ -101,6 +101,17 @@ describe("DispatchCard — the BYOK refusal tone (owner, 2026-09-22)", () => {
assert.ok(!html.includes("text-red-500")); 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", () => { it("a real failure keeps the honest red failure", () => {
const html = renderToStaticMarkup(inJobs([job({ error: "FAILED: headless crashed" })])); const html = renderToStaticMarkup(inJobs([job({ error: "FAILED: headless crashed" })]));
assert.ok(html.includes('data-testid="dispatch-settled"')); assert.ok(html.includes('data-testid="dispatch-settled"'));
+118
View File
@@ -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");
});
});