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>)}
</>