Merge PR114 dictation foundation for approved provider-row finishing
Preserve original contributor commit 3bb28e5f29. Resolve only the settings-dialog conflict by retaining current General/update behavior: the donor General-only language placement is explicitly superseded by the human-approved row BELOW the full-width Deepgram key. The assigned implementation worker will implement that placement and approved shared dropdown finishing. Foundation only, not a checked candidate.
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
* discipline, same as the per-chat and defaults routes); sections are
|
* discipline, same as the per-chat and defaults routes); sections are
|
||||||
* independent, so partial writes can never clobber each other.
|
* independent, so partial writes can never clobber each other.
|
||||||
*
|
*
|
||||||
* section ∈ general | chat | agent | appearance | graph | compaction
|
* section ∈ general | chat | agent | appearance | graph | compaction | models | dictation
|
||||||
* Body: the complete desired section object. Parse + validate strictly
|
* Body: the complete desired section object. Parse + validate strictly
|
||||||
* (server/settings-body.ts): a bad shape/type is a 400 carrying the
|
* (server/settings-body.ts): a bad shape/type is a 400 carrying the
|
||||||
* supported list where one exists. The chat and models sections
|
* supported list where one exists. The chat and models sections
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||||
|
import { Command } from "cmdk";
|
||||||
|
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DICTATION_LANGUAGES, fuzzyScore, type DictationLanguage,
|
||||||
|
} from "@/src/core";
|
||||||
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Hint } from "@/components/ui/hint";
|
||||||
|
import { SettingsRow } from "@/components/settings/controls";
|
||||||
|
import { useDictationActive } from "@/components/dictation-state";
|
||||||
|
import { useDictationLanguage } from "@/components/dictation-preference";
|
||||||
|
|
||||||
|
type LanguagePreference = ReturnType<typeof useDictationLanguage>;
|
||||||
|
|
||||||
|
export function DictationLanguageSetting() {
|
||||||
|
const preference = useDictationLanguage();
|
||||||
|
const active = useDictationActive();
|
||||||
|
return <SettingsRow label="Language" testid="row-dictation-language" dimmed={active}>
|
||||||
|
<DictationLanguagePicker preference={preference} disabled={active} />
|
||||||
|
</SettingsRow>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DictationLanguagePicker({ preference, disabled }: {
|
||||||
|
preference: LanguagePreference;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
const { language, saving, error, load, save } = preference;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const label = DICTATION_LANGUAGES.find((item) => item.code === language)?.label ?? "—";
|
||||||
|
// Own ordering so clearing a search restores the catalog's order.
|
||||||
|
// cmdk handles keyboard/ARIA, but its DOM sorting retains old ranks.
|
||||||
|
const results = query.trim() ? DICTATION_LANGUAGES
|
||||||
|
.map((item) => ({ item, score: fuzzyScore(query, item.code, item.label) }))
|
||||||
|
.filter((item) => item.score !== null)
|
||||||
|
.sort((a, b) => b.score! - a.score!)
|
||||||
|
.map(({ item }) => item) : DICTATION_LANGUAGES;
|
||||||
|
|
||||||
|
useEffect(() => { if (disabled) setOpen(false); }, [disabled]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
|
const target = event.target as Element;
|
||||||
|
if (target instanceof Element && target.closest('[data-testid="dictation-language-menu"]')) return;
|
||||||
|
if (!triggerRef.current?.contains(target)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", onPointerDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
const closeOnEscape = (event: ReactKeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
};
|
||||||
|
const pick = async (code: DictationLanguage) => {
|
||||||
|
if (disabled || saving || language === undefined) return;
|
||||||
|
if (await save(code)) {
|
||||||
|
setOpen(false);
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Hint label={error ?? `Dictation language: ${label}`} side="top">
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
data-testid="dictation-language"
|
||||||
|
aria-label={`Dictation language: ${label}`}
|
||||||
|
aria-expanded={open && !disabled}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
disabled={disabled || saving}
|
||||||
|
onKeyDown={(event) => { if (open) closeOnEscape(event); }}
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setOpen((value) => !value);
|
||||||
|
if (language === undefined || error) 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>
|
||||||
|
</Hint>
|
||||||
|
{open && !disabled && <MenuShell
|
||||||
|
anchorRef={triggerRef}
|
||||||
|
testid="dictation-language-menu"
|
||||||
|
preferredSide="down"
|
||||||
|
menuClass="flex w-72 max-w-[calc(100vw-1rem)] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl"
|
||||||
|
>
|
||||||
|
{error && <div role="alert" className="mb-1.5 text-destructive">{error}</div>}
|
||||||
|
<Command
|
||||||
|
label="Dictation languages"
|
||||||
|
vimBindings={false}
|
||||||
|
shouldFilter={false}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
closeOnEscape(event);
|
||||||
|
// Keep menu navigation out of the composer's submit/shortcut
|
||||||
|
// handlers. cmdk owns selection, scrolling and ARIA state.
|
||||||
|
if (["ArrowDown", "ArrowUp", "Enter", "Home", "End"].includes(event.key)) event.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Command.Input asChild disabled={saving} value={query} onValueChange={setQuery}>
|
||||||
|
<Input aria-label="Search dictation languages" placeholder="Search languages…" className="mb-1.5 h-8 bg-background px-2 text-xs md:text-xs" />
|
||||||
|
</Command.Input>
|
||||||
|
<Command.List className="max-h-72 overflow-y-auto px-1">
|
||||||
|
<Command.Empty className="px-2 py-3 text-center text-muted-foreground">No languages match.</Command.Empty>
|
||||||
|
{results.map((item) => <Command.Item
|
||||||
|
key={item.code}
|
||||||
|
value={item.code}
|
||||||
|
disabled={saving || language === undefined}
|
||||||
|
onSelect={() => { void pick(item.code); }}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
tabIndex={-1}
|
||||||
|
disabled={saving || language === undefined}
|
||||||
|
data-testid={`dictation-language-${item.code}`}
|
||||||
|
className="h-auto w-full justify-between whitespace-normal rounded-lg px-2 py-1.5 text-left text-xs font-normal text-card-foreground data-[selected=true]:bg-accent/40"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{language === item.code && <CheckIcon aria-hidden className="size-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</Command.Item>)}
|
||||||
|
</Command.List>
|
||||||
|
</Command>
|
||||||
|
</MenuShell>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import {
|
||||||
|
FACTORY_SETTINGS, isDictationLanguage, sanitizeUserSettings,
|
||||||
|
type DictationLanguage, type UserSettings,
|
||||||
|
} from "@/src/core";
|
||||||
|
import { notifySettingsSaved, onSettingsSaved } from "@/components/settings-signal";
|
||||||
|
|
||||||
|
const INITIAL = { language: undefined as DictationLanguage | undefined, saving: false, error: null as string | null };
|
||||||
|
|
||||||
|
/** One confirmed preference and one write in flight, shared by every surface.
|
||||||
|
* The factory keeps transport tests isolated without a second persistence path.
|
||||||
|
*/
|
||||||
|
export function createDictationLanguageStore(request: typeof fetch, saved: () => void = () => {}) {
|
||||||
|
let snapshot = INITIAL;
|
||||||
|
let reading: Promise<DictationLanguage> | null = null;
|
||||||
|
let writing: Promise<boolean> | null = null;
|
||||||
|
let revision = 0;
|
||||||
|
let needsRead = true;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
const publish = (patch: Partial<typeof INITIAL>) => {
|
||||||
|
snapshot = { ...snapshot, ...patch };
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
};
|
||||||
|
|
||||||
|
function load(refresh = false): Promise<DictationLanguage> {
|
||||||
|
if (writing) return getLanguage();
|
||||||
|
if (reading && !refresh) return reading;
|
||||||
|
const version = ++revision;
|
||||||
|
needsRead = true;
|
||||||
|
reading = Promise.resolve().then(async () => {
|
||||||
|
try {
|
||||||
|
const res = await request("/api/settings");
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const body = await res.json() as { settings: UserSettings | null };
|
||||||
|
if (!("settings" in body)) throw new Error();
|
||||||
|
const record = sanitizeUserSettings(body.settings);
|
||||||
|
if (body.settings !== null && record === null) throw new Error();
|
||||||
|
if (version !== revision) return getLanguage();
|
||||||
|
const language = record?.dictation?.language ?? FACTORY_SETTINGS.dictation.language;
|
||||||
|
needsRead = false;
|
||||||
|
publish({ language, error: null });
|
||||||
|
return language;
|
||||||
|
} catch {
|
||||||
|
if (version !== revision) return getLanguage();
|
||||||
|
const message = "Could not load dictation language. Try again.";
|
||||||
|
publish({ error: message });
|
||||||
|
throw new Error(message);
|
||||||
|
} finally {
|
||||||
|
if (version === revision) reading = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return reading;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(language: DictationLanguage): Promise<boolean> {
|
||||||
|
// A different selection must never inherit the previous write's success.
|
||||||
|
if (writing || snapshot.language === undefined) return Promise.resolve(false);
|
||||||
|
revision++;
|
||||||
|
reading = null;
|
||||||
|
writing = Promise.resolve().then(async () => {
|
||||||
|
try {
|
||||||
|
const res = await request("/api/settings/dictation", {
|
||||||
|
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ language }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const body = await res.json() as { settings: UserSettings };
|
||||||
|
const confirmed = body.settings?.dictation?.language;
|
||||||
|
if (!isDictationLanguage(confirmed)) throw new Error();
|
||||||
|
needsRead = false;
|
||||||
|
publish({ language: confirmed, error: null });
|
||||||
|
saved();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
publish({ error: "Could not save dictation language. Try again." });
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
writing = null;
|
||||||
|
publish({ saving: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
publish({ saving: true, error: null });
|
||||||
|
return writing;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLanguage(): Promise<DictationLanguage> {
|
||||||
|
for (;;) {
|
||||||
|
if (writing) await writing;
|
||||||
|
else if (reading) await reading;
|
||||||
|
else if (needsRead || snapshot.language === undefined) await load();
|
||||||
|
else return snapshot.language;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
load, save, getLanguage,
|
||||||
|
getSnapshot: () => snapshot,
|
||||||
|
getServerSnapshot: () => INITIAL,
|
||||||
|
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let announcingSave = false;
|
||||||
|
const store = createDictationLanguageStore((...args) => fetch(...args), () => {
|
||||||
|
// All our readers already received the confirmed PUT response. Keep the
|
||||||
|
// repo's notification contract without depending on a redundant GET.
|
||||||
|
announcingSave = true;
|
||||||
|
try { notifySettingsSaved("dictation"); } finally { announcingSave = false; }
|
||||||
|
});
|
||||||
|
let readers = 0;
|
||||||
|
let stopSignals: (() => void) | undefined;
|
||||||
|
|
||||||
|
function subscribe(listener: () => void) {
|
||||||
|
const off = store.subscribe(listener);
|
||||||
|
if (readers++ === 0) {
|
||||||
|
stopSignals = onSettingsSaved((section) => {
|
||||||
|
if (section === "dictation" && !announcingSave) void store.load(true).catch(() => {});
|
||||||
|
});
|
||||||
|
if (!store.getSnapshot().saving) void store.load(true).catch(() => {});
|
||||||
|
}
|
||||||
|
return () => { off(); if (--readers === 0) { stopSignals?.(); stopSignals = undefined; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDictationLanguage() {
|
||||||
|
const snapshot = useSyncExternalStore(subscribe, store.getSnapshot, store.getServerSnapshot);
|
||||||
|
return { ...snapshot, load: store.load, save: store.save, getLanguage: store.getLanguage };
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
// The keyboard chain needs a synchronous answer; settings also subscribe
|
||||||
|
// so the language control locks from connecting until cleanup completes.
|
||||||
|
let active = false;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function isDictationActive(): boolean { return active; }
|
||||||
|
|
||||||
|
export function setDictationActive(next: boolean): void {
|
||||||
|
if (active === next) return;
|
||||||
|
active = next;
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => { listeners.delete(listener); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDictationActive(): boolean {
|
||||||
|
return useSyncExternalStore(subscribe, isDictationActive, () => false);
|
||||||
|
}
|
||||||
+49
-64
@@ -4,6 +4,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { Loader2Icon, MicIcon } from "lucide-react";
|
import { Loader2Icon, MicIcon } from "lucide-react";
|
||||||
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
||||||
import { Hint } from "@/components/ui/hint";
|
import { Hint } from "@/components/ui/hint";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { deepgramListenUrl, type DictationLanguage } from "@/src/core";
|
||||||
|
import { useDictationLanguage } from "@/components/dictation-preference";
|
||||||
|
import { setDictationActive } from "@/components/dictation-state";
|
||||||
|
export { isDictationActive } from "@/components/dictation-state";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Voice dictation — the cloud engine (STT wave 1, 2026-09-17).
|
* Voice dictation — the cloud engine (STT wave 1, 2026-09-17).
|
||||||
@@ -86,15 +91,7 @@ import { Hint } from "@/components/ui/hint";
|
|||||||
* endpointing=300, no interims. Silence streams too (the worklet never
|
* endpointing=300, no interims. Silence streams too (the worklet never
|
||||||
* pauses), so the socket never idles — no KeepAlive needed.
|
* pauses), so the socket never idles — no KeepAlive needed.
|
||||||
*/
|
*/
|
||||||
const DG_URL =
|
// URL construction lives in the browser-safe core; only language varies.
|
||||||
"wss://api.deepgram.com/v1/listen" +
|
|
||||||
"?model=nova-3" +
|
|
||||||
"&encoding=linear16" +
|
|
||||||
"&sample_rate=16000" +
|
|
||||||
"&channels=1" +
|
|
||||||
"&smart_format=true" +
|
|
||||||
"&punctuate=true" +
|
|
||||||
"&endpointing=300";
|
|
||||||
|
|
||||||
/** Meter tuning — the owner's tested values, see the file header. */
|
/** Meter tuning — the owner's tested values, see the file header. */
|
||||||
const METER_FLOOR_DB = -50;
|
const METER_FLOOR_DB = -50;
|
||||||
@@ -204,16 +201,6 @@ export function rmsToMeter(rms: number): number {
|
|||||||
*/
|
*/
|
||||||
let questionTarget: ((text: string) => void) | null = null;
|
let questionTarget: ((text: string) => void) | null = null;
|
||||||
|
|
||||||
/* Is a dictation run live on this page? The Esc chain (keyboard wave A,
|
|
||||||
* components/keyboard-shortcuts.tsx) needs a SYNCHRONOUS answer — the
|
|
||||||
* stop handler can't report back through a CustomEvent. Set at start(),
|
|
||||||
* cleared in cleanup() (every terminal path runs through cleanup). */
|
|
||||||
let dictationActiveFlag = false;
|
|
||||||
|
|
||||||
export function isDictationActive(): boolean {
|
|
||||||
return dictationActiveFlag;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The finisher registry: submit paths FAR from the hook's owner (the
|
/* The finisher registry: submit paths FAR from the hook's owner (the
|
||||||
* pending question's dock) need finish() without a prop bridge. Same
|
* pending question's dock) need finish() without a prop bridge. Same
|
||||||
* module-level pattern as questionTarget — one window, one instance,
|
* module-level pattern as questionTarget — one window, one instance,
|
||||||
@@ -285,6 +272,7 @@ export type Dictation = {
|
|||||||
* question waits.
|
* question waits.
|
||||||
*/
|
*/
|
||||||
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
|
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
|
||||||
|
const { getLanguage } = useDictationLanguage();
|
||||||
const [phase, setPhase] = useState<DictationPhase>("idle");
|
const [phase, setPhase] = useState<DictationPhase>("idle");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
|
||||||
@@ -327,9 +315,9 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
|
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
|
||||||
const cleanup = useCallback(() => {
|
const cleanup = useCallback((warmNext = true) => {
|
||||||
genRef.current++;
|
genRef.current++;
|
||||||
dictationActiveFlag = false;
|
setDictationActive(false);
|
||||||
flush(); // the deferred transcript appears when the run ends (owner round 2)
|
flush(); // the deferred transcript appears when the run ends (owner round 2)
|
||||||
if (safetyRef.current) {
|
if (safetyRef.current) {
|
||||||
clearTimeout(safetyRef.current);
|
clearTimeout(safetyRef.current);
|
||||||
@@ -357,16 +345,16 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
for (const w of waitersRef.current.splice(0)) w();
|
for (const w of waitersRef.current.splice(0)) w();
|
||||||
// Pre-mint for the next run (mitigation D) — best-effort; a keyless
|
// Pre-mint for the next run (mitigation D) — best-effort; a keyless
|
||||||
// page just doesn't warm.
|
// page just doesn't warm.
|
||||||
warmTokens.refresh();
|
if (warmNext) warmTokens.refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Open the Deepgram socket and wire its messages. `gen` guards staleness. */
|
/** Open the Deepgram socket and wire its messages. `gen` guards staleness. */
|
||||||
const openSocket = useCallback(
|
const openSocket = useCallback(
|
||||||
(gen: number, token: string) => {
|
(gen: number, token: string, language: DictationLanguage) => {
|
||||||
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
|
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
|
||||||
// jwt] is rejected at the handshake (verified live; "token" is for
|
// jwt] is rejected at the handshake (verified live; "token" is for
|
||||||
// raw API keys only, as in the owner's pi dictate extension).
|
// raw API keys only, as in the owner's pi dictate extension).
|
||||||
const ws = new WebSocket(DG_URL, ["bearer", token]);
|
const ws = new WebSocket(deepgramListenUrl(language), ["bearer", token]);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.addEventListener("open", () => {
|
ws.addEventListener("open", () => {
|
||||||
@@ -408,7 +396,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
reconnectsRef.current++;
|
reconnectsRef.current++;
|
||||||
mintFreshToken() // always FRESH — a stale warm token is the likely culprit
|
mintFreshToken() // always FRESH — a stale warm token is the likely culprit
|
||||||
.then((t) => {
|
.then((t) => {
|
||||||
if (gen === genRef.current) openSocket(gen, t);
|
if (gen === genRef.current) openSocket(gen, t, language);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (gen === genRef.current) {
|
if (gen === genRef.current) {
|
||||||
@@ -426,13 +414,23 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
);
|
);
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(async () => {
|
||||||
dictationActiveFlag = true;
|
setDictationActive(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setPhase("connecting");
|
setPhase("connecting");
|
||||||
reconnectsRef.current = 0;
|
reconnectsRef.current = 0;
|
||||||
bufferRef.current = []; // fresh run, fresh buffer (round 2)
|
bufferRef.current = []; // fresh run, fresh buffer (round 2)
|
||||||
pendingRef.current = []; // fresh run, fresh queue (mitigation A)
|
pendingRef.current = []; // fresh run, fresh queue (mitigation A)
|
||||||
const gen = ++genRef.current;
|
const gen = ++genRef.current;
|
||||||
|
let language: DictationLanguage;
|
||||||
|
try {
|
||||||
|
language = await getLanguage();
|
||||||
|
} catch (e) {
|
||||||
|
if (gen !== genRef.current) return;
|
||||||
|
setError(e instanceof Error ? e.message : "Dictation language unavailable");
|
||||||
|
cleanup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gen !== genRef.current) return;
|
||||||
|
|
||||||
// Mitigation D: the warm token (pre-minted at mount / after the last
|
// Mitigation D: the warm token (pre-minted at mount / after the last
|
||||||
// run) resolves ~instantly; a cold/keyless mint surfaces below. The
|
// run) resolves ~instantly; a cold/keyless mint surfaces below. The
|
||||||
@@ -509,8 +507,8 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
if (gen !== genRef.current) return;
|
if (gen !== genRef.current) return;
|
||||||
// Mitigation B: the handshake fires the moment the graph is live,
|
// Mitigation B: the handshake fires the moment the graph is live,
|
||||||
// overlapping any remaining token wait — mic-gated, never before it.
|
// overlapping any remaining token wait — mic-gated, never before it.
|
||||||
openSocket(gen, token);
|
openSocket(gen, token, language);
|
||||||
}, [openSocket, cleanup]);
|
}, [openSocket, cleanup, getLanguage]);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
const gen = genRef.current;
|
const gen = genRef.current;
|
||||||
@@ -586,7 +584,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
// attached.
|
// attached.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
warmTokens.refresh(); // pre-mint at mount (mitigation D) — silent if keyless
|
warmTokens.refresh(); // pre-mint at mount (mitigation D) — silent if keyless
|
||||||
}, []);
|
return () => {
|
||||||
|
// Navigation cancels capture: invalidate every pending await before
|
||||||
|
// releasing resources, and never append into a different page's input.
|
||||||
|
bufferRef.current = [];
|
||||||
|
cleanup(false);
|
||||||
|
};
|
||||||
|
}, [cleanup]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener("lh:kbd-toggle-dictation", toggle);
|
window.addEventListener("lh:kbd-toggle-dictation", toggle);
|
||||||
return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle);
|
return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle);
|
||||||
@@ -630,7 +634,6 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
|||||||
* error keeps the old behavior (click = retry, which clears). */
|
* error keeps the old behavior (click = retry, which clears). */
|
||||||
const keyless = error === DICTATION_KEYLESS;
|
const keyless = error === DICTATION_KEYLESS;
|
||||||
const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0));
|
const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0));
|
||||||
const recording = phase === "recording" || phase === "stopping";
|
|
||||||
/** Adaptive cell count (owner round 2): derived from the meter's
|
/** Adaptive cell count (owner round 2): derived from the meter's
|
||||||
* measured width — narrow rows show fewer bars instead of
|
* measured width — narrow rows show fewer bars instead of
|
||||||
* overflowing under neighbors. */
|
* overflowing under neighbors. */
|
||||||
@@ -673,17 +676,11 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
|||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return (
|
return (
|
||||||
<Hint
|
<Hint label={error ?? "Dictate"} action={error ? undefined : "toggle-dictation"} side="top">
|
||||||
// Errored: the button span truncates the line — the tooltip is its
|
<Button
|
||||||
// full reveal (the keyless line KEEPS "Deepgram" — owner overrule:
|
|
||||||
// it's the Keys row label, the truth the user needs). Otherwise the
|
|
||||||
// quiet label + the live ⌘D binding.
|
|
||||||
label={error ?? "Dictate"}
|
|
||||||
action={error ? undefined : "toggle-dictation"}
|
|
||||||
side="top"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (keyless) {
|
if (keyless) {
|
||||||
clearError();
|
clearError();
|
||||||
@@ -694,45 +691,33 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
|
|||||||
}}
|
}}
|
||||||
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
|
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
|
||||||
data-testid="dictation-toggle"
|
data-testid="dictation-toggle"
|
||||||
// Same blurring as the floating chrome (owner, fix wave round 3),
|
className="h-7 w-full min-w-0 gap-1.5 overflow-hidden border-border/50 bg-card/50 font-normal text-muted-foreground/70 shadow-lg backdrop-blur hover:border-border hover:bg-card/50 hover:text-muted-foreground dark:bg-card/50 dark:hover:bg-card/50"
|
||||||
// but lower opacity — the mic stays quieter than the model pills.
|
>
|
||||||
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 bg-card/50 text-xs text-muted-foreground/70 shadow-lg backdrop-blur transition-colors hover:border-border hover:text-muted-foreground"
|
|
||||||
>
|
|
||||||
<MicIcon className="size-3.5" />
|
<MicIcon className="size-3.5" />
|
||||||
<span className="max-w-full truncate">{error ?? "Dictate"}</span>
|
<span className="max-w-full truncate">{error ?? "Dictate"}</span>
|
||||||
</button>
|
</Button>
|
||||||
</Hint>
|
</Hint>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
|
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
|
||||||
<Hint
|
<Hint label="Stop dictation (Esc)" side="top">
|
||||||
// The owner flagged this copy good ("Stop dictation (Esc)") —
|
<Button
|
||||||
// kept verbatim; only the machinery moved to the styled tooltip.
|
|
||||||
label="Stop dictation (Esc)"
|
|
||||||
side="top"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
disabled={phase === "stopping"}
|
disabled={phase === "stopping"}
|
||||||
aria-label="Stop dictation"
|
aria-label="Stop dictation"
|
||||||
data-testid="dictation-toggle"
|
data-testid="dictation-toggle"
|
||||||
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md bg-destructive/10 px-2 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
|
className="h-7 gap-1.5 bg-destructive/10 text-destructive hover:bg-destructive/20"
|
||||||
>
|
>
|
||||||
{phase === "stopping" ? (
|
{phase === "stopping" ? <Loader2Icon className="size-3.5 animate-spin" /> : <MicIcon className="size-3.5" />}
|
||||||
<Loader2Icon className="size-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<MicIcon className="size-3.5" />
|
|
||||||
)}
|
|
||||||
Stop
|
Stop
|
||||||
</button>
|
</Button>
|
||||||
</Hint>
|
</Hint>
|
||||||
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden />
|
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden />
|
||||||
{/* Fixed width while recording — nothing ever appends text beside it
|
|
||||||
(no interims, owner round 1). Cells carry no minimum width; the
|
|
||||||
COUNT adapts to the measured row space (owner round 2). */}
|
|
||||||
<div ref={meterRef} className="flex h-4 min-w-0 flex-1 items-center gap-[2px]" data-testid="dictation-meter">
|
<div ref={meterRef} className="flex h-4 min-w-0 flex-1 items-center gap-[2px]" data-testid="dictation-meter">
|
||||||
{levels.slice(-cellCount).map((v, i) => (
|
{levels.slice(-cellCount).map((v, i) => (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const SETTINGS_SAVED_EVENT = "lh:settings-saved";
|
|||||||
// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section
|
// "keys" joined the roster (zero-keys wave, 2026-09-25): keys-section
|
||||||
// dispatches it after save AND delete — deletions used to be invisible to
|
// dispatches it after save AND delete — deletions used to be invisible to
|
||||||
// the zero-keys pill and the setup dialog's auto-advance.
|
// the zero-keys pill and the setup dialog's auto-advance.
|
||||||
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction";
|
export type SettingsSection = "general" | "keys" | "agent" | "models" | "appearance" | "graph" | "compaction" | "dictation";
|
||||||
|
|
||||||
/** Writers call this after the PUT resolves ok — never before (a failed
|
/** Writers call this after the PUT resolves ok — never before (a failed
|
||||||
* write notifies nothing; the record is unchanged). */
|
* write notifies nothing; the record is unchanged). */
|
||||||
@@ -47,4 +47,4 @@ export function useSettingsSaved(handler: (section: SettingsSection) => void): v
|
|||||||
const saved = useRef(handler);
|
const saved = useRef(handler);
|
||||||
saved.current = handler;
|
saved.current = handler;
|
||||||
useEffect(() => onSettingsSaved((section) => saved.current(section)), []);
|
useEffect(() => onSettingsSaved((section) => saved.current(section)), []);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -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.
|
**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.
|
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. Settings → General → Dictation → Language 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; subsequent read failures cannot revert a confirmed choice. External 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 composes the shared Button, Input, Hint and MenuShell with the existing `cmdk` dependency for accessible list navigation, using the app's fuzzy-search scoring.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -196,4 +196,4 @@ The teaching methodology comes from an Obsidian-based POC (its `knowledge graph
|
|||||||
|
|
||||||
Nothing under `data/` is ever tracked by git. Chats, graph ledgers, jobs, positions, and the settings records are plain files (JSON/JSONL) — inspect them freely; they are the canonical records. The desktop app relocates the whole directory via `LH_DATA_DIR`.
|
Nothing under `data/` is ever tracked by git. Chats, graph ledgers, jobs, positions, and the settings records are plain files (JSON/JSONL) — inspect them freely; they are the canonical records. The desktop app relocates the whole directory via `LH_DATA_DIR`.
|
||||||
|
|
||||||
**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand.
|
**Schema stamps (2026-10-10):** chat records carry `version` (stamped at the store's write choke point; absence reads as v1 — lazy migration, no mass rewrite), and the graph family (ledgers, sources, render-settings) shares one sidecar `graph/format.json`, created on the family's first write. `positions.json` is deliberately unstamped (rebuildable). A record stamped NEWER than the running build is refused on read — honest refusal for the downgrade scenario: a read-modify-write store never rewrites what it doesn't understand.
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/** Nova-3 monolingual language codes, including documented locale aliases.
|
||||||
|
* Source: https://developers.deepgram.com/docs/models-languages-overview
|
||||||
|
* Multilingual is a separate mode; its supported language set differs.
|
||||||
|
*/
|
||||||
|
const NOVA_3_LANGUAGES = [
|
||||||
|
["Afrikaans", ["af", "af-ZA"]],
|
||||||
|
["Arabic", ["ar", "ar-AE", "ar-SA", "ar-QA", "ar-KW", "ar-SY", "ar-LB", "ar-PS", "ar-JO", "ar-EG", "ar-SD", "ar-TD", "ar-MA", "ar-DZ", "ar-TN", "ar-IQ", "ar-IR"]],
|
||||||
|
["Armenian", ["hy"]],
|
||||||
|
["Assamese", ["as", "as-IN"]],
|
||||||
|
["Belarusian", ["be"]],
|
||||||
|
["Bengali", ["bn"]],
|
||||||
|
["Bosnian", ["bs"]],
|
||||||
|
["Bulgarian", ["bg"]],
|
||||||
|
["Catalan", ["ca"]],
|
||||||
|
["Chinese (Cantonese, Traditional)", ["zh-HK"]],
|
||||||
|
["Chinese (Mandarin, Simplified)", ["zh", "zh-CN", "zh-Hans"]],
|
||||||
|
["Chinese (Mandarin, Traditional)", ["zh-TW", "zh-Hant"]],
|
||||||
|
["Croatian", ["hr"]],
|
||||||
|
["Czech", ["cs", "cs-CZ"]],
|
||||||
|
["Danish", ["da", "da-DK"]],
|
||||||
|
["Dutch", ["nl"]],
|
||||||
|
["English", ["en", "en-US", "en-AU", "en-GB", "en-IN", "en-NZ"]],
|
||||||
|
["Estonian", ["et"]],
|
||||||
|
["Finnish", ["fi"]],
|
||||||
|
["Flemish", ["nl-BE"]],
|
||||||
|
["French", ["fr", "fr-CA"]],
|
||||||
|
["Georgian", ["ka", "ka-GE"]],
|
||||||
|
["German", ["de"]],
|
||||||
|
["German (Switzerland)", ["de-CH"]],
|
||||||
|
["Greek", ["el"]],
|
||||||
|
["Gujarati", ["gu", "gu-IN"]],
|
||||||
|
["Hebrew", ["he"]],
|
||||||
|
["Hindi", ["hi"]],
|
||||||
|
["Hungarian", ["hu"]],
|
||||||
|
["Indonesian", ["id"]],
|
||||||
|
["Italian", ["it"]],
|
||||||
|
["Japanese", ["ja"]],
|
||||||
|
["Kannada", ["kn"]],
|
||||||
|
["Kazakh", ["kk", "kk-KZ"]],
|
||||||
|
["Korean", ["ko", "ko-KR"]],
|
||||||
|
["Latvian", ["lv"]],
|
||||||
|
["Lithuanian", ["lt"]],
|
||||||
|
["Macedonian", ["mk"]],
|
||||||
|
["Malay", ["ms"]],
|
||||||
|
["Marathi", ["mr"]],
|
||||||
|
["Mongolian", ["mn"]],
|
||||||
|
["Nepali", ["ne"]],
|
||||||
|
["Norwegian", ["no"]],
|
||||||
|
["Pashto", ["ps", "ps-AF"]],
|
||||||
|
["Persian", ["fa"]],
|
||||||
|
["Polish", ["pl"]],
|
||||||
|
["Portuguese", ["pt", "pt-BR", "pt-PT"]],
|
||||||
|
["Punjabi", ["pa", "pa-IN"]],
|
||||||
|
["Romanian", ["ro"]],
|
||||||
|
["Russian", ["ru"]],
|
||||||
|
["Serbian", ["sr"]],
|
||||||
|
["Slovak", ["sk"]],
|
||||||
|
["Slovenian", ["sl"]],
|
||||||
|
["Spanish", ["es", "es-419"]],
|
||||||
|
["Swedish", ["sv", "sv-SE"]],
|
||||||
|
["Tagalog", ["tl"]],
|
||||||
|
["Tamil", ["ta"]],
|
||||||
|
["Telugu", ["te"]],
|
||||||
|
["Thai", ["th", "th-TH"]],
|
||||||
|
["Turkish", ["tr", "tr-TR"]],
|
||||||
|
["Ukrainian", ["uk"]],
|
||||||
|
["Urdu", ["ur"]],
|
||||||
|
["Vietnamese", ["vi"]],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type DictationLanguage = "multi" | (typeof NOVA_3_LANGUAGES)[number][1][number];
|
||||||
|
|
||||||
|
export const DICTATION_LANGUAGES: readonly { code: DictationLanguage; label: string }[] = [
|
||||||
|
{ code: "multi", label: "Multilingual" },
|
||||||
|
...NOVA_3_LANGUAGES.flatMap(([name, codes]) =>
|
||||||
|
codes.map((code, i) => ({ code, label: i === 0 ? name : `${name} (${code})` })),
|
||||||
|
).sort((a, b) => a.label.localeCompare(b.label, "en")),
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isDictationLanguage(value: unknown): value is DictationLanguage {
|
||||||
|
return DICTATION_LANGUAGES.some(({ code }) => code === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only language varies. Keep the owner's tuned streaming parameters. */
|
||||||
|
export function deepgramListenUrl(language: DictationLanguage): string {
|
||||||
|
return "wss://api.deepgram.com/v1/listen" +
|
||||||
|
"?model=nova-3&encoding=linear16&sample_rate=16000&channels=1" +
|
||||||
|
"&smart_format=true&punctuate=true&endpointing=300" +
|
||||||
|
`&language=${encodeURIComponent(language)}`;
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ export * from "./fuzzy.ts";
|
|||||||
// the user-settings record's shape/sanitizer (pure) and its fully-resolved
|
// the user-settings record's shape/sanitizer (pure) and its fully-resolved
|
||||||
// view. Browser-safe — the Wave-4 panel imports these directly.
|
// view. Browser-safe — the Wave-4 panel imports these directly.
|
||||||
export * from "./settings.ts";
|
export * from "./settings.ts";
|
||||||
|
export * from "./dictation.ts";
|
||||||
// graph-deletion.ts (wave 6): the whole module is PURE but storage-port
|
// graph-deletion.ts (wave 6): the whole module is PURE but storage-port
|
||||||
// driven — the deletion RUNS server-side only. The shell needs exactly one
|
// driven — the deletion RUNS server-side only. The shell needs exactly one
|
||||||
// two type-only things from it: the ChatDeleteMode union (the delete
|
// two type-only things from it: the ChatDeleteMode union (the delete
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
type BackgroundModelRole,
|
type BackgroundModelRole,
|
||||||
type FactorySettings,
|
type FactorySettings,
|
||||||
type GeneralSettings,
|
type GeneralSettings,
|
||||||
|
type DictationSettings,
|
||||||
type CompactionSettings,
|
type CompactionSettings,
|
||||||
type GraphSettings,
|
type GraphSettings,
|
||||||
type ModelSlot,
|
type ModelSlot,
|
||||||
@@ -69,6 +70,7 @@ export type EffectiveAppearance = FactorySettings["appearance"];
|
|||||||
/** The concrete view: every knob resolved, no absent fields. */
|
/** The concrete view: every knob resolved, no absent fields. */
|
||||||
export interface EffectiveSettings {
|
export interface EffectiveSettings {
|
||||||
general: Required<GeneralSettings>;
|
general: Required<GeneralSettings>;
|
||||||
|
dictation: Required<DictationSettings>;
|
||||||
agent: {
|
agent: {
|
||||||
models: Record<BackgroundModelRole, EffectiveModelSpec | undefined>;
|
models: Record<BackgroundModelRole, EffectiveModelSpec | undefined>;
|
||||||
/** Delta map as stored — consumers resolve absent key → enabled. */
|
/** Delta map as stored — consumers resolve absent key → enabled. */
|
||||||
@@ -176,6 +178,9 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
|
|||||||
general: {
|
general: {
|
||||||
autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate,
|
autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate,
|
||||||
},
|
},
|
||||||
|
dictation: {
|
||||||
|
language: record?.dictation?.language ?? FACTORY_SETTINGS.dictation.language,
|
||||||
|
},
|
||||||
agent: {
|
agent: {
|
||||||
models,
|
models,
|
||||||
tools: agent.tools ?? {},
|
tools: agent.tools ?? {},
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
|
|||||||
import type { ModelRole } from "../../config.ts";
|
import type { ModelRole } from "../../config.ts";
|
||||||
import type { ChatSettings } from "./contracts/chat.ts";
|
import type { ChatSettings } from "./contracts/chat.ts";
|
||||||
import type { SearchEngineId } from "./search.ts";
|
import type { SearchEngineId } from "./search.ts";
|
||||||
|
import { isDictationLanguage, type DictationLanguage } from "./dictation.ts";
|
||||||
|
|
||||||
/** The schema version this code understands. Bump ONLY for breaking shape
|
/** The schema version this code understands. Bump ONLY for breaking shape
|
||||||
* changes (a field renamed/meaning changed); pure additions never bump. */
|
* changes (a field renamed/meaning changed); pure additions never bump. */
|
||||||
@@ -235,6 +236,11 @@ export interface GeneralSettings {
|
|||||||
autoUpdate?: boolean;
|
autoUpdate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One app-wide dictation preference, shared by every composer. */
|
||||||
|
export interface DictationSettings {
|
||||||
|
language?: DictationLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
/** Agent settings — everything about the non-conversation machinery. */
|
/** Agent settings — everything about the non-conversation machinery. */
|
||||||
export interface AgentSettings {
|
export interface AgentSettings {
|
||||||
/** Background-role model/thinking overrides (absent role = config pin). */
|
/** Background-role model/thinking overrides (absent role = config pin). */
|
||||||
@@ -361,6 +367,7 @@ export interface CompactionSettings {
|
|||||||
export interface UserSettings {
|
export interface UserSettings {
|
||||||
version: number;
|
version: number;
|
||||||
general?: GeneralSettings;
|
general?: GeneralSettings;
|
||||||
|
dictation?: DictationSettings;
|
||||||
agent?: AgentSettings;
|
agent?: AgentSettings;
|
||||||
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
|
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
|
||||||
models?: ModelsSettings;
|
models?: ModelsSettings;
|
||||||
@@ -378,6 +385,7 @@ export interface UserSettings {
|
|||||||
export interface FactorySettings {
|
export interface FactorySettings {
|
||||||
version: number;
|
version: number;
|
||||||
general: Required<GeneralSettings>;
|
general: Required<GeneralSettings>;
|
||||||
|
dictation: Required<DictationSettings>;
|
||||||
appearance: {
|
appearance: {
|
||||||
mode: "dark" | "light";
|
mode: "dark" | "light";
|
||||||
background: string | null;
|
background: string | null;
|
||||||
@@ -397,6 +405,7 @@ export interface FactorySettings {
|
|||||||
export const FACTORY_SETTINGS: FactorySettings = {
|
export const FACTORY_SETTINGS: FactorySettings = {
|
||||||
version: CURRENT_SETTINGS_VERSION,
|
version: CURRENT_SETTINGS_VERSION,
|
||||||
general: { autoUpdate: true },
|
general: { autoUpdate: true },
|
||||||
|
dictation: { language: "en" },
|
||||||
appearance: {
|
appearance: {
|
||||||
mode: "dark",
|
mode: "dark",
|
||||||
background: null, // the dark default (#141414) lives in the mode's derivation curve
|
background: null, // the dark default (#141414) lives in the mode's derivation curve
|
||||||
@@ -615,6 +624,13 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
|
|||||||
if (r.general !== null && typeof r.general === "object" && !Array.isArray(r.general)) {
|
if (r.general !== null && typeof r.general === "object" && !Array.isArray(r.general)) {
|
||||||
out.general = sanitizeGeneral(r.general as Record<string, unknown>);
|
out.general = sanitizeGeneral(r.general as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
if (r.dictation !== undefined) {
|
||||||
|
const raw = r.dictation;
|
||||||
|
out.dictation = raw !== null && typeof raw === "object" && !Array.isArray(raw) &&
|
||||||
|
isDictationLanguage((raw as Record<string, unknown>).language)
|
||||||
|
? { language: (raw as DictationSettings).language }
|
||||||
|
: {};
|
||||||
|
}
|
||||||
if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) {
|
if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) {
|
||||||
out.agent = sanitizeAgent(r.agent as Record<string, unknown>);
|
out.agent = sanitizeAgent(r.agent as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ import type {
|
|||||||
BackgroundModelRole,
|
BackgroundModelRole,
|
||||||
CompactionSettings,
|
CompactionSettings,
|
||||||
GeneralSettings,
|
GeneralSettings,
|
||||||
|
DictationSettings,
|
||||||
GraphSettings,
|
GraphSettings,
|
||||||
ModelsSettings,
|
ModelsSettings,
|
||||||
RoleModelOverride,
|
RoleModelOverride,
|
||||||
} from "../core/settings.ts";
|
} from "../core/settings.ts";
|
||||||
import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
|
import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
|
||||||
import { WRITABLE_TOOL_KEYS } from "../core/pending.ts";
|
import { WRITABLE_TOOL_KEYS } from "../core/pending.ts";
|
||||||
|
import { isDictationLanguage } from "../core/dictation.ts";
|
||||||
|
|
||||||
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */
|
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */
|
||||||
export type SettingsBodyResult<T> =
|
export type SettingsBodyResult<T> =
|
||||||
@@ -375,6 +377,15 @@ export function parseCompactionSettingsBody(body: unknown): SettingsBodyResult<C
|
|||||||
return { ok: true, settings };
|
return { ok: true, settings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** dictation: { language?: one of Nova-3's supported language codes } */
|
||||||
|
export function parseDictationSettingsBody(body: unknown): SettingsBodyResult<DictationSettings> {
|
||||||
|
const raw = asRecord(body);
|
||||||
|
if (!raw) return { ok: false, error: "Invalid JSON body" };
|
||||||
|
if (raw.language === undefined) return { ok: true, settings: {} };
|
||||||
|
if (!isDictationLanguage(raw.language)) return { ok: false, error: "Unsupported dictation language" };
|
||||||
|
return { ok: true, settings: { language: raw.language } };
|
||||||
|
}
|
||||||
|
|
||||||
/** The section→parser map for PUT /api/settings/[section] (the route also
|
/** The section→parser map for PUT /api/settings/[section] (the route also
|
||||||
* carries the special prompts sub-resource). */
|
* carries the special prompts sub-resource). */
|
||||||
export const SETTINGS_SECTIONS = {
|
export const SETTINGS_SECTIONS = {
|
||||||
@@ -385,6 +396,7 @@ export const SETTINGS_SECTIONS = {
|
|||||||
graph: parseGraphSettingsBody,
|
graph: parseGraphSettingsBody,
|
||||||
compaction: parseCompactionSettingsBody,
|
compaction: parseCompactionSettingsBody,
|
||||||
models: parseModelsSettingsBody,
|
models: parseModelsSettingsBody,
|
||||||
|
dictation: parseDictationSettingsBody,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;
|
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import React, { act } from "react";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { afterEach, test } from "node:test";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
import { useDictation, type Dictation } from "../components/dictation.tsx";
|
||||||
|
import { notifySettingsSaved } from "../components/settings-signal.ts";
|
||||||
|
import { useDictationLanguage } from "../components/dictation-preference.ts";
|
||||||
|
import { isDictationActive, useDictationActive } from "../components/dictation-state.ts";
|
||||||
|
import type { UserSettings } from "../src/core/index.ts";
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((done) => { resolve = done; });
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
class Socket extends EventTarget {
|
||||||
|
static CONNECTING = 0;
|
||||||
|
static OPEN = 1;
|
||||||
|
static all: Socket[] = [];
|
||||||
|
readyState = Socket.CONNECTING;
|
||||||
|
sent: unknown[] = [];
|
||||||
|
constructor(readonly url: string, readonly protocols: string[]) { super(); Socket.all.push(this); }
|
||||||
|
get language() { return new URL(this.url).searchParams.get("language"); }
|
||||||
|
send(data: unknown) { this.sent.push(data); }
|
||||||
|
close() { this.readyState = 3; }
|
||||||
|
open() { this.readyState = Socket.OPEN; this.dispatchEvent(new Event("open")); }
|
||||||
|
end(code = 1000) { this.close(); this.dispatchEvent(Object.assign(new Event("close"), { code })); }
|
||||||
|
final(text: string) {
|
||||||
|
this.dispatchEvent(Object.assign(new Event("message"), {
|
||||||
|
data: JSON.stringify({ type: "Results", is_final: true, channel: { alternatives: [{ transcript: text }] } }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AudioGraph {
|
||||||
|
state = "running";
|
||||||
|
destination = {};
|
||||||
|
audioWorklet = { addModule: async () => {} };
|
||||||
|
createMediaStreamSource() { return { connect() {}, disconnect() {} }; }
|
||||||
|
}
|
||||||
|
class Worklet { port = {}; connect() {} disconnect() {} }
|
||||||
|
|
||||||
|
const globals = globalThis as Record<string, unknown>;
|
||||||
|
let restore: (() => void) | undefined;
|
||||||
|
let dispose: (() => Promise<void>) | undefined;
|
||||||
|
|
||||||
|
async function fixture(language: "pl" | "en" = "pl", delayedRead?: Promise<Response>) {
|
||||||
|
const dom = parseHTML("<html><body><div id='root'></div></body></html>");
|
||||||
|
const state = {
|
||||||
|
record: { version: 1, dictation: { language } } as UserSettings,
|
||||||
|
nextRead: delayedRead,
|
||||||
|
nextWrite: undefined as Promise<Response> | undefined,
|
||||||
|
nextMic: undefined as Promise<{ getTracks: () => { stop: () => void }[] }> | undefined,
|
||||||
|
readFails: false,
|
||||||
|
active: false,
|
||||||
|
captures: 0,
|
||||||
|
stops: 0,
|
||||||
|
appends: [] as string[],
|
||||||
|
};
|
||||||
|
Socket.all = [];
|
||||||
|
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||||
|
for (const [key, value] of Object.entries({
|
||||||
|
React, window: dom.window, document: dom.document, CustomEvent: dom.window.CustomEvent,
|
||||||
|
navigator: { userAgent: "", platform: "", mediaDevices: { getUserMedia: async () => {
|
||||||
|
state.captures++;
|
||||||
|
if (state.nextMic) return state.nextMic;
|
||||||
|
return { getTracks: () => [{ stop() { state.stops++; } }] };
|
||||||
|
} } },
|
||||||
|
WebSocket: Socket, AudioContext: AudioGraph, AudioWorkletNode: Worklet,
|
||||||
|
IS_REACT_ACT_ENVIRONMENT: true,
|
||||||
|
fetch: async (url: string, init?: RequestInit) => {
|
||||||
|
if (url === "/api/stt/token") return Response.json({ token: "test-token" });
|
||||||
|
if (url === "/api/settings") {
|
||||||
|
if (state.nextRead) { const response = state.nextRead; state.nextRead = undefined; return response; }
|
||||||
|
return state.readFails ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record });
|
||||||
|
}
|
||||||
|
assert.equal(url, "/api/settings/dictation");
|
||||||
|
assert.equal(init?.method, "PUT");
|
||||||
|
if (state.nextWrite) { const response = state.nextWrite; state.nextWrite = undefined; return response; }
|
||||||
|
state.record = { ...state.record, dictation: JSON.parse(init.body as string) };
|
||||||
|
return Response.json({ settings: state.record });
|
||||||
|
},
|
||||||
|
})) {
|
||||||
|
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||||
|
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||||
|
}
|
||||||
|
restore = () => {
|
||||||
|
for (const [key, descriptor] of previous) {
|
||||||
|
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||||
|
else delete globals[key];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const { createRoot } = await import("react-dom/client");
|
||||||
|
let latest!: Dictation;
|
||||||
|
let setting!: ReturnType<typeof useDictationLanguage>;
|
||||||
|
function Harness() {
|
||||||
|
latest = useDictation({ append: (text) => state.appends.push(text) });
|
||||||
|
setting = useDictationLanguage();
|
||||||
|
state.active = useDictationActive();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let root = createRoot(document.getElementById("root")!);
|
||||||
|
const mount = async () => { await act(async () => { root.render(<Harness />); }); };
|
||||||
|
dispose = async () => { await act(async () => { root.unmount(); }); };
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
get dictation() { return latest; },
|
||||||
|
get setting() { return setting; },
|
||||||
|
mount,
|
||||||
|
unmount: () => dispose!(),
|
||||||
|
remount: async () => {
|
||||||
|
await dispose!();
|
||||||
|
root = createRoot(document.getElementById("root")!);
|
||||||
|
await mount();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => { await dispose?.(); restore?.(); dispose = undefined; restore = undefined; });
|
||||||
|
|
||||||
|
test("waits for the saved language; reconnect preserves it and finals still flush exactly once", async () => {
|
||||||
|
const read = deferred<Response>();
|
||||||
|
const f = await fixture("pl", read.promise);
|
||||||
|
await f.mount();
|
||||||
|
await act(async () => { f.dictation.toggle(); });
|
||||||
|
assert.equal(f.dictation.phase, "connecting");
|
||||||
|
assert.equal(f.state.active, true, "the settings row locks while connecting");
|
||||||
|
assert.equal(f.state.captures, 0);
|
||||||
|
assert.equal(Socket.all.length, 0, "must not open a default-English socket before settings arrive");
|
||||||
|
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
|
||||||
|
const first = Socket.all[0]!;
|
||||||
|
assert.equal(first.language, "pl");
|
||||||
|
await act(async () => { first.open(); first.final("Cześć"); });
|
||||||
|
assert.deepEqual(f.state.appends, []);
|
||||||
|
// A preference refresh during capture affects the next run only.
|
||||||
|
f.state.record.dictation = { language: "multi" };
|
||||||
|
await act(async () => { notifySettingsSaved("dictation"); });
|
||||||
|
await act(async () => { first.end(1006); });
|
||||||
|
const second = Socket.all[1]!;
|
||||||
|
assert.equal(second.language, "pl");
|
||||||
|
assert.deepEqual(second.protocols, ["bearer", "test-token"]);
|
||||||
|
await act(async () => { second.open(); second.final("świecie"); });
|
||||||
|
await act(async () => { f.dictation.stop(); });
|
||||||
|
assert.equal(f.dictation.phase, "stopping");
|
||||||
|
assert.equal(f.state.active, true, "the settings row remains locked while stopping");
|
||||||
|
assert.equal(second.sent.at(-1), JSON.stringify({ type: "CloseStream" }));
|
||||||
|
await act(async () => { second.end(); });
|
||||||
|
assert.deepEqual(f.state.appends, ["Cześć świecie"]);
|
||||||
|
assert.equal(f.dictation.phase, "idle");
|
||||||
|
assert.equal(f.state.active, false);
|
||||||
|
await act(async () => { f.dictation.toggle(); });
|
||||||
|
assert.equal(Socket.all[2]!.language, "multi");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed save keeps the previous language, including a shortcut pressed while saving", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await f.mount();
|
||||||
|
const write = deferred<Response>();
|
||||||
|
f.state.nextWrite = write.promise;
|
||||||
|
let saved!: Promise<boolean>;
|
||||||
|
await act(async () => { saved = f.setting.save("multi"); });
|
||||||
|
assert.equal(f.setting.saving, true);
|
||||||
|
await act(async () => { f.dictation.toggle(); });
|
||||||
|
assert.equal(f.state.captures, 0);
|
||||||
|
await act(async () => { write.resolve(Response.json({}, { status: 500 })); await saved; });
|
||||||
|
assert.equal(await saved, false);
|
||||||
|
assert.equal(f.setting.language, "pl");
|
||||||
|
assert.match(f.setting.error!, /Could not save/);
|
||||||
|
assert.equal(Socket.all[0]!.language, "pl");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("successful choices survive unmount/remount and failed reads never start capture", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await f.mount();
|
||||||
|
await act(async () => { assert.equal(await f.setting.save("multi"), true); });
|
||||||
|
await f.remount();
|
||||||
|
assert.equal(f.setting.language, "multi");
|
||||||
|
f.state.readFails = true;
|
||||||
|
await f.remount();
|
||||||
|
await act(async () => { f.dictation.toggle(); });
|
||||||
|
assert.match(f.dictation.error!, /Could not load/);
|
||||||
|
assert.equal(f.state.captures, 0);
|
||||||
|
assert.equal(Socket.all.length, 0);
|
||||||
|
f.state.readFails = false;
|
||||||
|
await act(async () => { f.dictation.toggle(); });
|
||||||
|
assert.equal(Socket.all[0]!.language, "multi");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a shortcut waits for a language save made by the separate settings control", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await f.mount();
|
||||||
|
const write = deferred<Response>();
|
||||||
|
f.state.nextWrite = write.promise;
|
||||||
|
let saved!: Promise<boolean>;
|
||||||
|
await act(async () => { saved = f.setting.save("multi"); });
|
||||||
|
await act(async () => { f.dictation.toggle(); });
|
||||||
|
assert.equal(f.state.captures, 0);
|
||||||
|
f.state.record.dictation = { language: "multi" };
|
||||||
|
await act(async () => { write.resolve(Response.json({ settings: f.state.record })); await saved; });
|
||||||
|
assert.equal(Socket.all[0]!.language, "multi");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a saved-language refresh gates capture even when an older choice is cached", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await f.mount();
|
||||||
|
const read = deferred<Response>();
|
||||||
|
f.state.nextRead = read.promise;
|
||||||
|
f.state.record.dictation = { language: "multi" };
|
||||||
|
await act(async () => { notifySettingsSaved("dictation"); });
|
||||||
|
await act(async () => { f.dictation.toggle(); });
|
||||||
|
assert.equal(f.state.captures, 0);
|
||||||
|
assert.equal(Socket.all.length, 0);
|
||||||
|
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
|
||||||
|
assert.equal(Socket.all[0]!.language, "multi");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stale settings refresh cannot overwrite a newer confirmed choice", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await f.mount();
|
||||||
|
const read = deferred<Response>();
|
||||||
|
f.state.nextRead = read.promise;
|
||||||
|
await act(async () => { notifySettingsSaved("dictation"); });
|
||||||
|
await act(async () => { await f.setting.save("multi"); });
|
||||||
|
await act(async () => { read.resolve(Response.json({ settings: { version: 1, dictation: { language: "pl" } } })); });
|
||||||
|
assert.equal(f.setting.language, "multi");
|
||||||
|
await act(async () => { f.dictation.toggle(); });
|
||||||
|
assert.equal(Socket.all[0]!.language, "multi");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancel while settings load does not open a microphone or socket later", async () => {
|
||||||
|
const read = deferred<Response>();
|
||||||
|
const f = await fixture("pl", read.promise);
|
||||||
|
await f.mount();
|
||||||
|
await act(async () => { f.dictation.toggle(); });
|
||||||
|
await act(async () => { f.dictation.cancel(); });
|
||||||
|
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
|
||||||
|
assert.equal(f.dictation.phase, "idle");
|
||||||
|
assert.equal(f.state.captures, 0);
|
||||||
|
assert.equal(Socket.all.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reopening settings during a save shares its busy state and rejects a competing selection", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await f.mount();
|
||||||
|
const write = deferred<Response>();
|
||||||
|
f.state.nextWrite = write.promise;
|
||||||
|
let first!: Promise<boolean>;
|
||||||
|
await act(async () => { first = f.setting.save("multi"); });
|
||||||
|
await f.remount();
|
||||||
|
assert.equal(f.setting.saving, true);
|
||||||
|
assert.equal(await f.setting.save("en"), false, "a different selection cannot inherit another write's success");
|
||||||
|
f.state.record.dictation = { language: "multi" };
|
||||||
|
await act(async () => { write.resolve(Response.json({ settings: f.state.record })); await first; });
|
||||||
|
assert.equal(f.setting.saving, false);
|
||||||
|
assert.equal(f.setting.language, "multi");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a confirmed save applies to capture even when subsequent settings GET requests would fail", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await f.mount();
|
||||||
|
f.state.readFails = true;
|
||||||
|
await act(async () => { assert.equal(await f.setting.save("multi"), true); });
|
||||||
|
assert.equal(f.state.record.dictation?.language, "multi");
|
||||||
|
await act(async () => { f.dictation.toggle(); });
|
||||||
|
assert.equal(Socket.all[0]!.language, "multi");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unmount during a pending language read prevents orphaned capture", async () => {
|
||||||
|
const read = deferred<Response>();
|
||||||
|
const f = await fixture("pl", read.promise);
|
||||||
|
await f.mount();
|
||||||
|
await act(async () => { f.dictation.toggle(); });
|
||||||
|
await f.unmount();
|
||||||
|
await act(async () => { read.resolve(Response.json({ settings: f.state.record })); });
|
||||||
|
assert.equal(f.state.captures, 0);
|
||||||
|
assert.equal(Socket.all.length, 0);
|
||||||
|
assert.equal(isDictationActive(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unmount during recording stops resources and discards the departing page's buffered text", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await f.mount();
|
||||||
|
await act(async () => { f.dictation.toggle(); });
|
||||||
|
const socket = Socket.all[0]!;
|
||||||
|
await act(async () => { socket.open(); socket.final("do not append into another page"); });
|
||||||
|
await f.unmount();
|
||||||
|
assert.equal(f.state.stops, 1);
|
||||||
|
assert.equal(socket.readyState, 3);
|
||||||
|
assert.equal(isDictationActive(), false);
|
||||||
|
assert.deepEqual(f.state.appends, []);
|
||||||
|
await act(async () => { socket.final("late event"); socket.end(1006); });
|
||||||
|
assert.equal(Socket.all.length, 1);
|
||||||
|
assert.deepEqual(f.state.appends, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("microphone permission resolving after unmount immediately stops the late stream", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await f.mount();
|
||||||
|
const mic = deferred<{ getTracks: () => { stop: () => void }[] }>();
|
||||||
|
f.state.nextMic = mic.promise;
|
||||||
|
await act(async () => { f.dictation.toggle(); });
|
||||||
|
assert.equal(f.state.captures, 1);
|
||||||
|
await f.unmount();
|
||||||
|
await act(async () => { mic.resolve({ getTracks: () => [{ stop() { f.state.stops++; } }] }); });
|
||||||
|
assert.equal(f.state.stops, 1);
|
||||||
|
assert.equal(Socket.all.length, 0);
|
||||||
|
assert.equal(isDictationActive(), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { after, describe, it } from "node:test";
|
||||||
|
import { DICTATION_LANGUAGES, deepgramListenUrl, isDictationLanguage } from "../src/core/dictation.ts";
|
||||||
|
import { sanitizeUserSettings } from "../src/core/settings.ts";
|
||||||
|
import { resolveSettings } from "../src/core/settings-resolution.ts";
|
||||||
|
import { parseDictationSettingsBody } from "../src/server/settings-body.ts";
|
||||||
|
|
||||||
|
describe("Nova-3 dictation language contract", () => {
|
||||||
|
it("offers monolingual languages/locales and a distinct multilingual mode", () => {
|
||||||
|
assert.equal(DICTATION_LANGUAGES[0]?.code, "multi");
|
||||||
|
assert.equal(new Set(DICTATION_LANGUAGES.map((item) => item.code)).size, DICTATION_LANGUAGES.length);
|
||||||
|
for (const code of ["en", "pl", "en-GB", "pt-BR", "zh-Hant", "ar-EG", "multi"]) {
|
||||||
|
assert.ok(isDictationLanguage(code), code);
|
||||||
|
}
|
||||||
|
// Xhosa and Zulu are documented for multi, not monolingual Nova-3.
|
||||||
|
for (const code of ["xh", "zu", "en-CA", "auto", "xx", "pl&model=other", null, 3]) {
|
||||||
|
assert.equal(isDictationLanguage(code), false, String(code));
|
||||||
|
}
|
||||||
|
const labels = DICTATION_LANGUAGES.slice(1).map((item) => item.label);
|
||||||
|
assert.deepEqual(labels, [...labels].sort((a, b) => a.localeCompare(b, "en")));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only language varies in the streaming URL; all tuned parameters survive", () => {
|
||||||
|
for (const language of ["en", "pl", "multi", "en-GB"] as const) {
|
||||||
|
const url = new URL(deepgramListenUrl(language));
|
||||||
|
assert.equal(url.origin + url.pathname, "wss://api.deepgram.com/v1/listen");
|
||||||
|
assert.deepEqual(Object.fromEntries(url.searchParams), {
|
||||||
|
model: "nova-3", encoding: "linear16", sample_rate: "16000", channels: "1",
|
||||||
|
smart_format: "true", punctuate: "true", endpointing: "300", language,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("old records default to English and explicit preferences resolve without a migration", () => {
|
||||||
|
assert.equal(resolveSettings(null).dictation.language, "en");
|
||||||
|
assert.equal(resolveSettings({ version: 1 }).dictation.language, "en");
|
||||||
|
for (const language of ["pl", "multi", "en-GB"] as const) {
|
||||||
|
const record = sanitizeUserSettings({ version: 1, dictation: { language } });
|
||||||
|
assert.equal(record?.version, 1);
|
||||||
|
assert.equal(resolveSettings(record).dictation.language, language);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid API values and drops invalid stored preferences", () => {
|
||||||
|
assert.deepEqual(parseDictationSettingsBody({}), { ok: true, settings: {} });
|
||||||
|
assert.deepEqual(parseDictationSettingsBody({ language: "multi" }), { ok: true, settings: { language: "multi" } });
|
||||||
|
for (const value of [null, [], "pl", { language: "xx" }, { language: null }, { language: 3 }]) {
|
||||||
|
assert.equal(parseDictationSettingsBody(value).ok, false);
|
||||||
|
}
|
||||||
|
for (const dictation of [null, [], "pl", { language: "xx" }, { language: 3 }]) {
|
||||||
|
const record = sanitizeUserSettings({ version: 1, dictation, general: { autoUpdate: false } });
|
||||||
|
assert.equal(resolveSettings(record).dictation.language, "en");
|
||||||
|
assert.equal(record?.general?.autoUpdate, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const testData = mkdtempSync(path.join(tmpdir(), "lh-dictation-settings-"));
|
||||||
|
process.env.LH_DATA_DIR = testData;
|
||||||
|
after(() => { rmSync(testData, { recursive: true, force: true }); });
|
||||||
|
const { readSettings, writeSettings } = await import("../src/store/settings.ts");
|
||||||
|
|
||||||
|
it("the section route persists language independently and rejects unsupported values without writing", async () => {
|
||||||
|
const { PUT } = await import("../app/api/settings/[section]/route.ts");
|
||||||
|
writeSettings({ version: 1, general: { autoUpdate: false } });
|
||||||
|
const put = (language: string) => PUT(new Request("http://localhost/api/settings/dictation", {
|
||||||
|
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ language }),
|
||||||
|
}), { params: Promise.resolve({ section: "dictation" }) });
|
||||||
|
assert.equal((await put("pl")).status, 200);
|
||||||
|
const saved = { version: 1, dictation: { language: "pl" }, general: { autoUpdate: false } };
|
||||||
|
assert.deepEqual(readSettings(), saved);
|
||||||
|
assert.equal((await put("unsupported")).status, 400);
|
||||||
|
assert.deepEqual(readSettings(), saved);
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import React, { act } from "react";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { afterEach, test } from "node:test";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
import { setDictationActive } from "../components/dictation-state.ts";
|
||||||
|
import { DICTATION_LANGUAGES, type UserSettings } from "../src/core/index.ts";
|
||||||
|
|
||||||
|
let dispose: (() => Promise<void>) | undefined;
|
||||||
|
let restore: (() => void) | undefined;
|
||||||
|
const byTest = (id: string) => document.querySelector<HTMLButtonElement>(`[data-testid="${id}"]`)!;
|
||||||
|
const trigger = () => byTest("dictation-language");
|
||||||
|
const input = () => document.querySelector<HTMLInputElement>('input[aria-label="Search dictation languages"]')!;
|
||||||
|
const menu = () => document.querySelector('[data-testid="dictation-language-menu"]');
|
||||||
|
const click = async (element: HTMLElement) => { assert(element); await act(async () => { element.click(); }); };
|
||||||
|
async function key(element: HTMLElement, value: string) {
|
||||||
|
await act(async () => {
|
||||||
|
const event = new window.Event("keydown", { bubbles: true, cancelable: true });
|
||||||
|
Object.assign(event, { key: value });
|
||||||
|
element.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function search(value: string) {
|
||||||
|
await act(async () => {
|
||||||
|
const element = input();
|
||||||
|
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!.call(element, value);
|
||||||
|
element.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixture(failRead = false) {
|
||||||
|
const { window: win, document: doc } = parseHTML("<html><body><div id='root'></div></body></html>");
|
||||||
|
let active: Element = doc.body;
|
||||||
|
Object.defineProperty(doc, "activeElement", { get: () => active });
|
||||||
|
Object.assign(doc, { oninput: null });
|
||||||
|
Object.assign(win, { innerWidth: 1280, innerHeight: 900 });
|
||||||
|
win.HTMLElement.prototype.focus = function () { active = this; };
|
||||||
|
win.HTMLElement.prototype.scrollIntoView = () => {};
|
||||||
|
win.HTMLElement.prototype.getBoundingClientRect = () => ({ top: 200, bottom: 230, left: 400, right: 500, width: 100, height: 30, x: 400, y: 200, toJSON() {} });
|
||||||
|
const state = {
|
||||||
|
record: { version: 1, dictation: { language: "en" } } as UserSettings,
|
||||||
|
writes: [] as string[], failWrite: false, outerEscapes: 0,
|
||||||
|
failRead,
|
||||||
|
nextWrite: undefined as Promise<Response> | undefined,
|
||||||
|
};
|
||||||
|
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||||
|
for (const [name, value] of Object.entries({
|
||||||
|
React, window: win, document: doc, Element: win.Element, HTMLElement: win.HTMLElement,
|
||||||
|
HTMLInputElement: win.HTMLInputElement, HTMLTextAreaElement: win.HTMLTextAreaElement,
|
||||||
|
Event: win.Event, CustomEvent: win.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true,
|
||||||
|
ResizeObserver: class { observe() {} unobserve() {} disconnect() {} },
|
||||||
|
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0),
|
||||||
|
cancelAnimationFrame: clearTimeout,
|
||||||
|
fetch: async (url: string, init?: RequestInit) => {
|
||||||
|
if (url === "/api/settings") return state.failRead ? Response.json({}, { status: 500 }) : Response.json({ settings: state.record });
|
||||||
|
assert.equal(url, "/api/settings/dictation");
|
||||||
|
const body = JSON.parse(init!.body as string);
|
||||||
|
state.writes.push(body.language);
|
||||||
|
if (state.nextWrite) { const next = state.nextWrite; state.nextWrite = undefined; return next; }
|
||||||
|
if (state.failWrite) return Response.json({}, { status: 500 });
|
||||||
|
state.record = { ...state.record, dictation: body };
|
||||||
|
return Response.json({ settings: state.record });
|
||||||
|
},
|
||||||
|
})) {
|
||||||
|
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||||
|
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||||
|
}
|
||||||
|
restore = () => {
|
||||||
|
for (const [name, descriptor] of previous) {
|
||||||
|
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||||
|
else delete (globalThis as Record<string, unknown>)[name];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Register the outer dialog listener first: one Escape must close only
|
||||||
|
// the language menu, even when the settings dialog owns another listener.
|
||||||
|
doc.addEventListener("keydown", (event) => { if ((event as KeyboardEvent).key === "Escape") state.outerEscapes++; });
|
||||||
|
const { createRoot } = await import("react-dom/client");
|
||||||
|
const { DictationLanguageSetting } = await import("../components/dictation-language.tsx");
|
||||||
|
let root = createRoot(doc.getElementById("root")!);
|
||||||
|
const mount = async () => { await act(async () => { root.render(<DictationLanguageSetting />); }); };
|
||||||
|
dispose = async () => { await act(async () => { setDictationActive(false); root.unmount(); }); };
|
||||||
|
await mount();
|
||||||
|
return { state, remount: async () => { await dispose!(); root = createRoot(doc.getElementById("root")!); await mount(); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => { await dispose?.(); restore?.(); dispose = undefined; restore = undefined; });
|
||||||
|
|
||||||
|
test("an initial load failure is visible and opening the menu again can retry", async () => {
|
||||||
|
const f = await fixture(true);
|
||||||
|
await click(trigger());
|
||||||
|
assert.match(menu()!.querySelector('[role="alert"]')!.textContent!, /Could not load/);
|
||||||
|
f.state.failRead = false;
|
||||||
|
await click(trigger());
|
||||||
|
await click(trigger());
|
||||||
|
assert.equal(menu()!.querySelector('[role="alert"]'), null);
|
||||||
|
await search("Polish");
|
||||||
|
await key(input(), "Enter");
|
||||||
|
assert.match(trigger().textContent!, /Polish/);
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
await click(trigger());
|
||||||
|
assert.equal(input().getAttribute("data-slot"), "input");
|
||||||
|
assert.equal(input().getAttribute("role"), "combobox");
|
||||||
|
assert.equal(document.activeElement, input());
|
||||||
|
assert.equal(document.querySelector("[cmdk-item]")!.textContent, "Multilingual");
|
||||||
|
await search("Polish");
|
||||||
|
assert.equal(document.querySelectorAll("[cmdk-item]").length, 1);
|
||||||
|
await key(input(), "ArrowDown");
|
||||||
|
await key(input(), "Enter");
|
||||||
|
assert.deepEqual(f.state.writes, ["pl"]);
|
||||||
|
assert.match(trigger().textContent!, /Polish/);
|
||||||
|
assert.equal(menu(), null);
|
||||||
|
assert.equal(document.activeElement, trigger());
|
||||||
|
await f.remount();
|
||||||
|
assert.match(trigger().textContent!, /Polish/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty results cannot save; Escape and outside click close the menu without closing settings", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await click(trigger());
|
||||||
|
await search("zzzz-no-language");
|
||||||
|
assert.match(document.querySelector("[cmdk-empty]")!.textContent!, /No languages match/);
|
||||||
|
await key(input(), "Enter");
|
||||||
|
assert.deepEqual(f.state.writes, []);
|
||||||
|
await key(input(), "Escape");
|
||||||
|
assert.equal(menu(), null);
|
||||||
|
assert.equal(f.state.outerEscapes, 0);
|
||||||
|
await click(trigger());
|
||||||
|
await key(trigger(), "Escape");
|
||||||
|
assert.equal(menu(), null);
|
||||||
|
assert.equal(f.state.outerEscapes, 0);
|
||||||
|
await click(trigger());
|
||||||
|
await act(async () => { document.body.dispatchEvent(new window.Event("pointerdown", { bubbles: true })); });
|
||||||
|
assert.equal(menu(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed saves retain the confirmed choice and the same menu can retry", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
f.state.failWrite = true;
|
||||||
|
await click(trigger());
|
||||||
|
await search("Polish");
|
||||||
|
await key(input(), "Enter");
|
||||||
|
assert.match(trigger().textContent!, /English/);
|
||||||
|
assert.match(menu()!.querySelector('[role="alert"]')!.textContent!, /Could not save/);
|
||||||
|
f.state.failWrite = false;
|
||||||
|
await click(byTest("dictation-language-pl"));
|
||||||
|
assert.equal(menu(), null);
|
||||||
|
assert.match(trigger().textContent!, /Polish/);
|
||||||
|
assert.deepEqual(f.state.writes, ["pl", "pl"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a remounted picker stays disabled throughout an outstanding save", async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
let complete!: (response: Response) => void;
|
||||||
|
f.state.nextWrite = new Promise((resolve) => { complete = resolve; });
|
||||||
|
await click(trigger());
|
||||||
|
await click(byTest("dictation-language-multi"));
|
||||||
|
assert.equal(trigger().disabled, true);
|
||||||
|
await f.remount();
|
||||||
|
assert.equal(trigger().disabled, true);
|
||||||
|
await click(trigger());
|
||||||
|
assert.equal(menu(), null);
|
||||||
|
f.state.record.dictation = { language: "multi" };
|
||||||
|
await act(async () => { complete(Response.json({ settings: f.state.record })); });
|
||||||
|
assert.equal(trigger().disabled, false);
|
||||||
|
assert.match(trigger().textContent!, /Multilingual/);
|
||||||
|
assert.deepEqual(f.state.writes, ["multi"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("capture locks the row and dismisses an open language menu until capture ends", async () => {
|
||||||
|
await fixture();
|
||||||
|
await click(trigger());
|
||||||
|
await act(async () => { setDictationActive(true); });
|
||||||
|
assert.equal(trigger().disabled, true);
|
||||||
|
assert.match(byTest("row-dictation-language").className, /opacity-40/);
|
||||||
|
assert.equal(menu(), null);
|
||||||
|
await act(async () => { setDictationActive(false); });
|
||||||
|
assert.equal(trigger().disabled, false);
|
||||||
|
await click(trigger());
|
||||||
|
assert(menu());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing each search restores the complete catalog order with Multilingual first", async () => {
|
||||||
|
await fixture();
|
||||||
|
await click(trigger());
|
||||||
|
const order = () => [...document.querySelectorAll("[cmdk-item]")].map((item) => item.getAttribute("data-value"));
|
||||||
|
const expected = DICTATION_LANGUAGES.map((item) => item.code);
|
||||||
|
assert.deepEqual(order(), expected);
|
||||||
|
for (const query of ["en", "multi", "Polish", "zzzzz"]) {
|
||||||
|
await search(query);
|
||||||
|
await search("");
|
||||||
|
assert.deepEqual(order(), expected, `catalog order after clearing ${query}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { createDictationLanguageStore } from "../components/dictation-preference.ts";
|
||||||
|
|
||||||
|
test("all readers immediately receive the confirmed write without another GET", async () => {
|
||||||
|
let reads = 0;
|
||||||
|
let notifications = 0;
|
||||||
|
let failReads = false;
|
||||||
|
const store = createDictationLanguageStore(async (_url, init) => {
|
||||||
|
if (init?.method === "PUT") return Response.json({ settings: { version: 1, dictation: JSON.parse(init.body as string) } });
|
||||||
|
reads++;
|
||||||
|
return failReads ? Response.json({}, { status: 500 }) : Response.json({ settings: null });
|
||||||
|
}, () => { notifications++; });
|
||||||
|
assert.equal(await store.load(), "en");
|
||||||
|
const observed: string[] = [];
|
||||||
|
const off = store.subscribe(() => { observed.push(`${store.getSnapshot().language}:${store.getSnapshot().saving}`); });
|
||||||
|
failReads = true;
|
||||||
|
assert.equal(await store.save("multi"), true);
|
||||||
|
assert.equal(await store.getLanguage(), "multi");
|
||||||
|
assert.equal(reads, 1);
|
||||||
|
assert.equal(notifications, 1);
|
||||||
|
assert(observed.includes("multi:true"));
|
||||||
|
assert.equal(observed.at(-1), "multi:false");
|
||||||
|
off();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed external refresh invalidates the cache until a successful retry", async () => {
|
||||||
|
let fail = false;
|
||||||
|
const store = createDictationLanguageStore(async () => fail
|
||||||
|
? Response.json({}, { status: 500 })
|
||||||
|
: Response.json({ settings: { version: 1, dictation: { language: "pl" } } }));
|
||||||
|
await store.load();
|
||||||
|
fail = true;
|
||||||
|
await assert.rejects(store.load(true), /Could not load/);
|
||||||
|
await assert.rejects(store.getLanguage(), /Could not load/);
|
||||||
|
fail = false;
|
||||||
|
assert.equal(await store.getLanguage(), "pl");
|
||||||
|
assert.equal(store.getSnapshot().error, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stale reads cannot overwrite a newer write and subscribers see busy before transport runs", async () => {
|
||||||
|
let stale!: (response: Response) => void;
|
||||||
|
let nextRead: Promise<Response> | undefined;
|
||||||
|
const store = createDictationLanguageStore(async (_url, init) => {
|
||||||
|
if (init?.method === "PUT") return Response.json({ settings: { version: 1, dictation: { language: "multi" } } });
|
||||||
|
if (nextRead) { const response = nextRead; nextRead = undefined; return response; }
|
||||||
|
return Response.json({ settings: { version: 1, dictation: { language: "pl" } } });
|
||||||
|
});
|
||||||
|
await store.load();
|
||||||
|
nextRead = new Promise((resolve) => { stale = resolve; });
|
||||||
|
const oldRead = store.load(true);
|
||||||
|
await Promise.resolve();
|
||||||
|
let startedDuringPublish: Promise<string> | undefined;
|
||||||
|
const off = store.subscribe(() => {
|
||||||
|
if (store.getSnapshot().saving && !startedDuringPublish) startedDuringPublish = store.getLanguage();
|
||||||
|
});
|
||||||
|
assert.equal(await store.save("multi"), true);
|
||||||
|
stale(Response.json({ settings: { version: 1, dictation: { language: "pl" } } }));
|
||||||
|
assert.equal(await oldRead, "multi");
|
||||||
|
assert.equal(await startedDuringPublish, "multi");
|
||||||
|
assert.equal(store.getSnapshot().language, "multi");
|
||||||
|
off();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const newestFirst of [false, true]) test(`overlapping reads keep the newest value, newest resolves first: ${newestFirst}`, async () => {
|
||||||
|
const responses: ((response: Response) => void)[] = [];
|
||||||
|
const store = createDictationLanguageStore(() => new Promise((resolve) => { responses.push(resolve); }));
|
||||||
|
const first = store.load();
|
||||||
|
await Promise.resolve();
|
||||||
|
const second = store.load(true);
|
||||||
|
await Promise.resolve();
|
||||||
|
const old = Response.json({ settings: { version: 1, dictation: { language: "pl" } } });
|
||||||
|
const fresh = Response.json({ settings: { version: 1, dictation: { language: "multi" } } });
|
||||||
|
if (newestFirst) { responses[1]!(fresh); responses[0]!(old); }
|
||||||
|
else { responses[0]!(old); responses[1]!(fresh); }
|
||||||
|
assert.deepEqual(await Promise.all([first, second]), ["multi", "multi"]);
|
||||||
|
assert.equal(await store.getLanguage(), "multi");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("capture also waits for a write initiated as a pending read publishes its result", async () => {
|
||||||
|
const store = createDictationLanguageStore(async (_url, init) => Response.json({ settings: {
|
||||||
|
version: 1, dictation: { language: init?.method === "PUT" ? "multi" : "en" },
|
||||||
|
} }));
|
||||||
|
let started = false;
|
||||||
|
const off = store.subscribe(() => {
|
||||||
|
if (!started && store.getSnapshot().language === "en") {
|
||||||
|
started = true;
|
||||||
|
void store.save("multi");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.equal(await store.getLanguage(), "multi");
|
||||||
|
off();
|
||||||
|
});
|
||||||
@@ -234,12 +234,12 @@ describe("parseCompactionSettingsBody (owner round 14)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("SETTINGS_SECTIONS (the route's section map)", () => {
|
describe("SETTINGS_SECTIONS (the route's section map)", () => {
|
||||||
it("maps exactly the seven record sections", () => {
|
it("maps exactly the eight record sections", () => {
|
||||||
// compaction joined (owner round 14): its own section PUT — the
|
// compaction joined (owner round 14): its own section PUT — the
|
||||||
// threshold is engine behavior, not an agent-section key.
|
// threshold is engine behavior, not an agent-section key.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
Object.keys(SETTINGS_SECTIONS),
|
Object.keys(SETTINGS_SECTIONS),
|
||||||
["general", "chat", "agent", "appearance", "graph", "compaction", "models"],
|
["general", "chat", "agent", "appearance", "graph", "compaction", "models", "dictation"],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user