import { useMemo, type CSSProperties } from "react";
import { deriveTeam, teamNow, type RunPhase, type TeamSeat } from "../lib/run";
import type { ExecEvent } from "../lib/api";
import roomSprite from "../assets/office/room.webp";
import leadSprite from "../assets/office/lead.webp";
import deskSprite from "../assets/office/desk.webp";
import a0 from "../assets/office/a0.webp";
import a1 from "../assets/office/a1.webp";
import a2 from "../assets/office/a2.webp";
import a3 from "../assets/office/a3.webp";
import a4 from "../assets/office/a4.webp";

// 专家立绘：按工位序号循环取，同一次运行里每个专家稳定对应同一个人。
const ACTORS = [a0, a1, a2, a3, a4];

// 2D 卡通办公室：把多智能体协调演成一出办公室短剧。
//   专家被派发 → 从门口走进来 → 在工位敲键盘 → 干完起身走到协调者桌前递简报 → 回工位。
// 手绘贴纸风（粗描边 + 平涂 + 大头身），纯 SVG 手写——不引 PixiJS/Spine、不下任何素材，
// 因此没有授权问题，整个场景几 KB。数据与「卡片看板」同源（deriveTeam），换皮不换数据。
//
// 走位全部是 CSS 关键帧（无 rAF / 无定时器 / 无状态机）：
//   直播时(streaming) 工位随事件到达而挂载，延迟一律 0，动画即时反映真实进度；
//   复盘时按事件时间戳算出各自的出场/交付时刻，压进 PLAY_MS 内当一出戏放。
// 动效=状态编码，不是装饰；并守 prefers-reduced-motion。

// 场景尺寸 = 房间贴图的比例(2760×1504 → 1.835)，让背景零拉伸：
// 拉伸 30% 的话墙上那个挂钟会变成椭圆，一眼假。宁可改场景去迁就图。
const VB_W = 940;
const VB_H = 512;
const FLOOR_Y = 242; // 贴图里量的地平线在 47.3%

const OUTLINE = "#2f2a26";
const SKIN = "#f7d9bd";
const PANTS = "#4a5568";

const RUN = "#7c5cf6";
const OK = "#34d399";
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 = 279; // 贴图里那扇门的中心(29.7%)——角色从画上的真门里走出来
const DOOR_Y = 250;
const DOOR_SCALE = 0.5;

// 协调者机位；递简报时专家走到它桌子两侧。
const LEAD_X = 470;
const LEAD_Y = 268; // 下限 242(地平线，再高就飘到墙上)、上限 276(再低撞到工位气泡)
const LEAD_SCALE = 0.62;
const HANDOFF_DX = 86;
const HANDOFF_Y = 288;
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;
}

function fmtMs(ms?: number): string {
  if (ms == null) return "";
  return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
}

function truncate(s: string | undefined, n: number): string {
  if (!s) return "";
  return s.length > n ? s.slice(0, n) + "…" : s;
}

// 铭牌按视觉宽度截断：中日韩字符约 1em，拉丁/数字约 0.55em。
// 按字数一刀切会把 wiki_search 这类窄字名字冤枉截掉。
const emOf = (c: string) => (/[　-鿿＀-￯]/.test(c) ? 1 : 0.55);

function fitText(s: string, emBudget: number): string {
  let used = 0;
  for (let i = 0; i < s.length; i++) {
    used += emOf(s[i]);
    if (used > emBudget) return s.slice(0, Math.max(1, i)) + "…";
  }
  return s;
}

// ---- 角色 ----
// 局部坐标：脚在原点，向上为负。整体高约 150 单位，由外层 scale 缩放。

function Hair({ color, style }: { color: string; style: number }) {
  return (
    <>
      <circle cx={0} cy={-119} r={28.5} fill={color} stroke={OUTLINE} strokeWidth={2.4} />
      {style === 0 && (
        <>
          <ellipse cx={-25} cy={-108} rx={6.5} ry={13} fill={color} stroke={OUTLINE} strokeWidth={2.4} />
          <ellipse cx={25} cy={-108} rx={6.5} ry={13} fill={color} stroke={OUTLINE} strokeWidth={2.4} />
        </>
      )}
      {style === 1 && (
        <>
          <circle cx={27} cy={-131} r={9} fill={color} stroke={OUTLINE} strokeWidth={2.4} />
          <ellipse cx={33} cy={-114} rx={6} ry={11} fill={color} stroke={OUTLINE} strokeWidth={2.4} />
        </>
      )}
      {style === 2 && <ellipse cx={0} cy={-142} rx={9} ry={7} fill={color} stroke={OUTLINE} strokeWidth={2.4} />}
    </>
  );
}

// 立绘贴图：AI 生成 + 抠绿幕的角色。给了 sprite 就用贴图，没给则回落到手绘。
// 贴图是扁的：手臂动不了、表情换不了 —— 状态改由显示器辉光 / 头顶气泡 / 铭牌圆点表达（本来就有）。
// 局部坐标与手绘版对齐：脚在原点、身高 150、宽约 72。
const SPRITE_H = 168;
const SPRITE_W = 81;

function ChibiSprite({ sprite, delay }: { sprite: string; delay: number }) {
  return (
    <g className="sdx-breathe" style={{ animationDelay: `${delay}s` }}>
      <image
        href={sprite}
        x={-SPRITE_W / 2}
        y={-SPRITE_H}
        width={SPRITE_W}
        height={SPRITE_H}
        preserveAspectRatio="xMidYMax meet"
      />
    </g>
  );
}

// typing: undefined=不敲；{count,delay}=敲 count 次（count=Infinity 即一直敲）
function Chibi({
  shirt,
  hair,
  style,
  typing,
  happy,
  sad,
  delay,
  sprite,
}: {
  shirt: string;
  hair: string;
  style: number;
  typing?: { count: number; delay: number };
  happy?: boolean;
  sad?: boolean;
  delay: number;
  sprite?: string;
}) {
  if (sprite) return <ChibiSprite sprite={sprite} delay={delay} />;
  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="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="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>

      {/* 躯干 */}
      <rect x={-21} y={-96} width={42} height={54} rx={15} fill={shirt} stroke={OUTLINE} strokeWidth={2.6} />
      <path d={`M -8 -95 L 0 -86 L 8 -95`} fill="none" stroke={OUTLINE} strokeWidth={2.2} strokeLinecap="round" />

      {/* 头 */}
      <Hair color={hair} style={style} />
      <ellipse cx={0} cy={-112} rx={24} ry={23} fill={SKIN} stroke={OUTLINE} strokeWidth={2.5} />
      {/* 刘海 */}
      <path
        d="M -23 -120 C -19 -107 -7 -111 -1 -117 C 7 -109 17 -110 23 -120 C 20 -134 -20 -134 -23 -120 Z"
        fill={hair}
        stroke={OUTLINE}
        strokeWidth={2.2}
        strokeLinejoin="round"
      />
      <ellipse cx={-15} cy={-104} rx={4.5} ry={3} fill="#f0a0a0" opacity={0.5} />
      <ellipse cx={15} cy={-104} rx={4.5} ry={3} fill="#f0a0a0" opacity={0.5} />
      {/* 眼睛：完成时眯眼笑，失败时垂眼 */}
      {happy ? (
        <>
          <path d="M -13 -110 q 4.5 -5 9 0" fill="none" stroke={OUTLINE} strokeWidth={2.4} strokeLinecap="round" />
          <path d="M 4 -110 q 4.5 -5 9 0" fill="none" stroke={OUTLINE} strokeWidth={2.4} strokeLinecap="round" />
        </>
      ) : (
        <>
          <ellipse cx={-8.5} cy={-110} rx={3} ry={sad ? 2.6 : 4} fill={OUTLINE} />
          <ellipse cx={8.5} cy={-110} rx={3} ry={sad ? 2.6 : 4} fill={OUTLINE} />
        </>
      )}
      {sad ? (
        <path d="M -4 -98 q 4 -4 8 0" fill="none" stroke={OUTLINE} strokeWidth={2.2} strokeLinecap="round" />
      ) : (
        <path d="M -4 -101 q 4 5 8 0" fill="none" stroke={OUTLINE} strokeWidth={2.2} strokeLinecap="round" />
      )}
    </g>
  );
}

// 头顶气泡：工作中=打字三点，完成=✓，失败=!
function Bubble({ status, delay }: { status: TeamSeat["status"]; delay: number }) {
  const tone = status === "error" ? ERR : status === "running" ? RUN : OK;
  return (
    <g className="sdx-pop" style={{ animationDelay: `${delay}s` }}>
      <rect x={-24} y={-186} width={48} height={30} rx={11} fill="#fff" stroke={OUTLINE} strokeWidth={2.4} />
      <path d="M -6 -158 L 0 -148 L 6 -158 Z" fill="#fff" stroke={OUTLINE} strokeWidth={2.4} strokeLinejoin="round" />
      <path d="M -7 -158.6 L 7 -158.6" stroke="#fff" strokeWidth={3} />
      {status === "running" ? (
        [-11, 0, 11].map((x, i) => (
          <circle key={x} className="sdx-dot" style={{ animationDelay: `${i * 0.18}s` }} cx={x} cy={-171} r={3.6} fill={tone} />
        ))
      ) : status === "error" ? (
        <>
          <rect x={-2.2} y={-181} width={4.4} height={12} rx={2.2} fill={ERR} />
          <circle cx={0} cy={-164} r={2.6} fill={ERR} />
        </>
      ) : (
        <path d="M -9 -171 L -3 -165 L 10 -178" fill="none" stroke={OK} strokeWidth={3.6} strokeLinecap="round" strokeLinejoin="round" />
      )}
    </g>
  );
}

// 手里那页简报：只在递交那一程可见，到协调者桌前就「放下」了。
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>
  );
}

// 工位（桌子层）：白桌 + 显示器辉光（状态色）+ 桌面铭牌。桌子挡住下半身，人只露上半身。
// 桌子贴图的解剖（量出来的，单位=贴图内百分比）：
//   显示器 x 56%~94% / y 0~37%；咖啡杯 x 13%~26%；桌面 y 42%~56%；挡板 y 58%~94%。
// 锚点按「桌面」而不是「底边」：底边贴地的话桌宽 200 时显示器会顶到 y=-136 糊住人脸。
// 按桌面对齐后桌子下半截自然超出脚线——这是对的，桌子比人更靠近镜头，本就该画得更低。
const DESK_RATIO = 376 / 260;
const DESK_SURFACE_FRAC = 0.44; // 桌面在贴图里的高度位置
const DESK_MON_CX = 0.75; // 显示器中心的横向位置（辉光叠在这儿）
const DESK_MON_CY = 0.2;

function Deskette({ name, sub, tone, glow, w = 200 }: { name: string; sub: string; tone: string; glow?: boolean; w?: number }) {
  const hw = w / 2;
  const dh = w / DESK_RATIO;
  const top = -46 - DESK_SURFACE_FRAC * dh; // 让桌面落在 y=-46（与原手绘桌一致）
  const monX = -hw + DESK_MON_CX * w;
  const monY = top + DESK_MON_CY * dh;
  return (
    <g>
      {/* 屏幕光溢到桌面：画在贴图「之下」，是光晕不是色斑。
          贴图里的显示器背壳朝着我们，把状态色直接糊在背壳上物理讲不通、也像贴纸。 */}
      <ellipse cx={monX} cy={monY + dh * 0.2} rx={w * 0.19} ry={dh * 0.22} fill={`url(#sdx-glow-${tone.slice(1)})`} opacity={glow ? 1 : 0.45} className={glow ? "sdx-glow" : undefined} />
      <image href={deskSprite} x={-hw} y={top} width={w} height={dh} preserveAspectRatio="xMidYMid meet" />
      {/* 铭牌 + 副标题：都放在挡板内（实测挡板 = local -27~+23），
          掉到挡板外会骑在桌子下边线上、被切成半截。 */}
      <rect x={-hw + 18} y={-24} width={w - 36} height={23} rx={4} fill="#fff" stroke={OUTLINE} strokeWidth={2.2} />
      <circle cx={-hw + 31} cy={-12.5} r={4.2} fill={tone} />
      <text x={-hw + 42} y={-7} fontSize={20} fontWeight={600} fill="#2f2a26" style={{ fontFamily: "system-ui, sans-serif" }}>
        {fitText(name, (w - 62) / 20)}
      </text>
      <text x={0} y={16} fontSize={16} textAnchor="middle" fill="#6b6156" style={{ fontFamily: "system-ui, sans-serif" }}>
        {sub}
      </text>
    </g>
  );
}

// 一位专家的完整档期：出场时刻、敲多久、何时去递简报。
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, hasLead }: { cue: Cue; reduced: boolean; hasLead: boolean }) {
  const { seat, x, y, scale: s, index } = cue;
  const finished = seat.status === "done";
  // 没有协调者就没人可递：干完仍然眯眼笑、打✓，只是不演交付那一程。
  const handoff = finished && hasLead;

  // 入场：从门口走到工位（局部坐标 = 场景增量 ÷ 本工位缩放）。
  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}
        typing={typing}
        happy={finished}
        sad={seat.status === "error"}
        delay={index * 0.23}
        sprite={ACTORS[index % ACTORS.length]}
      />
      {handoff && !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}>
          {handoff ? (
            <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>
  );
}

// 场景道具：整间房是一张贴图（AI 生成），门/窗/挂钟/绿植都在图里。
// 原来这里是手绘 SVG 的墙地门窗，被立绘一衬托就露怯，整段换掉。
function Room() {
  return <image href={roomSprite} x={0} y={0} width={VB_W} height={VB_H} preserveAspectRatio="xMidYMid slice" />;
}

export function OfficeView({ events, output, phase }: { events: ExecEvent[]; output?: string; phase?: RunPhase }) {
  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 thinking = truncate((output ?? "").slice(-110).replace(/\s+/g, " "), 110);
  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: 470, s: 1 }] : [{ y: 358, s: 0.62 }, { y: 478, s: 0.85 }];

  // 档期：直播时事件到哪演到哪（延迟一律 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) => {
      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 + (lead ? HANDOFF_MS : 0)
              : 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-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-arm{transform-box:fill-box;transform-origin:50% 8%}
        .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-arm,.sdx-enter,.sdx-handoff,.sdx-walk,.sdx-paper,.sdx-dot,.sdx-glow,.sdx-pop,.sdx-flow,.sdx-caret{animation:none}
        }
      `}</style>

      <svg viewBox={`0 0 ${VB_W} ${VB_H}`} className="w-full shrink-0 rounded-lg" role="img" aria-label="办公室视图：协调者与专家工位">
        {/* 屏幕光的柔性衰减：硬边椭圆看着像色斑，径向渐变才像光 */}
        <defs>
          {[RUN, OK, ERR].map((c) => (
            <radialGradient key={c} id={`sdx-glow-${c.slice(1)}`}>
              <stop offset="0%" stopColor={c} stopOpacity={0.85} />
              <stop offset="55%" stopColor={c} stopOpacity={0.35} />
              <stop offset="100%" stopColor={c} stopOpacity={0} />
            </radialGradient>
          ))}
        </defs>
        <Room />

        {/* 白板：协调者的思考/综合过程 */}
        <g>
          <rect x={347} y={22} width={266} height={104} rx={6} fill="#fff" stroke={OUTLINE} strokeWidth={2.8} />
          <rect x={347} y={118} width={266} height={8} rx={3} fill="#e2dbcf" stroke={OUTLINE} strokeWidth={2.2} />
          <foreignObject x={357} y={30} width={246} height={86}>
            <div style={{ fontFamily: "system-ui, sans-serif", color: "#4a4238", lineHeight: 1.35 }}>
              <div style={{ fontSize: 15, color: leadTone, fontWeight: 600 }}>
                {lead
                  ? streaming
                    ? "综合中…"
                    : lead.status === "error"
                      ? "协调失败"
                      : lead.status === "done"
                        ? "已综合"
                        : "协调中"
                  : streaming
                    ? "生成中…"
                    : "任务产出"}
              </div>
              <div
                style={{
                  fontSize: 16,
                  marginTop: 3,
                  wordBreak: "break-word",
                  display: "-webkit-box",
                  WebkitBoxOrient: "vertical",
                  WebkitLineClamp: 3,
                  overflow: "hidden",
                }}
              >
                {thinking || lead?.detail || "等待协调者输出…"}
                {streaming && <span className="sdx-caret" style={{ color: RUN }}>▌</span>}
              </div>
            </div>
          </foreignObject>
        </g>

        {/* 协调者：只有真走了协调者节点才画。用户自己并排多个 agent 的图没有协调者，
            那就不该凭空画一个人出来——白板照样挂产出，专家各自干各自的。 */}
        {lead && (
          <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}
              sprite={leadSprite}
            />
            <Deskette name={lead.label} sub="协调者" tone={leadTone} glow={streaming} w={230} />
          </g>
        )}

        {/* 派发连线：只画正在工作的工位；没有协调者就没有派发这回事 */}
        {(lead ? cues : [])
          .filter((c) => c.seat.status === "running")
          .map((c) => (
            <path
              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}
              opacity={0.35}
              className="sdx-flow"
            />
          ))}

        {/* 角色层在前、桌子层在后：角色才能走到别人桌前而不被自己那张桌子焊死 */}
        {cues.map((c) => (
          <Actor key={c.seat.node} cue={c} reduced={reduced} hasLead={!!lead} />
        ))}
        {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>

      <div className="shrink-0 border-t border-line px-3 pb-3 pt-2">
        <div className="mb-1.5 text-[11px] text-slate-600">并发时间轴 · 共 {fmtMs(span)}</div>
        <div className="space-y-1">
          {seats.map((s) => {
            const left = ((s.startTS - t0) / span) * 100;
            const width = Math.max((((s.endTS ?? t1) - s.startTS) / span) * 100, 2);
            return (
              <div key={s.node} className="grid grid-cols-[80px_1fr] items-center gap-2">
                <span className="truncate text-[11px] text-slate-500">{s.name}</span>
                <div className="h-1.5 rounded-full bg-ink-800">
                  <div
                    className="h-1.5 rounded-full"
                    style={{
                      marginLeft: `${left}%`,
                      width: `${width}%`,
                      background: s.status === "running" ? RUN : s.status === "error" ? ERR : OK,
                    }}
                  />
                </div>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}
