Align dictation controls with settings dropdowns and show setup guidance

This commit is contained in:
amosblomqvist
2026-10-27 22:18:01 +02:00
parent f5a655b2f9
commit 27e277099f
8 changed files with 100 additions and 45 deletions
+11 -14
View File
@@ -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<typeof useDictationLanguage>;
export function DictationLanguageSetting() {
const preference = useDictationLanguage();
const active = useDictationActive();
return <SettingsRow label="Language" testid="row-dictation-language" dimmed={active}>
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>
<DictationLanguagePicker preference={preference} disabled={active} />
</SettingsRow>;
</div>;
}
function DictationLanguagePicker({ preference, disabled }: {
@@ -58,11 +60,9 @@ function DictationLanguagePicker({ preference, disabled }: {
return <>
<Hint label={error ?? `Dictation language: ${label}`} side="top">
<Button
<SettingsMenuTrigger
ref={triggerRef}
type="button"
variant="outline"
size="xs"
value={label}
data-testid="dictation-language"
aria-label={`Dictation language: ${label}`}
aria-expanded={open && !disabled}
@@ -72,11 +72,8 @@ function DictationLanguagePicker({ preference, disabled }: {
setOpen((value) => !value);
if (language === undefined) void load().catch(() => {});
}}
className="h-8 min-w-0 max-w-[65%] px-2.5 font-normal"
>
<span className="truncate">{label}</span>
<ChevronDownIcon aria-hidden className="size-3" />
</Button>
className="min-w-20 max-w-[65%]"
/>
</Hint>
{open && !disabled && <MenuShell
anchorRef={triggerRef}
+6 -12
View File
@@ -71,7 +71,7 @@ import {
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { prettyModel } from "@/components/usage-format";
import { cn } from "@/lib/utils";
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
import { SettingsGroup, SettingsRow, SettingsMenuTrigger, Toggle, CommitInput } from "./controls.tsx";
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
import { NativeSubscriptionBackgroundAccess } from "./subscription-connections";
@@ -245,23 +245,17 @@ function MenuTrigger({
disabled?: boolean;
}) {
const button = (
<button
type="button"
<SettingsMenuTrigger
value={value || emptyLabel}
aria-label={ariaLabel}
aria-expanded={open}
data-testid={testid}
onClick={() => {
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}`}
>
<span className="min-w-0 flex-1 truncate">{value || emptyLabel}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
</button>
unavailable={disabled}
className={widthClass}
/>
);
return hint ? <Hint label={hint}>{button}</Hint> : button;
}
+8 -15
View File
@@ -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"
>
<span ref={fontMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
{/* 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 = (
<button
type="button"
<SettingsMenuTrigger
value={appearance?.fontFamily || DEFAULT_READING_FONT}
aria-label="Reading font"
aria-expanded={fontMenuOpen}
data-testid="font-trigger"
onClick={openFontMenu}
className="flex h-7 min-w-20 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">{appearance?.fontFamily || DEFAULT_READING_FONT}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
</button>
className="min-w-20"
/>
);
return fontMenuOpen ? (
triggerButton
+25 -1
View File
@@ -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<ComponentPropsWithRef<"button">, "value" | "children"> & {
value: string;
unavailable?: boolean;
}) {
return <button
type="button"
{...props}
disabled={disabled}
className={cn(
"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 || unavailable ? "cursor-not-allowed" : "hover:border-muted-foreground/50",
className,
)}
>
<span className="min-w-0 flex-1 truncate">{value}</span>
<span aria-hidden className="text-muted-foreground">▾</span>
</button>;
}
/** The on/off switch — a plain button in the switch role. */
export function Toggle({
checked,
+11 -1
View File
@@ -490,7 +490,17 @@ export function KeysSection({
testid={`key-${status.id.toLowerCase()}`}
/>
))}
{label === "Dictation" && <DictationLanguageSetting />}
{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"
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>Member role or higher</a>, then add it above.
</p>
</>}
</SettingsGroup>)}
</>
+1 -1
View File
@@ -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
+9 -1
View File
@@ -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");
+29
View File
@@ -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(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />), "slot-model-strong"],
[renderToStaticMarkup(<AppearanceSection appearance={undefined} onSectionChange={() => {}} />), "font-trigger"],
[renderToStaticMarkup(<DictationLanguageSetting />), "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(<SettingsMenuTrigger value="English" disabled data-testid="disabled-trigger" />), "disabled-trigger");
const unavailable = tagOf(renderToStaticMarkup(<SettingsMenuTrigger value="Automatic" unavailable data-testid="unavailable-trigger" />), "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(<AgentSection agent={undefined} models={undefined} graph={undefined} onSectionChange={() => {}} onModelsChange={() => {}} />);