Keep Chinese output script aligned with the selected UI locale

This change binds locale selection to a persisted chineseScriptPreference so zh-CN and zh-TW drive output script consistently.

It injects script preference into LLM context for polish/translate/QA, removes ASR language-hint submission, and applies script conversion only in pure-ASR fallback paths. It also hardens preference persistence sequencing in HotkeySettingsContext to reduce write-order races.

Constraint: Must keep output script behavior coupled to locale choice without adding a new toggle
Constraint: ASR requests should not send language parameters
Rejected: Add an independent output-script option | Conflicts with required locale-driven behavior
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep zh-CN=>simplified and zh-TW=>traditional mapping consistent in both Settings entry points and backend consumers
Tested: npm run -s build (openless-all/app)
Tested: cargo test -q (openless-all/app/src-tauri)
Not-tested: Live external provider E2E verification across multiple real ASR/LLM vendors
Related: #242
Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
H-Chris233andOmX committed 2026-05-05 16:06:29 +08:00
1 parent c85b1bd4c6
commit 1075ba463f
12 files changed
+562 -453

No files matched your search

+328 -427
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -39,6 +39,7 @@ chrono = { version = "0.4", features = ["serde"] }
bytes = "1"
url = "2"
raw-window-handle = "0.6"
ferrous-opencc = "0.4"
# Hotkey + audio + insertion
global-hotkey = "0.6"
+11 -3
View File
@@ -12,8 +12,9 @@ use crate::permissions::{self, PermissionStatus};
use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVault};
use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
use crate::types::{
CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus,
PolishMode, QaHotkeyBinding, UserPreferences, VocabPresetStore, WindowsImeStatus,
ChineseScriptPreference, CredentialsStatus, DictationSession, DictionaryEntry,
HotkeyCapability, HotkeyStatus, PolishMode, QaHotkeyBinding, UserPreferences,
VocabPresetStore, WindowsImeStatus,
};
type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
@@ -223,7 +224,14 @@ async fn validate_llm_provider() -> Result<(), String> {
model,
));
provider
.polish("验证连接", PolishMode::Raw, &[], &[], None)
.polish(
"验证连接",
PolishMode::Raw,
&[],
&[],
ChineseScriptPreference::Auto,
None,
)
.await
.map(|_| ())
.map_err(|e| match e {
+82 -7
View File
@@ -11,6 +11,7 @@ use std::sync::Arc;
use std::time::Instant;
use chrono::Utc;
use ferrous_opencc::{config::BuiltinConfig, OpenCC};
use parking_lot::Mutex;
use tauri::{async_runtime, AppHandle, Emitter, Manager};
use uuid::Uuid;
@@ -30,8 +31,8 @@ use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor};
use crate::recorder::{Recorder, RecorderError};
use crate::selection::{capture_selection, SelectionContext};
use crate::types::{
CapsulePayload, CapsuleState, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus,
HotkeyStatusState, InsertStatus, PolishMode,
CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability,
HotkeyMode, HotkeyStatus, HotkeyStatusState, InsertStatus, PolishMode,
};
#[cfg(target_os = "windows")]
use crate::windows_ime_ipc::ImeSubmitTarget;
@@ -473,7 +474,9 @@ impl Coordinator {
pub async fn repolish(&self, raw_text: String, mode: PolishMode) -> Result<String, String> {
let hotwords = enabled_phrases(&self.inner);
let working_languages = self.inner.prefs.get().working_languages;
let prefs = self.inner.prefs.get();
let working_languages = prefs.working_languages;
let chinese_script_preference = prefs.chinese_script_preference;
// repolish 是历史记录里手动重新润色,不再绑定原 session 的前台 app;
// 当下用户调起的 app 才是相关上下文(如果可拿)。
let front_app = capture_frontmost_app();
@@ -482,6 +485,7 @@ impl Coordinator {
mode,
&hotwords,
&working_languages,
chinese_script_preference,
front_app.as_deref(),
)
.await
@@ -1640,6 +1644,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let mode = prefs.default_mode;
let hotword_strs = enabled_phrases(inner);
let working_languages = prefs.working_languages.clone();
let chinese_script_preference = prefs.chinese_script_preference;
let front_app = inner.state.lock().front_app.clone();
let translation_target = prefs.translation_target_language.trim().to_string();
let translation_active =
@@ -1655,6 +1660,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
&raw,
&translation_target,
&working_languages,
chinese_script_preference,
front_app.as_deref(),
)
.await
@@ -1664,11 +1670,21 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
mode,
&hotword_strs,
&working_languages,
chinese_script_preference,
front_app.as_deref(),
)
.await
};
// 仅在“纯 ASR 直出”场景做强制简繁收敛,避免误伤翻译/常规 LLM 输出:
// - mode=Raw:本来就不走润色
// - polish_error.is_some():润色/翻译失败后回退到原始 ASR 文本
let polished = if !translation_active && (mode == PolishMode::Raw || polish_error.is_some()) {
apply_chinese_script_preference(&polished, chinese_script_preference)
} else {
polished
};
// 原子化最后一次 cancel 检查 + 转 Inserting:
// 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting,
// cancel_session 就拒绝介入(Cmd+V 已发出,撤销不掉)。这是 audit HIGH #2 的修复,
@@ -2154,6 +2170,27 @@ fn is_whisper_compatible_provider(id: &str) -> bool {
matches!(id, "whisper" | "siliconflow" | "zhipu" | "groq")
}
fn apply_chinese_script_preference(text: &str, pref: ChineseScriptPreference) -> String {
if text.is_empty() {
return String::new();
}
let config = match pref {
ChineseScriptPreference::Simplified => Some(BuiltinConfig::T2s),
ChineseScriptPreference::Traditional => Some(BuiltinConfig::S2t),
ChineseScriptPreference::Auto => None,
};
let Some(config) = config else {
return text.to_string();
};
match OpenCC::from_config(config) {
Ok(converter) => converter.convert(text),
Err(err) => {
log::warn!("[coord] OpenCC init failed, skip script conversion: {err}");
text.to_string()
}
}
}
/// QA 路径专用:begin_qa_session 永远走 Volcengine 流式(低延迟要求),所以
/// 凭据校验也只看 Volcengine 字段,不依赖 active_asr。dictation 路径请用
/// `ensure_asr_credentials`。
@@ -2173,12 +2210,22 @@ async fn polish_or_passthrough(
mode: PolishMode,
hotwords: &[String],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> (String, Option<String>) {
if mode == PolishMode::Raw {
return (raw.text.clone(), None);
}
match polish_text(&raw.text, mode, hotwords, working_languages, front_app).await {
match polish_text(
&raw.text,
mode,
hotwords,
working_languages,
chinese_script_preference,
front_app,
)
.await
{
Ok(s) => (s, None),
Err(e) => {
let reason = e.to_string();
@@ -2193,6 +2240,7 @@ async fn polish_text(
mode: PolishMode,
hotwords: &[String],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> anyhow::Result<String> {
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
@@ -2208,7 +2256,14 @@ async fn polish_text(
let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model);
let provider = OpenAICompatibleLLMProvider::new(config);
Ok(provider
.polish(raw, mode, hotwords, working_languages, front_app)
.polish(
raw,
mode,
hotwords,
working_languages,
chinese_script_preference,
front_app,
)
.await?)
}
@@ -2217,9 +2272,18 @@ async fn translate_or_passthrough(
raw: &RawTranscript,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> (String, Option<String>) {
match translate_text(&raw.text, target_language, working_languages, front_app).await {
match translate_text(
&raw.text,
target_language,
working_languages,
chinese_script_preference,
front_app,
)
.await
{
Ok(s) => (s, None),
Err(e) => {
let reason = e.to_string();
@@ -2233,6 +2297,7 @@ async fn translate_text(
raw: &str,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> anyhow::Result<String> {
let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default();
@@ -2248,7 +2313,13 @@ async fn translate_text(
let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model);
let provider = OpenAICompatibleLLMProvider::new(config);
Ok(provider
.translate_to(raw, target_language, working_languages, front_app)
.translate_to(
raw,
target_language,
working_languages,
chinese_script_preference,
front_app,
)
.await?)
}
@@ -2574,6 +2645,7 @@ async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
let prefs = inner.prefs.get();
let working_languages = prefs.working_languages.clone();
let chinese_script_preference = prefs.chinese_script_preference;
let (messages_for_llm, front_app) = {
let st = inner.qa_state.lock();
(st.messages.clone(), st.front_app.clone())
@@ -2612,6 +2684,7 @@ async fn end_qa_session(inner: &Arc<Inner>) -> Result<(), String> {
let answer = match answer_chat_dispatch(
&messages_for_llm,
&working_languages,
chinese_script_preference,
front_app.as_deref(),
on_delta,
should_cancel,
@@ -2759,6 +2832,7 @@ fn cancel_qa_session(inner: &Arc<Inner>) {
async fn answer_chat_dispatch<F, C>(
messages: &[crate::types::QaChatMessage],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
on_delta: F,
should_cancel: C,
@@ -2782,6 +2856,7 @@ where
.answer_chat_streaming(
messages,
working_languages,
chinese_script_preference,
front_app,
on_delta,
should_cancel,
+42 -7
View File
@@ -10,7 +10,7 @@ use std::time::Duration;
use serde_json::{json, Value};
use thiserror::Error;
use crate::types::{PolishMode, QaChatMessage};
use crate::types::{ChineseScriptPreference, PolishMode, QaChatMessage};
const DEFAULT_TEMPERATURE: f32 = 0.3;
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
@@ -90,10 +90,13 @@ impl OpenAICompatibleLLMProvider {
mode: PolishMode,
hotwords: &[String],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> Result<String, LLMError> {
let mut system_prompt = compose_system_prompt(mode, hotwords);
if let Some(premise) = context_premise(working_languages, front_app) {
if let Some(premise) =
context_premise(working_languages, chinese_script_preference, front_app)
{
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
let user_prompt = prompts::user_prompt(raw_text);
@@ -108,6 +111,7 @@ impl OpenAICompatibleLLMProvider {
&self,
messages: &[QaChatMessage],
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
on_delta: F,
should_cancel: C,
@@ -117,7 +121,9 @@ impl OpenAICompatibleLLMProvider {
C: Fn() -> bool + Send + Sync,
{
let mut system_prompt = prompts::qa_system_prompt();
if let Some(premise) = context_premise(working_languages, front_app) {
if let Some(premise) =
context_premise(working_languages, chinese_script_preference, front_app)
{
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
self.chat_completion_history_streaming(&system_prompt, messages, on_delta, should_cancel)
@@ -131,10 +137,13 @@ impl OpenAICompatibleLLMProvider {
raw_text: &str,
target_language: &str,
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> Result<String, LLMError> {
let mut system_prompt = prompts::translate_system_prompt(target_language);
if let Some(premise) = context_premise(working_languages, front_app) {
if let Some(premise) =
context_premise(working_languages, chinese_script_preference, front_app)
{
system_prompt = format!("{}\n\n{}", premise, system_prompt);
}
let user_prompt = prompts::user_prompt(raw_text);
@@ -378,7 +387,11 @@ fn chat_completions_url(base_url: &str) -> String {
/// 当前前台应用:…(请按这个 app 的常见沟通风格调整语气)
///
/// 两个字段都空时返回 None,调用方就不拼前缀。详见 issue #4 / #116。
fn context_premise(working_languages: &[String], front_app: Option<&str>) -> Option<String> {
fn context_premise(
working_languages: &[String],
chinese_script_preference: ChineseScriptPreference,
front_app: Option<&str>,
) -> Option<String> {
let langs: Vec<&str> = working_languages
.iter()
.map(|s| s.trim())
@@ -386,7 +399,19 @@ fn context_premise(working_languages: &[String], front_app: Option<&str>) -> Opt
.collect();
let app = front_app.map(str::trim).filter(|s| !s.is_empty());
if langs.is_empty() && app.is_none() {
let script_line = match chinese_script_preference {
ChineseScriptPreference::Simplified => Some(
"中文输出偏好:简体中文。若最终输出包含中文,请统一使用简体字形(不要混用繁体)。"
.to_string(),
),
ChineseScriptPreference::Traditional => Some(
"中文输出偏好:繁体中文。若最终输出包含中文,请统一使用繁体字形(不要混用简体)。"
.to_string(),
),
ChineseScriptPreference::Auto => None,
};
if langs.is_empty() && app.is_none() && script_line.is_none() {
return None;
}
@@ -402,6 +427,9 @@ fn context_premise(working_languages: &[String], front_app: Option<&str>) -> Opt
"当前前台应用:{name}。请按这个应用的常见沟通风格调整语气——例如邮件类 app 偏正式、聊天类 app 偏口语、IDE / 文档类 app 偏技术或结构化。\u{4E0D}主动加入与用户原意无关的客套话。"
));
}
if let Some(line) = script_line {
lines.push(line);
}
Some(lines.join("\n"))
}
@@ -982,7 +1010,14 @@ mod tests {
));
let output = provider
.polish("原文", PolishMode::Raw, &[], &[], None)
.polish(
"原文",
PolishMode::Raw,
&[],
&[],
ChineseScriptPreference::Auto,
None,
)
.await
.unwrap();
assert_eq!(output, "最终文本。");
+18
View File
@@ -24,6 +24,15 @@ impl PolishMode {
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum ChineseScriptPreference {
#[default]
Auto,
Simplified,
Traditional,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum InsertStatus {
@@ -114,6 +123,14 @@ pub struct UserPreferences {
/// 由前端从内置语言列表中选择,后端只接收最终的原生名字符串拼进 prompt。详见 issue #4。
#[serde(default)]
pub translation_target_language: String,
/// 中文输出字形偏好(不额外暴露为 UI 开关):
/// - Simplified: 中文输出优先简体
/// - Traditional: 中文输出优先繁体
/// - Auto: 不额外约束
///
/// 由前端「界面语言」选择同步驱动(简体/繁体),详见 issue #259。
#[serde(default)]
pub chinese_script_preference: ChineseScriptPreference,
/// 划词语音问答(QA)的全局快捷键。`None` = 关闭功能;`Some(...)` 时
/// coordinator 用 global-hotkey crate 注册组合键(modifier + 主键)。
/// 默认 Cmd+Shift+; (macOS) / Ctrl+Shift+; (Windows)。详见 issue #118。
@@ -176,6 +193,7 @@ impl Default for UserPreferences {
allow_non_tsf_insertion_fallback: true,
working_languages: default_working_languages(),
translation_target_language: String::new(),
chinese_script_preference: ChineseScriptPreference::Auto,
qa_hotkey: default_qa_hotkey(),
qa_save_history: false,
local_asr_active_model: default_local_asr_model(),
@@ -17,6 +17,7 @@ import {
setLocalePreference,
type SupportedLocale,
} from '../i18n';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import type { OS } from './WindowChrome';
interface SettingsModalProps {
@@ -311,11 +312,17 @@ const btnGhost: CSSProperties = {
// 真正可用的语言切换器 —— 用原生 <select>,与 Settings → Language 分区共享同一份 localStorage 偏好。
function LanguagePicker() {
const { t } = useTranslation();
const { prefs, updatePrefs } = useHotkeySettings();
const [pref, setPref] = useState<SupportedLocale | typeof FOLLOW_SYSTEM>(getLocalePreference());
const apply = async (next: SupportedLocale | typeof FOLLOW_SYSTEM) => {
setPref(next);
await setLocalePreference(next);
const resolved = await setLocalePreference(next);
if (!prefs) return;
const chineseScriptPreference =
resolved === 'zh-CN' ? 'simplified' : resolved === 'zh-TW' ? 'traditional' : 'auto';
if (prefs.chineseScriptPreference === chineseScriptPreference) return;
await updatePrefs({ ...prefs, chineseScriptPreference });
};
return (
+13 -5
View File
@@ -30,6 +30,11 @@ function detectSystemLocale(): SupportedLocale {
return 'en';
}
function resolveLocalePreference(pref: SupportedLocale | typeof FOLLOW_SYSTEM_VALUE): SupportedLocale {
if (pref === FOLLOW_SYSTEM_VALUE) return detectSystemLocale();
return pref;
}
function getStoredLocale(): SupportedLocale | null {
if (typeof window === 'undefined') return null;
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
@@ -66,14 +71,17 @@ export function getLocalePreference(): SupportedLocale | typeof FOLLOW_SYSTEM_VA
* 写入用户偏好并立即切换 i18n 语言。
* pref === 'system' 时清除存储项,重新走 navigator 检测。
*/
export async function setLocalePreference(pref: SupportedLocale | typeof FOLLOW_SYSTEM_VALUE): Promise<void> {
export async function setLocalePreference(
pref: SupportedLocale | typeof FOLLOW_SYSTEM_VALUE,
): Promise<SupportedLocale> {
const resolved = resolveLocalePreference(pref);
if (pref === FOLLOW_SYSTEM_VALUE) {
window.localStorage.removeItem(LOCALE_STORAGE_KEY);
await i18n.changeLanguage(detectSystemLocale());
return;
} else {
window.localStorage.setItem(LOCALE_STORAGE_KEY, pref);
}
window.localStorage.setItem(LOCALE_STORAGE_KEY, pref);
await i18n.changeLanguage(pref);
await i18n.changeLanguage(resolved);
return resolved;
}
export const FOLLOW_SYSTEM = FOLLOW_SYSTEM_VALUE;
+1
View File
@@ -51,6 +51,7 @@ const mockSettings: UserPreferences = {
allowNonTsfInsertionFallback: true,
workingLanguages: ['简体中文'],
translationTargetLanguage: '',
chineseScriptPreference: 'auto',
qaHotkey: { primary: ';', modifiers: ['cmd', 'shift'] },
qaSaveHistory: false,
localAsrActiveModel: 'qwen3-asr-0.6b',
+2
View File
@@ -120,6 +120,8 @@ export interface UserPreferences {
workingLanguages: string[];
/** 翻译模式目标语言(单选,原生名);空串 = 不启用 Shift 翻译。详见 issue #4。 */
translationTargetLanguage: string;
/** 中文输出字形偏好:由界面语言(简/繁)自动同步,不单独暴露设置项。 */
chineseScriptPreference: 'auto' | 'simplified' | 'traditional';
/** 划词语音问答快捷键。null = 未启用。详见 issue #118。 */
qaHotkey: QaHotkeyBinding | null;
/** 是否把 Q&A 历史写到本地存档。详见 issue #118。 */
+7 -1
View File
@@ -1092,11 +1092,17 @@ function PermissionPill({ status }: { status: PermissionStatus | 'loading' }) {
function LanguageSection() {
const { t } = useTranslation();
const { prefs, updatePrefs } = useHotkeySettings();
const [pref, setPref] = useState<SupportedLocale | typeof FOLLOW_SYSTEM>(getLocalePreference());
const apply = async (next: SupportedLocale | typeof FOLLOW_SYSTEM) => {
setPref(next);
await setLocalePreference(next);
const resolved = await setLocalePreference(next);
if (!prefs) return;
const chineseScriptPreference =
resolved === 'zh-CN' ? 'simplified' : resolved === 'zh-TW' ? 'traditional' : 'auto';
if (prefs.chineseScriptPreference === chineseScriptPreference) return;
await updatePrefs({ ...prefs, chineseScriptPreference });
};
return (
@@ -4,11 +4,13 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { getHotkeyCapability, getSettings, setSettings } from '../lib/ipc';
import type { HotkeyBinding, HotkeyCapability, UserPreferences } from '../lib/types';
import i18n from '../i18n';
interface HotkeySettingsContextValue {
prefs: UserPreferences | null;
@@ -25,6 +27,9 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
const [prefs, setPrefs] = useState<UserPreferences | null>(null);
const [capability, setCapability] = useState<HotkeyCapability | null>(null);
const [loading, setLoading] = useState(true);
const syncSeqRef = useRef(0);
const persistQueueRef = useRef<Promise<void>>(Promise.resolve());
const latestPrefsRef = useRef<UserPreferences | null>(null);
const refresh = useCallback(async () => {
const [nextPrefs, nextCapability] = await Promise.all([getSettings(), getHotkeyCapability()]);
@@ -33,14 +38,56 @@ export function HotkeySettingsProvider({ children }: { children: ReactNode }) {
setLoading(false);
}, []);
const queueSetSettings = useCallback((next: UserPreferences) => {
const task = persistQueueRef.current
.catch(() => undefined)
.then(() => setSettings(next));
persistQueueRef.current = task;
return task;
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
latestPrefsRef.current = prefs;
}, [prefs]);
useEffect(() => {
const currentPrefs = latestPrefsRef.current;
if (!currentPrefs) return;
const lang = (i18n.resolvedLanguage || i18n.language || '').toLowerCase();
const nextScript: UserPreferences['chineseScriptPreference'] =
lang.startsWith('zh-tw') || lang.includes('hant')
? 'traditional'
: lang.startsWith('zh-cn') || lang.startsWith('zh')
? 'simplified'
: 'auto';
if (currentPrefs.chineseScriptPreference === nextScript) return;
const previousScript = currentPrefs.chineseScriptPreference;
const next = { ...currentPrefs, chineseScriptPreference: nextScript };
const seq = ++syncSeqRef.current;
void queueSetSettings(next)
.then(() => {
setPrefs(current => {
if (!current || syncSeqRef.current !== seq) return current;
if (current.chineseScriptPreference !== previousScript) return current;
return { ...current, chineseScriptPreference: nextScript };
});
})
.catch(error => {
if (syncSeqRef.current === seq) {
console.warn('[settings] sync chineseScriptPreference failed', error);
}
});
}, [prefs, queueSetSettings]);
const updatePrefs = useCallback(async (next: UserPreferences) => {
setPrefs(next);
await setSettings(next);
}, []);
latestPrefsRef.current = next;
await queueSetSettings(next);
}, [queueSetSettings]);
const value = useMemo<HotkeySettingsContextValue>(
() => ({