fix(less-computer): 长按松手停录音(latch不再被5s轮询重置) + 全屏弧形彩虹描边浮层(点击穿透/无缝) + 流式进场动画 + 文案

This commit is contained in:
吕柏青 committed 2026-06-05 20:52:49 +08:00
1 parent 93c54ca2d5
commit cf933cb347
15 files changed
+183 -37

No files matched your search

@@ -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",
@@ -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);
}
}
@@ -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 } => {
+11 -1
View File
@@ -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);
+54
View File
@@ -1378,6 +1378,60 @@ pub(crate) fn hide_less_computer_window<R: tauri::Runtime>(app: &AppHandle<R>) {
#[cfg(not(target_os = "macos"))]
pub(crate) fn hide_less_computer_window<R: tauri::Runtime>(_app: &AppHandle<R>) {}
/// 显示全屏彩虹描边浮层:盖满当前显示器、点击穿透、置顶。Agent 工作时点亮整屏边缘。
#[cfg(target_os = "macos")]
pub(crate) fn show_less_computer_glow<R: tauri::Runtime>(app: &AppHandle<R>) {
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<R: tauri::Runtime>(_app: &AppHandle<R>) {}
/// 隐藏全屏彩虹描边浮层。
#[cfg(target_os = "macos")]
pub(crate) fn hide_less_computer_glow<R: tauri::Runtime>(app: &AppHandle<R>) {
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<R: tauri::Runtime>(_app: &AppHandle<R>) {}
/// 前端按内容测高后回传,Rust 端 clamp + bottom-anchored 重新摆放。`macos` 专用。
#[cfg(target_os = "macos")]
pub(crate) fn resize_less_computer_window<R: tauri::Runtime>(app: &AppHandle<R>, height: f64) {
@@ -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": {
+6 -1
View File
@@ -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 <Capsule />;
}
@@ -40,6 +42,9 @@ export function App({ isCapsule, isQa, isLessComputer, forcedOs }: AppProps) {
if (isLessComputer) {
return <LessComputerPanel />;
}
if (isLessComputerGlow) {
return <LessComputerGlow />;
}
const os = forcedOs ?? detectOS();
// Windows 启动不应被权限探测阻塞首屏。
+1 -1
View File
@@ -56,7 +56,7 @@ export const en: typeof zhCN = {
},
lessComputer: {
you: 'You',
working: 'Working…',
working: 'Operating…',
tool: 'Used {{name}}',
done: 'Done',
cost: '${{cost}}',
+1 -1
View File
@@ -58,7 +58,7 @@ export const ja: typeof zhCN = {
},
lessComputer: {
you: 'あなた',
working: '実行中…',
working: '操作中…',
tool: '{{name}} を使用',
done: '完了',
cost: '${{cost}}',
+1 -1
View File
@@ -58,7 +58,7 @@ export const ko: typeof zhCN = {
},
lessComputer: {
you: '나',
working: '실행 중…',
working: '조작 중…',
tool: '{{name}} 사용',
done: '완료',
cost: '${{cost}}',
+1 -1
View File
@@ -54,7 +54,7 @@ export const zhCN = {
},
lessComputer: {
you: '你',
working: '执行中…',
working: '正在操控电脑…',
tool: '调用了 {{name}}',
done: '完成',
cost: '${{cost}}',
+1 -1
View File
@@ -56,7 +56,7 @@ export const zhTW: typeof zhCN = {
},
lessComputer: {
you: '你',
working: '執行中…',
working: '正在操控電腦…',
tool: '呼叫了 {{name}}',
done: '完成',
cost: '${{cost}}',
+2
View File
@@ -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}
/>
</React.StrictMode>,
@@ -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: '<angle>'; 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 (
<>
<div className="lcg-soft" aria-hidden />
<div className="lcg-ring" aria-hidden />
</>
);
}
@@ -265,7 +265,7 @@ function Toolbar({ label, onClose }: { label: string; onClose: () => void }) {
function UserBubble({ text, label }: { text: string; label: string }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 3 }}>
<div className="lc-enter" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 3 }}>
<span style={roleLabelStyle}>{label}</span>
<div style={userBubbleStyle}>{text}</div>
</div>
@@ -282,7 +282,7 @@ function AssistantBubble({ markdown, working }: { markdown: string; working: boo
}
}, [markdown]);
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3 }}>
<div className="lc-enter" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 3 }}>
<div
className="lc-answer"
style={assistantBubbleStyle}
@@ -302,7 +302,7 @@ function ToolChipRow({
t: ReturnType<typeof useTranslation>['t'];
}) {
return (
<div style={{ display: 'flex' }}>
<div className="lc-enter" style={{ display: 'flex' }}>
<span style={toolChipStyle}>
<span aria-hidden style={{ marginRight: 4 }}>
{'\u{1F6E0}'}
@@ -333,7 +333,7 @@ function ApprovalRow({
}) {
const decided = card.decision != null;
return (
<div style={approvalCardStyle}>
<div className="lc-enter" style={approvalCardStyle}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--ol-ink)' }}>
{t('lessComputer.approvalTitle')}
</div>
@@ -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: '<angle>'; 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; }