diff --git a/components/dictation-language.tsx b/components/dictation-language.tsx index eeec821..b0db181 100644 --- a/components/dictation-language.tsx +++ b/components/dictation-language.tsx @@ -1,12 +1,10 @@ "use client"; import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; -import { ChevronDownIcon } from "lucide-react"; import { DICTATION_LANGUAGES, type DictationLanguage } from "@/src/core"; import { MenuShell } from "@/components/menu-shell"; -import { Button } from "@/components/ui/button"; import { Hint } from "@/components/ui/hint"; -import { SettingsRow } from "@/components/settings/controls"; +import { SettingsMenuTrigger } from "@/components/settings/controls"; import { SearchMenuList } from "@/components/settings/search-menu-list"; import { useDictationActive } from "@/components/dictation-state"; import { useDictationLanguage } from "@/components/dictation-preference"; @@ -16,9 +14,13 @@ type LanguagePreference = ReturnType; export function DictationLanguageSetting() { const preference = useDictationLanguage(); const active = useDictationActive(); - return + return
+ Language - ; +
; } function DictationLanguagePicker({ preference, disabled }: { @@ -58,11 +60,9 @@ function DictationLanguagePicker({ preference, disabled }: { return <> - + className="min-w-20 max-w-[65%]" + /> {open && !disabled && { if (!disabled) onClick(); }} - className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${ - disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50" - } ${widthClass}`} - > - {value || emptyLabel} - - ▾ - - + unavailable={disabled} + className={widthClass} + /> ); return hint ? {button} : button; } diff --git a/components/settings/appearance-section.tsx b/components/settings/appearance-section.tsx index 30d3af9..d75ee47 100644 --- a/components/settings/appearance-section.tsx +++ b/components/settings/appearance-section.tsx @@ -41,7 +41,7 @@ import { MenuShell } from "@/components/menu-shell"; import { Hint } from "@/components/ui/hint"; import { useAppearance } from "./appearance.tsx"; import { FontMenuList } from "./model-menu"; -import { CommitInput, SettingsRow, ThinSelect, Toggle } from "./controls.tsx"; +import { CommitInput, SettingsMenuTrigger, SettingsRow, ThinSelect, Toggle } from "./controls.tsx"; /** The factory reading family — the DEFAULT row's identity (unset = * this). Exported for tests. */ @@ -210,29 +210,22 @@ export function AppearanceSection({ testid="row-font" > - {/* The MenuTrigger look (agent-section's collapsed - select — it lives unexported there, so the - classes ride inline; the searchable machine is - the shared part). The tooltip rides the CLOSED - button ONLY (owner ruling 2026-09-26: never while + {/* The shared Agent/Appearance collapsed-menu trigger. + The tooltip rides the CLOSED button ONLY + (owner ruling 2026-09-26: never while the dropdown is out — it popped over the open menu when the wrap covered the whole anchor span). */} {(() => { const triggerButton = ( - + className="min-w-20" + /> ); return fontMenuOpen ? ( triggerButton diff --git a/components/settings/controls.tsx b/components/settings/controls.tsx index 4aaa385..1715be1 100644 --- a/components/settings/controls.tsx +++ b/components/settings/controls.tsx @@ -9,7 +9,7 @@ * control together) and the control itself locks — the row owns the dim. */ -import { useState, type ReactNode } from "react"; +import { useState, type ComponentPropsWithRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; /** A section block: pronounced mono heading + content. */ @@ -81,6 +81,30 @@ export function SettingsRow({ ); } +/** The existing Agent/Appearance collapsed-menu look, shared with dictation. + * `unavailable` preserves callers that gate clicks without native disabling; + * `disabled` retains native button semantics where the caller requires them. */ +export function SettingsMenuTrigger({ + value, unavailable, disabled, className, ...props +}: Omit, "value" | "children"> & { + value: string; + unavailable?: boolean; +}) { + return ; +} + /** The on/off switch — a plain button in the switch role. */ export function Toggle({ checked, diff --git a/components/settings/keys-section.tsx b/components/settings/keys-section.tsx index 14680b7..e72f6fc 100644 --- a/components/settings/keys-section.tsx +++ b/components/settings/keys-section.tsx @@ -490,7 +490,17 @@ export function KeysSection({ testid={`key-${status.id.toLowerCase()}`} /> ))} - {label === "Dictation" && } + {label === "Dictation" && <> + +

+ Create a Deepgram key with the Member role or higher, then add it above. +

+ } )} diff --git a/docs/development.md b/docs/development.md index 0eee7fe..d5465bc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. +**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. ## Testing diff --git a/tests/dictation-picker.test.tsx b/tests/dictation-picker.test.tsx index 4bf33b6..8e578ea 100644 --- a/tests/dictation-picker.test.tsx +++ b/tests/dictation-picker.test.tsx @@ -110,7 +110,9 @@ test("an initial load failure is visible and opening the menu again can retry", test("the real picker searches by language, chooses with Arrow/Enter, and restores focus", async () => { const f = await fixture(); assert.match(trigger().textContent!, /English/); - assert.equal(trigger().getAttribute("data-variant"), "outline"); + for (const token of ["h-7", "rounded-sm", "border-border", "bg-background", "font-ui", "text-xs"]) { + assert.ok(trigger().className.split(" ").includes(token), `shared settings trigger: ${token}`); + } await click(trigger()); assert.match(input().className, /lh-input/); assert.equal(document.activeElement, input()); @@ -226,9 +228,15 @@ for (const source of ["none", "file", "env"] as const) test(`Language is below t const rows = group.lastElementChild!.children; 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.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(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")); + assert.ok(byTest("dictation-setup-hint"), "guidance remains visible while the key is edited"); await click(trigger()); assert(menu()); await key(input(), "Escape"); diff --git a/tests/render-settings.test.tsx b/tests/render-settings.test.tsx index 6c21490..5cbb123 100644 --- a/tests/render-settings.test.tsx +++ b/tests/render-settings.test.tsx @@ -19,6 +19,8 @@ import { KeyboardSection } from "../components/settings/keyboard-section.tsx"; import type { GraphSettings } from "../src/core/index.ts"; import { SettingsDialogBody, SettingsDialogProvider } from "../components/settings/settings-dialog.tsx"; import { KEYBOARD_ACTIONS } from "../src/core/index.ts"; +import { DictationLanguageSetting } from "../components/dictation-language.tsx"; +import { SettingsMenuTrigger } from "../components/settings/controls.tsx"; /** The full opening tag of the element carrying a testid — attribute * order in React output is not contractual, so assertions run on the @@ -60,6 +62,33 @@ describe("SettingsDialog (the designed surface, SSR)", () => { }); }); +describe("the shared settings dropdown trigger", () => { + it("Agent, Appearance and dictation use the same compact dark mono trigger", () => { + const surfaces = [ + [renderToStaticMarkup( {}} onModelsChange={() => {}} />), "slot-model-strong"], + [renderToStaticMarkup( {}} />), "font-trigger"], + [renderToStaticMarkup(), "dictation-language"], + ] as const; + for (const [html, id] of surfaces) { + const tag = tagOf(html, id); + const classes = tag.match(/class="([^"]+)"/)![1]!.split(" "); + for (const token of ["h-7", "rounded-sm", "border", "border-border", "bg-background", "px-1.5", "font-ui", "text-xs", "hover:border-muted-foreground/50"]) { + assert.ok(classes.includes(token), `${id}: ${token}`); + } + assert.ok(html.includes("▾"), `${id}: shared dropdown indicator`); + } + }); + + it("preserves native disabling separately from callers' existing click-gated unavailable state", () => { + const disabled = tagOf(renderToStaticMarkup(), "disabled-trigger"); + const unavailable = tagOf(renderToStaticMarkup(), "unavailable-trigger"); + assert.ok(disabled.includes('disabled=""')); + assert.ok(!unavailable.includes('disabled=""')); + assert.ok(unavailable.includes("cursor-not-allowed")); + assert.ok(!unavailable.includes("hover:border-muted-foreground/50")); + }); +}); + describe("AgentSection (thin SSR)", () => { it("renders the three model SLOT rows (slots-only, fix wave: unpicked reads —, no Default option)", () => { const html = renderToStaticMarkup( {}} onModelsChange={() => {}} />);