From 5b8be1664a73104113290b901b485d797aadbcea Mon Sep 17 00:00:00 2001 From: baiqing Date: Sun, 3 May 2026 16:49:54 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(qa,windows):=20=E5=88=92=E8=AF=8D?= =?UTF-8?q?=E8=BF=BD=E9=97=AE=E6=B5=AE=E7=AA=97=E8=AF=BB=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E5=BD=95=E9=9F=B3=E9=94=AE=E3=80=81Windows?= =?UTF-8?q?=20=E5=8A=A0=20toolbar=20=E6=8B=96=E6=8B=BD=20(#205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows 端的滑词追问浮窗存在三处问题:(1) qa.* i18n 硬编码 "Option", Windows 用户没这个键;(2) 拖拽只走 macOS NSWindow.movableByWindowBackground, Windows 上无等价路径;(3) toolbar 注释明确写「不需要 data-tauri-drag-region」 导致 Tauri 标准拖拽通路也没接。 修复: - QaPanel mount 时读 prefs.hotkey.trigger,转成 label(如「右 Control」/ 「Right Alt」),通过 i18n {{recordHotkey}} 插值传给 EmptyHint / RecordingHeader / TurnIndicator / ErrorRow / StatusBar 五个子组件,平台 与用户配置都能跟上。 - Toolbar 空白 spacer 加 data-tauri-drag-region:mousedown 走 Tauri 标准 startDragging() → Windows 上是 WM_NCLBUTTONDOWN(HTCAPTION),在 focus:false 浮窗上也能拖;macOS 同时保留原 NSWindow movableByWindowBackground,两条 路径并存不冲突。 --- openless-all/app/src/i18n/en.ts | 10 +-- openless-all/app/src/i18n/zh-CN.ts | 10 +-- openless-all/app/src/pages/QaPanel.tsx | 100 ++++++++++++++++++++----- 3 files changed, 93 insertions(+), 27 deletions(-) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 157a63e5..c946099c 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -38,15 +38,15 @@ export const en: typeof zhCN = { thinking: 'Thinking…', error: 'Something went wrong. Please try again.', errorRetry: 'Retry', - errorRetryHint: 'Press Option again to retry.', + errorRetryHint: 'Press {{recordHotkey}} again to retry.', pinTooltip: 'Pin (stay open)', unpinTooltip: 'Unpin', closeTooltip: 'Close', selectionPreview: 'From selected text:', - emptyTitle: 'Press Option to ask', - emptyDesc: 'Select text in any app, press Option once to start recording, press it again to submit. Answers appear here. You can ask follow-up questions in the same panel.', - recordingHint: 'Recording… press Option again to submit', - statusIdle: 'Press Option to ask', + emptyTitle: 'Press {{recordHotkey}} to ask', + emptyDesc: 'Select text in any app, press {{recordHotkey}} once to start recording, press it again to submit. Answers appear here. You can ask follow-up questions in the same panel.', + recordingHint: 'Recording… press {{recordHotkey}} again to submit', + statusIdle: 'Press {{recordHotkey}} to ask', statusRecording: 'Recording', statusThinking: 'Thinking', statusError: 'Error', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index c98358bb..a473492b 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -36,15 +36,15 @@ export const zhCN = { thinking: '思考中…', error: '出错了,请稍后再试。', errorRetry: '重试', - errorRetryHint: '再按 Option 重新提问。', + errorRetryHint: '再按 {{recordHotkey}} 重新提问。', pinTooltip: '固定(不自动关闭)', unpinTooltip: '取消固定', closeTooltip: '关闭', selectionPreview: '基于选中文本:', - emptyTitle: '按 Option 开始提问', - emptyDesc: '在任意 app 选中一段文字后,按一次 Option 开始录音,再按一次结束并提交。回答会显示在这里,可以连续多轮追问。', - recordingHint: '录音中…再按一次 Option 结束并提问', - statusIdle: '按 Option 提问', + emptyTitle: '按 {{recordHotkey}} 开始提问', + emptyDesc: '在任意 app 选中一段文字后,按一次 {{recordHotkey}} 开始录音,再按一次结束并提交。回答会显示在这里,可以连续多轮追问。', + recordingHint: '录音中…再按一次 {{recordHotkey}} 结束并提问', + statusIdle: '按 {{recordHotkey}} 提问', statusRecording: '录音中', statusThinking: '思考中', statusError: '出错了', diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx index 50ca1f15..5bb43afd 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -13,8 +13,9 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { marked } from 'marked'; -import { isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc'; +import { getSettings, isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc'; import type { QaChatMessage, QaStatePayload } from '../lib/types'; +import { getHotkeyTriggerLabel } from '../lib/hotkey'; const SELECTION_PREVIEW_MAX = 60; @@ -23,7 +24,7 @@ marked.setOptions({ gfm: true, breaks: true }); type Status = 'idle' | 'recording' | 'thinking' | 'error'; export function QaPanel() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [messages, setMessages] = useState([]); const [status, setStatus] = useState('idle'); const [errorMsg, setErrorMsg] = useState(''); @@ -33,6 +34,12 @@ export function QaPanel() { const [streamingAnswer, setStreamingAnswer] = useState(''); /** 录音电平:0..1。后端每帧 33ms 通过 qa:level emit。详见 issue #162。 */ const [level, setLevel] = useState(0); + /** 用户当前的录音热键 label(如 "右 Option" / "Right Alt")。issue #205: + * 原版硬编码 "Option",Windows 用户没这个键,文案失真。读 prefs 后由 i18n + * 插值动态显示,平台与用户配置都能跟上。 */ + const [recordHotkeyLabel, setRecordHotkeyLabel] = useState(() => + i18n.t('hotkey.fallback'), + ); const tRef = useRef(t); tRef.current = t; @@ -144,6 +151,29 @@ export function QaPanel() { return () => window.removeEventListener('keydown', onKey, true); }, []); + // ── 读取用户当前的录音热键 label,给 i18n 插值用(issue #205)。 + // QaPanel 跑在独立 webview(label="qa"),没有 HotkeySettingsContext + // 注入,所以直接走 IPC 拿一次 prefs。语言切换时 i18n.t('hotkey.fallback') + // 也要跟着重算,所以 i18n.language 入依赖。 + useEffect(() => { + if (!isTauri) { + setRecordHotkeyLabel(i18n.t('hotkey.fallback')); + return; + } + let cancelled = false; + void getSettings() + .then(prefs => { + if (cancelled) return; + setRecordHotkeyLabel(getHotkeyTriggerLabel(prefs.hotkey?.trigger)); + }) + .catch(err => { + console.warn('[QaPanel] load hotkey label failed', err); + }); + return () => { + cancelled = true; + }; + }, [i18n.language]); + const onTogglePin = () => { const next = !pinned; setPinned(next); @@ -166,13 +196,26 @@ export function QaPanel() {
- {messages.length === 0 && status === 'idle' && } + {messages.length === 0 && status === 'idle' && ( + + )} {messages.length === 0 && status === 'recording' && ( - + )} {status === 'recording' && messages.length > 0 && ( - + )} {streamingAnswer && ( @@ -180,9 +223,11 @@ export function QaPanel() { {status === 'thinking' && !streamingAnswer && ( )} - {status === 'error' && } + {status === 'error' && ( + + )}
- +
); } @@ -197,11 +242,16 @@ interface ToolbarProps { function Toolbar({ pinned, onTogglePin, onClose }: ToolbarProps) { const { t } = useTranslation(); - // 拖动靠 NSWindow.movableByWindowBackground=YES(lib.rs::make_qa_window_draggable_macos) - // 在 AppKit 层处理。前端不需要 onMouseDown / data-tauri-drag-region。 + // 拖动 (issue #205): + // - macOS: lib.rs::make_qa_window_draggable_macos 在 NSWindow 层把整窗口设 + // movableByWindowBackground=YES,所以 macOS 上整片背景都可拖。 + // - Windows: NSWindow 走不了;用 Tauri 标准 data-tauri-drag-region —— mousedown + // 走 startDragging() → WM_NCLBUTTONDOWN(HTCAPTION),在 focus:false 浮窗上也能用。 + // 两条路径并存不冲突;data-tauri-drag-region 放在 toolbar 的空白 spacer 上,IconBtn + // 作为 button 子元素仍然正常 click。 return (
-
+
['t'] }) { +function EmptyHint({ + t, + recordHotkey, +}: { + t: ReturnType['t']; + recordHotkey: string; +}) { return (
- {t('qa.emptyTitle')} + {t('qa.emptyTitle', { recordHotkey })}
- {t('qa.emptyDesc')} + {t('qa.emptyDesc', { recordHotkey })}
); @@ -272,10 +328,12 @@ function RecordingHeader({ preview, t, level, + recordHotkey, }: { preview: string; t: ReturnType['t']; level: number; + recordHotkey: string; }) { const truncated = useMemo(() => truncate(preview, SELECTION_PREVIEW_MAX), [preview]); return ( @@ -290,7 +348,7 @@ function RecordingHeader({ )}
- {t('qa.recordingHint')} + {t('qa.recordingHint', { recordHotkey })}
@@ -418,11 +476,13 @@ function TurnIndicator({ preview, t, level, + recordHotkey, }: { kind: 'recording' | 'thinking'; preview?: string; t: ReturnType['t']; level?: number; + recordHotkey?: string; }) { if (kind === 'recording') { const truncated = preview ? truncate(preview, SELECTION_PREVIEW_MAX) : ''; @@ -438,7 +498,7 @@ function TurnIndicator({ )}
- {t('qa.recordingHint')} + {t('qa.recordingHint', { recordHotkey: recordHotkey ?? '' })}
@@ -459,14 +519,18 @@ function TurnIndicator({ function ErrorRow({ message, t, + recordHotkey, }: { message: string; t: ReturnType['t']; + recordHotkey: string; }) { return (
{message}
-
{t('qa.errorRetryHint')}
+
+ {t('qa.errorRetryHint', { recordHotkey })} +
); } @@ -474,15 +538,17 @@ function ErrorRow({ function StatusBar({ status, t, + recordHotkey, }: { status: Status; t: ReturnType['t']; + recordHotkey: string; }) { let label = ''; let dotColor = 'transparent'; switch (status) { case 'idle': - label = t('qa.statusIdle'); + label = t('qa.statusIdle', { recordHotkey }); dotColor = 'rgba(0,0,0,0.18)'; break; case 'recording': From 17bb078f195d1746e7b566cc2de9d36a9a3b7164 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 4 May 2026 06:58:13 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(qa):=20=E5=BD=95=E9=9F=B3=E9=94=AE?= =?UTF-8?q?=E6=94=B9=E4=BA=86=E7=AB=8B=E5=88=BB=E5=88=B7=E6=96=B0=E6=B5=AE?= =?UTF-8?q?=E7=AA=97=E6=96=87=E6=A1=88=EF=BC=8C=E4=B8=8D=E7=94=A8=E7=AD=89?= =?UTF-8?q?=E9=87=8D=E5=BC=80=20(#205=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #206 review 反馈:QaPanel 的 recordHotkeyLabel 只在 mount / 语言切换时 重读 prefs;用户在主窗口改了录音键,浮窗如果还开着就一直停留在旧值 ("按 右 Option 提问" 之类),误导用户按错键。 修法是补一条事件通路,不动现有的初始读取: - commands.rs::set_settings 持久化后 `app.emit("prefs:changed", &prefs)`, 广播给所有 webview。沿用 vocab:updated 同款跨窗口通知模式。 - QaPanel.tsx 在原有 qa:state / qa:dismiss / qa:level 订阅旁边加一条 prefs:changed listener,收到后用 getHotkeyTriggerLabel 重算 label。 cleanup 一并处理。 QaPanel 跑在独立 webview(label="qa"),没有 HotkeySettingsContext 注入, 所以必须走 IPC 事件而不是 React context;这也是为什么不能复用 Settings 页面的本地 state 更新。 --- openless-all/app/src-tauri/src/commands.rs | 15 ++++++++++++--- openless-all/app/src/pages/QaPanel.tsx | 12 +++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 0d37f1e7..3ef48d5b 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -5,7 +5,7 @@ use std::time::Duration; use serde::Serialize; use serde_json::Value; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Emitter, State}; use crate::coordinator::Coordinator; use crate::permissions::{self, PermissionStatus}; @@ -67,8 +67,17 @@ fn persist_settings(coord: &T, prefs: UserPreferences) -> Res } #[tauri::command] -pub fn set_settings(coord: CoordinatorState<'_>, prefs: UserPreferences) -> Result<(), String> { - persist_settings(&*coord, prefs) +pub fn set_settings( + coord: CoordinatorState<'_>, + app: AppHandle, + prefs: UserPreferences, +) -> Result<(), String> { + // 广播给所有 webview。issue #205:QaPanel 跑在独立 webview, + // 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时 + // 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。 + persist_settings(&*coord, prefs.clone())?; + let _ = app.emit("prefs:changed", &prefs); + Ok(()) } #[tauri::command] diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx index 5bb43afd..5703ac82 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -14,7 +14,7 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { useTranslation } from 'react-i18next'; import { marked } from 'marked'; import { getSettings, isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc'; -import type { QaChatMessage, QaStatePayload } from '../lib/types'; +import type { QaChatMessage, QaStatePayload, UserPreferences } from '../lib/types'; import { getHotkeyTriggerLabel } from '../lib/hotkey'; const SELECTION_PREVIEW_MAX = 60; @@ -49,6 +49,7 @@ export function QaPanel() { let unlistenState: (() => void) | undefined; let unlistenDismiss: (() => void) | undefined; let unlistenLevel: (() => void) | undefined; + let unlistenPrefs: (() => void) | undefined; let cancelled = false; (async () => { try { @@ -118,14 +119,22 @@ export function QaPanel() { const levelHandle = await listen<{ level: number }>('qa:level', event => { setLevel(event.payload.level ?? 0); }); + // prefs:changed — 后端在 set_settings 后广播。issue #205:QA 浮窗在独立 + // webview,没有 HotkeySettingsContext;如果用户在主窗口改了录音键, + // 浮窗里的 "{recordHotkey}" 文案必须立刻跟上,否则会一直停在旧值。 + const prefsHandle = await listen('prefs:changed', event => { + setRecordHotkeyLabel(getHotkeyTriggerLabel(event.payload?.hotkey?.trigger)); + }); if (cancelled) { stateHandle(); dismissHandle(); levelHandle(); + prefsHandle(); } else { unlistenState = stateHandle; unlistenDismiss = dismissHandle; unlistenLevel = levelHandle; + unlistenPrefs = prefsHandle; } } catch (error) { console.error('[QaPanel] listener setup failed', error); @@ -136,6 +145,7 @@ export function QaPanel() { unlistenState?.(); unlistenDismiss?.(); unlistenLevel?.(); + unlistenPrefs?.(); }; }, []);