Use sans settings labels and link Deepgram key setup
This commit is contained in:
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent }
|
||||
import { DICTATION_LANGUAGES, type DictationLanguage } from "@/src/core";
|
||||
import { MenuShell } from "@/components/menu-shell";
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { SettingsMenuTrigger } from "@/components/settings/controls";
|
||||
import { SettingsMenuTrigger, SettingsRow } from "@/components/settings/controls";
|
||||
import { SearchMenuList } from "@/components/settings/search-menu-list";
|
||||
import { useDictationActive } from "@/components/dictation-state";
|
||||
import { useDictationLanguage } from "@/components/dictation-preference";
|
||||
@@ -14,13 +14,9 @@ type LanguagePreference = ReturnType<typeof useDictationLanguage>;
|
||||
export function DictationLanguageSetting() {
|
||||
const preference = useDictationLanguage();
|
||||
const active = useDictationActive();
|
||||
return <div
|
||||
data-testid="row-dictation-language"
|
||||
className={`flex items-center justify-between gap-3 rounded-sm px-1 py-1.5${active ? " opacity-40" : ""}`}
|
||||
>
|
||||
<span className="font-ui text-xs text-foreground">Language</span>
|
||||
return <SettingsRow label="Language" testid="row-dictation-language" dimmed={active}>
|
||||
<DictationLanguagePicker preference={preference} disabled={active} />
|
||||
</div>;
|
||||
</SettingsRow>;
|
||||
}
|
||||
|
||||
function DictationLanguagePicker({ preference, disabled }: {
|
||||
|
||||
@@ -328,12 +328,12 @@ function AccountDialog({ children, onClose, busy }: { children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Shared compact settings/setup row; account selection precedes the quiet action. */
|
||||
export function ChatGPTConnection(_props: { setup?: boolean } = {}) {
|
||||
export function ChatGPTConnection({ labelClassName }: { setup?: boolean; labelClassName?: string } = {}) {
|
||||
const ui = useContext(Context);
|
||||
const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
|
||||
const accounts = ui.status.accounts.filter((a) => a.connected);
|
||||
return <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
|
||||
<ConnectionRow label="ChatGPT" actionTestid={active ? undefined : "chatgpt-login"} action={active ? "Manage…" : "Connect"} disabled={!ui.status.available || ui.busy || ui.status.pending} onAction={() => active ? ui.manage(active.id) : ui.connect()}>
|
||||
<ConnectionRow label="ChatGPT" labelClassName={labelClassName} actionTestid={active ? undefined : "chatgpt-login"} action={active ? "Manage…" : "Connect"} disabled={!ui.status.available || ui.busy || ui.status.pending} onAction={() => active ? ui.manage(active.id) : ui.connect()}>
|
||||
{active && <select aria-label="ChatGPT account" value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground">
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
||||
</select>}
|
||||
|
||||
@@ -4,9 +4,9 @@ import { createPortal } from "react-dom";
|
||||
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string }) {
|
||||
export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) {
|
||||
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
|
||||
<span className="font-ui text-xs text-foreground">{label}</span>
|
||||
<span className={labelClassName ?? "font-ui text-xs text-foreground"}>{label}</span>
|
||||
{children}
|
||||
<Button type="button" size="xs" variant={action === "Connect" ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === "Connect" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
|
||||
</div>;
|
||||
|
||||
@@ -493,12 +493,12 @@ export function KeysSection({
|
||||
{label === "Dictation" && <>
|
||||
<DictationLanguageSetting />
|
||||
<p data-testid="dictation-setup-hint" className="px-1 text-[11px] leading-relaxed text-muted-foreground/70">
|
||||
Create a Deepgram key with the <a
|
||||
href="https://developers.deepgram.com/reference/auth/tokens/grant"
|
||||
Create a <a
|
||||
href="https://console.deepgram.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
>Member role or higher</a>, then add it above.
|
||||
>Deepgram API key</a> with the Member role or higher, then add it above.
|
||||
</p>
|
||||
</>}
|
||||
</SettingsGroup>)}
|
||||
|
||||
@@ -119,16 +119,19 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
|
||||
</Context.Provider>;
|
||||
}
|
||||
|
||||
export function NativeSubscriptionConnection({ providerId }: { providerId: NativeSubscriptionProviderId }) {
|
||||
export function NativeSubscriptionConnection({ providerId, labelClassName }: { providerId: NativeSubscriptionProviderId; labelClassName?: string }) {
|
||||
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!;
|
||||
const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId);
|
||||
return <div className="flex flex-col gap-2" data-testid={`${providerId}-connection`}>
|
||||
<ConnectionRow label={descriptor.label} action={status.connectionId ? "Manage…" : "Connect"} disabled={ui.busy || !status.available || ui.statuses.some((s) => s.pending)} onAction={() => ui.open(providerId)} />
|
||||
<ConnectionRow label={descriptor.label} labelClassName={labelClassName} action={status.connectionId ? "Manage…" : "Connect"} disabled={ui.busy || !status.available || ui.statuses.some((s) => s.pending)} onAction={() => ui.open(providerId)} />
|
||||
{status.error && <p role="alert" className="text-xs text-red-400">{status.error}</p>}
|
||||
</div>;
|
||||
}
|
||||
export function SubscriptionConnections({ setup = false }: { setup?: boolean }) {
|
||||
return <div className="space-y-3" data-testid="subscription-connections"><ChatGPTConnection setup={setup} />{NATIVE_SUBSCRIPTIONS.map((p) => <NativeSubscriptionConnection key={p.providerId} providerId={p.providerId} />)}</div>;
|
||||
// Providers uses ordinary settings-label typography. The setup surface
|
||||
// keeps its existing compact labels; dialogs and actions are untouched.
|
||||
const labelClassName = setup ? undefined : "font-prose text-(length:--lh-prose-size) text-foreground";
|
||||
return <div className="space-y-3" data-testid="subscription-connections"><ChatGPTConnection setup={setup} labelClassName={labelClassName} />{NATIVE_SUBSCRIPTIONS.map((p) => <NativeSubscriptionConnection key={p.providerId} providerId={p.providerId} labelClassName={labelClassName} />)}</div>;
|
||||
}
|
||||
export function NativeSubscriptionBackgroundAccess({ provider }: { provider: string | undefined }) {
|
||||
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.logicalId === provider);
|
||||
|
||||
+1
-1
@@ -176,7 +176,7 @@ pricing-generation step: Pi AI's catalog and public calculator are the authority
|
||||
|
||||
**Practice (problem sets).** A node selection resolves to the STORED reasoning chains it fully contains (a chain rides iff every member is selected; unchained nodes ride as singletons); the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — a full-bleed sortable table of every reasoning chain, grouped by birth cluster under the shared top bar, selecting chips, chains, or whole clusters, whose floating selection bar shows the exact `N nodes → M chains` the agents will receive — handing a free-form node selection to the same dialog — and carries the fenced Delete action, making it the graph's management view (whole-cluster cleanup through the same full-death confirm dialog). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
|
||||
|
||||
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → Providers → Dictation → Language (below the Deepgram key) offers Nova-3's documented monolingual languages/locales or `language=multi` for automatic multilingual transcription. The app-wide `dictation.language` preference persists through the existing settings record and `PUT /api/settings/dictation`; absent preferences resolve to English (`en`). The settings control locks from connecting through stopping, and reconnects keep the run's original language. A shared preference controller publishes confirmed PUT responses to every reader immediately; the confirmed preference is cached across page navigation and reopening Settings, with no polling or per-recording read. Explicit external dictation-settings notifications invalidate the cache, and capture waits for a successful refresh. Navigating away cancels the run, invalidates pending startup/reconnect work and releases its microphone/socket without appending into another page's input. All other streaming parameters remain unchanged. The picker uses the model dropdown's shared `SearchMenuList` and `MenuShell`: the same fuzzy search, arrow navigation, Enter selection, row styling and scrolling. Its compact mono `SettingsMenuTrigger` is shared with Agent and Appearance; a muted setup line below the key and language rows links the Member-or-higher key-role requirement.
|
||||
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → Providers → Dictation → Language (below the Deepgram key) offers Nova-3's documented monolingual languages/locales or `language=multi` for automatic multilingual transcription. The app-wide `dictation.language` preference persists through the existing settings record and `PUT /api/settings/dictation`; absent preferences resolve to English (`en`). The settings control locks from connecting through stopping, and reconnects keep the run's original language. A shared preference controller publishes confirmed PUT responses to every reader immediately; the confirmed preference is cached across page navigation and reopening Settings, with no polling or per-recording read. Explicit external dictation-settings notifications invalidate the cache, and capture waits for a successful refresh. Navigating away cancels the run, invalidates pending startup/reconnect work and releases its microphone/socket without appending into another page's input. All other streaming parameters remain unchanged. The picker uses the model dropdown's shared `SearchMenuList` and `MenuShell`: the same fuzzy search, arrow navigation, Enter selection, row styling and scrolling. Its compact mono `SettingsMenuTrigger` is shared with Agent and Appearance; a muted setup line below the key and language rows links the Deepgram Console and states the Member-or-higher key-role requirement.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -229,10 +229,12 @@ for (const source of ["none", "file", "env"] as const) test(`Language is below t
|
||||
assert.equal(rows[0]!.getAttribute("data-testid"), "key-deepgram_api_key");
|
||||
assert.equal(rows[1]!.getAttribute("data-testid"), "row-dictation-language");
|
||||
assert.equal(rows[2]!.getAttribute("data-testid"), "dictation-setup-hint");
|
||||
assert.equal(byTest("dictation-setup-hint").textContent, "Create a Deepgram key with the Member role or higher, then add it above.");
|
||||
assert.equal(byTest("dictation-setup-hint").textContent, "Create a Deepgram API key with the Member role or higher, then add it above.");
|
||||
assert.match(byTest("dictation-setup-hint").className, /text-muted-foreground/);
|
||||
assert.equal(byTest("dictation-setup-hint").querySelector("a")!.href, "https://developers.deepgram.com/reference/auth/tokens/grant");
|
||||
assert.match(rows[1]!.querySelector("span")!.className, /font-ui text-xs/);
|
||||
assert.equal(byTest("dictation-setup-hint").querySelector("a")!.href, "https://console.deepgram.com/");
|
||||
assert.equal(byTest("dictation-setup-hint").querySelector("a")!.textContent, "Deepgram API key");
|
||||
assert.equal(byTest("dictation-setup-hint").querySelectorAll("a").length, 1);
|
||||
assert.match(rows[1]!.querySelector(".font-prose")!.className, /text-\(length:--lh-prose-size\)/);
|
||||
assert.equal(trigger().disabled, false, "language is independent of key ownership");
|
||||
await click(byTest("key-deepgram_api_key-row"));
|
||||
if (source !== "env") assert.ok(byTest("key-deepgram_api_key-editor"));
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SettingsDialogBody, SettingsDialogProvider } from "../components/settin
|
||||
import { KEYBOARD_ACTIONS } from "../src/core/index.ts";
|
||||
import { DictationLanguageSetting } from "../components/dictation-language.tsx";
|
||||
import { SettingsMenuTrigger } from "../components/settings/controls.tsx";
|
||||
import { SubscriptionConnections } from "../components/settings/subscription-connections.tsx";
|
||||
|
||||
/** The full opening tag of the element carrying a testid — attribute
|
||||
* order in React output is not contractual, so assertions run on the
|
||||
@@ -89,6 +90,25 @@ describe("the shared settings dropdown trigger", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Providers settings-label typography", () => {
|
||||
it("Language uses the standard sans SettingsRow label without changing its mono trigger", () => {
|
||||
const html = renderToStaticMarkup(<DictationLanguageSetting />);
|
||||
assert.ok(html.includes('<span class="block font-prose text-(length:--lh-prose-size)">Language</span>'));
|
||||
assert.ok(tagOf(html, "dictation-language").includes("font-ui text-xs"));
|
||||
});
|
||||
|
||||
it("only Providers subscription labels use sans; setup labels retain their compact mono styling", () => {
|
||||
const settings = renderToStaticMarkup(<SubscriptionConnections />);
|
||||
const setup = renderToStaticMarkup(<SubscriptionConnections setup />);
|
||||
for (const label of ["ChatGPT", "GitHub Copilot", "Grok"]) {
|
||||
assert.ok(settings.includes(`<span class="font-prose text-(length:--lh-prose-size) text-foreground">${label}</span>`), `${label}: settings sans label`);
|
||||
assert.ok(setup.includes(`<span class="font-ui text-xs text-foreground">${label}</span>`), `${label}: unchanged setup label`);
|
||||
}
|
||||
assert.equal((settings.match(/>Connect<\/button>/g) ?? []).length, 3);
|
||||
assert.equal((setup.match(/>Connect<\/button>/g) ?? []).length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentSection (thin SSR)", () => {
|
||||
it("renders the three model SLOT rows (slots-only, fix wave: unpicked reads —, no Default option)", () => {
|
||||
const html = renderToStaticMarkup(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);
|
||||
|
||||
Reference in New Issue
Block a user