@sleep2agi/agent-network-dashboard 0.5.3-preview.260 → 0.5.3-preview.262

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/diagnostics/route-bundle-stats.json +5 -5
  4. package/.next/fallback-build-manifest.json +3 -3
  5. package/.next/server/app/_global-error.html +1 -1
  6. package/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/.next/server/app/_not-found.html +1 -1
  13. package/.next/server/app/_not-found.rsc +1 -1
  14. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  15. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  16. package/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  17. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  18. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  19. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  20. package/.next/server/app/admin.html +1 -1
  21. package/.next/server/app/admin.rsc +1 -1
  22. package/.next/server/app/admin.segments/_full.segment.rsc +1 -1
  23. package/.next/server/app/admin.segments/_head.segment.rsc +1 -1
  24. package/.next/server/app/admin.segments/_index.segment.rsc +1 -1
  25. package/.next/server/app/admin.segments/_tree.segment.rsc +1 -1
  26. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +1 -1
  27. package/.next/server/app/admin.segments/admin.segment.rsc +1 -1
  28. package/.next/server/app/index.html +2 -2
  29. package/.next/server/app/index.rsc +2 -2
  30. package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  31. package/.next/server/app/index.segments/_full.segment.rsc +2 -2
  32. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  33. package/.next/server/app/index.segments/_index.segment.rsc +1 -1
  34. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  35. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  36. package/.next/server/app/login.html +2 -2
  37. package/.next/server/app/login.rsc +2 -2
  38. package/.next/server/app/login.segments/_full.segment.rsc +2 -2
  39. package/.next/server/app/login.segments/_head.segment.rsc +1 -1
  40. package/.next/server/app/login.segments/_index.segment.rsc +1 -1
  41. package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
  42. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
  43. package/.next/server/app/login.segments/login.segment.rsc +1 -1
  44. package/.next/server/app/logs.html +1 -1
  45. package/.next/server/app/logs.rsc +1 -1
  46. package/.next/server/app/logs.segments/_full.segment.rsc +1 -1
  47. package/.next/server/app/logs.segments/_head.segment.rsc +1 -1
  48. package/.next/server/app/logs.segments/_index.segment.rsc +1 -1
  49. package/.next/server/app/logs.segments/_tree.segment.rsc +1 -1
  50. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +1 -1
  51. package/.next/server/app/logs.segments/logs.segment.rsc +1 -1
  52. package/.next/server/app/messages.html +1 -1
  53. package/.next/server/app/messages.rsc +1 -1
  54. package/.next/server/app/messages.segments/_full.segment.rsc +1 -1
  55. package/.next/server/app/messages.segments/_head.segment.rsc +1 -1
  56. package/.next/server/app/messages.segments/_index.segment.rsc +1 -1
  57. package/.next/server/app/messages.segments/_tree.segment.rsc +1 -1
  58. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +1 -1
  59. package/.next/server/app/messages.segments/messages.segment.rsc +1 -1
  60. package/.next/server/app/node.html +1 -1
  61. package/.next/server/app/node.rsc +1 -1
  62. package/.next/server/app/node.segments/_full.segment.rsc +1 -1
  63. package/.next/server/app/node.segments/_head.segment.rsc +1 -1
  64. package/.next/server/app/node.segments/_index.segment.rsc +1 -1
  65. package/.next/server/app/node.segments/_tree.segment.rsc +1 -1
  66. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +1 -1
  67. package/.next/server/app/node.segments/node.segment.rsc +1 -1
  68. package/.next/server/app/nodes.html +1 -1
  69. package/.next/server/app/nodes.rsc +1 -1
  70. package/.next/server/app/nodes.segments/_full.segment.rsc +1 -1
  71. package/.next/server/app/nodes.segments/_head.segment.rsc +1 -1
  72. package/.next/server/app/nodes.segments/_index.segment.rsc +1 -1
  73. package/.next/server/app/nodes.segments/_tree.segment.rsc +1 -1
  74. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +1 -1
  75. package/.next/server/app/nodes.segments/nodes.segment.rsc +1 -1
  76. package/.next/server/app/page_client-reference-manifest.js +1 -1
  77. package/.next/server/app/server-logs.html +1 -1
  78. package/.next/server/app/server-logs.rsc +1 -1
  79. package/.next/server/app/server-logs.segments/_full.segment.rsc +1 -1
  80. package/.next/server/app/server-logs.segments/_head.segment.rsc +1 -1
  81. package/.next/server/app/server-logs.segments/_index.segment.rsc +1 -1
  82. package/.next/server/app/server-logs.segments/_tree.segment.rsc +1 -1
  83. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +1 -1
  84. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +1 -1
  85. package/.next/server/app/settings/networks.html +1 -1
  86. package/.next/server/app/settings/networks.rsc +1 -1
  87. package/.next/server/app/settings/networks.segments/_full.segment.rsc +1 -1
  88. package/.next/server/app/settings/networks.segments/_head.segment.rsc +1 -1
  89. package/.next/server/app/settings/networks.segments/_index.segment.rsc +1 -1
  90. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +1 -1
  91. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +1 -1
  92. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +1 -1
  93. package/.next/server/app/settings/networks.segments/settings.segment.rsc +1 -1
  94. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  95. package/.next/server/app/settings/tokens.html +1 -1
  96. package/.next/server/app/settings/tokens.rsc +1 -1
  97. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +1 -1
  98. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +1 -1
  99. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +1 -1
  100. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +1 -1
  101. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +1 -1
  102. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +1 -1
  103. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +1 -1
  104. package/.next/server/app/settings.html +2 -2
  105. package/.next/server/app/settings.rsc +2 -2
  106. package/.next/server/app/settings.segments/_full.segment.rsc +2 -2
  107. package/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  108. package/.next/server/app/settings.segments/_index.segment.rsc +1 -1
  109. package/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
  110. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
  111. package/.next/server/app/settings.segments/settings.segment.rsc +1 -1
  112. package/.next/server/app/tasks.html +1 -1
  113. package/.next/server/app/tasks.rsc +1 -1
  114. package/.next/server/app/tasks.segments/_full.segment.rsc +1 -1
  115. package/.next/server/app/tasks.segments/_head.segment.rsc +1 -1
  116. package/.next/server/app/tasks.segments/_index.segment.rsc +1 -1
  117. package/.next/server/app/tasks.segments/_tree.segment.rsc +1 -1
  118. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +1 -1
  119. package/.next/server/app/tasks.segments/tasks.segment.rsc +1 -1
  120. package/.next/server/chunks/ssr/[root-of-the-server]__0sv~g.o._.js +1 -1
  121. package/.next/server/chunks/ssr/[root-of-the-server]__0sv~g.o._.js.map +1 -1
  122. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js +3 -3
  123. package/.next/server/chunks/ssr/agent-network-dashboard_09kk21a._.js.map +1 -1
  124. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js +1 -1
  125. package/.next/server/chunks/ssr/agent-network-dashboard_app_01jhlxz._.js.map +1 -1
  126. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js +1 -1
  127. package/.next/server/chunks/ssr/agent-network-dashboard_app_09d29my._.js.map +1 -1
  128. package/.next/server/middleware-build-manifest.js +3 -3
  129. package/.next/server/pages/404.html +1 -1
  130. package/.next/server/pages/500.html +1 -1
  131. package/.next/static/chunks/{0u8v68gl7g6j1.js → 01~4zvq_o_k32.js} +1 -1
  132. package/.next/static/chunks/{0p142v5va508~.js → 070._s.6bq1dx.js} +1 -1
  133. package/.next/static/chunks/0vuc69a~-fb7u.js +4 -0
  134. package/.next/static/chunks/{11sahbo6ikg8g.js → 0~~5hvnaek6hz.js} +1 -1
  135. package/.next/trace +2 -2
  136. package/.next/trace-build +1 -1
  137. package/app/components/TopoGraph.tsx +396 -22
  138. package/app/lib/vendorIdentity.ts +74 -56
  139. package/package.json +1 -1
  140. package/public/vendors/claude.svg +7 -8
  141. package/public/vendors/minimax.svg +8 -9
  142. package/public/vendors/openai.svg +8 -10
  143. package/scripts/topo-overlap-test.mjs +22 -8
  144. package/scripts/topo-tree-diag.mjs +95 -0
  145. package/.next/static/chunks/083elibefsefi.js +0 -4
  146. /package/.next/static/{wz1T-LhLDalz691PpN3E7 → uxzbzXUe6BEA30oLCqiD_}/_buildManifest.js +0 -0
  147. /package/.next/static/{wz1T-LhLDalz691PpN3E7 → uxzbzXUe6BEA30oLCqiD_}/_clientMiddlewareManifest.js +0 -0
  148. /package/.next/static/{wz1T-LhLDalz691PpN3E7 → uxzbzXUe6BEA30oLCqiD_}/_ssgManifest.js +0 -0
@@ -85,6 +85,23 @@ interface Point {
85
85
  y: number;
86
86
  }
87
87
 
88
+ /** #170 tree-view MVP — payload for the org-chart layout. `lines` are the
89
+ * parent→child elbow connectors (raw endpoints; the render branch draws
90
+ * the right-angle path). `synthLabels` are placeholder chips for the
91
+ * synthetic nodes (指挥中心 root / 未分组 bucket) that have no backing
92
+ * session. Empty in ring/grid layouts. */
93
+ interface TreeConnectorLine {
94
+ x1: number;
95
+ y1: number;
96
+ x2: number;
97
+ y2: number;
98
+ }
99
+ interface TreeLayout {
100
+ lines: TreeConnectorLine[];
101
+ synthLabels: { x: number; y: number; label: string }[];
102
+ synthRoot: boolean;
103
+ }
104
+
88
105
  interface FlowLink {
89
106
  key: string;
90
107
  from: string;
@@ -607,11 +624,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
607
624
  // grid arranges nodes in an N×M grid (better for 30+ nodes). Persisted to
608
625
  // localStorage like the zoom/pan view state. Declared above nodePositions
609
626
  // since that useMemo branches on it.
610
- const [layout, setLayout] = useState<'ring' | 'grid'>('ring');
627
+ const [layout, setLayout] = useState<'ring' | 'grid' | 'tree'>('ring');
611
628
  useEffect(() => {
612
629
  try {
613
630
  const saved = localStorage.getItem('anet-topo-layout');
614
- if (saved === 'grid' || saved === 'ring') {
631
+ if (saved === 'grid' || saved === 'ring' || saved === 'tree') {
615
632
  setLayout(saved);
616
633
  } else if (sessions.length >= 20) {
617
634
  // v0.10.0 Hero 3 Wave 1 §3.D — auto-grid for dense fleets.
@@ -650,11 +667,26 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
650
667
  // R168's smoothView arming but on a different visual axis
651
668
  // (opacity, not transform).
652
669
  const [layoutSwitching, setLayoutSwitching] = useState(false);
670
+ // #170 tree-view MVP — layout is now 3-way (ring | grid | tree).
671
+ // selectLayout sets a specific mode (used by the chrome segmented
672
+ // control's 3 buttons); toggleLayout cycles ring → grid → tree → ring
673
+ // (used by the `l` keyboard shortcut). Both arm the R170 crossfade-
674
+ // blink so the node teleport between layouts reads as a soft swap.
675
+ const LAYOUT_CYCLE: ('ring' | 'grid' | 'tree')[] = ['ring', 'grid', 'tree'];
676
+ const selectLayout = (next: 'ring' | 'grid' | 'tree') => {
677
+ setLayoutSwitching(true);
678
+ setTimeout(() => setLayoutSwitching(false), 400);
679
+ setLayout(prev => {
680
+ if (prev === next) return prev;
681
+ try { localStorage.setItem('anet-topo-layout', next); } catch {}
682
+ return next;
683
+ });
684
+ };
653
685
  const toggleLayout = () => {
654
686
  setLayoutSwitching(true);
655
687
  setTimeout(() => setLayoutSwitching(false), 400);
656
688
  setLayout(prev => {
657
- const next = prev === 'ring' ? 'grid' : 'ring';
689
+ const next = LAYOUT_CYCLE[(LAYOUT_CYCLE.indexOf(prev) + 1) % LAYOUT_CYCLE.length];
658
690
  try { localStorage.setItem('anet-topo-layout', next); } catch {}
659
691
  return next;
660
692
  });
@@ -718,6 +750,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
718
750
  groupKeys,
719
751
  groupBoxes,
720
752
  gridContentBottom,
753
+ treeConnectors,
754
+ treeContentBox,
721
755
  } = useMemo(() => {
722
756
  const sseCount = (s: { alias: string; network_id?: string }) =>
723
757
  (s.network_id ? sseSessions[`${s.network_id}:${s.alias}`] : undefined) ?? sseSessions[s.alias];
@@ -922,6 +956,219 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
922
956
  groupKeys,
923
957
  groupBoxes,
924
958
  gridContentBottom,
959
+ treeConnectors: { lines: [], synthLabels: [], synthRoot: false } as TreeLayout,
960
+ treeContentBox: null as { x: number; y: number; w: number; h: number } | null,
961
+ };
962
+ }
963
+
964
+ if (layout === 'tree') {
965
+ // #170 tree-view MVP — classic top-down org chart, derived purely
966
+ // from alias + runtime (zero-config; org.json override is the
967
+ // RFC-017 formal version, out of scope here).
968
+ //
969
+ // layer 0 总指挥 ── synthetic root if none / multiple
970
+ // layer 1 副指挥 ── report to 总指挥
971
+ // layer 2 team leads ── one per computeGroups() team
972
+ // + a 未分组 bucket for orphans
973
+ // layer 3 deputy + members hanging under their team lead
974
+ //
975
+ // The classifier per team: LEAD = runtime claude-code-cli OR alias
976
+ // contains 负责人; DEPUTY = runtime codex-sdk (or runtime string
977
+ // containing "codex"); everyone else = members.
978
+ const all = [...online, ...offline];
979
+ const groupKeys = computeGroups(all);
980
+ const isCommander = (a: string) => a.includes('总指挥');
981
+ const isDeputyCmdr = (a: string) => a.includes('副指挥');
982
+ const runtimeOf = (s: Session) => (s.runtime || '').toLowerCase();
983
+ const isCodexRt = (s: Session) => runtimeOf(s).includes('codex');
984
+ const isCliRt = (s: Session) => runtimeOf(s) === 'claude-code-cli';
985
+
986
+ // commanders are roots, never team members
987
+ const commanders = all.filter(s => isCommander(s.alias));
988
+ const deputyCommanders = all.filter(s => isDeputyCmdr(s.alias) && !isCommander(s.alias));
989
+ const rankAliases = new Set([...commanders, ...deputyCommanders].map(s => s.alias));
990
+
991
+ // teams = computeGroups components, minus the commander aliases.
992
+ // A "real" team is a group key shared by ≥2 non-commander members.
993
+ const teamMembers: Record<string, Session[]> = {};
994
+ const orphans: Session[] = [];
995
+ for (const s of all) {
996
+ if (rankAliases.has(s.alias)) continue;
997
+ const gk = groupKeys[s.alias] ?? s.alias;
998
+ (teamMembers[gk] ||= []).push(s);
999
+ }
1000
+ const realTeams: { key: string; members: Session[] }[] = [];
1001
+ for (const [key, members] of Object.entries(teamMembers)) {
1002
+ if (members.length >= 2) realTeams.push({ key, members });
1003
+ else orphans.push(...members);
1004
+ }
1005
+ realTeams.sort((a, b) => a.key.localeCompare(b.key));
1006
+
1007
+ // within a team: pick the LEAD (claude-code-cli runtime, or 负责人
1008
+ // in the alias forces it), then DEPUTY (codex runtime), rest = members.
1009
+ type TeamTree = { key: string; lead: Session; deputy: Session | null; members: Session[] };
1010
+ const pickLead = (members: Session[]): Session => {
1011
+ const forced = members.find(s => s.alias.includes('负责人'));
1012
+ if (forced) return forced;
1013
+ const cli = members.find(s => isCliRt(s));
1014
+ if (cli) return cli;
1015
+ return [...members].sort((a, b) => a.alias.localeCompare(b.alias))[0];
1016
+ };
1017
+ const teamTrees: TeamTree[] = realTeams.map(({ key, members }) => {
1018
+ const lead = pickLead(members);
1019
+ const rest = members.filter(s => s.alias !== lead.alias);
1020
+ const deputy = rest.find(s => isCodexRt(s)) ?? null;
1021
+ const others = rest
1022
+ .filter(s => !deputy || s.alias !== deputy.alias)
1023
+ .sort((a, b) => a.alias.localeCompare(b.alias));
1024
+ return { key, lead, deputy, members: others };
1025
+ });
1026
+
1027
+ // ---- swimlane org layout (Vincent dispatch: "一行一个团队,成员
1028
+ // 不换行,最左边放总指挥和副指挥") ------------------------------------
1029
+ // Each team is ONE horizontal lane — a wide box whose members sit in
1030
+ // a single NON-WRAPPING row. Lanes stack top-to-bottom (one team per
1031
+ // row). The command layer (总指挥 + 副指挥) is pinned in its own box
1032
+ // down the left edge. Reverses the iter1–3 2D box-grid; the iter5
1033
+ // treeContentBox auto-fit still scales + centres the whole chart.
1034
+ const nodeR = Math.round(26 * nodeScale);
1035
+ const cellW = Math.max(118, 2 * nodeR + 66); // member slot — node + dense label
1036
+ const BOX_LABEL = 26; // box top label band
1037
+ const LANE_BODY = Math.max(82, 2 * nodeR + 36);// member-row band height
1038
+ const LANE_H = BOX_LABEL + LANE_BODY; // full lane box height
1039
+ const LANE_GAP = 16; // vertical gap between lanes
1040
+ const BOX_PAD = 14; // lane inner side padding
1041
+ const CMD_W = Math.max(150, 2 * nodeR + 100); // command column width
1042
+ const CMD_GAP = 44; // gap: command column → lanes
1043
+ const CMD_SLOT = Math.max(116, 2 * nodeR + 64);// command node vertical pitch
1044
+ const TOP = 92;
1045
+ const LEFT = 44;
1046
+ const laneX0 = LEFT + CMD_W + CMD_GAP; // x where team lanes start
1047
+
1048
+ // ordered lanes: one per team (teamTrees is already alias-sorted) +
1049
+ // a trailing 未分组 lane collecting every orphan so none are dropped.
1050
+ type Lane = { key: string; isOrphan: boolean; members: Session[] };
1051
+ const lanes: Lane[] = teamTrees.map(tt => ({
1052
+ key: tt.key, isOrphan: false,
1053
+ members: [tt.lead, ...(tt.deputy ? [tt.deputy] : []), ...tt.members],
1054
+ }));
1055
+ if (orphans.length > 0) {
1056
+ lanes.push({
1057
+ key: '未分组', isOrphan: true,
1058
+ members: [...orphans].sort((a, b) => a.alias.localeCompare(b.alias)),
1059
+ });
1060
+ }
1061
+
1062
+ // each lane = a wide box; its members in a single row inside it.
1063
+ const laneStep = LANE_H + LANE_GAP;
1064
+ const laneBoxes = lanes.map((lane, i) => {
1065
+ const y = TOP + i * laneStep;
1066
+ const w = 2 * BOX_PAD + Math.max(1, lane.members.length) * cellW;
1067
+ lane.members.forEach((s, j) => {
1068
+ positions[s.alias] = {
1069
+ x: laneX0 + BOX_PAD + (j + 0.5) * cellW,
1070
+ y: y + BOX_LABEL + LANE_BODY / 2,
1071
+ };
1072
+ });
1073
+ return { lane, x: laneX0, y, w, h: LANE_H };
1074
+ });
1075
+ const laneBlockH = lanes.length ? lanes.length * laneStep - LANE_GAP : LANE_H;
1076
+ const laneBlockBottom = TOP + laneBlockH;
1077
+
1078
+ // command column — 总指挥 + 副指挥 stacked in a compact box pinned to
1079
+ // the far left, vertically centred against the lane block.
1080
+ const cmdMembers: Session[] = [...commanders, ...deputyCommanders];
1081
+ const cmdX = LEFT + CMD_W / 2;
1082
+ const cmdH = cmdMembers.length > 0 ? BOX_LABEL + cmdMembers.length * CMD_SLOT : 0;
1083
+ const cmdBox = cmdMembers.length > 0
1084
+ ? { x: LEFT, y: TOP + Math.max(0, (laneBlockH - cmdH) / 2), w: CMD_W, h: cmdH }
1085
+ : null;
1086
+ if (cmdBox) {
1087
+ cmdMembers.forEach((s, i) => {
1088
+ positions[s.alias] = {
1089
+ x: cmdX,
1090
+ y: cmdBox.y + BOX_LABEL + (i + 0.5) * CMD_SLOT,
1091
+ };
1092
+ });
1093
+ }
1094
+
1095
+ // org link: a light connector from the command column to each lane.
1096
+ const connectors: TreeConnectorLine[] = [];
1097
+ if (cmdBox) {
1098
+ const cmdRight = cmdBox.x + cmdBox.w;
1099
+ const cmdMidY = cmdBox.y + cmdBox.h / 2;
1100
+ for (const lb of laneBoxes) {
1101
+ connectors.push({ x1: cmdRight, y1: cmdMidY, x2: lb.x, y2: lb.y + LANE_H / 2 });
1102
+ }
1103
+ }
1104
+
1105
+ // swimlane has no synthetic root chip — the command box (labelled
1106
+ // 指挥中心 when there is no single 总指挥) is the command layer.
1107
+ const synthRoot = false;
1108
+ const synthBoxes: { x: number; y: number; label: string }[] = [];
1109
+
1110
+ // lanes + command column surfaced as groupBoxes — reuses the cluster
1111
+ // box render + per-box working/idle/offline pip strip.
1112
+ const tally = (members: Session[]) => {
1113
+ let w = 0, i = 0, o = 0;
1114
+ for (const s of members) {
1115
+ const isOn = s.status !== 'offline' || !!sseCount(s);
1116
+ if (s.status === 'working') w++;
1117
+ else if (isOn) i++;
1118
+ else o++;
1119
+ }
1120
+ return { working: w, idle: i, offline: o };
1121
+ };
1122
+ const teamGroupBoxes = [
1123
+ ...(cmdBox ? [{
1124
+ key: commanders.length === 1 ? commanders[0].alias : '指挥中心',
1125
+ isOrphan: false, count: cmdMembers.length,
1126
+ statuses: tally(cmdMembers),
1127
+ x: cmdBox.x, y: cmdBox.y, w: cmdBox.w, h: cmdBox.h,
1128
+ }] : []),
1129
+ ...laneBoxes.map(lb => ({
1130
+ key: lb.lane.key, isOrphan: lb.lane.isOrphan,
1131
+ count: lb.lane.members.length,
1132
+ statuses: tally(lb.lane.members),
1133
+ x: lb.x, y: lb.y, w: lb.w, h: lb.h,
1134
+ })),
1135
+ ];
1136
+
1137
+ // content bottom for auto-fit — bottom of the lowest box.
1138
+ const treeBottom = Math.max(
1139
+ laneBlockBottom,
1140
+ cmdBox ? cmdBox.y + cmdBox.h : TOP + LANE_H,
1141
+ );
1142
+ const gridContentBottom = treeBottom + 48;
1143
+
1144
+ // iter5 (Vincent UX): full content bounding box → the auto-fit effect
1145
+ // scales the chart to fit BOTH viewBox dimensions AND centres it.
1146
+ const treeBX: number[] = [], treeBY: number[] = [];
1147
+ for (const gb of teamGroupBoxes) { treeBX.push(gb.x, gb.x + gb.w); treeBY.push(gb.y, gb.y + gb.h); }
1148
+ for (const a of Object.keys(positions)) { treeBX.push(positions[a].x); treeBY.push(positions[a].y); }
1149
+ // TREE_M pads the raw box/node extent so node radii + label drops
1150
+ // never clip at the fitted edge.
1151
+ const TREE_M = 52;
1152
+ const treeContentBox: { x: number; y: number; w: number; h: number } | null = treeBX.length
1153
+ ? {
1154
+ x: Math.min(...treeBX) - TREE_M,
1155
+ y: Math.min(...treeBY) - TREE_M,
1156
+ w: Math.max(...treeBX) - Math.min(...treeBX) + 2 * TREE_M,
1157
+ h: Math.max(...treeBY) - Math.min(...treeBY) + 2 * TREE_M,
1158
+ }
1159
+ : null;
1160
+
1161
+ return {
1162
+ onlineNodes: online,
1163
+ offlineNodes: offline,
1164
+ nodePositions: positions,
1165
+ flowLinks: [],
1166
+ activeAliases: new Set<string>(),
1167
+ groupKeys,
1168
+ groupBoxes: teamGroupBoxes,
1169
+ gridContentBottom,
1170
+ treeConnectors: { lines: connectors, synthLabels: synthBoxes, synthRoot } as TreeLayout,
1171
+ treeContentBox,
925
1172
  };
926
1173
  }
927
1174
 
@@ -1006,6 +1253,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1006
1253
  groupBoxes: [] as { key: string; isOrphan?: boolean; count: number; statuses: { working: number; idle: number; offline: number }; x: number; y: number; w: number; h: number }[],
1007
1254
  // ring fits within VIEWBOX_H by construction (offlineRadius=325 + centre at y=330)
1008
1255
  gridContentBottom: 0,
1256
+ treeConnectors: { lines: [], synthLabels: [], synthRoot: false } as TreeLayout,
1257
+ treeContentBox: null as { x: number; y: number; w: number; h: number } | null,
1009
1258
  };
1010
1259
  }, [messages, sessions, sseSessions, layout, nodeScale]);
1011
1260
 
@@ -1026,7 +1275,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1026
1275
  const vendorDist = useMemo(() => {
1027
1276
  const tally = new Map<string, { initial: string; count: number; color: string }>();
1028
1277
  for (const s of [...onlineNodes, ...offlineNodes]) {
1029
- const v = vendorForModel(s.model);
1278
+ const v = vendorForModel(s.model, s.runtime);
1030
1279
  const key = v.id === 'unknown' ? '?' : v.initial;
1031
1280
  const cur = tally.get(key);
1032
1281
  if (cur) cur.count++;
@@ -1175,7 +1424,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1175
1424
  // a single-axis paint lift. Post-R674 inline filter applies the
1176
1425
  // same 2+4 stride at pal.legendAccent tint as R667/R668 chrome-
1177
1426
  // control siblings.
1178
- const [hoveredLayout, setHoveredLayout] = useState<'ring' | 'grid' | null>(null);
1427
+ const [hoveredLayout, setHoveredLayout] = useState<'ring' | 'grid' | 'tree' | null>(null);
1179
1428
  // R675 — hover state for the chrome nodeSize S/M/L segmented trio.
1180
1429
  // Value-typed (single state covers all three buttons) drives the
1181
1430
  // multi-layer halo filter completing the chrome strip's nodeSize
@@ -1328,7 +1577,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1328
1577
  // {zoom,x,y} persists to localStorage (same sticky pattern as brand flag).
1329
1578
  const VIEWBOX_W = 1000;
1330
1579
  const VIEWBOX_H = 680;
1331
- const ZOOM_MIN = 0.5;
1580
+ // tree mode can render a tall multi-row org chart — it needs a lower
1581
+ // zoom-out floor than ring/grid so the whole tree can fit (Vincent iter
1582
+ // 3: "缩放最多 50%" couldn't shrink the tree enough).
1583
+ const ZOOM_MIN = layout === 'tree' ? 0.3 : 0.5;
1332
1584
  const ZOOM_MAX = 4;
1333
1585
  const containerRef = useRef<HTMLDivElement>(null);
1334
1586
  const svgRef = useRef<SVGSVGElement>(null);
@@ -1368,7 +1620,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1368
1620
  // keeps its own R184 rotation animation (different gesture, semantic).
1369
1621
  // Node-size S/M/L keep their R171 layoutSwitching crossfade (already
1370
1622
  // gestural). Type union grows but the helper signature stays one-arg.
1371
- type ChromePop = 'zoom-in' | 'zoom-out' | 'layout-ring' | 'layout-grid' | 'fullscreen' | 'size-S' | 'size-M' | 'size-L';
1623
+ type ChromePop = 'zoom-in' | 'zoom-out' | 'layout-ring' | 'layout-grid' | 'layout-tree' | 'fullscreen' | 'size-S' | 'size-M' | 'size-L';
1372
1624
  const [chromePopping, setChromePopping] = useState<ChromePop | null>(null);
1373
1625
  const popChrome = (which: ChromePop) => {
1374
1626
  setChromePopping(which);
@@ -1442,7 +1694,27 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1442
1694
  autoFitDoneRef.current = true;
1443
1695
  return;
1444
1696
  }
1445
- if (layout !== 'grid' || sessions.length === 0 || !gridContentBottom) return;
1697
+ if (sessions.length === 0) return;
1698
+ // iter5 (Vincent UX): tree auto-fit scales the org chart to fit BOTH
1699
+ // viewBox dimensions and CENTRES it. Pre-iter5 the shared grid path
1700
+ // only fit height and left-anchored the chart, so the tree hugged the
1701
+ // top-left corner with dead canvas down the right side.
1702
+ if (layout === 'tree') {
1703
+ if (!treeContentBox) return;
1704
+ const b = treeContentBox;
1705
+ if (b.w <= 0 || b.h <= 0) { autoFitDoneRef.current = true; return; }
1706
+ const pad = 24;
1707
+ const z = Math.max(ZOOM_MIN, Math.min(
1708
+ 1, (VIEWBOX_W - 2 * pad) / b.w, (VIEWBOX_H - 2 * pad) / b.h));
1709
+ setView({
1710
+ zoom: z,
1711
+ x: VIEWBOX_W / 2 - (b.x + b.w / 2) * z,
1712
+ y: VIEWBOX_H / 2 - (b.y + b.h / 2) * z,
1713
+ });
1714
+ autoFitDoneRef.current = true;
1715
+ return;
1716
+ }
1717
+ if (layout !== 'grid' || !gridContentBottom) return;
1446
1718
  if (gridContentBottom <= VIEWBOX_H) {
1447
1719
  autoFitDoneRef.current = true; // no overflow → no fit needed
1448
1720
  return;
@@ -1450,7 +1722,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1450
1722
  const fitZoom = Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1451
1723
  setView({ zoom: fitZoom, x: 0, y: 0 });
1452
1724
  autoFitDoneRef.current = true;
1453
- }, [layout, sessions.length, gridContentBottom, hadPersistedViewOnMount]);
1725
+ }, [layout, sessions.length, gridContentBottom, treeContentBox, hadPersistedViewOnMount]);
1454
1726
 
1455
1727
  // persist view
1456
1728
  useEffect(() => {
@@ -1594,13 +1866,27 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1594
1866
  // when the operator invokes fit-to-content via the hub click (R52),
1595
1867
  // chrome button, `f` key, or palette command.
1596
1868
  const fitView = useCallback(() => {
1869
+ setSmoothView(true);
1870
+ setTimeout(() => setSmoothView(false), 350);
1871
+ // iter5: tree fits-and-centres via its content bbox (sister to the
1872
+ // auto-fit effect); grid/ring keep the height-only fit-to-content.
1873
+ if (layout === 'tree' && treeContentBox && treeContentBox.w > 0 && treeContentBox.h > 0) {
1874
+ const b = treeContentBox;
1875
+ const pad = 24;
1876
+ const z = Math.max(ZOOM_MIN, Math.min(
1877
+ 1, (VIEWBOX_W - 2 * pad) / b.w, (VIEWBOX_H - 2 * pad) / b.h));
1878
+ setView({
1879
+ zoom: z,
1880
+ x: VIEWBOX_W / 2 - (b.x + b.w / 2) * z,
1881
+ y: VIEWBOX_H / 2 - (b.y + b.h / 2) * z,
1882
+ });
1883
+ return;
1884
+ }
1597
1885
  const zoom = !gridContentBottom || gridContentBottom <= VIEWBOX_H
1598
1886
  ? 1
1599
1887
  : Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1600
- setSmoothView(true);
1601
- setTimeout(() => setSmoothView(false), 350);
1602
1888
  setView({ zoom, x: 0, y: 0 });
1603
- }, [gridContentBottom]);
1889
+ }, [gridContentBottom, layout, treeContentBox]);
1604
1890
 
1605
1891
  // R74: listen for layout + view palette commands. Sister to R69's
1606
1892
  // pin listener — palette dispatches a CustomEvent, the reducer here
@@ -2394,11 +2680,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2394
2680
  data-topo-chrome-wrapper-halo-family="layout"
2395
2681
  >
2396
2682
  <button
2397
- onClick={() => { popChrome('layout-ring'); if (layout !== 'ring') toggleLayout(); }}
2683
+ onClick={() => { popChrome('layout-ring'); selectLayout('ring'); }}
2398
2684
  onMouseEnter={() => setHoveredLayout('ring')}
2399
2685
  onMouseLeave={() => setHoveredLayout((prev) => prev === 'ring' ? null : prev)}
2400
2686
  aria-pressed={layout === 'ring'}
2401
- title="Ring layout (l to toggle)"
2687
+ title="Ring layout (l to cycle)"
2402
2688
  data-topo-chrome-layout="ring"
2403
2689
  data-topo-chrome-layout-active={layout === 'ring' ? 'true' : 'false'}
2404
2690
  data-topo-chrome-layout-ring-popping={chromePopping === 'layout-ring' ? 'true' : 'false'}
@@ -2533,11 +2819,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2533
2819
  Ring
2534
2820
  </button>
2535
2821
  <button
2536
- onClick={() => { popChrome('layout-grid'); if (layout !== 'grid') toggleLayout(); }}
2822
+ onClick={() => { popChrome('layout-grid'); selectLayout('grid'); }}
2537
2823
  onMouseEnter={() => setHoveredLayout('grid')}
2538
2824
  onMouseLeave={() => setHoveredLayout((prev) => prev === 'grid' ? null : prev)}
2539
2825
  aria-pressed={layout === 'grid'}
2540
- title="Grid layout (l to toggle)"
2826
+ title="Grid layout (l to cycle)"
2541
2827
  data-topo-chrome-layout="grid"
2542
2828
  data-topo-chrome-layout-active={layout === 'grid' ? 'true' : 'false'}
2543
2829
  data-topo-chrome-layout-grid-popping={chromePopping === 'layout-grid' ? 'true' : 'false'}
@@ -2585,6 +2871,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2585
2871
  >
2586
2872
  Grid
2587
2873
  </button>
2874
+ {/* #170 tree-view MVP — third segment in the layout control.
2875
+ Org-chart view: 总指挥/副指挥 root → teams → members.
2876
+ Mirrors the Grid button's border-l divider + active/
2877
+ inactive paint vocabulary; the heavier R-series polish
2878
+ (halo layers, hover-fw preview attrs) is deferred to the
2879
+ RFC-017 formal version. */}
2880
+ <button
2881
+ onClick={() => { popChrome('layout-tree'); selectLayout('tree'); }}
2882
+ onMouseEnter={() => setHoveredLayout('tree')}
2883
+ onMouseLeave={() => setHoveredLayout((prev) => prev === 'tree' ? null : prev)}
2884
+ aria-pressed={layout === 'tree'}
2885
+ title="Tree layout — org chart (l to cycle)"
2886
+ data-topo-chrome-layout="tree"
2887
+ data-topo-chrome-layout-active={layout === 'tree' ? 'true' : 'false'}
2888
+ data-topo-chrome-layout-tree-popping={chromePopping === 'layout-tree' ? 'true' : 'false'}
2889
+ className={`px-2.5 py-1 border-l focus:outline-none focus-visible:ring-1 focus-visible:ring-cyan-400/60 focus-visible:ring-inset hover:tracking-wide hover:brightness-[1.15] active:scale-95 transform-gpu ${layout === 'tree' ? 'bg-cyan-500/15 text-cyan-300 font-medium hover:bg-cyan-500/20 hover:text-cyan-200 active:bg-cyan-500/25' : 'text-gray-400 hover:text-cyan-300 hover:bg-cyan-500/5 active:bg-cyan-500/15 hover:font-medium'} ${chromePopping === 'layout-tree' ? ' anet-chrome-pop' : ''}`}
2890
+ style={{
2891
+ borderColor: pal.containerBorder,
2892
+ transition: 'background-color 150ms ease, color 150ms ease, border-color 200ms ease-out, letter-spacing 200ms ease-out, transform 150ms ease-out, font-weight 150ms ease, filter 150ms ease',
2893
+ filter: hoveredLayout === 'tree' ? `drop-shadow(0 0 2px ${pal.legendAccent}80) drop-shadow(0 0 4px ${pal.legendAccent}40) brightness(1.15)` : undefined,
2894
+ }}
2895
+ >
2896
+ Tree
2897
+ </button>
2588
2898
  </div>
2589
2899
  {/* R79: working + online count chips become hover affordances —
2590
2900
  extends R77's chip-hover pattern to status counts. Hover the
@@ -3506,7 +3816,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3506
3816
  // unknowns folded to '?').
3507
3817
  const matchAliases = [...onlineNodes, ...offlineNodes]
3508
3818
  .filter(s => {
3509
- const v = vendorForModel(s.model);
3819
+ const v = vendorForModel(s.model, s.runtime);
3510
3820
  return (v.id === 'unknown' ? '?' : v.initial) === pinnedVendor;
3511
3821
  })
3512
3822
  .map(s => s.alias);
@@ -3776,7 +4086,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3776
4086
  if (gk !== pinnedGroup) return false;
3777
4087
  }
3778
4088
  if (pinnedVendor) {
3779
- const v = vendorForModel(s.model);
4089
+ const v = vendorForModel(s.model, s.runtime);
3780
4090
  const initial = v.id === 'unknown' ? '?' : v.initial;
3781
4091
  if (initial !== pinnedVendor) return false;
3782
4092
  }
@@ -3974,7 +4284,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3974
4284
  // UI shows "A:3" should hover-explain which 3.
3975
4285
  const aliases = [...onlineNodes, ...offlineNodes]
3976
4286
  .filter(s => {
3977
- const vid = vendorForModel(s.model);
4287
+ const vid = vendorForModel(s.model, s.runtime);
3978
4288
  return (vid.id === 'unknown' ? '?' : vid.initial) === v.initial;
3979
4289
  })
3980
4290
  .map(s => s.alias);
@@ -6400,6 +6710,70 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6400
6710
  });
6401
6711
  })()}
6402
6712
 
6713
+ {/* #170 tree-view MVP — org-chart elbow connectors. Tree layout
6714
+ only (treeConnectors.lines is empty in ring/grid). Rendered
6715
+ here, behind the nodes + labels, as right-angle polylines:
6716
+ vertical drop from the parent, horizontal at the mid-y, then
6717
+ vertical into the child. pointer-events off so they never
6718
+ intercept a node click. The synthetic-node chips (指挥中心
6719
+ root / 未分组 bucket) render here too — they have no backing
6720
+ session so the node-map below skips them. */}
6721
+ {layout === 'tree' && treeConnectors.lines.map((ln, i) => {
6722
+ const midY = (ln.y1 + ln.y2) / 2;
6723
+ // drop from just below the parent ring to just above the child
6724
+ // ring — keeps the connector clear of the avatars themselves.
6725
+ const startY = ln.y1;
6726
+ const endY = ln.y2;
6727
+ const d = `M ${ln.x1} ${startY} L ${ln.x1} ${midY} L ${ln.x2} ${midY} L ${ln.x2} ${endY}`;
6728
+ return (
6729
+ <path
6730
+ key={`tree-conn-${i}`}
6731
+ d={d}
6732
+ fill="none"
6733
+ stroke={pal.containerBorder}
6734
+ strokeWidth={1.5}
6735
+ strokeLinejoin="round"
6736
+ strokeLinecap="round"
6737
+ opacity={0.55}
6738
+ style={{ pointerEvents: 'none' }}
6739
+ />
6740
+ );
6741
+ })}
6742
+ {layout === 'tree' && treeConnectors.synthLabels.map((s, i) => {
6743
+ // synthetic node chip: a small pill with a centred label, drawn
6744
+ // at the synthetic node's slot. No status ring → never counted
6745
+ // by the node↔node overlap test; sized to clear neighbours.
6746
+ const chipW = Math.max(58, s.label.length * 13 + 20);
6747
+ const chipH = 26;
6748
+ return (
6749
+ <g key={`tree-synth-${i}`} style={{ pointerEvents: 'none' }} data-topo-tree-synth={s.label}>
6750
+ <rect
6751
+ x={s.x - chipW / 2}
6752
+ y={s.y - chipH / 2}
6753
+ width={chipW}
6754
+ height={chipH}
6755
+ rx={13}
6756
+ fill={pal.containerBg}
6757
+ stroke={pal.containerBorder}
6758
+ strokeWidth={1.25}
6759
+ strokeDasharray="3 3"
6760
+ opacity={0.92}
6761
+ />
6762
+ <text
6763
+ x={s.x}
6764
+ y={s.y}
6765
+ textAnchor="middle"
6766
+ dominantBaseline="central"
6767
+ fontSize={12}
6768
+ fontWeight={600}
6769
+ fill={pal.legendAccent}
6770
+ >
6771
+ {s.label}
6772
+ </text>
6773
+ </g>
6774
+ );
6775
+ })}
6776
+
6403
6777
  {/* #111: prefix-group boundary boxes (Vincent 4722). Grid layout
6404
6778
  only — groupBoxes is empty in ring mode. Rendered behind the
6405
6779
  flow links + nodes; pointer-events off so they never intercept
@@ -10105,7 +10479,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10105
10479
  opacity: hoveredEdgeEndpoints && !hoveredEdgeEndpoints.has(session.alias) && chatAlias !== session.alias
10106
10480
  ? 0.28
10107
10481
  : activeVendor && chatAlias !== session.alias && (() => {
10108
- const v = vendorForModel(session.model);
10482
+ const v = vendorForModel(session.model, session.runtime);
10109
10483
  const initial = v.id === 'unknown' ? '?' : v.initial;
10110
10484
  return initial !== activeVendor;
10111
10485
  })()
@@ -11370,7 +11744,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11370
11744
  {(() => {
11371
11745
  const ar = Math.round((isOnline ? 14 : 10) * nodeScale);
11372
11746
  const size = radius * 2;
11373
- const vendor = vendorForModel(session.model);
11747
+ const vendor = vendorForModel(session.model, session.runtime);
11374
11748
  const internByAlias = /书生|书小生|intern/i.test(session.alias);
11375
11749
 
11376
11750
  if (isIntern || internByAlias || vendor.logo) {
@@ -12624,7 +12998,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
12624
12998
  gate as showFullLabel; dense fleets already have
12625
12999
  too much per-node chrome competing. */}
12626
13000
  {!reducedMotion && hoveredAlias === session.alias && !denseLayout && (() => {
12627
- const v = vendorForModel(session.model);
13001
+ const v = vendorForModel(session.model, session.runtime);
12628
13002
  const rt = runtimeIdentity(session.runtime);
12629
13003
  const flipLeft = pos.x > VIEWBOX_W * 0.65;
12630
13004
  const detailW = 192;