@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,422 +1,422 @@
1
- /**
2
- * The planning board's popovers: the 建队 name dialog (focus-trapped modal),
3
- * the node / frame-head context menu (single-level drill-down, full
4
- * keyboard parity) and the 未入网 dropdown on the toolbar. Split out of
5
- * planning-view (which keeps gestures, rendering and reconcile); the view
6
- * hands in the model, its element anchors and the write channel, and asks
7
- * back only through the returned surface.
8
- * @module nexus-stage/planning-menus
9
- */
10
- import { reorderOps, type CanvasAction } from './canvas-ops'
11
- import { rosterFrameName, rosterFrameTitle } from './planning-roster'
12
- import type { PlanningSession, PlanningTeam } from './planning-types'
13
- import type { WorldModel } from './world'
14
-
15
- export type MenuTarget = { kind: 'node'; id: string } | { kind: 'frame'; name: string }
16
- export type MenuLevel = 'root' | 'join' | 'leave' | 'promote'
17
-
18
- export interface MenusDeps {
19
- readonly root: HTMLElement
20
- /** The toolbar's 未入网 chip: the dropdown anchors to it and it shows the count. */
21
- readonly netBtn: HTMLButtonElement
22
- readonly signal: AddEventListenerOptions
23
- readonly model: WorldModel
24
- /** Canvas teams from the latest poll. */
25
- teams(): ReadonlyArray<PlanningTeam>
26
- joined(id: string): boolean
27
- isRoster(frame: string): boolean
28
- /** The card or frame-head element a menu re-focuses on close. */
29
- anchorFor(target: MenuTarget): HTMLElement | null
30
- emit(a: CanvasAction): void
31
- notice(kind: 'error' | 'info', text: string): void
32
- }
33
-
34
- export interface PlanningMenus {
35
- menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement
36
- isMenuOpen(): boolean
37
- isDialogOpen(): boolean
38
- closeMenu(): void
39
- openMenu(x: number, y: number, target: MenuTarget, level?: MenuLevel, anchor?: HTMLElement): void
40
- /** Context menu from a real contextmenu event or the keyboard equivalent. */
41
- contextMenuAt(x: number, y: number, target: Element | null): void
42
- openNameDialog(ids: readonly string[]): void
43
- startCreateFromSelection(): void
44
- toggleNetMenu(): void
45
- /** Latest unjoined sessions: refreshes the chip's count and closes an emptied dropdown. */
46
- setUnjoined(sessions: ReadonlyArray<PlanningSession>): void
47
- }
48
-
49
- export function createPlanningMenus(d: MenusDeps): PlanningMenus {
50
- const { root, netBtn, signal, model } = d
51
- const titleOf = (frame: string): string => d.isRoster(frame) ? rosterFrameTitle(frame) : frame
52
-
53
- // ── 建队:selection → 命名对话框 → create-team ──
54
- function selectedByY(): string[] {
55
- return model
56
- .selectedIds()
57
- .map(id => ({ id, y: model.getNode(id)?.y ?? 0 }))
58
- .sort((a, b) => a.y - b.y)
59
- .map(r => r.id)
60
- }
61
-
62
- function startCreateFromSelection(): void {
63
- if (dialog !== null) return
64
- // gh#20 W-B: a mixed local+remote selection composes a cross-host team —
65
- // peers ride their zone/id8 handle at the server boundary. A peer
66
- // without a handle yet (no remote team rows) cannot join; drop it and
67
- // let the ≥2 floor judge the rest.
68
- const ids = selectedByY().filter(id => {
69
- const node = model.getNode(id)
70
- if (node === undefined) return false
71
- return node.remote !== true || (node.remoteHandle !== undefined && node.remoteHandle !== '')
72
- })
73
- if (ids.length < 2) { d.notice('info', '框选至少 2 个会话节点'); return }
74
- openNameDialog(ids)
75
- }
76
-
77
- let dialog: {
78
- wrap: HTMLDivElement
79
- restoreFocus: HTMLElement | null
80
- } | null = null
81
-
82
- function openNameDialog(ids: readonly string[]): void {
83
- const wrap = document.createElement('div')
84
- wrap.className = 'p-dialog'
85
- const panel = document.createElement('div')
86
- panel.className = 'p-dialog-panel'
87
- panel.setAttribute('role', 'dialog')
88
- panel.setAttribute('aria-modal', 'true')
89
- panel.setAttribute('aria-labelledby', 'p-dialog-title')
90
- const title = document.createElement('div')
91
- title.id = 'p-dialog-title'
92
- title.className = 'p-dialog-title'
93
- title.textContent = `组建团队(${ids.length} 个节点,自上而下即优先级)`
94
- // Team type: a visible two-way toggle (a bare <select> read as a label and roster went unnoticed).
95
- const kind = { value: 'canvas' as 'canvas' | 'roster' }
96
- const kindGroup = document.createElement('div')
97
- kindGroup.className = 'p-dialog-kind'
98
- kindGroup.setAttribute('role', 'radiogroup')
99
- kindGroup.setAttribute('aria-label', '团队类型')
100
- const kindHint = document.createElement('div')
101
- kindHint.className = 'p-dialog-hint'
102
- const KIND_HINT = {
103
- canvas: 'canvas 队:画布上的协作组,可以加入远端成员。',
104
- roster: 'roster 网络队:只收本机已入网的会话;队名会写进属主的精确允许名单。',
105
- } as const
106
- const kindButtons = (['canvas', 'roster'] as const).map(value => {
107
- const b = document.createElement('button')
108
- b.type = 'button'
109
- b.dataset.kind = value
110
- b.setAttribute('role', 'radio')
111
- b.textContent = value === 'canvas' ? 'canvas 队' : 'roster 网络队'
112
- b.addEventListener('click', () => setKind(value))
113
- kindGroup.appendChild(b)
114
- return b
115
- })
116
- function setKind(value: 'canvas' | 'roster'): void {
117
- kind.value = value
118
- for (const b of kindButtons) {
119
- const on = b.dataset.kind === value
120
- b.setAttribute('aria-checked', String(on))
121
- b.tabIndex = on ? 0 : -1
122
- }
123
- kindHint.textContent = KIND_HINT[value]
124
- err.textContent = ''
125
- }
126
- // Arrow keys move between the two options (radiogroup pattern).
127
- kindGroup.addEventListener('keydown', (ev) => {
128
- const e = ev as KeyboardEvent
129
- if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return
130
- e.preventDefault()
131
- setKind(kind.value === 'canvas' ? 'roster' : 'canvas')
132
- kindButtons.find(b => b.dataset.kind === kind.value)?.focus()
133
- })
134
- const input = document.createElement('input')
135
- input.className = 'p-dialog-input'
136
- input.maxLength = 40
137
- input.setAttribute('aria-label', '团队名')
138
- const err = document.createElement('div')
139
- err.className = 'p-dialog-err'
140
- err.setAttribute('aria-live', 'polite')
141
- const row = document.createElement('div')
142
- row.className = 'p-dialog-row'
143
- const okBtn = document.createElement('button')
144
- okBtn.type = 'button'
145
- okBtn.textContent = '组建'
146
- const cancelBtn = document.createElement('button')
147
- cancelBtn.type = 'button'
148
- cancelBtn.textContent = '取消'
149
- row.append(okBtn, cancelBtn)
150
- setKind('canvas')
151
- panel.append(title, kindGroup, kindHint, input, err, row)
152
- wrap.appendChild(panel)
153
- root.appendChild(wrap)
154
-
155
- const restoreFocus = document.activeElement as HTMLElement | null
156
- const close = (): void => {
157
- wrap.remove()
158
- dialog = null
159
- restoreFocus?.focus()
160
- }
161
- const nameError = (): string | null => {
162
- const name = input.value.trim()
163
- if (name === '' || name.length > 40 || name.includes('/') || name.startsWith('roster:') || (kind.value === 'roster' && name.includes('*')) || /^\d+$/.test(name)) return '队名需 1..40 字,不含“/”、通配符或保留前缀,不为纯数字'
164
- if (kind.value === 'roster' && model.getFrame(name) !== undefined) return '同名 canvas 队已存在,不能创建 roster 队'
165
- if (kind.value === 'roster' && model.getFrame(rosterFrameName(name)) !== undefined && !d.isRoster(rosterFrameName(name))) return 'roster 框名已被 canvas 或镜像队占用'
166
- if (kind.value === 'canvas' && model.getFrame(rosterFrameName(name)) !== undefined) return '同名 roster 队已存在,不能创建 canvas 队'
167
- if (kind.value === 'roster' && ids.some(id => model.getNode(id)?.remote === true || !d.joined(id))) return 'roster 仅接受本机已入网的会话'
168
- return null
169
- }
170
- const confirm = (): void => {
171
- const problem = nameError()
172
- if (problem !== null) { err.textContent = problem; input.focus(); return }
173
- const name = kind.value === 'roster' ? rosterFrameName(input.value.trim()) : input.value.trim()
174
- close()
175
- if (model.getFrame(name) !== undefined) {
176
- // Duplicate name: host create is idempotent-ok, but the optimistic
177
- // create-team would WIPE the existing roster and overwrite its saved
178
- // frame rect. Join instead — same wire result, nothing destroyed.
179
- d.notice('info', `已加入现有团队「${kind.value === 'roster' ? rosterFrameTitle(name) : name}」`)
180
- d.emit({ type: 'add-member', team: name, ids })
181
- return
182
- }
183
- d.emit({ type: 'create-team', name, ids, created: true, ...(kind.value === 'roster' ? { roster: true } : {}) })
184
- }
185
- okBtn.addEventListener('click', confirm)
186
- cancelBtn.addEventListener('click', close)
187
- input.addEventListener('keydown', (ev) => {
188
- const e = ev as KeyboardEvent
189
- if (e.key === 'Enter') { e.preventDefault(); confirm() }
190
- if (e.key === 'Escape') { e.preventDefault(); close() }
191
- })
192
- // Focus trap (aria-modal must not be a false promise): Tab cycles the
193
- // three focusables; Esc anywhere in the panel cancels.
194
- wrap.addEventListener('keydown', (ev) => {
195
- const e = ev as KeyboardEvent
196
- if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return }
197
- if (e.key !== 'Tab') return
198
- e.preventDefault()
199
- const focusables = Array.from(panel.querySelectorAll<HTMLElement>('input, select, button:not([tabindex="-1"])'))
200
- const idx = focusables.indexOf(document.activeElement as HTMLElement)
201
- const next = e.shiftKey
202
- ? focusables[(idx - 1 + focusables.length) % focusables.length]
203
- : focusables[(idx + 1) % focusables.length]
204
- next?.focus()
205
- }, signal)
206
- dialog = { wrap, restoreFocus }
207
- input.focus()
208
- }
209
-
210
- // ── context menu(节点/框头;下钻单层;键盘全等)──
211
- let menu: HTMLDivElement | null = null
212
- let menuTarget: MenuTarget | undefined
213
- let menuLevel: MenuLevel = 'root'
214
- let menuAnchor: HTMLElement | null = null
215
-
216
- // ── 未入网会话 dropdown(面板瘦身后迁入的入网面)──
217
- let netMenu: HTMLDivElement | null = null
218
- let lastUnjoined: ReadonlyArray<PlanningSession> = []
219
- function closeNetMenu(): void {
220
- netMenu?.remove()
221
- netMenu = null
222
- }
223
- function toggleNetMenu(): void {
224
- if (netMenu !== null) { closeNetMenu(); return }
225
- if (lastUnjoined.length === 0) return
226
- const el = document.createElement('div')
227
- el.className = 'p-menu p-netmenu'
228
- el.setAttribute('role', 'menu')
229
- el.setAttribute('aria-label', '未入网会话')
230
- for (const s of lastUnjoined) {
231
- el.appendChild(menuItem(`${s.name !== undefined && s.name !== '' ? s.name : s.label} — 入网`, true, () => {
232
- closeNetMenu()
233
- d.emit({ type: 'join-network', id: s.id })
234
- }))
235
- }
236
- netMenu = el
237
- // Menus append to root (not document.body): the .p-menu styles are
238
- // #dsh-plan-scoped, and root has no transformed ancestor, so fixed
239
- // positioning stays viewport-relative.
240
- root.appendChild(el)
241
- const rect = netBtn.getBoundingClientRect()
242
- el.style.left = `${String(Math.min(rect.left, window.innerWidth - 220))}px`
243
- el.style.top = `${String(Math.max(8, rect.top - (lastUnjoined.length * 30 + 10)))}px`
244
- el.addEventListener('keydown', (ev) => {
245
- if ((ev as KeyboardEvent).key === 'Escape') {
246
- ev.stopPropagation()
247
- closeNetMenu()
248
- netBtn.focus()
249
- }
250
- })
251
- el.querySelector('button')?.focus()
252
- // Outside-click dismissal: the dropdown lives at root level, so canvas
253
- // presses never reach a menu-local handler — and focus leaves with the
254
- // first outside click, killing the Escape path too (the stuck-dropdown
255
- // defect the owner hit). The trigger button stays exempt so its click
256
- // still toggles.
257
- document.addEventListener('pointerdown', (ev) => {
258
- if (netMenu === null) return
259
- const t = ev.target as Element | null
260
- if (t !== null && (t.closest('.p-netmenu') !== null || t === netBtn || netBtn.contains(t))) return
261
- closeNetMenu()
262
- }, signal)
263
- }
264
-
265
- function closeMenu(): void {
266
- menu?.remove()
267
- menu = null
268
- menuTarget = undefined
269
- menuLevel = 'root'
270
- // The anchor may have been detached by a poll while the menu was open —
271
- // focus() on a detached node drops focus to body and the canvas goes deaf.
272
- if (menuAnchor !== null && menuAnchor.isConnected) menuAnchor.focus()
273
- else root.focus()
274
- menuAnchor = null
275
- }
276
-
277
- function menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement {
278
- const b = document.createElement('button')
279
- b.type = 'button'
280
- b.setAttribute('role', 'menuitem')
281
- b.textContent = label
282
- if (!enabled) { b.disabled = true; b.setAttribute('aria-disabled', 'true') }
283
- else b.addEventListener('click', () => { closeMenu(); onPick() })
284
- return b
285
- }
286
-
287
- function openMenu(x: number, y: number, target: MenuTarget, level: MenuLevel = 'root', anchor?: HTMLElement): void {
288
- // Re-seed the anchor after a drill-down: closeMenu focuses whatever this
289
- // menu was anchored to, or keyboard focus falls to body and the canvas
290
- // goes deaf.
291
- if (anchor !== undefined) menuAnchor = anchor
292
- if (menuAnchor === null) menuAnchor = d.anchorFor(target)
293
- menu?.remove()
294
- menuTarget = target
295
- menuLevel = level
296
- const el = document.createElement('div')
297
- el.className = 'p-menu'
298
- el.setAttribute('role', 'menu')
299
- el.setAttribute('aria-label', '团队操作')
300
- let items: HTMLButtonElement[] = []
301
- if (target.kind === 'frame') {
302
- items = [menuItem(`解散团队「${titleOf(target.name)}」`, true, () => d.emit({ type: 'remove-team', name: target.name }))]
303
- } else {
304
- const id = target.id
305
- if (level === 'root') {
306
- const create = menuItem('组成团队…', model.selectedIds().length >= 2, () => openNameDialog(selectedByY()))
307
- create.title = '先框选或 Shift 加选至少 2 个节点'
308
- items.push(create)
309
- const teamsOf = model.getNode(id)?.memberships.map(m => m.team) ?? []
310
- if (d.teams().length > 0) {
311
- items.push(menuItem('加入团队 ▸', true, () => openMenu(x, y, target, 'join')))
312
- if (teamsOf.length > 0) items.push(menuItem('置顶路由 ▸', true, () => openMenu(x, y, target, 'promote')))
313
- }
314
- // A card that is only in mirror frames (a peer's team) still has a team to leave.
315
- if (teamsOf.length > 0) items.push(menuItem('离队 ▸', true, () => openMenu(x, y, target, 'leave')))
316
- // Network-level leave (distinct from canvas-team membership): the
317
- // panel-slim migration moved the join/leave toggle here. Remote
318
- // nodes are read-only entries — never a leave target.
319
- if (model.getNode(id)?.remote !== true) {
320
- items.push(menuItem('退网', true, () => d.emit({ type: 'leave-network', id })))
321
- }
322
- } else {
323
- items.push(menuItem('‹ 返回', true, () => openMenu(x, y, target, 'root')))
324
- for (const t of d.teams()) {
325
- if (level === 'join') {
326
- const already = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? true
327
- const allowed = !d.isRoster(t.name) || (d.joined(id) && model.getNode(id)?.remote !== true)
328
- const item = menuItem(already ? `${titleOf(t.name)}(已加入)` : titleOf(t.name), !already && allowed, () => d.emit({ type: 'add-member', team: t.name, ids: [id] }))
329
- if (!allowed) item.title = 'roster 只接受本机已入网会话'
330
- items.push(item)
331
- } else if (level === 'leave') {
332
- const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
333
- items.push(menuItem(titleOf(t.name), member, () => d.emit({ type: 'remove-member', team: t.name, ids: [id] })))
334
- } else {
335
- const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
336
- if (d.isRoster(t.name)) continue
337
- items.push(menuItem(titleOf(t.name), member, () => {
338
- const current = model.teamMemberIds(t.name)
339
- const desired = [id, ...current.filter(x => x !== id)]
340
- d.emit({ type: 'reorder', team: t.name, ops: reorderOps(current, desired) })
341
- }))
342
- }
343
- }
344
- // d.teams() is this host's own teams; a mirror frame's membership leaves through
345
- // the owner's relay (the view adds fleetPeer), so list those memberships too.
346
- if (level === 'leave') {
347
- const local = new Set(d.teams().map(t => t.name))
348
- for (const m of model.getNode(id)?.memberships ?? []) {
349
- if (!local.has(m.team)) items.push(menuItem(titleOf(m.team), true, () => d.emit({ type: 'remove-member', team: m.team, ids: [id] })))
350
- }
351
- }
352
- }
353
- }
354
- el.append(...items)
355
- el.style.left = `${Math.min(x, window.innerWidth - 200)}px`
356
- const menuH = items.length * 30 + 8
357
- el.style.top = `${Math.max(8, Math.min(y, window.innerHeight - menuH - 8))}px`
358
- el.addEventListener('keydown', (ev) => {
359
- const e = ev as KeyboardEvent
360
- const buttons = Array.from(el.querySelectorAll<HTMLButtonElement>('[role=menuitem]'))
361
- const idx = buttons.indexOf(document.activeElement as HTMLButtonElement)
362
- if (e.key === 'ArrowDown') { e.preventDefault(); buttons[(idx + 1 + buttons.length) % buttons.length]?.focus() }
363
- else if (e.key === 'ArrowUp') { e.preventDefault(); buttons[(idx - 1 + buttons.length) % buttons.length]?.focus() }
364
- else if (e.key === 'Home') { e.preventDefault(); buttons[0]?.focus() }
365
- else if (e.key === 'End') { e.preventDefault(); buttons[buttons.length - 1]?.focus() }
366
- else if (e.key === 'Escape') {
367
- e.preventDefault()
368
- e.stopPropagation() // drill-back is menu-internal; root must not double-close
369
- if (menuLevel !== 'root') openMenu(x, y, target, 'root')
370
- else closeMenu()
371
- }
372
- })
373
- root.appendChild(el)
374
- menu = el
375
- items[0]?.focus()
376
- }
377
-
378
- /** Context menu from a real contextmenu event or the keyboard equivalent. */
379
- function contextMenuAt(x: number, y: number, target: Element | null): void {
380
- if (dialog !== null) return
381
- // gh#20 W-B: a peer WITH a remote handle gets the roster menu (加入团队
382
- // composes a cross-host team through the handle boundary); a peer
383
- // WITHOUT one stays a read-only entry (nothing the server would accept).
384
- const probeNode = target?.closest<HTMLElement>('.p-node') ?? null
385
- if (probeNode !== null) {
386
- const probe = model.getNode(probeNode.dataset.id ?? '')
387
- if (probe?.remote === true && (probe.remoteHandle === undefined || probe.remoteHandle === '')) return
388
- }
389
- const nodeEl = probeNode
390
- const headEl = target?.closest<HTMLElement>('.p-frame-head') ?? null
391
- if (nodeEl !== null) {
392
- const id = nodeEl.dataset.id ?? ''
393
- if (!model.isSelected(id)) model.setSelection([id])
394
- menuAnchor = nodeEl
395
- openMenu(x, y, { kind: 'node', id })
396
- } else if (headEl !== null) {
397
- menuAnchor = headEl
398
- openMenu(x, y, { kind: 'frame', name: headEl.dataset.frame ?? '' })
399
- }
400
- }
401
-
402
- function setUnjoined(sessions: ReadonlyArray<PlanningSession>): void {
403
- lastUnjoined = sessions
404
- netBtn.textContent = `未入网${lastUnjoined.length > 0 ? `(${String(lastUnjoined.length)})` : ''}`
405
- netBtn.disabled = lastUnjoined.length === 0
406
- netBtn.setAttribute('aria-disabled', String(lastUnjoined.length === 0))
407
- if (lastUnjoined.length === 0) closeNetMenu()
408
- }
409
-
410
- return {
411
- menuItem,
412
- isMenuOpen: () => menu !== null,
413
- isDialogOpen: () => dialog !== null,
414
- closeMenu,
415
- openMenu,
416
- contextMenuAt,
417
- openNameDialog,
418
- startCreateFromSelection,
419
- toggleNetMenu,
420
- setUnjoined,
421
- }
422
- }
1
+ /**
2
+ * The planning board's popovers: the 建队 name dialog (focus-trapped modal),
3
+ * the node / frame-head context menu (single-level drill-down, full
4
+ * keyboard parity) and the 未入网 dropdown on the toolbar. Split out of
5
+ * planning-view (which keeps gestures, rendering and reconcile); the view
6
+ * hands in the model, its element anchors and the write channel, and asks
7
+ * back only through the returned surface.
8
+ * @module nexus-stage/planning-menus
9
+ */
10
+ import { reorderOps, type CanvasAction } from './canvas-ops'
11
+ import { rosterFrameName, rosterFrameTitle } from './planning-roster'
12
+ import type { PlanningSession, PlanningTeam } from './planning-types'
13
+ import type { WorldModel } from './world'
14
+
15
+ export type MenuTarget = { kind: 'node'; id: string } | { kind: 'frame'; name: string }
16
+ export type MenuLevel = 'root' | 'join' | 'leave' | 'promote'
17
+
18
+ export interface MenusDeps {
19
+ readonly root: HTMLElement
20
+ /** The toolbar's 未入网 chip: the dropdown anchors to it and it shows the count. */
21
+ readonly netBtn: HTMLButtonElement
22
+ readonly signal: AddEventListenerOptions
23
+ readonly model: WorldModel
24
+ /** Canvas teams from the latest poll. */
25
+ teams(): ReadonlyArray<PlanningTeam>
26
+ joined(id: string): boolean
27
+ isRoster(frame: string): boolean
28
+ /** The card or frame-head element a menu re-focuses on close. */
29
+ anchorFor(target: MenuTarget): HTMLElement | null
30
+ emit(a: CanvasAction): void
31
+ notice(kind: 'error' | 'info', text: string): void
32
+ }
33
+
34
+ export interface PlanningMenus {
35
+ menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement
36
+ isMenuOpen(): boolean
37
+ isDialogOpen(): boolean
38
+ closeMenu(): void
39
+ openMenu(x: number, y: number, target: MenuTarget, level?: MenuLevel, anchor?: HTMLElement): void
40
+ /** Context menu from a real contextmenu event or the keyboard equivalent. */
41
+ contextMenuAt(x: number, y: number, target: Element | null): void
42
+ openNameDialog(ids: readonly string[]): void
43
+ startCreateFromSelection(): void
44
+ toggleNetMenu(): void
45
+ /** Latest unjoined sessions: refreshes the chip's count and closes an emptied dropdown. */
46
+ setUnjoined(sessions: ReadonlyArray<PlanningSession>): void
47
+ }
48
+
49
+ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
50
+ const { root, netBtn, signal, model } = d
51
+ const titleOf = (frame: string): string => d.isRoster(frame) ? rosterFrameTitle(frame) : frame
52
+
53
+ // ── 建队:selection → 命名对话框 → create-team ──
54
+ function selectedByY(): string[] {
55
+ return model
56
+ .selectedIds()
57
+ .map(id => ({ id, y: model.getNode(id)?.y ?? 0 }))
58
+ .sort((a, b) => a.y - b.y)
59
+ .map(r => r.id)
60
+ }
61
+
62
+ function startCreateFromSelection(): void {
63
+ if (dialog !== null) return
64
+ // gh#20 W-B: a mixed local+remote selection composes a cross-host team —
65
+ // peers ride their zone/id8 handle at the server boundary. A peer
66
+ // without a handle yet (no remote team rows) cannot join; drop it and
67
+ // let the ≥2 floor judge the rest.
68
+ const ids = selectedByY().filter(id => {
69
+ const node = model.getNode(id)
70
+ if (node === undefined) return false
71
+ return node.remote !== true || (node.remoteHandle !== undefined && node.remoteHandle !== '')
72
+ })
73
+ if (ids.length < 2) { d.notice('info', '框选至少 2 个会话节点'); return }
74
+ openNameDialog(ids)
75
+ }
76
+
77
+ let dialog: {
78
+ wrap: HTMLDivElement
79
+ restoreFocus: HTMLElement | null
80
+ } | null = null
81
+
82
+ function openNameDialog(ids: readonly string[]): void {
83
+ const wrap = document.createElement('div')
84
+ wrap.className = 'p-dialog'
85
+ const panel = document.createElement('div')
86
+ panel.className = 'p-dialog-panel'
87
+ panel.setAttribute('role', 'dialog')
88
+ panel.setAttribute('aria-modal', 'true')
89
+ panel.setAttribute('aria-labelledby', 'p-dialog-title')
90
+ const title = document.createElement('div')
91
+ title.id = 'p-dialog-title'
92
+ title.className = 'p-dialog-title'
93
+ title.textContent = `组建团队(${ids.length} 个节点,自上而下即优先级)`
94
+ // Team type: a visible two-way toggle (a bare <select> read as a label and roster went unnoticed).
95
+ const kind = { value: 'canvas' as 'canvas' | 'roster' }
96
+ const kindGroup = document.createElement('div')
97
+ kindGroup.className = 'p-dialog-kind'
98
+ kindGroup.setAttribute('role', 'radiogroup')
99
+ kindGroup.setAttribute('aria-label', '团队类型')
100
+ const kindHint = document.createElement('div')
101
+ kindHint.className = 'p-dialog-hint'
102
+ const KIND_HINT = {
103
+ canvas: 'canvas 队:画布上的协作组,可以加入远端成员。',
104
+ roster: 'roster 网络队:只收本机已入网的会话;队名会写进属主的精确允许名单。',
105
+ } as const
106
+ const kindButtons = (['canvas', 'roster'] as const).map(value => {
107
+ const b = document.createElement('button')
108
+ b.type = 'button'
109
+ b.dataset.kind = value
110
+ b.setAttribute('role', 'radio')
111
+ b.textContent = value === 'canvas' ? 'canvas 队' : 'roster 网络队'
112
+ b.addEventListener('click', () => setKind(value))
113
+ kindGroup.appendChild(b)
114
+ return b
115
+ })
116
+ function setKind(value: 'canvas' | 'roster'): void {
117
+ kind.value = value
118
+ for (const b of kindButtons) {
119
+ const on = b.dataset.kind === value
120
+ b.setAttribute('aria-checked', String(on))
121
+ b.tabIndex = on ? 0 : -1
122
+ }
123
+ kindHint.textContent = KIND_HINT[value]
124
+ err.textContent = ''
125
+ }
126
+ // Arrow keys move between the two options (radiogroup pattern).
127
+ kindGroup.addEventListener('keydown', (ev) => {
128
+ const e = ev as KeyboardEvent
129
+ if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return
130
+ e.preventDefault()
131
+ setKind(kind.value === 'canvas' ? 'roster' : 'canvas')
132
+ kindButtons.find(b => b.dataset.kind === kind.value)?.focus()
133
+ })
134
+ const input = document.createElement('input')
135
+ input.className = 'p-dialog-input'
136
+ input.maxLength = 40
137
+ input.setAttribute('aria-label', '团队名')
138
+ const err = document.createElement('div')
139
+ err.className = 'p-dialog-err'
140
+ err.setAttribute('aria-live', 'polite')
141
+ const row = document.createElement('div')
142
+ row.className = 'p-dialog-row'
143
+ const okBtn = document.createElement('button')
144
+ okBtn.type = 'button'
145
+ okBtn.textContent = '组建'
146
+ const cancelBtn = document.createElement('button')
147
+ cancelBtn.type = 'button'
148
+ cancelBtn.textContent = '取消'
149
+ row.append(okBtn, cancelBtn)
150
+ setKind('canvas')
151
+ panel.append(title, kindGroup, kindHint, input, err, row)
152
+ wrap.appendChild(panel)
153
+ root.appendChild(wrap)
154
+
155
+ const restoreFocus = document.activeElement as HTMLElement | null
156
+ const close = (): void => {
157
+ wrap.remove()
158
+ dialog = null
159
+ restoreFocus?.focus()
160
+ }
161
+ const nameError = (): string | null => {
162
+ const name = input.value.trim()
163
+ if (name === '' || name.length > 40 || name.includes('/') || name.startsWith('roster:') || (kind.value === 'roster' && name.includes('*')) || /^\d+$/.test(name)) return '队名需 1..40 字,不含“/”、通配符或保留前缀,不为纯数字'
164
+ if (kind.value === 'roster' && model.getFrame(name) !== undefined) return '同名 canvas 队已存在,不能创建 roster 队'
165
+ if (kind.value === 'roster' && model.getFrame(rosterFrameName(name)) !== undefined && !d.isRoster(rosterFrameName(name))) return 'roster 框名已被 canvas 或镜像队占用'
166
+ if (kind.value === 'canvas' && model.getFrame(rosterFrameName(name)) !== undefined) return '同名 roster 队已存在,不能创建 canvas 队'
167
+ if (kind.value === 'roster' && ids.some(id => model.getNode(id)?.remote === true || !d.joined(id))) return 'roster 仅接受本机已入网的会话'
168
+ return null
169
+ }
170
+ const confirm = (): void => {
171
+ const problem = nameError()
172
+ if (problem !== null) { err.textContent = problem; input.focus(); return }
173
+ const name = kind.value === 'roster' ? rosterFrameName(input.value.trim()) : input.value.trim()
174
+ close()
175
+ if (model.getFrame(name) !== undefined) {
176
+ // Duplicate name: host create is idempotent-ok, but the optimistic
177
+ // create-team would WIPE the existing roster and overwrite its saved
178
+ // frame rect. Join instead — same wire result, nothing destroyed.
179
+ d.notice('info', `已加入现有团队「${kind.value === 'roster' ? rosterFrameTitle(name) : name}」`)
180
+ d.emit({ type: 'add-member', team: name, ids })
181
+ return
182
+ }
183
+ d.emit({ type: 'create-team', name, ids, created: true, ...(kind.value === 'roster' ? { roster: true } : {}) })
184
+ }
185
+ okBtn.addEventListener('click', confirm)
186
+ cancelBtn.addEventListener('click', close)
187
+ input.addEventListener('keydown', (ev) => {
188
+ const e = ev as KeyboardEvent
189
+ if (e.key === 'Enter') { e.preventDefault(); confirm() }
190
+ if (e.key === 'Escape') { e.preventDefault(); close() }
191
+ })
192
+ // Focus trap (aria-modal must not be a false promise): Tab cycles the
193
+ // three focusables; Esc anywhere in the panel cancels.
194
+ wrap.addEventListener('keydown', (ev) => {
195
+ const e = ev as KeyboardEvent
196
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return }
197
+ if (e.key !== 'Tab') return
198
+ e.preventDefault()
199
+ const focusables = Array.from(panel.querySelectorAll<HTMLElement>('input, select, button:not([tabindex="-1"])'))
200
+ const idx = focusables.indexOf(document.activeElement as HTMLElement)
201
+ const next = e.shiftKey
202
+ ? focusables[(idx - 1 + focusables.length) % focusables.length]
203
+ : focusables[(idx + 1) % focusables.length]
204
+ next?.focus()
205
+ }, signal)
206
+ dialog = { wrap, restoreFocus }
207
+ input.focus()
208
+ }
209
+
210
+ // ── context menu(节点/框头;下钻单层;键盘全等)──
211
+ let menu: HTMLDivElement | null = null
212
+ let menuTarget: MenuTarget | undefined
213
+ let menuLevel: MenuLevel = 'root'
214
+ let menuAnchor: HTMLElement | null = null
215
+
216
+ // ── 未入网会话 dropdown(面板瘦身后迁入的入网面)──
217
+ let netMenu: HTMLDivElement | null = null
218
+ let lastUnjoined: ReadonlyArray<PlanningSession> = []
219
+ function closeNetMenu(): void {
220
+ netMenu?.remove()
221
+ netMenu = null
222
+ }
223
+ function toggleNetMenu(): void {
224
+ if (netMenu !== null) { closeNetMenu(); return }
225
+ if (lastUnjoined.length === 0) return
226
+ const el = document.createElement('div')
227
+ el.className = 'p-menu p-netmenu'
228
+ el.setAttribute('role', 'menu')
229
+ el.setAttribute('aria-label', '未入网会话')
230
+ for (const s of lastUnjoined) {
231
+ el.appendChild(menuItem(`${s.name !== undefined && s.name !== '' ? s.name : s.label} — 入网`, true, () => {
232
+ closeNetMenu()
233
+ d.emit({ type: 'join-network', id: s.id })
234
+ }))
235
+ }
236
+ netMenu = el
237
+ // Menus append to root (not document.body): the .p-menu styles are
238
+ // #dsh-plan-scoped, and root has no transformed ancestor, so fixed
239
+ // positioning stays viewport-relative.
240
+ root.appendChild(el)
241
+ const rect = netBtn.getBoundingClientRect()
242
+ el.style.left = `${String(Math.min(rect.left, window.innerWidth - 220))}px`
243
+ el.style.top = `${String(Math.max(8, rect.top - (lastUnjoined.length * 30 + 10)))}px`
244
+ el.addEventListener('keydown', (ev) => {
245
+ if ((ev as KeyboardEvent).key === 'Escape') {
246
+ ev.stopPropagation()
247
+ closeNetMenu()
248
+ netBtn.focus()
249
+ }
250
+ })
251
+ el.querySelector('button')?.focus()
252
+ // Outside-click dismissal: the dropdown lives at root level, so canvas
253
+ // presses never reach a menu-local handler — and focus leaves with the
254
+ // first outside click, killing the Escape path too (the stuck-dropdown
255
+ // defect the owner hit). The trigger button stays exempt so its click
256
+ // still toggles.
257
+ document.addEventListener('pointerdown', (ev) => {
258
+ if (netMenu === null) return
259
+ const t = ev.target as Element | null
260
+ if (t !== null && (t.closest('.p-netmenu') !== null || t === netBtn || netBtn.contains(t))) return
261
+ closeNetMenu()
262
+ }, signal)
263
+ }
264
+
265
+ function closeMenu(): void {
266
+ menu?.remove()
267
+ menu = null
268
+ menuTarget = undefined
269
+ menuLevel = 'root'
270
+ // The anchor may have been detached by a poll while the menu was open —
271
+ // focus() on a detached node drops focus to body and the canvas goes deaf.
272
+ if (menuAnchor !== null && menuAnchor.isConnected) menuAnchor.focus()
273
+ else root.focus()
274
+ menuAnchor = null
275
+ }
276
+
277
+ function menuItem(label: string, enabled: boolean, onPick: () => void): HTMLButtonElement {
278
+ const b = document.createElement('button')
279
+ b.type = 'button'
280
+ b.setAttribute('role', 'menuitem')
281
+ b.textContent = label
282
+ if (!enabled) { b.disabled = true; b.setAttribute('aria-disabled', 'true') }
283
+ else b.addEventListener('click', () => { closeMenu(); onPick() })
284
+ return b
285
+ }
286
+
287
+ function openMenu(x: number, y: number, target: MenuTarget, level: MenuLevel = 'root', anchor?: HTMLElement): void {
288
+ // Re-seed the anchor after a drill-down: closeMenu focuses whatever this
289
+ // menu was anchored to, or keyboard focus falls to body and the canvas
290
+ // goes deaf.
291
+ if (anchor !== undefined) menuAnchor = anchor
292
+ if (menuAnchor === null) menuAnchor = d.anchorFor(target)
293
+ menu?.remove()
294
+ menuTarget = target
295
+ menuLevel = level
296
+ const el = document.createElement('div')
297
+ el.className = 'p-menu'
298
+ el.setAttribute('role', 'menu')
299
+ el.setAttribute('aria-label', '团队操作')
300
+ let items: HTMLButtonElement[] = []
301
+ if (target.kind === 'frame') {
302
+ items = [menuItem(`解散团队「${titleOf(target.name)}」`, true, () => d.emit({ type: 'remove-team', name: target.name }))]
303
+ } else {
304
+ const id = target.id
305
+ if (level === 'root') {
306
+ const create = menuItem('组成团队…', model.selectedIds().length >= 2, () => openNameDialog(selectedByY()))
307
+ create.title = '先框选或 Shift 加选至少 2 个节点'
308
+ items.push(create)
309
+ const teamsOf = model.getNode(id)?.memberships.map(m => m.team) ?? []
310
+ if (d.teams().length > 0) {
311
+ items.push(menuItem('加入团队 ▸', true, () => openMenu(x, y, target, 'join')))
312
+ if (teamsOf.length > 0) items.push(menuItem('置顶路由 ▸', true, () => openMenu(x, y, target, 'promote')))
313
+ }
314
+ // A card that is only in mirror frames (a peer's team) still has a team to leave.
315
+ if (teamsOf.length > 0) items.push(menuItem('离队 ▸', true, () => openMenu(x, y, target, 'leave')))
316
+ // Network-level leave (distinct from canvas-team membership): the
317
+ // panel-slim migration moved the join/leave toggle here. Remote
318
+ // nodes are read-only entries — never a leave target.
319
+ if (model.getNode(id)?.remote !== true) {
320
+ items.push(menuItem('退网', true, () => d.emit({ type: 'leave-network', id })))
321
+ }
322
+ } else {
323
+ items.push(menuItem('‹ 返回', true, () => openMenu(x, y, target, 'root')))
324
+ for (const t of d.teams()) {
325
+ if (level === 'join') {
326
+ const already = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? true
327
+ const allowed = !d.isRoster(t.name) || (d.joined(id) && model.getNode(id)?.remote !== true)
328
+ const item = menuItem(already ? `${titleOf(t.name)}(已加入)` : titleOf(t.name), !already && allowed, () => d.emit({ type: 'add-member', team: t.name, ids: [id] }))
329
+ if (!allowed) item.title = 'roster 只接受本机已入网会话'
330
+ items.push(item)
331
+ } else if (level === 'leave') {
332
+ const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
333
+ items.push(menuItem(titleOf(t.name), member, () => d.emit({ type: 'remove-member', team: t.name, ids: [id] })))
334
+ } else {
335
+ const member = (model.getNode(id)?.memberships.some(m => m.team === t.name)) ?? false
336
+ if (d.isRoster(t.name)) continue
337
+ items.push(menuItem(titleOf(t.name), member, () => {
338
+ const current = model.teamMemberIds(t.name)
339
+ const desired = [id, ...current.filter(x => x !== id)]
340
+ d.emit({ type: 'reorder', team: t.name, ops: reorderOps(current, desired) })
341
+ }))
342
+ }
343
+ }
344
+ // d.teams() is this host's own teams; a mirror frame's membership leaves through
345
+ // the owner's relay (the view adds fleetPeer), so list those memberships too.
346
+ if (level === 'leave') {
347
+ const local = new Set(d.teams().map(t => t.name))
348
+ for (const m of model.getNode(id)?.memberships ?? []) {
349
+ if (!local.has(m.team)) items.push(menuItem(titleOf(m.team), true, () => d.emit({ type: 'remove-member', team: m.team, ids: [id] })))
350
+ }
351
+ }
352
+ }
353
+ }
354
+ el.append(...items)
355
+ el.style.left = `${Math.min(x, window.innerWidth - 200)}px`
356
+ const menuH = items.length * 30 + 8
357
+ el.style.top = `${Math.max(8, Math.min(y, window.innerHeight - menuH - 8))}px`
358
+ el.addEventListener('keydown', (ev) => {
359
+ const e = ev as KeyboardEvent
360
+ const buttons = Array.from(el.querySelectorAll<HTMLButtonElement>('[role=menuitem]'))
361
+ const idx = buttons.indexOf(document.activeElement as HTMLButtonElement)
362
+ if (e.key === 'ArrowDown') { e.preventDefault(); buttons[(idx + 1 + buttons.length) % buttons.length]?.focus() }
363
+ else if (e.key === 'ArrowUp') { e.preventDefault(); buttons[(idx - 1 + buttons.length) % buttons.length]?.focus() }
364
+ else if (e.key === 'Home') { e.preventDefault(); buttons[0]?.focus() }
365
+ else if (e.key === 'End') { e.preventDefault(); buttons[buttons.length - 1]?.focus() }
366
+ else if (e.key === 'Escape') {
367
+ e.preventDefault()
368
+ e.stopPropagation() // drill-back is menu-internal; root must not double-close
369
+ if (menuLevel !== 'root') openMenu(x, y, target, 'root')
370
+ else closeMenu()
371
+ }
372
+ })
373
+ root.appendChild(el)
374
+ menu = el
375
+ items[0]?.focus()
376
+ }
377
+
378
+ /** Context menu from a real contextmenu event or the keyboard equivalent. */
379
+ function contextMenuAt(x: number, y: number, target: Element | null): void {
380
+ if (dialog !== null) return
381
+ // gh#20 W-B: a peer WITH a remote handle gets the roster menu (加入团队
382
+ // composes a cross-host team through the handle boundary); a peer
383
+ // WITHOUT one stays a read-only entry (nothing the server would accept).
384
+ const probeNode = target?.closest<HTMLElement>('.p-node') ?? null
385
+ if (probeNode !== null) {
386
+ const probe = model.getNode(probeNode.dataset.id ?? '')
387
+ if (probe?.remote === true && (probe.remoteHandle === undefined || probe.remoteHandle === '')) return
388
+ }
389
+ const nodeEl = probeNode
390
+ const headEl = target?.closest<HTMLElement>('.p-frame-head') ?? null
391
+ if (nodeEl !== null) {
392
+ const id = nodeEl.dataset.id ?? ''
393
+ if (!model.isSelected(id)) model.setSelection([id])
394
+ menuAnchor = nodeEl
395
+ openMenu(x, y, { kind: 'node', id })
396
+ } else if (headEl !== null) {
397
+ menuAnchor = headEl
398
+ openMenu(x, y, { kind: 'frame', name: headEl.dataset.frame ?? '' })
399
+ }
400
+ }
401
+
402
+ function setUnjoined(sessions: ReadonlyArray<PlanningSession>): void {
403
+ lastUnjoined = sessions
404
+ netBtn.textContent = `未入网${lastUnjoined.length > 0 ? `(${String(lastUnjoined.length)})` : ''}`
405
+ netBtn.disabled = lastUnjoined.length === 0
406
+ netBtn.setAttribute('aria-disabled', String(lastUnjoined.length === 0))
407
+ if (lastUnjoined.length === 0) closeNetMenu()
408
+ }
409
+
410
+ return {
411
+ menuItem,
412
+ isMenuOpen: () => menu !== null,
413
+ isDialogOpen: () => dialog !== null,
414
+ closeMenu,
415
+ openMenu,
416
+ contextMenuAt,
417
+ openNameDialog,
418
+ startCreateFromSelection,
419
+ toggleNetMenu,
420
+ setUnjoined,
421
+ }
422
+ }