From 38eeec9651aa2698d84f1a4817573d841af7bd3b Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Wed, 7 Oct 2026 01:44:31 +0800 Subject: [PATCH] feat: Add door entrances, report-in hops and goodbyes to the desktop office MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New members wait for the door to swing open, fade in on the threshold and, once seated, hop with a star and say 报到! like the original pet's recruits. Leaving members say 再见 after handing in their report, walk out at 1.7x speed and fade out through the doorway. Reduced motion keeps the report-in and goodbye without walking. Simulation-time timers run synchronously so stepped tests and real frames behave the same. --- assets/desktop_pet/DESIGN.md | 2 +- assets/desktop_pet/agent_office_pet.html | 84 ++++++++++++++----- assets/desktop_pet/surface.md | 6 +- docs/desktop-agent-office.md | 2 +- .../verification.md | 4 + web/scripts/test-desktop-office.mjs | 11 ++- 6 files changed, 83 insertions(+), 26 deletions(-) diff --git a/assets/desktop_pet/DESIGN.md b/assets/desktop_pet/DESIGN.md index 40a9c4da..fa244cfb 100644 --- a/assets/desktop_pet/DESIGN.md +++ b/assets/desktop_pet/DESIGN.md @@ -106,7 +106,7 @@ components: ### 交接 -成员加入、完成和 agent 间消息用走路与信封表达:信封带两格残影的抛物线和黄色标签(任务 / 回报 / 消息 / 新任务),落地时收件人头顶冒「!」。走路沿 2 单位网格寻路、不穿家具。 +成员加入、完成和 agent 间消息用走路与信封表达。进门时门先向屋里打开,人在门槛上淡入后走进来;坐下时沿用原作新人入职的效果——跳一跳、头顶冒星星、说「报到!」。离开时先说「再见」,再以约 1.7 倍速走到门口、在门槛淡出,门随后关上。信封带两格残影的抛物线和黄色标签(任务 / 回报 / 消息 / 新任务),落地时收件人头顶冒「!」。走路沿 2 单位网格寻路、不穿家具。 ### 溢出成员 diff --git a/assets/desktop_pet/agent_office_pet.html b/assets/desktop_pet/agent_office_pet.html index 8a53c910..e139f492 100644 --- a/assets/desktop_pet/agent_office_pet.html +++ b/assets/desktop_pet/agent_office_pet.html @@ -744,7 +744,7 @@ } /* ---------- 6b. 走路:2 单位一格的占地图 + 8 向 BFS,家具外扩 1 单位不贴边走 ---------- */ -const GRID = 2, GW = RW / GRID, GH = RD / GRID, DOOR = [2, 59]; +const GRID = 2, GW = RW / GRID, GH = RD / GRID, DOOR = [3, 59], DOOR_SILL = [0.4, 59]; // 门内一步 / 门槛(左墙 y 54–64) let BLOCK = new Uint8Array(GW * GH); function buildBlockMap() { BLOCK = new Uint8Array(GW * GH); @@ -952,7 +952,15 @@ const step = ((simNow / 150) | 0) % 2; const spr = charSprite(a.look, pose, w.moving ? (step ? 'walkA' : 'walkB') : 'stand', flip); const [sx, sy] = P(VIEW, w.x, w.y, 0), bob = w.moving && step ? 1 : 0; + // 进门第一段从门槛淡入,出门最后一段淡出到门槛 + const gap = (px, py) => Math.hypot(w.x - px, w.y - py) / 2.5; + let alpha = 1; + if (w.fadeIn && w.i <= 1) alpha = Math.min(alpha, gap(...w.path[0].p)); + if (w.fadeOut && w.i >= w.path.length - 1) alpha = Math.min(alpha, gap(...w.path[w.path.length - 1].p)); + a.fade = Math.max(0, Math.min(1, alpha)); + c.globalAlpha = a.fade; c.drawImage(spr.cv, Math.round(sx - spr.ax), Math.round(sy - spr.ay - bob)); blits++; + c.globalAlpha = 1; a.hx = Math.round(sx); a.hy = Math.round(sy - spr.ay - bob); } function leds(c, rx) { @@ -1012,6 +1020,10 @@ const F = d.wall === 'y' ? (a, b, z0, z1, col) => faceY(c, o, 0, a, b, z0, z1, col) : (a, b, z0, z1, col) => faceX(c, o, 0, a, b, z0, z1, col); WALL_KINDS[d.kind].draw(F, d.a, d); } + if (doorOpen > .04) { // 门开着:门洞是暗的,门板绕 y=54 一侧的铰链转进屋里 + faceX(c, o, 0, 55, 63, 0, 24, '#2b2230'); faceX(c, o, 0, 55, 63, 16, 24, '#3a2f3f'); + box(c, o, 0, 54, 0, Math.max(1, Math.round(7 * doorOpen)), 1, 24, { top: '#c4884f', left: '#b07342', right: '#8d5a33' }); + } } // 机器人:场景里唯一会走动的角色(也是 box() 拼的) @@ -1038,13 +1050,25 @@ // 走动的组员:进门领任务、回工位、交结果、出门;占用与机器人同样的深度排序 const WALK_SPEED = 0.032; // 世界单位 / 毫秒,穿过房间约 2–3 秒 +const LEAVE_SPEED = WALK_SPEED * 1.7; // 离场快步出门 +const DOORWAY_SPEED = 0.008; // 跨门槛那一步放慢(约 0.3 秒),淡入淡出看得见 const walkers = new Set(); -function startWalk(a, path, done) { +// opts.fadeIn:从门槛淡入(进门);opts.fadeOut:走到终点(门槛)时淡出(出门) +function startWalk(a, path, done, opts = {}) { const [x, y] = path[0].p; - a.walk = { path, i: 1, x, y, dir: a.walk ? a.walk.dir : '+x', moving: false, pause: 0, done, + a.walk = { path, i: 1, x, y, dir: a.walk ? a.walk.dir : '+x', moving: false, done, + speed: opts.speed || WALK_SPEED, fadeIn: !!opts.fadeIn, fadeOut: !!opts.fadeOut, + pause: opts.delay || 0, obj: { h: 20, minX: 0, maxX: 0, minY: 0, maxY: 0, draw: c => a.walk && drawWalker(c, a) } }; walkers.add(a); } +// 门:有人从门槛进出时打开,人走远后关上 +let doorOpen = 0; +function updDoor(dt) { + const near = [...walkers].some(a => a.walk && (a.walk.fadeIn || a.walk.fadeOut) && + Math.hypot(a.walk.x - DOOR_SILL[0], a.walk.y - DOOR_SILL[1]) < 10); + doorOpen += ((near ? 1 : 0) - doorOpen) * Math.min(1, dt / 110); +} const faceDir = (fx, fy, tx, ty) => Math.abs(tx - fx) > Math.abs(ty - fy) ? (tx > fx ? '+x' : '-x') : (ty > fy ? '+y' : '-y'); function updWalkers(dt) { for (const a of [...walkers]) { @@ -1052,7 +1076,7 @@ if (!w) { walkers.delete(a); continue; } if (w.pause > 0) { w.pause -= dt; w.moving = false; continue; } if (w.i >= w.path.length) { walkers.delete(a); a.walk = null; w.done && w.done(); continue; } - const stop = w.path[w.i], [tx, ty] = stop.p, dx = tx - w.x, dy = ty - w.y, dist = Math.hypot(dx, dy), sp = dt * WALK_SPEED; + const stop = w.path[w.i], [tx, ty] = stop.p, dx = tx - w.x, dy = ty - w.y, dist = Math.hypot(dx, dy), sp = dt * (stop.speed || w.speed); w.moving = true; if (dist > .01) w.dir = faceDir(w.x, w.y, tx, ty); if (dist <= sp) { @@ -1113,6 +1137,7 @@ if (!a.hx) continue; let n = null, col = '#ffe14d'; if (simNow < a.pingUntil) n = 'ex'; + else if (simNow < a.arriveUntil) n = 'star'; else if (a.state === 'question') n = 'q'; else if (a.state === 'error') { n = 'ex'; col = '#ff5a4f'; } else if (a.state === 'done') n = 'star'; @@ -1178,7 +1203,7 @@ function layoutBubbles(k) { const W = innerWidth, minTop = k * 30, items = []; for (const a of Object.values(AG)) { - const c = a.hx ? bubbleContent(a) : null; + const c = a.hx && !(a.walk && a.fade < .5) ? bubbleContent(a) : null; // 门口还没完全显形时不说话 if (!c) { if (!a.bub.hidden) { a.bub.hidden = true; a.bubKey = ''; } continue; } renderBubble(a, c); items.push([c.prio, a]); } @@ -1235,13 +1260,18 @@ /* ---------- 9. 时间 & 主循环 ---------- */ const waiters = []; const wait = ms => new Promise(r => waiters.push({ t: simNow + ms, r })); +// 同步的模拟时间定时器:回调在推进时间的同一步里执行(Promise 回调要等到微任务) +const timers = []; +const after = (ms, fn) => timers.push({ t: simNow + ms, fn }); const FRAME_MS = 33; // 常驻桌面:像素动画本来就是 150ms 级步进,约 30fps 足够,省 CPU/GPU let last = performance.now(), sinceDraw = FRAME_MS; function advance(dt) { simNow += dt; for (let i = waiters.length - 1; i >= 0; i--) if (waiters[i].t <= simNow) { const w = waiters[i]; waiters.splice(i, 1); w.r(); } + for (let i = timers.length - 1; i >= 0; i--) if (timers[i].t <= simNow) { const t = timers[i]; timers.splice(i, 1); t.fn(); } updRobot(dt); updWalkers(dt); + updDoor(dt); } function loop(ts) { const dt = Math.min(64, ts - last); last = ts; @@ -1374,21 +1404,29 @@ return boss&&AG[boss.id]&&boss.id!==item.id?AG[boss.id]:null; } const posOf=a=>a.walk?[a.walk.x,a.walk.y]:STN[a.seat]?[STN[a.seat].x,STN[a.seat].y]:DOOR; -// 新成员:进门 → 到派活的人桌前领信封 → 绕到自己椅子后面 → 坐下 -function enterOffice(a,boss,from=DOOR) { +// 报到:在工位上跳一跳、头顶冒星星(原桌宠新人入职的效果) +function arrive(a) { + if(!AG[a.id]||a.leaving)return; + a.hopUntil=simNow+1600;a.arriveUntil=simNow+1600;say(a.id,'报到!',1600); +} +// 道别:气泡留到人走出门为止 +const farewell=a=>{if(AG[a.id]===a&&a.leaving)say(a.id,'再见',60000);}; +// 新成员:开门从门槛淡入 → 到派活的人桌前领信封 → 绕到自己椅子后面 → 坐下报到 +function enterOffice(a,boss,from=null) { const S=STN[a.seat]; if(!S)return; - if(!motionOK()) { // 减少动态效果:不走路,信封直接从派活的人飞到工位 - if(boss&&liveReady){send(boss.id,a.id,'任务');say(boss.id,'派给 '+shortName(a.name),2200);} + if(!motionOK()) { // 减少动态效果:不走路,信封直接从派活的人飞到工位,人在工位上报到 + if(liveReady){if(boss){send(boss.id,a.id,'任务');say(boss.id,'派给 '+shortName(a.name),2200);}arrive(a);} return; } - const stops=[{p:from}]; + const stops=[{p:from||DOOR_SILL}]; + if(!from)stops.push({p:DOOR,direct:true,speed:DOORWAY_SPEED}); const B=boss&&!boss.walk&&STN[boss.seat]; if(B) stops.push({p:deskFront(B),wait:1000,face:[B.x,B.y],call:()=>{send(boss.id,a.id,'任务');say(boss.id,'交给你了',1500);}}); stops.push({p:seatBehind(S)},{p:[S.x,S.y],direct:true}); - startWalk(a,route(stops),null); + startWalk(a,route(stops),()=>arrive(a),{fadeIn:!from,delay:from?0:220}); // 先开门再迈进来 } -// 离开:完成的人先到上级桌前交回报,再出门;失败/停止的人直接出门 +// 离开:完成的人先到上级桌前交回报,说「再见」,快步走出大门;失败/停止的人直接道别出门 function leaveOffice(a,boss,report) { const S=STN[a.seat]; if(S&&S.agent===a.id)S.agent=null; @@ -1396,13 +1434,17 @@ const from=posOf(a),stops=[{p:from}]; if(S&&!a.walk)stops.push({p:seatBehind(S),direct:true}); const B=report&&boss&&!boss.walk&&STN[boss.seat]; - if(B) stops.push({p:deskFront(B),wait:1000,face:[B.x,B.y],call:()=>{send(a.id,boss.id,'回报');say(boss.id,'收到 '+shortName(a.name)+' 的结果',2600);}}); - stops.push({p:DOOR}); - startWalk(a,route(stops),()=>removeAgent(a.id)); + if(B) stops.push({p:deskFront(B),wait:800,face:[B.x,B.y],call:()=>{ + send(a.id,boss.id,'回报');say(boss.id,'收到 '+shortName(a.name)+' 的结果',2600); + after(600,()=>farewell(a)); + }}); + stops.push({p:DOOR},{p:DOOR_SILL,direct:true,speed:DOORWAY_SPEED}); + startWalk(a,route(stops),()=>removeAgent(a.id),{fadeOut:true,speed:LEAVE_SPEED}); + if(!B)farewell(a); } // 正在往外走的人又接到任务:掉头回工位 function returnToSeat(a,boss) { - const seat=freeSeat(a.seat); + const seat=STN[a.seat]&&STN[a.seat].agent===a.id?a.seat:freeSeat(a.seat); if(!seat){removeAgent(a.id);return false;} a.leaving=false;a.seat=seat;STN[seat].agent=a.id; enterOffice(a,boss,posOf(a)); @@ -1435,10 +1477,11 @@ if(ids.has(id)||a.leaving)continue; const item={id,parentPath:a.parentPath||''}, boss=bossOf(item,visible), report=completed.has(id); if(motionOK()) {a.state=report?'done':a.state;a.activityLabel=report?'已完成':a.activityLabel;a.detail='';leaveOffice(a,boss,report);} - else { - if(liveReady&&report&&boss){send(id,boss.id,'回报');say(boss.id,'收到 '+shortName(a.name)+' 的结果',2600);} - removeAgent(id); - } + else if(liveReady) { // 减少动态效果:原地道别片刻后离开 + if(report&&boss){send(id,boss.id,'回报');say(boss.id,'收到 '+shortName(a.name)+' 的结果',2600);} + a.leaving=true;farewell(a); + after(900,()=>{if(AG[id]===a&&a.leaving)removeAgent(id);}); + } else removeAgent(id); } for(const item of visible) { let a=AG[item.id]; @@ -1527,6 +1570,7 @@ get agents(){return Object.values(AG).map(a=>({id:a.id,name:a.name,state:a.state,contextRatio:a.contextRatio,look:a.look.key,seat:a.seat, walking:!!a.walk,leaving:a.leaving,bubble:a.bub.hidden?'':a.bub.textContent,compact:a.bub.classList.contains('compact')}));}, get envelopes(){return envelopes.map(e=>({from:e.a.id,to:e.b.id,label:e.el?e.el.textContent:''}));}, + get door(){return doorOpen;}, get layoutSignature(){return JSON.stringify({theme:THEME,seats:Object.values(STN).map(s=>[s.id,s.x,s.y,s.axis,s.s])});}, // 测试用:按 32ms 步长推进模拟时间(走路、信封、等待)后重画一帧 advance(ms){for(let left=ms;left>0;left-=32)advance(Math.min(32,left));render();presentUpscale();}, diff --git a/assets/desktop_pet/surface.md b/assets/desktop_pet/surface.md index 795e6719..a5a8e181 100644 --- a/assets/desktop_pet/surface.md +++ b/assets/desktop_pet/surface.md @@ -24,12 +24,12 @@ | 等成员回报 | 沙漏;气泡显示「等成员回报」 | | 等待授权、等待回答、重试 | 保留各自的明确状态文案 | | 主 agent 成功完成 | 留在主工位,显示 `zzz`;下一轮开始后恢复执行状态 | -| 子 agent 加入 | 从门口走进,到派活的人桌前领「任务」信封,再回工位 | -| 子 agent 成功完成 | 走到派活的人桌前递「回报」信封后出门;后续任务出现时再按真实成员恢复 | +| 子 agent 加入 | 门先打开,人从门槛淡入走进来,到派活的人桌前领「任务」信封,回工位坐下后跳一跳、冒星星、说「报到!」 | +| 子 agent 成功完成 | 走到派活的人桌前递「回报」信封,说「再见」,快步走到门口、开门淡出;后续任务出现时再按真实成员恢复 | | 执行失败或停止 | 本轮保留失败或停止状态,不能表现为成功休息 | | 整理上下文 | 显示整理状态及机器人提示;文件堆随后跟随新上下文比例 | -文件堆反映当前上下文使用量相对窗口容量的比例,不使用会话累计消耗量。容量未知时不绘制文件堆,不能把未知当作零用量。消息信封由真实成员变化或消息记录触发;减少动态效果偏好下不走路,信封仍然飞过(它表达谁和谁在交接,不是装饰)。 +文件堆反映当前上下文使用量相对窗口容量的比例,不使用会话累计消耗量。容量未知时不绘制文件堆,不能把未知当作零用量。消息信封由真实成员变化或消息记录触发;减少动态效果偏好下不走路,信封仍然飞过(它表达谁和谁在交接,不是装饰),新成员在工位上「报到!」,离开的成员原地说「再见」后消失。 状态气泡常驻于忙碌的 agent 头顶,按「求助 > 主 agent > 正在输出 > 刚变化」的优先级摆放:撞上就左右错开或抬高(引线连回头顶),再放不下只显示动作。有人忙时机器人不闲聊。 diff --git a/docs/desktop-agent-office.md b/docs/desktop-agent-office.md index ba9d41ae..a2c346ea 100644 --- a/docs/desktop-agent-office.md +++ b/docs/desktop-agent-office.md @@ -8,7 +8,7 @@ Windows 与 macOS Desktop 会显示一个透明的像素办公室。单会话有 - “跟随”默认开启,办公室随主窗口当前会话切换。点击门牌会关闭跟随并固定办公室;再次打开跟随后恢复跟随。查看最近五个之外的当前会话不改变门牌排序。 - 主 agent 成功完成后留在座位显示 `zzz`,下一轮自动醒来;成功完成的子 agent 立即离座,重新工作时以同样的外观返回。 - 忙碌的 agent 头顶常驻状态气泡:「图标 动作 · 细节」。正在输出时显示「撰写回复」和最新的几个字(打字动画 + 光标),推理时显示思考点和推理尾巴,调用工具时显示动作(读取、运行命令、修改代码…)和调用预览(路径、命令)。默认「用于编程」模式下服务端不发正文进度,桌宠由 token 事件推断。气泡互相避让:先左右错开,再抬高并用引线连回头顶,放不下时只留动作。 -- 子 agent 出现时从门口走进来,先到派活的人桌前领「任务」信封再回工位;完成后走到派活的人桌前递「回报」信封(派活的人显示「收到…的结果」)再出门;失败或停止的成员直接出门。网状协作中 agent 间消息以带「消息 / 新任务 / 回报」标签的信封在工位间飞过。系统开启「减少动态效果」时不走路,交接信封仍然显示。 +- 子 agent 出现时门先打开,人从门槛淡入走进来,先到派活的人桌前领「任务」信封再回工位,坐下后跳一跳、冒星星、说「报到!」;完成后走到派活的人桌前递「回报」信封(派活的人显示「收到…的结果」),说「再见」后快步出门、在门槛淡出;失败或停止的成员直接道别出门。网状协作中 agent 间消息以带「消息 / 新任务 / 回报」标签的信封在工位间飞过。系统开启「减少动态效果」时不走路,交接信封、「报到!」和原地「再见」仍然显示。 - 等待授权、等待回答、重试、压缩、失败和取消有各自状态。超过七个子角色时点击 `+N` 查看成员并打开对应会话;列表随状态更新,Escape 收起。选中房间人物后双击可打开其会话。 - 文件堆随当前上下文占用增长,压缩后依据实际新统计变薄。工具符号与聊天工具行共用 SVG 来源。 - 拖动房间空地移动窗口;滚轮、右下角把手和右键菜单可缩放。窗口不抢主界面焦点,透明角落可穿透点击;尺寸按原实现保存。 diff --git a/openspec/changes/enhance-desktop-agent-office-motion/verification.md b/openspec/changes/enhance-desktop-agent-office-motion/verification.md index 4edfc3eb..1b436624 100644 --- a/openspec/changes/enhance-desktop-agent-office-motion/verification.md +++ b/openspec/changes/enhance-desktop-agent-office-motion/verification.md @@ -13,3 +13,7 @@ | Windows 原生 | 隔离用户目录 + 本机假模型(OpenAI 兼容流式,无真实模型调用)运行开发版 Desktop,经 WebView2 调试端口采样:主 agent 推理尾巴 → 撰写回复实时文字 → 派发任务、两名成员进门领「任务」信封入座 → 成员思考、流式回复 → 完成后递「回报」信封、主 agent「收到…的结果」→ zzz。窗口扩展样式保持 TOPMOST/NOACTIVATE/TOOLWINDOW;WindowFromPoint 确认气泡可点、透明角落穿透,注入的浮层矩形并入窗口区域、清除后恢复穿透。 | 未执行:macOS 编译与实机验收(本机为 Windows;`desktop_pet_mac.mm` 仅经代码审查);Linux 保持无桌宠。 + +## 进出场过渡(2026-10-07 追加) + +用户反馈新成员出现太突然、离场要快步出门并道别。改为:门先打开,人在门槛淡入后走进(跨门槛一步约 0.3 秒);坐下后沿用原桌宠新人入职效果——跳一跳、冒星星、说「报到!」;离开时交完回报说「再见」,约 1.7 倍速走到门口在门槛淡出,门随后关上;减少动态效果时原地「报到!」/「再见」。浏览器自动化 47 项通过;本机假模型驱动的 Windows 原生运行采样确认开门、报到、再见、出门顺序。 diff --git a/web/scripts/test-desktop-office.mjs b/web/scripts/test-desktop-office.mjs index adbca1ba..a63c491b 100644 --- a/web/scripts/test-desktop-office.mjs +++ b/web/scripts/test-desktop-office.mjs @@ -61,14 +61,19 @@ try { assert.ok(await page.evaluate(()=>AgentOffice.agents.filter(a=>a.leaving).every(a=>a.walking&&a.state==='done'))); assert.ok(await advanceUntil("AgentOffice.envelopes.some(e=>e.label==='回报'&&e.to==='root')"),'report envelope flies to the dispatcher'); assert.ok(await advanceUntil("AgentOffice.agents.some(a=>a.id==='root'&&/收到.*的结果/.test(a.bubble))"),'dispatcher acknowledges the report'); + assert.ok(await advanceUntil("AgentOffice.agents.some(a=>a.leaving&&a.bubble.includes('再见'))"),'leaving workers say goodbye'); + assert.ok(await advanceUntil("AgentOffice.door>0.6"),'the door opens for the exit'); assert.ok(await advanceUntil("AgentOffice.agents.length===1"),'workers leave through the door'); + assert.ok(await advanceUntil("AgentOffice.door<0.1"),'the door closes again'); assert.deepEqual(await page.evaluate(()=>AgentOffice.agents.map(a=>a.state)),['sleep']); await page.screenshot({path:path.join(output,'office-sleep-688.png')}); // A new worker walks in, receives a task envelope at the dispatcher's desk, then sits down. await apply(data()); assert.ok(await page.evaluate(()=>AgentOffice.agents.filter(a=>!a.root&&a.id!=='root').every(a=>a.walking)),'new workers enter on foot'); + assert.ok(await advanceUntil("AgentOffice.door>0.6",1000),'the door opens as workers step in'); assert.ok(await advanceUntil("AgentOffice.envelopes.some(e=>e.label==='任务'&&e.from==='root')"),'task envelope leaves the dispatcher'); assert.ok(await advanceUntil("AgentOffice.agents.every(a=>!a.walking)"),'workers reach their seats'); + assert.ok(await page.evaluate(()=>AgentOffice.agents.some(a=>a.bubble.includes('报到'))),'arrivals hop and report in at their desks'); assert.equal(await page.evaluate(()=>AgentOffice.agents.find(a=>a.id==='代码审查').look),look); assert.equal(await page.evaluate(()=>AgentOffice.layoutSignature),originalLayout); const toolImage=await page.locator('.office-tool:not([hidden])').first().getAttribute('src'); @@ -96,6 +101,10 @@ try { await apply(data({agents:[actor('root'),actor('代码审查',{path:'/root/review'}),actor('回归测试',{path:'/root/test'}),actor('文档')]})); assert.equal(await page.evaluate(()=>AgentOffice.agents.find(a=>a.id==='文档').walking),false); assert.ok(await page.evaluate(()=>AgentOffice.envelopes.some(e=>e.to==='文档'&&e.label==='任务')),'reduced motion keeps the hand-off'); + assert.match(await page.evaluate(()=>AgentOffice.agents.find(a=>a.id==='文档').bubble),/报到/,'reduced motion still reports in'); + await apply(data({agents:[actor('root'),actor('代码审查',{path:'/root/review'}),actor('回归测试',{path:'/root/test'})]})); + assert.match(await page.evaluate(()=>AgentOffice.agents.find(a=>a.id==='文档')?.bubble||''),/再见/,'reduced motion says goodbye in place'); + assert.ok(await advanceUntil("!AgentOffice.agents.some(a=>a.id==='文档')",2000),'then leaves without walking'); await page.emulateMedia({reducedMotion:'no-preference'}); 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;}}))); @@ -116,5 +125,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:47,errors,external},null,2)); } finally {await browser.close();}