diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 99b07d86..c78ee1e0 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -3007,10 +3007,15 @@ fn enabled_phrases(inner: &Arc) -> Vec { .collect() } -/// 终止态(Done / Cancelled / Error)后延迟 N ms 把胶囊改回 Idle,让浮窗自动消失。 -/// 用户点 ✕ / ✓ / 中途出错 / 按 Esc 都走这里,统一 2 秒。 +/// 终止态(Done / Error)后延迟 N ms 把胶囊改回 Idle,让浮窗自动消失。 +/// 点 ✓ / 中途出错走这里,保留 2 秒让用户看清结果 / 错误提示。 const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000; +/// 用户主动取消(Esc / 点 ✕)时的收起延迟。取消是明确的「我不要了」意图, +/// 不需要像 Done/Error 那样停留 2 秒给用户读——立刻回 Idle,由前端 capsule-out +/// 淡出动画(520ms)负责优雅收尾,观感上「按下即消失」(对齐 Typeless)。 +const CAPSULE_CANCEL_HIDE_DELAY_MS: u64 = 0; + /// Toggle 模式下,end_session 将 phase 设为 Idle 后在此时间内禁止新的 begin_session。 /// 避免用户三连按时第 3 次按下误激活新听写(此时胶囊仍在离场动画周期内)。 /// 值取 capsule EXIT_ANIM_MS (360ms) + 余量 ≈ 600ms。 diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 01384017..c891150c 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1,7 +1,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use crate::coordinator_state::request_stop_during_starting_state; +use crate::coordinator_state::{ + finish_cancelled_processing_state, request_stop_during_starting_state, +}; use crate::correction::apply_correction_rules; use crate::types::HotkeyMode; @@ -961,7 +963,7 @@ pub(super) async fn run_voice_agent_transcript( log::info!("[coord] Cloud Agent 语音已取消"); emit_less_computer(inner, serde_json::json!({ "kind": "cancelled" })); emit_capsule(inner, CapsuleState::Cancelled, 0.0, elapsed, None, None); - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + schedule_capsule_idle(inner, CAPSULE_CANCEL_HIDE_DELAY_MS); Err("voice agent cancelled".to_string()) } } @@ -2104,6 +2106,17 @@ async fn try_silent_retranscribe( None } +fn finish_cancelled_processing(inner: &Arc, session_id: SessionId) -> bool { + let finished = { + let mut state = inner.state.lock(); + finish_cancelled_processing_state(&mut state, session_id) + }; + if finished { + schedule_capsule_idle(inner, CAPSULE_CANCEL_HIDE_DELAY_MS); + } + finished +} + pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { let current_session_id = { let mut state = inner.state.lock(); @@ -2126,7 +2139,9 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { Some(a) => a, None => { restore_prepared_windows_ime_session(inner, current_session_id); - inner.state.lock().phase = SessionPhase::Idle; + if !finish_cancelled_processing(inner, current_session_id) { + set_phase_idle_if_session_matches(inner, current_session_id); + } return Ok(()); } }; @@ -2268,7 +2283,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { AsrReleaseSession::Dictation(current_session_id), ); restore_prepared_windows_ime_session(inner, current_session_id); - set_phase_idle_if_session_matches(inner, current_session_id); + finish_cancelled_processing(inner, current_session_id); return Ok(()); } log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}"); @@ -2306,7 +2321,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { AsrReleaseSession::Dictation(current_session_id), ); restore_prepared_windows_ime_session(inner, current_session_id); - set_phase_idle_if_session_matches(inner, current_session_id); + finish_cancelled_processing(inner, current_session_id); return Ok(()); } log::error!("[coord] sherpa-onnx transcribe failed: {e:#}"); @@ -2375,7 +2390,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { "[coord] Apple Speech transcribe cancelled - discarding transcript" ); restore_prepared_windows_ime_session(inner, current_session_id); - set_phase_idle_if_session_matches(inner, current_session_id); + finish_cancelled_processing(inner, current_session_id); return Ok(()); } log::error!("[coord] Apple Speech transcribe failed: {e:#}"); @@ -2405,11 +2420,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // cancel_session 在 Processing 阶段故意跳过 finish_cancel_session_state(让 // 这里收尾),但此前的 end_session 没把 focus_target 清掉。logic-review // 2026-05-10 P3 (🚩) 把这条补完。 - { - let mut state = inner.state.lock(); - state.phase = SessionPhase::Idle; - state.focus_target = None; - } + finish_cancelled_processing(inner, current_session_id); return Ok(()); } @@ -2695,7 +2706,6 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { let proceed_to_insert = { let mut state = inner.state.lock(); if state.cancelled && !already_streamed { - state.phase = SessionPhase::Idle; false } else { state.phase = SessionPhase::Inserting; @@ -2708,6 +2718,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { polished.chars().count() ); restore_prepared_windows_ime_session(inner, current_session_id); + finish_cancelled_processing(inner, current_session_id); return Ok(()); } @@ -2968,7 +2979,7 @@ pub(super) fn cancel_session(inner: &Arc) { } emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None); log::info!("[coord] session cancelled (was {:?})", decision.phase); - schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + schedule_capsule_idle(inner, CAPSULE_CANCEL_HIDE_DELAY_MS); // 取消时也熄灭整屏彩虹描边(dictation session 没开描边,hide 是无害 no-op)。 if let Some(app) = inner.app.lock().clone() { crate::hide_less_computer_glow(&app); diff --git a/openless-all/app/src-tauri/src/coordinator_state.rs b/openless-all/app/src-tauri/src/coordinator_state.rs index bdb59081..9977af56 100644 --- a/openless-all/app/src-tauri/src/coordinator_state.rs +++ b/openless-all/app/src-tauri/src/coordinator_state.rs @@ -181,6 +181,28 @@ pub(crate) fn finish_cancel_session_state(state: &mut SessionState, decision: Ca } } +/// 完成已进入 Processing 的取消收尾。 +/// +/// cancel_session 不能直接把 Processing 改成 Idle,否则会和 end_session 的润色/插入 +/// 收尾并发竞争;因此由 end_session 在取消早退点调用。session id 校验避免旧会话的迟到 +/// continuation 修改新会话状态。 +pub(crate) fn finish_cancelled_processing_state( + state: &mut SessionState, + session_id: SessionId, +) -> bool { + if state.session_id != session_id || !state.cancelled { + return false; + } + if state.phase == SessionPhase::Processing { + state.phase = SessionPhase::Idle; + } + if state.phase != SessionPhase::Idle { + return false; + } + state.focus_target = None; + true +} + pub(crate) fn start_processing_if_listening(state: &mut SessionState) -> Option { if state.phase != SessionPhase::Listening { return None; @@ -405,6 +427,42 @@ mod tests { } } + #[test] + fn finish_cancelled_processing_state_returns_idle_for_matching_session() { + let mut state = SessionState { + phase: SessionPhase::Processing, + cancelled: true, + focus_target: Some(1), + session_id: session_id(42), + ..Default::default() + }; + + assert!(finish_cancelled_processing_state( + &mut state, + session_id(42) + )); + assert_eq!(state.phase, SessionPhase::Idle); + assert!(state.focus_target.is_none()); + } + + #[test] + fn finish_cancelled_processing_state_rejects_stale_session() { + let mut state = SessionState { + phase: SessionPhase::Processing, + cancelled: true, + focus_target: Some(1), + session_id: session_id(42), + ..Default::default() + }; + + assert!(!finish_cancelled_processing_state( + &mut state, + session_id(41) + )); + assert_eq!(state.phase, SessionPhase::Processing); + assert_eq!(state.focus_target, Some(1)); + } + #[test] fn stop_dictation_from_listening_enters_processing_once() { let mut state = SessionState { diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index dcbfaca2..e115328d 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -99,17 +99,24 @@ export function History() { return () => window.clearTimeout(id); }, [query]); - // ⌘K / Ctrl+K 聚焦搜索框(设计稿提示的快捷键)。 + // ⌘K / Ctrl+K 聚焦搜索框(设计稿提示的快捷键);⌘R / Ctrl+R 刷新历史列表 + // (与浏览器「重新加载」直觉一致)。preventDefault 拦掉 webview 默认的整页 + // reload,改为只重拉 listHistory,避免整个前端重挂载。 useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); searchInputRef.current?.focus(); + return; + } + if ((e.metaKey || e.ctrlKey) && (e.key === 'r' || e.key === 'R')) { + e.preventDefault(); + void refresh(); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, []); + }, [refresh]); const filtered = useMemo(() => { const byMode = filter === 'all' ? items : items.filter(s => s.mode === filter); diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index db0eb48a..17d5129f 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -409,6 +409,21 @@ function WeekChart({ data }: { data: number[] }) { function RecentRow({ session, modeLabel }: { session: DictationSession; modeLabel: Record }) { const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + + const onCopy = async () => { + try { + if (!navigator.clipboard?.writeText) throw new Error('clipboard unavailable'); + // 与 History 一致:润色失败/未产出时 finalText 为空,回退到识别原文, + // 避免复制到空字符串。 + await navigator.clipboard.writeText(session.finalText.trim() ? session.finalText : session.rawTranscript); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch (error) { + console.error('[overview] failed to copy recent entry', error); + } + }; + return (
@@ -420,9 +435,20 @@ function RecentRow({ session, modeLabel }: { session: DictationSession; modeLabe
{session.finalText.split('\n')[0]}
- - {formatDuration(session.durationMs ?? 0, t)} - +
+ + {formatDuration(session.durationMs ?? 0, t)} + + void onCopy()} + style={{ padding: '3px 8px' }} + > + {copied ? t('common.copied') : t('common.copy')} + +
); } diff --git a/openless-all/app/src/pages/_atoms.tsx b/openless-all/app/src/pages/_atoms.tsx index 89644c6b..87fa76ab 100644 --- a/openless-all/app/src/pages/_atoms.tsx +++ b/openless-all/app/src/pages/_atoms.tsx @@ -114,7 +114,7 @@ export type BtnVariant = 'primary' | 'blue' | 'ghost' | 'soft'; export type BtnSize = 'sm' | 'md'; interface BtnProps { - children: ReactNode; + children?: ReactNode; variant?: BtnVariant; size?: BtnSize; icon?: string;