feat(desktop): 团队视图加「卡通办公室」皮肤(手绘 SVG 贴纸风)

多智能体协调演成办公室:协调者在白板前统筹(白板即思考/综合流),
专家在工位敲键盘,完成打✓、失败挂!、派发走虚线。

风格对齐 ai-office-react(粗描边+平涂+大头身),但用手写 SVG 而非
PixiJS+Spine:零外部素材、无 Spine 运行时授权问题、几 KB 进包。
先试过 three.js 真 3D,低模程序化角色出来是玩具味,已弃并卸干净依赖。

数据与「卡片看板」同源(deriveTeam),换皮不换数据,后端零改动。
动效=状态编码(敲键盘/思考流/辉光),守 prefers-reduced-motion。

顺带修一个真 bug:并发时间轴复盘卡在 running 的旧任务时,未收口工位
会一路量到 Date.now(),跨度爆表(实测 32103562.4s)。抽 teamNow():
直播用此刻、复盘用最后一个事件时间戳;两个皮肤共用,+3 单测。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-15 16:25:54 +08:00
parent 0152e7837a
commit 225fee5519
5 changed files with 452 additions and 4 deletions
@@ -0,0 +1,395 @@
import { useMemo } from "react";
import { deriveTeam, teamNow, type RunPhase, type TeamSeat } from "../lib/run";
import type { ExecEvent } from "../lib/api";
// 2D 卡通办公室:把多智能体协调演成「协调者在白板前统筹 + 专家在工位干活」。
// 手绘贴纸风(粗描边 + 平涂 + 大头身),纯 SVG 手写——不引 PixiJS/Spine、不下任何素材,
// 因此没有授权问题,整个场景几 KB。动效=状态编码(敲键盘/思考流),并守 reduced-motion。
// 数据与「卡片看板」同源(deriveTeam),换皮不换数据。
const VB_W = 720;
const VB_H = 460;
const FLOOR_Y = 200;
const OUTLINE = "#2f2a26";
const SKIN = "#f7d9bd";
const WALL = "#f3ede4";
const FLOOR = "#ddd0be";
const DESK_TOP = "#ffffff";
const DESK_FRONT = "#f1ebe2";
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"];
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} />}
</>
);
}
function Chibi({
shirt,
hair,
style,
working,
happy,
sad,
delay,
}: {
shirt: string;
hair: string;
style: number;
working?: boolean;
happy?: boolean;
sad?: boolean;
delay: number;
}) {
const d = `${delay}s`;
return (
<g className={working ? "sdx-bob" : "sdx-breathe"} style={{ animationDelay: d }}>
{/* 手臂:working 时上下敲键盘 */}
<g className={working ? "sdx-arm sdx-type" : "sdx-arm"} style={{ animationDelay: d }}>
<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` }}>
<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 Deskette({ name, sub, tone, running, w = 200 }: { name: string; sub: string; tone: string; running?: 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} />
<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} />
</g>
{/* 桌面 + 桌沿 */}
<rect x={-hw} y={-46} width={w} height={9} rx={4} fill={DESK_TOP} stroke={OUTLINE} strokeWidth={2.6} />
<rect x={-hw + 8} y={-38} width={w - 16} height={36} rx={3} fill={DESK_FRONT} stroke={OUTLINE} strokeWidth={2.4} />
{/* 咖啡杯 */}
<g transform={`translate(${-hw + 26}, -46)`}>
<rect x={-6} y={-13} width={12} height={13} rx={2.5} fill="#fff" stroke={OUTLINE} strokeWidth={2} />
<path d="M 6 -10 q 6 3 0 7" fill="none" stroke={OUTLINE} strokeWidth={2} />
</g>
{/* 铭牌 */}
<rect x={-hw + 18} y={-32} width={w - 36} height={24} rx={4} fill="#fff" stroke={OUTLINE} strokeWidth={2.2} />
<circle cx={-hw + 31} cy={-20} r={4.2} fill={tone} />
<text x={-hw + 42} y={-15} fontSize={17} fontWeight={600} fill="#2f2a26" style={{ fontFamily: "system-ui, sans-serif" }}>
{fitText(name, (w - 62) / 17)}
</text>
<text x={0} y={11} fontSize={14} textAnchor="middle" fill="#6b6156" style={{ fontFamily: "system-ui, sans-serif" }}>
{sub}
</text>
</g>
);
}
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} />
<Chibi
shirt={SHIRTS[index % SHIRTS.length]}
hair={HAIRS[index % HAIRS.length]}
style={index % 3}
working={running}
happy={seat.status === "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} />
</g>
);
}
// 场景道具
function Room() {
return (
<g>
<rect x={0} y={0} width={VB_W} height={FLOOR_Y} fill={WALL} />
<rect x={0} y={FLOOR_Y} width={VB_W} height={VB_H - FLOOR_Y} fill={FLOOR} />
<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} />
</g>
{/* 挂钟 */}
<g transform="translate(660, 54)">
<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 transform="translate(668, 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" />
</g>
</g>
);
}
export function OfficeView({ events, output, phase }: { events: ExecEvent[]; output?: string; phase?: RunPhase }) {
const team = useMemo(() => deriveTeam(events, teamNow(events, phase === "streaming")), [events, phase]);
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>;
}
// 排位:≤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) => {
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,
}));
});
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-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-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}
}
`}</style>
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} className="w-full shrink-0 rounded-lg" role="img" aria-label="办公室视图:协调者与专家工位">
<Room />
{/* 白板:协调者的思考/综合过程 */}
<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}>
<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" ? "已综合" : "协调中"}
</div>
<div
style={{
fontSize: 13,
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>
{/* 派发连线:只画正在工作的工位 */}
{placed
.filter((p) => p.seat.status === "running")
.map((p) => (
<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}`}
fill="none"
stroke={RUN}
strokeWidth={1.6}
opacity={0.35}
className="sdx-flow"
/>
))}
{/* 协调者:白板前,比前排小 → 纵深 */}
<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} />
))}
</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>
);
}
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { Users, Wrench, Network } from "lucide-react";
import { deriveTeam, type RunPhase, type TeamSeat } from "../lib/run";
import { deriveTeam, teamNow, type RunPhase, type TeamSeat } from "../lib/run";
import type { ExecEvent } from "../lib/api";
import { cn } from "../ui";
@@ -67,7 +67,7 @@ function SeatCard({ seat }: { seat: TeamSeat }) {
}
export function TeamView({ events, output, phase }: { events: ExecEvent[]; output?: string; phase?: RunPhase }) {
const team = useMemo(() => deriveTeam(events), [events]);
const team = useMemo(() => deriveTeam(events, teamNow(events, phase === "streaming")), [events, phase]);
const { lead, seats, t0, t1 } = team;
const span = Math.max(t1 - t0, 1);
const streaming = phase === "streaming";
+26 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import type { ExecEvent } from "./api";
import { deriveNodes, pendingApproval, isMultiAgent, deriveTeam } from "./run";
import { deriveNodes, pendingApproval, isMultiAgent, deriveTeam, teamNow } from "./run";
let seq = 0;
function ev(node: string, phase: string, extra: Partial<ExecEvent> = {}): ExecEvent {
@@ -160,3 +160,28 @@ describe("deriveTeam(协调者 + 工位)", () => {
expect(t.t1).toBe(5000);
});
});
describe("teamNow(并发时间轴的右端)", () => {
it("直播时用此刻", () => {
const before = Date.now();
expect(teamNow([ev("agent:a", "start", { kind: "agent", ts: 100 })], true)).toBeGreaterThanOrEqual(before);
});
it("复盘时用最后一个事件的时间戳", () => {
const n = teamNow(
[ev("agent:a", "start", { kind: "agent", ts: 100 }), ev("agent:b", "end", { kind: "agent", ts: 900, ms: 800 })],
false,
);
expect(n).toBe(900);
});
it("复盘一条卡在 running 的旧任务,跨度不会拉到今天", () => {
const old = Date.now() - 365 * 24 * 3600 * 1000;
const events = [
ev("agent:a", "start", { kind: "agent", ts: old }),
ev("agent:b", "start", { kind: "agent", ts: old + 2000 }), // 永远没收口
];
const t = deriveTeam(events, teamNow(events, false));
expect(t.t1 - t.t0).toBe(2000);
});
});
+10
View File
@@ -141,6 +141,16 @@ function splitBrief(detail?: string): { brief?: string; output?: string } {
return { output: detail };
}
// teamNow 决定并发时间轴的右端。直播时是此刻——未收口的工位应持续生长;
// 复盘时必须是最后一个事件的时间戳:回放一条卡在 running 的历史任务(崩溃/超时留下的),
// 未收口工位若量到 Date.now(),时间轴会一路拉到今天,跨度直接爆表。
export function teamNow(events: ExecEvent[], live: boolean): number {
if (live) return Date.now();
let last = 0;
for (const e of events) if (e.ts > last) last = e.ts;
return last || Date.now();
}
// deriveTeam 把事件流派生成「协调者 + 工位」模型(保留时间戳供并发时间轴)。
export function deriveTeam(events: ExecEvent[], now = Date.now()): TeamModel {
let lead: TeamLead | null = null;
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { Activity, FileText, History, Users, Wrench } from "lucide-react";
import { ExecTrace } from "../components/ExecTrace";
import { TeamView } from "../components/TeamView";
import { OfficeView } from "../components/OfficeView";
import { Markdown } from "../components/Markdown";
import { ChartView } from "../components/ChartView";
import { extractChartBlocks, hasChart } from "../lib/chartspec";
@@ -33,6 +34,7 @@ export function RunsView({ run }: { run: RunState }) {
const [replay, setReplay] = useState<RunState>(emptyRun);
const [evalRes, setEvalRes] = useState<EvalResult | null>(null);
const [tab, setTab] = useState<DetailTab>("trace");
const [teamSkin, setTeamSkin] = useState<"office" | "board">("office"); // 团队视图皮肤:卡通办公室 / 卡片看板
useEffect(() => {
let alive = true;
@@ -139,7 +141,23 @@ export function RunsView({ run }: { run: RunState }) {
) : activeTab === "trace" ? (
<ExecTrace events={cur.exec} phase={cur.phase} />
) : activeTab === "team" ? (
<TeamView events={cur.exec} output={cur.output} phase={cur.phase} />
<div className="flex h-full min-h-0 flex-col gap-2">
<div className="flex shrink-0 items-center gap-1 self-start rounded-md border border-line p-0.5">
{(["office", "board"] as const).map((k) => (
<button key={k} onClick={() => setTeamSkin(k)}
className={cn("rounded px-2 py-0.5 text-[11px] transition", teamSkin === k ? "bg-ink-800 text-slate-100" : "text-slate-500 hover:text-slate-300")}>
{k === "office" ? "办公室" : "看板"}
</button>
))}
</div>
<div className="min-h-0 flex-1">
{teamSkin === "office" ? (
<OfficeView events={cur.exec} output={cur.output} phase={cur.phase} />
) : (
<TeamView events={cur.exec} output={cur.output} phase={cur.phase} />
)}
</div>
</div>
) : activeTab === "tools" ? (
<ToolCalls run={cur} />
) : (