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

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/{0p142v5va508~.js → 0-vlr8~b0f-cx.js} +1 -1
  132. package/.next/static/chunks/{11sahbo6ikg8g.js → 08.pwokcpknmp.js} +1 -1
  133. package/.next/static/chunks/{0u8v68gl7g6j1.js → 0nd-y~i5proep.js} +1 -1
  134. package/.next/static/chunks/0ztakmtfxkgya.js +4 -0
  135. package/.next/trace +2 -2
  136. package/.next/trace-build +1 -1
  137. package/app/components/TopoGraph.tsx +446 -22
  138. package/app/lib/vendorIdentity.ts +74 -56
  139. package/package.json +4 -4
  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 → CLsgsYpGMRUiIcJJK3xkR}/_buildManifest.js +0 -0
  147. /package/.next/static/{wz1T-LhLDalz691PpN3E7 → CLsgsYpGMRUiIcJJK3xkR}/_clientMiddlewareManifest.js +0 -0
  148. /package/.next/static/{wz1T-LhLDalz691PpN3E7 → CLsgsYpGMRUiIcJJK3xkR}/_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,269 @@ 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
+ // ---- box-based org layout (Vincent #170 feedback) ----------------
1028
+ // Each team is a rectangular BOX (reusing the grid-mode cluster-box
1029
+ // visual via groupBoxes); its members pack into a compact ~√n grid
1030
+ // INSIDE the box rather than one wide row — an 8-person team is a
1031
+ // ~3×3 block, not a 1×8 strip. Boxes sit side-by-side at layer 2;
1032
+ // 副指挥 (layer 1) centre over their contiguous box-run; the root
1033
+ // (layer 0) centres over the 副指挥. Keeps the whole chart compact
1034
+ // and screenshot-friendly even at dozens of nodes.
1035
+ const nodeR = Math.round(26 * nodeScale);
1036
+ const cellW = Math.max(108, 2 * nodeR + 56); // member grid cell — fits a label card
1037
+ const cellH = Math.max(96, 2 * nodeR + 48); // + label drop room below the node
1038
+ const BOX_PAD = 16; // box inner padding
1039
+ const BOX_LABEL = 26; // box top label band
1040
+ const BOX_GAP = 48; // gap between sibling boxes
1041
+ // iter5 (Vincent UX — tree presentation): the two header layers
1042
+ // (总指挥 / 副指挥) hold single small nodes; the iter-2 ROW=116 gap
1043
+ // gave them ~362px of pure header before the first team box. HEAD_ROW
1044
+ // tightens those two layer gaps — still clears node radius + label
1045
+ // drop + connector elbow (needs >72px; 88 leaves a safe margin) but
1046
+ // trims header bloat so the box-layout auto-fit lands at a larger
1047
+ // zoom. The team-box rows below keep their own spacing untouched.
1048
+ const HEAD_ROW = Math.max(88, nodeR + 62); // header layer gap (iter5)
1049
+ const TOP = 130; // y of layer-0 root (clears corner panels)
1050
+ const LEFT = 70;
1051
+
1052
+ // a "box unit" = a team (or the 未分组 bucket) drawn as a grid box.
1053
+ type BoxUnit = {
1054
+ key: string; isOrphan: boolean;
1055
+ members: Session[]; // grid order: lead, deputy, …
1056
+ cols: number; rows: number; w: number; h: number;
1057
+ x: number; y: number; // top-left, filled below
1058
+ };
1059
+ const mkBox = (key: string, isOrphan: boolean, members: Session[]): BoxUnit => {
1060
+ const n = Math.max(1, members.length);
1061
+ const cols = Math.ceil(Math.sqrt(n));
1062
+ const rows = Math.ceil(n / cols);
1063
+ return {
1064
+ key, isOrphan, members, cols, rows,
1065
+ w: cols * cellW + 2 * BOX_PAD,
1066
+ h: rows * cellH + 2 * BOX_PAD + BOX_LABEL,
1067
+ x: 0, y: 0,
1068
+ };
1069
+ };
1070
+ const boxes: BoxUnit[] = teamTrees.map(tt =>
1071
+ mkBox(tt.key, false, [tt.lead, ...(tt.deputy ? [tt.deputy] : []), ...tt.members]));
1072
+ // orphan bucket — every underivable agent in its own 未分组 box so
1073
+ // none are ever dropped from the chart.
1074
+ if (orphans.length > 0) {
1075
+ boxes.push(mkBox('未分组', true,
1076
+ [...orphans].sort((a, b) => a.alias.localeCompare(b.alias))));
1077
+ }
1078
+
1079
+ // lay boxes into a 2D wrapped grid (Vincent iter 3: team boxes need
1080
+ // not sit in one long row — wrap into rows so the chart stays
1081
+ // compact / screenshot-friendly even with many teams).
1082
+ const layer2Y = TOP + 2 * HEAD_ROW;
1083
+ const BOX_ROW_GAP = 40; // vertical gap between box rows
1084
+ const ROW_W_TARGET = 1240; // wrap once a row would exceed this
1085
+ let rowX = LEFT, rowY = layer2Y, rowMaxH = 0, rowCount = 0;
1086
+ let lastRowBottom = layer2Y;
1087
+ for (const b of boxes) {
1088
+ if (rowCount > 0 && rowX + b.w > LEFT + ROW_W_TARGET) {
1089
+ rowY += rowMaxH + BOX_ROW_GAP; // wrap to next box row
1090
+ rowX = LEFT; rowMaxH = 0; rowCount = 0;
1091
+ }
1092
+ b.x = rowX; b.y = rowY;
1093
+ rowX += b.w + BOX_GAP;
1094
+ rowMaxH = Math.max(rowMaxH, b.h);
1095
+ rowCount++;
1096
+ lastRowBottom = rowY + rowMaxH;
1097
+ }
1098
+ // member positions — compact grid inside each box.
1099
+ for (const b of boxes) {
1100
+ b.members.forEach((s, i) => {
1101
+ const col = i % b.cols;
1102
+ const row = Math.floor(i / b.cols);
1103
+ positions[s.alias] = {
1104
+ x: b.x + BOX_PAD + (col + 0.5) * cellW,
1105
+ y: b.y + BOX_LABEL + BOX_PAD + (row + 0.5) * cellH,
1106
+ };
1107
+ });
1108
+ }
1109
+ const boxCentreX = (b: BoxUnit) => b.x + b.w / 2;
1110
+
1111
+ // layer 1: 副指挥, each centred over a contiguous run of boxes.
1112
+ const layer1Y = TOP + HEAD_ROW;
1113
+ const depNodes: { alias: string; x: number; y: number }[] = [];
1114
+ const depPer = deputyCommanders.length > 0
1115
+ ? Math.ceil(Math.max(boxes.length, 1) / deputyCommanders.length)
1116
+ : 0;
1117
+ deputyCommanders.forEach((s, di) => {
1118
+ const run = boxes.slice(di * depPer, di * depPer + depPer);
1119
+ const xs = run.length ? run.map(boxCentreX)
1120
+ : [LEFT + di * (cellW + BOX_GAP) + cellW / 2];
1121
+ const x = (Math.min(...xs) + Math.max(...xs)) / 2;
1122
+ depNodes.push({ alias: s.alias, x, y: layer1Y });
1123
+ positions[s.alias] = { x, y: layer1Y };
1124
+ });
1125
+
1126
+ // layer 0: root — single 总指挥, else a synthetic 指挥中心.
1127
+ const anchorXs = depNodes.length > 0 ? depNodes.map(d => d.x)
1128
+ : boxes.length > 0 ? boxes.map(boxCentreX)
1129
+ : [LEFT + cellW / 2];
1130
+ const rootX = (Math.min(...anchorXs) + Math.max(...anchorXs)) / 2;
1131
+ let synthRoot = false;
1132
+ if (commanders.length === 1) {
1133
+ positions[commanders[0].alias] = { x: rootX, y: TOP };
1134
+ } else {
1135
+ synthRoot = true;
1136
+ // any 总指挥 (0, or >1) spread along layer 0 beside the synth anchor
1137
+ commanders.forEach((c, ci) => {
1138
+ positions[c.alias] = {
1139
+ x: rootX + (ci - (commanders.length - 1) / 2) * (cellW + 28),
1140
+ y: TOP,
1141
+ };
1142
+ });
1143
+ }
1144
+
1145
+ // elbow connectors: root → 副指挥 → team-box top edge (render draws
1146
+ // the right-angle path behind the nodes).
1147
+ const connectors: TreeConnectorLine[] = [];
1148
+ for (const d of depNodes) {
1149
+ connectors.push({ x1: rootX, y1: TOP, x2: d.x, y2: d.y });
1150
+ }
1151
+ boxes.forEach((b, bi) => {
1152
+ let px = rootX, py = TOP;
1153
+ if (depNodes.length > 0) {
1154
+ const di = Math.min(depNodes.length - 1, Math.floor(bi / Math.max(depPer, 1)));
1155
+ px = depNodes[di].x; py = depNodes[di].y;
1156
+ }
1157
+ connectors.push({ x1: px, y1: py, x2: boxCentreX(b), y2: b.y });
1158
+ });
1159
+
1160
+ // synthetic root label (only when there is no single 总指挥)
1161
+ const synthBoxes = synthRoot ? [{ x: rootX, y: TOP, label: '指挥中心' }] : [];
1162
+
1163
+ // team boxes surfaced as groupBoxes — reuses the grid-mode cluster
1164
+ // box render + per-box working/idle/offline pip strip.
1165
+ const teamGroupBoxes = boxes.map(b => {
1166
+ let w = 0, i = 0, o = 0;
1167
+ for (const s of b.members) {
1168
+ const isOn = s.status !== 'offline' || !!sseCount(s);
1169
+ if (s.status === 'working') w++;
1170
+ else if (isOn) i++;
1171
+ else o++;
1172
+ }
1173
+ return {
1174
+ key: b.key, isOrphan: b.isOrphan, count: b.members.length,
1175
+ statuses: { working: w, idle: i, offline: o },
1176
+ x: b.x, y: b.y, w: b.w, h: b.h,
1177
+ };
1178
+ });
1179
+
1180
+ // tree mode is a clean org chart (Vincent iter 4): message flow-links
1181
+ // between nodes in different team boxes tangle as long curves across
1182
+ // the hierarchy and bury the structure. Suppress them in tree — the
1183
+ // right-angle report connectors (treeConnectors) carry the org lines.
1184
+
1185
+ // content bottom for auto-fit zoom — bottom of the LAST box row.
1186
+ // Boxes wrap into multiple rows (iter 3), so this must track the
1187
+ // wrapped lastRowBottom, not layer2Y + tallest-box.
1188
+ const treeBottom = boxes.length ? lastRowBottom : TOP + HEAD_ROW;
1189
+ const gridContentBottom = treeBottom + 48;
1190
+
1191
+ // iter5 (Vincent UX): full content bounding box over every team box
1192
+ // + every node. The auto-fit effect uses this to scale the org chart
1193
+ // to fit BOTH viewBox dimensions AND centre it. Pre-iter5 auto-fit
1194
+ // only fit height and left-anchored the chart (LEFT=70), so the tree
1195
+ // hugged the top-left corner with dead canvas down the right side.
1196
+ const treeBX: number[] = [], treeBY: number[] = [];
1197
+ for (const b of boxes) { treeBX.push(b.x, b.x + b.w); treeBY.push(b.y, b.y + b.h); }
1198
+ for (const a of Object.keys(positions)) { treeBX.push(positions[a].x); treeBY.push(positions[a].y); }
1199
+ // TREE_M pads the raw box/node extent so node radii + label drops
1200
+ // (root/副指挥 sit above the boxes) never clip at the fitted edge.
1201
+ const TREE_M = 52;
1202
+ const treeContentBox: { x: number; y: number; w: number; h: number } | null = treeBX.length
1203
+ ? {
1204
+ x: Math.min(...treeBX) - TREE_M,
1205
+ y: Math.min(...treeBY) - TREE_M,
1206
+ w: Math.max(...treeBX) - Math.min(...treeBX) + 2 * TREE_M,
1207
+ h: Math.max(...treeBY) - Math.min(...treeBY) + 2 * TREE_M,
1208
+ }
1209
+ : null;
1210
+
1211
+ return {
1212
+ onlineNodes: online,
1213
+ offlineNodes: offline,
1214
+ nodePositions: positions,
1215
+ flowLinks: [],
1216
+ activeAliases: new Set<string>(),
1217
+ groupKeys,
1218
+ groupBoxes: teamGroupBoxes,
1219
+ gridContentBottom,
1220
+ treeConnectors: { lines: connectors, synthLabels: synthBoxes, synthRoot } as TreeLayout,
1221
+ treeContentBox,
925
1222
  };
926
1223
  }
927
1224
 
@@ -1006,6 +1303,8 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1006
1303
  groupBoxes: [] as { key: string; isOrphan?: boolean; count: number; statuses: { working: number; idle: number; offline: number }; x: number; y: number; w: number; h: number }[],
1007
1304
  // ring fits within VIEWBOX_H by construction (offlineRadius=325 + centre at y=330)
1008
1305
  gridContentBottom: 0,
1306
+ treeConnectors: { lines: [], synthLabels: [], synthRoot: false } as TreeLayout,
1307
+ treeContentBox: null as { x: number; y: number; w: number; h: number } | null,
1009
1308
  };
1010
1309
  }, [messages, sessions, sseSessions, layout, nodeScale]);
1011
1310
 
@@ -1026,7 +1325,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1026
1325
  const vendorDist = useMemo(() => {
1027
1326
  const tally = new Map<string, { initial: string; count: number; color: string }>();
1028
1327
  for (const s of [...onlineNodes, ...offlineNodes]) {
1029
- const v = vendorForModel(s.model);
1328
+ const v = vendorForModel(s.model, s.runtime);
1030
1329
  const key = v.id === 'unknown' ? '?' : v.initial;
1031
1330
  const cur = tally.get(key);
1032
1331
  if (cur) cur.count++;
@@ -1175,7 +1474,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1175
1474
  // a single-axis paint lift. Post-R674 inline filter applies the
1176
1475
  // same 2+4 stride at pal.legendAccent tint as R667/R668 chrome-
1177
1476
  // control siblings.
1178
- const [hoveredLayout, setHoveredLayout] = useState<'ring' | 'grid' | null>(null);
1477
+ const [hoveredLayout, setHoveredLayout] = useState<'ring' | 'grid' | 'tree' | null>(null);
1179
1478
  // R675 — hover state for the chrome nodeSize S/M/L segmented trio.
1180
1479
  // Value-typed (single state covers all three buttons) drives the
1181
1480
  // multi-layer halo filter completing the chrome strip's nodeSize
@@ -1328,7 +1627,10 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1328
1627
  // {zoom,x,y} persists to localStorage (same sticky pattern as brand flag).
1329
1628
  const VIEWBOX_W = 1000;
1330
1629
  const VIEWBOX_H = 680;
1331
- const ZOOM_MIN = 0.5;
1630
+ // tree mode can render a tall multi-row org chart — it needs a lower
1631
+ // zoom-out floor than ring/grid so the whole tree can fit (Vincent iter
1632
+ // 3: "缩放最多 50%" couldn't shrink the tree enough).
1633
+ const ZOOM_MIN = layout === 'tree' ? 0.3 : 0.5;
1332
1634
  const ZOOM_MAX = 4;
1333
1635
  const containerRef = useRef<HTMLDivElement>(null);
1334
1636
  const svgRef = useRef<SVGSVGElement>(null);
@@ -1368,7 +1670,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1368
1670
  // keeps its own R184 rotation animation (different gesture, semantic).
1369
1671
  // Node-size S/M/L keep their R171 layoutSwitching crossfade (already
1370
1672
  // 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';
1673
+ type ChromePop = 'zoom-in' | 'zoom-out' | 'layout-ring' | 'layout-grid' | 'layout-tree' | 'fullscreen' | 'size-S' | 'size-M' | 'size-L';
1372
1674
  const [chromePopping, setChromePopping] = useState<ChromePop | null>(null);
1373
1675
  const popChrome = (which: ChromePop) => {
1374
1676
  setChromePopping(which);
@@ -1442,7 +1744,27 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1442
1744
  autoFitDoneRef.current = true;
1443
1745
  return;
1444
1746
  }
1445
- if (layout !== 'grid' || sessions.length === 0 || !gridContentBottom) return;
1747
+ if (sessions.length === 0) return;
1748
+ // iter5 (Vincent UX): tree auto-fit scales the org chart to fit BOTH
1749
+ // viewBox dimensions and CENTRES it. Pre-iter5 the shared grid path
1750
+ // only fit height and left-anchored the chart, so the tree hugged the
1751
+ // top-left corner with dead canvas down the right side.
1752
+ if (layout === 'tree') {
1753
+ if (!treeContentBox) return;
1754
+ const b = treeContentBox;
1755
+ if (b.w <= 0 || b.h <= 0) { autoFitDoneRef.current = true; return; }
1756
+ const pad = 24;
1757
+ const z = Math.max(ZOOM_MIN, Math.min(
1758
+ 1, (VIEWBOX_W - 2 * pad) / b.w, (VIEWBOX_H - 2 * pad) / b.h));
1759
+ setView({
1760
+ zoom: z,
1761
+ x: VIEWBOX_W / 2 - (b.x + b.w / 2) * z,
1762
+ y: VIEWBOX_H / 2 - (b.y + b.h / 2) * z,
1763
+ });
1764
+ autoFitDoneRef.current = true;
1765
+ return;
1766
+ }
1767
+ if (layout !== 'grid' || !gridContentBottom) return;
1446
1768
  if (gridContentBottom <= VIEWBOX_H) {
1447
1769
  autoFitDoneRef.current = true; // no overflow → no fit needed
1448
1770
  return;
@@ -1450,7 +1772,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1450
1772
  const fitZoom = Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1451
1773
  setView({ zoom: fitZoom, x: 0, y: 0 });
1452
1774
  autoFitDoneRef.current = true;
1453
- }, [layout, sessions.length, gridContentBottom, hadPersistedViewOnMount]);
1775
+ }, [layout, sessions.length, gridContentBottom, treeContentBox, hadPersistedViewOnMount]);
1454
1776
 
1455
1777
  // persist view
1456
1778
  useEffect(() => {
@@ -1594,13 +1916,27 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
1594
1916
  // when the operator invokes fit-to-content via the hub click (R52),
1595
1917
  // chrome button, `f` key, or palette command.
1596
1918
  const fitView = useCallback(() => {
1919
+ setSmoothView(true);
1920
+ setTimeout(() => setSmoothView(false), 350);
1921
+ // iter5: tree fits-and-centres via its content bbox (sister to the
1922
+ // auto-fit effect); grid/ring keep the height-only fit-to-content.
1923
+ if (layout === 'tree' && treeContentBox && treeContentBox.w > 0 && treeContentBox.h > 0) {
1924
+ const b = treeContentBox;
1925
+ const pad = 24;
1926
+ const z = Math.max(ZOOM_MIN, Math.min(
1927
+ 1, (VIEWBOX_W - 2 * pad) / b.w, (VIEWBOX_H - 2 * pad) / b.h));
1928
+ setView({
1929
+ zoom: z,
1930
+ x: VIEWBOX_W / 2 - (b.x + b.w / 2) * z,
1931
+ y: VIEWBOX_H / 2 - (b.y + b.h / 2) * z,
1932
+ });
1933
+ return;
1934
+ }
1597
1935
  const zoom = !gridContentBottom || gridContentBottom <= VIEWBOX_H
1598
1936
  ? 1
1599
1937
  : Math.max(ZOOM_MIN, Math.min(1, VIEWBOX_H / gridContentBottom));
1600
- setSmoothView(true);
1601
- setTimeout(() => setSmoothView(false), 350);
1602
1938
  setView({ zoom, x: 0, y: 0 });
1603
- }, [gridContentBottom]);
1939
+ }, [gridContentBottom, layout, treeContentBox]);
1604
1940
 
1605
1941
  // R74: listen for layout + view palette commands. Sister to R69's
1606
1942
  // pin listener — palette dispatches a CustomEvent, the reducer here
@@ -2394,11 +2730,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2394
2730
  data-topo-chrome-wrapper-halo-family="layout"
2395
2731
  >
2396
2732
  <button
2397
- onClick={() => { popChrome('layout-ring'); if (layout !== 'ring') toggleLayout(); }}
2733
+ onClick={() => { popChrome('layout-ring'); selectLayout('ring'); }}
2398
2734
  onMouseEnter={() => setHoveredLayout('ring')}
2399
2735
  onMouseLeave={() => setHoveredLayout((prev) => prev === 'ring' ? null : prev)}
2400
2736
  aria-pressed={layout === 'ring'}
2401
- title="Ring layout (l to toggle)"
2737
+ title="Ring layout (l to cycle)"
2402
2738
  data-topo-chrome-layout="ring"
2403
2739
  data-topo-chrome-layout-active={layout === 'ring' ? 'true' : 'false'}
2404
2740
  data-topo-chrome-layout-ring-popping={chromePopping === 'layout-ring' ? 'true' : 'false'}
@@ -2533,11 +2869,11 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2533
2869
  Ring
2534
2870
  </button>
2535
2871
  <button
2536
- onClick={() => { popChrome('layout-grid'); if (layout !== 'grid') toggleLayout(); }}
2872
+ onClick={() => { popChrome('layout-grid'); selectLayout('grid'); }}
2537
2873
  onMouseEnter={() => setHoveredLayout('grid')}
2538
2874
  onMouseLeave={() => setHoveredLayout((prev) => prev === 'grid' ? null : prev)}
2539
2875
  aria-pressed={layout === 'grid'}
2540
- title="Grid layout (l to toggle)"
2876
+ title="Grid layout (l to cycle)"
2541
2877
  data-topo-chrome-layout="grid"
2542
2878
  data-topo-chrome-layout-active={layout === 'grid' ? 'true' : 'false'}
2543
2879
  data-topo-chrome-layout-grid-popping={chromePopping === 'layout-grid' ? 'true' : 'false'}
@@ -2585,6 +2921,30 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
2585
2921
  >
2586
2922
  Grid
2587
2923
  </button>
2924
+ {/* #170 tree-view MVP — third segment in the layout control.
2925
+ Org-chart view: 总指挥/副指挥 root → teams → members.
2926
+ Mirrors the Grid button's border-l divider + active/
2927
+ inactive paint vocabulary; the heavier R-series polish
2928
+ (halo layers, hover-fw preview attrs) is deferred to the
2929
+ RFC-017 formal version. */}
2930
+ <button
2931
+ onClick={() => { popChrome('layout-tree'); selectLayout('tree'); }}
2932
+ onMouseEnter={() => setHoveredLayout('tree')}
2933
+ onMouseLeave={() => setHoveredLayout((prev) => prev === 'tree' ? null : prev)}
2934
+ aria-pressed={layout === 'tree'}
2935
+ title="Tree layout — org chart (l to cycle)"
2936
+ data-topo-chrome-layout="tree"
2937
+ data-topo-chrome-layout-active={layout === 'tree' ? 'true' : 'false'}
2938
+ data-topo-chrome-layout-tree-popping={chromePopping === 'layout-tree' ? 'true' : 'false'}
2939
+ 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' : ''}`}
2940
+ style={{
2941
+ borderColor: pal.containerBorder,
2942
+ 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',
2943
+ filter: hoveredLayout === 'tree' ? `drop-shadow(0 0 2px ${pal.legendAccent}80) drop-shadow(0 0 4px ${pal.legendAccent}40) brightness(1.15)` : undefined,
2944
+ }}
2945
+ >
2946
+ Tree
2947
+ </button>
2588
2948
  </div>
2589
2949
  {/* R79: working + online count chips become hover affordances —
2590
2950
  extends R77's chip-hover pattern to status counts. Hover the
@@ -3506,7 +3866,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3506
3866
  // unknowns folded to '?').
3507
3867
  const matchAliases = [...onlineNodes, ...offlineNodes]
3508
3868
  .filter(s => {
3509
- const v = vendorForModel(s.model);
3869
+ const v = vendorForModel(s.model, s.runtime);
3510
3870
  return (v.id === 'unknown' ? '?' : v.initial) === pinnedVendor;
3511
3871
  })
3512
3872
  .map(s => s.alias);
@@ -3776,7 +4136,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3776
4136
  if (gk !== pinnedGroup) return false;
3777
4137
  }
3778
4138
  if (pinnedVendor) {
3779
- const v = vendorForModel(s.model);
4139
+ const v = vendorForModel(s.model, s.runtime);
3780
4140
  const initial = v.id === 'unknown' ? '?' : v.initial;
3781
4141
  if (initial !== pinnedVendor) return false;
3782
4142
  }
@@ -3974,7 +4334,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
3974
4334
  // UI shows "A:3" should hover-explain which 3.
3975
4335
  const aliases = [...onlineNodes, ...offlineNodes]
3976
4336
  .filter(s => {
3977
- const vid = vendorForModel(s.model);
4337
+ const vid = vendorForModel(s.model, s.runtime);
3978
4338
  return (vid.id === 'unknown' ? '?' : vid.initial) === v.initial;
3979
4339
  })
3980
4340
  .map(s => s.alias);
@@ -6400,6 +6760,70 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
6400
6760
  });
6401
6761
  })()}
6402
6762
 
6763
+ {/* #170 tree-view MVP — org-chart elbow connectors. Tree layout
6764
+ only (treeConnectors.lines is empty in ring/grid). Rendered
6765
+ here, behind the nodes + labels, as right-angle polylines:
6766
+ vertical drop from the parent, horizontal at the mid-y, then
6767
+ vertical into the child. pointer-events off so they never
6768
+ intercept a node click. The synthetic-node chips (指挥中心
6769
+ root / 未分组 bucket) render here too — they have no backing
6770
+ session so the node-map below skips them. */}
6771
+ {layout === 'tree' && treeConnectors.lines.map((ln, i) => {
6772
+ const midY = (ln.y1 + ln.y2) / 2;
6773
+ // drop from just below the parent ring to just above the child
6774
+ // ring — keeps the connector clear of the avatars themselves.
6775
+ const startY = ln.y1;
6776
+ const endY = ln.y2;
6777
+ const d = `M ${ln.x1} ${startY} L ${ln.x1} ${midY} L ${ln.x2} ${midY} L ${ln.x2} ${endY}`;
6778
+ return (
6779
+ <path
6780
+ key={`tree-conn-${i}`}
6781
+ d={d}
6782
+ fill="none"
6783
+ stroke={pal.containerBorder}
6784
+ strokeWidth={1.5}
6785
+ strokeLinejoin="round"
6786
+ strokeLinecap="round"
6787
+ opacity={0.55}
6788
+ style={{ pointerEvents: 'none' }}
6789
+ />
6790
+ );
6791
+ })}
6792
+ {layout === 'tree' && treeConnectors.synthLabels.map((s, i) => {
6793
+ // synthetic node chip: a small pill with a centred label, drawn
6794
+ // at the synthetic node's slot. No status ring → never counted
6795
+ // by the node↔node overlap test; sized to clear neighbours.
6796
+ const chipW = Math.max(58, s.label.length * 13 + 20);
6797
+ const chipH = 26;
6798
+ return (
6799
+ <g key={`tree-synth-${i}`} style={{ pointerEvents: 'none' }} data-topo-tree-synth={s.label}>
6800
+ <rect
6801
+ x={s.x - chipW / 2}
6802
+ y={s.y - chipH / 2}
6803
+ width={chipW}
6804
+ height={chipH}
6805
+ rx={13}
6806
+ fill={pal.containerBg}
6807
+ stroke={pal.containerBorder}
6808
+ strokeWidth={1.25}
6809
+ strokeDasharray="3 3"
6810
+ opacity={0.92}
6811
+ />
6812
+ <text
6813
+ x={s.x}
6814
+ y={s.y}
6815
+ textAnchor="middle"
6816
+ dominantBaseline="central"
6817
+ fontSize={12}
6818
+ fontWeight={600}
6819
+ fill={pal.legendAccent}
6820
+ >
6821
+ {s.label}
6822
+ </text>
6823
+ </g>
6824
+ );
6825
+ })}
6826
+
6403
6827
  {/* #111: prefix-group boundary boxes (Vincent 4722). Grid layout
6404
6828
  only — groupBoxes is empty in ring mode. Rendered behind the
6405
6829
  flow links + nodes; pointer-events off so they never intercept
@@ -10105,7 +10529,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
10105
10529
  opacity: hoveredEdgeEndpoints && !hoveredEdgeEndpoints.has(session.alias) && chatAlias !== session.alias
10106
10530
  ? 0.28
10107
10531
  : activeVendor && chatAlias !== session.alias && (() => {
10108
- const v = vendorForModel(session.model);
10532
+ const v = vendorForModel(session.model, session.runtime);
10109
10533
  const initial = v.id === 'unknown' ? '?' : v.initial;
10110
10534
  return initial !== activeVendor;
10111
10535
  })()
@@ -11370,7 +11794,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
11370
11794
  {(() => {
11371
11795
  const ar = Math.round((isOnline ? 14 : 10) * nodeScale);
11372
11796
  const size = radius * 2;
11373
- const vendor = vendorForModel(session.model);
11797
+ const vendor = vendorForModel(session.model, session.runtime);
11374
11798
  const internByAlias = /书生|书小生|intern/i.test(session.alias);
11375
11799
 
11376
11800
  if (isIntern || internByAlias || vendor.logo) {
@@ -12624,7 +13048,7 @@ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProp
12624
13048
  gate as showFullLabel; dense fleets already have
12625
13049
  too much per-node chrome competing. */}
12626
13050
  {!reducedMotion && hoveredAlias === session.alias && !denseLayout && (() => {
12627
- const v = vendorForModel(session.model);
13051
+ const v = vendorForModel(session.model, session.runtime);
12628
13052
  const rt = runtimeIdentity(session.runtime);
12629
13053
  const flipLeft = pos.x > VIEWBOX_W * 0.65;
12630
13054
  const detailW = 192;