From b027d8b210961639774e0d75d96a9caec48096b4 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Thu, 7 May 2026 23:32:41 +0800 Subject: [PATCH 1/2] Expose Selection Ask save failures before state drifts Selection Ask reused the same QA hotkey and history persistence paths as other settings pages, but the page did not surface pending, success, or failure states. A rejected hotkey registration or settings write could therefore leave users believing the feature or history option was enabled when the backend had not accepted the change.\n\nThis keeps the existing persistence contract and mirrors the Translation page status treatment: register QA hotkeys before saving prefs, stop on registration errors, refresh after write failures, and add localized feedback for all supported UI languages.\n\nConstraint: Issue #315 requests minimal UI feedback and rollback behavior without refactoring the shared settings queue.\nRejected: Refactor HotkeySettingsContext persistence semantics | broader cross-page behavior change than needed for this bug.\nConfidence: high\nScope-risk: narrow\nTested: cd openless-all/app && npm run build\nTested: git diff --check\nNot-tested: Manual Tauri hotkey conflict flow on macOS/Windows.\nRelated: https://github.com/appergb/openless/issues/315 --- openless-all/app/src/i18n/en.ts | 5 + openless-all/app/src/i18n/ja.ts | 5 + openless-all/app/src/i18n/ko.ts | 5 + openless-all/app/src/i18n/zh-CN.ts | 5 + openless-all/app/src/i18n/zh-TW.ts | 5 + openless-all/app/src/pages/SelectionAsk.tsx | 112 ++++++++++++++++++-- 6 files changed, 126 insertions(+), 11 deletions(-) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 97406276..27c668c8 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -240,6 +240,11 @@ export const en: typeof zhCN = { optionDisabled: 'Disabled', chordWarning: '', }, + save: { + hotkeyRegisterFailed: 'Failed to register the Selection Ask shortcut. The preference was not saved.', + hotkeySaveFailed: 'Failed to save the Selection Ask shortcut. Please try again.', + historySaveFailed: 'Failed to save the Q&A history setting. Please try again.', + }, history: { title: 'Save history', desc: 'When on, every selection + voice question + AI answer is persisted locally (never uploaded). Off by default — closing the panel forgets the exchange entirely, more private.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index b2c1fa46..d2870995 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -242,6 +242,11 @@ export const ja: typeof zhCN = { optionDisabled: '無効', chordWarning: '', }, + save: { + hotkeyRegisterFailed: '選択追問ショートカットの登録に失敗しました。設定は保存されていません。', + hotkeySaveFailed: '選択追問ショートカットの保存に失敗しました。もう一度お試しください。', + historySaveFailed: 'Q&A 履歴設定の保存に失敗しました。もう一度お試しください。', + }, history: { title: '履歴を保存', desc: 'ON にすると、各追問の「選択テキスト + あなたの音声質問 + AI 回答」をローカルに保存(クラウドへは送信しない)。デフォルト OFF では、フロートウィンドウを閉じると問答が消去されプライバシー優先。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index fcfd977c..916d3d2c 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -242,6 +242,11 @@ export const ko: typeof zhCN = { optionDisabled: '비활성화', chordWarning: '', }, + save: { + hotkeyRegisterFailed: '선택 질문 단축키 등록에 실패했습니다. 설정은 저장되지 않았습니다.', + hotkeySaveFailed: '선택 질문 단축키 저장에 실패했습니다. 다시 시도하세요.', + historySaveFailed: 'Q&A 기록 설정 저장에 실패했습니다. 다시 시도하세요.', + }, history: { title: '기록 저장', desc: '체크 시 매 후속 질문의 "선택 텍스트 + 음성 질문 + AI 답변"을 로컬 보관(클라우드 비전송). 기본 OFF 일 때는 창을 닫으면 문답이 즉시 사라져 프라이버시 우선.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 73f53d77..e1b45682 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -238,6 +238,11 @@ export const zhCN = { optionDisabled: '不启用', chordWarning: '', }, + save: { + hotkeyRegisterFailed: '划词追问快捷键注册失败,未继续保存。', + hotkeySaveFailed: '划词追问快捷键保存失败,请重试。', + historySaveFailed: 'Q&A 历史保存设置保存失败,请重试。', + }, history: { title: '保存历史', desc: '勾上则把每次追问的「选中文本 + 你的语音问题 + AI 答案」写入本地存档(不上云)。默认关,关闭时浮窗一关问答即遗忘,更注重隐私。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 7d740e31..cbc3af95 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -240,6 +240,11 @@ export const zhTW: typeof zhCN = { optionDisabled: '不啓用', chordWarning: '', }, + save: { + hotkeyRegisterFailed: '劃詞追問快捷鍵註冊失敗,未繼續保存。', + hotkeySaveFailed: '劃詞追問快捷鍵保存失敗,請重試。', + historySaveFailed: 'Q&A 歷史保存設置保存失敗,請重試。', + }, history: { title: '保存歷史', desc: '勾上則把每次追問的「選中文本 + 你的語音問題 + AI 答案」寫入本地存檔(不上雲)。默認關,關閉時浮窗一關問答即遺忘,更注重隱私。', diff --git a/openless-all/app/src/pages/SelectionAsk.tsx b/openless-all/app/src/pages/SelectionAsk.tsx index ce1b8c43..cec3acbc 100644 --- a/openless-all/app/src/pages/SelectionAsk.tsx +++ b/openless-all/app/src/pages/SelectionAsk.tsx @@ -5,19 +5,83 @@ // 这一页把原本散在 Settings → 录音 里的两条配置(hotkey 预设 / 保存 Q&A 历史) // 集中起来 + 加完整使用指南,跟"翻译"页平级。 +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, PageHeader } from './_atoms'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { setQaHotkey } from '../lib/ipc'; import { defaultQaShortcut, formatComboLabel } from '../lib/hotkey'; import { ShortcutRecorder } from '../components/ShortcutRecorder'; +import type { QaHotkeyBinding, UserPreferences } from '../lib/types'; + +type SaveState = 'idle' | 'saving' | 'saved' | 'failed'; export function SelectionAsk() { const { t } = useTranslation(); - const { prefs, updatePrefs: savePrefs } = useHotkeySettings(); + const { prefs, refresh, updatePrefs: savePrefs } = useHotkeySettings(); + const [saveState, setSaveState] = useState('idle'); + const [saveMessage, setSaveMessage] = useState(''); + const statusTimer = useRef(null); const defaultQaHotkey = defaultQaShortcut(); const defaultHotkeyLabel = formatComboLabel(defaultQaHotkey); const recordHotkeyLabel = prefs ? formatComboLabel(prefs.dictationHotkey) : '快捷键'; + + useEffect(() => () => { + if (statusTimer.current !== null) window.clearTimeout(statusTimer.current); + }, []); + + const showSaveStatus = (state: SaveState, message: string, temporary = false) => { + if (statusTimer.current !== null) { + window.clearTimeout(statusTimer.current); + statusTimer.current = null; + } + setSaveState(state); + setSaveMessage(message); + if (temporary) { + statusTimer.current = window.setTimeout(() => { + setSaveState('idle'); + setSaveMessage(''); + statusTimer.current = null; + }, 1600); + } + }; + + const persistPrefs = async ( + resolveNext: (current: UserPreferences) => UserPreferences, + failureMessage: string, + ) => { + try { + await savePrefs(resolveNext); + showSaveStatus('saved', t('common.saved'), true); + return true; + } catch (error) { + console.error('[selection-ask] failed to save preferences', error); + showSaveStatus('failed', failureMessage); + await refresh().catch(refreshError => { + console.warn('[selection-ask] failed to refresh preferences after save error', refreshError); + }); + return false; + } + }; + + const saveQaHotkey = async (nextHotkey: QaHotkeyBinding | null) => { + showSaveStatus('saving', t('common.saving')); + try { + await setQaHotkey(nextHotkey); + } catch (error) { + console.error('[selection-ask] failed to register QA hotkey', error); + showSaveStatus('failed', t('selectionAsk.save.hotkeyRegisterFailed')); + await refresh().catch(refreshError => { + console.warn('[selection-ask] failed to refresh preferences after hotkey error', refreshError); + }); + return; + } + await persistPrefs( + current => ({ ...current, qaHotkey: nextHotkey }), + t('selectionAsk.save.hotkeySaveFailed'), + ); + }; + if (!prefs) { return ( <> @@ -36,11 +100,17 @@ export function SelectionAsk() { ); } - const onSaveHistoryChange = (qaSaveHistory: boolean) => - savePrefs({ ...prefs, qaSaveHistory }); + const onSaveHistoryChange = (qaSaveHistory: boolean) => { + showSaveStatus('saving', t('common.saving')); + void persistPrefs( + current => ({ ...current, qaSaveHistory }), + t('selectionAsk.save.historySaveFailed'), + ); + }; const enabled = prefs.qaHotkey !== null; const currentLabel = prefs.qaHotkey ? formatComboLabel(prefs.qaHotkey) : defaultHotkeyLabel; + const saving = saveState === 'saving'; return ( <> @@ -54,16 +124,34 @@ export function SelectionAsk() { />
+ {saveState !== 'idle' && ( +
+ {saveMessage} +
+ )} {/* 1. 触发快捷键 */} { + disabled={saving} + onToggle={() => { const nextHotkey = enabled ? null : defaultQaHotkey; - await setQaHotkey(nextHotkey); - await savePrefs({ ...prefs, qaHotkey: nextHotkey }); + void saveQaHotkey(nextHotkey); }} />
@@ -72,10 +160,7 @@ export function SelectionAsk() { {prefs.qaHotkey && ( { - await setQaHotkey(binding); - await savePrefs({ ...prefs, qaHotkey: binding }); - }} + onSave={saveQaHotkey} /> )} @@ -85,6 +170,7 @@ export function SelectionAsk() { onSaveHistoryChange(!prefs.qaSaveHistory)} />
@@ -142,10 +228,12 @@ export function SelectionAsk() { function CardHeaderToggle({ title, checked, + disabled = false, onToggle, }: { title: string; checked: boolean; + disabled?: boolean; onToggle: () => void; }) { return ( @@ -154,6 +242,7 @@ function CardHeaderToggle({