Align dictation controls with settings dropdowns and show setup guidance
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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={() => {}} />);
|
||||
|
||||
Reference in New Issue
Block a user