@nelsonlongxiang/dsh-open-a2a-net 0.6.41 → 0.6.43

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 (72) hide show
  1. package/README.md +2 -1
  2. package/README_zh.md +2 -1
  3. package/assets/nexusDist/assets/index-CaA76j0F.js +9 -0
  4. package/assets/nexusDist/index.html +1 -1
  5. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
  6. package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
  7. package/assets/stageDist/index.html +12 -12
  8. package/cli/a2a.mjs +24 -4
  9. package/lib/canvas-store.js +249 -17
  10. package/lib/client.js +62 -72
  11. package/lib/client.js.map +1 -1
  12. package/lib/config.js +1 -0
  13. package/lib/faces/admin.js +103 -63
  14. package/lib/faces/wire.js +3 -1
  15. package/lib/index.js +116 -22
  16. package/lib/origin-claim.js +4 -0
  17. package/lib/skill/SKILL.md +117 -117
  18. package/lib/skill/references/collaboration.md +428 -428
  19. package/lib/skill/references/discovery.md +78 -78
  20. package/lib/skill/references/intake.md +107 -107
  21. package/lib/skill/references/membership.md +73 -73
  22. package/lib/skill/references/pitfalls.md +51 -51
  23. package/lib/skill/references/router.md +96 -96
  24. package/lib/skill/references/routing.md +65 -65
  25. package/lib/types/canvas-store.d.ts +48 -1
  26. package/lib/types/config.d.ts +2 -0
  27. package/lib/types/faces/admin.d.ts +2 -0
  28. package/lib/types/origin-claim.d.ts +2 -0
  29. package/nexus-stage/package.json +21 -21
  30. package/nexus-stage/scripts/mockhost.py +261 -261
  31. package/nexus-stage/src/canvas-ops.ts +191 -191
  32. package/nexus-stage/src/canvas-wire.ts +164 -164
  33. package/nexus-stage/src/cli-home-picker.ts +42 -42
  34. package/nexus-stage/src/fault.ts +56 -56
  35. package/nexus-stage/src/federation-overlay.ts +103 -103
  36. package/nexus-stage/src/federation.ts +241 -241
  37. package/nexus-stage/src/layout-doc.ts +145 -145
  38. package/nexus-stage/src/main.ts +520 -513
  39. package/nexus-stage/src/planning-card-text.ts +73 -73
  40. package/nexus-stage/src/planning-card.ts +63 -63
  41. package/nexus-stage/src/planning-edges.ts +26 -26
  42. package/nexus-stage/src/planning-fit.ts +45 -45
  43. package/nexus-stage/src/planning-frame-hue.ts +13 -13
  44. package/nexus-stage/src/planning-legend.ts +39 -39
  45. package/nexus-stage/src/planning-member-handles.ts +61 -61
  46. package/nexus-stage/src/planning-menus.ts +422 -422
  47. package/nexus-stage/src/planning-mirror.ts +75 -75
  48. package/nexus-stage/src/planning-nodelist.ts +294 -294
  49. package/nexus-stage/src/planning-overlap.ts +159 -159
  50. package/nexus-stage/src/planning-reachability.ts +28 -28
  51. package/nexus-stage/src/planning-relayout.ts +121 -121
  52. package/nexus-stage/src/planning-roster.ts +15 -15
  53. package/nexus-stage/src/planning-search.ts +168 -168
  54. package/nexus-stage/src/planning-types.ts +115 -115
  55. package/nexus-stage/src/planning-undo.ts +68 -68
  56. package/nexus-stage/src/planning-view.ts +1499 -1499
  57. package/nexus-stage/src/planning.css +340 -340
  58. package/nexus-stage/src/seat-name-editor.ts +31 -31
  59. package/nexus-stage/src/session-drag.ts +64 -64
  60. package/nexus-stage/src/viewport.ts +106 -106
  61. package/nexus-stage/src/wb-chat.ts +684 -684
  62. package/nexus-stage/src/wb-directory.ts +237 -237
  63. package/nexus-stage/src/wb-ledger.ts +279 -279
  64. package/nexus-stage/src/wb-names.ts +43 -43
  65. package/nexus-stage/src/wb-rail.ts +147 -147
  66. package/nexus-stage/src/workbench.css +388 -388
  67. package/nexus-stage/src/workbench.ts +1294 -1272
  68. package/nexus-stage/src/world.ts +509 -509
  69. package/nexus-stage/tsconfig.json +24 -24
  70. package/nexus-stage/vite.config.ts +15 -15
  71. package/package.json +1 -1
  72. package/assets/nexusDist/assets/index-NkNSeIx6.js +0 -9
@@ -1,294 +1,294 @@
1
- /**
2
- * The 节点 drawer (network-membership-display): the canvas shows teamed
3
- * nodes only, so every other session — teamless joined, unjoined, and each
4
- * peer's published nodes — is listed and managed here, switched by host.
5
- * Split out of planning-view; it reads the latest poll facts through
6
- * `facts()` and writes only through the view's action channel.
7
- * @module nexus-stage/planning-nodelist
8
- */
9
- import type { CanvasAction } from './canvas-ops'
10
- import type { RemoteTeamRow } from './federation'
11
- import type { PlanningSession, PlanningTeam } from './planning-types'
12
- import type { WorldModel } from './world'
13
-
14
- export interface NodeListFacts {
15
- readonly sessions: ReadonlyArray<PlanningSession>
16
- readonly canvasMemberIds: ReadonlySet<string>
17
- /** Remote team rows grouped by peer host (federation.groupRemoteTeams). */
18
- readonly remoteTeams: ReadonlyMap<string, RemoteTeamRow[]>
19
- readonly teams: ReadonlyArray<PlanningTeam>
20
- }
21
-
22
- export interface NodeListDeps {
23
- readonly root: HTMLElement
24
- /** The toolbar's 节点 button: the drawer anchors to it and returns focus there. */
25
- readonly trigger: HTMLButtonElement
26
- readonly signal: AddEventListenerOptions
27
- readonly model: WorldModel
28
- facts(): NodeListFacts
29
- menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement
30
- emit(a: CanvasAction): void
31
- openNameDialog(ids: readonly string[]): void
32
- /** Center the board on one canvas node (closes the drawer). */
33
- locate(id: string): void
34
- }
35
-
36
- export interface PlanningNodeList {
37
- toggle(): void
38
- close(): void
39
- /** Re-render an open drawer from the latest facts (no-op when closed). */
40
- refresh(): void
41
- }
42
-
43
- export function createNodeList(d: NodeListDeps): PlanningNodeList {
44
- const { root, trigger: nodesBtn, signal, model } = d
45
-
46
- // ── 节点列表 drawer(组队节点上画布;单一节点按 host/对等节点在列表管理)──
47
- let nodePanel: HTMLDivElement | null = null
48
- let nodeHost = '' // '' = 本机;否则为 d.facts().remoteTeams 的 host:port 键
49
- let nodeJoinMenu: HTMLDivElement | null = null
50
-
51
- function closeNodeJoinMenu(): void {
52
- nodeJoinMenu?.remove()
53
- nodeJoinMenu = null
54
- }
55
- function closeNodePanel(): void {
56
- closeNodeJoinMenu()
57
- nodePanel?.remove()
58
- nodePanel = null
59
- }
60
- function toggleNodePanel(): void {
61
- if (nodePanel !== null) { closeNodePanel(); return }
62
- const el = document.createElement('div')
63
- el.className = 'p-menu p-nodelist'
64
- el.setAttribute('role', 'dialog')
65
- el.setAttribute('aria-label', '节点清单')
66
- nodePanel = el
67
- root.appendChild(el)
68
- const rect = nodesBtn.getBoundingClientRect()
69
- el.style.left = `${String(Math.min(rect.left, window.innerWidth - 340))}px`
70
- el.style.top = `${String(Math.max(8, rect.top - 320))}px`
71
- el.addEventListener('keydown', (ev) => {
72
- if ((ev as KeyboardEvent).key === 'Escape') {
73
- ev.stopPropagation()
74
- closeNodePanel()
75
- nodesBtn.focus()
76
- }
77
- })
78
- renderNodePanel()
79
- }
80
-
81
- /** Rebuild the panel's host selector + body from the latest poll facts. */
82
- function renderNodePanel(): void {
83
- const el = nodePanel
84
- if (el === null) return
85
- closeNodeJoinMenu()
86
- el.textContent = ''
87
- const head = document.createElement('div')
88
- head.className = 'p-nodelist-head'
89
- const headLabel = document.createElement('span')
90
- headLabel.className = 'p-nodelist-title'
91
- headLabel.textContent = '节点'
92
- const sel = document.createElement('select')
93
- sel.className = 'p-nodesel'
94
- sel.setAttribute('aria-label', '选择 host 或对等节点')
95
- const localOpt = document.createElement('option')
96
- localOpt.value = ''
97
- localOpt.textContent = '本机'
98
- sel.appendChild(localOpt)
99
- for (const [host, rows] of d.facts().remoteTeams) {
100
- if (host === '') continue
101
- const opt = document.createElement('option')
102
- opt.value = host
103
- opt.textContent = rows[0]?.origin !== undefined && rows[0].origin !== '' ? `${rows[0].origin}(${host})` : host
104
- sel.appendChild(opt)
105
- }
106
- if (nodeHost !== '' && !d.facts().remoteTeams.has(nodeHost)) nodeHost = ''
107
- sel.value = nodeHost
108
- sel.addEventListener('change', () => { nodeHost = sel.value; renderNodePanel() })
109
- head.append(headLabel, sel)
110
- const body = document.createElement('div')
111
- body.className = 'p-nodelist-body'
112
- el.append(head, body)
113
- if (nodeHost === '') renderLocalNodeGroups(body)
114
- else renderRemoteNodeGroups(body, d.facts().remoteTeams.get(nodeHost) ?? [])
115
- }
116
-
117
- function nodeRowDot(live: boolean | undefined, joined: boolean): HTMLSpanElement {
118
- const dot = document.createElement('span')
119
- dot.className = 'p-nodedot' + (joined !== true ? ' off' : live === false ? ' cold' : '')
120
- return dot
121
- }
122
-
123
- function listBtn(label: string, onPick: () => void): HTMLButtonElement {
124
- const b = document.createElement('button')
125
- b.type = 'button'
126
- b.className = 'p-listbtn'
127
- b.textContent = label
128
- b.addEventListener('click', (ev) => { ev.stopPropagation(); onPick() })
129
- return b
130
- }
131
-
132
- function groupTitle(text: string, note?: string): HTMLDivElement {
133
- const t = document.createElement('div')
134
- t.className = 'p-nodelist-group'
135
- t.textContent = text
136
- if (note !== undefined) {
137
- const n = document.createElement('span')
138
- n.className = 'p-nodelist-note'
139
- n.textContent = note
140
- t.appendChild(n)
141
- }
142
- return t
143
- }
144
-
145
- /** 入队▸ submenu: existing canvas teams, plus 新团队… for the bootstrap case. */
146
- function openNodeJoinMenu(anchor: HTMLElement, id: string): void {
147
- closeNodeJoinMenu()
148
- const el = document.createElement('div')
149
- el.className = 'p-menu'
150
- el.setAttribute('role', 'menu')
151
- el.setAttribute('aria-label', '加入团队')
152
- for (const t of d.facts().teams) {
153
- const already = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
154
- el.appendChild(d.menuItem(already ? `${t.name}(已加入)` : t.name, !already, () => {
155
- closeNodePanel()
156
- d.emit({ type: 'add-member', team: t.name, ids: [id] })
157
- }))
158
- }
159
- el.appendChild(d.menuItem('新团队…', true, () => {
160
- closeNodePanel()
161
- d.openNameDialog([id])
162
- }))
163
- nodeJoinMenu = el
164
- root.appendChild(el)
165
- const rect = anchor.getBoundingClientRect()
166
- el.style.left = `${String(Math.min(rect.right + 4, window.innerWidth - 180))}px`
167
- el.style.top = `${String(Math.max(8, rect.top - 60))}px`
168
- el.addEventListener('keydown', (ev) => {
169
- if ((ev as KeyboardEvent).key === 'Escape') { ev.stopPropagation(); closeNodeJoinMenu(); anchor.focus() }
170
- })
171
- el.querySelector('button')?.focus()
172
- }
173
-
174
- function renderLocalNodeGroups(body: HTMLDivElement): void {
175
- const teamed = new Set<string>(d.facts().canvasMemberIds)
176
- for (const s of d.facts().sessions) if (s.teams !== undefined && s.teams.length > 0) teamed.add(s.id)
177
- const unteamed = d.facts().sessions.filter(s => s.joined === true && !teamed.has(s.id))
178
- const unjoined = d.facts().sessions.filter(s => s.joined !== true)
179
- const teamedRows = d.facts().sessions.filter(s => s.joined === true && teamed.has(s.id))
180
-
181
- body.appendChild(groupTitle(`未组队 (${String(unteamed.length)})`, '无网络能力'))
182
- for (const s of unteamed) {
183
- const row = document.createElement('div')
184
- row.className = 'p-noderow'
185
- const name = document.createElement('span')
186
- name.className = 'p-nodename'
187
- name.textContent = s.name !== undefined && s.name !== '' ? s.name : s.label
188
- name.title = s.team
189
- const tag = document.createElement('span')
190
- tag.className = 'p-nonet'
191
- tag.textContent = '无网络能力'
192
- const joinBtn = listBtn('入队▸', () => openNodeJoinMenu(joinBtn, s.id))
193
- row.append(nodeRowDot(s.live, true), name, tag, joinBtn,
194
- listBtn('退网', () => { closeNodePanel(); d.emit({ type: 'leave-network', id: s.id }) }))
195
- body.appendChild(row)
196
- }
197
-
198
- body.appendChild(groupTitle(`未入网 (${String(unjoined.length)})`))
199
- for (const s of unjoined) {
200
- const row = document.createElement('div')
201
- row.className = 'p-noderow'
202
- const name = document.createElement('span')
203
- name.className = 'p-nodename'
204
- name.textContent = s.name !== undefined && s.name !== '' ? s.name : s.label
205
- name.title = s.id
206
- row.append(nodeRowDot(s.live, false), name,
207
- listBtn('入网', () => { closeNodePanel(); d.emit({ type: 'join-network', id: s.id }) }))
208
- body.appendChild(row)
209
- }
210
-
211
- body.appendChild(groupTitle(`已组队 (${String(teamedRows.length)})`, '画布展示'))
212
- for (const s of teamedRows) {
213
- const row = document.createElement('div')
214
- row.className = 'p-noderow'
215
- const name = document.createElement('span')
216
- name.className = 'p-nodename'
217
- name.textContent = s.name !== undefined && s.name !== '' ? s.name : s.label
218
- name.title = s.team
219
- const canvasCount = model.getNode(s.id)?.memberships.length ?? 0
220
- const rosterCount = s.teams?.length ?? 0
221
- const badge = document.createElement('span')
222
- badge.className = 'p-teamcount'
223
- badge.textContent = `队×${String(Math.max(canvasCount, 0) + rosterCount)}`
224
- row.append(nodeRowDot(s.live, true), name, badge,
225
- listBtn('定位', () => { d.locate(s.id) }))
226
- body.appendChild(row)
227
- }
228
- if (d.facts().sessions.length === 0) {
229
- const empty = document.createElement('div')
230
- empty.className = 'p-nodelist-empty'
231
- empty.textContent = '无会话节点'
232
- body.appendChild(empty)
233
- }
234
- }
235
-
236
- function renderRemoteNodeGroups(body: HTMLDivElement, rows: ReadonlyArray<RemoteTeamRow>): void {
237
- const unteamed = rows.filter(r => r.teams === undefined || r.teams.length === 0)
238
- const teamedRows = rows.filter(r => r.teams !== undefined && r.teams.length > 0)
239
- body.appendChild(groupTitle(`未组队 (${String(unteamed.length)})`, '无网络能力 · 只读'))
240
- for (const r of unteamed) {
241
- const row = document.createElement('div')
242
- row.className = 'p-noderow'
243
- const name = document.createElement('span')
244
- name.className = 'p-nodename'
245
- name.textContent = r.name !== undefined && r.name !== '' ? r.name : r.team
246
- name.title = r.team
247
- const tag = document.createElement('span')
248
- tag.className = 'p-nonet'
249
- tag.textContent = '无网络能力'
250
- row.append(nodeRowDot(true, true), name, tag)
251
- body.appendChild(row)
252
- }
253
- body.appendChild(groupTitle(`已组队 (${String(teamedRows.length)})`, '只读'))
254
- for (const r of teamedRows) {
255
- const row = document.createElement('div')
256
- row.className = 'p-noderow'
257
- const name = document.createElement('span')
258
- name.className = 'p-nodename'
259
- name.textContent = r.name !== undefined && r.name !== '' ? r.name : r.team
260
- name.title = r.team
261
- const badge = document.createElement('span')
262
- badge.className = 'p-teamcount'
263
- badge.textContent = `队×${String(r.teams?.length ?? 0)}`
264
- row.append(nodeRowDot(true, true), name, badge)
265
- body.appendChild(row)
266
- }
267
- if (rows.length === 0) {
268
- const empty = document.createElement('div')
269
- empty.className = 'p-nodelist-empty'
270
- empty.textContent = '该节点未发布会话'
271
- body.appendChild(empty)
272
- }
273
- }
274
-
275
- // Outside-click dismissal for the node panel: the popover appends to root
276
- // (scoped .p-menu styles apply; no transformed ancestor), so dismissal
277
- // rides a document listener — the canvas pointer seam never sees presses
278
- // on body-level chrome. The panel, its 入队 submenu, and the trigger are
279
- // exempt.
280
- document.addEventListener('pointerdown', (ev) => {
281
- if (nodePanel === null) return
282
- const t = ev.target as Element | null
283
- if (t === null) { closeNodePanel(); return }
284
- if (t.closest('.p-nodelist') !== null || t.closest('.p-menu') !== null) return
285
- if (t === nodesBtn || nodesBtn.contains(t)) return
286
- closeNodePanel()
287
- }, signal)
288
-
289
- return {
290
- toggle: toggleNodePanel,
291
- close: closeNodePanel,
292
- refresh: () => { if (nodePanel !== null) renderNodePanel() },
293
- }
294
- }
1
+ /**
2
+ * The 节点 drawer (network-membership-display): the canvas shows teamed
3
+ * nodes only, so every other session — teamless joined, unjoined, and each
4
+ * peer's published nodes — is listed and managed here, switched by host.
5
+ * Split out of planning-view; it reads the latest poll facts through
6
+ * `facts()` and writes only through the view's action channel.
7
+ * @module nexus-stage/planning-nodelist
8
+ */
9
+ import type { CanvasAction } from './canvas-ops'
10
+ import type { RemoteTeamRow } from './federation'
11
+ import type { PlanningSession, PlanningTeam } from './planning-types'
12
+ import type { WorldModel } from './world'
13
+
14
+ export interface NodeListFacts {
15
+ readonly sessions: ReadonlyArray<PlanningSession>
16
+ readonly canvasMemberIds: ReadonlySet<string>
17
+ /** Remote team rows grouped by peer host (federation.groupRemoteTeams). */
18
+ readonly remoteTeams: ReadonlyMap<string, RemoteTeamRow[]>
19
+ readonly teams: ReadonlyArray<PlanningTeam>
20
+ }
21
+
22
+ export interface NodeListDeps {
23
+ readonly root: HTMLElement
24
+ /** The toolbar's 节点 button: the drawer anchors to it and returns focus there. */
25
+ readonly trigger: HTMLButtonElement
26
+ readonly signal: AddEventListenerOptions
27
+ readonly model: WorldModel
28
+ facts(): NodeListFacts
29
+ menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement
30
+ emit(a: CanvasAction): void
31
+ openNameDialog(ids: readonly string[]): void
32
+ /** Center the board on one canvas node (closes the drawer). */
33
+ locate(id: string): void
34
+ }
35
+
36
+ export interface PlanningNodeList {
37
+ toggle(): void
38
+ close(): void
39
+ /** Re-render an open drawer from the latest facts (no-op when closed). */
40
+ refresh(): void
41
+ }
42
+
43
+ export function createNodeList(d: NodeListDeps): PlanningNodeList {
44
+ const { root, trigger: nodesBtn, signal, model } = d
45
+
46
+ // ── 节点列表 drawer(组队节点上画布;单一节点按 host/对等节点在列表管理)──
47
+ let nodePanel: HTMLDivElement | null = null
48
+ let nodeHost = '' // '' = 本机;否则为 d.facts().remoteTeams 的 host:port 键
49
+ let nodeJoinMenu: HTMLDivElement | null = null
50
+
51
+ function closeNodeJoinMenu(): void {
52
+ nodeJoinMenu?.remove()
53
+ nodeJoinMenu = null
54
+ }
55
+ function closeNodePanel(): void {
56
+ closeNodeJoinMenu()
57
+ nodePanel?.remove()
58
+ nodePanel = null
59
+ }
60
+ function toggleNodePanel(): void {
61
+ if (nodePanel !== null) { closeNodePanel(); return }
62
+ const el = document.createElement('div')
63
+ el.className = 'p-menu p-nodelist'
64
+ el.setAttribute('role', 'dialog')
65
+ el.setAttribute('aria-label', '节点清单')
66
+ nodePanel = el
67
+ root.appendChild(el)
68
+ const rect = nodesBtn.getBoundingClientRect()
69
+ el.style.left = `${String(Math.min(rect.left, window.innerWidth - 340))}px`
70
+ el.style.top = `${String(Math.max(8, rect.top - 320))}px`
71
+ el.addEventListener('keydown', (ev) => {
72
+ if ((ev as KeyboardEvent).key === 'Escape') {
73
+ ev.stopPropagation()
74
+ closeNodePanel()
75
+ nodesBtn.focus()
76
+ }
77
+ })
78
+ renderNodePanel()
79
+ }
80
+
81
+ /** Rebuild the panel's host selector + body from the latest poll facts. */
82
+ function renderNodePanel(): void {
83
+ const el = nodePanel
84
+ if (el === null) return
85
+ closeNodeJoinMenu()
86
+ el.textContent = ''
87
+ const head = document.createElement('div')
88
+ head.className = 'p-nodelist-head'
89
+ const headLabel = document.createElement('span')
90
+ headLabel.className = 'p-nodelist-title'
91
+ headLabel.textContent = '节点'
92
+ const sel = document.createElement('select')
93
+ sel.className = 'p-nodesel'
94
+ sel.setAttribute('aria-label', '选择 host 或对等节点')
95
+ const localOpt = document.createElement('option')
96
+ localOpt.value = ''
97
+ localOpt.textContent = '本机'
98
+ sel.appendChild(localOpt)
99
+ for (const [host, rows] of d.facts().remoteTeams) {
100
+ if (host === '') continue
101
+ const opt = document.createElement('option')
102
+ opt.value = host
103
+ opt.textContent = rows[0]?.origin !== undefined && rows[0].origin !== '' ? `${rows[0].origin}(${host})` : host
104
+ sel.appendChild(opt)
105
+ }
106
+ if (nodeHost !== '' && !d.facts().remoteTeams.has(nodeHost)) nodeHost = ''
107
+ sel.value = nodeHost
108
+ sel.addEventListener('change', () => { nodeHost = sel.value; renderNodePanel() })
109
+ head.append(headLabel, sel)
110
+ const body = document.createElement('div')
111
+ body.className = 'p-nodelist-body'
112
+ el.append(head, body)
113
+ if (nodeHost === '') renderLocalNodeGroups(body)
114
+ else renderRemoteNodeGroups(body, d.facts().remoteTeams.get(nodeHost) ?? [])
115
+ }
116
+
117
+ function nodeRowDot(live: boolean | undefined, joined: boolean): HTMLSpanElement {
118
+ const dot = document.createElement('span')
119
+ dot.className = 'p-nodedot' + (joined !== true ? ' off' : live === false ? ' cold' : '')
120
+ return dot
121
+ }
122
+
123
+ function listBtn(label: string, onPick: () => void): HTMLButtonElement {
124
+ const b = document.createElement('button')
125
+ b.type = 'button'
126
+ b.className = 'p-listbtn'
127
+ b.textContent = label
128
+ b.addEventListener('click', (ev) => { ev.stopPropagation(); onPick() })
129
+ return b
130
+ }
131
+
132
+ function groupTitle(text: string, note?: string): HTMLDivElement {
133
+ const t = document.createElement('div')
134
+ t.className = 'p-nodelist-group'
135
+ t.textContent = text
136
+ if (note !== undefined) {
137
+ const n = document.createElement('span')
138
+ n.className = 'p-nodelist-note'
139
+ n.textContent = note
140
+ t.appendChild(n)
141
+ }
142
+ return t
143
+ }
144
+
145
+ /** 入队▸ submenu: existing canvas teams, plus 新团队… for the bootstrap case. */
146
+ function openNodeJoinMenu(anchor: HTMLElement, id: string): void {
147
+ closeNodeJoinMenu()
148
+ const el = document.createElement('div')
149
+ el.className = 'p-menu'
150
+ el.setAttribute('role', 'menu')
151
+ el.setAttribute('aria-label', '加入团队')
152
+ for (const t of d.facts().teams) {
153
+ const already = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
154
+ el.appendChild(d.menuItem(already ? `${t.name}(已加入)` : t.name, !already, () => {
155
+ closeNodePanel()
156
+ d.emit({ type: 'add-member', team: t.name, ids: [id] })
157
+ }))
158
+ }
159
+ el.appendChild(d.menuItem('新团队…', true, () => {
160
+ closeNodePanel()
161
+ d.openNameDialog([id])
162
+ }))
163
+ nodeJoinMenu = el
164
+ root.appendChild(el)
165
+ const rect = anchor.getBoundingClientRect()
166
+ el.style.left = `${String(Math.min(rect.right + 4, window.innerWidth - 180))}px`
167
+ el.style.top = `${String(Math.max(8, rect.top - 60))}px`
168
+ el.addEventListener('keydown', (ev) => {
169
+ if ((ev as KeyboardEvent).key === 'Escape') { ev.stopPropagation(); closeNodeJoinMenu(); anchor.focus() }
170
+ })
171
+ el.querySelector('button')?.focus()
172
+ }
173
+
174
+ function renderLocalNodeGroups(body: HTMLDivElement): void {
175
+ const teamed = new Set<string>(d.facts().canvasMemberIds)
176
+ for (const s of d.facts().sessions) if (s.teams !== undefined && s.teams.length > 0) teamed.add(s.id)
177
+ const unteamed = d.facts().sessions.filter(s => s.joined === true && !teamed.has(s.id))
178
+ const unjoined = d.facts().sessions.filter(s => s.joined !== true)
179
+ const teamedRows = d.facts().sessions.filter(s => s.joined === true && teamed.has(s.id))
180
+
181
+ body.appendChild(groupTitle(`未组队 (${String(unteamed.length)})`, '无网络能力'))
182
+ for (const s of unteamed) {
183
+ const row = document.createElement('div')
184
+ row.className = 'p-noderow'
185
+ const name = document.createElement('span')
186
+ name.className = 'p-nodename'
187
+ name.textContent = s.name !== undefined && s.name !== '' ? s.name : s.label
188
+ name.title = s.team
189
+ const tag = document.createElement('span')
190
+ tag.className = 'p-nonet'
191
+ tag.textContent = '无网络能力'
192
+ const joinBtn = listBtn('入队▸', () => openNodeJoinMenu(joinBtn, s.id))
193
+ row.append(nodeRowDot(s.live, true), name, tag, joinBtn,
194
+ listBtn('退网', () => { closeNodePanel(); d.emit({ type: 'leave-network', id: s.id }) }))
195
+ body.appendChild(row)
196
+ }
197
+
198
+ body.appendChild(groupTitle(`未入网 (${String(unjoined.length)})`))
199
+ for (const s of unjoined) {
200
+ const row = document.createElement('div')
201
+ row.className = 'p-noderow'
202
+ const name = document.createElement('span')
203
+ name.className = 'p-nodename'
204
+ name.textContent = s.name !== undefined && s.name !== '' ? s.name : s.label
205
+ name.title = s.id
206
+ row.append(nodeRowDot(s.live, false), name,
207
+ listBtn('入网', () => { closeNodePanel(); d.emit({ type: 'join-network', id: s.id }) }))
208
+ body.appendChild(row)
209
+ }
210
+
211
+ body.appendChild(groupTitle(`已组队 (${String(teamedRows.length)})`, '画布展示'))
212
+ for (const s of teamedRows) {
213
+ const row = document.createElement('div')
214
+ row.className = 'p-noderow'
215
+ const name = document.createElement('span')
216
+ name.className = 'p-nodename'
217
+ name.textContent = s.name !== undefined && s.name !== '' ? s.name : s.label
218
+ name.title = s.team
219
+ const canvasCount = model.getNode(s.id)?.memberships.length ?? 0
220
+ const rosterCount = s.teams?.length ?? 0
221
+ const badge = document.createElement('span')
222
+ badge.className = 'p-teamcount'
223
+ badge.textContent = `队×${String(Math.max(canvasCount, 0) + rosterCount)}`
224
+ row.append(nodeRowDot(s.live, true), name, badge,
225
+ listBtn('定位', () => { d.locate(s.id) }))
226
+ body.appendChild(row)
227
+ }
228
+ if (d.facts().sessions.length === 0) {
229
+ const empty = document.createElement('div')
230
+ empty.className = 'p-nodelist-empty'
231
+ empty.textContent = '无会话节点'
232
+ body.appendChild(empty)
233
+ }
234
+ }
235
+
236
+ function renderRemoteNodeGroups(body: HTMLDivElement, rows: ReadonlyArray<RemoteTeamRow>): void {
237
+ const unteamed = rows.filter(r => r.teams === undefined || r.teams.length === 0)
238
+ const teamedRows = rows.filter(r => r.teams !== undefined && r.teams.length > 0)
239
+ body.appendChild(groupTitle(`未组队 (${String(unteamed.length)})`, '无网络能力 · 只读'))
240
+ for (const r of unteamed) {
241
+ const row = document.createElement('div')
242
+ row.className = 'p-noderow'
243
+ const name = document.createElement('span')
244
+ name.className = 'p-nodename'
245
+ name.textContent = r.name !== undefined && r.name !== '' ? r.name : r.team
246
+ name.title = r.team
247
+ const tag = document.createElement('span')
248
+ tag.className = 'p-nonet'
249
+ tag.textContent = '无网络能力'
250
+ row.append(nodeRowDot(true, true), name, tag)
251
+ body.appendChild(row)
252
+ }
253
+ body.appendChild(groupTitle(`已组队 (${String(teamedRows.length)})`, '只读'))
254
+ for (const r of teamedRows) {
255
+ const row = document.createElement('div')
256
+ row.className = 'p-noderow'
257
+ const name = document.createElement('span')
258
+ name.className = 'p-nodename'
259
+ name.textContent = r.name !== undefined && r.name !== '' ? r.name : r.team
260
+ name.title = r.team
261
+ const badge = document.createElement('span')
262
+ badge.className = 'p-teamcount'
263
+ badge.textContent = `队×${String(r.teams?.length ?? 0)}`
264
+ row.append(nodeRowDot(true, true), name, badge)
265
+ body.appendChild(row)
266
+ }
267
+ if (rows.length === 0) {
268
+ const empty = document.createElement('div')
269
+ empty.className = 'p-nodelist-empty'
270
+ empty.textContent = '该节点未发布会话'
271
+ body.appendChild(empty)
272
+ }
273
+ }
274
+
275
+ // Outside-click dismissal for the node panel: the popover appends to root
276
+ // (scoped .p-menu styles apply; no transformed ancestor), so dismissal
277
+ // rides a document listener — the canvas pointer seam never sees presses
278
+ // on body-level chrome. The panel, its 入队 submenu, and the trigger are
279
+ // exempt.
280
+ document.addEventListener('pointerdown', (ev) => {
281
+ if (nodePanel === null) return
282
+ const t = ev.target as Element | null
283
+ if (t === null) { closeNodePanel(); return }
284
+ if (t.closest('.p-nodelist') !== null || t.closest('.p-menu') !== null) return
285
+ if (t === nodesBtn || nodesBtn.contains(t)) return
286
+ closeNodePanel()
287
+ }, signal)
288
+
289
+ return {
290
+ toggle: toggleNodePanel,
291
+ close: closeNodePanel,
292
+ refresh: () => { if (nodePanel !== null) renderNodePanel() },
293
+ }
294
+ }