From 3ce107e85e9c251234d561a030d82b1010d0f34a Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 2 Oct 2026 12:49:43 +0800 Subject: [PATCH 01/20] fix(linux-egui): restore settings and QA panel actions --- docs/architecture.md | 6 +- openless-all/app/linux-egui/src/lib.rs | 4 +- openless-all/app/linux-egui/src/main.rs | 877 +++++++++++------- openless-all/app/linux-egui/src/popup.rs | 12 + openless-all/app/linux-egui/src/settings.rs | 30 + .../app/linux-egui/src/ui/frontend/mod.rs | 42 + .../app/linux-egui/src/ui/frontend/siri_gl.rs | 9 +- 7 files changed, 650 insertions(+), 330 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 04306b686..27346a9e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # OpenLess 2.0 架构 -状态:canonical,当前实现说明;更新:2026-09-30。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 +状态:canonical,当前实现说明;更新:2026-10-02。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 ## 1. 分层与工作区 @@ -75,6 +75,10 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装 主窗口默认逻辑尺寸为 1300×835,允许用户调整;macOS 原生窗口按钮左侧和顶部均留出 16px,前端保留 44px 拖动区。桌面侧栏宽 226px,主内容从版本行下方开始,设置面板单独限制高度并在内部滚动。 +Linux 主窗口和 QA 面板由常驻宿主拉起的独立进程承载。QA 空态没有 Core 会话,宿主与面板统一使用空态标识校验操作;文字发送通过 `submit_text_in_context` 在 Core 中检查当前上下文并创建新轮次。早期调用失败回传到对应面板,迟到的失败不能覆盖新轮次。弹窗消息使用宿主递增的序号,不依赖 Core 是否发布了新事件;显式收起 QA 结束窗口进程,隐藏时的 QA 快照只更新宿主状态,下一次打开再创建窗口。没有托盘时,正常关闭主窗口仍会退出应用,托盘注册失败的原因写入启动日志。 + +Linux 的 Siri 波形包络在分数次幂运算前限制为非负值,避免浮点端点误差生成 NaN。设置和胶囊绘制回归须经过 egui tessellation 并检查生成顶点的有限性,文字快照不能单独证明绘制成功。 + Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺寸与点击范围在保存偏好时同步。胶囊按显示器工作区底部定位,避开未自动隐藏的 Dock/任务栏;可见期间重新检查工作区。带正文的浮窗使用不透明底色,聊天面板另叠加细噪点纹理,圆角外部仍保留透明区域。 思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入由会话内串行 worker 维护原控件和累计 UTF-16 终点;每批发送后不再等待 AX 长确认,finish/cancel 在已接收写入之后等待最终屏障,再恢复输入源。AX 仅读选区范围,不读正文;采用 250 ms 无进展预算和 10 秒总预算。不可读、提交型 Return 或预算耗尽时明确降级到按键已发送语义,不重新粘贴已发送文字。目标应用的实际输入表现仍需设备验收。 diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index 889bfeaa8..c0bac3b03 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -114,7 +114,9 @@ pub use resources::{ }; pub use runtime::{LinuxNativeRuntime, LinuxRuntimePumpResult}; pub use selection::LinuxSelectionRuntime; -pub use settings::{is_bare_modifier_binding, LinuxSettingsEffects, LinuxSettingsRuntime}; +pub use settings::{ + is_bare_modifier_binding, shortcut_display_label, LinuxSettingsEffects, LinuxSettingsRuntime, +}; pub use single_instance::{ LinuxLaunchIntent, SingleInstanceBroker, SingleInstanceGuard, SingleInstanceRole, }; diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 799a50609..5c44376e4 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -48,6 +48,10 @@ mod linux_app { enum UiResult { Message(String), + QaAction { + context: Option, + result: Result, + }, HistoryLoaded { generation: u64, revision: u64, @@ -585,6 +589,7 @@ mod linux_app { hotkey_dedupe: openless_linux_egui::HotkeyDeduplicator, /// 已在 60s 内重开过的面板次数(索引见 `popup_kind_index`)。 popup_restarts: [PopupRestartBudget; POPUP_KIND_COUNT], + popup_sequences: [u64; POPUP_KIND_COUNT], /// 最近一次下发给窗口/面板的热键配置;变了才重发。 hotkeys_sent: Option, /// 速记页快捷键卡片是否被收起(持久化在 linux-ui-state.json)。 @@ -742,6 +747,7 @@ mod linux_app { hotkey_dedupe: openless_linux_egui::HotkeyDeduplicator::default(), quick_note_shortcut_hidden, popup_restarts: [PopupRestartBudget::default(); POPUP_KIND_COUNT], + popup_sequences: [0; POPUP_KIND_COUNT], hotkeys_sent: None, pending_ui_pongs: Vec::new(), history, @@ -838,6 +844,7 @@ mod linux_app { hotkey_dedupe: openless_linux_egui::HotkeyDeduplicator::default(), quick_note_shortcut_hidden, popup_restarts: [PopupRestartBudget::default(); POPUP_KIND_COUNT], + popup_sequences: [0; POPUP_KIND_COUNT], hotkeys_sent: None, pending_ui_pongs: Vec::new(), history: HistoryCache::default(), @@ -1025,8 +1032,12 @@ mod linux_app { } } - fn send_popup(&mut self, kind: PopupKind, message: HostToPopup) { + fn send_popup(&mut self, kind: PopupKind, mut message: HostToPopup) { let lang = self.lang; + // Window actions and failures can occur without a new Core event. + let sequence = &mut self.popup_sequences[popup_kind_index(kind)]; + *sequence = sequence.saturating_add(1); + message.set_sequence(*sequence); // 记录胶囊当前承载的会话:兜底收起要靠它判断「会话是否还在快照里」。 if let HostToPopup::Capsule { session_id, .. } = &message { self.capsule_session = Some(session_id.clone()); @@ -1051,6 +1062,14 @@ mod linux_app { } fn hide_popup(&mut self, kind: PopupKind, session_id: String, sequence: u64) { + if kind == PopupKind::Qa { + // End the native window on both X11 and Wayland. Core owns the + // conversation; the next show creates a fresh protocol state. + if let Some(supervisor) = self.popup_slot(kind).take() { + let _ = supervisor.request_shutdown(); + } + return; + } self.send_popup( kind, HostToPopup::Hide { @@ -1103,6 +1122,7 @@ mod linux_app { fn expected_popup_session(&self, kind: PopupKind) -> Option { match kind { + PopupKind::Qa if !self.qa_visible && !self.polish_result_visible => None, PopupKind::Qa => self .qa_state .as_ref() @@ -1119,6 +1139,9 @@ mod linux_app { } fn show_qa_popup(&mut self) { + if !self.qa_visible { + return; + } self.ensure_popup(PopupKind::Qa); let Some(state) = self.qa_state.clone() else { return; @@ -1383,7 +1406,6 @@ mod linux_app { } fn poll_popup_supervisors(&mut self) { - let lang = self.lang; let mut events = Vec::new(); for kind in [PopupKind::Qa, PopupKind::Capsule] { if let Some(supervisor) = self.popup_slot(kind) { @@ -1393,172 +1415,182 @@ mod linux_app { } } for (kind, event) in events { - if let PopupSupervisorEvent::Message(message) = &event { - let Some(expected_session) = self.expected_popup_session(kind) else { - self.status = tr_l10n(lang, "popup.ignore_no_session").to_string(); - continue; - }; - if !self - .popup_action_guard - .accept(kind, message, &expected_session) - { - self.status = tr_l10n(lang, "popup.ignore_stale").to_string(); - continue; + self.apply_popup_event(kind, event); + } + } + + fn apply_popup_event(&mut self, kind: PopupKind, event: PopupSupervisorEvent) { + let lang = self.lang; + if let PopupSupervisorEvent::Message(message) = &event { + let Some(expected_session) = self.expected_popup_session(kind) else { + self.status = tr_l10n(lang, "popup.ignore_no_session").to_string(); + return; + }; + if !self + .popup_action_guard + .accept(kind, message, &expected_session) + { + self.status = tr_l10n(lang, "popup.ignore_stale").to_string(); + return; + } + } + match event { + PopupSupervisorEvent::Message(PopupToHost::SubmitQa { + session_id, text, .. + }) if self.expected_popup_session(PopupKind::Qa).as_deref() + == Some(session_id.as_str()) => + { + if let Some(backend) = self.backend() { + let context = self + .qa_state + .as_ref() + .and_then(|state| state.session_id.clone()); + let expected = context + .as_deref() + .map(str::parse::) + .transpose() + .map(|id| id.map(openless_core::SessionId::from_uuid)); + self.spawn_qa_action(context, async move { + let expected = expected.map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::InvalidState, + error.to_string(), + ) + })?; + backend + .services() + .qa + .submit_text_in_context(text, expected) + .await?; + Ok(tr_l10n(lang, "qa.submitted").to_string()) + }); } } - match event { - PopupSupervisorEvent::Message(PopupToHost::SubmitQa { - session_id, - text, - .. - }) if self - .qa_state - .as_ref() - .and_then(|state| state.session_id.as_deref()) + PopupSupervisorEvent::Message(PopupToHost::ToggleQaRecording { + session_id, + .. + }) if self.expected_popup_session(PopupKind::Qa).as_deref() + == Some(session_id.as_str()) => + { + if let Some(backend) = self.backend() { + let context = self + .qa_state + .as_ref() + .and_then(|state| state.session_id.clone()); + self.spawn_qa_action(context, async move { + backend.services().qa.toggle_recording().await?; + Ok(tr_l10n(lang, "qa.recording_updated").to_string()) + }); + } + } + PopupSupervisorEvent::Message(PopupToHost::DismissQa { session_id, .. }) + if self.expected_popup_session(PopupKind::Qa).as_deref() == Some(session_id.as_str()) => - { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.services().qa.submit_text(text).await?; - Ok(tr_l10n(lang, "qa.submitted").to_string()) - }); - } + { + // 面板 ✕:立刻清掉宿主侧的可见标志,不等 Core 的 HideQa 回环 + // (那条被图钉门禁拦着,pinned 时不清 → 标志残留会让录音热键 + // 之后又把面板弹出来)。 + self.qa_visible = false; + self.polish_result_visible = false; + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().qa.dismiss().await?; + Ok(tr_l10n(lang, "qa.closed").to_string()) + }); + } + } + PopupSupervisorEvent::Message(PopupToHost::SetPinned { + session_id, + pinned, + .. + }) if self.expected_popup_session(PopupKind::Qa).as_deref() + == Some(session_id.as_str()) => + { + self.qa_pinned = pinned; + self.show_qa_popup(); + } + PopupSupervisorEvent::Message(PopupToHost::SetEditInstructionMode { + session_id, + enabled, + .. + }) if self.expected_popup_session(PopupKind::Qa).as_deref() + == Some(session_id.as_str()) => + { + if let Some(backend) = self.backend() { + let context = self + .qa_state + .as_ref() + .and_then(|state| state.session_id.clone()); + self.spawn_qa_action(context, async move { + backend + .services() + .qa + .set_edit_instruction_mode(enabled) + .await?; + Ok(String::new()) + }); } - PopupSupervisorEvent::Message(PopupToHost::ToggleQaRecording { - session_id, - .. - }) if self + } + PopupSupervisorEvent::Message(PopupToHost::RevertEdit { session_id, .. }) + if self .qa_state .as_ref() .and_then(|state| state.session_id.as_deref()) == Some(session_id.as_str()) => - { + { + if let Ok(qa_session) = session_id.parse::() { + let qa_session = openless_core::SessionId::from_uuid(qa_session); if let Some(backend) = self.backend() { + let lang = self.lang; self.spawn(async move { - backend.services().qa.toggle_recording().await?; - Ok(tr_l10n(lang, "qa.recording_updated").to_string()) + backend + .services() + .qa + .revert_edit_preview(qa_session) + .await?; + Ok(tr_l10n(lang, "selection.reverted").to_string()) }); } } - PopupSupervisorEvent::Message(PopupToHost::DismissQa { - session_id, .. - }) if self + } + PopupSupervisorEvent::Message(PopupToHost::ApplyEdit { session_id, .. }) + if self .qa_state .as_ref() .and_then(|state| state.session_id.as_deref()) == Some(session_id.as_str()) => - { - // 面板 ✕:立刻清掉宿主侧的可见标志,不等 Core 的 HideQa 回环 - // (那条被图钉门禁拦着,pinned 时不清 → 标志残留会让录音热键 - // 之后又把面板弹出来)。 - self.qa_visible = false; - self.polish_result_visible = false; + { + if let Ok(qa_session) = session_id.parse::() { + let qa_session = openless_core::SessionId::from_uuid(qa_session); if let Some(backend) = self.backend() { - self.spawn(async move { - backend.services().qa.dismiss().await?; - Ok(tr_l10n(lang, "qa.closed").to_string()) - }); + self.spawn_qa_edit_apply(backend, qa_session); } } - PopupSupervisorEvent::Message(PopupToHost::SetPinned { - session_id, - pinned, - .. - }) if self - .qa_state - .as_ref() - .and_then(|state| state.session_id.as_deref()) - == Some(session_id.as_str()) => - { - self.qa_pinned = pinned; - self.show_qa_popup(); - } - PopupSupervisorEvent::Message(PopupToHost::SetEditInstructionMode { - session_id, - enabled, - .. - }) if self - .qa_state - .as_ref() - .and_then(|state| state.session_id.as_deref()) - == Some(session_id.as_str()) => - { + } + PopupSupervisorEvent::Message(PopupToHost::ConfirmPolish { + session_id, + text, + .. + }) => match session_id.parse::() { + Ok(session_id) => { + // 润色结束:选区助手面板回到提问模式(同一个弹窗)。 + self.polish_result_visible = false; + let session_id = openless_core::SessionId::from_uuid(session_id); if let Some(backend) = self.backend() { self.spawn(async move { backend .services() - .qa - .set_edit_instruction_mode(enabled) + .selection + .confirm(session_id, Some(text)) .await?; - Ok(String::new()) + Ok(tr_l10n(lang, "selection.replaced").to_string()) }); } } - PopupSupervisorEvent::Message(PopupToHost::RevertEdit { - session_id, .. - }) if self - .qa_state - .as_ref() - .and_then(|state| state.session_id.as_deref()) - == Some(session_id.as_str()) => - { - if let Ok(qa_session) = session_id.parse::() { - let qa_session = openless_core::SessionId::from_uuid(qa_session); - if let Some(backend) = self.backend() { - let lang = self.lang; - self.spawn(async move { - backend - .services() - .qa - .revert_edit_preview(qa_session) - .await?; - Ok(tr_l10n(lang, "selection.reverted").to_string()) - }); - } - } - } - PopupSupervisorEvent::Message(PopupToHost::ApplyEdit { - session_id, .. - }) if self - .qa_state - .as_ref() - .and_then(|state| state.session_id.as_deref()) - == Some(session_id.as_str()) => - { - if let Ok(qa_session) = session_id.parse::() { - let qa_session = openless_core::SessionId::from_uuid(qa_session); - if let Some(backend) = self.backend() { - self.spawn_qa_edit_apply(backend, qa_session); - } - } - } - PopupSupervisorEvent::Message(PopupToHost::ConfirmPolish { - session_id, - text, - .. - }) => match session_id.parse::() { - Ok(session_id) => { - // 润色结束:选区助手面板回到提问模式(同一个弹窗)。 - self.polish_result_visible = false; - let session_id = openless_core::SessionId::from_uuid(session_id); - if let Some(backend) = self.backend() { - self.spawn(async move { - backend - .services() - .selection - .confirm(session_id, Some(text)) - .await?; - Ok(tr_l10n(lang, "selection.replaced").to_string()) - }); - } - } - Err(error) => { - self.status = fmt_l10n(lang, "popup.session_invalid", &[&error]) - } - }, - PopupSupervisorEvent::Message(PopupToHost::CancelPolish { - session_id, .. - }) => match session_id.parse::() { + Err(error) => self.status = fmt_l10n(lang, "popup.session_invalid", &[&error]), + }, + PopupSupervisorEvent::Message(PopupToHost::CancelPolish { session_id, .. }) => { + match session_id.parse::() { Ok(session_id) => { // 取消润色:同样退出润色模式。 self.polish_result_visible = false; @@ -1577,171 +1609,203 @@ mod linux_app { Err(error) => { self.status = fmt_l10n(lang, "popup.session_invalid", &[&error]) } - }, - PopupSupervisorEvent::Message(PopupToHost::Ready { .. }) => match kind { - PopupKind::Qa => { - // 面板自己也要匹配本地热键(面板有焦点时插件收不到按键), - // 所以先下发绑定,再送内容。 - self.send_popup_hotkeys(kind); - // 选区助手面板既可能是提问模式,也可能是润色结果模式。 - if self.polish_result_visible { - self.show_selection_popup(); - } else { - self.show_qa_popup(); - } - } - PopupKind::Capsule => self.show_capsule_popup(), - PopupKind::LessComputer => { - self.send_popup_hotkeys(kind); - self.show_less_computer_popup(); - } - }, - PopupSupervisorEvent::Message(PopupToHost::Hotkey { edge, .. }) => { - log::info!("[hotkey] local edge from the {kind:?} panel: {edge:?}"); - self.pending_local_hotkeys - .push((std::time::Instant::now(), edge)); - } - PopupSupervisorEvent::Message(PopupToHost::DismissCapsule { .. }) => { - if let Some(snapshot) = self.snapshot.as_mut() { - snapshot.dictation.message = None; - } } - PopupSupervisorEvent::Message(PopupToHost::CancelDictation { .. }) => { - // 胶囊 ✕:放弃这次听写。 - let session = self - .snapshot - .as_ref() - .and_then(|snapshot| snapshot.dictation.session_id); - if let (Some(backend), Some(session)) = (self.backend(), session) { - self.spawn(async move { - // 连点两次 ✕、会话已收尾之类的错误是预期内的, - // 归一掉,不要再弹成失败。 - normalize_stop_result( - backend.cancel_dictation(Some(session)).await, - )?; - Ok(String::new()) - }); + } + PopupSupervisorEvent::Message(PopupToHost::Ready { .. }) => match kind { + PopupKind::Qa => { + // 面板自己也要匹配本地热键(面板有焦点时插件收不到按键), + // 所以先下发绑定,再送内容。 + self.send_popup_hotkeys(kind); + // 选区助手面板既可能是提问模式,也可能是润色结果模式。 + if self.polish_result_visible { + self.show_selection_popup(); + } else { + self.show_qa_popup(); } } - PopupSupervisorEvent::Message(PopupToHost::StopDictation { .. }) => { - // 胶囊 ✓:结束录音并落字。 - let session = self - .snapshot - .as_ref() - .and_then(|snapshot| snapshot.dictation.session_id); - if let (Some(backend), Some(session)) = (self.backend(), session) { - self.spawn(async move { - // 没说话(空音频 → InvalidArgument)也是预期内的终态: - // 胶囊会显示本地化文案并自动收起,这里不再报错误。 - normalize_stop_result( - backend.stop_dictation_session(session).await, - )?; - Ok(String::new()) - }); - } + PopupKind::Capsule => self.show_capsule_popup(), + PopupKind::LessComputer => { + self.send_popup_hotkeys(kind); + self.show_less_computer_popup(); } - PopupSupervisorEvent::Message(PopupToHost::SubmitLessComputer { - session_id, - text, - .. - }) if self - .less_computer_session - .map(|session| session.to_string()) - .as_deref() - == Some(session_id.as_str()) => - { - if let Some(backend) = self.backend() { - // Core 自己解析 provider / 模型 / 权限 / workdir; - // 宿主只负责把用户文本交给它(Tauri `lessComputerSubmitText`)。 - self.spawn(async move { - backend.submit_less_computer(text).await?; - Ok(String::new()) - }); - } + }, + PopupSupervisorEvent::Message(PopupToHost::Hotkey { edge, .. }) => { + log::info!("[hotkey] local edge from the {kind:?} panel: {edge:?}"); + self.pending_local_hotkeys + .push((std::time::Instant::now(), edge)); + } + PopupSupervisorEvent::Message(PopupToHost::DismissCapsule { .. }) => { + if let Some(snapshot) = self.snapshot.as_mut() { + snapshot.dictation.message = None; } - PopupSupervisorEvent::Message(PopupToHost::ApproveLessComputer { - token, - approved, - .. - }) => { - let backend = self.backend(); + } + PopupSupervisorEvent::Message(PopupToHost::CancelDictation { .. }) => { + // 胶囊 ✕:放弃这次听写。 + let session = self + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.dictation.session_id); + if let (Some(backend), Some(session)) = (self.backend(), session) { self.spawn(async move { - if let Some(backend) = backend { - backend - .services() - .less_computer - .approve(token, approved) - .await?; - } + // 连点两次 ✕、会话已收尾之类的错误是预期内的, + // 归一掉,不要再弹成失败。 + normalize_stop_result(backend.cancel_dictation(Some(session)).await)?; Ok(String::new()) }); } - PopupSupervisorEvent::Message(PopupToHost::CancelLessComputer { .. }) => { - let session = self.less_computer_session; - let backend = self.backend(); + } + PopupSupervisorEvent::Message(PopupToHost::StopDictation { .. }) => { + // 胶囊 ✓:结束录音并落字。 + let session = self + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.dictation.session_id); + if let (Some(backend), Some(session)) = (self.backend(), session) { self.spawn(async move { - if let Some(backend) = backend { - backend.cancel_less_computer(session).await?; - } + // 没说话(空音频 → InvalidArgument)也是预期内的终态: + // 胶囊会显示本地化文案并自动收起,这里不再报错误。 + normalize_stop_result(backend.stop_dictation_session(session).await)?; Ok(String::new()) }); } - PopupSupervisorEvent::Message(PopupToHost::DismissLessComputer { .. }) => { - // 只收起面板:已完成的一轮保留在宿主状态里,下次打开仍在。 - self.hide_less_computer_popup(); - } - PopupSupervisorEvent::Message( - PopupToHost::SubmitQa { .. } - | PopupToHost::ToggleQaRecording { .. } - | PopupToHost::DismissQa { .. } - | PopupToHost::SetPinned { .. } - | PopupToHost::SetEditInstructionMode { .. } - | PopupToHost::ApplyEdit { .. } - | PopupToHost::RevertEdit { .. } - | PopupToHost::SubmitLessComputer { .. }, - ) => { - self.status = tr_l10n(lang, "popup.ignore_late_qa").to_string(); - } - PopupSupervisorEvent::ProtocolError(error) => { - self.status = fmt_l10n(lang, "popup.protocol_error", &[&error]); - } - PopupSupervisorEvent::SpawnFailed(error) => { - self.status = fmt_l10n(lang, "popup.spawn_failed", &[&error]); - *self.popup_slot(kind) = None; + } + PopupSupervisorEvent::Message(PopupToHost::SubmitLessComputer { + session_id, + text, + .. + }) if self + .less_computer_session + .map(|session| session.to_string()) + .as_deref() + == Some(session_id.as_str()) => + { + if let Some(backend) = self.backend() { + // Core 自己解析 provider / 模型 / 权限 / workdir; + // 宿主只负责把用户文本交给它(Tauri `lessComputerSubmitText`)。 + self.spawn(async move { + backend.submit_less_computer(text).await?; + Ok(String::new()) + }); } - PopupSupervisorEvent::Exited { code, crashed } => { - if crashed { - self.status = fmt_l10n(lang, "popup.exited", &[&format!("{code:?}")]); + } + PopupSupervisorEvent::Message(PopupToHost::ApproveLessComputer { + token, + approved, + .. + }) => { + let backend = self.backend(); + self.spawn(async move { + if let Some(backend) = backend { + backend + .services() + .less_computer + .approve(token, approved) + .await?; } - *self.popup_slot(kind) = None; - if !crashed { - // 面板进程正常退出(用户/合成器关掉窗口等):宿主这边的 - // 可见标志必须跟着清,否则「录音热键」会被错当成 - // 「向选区助手提问」,把面板又弹出来。 + Ok(String::new()) + }); + } + PopupSupervisorEvent::Message(PopupToHost::CancelLessComputer { .. }) => { + let session = self.less_computer_session; + let backend = self.backend(); + self.spawn(async move { + if let Some(backend) = backend { + backend.cancel_less_computer(session).await?; + } + Ok(String::new()) + }); + } + PopupSupervisorEvent::Message(PopupToHost::DismissLessComputer { .. }) => { + // 只收起面板:已完成的一轮保留在宿主状态里,下次打开仍在。 + self.hide_less_computer_popup(); + } + PopupSupervisorEvent::Message( + PopupToHost::SubmitQa { .. } + | PopupToHost::ToggleQaRecording { .. } + | PopupToHost::DismissQa { .. } + | PopupToHost::SetPinned { .. } + | PopupToHost::SetEditInstructionMode { .. } + | PopupToHost::ApplyEdit { .. } + | PopupToHost::RevertEdit { .. } + | PopupToHost::SubmitLessComputer { .. }, + ) => { + self.status = tr_l10n(lang, "popup.ignore_late_qa").to_string(); + } + PopupSupervisorEvent::ProtocolError(error) => { + self.status = fmt_l10n(lang, "popup.protocol_error", &[&error]); + } + PopupSupervisorEvent::SpawnFailed(error) => { + self.status = fmt_l10n(lang, "popup.spawn_failed", &[&error]); + *self.popup_slot(kind) = None; + } + PopupSupervisorEvent::Exited { code, crashed } => { + if crashed { + self.status = fmt_l10n(lang, "popup.exited", &[&format!("{code:?}")]); + } + *self.popup_slot(kind) = None; + if !crashed { + // 面板进程正常退出(用户/合成器关掉窗口等):宿主这边的 + // 可见标志必须跟着清,否则「录音热键」会被错当成 + // 「向选区助手提问」,把面板又弹出来。 + self.forget_qa_panel_visibility(kind); + } + if crashed { + // 必崩的面板不做无限重开:预算内重开,超了就停手(否则 + // 用户看到的是“弹窗一直反复弹出”)。 + let restarts = &mut self.popup_restarts[popup_kind_index(kind)]; + if !restarts.allow(std::time::Instant::now()) { + log::warn!( + "[popup] {kind:?} crashed {POPUP_RESTART_LIMIT} times within {}s; not restarting", + POPUP_RESTART_WINDOW.as_secs() + ); self.forget_qa_panel_visibility(kind); + return; } - if crashed { - // 必崩的面板不做无限重开:预算内重开,超了就停手(否则 - // 用户看到的是“弹窗一直反复弹出”)。 - let restarts = &mut self.popup_restarts[popup_kind_index(kind)]; - if !restarts.allow(std::time::Instant::now()) { - log::warn!( - "[popup] {kind:?} crashed {POPUP_RESTART_LIMIT} times within {}s; not restarting", - POPUP_RESTART_WINDOW.as_secs() - ); - self.forget_qa_panel_visibility(kind); - return; + match kind { + PopupKind::Qa if self.qa_visible => self.show_qa_popup(), + PopupKind::Capsule + if self.snapshot.as_ref().is_some_and(|snapshot| { + snapshot.dictation.phase != DictationPhase::Idle + }) => + { + self.show_capsule_popup(); } - match kind { - PopupKind::Qa if self.qa_visible => self.show_qa_popup(), - PopupKind::Capsule - if self.snapshot.as_ref().is_some_and(|snapshot| { - snapshot.dictation.phase != DictationPhase::Idle - }) => - { - self.show_capsule_popup(); - } - _ => {} + _ => {} + } + } + } + } + } + + fn spawn_qa_action(&self, context: Option, future: F) + where + F: Future> + Send + 'static, + { + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = future.await.map_err(|error| error.to_string()); + let _ = tx.send(UiResult::QaAction { context, result }); + }); + } + + fn apply_qa_action_result( + &mut self, + context: Option, + result: Result, + ) { + match result { + Ok(message) => self.status = message, + Err(error) => { + self.status = error.clone(); + if self.qa_visible { + if let Some(state) = self + .qa_state + .as_mut() + .filter(|state| state.session_id == context) + { + state.error = Some(error); + if self.qa_popup.is_some() { + self.show_qa_popup(); } } } @@ -2574,36 +2638,8 @@ mod linux_app { { self.qa_state = Some(state); } - if let Some(state) = self.qa_state.clone() { - let session_id = - state.session_id.clone().unwrap_or_else(|| "qa".to_string()); - self.send_popup( - PopupKind::Qa, - HostToPopup::QaSnapshot { - version: POPUP_PROTOCOL_VERSION, - session_id, - sequence: event_sequence.saturating_mul(2), - phase: format!("{:?}", state.kind), - messages: state - .messages - .unwrap_or_default() - .into_iter() - .map(|message| PopupChatMessage { - role: message.role, - content: message.content, - selection_text: message.selection_text, - }) - .collect(), - selection_preview: state.selection_preview, - streaming_answer: state.chunk.unwrap_or_default(), - error: state.error, - edit_instruction_mode: self.qa_edit.instruction_mode, - edit_apply_available: self.qa_edit.apply_available, - edit_revert_available: self.qa_edit.revert_available, - pinned: self.qa_pinned, - viewer_login: self.marketplace_login(), - }, - ); + if self.qa_visible { + self.show_qa_popup(); } } BackendEventKind::SelectionStateChanged(snapshot) => { @@ -3282,6 +3318,9 @@ mod linux_app { } } UiResult::Message(message) => self.status = message, + UiResult::QaAction { context, result } => { + self.apply_qa_action_result(context, result) + } UiResult::HistoryRepolish { id, result } => { self.frontend_vm.history_repolish_running = false; match result { @@ -3812,35 +3851,37 @@ mod linux_app { .map(|preferences| preferences.theme_mode) .unwrap_or_default(); if let Some(prefs) = &self.preferences { - vm.dictation_hotkey = prefs.dictation_hotkey.display_label(); + vm.dictation_hotkey = + openless_linux_egui::shortcut_display_label(&prefs.dictation_hotkey); vm.qa_hotkey = prefs .qa_hotkey .as_ref() - .map(|binding| binding.display_label()) + .map(openless_linux_egui::shortcut_display_label) .unwrap_or_default(); vm.quick_note_hotkey = prefs .quick_note_hotkey .as_ref() - .map(|binding| binding.display_label()) + .map(openless_linux_egui::shortcut_display_label) .unwrap_or_default(); vm.quick_note_shortcut_hidden = self.quick_note_shortcut_hidden; - vm.translation_hotkey = prefs.translation_hotkey.display_label(); + vm.translation_hotkey = + openless_linux_egui::shortcut_display_label(&prefs.translation_hotkey); // 这几行一直在界面上,但宿主以前只填了前四个:没填的行无论 Core 里 // 有没有绑定都显示成空键帽,看起来像「未设置」。一律从偏好取真值。 vm.switch_style_hotkey = prefs .switch_style_hotkey .as_ref() - .map(|binding| binding.display_label()) + .map(openless_linux_egui::shortcut_display_label) .unwrap_or_default(); vm.open_app_hotkey = prefs .open_app_hotkey .as_ref() - .map(|binding| binding.display_label()) + .map(openless_linux_egui::shortcut_display_label) .unwrap_or_default(); vm.coding_agent_hotkey = prefs .coding_agent_voice_hotkey .as_ref() - .map(|binding| binding.display_label()) + .map(openless_linux_egui::shortcut_display_label) .unwrap_or_default(); } @@ -4022,7 +4063,7 @@ mod linux_app { vm.selection_polish_hotkey = prefs .selection_polish_hotkey .as_ref() - .map(|binding| binding.display_label()) + .map(openless_linux_egui::shortcut_display_label) .unwrap_or_default(); vm.qa_save_history = prefs.qa_save_history; // 风格包直选:只展示已经录过的快捷键(录制器尚未实现)。 @@ -4039,7 +4080,7 @@ mod linux_app { name: pack .map(|pack| pack.name.clone()) .unwrap_or_else(|| entry.pack_id.clone()), - hotkey: entry.binding.display_label(), + hotkey: openless_linux_egui::shortcut_display_label(&entry.binding), } }) .collect(); @@ -7589,13 +7630,24 @@ Internal flags (set by OpenLess itself, not for regular use): BrokerAcquisition::Primary(broker) => Arc::new(broker), BrokerAcquisition::Forwarded => return Ok(()), }; - let tray = openless_linux_egui::LinuxTray::start().ok(); + let (tray, tray_error) = match openless_linux_egui::LinuxTray::start() { + Ok(tray) => (Some(tray), None), + Err(error) => { + eprintln!("OpenLess tray unavailable: {error}"); + (None, Some(error.to_string())) + } + }; let tray_available = tray.is_some(); let native = (|| { let mut config = backend_config()?; if let Err(error) = openless_linux_egui::init_file_logger(&config.data_dir) { eprintln!("OpenLess file logger unavailable: {error}"); } + if let Some(error) = tray_error { + log::warn!( + "OpenLess tray unavailable: {error}; closing the main window will exit the app" + ); + } ensure_fcitx5_ready(&config)?; let hotkeys = Some(Fcitx5HotkeyListener::start().map_err(|error| error.to_string())?); config.platform = LinuxCapabilitySnapshot::detect(tray_available).capabilities; @@ -7637,6 +7689,171 @@ Internal flags (set by OpenLess itself, not for regular use): mod tests { use super::*; + struct FailingQaRuntime; + + impl openless_core::QaRuntimeAdapter for FailingQaRuntime { + fn prepare_text( + &self, + _: openless_core::SessionId, + text: String, + ) -> futures_util::future::BoxFuture< + 'static, + Result, + > { + Box::pin(async move { + Ok(openless_core::QaInput { + text, + selection_text: None, + selection_source_app: None, + }) + }) + } + fn start_recording( + &self, + _: openless_core::SessionId, + _: Arc, + ) -> futures_util::future::BoxFuture<'static, Result<(), BackendError>> { + panic!("text submission must not start recording") + } + fn finish_recording( + &self, + _: openless_core::SessionId, + ) -> futures_util::future::BoxFuture< + 'static, + Result, + > { + panic!("text submission must not finish recording") + } + fn answer( + &self, + _: openless_core::QaTurnRequest, + _: Arc, + ) -> futures_util::future::BoxFuture< + 'static, + Result, + > { + Box::pin(async { + Err(BackendError::new( + openless_core::BackendErrorCode::InvalidArgument, + "fixture provider unavailable", + )) + }) + } + fn cancel( + &self, + _: openless_core::SessionId, + ) -> futures_util::future::BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Ok(()) }) + } + } + + #[test] + fn an_idle_popup_submission_reaches_core_and_displays_failure() { + let data_dir = tempfile::tempdir().unwrap(); + let tokio = Arc::new(tokio::runtime::Runtime::new().unwrap()); + let host_actions = Arc::new(openless_linux_egui::LinuxHostActions::default()); + let mut dependencies = openless_core::BackendDependencies::unsupported(); + dependencies.host_actions = host_actions.clone(); + dependencies.credential_store = + Arc::new(openless_core::InMemoryCredentialStore::default()); + dependencies.qa_runtime = Some(Arc::new(FailingQaRuntime)); + dependencies.services.remote_input = Arc::new( + openless_core::RemoteInputService::new( + Arc::new(openless_core::testing::RecordingRemoteInputRuntime::default()), + 8443, + "en", + ) + .unwrap(), + ); + let backend = Arc::new( + openless_core::OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.path().to_path_buf(), + ..Default::default() + }, + dependencies, + ) + .unwrap(), + ); + let runtime = openless_linux_egui::LinuxBackendRuntime { + backend, + host_actions, + settings_runtime: Arc::new(openless_core::settings::NoopSettingsRuntime), + }; + let backend = runtime.backend.clone(); + let native = tokio + .block_on(LinuxNativeRuntime::start(runtime, None, None)) + .unwrap(); + let mut app = OpenLessEguiApp::new(tokio.clone(), Ok(native), None, true); + app.qa_visible = true; + app.qa_state = Some(QaStateEvent::simple(QaStateKind::Idle)); + let submission = PopupToHost::SubmitQa { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa".into(), + sequence: 1, + text: "probe hello".into(), + }; + app.apply_popup_event( + PopupKind::Qa, + PopupSupervisorEvent::Message(submission.clone()), + ); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if let Ok(UiResult::QaAction { context, result }) = app.rx.try_recv() { + assert!(result.is_err(), "the fixture rejects the provider request"); + app.apply_qa_action_result(context, result); + break; + } + assert!( + std::time::Instant::now() < deadline, + "idle submission was silently discarded" + ); + std::thread::sleep(Duration::from_millis(10)); + } + let snapshot = tokio.block_on(backend.services().qa.snapshot()).unwrap(); + assert!( + snapshot.session_id.is_some(), + "Core must have claimed a QA turn" + ); + assert_eq!(snapshot.phase, openless_core::QaPhase::Failed); + assert!(app.qa_state.as_ref().unwrap().error.is_some()); + app.apply_popup_event(PopupKind::Qa, PopupSupervisorEvent::Message(submission)); + assert_eq!(app.status, tr_l10n(app.lang, "popup.ignore_stale")); + tokio.block_on(backend.shutdown()).unwrap(); + } + + #[test] + fn closing_qa_drops_its_window_and_late_idle_does_not_reopen_it() { + let mut app = fixture_app(true); + app.qa_visible = true; + app.qa_state = Some(QaStateEvent::simple(QaStateKind::Idle)); + app.qa_popup = Some(PopupSupervisor::spawn_command( + app.tokio.handle(), + tokio::process::Command::new("/bin/cat"), + )); + app.toggle_qa_panel(); + assert!(!app.qa_visible); + assert!(app.qa_popup.is_none()); + app.apply_event(BackendEvent { + sequence: 1, + session_id: None, + kind: BackendEventKind::QaState(QaStateEvent::simple(QaStateKind::Idle)), + }); + assert!(app.qa_popup.is_none()); + assert!(app.expected_popup_session(PopupKind::Qa).is_none()); + } + + #[test] + fn late_qa_action_failures_do_not_overwrite_a_new_turn() { + let mut app = fixture_app(true); + let mut state = QaStateEvent::simple(QaStateKind::Thinking); + state.session_id = Some(openless_core::SessionId::new().to_string()); + app.qa_state = Some(state); + app.qa_visible = true; + app.apply_qa_action_result(None, Err("old failure".into())); + assert!(app.qa_state.as_ref().unwrap().error.is_none()); + } + #[test] fn style_example_changes_mark_the_editor_dirty() { let saved = openless_core::StylePack::default(); @@ -8017,7 +8234,12 @@ Internal flags (set by OpenLess itself, not for regular use): #[test] fn shortcut_rows_show_the_bindings_core_holds() { let mut app = fixture_app(true); + app.lang = Lang::En; app.preferences = Some(UserPreferences { + dictation_hotkey: openless_core::shared_types::ShortcutBinding { + primary: "RightOption".into(), + modifiers: Vec::new(), + }, switch_style_hotkey: Some(openless_core::shared_types::ShortcutBinding { primary: "1".into(), modifiers: vec!["ctrl".into(), "shift".into()], @@ -8034,6 +8256,7 @@ Internal flags (set by OpenLess itself, not for regular use): }); app.sync_view_model(); let vm = &app.frontend_vm; + assert_eq!(vm.dictation_hotkey, "Right Alt"); for (label, value) in [ ("switch_style", &vm.switch_style_hotkey), ("open_app", &vm.open_app_hotkey), diff --git a/openless-all/app/linux-egui/src/popup.rs b/openless-all/app/linux-egui/src/popup.rs index 22f71097c..ba66b379d 100644 --- a/openless-all/app/linux-egui/src/popup.rs +++ b/openless-all/app/linux-egui/src/popup.rs @@ -169,6 +169,18 @@ pub enum HostToPopup { } impl HostToPopup { + pub fn set_sequence(&mut self, value: u64) { + match self { + Self::PolishPreview { sequence, .. } + | Self::QaSnapshot { sequence, .. } + | Self::Capsule { sequence, .. } + | Self::LessComputer { sequence, .. } + | Self::Hotkeys { sequence, .. } + | Self::Hide { sequence, .. } + | Self::Shutdown { sequence, .. } => *sequence = value, + } + } + pub fn version(&self) -> u16 { match self { Self::PolishPreview { version, .. } diff --git a/openless-all/app/linux-egui/src/settings.rs b/openless-all/app/linux-egui/src/settings.rs index 0f5e6b951..63ab72caa 100644 --- a/openless-all/app/linux-egui/src/settings.rs +++ b/openless-all/app/linux-egui/src/settings.rs @@ -9,6 +9,36 @@ use openless_core::{ use crate::LinuxCredentialStore; +/// Render legacy modifier names as the keys registered on Linux. +pub fn shortcut_display_label(binding: &ShortcutBinding) -> String { + let primary = match legacy_modifier_trigger(binding) { + Some(HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt) => "Right Alt", + Some(HotkeyTrigger::LeftOption) => "Left Alt", + Some(HotkeyTrigger::RightControl) => "Right Ctrl", + Some(HotkeyTrigger::LeftControl) => "Left Ctrl", + Some(HotkeyTrigger::RightCommand) => "Right Super", + Some(HotkeyTrigger::LeftCommand) => "Left Super", + Some(HotkeyTrigger::LeftShift) => "Left Shift", + Some(HotkeyTrigger::RightShift) => "Right Shift", + _ => match binding.primary.trim() { + "AltRight" => "Right Alt", + "AltLeft" => "Left Alt", + "ControlRight" => "Right Ctrl", + "ControlLeft" => "Left Ctrl", + "ShiftRight" => "Right Shift", + "ShiftLeft" => "Left Shift", + "MetaRight" | "OSRight" => "Right Super", + "MetaLeft" | "OSLeft" => "Left Super", + _ => return binding.display_label(), + }, + }; + ShortcutBinding { + primary: primary.to_string(), + modifiers: binding.modifiers.clone(), + } + .display_label() +} + /// Executes Linux-only settings effects from an explicit Core target. /// /// Implementations must not read or write `UserPreferences`. This narrow seam diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index 88a17c578..d33fd0b72 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -617,6 +617,48 @@ mod tests { ); } + #[test] + fn settings_modal_tessellates_after_opening_and_resizing() { + use super::view_model::SettingsSection; + for section in [ + SettingsSection::General, + SettingsSection::Shortcuts, + SettingsSection::Services, + SettingsSection::Appearance, + SettingsSection::Privacy, + SettingsSection::Advanced, + SettingsSection::About, + ] { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::En, + settings_open: true, + settings_section: section, + ..Default::default() + }; + for size in [egui::vec2(1300.0, 835.0), egui::vec2(960.0, 640.0)] { + for _ in 0..3 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + let output = end_pass(&ctx); + let primitives = ctx.tessellate(output.shapes, output.pixels_per_point); + assert!(!primitives.is_empty(), "{section:?} must paint a frame"); + for primitive in primitives { + if let egui::epaint::Primitive::Mesh(mesh) = primitive.primitive { + assert!( + mesh.vertices.iter().all(|vertex| vertex.pos.is_finite()), + "{section:?} produced non-finite vertices at {size:?}" + ); + } + } + } + } + } + } + #[test] fn settings_overlay_opens_from_every_page() { // Regression: Overview / Style / History returned early from `render`, so diff --git a/openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs b/openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs index 1c34512cd..cea96914e 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs @@ -192,7 +192,8 @@ pub fn paint(ui: &egui::Ui, rect: egui::Rect, glow: SiriGlow) -> bool { let points = (0..=48) .map(|step| { let t = step as f32 / 48.0; - let envelope = (std::f32::consts::PI * t).sin().powf(0.7); + // Rounding at t == 1 can make sin(PI) slightly negative. + let envelope = (std::f32::consts::PI * t).sin().max(0.0).powf(0.7); let y = center_y + (glow.time * 2.1 + t * 10.0 + phase).sin() * amplitude * envelope; egui::pos2(rect.left() + rect.width() * t, y) @@ -283,6 +284,12 @@ mod tests { !output.shapes.is_empty(), "the glow must still reach the frame as CPU shapes" ); + let primitives = ctx.tessellate(output.shapes.clone(), output.pixels_per_point); + for primitive in primitives { + if let egui::epaint::Primitive::Mesh(mesh) = primitive.primitive { + assert!(mesh.vertices.iter().all(|vertex| vertex.pos.is_finite())); + } + } let callbacks = output .shapes .iter() From 23c4f796f7cfc959875e9227b4f45e183e7b2579 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 2 Oct 2026 13:38:48 +0800 Subject: [PATCH 02/20] fix(linux-egui): scope popup shutdown and delayed QA results --- docs/architecture.md | 2 + openless-all/app/linux-egui/src/main.rs | 208 ++++++++++++++++++++++-- 2 files changed, 197 insertions(+), 13 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 27346a9e0..0f4623a16 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,6 +79,8 @@ Linux 主窗口和 QA 面板由常驻宿主拉起的独立进程承载。QA 空 Linux 的 Siri 波形包络在分数次幂运算前限制为非负值,避免浮点端点误差生成 NaN。设置和胶囊绘制回归须经过 egui tessellation 并检查生成顶点的有限性,文字快照不能单独证明绘制成功。 +QA 与润色预览共用浮窗时,宿主记录实际发送的上下文,只允许当前上下文的隐藏请求结束窗口;切回 QA 会创建新的窗口协议状态,后台 QA 更新不能覆盖正在显示的润色预览。异步 QA 调用结果还绑定窗口代次,空态关闭后重新打开仍没有 Core 会话 ID,旧代次的结果也不能显示到新面板。 + Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺寸与点击范围在保存偏好时同步。胶囊按显示器工作区底部定位,避开未自动隐藏的 Dock/任务栏;可见期间重新检查工作区。带正文的浮窗使用不透明底色,聊天面板另叠加细噪点纹理,圆角外部仍保留透明区域。 思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入由会话内串行 worker 维护原控件和累计 UTF-16 终点;每批发送后不再等待 AX 长确认,finish/cancel 在已接收写入之后等待最终屏障,再恢复输入源。AX 仅读选区范围,不读正文;采用 250 ms 无进展预算和 10 秒总预算。不可读、提交型 Return 或预算耗尽时明确降级到按键已发送语义,不重新粘贴已发送文字。目标应用的实际输入表现仍需设备验收。 diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 5c44376e4..ade8e204e 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -49,6 +49,7 @@ mod linux_app { enum UiResult { Message(String), QaAction { + generation: u64, context: Option, result: Result, }, @@ -500,6 +501,12 @@ mod linux_app { } } + #[derive(Default)] + struct QaPopupPresentation { + generation: u64, + session_id: Option, + } + pub struct OpenLessEguiApp { tokio: Arc, native: Option, @@ -590,6 +597,7 @@ mod linux_app { /// 已在 60s 内重开过的面板次数(索引见 `popup_kind_index`)。 popup_restarts: [PopupRestartBudget; POPUP_KIND_COUNT], popup_sequences: [u64; POPUP_KIND_COUNT], + qa_popup_presentation: QaPopupPresentation, /// 最近一次下发给窗口/面板的热键配置;变了才重发。 hotkeys_sent: Option, /// 速记页快捷键卡片是否被收起(持久化在 linux-ui-state.json)。 @@ -748,6 +756,7 @@ mod linux_app { quick_note_shortcut_hidden, popup_restarts: [PopupRestartBudget::default(); POPUP_KIND_COUNT], popup_sequences: [0; POPUP_KIND_COUNT], + qa_popup_presentation: QaPopupPresentation::default(), hotkeys_sent: None, pending_ui_pongs: Vec::new(), history, @@ -845,6 +854,7 @@ mod linux_app { quick_note_shortcut_hidden, popup_restarts: [PopupRestartBudget::default(); POPUP_KIND_COUNT], popup_sequences: [0; POPUP_KIND_COUNT], + qa_popup_presentation: QaPopupPresentation::default(), hotkeys_sent: None, pending_ui_pongs: Vec::new(), history: HistoryCache::default(), @@ -1024,6 +1034,11 @@ mod linux_app { } match std::env::current_exe() { Ok(executable) => { + if kind == PopupKind::Qa { + self.qa_popup_presentation.generation = + self.qa_popup_presentation.generation.saturating_add(1); + self.qa_popup_presentation.session_id = None; + } self.popup_action_guard.reset(kind); let supervisor = PopupSupervisor::spawn(self.tokio.handle(), executable, kind); *self.popup_slot(kind) = Some(supervisor); @@ -1034,6 +1049,11 @@ mod linux_app { fn send_popup(&mut self, kind: PopupKind, mut message: HostToPopup) { let lang = self.lang; + let qa_session = match &message { + HostToPopup::QaSnapshot { session_id, .. } + | HostToPopup::PolishPreview { session_id, .. } => Some(session_id.clone()), + _ => None, + }; // Window actions and failures can occur without a new Core event. let sequence = &mut self.popup_sequences[popup_kind_index(kind)]; *sequence = sequence.saturating_add(1); @@ -1055,19 +1075,43 @@ mod linux_app { "popup.recover_failed", &[&format!("{retry_error:?}")], ); + return; } + } else { + return; } } + if let Some(session_id) = qa_session { + self.qa_popup_presentation.session_id = Some(session_id); + } + } + } + + fn close_qa_popup(&mut self) { + self.qa_visible = false; + self.polish_result_visible = false; + self.qa_popup_presentation.session_id = None; + self.qa_popup_presentation.generation = + self.qa_popup_presentation.generation.saturating_add(1); + if let Some(supervisor) = self.qa_popup.take() { + let _ = supervisor.request_shutdown(); } } fn hide_popup(&mut self, kind: PopupKind, session_id: String, sequence: u64) { if kind == PopupKind::Qa { + if (self.qa_visible || self.polish_result_visible) + && self + .qa_popup_presentation + .session_id + .as_deref() + .is_some_and(|current| current != session_id.as_str()) + { + return; + } // End the native window on both X11 and Wayland. Core owns the // conversation; the next show creates a fresh protocol state. - if let Some(supervisor) = self.popup_slot(kind).take() { - let _ = supervisor.request_shutdown(); - } + self.close_qa_popup(); return; } self.send_popup( @@ -1123,6 +1167,11 @@ mod linux_app { fn expected_popup_session(&self, kind: PopupKind) -> Option { match kind { PopupKind::Qa if !self.qa_visible && !self.polish_result_visible => None, + PopupKind::Qa if self.polish_result_visible => self + .selection + .as_ref() + .and_then(|selection| selection.session_id) + .map(|id| id.to_string()), PopupKind::Qa => self .qa_state .as_ref() @@ -1139,7 +1188,7 @@ mod linux_app { } fn show_qa_popup(&mut self) { - if !self.qa_visible { + if !self.qa_visible || self.polish_result_visible { return; } self.ensure_popup(PopupKind::Qa); @@ -1490,8 +1539,7 @@ mod linux_app { // 面板 ✕:立刻清掉宿主侧的可见标志,不等 Core 的 HideQa 回环 // (那条被图钉门禁拦着,pinned 时不清 → 标志残留会让录音热键 // 之后又把面板弹出来)。 - self.qa_visible = false; - self.polish_result_visible = false; + self.close_qa_popup(); if let Some(backend) = self.backend() { self.spawn(async move { backend.services().qa.dismiss().await?; @@ -1782,17 +1830,26 @@ mod linux_app { F: Future> + Send + 'static, { let tx = self.tx.clone(); + let generation = self.qa_popup_presentation.generation; self.tokio.spawn(async move { let result = future.await.map_err(|error| error.to_string()); - let _ = tx.send(UiResult::QaAction { context, result }); + let _ = tx.send(UiResult::QaAction { + generation, + context, + result, + }); }); } fn apply_qa_action_result( &mut self, + generation: u64, context: Option, result: Result, ) { + if generation != self.qa_popup_presentation.generation { + return; + } match result { Ok(message) => self.status = message, Err(error) => { @@ -2745,6 +2802,7 @@ mod linux_app { // 图钉只管“失焦自动收起”那条路径。 log::info!("[hotkey] QA panel toggle: dismissing"); self.qa_visible = false; + self.polish_result_visible = false; let session_id = self .qa_state .as_ref() @@ -2889,6 +2947,9 @@ mod linux_app { } self.qa_visible = false; self.polish_result_visible = false; + self.qa_popup_presentation.session_id = None; + self.qa_popup_presentation.generation = + self.qa_popup_presentation.generation.saturating_add(1); } fn apply_local_hotkey_edges( @@ -3219,6 +3280,9 @@ mod linux_app { } HostAction::ShowQa => { log::info!("[hotkey] QA panel show requested by the host action"); + if self.polish_result_visible { + self.close_qa_popup(); + } self.qa_visible = true; self.show_qa_popup(); } @@ -3318,9 +3382,11 @@ mod linux_app { } } UiResult::Message(message) => self.status = message, - UiResult::QaAction { context, result } => { - self.apply_qa_action_result(context, result) - } + UiResult::QaAction { + generation, + context, + result, + } => self.apply_qa_action_result(generation, context, result), UiResult::HistoryRepolish { id, result } => { self.frontend_vm.history_repolish_running = false; match result { @@ -7799,9 +7865,14 @@ Internal flags (set by OpenLess itself, not for regular use): ); let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { - if let Ok(UiResult::QaAction { context, result }) = app.rx.try_recv() { + if let Ok(UiResult::QaAction { + generation, + context, + result, + }) = app.rx.try_recv() + { assert!(result.is_err(), "the fixture rejects the provider request"); - app.apply_qa_action_result(context, result); + app.apply_qa_action_result(generation, context, result); break; } assert!( @@ -7850,8 +7921,119 @@ Internal flags (set by OpenLess itself, not for regular use): state.session_id = Some(openless_core::SessionId::new().to_string()); app.qa_state = Some(state); app.qa_visible = true; - app.apply_qa_action_result(None, Err("old failure".into())); + app.apply_qa_action_result( + app.qa_popup_presentation.generation, + None, + Err("old failure".into()), + ); + assert!(app.qa_state.as_ref().unwrap().error.is_none()); + } + + #[test] + fn a_previous_preview_cannot_close_the_current_qa_window() { + let mut app = fixture_app(true); + let current = openless_core::SessionId::new().to_string(); + app.qa_visible = true; + app.qa_popup_presentation.session_id = Some(current.clone()); + app.qa_popup = Some(PopupSupervisor::spawn_command( + app.tokio.handle(), + tokio::process::Command::new("/bin/cat"), + )); + app.hide_popup( + PopupKind::Qa, + openless_core::SessionId::new().to_string(), + 1, + ); + assert!(app.qa_popup.is_some()); + assert!(app.qa_visible); + assert_eq!(app.qa_popup_presentation.session_id, Some(current)); + } + + #[test] + fn a_previous_qa_turn_cannot_close_the_current_preview() { + let mut app = fixture_app(true); + let current = openless_core::SessionId::new(); + app.polish_result_visible = true; + app.selection = Some(SelectionSnapshot { + session_id: Some(current), + phase: SelectionPhase::Preview, + ..Default::default() + }); + app.qa_popup_presentation.session_id = Some(current.to_string()); + app.qa_popup = Some(PopupSupervisor::spawn_command( + app.tokio.handle(), + tokio::process::Command::new("/bin/cat"), + )); + app.hide_popup( + PopupKind::Qa, + openless_core::SessionId::new().to_string(), + 1, + ); + assert!(app.qa_popup.is_some()); + assert!(app.polish_result_visible); + assert_eq!( + app.expected_popup_session(PopupKind::Qa), + Some(current.to_string()) + ); + } + + #[test] + fn closing_the_current_preview_also_clears_qa_visibility() { + let mut app = fixture_app(true); + let current = openless_core::SessionId::new().to_string(); + app.qa_visible = true; + app.qa_popup_presentation.session_id = Some(current.clone()); + app.qa_popup = Some(PopupSupervisor::spawn_command( + app.tokio.handle(), + tokio::process::Command::new("/bin/cat"), + )); + app.hide_popup(PopupKind::Qa, current, 1); + assert!(app.qa_popup.is_none()); + assert!(!app.qa_visible); + assert!(!app.polish_result_visible); + } + + #[test] + fn background_qa_updates_leave_the_current_preview_visible() { + let mut app = fixture_app(true); + let preview = openless_core::SessionId::new().to_string(); + app.qa_visible = true; + app.polish_result_visible = true; + app.qa_state = Some(QaStateEvent::simple(QaStateKind::Idle)); + app.qa_popup_presentation.session_id = Some(preview.clone()); + app.qa_popup = Some(PopupSupervisor::spawn_command( + app.tokio.handle(), + tokio::process::Command::new("/bin/cat"), + )); + let generation = app.qa_popup_presentation.generation; + app.show_qa_popup(); + assert!(app.qa_popup.is_some()); + assert_eq!(app.qa_popup_presentation.session_id, Some(preview)); + assert_eq!(app.qa_popup_presentation.generation, generation); + } + + #[test] + fn an_old_idle_failure_cannot_appear_in_a_reopened_idle_panel() { + let mut app = fixture_app(true); + app.qa_visible = true; + app.qa_state = Some(QaStateEvent::simple(QaStateKind::Idle)); + let old_generation = app.qa_popup_presentation.generation; + app.close_qa_popup(); + app.qa_visible = true; + app.qa_state = Some(QaStateEvent::simple(QaStateKind::Idle)); + let status = app.status.clone(); + app.apply_qa_action_result(old_generation, None, Err("old failure".into())); assert!(app.qa_state.as_ref().unwrap().error.is_none()); + assert_eq!(app.status, status); + app.apply_qa_action_result( + app.qa_popup_presentation.generation, + None, + Err("current failure".into()), + ); + assert_eq!( + app.qa_state.as_ref().unwrap().error.as_deref(), + Some("current failure") + ); } #[test] From b5f5c022115e33f06111a96759b1f4f3bd34bfa0 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 2 Oct 2026 13:44:53 +0800 Subject: [PATCH 03/20] fix(linux-egui): reject queued events from replaced QA processes --- openless-all/app/linux-egui/src/main.rs | 42 +++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index ade8e204e..3d0f2053d 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -1457,17 +1457,31 @@ mod linux_app { fn poll_popup_supervisors(&mut self) { let mut events = Vec::new(); for kind in [PopupKind::Qa, PopupKind::Capsule] { + let generation = self.qa_popup_presentation.generation; if let Some(supervisor) = self.popup_slot(kind) { while let Ok(event) = supervisor.try_recv() { - events.push((kind, event)); + events.push((kind, generation, event)); } } } - for (kind, event) in events { - self.apply_popup_event(kind, event); + for (kind, generation, event) in events { + self.apply_popup_event_in_generation(kind, generation, event); } } + fn apply_popup_event_in_generation( + &mut self, + kind: PopupKind, + generation: u64, + event: PopupSupervisorEvent, + ) { + // A handler may replace the process while draining a queued batch. + if kind == PopupKind::Qa && generation != self.qa_popup_presentation.generation { + return; + } + self.apply_popup_event(kind, event); + } + fn apply_popup_event(&mut self, kind: PopupKind, event: PopupSupervisorEvent) { let lang = self.lang; if let PopupSupervisorEvent::Message(message) = &event { @@ -8012,6 +8026,28 @@ Internal flags (set by OpenLess itself, not for regular use): assert_eq!(app.qa_popup_presentation.generation, generation); } + #[test] + fn an_old_process_exit_cannot_clear_a_reopened_qa_window() { + let mut app = fixture_app(true); + let old_generation = app.qa_popup_presentation.generation; + app.close_qa_popup(); + app.qa_visible = true; + app.qa_popup = Some(PopupSupervisor::spawn_command( + app.tokio.handle(), + tokio::process::Command::new("/bin/cat"), + )); + app.apply_popup_event_in_generation( + PopupKind::Qa, + old_generation, + PopupSupervisorEvent::Exited { + code: Some(0), + crashed: false, + }, + ); + assert!(app.qa_popup.is_some()); + assert!(app.qa_visible); + } + #[test] fn an_old_idle_failure_cannot_appear_in_a_reopened_idle_panel() { let mut app = fixture_app(true); From 4fa9599cc2463a355860c047f2ff60e04c7336ed Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 3 Oct 2026 16:38:03 +0800 Subject: [PATCH 04/20] fix(macos): prevent input-source switch crashes in the event tap --- docs/architecture.md | 4 +- openless-all/app/src-tauri/Cargo.lock | 1 + openless-all/app/src-tauri/Cargo.toml | 1 + openless-all/app/src-tauri/src/hotkey.rs | 98 +++++++++++++++++++----- 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 04306b686..73a0ac86d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # OpenLess 2.0 架构 -状态:canonical,当前实现说明;更新:2026-09-30。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 +状态:canonical,当前实现说明;更新:2026-10-03。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 ## 1. 分层与工作区 @@ -75,6 +75,8 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装 主窗口默认逻辑尺寸为 1300×835,允许用户调整;macOS 原生窗口按钮左侧和顶部均留出 16px,前端保留 44px 拖动区。桌面侧栏宽 226px,主内容从版本行下方开始,设置面板单独限制高度并在内部滚动。 +macOS 全局热键在后台 CGEventTap 线程读取按键与修饰键边沿。只有配置的 Fn 正处于按住状态时,监听器才解析系统功能键负载;`NSEvent eventWithCGEvent:` 的转换同步交给主线程,完成分类后再处理 Fn 松键。此转换会进入 HIToolbox 的输入源逻辑,不能在热键线程执行;普通 Caps Lock 中英文切换不需要该转换。Esc 与组合键取消仍由原有独立通道处理。 + Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺寸与点击范围在保存偏好时同步。胶囊按显示器工作区底部定位,避开未自动隐藏的 Dock/任务栏;可见期间重新检查工作区。带正文的浮窗使用不透明底色,聊天面板另叠加细噪点纹理,圆角外部仍保留透明区域。 思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入由会话内串行 worker 维护原控件和累计 UTF-16 终点;每批发送后不再等待 AX 长确认,finish/cancel 在已接收写入之后等待最终屏障,再恢复输入源。AX 仅读选区范围,不读正文;采用 250 ms 无进展预算和 10 秒总预算。不可读、提交型 Return 或预算耗尽时明确降级到按键已发送语义,不重新粘贴已发送文字。目标应用的实际输入表现仍需设备验收。 diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 5217bc7b6..82cbb206f 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -4212,6 +4212,7 @@ dependencies = [ "core-graphics 0.24.0", "coreaudio-sys", "cpal", + "dispatch2", "enigo", "env_logger", "foundry-local-sdk", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 22729c64c..49df24e2c 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -130,6 +130,7 @@ core-foundation = "0.10" tokenizers = "0.21" whisper-rs = { version = "0.14", features = ["metal"] } core-graphics = "0.24" +dispatch2 = "0.3" # issue #470:托盘麦克风设备变更改用 CoreAudio AudioObjectAddPropertyListener 原生通知 # (替代 10s 轮询,空闲零唤醒)。符号本就在依赖树(cpal → coreaudio-sys 0.2),提升为 # 直接依赖零额外编译成本。 diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 2beb41961..1ff32703d 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -564,6 +564,11 @@ mod platform { #[repr(C)] struct OpaqueCgEvent(c_void); + // SAFETY: NSEvent's public signature uses an opaque __CGEvent pointer. + unsafe impl objc2::encode::RefEncode for OpaqueCgEvent { + const ENCODING_REF: objc2::encode::Encoding = + objc2::encode::Encoding::Pointer(&objc2::encode::Encoding::Struct("__CGEvent", &[])); + } type CgEventRef = *mut OpaqueCgEvent; #[repr(C)] @@ -776,9 +781,7 @@ mod platform { // systemDefined. While the Fn trigger is held, treat these as function-layer combos — // dictation must not start at the same time. SYSTEM_DEFINED => { - if let Some((subtype, data1)) = system_defined_event_payload(event) { - note_fn_function_layer_event(ctx, subtype, data1); - } + handle_system_defined_event(ctx, || system_defined_event_payload(event)); } _ => {} } @@ -980,22 +983,58 @@ mod platform { note_companion_key_down(ctx); } - fn system_defined_event_payload(event: CgEventRef) -> Option<(i16, isize)> { - use objc2::msg_send; - use objc2::runtime::{AnyClass, AnyObject}; + fn handle_system_defined_event( + ctx: &CallbackContext, + payload: impl FnOnce() -> Option<(i16, isize)>, + ) { + // System controls only affect dictation while a configured Fn press is held. + if !ctx.shared.trigger_held.load(Ordering::SeqCst) + || ctx.shared.binding.read().trigger != HotkeyTrigger::Fn + { + return; + } + if let Some((subtype, data1)) = payload() { + note_fn_function_layer_event(ctx, subtype, data1); + } + } + fn system_defined_event_payload(event: CgEventRef) -> Option<(i16, isize)> { if event.is_null() { return None; } - let cls = AnyClass::get("NSEvent")?; - let ns_event: *mut AnyObject = - unsafe { msg_send![cls, eventWithCGEvent: event.cast::()] }; - if ns_event.is_null() { - return None; + if unsafe { libc::pthread_main_np() } != 0 { + return system_defined_event_payload_on_main(event); } - let subtype: i16 = unsafe { msg_send![ns_event, subtype] }; - let data1: isize = unsafe { msg_send![ns_event, data1] }; - Some((subtype, data1)) + // NSEvent conversion can enter HIToolbox's main-thread-only input-source + // machinery. Complete classification before the listener handles Fn release. + let address = event as usize; + let mut payload = None; + dispatch2::DispatchQueue::main().exec_sync(|| { + payload = system_defined_event_payload_on_main(address as CgEventRef); + }); + payload + } + + fn system_defined_event_payload_on_main(event: CgEventRef) -> Option<(i16, isize)> { + use objc2::msg_send; + use objc2::rc::autoreleasepool; + use objc2::runtime::{AnyClass, AnyObject}; + + debug_assert_ne!(unsafe { libc::pthread_main_np() }, 0); + autoreleasepool(|_| { + let cls = AnyClass::get("NSEvent")?; + let ns_event: *mut AnyObject = unsafe { msg_send![cls, eventWithCGEvent: event] }; + if ns_event.is_null() { + return None; + } + let event_type: usize = unsafe { msg_send![ns_event, type] }; + if event_type != SYSTEM_DEFINED as usize { + return None; + } + let subtype: i16 = unsafe { msg_send![ns_event, subtype] }; + let data1: isize = unsafe { msg_send![ns_event, data1] }; + Some((subtype, data1)) + }) } fn is_auxiliary_function_key_event(subtype: i16, data1: isize) -> bool { @@ -1301,6 +1340,29 @@ mod platform { assert_eq!(drain_combo(&combo_rx), 0); } + #[test] + fn mac_idle_input_source_switch_does_not_decode_appkit_events() { + for trigger in [HotkeyTrigger::Fn, HotkeyTrigger::RightOption] { + let shared = shared(trigger); + let (ctx, rx) = callback_context(shared); + handle_system_defined_event(&ctx, || { + panic!("idle input-source switching must not convert CGEvent to NSEvent"); + }); + assert!(drain(&rx).is_empty()); + } + } + + #[test] + fn mac_system_controls_do_not_decode_appkit_during_non_fn_holds() { + let shared = shared(HotkeyTrigger::RightOption); + shared.trigger_held.store(true, Ordering::SeqCst); + let (ctx, rx) = callback_context(shared); + handle_system_defined_event(&ctx, || { + panic!("non-Fn dictation must not decode unrelated system controls"); + }); + assert!(drain(&rx).is_empty()); + } + #[test] fn mac_fn_function_layer_event_suppresses_tap_without_pending_abort() { let shared = shared(HotkeyTrigger::Fn); @@ -1315,11 +1377,9 @@ mod platform { pressed_at, ); // subtype/data1 encoding for brightness-up (NX_KEYTYPE_BRIGHTNESS_UP=2). - note_fn_function_layer_event( - &ctx, - NX_SUBTYPE_AUX_CONTROL_BUTTONS, - (2_i64 << 16) as isize, - ); + handle_system_defined_event(&ctx, || { + Some((NX_SUBTYPE_AUX_CONTROL_BUTTONS, (2_i64 << 16) as isize)) + }); handle_dictation_trigger_flags_changed( &ctx, trigger_to_keycode(HotkeyTrigger::Fn), From edc142e4caa27c801e67c1448f9e24a1ffe29c51 Mon Sep 17 00:00:00 2001 From: sim Date: Sun, 4 Oct 2026 00:08:12 +0800 Subject: [PATCH 05/20] feat(settings): gate unfinished features behind a local Beta switch --- docs/architecture.md | 4 +- docs/encrypted-cloud-sync.md | 2 + docs/linux-egui-backend-contract.md | 5 +- openless-all/app/contract/backend-2.0.json | 9 + .../app/crates/openless-core/src/api.rs | 173 +++++++++++++++--- .../src/cloud_sync_e2ee_documents/registry.rs | 7 + .../src/cloud_sync_e2ee_documents/tests.rs | 21 +++ .../src/cloud_sync_e2ee_store/native.rs | 7 + .../src/cloud_sync_e2ee_store/tests.rs | 7 +- .../crates/openless-core/src/coding_agent.rs | 130 +++++++++++-- .../app/crates/openless-core/src/domains.rs | 7 + .../crates/openless-core/src/less_computer.rs | 62 +++++++ .../app/crates/openless-core/src/settings.rs | 16 +- .../crates/openless-core/src/shared_types.rs | 35 ++++ .../tests/auxiliary_voice_lifecycle.rs | 1 + openless-all/app/e2e/testing-features.spec.ts | 45 +++++ .../app/linux-egui/examples/headless_host.rs | 1 + openless-all/app/linux-egui/src/i18n.rs | 50 ++++- .../linux-egui/src/linux_app/settings_save.rs | 3 + openless-all/app/linux-egui/src/main.rs | 24 +++ .../app/linux-egui/src/ui/frontend/mod.rs | 27 ++- .../linux-egui/src/ui/frontend/settings.rs | 46 ++++- .../linux-egui/src/ui/frontend/view_model.rs | 3 + .../app/linux-egui/tests/host_contract.rs | 1 + openless-all/app/scripts/sync-egui-i18n.mjs | 2 + openless-all/app/src-tauri/src/commands/qa.rs | 4 + .../src-tauri/src/coordinator/hotkey_loops.rs | 2 + openless-all/app/src-tauri/src/lib.rs | 19 ++ .../app/src-tauri/src/tauri_events.rs | 4 + .../app/src/components/SettingsModal.tsx | 25 ++- openless-all/app/src/i18n/de.ts | 11 +- openless-all/app/src/i18n/en.ts | 11 +- openless-all/app/src/i18n/es.ts | 12 +- openless-all/app/src/i18n/fr.ts | 12 +- openless-all/app/src/i18n/ja.ts | 11 +- openless-all/app/src/i18n/ko.ts | 11 +- openless-all/app/src/i18n/zh-CN.ts | 11 +- openless-all/app/src/i18n/zh-TW.ts | 11 +- openless-all/app/src/lib/ipc/mock-data.ts | 1 + openless-all/app/src/lib/types.ts | 2 + .../src/pages/settings/ShortcutsSection.tsx | 2 +- .../app/src/pages/settings/navigation.test.ts | 26 ++- .../app/src/pages/settings/navigation.ts | 12 +- .../pages/settings/navigationLocale.test.ts | 20 +- openless-all/app/src/pages/settings/tabs.tsx | 118 ++++++++---- 45 files changed, 908 insertions(+), 105 deletions(-) create mode 100644 openless-all/app/e2e/testing-features.spec.ts diff --git a/docs/architecture.md b/docs/architecture.md index 04306b686..e6b2fc519 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # OpenLess 2.0 架构 -状态:canonical,当前实现说明;更新:2026-09-30。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 +状态:canonical,当前实现说明;更新:2026-10-03。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 ## 1. 分层与工作区 @@ -79,6 +79,8 @@ Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺 思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入由会话内串行 worker 维护原控件和累计 UTF-16 终点;每批发送后不再等待 AX 长确认,finish/cancel 在已接收写入之后等待最终屏障,再恢复输入源。AX 仅读选区范围,不读正文;采用 250 ms 无进展预算和 10 秒总预算。不可读、提交型 Return 或预算耗尽时明确降级到按键已发送语义,不重新粘贴已发送文字。目标应用的实际输入表现仍需设备验收。 +Less Computer 与 Claude 控制台收拢到「实验与扩展 → 正在测试中的功能(Beta)」。本设备的 `testingFeaturesEnabled` 默认关闭,旧版已开启 Less Computer 的配置也不会自动打开总开关。开启后按真实平台能力显示入口;React 的 Less Computer 仅 Windows/macOS、Claude 控制台仅 macOS,Linux egui 保留自己的 Less Computer 接入入口,Android 当前显示无可用测试功能。关闭总开关会隐藏入口、快捷键和独立窗口,Core 停止当前捕获与 Agent/控制台任务并拒绝新的运行请求;已有 Agent、模型、权限与路径配置保留。总开关不进入云同步,恢复保留接收设备的本机选择。 + Less Computer 面板将听写与直接语音提交分开:麦克风把转写填入草稿供编辑,语音模式和快捷键可直接提交给 Agent。Core 的 `voice_state` 事件携带会话 ID、模式、实时转写及收尾结果;波形使用实际音量采样。停止和取消均绑定指定会话,延迟请求不能结束下一段录音;开麦与文字发送互斥。工具过程默认折叠,运行状态只在真实活动步骤显示动效,右侧工作台汇总当前轮次;历史和多会话仍标为暂不可用。登录弹窗打开时,听写结果只更新草稿,不抢走弹窗或授权浏览器的焦点。 选区直接润色在捕获文字和原输入目标后显示处理中提示,重复快捷键的 Busy 返回不覆盖该提示。已有语音选区入口在松开快捷键后继续显示思考动画,直到处理/替换完成,或交给确认和预览面板。录音提示音的 Web Audio context 在恢复超时或音频时钟冻结时丢弃并最多重试一次,重试沿用原请求的取消和迟到边界。 diff --git a/docs/encrypted-cloud-sync.md b/docs/encrypted-cloud-sync.md index 1caa901eb..05aa90a91 100644 --- a/docs/encrypted-cloud-sync.md +++ b/docs/encrypted-cloud-sync.md @@ -33,6 +33,8 @@ 短暂的本机写入竞争最多触发 4 次有界延后重试;网络失败、结果未知、版本冲突不进入该重试。后续手动任务、锁定或停用会淘汰旧重试链。同步连接遵循当前系统代理偏好,策略变化后重建连接,回环测试始终直连。 +测试功能授权 `testingFeaturesEnabled` 不进入远端文档或设备档案;正常恢复和失败回滚均保留接收设备的本机值,不能通过同步打开 Less Computer 或 Claude 控制台。 + 系统自启动 `launchAtLogin` 不进入远端文档或设备档案;保留接收设备本机值,实际 OS 状态仍由现有 UI 自启动插件管理,恢复不宣称替用户重建自启动授权。 恢复必须绑定 `RestoreRuntimeEffects` 的真实 Host 适配器。所有仓库写入后,在同一加密 journal、独占写入门和运行态屏障内等待绝对目标设置生效;副作用失败按 before 镜像回滚。只保存在本地 journal 的 Agent 开关、provider 镜像和录音标记恢复后,再应用最终旧设置;若回滚副作用也失败,保留 recovery_required,不能提前放行录音或 Agent。未绑定适配器明确失败,不使用空实现代替平台确认。 diff --git a/docs/linux-egui-backend-contract.md b/docs/linux-egui-backend-contract.md index 83d60420e..56832a77e 100644 --- a/docs/linux-egui-backend-contract.md +++ b/docs/linux-egui-backend-contract.md @@ -1,6 +1,6 @@ # Linux egui 后端接口契约(2.0.0) -状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-27。范围以[2.0 需求](2.0-requirements.md)为准;本文是长接口与实现参考,交接材料以[交接目录](linux-egui-handoff/README.md)为准。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-10-03。范围以[2.0 需求](2.0-requirements.md)为准;本文是长接口与实现参考,交接材料以[交接目录](linux-egui-handoff/README.md)为准。 ## 1. 合同文件 @@ -10,6 +10,7 @@ | --- | --- | | `startupSnapshot` | 启动快照结构与版本校验规则;UI 必须先消费快照再渲染 | | `backendEvent` | 语义事件清单、顺序与重放规则 | +| `testingFeatures` | 本设备 Beta 总开关、默认关闭、运行门禁与取消合同 | | `lessComputerVoice` | Less Computer 语音事件面 | | `androidJni` | Android JNI 合同(src-tauri android 桥接共用) | | `linuxFacade` | Linux 专用 facade 面(`LinuxHost` 公开方法对应) | @@ -33,6 +34,8 @@ ## 5. 行为约定(合同级) +- `UserPreferences.testingFeaturesEnabled` 默认 `false`,设备独立选择,不进入云同步。实验与扩展中的 Less Computer 移到 Beta 测试功能子页,开启总开关后才显示,运行还须开启原有 `codingAgentEnabled`。Core 在会话预约、录音与文字提交时验证有效开关,关闭返回 `PermissionDenied`;关闭总开关取消当前任务、注销热键并关闭独立弹窗,保留功能配置。 + - fcitx5 为生产启动硬依赖:持有单实例锁后检查插件和会话 D-Bus,按需刷新并在 15 秒就绪预算内等待,再启动监听器、注册必需热键、启动 Core 和 Remote Input。失败清理已启动资源并显示错误窗口;关闭后以非零状态退出,外层锁覆盖完整生命周期。 - 设置保存状态与动作处理位于 `linux_app/settings_save.rs`:按序列化字段生成补丁、串行提交、只重试明确 revision 冲突(最多三次),失败保留草稿并显式重试;Core 合同与持久化格式不变。 - 窗口生命周期与 client 位于 `linux_app/window.rs`,协议门禁位于 `ui/bridge.rs`。显式显示窗口请求创建窗口或发送 FocusMain;Wayland 是否激活由合成器决定。 diff --git a/openless-all/app/contract/backend-2.0.json b/openless-all/app/contract/backend-2.0.json index d9c176a9d..c62a33ce0 100644 --- a/openless-all/app/contract/backend-2.0.json +++ b/openless-all/app/contract/backend-2.0.json @@ -1,5 +1,14 @@ { "contractVersion": "2.0.0", + "testingFeatures": { + "preferenceField": "testingFeaturesEnabled", + "default": false, + "scope": "device_local", + "features": ["lessComputer", "claudeConsole"], + "disabledBehavior": ["hide_entries_and_windows", "unregister_less_computer_hotkeys", "reject_runtime_admission", "cancel_active_tasks", "preserve_feature_settings"], + "disabledErrorCode": "permission_denied", + "cloudSync": "excluded" + }, "startupSnapshot": { "fields": ["contractVersion", "backend"], "sample": { diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index a96c1cbe9..d461dbd03 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -1,4 +1,5 @@ mod cloud_note; +use futures_util::future::BoxFuture; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, AtomicU8, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -2527,6 +2528,10 @@ impl OpenLessBackend { let history_revision = Arc::new(AtomicU64::new(0)); let vocabulary_revision = Arc::new(AtomicU64::new(0)); let settings_write_gate = Arc::new(Mutex::new(())); + deps.services.less_computer.bind_testing_features( + Arc::clone(&repositories.preferences), + Arc::clone(&settings_write_gate), + ); let voice_sessions = Arc::clone(&deps.services.voice_sessions); let runtime_start_work = Arc::new(crate::voice_session::RuntimeActivityGate::default()); deps.services.selection_voice = @@ -2555,6 +2560,8 @@ impl OpenLessBackend { process, Arc::clone(&deps.services.less_computer), BackendEventPublisher::new(Arc::clone(&events)), + Arc::clone(&repositories.preferences), + Arc::clone(&settings_write_gate), )); } if let Some(runtime) = deps.local_asr_runtime.take() { @@ -2929,7 +2936,7 @@ impl OpenLessBackend { /// [`Self::abort_less_computer_capture`]. pub fn begin_less_computer_capture(&self, session_id: SessionId) -> Result<(), BackendError> { let _runtime = self.runtime_start_work.acquire()?; - if !self.get_preferences().coding_agent_enabled { + if !self.get_preferences().less_computer_available() { return Err(BackendError::new( BackendErrorCode::PermissionDenied, "Less Computer is disabled", @@ -2946,6 +2953,11 @@ impl OpenLessBackend { self.deps.services.less_computer.begin_capture(session_id) } + /// Require this device's explicit opt-in for unfinished features. + pub fn ensure_testing_features_enabled(&self) -> Result<(), BackendError> { + crate::settings::ensure_testing_features_enabled(&self.get_preferences()) + } + /// Return the current Less Computer capture/run session, if any. pub fn less_computer_active_session(&self) -> Option { self.deps.services.less_computer.active_session() @@ -2990,7 +3002,7 @@ impl OpenLessBackend { ) -> Result { let _runtime = self.runtime_start_work.acquire()?; let preferences = self.get_preferences(); - if !preferences.coding_agent_enabled { + if !preferences.less_computer_available() { return Err(BackendError::new( BackendErrorCode::PermissionDenied, "Less Computer is disabled", @@ -3564,7 +3576,7 @@ impl OpenLessBackend { ) -> Result { let _runtime = self.runtime_start_work.acquire()?; let preferences = self.get_preferences(); - if !preferences.coding_agent_enabled { + if !preferences.less_computer_available() { return Err(BackendError::new( BackendErrorCode::PermissionDenied, "Less Computer is disabled", @@ -3616,6 +3628,17 @@ impl OpenLessBackend { let Some(session_id) = session_id.or_else(|| self.less_computer_active_session()) else { return Ok(()); }; + own_voice_effect( + &self.deps.task_spawner, + self.less_computer_cancellation(session_id), + ) + .await + } + + fn less_computer_cancellation( + &self, + session_id: SessionId, + ) -> BoxFuture<'static, Result<(), BackendError>> { let control = self .less_computer_voice_controls .lock() @@ -3624,26 +3647,22 @@ impl OpenLessBackend { .cloned(); let controls = Arc::clone(&self.less_computer_voice_controls); let less_computer = Arc::clone(&self.deps.services.less_computer); - own_voice_effect( - &self.deps.task_spawner, - Box::pin(async move { - let service_result = less_computer.cancel(Some(session_id)).await; - let resource_result = match control { - Some(control) => { - let _guard = VoiceControlGuard { - session_id, - control: Arc::clone(&control), - controls, - }; - control.cancel_resources().await - } - None => Ok(()), - }; - let _ = less_computer.abort_capture(session_id); - resource_result.and(service_result) - }), - ) - .await + Box::pin(async move { + let service_result = less_computer.cancel(Some(session_id)).await; + let resource_result = match control { + Some(control) => { + let _guard = VoiceControlGuard { + session_id, + control: Arc::clone(&control), + controls, + }; + control.cancel_resources().await + } + None => Ok(()), + }; + let _ = less_computer.abort_capture(session_id); + resource_result.and(service_result) + }) } pub async fn cancel_active_voice_session( @@ -4765,6 +4784,21 @@ impl OpenLessBackend { self.disarm_edit_observation(); self.dismiss_pending_corrections(); } + if previous.testing_features_enabled && !preferences.testing_features_enabled { + // Capture the old owners while settings admission is still locked. + // A later opt-in must not let cleanup cancel a replacement task. + let cancellation = self + .less_computer_active_session() + .map(|session_id| self.less_computer_cancellation(session_id)); + self.deps.services.less_computer.dismiss(); + let cancel_test = self.deps.services.coding_agent.cancel_test(); + self.deps.task_spawner.spawn(Box::pin(async move { + let _ = cancel_test.await; + if let Some(cancellation) = cancellation { + let _ = cancellation.await; + } + })); + } self.publish_preferences_changed(); Ok(crate::SettingsUpdateOutcome { preferences, @@ -7626,6 +7660,7 @@ mod tests { async fn voice_workflows_share_one_busy_lease_and_release_it_on_terminal_paths() { let (backend, _) = backend(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend .update_settings( @@ -7733,6 +7768,7 @@ mod tests { ) .unwrap(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; preferences.coding_agent_provider = "dsh-cli".into(); preferences.coding_agent_permission_mode = "bypassPermissions".into(); @@ -7785,6 +7821,24 @@ mod tests { .await .unwrap_err(); assert_eq!(error.code, BackendErrorCode::PermissionDenied); + let mut legacy = backend.get_preferences(); + legacy.coding_agent_enabled = true; + backend.set_preferences(legacy).unwrap(); + assert_eq!( + backend + .submit_less_computer("legacy".into()) + .await + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); + assert_eq!( + backend + .begin_less_computer_capture(SessionId::new()) + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); assert!(runtime.request.lock().unwrap().is_none()); } @@ -7792,6 +7846,7 @@ mod tests { async fn less_computer_capture_facade_is_session_scoped_and_cancel_releases_it() { let (backend, _) = backend(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend.set_preferences(preferences).unwrap(); @@ -7812,10 +7867,74 @@ mod tests { assert_eq!(backend.less_computer_active_session(), None); } + #[tokio::test] + async fn disabling_testing_features_cancels_capture_and_preserves_configuration() { + let (backend, _) = backend(); + backend.start().await.unwrap(); + let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; + preferences.coding_agent_enabled = true; + preferences.coding_agent_model = Some("saved-model".into()); + backend + .update_settings( + preferences, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); + let owner = SessionId::new(); + backend.begin_less_computer_capture(owner).unwrap(); + let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = false; + let outcome = backend + .update_settings( + preferences, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); + let hotkeys = outcome.effects.hotkeys.unwrap(); + assert!(hotkeys.previous.coding_agent_enabled); + assert!(!hotkeys.next.coding_agent_enabled); + assert!(outcome.preferences.coding_agent_enabled); + assert_eq!( + outcome.preferences.coding_agent_model.as_deref(), + Some("saved-model") + ); + assert!(backend.less_computer_capture_cancelled(owner)); + assert!(backend.ensure_testing_features_enabled().is_err()); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while backend.less_computer_active_session().is_some() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!( + backend + .begin_less_computer_capture(SessionId::new()) + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); + let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; + backend + .update_settings( + preferences, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); + backend.begin_less_computer_capture(owner).unwrap(); + backend.cancel_less_computer(Some(owner)).await.unwrap(); + } + #[test] fn less_computer_hotkey_modes_share_hold_toggle_auto_and_combined_rules() { let (backend, _) = backend(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; preferences.hotkey.mode = crate::HotkeyMode::Hold; @@ -8072,6 +8191,7 @@ mod tests { .unwrap(), ); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend.set_preferences(preferences).unwrap(); let session_id = SessionId::new(); @@ -8140,6 +8260,7 @@ mod tests { ) .unwrap(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; preferences.stable_transcription_enabled = true; backend.set_preferences(preferences).unwrap(); @@ -8300,6 +8421,7 @@ mod tests { ) .unwrap(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend.set_preferences(preferences).unwrap(); (data_dir, backend, transcription, runtime) @@ -8879,6 +9001,10 @@ mod tests { BackendDependencies::unsupported(), ) .unwrap(); + let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; + preferences.coding_agent_enabled = true; + backend.set_preferences(preferences).unwrap(); let control = Arc::new(FakeRecordingControl::default()); let stop_session = SessionId::new(); @@ -14635,6 +14761,7 @@ mod tests { let backend = runtime_restore_backend(); backend.ensure_runtime_ready().unwrap(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend .update_settings( diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index 3eedc0809..674c67856 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -410,6 +410,13 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::ObjectList, reason: "device_bound", }, + PreferenceField { + rust_name: "testing_features_enabled", + key: "testingFeaturesEnabled", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_consent_or_secret", + }, PreferenceField { rust_name: "coding_agent_enabled", key: "codingAgentEnabled", diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs index c62a51ed8..0f7c258cf 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs @@ -118,6 +118,27 @@ fn all_current_preference_fields_have_an_explicit_registration() { assert_eq!(registered.len(), registry::PREFERENCE_FIELDS.len()); } +#[test] +fn testing_features_opt_in_is_never_exported_or_accepted_from_another_device() { + let mut data = snapshot(); + data.preferences = SecretJson::new(json!({"testingFeaturesEnabled": true})); + let exported = export_snapshot(data).unwrap(); + assert!(!serde_json::to_string(exported.documents.documents()) + .unwrap() + .contains("testingFeaturesEnabled")); + let mut invalid = sample().documents().clone(); + invalid.documents.push(LogicalDocument { + id: "testingFeaturesEnabled".into(), + kind: DocumentKind::Preferences, + schema_version: 1, + value: json!(true), + }); + assert_eq!( + validate_sync_documents(invalid, Revision::new(3)).unwrap_err(), + DocumentError::ExcludedField + ); +} + #[test] fn controlled_export_keeps_real_service_keys_but_excludes_local_secrets_and_grants() { let mut data = channel_snapshot(); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs index 4a30c0940..bdfc466fd 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs @@ -89,6 +89,8 @@ pub(super) fn canonicalize_native_documents( #[serde(rename_all = "camelCase", deny_unknown_fields)] struct LocalRollbackState { schema_version: u32, + #[serde(default)] + testing_features_enabled: bool, coding_agent_enabled: bool, active_asr_provider: String, active_llm_provider: String, @@ -126,6 +128,7 @@ impl CoreSyncStore { } SecretJson::from_serializable(&LocalRollbackState { schema_version: 1, + testing_features_enabled: prefs.testing_features_enabled, coding_agent_enabled: prefs.coding_agent_enabled, active_asr_provider: prefs.active_asr_provider, active_llm_provider: prefs.active_llm_provider, @@ -177,6 +180,10 @@ impl CoreSyncStore { let object = current .as_object_mut() .ok_or(DocumentError::InvalidDocument)?; + object.insert( + "testingFeaturesEnabled".into(), + Value::Bool(local.testing_features_enabled), + ); object.insert( "codingAgentEnabled".into(), Value::Bool(local.coding_agent_enabled), diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs index 4cad49904..6cb137ff8 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs @@ -1553,7 +1553,10 @@ async fn launch_at_login_is_excluded_and_preserves_the_receivers_local_setting() fixture .repo .preferences - .update(|prefs| prefs.launch_at_login = true) + .update(|prefs| { + prefs.launch_at_login = true; + prefs.testing_features_enabled = true; + }) .unwrap(); let before = fixture .store @@ -1562,6 +1565,7 @@ async fn launch_at_login_is_excluded_and_preserves_the_receivers_local_setting() .unwrap(); let encoded = serde_json::to_string(before.documents.documents()).unwrap(); assert!(!encoded.contains("launchAtLogin")); + assert!(!encoded.contains("testingFeaturesEnabled")); let mut desired = before.documents.documents().clone(); set_doc( &mut desired, @@ -1575,6 +1579,7 @@ async fn launch_at_login_is_excluded_and_preserves_the_receivers_local_setting() .await .unwrap(); assert!(fixture.repo.preferences.get().launch_at_login); + assert!(fixture.repo.preferences.get().testing_features_enabled); assert!( fixture .effects diff --git a/openless-all/app/crates/openless-core/src/coding_agent.rs b/openless-all/app/crates/openless-core/src/coding_agent.rs index 77ff713e0..b1283e8ff 100644 --- a/openless-all/app/crates/openless-core/src/coding_agent.rs +++ b/openless-all/app/crates/openless-core/src/coding_agent.rs @@ -1644,6 +1644,8 @@ pub struct CodingAgentService { events: BackendEventPublisher, active_test: Arc>>>, request_counter: AtomicU64, + preferences: Arc, + settings_write_gate: Arc>, } impl CodingAgentService { @@ -1652,6 +1654,8 @@ impl CodingAgentService { process: Arc, less_computer: Arc, events: BackendEventPublisher, + preferences: Arc, + settings_write_gate: Arc>, ) -> Self { Self { runner, @@ -1660,6 +1664,8 @@ impl CodingAgentService { events, active_test: Arc::new(Mutex::new(None)), request_counter: AtomicU64::new(0), + preferences, + settings_write_gate, } } @@ -1757,7 +1763,9 @@ impl crate::domains::CodingAgentApi for CodingAgentService { request: CodingAgentDetectRequest, ) -> BoxFuture<'static, Result> { let process = Arc::clone(&self.process); + let preferences = Arc::clone(&self.preferences); Box::pin(async move { + crate::settings::ensure_testing_features_enabled(&preferences.get())?; let executable = normalize_coding_agent_executable(request.provider, request.executable)?; let probe = execute_capture( @@ -1809,7 +1817,9 @@ impl crate::domains::CodingAgentApi for CodingAgentService { request: CodingAgentModelsRequest, ) -> BoxFuture<'static, Result, BackendError>> { let process = Arc::clone(&self.process); + let preferences = Arc::clone(&self.preferences); Box::pin(async move { + crate::settings::ensure_testing_features_enabled(&preferences.get())?; if request.provider != CodingAgentProvider::OpenCodeCli { return Err(BackendError::new( BackendErrorCode::Unsupported, @@ -1864,10 +1874,16 @@ impl crate::domains::CodingAgentApi for CodingAgentService { let active_test = Arc::clone(&self.active_test); let events = self.events.clone(); let request_id = self.next_request_id(); + let preferences = Arc::clone(&self.preferences); + let settings_write_gate = Arc::clone(&self.settings_write_gate); Box::pin(async move { let normalized = normalized?; let cancel = Arc::new(AtomicBool::new(false)); { + let _admission = settings_write_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + crate::settings::ensure_testing_features_enabled(&preferences.get())?; let mut active = active_test.lock().expect("coding agent test lock poisoned"); if active.is_some() { return Err(BackendError::new( @@ -1918,17 +1934,15 @@ impl crate::domains::CodingAgentApi for CodingAgentService { } fn cancel_test(&self) -> BoxFuture<'static, Result<(), BackendError>> { - let active_test = Arc::clone(&self.active_test); - Box::pin(async move { - if let Some(cancel) = active_test - .lock() - .expect("coding agent test lock poisoned") - .clone() - { - cancel.store(true, Ordering::Release); - } - Ok(()) - }) + if let Some(cancel) = self + .active_test + .lock() + .expect("coding agent test lock poisoned") + .as_ref() + { + cancel.store(true, Ordering::Release); + } + Box::pin(async move { Ok(()) }) } fn approve( @@ -1944,6 +1958,100 @@ impl crate::domains::CodingAgentApi for CodingAgentService { mod tests { use super::*; + #[tokio::test] + async fn console_requires_device_opt_in_and_revocation_cancels_only_the_current_test() { + use crate::domains::CodingAgentApi; + let process = Arc::new(CancelAwareProcess); + let path = std::env::temp_dir().join(format!( + "openless-testing-console-{}.json", + uuid::Uuid::new_v4() + )); + let preferences = Arc::new(crate::PreferencesStore::fallback(path.clone())); + let events = BackendEventPublisher::new(Arc::new(crate::events::EventBus::new(32))); + let service = CodingAgentService::new( + Arc::new(CodingAgentRunner::new(process.clone())), + process, + Arc::new(crate::less_computer::LessComputerService::new()), + events, + preferences.clone(), + Arc::new(Mutex::new(())), + ); + assert_eq!( + service + .detect(CodingAgentDetectRequest { + provider: CodingAgentProvider::ClaudeCodeCli, + executable: None, + }) + .await + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); + assert_eq!( + service + .list_models(CodingAgentModelsRequest { + provider: CodingAgentProvider::OpenCodeCli, + executable: None, + refresh: false, + }) + .await + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); + let request = CodingAgentTestRequest { + provider: CodingAgentProvider::ClaudeCodeCli, + executable: None, + prompt: "probe".into(), + permission_mode: CodingAgentPermissionMode::default(), + workdir: None, + model: None, + max_budget_usd: None, + timeout_secs: 5, + }; + let mut queued = service.run_test(request.clone()); + preferences + .update(|prefs| prefs.testing_features_enabled = true) + .unwrap(); + assert!(futures_util::poll!(queued.as_mut()).is_pending()); + preferences + .update(|prefs| prefs.testing_features_enabled = false) + .unwrap(); + let cleanup = service.cancel_test(); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_secs(2), queued) + .await + .unwrap() + .unwrap_err() + .code, + BackendErrorCode::Cancelled + ); + assert_eq!( + service.run_test(request.clone()).await.unwrap_err().code, + BackendErrorCode::PermissionDenied + ); + + preferences + .update(|prefs| prefs.testing_features_enabled = true) + .unwrap(); + let mut replacement = service.run_test(request); + assert!(futures_util::poll!(replacement.as_mut()).is_pending()); + cleanup.await.unwrap(); + assert!(!service + .active_test + .lock() + .unwrap() + .as_ref() + .unwrap() + .load(Ordering::Acquire)); + service.cancel_test().await.unwrap(); + assert_eq!( + replacement.await.unwrap_err().code, + BackendErrorCode::Cancelled + ); + std::fs::remove_file(path).unwrap(); + } + struct ScriptedProcess(Vec, Result); impl CodingAgentProcessAdapter for ScriptedProcess { diff --git a/openless-all/app/crates/openless-core/src/domains.rs b/openless-all/app/crates/openless-core/src/domains.rs index 11e5f0116..8c07d425c 100644 --- a/openless-all/app/crates/openless-core/src/domains.rs +++ b/openless-all/app/crates/openless-core/src/domains.rs @@ -1468,6 +1468,13 @@ pub struct LessComputerRunResult { /// the typed event and return the user's decision; they must not maintain a /// second token registry or conversation flag. pub trait LessComputerApi: Send + Sync { + /// Bind admission to this backend's device-local settings transaction. + fn bind_testing_features( + &self, + _preferences: Arc, + _settings_write_gate: Arc>, + ) { + } #[doc(hidden)] fn bind_event_publisher(&self, _publisher: crate::events::BackendEventPublisher) {} #[doc(hidden)] diff --git a/openless-all/app/crates/openless-core/src/less_computer.rs b/openless-all/app/crates/openless-core/src/less_computer.rs index 157528278..dedf04a1c 100644 --- a/openless-all/app/crates/openless-core/src/less_computer.rs +++ b/openless-all/app/crates/openless-core/src/less_computer.rs @@ -23,6 +23,7 @@ const MAX_DSH_CONTINUATION_TURNS: usize = 2; pub(crate) const VOICE_CAPTURE_FAILED: &str = "Less Computer voice input failed. Please try again."; struct LessComputerState { + testing_gate: Mutex>, conversation_active: AtomicBool, approvals: Mutex>>, events: Mutex>, @@ -33,6 +34,12 @@ struct LessComputerState { voice_sessions: Arc, } +#[derive(Clone)] +struct TestingFeaturesGate { + preferences: Arc, + settings_write_gate: Arc>, +} + enum ActiveLease { Capture(ActiveCapture), Run(ActiveRun), @@ -85,6 +92,7 @@ impl LessComputerService { ) -> Self { Self { state: Arc::new(LessComputerState { + testing_gate: Mutex::new(None), conversation_active: AtomicBool::new(false), approvals: Mutex::new(HashMap::new()), events: Mutex::new(None), @@ -134,6 +142,26 @@ impl LessComputerService { } fn begin_capture_inner(&self, session_id: SessionId) -> Result<(), BackendError> { + let gate = self + .state + .testing_gate + .lock() + .expect("testing feature gate lock poisoned") + .clone(); + let _admission = gate.as_ref().map(|gate| { + gate.settings_write_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + }); + if gate + .as_ref() + .is_some_and(|gate| !gate.preferences.get().less_computer_available()) + { + return Err(BackendError::new( + BackendErrorCode::PermissionDenied, + "Less Computer is disabled", + )); + } let mut active = self .state .active_lease @@ -160,6 +188,26 @@ impl LessComputerService { &self, session_id: SessionId, ) -> Result, BackendError> { + let gate = self + .state + .testing_gate + .lock() + .expect("testing feature gate lock poisoned") + .clone(); + let _admission = gate.as_ref().map(|gate| { + gate.settings_write_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + }); + if gate + .as_ref() + .is_some_and(|gate| !gate.preferences.get().less_computer_available()) + { + return Err(BackendError::new( + BackendErrorCode::PermissionDenied, + "Less Computer is disabled", + )); + } let mut active = self .state .active_lease @@ -382,6 +430,20 @@ impl Drop for ApprovalLease { } impl LessComputerApi for LessComputerService { + fn bind_testing_features( + &self, + preferences: Arc, + settings_write_gate: Arc>, + ) { + *self + .state + .testing_gate + .lock() + .expect("testing feature gate lock poisoned") = Some(TestingFeaturesGate { + preferences, + settings_write_gate, + }); + } fn bind_event_publisher(&self, publisher: BackendEventPublisher) { *self .state diff --git a/openless-all/app/crates/openless-core/src/settings.rs b/openless-all/app/crates/openless-core/src/settings.rs index eb2100d1a..0f1a573a3 100644 --- a/openless-all/app/crates/openless-core/src/settings.rs +++ b/openless-all/app/crates/openless-core/src/settings.rs @@ -5,6 +5,20 @@ use crate::shared_types::{ HotkeyMode, ShortcutBinding, StylePackHotkey, UserPreferences, WindowsInsertionMode, }; +pub(crate) fn ensure_testing_features_enabled( + preferences: &UserPreferences, +) -> Result<(), BackendError> { + if preferences.testing_features_enabled { + Ok(()) + } else { + Err(BackendError::new( + crate::BackendErrorCode::PermissionDenied, + "Enable features under testing in Advanced settings first", + ) + .retryable(false)) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SettingsCollisionPolicy { @@ -71,7 +85,7 @@ impl From<&UserPreferences> for HotkeyRuntimeTarget { switch_style: preferences.switch_style_hotkey.clone(), open_app: preferences.open_app_hotkey.clone(), selection_polish: preferences.selection_polish_hotkey.clone(), - coding_agent_enabled: preferences.coding_agent_enabled, + coding_agent_enabled: preferences.less_computer_available(), coding_agent_voice: preferences.coding_agent_voice_hotkey.clone(), coding_agent_panel: preferences.coding_agent_panel_hotkey.clone(), coding_agent_quick: preferences.coding_agent_quick_hotkey.clone(), diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 9da6eb7a3..4982e809a 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -588,6 +588,9 @@ pub struct UserPreferences { /// a disabled pack auto-enables and activates it. #[serde(default)] pub style_pack_hotkeys: Vec, + /// Device-local opt-in for unfinished features. Hidden and unavailable by default. + #[serde(default)] + pub testing_features_enabled: bool, /// Less Computer: whether enabled. Default off; users must enable it in Advanced settings. #[serde(default)] pub coding_agent_enabled: bool, @@ -849,6 +852,10 @@ pub struct UserPreferences { } impl UserPreferences { + pub fn less_computer_available(&self) -> bool { + self.testing_features_enabled && self.coding_agent_enabled + } + pub fn preserve_style_preferences_from(&mut self, current: &Self) { self.default_mode = current.default_mode; self.enabled_modes = current.enabled_modes.clone(); @@ -1016,6 +1023,8 @@ struct UserPreferencesWire { #[serde(default)] style_pack_hotkeys: Vec, #[serde(default)] + testing_features_enabled: bool, + #[serde(default)] coding_agent_enabled: bool, #[serde(default = "default_coding_agent_provider")] coding_agent_provider: String, @@ -1228,6 +1237,7 @@ impl Default for UserPreferencesWire { switch_style_hotkey: prefs.switch_style_hotkey, open_app_hotkey: prefs.open_app_hotkey, style_pack_hotkeys: prefs.style_pack_hotkeys, + testing_features_enabled: prefs.testing_features_enabled, coding_agent_enabled: prefs.coding_agent_enabled, coding_agent_provider: prefs.coding_agent_provider, coding_agent_model: prefs.coding_agent_model, @@ -1425,6 +1435,7 @@ impl<'de> Deserialize<'de> for UserPreferences { selection_voice_edit_plan_format: wire.selection_voice_edit_plan_format, selection_voice_edit_system_prompt: wire.selection_voice_edit_system_prompt, qa_save_history: wire.qa_save_history, + testing_features_enabled: wire.testing_features_enabled, coding_agent_enabled: wire.coding_agent_enabled, coding_agent_provider: wire.coding_agent_provider, coding_agent_model: wire.coding_agent_model, @@ -1798,6 +1809,7 @@ impl Default for UserPreferences { switch_style_hotkey: default_switch_style_hotkey(), open_app_hotkey: default_open_app_hotkey(), style_pack_hotkeys: Vec::new(), + testing_features_enabled: false, coding_agent_enabled: false, coding_agent_provider: default_coding_agent_provider(), coding_agent_model: None, @@ -2806,6 +2818,29 @@ mod translation_effective_tests { mod tests { use super::*; + #[test] + fn legacy_less_computer_settings_remain_sealed_until_device_opt_in() { + let legacy: UserPreferences = serde_json::from_value(serde_json::json!({ + "codingAgentEnabled": true, + "codingAgentProvider": "codex-cli", + "codingAgentModel": "saved-model" + })) + .unwrap(); + assert!(!UserPreferences::default().testing_features_enabled); + assert!(!legacy.testing_features_enabled); + assert!(legacy.coding_agent_enabled); + assert!(!legacy.less_computer_available()); + let mut opted_in = legacy.clone(); + opted_in.testing_features_enabled = true; + let restored: UserPreferences = + serde_json::from_value(serde_json::to_value(&opted_in).unwrap()).unwrap(); + assert!(restored.less_computer_available()); + opted_in.testing_features_enabled = false; + assert!(!opted_in.less_computer_available()); + assert_eq!(opted_in.coding_agent_model, legacy.coding_agent_model); + assert_eq!(opted_in.coding_agent_provider, legacy.coding_agent_provider); + } + #[test] fn macos_newline_modes_round_trip_legacy_auto_and_line_feed() { assert_eq!(MacosNewlineMode::default(), MacosNewlineMode::Auto); diff --git a/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs b/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs index 7744a6171..c90c0b272 100644 --- a/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs +++ b/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs @@ -334,6 +334,7 @@ fn backend( ); let mut prefs = backend.get_preferences(); prefs.coding_agent_enabled = true; + prefs.testing_features_enabled = true; backend .update_settings(prefs, SettingsUpdateOptions::STRICT, &NoopSettingsRuntime) .unwrap(); diff --git a/openless-all/app/e2e/testing-features.spec.ts b/openless-all/app/e2e/testing-features.spec.ts new file mode 100644 index 000000000..6ad5f892c --- /dev/null +++ b/openless-all/app/e2e/testing-features.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; + +test('testing features require opt-in and retain their settings across revocation', async ({ + page, +}) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto('/'); + await page.getByRole('button', { name: '设置', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '设置', exact: true }); + await dialog.getByRole('button', { name: '实验与扩展', exact: true }).click(); + const beta = dialog.locator('[data-ol-advanced-entry="testingFeatures"]'); + const lessEntry = dialog.locator('[data-ol-advanced-entry="lessComputer"]'); + const consoleEntry = dialog.locator('[data-ol-advanced-entry="claudeConsole"]'); + const lessDetail = dialog.locator('[data-ol-advanced-page="lessComputer"]'); + await expect(beta).toBeVisible(); + await expect(lessEntry).toHaveCount(0); + await expect(consoleEntry).toHaveCount(0); + await expect(lessDetail).toHaveCount(0); + await beta.click(); + const toggle = dialog.getByRole('switch', { name: '启用正在测试中的功能', exact: true }); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + await toggle.click(); + await expect(lessEntry).toBeVisible(); + await expect(consoleEntry).toBeVisible(); + await lessEntry.click(); + await expect(lessDetail).toBeVisible(); + await lessDetail.getByRole('switch').first().click(); + await expect(lessDetail.getByRole('switch').first()).toHaveAttribute('aria-checked', 'true'); + await dialog.getByRole('button', { name: '返回正在测试中的功能', exact: true }).click(); + await toggle.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + await expect(lessDetail).toHaveCount(0); + await expect(lessEntry).toHaveCount(0); + await expect(consoleEntry).toHaveCount(0); + await dialog.getByRole('button', { name: '快捷键与选区', exact: true }).click(); + await expect(dialog.getByText('Less Computer', { exact: true })).toHaveCount(0); + await dialog.getByRole('button', { name: '实验与扩展', exact: true }).click(); + await beta.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + await toggle.click(); + await lessEntry.click(); + await expect(lessDetail.getByRole('switch').first()).toHaveAttribute('aria-checked', 'true'); + expect(errors).toEqual([]); +}); diff --git a/openless-all/app/linux-egui/examples/headless_host.rs b/openless-all/app/linux-egui/examples/headless_host.rs index 7e7e6bf04..2ada52eb1 100644 --- a/openless-all/app/linux-egui/examples/headless_host.rs +++ b/openless-all/app/linux-egui/examples/headless_host.rs @@ -155,6 +155,7 @@ async fn main() -> Result<(), BackendError> { // requiring a coding-agent process. let mut less_computer_preferences = backend.get_preferences(); less_computer_preferences.coding_agent_enabled = true; + less_computer_preferences.testing_features_enabled = true; linux_host.update_settings_strict( less_computer_preferences, linux_host.snapshot().preferences_revision, diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index ec1ec21a2..2ce768e60 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -2757,6 +2757,46 @@ pub const CATALOG: &[Msg] = &[ "키를 누르고 말하면 선택한 Agent가 PC를 조작합니다. macOS 전용.", ), }, + Msg { + key: "settings.testing_features.title", + text: row( + "正在测试中的功能", + "正在測試中的功能", + "Features under testing", + "テスト中の機能", + "테스트 중인 기능", + ), + }, + Msg { + key: "settings.testing_features.enable", + text: row( + "启用正在测试中的功能", + "啟用正在測試中的功能", + "Enable features under testing", + "テスト中の機能を有効にする", + "테스트 중인 기능 활성화", + ), + }, + Msg { + key: "settings.testing_features.description", + text: row( + "开启后显示 Less Computer 和 Claude 控制台,可在支持的平台配置和使用。关闭会停止运行并隐藏入口,保留已有配置。仅在当前设备生效。", + "開啟後顯示 Less Computer 和 Claude 控制台,可在支援的平台設定和使用。關閉會停止執行並隱藏入口,保留既有設定。僅在目前裝置生效。", + "Show Less Computer and the Claude console for setup and use on supported platforms. Turning this off stops active tasks and hides their entries while keeping your settings. Applies only to this device.", + "対応するプラットフォームで Less Computer と Claude コンソールを表示し、設定・使用できます。オフにすると実行中のタスクを停止して項目を非表示にしますが、設定は保持されます。このデバイスのみに適用されます。", + "지원되는 플랫폼에서 Less Computer와 Claude 콘솔을 표시하고 설정 및 사용할 수 있습니다. 끄면 실행 중인 작업을 중지하고 항목을 숨기지만 설정은 유지됩니다. 이 기기에만 적용됩니다.", + ), + }, + Msg { + key: "modal.advanced_pages.testing_features", + text: row( + "按需开启尚在测试中的功能。", + "依需要開啟尚在測試中的功能。", + "Opt in to features that are still being tested.", + "テスト中の機能を必要に応じて有効にします。", + "필요할 때 테스트 중인 기능을 활성화합니다.", + ), + }, Msg { key: "settings.coding_agent.title", text: row( @@ -3756,11 +3796,11 @@ pub const CATALOG: &[Msg] = &[ Msg { key: "modal.descriptions.advanced", text: row( - "按需配置 Less Computer、多模态与调试功能。", - "按需設定 Less Computer、多模態與除錯功能。", - "Configure Less Computer, multimodal processing and debugging as needed.", - "必要に応じて Less Computer、マルチモーダル処理、デバッグを設定します。", - "필요에 따라 Less Computer, 멀티모달 처리 및 디버깅을 설정합니다.", + "按需配置测试功能、多模态与调试工具。", + "依需要設定測試功能、多模態與除錯工具。", + "Configure testing features, multimodal processing, and debugging tools.", + "テスト機能、マルチモーダル処理、デバッグツールを設定します。", + "테스트 기능, 멀티모달 처리 및 디버깅 도구를 설정합니다.", ), }, Msg { diff --git a/openless-all/app/linux-egui/src/linux_app/settings_save.rs b/openless-all/app/linux-egui/src/linux_app/settings_save.rs index 3a4f3a2a6..65ad3cdec 100644 --- a/openless-all/app/linux-egui/src/linux_app/settings_save.rs +++ b/openless-all/app/linux-egui/src/linux_app/settings_save.rs @@ -177,6 +177,9 @@ impl OpenLessEguiApp { frontend::view_model::SettingsField::LessComputer => { preferences.coding_agent_enabled = !preferences.coding_agent_enabled; } + frontend::view_model::SettingsField::TestingFeatures => { + preferences.testing_features_enabled = !preferences.testing_features_enabled; + } frontend::view_model::SettingsField::SilenceAutoStop => { preferences.silence_auto_stop_enabled = !preferences.silence_auto_stop_enabled; } diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 799a50609..74bbb9865 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -1086,6 +1086,12 @@ mod linux_app { } fn show_less_computer_popup(&mut self) { + if self + .backend() + .is_none_or(|backend| backend.ensure_testing_features_enabled().is_err()) + { + return; + } self.ensure_popup(PopupKind::LessComputer); let message = self.less_computer_snapshot(self.lang); self.send_popup(PopupKind::LessComputer, message); @@ -2408,6 +2414,12 @@ mod linux_app { } } BackendEventKind::LessComputerEvent(event) => { + if self + .backend() + .is_some_and(|backend| !backend.get_preferences().testing_features_enabled) + { + return; + } // Less Computer events may complete after a newer turn has // already started. Session ownership, not arrival time, // decides whether a delta/terminal may mutate this view. @@ -2538,6 +2550,17 @@ mod linux_app { } self.preferences = Some(self.settings_save.rebase(&latest)); } + if self + .backend() + .is_some_and(|backend| !backend.get_preferences().testing_features_enabled) + { + // Dropping the supervisor closes a feature that is no longer opted in. + self.less_computer_popup = None; + self.less_computer_working = false; + self.pending_approval = None; + self.less_computer_session = None; + self.less_computer_entries.clear(); + } self.load_remote_status(); self.load_library(); } @@ -3969,6 +3992,7 @@ mod linux_app { s.system_proxy = prefs.use_system_proxy; s.multimodal = prefs.multimodal_pipeline_enabled; s.less_computer = prefs.coding_agent_enabled; + s.testing_features = prefs.testing_features_enabled; s.coding_agent_provider = match prefs.coding_agent_provider.as_str() { "opencode-cli" => 1, "codex-cli" => 2, diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index 88a17c578..91bd6c0e1 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -789,6 +789,7 @@ mod tests { ..Default::default() }; vm.settings.less_computer = enabled; + vm.settings.testing_features = true; let mut painted = String::new(); for _ in 0..2 { ctx.begin_pass(egui::RawInput { @@ -807,7 +808,31 @@ mod tests { } } - /// 渲染设置页并把这一帧画出的文字按行返回。 + #[test] + fn testing_features_hide_root_entries_and_revoke_an_open_detail_page() { + use super::view_model::SettingsSection; + let mut vm = FrontendViewModel::default(); + let root = painted_settings_lines(SettingsSection::Advanced, &mut vm); + assert!(root.iter().any(|line| line.contains("正在测试中的功能"))); + assert!(!root.iter().any(|line| line == "Less Computer")); + vm.advanced_open = 3; + let off = painted_settings_lines(SettingsSection::Advanced, &mut vm); + assert!(!off.iter().any(|line| line == "Less Computer")); + vm.settings.testing_features = true; + let on = painted_settings_lines(SettingsSection::Advanced, &mut vm); + assert!(on.iter().any(|line| line == "Less Computer")); + vm.advanced_open = 0; + vm.settings.less_computer = true; + vm.settings.testing_features = false; + let revoked = painted_settings_lines(SettingsSection::Advanced, &mut vm); + assert_eq!(vm.advanced_open, 3); + assert!(!revoked.iter().any(|line| line == "Less Computer")); + let shortcuts = painted_settings_lines(SettingsSection::Shortcuts, &mut vm); + let agent_voice = openless_linux_egui::tr_l10n(vm.lang, "settings.shortcuts.agent_voice"); + assert!(!shortcuts.iter().any(|line| line == agent_voice)); + } + + /// Render two frames so layout and text are settled. fn painted_settings_lines( section: super::view_model::SettingsSection, vm: &mut FrontendViewModel, diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index fb07a1d54..b3dbe8d73 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -338,6 +338,9 @@ fn panel(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec Option<(&'static s tr_l10n(lang, "settings.debug.title"), tr_l10n(lang, "modal.advanced_pages.debug"), )), + 3 => Some(( + tr_l10n(lang, "settings.testing_features.title"), + tr_l10n(lang, "modal.advanced_pages.testing_features"), + )), _ => None, } } @@ -927,6 +934,9 @@ fn shortcuts(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec( /// as QA). `macos` build only. #[cfg(target_os = "macos")] pub(crate) fn show_less_computer_window(app: &AppHandle) { + if app + .try_state::>() + .is_none_or(|backend| backend.ensure_testing_features_enabled().is_err()) + { + return; + } let Some(window) = ensure_less_computer_window(app) else { log::info!("[less-computer] show 跳过:窗口不存在"); return; }; let window_clone = window.clone(); + let app_for_show = app.clone(); let _ = app.run_on_main_thread(move || { + if app_for_show + .try_state::>() + .is_none_or(|backend| backend.ensure_testing_features_enabled().is_err()) + { + return; + } use objc2::msg_send; use objc2::runtime::AnyObject; // This helper is also called from the Tokio worker that executes a text or @@ -3108,6 +3121,12 @@ pub(crate) fn show_less_computer_window(app: &AppHandle) { #[cfg(target_os = "windows")] pub(crate) fn show_less_computer_window(app: &AppHandle) { + if app + .try_state::>() + .is_none_or(|backend| backend.ensure_testing_features_enabled().is_err()) + { + return; + } let Some(window) = ensure_less_computer_window(app) else { return; }; diff --git a/openless-all/app/src-tauri/src/tauri_events.rs b/openless-all/app/src-tauri/src/tauri_events.rs index cfb928f9c..dd67fe96a 100644 --- a/openless-all/app/src-tauri/src/tauri_events.rs +++ b/openless-all/app/src-tauri/src/tauri_events.rs @@ -527,6 +527,10 @@ fn map_dictation_state( fn emit_preferences(app: &AppHandle, backend: &OpenLessBackend) { let preferences = backend.get_preferences(); + if !preferences.testing_features_enabled { + crate::hide_less_computer_window(app); + crate::hide_less_computer_glow(app); + } // Cloud restore also changes preferences directly through Core. Keep native // capsule geometry and hit testing current for every source of a preference change. if let Some(coordinator) = app.try_state::>() { diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index 0d8ded3ff..c5bdd15cc 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -1,4 +1,6 @@ // Settings categories reuse the existing consumers; complex experiments open in the right pane. +import { useHotkeySettings } from '../state/HotkeySettingsContext'; + import { useEffect, useLayoutEffect, @@ -29,6 +31,7 @@ import { searchSettingsSections, visibleSettingsSections, visibleAdvancedPages, + isTestingFeaturePage, type AdvancedPageId, type SettingsSectionId, } from '../pages/settings/navigation'; @@ -99,7 +102,9 @@ export function SettingsModal({ const searching = query.trim().length > 0; useContentMotion(scrollRef, `${section}:${searching ? 'search' : (advancedPage ?? 'root')}`); const results = searchSettingsSections(sections, query); - const advancedPages = visibleAdvancedPages(platformCaps?.platform, os); + const { prefs } = useHotkeySettings(); + const testingFeaturesEnabled = prefs?.testingFeaturesEnabled === true; + const advancedPages = visibleAdvancedPages(platformCaps?.platform, os, testingFeaturesEnabled); const activeAdvancedPage = section === 'advanced' && !searching ? advancedPages.find((page) => page.id === advancedPage) @@ -187,8 +192,8 @@ export function SettingsModal({ useEffect(() => { if (advancedPage && !advancedPages.some((page) => page.id === advancedPage)) - setAdvancedPage(null); - }, [advancedPage, platformCaps, os]); + setAdvancedPage(isTestingFeaturePage(advancedPage) ? 'testingFeatures' : null); + }, [advancedPage, platformCaps, os, testingFeaturesEnabled]); const openAdvancedPage = (page: AdvancedPageId) => { setAdvancedPage(page); @@ -197,7 +202,7 @@ export function SettingsModal({ const backToAdvanced = () => { const previousPage = advancedPage; - setAdvancedPage(null); + setAdvancedPage(isTestingFeaturePage(previousPage) ? 'testingFeatures' : null); window.requestAnimationFrame(() => { surfaceRef.current ?.querySelector(`[data-ol-advanced-entry="${previousPage}"]`) @@ -533,8 +538,16 @@ export function SettingsModal({ type="button" className="ol-settings-back" onClick={backToAdvanced} - aria-label={t('modal.backToAdvanced')} - title={t('modal.backToAdvanced')} + aria-label={t( + isTestingFeaturePage(advancedPage) + ? 'modal.backToTestingFeatures' + : 'modal.backToAdvanced', + )} + title={t( + isTestingFeaturePage(advancedPage) + ? 'modal.backToTestingFeatures' + : 'modal.backToAdvanced', + )} > diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index ed9c03e2e..cc94ad24a 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1221,6 +1221,13 @@ export const de: typeof zhCN = { cancelledTitle: 'Aufnahme abgebrochen', }, settings: { + testingFeatures: { + title: 'Funktionen im Test', + enable: 'Funktionen im Test aktivieren', + description: + 'Less Computer und die Claude-Konsole auf unterstützten Plattformen anzeigen, einrichten und verwenden. Das Ausschalten stoppt laufende Aufgaben und blendet die Einträge aus. Ihre Einstellungen bleiben erhalten. Gilt nur für dieses Gerät.', + unavailable: 'Auf dieser Plattform sind noch keine Testfunktionen verfügbar.', + }, selectionWorkspace: { title: 'Assistent für Textauswahl', hint: 'Wähle Text aus und verwende einen Kurzbefehl: Ohne Sprachbearbeitung wird der Text direkt überarbeitet. Mit Sprachbearbeitung hältst du die Taste gedrückt und sprichst; danach wählst du „Fragen“ oder „Bearbeiten“.', @@ -2368,7 +2375,9 @@ export const de: typeof zhCN = { noResults: 'Keine passenden Kategorien. Versuche „Mikrofon“, „Modelle“ oder „Design“.', autoSaveHint: 'Änderungen werden automatisch gespeichert', backToAdvanced: 'Zurück zu Experimente und Erweiterungen', + backToTestingFeatures: 'Zurück zu Funktionen im Test', advancedPages: { + testingFeatures: 'Funktionen, die noch getestet werden, bei Bedarf aktivieren.', vocabularyLearning: 'Aus manuellen Korrekturen lernen; Beobachtung, Bestätigungszeit und Wortlänge einstellen.', lessComputer: @@ -2386,7 +2395,7 @@ export const de: typeof zhCN = { appearance: 'Design, Seitenlayout und Sprache der Oberfläche für angenehmes Lesen anpassen.', privacy: 'Systemberechtigungen und Verbindungen prüfen. Verlauf, Aufnahmen und lokale Daten verwalten.', - advanced: 'Less Computer und Diagnose nach Bedarf einrichten.', + advanced: 'Testfunktionen, Vokabellernen und Debugging-Werkzeuge konfigurieren.', about: 'Version, Updatekanal und automatische Updateeinstellungen anzeigen.', }, searchKeywords: { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 970ab81bc..8a3b039bf 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1199,6 +1199,13 @@ export const en: typeof zhCN = { cancelledTitle: 'Recording cancelled', }, settings: { + testingFeatures: { + title: 'Features under testing', + enable: 'Enable features under testing', + description: + 'Show Less Computer and the Claude console for setup and use on supported platforms. Turning this off stops active tasks and hides their entries while keeping your settings. Applies only to this device.', + unavailable: 'No testing features are available on this platform yet.', + }, selectionWorkspace: { title: 'Selection Assistant', hint: 'With a selection, one shortcut polishes (voice edit off) or takes a spoken instruction to ask or edit. With no selection but focus in a text field, speak to Help me write a draft or ask a question.', @@ -2306,7 +2313,9 @@ export const en: typeof zhCN = { noResults: 'No matching categories. Try “microphone”, “models” or “theme”.', autoSaveHint: 'Changes save automatically', backToAdvanced: 'Back to Experiments & extensions', + backToTestingFeatures: 'Back to features under testing', advancedPages: { + testingFeatures: 'Opt in to features that are still being tested.', vocabularyLearning: 'Learn from manual corrections after dictation; configure observation, confirmation time and phrase length.', lessComputer: 'Choose an agent and configure its model, permissions, and working directory.', @@ -2323,7 +2332,7 @@ export const en: typeof zhCN = { appearance: 'Adjust the theme, page layout and interface language for comfortable reading.', privacy: 'Check system permissions and connections. Manage history, recordings and local data.', - advanced: 'Configure Less Computer and debugging as needed.', + advanced: 'Configure testing features, vocabulary learning, and debugging tools.', about: 'View your version, update channel and automatic update settings.', }, searchKeywords: { diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index c3ecec806..b1e37fbe9 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1214,6 +1214,13 @@ export const es: typeof zhCN = { cancelledTitle: 'Grabación cancelada', }, settings: { + testingFeatures: { + title: 'Funciones en fase de pruebas', + enable: 'Activar funciones en fase de pruebas', + description: + 'Mostrar Less Computer y la consola de Claude para configurarlos y usarlos en plataformas compatibles. Al desactivar esta opción, se detienen las tareas y se ocultan las entradas, pero se conservan los ajustes. Solo se aplica a este dispositivo.', + unavailable: 'Todavía no hay funciones de prueba disponibles en esta plataforma.', + }, selectionWorkspace: { title: 'Asistente de selección', hint: 'Selecciona texto y usa un solo atajo: mejora el texto si la edición por voz está desactivada; si está activada, mantén pulsado y habla, y luego elige Preguntar o Editar.', @@ -2350,7 +2357,9 @@ export const es: typeof zhCN = { noResults: 'No se encontraron categorías. Prueba «micrófono», «modelos» o «tema».', autoSaveHint: 'Los cambios se guardan automáticamente', backToAdvanced: 'Volver a Experimentos y extensiones', + backToTestingFeatures: 'Volver a funciones en fase de pruebas', advancedPages: { + testingFeatures: 'Activar opcionalmente las funciones que siguen en pruebas.', vocabularyLearning: 'Aprender de correcciones manuales; configurar observación, confirmación y longitud.', lessComputer: 'Elige un agente y configura su modelo, permisos y directorio de trabajo.', @@ -2368,7 +2377,8 @@ export const es: typeof zhCN = { appearance: 'Ajusta el tema, el diseño y el idioma de la interfaz para leer con comodidad.', privacy: 'Comprueba los permisos y las conexiones. Administra el historial, las grabaciones y los datos locales.', - advanced: 'Configura Less Computer y la depuración según tus necesidades.', + advanced: + 'Configurar funciones de prueba, aprendizaje de vocabulario y herramientas de depuración.', about: 'Consulta tu versión, el canal y los ajustes de actualización automática.', }, searchKeywords: { diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index c9b2bc894..7d609f4b6 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1235,6 +1235,13 @@ export const fr: typeof zhCN = { cancelledTitle: 'Enregistrement annulé', }, settings: { + testingFeatures: { + title: 'Fonctionnalités en cours de test', + enable: 'Activer les fonctionnalités en cours de test', + description: + 'Afficher Less Computer et la console Claude pour les configurer et les utiliser sur les plateformes compatibles. La désactivation arrête les tâches en cours et masque les entrées tout en conservant vos réglages. Ce choix ne concerne que cet appareil.', + unavailable: 'Aucune fonctionnalité de test n’est encore disponible sur cette plateforme.', + }, selectionWorkspace: { title: 'Assistant de sélection', hint: 'Sélectionnez du texte, puis utilisez un seul raccourci : amélioration directe sans édition vocale ; sinon, maintenez et parlez, puis choisissez Question ou Modification.', @@ -2383,7 +2390,9 @@ export const fr: typeof zhCN = { noResults: 'Aucune catégorie correspondante. Essayez « microphone », « modèles » ou « thème ».', autoSaveHint: 'Les modifications sont enregistrées automatiquement', backToAdvanced: 'Retour à Expériences et extensions', + backToTestingFeatures: 'Retour aux fonctionnalités en cours de test', advancedPages: { + testingFeatures: 'Activer à la demande les fonctionnalités encore en test.', vocabularyLearning: 'Apprendre des corrections manuelles ; régler l’observation, la confirmation et la longueur.', lessComputer: @@ -2404,7 +2413,8 @@ export const fr: typeof zhCN = { 'Réglez le thème, la disposition et la langue de l’interface pour une lecture confortable.', privacy: 'Vérifiez les autorisations et les connexions. Gérez l’historique, les enregistrements et les données locales.', - advanced: 'Configurez Less Computer et le débogage selon vos besoins.', + advanced: + 'Configurer les fonctions de test, l’apprentissage du vocabulaire et les outils de débogage.', about: 'Consultez votre version, le canal et les réglages de mise à jour automatique.', }, searchKeywords: { diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 64e9e0363..8ed1672bd 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1188,6 +1188,13 @@ export const ja: typeof zhCN = { cancelledTitle: '録音をキャンセルしました', }, settings: { + testingFeatures: { + title: 'テスト中の機能', + enable: 'テスト中の機能を有効にする', + description: + '対応するプラットフォームで Less Computer と Claude コンソールを表示し、設定・使用できます。オフにすると実行中のタスクを停止して項目を非表示にしますが、設定は保持されます。このデバイスのみに適用されます。', + unavailable: 'このプラットフォームでは、利用できるテスト機能はまだありません。', + }, selectionWorkspace: { title: '選択範囲アシスタント', hint: 'テキスト選択後、同じショートカットで:音声編集オフ時は推敲、オン時は押しながら話してから「質問」か「編集」を選択。', @@ -2275,7 +2282,9 @@ export const ja: typeof zhCN = { noResults: 'カテゴリが見つかりません。「マイク」「モデル」「テーマ」などをお試しください。', autoSaveHint: '変更は自動保存されます', backToAdvanced: '実験と拡張に戻る', + backToTestingFeatures: 'テスト中の機能に戻る', advancedPages: { + testingFeatures: 'テスト中の機能を必要に応じて有効にします。', vocabularyLearning: '音声入力後の手動修正から学習し、監視時間・確認時間・文字数を設定します。', lessComputer: 'Agent を選び、モデル・権限・作業ディレクトリを設定します。', @@ -2290,7 +2299,7 @@ export const ja: typeof zhCN = { services: '音声認識と文章処理のサービス、チャンネル、ローカルモデル、接続を管理します。', appearance: 'テーマ、レイアウト、表示言語を読みやすく調整します。', privacy: 'システム権限と接続を確認し、履歴、録音、ローカルデータを管理します。', - advanced: '必要に応じて Less Computer とデバッグを設定します。', + advanced: 'テスト機能、語彙学習、デバッグツールを設定します。', about: '現在のバージョン、更新チャンネル、自動更新を確認します。', }, searchKeywords: { diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 371cc7e2d..63b0a1208 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1182,6 +1182,13 @@ export const ko: typeof zhCN = { cancelledTitle: '녹음이 취소됨', }, settings: { + testingFeatures: { + title: '테스트 중인 기능', + enable: '테스트 중인 기능 활성화', + description: + '지원되는 플랫폼에서 Less Computer와 Claude 콘솔을 표시하고 설정 및 사용할 수 있습니다. 끄면 실행 중인 작업을 중지하고 항목을 숨기지만 설정은 유지됩니다. 이 기기에만 적용됩니다.', + unavailable: '이 플랫폼에서는 아직 사용 가능한 테스트 기능이 없습니다.', + }, selectionWorkspace: { title: '선택 영역 도우미', hint: '텍스트 선택 후 같은 단축키: 음성 편집 끄면 바로 다듬기, 켜면 누른 채 말한 뒤 「질문」 또는 「편집」 선택.', @@ -2256,7 +2263,9 @@ export const ko: typeof zhCN = { noResults: '일치하는 카테고리가 없습니다. “마이크”, “모델” 또는 “테마”를 검색해 보세요.', autoSaveHint: '변경 사항이 자동 저장됩니다', backToAdvanced: '실험 및 확장으로 돌아가기', + backToTestingFeatures: '테스트 중인 기능으로 돌아가기', advancedPages: { + testingFeatures: '필요할 때 테스트 중인 기능을 활성화합니다.', vocabularyLearning: '음성 입력 후 수동 수정으로 학습하고 관찰 시간, 확인 시간, 글자 수를 설정합니다.', lessComputer: 'Agent를 선택하고 모델, 권한, 작업 디렉터리를 설정합니다.', @@ -2271,7 +2280,7 @@ export const ko: typeof zhCN = { services: '음성 인식과 텍스트 처리 서비스, 채널, 로컬 모델 및 연결을 관리합니다.', appearance: '테마, 페이지 배치, 인터페이스 언어를 편하게 읽도록 조정합니다.', privacy: '시스템 권한과 연결을 확인하고 기록, 녹음 및 로컬 데이터를 관리합니다.', - advanced: '필요에 따라 Less Computer와 디버깅을 설정합니다.', + advanced: '테스트 기능, 어휘 학습 및 디버깅 도구를 설정합니다.', about: '현재 버전, 업데이트 채널 및 자동 업데이트 설정을 확인합니다.', }, searchKeywords: { diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ee582d872..5e05b0d2d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1152,6 +1152,13 @@ export const zhCN = { cancelledTitle: '已取消录音', }, settings: { + testingFeatures: { + title: '正在测试中的功能', + enable: '启用正在测试中的功能', + description: + '开启后显示 Less Computer 和 Claude 控制台,可在支持的平台配置和使用。关闭会停止运行并隐藏入口,保留已有配置。仅在当前设备生效。', + unavailable: '当前平台暂无可用的测试功能。', + }, selectionWorkspace: { title: '选区助手', hint: '有选区时按同一快捷键:关闭语音编辑则润色;开启后口述指令,可提问或编辑选区。无选区但光标在文本框时:可「帮我写」成稿或提问。', @@ -2175,7 +2182,9 @@ export const zhCN = { noResults: '没有找到相关分类。试试“麦克风”“模型”或“主题”。', autoSaveHint: '修改后自动保存', backToAdvanced: '返回实验与扩展', + backToTestingFeatures: '返回正在测试中的功能', advancedPages: { + testingFeatures: '按需开启尚在测试中的功能。', vocabularyLearning: '从听写后的手动改词中学习,配置观察时长、确认时间和词长。', lessComputer: '选择 Agent,配置模型、权限与工作目录。', claudeConsole: '检测 Claude Code,并查看测试任务的运行输出。', @@ -2189,7 +2198,7 @@ export const zhCN = { services: '选择语音识别与文字处理服务,管理渠道、本地模型和网络连接。', appearance: '调整主题、页面排版和界面语言,让阅读更舒服。', privacy: '检查系统权限与连接状态,管理历史、录音和本地数据。', - advanced: '按需配置 Less Computer 与调试功能。', + advanced: '配置测试功能、手改学词与调试工具。', about: '查看当前版本、更新渠道与自动更新设置。', }, searchKeywords: { diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 5b7cc7b3e..a0491e1bd 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1153,6 +1153,13 @@ export const zhTW: typeof zhCN = { cancelledTitle: '已取消錄音', }, settings: { + testingFeatures: { + title: '正在測試中的功能', + enable: '啟用正在測試中的功能', + description: + '開啟後顯示 Less Computer 和 Claude 控制台,可在支援的平台設定和使用。關閉會停止執行並隱藏入口,保留既有設定。僅在目前裝置生效。', + unavailable: '目前平台暫無可用的測試功能。', + }, selectionWorkspace: { title: '選區助手', hint: '選中文字後按同一快捷鍵:關閉語音編輯時直接潤色;開啟後口述指令,說完再選擇「提問」或「編輯選區」。', @@ -2175,7 +2182,9 @@ export const zhTW: typeof zhCN = { noResults: '找不到相關分類。試試「麥克風」「模型」或「主題」。', autoSaveHint: '修改後自動儲存', backToAdvanced: '返回實驗與擴充', + backToTestingFeatures: '返回正在測試中的功能', advancedPages: { + testingFeatures: '依需要開啟尚在測試中的功能。', vocabularyLearning: '從聽寫後的手動改詞中學習,設定觀察時長、確認時間和詞長。', lessComputer: '選擇 Agent,設定模型、權限與工作目錄。', claudeConsole: '偵測 Claude Code,並查看測試工作的執行輸出。', @@ -2189,7 +2198,7 @@ export const zhTW: typeof zhCN = { services: '選擇語音辨識與文字處理服務,管理管道、本機模型和網路連線。', appearance: '調整主題、頁面排版和介面語言,讓閱讀更舒服。', privacy: '檢查系統權限與連線狀態,管理歷史、錄音和本機資料。', - advanced: '按需設定 Less Computer 與除錯功能。', + advanced: '設定測試功能、手改學詞與除錯工具。', about: '查看目前版本、更新管道與自動更新設定。', }, searchKeywords: { diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 799121d82..81e0192d5 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -94,6 +94,7 @@ export let mockSettings: UserPreferences = { }, openAppHotkey: { primary: 'O', modifiers: defaultAppShortcutModifiers() }, stylePackHotkeys: [], + testingFeaturesEnabled: false, codingAgentEnabled: false, codingAgentProvider: 'claude-code-cli', codingAgentModel: null, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index d9cf11ab8..e8916aca7 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -438,6 +438,8 @@ export interface UserPreferences { openAppHotkey: ShortcutBinding | null; /** Style pack direct hotkeys: pressing one activates the corresponding style pack. Default empty list (issue #759). */ stylePackHotkeys: StylePackHotkey[]; + /** Local opt-in for features under testing. Defaults to false. */ + testingFeaturesEnabled: boolean; /** Less Computer: whether enabled. Off by default. */ codingAgentEnabled: boolean; /** Agent backend: claude-code-cli (default) / opencode-cli / codex-cli / dsh-cli. */ diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index f833fda63..a36d5cc34 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -326,7 +326,7 @@ export function ShortcutsSection() { }} /> - {(os === 'mac' || os === 'win') && ( + {prefs.testingFeaturesEnabled && (os === 'mac' || os === 'win') && ( item.id === 'lessComputer'), + visibleAdvancedPages('desktop', 'win', true).some((item) => item.id === 'lessComputer'), true, 'Windows retains Less Computer configuration', ); @@ -91,7 +91,7 @@ assert.equal( ); assert.deepEqual( visibleAdvancedPages('android', 'android').map((item) => item.id), - ['vocabularyLearning', 'debug'], + ['testingFeatures', 'vocabularyLearning', 'debug'], 'the pipeline switch lives in Services on every platform', ); console.log('settings navigation tests passed'); @@ -175,3 +175,25 @@ assert.deepEqual( 'mobile service tabs stay stable', ); console.log('settings service view tests passed'); + +for (const os of ['mac', 'win', 'android'] as const) { + assert.equal( + visibleAdvancedPages('desktop', os).some((item) => + ['lessComputer', 'claudeConsole'].includes(item.id), + ), + false, + `${os}: unfinished features are hidden without a device opt-in`, + ); + assert.equal( + visibleAdvancedPages('desktop', os).some((item) => item.id === 'testingFeatures'), + true, + `${os}: the Beta opt-in page remains reachable`, + ); +} +assert.deepEqual( + visibleAdvancedPages('desktop', 'mac', true) + .filter((item) => ['lessComputer', 'claudeConsole'].includes(item.id)) + .map((item) => item.id), + ['lessComputer', 'claudeConsole'], + 'macOS exposes both testing features after opt-in', +); diff --git a/openless-all/app/src/pages/settings/navigation.ts b/openless-all/app/src/pages/settings/navigation.ts index d24223c4e..07d897b6a 100644 --- a/openless-all/app/src/pages/settings/navigation.ts +++ b/openless-all/app/src/pages/settings/navigation.ts @@ -12,6 +12,7 @@ export type SettingsSectionId = | 'about'; export const ADVANCED_PAGES = [ + { id: 'testingFeatures', icon: 'sparkle', titleKey: 'settings.testingFeatures.title' }, { id: 'lessComputer', icon: 'mac', titleKey: 'settings.codingAgent.title' }, { id: 'claudeConsole', icon: 'chevLR', titleKey: 'settings.codingConsole.title' }, { id: 'vocabularyLearning', icon: 'sparkle', titleKey: 'settings.vocabularyLearning.title' }, @@ -21,8 +22,17 @@ export const ADVANCED_PAGES = [ export type AdvancedPage = (typeof ADVANCED_PAGES)[number]; export type AdvancedPageId = AdvancedPage['id']; -export function visibleAdvancedPages(platform: PlatformKind | undefined, os: OS): AdvancedPage[] { +export function isTestingFeaturePage(page: AdvancedPageId | null): boolean { + return page === 'lessComputer' || page === 'claudeConsole'; +} + +export function visibleAdvancedPages( + platform: PlatformKind | undefined, + os: OS, + enabled = false, +): AdvancedPage[] { return ADVANCED_PAGES.filter((page) => { + if (isTestingFeaturePage(page.id) && !enabled) return false; if (page.id === 'lessComputer') return platform === 'desktop' && (os === 'mac' || os === 'win'); if (page.id === 'claudeConsole') return platform === 'desktop' && os === 'mac'; if (page.id === 'vocabularyLearning') diff --git a/openless-all/app/src/pages/settings/navigationLocale.test.ts b/openless-all/app/src/pages/settings/navigationLocale.test.ts index b1b60f493..31ab35fbc 100644 --- a/openless-all/app/src/pages/settings/navigationLocale.test.ts +++ b/openless-all/app/src/pages/settings/navigationLocale.test.ts @@ -4,15 +4,31 @@ import { zhTW } from '../../i18n/zh-TW'; import { en } from '../../i18n/en'; import { ja } from '../../i18n/ja'; import { ko } from '../../i18n/ko'; +import { de } from '../../i18n/de'; +import { fr } from '../../i18n/fr'; +import { es } from '../../i18n/es'; import { ADVANCED_PAGES, SETTINGS_SECTIONS, searchSettingsSections } from './navigation'; // Resources must resolve where the dialog actually reads them, not in a nested // feature's unrelated "modal" object. Type-shape parity alone cannot detect this. -for (const [locale, translation] of Object.entries({ 'zh-CN': zhCN, 'zh-TW': zhTW, en, ja, ko })) { +for (const [locale, translation] of Object.entries({ + 'zh-CN': zhCN, + 'zh-TW': zhTW, + en, + ja, + ko, + de, + fr, + es, +})) { const i18n = createInstance(); await i18n.init({ lng: locale, fallbackLng: false, resources: { [locale]: { translation } } }); for (const key of [ 'modal.backToAdvanced', + 'modal.backToTestingFeatures', + ...['title', 'enable', 'description', 'unavailable'].map( + (key) => `settings.testingFeatures.${key}`, + ), ...ADVANCED_PAGES.flatMap((page) => [page.titleKey, `modal.advancedPages.${page.id}`]), ]) { if (!i18n.exists(key)) throw new Error(`${locale}: missing advanced navigation label ${key}`); @@ -47,4 +63,4 @@ for (const [locale, translation] of Object.entries({ 'zh-CN': zhCN, 'zh-TW': zhT if (!i18n.t('modal.searchCount', { count: 2 }).includes('2')) throw new Error(`${locale}: result count is not rendered`); } -console.log('settings localization and search routing passed in five languages'); +console.log('settings localization and search routing passed in eight languages'); diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index c04d6d5e1..661c504a1 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -32,11 +32,12 @@ import { listChannels } from '../../lib/ipc'; import type { PlatformCapabilities } from '../../lib/types'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { emitSaved } from '../../lib/savedEvent'; -import { SettingRow, segmentedTrackStyle } from './shared'; +import { SettingRow, Toggle, ExperimentalSectionTitle, segmentedTrackStyle } from './shared'; import { Card } from '../_atoms'; import { useContentMotion, useSelectionMotion } from '../../lib/motion'; import { availableServiceViews, + isTestingFeaturePage, isServiceViewInactive, resolveServiceView, type ServiceViewId, @@ -357,46 +358,87 @@ export function AdvancedTab({ onOpenPage: (page: AdvancedPageId) => void; }) { const { t } = useTranslation(); - return ( - <> - - {pages.map((item) => ( -
+ page === 'testingFeatures' + ? testing && isTestingFeaturePage(item.id) + : !isTestingFeaturePage(item.id), + ); + const list = ( +
+ {entries.map((item) => ( +
+ + + + + {t(item.titleKey)} + + {t(`modal.advancedPages.${item.id}`)} + + + {item.id === 'testingFeatures' && ( + Beta + )} + + ))} + + ); + return ( + <> + {page === null && list} + {page === 'testingFeatures' && ( + + + {t('settings.testingFeatures.title')} + + + { + void updatePrefs((current) => ({ ...current, testingFeaturesEnabled: next })).catch( + () => emitSaved('failed', t('common.operationFailed')), + ); + }} + /> + + {testing && list} + {testing && entries.length === 0 && ( +

+ {t('settings.testingFeatures.unavailable')} +

+ )} +
+ )} + {pages + .filter((item) => item.id !== 'testingFeatures') + .map((item) => ( + + ))} ); } From f7ea09371e4716ddd4389cbfa0d3212a7d175a56 Mon Sep 17 00:00:00 2001 From: sim Date: Sun, 4 Oct 2026 00:27:09 +0800 Subject: [PATCH 06/20] fix(settings): await Beta opt-in before mounting testing tools --- .../pages/settings/ClaudeConsoleSection.tsx | 3 +++ openless-all/app/src/pages/settings/tabs.tsx | 27 ++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx b/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx index 154eb2923..438396431 100644 --- a/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx +++ b/openless-all/app/src/pages/settings/ClaudeConsoleSection.tsx @@ -60,6 +60,9 @@ export function ClaudeConsoleSection() { setDetecting(true); try { setDetection(await codingAgentDetect(exe.trim() || undefined)); + } catch (error) { + setDetection(null); + setSummary(`✗ ${String(error)}`); } finally { setDetecting(false); } diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index 661c504a1..a39057ace 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -360,9 +360,11 @@ export function AdvancedTab({ const { t } = useTranslation(); const { prefs, updatePrefs } = useHotkeySettings(); const testing = prefs?.testingFeaturesEnabled === true; + const [savingTesting, setSavingTesting] = useState(false); + const testingReady = testing && !savingTesting; const entries = pages.filter((item) => page === 'testingFeatures' - ? testing && isTestingFeaturePage(item.id) + ? testingReady && isTestingFeaturePage(item.id) : !isTestingFeaturePage(item.id), ); const list = ( @@ -406,17 +408,22 @@ export function AdvancedTab({ > { - void updatePrefs((current) => ({ ...current, testingFeaturesEnabled: next })).catch( - () => emitSaved('failed', t('common.operationFailed')), - ); + onToggle={async (next) => { + setSavingTesting(true); + try { + await updatePrefs((current) => ({ ...current, testingFeaturesEnabled: next })); + } catch { + emitSaved('failed', t('common.operationFailed')); + } finally { + setSavingTesting(false); + } }} />
- {testing && list} - {testing && entries.length === 0 && ( + {testingReady && list} + {testingReady && entries.length === 0 && (

{t('settings.testingFeatures.unavailable')}

@@ -433,8 +440,8 @@ export function AdvancedTab({ aria-label={t(item.titleKey)} data-ol-advanced-page={item.id} > - {testing && item.id === 'lessComputer' && } - {testing && item.id === 'claudeConsole' && } + {testingReady && item.id === 'lessComputer' && } + {testingReady && item.id === 'claudeConsole' && } {item.id === 'vocabularyLearning' && } {item.id === 'debug' && } From 24a0d30ef2d56b6955ff58e3e8eb4ec0c76ef915 Mon Sep 17 00:00:00 2001 From: sim Date: Sun, 4 Oct 2026 00:35:15 +0800 Subject: [PATCH 07/20] test(linux): cover console opt-in before unavailable CLI detection --- openless-all/app/linux-egui/src/backend.rs | 37 ++++++++++++++++------ 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/openless-all/app/linux-egui/src/backend.rs b/openless-all/app/linux-egui/src/backend.rs index d01c3c4d1..0f42f1837 100644 --- a/openless-all/app/linux-egui/src/backend.rs +++ b/openless-all/app/linux-egui/src/backend.rs @@ -609,20 +609,37 @@ mod tests { .unwrap() .build() .unwrap(); + let request = CodingAgentTestRequest { + provider: CodingAgentProvider::ClaudeCodeCli, + executable: Some("openless-command-that-does-not-exist".into()), + prompt: "test".into(), + permission_mode: openless_core::CodingAgentPermissionMode::Plan, + workdir: None, + model: None, + max_budget_usd: Some(0.5), + timeout_secs: 5, + }; + let denied = runtime + .backend + .services() + .coding_agent + .run_test(request.clone()) + .await + .unwrap_err(); + assert_eq!(denied.code, BackendErrorCode::PermissionDenied); + let mut preferences = runtime.backend.get_preferences(); + preferences.testing_features_enabled = true; + runtime + .backend + .repositories() + .preferences + .set(preferences) + .unwrap(); let error = runtime .backend .services() .coding_agent - .run_test(CodingAgentTestRequest { - provider: CodingAgentProvider::ClaudeCodeCli, - executable: Some("openless-command-that-does-not-exist".into()), - prompt: "test".into(), - permission_mode: openless_core::CodingAgentPermissionMode::Plan, - workdir: None, - model: None, - max_budget_usd: Some(0.5), - timeout_secs: 5, - }) + .run_test(request) .await .expect_err("missing coding agent executable must be explicit"); assert_eq!(error.code, BackendErrorCode::Unsupported); From bc1234331fa44acbe293417fde249bc3ae7f8187 Mon Sep 17 00:00:00 2001 From: sim Date: Sun, 4 Oct 2026 01:11:30 +0800 Subject: [PATCH 08/20] test(linux): opt in before recording testing-feature shortcuts --- openless-all/app/linux-egui/src/main.rs | 1 + openless-all/app/linux-egui/src/ui/frontend/mod.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 74bbb9865..eabcfbdab 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -7912,6 +7912,7 @@ Internal flags (set by OpenLess itself, not for regular use): settings_section: frontend::view_model::SettingsSection::Shortcuts, ..Default::default() }; + vm.settings.testing_features = true; let _ = render(&mut vm, Vec::new()); let painted = render(&mut vm, Vec::new()); for key in [ diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index 91bd6c0e1..efa1ca87d 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -932,6 +932,7 @@ mod tests { shortcut_recording: Some(ShortcutField::CodingAgentVoice), ..Default::default() }; + vm.settings.testing_features = true; let held = egui::Modifiers { ctrl: true, command: true, From d17c338a4aec955265b35102ec5f35be698c6885 Mon Sep 17 00:00:00 2001 From: sim Date: Sun, 4 Oct 2026 01:51:21 +0800 Subject: [PATCH 09/20] fix(ui): serialize testing-feature window visibility with opt-out --- openless-all/app/linux-egui/src/main.rs | 7 +++++ openless-all/app/src-tauri/src/lib.rs | 27 ++++++++++++------- .../app/src-tauri/src/tauri_events.rs | 12 +++++++-- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index eabcfbdab..4250aef1b 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -1093,6 +1093,13 @@ mod linux_app { return; } self.ensure_popup(PopupKind::LessComputer); + if self + .backend() + .is_none_or(|backend| backend.ensure_testing_features_enabled().is_err()) + { + self.less_computer_popup = None; + return; + } let message = self.less_computer_snapshot(self.lang); self.send_popup(PopupKind::LessComputer, message); } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 27167f572..a720de9cf 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -3113,10 +3113,10 @@ pub(crate) fn show_less_computer_window(app: &AppHandle) { let _ = window_clone.show(); } } + // Keep the entrance epoch ordered with preference-driven hides on the UI thread. + LESS_COMPUTER_PANEL_EPOCH.fetch_add(1, Ordering::SeqCst); + let _ = app_for_show.emit_to("less-computer", "chat-panel:shown", serde_json::json!({})); }); - // Cancel the pending exit-hide (fast close-open) and replay the entrance animation. - LESS_COMPUTER_PANEL_EPOCH.fetch_add(1, Ordering::SeqCst); - let _ = app.emit_to("less-computer", "chat-panel:shown", serde_json::json!({})); } #[cfg(target_os = "windows")] @@ -3130,12 +3130,21 @@ pub(crate) fn show_less_computer_window(app: &AppHandle) { let Some(window) = ensure_less_computer_window(app) else { return; }; - if let Err(error) = window.show() { - log::warn!("[less-computer] show failed: {error}"); - return; - } - LESS_COMPUTER_PANEL_EPOCH.fetch_add(1, Ordering::SeqCst); - let _ = app.emit_to("less-computer", "chat-panel:shown", serde_json::json!({})); + let app_for_show = app.clone(); + let _ = app.run_on_main_thread(move || { + if app_for_show + .try_state::>() + .is_none_or(|backend| backend.ensure_testing_features_enabled().is_err()) + { + return; + } + if let Err(error) = window.show() { + log::warn!("[less-computer] show failed: {error}"); + return; + } + LESS_COMPUTER_PANEL_EPOCH.fetch_add(1, Ordering::SeqCst); + let _ = app_for_show.emit_to("less-computer", "chat-panel:shown", serde_json::json!({})); + }); } #[cfg(not(any(target_os = "macos", target_os = "windows")))] diff --git a/openless-all/app/src-tauri/src/tauri_events.rs b/openless-all/app/src-tauri/src/tauri_events.rs index dd67fe96a..190912f50 100644 --- a/openless-all/app/src-tauri/src/tauri_events.rs +++ b/openless-all/app/src-tauri/src/tauri_events.rs @@ -528,8 +528,16 @@ fn map_dictation_state( fn emit_preferences(app: &AppHandle, backend: &OpenLessBackend) { let preferences = backend.get_preferences(); if !preferences.testing_features_enabled { - crate::hide_less_computer_window(app); - crate::hide_less_computer_glow(app); + let app_for_hide = app.clone(); + let _ = app.run_on_main_thread(move || { + if app_for_hide + .try_state::>() + .is_some_and(|backend| backend.ensure_testing_features_enabled().is_err()) + { + crate::hide_less_computer_window(&app_for_hide); + crate::hide_less_computer_glow(&app_for_hide); + } + }); } // Cloud restore also changes preferences directly through Core. Keep native // capsule geometry and hit testing current for every source of a preference change. From 3cbf9a65a3d978072f0802750762b39799622dbb Mon Sep 17 00:00:00 2001 From: sim Date: Sun, 4 Oct 2026 02:12:54 +0800 Subject: [PATCH 10/20] test(linux): resolve Beta title through the localization catalog --- openless-all/app/linux-egui/src/ui/frontend/mod.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index efa1ca87d..2c8716e57 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -813,7 +813,11 @@ mod tests { use super::view_model::SettingsSection; let mut vm = FrontendViewModel::default(); let root = painted_settings_lines(SettingsSection::Advanced, &mut vm); - assert!(root.iter().any(|line| line.contains("正在测试中的功能"))); + let title = openless_linux_egui::tr_l10n( + openless_linux_egui::Lang::ZhCn, + "settings.testing_features.title", + ); + assert!(root.iter().any(|line| line.contains(title))); assert!(!root.iter().any(|line| line == "Less Computer")); vm.advanced_open = 3; let off = painted_settings_lines(SettingsSection::Advanced, &mut vm); From 8c32db9ed99f966087da7d7fe6ddcd759c87f233 Mon Sep 17 00:00:00 2001 From: TRIP <192432815+appergb@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:41:28 +0800 Subject: [PATCH 11/20] feat(desktop): integrate UI, startup and interaction refinements --- docs/ui-ux-refinement.md | 103 +++ openless-all/app/e2e/auxiliary-smoke.spec.ts | 27 + .../app/e2e/fixtures/refinements.html | 11 + openless-all/app/e2e/fixtures/refinements.tsx | 129 ++++ openless-all/app/e2e/fixtures/typography.html | 11 + openless-all/app/e2e/fixtures/typography.tsx | 91 +++ openless-all/app/e2e/motion.spec.ts | 13 +- openless-all/app/e2e/refinements.spec.ts | 292 ++++++++ .../app/e2e/resource-lifecycle.spec.ts | 58 ++ .../app/e2e/settings-feedback.spec.ts | 189 +++++ openless-all/app/e2e/typography.spec.ts | 256 +++++++ .../app/e2e/visual-refinements.spec.ts | 24 + .../shortcuts-dark-chromium-darwin.png | Bin 0 -> 84256 bytes .../shortcuts-dark-webkit-darwin.png | Bin 0 -> 108834 bytes .../shortcuts-light-chromium-darwin.png | Bin 0 -> 84358 bytes .../shortcuts-light-webkit-darwin.png | Bin 0 -> 110380 bytes .../typography-dark-chromium-darwin.png | Bin 0 -> 64101 bytes .../typography-dark-webkit-darwin.png | Bin 0 -> 86964 bytes .../typography-light-chromium-darwin.png | Bin 0 -> 64222 bytes .../typography-light-webkit-darwin.png | Bin 0 -> 87637 bytes openless-all/app/index.html | 47 +- openless-all/app/playwright.config.ts | 18 +- .../app/public/openless-2.0-splash.mp4 | Bin 6381933 -> 0 bytes openless-all/app/public/startup.js | 10 + .../scripts/encrypted-sync-ui-bridge.test.mjs | 10 + .../less-computer-opencode-contract.test.mjs | 3 +- openless-all/app/scripts/measure-startup.mjs | 55 ++ openless-all/app/src/App.tsx | 8 +- openless-all/app/src/components/Capsule.tsx | 8 +- .../app/src/components/CapsuleStyles.css | 8 +- .../app/src/components/CoreStartupScreen.tsx | 96 +-- .../app/src/components/FloatingShell.tsx | 191 +++-- openless-all/app/src/components/Kbd.tsx | 2 +- .../app/src/components/MobileMoreSheet.tsx | 8 +- .../app/src/components/MobileStyleSheet.tsx | 8 +- .../app/src/components/Onboarding.tsx | 22 +- .../app/src/components/SavedToast.tsx | 2 +- .../app/src/components/SettingsModal.tsx | 119 ++- .../app/src/components/ShortcutRecorder.tsx | 252 +++---- openless-all/app/src/components/SiriGL.tsx | 54 +- .../app/src/components/SplashVideo.tsx | 88 --- openless-all/app/src/components/Tooltip.css | 89 +++ openless-all/app/src/components/Tooltip.tsx | 369 +++------ .../app/src/components/VoiceOrbStage.tsx | 2 +- openless-all/app/src/components/chat/chat.css | 35 +- .../app/src/components/chat/markdown.tsx | 45 +- .../app/src/components/chat/ui/button.tsx | 2 +- .../app/src/components/ui/ChoiceChips.tsx | 38 + .../app/src/components/ui/Disclosure.tsx | 80 ++ .../app/src/components/ui/DismissibleCard.tsx | 57 ++ .../app/src/components/ui/PageSkeleton.tsx | 18 + openless-all/app/src/components/ui/Row.tsx | 10 +- openless-all/app/src/components/ui/Select.css | 109 +++ openless-all/app/src/components/ui/Select.tsx | 53 ++ .../app/src/components/ui/SelectLite.tsx | 4 +- openless-all/app/src/i18n/de.ts | 8 + openless-all/app/src/i18n/en.ts | 12 +- openless-all/app/src/i18n/es.ts | 8 + openless-all/app/src/i18n/fr.ts | 8 + openless-all/app/src/i18n/ja.ts | 8 + openless-all/app/src/i18n/ko.ts | 8 + openless-all/app/src/i18n/uiRefinement.ts | 114 +++ openless-all/app/src/i18n/zh-CN.ts | 12 +- openless-all/app/src/i18n/zh-TW.ts | 12 +- .../app/src/lib/assistantText.test.ts | 88 +++ openless-all/app/src/lib/assistantText.ts | 192 +++++ openless-all/app/src/lib/cardDeparture.ts | 39 + .../app/src/lib/encryptedSyncUiBridge.ts | 18 +- openless-all/app/src/lib/fontScale.ts | 1 + openless-all/app/src/lib/hotkey.ts | 21 +- openless-all/app/src/lib/interactionMode.ts | 12 + openless-all/app/src/lib/motion.ts | 81 +- .../app/src/lib/springReorder.test.ts | 36 + openless-all/app/src/lib/startupMetrics.ts | 31 + openless-all/app/src/lib/toggleFeedback.ts | 31 + openless-all/app/src/lib/typography.test.ts | 42 ++ openless-all/app/src/lib/typography.ts | 135 ++++ openless-all/app/src/lib/useExitMount.ts | 3 +- openless-all/app/src/lib/useRafThrottle.ts | 2 + openless-all/app/src/lib/useSpringReorder.ts | 235 ++++++ openless-all/app/src/main.tsx | 51 +- openless-all/app/src/pages/History.tsx | 49 +- .../app/src/pages/LessComputerGlow.tsx | 40 +- .../app/src/pages/LessComputerPanel.tsx | 2 +- .../app/src/pages/LocalAsr/components.tsx | 4 +- openless-all/app/src/pages/LocalAsr/index.tsx | 8 +- .../app/src/pages/LocalAsr/local-asr.css | 18 +- openless-all/app/src/pages/Marketplace.tsx | 34 +- openless-all/app/src/pages/Overview.tsx | 56 +- openless-all/app/src/pages/QuickNote.tsx | 6 +- openless-all/app/src/pages/SelectionAsk.css | 6 +- openless-all/app/src/pages/Style.test.ts | 12 +- openless-all/app/src/pages/Style.tsx | 258 ++++--- openless-all/app/src/pages/Translation.tsx | 36 +- openless-all/app/src/pages/Vocab.tsx | 11 +- openless-all/app/src/pages/_atoms.tsx | 13 +- .../app/src/pages/less-computer-panel.css | 22 +- .../app/src/pages/settings/AboutSection.tsx | 2 +- .../src/pages/settings/ChannelList.test.ts | 18 + .../app/src/pages/settings/ChannelList.tsx | 281 ++----- .../src/pages/settings/CloudSyncSection.tsx | 4 +- .../src/pages/settings/LanguageSection.tsx | 2 +- .../src/pages/settings/MarketplaceSection.tsx | 2 +- .../src/pages/settings/PermissionsSection.tsx | 4 +- .../src/pages/settings/ProvidersSection.tsx | 19 +- .../pages/settings/RecordingInputSection.tsx | 11 +- .../src/pages/settings/RemoteInputSection.tsx | 26 +- .../settings/SelectionWorkspaceSection.tsx | 16 +- .../src/pages/settings/ShortcutsSection.tsx | 706 +++++++++--------- .../src/pages/settings/StartupDiagnostics.tsx | 12 + .../app/src/pages/settings/ThemeSection.tsx | 2 +- .../src/pages/settings/TypographySection.tsx | 81 ++ .../settings/models/LocalModelPicker.tsx | 7 +- .../settings/models/LocalModelsSection.tsx | 2 +- .../app/src/pages/settings/navigation.ts | 2 + .../app/src/pages/settings/shared.tsx | 84 +-- openless-all/app/src/pages/settings/tabs.tsx | 77 +- openless-all/app/src/styles/global.css | 63 +- openless-all/app/src/styles/refinements.css | 619 +++++++++++++++ openless-all/app/src/styles/tokens.css | 26 +- 120 files changed, 5206 insertions(+), 1767 deletions(-) create mode 100644 docs/ui-ux-refinement.md create mode 100644 openless-all/app/e2e/auxiliary-smoke.spec.ts create mode 100644 openless-all/app/e2e/fixtures/refinements.html create mode 100644 openless-all/app/e2e/fixtures/refinements.tsx create mode 100644 openless-all/app/e2e/fixtures/typography.html create mode 100644 openless-all/app/e2e/fixtures/typography.tsx create mode 100644 openless-all/app/e2e/refinements.spec.ts create mode 100644 openless-all/app/e2e/resource-lifecycle.spec.ts create mode 100644 openless-all/app/e2e/settings-feedback.spec.ts create mode 100644 openless-all/app/e2e/typography.spec.ts create mode 100644 openless-all/app/e2e/visual-refinements.spec.ts create mode 100644 openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-dark-chromium-darwin.png create mode 100644 openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-dark-webkit-darwin.png create mode 100644 openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-light-chromium-darwin.png create mode 100644 openless-all/app/e2e/visual-refinements.spec.ts-snapshots/shortcuts-light-webkit-darwin.png create mode 100644 openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-dark-chromium-darwin.png create mode 100644 openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-dark-webkit-darwin.png create mode 100644 openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-light-chromium-darwin.png create mode 100644 openless-all/app/e2e/visual-refinements.spec.ts-snapshots/typography-light-webkit-darwin.png delete mode 100644 openless-all/app/public/openless-2.0-splash.mp4 create mode 100644 openless-all/app/public/startup.js create mode 100644 openless-all/app/scripts/measure-startup.mjs delete mode 100644 openless-all/app/src/components/SplashVideo.tsx create mode 100644 openless-all/app/src/components/Tooltip.css create mode 100644 openless-all/app/src/components/ui/ChoiceChips.tsx create mode 100644 openless-all/app/src/components/ui/Disclosure.tsx create mode 100644 openless-all/app/src/components/ui/DismissibleCard.tsx create mode 100644 openless-all/app/src/components/ui/PageSkeleton.tsx create mode 100644 openless-all/app/src/components/ui/Select.css create mode 100644 openless-all/app/src/components/ui/Select.tsx create mode 100644 openless-all/app/src/i18n/uiRefinement.ts create mode 100644 openless-all/app/src/lib/assistantText.test.ts create mode 100644 openless-all/app/src/lib/assistantText.ts create mode 100644 openless-all/app/src/lib/cardDeparture.ts create mode 100644 openless-all/app/src/lib/interactionMode.ts create mode 100644 openless-all/app/src/lib/springReorder.test.ts create mode 100644 openless-all/app/src/lib/startupMetrics.ts create mode 100644 openless-all/app/src/lib/toggleFeedback.ts create mode 100644 openless-all/app/src/lib/typography.test.ts create mode 100644 openless-all/app/src/lib/typography.ts create mode 100644 openless-all/app/src/lib/useSpringReorder.ts create mode 100644 openless-all/app/src/pages/settings/StartupDiagnostics.tsx create mode 100644 openless-all/app/src/pages/settings/TypographySection.tsx create mode 100644 openless-all/app/src/styles/refinements.css diff --git a/docs/ui-ux-refinement.md b/docs/ui-ux-refinement.md new file mode 100644 index 000000000..0dcbd34d6 --- /dev/null +++ b/docs/ui-ux-refinement.md @@ -0,0 +1,103 @@ +# 当前工作区整合与 UI/UX 校验 + +## 来源与边界 + +共同基线:`e4727697`。两边均为未提交改动,不能只合并分支指针。 + +- `/Users/trip/orca/openless`:本地实验语音识别,45 个变更文件。 +- `/Users/trip/orca/workspaces/openless/UI-UX-细节增强`:54 个变更文件。 +- 将这两份快照做三方整合到 `appergb/合并两个分支`,没有修改原工作区,没有提交或推送。完成时再次比对,原工作区内容与快照一致。 +- 原始补丁、文件与清单保存在 `/var/folders/lf/zll5whg118q4yd9jnq61mxgm0000gn/T/openless-merge-5irykbgr`。临时目录不是长期备份。 +- 根目录原有的未跟踪 `package.json`、`pnpm-lock.yaml` 保持不变;真正应用位于 `openless-all/app/`。 + +## 设计与实现 + +遵循工具型软件的即时响应,不把展示性动画塞到高频键盘操作中。 + +| Before | After | Why | +| --- | --- | --- | +| 6.1 MB 启动视频,25 秒 watchdog,另加 1.2 秒退出 | 删除视频与播放器;代码生成的三条线标记,装饰运动 2.5 秒内结束,就绪即进入 | 动画不再延迟进入;真正的后端契约失败仍阻止业务操作 | +| 首帧等待 UI 同步/Keychain | 本地主题与字体预先应用、HTML 首帧、React 就绪后渐进加载;同步后台初始化 | 避免空白等待;提前操作的偏好仍进入原有 CAS 队列,不被旧镜像覆盖 | +| 所有主页面和设置随主壳加载 | 非首页页面与设置按需加载,设置高级页面首次访问才挂载 | 降低初始解析和未访问功能的常驻开销;已打开的实验页仍保留草稿 | +| 设置浮窗、双层边框 | 设置占用窗口本身,统一左栏与右侧内容,返回应用入口 | 减少嵌套;保留焦点恢复、弹出子层与待保存表单保护 | +| 快捷键长卡片和层层展开的录制控件 | 右侧内嵌命令搜索、分类、分配状态、独立 keycaps,直接点击录制 | 高频动作即时生效;冲突校验、取消、禁用、重置继续复用真实 IPC | +| 渠道拖动时直接交换整行 | 临时脱离文档流的列表、逐帧计算连续插槽、每行独立临界阻尼弹簧 | 半程拖动也有部分让位;拖动帧不写 React state;取消不保存 | +| 无键盘重排 | 拖动手柄可聚焦,方向键移动,结果播报 | 不依赖鼠标/触控;持久化失败恢复后端状态 | +| 开关用 `left` 移动 | `transform` 驱动滑块;组内邻近开关有最多 1.5px 的视觉反馈 | 不改变其他开关状态,键盘与减少动态效果时不传播运动 | +| 描述文本截断 | 有明确入口的高度展开、180° 箭头 | 仅内容变化和尺寸改变时测量,200ms 局部高度过渡 | +| 删除卡片只有淡出 | 向右下删除方向的短弧线、缩小/旋转/透明度变化;支持水平划走 | 240ms 的定向反馈,保留 6 秒撤销及删除失败恢复 | +| 标签仅变色 | 选中项轻微放大,实际增加布局占位,邻项弹簧让位 | 不覆盖相邻选项;用于风格切换 | +| 滚动页面整面 mask/纹理合成 | 移除整页滚动遮罩与装饰噪点;稳定滚动条槽位 | 降低合成开销,避免内容顶部淡隐和滚动条出现时宽度抖动 | +| WebGL 预览离开可视区域仍绘制 | 离屏和隐藏时停钟;尺寸只在 resize 时测量;减少动态效果使用静态帧 | 自动测试确认离屏绘制计数不增长,重新可见后恢复 | +| 字体与排版占满一屏 | 仅保留字体、界面大小、阅读宽度三行,使用 Base UI Select | 删除文字用途、平铺字体、预设、最近使用、预览及导入导出面板;只用本机字体 | +| 问号按钮和内嵌长说明 | 共用 `Hint`,文字虚线下划线,Base UI Tooltip 优先向上弹出 | Portal 不撑开设置容器;支持悬浮、键盘、触屏、Esc 与碰撞避让 | +| 实验模型默认显示多段说明 | 默认两个模型名称和开关,规格及启停影响放入名称提示 | 下载进度、错误、重试和完成后的渠道引导仍然可见 | + +### 滚动策略 + +不接管 `wheel`,不对滚动事件做防抖,不安装第二套模拟惯性。macOS 触控板惯性由系统/浏览器处理,用户反向输入、触碰或停止时不会再被 JS 速度累加器拖走。保留原有历史/市场搜索的 300ms 防抖,以及流式内容的 rAF 合并。动画主要使用 transform/opacity;高度动画仅用于小型展开容器。真实触控板的加速度与制动感不能用合成 wheel 事件完全验证。 + +### 字体与数据范围 + +字体配置保存在设备本地 `ol.typography.v1`;不包含账号、密钥或任意 CSS,旧数据仍可读取。新的单一字体选择同时应用于界面、正文、标题和数字,代码保持原来的等宽字体设置。界面整体缩放继续使用原有 `ol-font-scale` 偏好和同步机制;Base UI Portal 抵消根节点缩放,再在弹层恢复字号,避免 90%/110% 下重复缩放坐标。新增排版资源提供中英文,其余语言的新增文案暂用英文。 + +风格卡片通过 `elementRef` 传递页面测量引用,内部与 `forwardRef` 合并,避免 Framer Motion 的 `PopChild` 在 React 18 中访问特殊属性 `props.ref`。保留 `popLayout`、滑动删除、退出动画和撤销,不修改依赖包、不屏蔽控制台警告。 + +## 可复现验证 + +设计反馈的新增回归在 `e2e/settings-feedback.spec.ts`:开发模式控制台、文字提示、触屏持久显示、键盘/Esc、窄屏、三档缩放下的定位;排版持久化用例位于 `e2e/refinements.spec.ts`。实测 1429×930 视口下,字体区域由约 739px 收至 251px,实验模型区域由约 649px 收至 127px。本轮生产构建、前端测试集及 Chromium/WebKit 全部 80 项 UI 回归通过;4 张排版视觉基线已更新,并在不更新快照的全量测试中复核通过。 + +在 `openless-all/app/` 执行: + +```sh +npm ci +npm run build +node scripts/frontend-test-runner.mjs +npm run test:ui-motion +cargo test -p openless-core --lib +node scripts/measure-startup.mjs 5 +``` + +以下数量为最初两分支整合的验证记录(不包含后续设计反馈用例): + +- TypeScript + Vite 生产构建通过。 +- 118 个前端测试文件通过,包括新的排版输入校验、连续插槽计算、启动阶段偏好写入、删除生命周期。 +- Chromium / WebKit:64 项 UI 测试通过,包含主页面和设置入口、7 类辅助窗口、移动窄屏、查询、导入导出、取消重排、视觉反馈不改状态、离屏 WebGL 停止、减少动态效果。 +- 8 张深浅主题视觉基线位于 `openless-all/app/e2e/visual-refinements.spec.ts-snapshots/`,按浏览器和 OS 区分;比较阈值 100 像素。更新后已独立比较通过,而非只生成图片。 +- Core:1,163 通过、1 跳过。其中本地实验 ASR 的 7 项生命周期/渠道隔离测试全部通过。 +- 修复了 Less Computer 的跨 chunk `getSettings` 重导出警告;主入口约 616KB 的大 chunk 提示仍存在,不将调高告警阈值当作优化。 + +### 启动测量的含义 + +`startupMetrics.ts` 记录导航相对时间:HTML 引导、前端代码、React 帧估计、后端就绪、壳与偏好就绪、同步桥。首屏与可交互不是同一个指标,不用假进度或动画时长抵扣真正工作。设置 → 关于与更新 → 启动诊断可查看当次数据。 + +一次本机 Chromium headless、生产资源、5 个新浏览器 context、模拟后端的结果: + +| 指标 | 结果 | +| --- | --- | +| FCP | 32–92ms,中位数 32ms | +| 壳与偏好就绪的双 rAF 估计 | 254–350ms,中位数 256ms | +| 首屏资源请求数 | 每次 9 个 | +| 主壳 chunk(未压缩) | 整合基线 369.92KB → 约 50.46KB;设置与其余页面改为按需加载 | + +这些是浏览器样本,不是原生进程冷启动,也不是优化前后速度对照;系统文件缓存、CPU/GPU 负载会影响结果。未声称 CPU 或 GPU 百分比下降。 + +## 预览与人工验收 + +Orca 当前工作区的内置浏览器:`http://127.0.0.1:5337/`。对应托管终端为 `OpenLess UI Preview`,支持源码修改后热更新。浏览器用模拟后端,不能据此验证真实麦克风、模型推理、系统级快捷键或系统权限。 + +建议实机检查: + +1. 快捷键搜索、录制、Esc 取消、冲突提示、分类切换;检查浅色/深色、90%/110% 缩放。 +2. AI 服务中的渠道上拖、半程停留、反向拖、边缘自动滚动、Esc 取消和方向键重排。 +3. 设置中的开关反馈,确认只有当前开关改变;开启系统“减少动态效果”再试。 +4. 风格卡片展开、水平划走、撤销、快速连续删除和切换页面。 +5. 在真实 macOS 打包 App 中用触控板检验原生惯性与快速制动;测原生启动及真实 ASR。此次没有构建或验证新的原生 App 安装包。 + +参考: + +- https://developer.apple.com/design/human-interface-guidelines/motion +- https://developer.apple.com/design/human-interface-guidelines/launching +- https://developer.apple.com/design/human-interface-guidelines/loading +- https://developer.apple.com/design/human-interface-guidelines/typography +- https://developer.apple.com/videos/play/wwdc2018/803/ diff --git a/openless-all/app/e2e/auxiliary-smoke.spec.ts b/openless-all/app/e2e/auxiliary-smoke.spec.ts new file mode 100644 index 000000000..047dbd088 --- /dev/null +++ b/openless-all/app/e2e/auxiliary-smoke.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; + +test('auxiliary window entries load without blank-page exceptions or startup video', async ({ + page, +}) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + for (const kind of [ + 'qa', + 'selection-voice-intent', + 'voice-edit', + 'less-computer', + 'less-computer-glow', + 'capsule', + 'capsule-rail', + ]) { + await page.goto('/?window=' + kind); + await page.waitForLoadState('networkidle'); + await expect(page.locator('.ol-first-frame')).toHaveCount(0); + await expect(page.locator('video')).toHaveCount(0); + // Idle capsule/rail/glow can intentionally be empty; business surfaces must not be. + if (['qa', 'voice-edit', 'less-computer'].includes(kind)) { + expect(await page.locator('#root').textContent()).not.toBe(''); + } + } + expect(errors).toEqual([]); +}); diff --git a/openless-all/app/e2e/fixtures/refinements.html b/openless-all/app/e2e/fixtures/refinements.html new file mode 100644 index 000000000..75ce49b8b --- /dev/null +++ b/openless-all/app/e2e/fixtures/refinements.html @@ -0,0 +1,11 @@ + + + + + Interaction tests + + +
+ + + diff --git a/openless-all/app/e2e/fixtures/refinements.tsx b/openless-all/app/e2e/fixtures/refinements.tsx new file mode 100644 index 000000000..b05d1d36e --- /dev/null +++ b/openless-all/app/e2e/fixtures/refinements.tsx @@ -0,0 +1,129 @@ +import React, { useState, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AnimatePresence } from 'framer-motion'; +import { i18nReady } from '../../src/i18n'; +import { useSpringReorder } from '../../src/lib/useSpringReorder'; +import { initInteractionMode } from '../../src/lib/interactionMode'; +import { Toggle } from '../../src/pages/settings/shared'; +import { ExpandableText } from '../../src/components/ui/Disclosure'; +import { ChoiceChips } from '../../src/components/ui/ChoiceChips'; +import { DismissibleCard } from '../../src/components/ui/DismissibleCard'; +import { cardDeparture } from '../../src/lib/cardDeparture'; +import '../../src/styles/tokens.css'; +import '../../src/styles/global.css'; +import '../../src/styles/refinements.css'; + +function Fixture() { + const [items, setItems] = useState([ + { id: 'Alpha' }, + { id: 'Beta' }, + { id: 'Gamma' }, + { id: 'Delta' }, + ]); + const [commits, setCommits] = useState(0); + const reorder = useSpringReorder(items, (next) => { + setItems(next); + setCommits((count) => count + 1); + }); + const [toggles, setToggles] = useState([false, true, false]); + const [chip, setChip] = useState('one'); + const [deleted, setDeleted] = useState(false); + const card = useRef(null); + const dismiss = async () => { + await cardDeparture(card.current ?? undefined); + setDeleted(true); + }; + return ( +
+
+ {items.map((item) => ( +
{ + if (el) reorder.rows.current.set(item.id, el); + else reorder.rows.current.delete(item.id); + }} + style={{ + height: 64, + background: 'var(--ol-surface-2)', + display: 'flex', + alignItems: 'center', + }} + > + + {item.id} +
+ ))} +
+ {commits} +
+ {toggles.map((on, i) => ( + + setToggles((current) => current.map((value, index) => (i === index ? next : value))) + } + /> + ))} +
+
+ `第 ${i + 1} 行 · Readable content.`).join( + '\n', + )} + /> +
+ + + <> + {!deleted && ( + void dismiss()} + data-testid="card" + exit={{ opacity: 0 }} + style={{ width: 320, height: 120, padding: 18, background: 'var(--ol-surface-2)' }} + > + Swipe this card + + + )} + + + {deleted && } +
+ ); +} +initInteractionMode(); +void i18nReady.then(() => + createRoot(document.getElementById('root')!).render( + + + , + ), +); diff --git a/openless-all/app/e2e/fixtures/typography.html b/openless-all/app/e2e/fixtures/typography.html new file mode 100644 index 000000000..76280423f --- /dev/null +++ b/openless-all/app/e2e/fixtures/typography.html @@ -0,0 +1,11 @@ + + + + + Answer typography fixture + + +
+ + + diff --git a/openless-all/app/e2e/fixtures/typography.tsx b/openless-all/app/e2e/fixtures/typography.tsx new file mode 100644 index 000000000..304b1732c --- /dev/null +++ b/openless-all/app/e2e/fixtures/typography.tsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AssistantMarkdown } from '../../src/components/chat/markdown'; +import '../../src/styles/tokens.css'; + +function Fixture() { + const [draft, setDraft] = useState(''); + const [markdown, setMarkdown] = useState(''); + const [streaming, setStreaming] = useState(false); + const [mounted, setMounted] = useState(true); + return ( +
+