diff --git a/sundynix-desktop/frontend/src/components/OfficeView.tsx b/sundynix-desktop/frontend/src/components/OfficeView.tsx
new file mode 100644
index 0000000..ca2693b
--- /dev/null
+++ b/sundynix-desktop/frontend/src/components/OfficeView.tsx
@@ -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 (
+ <>
+
+ {style === 0 && (
+ <>
+
+
+ >
+ )}
+ {style === 1 && (
+ <>
+
+
+ >
+ )}
+ {style === 2 && }
+ >
+ );
+}
+
+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 (
+
+ {/* 手臂:working 时上下敲键盘 */}
+
+
+
+
+
+
+
+ {/* 躯干 */}
+
+
+
+ {/* 头 */}
+
+
+ {/* 刘海 */}
+
+
+
+ {/* 眼睛:完成时眯眼笑,失败时垂眼 */}
+ {happy ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+ {sad ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+// 头顶气泡:工作中=打字三点,完成=✓,失败=!
+function Bubble({ status, delay }: { status: TeamSeat["status"]; delay: number }) {
+ const tone = status === "error" ? ERR : status === "running" ? RUN : OK;
+ return (
+
+
+
+
+ {status === "running" ? (
+ [-11, 0, 11].map((x, i) => (
+
+ ))
+ ) : status === "error" ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+ );
+}
+
+// 工位:白桌 + 显示器辉光(状态色)+ 桌面铭牌。桌子挡住下半身,人只露上半身。
+function Deskette({ name, sub, tone, running, w = 200 }: { name: string; sub: string; tone: string; running?: boolean; w?: number }) {
+ const hw = w / 2;
+ return (
+
+ {/* 显示器(背对我们,屏幕辉光溢出到桌面) */}
+
+
+
+
+
+
+ {/* 桌面 + 桌沿 */}
+
+
+ {/* 咖啡杯 */}
+
+
+
+
+ {/* 铭牌 */}
+
+
+
+ {fitText(name, (w - 62) / 17)}
+
+
+ {sub}
+
+
+ );
+}
+
+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 (
+
+
+
+ {bubble && }
+
+
+ );
+}
+
+// 场景道具
+function Room() {
+ return (
+
+
+
+
+ {/* 地毯 */}
+
+ {/* 窗 */}
+
+
+
+
+
+ {/* 挂钟 */}
+
+
+
+
+ {/* 绿植 */}
+
+
+
+
+
+
+ );
+}
+
+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
暂无协调轨迹。
;
+ }
+
+ // 排位:≤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 (
+
+
+
+
+
+
+
并发时间轴 · 共 {fmtMs(span)}
+
+ {seats.map((s) => {
+ const left = ((s.startTS - t0) / span) * 100;
+ const width = Math.max((((s.endTS ?? t1) - s.startTS) / span) * 100, 2);
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/sundynix-desktop/frontend/src/components/TeamView.tsx b/sundynix-desktop/frontend/src/components/TeamView.tsx
index 511d98e..0b1dfab 100644
--- a/sundynix-desktop/frontend/src/components/TeamView.tsx
+++ b/sundynix-desktop/frontend/src/components/TeamView.tsx
@@ -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";
diff --git a/sundynix-desktop/frontend/src/lib/run.test.ts b/sundynix-desktop/frontend/src/lib/run.test.ts
index 5742c2c..ae363cb 100644
--- a/sundynix-desktop/frontend/src/lib/run.test.ts
+++ b/sundynix-desktop/frontend/src/lib/run.test.ts
@@ -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 {
@@ -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);
+ });
+});
diff --git a/sundynix-desktop/frontend/src/lib/run.ts b/sundynix-desktop/frontend/src/lib/run.ts
index f37c771..4b65d1e 100644
--- a/sundynix-desktop/frontend/src/lib/run.ts
+++ b/sundynix-desktop/frontend/src/lib/run.ts
@@ -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;
diff --git a/sundynix-desktop/frontend/src/views/RunsView.tsx b/sundynix-desktop/frontend/src/views/RunsView.tsx
index fca06a7..fcebcae 100644
--- a/sundynix-desktop/frontend/src/views/RunsView.tsx
+++ b/sundynix-desktop/frontend/src/views/RunsView.tsx
@@ -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(emptyRun);
const [evalRes, setEvalRes] = useState(null);
const [tab, setTab] = useState("trace");
+ const [teamSkin, setTeamSkin] = useState<"office" | "board">("office"); // 团队视图皮肤:卡通办公室 / 卡片看板
useEffect(() => {
let alive = true;
@@ -139,7 +141,23 @@ export function RunsView({ run }: { run: RunState }) {
) : activeTab === "trace" ? (
) : activeTab === "team" ? (
-
+
+
+ {(["office", "board"] as const).map((k) => (
+
+ ))}
+
+
+ {teamSkin === "office" ? (
+
+ ) : (
+
+ )}
+
+
) : activeTab === "tools" ? (
) : (