Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3007,10 +3007,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。
Expand Down
37 changes: 24 additions & 13 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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())
}
}
Expand Down Expand Up @@ -2104,6 +2106,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();
Expand All @@ -2126,7 +2139,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(());
}
};
Expand Down Expand Up @@ -2268,7 +2283,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:#}");
Expand Down Expand Up @@ -2306,7 +2321,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:#}");
Expand Down Expand Up @@ -2375,7 +2390,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:#}");
Expand Down Expand Up @@ -2405,11 +2420,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(());
}

Expand Down Expand Up @@ -2695,7 +2706,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;
Expand All @@ -2708,6 +2718,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(());
}

Expand Down Expand Up @@ -2968,7 +2979,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);
Expand Down
58 changes: 58 additions & 0 deletions openless-all/app/src-tauri/src/coordinator_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 9 additions & 2 deletions openless-all/app/src/pages/History.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 29 additions & 3 deletions openless-all/app/src/pages/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}>
Expand All @@ -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>
);
}
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src/pages/_atoms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading