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

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 (61) hide show
  1. package/README.md +1 -1
  2. package/README_zh.md +1 -1
  3. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -1
  4. package/assets/stageDist/assets/index-C-28k_o5.js +974 -974
  5. package/assets/stageDist/index.html +12 -12
  6. package/lib/client.js +64 -64
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +57 -9
  9. package/lib/origin-claim.js +4 -0
  10. package/lib/skill/SKILL.md +117 -117
  11. package/lib/skill/references/collaboration.md +428 -428
  12. package/lib/skill/references/discovery.md +78 -78
  13. package/lib/skill/references/intake.md +107 -107
  14. package/lib/skill/references/membership.md +73 -73
  15. package/lib/skill/references/pitfalls.md +51 -51
  16. package/lib/skill/references/router.md +96 -96
  17. package/lib/skill/references/routing.md +65 -65
  18. package/lib/types/origin-claim.d.ts +2 -0
  19. package/nexus-stage/package.json +21 -21
  20. package/nexus-stage/scripts/mockhost.py +261 -261
  21. package/nexus-stage/src/canvas-ops.ts +191 -191
  22. package/nexus-stage/src/canvas-wire.ts +164 -164
  23. package/nexus-stage/src/cli-home-picker.ts +42 -42
  24. package/nexus-stage/src/fault.ts +56 -56
  25. package/nexus-stage/src/federation-overlay.ts +103 -103
  26. package/nexus-stage/src/federation.ts +241 -241
  27. package/nexus-stage/src/layout-doc.ts +145 -145
  28. package/nexus-stage/src/main.ts +513 -513
  29. package/nexus-stage/src/planning-card-text.ts +73 -73
  30. package/nexus-stage/src/planning-card.ts +63 -63
  31. package/nexus-stage/src/planning-edges.ts +26 -26
  32. package/nexus-stage/src/planning-fit.ts +45 -45
  33. package/nexus-stage/src/planning-frame-hue.ts +13 -13
  34. package/nexus-stage/src/planning-legend.ts +39 -39
  35. package/nexus-stage/src/planning-member-handles.ts +61 -61
  36. package/nexus-stage/src/planning-menus.ts +422 -422
  37. package/nexus-stage/src/planning-mirror.ts +75 -75
  38. package/nexus-stage/src/planning-nodelist.ts +294 -294
  39. package/nexus-stage/src/planning-overlap.ts +159 -159
  40. package/nexus-stage/src/planning-reachability.ts +28 -28
  41. package/nexus-stage/src/planning-relayout.ts +121 -121
  42. package/nexus-stage/src/planning-roster.ts +15 -15
  43. package/nexus-stage/src/planning-search.ts +168 -168
  44. package/nexus-stage/src/planning-types.ts +115 -115
  45. package/nexus-stage/src/planning-undo.ts +68 -68
  46. package/nexus-stage/src/planning-view.ts +1499 -1499
  47. package/nexus-stage/src/planning.css +340 -340
  48. package/nexus-stage/src/seat-name-editor.ts +31 -31
  49. package/nexus-stage/src/session-drag.ts +64 -64
  50. package/nexus-stage/src/viewport.ts +106 -106
  51. package/nexus-stage/src/wb-chat.ts +684 -684
  52. package/nexus-stage/src/wb-directory.ts +237 -237
  53. package/nexus-stage/src/wb-ledger.ts +279 -279
  54. package/nexus-stage/src/wb-names.ts +43 -43
  55. package/nexus-stage/src/wb-rail.ts +147 -147
  56. package/nexus-stage/src/workbench.css +388 -388
  57. package/nexus-stage/src/workbench.ts +1272 -1272
  58. package/nexus-stage/src/world.ts +509 -509
  59. package/nexus-stage/tsconfig.json +24 -24
  60. package/nexus-stage/vite.config.ts +15 -15
  61. package/package.json +1 -1
@@ -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
+ }