Merge branch 'appergb:beta' into beta

This commit is contained in:
wei keyi authored and GitHub committed 2026-05-09 17:03:47 +08:00
commit 9cdb58d3ae
18 files changed
+1820 -1879

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "openless-app",
"private": true,
"version": "1.2.24-5",
"version": "1.2.24-6",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -3751,7 +3751,7 @@ dependencies = [
[[package]]
name = "openless"
version = "1.2.24-5"
version = "1.2.24-6"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "openless"
version = "1.2.24-5"
version = "1.2.24-6"
description = "OpenLess — local voice input that types where your cursor is"
authors = ["OpenLess"]
edition = "2021"
+24 -7
View File
@@ -150,10 +150,22 @@ pub fn set_settings(
// 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
persist_settings(&*coord, prefs.clone())?;
if let Err(err) = crate::refresh_tray_microphone_menu(&app) {
log::warn!("[tray] refresh microphone menu after settings save failed: {err}");
sync_tray_microphone_selection(&tray_microphones.lock(), &prefs.microphone_device_name);
}
// refresh_tray_microphone_menu 内部会调用 NSStatusItem.set_menu,必须在主线程上跑。
// set_settings 本身是同步 Tauri command,在 IPC handler 线程上执行;从这里直接调
// 会触发 macOS 主线程断言或在 dispatch 队列上死锁,导致整个 UI 无响应(用户改
// 偏好后所有按键都没反应即此根因)。dispatch 到主线程后立即返回,IPC 线程不阻塞。
let app_for_main = app.clone();
let prefs_for_main = prefs.clone();
let _ = app.run_on_main_thread(move || {
if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) {
log::warn!("[tray] refresh microphone menu after settings save failed: {err}");
let tray_state = app_for_main.state::<TrayMicrophoneMenuState>();
sync_tray_microphone_selection(&tray_state.lock(), &prefs_for_main.microphone_device_name);
}
});
// 抑制 unused 警告:tray_microphones 现在改在闭包里通过 app.state 取,
// 但函数签名保留 State 入参,以便 Tauri 在调用前注入。
let _ = tray_microphones;
let _ = app.emit("prefs:changed", &prefs);
Ok(())
}
@@ -929,9 +941,14 @@ pub fn set_default_polish_mode(
let mut prefs = coord.prefs().get();
prefs.default_mode = mode;
coord.prefs().set(prefs.clone()).map_err(|e| e.to_string())?;
if let Err(err) = crate::refresh_tray_microphone_menu(&app) {
log::warn!("[tray] refresh style menu after polish mode IPC change failed: {err}");
}
// 跟 set_settings 同样:refresh_tray_microphone_menu 里 tray.set_menu 改 NSStatusItem,
// 必须主线程;这里是同步 Tauri command 跑在 IPC 线程,直调会让 macOS 死锁。
let app_for_main = app.clone();
let _ = app.run_on_main_thread(move || {
if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) {
log::warn!("[tray] refresh style menu after polish mode IPC change failed: {err}");
}
});
let _ = app.emit("prefs:changed", &prefs);
let _ = app.emit_to("main", "prefs:changed", &prefs);
Ok(())
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,124 @@
use std::sync::Arc;
use tauri::Emitter;
use crate::coordinator_state::{initial_session_id, SessionId};
use crate::selection::SelectionContext;
use crate::types::CapsuleState;
use super::{
begin_qa_session, cancel_qa_session, capture_frontmost_app, emit_capsule, end_qa_session, Inner,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum QaPhase {
Idle,
Recording,
Processing,
}
pub(super) struct QaSessionState {
pub(super) phase: QaPhase,
pub(super) cancelled: bool,
pub(super) selection: Option<SelectionContext>,
pub(super) front_app: Option<String>,
/// 用于忽略迟到的 RMS / runtime error。
pub(super) session_id: SessionId,
/// QA 浮窗是否被用户钉住(pinned)。pinned=true 时不自动隐藏。
pub(super) pinned: bool,
/// 浮窗是否对用户可见。Cmd+Shift+; 边沿 toggle 此 flag;
/// 主听写 hotkey(rightOption)边沿来时,看这个 flag 决定是走 QA 还是走 dictation。
/// 详见 issue #118 v2。
pub(super) panel_visible: bool,
/// 多轮对话累积。每轮 user→assistant 加两条;关浮窗清空。
pub(super) messages: Vec<crate::types::QaChatMessage>,
}
impl Default for QaSessionState {
fn default() -> Self {
Self {
phase: QaPhase::Idle,
cancelled: false,
selection: None,
front_app: None,
session_id: initial_session_id(),
pinned: false,
panel_visible: false,
messages: Vec::new(),
}
}
}
pub(super) async fn handle_qa_hotkey_pressed(inner: &Arc<Inner>) {
// QA hotkey(默认 Cmd+Shift+;)现在只 toggle 浮窗可见性。
// 浮窗内的录音 / 提问由 Option 边沿驱动(handle_pressed_edge → handle_qa_option_edge)。
let visible = inner.qa_state.lock().panel_visible;
log::info!("[coord] QA hotkey edge (panel_visible={visible})");
if visible {
close_qa_panel(inner);
} else {
open_qa_panel(inner);
}
}
pub(super) async fn handle_qa_option_edge(inner: &Arc<Inner>) {
let phase = inner.qa_state.lock().phase;
log::info!("[coord] QA option edge (phase={phase:?})");
match phase {
QaPhase::Idle => {
let _ = begin_qa_session(inner).await;
}
QaPhase::Recording => {
let _ = end_qa_session(inner).await;
}
// Processing 阶段再次按键忽略(避免与正在跑的 LLM 冲突)。
QaPhase::Processing => {}
}
}
pub(super) fn open_qa_panel(inner: &Arc<Inner>) {
{
let mut state = inner.qa_state.lock();
state.panel_visible = true;
state.phase = QaPhase::Idle;
state.cancelled = false;
state.messages.clear();
state.selection = None;
state.front_app = capture_frontmost_app();
}
// 先把胶囊清干净,避免主听写上一次 Done 状态残留的 message/insertedChars
// 在 QA Done 阶段被 capsule UI 错误复用("已之一粘贴这个 0" 那种)。
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
if let Some(app) = inner.app.lock().clone() {
crate::show_qa_window(&app, "idle");
let _ = app.emit_to(
"qa",
"qa:state",
serde_json::json!({
"kind": "idle",
"messages": Vec::<crate::types::QaChatMessage>::new(),
}),
);
}
log::info!("[coord] QA panel opened (awaiting Option to record)");
}
pub(super) fn close_qa_panel(inner: &Arc<Inner>) {
cancel_qa_session(inner);
{
let mut state = inner.qa_state.lock();
state.panel_visible = false;
state.pinned = false;
state.messages.clear();
state.selection = None;
state.front_app = None;
state.phase = QaPhase::Idle;
state.cancelled = false;
}
if let Some(app) = inner.app.lock().clone() {
crate::hide_qa_window(&app);
}
// 胶囊一同收掉,避免浮窗关了胶囊还在显示。
emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None);
log::info!("[coord] QA panel closed, history cleared");
}
@@ -0,0 +1,191 @@
use std::sync::Arc;
use crate::coordinator_state::{SessionId, SessionPhase};
use crate::recorder::Recorder;
use crate::types::CapsuleState;
use tauri::Manager;
use super::{emit_capsule, ActiveAsr, Inner};
pub(super) struct SessionResource<T> {
pub(super) session_id: SessionId,
resource: T,
}
impl<T> SessionResource<T> {
pub(super) fn new(session_id: SessionId, resource: T) -> Self {
Self {
session_id,
resource,
}
}
fn into_inner(self) -> T {
self.resource
}
}
pub(super) struct SharedRecordingMuteState {
guard: Option<crate::audio_mute::AudioMuteGuard>,
holders: u32,
}
impl SharedRecordingMuteState {
pub(super) fn new() -> Self {
Self {
guard: None,
holders: 0,
}
}
}
pub(super) fn take_session_resource<T>(
slot: &mut Option<SessionResource<T>>,
session_id: SessionId,
) -> Option<T> {
if slot
.as_ref()
.map(|resource| resource.session_id == session_id)
.unwrap_or(false)
{
slot.take().map(SessionResource::into_inner)
} else {
None
}
}
pub(super) fn store_asr_for_session(inner: &Arc<Inner>, session_id: SessionId, asr: ActiveAsr) {
*inner.asr.lock() = Some(SessionResource::new(session_id, asr));
}
pub(super) fn take_asr_for_session(inner: &Arc<Inner>, session_id: SessionId) -> Option<ActiveAsr> {
let mut slot = inner.asr.lock();
take_session_resource(&mut slot, session_id)
}
pub(super) fn cancel_active_asr(asr: ActiveAsr) {
match asr {
ActiveAsr::Volcengine(v) => v.cancel(),
ActiveAsr::Whisper(w) => w.cancel(),
#[cfg(target_os = "windows")]
ActiveAsr::FoundryLocalWhisper(local) => local.cancel(),
#[cfg(target_os = "macos")]
ActiveAsr::Local(local) => local.cancel(),
}
}
pub(super) fn cancel_asr_for_session(inner: &Arc<Inner>, session_id: SessionId) {
if let Some(asr) = take_asr_for_session(inner, session_id) {
cancel_active_asr(asr);
}
}
pub(super) fn store_recorder_for_session(
inner: &Arc<Inner>,
session_id: SessionId,
recorder: Recorder,
) {
*inner.recorder.lock() = Some(SessionResource::new(session_id, recorder));
}
pub(super) fn selected_microphone_device_name(inner: &Arc<Inner>) -> Option<String> {
let name = inner.prefs.get().microphone_device_name.trim().to_string();
if name.is_empty() {
None
} else {
Some(name)
}
}
pub(super) fn stop_microphone_preview_monitor(inner: &Arc<Inner>, owner: &str) {
let Some(app) = inner.app.lock().as_ref().cloned() else {
return;
};
let state = app.state::<crate::commands::MicrophoneMonitorState>();
let recorder = state.lock().take();
if let Some(recorder) = recorder {
log::info!("[recorder] stopping microphone preview monitor before {owner}");
recorder.stop();
}
}
pub(super) fn acquire_recording_mute(inner: &Arc<Inner>, owner: &str) {
if !inner.prefs.get().mute_during_recording {
return;
}
let mut mute = inner.recording_mute.lock();
if mute.holders == 0 {
match crate::audio_mute::AudioMuteGuard::activate() {
Ok(guard) => {
mute.guard = Some(guard);
log::info!("[audio-mute] system output muted for recording");
}
Err(err) => {
log::warn!("[audio-mute] failed to mute output for {owner}: {err}");
return;
}
}
}
mute.holders = mute.holders.saturating_add(1);
log::info!("[audio-mute] acquired by {owner}; holders={}", mute.holders);
}
pub(super) fn release_recording_mute(inner: &Arc<Inner>, owner: &str) {
let mut mute = inner.recording_mute.lock();
if mute.holders == 0 {
return;
}
mute.holders -= 1;
log::info!("[audio-mute] released by {owner}; holders={}", mute.holders);
if mute.holders == 0 {
mute.guard.take();
log::info!("[audio-mute] system output mute restored after recording");
}
}
pub(super) fn stop_qa_recorder(inner: &Arc<Inner>) {
if let Some(rec) = inner.qa_recorder.lock().take() {
rec.stop();
release_recording_mute(inner, "qa");
}
}
pub(super) fn take_recorder_for_session(
inner: &Arc<Inner>,
session_id: SessionId,
) -> Option<Recorder> {
let mut slot = inner.recorder.lock();
take_session_resource(&mut slot, session_id)
}
pub(super) fn stop_recorder_for_session(inner: &Arc<Inner>, session_id: SessionId) {
if let Some(recorder) = take_recorder_for_session(inner, session_id) {
recorder.stop();
release_recording_mute(inner, "dictation");
}
}
pub(super) fn discard_startup_resources_for_session(inner: &Arc<Inner>, session_id: SessionId) {
stop_recorder_for_session(inner, session_id);
cancel_asr_for_session(inner, session_id);
}
pub(super) fn stop_recorder_if_pending_start_stop(inner: &Arc<Inner>) {
let (should_stop, session_id) = {
let state = inner.state.lock();
(
state.phase == SessionPhase::Starting && state.pending_stop,
state.session_id,
)
};
if !should_stop {
return;
}
if let Some(rec) = take_recorder_for_session(inner, session_id) {
rec.stop();
release_recording_mute(inner, "dictation");
let elapsed = inner.state.lock().started_at.elapsed().as_millis() as u64;
emit_capsule(inner, CapsuleState::Transcribing, 0.0, elapsed, None, None);
log::info!("[coord] stopped recorder while ASR is still connecting");
}
}
@@ -192,6 +192,23 @@ fn run_audio_thread(
while !stop_flag_for_watchdog.load(Ordering::SeqCst) {
thread::sleep(std::time::Duration::from_millis(WATCHDOG_CHECK_INTERVAL_MS));
// 关键:sleep 醒来后必须重新检查 stop_flag,再去看 elapsed。
//
// 否则会与 hotkey-release 停采路径产生竞态:
// 1. 用户松开 hotkey → end_session 调 rec.stop() → 设置 stop_flag
// → audio 线程 pause 了 cpal Stream → 回调真的静默
// 2. 但 watchdog 此时正卡在上面的 1 秒 sleep 里
// 3. sleep 结束后,若不重新检查 stop_flag,
// 就会读到 last_callback_time 已经"老 4 秒",
// 把"我们主动停掉的录音"错报成 EngineFailed("录音回调静默停止 N 秒"),
// coordinator 收到错误后会终止 session、胶囊弹错。
//
// 修复方式是 sleep 后立即再 load 一次:进入 stop 流程后 watchdog 静默退出,
// 不影响 watchdog 在真正活动期捕获 CoreAudio 设备掉线等真故障。
if stop_flag_for_watchdog.load(Ordering::SeqCst) {
break;
}
let last_callback = *state_for_watchdog.last_callback_time.lock();
match last_callback {
Some(last_time) => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenLess",
"version": "1.2.24-5",
"version": "1.2.24-6",
"identifier": "com.openless.app",
"build": {
"beforeDevCommand": "npm run dev",
+83 -205
View File
@@ -4,9 +4,8 @@
//
// Ported verbatim from design_handoff_openless/variants.jsx::FloatingShell.
import { useEffect, useMemo, useState, type CSSProperties, type ComponentType, type ReactNode } from 'react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentType } from 'react';
import { useTranslation } from 'react-i18next';
import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate';
import { Icon } from './Icon';
import { WindowChrome, detectOS, type OS } from './WindowChrome';
import { SettingsModal } from './SettingsModal';
@@ -23,15 +22,13 @@ import {
HOTKEY_MODE_MIGRATION_DEFERRED_KEY,
shouldShowHotkeyModeMigrationPrompt,
} from '../lib/hotkeyMigration';
import { formatComboLabel } from '../lib/hotkey';
import { applyFontScale, readFontScale } from '../lib/fontScale';
import { getCredentials, openExternal } from '../lib/ipc';
import { getCredentials } from '../lib/ipc';
import {
PROVIDER_SETUP_PROMPT_DEFERRED_KEY,
shouldShowProviderSetupPrompt,
} from '../lib/providerSetup';
import { NAVIGATE_LOCAL_ASR_EVENT, type SettingsSectionId } from '../pages/Settings';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { useAppState, type AppTab } from '../state/useAppState';
interface NavItem {
@@ -51,9 +48,6 @@ const NAV_BASE: Array<Omit<NavItem, 'name'>> = [
{ id: 'localAsr', icon: 'archive', cmp: LocalAsr },
];
const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases';
const HELP_DOCS_URL = 'https://github.com/appergb/openless#readme';
interface FloatingShellProps {
os?: OS;
initialTab?: AppTab;
@@ -75,8 +69,6 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionId | undefined>();
const [providerPromptOpen, setProviderPromptOpen] = useState(false);
const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false);
const [helpPopoverOpen, setHelpPopoverOpen] = useState(false);
const { prefs } = useHotkeySettings();
// tab 切换的 cross-fade:旧页 blur+fade out(180ms),结束后挂载新页(走 ol-page-slide enter)。
// displayTab 是实际渲染的 tab,currentTab 是用户点中的目标 tab。
@@ -97,26 +89,23 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
applyFontScale(readFontScale());
}, []);
// help popover 打开时,点击其他位置自动关闭
useEffect(() => {
if (!helpPopoverOpen) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Element | null;
if (target && target.closest('[data-ol-footer-popover]')) return;
setHelpPopoverOpen(false);
};
const id = window.setTimeout(() => document.addEventListener('mousedown', onDown), 0);
return () => {
window.clearTimeout(id);
document.removeEventListener('mousedown', onDown);
};
}, [helpPopoverOpen]);
const NAV = useMemo<NavItem[]>(
() => NAV_BASE.map(b => ({ ...b, name: t(`nav.${b.id}`) })),
[t],
);
const Page = (NAV.find((n) => n.id === displayTab) ?? NAV[0]).cmp;
// sidebar nav 滑动指示器:测量当前 active button 的 offsetTop / height,
// 用一个 absolute pill 平滑滑过去,而不是每个按钮各自瞬切背景色。
const navItemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
useLayoutEffect(() => {
const idx = NAV.findIndex(n => n.id === currentTab);
const el = navItemRefs.current[idx];
if (!el) return;
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
}, [currentTab, NAV]);
useEffect(() => {
let cancelled = false;
(async () => {
@@ -221,25 +210,47 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
<div style={{ fontSize: 13.5, fontWeight: 600, letterSpacing: '-0.01em', color: 'var(--ol-ink)' }}>OpenLess</div>
</div>
{/* nav */}
<nav style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{NAV.map((n) => {
{/* nav — 滑动指示器:active pill 是 absolute 元素,currentTab 改变时 top/height
过渡到目标按钮的位置,而非各按钮自己瞬切背景色。hover 灰底通过 .ol-nav-btn 的
CSS :hover 规则实现,仅对非 active 项生效。 */}
<nav style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1 }}>
{pillRect && (
<div
aria-hidden
style={{
position: 'absolute',
left: 0,
right: 0,
top: pillRect.top,
height: pillRect.height,
background: 'var(--ol-surface)',
borderRadius: 8,
boxShadow: '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)',
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
pointerEvents: 'none',
zIndex: 0,
}}
/>
)}
{NAV.map((n, i) => {
const active = currentTab === n.id;
return (
<button
key={n.id}
ref={el => { navItemRefs.current[i] = el; }}
onClick={() => setCurrentTab(n.id)}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px',
borderRadius: 8, border: 0,
background: active ? 'var(--ol-surface)' : 'transparent',
color: active ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
fontFamily: 'inherit', fontSize: 13, fontWeight: active ? 600 : 500,
boxShadow: active ? '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
background: 'transparent',
fontFamily: 'inherit', fontSize: 13,
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
textAlign: 'left',
position: 'relative',
zIndex: 1,
}}>
<Icon name={n.icon} size={14} />
@@ -250,30 +261,6 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
</nav>
<div style={{ flex: 1 }} />
{/* shortcut hint — 不要 dashed 边框,否则会切断"整片磨砂玻璃"的视觉 */}
<div style={{ padding: '10px 10px 6px', marginTop: 6 }}>
<div style={{ fontSize: 10.5, color: 'var(--ol-ink-4)', marginBottom: 6, letterSpacing: '0.02em' }}>{t('shell.shortcutLabel')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--ol-ink-2)' }}>
<kbd style={{
padding: '2px 7px', fontSize: 10.5,
background: 'rgba(255,255,255,0.7)', borderRadius: 5,
border: '0.5px solid var(--ol-line-strong)',
fontFamily: 'var(--ol-font-mono)', color: 'var(--ol-ink)',
boxShadow: '0 1px 0 rgba(0,0,0,.04)',
}}>{prefs ? formatComboLabel(prefs.dictationHotkey) : ''}</kbd>
<span style={{ color: 'var(--ol-ink-4)' }}>{t('shell.shortcutHint')}</span>
</div>
</div>
{/* BETA 区域 — 仅在 Beta build(version 含 semver prerelease 段,如 1.2.24-1)显示。
正式版 build 完全不渲染这块,避免普通用户误以为正式版是 Beta。 */}
{IS_BETA_BUILD && (
<div style={{ marginTop: 8, padding: '10px 10px 4px' }}>
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--ol-blue)', letterSpacing: '0.04em', textTransform: 'uppercase' }}>{t('shell.betaTag')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-2)', marginTop: 4, lineHeight: 1.5 }}>{t('shell.betaNote')}</div>
</div>
)}
</aside>
{/* Main content — inset white card sitting on the frosted backplate.
@@ -312,6 +299,9 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
flex: 1, minHeight: 0,
overflow: 'auto',
padding: '24px 28px 32px',
// position:relative 让页面里的"已保存"toast 用 absolute top:16 right:16
// 锚到这块控制台卡的右上角,而不是横在页头变成长横幅。
position: 'relative',
// 苹果"spring out"风格的曲线:开始快、收尾顺滑,符合人体直觉
animation: tabPhase === 'exiting'
? 'ol-page-fadeout 0.18s var(--ol-motion-soft) forwards'
@@ -345,24 +335,27 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
zIndex: 2,
}}>
<FooterIcon name="user" tip={t('shell.footer.account')} onClick={() => openSettings('providers')} />
<FooterIcon name="mail" tip={t('shell.footer.feedback')} onClick={() => openExternal('https://github.com/appergb/openless/issues')} />
<FooterIcon name="settings" tip={t('shell.footer.settings')} active={settingsOpen} onClick={() => openSettings()} />
{/* 问号 — 点击展开版本说明 popover */}
<FooterIconWithPopover
name="help"
tip={t('shell.footer.help')}
open={helpPopoverOpen}
onToggle={() => setHelpPopoverOpen(o => !o)}
>
<HelpPopoverBody />
</FooterIconWithPopover>
<div style={{ flex: 1 }} />
<span style={{ fontFamily: 'var(--ol-font-sans)' }}>{t('shell.footer.version', { version: APP_VERSION_LABEL })}</span>
<FooterAutoUpdateButton />
{IS_BETA_BUILD && (
<span style={{
display: 'inline-block',
padding: '2px 8px',
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: 'var(--ol-blue)',
background: 'rgba(37,99,235,0.10)',
borderRadius: 999,
marginRight: 8,
}}>{t('shell.betaTag')}</span>
)}
<span style={{ fontFamily: 'var(--ol-font-sans)', marginRight: 12 }}>{t('shell.footer.version', { version: APP_VERSION_LABEL })}</span>
<div style={{ marginRight: 12 }}>
<FooterIcon name="settings" tip={t('shell.footer.settings')} active={settingsOpen} onClick={() => openSettings()} />
</div>
</div>
{/* Settings modal — rendered inside this window */}
@@ -389,6 +382,24 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
{/* tab 切换 + provider prompt + footer popover 公用的入场关键帧 */}
<style>{`
/* nav 三段视觉层次:
基础态 → ink-3(中灰文字 + 透明底)
hover → ink(深色文字 + 浅灰底) ← 让"翻译"等字词在悬停时高亮,跟基础/选中都拉开差距
选中 → ink(深色文字 + 白色 pill 底,由 absolute pill 提供)
inline color/fontWeight 留给 active 项写最高优先级;非 active 走 class,
这样 :hover 能正确覆盖(CSS 不能盖 inline style)。 */
.ol-nav-btn {
color: var(--ol-ink-3);
font-weight: 500;
}
.ol-nav-btn.ol-nav-btn-active {
color: var(--ol-ink);
font-weight: 600;
}
.ol-nav-btn:not(.ol-nav-btn-active):hover {
background: rgba(0,0,0,0.04);
color: var(--ol-ink);
}
@keyframes ol-page-slide {
from { opacity: 0; transform: translate3d(10px, 0, 0) scale(.996); filter: blur(6px); }
to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); filter: blur(0); }
@@ -405,10 +416,6 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
from { opacity: 0; transform: translateY(6px) scale(.97); filter: blur(6px); }
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
}
@keyframes ol-popover-pop {
from { opacity: 0; transform: translateY(6px) scale(.96); filter: blur(6px); }
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
}
`}</style>
</div>
);
@@ -634,132 +641,3 @@ function FooterIcon({ name, tip, active, onClick }: FooterIconProps) {
);
}
// 把 footer icon 和它的 popover 绑在同一个相对定位容器里,popover 锚定在按钮正上方。
function FooterIconWithPopover({
name, tip, open, onToggle, children,
}: {
name: string;
tip: string;
open: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<div data-ol-footer-popover style={{ position: 'relative', display: 'inline-flex' }}>
<FooterIcon name={name} tip={tip} active={open} onClick={onToggle} />
{open && (
<div
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: 0,
zIndex: 80,
minWidth: 220,
padding: 12,
borderRadius: 12,
background: 'rgba(255,255,255,0.96)',
backdropFilter: 'blur(var(--ol-glass-blur)) saturate(180%)',
WebkitBackdropFilter: 'blur(var(--ol-glass-blur)) saturate(180%)',
border: '0.5px solid rgba(0,0,0,0.08)',
boxShadow: '0 18px 50px -22px rgba(15,17,22,0.32), 0 0 0 0.5px rgba(0,0,0,0.05)',
animation: 'ol-popover-pop 0.22s var(--ol-motion-spring) both',
transformOrigin: 'bottom left',
}}
>
{children}
</div>
)}
</div>
);
}
function HelpPopoverBody() {
const { t } = useTranslation();
return (
<div style={{ minWidth: 240 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<img src="AppIcon.png" alt="" style={{ width: 26, height: 26, borderRadius: 6, boxShadow: '0 1px 2px rgba(0,0,0,.1), 0 0 0 0.5px rgba(0,0,0,.06)' }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--ol-ink)' }}>OpenLess</div>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-mono)', marginTop: 1 }}>{APP_VERSION_LABEL}</div>
</div>
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)', lineHeight: 1.55, marginBottom: 10 }}>
{t('shell.footer.helpPopover.tagline')}
</div>
<button onClick={() => openExternal(RELEASE_NOTES_URL)} style={popoverLinkStyle}>
{t('shell.footer.helpPopover.releaseNotes')}
</button>
<button onClick={() => openExternal(HELP_DOCS_URL)} style={popoverLinkStyle}>
{t('shell.footer.helpPopover.docs')}
</button>
</div>
);
}
const popoverLinkStyle: CSSProperties = {
display: 'block',
width: '100%',
border: 0,
background: 'transparent',
color: 'var(--ol-blue)',
fontFamily: 'inherit',
fontSize: 12,
fontWeight: 500,
cursor: 'default',
textAlign: 'left',
padding: '6px 4px',
};
// Footer 的"检查更新"按钮 — 复用 Settings 页面的 useAutoUpdate hook + UpdateDialog 窗口,
// 跟"关于"section 走完全相同的状态机和确认对话框。按钮本身只显示触发文案 + 简短状态。
function FooterAutoUpdateButton() {
const { t } = useTranslation();
const u = useAutoUpdate();
const inlineHint = u.status === 'none'
? t('settings.about.upToDate')
: u.status === 'error'
? t('settings.about.updateError')
: null;
const inlineHintColor = u.status === 'error' ? 'var(--ol-err)' : 'var(--ol-ink-4)';
return (
<>
<button
onClick={u.checkForUpdates}
disabled={u.checking || u.busy}
style={{
color: 'var(--ol-blue)',
marginLeft: 8,
textDecoration: 'none',
fontWeight: 500,
border: 0,
background: 'transparent',
fontFamily: 'inherit',
fontSize: 11,
cursor: 'default',
padding: 0,
opacity: u.checking || u.busy ? 0.7 : 1,
transition: 'opacity 0.16s var(--ol-motion-soft)',
}}
>
{u.checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn')}
</button>
{inlineHint && (
<span style={{ marginLeft: 8, color: inlineHintColor, fontSize: 11 }}>{inlineHint}</span>
)}
{isDialogStatus(u.status) && (
<UpdateDialog
status={u.status}
version={u.version}
progress={u.progress}
downloaded={u.downloaded}
contentLength={u.contentLength}
onInstall={u.installUpdate}
onClose={u.dismissDialog}
/>
)}
</>
);
}
@@ -0,0 +1,53 @@
// SavedToast.tsx — 控制台卡右上角的"正在保存 / 已保存 / 失败"小 pill。
// 父级 scroll wrapper(FloatingShell main 区)已设 position:relative,
// 此 pill 用 absolute 锚到右上角,避免在页面顶部撑成一条难看的长横幅。
import type { CSSProperties } from 'react';
export type SaveToastState = 'idle' | 'saving' | 'saved' | 'failed';
interface SavedToastProps {
saveState: SaveToastState;
message: string;
/** 覆盖默认 top:16 right:16 偏移,例如 SettingsModal 里要避开 28×28 的关闭按钮。 */
offsetStyle?: Pick<CSSProperties, 'top' | 'right' | 'left' | 'bottom'>;
}
export function SavedToast({ saveState, message, offsetStyle }: SavedToastProps) {
if (saveState === 'idle') return null;
const failed = saveState === 'failed';
const style: CSSProperties = {
position: 'absolute',
top: 16,
right: 16,
...offsetStyle,
zIndex: 5,
padding: '5px 12px',
borderRadius: 999,
border: failed
? '0.5px solid rgba(239,68,68,0.22)'
: '0.5px solid rgba(37,99,235,0.16)',
background: failed ? 'rgba(239,68,68,0.10)' : 'rgba(37,99,235,0.10)',
color: failed ? 'var(--ol-red, #ef4444)' : 'var(--ol-blue)',
fontSize: 11.5,
fontWeight: 500,
lineHeight: 1.4,
boxShadow: '0 4px 12px -4px rgba(15,17,22,0.18), 0 0 0 0.5px rgba(0,0,0,0.04)',
backdropFilter: 'blur(12px) saturate(160%)',
WebkitBackdropFilter: 'blur(12px) saturate(160%)',
pointerEvents: 'none',
animation: 'ol-toast-pop 0.22s var(--ol-motion-spring)',
whiteSpace: 'nowrap',
};
return (
<div role={failed ? 'alert' : 'status'} style={style}>
{message}
<style>{`
@keyframes ol-toast-pop {
from { opacity: 0; transform: translateY(-6px) scale(.96); filter: blur(4px); }
to { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
}
`}</style>
</div>
);
}
@@ -4,11 +4,13 @@
// 开机自启)已从此弹窗移除,避免 "看似可点实际无效" 的负面体感。
// 待 backend 就位后再补回(参见 issue #69)。
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from './Icon';
import { AboutUpdateControl, Settings as SettingsContent, Toggle, type SettingsSectionId } from '../pages/Settings';
import { Row } from './ui/Row';
import { SavedToast } from './SavedToast';
import { useSavedToastListener } from '../lib/savedEvent';
import { readFontScale, setFontScale, type FontScaleId } from '../lib/fontScale';
import {
exportErrorLog,
@@ -55,6 +57,7 @@ const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases';
export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) {
const { t } = useTranslation();
const [section, setSection] = useState<ModalSectionId>('settings');
const savedToast = useSavedToastListener();
const groups: ModalGroup[] = [
{
items: [
@@ -71,6 +74,16 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
},
];
// 与 sidebar nav 一致的滑动指示器:仅第一组(可选中)有 pill;外链组永远不 active 不画 pill。
const firstGroupRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
useLayoutEffect(() => {
const idx = groups[0].items.findIndex(it => it.id === section);
const el = firstGroupRefs.current[idx];
if (!el) return;
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
}, [section]);
return (
<div
onClick={onClose}
@@ -109,12 +122,31 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
}}>
{groups.map((g, gi) => (
<div key={gi} style={{ display: 'flex', flexDirection: 'column', gap: 1, paddingTop: gi === 1 ? 8 : 0, borderTop: gi === 1 ? '0.5px solid var(--ol-line-soft)' : 'none' }}>
{g.items.map((it) => {
<div key={gi} style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1, paddingTop: gi === 1 ? 8 : 0, borderTop: gi === 1 ? '0.5px solid var(--ol-line-soft)' : 'none' }}>
{gi === 0 && pillRect && (
<div
aria-hidden
style={{
position: 'absolute',
left: 0,
right: 0,
top: pillRect.top,
height: pillRect.height,
background: '#fff',
borderRadius: 8,
boxShadow: '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)',
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
pointerEvents: 'none',
zIndex: 0,
}}
/>
)}
{g.items.map((it, idx) => {
const active = section === it.id && !it.external;
return (
<button
key={it.id}
ref={gi === 0 ? (el => { firstGroupRefs.current[idx] = el; }) : undefined}
onClick={() => {
if (it.external && it.href) {
void openExternal(it.href);
@@ -122,16 +154,17 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
setSection(it.id as ModalSectionId);
}
}}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px',
borderRadius: 8, border: 0,
background: active ? '#fff' : 'transparent',
color: active ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
fontFamily: 'inherit', fontSize: 13, fontWeight: active ? 600 : 500,
boxShadow: active ? '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
background: 'transparent',
fontFamily: 'inherit', fontSize: 13,
cursor: 'default', textAlign: 'left',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
position: 'relative',
zIndex: 1,
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
}}>
<Icon name={it.icon} size={14} />
@@ -148,6 +181,13 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
只有最里层的 scroll wrapper 真正滚动。这样模态左 sidebar、关闭按钮、
section 标题都不会跟着内容一起飘。 */}
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
{/* "已保存"toast 在内容区右上角;right:54 避开 28×28 关闭按钮 + 12px gap。
CredentialField 等通过 emitSaved 发事件,useSavedToastListener 接收。 */}
<SavedToast
saveState={savedToast.state}
message={savedToast.message}
offsetStyle={{ top: 16, right: 54 }}
/>
<button
onClick={onClose}
style={{
+64
View File
@@ -0,0 +1,64 @@
// savedEvent.ts — 跨组件的"已保存 / 失败"统一事件通道。
//
// 触发:任意组件保存成功 / 失败时调用 emitSaved(...)。
// 监听:根容器(Settings / Translation / SelectionAsk)通过 useSavedToastListener 订阅,
// 状态喂给 <SavedToast>,pill 浮在右上角。
//
// 用 DOM CustomEvent(而不是 React Context)是为了让 CredentialField / ProviderTools
// 这类深层叶子组件不必沿 props 链传 dispatcher,跟 NAVIGATE_LOCAL_ASR_EVENT 同惯例。
import { useEffect, useState } from 'react';
export const SAVED_TOAST_EVENT = 'openless:saved-toast';
export type SavedToastEventState = 'saving' | 'saved' | 'failed';
export interface SavedToastDetail {
state: SavedToastEventState;
message: string;
}
export function emitSaved(state: SavedToastEventState, message: string): void {
window.dispatchEvent(
new CustomEvent<SavedToastDetail>(SAVED_TOAST_EVENT, { detail: { state, message } }),
);
}
interface ToastSnapshot {
state: 'idle' | SavedToastEventState;
message: string;
}
const IDLE_SNAPSHOT: ToastSnapshot = { state: 'idle', message: '' };
/**
* 订阅 saved-toast 事件,自动管理"非 saving 状态 1.6s 后回 idle"逻辑。
* saving 状态保持显示直到下一条事件覆盖(避免长任务里 saving 中途消失)。
*/
export function useSavedToastListener(): ToastSnapshot {
const [snapshot, setSnapshot] = useState<ToastSnapshot>(IDLE_SNAPSHOT);
useEffect(() => {
let timer: number | null = null;
const handle = (event: Event) => {
const detail = (event as CustomEvent<SavedToastDetail>).detail;
if (!detail) return;
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
setSnapshot({ state: detail.state, message: detail.message });
if (detail.state !== 'saving') {
timer = window.setTimeout(() => {
setSnapshot(IDLE_SNAPSHOT);
timer = null;
}, 1600);
}
};
window.addEventListener(SAVED_TOAST_EVENT, handle);
return () => {
window.removeEventListener(SAVED_TOAST_EVENT, handle);
if (timer !== null) window.clearTimeout(timer);
};
}, []);
return snapshot;
}
+1 -28
View File
@@ -124,34 +124,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
return (
<>
<PageHeader
kicker={t('overview.kicker')}
title={t('overview.title')}
desc={t('overview.desc')}
right={
<div
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '6px 12px',
borderRadius: 999,
border: '0.5px solid var(--ol-line-strong)',
background: 'var(--ol-surface-2)',
color: 'var(--ol-ink-3)',
fontSize: 12,
}}
>
<Icon name="cmd" size={12} />
{t('overview.pressPrefix')}
<kbd style={{
padding: '2px 7px', fontSize: 11, fontFamily: 'var(--ol-font-mono)',
background: '#fff', borderRadius: 5,
border: '0.5px solid var(--ol-line-strong)',
color: 'var(--ol-ink)',
}}>{prefs ? formatComboLabel(prefs.dictationHotkey) : ''}</kbd>
{t('overview.pressSuffix')}
</div>
}
/>
<PageHeader title={t('overview.title')} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 18 }}>
<ProviderCard
+52 -170
View File
@@ -8,11 +8,10 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, PageHeader } from './_atoms';
import { SavedToast } from '../components/SavedToast';
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';
import type { UserPreferences } from '../lib/types';
type SaveState = 'idle' | 'saving' | 'saved' | 'failed';
@@ -22,8 +21,7 @@ export function SelectionAsk() {
const [saveState, setSaveState] = useState<SaveState>('idle');
const [saveMessage, setSaveMessage] = useState('');
const statusTimer = useRef<number | null>(null);
const defaultQaHotkey = defaultQaShortcut();
const defaultHotkeyLabel = formatComboLabel(defaultQaHotkey);
const defaultHotkeyLabel = formatComboLabel(defaultQaShortcut());
const recordHotkeyLabel = prefs ? formatComboLabel(prefs.dictationHotkey) : '快捷键';
useEffect(() => () => {
@@ -64,35 +62,10 @@ export function SelectionAsk() {
}
};
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 (
<>
<PageHeader
kicker={t('selectionAsk.kicker')}
title={t('selectionAsk.title')}
desc={t('selectionAsk.desc', {
hotkey: defaultHotkeyLabel,
recordHotkey: recordHotkeyLabel,
})}
/>
<PageHeader title={t('selectionAsk.title')} />
<Card>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>
</Card>
@@ -114,72 +87,61 @@ export function SelectionAsk() {
return (
<>
<PageHeader
kicker={t('selectionAsk.kicker')}
title={t('selectionAsk.title')}
desc={t('selectionAsk.desc', {
hotkey: enabled ? currentLabel : defaultHotkeyLabel,
recordHotkey: recordHotkeyLabel,
})}
/>
<PageHeader title={t('selectionAsk.title')} />
<SavedToast saveState={saveState} message={saveMessage} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{saveState !== 'idle' && (
<div
role={saveState === 'failed' ? 'alert' : 'status'}
{/* 1. 历史保存 — 小型自适宽度模块,避免在宽页面里拉成一条长横条 */}
<div
style={{
display: 'inline-flex',
alignSelf: 'flex-start',
alignItems: 'center',
gap: 12,
padding: '8px 14px',
borderRadius: 10,
background: 'rgba(0,0,0,0.04)',
border: '0.5px solid var(--ol-line)',
}}
>
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--ol-ink-2)' }}>
{t('selectionAsk.history.title')}
</span>
<button
onClick={() => onSaveHistoryChange(!prefs.qaSaveHistory)}
aria-pressed={prefs.qaSaveHistory}
disabled={saving}
style={{
padding: '8px 12px',
borderRadius: 10,
border: saveState === 'failed'
? '0.5px solid rgba(239,68,68,0.22)'
: '0.5px solid rgba(37,99,235,0.16)',
background: saveState === 'failed' ? 'rgba(239,68,68,0.07)' : 'rgba(37,99,235,0.06)',
color: saveState === 'failed' ? 'var(--ol-red, #ef4444)' : 'var(--ol-blue)',
fontSize: 11.5,
lineHeight: 1.5,
position: 'relative',
width: 36,
height: 20,
borderRadius: 999,
border: 0,
background: prefs.qaSaveHistory ? 'var(--ol-blue)' : 'rgba(0,0,0,0.18)',
cursor: saving ? 'not-allowed' : 'default',
opacity: saving ? 0.68 : 1,
transition: 'background 0.16s var(--ol-motion-quick)',
padding: 0,
}}
>
{saveMessage}
</div>
)}
{/* 1. 触发快捷键 */}
<Card>
<CardHeaderToggle
title={t('selectionAsk.hotkey.title')}
checked={enabled}
disabled={saving}
onToggle={() => {
const nextHotkey = enabled ? null : defaultQaHotkey;
void saveQaHotkey(nextHotkey);
}}
/>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: prefs.qaHotkey ? 12 : 0, lineHeight: 1.55 }}>
{t('selectionAsk.hotkey.desc', { recordHotkey: recordHotkeyLabel })}
</div>
{prefs.qaHotkey && (
<ShortcutRecorder
value={prefs.qaHotkey}
onSave={saveQaHotkey}
disabled={saving}
<span
style={{
position: 'absolute',
top: 2,
left: prefs.qaSaveHistory ? 18 : 2,
width: 16,
height: 16,
borderRadius: 999,
background: '#fff',
boxShadow: '0 1px 2px rgba(0,0,0,.2)',
transition: 'left .16s var(--ol-motion-spring)',
}}
/>
)}
</Card>
</button>
</div>
{/* 2. 历史保存 */}
<Card>
<CardHeaderToggle
title={t('selectionAsk.history.title')}
checked={prefs.qaSaveHistory}
disabled={saving}
onToggle={() => onSaveHistoryChange(!prefs.qaSaveHistory)}
/>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', lineHeight: 1.55 }}>
{t('selectionAsk.history.desc')}
</div>
</Card>
{/* 3. 使用方法 */}
{/* 2. 使用方法 */}
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>{t('selectionAsk.howto.title')}</div>
<ol style={{ margin: 0, paddingLeft: 18, fontSize: 12.5, color: 'var(--ol-ink-2)', lineHeight: 1.7 }}>
@@ -189,88 +151,8 @@ export function SelectionAsk() {
<li>{t('selectionAsk.howto.step4', { recordHotkey: recordHotkeyLabel })}</li>
<li>{t('selectionAsk.howto.step5', { hotkey: enabled ? currentLabel : defaultHotkeyLabel })}</li>
</ol>
<div
style={{
marginTop: 14,
padding: '10px 12px',
borderRadius: 10,
background: 'rgba(37,99,235,0.06)',
border: '0.5px solid rgba(37,99,235,0.15)',
fontSize: 11.5,
color: 'var(--ol-ink-2)',
lineHeight: 1.55,
}}
>
<div style={{ fontWeight: 600, color: 'var(--ol-blue)', marginBottom: 4 }}>{t('selectionAsk.howto.windowTitle')}</div>
{t('selectionAsk.howto.windowDesc')}
</div>
<div
style={{
marginTop: 10,
padding: '10px 12px',
borderRadius: 10,
background: 'rgba(0,0,0,0.04)',
fontSize: 11.5,
color: 'var(--ol-ink-3)',
lineHeight: 1.55,
}}
>
<div style={{ fontWeight: 600, color: 'var(--ol-ink-2)', marginBottom: 4 }}>{t('selectionAsk.howto.privacyTitle')}</div>
{t('selectionAsk.howto.privacyDesc')}
</div>
</Card>
</div>
</>
);
}
// 卡片标题行右侧开关:与 Style 页面顶栏的 36×20 toggle 同款,保持全局视觉一致。
function CardHeaderToggle({
title,
checked,
disabled = false,
onToggle,
}: {
title: string;
checked: boolean;
disabled?: boolean;
onToggle: () => void;
}) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{title}</div>
<button
onClick={onToggle}
aria-pressed={checked}
disabled={disabled}
style={{
position: 'relative',
width: 36,
height: 20,
borderRadius: 999,
border: 0,
background: checked ? 'var(--ol-blue)' : 'rgba(0,0,0,0.15)',
cursor: disabled ? 'not-allowed' : 'default',
opacity: disabled ? 0.68 : 1,
transition: 'background 0.16s var(--ol-motion-quick)',
padding: 0,
}}
>
<span
style={{
position: 'absolute',
top: 2,
left: checked ? 18 : 2,
width: 16,
height: 16,
borderRadius: 999,
background: '#fff',
boxShadow: '0 1px 2px rgba(0,0,0,.2)',
transition: 'left .16s var(--ol-motion-spring)',
}}
/>
</button>
</div>
);
}
+73 -27
View File
@@ -2,7 +2,7 @@
// Internal sub-sections (Recording / Providers / Shortcuts / Permissions / Language / About)
// keep their inline-style literals 1:1 with the source JSX.
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import { ShortcutRecorder } from '../components/ShortcutRecorder';
@@ -52,6 +52,7 @@ import type {
PermissionStatus,
WindowsImeStatus,
} from '../lib/types';
import { emitSaved } from '../lib/savedEvent';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import i18n, {
FOLLOW_SYSTEM,
@@ -105,6 +106,17 @@ export function Settings({ embedded = false, initialSection = 'recording' }: Set
setSection(initialSection);
}, [initialSection]);
// 跟 sidebar / SettingsModal 同款滑动 pill:测当前 active section 的 offsetTop/height
// → 用 absolute pill 平滑滑过去;--ol-motion-spring 是项目里的 Apple 风格 ease-out-quint。
const sectionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
useLayoutEffect(() => {
const idx = SECTION_ORDER.indexOf(section);
const el = sectionRefs.current[idx];
if (!el) return;
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
}, [section]);
return (
<>
{!embedded && (
@@ -126,23 +138,47 @@ export function Settings({ embedded = false, initialSection = 'recording' }: Set
...(embedded ? { flex: 1, minHeight: 0, gridTemplateRows: 'minmax(0, 1fr)' } : {}),
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{SECTION_ORDER.map(s => (
<button
key={s}
onClick={() => setSection(s)}
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 2 }}>
{pillRect && (
<div
aria-hidden
style={{
padding: '8px 12px', textAlign: 'left',
fontSize: 13, color: section === s ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
background: section === s ? 'rgba(0,0,0,0.04)' : 'transparent',
border: 0, borderRadius: 8, fontFamily: 'inherit', fontWeight: section === s ? 600 : 500,
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
position: 'absolute',
left: 0,
right: 0,
top: pillRect.top,
height: pillRect.height,
background: 'rgba(0,0,0,0.04)',
borderRadius: 8,
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
pointerEvents: 'none',
zIndex: 0,
}}
>
{t(`settings.sections.${s}`)}
</button>
))}
/>
)}
{SECTION_ORDER.map((s, i) => {
const active = section === s;
return (
<button
key={s}
ref={el => { sectionRefs.current[i] = el; }}
onClick={() => setSection(s)}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={{
padding: '8px 12px', textAlign: 'left',
fontSize: 13,
background: 'transparent',
border: 0, borderRadius: 8, fontFamily: 'inherit',
cursor: 'default',
position: 'relative',
zIndex: 1,
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
}}
>
{t(`settings.sections.${s}`)}
</button>
);
})}
</div>
<div
className={embedded ? 'ol-thinscroll' : undefined}
@@ -1532,7 +1568,21 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue
};
}, []);
// 改造:除 readError(持续错误,留在输入旁标识字段不可用)外,所有 saving / saved /
// saveError / copied / copyError 一律发到右上角 SavedToast。原内联文案太挤、跟其它
// 页面 toast 风格不统一。
const showTemporaryStatus = (next: CredentialFieldStatus) => {
if (next === 'saving') {
emitSaved('saving', t('common.saving'));
} else if (next === 'saved') {
emitSaved('saved', t('common.saved'));
} else if (next === 'saveError') {
emitSaved('failed', t('common.operationFailed'));
} else if (next === 'copied') {
emitSaved('saved', t('common.copied'));
} else if (next === 'copyError') {
emitSaved('failed', t('common.operationFailed'));
}
setStatus(next);
if (statusRef.current) clearTimeout(statusRef.current);
statusRef.current = window.setTimeout(() => setStatus('idle'), 1600);
@@ -1541,6 +1591,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue
const save = async (v: string, force = false) => {
if (!loaded || (!dirty && !force)) return;
setStatus('saving');
emitSaved('saving', t('common.saving'));
try {
await setCredential(account, v);
setDirty(false);
@@ -1628,23 +1679,18 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue
>
<Icon name="copy" size={14} />
</button>
{status !== 'idle' && (
{/* readError 是字段无法读取的持续错误,留在原位提示用户该字段不可用;
其它瞬态状态(saving / saved / saveError / copied / copyError)都通过
emitSaved 发到右上角统一 toast,不再内联占位。 */}
{status === 'readError' && (
<span
style={{
fontSize: 11,
color: status.endsWith('Error') ? 'var(--ol-warn)' : 'var(--ol-ok)',
color: 'var(--ol-warn)',
whiteSpace: 'nowrap',
}}
>
{status === 'saving'
? t('common.saving')
: status === 'saved'
? t('common.saved')
: status === 'copied'
? t('common.copied')
: status === 'readError'
? t('settings.providers.readFailed')
: t('common.operationFailed')}
{t('settings.providers.readFailed')}
</span>
)}
</div>
+7 -95
View File
@@ -7,12 +7,11 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, PageHeader } from './_atoms';
import { SavedToast } from '../components/SavedToast';
import { SUPPORTED_LANGUAGES } from '../lib/types';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { formatComboLabel } from '../lib/hotkey';
import { ShortcutRecorder } from '../components/ShortcutRecorder';
import { setTranslationHotkey } from '../lib/ipc';
import type { UserPreferences, ShortcutBinding } from '../lib/types';
import type { UserPreferences } from '../lib/types';
type SaveState = 'idle' | 'saving' | 'saved' | 'failed';
@@ -63,11 +62,7 @@ export function Translation() {
if (!prefs) {
return (
<>
<PageHeader
kicker={t('translation.kicker')}
title={t('translation.title')}
desc={t('translation.desc')}
/>
<PageHeader title={t('translation.title')} />
<Card>
{error ? (
<div role="alert" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
@@ -120,20 +115,6 @@ export function Translation() {
t('translation.save.targetFailed'),
);
};
const onTranslationHotkeySave = async (binding: ShortcutBinding) => {
showSaveStatus('saving', t('common.saving'));
try {
await setTranslationHotkey(binding);
} catch (error) {
console.error('[translation] failed to register translation hotkey', error);
showSaveStatus('failed', t('translation.save.hotkeyRegisterFailed'));
return;
}
await persistPrefs(
current => ({ ...current, translationHotkey: binding }),
t('translation.save.hotkeySaveFailed'),
);
};
const triggerLabel = formatComboLabel(prefs.dictationHotkey);
const translationHotkeyLabel = formatComboLabel(prefs.translationHotkey);
@@ -141,11 +122,7 @@ export function Translation() {
return (
<>
<PageHeader
kicker={t('translation.kicker')}
title={t('translation.title')}
desc={t('translation.desc')}
/>
<PageHeader title={t('translation.title')} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{error && (
@@ -188,31 +165,11 @@ export function Translation() {
</div>
)}
{saveState !== 'idle' && (
<div
role={saveState === 'failed' ? 'alert' : 'status'}
style={{
padding: '8px 12px',
borderRadius: 10,
border: saveState === 'failed'
? '0.5px solid rgba(239,68,68,0.22)'
: '0.5px solid rgba(37,99,235,0.16)',
background: saveState === 'failed' ? 'rgba(239,68,68,0.07)' : 'rgba(37,99,235,0.06)',
color: saveState === 'failed' ? 'var(--ol-red, #ef4444)' : 'var(--ol-blue)',
fontSize: 11.5,
lineHeight: 1.5,
}}
>
{saveMessage}
</div>
)}
<SavedToast saveState={saveState} message={saveMessage} />
{/* 1. 工作语言 */}
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('translation.working.title')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 12, lineHeight: 1.55 }}>
{t('translation.working.desc')}
</div>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12 }}>{t('translation.working.title')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{SUPPORTED_LANGUAGES.map(lang => {
const checked = prefs.workingLanguages.includes(lang);
@@ -242,7 +199,7 @@ export function Translation() {
{/* 2. 翻译目标语言 */}
<Card>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('translation.target.title')}</div>
<span
style={{
@@ -259,9 +216,6 @@ export function Translation() {
{enabled ? t('translation.statusEnabled') : t('translation.statusDisabled')}
</span>
</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 12, lineHeight: 1.55 }}>
{t('translation.target.desc')}
</div>
<select
value={prefs.translationTargetLanguage}
onChange={e => onTargetChange(e.target.value)}
@@ -286,17 +240,6 @@ export function Translation() {
</select>
</Card>
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{t('translation.hotkey.title', 'Translation shortcut')}</div>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginBottom: 12, lineHeight: 1.55 }}>
{t('translation.hotkey.desc', 'Press this during recording to switch the current dictation into translation mode.')}
</div>
<ShortcutRecorder
value={prefs.translationHotkey}
onSave={onTranslationHotkeySave}
/>
</Card>
{/* 3. 使用方法 */}
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 10 }}>{t('translation.howto.title')}</div>
@@ -307,37 +250,6 @@ export function Translation() {
<li>{t('translation.howto.step4')}</li>
<li>{t('translation.howto.step5')}</li>
</ol>
<div
style={{
marginTop: 14,
padding: '10px 12px',
borderRadius: 10,
background: 'rgba(37,99,235,0.06)',
border: '0.5px solid rgba(37,99,235,0.15)',
fontSize: 11.5,
color: 'var(--ol-ink-2)',
lineHeight: 1.55,
}}
>
<div style={{ fontWeight: 600, color: 'var(--ol-blue)', marginBottom: 4 }}>{t('translation.howto.indicatorTitle')}</div>
{t('translation.howto.indicatorDesc')}
</div>
<div
style={{
marginTop: 10,
padding: '10px 12px',
borderRadius: 10,
background: 'rgba(0,0,0,0.04)',
fontSize: 11.5,
color: 'var(--ol-ink-3)',
lineHeight: 1.55,
}}
>
<div style={{ fontWeight: 600, color: 'var(--ol-ink-2)', marginBottom: 4 }}>{t('translation.howto.fallbackTitle')}</div>
{t('translation.howto.fallbackDesc')}
</div>
</Card>
</div>
</>