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:
authored and GitHub committed 2026-07-12 21:42:50 +08:00
1 parent 9ed34dabea
commit 695813074f
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 {
+9 -2
View File
@@ -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);
+29 -3
View File
@@ -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>
);
}
+1 -1
View File
@@ -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;