feat(desktop): 办公室加走位编排(进场 / 敲键盘 / 递简报)

专家被派发 → 从门口走进来 → 在工位敲键盘 → 干完起身走到协调者桌前
递简报(手里真拿一页) → 回工位。协调者的白板仍是思考/综合流。

为什么做这个:重看参考项目(ai-office-react),它的 Spine 动画只有 idle
和 walking 两个——"好玩"来自角色在房间里走动,不是画质。上一版把人钉死
在工位上只让手动,这才是不好玩的根因。

实现:全 CSS 关键帧,无 rAF / 无定时器 / 无状态机。
- 直播(streaming):工位随事件到达而挂载,延迟一律 0,动画即真实进度。
- 复盘:按事件时间戳算出各自的出场/交付时刻,整段压进 PLAY_MS 当一出戏放;
  敲击次数由"工作时长 ÷ 敲击周期"换算,所以快的人敲几下、慢的人敲一路。
- 收工时刻强制不早于"进门 + 敲一下",否则压缩比大时人还没进门就来递简报。
- 角色层与桌子层分开画,人才能走到别人桌前而不被自己那张桌子焊死。
- 补了腿:原来是"永远被桌子挡住的半身像",一走出工位就成了悬空上半身。
- reduced-motion 直接就位不演。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-15 16:53:16 +08:00
parent 225fee5519
commit 02608bb6a4
@@ -1,11 +1,16 @@
import { useMemo } from "react";
import { useMemo, type CSSProperties } from "react";
import { deriveTeam, teamNow, type RunPhase, type TeamSeat } from "../lib/run";
import type { ExecEvent } from "../lib/api";
// 2D 卡通办公室:把多智能体协调演成「协调者在白板前统筹 + 专家在工位干活」
// 2D 卡通办公室:把多智能体协调演成一出办公室短剧
// 专家被派发 → 从门口走进来 → 在工位敲键盘 → 干完起身走到协调者桌前递简报 → 回工位。
// 手绘贴纸风(粗描边 + 平涂 + 大头身),纯 SVG 手写——不引 PixiJS/Spine、不下任何素材,
// 因此没有授权问题,整个场景几 KB。动效=状态编码(敲键盘/思考流),并守 reduced-motion
// 数据与「卡片看板」同源(deriveTeam),换皮不换数据。
// 因此没有授权问题,整个场景几 KB。数据与「卡片看板」同源(deriveTeam),换皮不换数据
//
// 走位全部是 CSS 关键帧(无 rAF / 无定时器 / 无状态机):
// 直播时(streaming) 工位随事件到达而挂载,延迟一律 0,动画即时反映真实进度;
// 复盘时按事件时间戳算出各自的出场/交付时刻,压进 PLAY_MS 内当一出戏放。
// 动效=状态编码,不是装饰;并守 prefers-reduced-motion。
const VB_W = 720;
const VB_H = 460;
@@ -13,6 +18,7 @@ const FLOOR_Y = 200;
const OUTLINE = "#2f2a26";
const SKIN = "#f7d9bd";
const PANTS = "#4a5568";
const WALL = "#f3ede4";
const FLOOR = "#ddd0be";
const DESK_TOP = "#ffffff";
@@ -26,6 +32,26 @@ const ERR = "#fb7185";
const SHIRTS = ["#7c5cf6", "#38bdf8", "#34d399", "#fbbf24", "#fb7185", "#a78bfa", "#22d3ee", "#f472b6"];
const HAIRS = ["#3f3229", "#6b4a2f", "#c8913f", "#2e3f52", "#8c4a4a", "#4a3a6b", "#5c5148", "#7a4a2a"];
// 门口:出场点。角色从这里走向自己的工位。
const DOOR_X = 84;
const DOOR_Y = 198;
const DOOR_SCALE = 0.5;
// 协调者机位;递简报时专家走到它桌子两侧。
const LEAD_X = 360;
const LEAD_Y = 232;
const LEAD_SCALE = 0.62;
const HANDOFF_DX = 86;
const HANDOFF_Y = 250;
const HANDOFF_SCALE = 0.66;
const ENTER_MS = 950;
const HANDOFF_MS = 1900;
const TYPE_MS = 340; // 单次敲击周期,用来把「工作时长」换算成敲击次数
const PLAY_MS = 6000; // 复盘时整段戏的目标时长上限(真实跨度更短就按真实节奏放)
type Vars = CSSProperties & Record<string, string | number>;
function toneOf(s: TeamSeat): string {
return s.status === "error" ? ERR : s.status === "running" ? RUN : OK;
}
@@ -77,11 +103,12 @@ function Hair({ color, style }: { color: string; style: number }) {
);
}
// typing: undefined=不敲;{count,delay}=敲 count 次(count=Infinity 即一直敲)
function Chibi({
shirt,
hair,
style,
working,
typing,
happy,
sad,
delay,
@@ -89,19 +116,26 @@ function Chibi({
shirt: string;
hair: string;
style: number;
working?: boolean;
typing?: { count: number; delay: number };
happy?: boolean;
sad?: boolean;
delay: number;
}) {
const d = `${delay}s`;
const armStyle: Vars | undefined = typing
? { animationName: "sdx-type", animationDuration: `${TYPE_MS}ms`, animationTimingFunction: "ease-in-out", animationIterationCount: typing.count === Infinity ? "infinite" : typing.count, animationDelay: `${typing.delay}s` }
: undefined;
return (
<g className={working ? "sdx-bob" : "sdx-breathe"} style={{ animationDelay: d }}>
{/* 手臂:working 时上下敲键盘 */}
<g className={working ? "sdx-arm sdx-type" : "sdx-arm"} style={{ animationDelay: d }}>
<g className="sdx-breathe" style={{ animationDelay: `${delay}s` }}>
{/* 腿:坐着时整段被桌子挡住,只有走位(入场/递简报)时才看得见。
没有腿的话人一离开工位就变成悬在影子上方的半身像。 */}
<rect x={-12} y={-50} width={11} height={50} rx={5.5} fill={PANTS} stroke={OUTLINE} strokeWidth={2.2} />
<rect x={1} y={-50} width={11} height={50} rx={5.5} fill={PANTS} stroke={OUTLINE} strokeWidth={2.2} />
{/* 手臂:敲键盘 */}
<g className="sdx-arm" style={armStyle}>
<rect x={-30} y={-90} width={11} height={36} rx={5.5} fill={shirt} stroke={OUTLINE} strokeWidth={2.4} />
</g>
<g className={working ? "sdx-arm sdx-type" : "sdx-arm"} style={{ animationDelay: `${delay + 0.18}s` }}>
<g className="sdx-arm" style={armStyle ? { ...armStyle, animationDelay: `${(typing?.delay ?? 0) + 0.17}s` } : undefined}>
<rect x={19} y={-90} width={11} height={36} rx={5.5} fill={shirt} stroke={OUTLINE} strokeWidth={2.4} />
</g>
@@ -167,17 +201,27 @@ function Bubble({ status, delay }: { status: TeamSeat["status"]; delay: number }
);
}
// 工位:白桌 + 显示器辉光(状态色)+ 桌面铭牌。桌子挡住下半身,人只露上半身
function Deskette({ name, sub, tone, running, w = 200 }: { name: string; sub: string; tone: string; running?: boolean; w?: number }) {
// 手里那页简报:只在递交那一程可见,到协调者桌前就「放下」了
function Paper({ dur, delay }: { dur: number; delay: number }) {
return (
<g className="sdx-paper" style={{ animationDuration: `${dur}ms`, animationDelay: `${delay}s` }}>
<rect x={22} y={-72} width={17} height={22} rx={2} fill="#fff" stroke={OUTLINE} strokeWidth={2} transform="rotate(-8 30 -61)" />
<path d="M 26 -66 L 35 -67 M 26 -61 L 35 -62 M 26 -56 L 32 -57" stroke={OUTLINE} strokeWidth={1.4} strokeLinecap="round" />
</g>
);
}
// 工位(桌子层):白桌 + 显示器辉光(状态色)+ 桌面铭牌。桌子挡住下半身,人只露上半身。
function Deskette({ name, sub, tone, glow, w = 200 }: { name: string; sub: string; tone: string; glow?: boolean; w?: number }) {
const hw = w / 2;
return (
<g>
{/* 显示器(背对我们,屏幕辉光溢出到桌面) */}
<g transform={`translate(${hw - 34}, 0)`}>
<ellipse cx={0} cy={-46} rx={26} ry={9} fill={tone} opacity={running ? 0.35 : 0.18} className={running ? "sdx-glow" : undefined} />
<ellipse cx={0} cy={-46} rx={26} ry={9} fill={tone} opacity={glow ? 0.35 : 0.18} className={glow ? "sdx-glow" : undefined} />
<rect x={-3} y={-58} width={6} height={12} fill="#c9c2b8" stroke={OUTLINE} strokeWidth={2} />
<rect x={-21} y={-84} width={42} height={28} rx={4} fill="#eceae6" stroke={OUTLINE} strokeWidth={2.4} />
<rect x={-17} y={-80} width={34} height={20} rx={2} fill={tone} opacity={running ? 0.8 : 0.45} />
<rect x={-17} y={-80} width={34} height={20} rx={2} fill={tone} opacity={glow ? 0.8 : 0.45} />
</g>
{/* 桌面 + 桌沿 */}
<rect x={-hw} y={-46} width={w} height={9} rx={4} fill={DESK_TOP} stroke={OUTLINE} strokeWidth={2.6} />
@@ -200,24 +244,94 @@ function Deskette({ name, sub, tone, running, w = 200 }: { name: string; sub: st
);
}
function Seat({ seat, x, y, scale, index, bubble }: { seat: TeamSeat; x: number; y: number; scale: number; index: number; bubble: boolean }) {
const tone = toneOf(seat);
const running = seat.status === "running";
const sub = running ? "工作中…" : seat.status === "error" ? "失败" : `已回报 ${fmtMs(seat.ms)}`;
return (
<g transform={`translate(${x}, ${y}) scale(${scale})`}>
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
// 一位专家的完整档期:出场时刻、敲多久、何时去递简报。
interface Cue {
seat: TeamSeat;
x: number;
y: number;
scale: number;
index: number;
bubble: boolean;
enterAt: number; // 秒
typeAt: number;
typeCount: number;
handoffAt: number;
bubbleAt: number;
}
// 角色层:走位全在这里。桌子不跟着走,所以单独画在上层。
function Actor({ cue, reduced }: { cue: Cue; reduced: boolean }) {
const { seat, x, y, scale: s, index } = cue;
const done = seat.status === "done";
// 入场:从门口走到工位(局部坐标 = 场景增量 ÷ 本工位缩放)。
const enterVars: Vars = {
"--ex": `${(DOOR_X - x) / s}px`,
"--ey": `${(DOOR_Y - y) / s}px`,
"--es": DOOR_SCALE / s,
animationDelay: `${cue.enterAt}s`,
};
// 递简报:走到协调者桌子一侧(按序号左右分流,免得挤成一堆),停一拍,再走回来。
const side = index % 2 === 0 ? -1 : 1;
const handoffVars: Vars = {
"--hx": `${(LEAD_X + side * HANDOFF_DX - x) / s}px`,
"--hy": `${(HANDOFF_Y - y) / s}px`,
"--hs": HANDOFF_SCALE / s,
animationDelay: `${cue.handoffAt}s`,
};
const typing =
seat.status === "running"
? { count: Infinity, delay: cue.typeAt }
: cue.typeCount > 0
? { count: cue.typeCount, delay: cue.typeAt }
: undefined;
const body = (
<>
<Chibi
shirt={SHIRTS[index % SHIRTS.length]}
hair={HAIRS[index % HAIRS.length]}
style={index % 3}
working={running}
happy={seat.status === "done"}
typing={typing}
happy={done}
sad={seat.status === "error"}
delay={index * 0.23}
/>
{bubble && <Bubble status={seat.status} delay={index * 0.23} />}
<Deskette name={seat.name} sub={sub} tone={tone} running={running} />
{done && !reduced && <Paper dur={HANDOFF_MS} delay={cue.handoffAt} />}
{cue.bubble && <Bubble status={seat.status} delay={cue.bubbleAt} />}
</>
);
// reduced-motion:直接就位,不演。
if (reduced) {
return (
<g transform={`translate(${x}, ${y}) scale(${s})`}>
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
{body}
</g>
);
}
return (
<g transform={`translate(${x}, ${y}) scale(${s})`}>
<g className="sdx-enter" style={enterVars}>
<g className="sdx-walk" style={{ "--wcount": Math.round(ENTER_MS / 300), animationDelay: `${cue.enterAt}s` } as Vars}>
{done ? (
<g className="sdx-handoff" style={handoffVars}>
<g className="sdx-walk" style={{ "--wcount": Math.round(HANDOFF_MS / 300), animationDelay: `${cue.handoffAt}s` } as Vars}>
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
{body}
</g>
</g>
) : (
<>
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
{body}
</>
)}
</g>
</g>
</g>
);
}
@@ -231,19 +345,25 @@ function Room() {
<rect x={0} y={FLOOR_Y - 5} width={VB_W} height={5} fill="#cabfae" />
{/* 地毯 */}
<ellipse cx={360} cy={402} rx={318} ry={48} fill="#e9dfd0" stroke="#cfc2ae" strokeWidth={2} />
{/* */}
{/* 门:专家从这里进场 */}
<g>
<rect x={38} y={30} width={112} height={92} rx={5} fill="#cfe9f5" stroke={OUTLINE} strokeWidth={2.6} />
<path d="M 94 30 L 94 122 M 38 76 L 150 76" stroke={OUTLINE} strokeWidth={2.2} />
<path d="M 48 116 L 88 38 L 104 38 L 64 116 Z" fill="#fff" opacity={0.35} />
<rect x={44} y={86} width={80} height={114} rx={4} fill="#e4d9c6" stroke={OUTLINE} strokeWidth={2.8} />
<rect x={52} y={94} width={64} height={98} rx={2} fill="#efe6d6" stroke={OUTLINE} strokeWidth={2} />
<circle cx={110} cy={146} r={3.6} fill={OUTLINE} />
</g>
{/* 挂钟 */}
<g transform="translate(660, 54)">
<g transform="translate(534, 58)">
<circle r={22} fill="#fff" stroke={OUTLINE} strokeWidth={2.6} />
<path d="M 0 0 L 0 -13 M 0 0 L 9 5" stroke={OUTLINE} strokeWidth={2.4} strokeLinecap="round" />
</g>
{/* 窗 */}
<g>
<rect x={578} y={34} width={116} height={92} rx={5} fill="#cfe9f5" stroke={OUTLINE} strokeWidth={2.6} />
<path d="M 636 34 L 636 126 M 578 80 L 694 80" stroke={OUTLINE} strokeWidth={2.2} />
<path d="M 588 120 L 626 42 L 642 42 L 604 120 Z" fill="#fff" opacity={0.35} />
</g>
{/* 绿植 */}
<g transform="translate(668, 186)">
<g transform="translate(636, 186)">
<path d="M 0 -6 C -22 -14 -26 -44 -4 -52 C -12 -30 -4 -18 0 -6 Z" fill="#4a9c6d" stroke={OUTLINE} strokeWidth={2.2} strokeLinejoin="round" />
<path d="M 0 -6 C 22 -18 24 -50 2 -58 C 12 -32 4 -18 0 -6 Z" fill="#5cb07d" stroke={OUTLINE} strokeWidth={2.2} strokeLinejoin="round" />
<path d="M -13 -6 L 13 -6 L 9 16 L -9 16 Z" fill="#e6e0d6" stroke={OUTLINE} strokeWidth={2.4} strokeLinejoin="round" />
@@ -253,59 +373,98 @@ function Room() {
}
export function OfficeView({ events, output, phase }: { events: ExecEvent[]; output?: string; phase?: RunPhase }) {
const team = useMemo(() => deriveTeam(events, teamNow(events, phase === "streaming")), [events, phase]);
const streaming = phase === "streaming";
const team = useMemo(() => deriveTeam(events, teamNow(events, streaming)), [events, streaming]);
const { lead, seats, t0, t1 } = team;
const span = Math.max(t1 - t0, 1);
const streaming = phase === "streaming";
const thinking = truncate((output ?? "").slice(-110).replace(/\s+/g, " "), 110);
if (!lead && seats.length === 0) {
return <div className="p-6 text-center text-xs text-slate-600"></div>;
}
const reduced = useMemo(
() => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches,
[],
);
// 排位:≤5 人一排(前排),否则拆两排,后排小、前排大 —— 用缩放造纵深。
// 两排时后排不出头顶气泡:气泡很高,会顶到协调者的桌子;状态仍由显示器辉光和铭牌圆点表达。
const rows: TeamSeat[][] = seats.length <= 5 ? [seats] : [seats.slice(0, Math.ceil(seats.length / 2)), seats.slice(Math.ceil(seats.length / 2))];
const rowCfg = rows.length === 1 ? [{ y: 430, s: 1 }] : [{ y: 322, s: 0.6 }, { y: 438, s: 0.82 }];
const placed = rows.flatMap((row, r) => {
// 档期:直播时事件到哪演到哪(延迟一律 0);复盘时按时间戳排,整段压进 PLAY_MS。
const rate = streaming ? 0 : Math.min(1, PLAY_MS / span);
const cues: Cue[] = rows.flatMap((row, r) => {
const pitch = VB_W / row.length;
const cfg = rowCfg[r];
const scale = Math.min(cfg.s * Math.min(pitch / 230, 1), 0.9);
return row.map((seat, i) => ({
seat,
x: pitch * (i + 0.5),
y: cfg.y,
scale,
index: seats.indexOf(seat),
bubble: rows.length === 1 || r === 1,
}));
return row.map((seat, i) => {
const enterMs = streaming ? 0 : (seat.startTS - t0) * rate;
// 收工时刻至少要在「走到工位 + 敲一下」之后,否则压缩比大时会人还没进门就来递简报。
const doneMs = streaming ? 0 : Math.max((seat.endTS ?? t1) - t0, 0) * rate;
const workEndMs = Math.max(doneMs, enterMs + ENTER_MS + 400);
const typeAtMs = enterMs + ENTER_MS;
return {
seat,
x: pitch * (i + 0.5),
y: cfg.y,
scale,
index: seats.indexOf(seat),
bubble: rows.length === 1 || r === 1,
enterAt: enterMs / 1000,
typeAt: typeAtMs / 1000,
typeCount: streaming ? 0 : Math.max(1, Math.round((workEndMs - typeAtMs) / TYPE_MS)),
handoffAt: workEndMs / 1000,
bubbleAt:
(streaming
? 0
: seat.status === "done"
? workEndMs + HANDOFF_MS
: seat.status === "error"
? workEndMs
: typeAtMs) / 1000,
};
});
});
if (!lead && seats.length === 0) {
return <div className="p-6 text-center text-xs text-slate-600"></div>;
}
const leadTone = streaming ? RUN : lead?.status === "error" ? ERR : OK;
return (
<div className="flex h-full min-h-0 flex-col overflow-auto">
<style>{`
@keyframes sdx-breathe{0%,100%{transform:translateY(0)}50%{transform:translateY(-1.5px)}}
@keyframes sdx-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
@keyframes sdx-type{0%,100%{transform:rotate(-7deg)}50%{transform:rotate(9deg)}}
@keyframes sdx-type{0%,100%{transform:rotate(0deg)}50%{transform:rotate(11deg)}}
@keyframes sdx-walk{0%,100%{transform:rotate(0deg) translateY(0)}25%{transform:rotate(-3.5deg) translateY(-2.5px)}75%{transform:rotate(3.5deg) translateY(-2.5px)}}
@keyframes sdx-enter{
0%{transform:translate(var(--ex),var(--ey)) scale(var(--es));opacity:0}
12%{opacity:1}
100%{transform:translate(0,0) scale(1);opacity:1}
}
@keyframes sdx-handoff{
0%{transform:translate(0,0) scale(1)}
38%{transform:translate(var(--hx),var(--hy)) scale(var(--hs))}
62%{transform:translate(var(--hx),var(--hy)) scale(var(--hs))}
100%{transform:translate(0,0) scale(1)}
}
@keyframes sdx-paper{0%,8%{opacity:0}14%{opacity:1}44%{opacity:1}52%,100%{opacity:0}}
@keyframes sdx-dot{0%,80%,100%{transform:translateY(0);opacity:.45}40%{transform:translateY(-3.5px);opacity:1}}
@keyframes sdx-glow{0%,100%{opacity:.2}50%{opacity:.5}}
@keyframes sdx-pop{from{transform:translateY(4px) scale(.85);opacity:0}to{transform:translateY(0) scale(1);opacity:1}}
@keyframes sdx-flow{to{stroke-dashoffset:-18}}
@keyframes sdx-blink{50%{opacity:0}}
.sdx-breathe{animation:sdx-breathe 3.4s ease-in-out infinite}
.sdx-bob{animation:sdx-bob 1.6s ease-in-out infinite}
.sdx-arm{transform-box:fill-box;transform-origin:50% 8%}
.sdx-type{animation:sdx-type .34s ease-in-out infinite}
.sdx-enter{transform-box:fill-box;transform-origin:50% 100%;animation:sdx-enter ${ENTER_MS}ms ease-out both}
.sdx-handoff{transform-box:fill-box;transform-origin:50% 100%;animation:sdx-handoff ${HANDOFF_MS}ms ease-in-out both}
.sdx-walk{transform-box:fill-box;transform-origin:50% 100%;animation:sdx-walk 300ms ease-in-out var(--wcount) both}
.sdx-paper{animation-name:sdx-paper;animation-timing-function:linear;animation-fill-mode:both}
.sdx-dot{transform-box:fill-box;transform-origin:50% 50%;animation:sdx-dot 1.1s ease-in-out infinite}
.sdx-glow{animation:sdx-glow 1.8s ease-in-out infinite}
.sdx-pop{transform-box:fill-box;transform-origin:50% 100%;animation:sdx-pop .32s cubic-bezier(.34,1.56,.64,1) both}
.sdx-flow{stroke-dasharray:5 5;animation:sdx-flow 1.1s linear infinite}
.sdx-caret{animation:sdx-blink 1.05s step-end infinite}
@media(prefers-reduced-motion:reduce){
.sdx-breathe,.sdx-bob,.sdx-type,.sdx-dot,.sdx-glow,.sdx-pop,.sdx-flow,.sdx-caret{animation:none}
.sdx-breathe,.sdx-arm,.sdx-enter,.sdx-handoff,.sdx-walk,.sdx-paper,.sdx-dot,.sdx-glow,.sdx-pop,.sdx-flow,.sdx-caret{animation:none}
}
`}</style>
@@ -314,9 +473,9 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
{/* 白板:协调者的思考/综合过程 */}
<g>
<rect x={196} y={18} width={330} height={94} rx={6} fill="#fff" stroke={OUTLINE} strokeWidth={2.8} />
<rect x={196} y={104} width={330} height={8} rx={3} fill="#e2dbcf" stroke={OUTLINE} strokeWidth={2.2} />
<foreignObject x={208} y={26} width={306} height={74}>
<rect x={176} y={18} width={320} height={94} rx={6} fill="#fff" stroke={OUTLINE} strokeWidth={2.8} />
<rect x={176} y={104} width={320} height={8} rx={3} fill="#e2dbcf" stroke={OUTLINE} strokeWidth={2.2} />
<foreignObject x={188} y={26} width={296} height={74}>
<div style={{ fontFamily: "system-ui, sans-serif", color: "#4a4238", lineHeight: 1.35 }}>
<div style={{ fontSize: 12, color: leadTone, fontWeight: 600 }}>
{streaming ? "综合中…" : lead?.status === "error" ? "协调失败" : lead?.status === "done" ? "已综合" : "协调中"}
@@ -339,13 +498,28 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
</foreignObject>
</g>
{/* 协调者 */}
<g transform={`translate(${LEAD_X}, ${LEAD_Y}) scale(${LEAD_SCALE})`}>
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
<Chibi
shirt={RUN}
hair="#3f3229"
style={2}
typing={streaming ? { count: Infinity, delay: 0 } : undefined}
happy={lead?.status === "done"}
sad={lead?.status === "error"}
delay={0}
/>
<Deskette name={lead?.label ?? "协调者"} sub="协调者" tone={leadTone} glow={streaming} w={230} />
</g>
{/* 派发连线:只画正在工作的工位 */}
{placed
.filter((p) => p.seat.status === "running")
.map((p) => (
{cues
.filter((c) => c.seat.status === "running")
.map((c) => (
<path
key={`w-${p.seat.node}`}
d={`M 360 214 Q ${(360 + p.x) / 2} ${(214 + p.y) / 2 - 24} ${p.x} ${p.y - 100 * p.scale}`}
key={`w-${c.seat.node}`}
d={`M ${LEAD_X} 214 Q ${(LEAD_X + c.x) / 2} ${(214 + c.y) / 2 - 24} ${c.x} ${c.y - 100 * c.scale}`}
fill="none"
stroke={RUN}
strokeWidth={1.6}
@@ -354,15 +528,19 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
/>
))}
{/* 协调者:白板前,比前排小 → 纵深 */}
<g transform={`translate(360, 232) scale(0.62)`}>
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
<Chibi shirt={RUN} hair="#3f3229" style={2} working={streaming} happy={lead?.status === "done"} sad={lead?.status === "error"} delay={0} />
<Deskette name={lead?.label ?? "协调者"} sub="协调者" tone={leadTone} running={streaming} w={230} />
</g>
{placed.map((p) => (
<Seat key={p.seat.node} seat={p.seat} x={p.x} y={p.y} scale={p.scale} index={p.index} bubble={p.bubble} />
{/* 角色层在前、桌子层在后:角色才能走到别人桌前而不被自己那张桌子焊死 */}
{cues.map((c) => (
<Actor key={c.seat.node} cue={c} reduced={reduced} />
))}
{cues.map((c) => (
<g key={`d-${c.seat.node}`} transform={`translate(${c.x}, ${c.y}) scale(${c.scale})`}>
<Deskette
name={c.seat.name}
sub={c.seat.status === "running" ? "工作中…" : c.seat.status === "error" ? "失败" : `已回报 ${fmtMs(c.seat.ms)}`}
tone={toneOf(c.seat)}
glow={c.seat.status === "running"}
/>
</g>
))}
</svg>