fix(less-computer): 长按松手停录音(latch不再被5s轮询重置) + 全屏弧形彩虹描边浮层(点击穿透/无缝) + 流式进场动画 + 文案
This commit is contained in:
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 } => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 启动不应被权限探测阻塞首屏。
|
||||
|
||||
@@ -56,7 +56,7 @@ export const en: typeof zhCN = {
|
||||
},
|
||||
lessComputer: {
|
||||
you: 'You',
|
||||
working: 'Working…',
|
||||
working: 'Operating…',
|
||||
tool: 'Used {{name}}',
|
||||
done: 'Done',
|
||||
cost: '${{cost}}',
|
||||
|
||||
@@ -58,7 +58,7 @@ export const ja: typeof zhCN = {
|
||||
},
|
||||
lessComputer: {
|
||||
you: 'あなた',
|
||||
working: '実行中…',
|
||||
working: '操作中…',
|
||||
tool: '{{name}} を使用',
|
||||
done: '完了',
|
||||
cost: '${{cost}}',
|
||||
|
||||
@@ -58,7 +58,7 @@ export const ko: typeof zhCN = {
|
||||
},
|
||||
lessComputer: {
|
||||
you: '나',
|
||||
working: '실행 중…',
|
||||
working: '조작 중…',
|
||||
tool: '{{name}} 사용',
|
||||
done: '완료',
|
||||
cost: '${{cost}}',
|
||||
|
||||
@@ -54,7 +54,7 @@ export const zhCN = {
|
||||
},
|
||||
lessComputer: {
|
||||
you: '你',
|
||||
working: '执行中…',
|
||||
working: '正在操控电脑…',
|
||||
tool: '调用了 {{name}}',
|
||||
done: '完成',
|
||||
cost: '${{cost}}',
|
||||
|
||||
@@ -56,7 +56,7 @@ export const zhTW: typeof zhCN = {
|
||||
},
|
||||
lessComputer: {
|
||||
you: '你',
|
||||
working: '執行中…',
|
||||
working: '正在操控電腦…',
|
||||
tool: '呼叫了 {{name}}',
|
||||
done: '完成',
|
||||
cost: '${{cost}}',
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user