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 { 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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" && (
|
||||||
|
|||||||
@@ -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 —
|
* 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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"'));
|
||||||
|
|||||||
@@ -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