feat(ux): 三个小改进——Esc 立即收起胶囊 / 概览复制按钮 / 历史页 ⌘R 刷新 (#790)
* feat(capsule): Esc/主动取消时胶囊立即消失(取消延迟 2000ms→0) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(overview): 「最近识别」每行加复制按钮(Btn children 改可选) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(history): ⌘R 刷新历史列表(拦截整页 reload,仅重拉 listHistory) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(dictation): finish cancelled processing sessions --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
6 files changed
+128
-21
No files matched your search
@@ -3147,10 +3147,15 @@ fn enabled_phrases(inner: &Arc<Inner>) -> Vec<String> {
|
||||
.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。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1019,7 +1021,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())
|
||||
}
|
||||
}
|
||||
@@ -2239,6 +2241,17 @@ async fn try_silent_retranscribe(
|
||||
None
|
||||
}
|
||||
|
||||
fn finish_cancelled_processing(inner: &Arc<Inner>, 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<Inner>) -> Result<(), String> {
|
||||
let current_session_id = {
|
||||
let mut state = inner.state.lock();
|
||||
@@ -2261,7 +2274,9 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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(());
|
||||
}
|
||||
};
|
||||
@@ -2434,7 +2449,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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:#}");
|
||||
@@ -2476,7 +2491,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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:#}");
|
||||
@@ -2545,7 +2560,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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:#}");
|
||||
@@ -2575,11 +2590,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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(());
|
||||
}
|
||||
|
||||
@@ -2865,7 +2876,6 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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;
|
||||
@@ -2878,6 +2888,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
polished.chars().count()
|
||||
);
|
||||
restore_prepared_windows_ime_session(inner, current_session_id);
|
||||
finish_cancelled_processing(inner, current_session_id);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -3138,7 +3149,7 @@ pub(super) fn cancel_session(inner: &Arc<Inner>) {
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -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<SessionId> {
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -409,6 +409,21 @@ function WeekChart({ data }: { data: number[] }) {
|
||||
|
||||
function RecentRow({ session, modeLabel }: { session: DictationSession; modeLabel: Record<PolishMode, string> }) {
|
||||
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 (
|
||||
<div style={{ padding: '12px 18px', borderBottom: '0.5px solid var(--ol-line-soft)', display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 4, minWidth: 60 }}>
|
||||
@@ -420,9 +435,20 @@ function RecentRow({ session, modeLabel }: { session: DictationSession; modeLabe
|
||||
<div style={{ flex: 1, fontSize: 12.5, color: 'var(--ol-ink-2)', whiteSpace: 'pre-line', lineHeight: 1.55, overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
{session.finalText.split('\n')[0]}
|
||||
</div>
|
||||
<span style={{ fontSize: 10.5, color: 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-mono)' }}>
|
||||
{formatDuration(session.durationMs ?? 0, t)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
||||
<span style={{ fontSize: 10.5, color: 'var(--ol-ink-4)', fontFamily: 'var(--ol-font-mono)' }}>
|
||||
{formatDuration(session.durationMs ?? 0, t)}
|
||||
</span>
|
||||
<Btn
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={copied ? 'check' : 'copy'}
|
||||
onClick={() => void onCopy()}
|
||||
style={{ padding: '3px 8px' }}
|
||||
>
|
||||
{copied ? t('common.copied') : t('common.copy')}
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user