@sleep2agi/agent-network-dashboard 0.4.6-preview.3 → 0.4.6-preview.5

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 (147) 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 +4 -4
  4. package/.next/fallback-build-manifest.json +3 -3
  5. package/.next/prerender-manifest.json +3 -3
  6. package/.next/server/app/_global-error.html +1 -1
  7. package/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found.html +1 -1
  14. package/.next/server/app/_not-found.rsc +1 -1
  15. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  16. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  17. package/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  18. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  20. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/server/app/admin.html +1 -1
  22. package/.next/server/app/admin.rsc +1 -1
  23. package/.next/server/app/admin.segments/_full.segment.rsc +1 -1
  24. package/.next/server/app/admin.segments/_head.segment.rsc +1 -1
  25. package/.next/server/app/admin.segments/_index.segment.rsc +1 -1
  26. package/.next/server/app/admin.segments/_tree.segment.rsc +1 -1
  27. package/.next/server/app/admin.segments/admin/__PAGE__.segment.rsc +1 -1
  28. package/.next/server/app/admin.segments/admin.segment.rsc +1 -1
  29. package/.next/server/app/index.html +2 -2
  30. package/.next/server/app/index.rsc +2 -2
  31. package/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  32. package/.next/server/app/index.segments/_full.segment.rsc +2 -2
  33. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  34. package/.next/server/app/index.segments/_index.segment.rsc +1 -1
  35. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  36. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  37. package/.next/server/app/login.html +2 -2
  38. package/.next/server/app/login.rsc +2 -2
  39. package/.next/server/app/login.segments/_full.segment.rsc +2 -2
  40. package/.next/server/app/login.segments/_head.segment.rsc +1 -1
  41. package/.next/server/app/login.segments/_index.segment.rsc +1 -1
  42. package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
  43. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
  44. package/.next/server/app/login.segments/login.segment.rsc +1 -1
  45. package/.next/server/app/logs.html +1 -1
  46. package/.next/server/app/logs.rsc +1 -1
  47. package/.next/server/app/logs.segments/_full.segment.rsc +1 -1
  48. package/.next/server/app/logs.segments/_head.segment.rsc +1 -1
  49. package/.next/server/app/logs.segments/_index.segment.rsc +1 -1
  50. package/.next/server/app/logs.segments/_tree.segment.rsc +1 -1
  51. package/.next/server/app/logs.segments/logs/__PAGE__.segment.rsc +1 -1
  52. package/.next/server/app/logs.segments/logs.segment.rsc +1 -1
  53. package/.next/server/app/messages.html +1 -1
  54. package/.next/server/app/messages.rsc +1 -1
  55. package/.next/server/app/messages.segments/_full.segment.rsc +1 -1
  56. package/.next/server/app/messages.segments/_head.segment.rsc +1 -1
  57. package/.next/server/app/messages.segments/_index.segment.rsc +1 -1
  58. package/.next/server/app/messages.segments/_tree.segment.rsc +1 -1
  59. package/.next/server/app/messages.segments/messages/__PAGE__.segment.rsc +1 -1
  60. package/.next/server/app/messages.segments/messages.segment.rsc +1 -1
  61. package/.next/server/app/node.html +1 -1
  62. package/.next/server/app/node.rsc +1 -1
  63. package/.next/server/app/node.segments/_full.segment.rsc +1 -1
  64. package/.next/server/app/node.segments/_head.segment.rsc +1 -1
  65. package/.next/server/app/node.segments/_index.segment.rsc +1 -1
  66. package/.next/server/app/node.segments/_tree.segment.rsc +1 -1
  67. package/.next/server/app/node.segments/node/__PAGE__.segment.rsc +1 -1
  68. package/.next/server/app/node.segments/node.segment.rsc +1 -1
  69. package/.next/server/app/nodes.html +1 -1
  70. package/.next/server/app/nodes.rsc +1 -1
  71. package/.next/server/app/nodes.segments/_full.segment.rsc +1 -1
  72. package/.next/server/app/nodes.segments/_head.segment.rsc +1 -1
  73. package/.next/server/app/nodes.segments/_index.segment.rsc +1 -1
  74. package/.next/server/app/nodes.segments/_tree.segment.rsc +1 -1
  75. package/.next/server/app/nodes.segments/nodes/__PAGE__.segment.rsc +1 -1
  76. package/.next/server/app/nodes.segments/nodes.segment.rsc +1 -1
  77. package/.next/server/app/page_client-reference-manifest.js +1 -1
  78. package/.next/server/app/server-logs.html +1 -1
  79. package/.next/server/app/server-logs.rsc +1 -1
  80. package/.next/server/app/server-logs.segments/_full.segment.rsc +1 -1
  81. package/.next/server/app/server-logs.segments/_head.segment.rsc +1 -1
  82. package/.next/server/app/server-logs.segments/_index.segment.rsc +1 -1
  83. package/.next/server/app/server-logs.segments/_tree.segment.rsc +1 -1
  84. package/.next/server/app/server-logs.segments/server-logs/__PAGE__.segment.rsc +1 -1
  85. package/.next/server/app/server-logs.segments/server-logs.segment.rsc +1 -1
  86. package/.next/server/app/settings/networks.html +1 -1
  87. package/.next/server/app/settings/networks.rsc +1 -1
  88. package/.next/server/app/settings/networks.segments/_full.segment.rsc +1 -1
  89. package/.next/server/app/settings/networks.segments/_head.segment.rsc +1 -1
  90. package/.next/server/app/settings/networks.segments/_index.segment.rsc +1 -1
  91. package/.next/server/app/settings/networks.segments/_tree.segment.rsc +1 -1
  92. package/.next/server/app/settings/networks.segments/settings/networks/__PAGE__.segment.rsc +1 -1
  93. package/.next/server/app/settings/networks.segments/settings/networks.segment.rsc +1 -1
  94. package/.next/server/app/settings/networks.segments/settings.segment.rsc +1 -1
  95. package/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  96. package/.next/server/app/settings/tokens.html +1 -1
  97. package/.next/server/app/settings/tokens.rsc +1 -1
  98. package/.next/server/app/settings/tokens.segments/_full.segment.rsc +1 -1
  99. package/.next/server/app/settings/tokens.segments/_head.segment.rsc +1 -1
  100. package/.next/server/app/settings/tokens.segments/_index.segment.rsc +1 -1
  101. package/.next/server/app/settings/tokens.segments/_tree.segment.rsc +1 -1
  102. package/.next/server/app/settings/tokens.segments/settings/tokens/__PAGE__.segment.rsc +1 -1
  103. package/.next/server/app/settings/tokens.segments/settings/tokens.segment.rsc +1 -1
  104. package/.next/server/app/settings/tokens.segments/settings.segment.rsc +1 -1
  105. package/.next/server/app/settings.html +2 -2
  106. package/.next/server/app/settings.rsc +2 -2
  107. package/.next/server/app/settings.segments/_full.segment.rsc +2 -2
  108. package/.next/server/app/settings.segments/_head.segment.rsc +1 -1
  109. package/.next/server/app/settings.segments/_index.segment.rsc +1 -1
  110. package/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
  111. package/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
  112. package/.next/server/app/settings.segments/settings.segment.rsc +1 -1
  113. package/.next/server/app/tasks.html +1 -1
  114. package/.next/server/app/tasks.rsc +1 -1
  115. package/.next/server/app/tasks.segments/_full.segment.rsc +1 -1
  116. package/.next/server/app/tasks.segments/_head.segment.rsc +1 -1
  117. package/.next/server/app/tasks.segments/_index.segment.rsc +1 -1
  118. package/.next/server/app/tasks.segments/_tree.segment.rsc +1 -1
  119. package/.next/server/app/tasks.segments/tasks/__PAGE__.segment.rsc +1 -1
  120. package/.next/server/app/tasks.segments/tasks.segment.rsc +1 -1
  121. package/.next/server/chunks/ssr/_0nne7mz._.js +1 -1
  122. package/.next/server/chunks/ssr/_0nne7mz._.js.map +1 -1
  123. package/.next/server/chunks/ssr/app_0aum_g5._.js +1 -1
  124. package/.next/server/chunks/ssr/app_0aum_g5._.js.map +1 -1
  125. package/.next/server/chunks/ssr/app_0esuljp._.js +1 -1
  126. package/.next/server/chunks/ssr/app_0esuljp._.js.map +1 -1
  127. package/.next/server/middleware-build-manifest.js +3 -3
  128. package/.next/server/pages/404.html +1 -1
  129. package/.next/server/pages/500.html +1 -1
  130. package/.next/server/server-reference-manifest.js +1 -1
  131. package/.next/server/server-reference-manifest.json +1 -1
  132. package/.next/static/chunks/0exo44x.p_gq7.js +1 -0
  133. package/.next/static/chunks/{0218jmcfde~9s.js → 0u0_jo83jrziw.js} +1 -1
  134. package/.next/static/chunks/{0z1xslnw3jx1s.js → 12_8n4k-hg72i.js} +1 -1
  135. package/.next/trace +2 -2
  136. package/.next/trace-build +1 -1
  137. package/app/components/TopoGraph.tsx +73 -29
  138. package/app/components/types.ts +4 -0
  139. package/app/lib/vendorIdentity.ts +145 -0
  140. package/app/page.tsx +18 -1
  141. package/package.json +1 -1
  142. package/scripts/topo-rename-test.mjs +106 -0
  143. package/scripts/topo-vendor-test.mjs +96 -0
  144. package/.next/static/chunks/13j9vr8_l9ket.js +0 -1
  145. /package/.next/static/{kXeBUTKSdNooA8p2BTTfp → FXhbXa0x4YZ0yjsgUY54q}/_buildManifest.js +0 -0
  146. /package/.next/static/{kXeBUTKSdNooA8p2BTTfp → FXhbXa0x4YZ0yjsgUY54q}/_clientMiddlewareManifest.js +0 -0
  147. /package/.next/static/{kXeBUTKSdNooA8p2BTTfp → FXhbXa0x4YZ0yjsgUY54q}/_ssgManifest.js +0 -0
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Session } from './types';
5
5
  import { aliasAvatarColors, aliasInitial } from './AliasAvatar';
6
6
  import { ChatPopover } from './ChatPopover';
7
+ import { vendorForModel, runtimeIdentity, identityLine } from '../lib/vendorIdentity';
7
8
 
8
9
  interface MessageFlow {
9
10
  from_alias: string;
@@ -15,6 +16,9 @@ interface MessageFlow {
15
16
  interface TopoGraphProps {
16
17
  sessions: Session[];
17
18
  sseSessions: Record<string, number>;
19
+ // #84: a node.renamed event from the Overview's SSE listener. When the
20
+ // currently open chat popover targets `from`, it follows the rename to `to`.
21
+ renameSignal?: { from: string; to: string; ts: number } | null;
18
22
  }
19
23
 
20
24
  interface Point {
@@ -283,7 +287,7 @@ function buildFlowLinks(messages: MessageFlow[], positions: Record<string, Point
283
287
  return [...links.values()].slice(0, 18);
284
288
  }
285
289
 
286
- export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
290
+ export function TopoGraph({ sessions, sseSessions, renameSignal }: TopoGraphProps) {
287
291
  const theme = useTheme();
288
292
  const isLight = theme === 'light';
289
293
  const pal = isLight ? LIGHT_PALETTE : DARK_PALETTE;
@@ -485,6 +489,16 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
485
489
  // Issue #100: singleton chat popover. One alias at a time — clicking
486
490
  // another node swaps the target and the conversation switches in place.
487
491
  const [chatAlias, setChatAlias] = useState<string | null>(null);
492
+ // #84: when a node is renamed while its chat popover is open, follow the
493
+ // rename so the conversation keeps targeting a live alias.
494
+ useEffect(() => {
495
+ if (renameSignal && renameSignal.from === chatAlias) {
496
+ setChatAlias(renameSignal.to);
497
+ }
498
+ // chatAlias intentionally omitted — only react to a new rename signal,
499
+ // not to the user opening/closing the popover.
500
+ // eslint-disable-next-line react-hooks/exhaustive-deps
501
+ }, [renameSignal]);
488
502
 
489
503
  useEffect(() => { viewRef.current = view; }, [view]);
490
504
 
@@ -908,6 +922,10 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
908
922
  onPointerLeave={() => denseLayout && setHoveredAlias(prev => (prev === session.alias ? null : prev))}
909
923
  onClick={() => setChatAlias(session.alias)}
910
924
  >
925
+ {/* Issue #96: native hover tooltip — "Vendor · model · Runtime".
926
+ Falls back to just the alias when the node reports no
927
+ model/runtime. */}
928
+ <title>{identityLine(session.model, session.runtime) || session.alias}</title>
911
929
  {isActive && (
912
930
  <circle cx={pos.x} cy={pos.y} r={radius + 14} fill={status.primary} opacity={isLight ? 0.08 : 0.12}>
913
931
  <animate attributeName="r" values={`${radius + 8};${radius + 22};${radius + 8}`} dur="2.4s" repeatCount="indefinite" />
@@ -925,33 +943,26 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
925
943
  strokeDasharray={isOnline ? 'none' : '5 5'}
926
944
  filter={isOnline && !isLight ? 'url(#topo-glow)' : undefined}
927
945
  />
928
- {/* Round 99 (issue #79): node "avatar" hue-hashed
929
- initials circle inside the status ring. Round 100:
930
- when brand=intern, swap the initials for the 书小生
931
- mascot image (asset from 群星马 task 51dd0d1d). The
932
- image already has a transparent bg so it sits cleanly
933
- on the node's dark/light fill; the outer status ring
934
- still carries working/idle/etc. */}
946
+ {/* Issue #96: node "avatar" is now driven by the model
947
+ vendor. Decision order:
948
+ 1. ?brand=intern flag, or an intern-aliased node with
949
+ no model field 书生 coin (preserves #79).
950
+ 2. vendor has a packaged logo asset that logo image
951
+ (intern-s1-* models land here via vendorForModel).
952
+ 3. known vendor, logo asset not shipped yet → a
953
+ vendor-tinted monogram (spec-mandated fallback).
954
+ 4. unknown vendor / null model → the prefix-group
955
+ hue-hashed initial (#83/#99 behaviour, unchanged). */}
935
956
  {(() => {
936
957
  const ar = isOnline ? 14 : 10;
937
- // Round 108 (issue #79 reopened): show the 书小生 avatar
938
- // coin for any agent whose alias marks it as an Intern /
939
- // 书生 runtime — not only under the global ?brand=intern
940
- // flag. Vincent 4565: a fleet of 书生N号 nodes should SHOW
941
- // 书生, not a generic "书" initial. ?brand=intern still
942
- // forces the coin on every node (full brand showcase).
943
- const isInternNode = isIntern || /书生|书小生|intern/i.test(session.alias);
944
- if (isInternNode) {
945
- // Round 102: self-contained "avatar coin" — 书小生 figure
946
- // keyed off its white background and composited onto a
947
- // cream circular backplate. The cream coin gives the
948
- // dark-haired figure consistent contrast on BOTH the
949
- // dark cyber theme and light theme. Render at the node
950
- // diameter so the coin fills inside the status ring.
951
- const size = radius * 2;
958
+ const size = radius * 2;
959
+ const vendor = vendorForModel(session.model);
960
+ const internByAlias = /书生|书小生|intern/i.test(session.alias);
961
+
962
+ if (isIntern || internByAlias || vendor.logo) {
952
963
  return (
953
964
  <image
954
- href="/intern_avatar.png"
965
+ href={vendor.logo ?? '/intern_avatar.png'}
955
966
  x={pos.x - size / 2}
956
967
  y={pos.y - size / 2}
957
968
  width={size}
@@ -960,12 +971,25 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
960
971
  />
961
972
  );
962
973
  }
974
+ if (vendor.id !== 'unknown') {
975
+ // Known model house, logo asset not in public/vendors/
976
+ // yet — vendor-tinted monogram stands in.
977
+ return (
978
+ <>
979
+ <circle cx={pos.x} cy={pos.y} r={ar} fill={vendor.mono.bg} stroke={vendor.mono.ring} strokeWidth="1" />
980
+ <text
981
+ x={pos.x} y={pos.y} dy="0.34em" textAnchor="middle"
982
+ fill={vendor.mono.text} fontSize={isOnline ? 14 : 10}
983
+ fontFamily="monospace" fontWeight="700"
984
+ >
985
+ {vendor.initial}
986
+ </text>
987
+ </>
988
+ );
989
+ }
963
990
  // Round 106 (issue #83): hue keyed to the prefix group,
964
- // not the full alias — every 通信* node shares one color,
965
- // every 研究员* another, so teams cluster visually even
966
- // when the tier layout spreads them across rings.
991
+ // not the full alias — every 通信* node shares one color.
967
992
  const c = aliasAvatarColors(groupKeys[session.alias] || session.alias);
968
- const fs = isOnline ? 14 : 10;
969
993
  return (
970
994
  <>
971
995
  <circle cx={pos.x} cy={pos.y} r={ar} fill={c.bg} stroke={c.ring} strokeWidth="1" />
@@ -975,7 +999,7 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
975
999
  dy="0.34em"
976
1000
  textAnchor="middle"
977
1001
  fill={c.text}
978
- fontSize={fs}
1002
+ fontSize={isOnline ? 14 : 10}
979
1003
  fontFamily="monospace"
980
1004
  fontWeight="700"
981
1005
  >
@@ -984,6 +1008,26 @@ export function TopoGraph({ sessions, sseSessions }: TopoGraphProps) {
984
1008
  </>
985
1009
  );
986
1010
  })()}
1011
+ {/* Issue #96: runtime badge — small corner glyph marking the
1012
+ execution shell (CLI / SDK / HTTP API). Sits bottom-right
1013
+ of the avatar; colours kept off the working/idle/offline
1014
+ status hues. Absent when the node reports no runtime. */}
1015
+ {(() => {
1016
+ const rt = runtimeIdentity(session.runtime);
1017
+ if (!rt) return null;
1018
+ const br = isOnline ? 7 : 5.5;
1019
+ const bx = pos.x + radius * 0.72;
1020
+ const by = pos.y + radius * 0.72;
1021
+ const icon = br * 2 * 0.62;
1022
+ return (
1023
+ <g style={{ pointerEvents: 'none' }}>
1024
+ <circle cx={bx} cy={by} r={br} fill={pal.containerBg} stroke={rt.color} strokeWidth="1.5" />
1025
+ <g transform={`translate(${bx - icon / 2} ${by - icon / 2}) scale(${icon / 24})`}>
1026
+ <path d={rt.iconPath} fill="none" stroke={rt.color} strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
1027
+ </g>
1028
+ </g>
1029
+ );
1030
+ })()}
987
1031
  {session.status === 'working' && (
988
1032
  // working pulse — small dot just inside the status ring,
989
1033
  // above the avatar text. Was a 18px horizontal bar at
@@ -12,6 +12,10 @@ export interface Session {
12
12
  last_seen_at?: string;
13
13
  config_path?: string;
14
14
  network_id?: string;
15
+ // #96: model vendor + execution runtime. Added to /api/status sessions in
16
+ // commhub-server 0.8.1-preview.0; null on older nodes / older hubs.
17
+ model?: string | null;
18
+ runtime?: string | null;
15
19
  }
16
20
 
17
21
  export interface Health {
@@ -0,0 +1,145 @@
1
+ /** Issue #96: node visual identity — model vendor + runtime.
2
+ *
3
+ * Single source for two orthogonal node dimensions:
4
+ * - vendor : which model house powers the node (avatar body)
5
+ * - runtime: which execution shell it runs in (corner badge)
6
+ *
7
+ * Both are driven by the server `model` / `runtime` fields added in
8
+ * commhub-server 0.8.1-preview.0 (#96 Phase 1, commit 08482ef). Old nodes
9
+ * report `model: null` / `runtime: null` and fall back gracefully — never a
10
+ * broken image or blank avatar.
11
+ *
12
+ * Adding a vendor = one entry in VENDOR_RULES. Dropping a real logo file into
13
+ * public/vendors/ + setting `logo` lights it up with zero render-code change;
14
+ * until then a vendor-tinted monogram stands in (not a fake official logo).
15
+ */
16
+
17
+ export interface VendorIdentity {
18
+ id: string;
19
+ label: string;
20
+ /** Monogram fallback colours, used when `logo` is null. */
21
+ mono: { bg: string; ring: string; text: string };
22
+ /** 1-char monogram shown when there's no logo asset. */
23
+ initial: string;
24
+ /** Packaged logo asset path, or null → render the monogram instead. */
25
+ logo: string | null;
26
+ }
27
+
28
+ const UNKNOWN_VENDOR: VendorIdentity = {
29
+ id: 'unknown',
30
+ label: 'Unknown vendor',
31
+ mono: { bg: 'hsl(220 10% 26%)', ring: 'hsl(220 10% 46%)', text: 'hsl(220 14% 82%)' },
32
+ initial: '?',
33
+ logo: null,
34
+ };
35
+
36
+ // Ordered prefix rules — first match wins. `test` runs against a lowercased
37
+ // model id. Keep the most specific prefixes first.
38
+ const VENDOR_RULES: Array<{ test: (m: string) => boolean; vendor: VendorIdentity }> = [
39
+ {
40
+ test: (m) => m.startsWith('intern'),
41
+ vendor: {
42
+ id: 'intern',
43
+ label: '书生 · 上海 AI 实验室',
44
+ mono: { bg: 'hsl(28 38% 24%)', ring: 'hsl(32 45% 52%)', text: 'hsl(34 60% 82%)' },
45
+ initial: '书',
46
+ // #79 shipped this asset — reuse it as the 书生 vendor logo.
47
+ logo: '/intern_avatar.png',
48
+ },
49
+ },
50
+ {
51
+ test: (m) => m.startsWith('minimax'),
52
+ vendor: {
53
+ id: 'minimax',
54
+ label: 'MiniMax',
55
+ mono: { bg: 'hsl(18 50% 26%)', ring: 'hsl(18 65% 52%)', text: 'hsl(20 80% 82%)' },
56
+ initial: 'M',
57
+ logo: null, // → public/vendors/minimax.* when assets land
58
+ },
59
+ },
60
+ {
61
+ test: (m) => m.startsWith('claude'),
62
+ vendor: {
63
+ id: 'anthropic',
64
+ label: 'Anthropic',
65
+ mono: { bg: 'hsl(16 32% 26%)', ring: 'hsl(16 48% 54%)', text: 'hsl(18 60% 84%)' },
66
+ initial: 'A',
67
+ logo: null,
68
+ },
69
+ },
70
+ {
71
+ test: (m) => m.startsWith('gpt') || m.startsWith('codex') || m.startsWith('o1') || m.startsWith('o3') || m.startsWith('o4'),
72
+ vendor: {
73
+ id: 'openai',
74
+ label: 'OpenAI',
75
+ mono: { bg: 'hsl(165 26% 22%)', ring: 'hsl(165 40% 44%)', text: 'hsl(165 45% 80%)' },
76
+ initial: 'O',
77
+ logo: null,
78
+ },
79
+ },
80
+ ];
81
+
82
+ /** Resolve a model id to its vendor identity. `null` / unmatched → UNKNOWN. */
83
+ export function vendorForModel(model: string | null | undefined): VendorIdentity {
84
+ if (!model) return UNKNOWN_VENDOR;
85
+ const m = model.toLowerCase();
86
+ for (const rule of VENDOR_RULES) {
87
+ if (rule.test(m)) return rule.vendor;
88
+ }
89
+ return UNKNOWN_VENDOR;
90
+ }
91
+
92
+ export type Runtime = 'claude-code-cli' | 'codex-sdk' | 'claude-agent-sdk' | 'http-api';
93
+
94
+ export interface RuntimeIdentity {
95
+ label: string;
96
+ /** SVG path(s) drawn inside a 0..24 viewBox for the corner badge. */
97
+ iconPath: string;
98
+ /** Badge accent colour — kept clear of working/idle/offline status hues. */
99
+ color: string;
100
+ }
101
+
102
+ const RUNTIME_MAP: Record<Runtime, RuntimeIdentity> = {
103
+ // terminal / CLI
104
+ 'claude-code-cli': {
105
+ label: 'Claude Code CLI',
106
+ iconPath: 'M4 5h16v14H4z M7 9l3 3-3 3 M13 15h4',
107
+ color: '#a78bfa',
108
+ },
109
+ // code-block / SDK
110
+ 'codex-sdk': {
111
+ label: 'Codex SDK',
112
+ iconPath: 'M9 7l-5 5 5 5 M15 7l5 5-5 5',
113
+ color: '#38bdf8',
114
+ },
115
+ // SDK, distinct hue from codex
116
+ 'claude-agent-sdk': {
117
+ label: 'Claude Agent SDK',
118
+ iconPath: 'M4 7h16v10H4z M8 11h8 M8 14h5',
119
+ color: '#34d399',
120
+ },
121
+ // cloud / API — non-resident process
122
+ 'http-api': {
123
+ label: 'HTTP API',
124
+ iconPath: 'M7 18a4 4 0 010-8 5 5 0 019.6-1.3A3.5 3.5 0 0118 18z',
125
+ color: '#fbbf24',
126
+ },
127
+ };
128
+
129
+ /** Resolve a server runtime string to badge identity. Unknown → null. */
130
+ export function runtimeIdentity(runtime: string | null | undefined): RuntimeIdentity | null {
131
+ if (!runtime) return null;
132
+ return RUNTIME_MAP[runtime as Runtime] ?? null;
133
+ }
134
+
135
+ /** Compact one-line identity for hover / detail surfaces:
136
+ * "Anthropic · claude-opus-4 · Claude Code CLI". Pieces with no data drop. */
137
+ export function identityLine(model: string | null | undefined, runtime: string | null | undefined): string {
138
+ const v = vendorForModel(model);
139
+ const r = runtimeIdentity(runtime);
140
+ const parts: string[] = [];
141
+ if (v.id !== 'unknown') parts.push(v.label);
142
+ if (model) parts.push(model);
143
+ if (r) parts.push(r.label);
144
+ return parts.join(' · ');
145
+ }
package/app/page.tsx CHANGED
@@ -42,6 +42,10 @@ export default function Dashboard() {
42
42
  const [showDispatch, setShowDispatch] = useState(false);
43
43
  const [inbox, setInbox] = useState<InboxMessage[]>([]);
44
44
  const [agentFilter, setAgentFilter] = useState<'all' | 'working' | 'idle' | 'offline'>('all');
45
+ // #84: last node.renamed event — passed to TopoGraph so an open chat
46
+ // popover follows the rename instead of pointing at a dead alias. `ts`
47
+ // makes the effect re-fire even when the same from/to repeats.
48
+ const [renameSignal, setRenameSignal] = useState<{ from: string; to: string; ts: number } | null>(null);
45
49
  const { mutate } = useSWRConfig();
46
50
 
47
51
  // SSE: instant revalidation on CommHub events
@@ -51,6 +55,19 @@ export default function Dashboard() {
51
55
  url: '/api/hub/events',
52
56
  enabled: sseEnabled,
53
57
  onEvent: (event) => {
58
+ // #84 (RFC-010 §3.4): node.renamed — revalidate the session list so the
59
+ // new alias propagates instantly (TopoGraph + node grid re-render, the
60
+ // avatar hue recomputes as a pure fn of alias) instead of waiting for
61
+ // the next 5s poll. The renamed node's history keeps the old alias
62
+ // server-side, so the task list needs no revalidation here.
63
+ if (event.type === 'node.renamed') {
64
+ mutate('/api/hub/status');
65
+ const d = event.data as { old_alias?: string; new_alias?: string } | undefined;
66
+ if (d?.old_alias && d?.new_alias) {
67
+ setRenameSignal({ from: d.old_alias, to: d.new_alias, ts: Date.now() });
68
+ }
69
+ return;
70
+ }
54
71
  if (['new_task', 'new_message', 'new_reply', 'node_status_changed', 'broadcast'].includes(event.type)) {
55
72
  mutate('/api/hub/status');
56
73
  mutate((key: string) => typeof key === 'string' && key.startsWith('/api/hub/tasks'), undefined, { revalidate: true });
@@ -368,7 +385,7 @@ export default function Dashboard() {
368
385
  </div>
369
386
  )}
370
387
 
371
- {showTopo && sessions.length > 0 && <TopoGraph sessions={sessions} sseSessions={sseSessions} />}
388
+ {showTopo && sessions.length > 0 && <TopoGraph sessions={sessions} sseSessions={sseSessions} renameSignal={renameSignal} />}
372
389
 
373
390
  {sessions.length === 0 && !sessError ? (
374
391
  <EmptyState
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/agent-network-dashboard",
3
- "version": "0.4.6-preview.3",
3
+ "version": "0.4.6-preview.5",
4
4
  "description": "Agent Network Dashboard — Web UI for managing AI Agent networks",
5
5
  "scripts": {
6
6
  "dev": "next dev",
@@ -0,0 +1,106 @@
1
+ /* Issue #84 verification: node.renamed SSE handler.
2
+ * Mocks the SSE stream + status API so a node.renamed event flips the
3
+ * topology node old→new alias instantly, and an open chat popover follows
4
+ * the rename instead of pointing at a dead alias. */
5
+ import { chromium } from 'playwright';
6
+ import { readFileSync } from 'node:fs';
7
+
8
+ const TOKEN = JSON.parse(readFileSync('/home/vansin/.anet/config.json', 'utf8')).token;
9
+ const OLD = '节点旧名', NEW = '节点新名';
10
+ const browser = await chromium.launch({ headless: true });
11
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
12
+ await ctx.addCookies([{ name: 'anet_dashboard_session', value: `v3:${TOKEN}`, domain: '127.0.0.1', path: '/' }]);
13
+ await ctx.addInitScript(() => {
14
+ try {
15
+ localStorage.setItem('anet-theme', 'cyber');
16
+ localStorage.removeItem('anet-brand');
17
+ localStorage.removeItem('anet-topo-view');
18
+ localStorage.setItem('anet-topo-layout', 'grid');
19
+ sessionStorage.setItem('anet_v3_auth', '1');
20
+ } catch {}
21
+ });
22
+
23
+ // shared test state
24
+ let fireRename = false; // test arms this once the old-name node is confirmed
25
+ let renamed = false; // flips true the moment the rename event is served
26
+
27
+ // status: old alias before rename, new alias after
28
+ await ctx.route('**/api/hub/status*', async (route) => {
29
+ const r = await route.fetch();
30
+ const b = await r.json();
31
+ const nid = (b.sessions || [])[0]?.network_id || 'default';
32
+ const alias = renamed ? NEW : OLD;
33
+ await route.fulfill({ response: r, json: { ...b, sessions: [
34
+ { alias, status: 'idle', network_id: nid, created_at: '2026-05-15T00:00:00Z', updated_at: '2026-05-15T00:00:00Z', last_seen_at: '2026-05-15T00:00:00Z' },
35
+ ] } });
36
+ });
37
+ await ctx.route('**/api/hub/tasks*', (route) => route.fulfill({ json: { tasks: [] } }));
38
+
39
+ // SSE: HEAD probe says "available"; GET serves `connected` until the test
40
+ // arms fireRename, then one reconnect later it serves the node.renamed event.
41
+ await ctx.route('**/api/hub/events**', async (route) => {
42
+ const req = route.request();
43
+ if (req.method() === 'HEAD') {
44
+ await route.fulfill({ status: 204, headers: { 'x-anet-sse-available': 'true' } });
45
+ return;
46
+ }
47
+ let body = `data: ${JSON.stringify({ type: 'connected', session: 'tester' })}\n\n`;
48
+ if (fireRename && !renamed) {
49
+ renamed = true; // status refetch triggered by the handler now returns NEW
50
+ body += `data: ${JSON.stringify({
51
+ type: 'node.renamed', event: 'node.renamed', alias: NEW,
52
+ txn_id: 'rtxn_test', ts: '2026-05-15T00:00:00Z',
53
+ data: { old_alias: OLD, new_alias: NEW, surfaces_updated: ['config', 'commhub'], history_policy: 'preserve' },
54
+ })}\n\n`;
55
+ }
56
+ await route.fulfill({ status: 200, headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }, body });
57
+ });
58
+
59
+ const page = await ctx.newPage();
60
+ await page.goto('http://127.0.0.1:3000/', { waitUntil: 'domcontentloaded' });
61
+ await page.waitForFunction(() => {
62
+ const svg = document.querySelector('svg[viewBox="0 0 1000 680"]');
63
+ return !!svg && svg.querySelectorAll('circle[r="26"]').length > 0;
64
+ }, { timeout: 30000 }).catch(() => {});
65
+ await page.waitForTimeout(600);
66
+
67
+ const results = {};
68
+ const svg = 'svg[viewBox="0 0 1000 680"]';
69
+ const nodeTexts = () => page.$$eval(`${svg} text`, els => els.map(e => (e.textContent || '').trim()));
70
+
71
+ // --- before rename: old alias rendered ---
72
+ let texts = await nodeTexts();
73
+ results.oldNameShown = texts.includes(OLD);
74
+
75
+ // open the chat popover on the old-name node
76
+ const ring = page.locator(`${svg} circle[r="26"]`).first();
77
+ const bb = await ring.boundingBox();
78
+ if (bb) await page.mouse.wheel(0, bb.y - 160);
79
+ await page.waitForTimeout(150);
80
+ await ring.click({ force: true });
81
+ await page.waitForTimeout(400);
82
+ const popover = page.locator('[role="dialog"][aria-label^="Chat with"]');
83
+ results.popoverOpenedOnOld = (await popover.getAttribute('aria-label')) === `Chat with ${OLD}`;
84
+
85
+ // --- arm the rename, wait for the SSE reconnect to deliver it ---
86
+ fireRename = true;
87
+ await page.waitForFunction((oldName) => {
88
+ const svg = document.querySelector('svg[viewBox="0 0 1000 680"]');
89
+ if (!svg) return false;
90
+ const txt = [...svg.querySelectorAll('text')].map(t => (t.textContent || '').trim());
91
+ return !txt.includes(oldName);
92
+ }, OLD, { timeout: 15000 }).catch(() => {});
93
+ await page.waitForTimeout(500);
94
+
95
+ // --- after rename: topology flipped to the new alias ---
96
+ texts = await nodeTexts();
97
+ results.newNameShown = texts.includes(NEW);
98
+ results.oldNameGone = !texts.includes(OLD);
99
+ // chat popover followed the rename
100
+ results.popoverFollowedRename = (await popover.getAttribute('aria-label').catch(() => null)) === `Chat with ${NEW}`;
101
+
102
+ await browser.close();
103
+ const ok = results.oldNameShown && results.popoverOpenedOnOld &&
104
+ results.newNameShown && results.oldNameGone && results.popoverFollowedRename;
105
+ console.log(`${ok ? '✅' : '❌'} node.renamed handler:`, JSON.stringify(results));
106
+ process.exit(ok ? 0 : 1);
@@ -0,0 +1,96 @@
1
+ /* Issue #96 verification: node visual identity — vendor avatar + runtime
2
+ * badge. Mocks /api/hub/status with model/runtime fields and checks the
3
+ * TopoGraph node rendering picks the right avatar + badge + tooltip. */
4
+ import { chromium } from 'playwright';
5
+ import { readFileSync, mkdirSync } from 'node:fs';
6
+
7
+ const TOKEN = JSON.parse(readFileSync('/home/vansin/.anet/config.json', 'utf8')).token;
8
+ mkdirSync('/tmp/anet-issue-96', { recursive: true });
9
+ const browser = await chromium.launch({ headless: true });
10
+
11
+ // alias → { model, runtime } — one node per vendor + runtime + a null-model node
12
+ const FLEET = [
13
+ { alias: 'intern-1', model: 'intern-s1-pro', runtime: 'claude-code-cli' },
14
+ { alias: 'minimax-1', model: 'MiniMax-Text-01', runtime: 'codex-sdk' },
15
+ { alias: 'claude-1', model: 'claude-opus-4', runtime: 'claude-agent-sdk' },
16
+ { alias: 'openai-1', model: 'gpt-4o', runtime: 'http-api' },
17
+ { alias: 'legacy-1', model: null, runtime: null },
18
+ ];
19
+
20
+ async function run(theme) {
21
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
22
+ await ctx.addCookies([{ name: 'anet_dashboard_session', value: `v3:${TOKEN}`, domain: '127.0.0.1', path: '/' }]);
23
+ await ctx.addInitScript((th) => {
24
+ try {
25
+ localStorage.setItem('anet-theme', th);
26
+ localStorage.removeItem('anet-brand');
27
+ localStorage.removeItem('anet-topo-view');
28
+ localStorage.setItem('anet-topo-layout', 'grid');
29
+ sessionStorage.setItem('anet_v3_auth', '1');
30
+ } catch {}
31
+ }, theme);
32
+ await ctx.route('**/api/hub/status*', async (route) => {
33
+ const r = await route.fetch();
34
+ const b = await r.json();
35
+ const nid = (b.sessions || [])[0]?.network_id || 'default';
36
+ const sessions = FLEET.map(f => ({
37
+ alias: f.alias, status: 'idle', network_id: nid, model: f.model, runtime: f.runtime,
38
+ created_at: '2026-05-14T00:00:00Z', updated_at: '2026-05-14T00:00:00Z', last_seen_at: '2026-05-14T00:00:00Z',
39
+ }));
40
+ await route.fulfill({ response: r, json: { ...b, sessions } });
41
+ });
42
+ await ctx.route('**/api/hub/tasks*', (route) => route.fulfill({ json: { tasks: [] } }));
43
+
44
+ const page = await ctx.newPage();
45
+ await page.goto('http://127.0.0.1:3000/', { waitUntil: 'domcontentloaded' });
46
+ await page.waitForFunction(() => {
47
+ const svg = document.querySelector('svg[viewBox="0 0 1000 680"]');
48
+ return !!svg && svg.querySelectorAll('circle[r="26"]').length > 0;
49
+ }, { timeout: 30000 }).catch(() => {});
50
+ await page.waitForTimeout(700);
51
+
52
+ const results = {};
53
+ const svg = 'svg[viewBox="0 0 1000 680"]';
54
+
55
+ // intern model → <image> avatar (intern_avatar.png)
56
+ results.internUsesImage = await page.locator(`${svg} image[href="/intern_avatar.png"]`).count() >= 1;
57
+
58
+ // vendor monograms — minimax 'M' / anthropic 'A' / openai 'O' present as avatar text
59
+ const texts = await page.$$eval(`${svg} text`, els => els.map(e => (e.textContent || '').trim()));
60
+ results.minimaxMonogram = texts.includes('M');
61
+ results.anthropicMonogram = texts.includes('A');
62
+ results.openaiMonogram = texts.includes('O');
63
+ // legacy null-model node → alias-hue initial 'L' (not a vendor monogram)
64
+ results.legacyAliasInitial = texts.includes('L');
65
+
66
+ // runtime badges: 4 nodes have a runtime → 4 badge groups. Badge = a small
67
+ // circle stroked in a runtime colour. Count distinct runtime stroke colours.
68
+ const badgeColors = await page.$$eval(`${svg} circle`, els =>
69
+ els.map(e => e.getAttribute('stroke'))
70
+ .filter(s => ['#a78bfa', '#38bdf8', '#34d399', '#fbbf24'].includes(s)));
71
+ results.runtimeBadges = new Set(badgeColors).size === 4;
72
+
73
+ // <title> tooltip carries the identity line for a vendor node
74
+ results.tooltipHasIdentity = await page.evaluate(() => {
75
+ const titles = [...document.querySelectorAll('svg[viewBox="0 0 1000 680"] g > title')]
76
+ .map(t => t.textContent || '');
77
+ return titles.some(t => /Anthropic · claude-opus-4 · Claude Agent SDK/.test(t));
78
+ });
79
+
80
+ await page.screenshot({ path: `/tmp/anet-issue-96/vendor-${theme}.png` });
81
+ await ctx.close();
82
+
83
+ const ok = results.internUsesImage && results.minimaxMonogram && results.anthropicMonogram &&
84
+ results.openaiMonogram && results.legacyAliasInitial && results.runtimeBadges &&
85
+ results.tooltipHasIdentity;
86
+ console.log(`${ok ? '✅' : '❌'} [${theme}]:`, JSON.stringify(results));
87
+ return ok;
88
+ }
89
+
90
+ const all = [];
91
+ all.push(await run('cyber'));
92
+ all.push(await run('light'));
93
+ await browser.close();
94
+ const pass = all.every(Boolean);
95
+ console.log(pass ? '\n✅ ALL PASS' : '\n❌ FAIL');
96
+ process.exit(pass ? 0 : 1);