diff --git a/openless-all/app/src-tauri/capabilities/default.json b/openless-all/app/src-tauri/capabilities/default.json index 36d538de..760d74ed 100644 --- a/openless-all/app/src-tauri/capabilities/default.json +++ b/openless-all/app/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capabilities for OpenLess windows", - "windows": ["main", "capsule", "qa", "less-computer"], + "windows": ["main", "capsule", "qa", "less-computer", "less-computer-glow"], "permissions": [ "core:default", "core:window:default", diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c5db9210..99e3b07d 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -841,6 +841,7 @@ impl Coordinator { .store(false, Ordering::SeqCst); if let Some(app) = self.inner.app.lock().clone() { crate::hide_less_computer_window(&app); + crate::hide_less_computer_glow(&app); } } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 2b964ca3..f3790f8b 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -594,6 +594,7 @@ async fn run_voice_agent_transcript( // 聊天浮窗:显示窗口 + 落用户气泡(语音指令转写)。macOS only(helper 内部 gating)。 if let Some(app) = inner.app.lock().clone() { crate::show_less_computer_window(&app); + crate::show_less_computer_glow(&app); } // 连续对话:浮窗里已有进行中的会话 → 本轮 `claude --continue` 续上下文;否则是新会话(fresh)。 // dismiss 关窗会把标志复位为 false。 @@ -659,6 +660,10 @@ async fn run_voice_agent_transcript( }; inner.state.lock().phase = SessionPhase::Idle; + // 工作结束:熄灭全屏彩虹描边(聊天浮窗保留,等用户读完/关闭)。 + if let Some(app) = inner.app.lock().clone() { + crate::hide_less_computer_glow(&app); + } match final_outcome { LessComputerOutcome::Done { text, cost_usd } => { diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index eaeb675e..db7fe5e3 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -228,7 +228,17 @@ where } fn update_shared_binding(shared: &Shared, binding: HotkeyBinding) { - *shared.binding.write() = binding; + { + let mut current = shared.binding.write(); + if *current == binding { + // 绑定未变化(如 supervisor 每 5s 周期性重新应用同一绑定):不要碰 held latch。 + // 否则会在长按期间把「已按住」清成 false,松手时 `!is_active && was_held` 不成立、 + // 不再发 Released —— hold 模式(Less Computer 按住说话)录音停不下来、要再按一次。 + // 复现:长按 >5s 跨过一次 supervisor 轮询即触发。 + return; + } + *current = binding; + } shared .trigger_held .store(false, std::sync::atomic::Ordering::SeqCst); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 8865c18f..1af21295 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -1378,6 +1378,60 @@ pub(crate) fn hide_less_computer_window(app: &AppHandle) { #[cfg(not(target_os = "macos"))] pub(crate) fn hide_less_computer_window(_app: &AppHandle) {} +/// 显示全屏彩虹描边浮层:盖满当前显示器、点击穿透、置顶。Agent 工作时点亮整屏边缘。 +#[cfg(target_os = "macos")] +pub(crate) fn show_less_computer_glow(app: &AppHandle) { + let Some(window) = app.get_webview_window("less-computer-glow") else { + return; + }; + // 盖满当前(否则主)显示器,含菜单栏/Dock 区域 —— 屏幕边缘环绕发光。 + let monitor = window + .current_monitor() + .ok() + .flatten() + .or_else(|| app.primary_monitor().ok().flatten()); + if let Some(monitor) = monitor { + let _ = window.set_position(*monitor.position()); + let _ = window.set_size(*monitor.size()); + } + // 点击穿透:纯视觉浮层,绝不拦截鼠标。 + let _ = window.set_ignore_cursor_events(true); + let window_clone = window.clone(); + let _ = app.run_on_main_thread(move || { + use objc2::msg_send; + use objc2::runtime::AnyObject; + match window_clone.ns_window() { + Ok(handle) => { + let ns = handle as *mut AnyObject; + if ns.is_null() { + let _ = window_clone.show(); + } else { + unsafe { + let _: () = msg_send![ns, orderFrontRegardless]; + } + } + } + Err(_) => { + let _ = window_clone.show(); + } + } + }); +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn show_less_computer_glow(_app: &AppHandle) {} + +/// 隐藏全屏彩虹描边浮层。 +#[cfg(target_os = "macos")] +pub(crate) fn hide_less_computer_glow(app: &AppHandle) { + if let Some(window) = app.get_webview_window("less-computer-glow") { + let _ = window.hide(); + } +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn hide_less_computer_glow(_app: &AppHandle) {} + /// 前端按内容测高后回传,Rust 端 clamp + bottom-anchored 重新摆放。`macos` 专用。 #[cfg(target_os = "macos")] pub(crate) fn resize_less_computer_window(app: &AppHandle, height: f64) { diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index e7e30457..5e79db90 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -80,6 +80,22 @@ "visible": false, "center": false, "acceptFirstMouse": true + }, + { + "label": "less-computer-glow", + "url": "index.html?window=less-computer-glow", + "title": "OpenLess Less Computer Glow", + "width": 800, + "height": 600, + "decorations": false, + "transparent": true, + "shadow": false, + "alwaysOnTop": true, + "skipTaskbar": true, + "resizable": false, + "focus": false, + "visible": false, + "center": false } ], "security": { diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index 3653fd96..1953cf97 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -18,6 +18,7 @@ import { } from './lib/windowHotkeyFallback'; import { QaPanel } from './pages/QaPanel'; import { LessComputerPanel } from './pages/LessComputerPanel'; +import { LessComputerGlow } from './pages/LessComputerGlow'; import { invoke } from '@tauri-apps/api/core'; import { HotkeySettingsProvider } from './state/HotkeySettingsContext'; @@ -25,12 +26,13 @@ interface AppProps { isCapsule: boolean; isQa: boolean; isLessComputer: boolean; + isLessComputerGlow: boolean; forcedOs?: OS | null; } type Gate = 'onboarding' | 'ready'; -export function App({ isCapsule, isQa, isLessComputer, forcedOs }: AppProps) { +export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, forcedOs }: AppProps) { if (isCapsule) { return ; } @@ -40,6 +42,9 @@ export function App({ isCapsule, isQa, isLessComputer, forcedOs }: AppProps) { if (isLessComputer) { return ; } + if (isLessComputerGlow) { + return ; + } const os = forcedOs ?? detectOS(); // Windows 启动不应被权限探测阻塞首屏。 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index e0cae00c..9c1075ae 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -56,7 +56,7 @@ export const en: typeof zhCN = { }, lessComputer: { you: 'You', - working: 'Working…', + working: 'Operating…', tool: 'Used {{name}}', done: 'Done', cost: '${{cost}}', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 248848d2..bbbcd671 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -58,7 +58,7 @@ export const ja: typeof zhCN = { }, lessComputer: { you: 'あなた', - working: '実行中…', + working: '操作中…', tool: '{{name}} を使用', done: '完了', cost: '${{cost}}', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 4a882ac0..56761136 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -58,7 +58,7 @@ export const ko: typeof zhCN = { }, lessComputer: { you: '나', - working: '실행 중…', + working: '조작 중…', tool: '{{name}} 사용', done: '완료', cost: '${{cost}}', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index d974774c..6c8e3485 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -54,7 +54,7 @@ export const zhCN = { }, lessComputer: { you: '你', - working: '执行中…', + working: '正在操控电脑…', tool: '调用了 {{name}}', done: '完成', cost: '${{cost}}', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 90d0523e..f202fca2 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -56,7 +56,7 @@ export const zhTW: typeof zhCN = { }, lessComputer: { you: '你', - working: '執行中…', + working: '正在操控電腦…', tool: '呼叫了 {{name}}', done: '完成', cost: '${{cost}}', diff --git a/openless-all/app/src/main.tsx b/openless-all/app/src/main.tsx index ecb4b766..f835aa19 100644 --- a/openless-all/app/src/main.tsx +++ b/openless-all/app/src/main.tsx @@ -12,6 +12,7 @@ const windowKind = params.get("window"); const isCapsule = windowKind === "capsule"; const isQa = windowKind === "qa"; const isLessComputer = windowKind === "less-computer"; +const isLessComputerGlow = windowKind === "less-computer-glow"; const osQuery = params.get("os") as OS | null; const root = ReactDOM.createRoot(document.getElementById("root")!); @@ -23,6 +24,7 @@ const renderApp = () => { isCapsule={isCapsule} isQa={isQa} isLessComputer={isLessComputer} + isLessComputerGlow={isLessComputerGlow} forcedOs={osQuery} /> , diff --git a/openless-all/app/src/pages/LessComputerGlow.tsx b/openless-all/app/src/pages/LessComputerGlow.tsx new file mode 100644 index 00000000..55f71386 --- /dev/null +++ b/openless-all/app/src/pages/LessComputerGlow.tsx @@ -0,0 +1,65 @@ +// Less Computer 全屏彩虹「跑马灯」描边浮层(独立窗口 window=less-computer-glow)。 +// +// 思路(参考开源 MacEdgeLight / Apple Edge Light):一个全屏、点击穿透、置顶、透明的 +// 覆盖窗,沿屏幕圆角画一圈旋转的彩虹环 + 柔光,营造「整屏被 AI 点亮」的氛围感。 +// 纯视觉:pointer-events:none,且后端对该窗口 set_ignore_cursor_events(true)。 +// 仅 macOS 实际显示(后端只在 macOS show/hide 这个窗口)。 + +const glowCss = ` +@property --lcg-angle { syntax: ''; initial-value: 0deg; inherits: false; } +@keyframes lcg-spin { to { --lcg-angle: 360deg; } } +@keyframes lcg-breathe { 0%, 100% { opacity: .55; } 50% { opacity: 1; } } +@keyframes lcg-glow-breathe { 0%, 100% { opacity: .35; } 50% { opacity: .7; } } + +html, body, #root { background: transparent !important; margin: 0; height: 100%; overflow: hidden; } + +/* 旋转的彩虹环:conic-gradient 首尾同色(无断层)→ mask 抠出一圈描边 → 绕 --lcg-angle 旋转。 */ +.lcg-ring { + position: fixed; + inset: 0; + pointer-events: none; + border-radius: var(--lcg-radius, 40px); + padding: var(--lcg-width, 5px); + background: conic-gradient(from var(--lcg-angle), + #ff2d78, #ff8a00, #ffd400, #38e08a, #00c2ff, #7a5cff, #ff2d78); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + animation: lcg-spin 6s linear infinite, lcg-breathe 5s ease-in-out infinite; + filter: saturate(1.15) blur(0.4px); +} + +/* 柔光层:贴着屏幕边往内渗的同色辉光(体积感),让描边不是一条硬线而是「发光」。 */ +.lcg-soft { + position: fixed; + inset: 0; + pointer-events: none; + border-radius: var(--lcg-radius, 40px); + box-shadow: + inset 0 0 38px 4px rgba(124, 92, 255, 0.20), + inset 0 0 90px 18px rgba(0, 194, 255, 0.10); + animation: lcg-glow-breathe 5s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .lcg-ring { animation: none; opacity: .8; } + .lcg-soft { animation: none; } +} +`; + +if (typeof document !== 'undefined' && !document.getElementById('less-computer-glow-style')) { + const tag = document.createElement('style'); + tag.id = 'less-computer-glow-style'; + tag.textContent = glowCss; + document.head.appendChild(tag); +} + +export function LessComputerGlow() { + return ( + <> +
+
+ + ); +} diff --git a/openless-all/app/src/pages/LessComputerPanel.tsx b/openless-all/app/src/pages/LessComputerPanel.tsx index 83b8f1e3..c0b68cce 100644 --- a/openless-all/app/src/pages/LessComputerPanel.tsx +++ b/openless-all/app/src/pages/LessComputerPanel.tsx @@ -265,7 +265,7 @@ function Toolbar({ label, onClose }: { label: string; onClose: () => void }) { function UserBubble({ text, label }: { text: string; label: string }) { return ( -
+
{label}
{text}
@@ -282,7 +282,7 @@ function AssistantBubble({ markdown, working }: { markdown: string; working: boo } }, [markdown]); return ( -
+
['t']; }) { return ( -
+
{'\u{1F6E0}'} @@ -333,7 +333,7 @@ function ApprovalRow({ }) { const decided = card.decision != null; return ( -
+
{t('lessComputer.approvalTitle')}
@@ -555,43 +555,31 @@ const globalCss = ` 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.35; transform: scale(.94); } } -/* 彩虹跑马灯描边:conic-gradient 绕 @property --lc-angle 旋转,mask 抠出一圈边框。 */ -@property --lc-angle { syntax: ''; initial-value: 0deg; inherits: false; } -@keyframes lc-spin { to { --lc-angle: 360deg; } } +/* 彩虹跑马灯描边已挪到独立全屏浮层(LessComputerGlow),这里只保留极淡的流动呼吸底色。 */ @keyframes lc-flow { 0% { background-position: 0% 50%; } 100% { background-position: 220% 50%; } } -@keyframes lc-breathe { 0%, 100% { opacity: .40; } 50% { opacity: .80; } } -.lc-shell { position: relative; } -.lc-shell::before { - content: ''; - position: absolute; - inset: 0; - border-radius: 14px; - padding: 1.5px; - background: conic-gradient(from var(--lc-angle), - #ff2d78, #ff8a00, #ffd400, #38e08a, #00c2ff, #7a5cff, #ff2d78); - -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); - -webkit-mask-composite: xor; - mask-composite: exclude; - animation: lc-spin 5s linear infinite; - pointer-events: none; - z-index: 3; +@keyframes lc-bg-breathe { 0%, 100% { opacity: .35; } 50% { opacity: .65; } } +/* 内容进场:工具芯片 / 气泡 / 审批卡出现时柔和淡入上滑,而不是直接闪出。 */ +@keyframes lc-enter { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } } -/* 流动+呼吸的彩虹背景层:低透明度,文字仍清晰可读。 */ +.lc-shell { position: relative; } .lc-bg { position: absolute; inset: 0; border-radius: 14px; background: linear-gradient(115deg, - rgba(255,45,120,.14), rgba(255,138,0,.12), rgba(255,212,0,.12), - rgba(56,224,138,.12), rgba(0,194,255,.13), rgba(122,92,255,.14), rgba(255,45,120,.14)); + rgba(255,45,120,.12), rgba(255,138,0,.10), rgba(255,212,0,.10), + rgba(56,224,138,.10), rgba(0,194,255,.11), rgba(122,92,255,.12), rgba(255,45,120,.12)); background-size: 220% 220%; - animation: lc-flow 14s linear infinite, lc-breathe 6.5s ease-in-out infinite; + animation: lc-flow 16s linear infinite, lc-bg-breathe 7s ease-in-out infinite; pointer-events: none; z-index: 0; } +.lc-enter { animation: lc-enter 0.30s var(--ol-motion-soft, cubic-bezier(.16,1,.3,1)) both; } @media (prefers-reduced-motion: reduce) { - .lc-shell::before { animation: none; } - .lc-bg { animation: none; opacity: .5; } + .lc-bg { animation: none; opacity: .45; } + .lc-enter { animation: none; } } .lc-answer p { margin: 0 0 6px; } .lc-answer p:last-child { margin-bottom: 0; }