From f073a82c46997f4b8d3bc7c79a2f25b987a1818b Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Fri, 16 Oct 2026 09:14:15 +0800 Subject: [PATCH 1/3] Match the whale peek to her sprite size and keep pets on screen The edge-peek art is drawn about 1.8 times larger than the sprite frames but was always painted 170 canvas pixels tall, so her head nearly doubled when she docked. It now uses the frame scale divided by that ratio. Releasing a pet over the screen edge without reaching the dock range (about a quarter of the window past the edge) left the window half off screen and cut off the session card above it. Releasing now pulls an undocked window back inside the work area on Windows and macOS. --- assets/desktop_pet/classic_pet.js | 16 +++++++++----- assets/desktop_pet/whale/README.md | 2 +- docs/desktop-agent-office.md | 4 ++-- src/apps/desktop/desktop_pet.cpp | 10 ++++++--- src/apps/desktop/desktop_pet_layout.cpp | 8 +++++++ src/apps/desktop/desktop_pet_layout.hpp | 5 +++++ src/apps/desktop/desktop_pet_mac.mm | 10 ++++++--- tests/desktop/desktop_pet_layout_test.cpp | 27 +++++++++++++++++++++++ web/scripts/test-classic-desktop-pets.mjs | 9 ++++++++ 9 files changed, 76 insertions(+), 15 deletions(-) diff --git a/assets/desktop_pet/classic_pet.js b/assets/desktop_pet/classic_pet.js index b60ec077..1bc058e3 100644 --- a/assets/desktop_pet/classic_pet.js +++ b/assets/desktop_pet/classic_pet.js @@ -55,7 +55,10 @@ // Extra hit margin (canvas px) so moving silhouettes stay clickable without per-frame region updates. // Docked Whale Girl peeks in from the screen edge; PEEK_HIDDEN of the art stays past the edge // so she can lean in without revealing where the drawing is cut. - const PEEK_HEIGHT = 170, PEEK_HIDDEN = 16; + // The peek art is drawn about 1.8 times larger than the sprite frames (eye height and headband-to- + // mouth both measure 1.8x), so it is shown at the frame scale divided by that ratio: her head + // keeps the size it has in every other pose instead of nearly doubling at the edge. + const PEEK_ART_RATIO = 1.8, PEEK_HIDDEN = 9; const MOTION_MARGIN = { float: [0, 4], breathe: [0, 3], shake: [3, 0], wiggle: [8, 0], tilt: [16, 2], peek: [PEEK_HIDDEN + 4, 8] }; const timing = { fidgetMin: 7000, fidgetMax: 16000, sleepAfter: 5 * 60 * 1000, greeting: 6000 }; // The original agents hold a pose through self-looping branches (Clippy's idle stares ~6s). @@ -101,14 +104,15 @@ const peeking = () => Boolean(docked && peek); const activeMotion = () => (peeking() ? 'peek' : motionKind || (profile?.breathe ? 'breathe' : '')); function geometry() { + const [w, h] = data.framesize; + const frameScale = Math.min(184 / w, 140 / h); if (peeking()) { - const height = PEEK_HEIGHT, width = height * peek.fw / peek.fh; - return { peek: true, w: peek.fw, h: peek.fh, scale: height / peek.fh, width, height, + const scale = frameScale / PEEK_ART_RATIO, width = peek.fw * scale, height = peek.fh * scale; + return { peek: true, w: peek.fw, h: peek.fh, scale, width, height, x: docked === 'right' ? cv.width - width + PEEK_HIDDEN : -PEEK_HIDDEN, y: cv.height - height - 2 }; } - const [w, h] = data.framesize; - const scale = Math.min(184 / w, 140 / h), width = w * scale, height = h * scale; - return { w, h, scale, width, height, x: (cv.width - width) / 2, y: cv.height - height - 8 }; + const width = w * frameScale, height = h * frameScale; + return { w, h, scale: frameScale, width, height, x: (cv.width - width) / 2, y: cv.height - height - 8 }; } function offsetAt(now, g) { const kindOfMotion = activeMotion(); diff --git a/assets/desktop_pet/whale/README.md b/assets/desktop_pet/whale/README.md index 07389b8a..aad5caeb 100644 --- a/assets/desktop_pet/whale/README.md +++ b/assets/desktop_pet/whale/README.md @@ -8,4 +8,4 @@ ## 趴边偷看(peek.png) -鲸鱼娘拖到屏幕左/右边缘吸附后改用 `peek.png`:两帧并排(左睁眼、右闭眼),每帧 178×300,透明背景,画面右缘就是她躲着的那条边(贴左边时页面水平镜像)。不来自上游项目,2026-10-16 用 Codex 的内置图像生成(codex-cli 0.162,`image_generation` 功能)绘制,以本目录 idle / welcome / joy 的首帧作为风格参考,要求同一角色设计与画风:从画面右缘探出半个头、双手扒着边、好奇又害羞地偷看。闭眼帧由同一张图做「只闭眼、其余不变」的编辑,再只把眼睛区域贴回睁眼图,眨眼时其余线条不抖动。纯绿背景抠成透明并去绿边,在她扒着的那条边线处裁掉,两帧同裁同缩放。生成用的是 ZipZipPipe 设计的角色形象,沿用上游许可说明。 +鲸鱼娘拖到屏幕左/右边缘吸附后改用 `peek.png`:两帧并排(左睁眼、右闭眼),每帧 178×300,透明背景,画面右缘就是她躲着的那条边(贴左边时页面水平镜像)。不来自上游项目,2026-10-16 用 Codex 的内置图像生成(codex-cli 0.162,`image_generation` 功能)绘制,以本目录 idle / welcome / joy 的首帧作为风格参考,要求同一角色设计与画风:从画面右缘探出半个头、双手扒着边、好奇又害羞地偷看。闭眼帧由同一张图做「只闭眼、其余不变」的编辑,再只把眼睛区域贴回睁眼图,眨眼时其余线条不抖动。纯绿背景抠成透明并去绿边,在她扒着的那条边线处裁掉,两帧同裁同缩放。这张原画比精灵帧大约 1.8 倍,页面按常规帧缩放 ÷ 1.8 绘制(`classic_pet.js` 的 `PEEK_ART_RATIO`),换图时要重新量这个比例。生成用的是 ZipZipPipe 设计的角色形象,沿用上游许可说明。 diff --git a/docs/desktop-agent-office.md b/docs/desktop-agent-office.md index 73eaa52d..ef427d31 100644 --- a/docs/desktop-agent-office.md +++ b/docs/desktop-agent-office.md @@ -16,8 +16,8 @@ Windows 与 macOS Desktop 可开启透明桌面宠物,默认关闭。设置 - **悬停**:鼠标移到宠物身上跳一下(Codex jumping,3 秒内不重复),悬停期间整个宠物框可点,避免跳动改变轮廓导致悬停闪烁。 - **单击**:做一个回应动作(回形针挥手、rover 被点、鲸鱼娘挥手);双击的第二下不会重复回应。 - **双击**:陪伴宠物双击把 ACECode 主窗口叫到前台(Codex Mini 也是直接打开主窗口),具体会话从卡片打开。虚拟办公室双击选中的人物打开其会话,双击空地打开当前会话,没有会话时也把主窗口叫到前台。Windows 由宠物窗口短暂挂接前台线程输入队列后激活主窗口(点击落在 WebView2 浏览器进程的子窗口里,本进程不一定有前台权限),macOS 走与通知点击相同的 `mac_bring_window_foreground`。 -- **趴边偷看(只有鲸鱼娘)**:拖到屏幕左 / 右边缘附近松手(窗口中心离边缘不超过窗口宽度的 1/4,约等于身体碰到屏幕边),窗口贴到那条边,鲸鱼娘换成从屏幕边探出半个头、双手扒着边偷看的样子(贴左边时镜像):缓慢地探进探出,每 2–5 秒眨一次眼、偶尔连眨两下;悬停时探得更靠里并开心地眯眼,有会话等你处理时也会多探出来一点,新完成的会话让她开心眯眼、失败时抖一下;双击照常打开 ACECode,再拖起来就恢复全身。缩放和恢复默认大小时保持贴边,「回到右下角」取消吸附。画面是用 Codex 按鲸鱼娘原有帧的画风画的两帧(睁眼 / 闭眼),来源与处理见 `assets/desktop_pet/whale/README.md`。吸附判定与贴边位置是 `desktop_pet_layout` 里的纯函数(`desktop_pet_dock_side` / `dock_desktop_pet`),原生在拖动结束时判定并发 `{type:'pet-dock', side:'left'|'right'|''}`。 -- **拖动**:原生拖动循环按窗口横向位移向页面发 `{type:'pet-drag', phase:'start'|'move'|'end', direction}`(`DesktopPetDragTracker` 累计 4 像素才报方向)。鲸鱼娘整个拖动过程都是被一只手拎起来的姿势(drag 帧)并以手为支点轻轻摆动;回形针朝拖动方向看;rover 先吃惊再朝拖动方向看,停住 0.45 秒转回正面。松开后继续原来的状态。 +- **趴边偷看(只有鲸鱼娘)**:拖到屏幕左 / 右边缘附近松手(窗口中心离边缘不超过窗口宽度的 1/4,约等于身体碰到屏幕边),窗口贴到那条边,鲸鱼娘换成从屏幕边探出半个头、双手扒着边偷看的样子(贴左边时镜像):缓慢地探进探出,每 2–5 秒眨一次眼、偶尔连眨两下;悬停时探得更靠里并开心地眯眼,有会话等你处理时也会多探出来一点,新完成的会话让她开心眯眼、失败时抖一下;双击照常打开 ACECode,再拖起来就恢复全身。缩放和恢复默认大小时保持贴边,「回到右下角」取消吸附。画面是用 Codex 按鲸鱼娘原有帧的画风画的两帧(睁眼 / 闭眼),来源与处理见 `assets/desktop_pet/whale/README.md`。偷看原画比精灵帧大约 1.8 倍(眼睛高度、发箍到嘴的距离都量过),按常规帧缩放 ÷ 1.8 绘制,头和站着时一样大;曾经固定画成 170 像素高,头大了将近一倍。吸附判定与贴边位置是 `desktop_pet_layout` 里的纯函数(`desktop_pet_dock_side` / `dock_desktop_pet`),原生在拖动结束时判定并发 `{type:'pet-dock', side:'left'|'right'|''}`。 +- **拖动**:原生拖动循环按窗口横向位移向页面发 `{type:'pet-drag', phase:'start'|'move'|'end', direction}`(`DesktopPetDragTracker` 累计 4 像素才报方向)。鲸鱼娘整个拖动过程都是被一只手拎起来的姿势(drag 帧)并以手为支点轻轻摆动;回形针朝拖动方向看;rover 先吃惊再朝拖动方向看,停住 0.45 秒转回正面。松开后继续原来的状态。松手时没有吸附,窗口又有一部分在屏幕外,就整窗收回工作区(`keep_desktop_pet_in_work_area`,所有宠物都适用)。吸附要窗口伸出屏幕约 1/4 宽,只是蹭到边上松手,正好会停在半出屏的位置,头顶的会话卡片会被屏幕边截掉。 虚拟办公室继续显示主/子 Agent 的完整协作场景。单会话有一个主角色 Maestro,多 agent 任务按真实子会话入座;无需启动额外模型,也没有演示任务循环。美术与拖动缩放来自原有 Claude 桌宠,旧的 `design-prototypes/agent-office` 事务所原型已移除。 diff --git a/src/apps/desktop/desktop_pet.cpp b/src/apps/desktop/desktop_pet.cpp index 7bc43959..eb64682e 100644 --- a/src/apps/desktop/desktop_pet.cpp +++ b/src/apps/desktop/desktop_pet.cpp @@ -283,11 +283,15 @@ struct PetController : std::enable_shared_from_this { webview->PostWebMessageAsJson(utf8_to_wide(event.dump()).c_str()); } - // 拖动松手后:鲸鱼娘离左/右边足够近就贴到那条边,页面换成趴边偷看。 + // 拖动松手后:鲸鱼娘离左/右边足够近就贴到那条边,页面换成趴边偷看; + // 其它情况把窗口收回工作区,头顶的会话卡片不会被屏幕边截掉。 void settle_dock() { const DesktopPetRect work = window_work_area(); - docked_side = pet_kind == "whale" ? desktop_pet_dock_side(window_rect(), work) : 0; - if (docked_side) apply_placement({dock_desktop_pet(window_rect(), work, docked_side), placement.scale}); + const DesktopPetRect current = window_rect(); + docked_side = pet_kind == "whale" ? desktop_pet_dock_side(current, work) : 0; + const DesktopPetRect next = docked_side ? dock_desktop_pet(current, work, docked_side) + : keep_desktop_pet_in_work_area(current, work); + if (next.x != current.x || next.y != current.y) apply_placement({next, placement.scale}); publish_dock(); } diff --git a/src/apps/desktop/desktop_pet_layout.cpp b/src/apps/desktop/desktop_pet_layout.cpp index d6e03933..04e83100 100644 --- a/src/apps/desktop/desktop_pet_layout.cpp +++ b/src/apps/desktop/desktop_pet_layout.cpp @@ -233,6 +233,14 @@ DesktopPetRect dock_desktop_pet(const DesktopPetRect& window, const DesktopPetRe return next; } +DesktopPetRect keep_desktop_pet_in_work_area(const DesktopPetRect& window, const DesktopPetRect& work_area) { + if (work_area.width <= 0 || work_area.height <= 0) return window; + DesktopPetRect next = window; + next.x = clamp_origin(window.x, window.width, work_area.x, work_area.width); + next.y = clamp_origin(window.y, window.height, work_area.y, work_area.height); + return next; +} + int DesktopPetDragTracker::move(double x) { if (!std::isfinite(x)) return 0; const double dx = x - anchor_; diff --git a/src/apps/desktop/desktop_pet_layout.hpp b/src/apps/desktop/desktop_pet_layout.hpp index a022b6d5..e34d2e45 100644 --- a/src/apps/desktop/desktop_pet_layout.hpp +++ b/src/apps/desktop/desktop_pet_layout.hpp @@ -92,6 +92,11 @@ int desktop_pet_dock_side(const DesktopPetRect& window, const DesktopPetRect& wo // 把窗口贴到工作区左(-1)/右(+1)边,竖直方向夹回工作区;side 为 0 时原样返回。 DesktopPetRect dock_desktop_pet(const DesktopPetRect& window, const DesktopPetRect& work_area, int side); +// 拖动松手后没有吸附时把整个窗口收回工作区:窗口一半露在屏幕外时,宠物头顶的会话卡片 +// 会被屏幕边截掉(吸附要求窗口伸出屏幕约 1/4 宽,只蹭到边上松手正好落在这个区间)。 +// 窗口比工作区还大时贴左 / 上边。 +DesktopPetRect keep_desktop_pet_in_work_area(const DesktopPetRect& window, const DesktopPetRect& work_area); + // 右键菜单「切换宠物」里的四种宠物,顺序与设置页一致;id 与 office.json 的 pet_kind 相同。 struct DesktopPetKind { const char* id; diff --git a/src/apps/desktop/desktop_pet_mac.mm b/src/apps/desktop/desktop_pet_mac.mm index e5d227a6..e48f65de 100644 --- a/src/apps/desktop/desktop_pet_mac.mm +++ b/src/apps/desktop/desktop_pet_mac.mm @@ -476,11 +476,15 @@ - (void)choose:(NSMenuItem*)item { deliver({{"type", "pet-dock"}, {"side", docked_side_ < 0 ? "left" : docked_side_ > 0 ? "right" : ""}}); } -// 拖动松手后:鲸鱼娘离左/右边足够近就贴到那条边,页面换成趴边偷看。 +// 拖动松手后:鲸鱼娘离左/右边足够近就贴到那条边,页面换成趴边偷看; +// 其它情况把窗口收回可见区域,头顶的会话卡片不会被屏幕边截掉。 void PetController::settle_dock() { const DesktopPetRect work = work_area(); - docked_side_ = pet_kind_ == "whale" ? desktop_pet_dock_side(window_rect(), work) : 0; - if (docked_side_) apply_placement({dock_desktop_pet(window_rect(), work, docked_side_), current_scale()}); + const DesktopPetRect current = window_rect(); + docked_side_ = pet_kind_ == "whale" ? desktop_pet_dock_side(current, work) : 0; + const DesktopPetRect next = docked_side_ ? dock_desktop_pet(current, work, docked_side_) + : keep_desktop_pet_in_work_area(current, work); + if (next.x != current.x || next.y != current.y) apply_placement({next, current_scale()}); publish_dock(); } diff --git a/tests/desktop/desktop_pet_layout_test.cpp b/tests/desktop/desktop_pet_layout_test.cpp index 99029e0d..4c84e2d5 100644 --- a/tests/desktop/desktop_pet_layout_test.cpp +++ b/tests/desktop/desktop_pet_layout_test.cpp @@ -362,6 +362,33 @@ TEST(DesktopPetLayout, DockingAlignsTheWindowEdgeAndKeepsItOnScreen) { EXPECT_EQ(kept.y, 200); } +// 场景:拖到屏幕边松手但没进入吸附范围(吸附要伸出约 1/4 宽),窗口停在半出屏的位置。 +// bug 表现:宠物头顶的会话卡片被屏幕右边截掉一截(2026-10-16 用户截图,窗口 1229,496 299x219、 +// 工作区只有 1440 宽)。期望:整个窗口收回工作区,尺寸不变;本来就在里面的不动; +// 比工作区还大时贴左 / 上边;工作区无效时原样返回。 +TEST(DesktopPetLayout, UndockedWindowIsPulledBackInsideTheWorkArea) { + const DesktopPetRect work{0, 0, 1440, 852}; + const auto right = keep_desktop_pet_in_work_area({1229, 496, 299, 219}, work); + EXPECT_EQ(right.x + right.width, 1440); + EXPECT_EQ(right.y, 496); + EXPECT_EQ(right.width, 299); + EXPECT_EQ(right.height, 219); + const auto corner = keep_desktop_pet_in_work_area({-40, 800, 299, 219}, work); + EXPECT_EQ(corner.x, 0); + EXPECT_EQ(corner.y, 852 - 219); + const auto top = keep_desktop_pet_in_work_area({300, -60, 299, 219}, {0, 40, 1440, 812}); + EXPECT_EQ(top.y, 40); + const auto inside = keep_desktop_pet_in_work_area({600, 300, 299, 219}, work); + EXPECT_EQ(inside.x, 600); + EXPECT_EQ(inside.y, 300); + const auto huge = keep_desktop_pet_in_work_area({100, 100, 2000, 1000}, work); + EXPECT_EQ(huge.x, 0); + EXPECT_EQ(huge.y, 0); + const DesktopPetRect left_monitor{-1920, 0, 1920, 1040}; + EXPECT_EQ(keep_desktop_pet_in_work_area({-100, 200, 299, 219}, left_monitor).x, -299); + EXPECT_EQ(keep_desktop_pet_in_work_area({50, 60, 299, 219}, {0, 0, 0, 0}).x, 50); +} + TEST(DesktopPetLayout, MacResizeGripRejectsInvalidOrOutsideCoordinates) { const double nan = std::numeric_limits::quiet_NaN(); const double inf = std::numeric_limits::infinity(); diff --git a/web/scripts/test-classic-desktop-pets.mjs b/web/scripts/test-classic-desktop-pets.mjs index bf11f051..8c686941 100644 --- a/web/scripts/test-classic-desktop-pets.mjs +++ b/web/scripts/test-classic-desktop-pets.mjs @@ -248,6 +248,15 @@ try { assert.equal(await page.evaluate(() => ClassicPet.docked), 'right'); assert.equal((await pet()).animation, 'peek'); assert.ok(await alphaAt(300, 344) > 500 && await alphaAt(0, 120) === 0, 'peeks in from the right edge'); + // 场景:贴右边换成偷看图。bug 表现:曾固定画成 170 像素高,头比站着时大将近一倍。 + // 期望:按常规帧缩放 ÷ 1.8(偷看原画比精灵帧大 1.8 倍)画,300 像素高的原画约 91 像素。 + const peekHeight = await page.evaluate(() => { + const d = document.getElementById('cv').getContext('2d').getImageData(0, 0, 344, 252).data; + let top = 252, bottom = -1; + for (let y = 0; y < 252; y++) for (let x = 0; x < 344; x++) if (d[(y * 344 + x) * 4 + 3] > 32) { top = Math.min(top, y); bottom = y; break; } + return bottom - top + 1; + }); + assert.ok(peekHeight >= 80 && peekHeight <= 100, `peek head matches the standing pose (${peekHeight}px tall)`); await page.waitForFunction(() => ClassicPet.peekEyes === 1, null, { timeout: 12000 }); await page.waitForFunction(() => ClassicPet.peekEyes === 0, null, { timeout: 2000 }); assert.ok(await longestStill(1500) < 1500, 'docked whale keeps swaying'); From c4cb8f6bf0d36e8a21adf687074f2b2c0414fa38 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Fri, 16 Oct 2026 09:21:40 +0800 Subject: [PATCH 2/3] Announce the new desktop pets once to existing users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users who already answered the virtual office invitation get a one-time dialog: "增加更多宠物,丰富您快乐工作的每一天" with Clippy, Rover and Whale Girl shown as picture and name only. Picking one switches to it and turns desktop pets on; "知道了" closes it. office.json gains pets_promo_seen, claimed atomically through aceDesktop_claimPetsPromo. First-time users consume it together with the invitation (or when they toggle pets before ever seeing it), so nobody gets two dialogs. The dialog shares the invitation's blocking and guided-tour rules. --- docs/desktop-agent-office.md | 1 + src/apps/desktop/desktop_office_service.hpp | 34 +++++++++--- tests/desktop/desktop_office_service_test.cpp | 45 ++++++++++++++++ web/scripts/i18n-en-overrides.mjs | 1 + .../test-virtual-office-preferences.mjs | 36 +++++++++++-- web/src/App.jsx | 27 ++++++++-- web/src/components/DesktopPetsPromo.jsx | 53 +++++++++++++++++++ web/src/i18n/sourceCatalog.generated.js | 2 + web/src/lib/desktopOfficePreferences.js | 6 ++- web/src/lib/desktopOfficePreferences.test.js | 24 +++++++++ web/src/lib/useDesktopOffice.js | 3 +- 11 files changed, 217 insertions(+), 15 deletions(-) create mode 100644 web/src/components/DesktopPetsPromo.jsx diff --git a/docs/desktop-agent-office.md b/docs/desktop-agent-office.md index ef427d31..12ded0a1 100644 --- a/docs/desktop-agent-office.md +++ b/docs/desktop-agent-office.md @@ -25,6 +25,7 @@ Windows 与 macOS Desktop 可开启透明桌面宠物,默认关闭。设置 - 左下角齿轮菜单的“显示桌面宠物 / 隐藏桌面宠物”位于“设置”和“外观”之间。设置里的“桌面宠物”位于“个性化”和“使用情况”之间,提供同一个开启开关。 - 从 0.9.37 稳定版起,没有保存过选择的用户会收到一次开启邀请。关闭或选择“暂不开启”保持关闭;以后仍可从菜单或设置开启。预览复用嵌入的办公室 HTML,在独立沙箱 iframe 中播放演示,关闭弹框后释放;支持暂停和系统的“减少动态效果”,不访问真实会话或额外网络。 +- 「更多宠物」宣传:已经答复过上面邀请的老用户,启动后弹一次「增加更多宠物,丰富您快乐工作的每一天」,只展示大眼回形针、rover、鲸鱼娘的图和名字,不加说明(用户要求)。点一只就切成它,宠物没开就同时开启;「知道了」关闭。`office.json` 的 `pets_promo_seen` 记录是否看过,由 `aceDesktop_claimPetsPromo` 原子认领。新用户认领开启邀请时,或者没看过邀请就在设置里开关宠物时,这个标记一并置位,不会连弹两个窗。弹窗在 `components/DesktopPetsPromo.jsx`,与开启邀请共用遮挡与引导让位规则。 - 顶部菜单在鼠标移入办公室时显示,移出后保留 1 秒再隐藏;重新移入会取消隐藏,键盘操作时保持可见。隐藏菜单的透明区域可穿透点击。 - 菜单下方显示当前会话标题,切换或改名后同步更新;长标题单行省略,悬停可查看全文。断线或尚无选中会话时保留对应状态提示。 - 菜单右侧 pin 控制办公室是否始终置顶:默认置顶,取消后可被其他普通窗口覆盖,拖动、缩放和停靠均保持当前选择;重新启动桌宠恢复默认置顶。叉号只关闭办公室并同步菜单/设置的关闭状态,不退出 ACECode,也不停止 agent 任务;可在同一次运行中重新开启。 diff --git a/src/apps/desktop/desktop_office_service.hpp b/src/apps/desktop/desktop_office_service.hpp index 44938f26..18516848 100644 --- a/src/apps/desktop/desktop_office_service.hpp +++ b/src/apps/desktop/desktop_office_service.hpp @@ -30,6 +30,7 @@ class DesktopOfficeService : public std::enable_shared_from_this())) pet_kind_ = kind.get(); } @@ -63,8 +64,21 @@ class DesktopOfficeService : public std::enable_shared_from_thisstate()["welcomePending"]; - if (show && !self->save(self->enabled_, true)) return self->failure("save_failed").dump(); - if (show) self->welcome_seen_ = true; + // A first-time user meets every pet through this invitation, so the later + // "more pets" announcement is consumed with it rather than shown on the next launch. + if (show && !self->save(self->enabled_, true, true)) return self->failure("save_failed").dump(); + if (show) self->welcome_seen_ = self->pets_promo_seen_ = true; + auto result = self->state(); + result["show"] = show; + return result.dump(); + }); + // One-time announcement of the companion pets for users who already answered the invitation. + host_.bind("aceDesktop_claimPetsPromo", [weak](const std::string&) { + const auto self = weak.lock(); + if (!self) return std::string("{\"ok\":false}"); + const bool show = self->state()["petsPromoPending"]; + if (show && !self->save(self->enabled_, self->welcome_seen_, true)) return self->failure("save_failed").dump(); + if (show) self->pets_promo_seen_ = true; auto result = self->state(); result["show"] = show; return result.dump(); @@ -98,7 +112,8 @@ class DesktopOfficeService : public std::enable_shared_from_this preview_; diff --git a/tests/desktop/desktop_office_service_test.cpp b/tests/desktop/desktop_office_service_test.cpp index 02798927..10f3f417 100644 --- a/tests/desktop/desktop_office_service_test.cpp +++ b/tests/desktop/desktop_office_service_test.cpp @@ -80,6 +80,51 @@ TEST_F(DesktopOfficeServiceTest, WelcomeIsClaimedOnceAcrossRestartsWithoutEnabli EXPECT_FALSE(service->state()["enabled"].get()); } +// 场景:升级上来的老用户(office.json 已记过 welcome_seen)第一次启动带「更多宠物」宣传的版本。 +// 期望:宣传待展示,认领一次就不再出现(重启后也不出现),认领不改变已开启的宠物与当前宠物。 +TEST_F(DesktopOfficeServiceTest, PetsPromoIsClaimedOnceByUsersWhoAnsweredTheInvitation) { + std::filesystem::create_directories(dir); + { std::ofstream file(dir / "office.json"); file << R"({"enabled":true,"welcome_seen":true,"pet_kind":"office"})"; } + open("1.0.8"); + EXPECT_FALSE(service->state()["welcomePending"].get()); + EXPECT_TRUE(service->state()["petsPromoPending"].get()); + const Json first = host.call("aceDesktop_claimPetsPromo"); + EXPECT_TRUE(first["show"].get()); + EXPECT_FALSE(first["petsPromoPending"].get()); + EXPECT_FALSE(host.call("aceDesktop_claimPetsPromo")["show"].get()); + open("1.0.8"); + EXPECT_FALSE(host.call("aceDesktop_claimPetsPromo")["show"].get()); + EXPECT_TRUE(service->state()["enabled"].get()); + EXPECT_EQ(service->state()["petKind"], "office"); +} + +// 场景:全新用户。期望:只弹虚拟办公室的开启邀请;认领邀请时一并消耗「更多宠物」宣传, +// 下次启动不会再弹第二个窗。没看过邀请就在设置里开 / 关宠物,同样视为已经见过所有宠物; +// 老用户在设置里开关宠物则不影响宣传。 +TEST_F(DesktopOfficeServiceTest, FirstTimeUsersOnlyGetTheInvitation) { + open("1.0.8"); + EXPECT_TRUE(service->state()["welcomePending"].get()); + EXPECT_FALSE(service->state()["petsPromoPending"].get()); + EXPECT_TRUE(host.call("aceDesktop_claimOfficeWelcome")["show"].get()); + EXPECT_FALSE(service->state()["petsPromoPending"].get()); + open("1.0.8"); + EXPECT_FALSE(host.call("aceDesktop_claimPetsPromo")["show"].get()); + + std::filesystem::remove_all(dir); + open("1.0.8"); + ASSERT_TRUE(service->set_enabled(true)["ok"].get()); + EXPECT_FALSE(service->state()["petsPromoPending"].get()); + + std::filesystem::remove_all(dir); + std::filesystem::create_directories(dir); + { std::ofstream file(dir / "office.json"); file << R"({"enabled":true,"welcome_seen":true})"; } + open("1.0.8"); + ASSERT_TRUE(service->set_enabled(false)["ok"].get()); + EXPECT_TRUE(service->state()["petsPromoPending"].get()); + open("0.9.37", false); + EXPECT_FALSE(service->state()["petsPromoPending"].get()); +} + TEST_F(DesktopOfficeServiceTest, EnablingPersistsAndClosingReopensWithLatestSnapshot) { open(); ASSERT_TRUE(service->set_enabled(true)["ok"].get()); diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 9e79bd02..9c0d1916 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -2137,6 +2137,7 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '桌面宠物': 'Desktop pets', '开启桌面宠物': 'Enable desktop pet', '开启桌面宠物?': 'Enable a desktop pet?', + '增加更多宠物,丰富您快乐工作的每一天': 'More pets to brighten every working day', '显示桌面宠物': 'Show desktop pet', '隐藏桌面宠物': 'Hide desktop pet', '选择桌面宠物': 'Choose a desktop pet', diff --git a/web/scripts/test-virtual-office-preferences.mjs b/web/scripts/test-virtual-office-preferences.mjs index 5429475e..4c68e773 100644 --- a/web/scripts/test-virtual-office-preferences.mjs +++ b/web/scripts/test-virtual-office-preferences.mjs @@ -18,16 +18,18 @@ import {createRoot} from 'react-dom/client'; import {SidebarQuickMenu} from '/src/components/SidebarQuickMenu.jsx'; import {VirtualOfficeSettings} from '/src/components/VirtualOfficeSettings.jsx'; import {VirtualOfficeWelcome} from '/src/components/VirtualOfficeWelcome.jsx'; +import {DesktopPetsPromo} from '/src/components/DesktopPetsPromo.jsx'; import {useDesktopOffice} from '/src/lib/useDesktopOffice.js'; import {api} from '/src/lib/api.js'; import {connection} from '/src/lib/connection.js'; import '/src/styles/globals.css'; -let prefs={ok:true,available:true,enabled:false,welcomePending:true}; +let prefs={ok:true,available:true,enabled:false,welcomePending:true,petsPromoPending:true}; window.snapshotRequests=0;window.published=[];window.previewRequests=0;window.retained=new Set(); window.aceDesktop_getOfficePreferences=async()=>prefs; window.aceDesktop_setOfficeEnabled=async enabled=>(prefs={...prefs,enabled,welcomePending:false}); window.aceDesktop_setPetKind=async petKind=>(prefs={...prefs,petKind}); window.aceDesktop_claimOfficeWelcome=async()=>{const show=prefs.welcomePending;prefs={...prefs,welcomePending:false};return {...prefs,show};}; +window.aceDesktop_claimPetsPromo=async()=>{const show=prefs.petsPromoPending;prefs={...prefs,petsPromoPending:false};return {...prefs,show};}; window.aceDesktop_getOfficeState=async()=>({follow:true}); window.aceDesktop_updateOffice=async value=>{window.published.push(JSON.parse(value));return true;}; window.aceDesktop_getOfficePreview=async()=>{window.previewRequests++;return ${JSON.stringify(html)};}; @@ -37,12 +39,15 @@ Object.assign(api,{getDesktopOffice:async()=>{window.snapshotRequests++;const s= function Fixture(){ const office=useDesktopOffice({sessionId:'live',workspaceHash:'w'}); const [welcome,setWelcome]=useState(false); + const [promo,setPromo]=useState(false); window.office=office; return
-
+ +
{welcome&&setWelcome(false)}/>} + {promo&&setPromo(false)}/>}
; } createRoot(document.getElementById('root')).render(); @@ -150,7 +155,32 @@ try { await page.locator('iframe').waitFor({ state: 'detached' }); assert.equal(await page.getByRole('dialog').count(), 0); assert.equal(await page.locator('iframe').count(), 0); + // 场景:老用户第一次看到「更多宠物」宣传,此时桌面宠物是关着的。 + // 期望:只有一句文案和三只新宠物(图 + 名字),不加说明;点一只就切成它并打开宠物, + // 弹窗关闭;宣传只认领一次。浅色 / 深色、窄屏都不出横向滚动。 + await page.evaluate(() => nativeClose()); + await page.waitForFunction(() => !office.enabled); + await page.locator('#pets-promo').click(); + const promo = page.getByRole('dialog'); + await promo.waitFor(); + assert.equal(await promo.locator('h2').textContent(), '增加更多宠物,丰富您快乐工作的每一天'); + assert.deepEqual(await promo.locator('button[aria-pressed]').allTextContents(), ['大眼回形针', 'rover', '鲸鱼娘']); + assert.equal(await promo.locator('img').count(), 3); + assert.equal(await promo.locator('p').count(), 0, 'no explanatory text'); + await page.waitForFunction(() => [...document.querySelectorAll('[role=dialog] img')].every(img => img.complete && img.naturalWidth > 0)); + for (const theme of ['light', 'dark']) for (const width of [1100, 390]) { + await page.setViewportSize({ width, height: 800 }); + await page.evaluate(t => { document.documentElement.dataset.theme = t; }, theme); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + await page.screenshot({ path: path.join(output, `pets-promo-${theme}-${width}.png`) }); + } + await page.setViewportSize({ width: 1100, height: 800 }); + await promo.getByRole('button', { name: '鲸鱼娘' }).click(); + await page.waitForFunction(() => office.enabled && office.petKind === 'whale'); + await promo.waitFor({ state: 'detached' }); + await page.locator('#pets-promo').click(); + assert.equal(await page.getByRole('dialog').count(), 0, 'the pets announcement is claimed once'); assert.deepEqual(errors, []); assert.deepEqual(external, []); - console.log(JSON.stringify({ ok: true, coverage: 'opt-in, menu ordering, close/reopen, polling teardown, sandbox preview, light/dark, narrow viewport, pause, reduced motion, once-only claim', output })); + console.log(JSON.stringify({ ok: true, coverage: 'opt-in, menu ordering, close/reopen, polling teardown, sandbox preview, light/dark, narrow viewport, pause, reduced motion, once-only claim, pets announcement', output })); } finally { await browser?.close(); await server.close(); } diff --git a/web/src/App.jsx b/web/src/App.jsx index c456a602..e2436d79 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,6 +1,7 @@ import { useDesktopOffice } from './lib/useDesktopOffice.js'; import { isOfficeWelcomeVisible } from './lib/desktopOfficePreferences.js'; import { VirtualOfficeWelcome } from './components/VirtualOfficeWelcome.jsx'; +import { DesktopPetsPromo } from './components/DesktopPetsPromo.jsx'; // 顶层 App:鉴权 gate(401 → TokenPrompt)+ 主壳。 // // 视觉对齐设计稿方向 C:顶部 41px TopBar + 270px Sidebar + 主区(单会话/4宫格/9宫格) @@ -260,6 +261,9 @@ export function App() { const office = useDesktopOffice(activeRef); const [officeWelcomeOpen, setOfficeWelcomeOpen] = useState(false); const officeWelcomeAttempted = useRef(false); + const [petsPromoOpen, setPetsPromoOpen] = useState(false); + const petsPromoAttempted = useRef(false); + const desktopAnnouncementOpen = officeWelcomeOpen || petsPromoOpen; const workbenchOwner = sessionWorkbench.ownerFor(activeRef); const [sessionTitleTarget, setSessionTitleTarget] = useState(null); const [sessionActionsTarget, setSessionActionsTarget] = useState(null); @@ -520,7 +524,9 @@ export function App() { // Blocking it with the queued invitation would make both surfaces wait forever. const officeWelcomeVisible = isOfficeWelcomeVisible({ requested: officeWelcomeOpen, blocked: officeInvitationBlocked, tourPreparing: guidedTourPreparing, tourRunning: guidedTourRun }); - const guidedTourBlocked = officeInvitationBlocked || officeWelcomeVisible; + const petsPromoVisible = isOfficeWelcomeVisible({ requested: petsPromoOpen, + blocked: officeInvitationBlocked, tourPreparing: guidedTourPreparing, tourRunning: guidedTourRun }); + const guidedTourBlocked = officeInvitationBlocked || officeWelcomeVisible || petsPromoVisible; useEffect(() => { if (officeWelcomeAttempted.current || !office.welcomePending || authState !== 'ok' || !health @@ -530,6 +536,16 @@ export function App() { if (result.ok && result.show) setOfficeWelcomeOpen(true); }); }, [office.welcomePending, office.claimWelcome, authState, health, officeInvitationBlocked, guidedTourPreparing, guidedTourRun]); + // "More pets" is announced once to users who already answered the invitation above. + useEffect(() => { + if (petsPromoAttempted.current || office.welcomePending || !office.petsPromoPending || officeWelcomeOpen + || authState !== 'ok' || !health || officeInvitationBlocked || guidedTourPreparing || guidedTourRun) return; + petsPromoAttempted.current = true; + void office.claimPetsPromo().then(result => { + if (result.ok && result.show) setPetsPromoOpen(true); + }); + }, [office.welcomePending, office.petsPromoPending, office.claimPetsPromo, officeWelcomeOpen, authState, health, + officeInvitationBlocked, guidedTourPreparing, guidedTourRun]); useEffect(() => { if (office.error) toast({ kind: 'err', text: office.error }); }, [office.error]); useEffect(() => initInactiveSelection(), []); @@ -2193,7 +2209,7 @@ export function App() { || (projectSidebarCollapsed && !guidedTourPreparing && !guidedTourRun); const nativeSurfacesVisible = !showSettings - && !officeWelcomeOpen + && !desktopAnnouncementOpen && !showFeedback && !searchOpen && !updateDialogOpen @@ -2212,7 +2228,7 @@ export function App() { desktopMode: desktopModeRef.current, chatVisible: view === 'single' && !activeRef?.loop && !activeRef?.expertComponents, blockingSurfaceOpen: showSettings || showFeedback || searchOpen || updateDialogOpen - || officeWelcomeOpen + || desktopAnnouncementOpen || desktopCloseDialogOpen || configRecoveryBlocking || !!visibleQuestionReq || guidedTourPreparing || guidedTourRun, }); @@ -2222,7 +2238,7 @@ export function App() { loop: !!activeRef?.loop, showSettings: showSettings || showFeedback, searchOpen, - updateDialogOpen: updateDialogOpen || desktopCloseDialogOpen || configRecoveryBlocking || officeWelcomeOpen, + updateDialogOpen: updateDialogOpen || desktopCloseDialogOpen || configRecoveryBlocking || desktopAnnouncementOpen, permissionOpen: false, questionOpen: !!visibleQuestionReq, guidedTourPreparing, @@ -2500,6 +2516,9 @@ export function App() { {officeWelcomeVisible && ( setOfficeWelcomeOpen(false)} /> )} + {petsPromoVisible && ( + setPetsPromoOpen(false)} /> + )} PREVIEWS[pet.id]); + +// One-time announcement of the companion pets: just the pets, no explanations (user decision). +// Picking one switches to it, turning desktop pets on if they were off. +export function DesktopPetsPromo({ office, onClose }) { + const close = () => { if (!office.busy) onClose(); }; + const choose = async kind => { + if (office.busy) return; + if (office.petKind !== kind && !(await office.setPetKind(kind)).ok) return; + if (!office.enabled && !(await office.setEnabled(true)).ok) return; + onClose(); + }; + return ( + +
+
+

增加更多宠物,丰富您快乐工作的每一天

+ +
+
+ {NEW_PETS.map(pet => { + const current = office.enabled && office.petKind === pet.id; + return ; + })} +
+ {office.error &&

{office.error}

} +
+ +
+
+
+ ); +} diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index d183858b..d9cbfc61 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -2166,6 +2166,7 @@ export const sourceCatalogs = { "s_cadd75ef8275de01": "已查看 {{p0}} 张图像", "s_caecb5d342cc889a": "恢复目标", "s_cafc8a4f6acda2cb": "Tag 可多选;同一专家会出现在每个已选 Tag 下", + "s_cb16eec3c3bd3c51": "增加更多宠物,丰富您快乐工作的每一天", "s_cb2fcb9099668d50": "暂无回答", "s_cb31eb7a0c2aa250": "编辑模型 · {{p0}}", "s_cb51a062db592988": "这个机器人正由另一个 ACECode 连接,它退出后这里会自动接管。", @@ -4921,6 +4922,7 @@ export const sourceCatalogs = { "s_cadd75ef8275de01": "Viewed {{p0}} images", "s_caecb5d342cc889a": "Resume goal", "s_cafc8a4f6acda2cb": "Tags can be selected multiple times; the same expert will appear under each selected Tag", + "s_cb16eec3c3bd3c51": "More pets to brighten every working day", "s_cb2fcb9099668d50": "No answer yet", "s_cb31eb7a0c2aa250": "Edit model · {{p0}}", "s_cb51a062db592988": "Another ACECode is connected to this bot. This one takes over automatically after it exits.", diff --git a/web/src/lib/desktopOfficePreferences.js b/web/src/lib/desktopOfficePreferences.js index 53305f83..0622497b 100644 --- a/web/src/lib/desktopOfficePreferences.js +++ b/web/src/lib/desktopOfficePreferences.js @@ -16,7 +16,8 @@ export function parseOfficePreferences(value) { // One owner for the menu, settings, invitation and native close notifications. export function createDesktopOfficePreferences(win) { - let state = { ready: false, available: false, enabled: false, petKind: 'office', welcomePending: false, busy: false, error: '' }; + let state = { ready: false, available: false, enabled: false, petKind: 'office', welcomePending: false, petsPromoPending: false, + busy: false, error: '' }; let revision = 0; let disposed = false; const listeners = new Set(); @@ -74,5 +75,8 @@ export function createDesktopOfficePreferences(win) { return result; }, claimWelcome: () => request('aceDesktop_claimOfficeWelcome'), + // An older desktop shell has no pets announcement; treat it as already seen. + claimPetsPromo: () => (typeof win?.aceDesktop_claimPetsPromo === 'function' + ? request('aceDesktop_claimPetsPromo') : Promise.resolve({ ok: true, show: false })), }; } diff --git a/web/src/lib/desktopOfficePreferences.test.js b/web/src/lib/desktopOfficePreferences.test.js index 78bd233b..d6e49f9c 100644 --- a/web/src/lib/desktopOfficePreferences.test.js +++ b/web/src/lib/desktopOfficePreferences.test.js @@ -52,6 +52,30 @@ function fixture(overrides = {}) { assert.equal(store.getSnapshot().enabled, true, 'closed office can be reenabled'); store.dispose(); } +// 场景:老用户启动带「更多宠物」宣传的版本,原生状态带 petsPromoPending。 +// 期望:状态透传给界面,认领一次后不再显示;旧版桌面壳没有这个 bridge 时按已看过处理,不报错。 +{ + let promo = true; + const { store } = fixture({ + aceDesktop_getOfficePreferences: async () => JSON.stringify({ ...initial, welcomePending: false, petsPromoPending: promo }), + aceDesktop_claimPetsPromo: async () => { + const show = promo; + promo = false; + return { ...initial, welcomePending: false, petsPromoPending: false, show }; + }, + }); + store.start(); await flush(); + assert.equal(store.getSnapshot().petsPromoPending, true); + assert.equal((await store.claimPetsPromo()).show, true); + assert.equal(store.getSnapshot().petsPromoPending, false); + assert.equal((await store.claimPetsPromo()).show, false); + store.dispose(); + const older = fixture(); + older.store.start(); await flush(); + assert.equal(older.store.getSnapshot().petsPromoPending, false); + assert.deepEqual(await older.store.claimPetsPromo(), { ok: true, show: false }); + older.store.dispose(); +} { let resolve; const { store, emit } = fixture({ aceDesktop_setOfficeEnabled: () => new Promise(r => { resolve = r; }) }); diff --git a/web/src/lib/useDesktopOffice.js b/web/src/lib/useDesktopOffice.js index 5e5f64ee..291b92ec 100644 --- a/web/src/lib/useDesktopOffice.js +++ b/web/src/lib/useDesktopOffice.js @@ -74,5 +74,6 @@ export function useDesktopOffice(activeRef) { }; }, [state.enabled, state.available]); useEffect(() => {controllerRef.current?.setActive(activeRef);}, [activeRef]); - return { ...state, setEnabled: preferences.setEnabled, setPetKind: preferences.setPetKind, claimWelcome: preferences.claimWelcome }; + return { ...state, setEnabled: preferences.setEnabled, setPetKind: preferences.setPetKind, claimWelcome: preferences.claimWelcome, + claimPetsPromo: preferences.claimPetsPromo }; } From 3d7b8cee5ee2c9dfc2ee2a16093520e396ffd1e4 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Fri, 16 Oct 2026 09:52:11 +0800 Subject: [PATCH 3/3] =?UTF-8?q?Give=20the=20pets=20announcement=20a=20?= =?UTF-8?q?=E6=96=B0=E7=89=B9=E6=80=A7=20title=20above=20its=20subtitle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/desktop-agent-office.md | 2 +- web/scripts/i18n-en-overrides.mjs | 1 + web/scripts/test-virtual-office-preferences.mjs | 9 ++++++--- web/src/components/DesktopPetsPromo.jsx | 8 +++++--- web/src/i18n/sourceCatalog.generated.js | 2 ++ 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/desktop-agent-office.md b/docs/desktop-agent-office.md index 12ded0a1..59cc4935 100644 --- a/docs/desktop-agent-office.md +++ b/docs/desktop-agent-office.md @@ -25,7 +25,7 @@ Windows 与 macOS Desktop 可开启透明桌面宠物,默认关闭。设置 - 左下角齿轮菜单的“显示桌面宠物 / 隐藏桌面宠物”位于“设置”和“外观”之间。设置里的“桌面宠物”位于“个性化”和“使用情况”之间,提供同一个开启开关。 - 从 0.9.37 稳定版起,没有保存过选择的用户会收到一次开启邀请。关闭或选择“暂不开启”保持关闭;以后仍可从菜单或设置开启。预览复用嵌入的办公室 HTML,在独立沙箱 iframe 中播放演示,关闭弹框后释放;支持暂停和系统的“减少动态效果”,不访问真实会话或额外网络。 -- 「更多宠物」宣传:已经答复过上面邀请的老用户,启动后弹一次「增加更多宠物,丰富您快乐工作的每一天」,只展示大眼回形针、rover、鲸鱼娘的图和名字,不加说明(用户要求)。点一只就切成它,宠物没开就同时开启;「知道了」关闭。`office.json` 的 `pets_promo_seen` 记录是否看过,由 `aceDesktop_claimPetsPromo` 原子认领。新用户认领开启邀请时,或者没看过邀请就在设置里开关宠物时,这个标记一并置位,不会连弹两个窗。弹窗在 `components/DesktopPetsPromo.jsx`,与开启邀请共用遮挡与引导让位规则。 +- 「更多宠物」宣传:已经答复过上面邀请的老用户,启动后在首屏弹一次:主标题「新特性」,副标题「增加更多宠物,丰富您快乐工作的每一天」,只展示大眼回形针、rover、鲸鱼娘的图和名字,不加说明(用户要求)。点一只就切成它,宠物没开就同时开启;「知道了」关闭。`office.json` 的 `pets_promo_seen` 记录是否看过,由 `aceDesktop_claimPetsPromo` 原子认领。新用户认领开启邀请时,或者没看过邀请就在设置里开关宠物时,这个标记一并置位,不会连弹两个窗。弹窗在 `components/DesktopPetsPromo.jsx`,与开启邀请共用遮挡与引导让位规则。 - 顶部菜单在鼠标移入办公室时显示,移出后保留 1 秒再隐藏;重新移入会取消隐藏,键盘操作时保持可见。隐藏菜单的透明区域可穿透点击。 - 菜单下方显示当前会话标题,切换或改名后同步更新;长标题单行省略,悬停可查看全文。断线或尚无选中会话时保留对应状态提示。 - 菜单右侧 pin 控制办公室是否始终置顶:默认置顶,取消后可被其他普通窗口覆盖,拖动、缩放和停靠均保持当前选择;重新启动桌宠恢复默认置顶。叉号只关闭办公室并同步菜单/设置的关闭状态,不退出 ACECode,也不停止 agent 任务;可在同一次运行中重新开启。 diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 9c0d1916..994f3bbb 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -2137,6 +2137,7 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '桌面宠物': 'Desktop pets', '开启桌面宠物': 'Enable desktop pet', '开启桌面宠物?': 'Enable a desktop pet?', + '新特性': 'New feature', '增加更多宠物,丰富您快乐工作的每一天': 'More pets to brighten every working day', '显示桌面宠物': 'Show desktop pet', '隐藏桌面宠物': 'Hide desktop pet', diff --git a/web/scripts/test-virtual-office-preferences.mjs b/web/scripts/test-virtual-office-preferences.mjs index 4c68e773..1d54477a 100644 --- a/web/scripts/test-virtual-office-preferences.mjs +++ b/web/scripts/test-virtual-office-preferences.mjs @@ -156,17 +156,20 @@ try { assert.equal(await page.getByRole('dialog').count(), 0); assert.equal(await page.locator('iframe').count(), 0); // 场景:老用户第一次看到「更多宠物」宣传,此时桌面宠物是关着的。 - // 期望:只有一句文案和三只新宠物(图 + 名字),不加说明;点一只就切成它并打开宠物, + // 期望:主标题「新特性」+ 一行副标题,再加三只新宠物(图 + 名字),不加说明;点一只就切成它并打开宠物, // 弹窗关闭;宣传只认领一次。浅色 / 深色、窄屏都不出横向滚动。 await page.evaluate(() => nativeClose()); await page.waitForFunction(() => !office.enabled); await page.locator('#pets-promo').click(); const promo = page.getByRole('dialog'); await promo.waitFor(); - assert.equal(await promo.locator('h2').textContent(), '增加更多宠物,丰富您快乐工作的每一天'); + assert.equal(await promo.locator('h2').textContent(), '新特性'); + assert.deepEqual(await promo.locator('p').allTextContents(), ['增加更多宠物,丰富您快乐工作的每一天'], + 'the subtitle is the only text besides the title'); + assert.ok(await promo.locator('h2').evaluate(h => parseFloat(getComputedStyle(h).fontSize)) + > await promo.locator('p').evaluate(p => parseFloat(getComputedStyle(p).fontSize)), 'title outranks the subtitle'); assert.deepEqual(await promo.locator('button[aria-pressed]').allTextContents(), ['大眼回形针', 'rover', '鲸鱼娘']); assert.equal(await promo.locator('img').count(), 3); - assert.equal(await promo.locator('p').count(), 0, 'no explanatory text'); await page.waitForFunction(() => [...document.querySelectorAll('[role=dialog] img')].every(img => img.complete && img.naturalWidth > 0)); for (const theme of ['light', 'dark']) for (const width of [1100, 390]) { await page.setViewportSize({ width, height: 800 }); diff --git a/web/src/components/DesktopPetsPromo.jsx b/web/src/components/DesktopPetsPromo.jsx index 6ce8044b..0c0759c0 100644 --- a/web/src/components/DesktopPetsPromo.jsx +++ b/web/src/components/DesktopPetsPromo.jsx @@ -9,7 +9,8 @@ const PREVIEWS = { clippy: clippyPreview, rover: roverPreview, whale: whalePrevi // The companion pets added after the virtual office, in Settings order. const NEW_PETS = DESKTOP_PET_KINDS.filter(pet => PREVIEWS[pet.id]); -// One-time announcement of the companion pets: just the pets, no explanations (user decision). +// One-time announcement of the companion pets: a "新特性" title, one subtitle line and the pets, +// no explanations (user decision). // Picking one switches to it, turning desktop pets on if they were off. export function DesktopPetsPromo({ office, onClose }) { const close = () => { if (!office.busy) onClose(); }; @@ -22,13 +23,14 @@ export function DesktopPetsPromo({ office, onClose }) { return (
-
-

增加更多宠物,丰富您快乐工作的每一天

+
+

新特性

+

增加更多宠物,丰富您快乐工作的每一天

{NEW_PETS.map(pet => { const current = office.enabled && office.petKind === pet.id; diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index d9cbfc61..eb4a48cc 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -806,6 +806,7 @@ export const sourceCatalogs = { "s_4ad8aa6461e64127": "上一题 (Shift+Tab)", "s_4af8f4c6dd2e233e": "大脑", "s_4afad488b461b58f": "侧边聊天记录", + "s_4b103ec0cea1c74b": "新特性", "s_4b19c2f91cd3f418": "消息通道只能在运行 ACECode 的这台电脑上设置", "s_4b427656a253ca1e": "请完全退出并重新启动 ACECode", "s_4b464b42a992847e": "无法更新桌面宠物,请重试。", @@ -3562,6 +3563,7 @@ export const sourceCatalogs = { "s_4ad8aa6461e64127": "Previous (Shift+Tab)", "s_4af8f4c6dd2e233e": "Brain", "s_4afad488b461b58f": "Side chat history", + "s_4b103ec0cea1c74b": "New feature", "s_4b19c2f91cd3f418": "Messaging channels can only be configured on the computer running ACECode", "s_4b427656a253ca1e": "Fully quit and restart ACECode", "s_4b464b42a992847e": "Could not update the desktop pet. Please try again.",