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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user