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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 <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";
|
||||
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 (
|
||||
<div
|
||||
@@ -62,7 +68,7 @@ function DispatchRefusedCard({ label }: { label: string }) {
|
||||
>
|
||||
<SettingsIcon className="size-3.5" />
|
||||
<span className="font-medium uppercase tracking-wide">{label}</span>
|
||||
<span>needs model setup</span>
|
||||
<span>{line}</span>
|
||||
{ctx && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -70,7 +76,7 @@ function DispatchRefusedCard({ label }: { label: string }) {
|
||||
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"
|
||||
>
|
||||
Set up models
|
||||
{button}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+41
-19
@@ -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={<DictationBar dictation={dictation} />}
|
||||
dictationCancel={dictation.cancel}
|
||||
setupCta={
|
||||
!configured && (
|
||||
<div className="pointer-events-auto mb-2 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="setup-cta"
|
||||
onClick={() => setSetupOpen(true)}
|
||||
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"
|
||||
>
|
||||
Set up your models
|
||||
</button>
|
||||
</div>
|
||||
zeroKeys ? (
|
||||
<ZeroKeysPill onOpen={() => setSetupOpen(true)} />
|
||||
) : (
|
||||
!configured && (
|
||||
<div className="pointer-events-auto mb-2 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="setup-cta"
|
||||
onClick={() => setSetupOpen(true)}
|
||||
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"
|
||||
>
|
||||
Set up your models
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
composer={
|
||||
@@ -1165,10 +1191,6 @@ export function Chat({
|
||||
void sendMessage({ text: t });
|
||||
}
|
||||
}}
|
||||
onOpenKeys={() => {
|
||||
setSetupOpen(false);
|
||||
openSettings("keys");
|
||||
}}
|
||||
/>
|
||||
</ChatActionsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -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<number[]>(() => 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
onClick={() => {
|
||||
if (keyless) {
|
||||
clearError();
|
||||
openSettings("keys", DEEPGRAM_KEY_ID);
|
||||
} else {
|
||||
toggle();
|
||||
}
|
||||
}}
|
||||
title={error ?? "Dictate into the composer"}
|
||||
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
|
||||
data-testid="dictation-toggle"
|
||||
|
||||
+23
-20
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { CornerDownLeftIcon } from "lucide-react";
|
||||
import { GraphView } from "@/components/graph-view";
|
||||
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 { DictationBar, useDictation } from "@/components/dictation";
|
||||
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
|
||||
// stores the slots and the queued message sends).
|
||||
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();
|
||||
// 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.
|
||||
const dictation = useDictation({
|
||||
@@ -113,17 +115,21 @@ export function Home({ initialSettings, initialConfigured }: { initialSettings:
|
||||
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-auto mx-auto w-full max-w-[46rem] px-4">
|
||||
{!configured && (
|
||||
<div className="mb-2 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="setup-cta"
|
||||
onClick={() => setSetupOpen(true)}
|
||||
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"
|
||||
>
|
||||
Set up your models — chats start after the three picks
|
||||
</button>
|
||||
</div>
|
||||
{zeroKeys ? (
|
||||
<ZeroKeysPill onOpen={() => setSetupOpen(true)} />
|
||||
) : (
|
||||
!configured && (
|
||||
<div className="mb-2 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="setup-cta"
|
||||
onClick={() => setSetupOpen(true)}
|
||||
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"
|
||||
>
|
||||
Set up your models — chats start after the three picks
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<form
|
||||
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>
|
||||
|
||||
{/* 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. */}
|
||||
<SetupDialog
|
||||
open={setupOpen}
|
||||
onClose={() => 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");
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<ZeroKeysPill onOpen={() => setSetupOpen(true)} />
|
||||
) : (
|
||||
!configured && (
|
||||
<div className="pointer-events-auto mb-2 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
@@ -206,10 +211,12 @@ export function NewSession({
|
||||
Set up your models — chats start after the three picks
|
||||
</button>
|
||||
</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
|
||||
open={setupOpen}
|
||||
onClose={() => setSetupOpen(false)}
|
||||
@@ -218,11 +225,7 @@ export function NewSession({
|
||||
setSetupOpen(false);
|
||||
send(true); // the queued draft sends
|
||||
}}
|
||||
onOpenKeys={() => {
|
||||
setSetupOpen(false);
|
||||
openSettings("keys");
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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<AuthKeyStatus[] | null>(null);
|
||||
const [providers, setProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
/** The unfolded row (its id), null = all folded. */
|
||||
const [openRow, setOpenRow] = useState<string | null>(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<string | null>(initialRow ?? null);
|
||||
/** The paste editor for a freshly chosen Add-provider target. */
|
||||
const [adding, setAdding] = useState<ProviderKeyInfo | null>(null);
|
||||
const [addError, setAddError] = useState<string | null>(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<string | null> {
|
||||
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<void> {
|
||||
await fetch(`/api/keys/${id}`, { method: "DELETE" }).catch(() => undefined);
|
||||
await reload();
|
||||
notifySettingsSaved("keys");
|
||||
}
|
||||
|
||||
if (loading || statuses === null) {
|
||||
|
||||
@@ -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<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);
|
||||
setLandOnRow(row ?? null);
|
||||
setShow(true);
|
||||
}, []);
|
||||
return (
|
||||
<DialogContext.Provider value={{ open }}>
|
||||
{children}
|
||||
{show && <SettingsDialog onClose={() => setShow(false)} initialSection={landOn} />}
|
||||
{show && (
|
||||
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
|
||||
)}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
<SettingsDialogBody onClose={onClose} initialSection={initialSection} />
|
||||
<SettingsDialogBody onClose={onClose} initialSection={initialSection} initialRow={initialRow} />
|
||||
</div>,
|
||||
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<UserSettings | null | undefined>(
|
||||
@@ -333,7 +355,7 @@ export function SettingsDialogBody({
|
||||
)}
|
||||
{active === "keys" && (
|
||||
<SettingsSection title="Keys" testid="section-keys">
|
||||
<KeysSection />
|
||||
<KeysSection initialRow={initialRow ?? undefined} />
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "agent" && (
|
||||
|
||||
@@ -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
@@ -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<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({
|
||||
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<ModelsBody | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
@@ -124,19 +232,46 @@ export function SetupDialog({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | 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
|
||||
* 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<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 →
|
||||
// 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({
|
||||
</header>
|
||||
|
||||
{stage === "keys" ? (
|
||||
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
|
||||
<p className="text-sm text-foreground">
|
||||
{failed ? "Model catalog could not be loaded — check the connection and retry." : "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>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="setup-open-keys"
|
||||
onClick={onOpenKeys}
|
||||
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"
|
||||
>
|
||||
Open Keys settings
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col px-6 py-5">
|
||||
{failed ? (
|
||||
<p className="text-sm text-foreground">
|
||||
Model catalog could not be loaded — check the connection and retry.
|
||||
</p>
|
||||
) : (
|
||||
<SetupKeysBody
|
||||
providers={keyProviders}
|
||||
provider={keyProvider}
|
||||
value={keyValue}
|
||||
saving={keySaving}
|
||||
error={keyError}
|
||||
menuOpen={keyMenuOpen}
|
||||
menuAnchor={keyTriggerRef}
|
||||
onToggleMenu={() => setKeyMenuOpen((v) => !v)}
|
||||
onPick={(p) => {
|
||||
setKeyProvider(p);
|
||||
setKeyMenuOpen(false);
|
||||
setKeyError(null);
|
||||
}}
|
||||
onValue={setKeyValue}
|
||||
onSave={() => void saveKey()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid="setup-stage-pickers">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"'));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user