+
正在连接会话
@@ -1252,6 +1266,42 @@
/* ---------- 10. 桌面宠物交互(原页面的面板 / 按钮 / 花名册都去掉了) ---------- */
const post = msg => { try { if (window.chrome && chrome.webview) chrome.webview.postMessage(msg); } catch (e) { /* 普通浏览器里打开时没有宿主 */ } };
+const officeControls = $('officeControls'), officeScene = $('scene');
+const CONTROLS_HIDE_DELAY_MS = 1000;
+let controlsHideTimer = 0, officeHovered = false, keyboardNavigation = false, officePinned = true;
+const hasKeyboardFocus = () => keyboardNavigation && officeScene.contains(document.activeElement);
+function showOfficeControls() {
+ clearTimeout(controlsHideTimer);
+ if (officeControls.classList.contains('is-visible')) return;
+ officeControls.classList.add('is-visible');
+ reportOfficeRegions();
+}
+function scheduleControlsHide() {
+ clearTimeout(controlsHideTimer);
+ if (officeHovered || hasKeyboardFocus()) return;
+ controlsHideTimer = setTimeout(() => {
+ if (officeHovered || hasKeyboardFocus()) return;
+ officeControls.classList.remove('is-visible');
+ reportOfficeRegions();
+ }, CONTROLS_HIDE_DELAY_MS);
+}
+officeScene.addEventListener('pointerenter', () => { officeHovered = true; showOfficeControls(); });
+officeScene.addEventListener('pointerleave', () => { officeHovered = false; scheduleControlsHide(); });
+document.addEventListener('keydown', () => { keyboardNavigation = true; if (hasKeyboardFocus()) showOfficeControls(); });
+document.addEventListener('pointerdown', () => { keyboardNavigation = false; scheduleControlsHide(); }, true);
+officeScene.addEventListener('focusin', () => { if (hasKeyboardFocus()) showOfficeControls(); });
+officeScene.addEventListener('focusout', e => {
+ if (!officeScene.contains(e.relatedTarget)) { keyboardNavigation = false; scheduleControlsHide(); }
+});
+window.addEventListener('blur', () => { keyboardNavigation = false; officeHovered = false; scheduleControlsHide(); });
+window.addEventListener('pagehide', () => clearTimeout(controlsHideTimer));
+function syncOfficePin(pinned) {
+ officePinned = pinned;
+ $('officePin').setAttribute('aria-pressed', String(pinned));
+ $('officePin').title = pinned ? '取消置顶' : '置顶办公室';
+}
+$('officePin').addEventListener('click', () => action({type:'pin', pinned:!officePinned}));
+$('officeClose').addEventListener('click', () => action({type:'close'}));
let readySent = false;
function notifyReady() { if (!readySent) { readySent = true; post('ready'); } } // 第一帧画完再让原生把窗口挪进屏幕
function renderTask() {}
@@ -1341,6 +1391,10 @@
badgeTimer = setTimeout(() => { b.hidden = true; }, 900);
}
if (hostAttached()) chrome.webview.addEventListener('message', e => {
+ if (e.data?.type === 'pet-window-state') {
+ if (typeof e.data.pinned === 'boolean') syncOfficePin(e.data.pinned);
+ return;
+ }
if (e.data && typeof e.data === 'object') {applyOfficeSnapshot(e.data);return;}
const m = String(e.data || '');
if (m.startsWith('size ')) showBadge(m.slice(5) + '%');
@@ -1503,7 +1557,11 @@
const rects=[$('officeMembers'),$('officeNotice')].filter(node=>node&&!node.hidden).map(node=>node.getBoundingClientRect());
const rect=rects.length?[Math.min(...rects.map(r=>r.left))/innerWidth,Math.min(...rects.map(r=>r.top))/innerHeight,
Math.max(...rects.map(r=>r.right))/innerWidth,Math.max(...rects.map(r=>r.bottom))/innerHeight]:null;
- action({type:'overlay',rect,bubbles:bubbleRegions});
+ const bounds=officeControls.getBoundingClientRect();
+ const controls=officeControls.classList.contains('is-visible')
+ ? [Math.max(0,bounds.left-3)/innerWidth,Math.max(0,bounds.top-3)/innerHeight,
+ Math.min(innerWidth,bounds.right+3)/innerWidth,Math.min(innerHeight,bounds.bottom+3)/innerHeight] : null;
+ action({type:'overlay',rect,controls,bubbles:bubbleRegions});
}
function syncOfficeMembers() {
const list=$('officeMembers');
diff --git a/assets/desktop_pet/surface.md b/assets/desktop_pet/surface.md
index 795e6719..7c9da9ad 100644
--- a/assets/desktop_pet/surface.md
+++ b/assets/desktop_pet/surface.md
@@ -4,7 +4,7 @@
## 视觉与首屏
-透明等距房间占据主体,顶部仅保留最近会话和跟随开关。原作家具、五套布局模板及像素角色保持;场景不扩展为管理页面。首次连接前显示空房间,连接或读取提示位于控制条下方。
+透明等距房间占据主体,顶部菜单包含最近会话、跟随开关及右侧的 pin / 关闭按钮,鼠标进入时显示、移出 1 秒后隐藏,键盘焦点保持可见。原作家具、五套布局模板及像素角色保持;场景不扩展为管理页面。首次连接前显示空房间,连接或读取提示位于控制条下方。
## 会话与成员
@@ -37,7 +37,7 @@
- 拖动场景移动窗口;单击角色查看名称和状态,双击打开对应会话。
- 滚轮或把手调整窗口大小,把手双击恢复默认大小;控件和成员列表中的滚动不触发场景缩放。
-- 右键交给原生菜单。桌面窗口保持透明、置顶及不抢焦点约束;主动打开会话属于独立操作。
+- 右键交给原生菜单。桌面窗口保持透明及不抢焦点,默认置顶;pin 可切换到普通窗口层级,缩放和停靠不改变选择。关闭只影响桌宠;主动打开会话属于独立操作。
- 成员列表独立滚动,点击成员打开会话并关闭列表,Escape 关闭列表;列表区域与原生命中区域保持同步。
- 连接中断时保留最后快照并显示重连提示,不能继续伪造实时进展。
diff --git a/docs/desktop-agent-office.md b/docs/desktop-agent-office.md
index ba9d41ae..9313c619 100644
--- a/docs/desktop-agent-office.md
+++ b/docs/desktop-agent-office.md
@@ -4,6 +4,8 @@ Windows 与 macOS Desktop 会显示一个透明的像素办公室。单会话有
## 操作
+- 顶部菜单在鼠标移入办公室时显示,移出后保留 1 秒再隐藏;重新移入会取消隐藏,键盘操作时保持可见。隐藏菜单的透明区域可穿透点击。
+- 菜单右侧 pin 控制办公室是否始终置顶:默认置顶,取消后可被其他普通窗口覆盖,拖动、缩放和停靠均保持当前选择;重新启动桌宠恢复默认置顶。叉号只关闭办公室,不退出 ACECode,也不停止 agent 任务。
- 顶部显示跨工作区最近发过消息的五个主会话。顺序只看用户最后发送消息的时间,打开、切换和 agent 输出不会把会话顶到最前面。
- “跟随”默认开启,办公室随主窗口当前会话切换。点击门牌会关闭跟随并固定办公室;再次打开跟随后恢复跟随。查看最近五个之外的当前会话不改变门牌排序。
- 主 agent 成功完成后留在座位显示 `zzz`,下一轮自动醒来;成功完成的子 agent 立即离座,重新工作时以同样的外观返回。
diff --git a/openspec/changes/hover-desktop-office-controls/.openspec.yaml b/openspec/changes/hover-desktop-office-controls/.openspec.yaml
new file mode 100644
index 00000000..a26662f7
--- /dev/null
+++ b/openspec/changes/hover-desktop-office-controls/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-10-07
diff --git a/openspec/changes/hover-desktop-office-controls/design.md b/openspec/changes/hover-desktop-office-controls/design.md
new file mode 100644
index 00000000..22178e66
--- /dev/null
+++ b/openspec/changes/hover-desktop-office-controls/design.md
@@ -0,0 +1,23 @@
+## Context
+
+顶部 `.office-controls` 常驻;Windows 创建窗口与 `apply_placement` 都使用 TOPMOST,macOS 使用 floating NSPanel。两端目前无条件把顶部 32 场景像素计入命中区域。共享页面通过 overlay 消息上报气泡与成员列表。
+
+## Goals / Non-Goals
+
+实现用户指定的悬停菜单、置顶切换与关闭。保留房间、动画、会话切换、跟随和拖动缩放。不改 daemon 协议,不增加用户设置页面;pin 状态在当前桌宠生命周期内保留,重启默认置顶。
+
+## Decisions
+
+- 页面管理唯一一个 1000ms 隐藏计时器。进入场景、菜单或键盘焦点时取消计时;离开后计时。鼠标点击留下的焦点不会永久锁住菜单,键盘焦点则保持可见。
+- 控件隐藏使用 opacity 与 pointer-events,不移除键盘顺序;焦点进入时显现。保留现有浅纸色按钮,pin 以 aria-pressed 和选中底色表达状态,两个按钮都有中文可访问名称。
+- overlay 新增独立 `controls` 矩形,隐藏时为 null;原生只合并实际显示的控件矩形与房间/气泡,移除固定顶部区域。几何解析仍执行边界、类型和数量限制。
+- `pin` 消息携带 boolean;宿主以 `pet-window-state` 回传实际 pinned 状态,ready 时同样回传。Windows 用 SetWindowPos TOPMOST/NOTOPMOST,普通布局调用改为 SWP_NOZORDER;macOS 切换 floating/normal level。
+- `close` 通过宿主下一轮窗口消息/主队列调用现有 close,避免在 WebView 回调中销毁自身。主界面和 agent 任务继续运行。
+
+## Risks / Trade-offs
+
+菜单与房间之间有透明空隙,延迟隐藏允许鼠标跨越空隙。最小 172px 宽度仍须容纳五个会话、跟随、pin 和关闭,按钮尺寸随现有紧凑断点收紧。Windows 本机编译与浏览器自动化验证;macOS 修改只做源码核对,无法在 Windows 宣称实测。
+
+## Validation
+
+扩展现有浏览器回归,检查默认隐藏、进入/离开/重新进入、鼠标与键盘焦点、pin 消息/回执、关闭和窄窗口。原生几何回归验证隐藏区域穿透、显示区域命中和浮层限制。Windows 增量构建并运行 DesktopPetLayout 测试;记录源码验证与运行中 Desktop 验证边界。
diff --git a/openspec/changes/hover-desktop-office-controls/proposal.md b/openspec/changes/hover-desktop-office-controls/proposal.md
new file mode 100644
index 00000000..bc5c2783
--- /dev/null
+++ b/openspec/changes/hover-desktop-office-controls/proposal.md
@@ -0,0 +1,22 @@
+## Why
+
+办公室顶部控件常驻会遮住桌面,且当前原生窗口始终置顶。用户希望控件按需出现,并能直接切换置顶或关闭桌宠。
+
+## What Changes
+
+- 鼠标进入办公室立即显示顶部菜单;移出后延迟 1000ms 隐藏,重新进入取消隐藏。键盘操作保留可见焦点。
+- 菜单最右侧增加 SVG pin 与关闭按钮。默认保持置顶,取消 pin 后按普通窗口层级显示;缩放、停靠和 DPI 更新不重置层级。
+- 隐藏的菜单不占用原生点击区域;关闭仅影响桌宠。
+
+## Capabilities
+
+### New Capabilities
+- `desktop-office-window-controls`: 桌宠菜单显隐、置顶开关及关闭操作。
+
+### Modified Capabilities
+
+无。
+
+## Impact
+
+共享桌宠 HTML、Windows/WebView2 与 macOS/WKWebView 宿主、原生命中区域解析、现有办公室浏览器和几何测试。保持 Claude 已增强的场景动画、会话快照与美术实现。
diff --git a/openspec/changes/hover-desktop-office-controls/specs/desktop-office-window-controls/spec.md b/openspec/changes/hover-desktop-office-controls/specs/desktop-office-window-controls/spec.md
new file mode 100644
index 00000000..b7d369e8
--- /dev/null
+++ b/openspec/changes/hover-desktop-office-controls/specs/desktop-office-window-controls/spec.md
@@ -0,0 +1,34 @@
+## ADDED Requirements
+
+### Requirement: Hover controls with delayed hiding
+The office SHALL hide its top controls at rest, reveal them immediately on hover, and wait 1000ms after pointer exit before hiding. Re-entry SHALL cancel pending hiding, and keyboard focus SHALL keep controls visible.
+
+#### Scenario: Cross the transparent gap to the menu
+- **WHEN** the pointer leaves the room and enters the menu within 1000ms
+- **THEN** the menu remains visible and usable
+
+#### Scenario: Pointer leaves after clicking a control
+- **WHEN** a mouse click leaves focus on a menu button and the pointer exits the office
+- **THEN** the menu hides after 1000ms and its native hit region is removed
+
+#### Scenario: Keyboard navigation
+- **WHEN** keyboard focus enters the controls
+- **THEN** controls become visible and remain visible while keyboard focus stays within the office controls
+
+### Requirement: Native pin state
+The office SHALL provide a pin button at the right side of its menu, default to pinned, and acknowledge the native state through the bridge. Unpinned windows SHALL be coverable by normal windows and remain unpinned during size and placement updates.
+
+#### Scenario: Unpin and resize
+- **WHEN** the user unpins the office and then zooms, resets size or docks it
+- **THEN** it retains the normal window level
+
+#### Scenario: Pin and reload
+- **WHEN** the user pins the office or the office page reloads
+- **THEN** the button reflects the actual native pinned state
+
+### Requirement: Close only the office
+The office SHALL provide an accessible close icon after the pin button and close only the pet window using the native close lifecycle.
+
+#### Scenario: Close from the top menu
+- **WHEN** the user activates the close button
+- **THEN** the pet closes while the main Desktop and agent sessions continue
diff --git a/openspec/changes/hover-desktop-office-controls/tasks.md b/openspec/changes/hover-desktop-office-controls/tasks.md
new file mode 100644
index 00000000..7a8026c9
--- /dev/null
+++ b/openspec/changes/hover-desktop-office-controls/tasks.md
@@ -0,0 +1,10 @@
+## 1. Implementation
+
+- [x] 1.1 实现菜单延迟显隐、键盘焦点、pin/关闭按钮与桥回执。
+- [x] 1.2 接通 Windows/macOS 窗口层级及关闭,修正动态命中区域。
+- [x] 1.3 扩展浏览器与几何回归,同步用户文档及局部视觉规则。
+
+## 2. Validation
+
+- [x] 2.1 运行办公室浏览器回归、Windows 增量编译及 DesktopPetLayout 测试。
+- [x] 2.2 完成 OpenSpec strict 校验、差异检查,记录平台实测边界。
diff --git a/openspec/changes/hover-desktop-office-controls/verification.md b/openspec/changes/hover-desktop-office-controls/verification.md
new file mode 100644
index 00000000..118f3d16
--- /dev/null
+++ b/openspec/changes/hover-desktop-office-controls/verification.md
@@ -0,0 +1,34 @@
+# Verification — 2026-10-07
+
+## Passed
+
+- `node web/scripts/test-desktop-office.mjs`: 57 checks, no page errors or external requests. Used installed Playwright with Edge. Includes hover delay/re-entry, mouse versus keyboard focus, native control rectangles, pin acknowledgements, close message, and 172/344/860px layouts; preserves the existing 40 office animation/state checks.
+- `pnpm test` in `web/`: passed.
+- `cmake --build build --config Release --target acecode-desktop acecode_unit_tests --parallel 1`: passed. Existing MSBuild shared-intermediate-directory warnings remain; build was serial. The initial compile caught removal of a constant also used for the room origin; the layout constant was restored while removing only unconditional toolbar hit testing, then the build passed.
+- `build/tests/Release/acecode_unit_tests.exe --gtest_filter=DesktopPetLayout.*`: 14/14 passed, including dynamic control hit testing, hidden-region passthrough, invalid rectangle rejection and the shared overlay limit.
+- `openspec validate hover-desktop-office-controls --strict`: passed.
+- `git diff --check`: passed.
+- Impeccable detector: reviewed advisories for inherited palette/radii and compact spacing. New controls reuse the established compact office control appearance; no scene redesign.
+
+## Windows native runtime
+
+Used the repository `dev_desktop.desktop_environment` / `launch_desktop` helpers with the current `build/Release/acecode-desktop.exe`, isolated temporary USERPROFILE/AppData, and no model calls. WebView2 CDP supplied pointer input; Win32 APIs inspected actual window regions, styles and Z order. The environment does not permit moving the physical desktop cursor, so this was automation, not a manual desktop interaction claim.
+
+Eight native checks passed:
+
+1. Default topmost state and no hidden toolbar hit region.
+2. Hover restores the region; exit delays hiding; re-entry cancels hiding.
+3. Pin click removes `WS_EX_TOPMOST`.
+4. A normal main window can be placed above the unpinned pet in real native Z order.
+5. Wheel zoom changes the pet size while retaining its unpinned state.
+6. Pin click restores `WS_EX_TOPMOST`.
+7. Focus left by a pointer click does not keep the toolbar region alive after exit.
+8. Close destroys the pet HWND while leaving the main app HWND alive.
+
+Temporary evidence: `C:/Users/shao/AppData/Local/Temp/ace-office-controls-native-q5q5vwbc/result.json` and `native-hover.png`; browser captures: `C:/Users/shao/AppData/Local/Temp/ace-office-browser-JN8kNi/`. The isolated app was closed after verification.
+
+## Boundaries
+
+- macOS floating/normal level and deferred close paths were updated and reviewed from source; no macOS compiler or runtime validation was performed on Windows.
+- Installed Desktop packages are not updated by this local build. The pet HTML is embedded directly by CMake, independently of `web/dist`; the user can run the current development build to see the change.
+- Existing dirty changes in AGENTS.md, ChannelsSettings.jsx, ComposerSessionControls.jsx and globals.css were left outside this change. No commit or push.
diff --git a/src/apps/desktop/desktop_pet.cpp b/src/apps/desktop/desktop_pet.cpp
index b68981b5..62e81f27 100644
--- a/src/apps/desktop/desktop_pet.cpp
+++ b/src/apps/desktop/desktop_pet.cpp
@@ -184,10 +184,6 @@ void apply_hit_region(HWND hwnd, double scale, const std::vector
(points.size()), WINDING));
- UniqueRegion toolbar(::CreateRectRgn(0, 0,
- static_cast(std::lround(kDesktopPetSceneWidth * scale)),
- static_cast(std::lround(kDesktopPetToolbarHeight * scale))));
- if (region && toolbar) ::CombineRgn(region.get(), region.get(), toolbar.get(), RGN_OR);
RECT bounds{};
::GetClientRect(hwnd, &bounds);
for (const auto& overlay : overlays) {
@@ -222,6 +218,7 @@ struct DesktopPet::Impl : std::enable_shared_from_this {
bool user_moved = false;
bool closed = false;
bool resizing = false;
+ bool pinned = true;
DesktopPetRect resize_start{};
POINT resize_cursor{};
@@ -257,6 +254,12 @@ struct DesktopPet::Impl : std::enable_shared_from_this {
webview->PostWebMessageAsJson(utf8_to_wide(office_snapshot.dump()).c_str());
}
+ void publish_window_state() {
+ if (closed || !page_ready || !webview) return;
+ const nlohmann::json state = {{"type", "pet-window-state"}, {"pinned", pinned}};
+ webview->PostWebMessageAsJson(utf8_to_wide(state.dump()).c_str());
+ }
+
void office_action(const std::wstring& message) {
if (message.size() > 16384) return;
const auto value = nlohmann::json::parse(wide_to_utf8(message), nullptr, false);
@@ -264,6 +267,24 @@ struct DesktopPet::Impl : std::enable_shared_from_this {
const auto type_field = value.find("type");
if (type_field == value.end() || !type_field->is_string()) return;
const auto type = type_field->get();
+ if (type == "pin") {
+ const auto field = value.find("pinned");
+ if (field == value.end() || !field->is_boolean()) return;
+ const bool next = field->get();
+ if (::SetWindowPos(hwnd, next ? HWND_TOPMOST : HWND_NOTOPMOST, 0, 0, 0, 0,
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE)) {
+ pinned = next;
+ } else {
+ LOG_WARN("[desktop-pet] pin update failed: " + std::to_string(::GetLastError()));
+ }
+ publish_window_state();
+ return;
+ }
+ if (type == "close") {
+ // Leave the WebView callback before destroying its controller.
+ ::PostMessageW(hwnd, WM_CLOSE, 0, 0);
+ return;
+ }
if (type == "overlay") {
auto next = desktop_pet_overlays_from_message(value);
if (next == hit_overlays) return;
@@ -468,6 +489,7 @@ struct DesktopPet::Impl : std::enable_shared_from_this {
page_ready = true;
reveal();
publish_snapshot();
+ publish_window_state();
} else if (!message.empty() && message.front() == L'{') {
office_action(message);
} else if (message == L"drag") {
@@ -501,9 +523,9 @@ struct DesktopPet::Impl : std::enable_shared_from_this {
void apply_placement(const DesktopPetPlacement& next) {
placement = next;
// 尺寸变了会同步收到 WM_SIZE,在那里更新 WebView2 边界和命中区域。
- ::SetWindowPos(hwnd, HWND_TOPMOST, next.window.x, next.window.y,
+ ::SetWindowPos(hwnd, nullptr, next.window.x, next.window.y,
next.window.width, next.window.height,
- SWP_NOACTIVATE | SWP_SHOWWINDOW);
+ SWP_NOACTIVATE | SWP_SHOWWINDOW | SWP_NOZORDER);
}
int window_dpi() const { return monitor_dpi(::MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY)); }
@@ -667,6 +689,9 @@ struct DesktopPet::Impl : std::enable_shared_from_this {
LRESULT handle_message(UINT message, WPARAM wparam, LPARAM lparam) {
switch (message) {
+ case WM_CLOSE:
+ close();
+ return 0;
case WM_MOUSEACTIVATE:
return MA_NOACTIVATE;
case WM_SIZE: {
diff --git a/src/apps/desktop/desktop_pet.hpp b/src/apps/desktop/desktop_pet.hpp
index 3a82f41e..11755db0 100644
--- a/src/apps/desktop/desktop_pet.hpp
+++ b/src/apps/desktop/desktop_pet.hpp
@@ -1,11 +1,12 @@
#pragma once
-// 桌面 agent 办公室:acecode-desktop 启动时在主屏右下角拉起一个透明、置顶、
+// 桌面 agent 办公室:acecode-desktop 启动时在主屏右下角拉起一个透明、默认置顶、
// 不抢焦点、不进任务栏的小窗口,里面是真实会话驱动的像素 AI 办公室
// (assets/desktop_pet/agent_office_pet.html,构建期嵌进 exe)。
// Web 应用经快照桥更新;GUI 线程拥有窗口和桥回调。
//
// 交互:左键拖动挪位置;单击小人头顶报状态;右键菜单「回到右下角 / 隐藏桌面宠物」
+// 顶部菜单悬停显示、移出延迟隐藏;pin 切换置顶,关闭按钮只关闭桌宠。
// (隐藏只到本次退出)。环境变量 ACECODE_DESKTOP_PET=0 可整体关闭。
//
// Windows 实现在 desktop_pet.cpp(独立的 WebView2 环境,用户数据在
diff --git a/src/apps/desktop/desktop_pet_layout.cpp b/src/apps/desktop/desktop_pet_layout.cpp
index 951fe737..e1488bcd 100644
--- a/src/apps/desktop/desktop_pet_layout.cpp
+++ b/src/apps/desktop/desktop_pet_layout.cpp
@@ -170,6 +170,9 @@ std::vector desktop_pet_overlays_from_message(const nlohmann:
std::vector overlays;
if (!message.is_object()) return overlays;
DesktopPetOverlay rect{};
+ if (const auto it = message.find("controls"); it != message.end() && overlay_from_json(*it, rect)) {
+ overlays.push_back(rect);
+ }
if (const auto it = message.find("rect"); it != message.end() && overlay_from_json(*it, rect)) {
overlays.push_back(rect);
}
@@ -186,7 +189,6 @@ bool desktop_pet_hit_test(double scale, int width, int height,
const std::vector& overlays, double x, double y) {
if (width <= 0 || height <= 0 || x < 0 || y < 0 || x >= width || y >= height) return false;
if (!(scale > 0.0)) scale = 1.0;
- if (y < kDesktopPetToolbarHeight * scale) return true;
for (const auto& overlay : overlays) {
if (x >= overlay[0] * width && x < overlay[2] * width &&
y >= overlay[1] * height && y < overlay[3] * height) {
diff --git a/src/apps/desktop/desktop_pet_layout.hpp b/src/apps/desktop/desktop_pet_layout.hpp
index 501baad1..8c625e04 100644
--- a/src/apps/desktop/desktop_pet_layout.hpp
+++ b/src/apps/desktop/desktop_pet_layout.hpp
@@ -18,6 +18,7 @@ namespace acecode::desktop {
inline constexpr int kDesktopPetSceneWidth = 344;
inline constexpr int kDesktopPetSceneHeight = 252;
+// Scene layout reserves this space even while the controls are hidden.
inline constexpr int kDesktopPetToolbarHeight = 32;
struct DesktopPetRect {
@@ -65,16 +66,16 @@ DesktopPetPlacement scale_desktop_pet(const DesktopPetRect& current, double new_
// 顺时针,已夹在窗口范围内。
std::vector desktop_pet_hit_polygon(double scale);
-// 页面浮层(成员列表、提示、状态气泡)在窗口里的矩形,按窗口宽高的比例给出:
+// 页面浮层(可见控制条、成员列表、提示、状态气泡)在窗口里的矩形,按窗口宽高的比例给出:
// {left, top, right, bottom},均在 [0, 1]。
using DesktopPetOverlay = std::array;
inline constexpr std::size_t kDesktopPetMaxOverlays = 24;
-// 解析页面发来的 {"type":"overlay","rect":[...]|null,"bubbles":[[...],...]}:
+// 解析页面发来的 {"type":"overlay","controls":[...]|null,"rect":[...]|null,"bubbles":[[...],...]}:
// 非数字 / 非有限值 / 空矩形整条丢弃,坐标夹到 [0, 1],最多保留 kDesktopPetMaxOverlays 个。
std::vector desktop_pet_overlays_from_message(const nlohmann::json& message);
-// 窗口内一点 (x, y)(设备像素,左上为原点)是否落在桌宠上:房间轮廓、顶部控制条或任一浮层。
+// 窗口内一点 (x, y)(设备像素,左上为原点)是否落在桌宠上:房间轮廓或页面上报的可见浮层。
// 透明区域返回 false,点击应穿透到后面的窗口。
bool desktop_pet_hit_test(double scale, int width, int height,
const std::vector& overlays, double x, double y);
diff --git a/src/apps/desktop/desktop_pet_mac.mm b/src/apps/desktop/desktop_pet_mac.mm
index 89ed3af3..590b0888 100644
--- a/src/apps/desktop/desktop_pet_mac.mm
+++ b/src/apps/desktop/desktop_pet_mac.mm
@@ -205,6 +205,7 @@ explicit PetController(WebHost& owner) : host_(owner) {}
bool closed_ = false;
bool resizing_ = false;
bool dragging_ = false;
+ bool pinned_ = true;
NSPoint gesture_mouse_ = NSZeroPoint;
DesktopPetRect gesture_start_{};
};
@@ -484,6 +485,7 @@ - (void)choose:(NSMenuItem*)item {
page_ready_ = true;
reveal();
publish_snapshot();
+ deliver({{"type", "pet-window-state"}, {"pinned", pinned_}});
} else if (!message.empty() && message.front() == '{') {
office_action(message);
} else if (message == "drag") {
@@ -512,6 +514,23 @@ - (void)choose:(NSMenuItem*)item {
const auto type_field = value.find("type");
if (type_field == value.end() || !type_field->is_string()) return;
const auto type = type_field->get();
+ if (type == "pin") {
+ const auto field = value.find("pinned");
+ if (field == value.end() || !field->is_boolean()) return;
+ pinned_ = field->get();
+ panel_.floatingPanel = pinned_;
+ panel_.level = pinned_ ? NSFloatingWindowLevel : NSNormalWindowLevel;
+ deliver({{"type", "pet-window-state"}, {"pinned", pinned_}});
+ return;
+ }
+ if (type == "close") {
+ // Leave the WebKit message callback before releasing the view and bridge.
+ const std::weak_ptr weak = weak_from_this();
+ dispatch_async(dispatch_get_main_queue(), ^{
+ if (const auto self = weak.lock()) self->close();
+ });
+ return;
+ }
if (type == "overlay") {
overlays_ = desktop_pet_overlays_from_message(value);
return;
diff --git a/tests/desktop/desktop_pet_layout_test.cpp b/tests/desktop/desktop_pet_layout_test.cpp
index f81a02e0..baa6fdf4 100644
--- a/tests/desktop/desktop_pet_layout_test.cpp
+++ b/tests/desktop/desktop_pet_layout_test.cpp
@@ -248,7 +248,12 @@ TEST(DesktopPetLayout, HitTestCoversRoomToolbarAndOverlays) {
const int width = 430, height = 315;
const std::vector none;
EXPECT_TRUE(desktop_pet_hit_test(scale, width, height, none, 215, 200)) << "房间中央";
- EXPECT_TRUE(desktop_pet_hit_test(scale, width, height, none, 400, 10)) << "顶部控制条";
+ EXPECT_FALSE(desktop_pet_hit_test(scale, width, height, none, 400, 10)) << "隐藏的控制条穿透";
+ const auto controls = desktop_pet_overlays_from_message(nlohmann::json{
+ {"controls", {0.0, 0.0, 1.0, 32.0 / kDesktopPetSceneHeight}}});
+ EXPECT_TRUE(desktop_pet_hit_test(scale, width, height, controls, 400, 10)) << "可见控制条";
+ const auto hidden = desktop_pet_overlays_from_message(nlohmann::json{{"controls", nullptr}});
+ EXPECT_FALSE(desktop_pet_hit_test(scale, width, height, hidden, 400, 10)) << "移出后控制条穿透";
EXPECT_FALSE(desktop_pet_hit_test(scale, width, height, none, 6, 60)) << "左上透明角落";
EXPECT_FALSE(desktop_pet_hit_test(scale, width, height, none, 6, 310)) << "左下透明角落";
const std::vector bubble{{0.0, 0.15, 0.1, 0.25}};
@@ -256,3 +261,15 @@ TEST(DesktopPetLayout, HitTestCoversRoomToolbarAndOverlays) {
EXPECT_FALSE(desktop_pet_hit_test(scale, width, height, bubble, -1, 60)) << "窗口外";
EXPECT_FALSE(desktop_pet_hit_test(scale, width, height, bubble, 430, 60)) << "右边界外";
}
+
+TEST(DesktopPetLayout, ControlsOverlayUsesValidationAndSharesOverlayLimit) {
+ EXPECT_TRUE(desktop_pet_overlays_from_message(nlohmann::json{{"controls", {0, "bad", 1, 1}}}).empty());
+ EXPECT_TRUE(desktop_pet_overlays_from_message(nlohmann::json{{"controls", {0, 0, 0, 1}}}).empty());
+ nlohmann::json many = nlohmann::json::array();
+ for (int i = 0; i < 40; ++i) many.push_back({0.1, 0.1, 0.2, 0.2});
+ const auto overlays = desktop_pet_overlays_from_message(nlohmann::json{
+ {"controls", {-0.1, 0.0, 1.2, 0.1}}, {"rect", {0.0, 0.2, 0.3, 0.4}}, {"bubbles", many}});
+ ASSERT_EQ(overlays.size(), kDesktopPetMaxOverlays);
+ EXPECT_EQ(overlays.front(), (DesktopPetOverlay{0.0, 0.0, 1.0, 0.1}));
+ EXPECT_EQ(overlays[1], (DesktopPetOverlay{0.0, 0.2, 0.3, 0.4}));
+}
diff --git a/web/scripts/test-desktop-office.mjs b/web/scripts/test-desktop-office.mjs
index adbca1ba..bb62329f 100644
--- a/web/scripts/test-desktop-office.mjs
+++ b/web/scripts/test-desktop-office.mjs
@@ -22,6 +22,40 @@ try {
await page.addInitScript(()=>{window.officeActions=[];window.chrome={webview:{postMessage:value=>officeActions.push(value),addEventListener:(type,listener)=>{window.hostMessage=listener;}}};});
await page.goto(pathToFileURL(path.join(root,'assets/desktop_pet/agent_office_pet.html')).href);
await page.waitForFunction(()=>!!window.AgentOffice&&window.officeActions.includes('ready'));
+ const controlsVisible=()=>page.locator('#officeControls').evaluate(node=>getComputedStyle(node).opacity==='1');
+ const lastOverlay=()=>page.evaluate(()=>officeActions.map(raw=>{try{return JSON.parse(raw);}catch{return {};}}).filter(m=>m.type==='overlay').at(-1));
+ assert.equal(await controlsVisible(),false,'menu starts hidden');
+ assert.equal((await lastOverlay()).controls,null,'hidden menu has no native hit region');
+ await page.screenshot({path:path.join(output,'office-controls-hidden.png')});
+ await page.mouse.move(344,280);
+ assert.equal(await controlsVisible(),true,'hover reveals the menu');
+ assert.ok(Array.isArray((await lastOverlay()).controls),'visible menu supplies a native hit region');
+ await page.mouse.move(-10,-10);
+ await page.waitForTimeout(550);
+ assert.equal(await controlsVisible(),true,'leaving allows time to cross the gap');
+ await page.locator('#officePin').hover();
+ await page.waitForTimeout(600);
+ assert.equal(await controlsVisible(),true,'returning cancels the previous hide timer');
+ await page.locator('#officePin').click();
+ assert.ok(await page.evaluate(()=>officeActions.includes(JSON.stringify({type:'pin',pinned:false}))));
+ await page.evaluate(()=>hostMessage({data:{type:'pet-window-state',pinned:false}}));
+ assert.equal(await page.locator('#officePin').getAttribute('aria-pressed'),'false');
+ await page.mouse.move(-10,-10);
+ await page.waitForTimeout(1100);
+ assert.equal(await controlsVisible(),false,'mouse click focus does not keep the menu open');
+ assert.equal((await lastOverlay()).controls,null);
+ await page.keyboard.press('Tab');
+ assert.equal(await controlsVisible(),true,'Tab reveals controls without a pointer');
+ await page.waitForTimeout(1100);
+ assert.equal(await controlsVisible(),true,'keyboard focus keeps controls available');
+ assert.equal(await page.locator('#officeClose').evaluate(node=>node===document.activeElement),true);
+ await page.keyboard.press('Enter');
+ assert.ok(await page.evaluate(()=>officeActions.includes(JSON.stringify({type:'close'}))));
+ await page.mouse.move(344,280);
+ await page.locator('#officePin').click();
+ assert.ok(await page.evaluate(()=>officeActions.includes(JSON.stringify({type:'pin',pinned:true}))));
+ await page.evaluate(()=>hostMessage({data:{type:'pet-window-state',pinned:true}}));
+ assert.equal(await page.locator('#officePin').getAttribute('aria-pressed'),'true');
assert.equal(await page.evaluate(()=>AgentOffice.agents.length),0,'production starts without demo workers');
const apply=async snapshot=>{await page.evaluate(value=>window.hostMessage({data:value}),snapshot);await page.waitForTimeout(90);};
// 以模拟时间推进(走路、信封、等待),不依赖真实帧率;predicate 在页面里求值。
@@ -100,11 +134,12 @@ try {
assert.equal(await page.locator('#officeMembers').isHidden(),true);
assert.ok(await page.evaluate(()=>officeActions.some(raw=>{try{const m=JSON.parse(raw);return m.type==='overlay'&&m.rect===null;}catch{return false;}})));
- // The narrowest native scale keeps all five controls and the follow switch clickable.
+ // The narrowest native scale keeps tabs, follow, pin and close clickable.
for(const [width,height] of [[172,126],[344,252],[860,630]]) {
await page.setViewportSize({width,height});await page.waitForTimeout(90);
+ await page.mouse.move(width/2,height/2);
const bounds=await page.locator('.office-controls button').evaluateAll(buttons=>buttons.map(b=>{const r=b.getBoundingClientRect();return {x:r.x,y:r.y,right:r.right,bottom:r.bottom};}));
- assert.equal(bounds.length,6);
+ assert.equal(bounds.length,8);
assert.ok(bounds.every(r=>r.x>=0&&r.y>=0&&r.right<=width&&r.bottom<=32*width/344+1),JSON.stringify({width,bounds}));
await page.screenshot({path:path.join(output,`office-size-${width}.png`)});
}
@@ -116,5 +151,5 @@ try {
assert.match(await page.locator('#officeNotice').textContent(),/发送消息后/);
assert.ok(await page.evaluate(()=>{const notices=officeActions.map(raw=>{try{return JSON.parse(raw);}catch{return {};}}).filter(m=>m.type==='overlay');return Array.isArray(notices.at(-1)?.rect);}), 'native region includes empty-state notice');
assert.deepEqual(errors,[]);assert.deepEqual(external,[]);
- console.log(JSON.stringify({output,checks:40,errors,external},null,2));
+ console.log(JSON.stringify({output,checks:57,errors,external},null,2));
} finally {await browser.close();}
diff --git a/web/src/components/ChannelsSettings.jsx b/web/src/components/ChannelsSettings.jsx
index 7723c61f..da1d9a23 100644
--- a/web/src/components/ChannelsSettings.jsx
+++ b/web/src/components/ChannelsSettings.jsx
@@ -582,7 +582,7 @@ function ConnectWizard({ platformKey, platform, bind, busy, run, onDecide, start
const toOwner = useCallback(() => setStep(2), []);
return (
-
+
@@ -619,7 +619,7 @@ function ManageDialog({ platformKey, platform, busy, onRevoke, onChangeBot, onCl
const titleId = `ace-channels-manage-${platformKey}`;
const bot = savedBotSummary(platformKey, platform);
return (
-
+
diff --git a/web/src/components/ComposerSessionControls.jsx b/web/src/components/ComposerSessionControls.jsx
index 764aa8f0..b0b68d4e 100644
--- a/web/src/components/ComposerSessionControls.jsx
+++ b/web/src/components/ComposerSessionControls.jsx
@@ -328,6 +328,7 @@ export function ComposerSessionControls({
{swarmTag && (
}
@@ -360,6 +362,7 @@ export function ComposerSessionControls({
{pendingExpertName && (
}
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index 3652c523..e875c96b 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -1433,6 +1433,40 @@ body {
cursor: pointer;
overflow: hidden;
}
+.ace-composer-adaptive-chip::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: var(--ace-accent-bg);
+ box-shadow: inset 0 0 0 1px var(--ace-accent);
+ pointer-events: none;
+ opacity: 0;
+ animation: ace-composer-chip-highlight 400ms ease-out;
+}
+/* Animate the contents, not the measured button: compact layout stays stable. */
+.ace-composer-adaptive-chip > .ace-composer-chip-icon,
+.ace-composer-adaptive-chip > .ace-composer-adaptive-content {
+ position: relative;
+ animation: ace-composer-chip-arrive 240ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+@keyframes ace-composer-chip-highlight {
+ 0%, 25% { opacity: 1; }
+ 100% { opacity: 0; }
+}
+@keyframes ace-composer-chip-arrive {
+ from { transform: translateY(-4px); }
+ to { transform: translateY(0); }
+}
+@media (prefers-reduced-motion: reduce) {
+ .ace-composer-adaptive-chip::before {
+ animation-duration: 180ms;
+ }
+ .ace-composer-adaptive-chip > .ace-composer-chip-icon,
+ .ace-composer-adaptive-chip > .ace-composer-adaptive-content {
+ animation: none;
+ }
+}
.ace-composer-pending-chip {
color: var(--ace-warn);
}