@nelsonlongxiang/dsh-open-a2a-net 0.6.28 → 0.6.30

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 (69) hide show
  1. package/README.md +87 -14
  2. package/README_zh.md +83 -7
  3. package/assets/nexusDist/assets/index-C_x3iAth.js +9 -0
  4. package/assets/nexusDist/assets/index-Dz8N80tI.css +1 -0
  5. package/assets/nexusDist/index.html +2 -2
  6. package/cli/a2a.mjs +0 -0
  7. package/cordis.patch.yml +21 -21
  8. package/lib/canvas-store.js +21 -1
  9. package/lib/client.js +64 -64
  10. package/lib/client.js.map +1 -1
  11. package/lib/faces/admin.js +755 -0
  12. package/lib/faces/canvas-notify.js +138 -0
  13. package/lib/faces/wire.js +669 -0
  14. package/lib/flood-guard.js +106 -0
  15. package/lib/index.js +200 -1392
  16. package/lib/recursion-guard.js +78 -0
  17. package/lib/runtime.js +1 -0
  18. package/lib/skill/SKILL.md +35 -9
  19. package/lib/skill/references/cli.md +24 -7
  20. package/lib/skill/references/collaboration.md +355 -0
  21. package/lib/skill/references/discovery.md +5 -2
  22. package/lib/skill/references/pitfalls.md +2 -1
  23. package/lib/skill/references/routing.md +18 -8
  24. package/lib/skill/references/setup.md +152 -0
  25. package/lib/task-ledger.js +40 -4
  26. package/lib/types/canvas-store.d.ts +10 -1
  27. package/lib/types/faces/admin.d.ts +133 -0
  28. package/lib/types/faces/canvas-notify.d.ts +74 -0
  29. package/lib/types/faces/wire.d.ts +171 -0
  30. package/lib/types/flood-guard.d.ts +29 -0
  31. package/lib/types/index.d.ts +11 -3
  32. package/lib/types/recursion-guard.d.ts +56 -0
  33. package/lib/types/runtime.d.ts +35 -0
  34. package/lib/types/task-ledger.d.ts +15 -1
  35. package/nexus-stage/package-lock.json +2262 -2262
  36. package/nexus-stage/src/canvas-ops.ts +175 -175
  37. package/nexus-stage/src/canvas-wire.ts +156 -156
  38. package/nexus-stage/src/edges.ts +72 -72
  39. package/nexus-stage/src/federation-overlay.ts +103 -103
  40. package/nexus-stage/src/federation.ts +240 -240
  41. package/nexus-stage/src/layout-doc.ts +143 -143
  42. package/nexus-stage/src/layout-wire.ts +158 -158
  43. package/nexus-stage/src/main.ts +5 -1
  44. package/nexus-stage/src/planning-card-text.ts +73 -0
  45. package/nexus-stage/src/planning-edges.ts +26 -0
  46. package/nexus-stage/src/planning-fit.ts +45 -0
  47. package/nexus-stage/src/planning-legend.ts +38 -0
  48. package/nexus-stage/src/planning-mirror.ts +31 -0
  49. package/nexus-stage/src/planning-overlap.ts +159 -0
  50. package/nexus-stage/src/planning-relayout.ts +121 -0
  51. package/nexus-stage/src/planning-search.ts +168 -0
  52. package/nexus-stage/src/planning-types.ts +1 -0
  53. package/nexus-stage/src/planning-undo.ts +68 -0
  54. package/nexus-stage/src/planning-view.ts +120 -121
  55. package/nexus-stage/src/planning.css +41 -8
  56. package/nexus-stage/src/seat.ts +35 -35
  57. package/nexus-stage/src/stage-name.ts +13 -13
  58. package/nexus-stage/src/tokens.ts +31 -31
  59. package/nexus-stage/src/viewport.ts +24 -1
  60. package/nexus-stage/src/wb-chat.ts +20 -6
  61. package/nexus-stage/src/wb-ledger.ts +20 -7
  62. package/nexus-stage/src/wb-names.ts +43 -0
  63. package/nexus-stage/src/workbench.css +8 -2
  64. package/nexus-stage/src/workbench.ts +55 -17
  65. package/nexus-stage/src/world.ts +13 -21
  66. package/package.json +216 -214
  67. package/assets/nexusDist/assets/index-1niOBYxp.js +0 -5
  68. package/assets/nexusDist/assets/index-uQf7NwjF.css +0 -1
  69. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 撤销 for the board layout (frontend seat, 2026-10-29): with no status-bar
3
+ * hint for automatic moves (owner: 载入时避让挪了卡片,不需要在状态条提示),
4
+ * a wrong move must be one keystroke away from undone. Covers card drags,
5
+ * frame drags and resizes, keyboard nudges, 整理成员, 一键重排 and the
6
+ * load-time settle — positions and frame rects only. Team membership
7
+ * (入队 / 离队, host writes) is not undone here.
8
+ *
9
+ * capture() before an edit, commit(before) after it: only an edit that
10
+ * actually moved something already on the board is recorded, so a click,
11
+ * a pan or a marquee leaves the stack alone, and a card that arrived by
12
+ * poll mid-gesture is not counted as a move. revert(before) is for a
13
+ * gesture the OS cancelled (pointercancel): layout back, nothing recorded.
14
+ * @module nexus-stage/planning-undo
15
+ */
16
+ import type { FrameGeom, WorldModel } from './world'
17
+
18
+ export interface LayoutSnap {
19
+ readonly nodes: ReadonlyMap<string, { readonly x: number; readonly y: number }>
20
+ readonly frames: ReadonlyMap<string, FrameGeom>
21
+ }
22
+
23
+ /** Undo depth (older steps fall off). */
24
+ export const UNDO_LIMIT = 30
25
+
26
+ export function createLayoutUndo(model: WorldModel) {
27
+ const stack: LayoutSnap[] = []
28
+ const capture = (): LayoutSnap => ({
29
+ nodes: new Map(model.allNodes().map(n => [n.id, { x: n.x, y: n.y }])),
30
+ frames: new Map(model.allFrames().map(([name, f]) => [name, { ...f }])),
31
+ })
32
+ /** True when something present both before and now has moved or changed size. */
33
+ const moved = (before: LayoutSnap): boolean =>
34
+ model.allNodes().some(n => { const b = before.nodes.get(n.id); return b !== undefined && (b.x !== n.x || b.y !== n.y) }) ||
35
+ model.allFrames().some(([name, f]) => { const b = before.frames.get(name); return b !== undefined && (b.x !== f.x || b.y !== f.y || b.w !== f.w || b.h !== f.h) })
36
+ const restore = (snap: LayoutSnap): void => {
37
+ for (const n of model.allNodes()) {
38
+ const b = snap.nodes.get(n.id)
39
+ if (b !== undefined && (b.x !== n.x || b.y !== n.y)) model.dragNodes([n.id], b.x - n.x, b.y - n.y)
40
+ }
41
+ for (const [name, f] of model.allFrames()) {
42
+ const b = snap.frames.get(name)
43
+ if (b !== undefined && (b.x !== f.x || b.y !== f.y || b.w !== f.w || b.h !== f.h)) model.setFrame(name, { ...b })
44
+ }
45
+ }
46
+ return {
47
+ capture,
48
+ commit(before: LayoutSnap): void {
49
+ if (!moved(before)) return
50
+ stack.push(before)
51
+ if (stack.length > UNDO_LIMIT) stack.shift()
52
+ },
53
+ /** Restore the last recorded layout; false when there is nothing to undo. */
54
+ undo(): boolean {
55
+ const snap = stack.pop()
56
+ if (snap === undefined) return false
57
+ restore(snap)
58
+ return true
59
+ },
60
+ /** Put back `before` without recording a step (an abandoned gesture); true when something moved. */
61
+ revert(before: LayoutSnap): boolean {
62
+ if (!moved(before)) return false
63
+ restore(before)
64
+ return true
65
+ },
66
+ get depth(): number { return stack.length },
67
+ }
68
+ }
@@ -27,7 +27,8 @@
27
27
 
28
28
  import type { LampState } from './layout-wire'
29
29
  import { buildLayoutDoc, clampDoc, type LayoutDoc, type LayoutRect } from './layout-doc'
30
- import { FRAME_HEAD_LIFT, starEdges } from './edges'
30
+ import { FRAME_HEAD_LIFT } from './edges'
31
+ import { drawMemberEdges } from './planning-edges'
31
32
  import { createFederationOverlay } from './federation-overlay'
32
33
  import { displayName } from './stage-name'
33
34
  import {
@@ -36,11 +37,19 @@ import {
36
37
  type RemoteTeamRow,
37
38
  } from './federation'
38
39
  import { clampViewport, fitView, panBy, screenToWorld, worldTransformCss, zoomAt, type LayoutViewport } from './viewport'
40
+ import { boardBounds, fitBelowHud, hudBottomOf } from './planning-fit'
39
41
  import { WorldModel, nodeRect, deriveInitialFrame, homeTeams, planSeatClear, planTeamClusters, NODE_W, NODE_H } from './world'
40
42
  import { applyAction, actionTeam, innermostFrameAt, reorderOps, yOrderedMembers, type CanvasAction } from './canvas-ops'
41
43
  import { FRAME_HUES } from './tokens'
42
44
  import { createPlanningMenus } from './planning-menus'
45
+ import { createNodeFlyer, mountBoardSearch } from './planning-search'
46
+ import { applyBoardRelayout } from './planning-relayout'
47
+ import { mirrorRoute } from './planning-mirror'
48
+ import { settleBoard, tidyMembers } from './planning-overlap'
49
+ import { createLayoutUndo, type LayoutSnap } from './planning-undo'
43
50
  import { createNodeList } from './planning-nodelist'
51
+ import { cardText } from './planning-card-text'
52
+ import { createLegend } from './planning-legend'
44
53
  import { viewportBucket, type PlanningDeps, type PlanningInput, type PlanningSession, type PlanningTeam, type PlanningView, type SeamKey, type SeamPointer, type ViewportBucket } from './planning-types'
45
54
  import './planning.css'
46
55
 
@@ -78,6 +87,9 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
78
87
  let renderedRevision = -1
79
88
  /** Until the first reconcile with nodes (or an adopted viewport), frame the content once. */
80
89
  let firstFitPending = true
90
+ let settledOnce = false
91
+ const layoutUndo = createLayoutUndo(model) // 撤销 (planning-undo)
92
+ let undoBefore: LayoutSnap | undefined
81
93
  let viewportAdopted = false
82
94
  const bucket = (): ViewportBucket => viewportBucket(globalThis.innerWidth || viewW)
83
95
  const markDirty = (): void => {
@@ -151,11 +163,15 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
151
163
  b.addEventListener('click', onClick, signal)
152
164
  return b
153
165
  }
166
+ // Review 2026-10-27: the toolbar reads as three groups — view | arrange |
167
+ // network — split by separators; every action stays one click (owner orders).
168
+ const sep = (): HTMLElement => { const s = document.createElement('span'); s.className = 'p-sep'; s.setAttribute('role', 'separator'); s.setAttribute('aria-orientation', 'vertical'); return s }
154
169
  toolbar.append(
155
170
  mkButton('适配', '适配视图(快捷键 0)', () => fitToContent()),
156
171
  mkButton('-', '缩小', () => zoomCenter(1 / 1.2)),
157
172
  zoomLabel,
158
173
  mkButton('+', '放大', () => zoomCenter(1.2)),
174
+ sep(),
159
175
  mkButton('建队', '组成团队(G):先框选至少 2 个节点', () => menus.startCreateFromSelection()),
160
176
  // gh#48 (owner order, review-seat spec): one-click tidy — inside every
161
177
  // team frame, the members that belong to EXACTLY that one team re-seat
@@ -165,6 +181,14 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
165
181
  // ride the same rule; the move persists through the ordinary layout
166
182
  // save loop exactly like a hand drag.
167
183
  mkButton('整理成员', '每队框内仅属一队的成员自动排齐(多队成员不动)', () => tidyTeamMembers()),
184
+ // Supervisor order v2 (upgrades gh#48): one-click WHOLE-BOARD relayout —
185
+ // the two buttons split the job (整理成员 = cards inside one frame;
186
+ // 一键重排 = every local frame AND its cards). Frame size derives from
187
+ // content (width and height both); frames tile in a row flow
188
+ // (constructively disjoint); multi-team members never enter the size
189
+ // math and never move; remote org frames and frameless nodes stay put.
190
+ mkButton('一键重排', '全板队框+成员重排:框尺寸随内容自适应、框位行流式平铺(远端队框与多归属成员不动)', () => relayoutBoard()),
191
+ mkButton('撤销', '撤销上一次布局改动:拖动、缩放、整理、重排、载入时避让(Ctrl+Z;入队离队不撤销)', () => undoLayout()),
168
192
  )
169
193
  // Unjoined sessions ride a toolbar chip + dropdown: the panel-slim
170
194
  // migration made the planning view the join surface — without this, an
@@ -172,7 +196,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
172
196
  const netBtn = mkButton('未入网', '未入网会话(点击选择入网)', () => menus.toggleNetMenu())
173
197
  netBtn.className = 'p-netbtn'
174
198
  netBtn.setAttribute('aria-haspopup', 'menu')
175
- toolbar.appendChild(netBtn)
199
+ toolbar.append(sep(), netBtn)
200
+ const search = mountBoardSearch(toolbar, model, displayName, id => flyToNode(id))
176
201
  // Node list (network-membership-display): the canvas shows teamed nodes
177
202
  // only; every other session — teamless joined, unjoined, and each peer's
178
203
  // published nodes — is managed from this list, switched by host.
@@ -218,20 +243,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
218
243
  if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); flyToFederation() }
219
244
  }, signal)
220
245
 
221
- const legend = document.createElement('div')
222
- legend.className = 'p-legend'
223
- const legendRow = (dashed: boolean, text: string, color?: string): void => {
224
- const row = document.createElement('span')
225
- const bar = document.createElement('i')
226
- if (dashed) bar.className = 'dashed'
227
- if (color !== undefined) bar.style.borderTopColor = color
228
- row.append(bar, document.createTextNode(text))
229
- legend.appendChild(row)
230
- }
231
- legendRow(false, '成员边(星形,框→成员)')
232
- legendRow(true, '跨队多属(虚线)')
233
- legendRow(false, '活动边(inFlight 路由,瞬时)', 'var(--accent)')
234
- legendRow(true, '联邦线(→peer)', 'var(--federal)')
246
+ // Edge key: a closed-by-default fold (planning-legend), not a fixed panel.
247
+ const legend = createLegend()
235
248
 
236
249
  const noticeStack = document.createElement('div')
237
250
  noticeStack.className = 'p-notice-stack'
@@ -269,31 +282,26 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
269
282
  * — the same persistence path a hand drag takes.
270
283
  */
271
284
  function tidyTeamMembers(): void {
272
- const PAD = 28
273
- const GAP_X = 16
274
- const GAP_Y = 14
275
- for (const [name, rect] of model.allFrames()) {
276
- const members = model.teamMemberIds(name)
277
- const sole = members
278
- .map(id => model.getNode(id))
279
- .filter((n): n is NonNullable<typeof n> => n !== undefined)
280
- .filter(n => n.memberships.length === 1)
281
- if (sole.length === 0) continue // empty frame / only shared members: untouched
282
- const perRow = Math.max(1, Math.floor((rect.w - 2 * PAD + GAP_X) / (NODE_W + GAP_X)))
283
- const rows = Math.ceil(sole.length / perRow)
284
- const needed = PAD * 2 + rows * NODE_H + (rows - 1) * GAP_Y
285
- if (needed > rect.h) {
286
- model.setFrame(name, { ...rect, h: Math.min(needed, rect.h + 400) })
287
- }
288
- const startX = rect.x + PAD + NODE_W / 2
289
- let y = rect.y + PAD + NODE_H / 2
290
- sole.forEach((n, i) => {
291
- const col = i % perRow
292
- if (i > 0 && col === 0) y += NODE_H + GAP_Y
293
- const x = startX + col * (NODE_W + GAP_X)
294
- model.dragNodes([n.id], x - n.x, y - n.y)
295
- })
296
- }
285
+ const before = layoutUndo.capture()
286
+ tidyMembers(model) // gh#48 grid (planning-overlap); then nothing may overlap
287
+ settleBoard(model, new Set(remoteFramesRef.keys()))
288
+ layoutUndo.commit(before)
289
+ markDirty()
290
+ render()
291
+ }
292
+
293
+ /**
294
+ * Supervisor order v2: whole-board deterministic relayout (the planner
295
+ * and the model adapter live in planning-relayout.ts): LOCAL frames +
296
+ * sole members only — remote org frames keep gh#44 live-rect-wins,
297
+ * frameless nodes and multi-team members never move; the same
298
+ * markDirty()/render() persistence path a hand drag takes.
299
+ */
300
+ function relayoutBoard(): void {
301
+ const before = layoutUndo.capture()
302
+ if (!applyBoardRelayout(model, new Set(remoteFramesRef.keys()))) return
303
+ settleBoard(model, new Set(remoteFramesRef.keys())) // vs remote frames + stray cards
304
+ layoutUndo.commit(before)
297
305
  markDirty()
298
306
  render()
299
307
  }
@@ -304,7 +312,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
304
312
  renderedRevision = model.revision
305
313
  renderFrames()
306
314
  renderNodes()
307
- renderEdges()
315
+ drawMemberEdges(svg, model) // planning-edges
308
316
  }
309
317
  renderFederation()
310
318
  }
@@ -373,24 +381,21 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
373
381
  }
374
382
  const cntText = `${members} 成员` + (cross > 0 ? ` +${cross} 跨队` : '')
375
383
  if (cnt.textContent !== cntText) cnt.textContent = cntText
376
- const routeText0 = routeText(name)
384
+ const mirror = mirrorRoute(remoteFramesRef.get(name), pendingTeams.has(name)) // PR#96 single owner + mirror
385
+ const routeText0 = mirror?.text ?? routeText(name)
377
386
  if (route.textContent !== routeText0) route.textContent = routeText0
387
+ route.title = mirror?.title ?? ''
388
+ el.classList.toggle('is-mirror', mirror !== undefined)
389
+ el.classList.toggle('is-syncing', mirror?.syncing === true)
390
+ const label = `团队框 ${name}${mirror !== undefined ? `(${mirror.text})` : ''}(拖动整组,回车菜单)`
391
+ const headEl = el.querySelector('.p-frame-head')!
392
+ if (headEl.getAttribute('aria-label') !== label) headEl.setAttribute('aria-label', label) // a rewrite re-announces
378
393
  }
379
394
  for (const [name, el] of [...frameEls]) {
380
395
  if (!seen.has(name)) { el.remove(); frameEls.delete(name) }
381
396
  }
382
397
  }
383
398
 
384
- /** Remote card sub line: score + remote team count/names behind this peer. */
385
- function remoteSubText(host: string): string {
386
- const rows = lastRemoteTeams.get(host) ?? []
387
- const names = rows.map(r => r.name !== undefined && r.name !== '' ? r.name : r.team)
388
- const head = names.slice(0, 2).join('、')
389
- const more = names.length > 2 ? ` +${names.length - 2}` : ''
390
- const score = peerScoreByText.get(host)
391
- return (score !== undefined ? `score ${score} · ` : '') + `${rows.length} 远端团队` + (head !== '' ? `:${head}${more}` : '')
392
- }
393
-
394
399
  /** The mono route line `<host>/canvas/<name>` when the payload carries it. */
395
400
  function routeText(name: string): string {
396
401
  const t = lastTeams.find(x => x.name === name)
@@ -431,25 +436,13 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
431
436
  el.classList.toggle('selected', model.isSelected(n.id))
432
437
  el.setAttribute('aria-selected', String(model.isSelected(n.id)))
433
438
  const host = n.id.slice('peer-'.length)
434
- // gh#38: member cards show their display NAME as the title (the whole
435
- // point is picking a person); peer HOST cards keep the host spelling.
436
- const nmText = n.id.startsWith('member-')
437
- ? (n.name !== undefined && n.name !== '' ? n.name : n.team)
438
- : n.remote === true ? host : displayName(n)
439
+ // Title / second line / tooltip rules: planning-card-text.
440
+ const txt = cardText(n, n.remote === true ? lastRemoteTeams.get(host) ?? [] : [], peerScoreByText.get(host))
439
441
  const nmEl = el.querySelector<HTMLElement>('.nm-text')!
440
- if (nmEl.textContent !== nmText) nmEl.textContent = nmText
442
+ if (nmEl.textContent !== txt.title) nmEl.textContent = txt.title
441
443
  const subEl = el.querySelector<HTMLElement>('.sub')!
442
- const subBase = n.team + (n.name !== undefined && n.name !== '' ? ' · ' + n.name : '')
443
- // When the title fell back to the team label the sub line would repeat
444
- // it verbatim; collapse to the cross-team suffix alone in that case.
445
- const subText = n.id.startsWith('member-')
446
- ? n.team
447
- : n.remote === true
448
- ? remoteSubText(host)
449
- : (subBase === nmText ? '' : subBase)
450
- + (n.memberships.length > 1 ? ` · 跨队×${n.memberships.length}` : '')
451
- + (n.teams !== undefined && n.teams.length > 0 ? ` · 网络队×${n.teams.length}` : '')
452
- if (subEl.textContent !== subText) subEl.textContent = subText
444
+ if (subEl.textContent !== txt.sub) subEl.textContent = txt.sub
445
+ if (el.title !== txt.tip) el.title = txt.tip
453
446
  const prioEl = el.querySelector<HTMLElement>('.prio')!
454
447
  if (n.remote === true) {
455
448
  // gh#20 W-A: the peer badge — link class colors the chip (linked
@@ -470,30 +463,14 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
470
463
  for (const [id, el] of [...nodeEls]) {
471
464
  if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
472
465
  }
473
- }
474
-
475
- // ── edges: stateless, rebuilt wholesale ──
476
- // Derived from the MODEL (not lastTeams): optimistic membership changes
477
- // must repaint their edges immediately, before the host confirms.
478
- function renderEdges(): void {
479
- while (svg.firstChild !== null) svg.firstChild.remove()
480
- const teams = model.allFrames().map(([name]) => ({ name, members: model.teamMemberIds(name).map(id => ({ id })) }))
481
- for (const e of starEdges(teams, new Map(model.allFrames()), model.positions())) {
482
- const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
483
- line.setAttribute('class', 'e-member' + (e.dashed ? ' dashed' : ''))
484
- line.setAttribute('x1', String(e.x1))
485
- line.setAttribute('y1', String(e.y1))
486
- line.setAttribute('x2', String(e.x2))
487
- line.setAttribute('y2', String(e.y2))
488
- svg.appendChild(line)
489
- }
466
+ search.refresh()
490
467
  }
491
468
 
492
469
  // ── federation overlay (PR D): peer cards' activity/federal edges ──
493
470
  // Runs on EVERY render, outside the model-revision gate: the poll's
494
471
  // inFlight ages must tick even when the model is unchanged, and the
495
472
  // badge column tracks the content bounds each reconcile. Its SVG
496
- // children are tracked separately because renderEdges clears the svg
473
+ // children are tracked separately because drawMemberEdges clears the svg
497
474
  // wholesale - this appends after it (render() orders the two).
498
475
  // gh#49: the DOM drawing lives in federation-overlay.ts (module-size
499
476
  // ruling); this view keeps only the seams (facts + fly).
@@ -515,20 +492,19 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
515
492
  markDirty()
516
493
  }
517
494
 
495
+ /** W1 pick: frame one node at ~100% and pulse its card. */
496
+ const flyToNode = createNodeFlyer({ model, viewSize: measure, setVp: next => { vp = next }, applyViewport, markDirty, cardEl: id => nodeEls.get(id) })
518
497
  // ── status counts ──
519
498
  function renderStatus(input: PlanningInput): void {
520
499
  const live = input.sessions.filter(s => s.live !== false).length
521
- const cold = input.sessions.length - live
522
- statusLive.textContent = ''
523
- const bLive = document.createElement('b')
524
- bLive.className = 'ok'
525
- bLive.textContent = `● ${live} live`
526
- statusLive.appendChild(bLive)
527
- statusCold.textContent = ''
528
- const bCold = document.createElement('b')
529
- bCold.className = 'warn'
530
- bCold.textContent = `● ${cold} cold`
531
- statusCold.appendChild(bCold)
500
+ const dot = (host: HTMLElement, cls: string, text: string): void => {
501
+ const b = document.createElement('b')
502
+ b.className = cls
503
+ b.textContent = text
504
+ host.replaceChildren(b)
505
+ }
506
+ dot(statusLive, 'ok', `● ${live} live`)
507
+ dot(statusCold, 'warn', `● ${input.sessions.length - live} cold`)
532
508
  statusAct.textContent = `${input.inFlight?.length ?? 0} inFlight`
533
509
  statusMut.className = 'mut'
534
510
  statusMut.textContent = `${input.teams.length} 队 · ${input.peerCount} peer`
@@ -539,8 +515,10 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
539
515
  // up until BOTH settle (a Set deleted per action let a mid-queue poll wipe
540
516
  // the second action's optimistic membership).
541
517
  const pendingTeams = new Map<string, number>()
542
- const pendingAdd = (team: string): void => { pendingTeams.set(team, (pendingTeams.get(team) ?? 0) + 1) }
518
+ // Each change re-renders frames: the mirror 「同步中…」 marker reads pendingTeams, not the model revision.
519
+ const pendingAdd = (team: string): void => { pendingTeams.set(team, (pendingTeams.get(team) ?? 0) + 1); renderedRevision = -1 }
543
520
  const pendingRelease = (team: string): void => {
521
+ renderedRevision = -1
544
522
  const n = (pendingTeams.get(team) ?? 0) - 1
545
523
  if (n <= 0) pendingTeams.delete(team)
546
524
  else pendingTeams.set(team, n)
@@ -595,7 +573,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
595
573
  const saved = layoutDoc?.nodes[id]
596
574
  const seat = saved !== undefined ? { x: saved.x, y: saved.y } : seatFor2(id)
597
575
  model.upsertNode({
598
- id, x: seat.x, y: seat.y, label: s.label, team: s.team, name: s.name,
576
+ id, x: seat.x, y: seat.y, label: s.label, team: s.team, name: s.name, description: s.description,
599
577
  live: s.live !== false, memberships: [],
600
578
  ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
601
579
  })
@@ -725,6 +703,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
725
703
  ev.preventDefault()
726
704
  return
727
705
  }
706
+ if (target !== null && target.closest('button, input, textarea, select, .p-toolbar, .p-search, .p-lamp, .p-status, .p-legend') !== null) return // HUD chrome owns its presses (no marquee / preventDefault: that stole the search box's focus — and, first, its native right-click menu)
707
+ undoBefore = layoutUndo.capture() // committed at pointerUp only if this press moved something
728
708
  const p = localXY(ev)
729
709
  // 右键按下:按住拖动 = 平移(Figma 惯例),原地抬起 = 上下文菜单。
730
710
  // Pan actually starts on the first move beyond RIGHT_DRAG_THRESHOLD px;
@@ -735,7 +715,6 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
735
715
  try { root.setPointerCapture(ev.pointerId ?? 0) } catch { /* jsdom */ }
736
716
  return
737
717
  }
738
- if (target !== null && (target.closest('button') !== null || target.closest('.p-lamp') !== null || target.closest('.p-status') !== null)) return
739
718
  const nodeEl = target !== null ? target.closest<HTMLElement>('.p-node') : null
740
719
  const headEl = target !== null ? target.closest<HTMLElement>('.p-frame-head') : null
741
720
  if ((nodeEl !== null || headEl !== null) && ev.button === 1) {
@@ -930,10 +909,9 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
930
909
  for (const el of frameEls.values()) el.classList.remove('drop-target')
931
910
  gesture.el?.classList.remove('dragging')
932
911
  render()
933
- } else if (gesture.kind === 'frame') {
934
- if (gesture.moved) markDirty()
935
- } else if (gesture.kind === 'frame-resize') {
936
- if (gesture.moved) markDirty() // the resized rect persists with the layout
912
+ } else if (gesture.kind === 'frame' || gesture.kind === 'frame-resize') {
913
+ // The frame stays where it was put; neighbours and stray cards yield (planning-overlap).
914
+ if (gesture.moved) { settleBoard(model, new Set(remoteFramesRef.keys()), gesture.name); markDirty(); render() }
937
915
  } else if (gesture.kind === 'pan') {
938
916
  if (gesture.moved) markDirty() // the viewport persists with the layout
939
917
  } else if (gesture.kind === 'marquee') {
@@ -945,6 +923,21 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
945
923
  render()
946
924
  }
947
925
  gesture = { kind: 'none' }
926
+ if (undoBefore !== undefined) { layoutUndo.commit(undoBefore); undoBefore = undefined }
927
+ try { root.releasePointerCapture(ev.pointerId ?? 0) } catch { /* not captured */ }
928
+ }
929
+
930
+ /** The OS took the pointer (touch scroll, system gesture): abandon the press — no drop, no menu, layout back. */
931
+ function pointerCancel(ev: SeamPointer): void {
932
+ for (const el of frameEls.values()) el.classList.remove('drop-target')
933
+ if (gesture.kind === 'node') gesture.el?.classList.remove('dragging')
934
+ const panned = (gesture.kind === 'pan' && gesture.moved) || rightPress?.moved === true // right-drag pans live in rightPress
935
+ marquee.style.display = 'none'
936
+ rightPress = null
937
+ gesture = { kind: 'none' }
938
+ if (undoBefore !== undefined && layoutUndo.revert(undoBefore)) render()
939
+ if (panned) markDirty() // the pan stays (it is the view, not the layout) and persists like a finished one
940
+ undoBefore = undefined
948
941
  try { root.releasePointerCapture(ev.pointerId ?? 0) } catch { /* not captured */ }
949
942
  }
950
943
 
@@ -975,15 +968,21 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
975
968
  markDirty()
976
969
  }
977
970
 
971
+ function undoLayout(): void {
972
+ if (layoutUndo.undo()) { markDirty(); render() } else notice('info', '没有可撤销的布局改动')
973
+ }
974
+
978
975
  function fitToContent(): void {
979
976
  syncViewSize()
980
- vp = fitView(model.contentBounds(), viewW, viewH)
977
+ vp = fitBelowHud(boardBounds(model.contentBounds(), model.allNodes()), viewW, viewH, hudBottomOf(toolbar, status))
981
978
  applyViewport()
982
979
  markDirty()
983
980
  }
984
981
 
985
982
  function key(ev: SeamKey): void {
986
- if (menus.isDialogOpen()) return // modal: the dialog input handles Enter/Esc itself
983
+ if (menus.isDialogOpen() || (ev.target as Element | null)?.closest?.('.p-search') !== null) return // modal / search box own their keys
984
+ if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 'k') { ev.preventDefault(); search.focus(); return } // W1 Ctrl+K
985
+ if ((ev.ctrlKey || ev.metaKey) && !ev.shiftKey && ev.key.toLowerCase() === 'z') { ev.preventDefault(); undoLayout(); return }
987
986
  if (ev.ctrlKey || ev.metaKey) return // browser/OS chords stay browser/OS
988
987
  if (menus.isMenuOpen()) {
989
988
  // The menu owns most keys (its own handler navigates); Escape still
@@ -1037,7 +1036,9 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1037
1036
  ev.preventDefault()
1038
1037
  const ids = model.selectedIds()
1039
1038
  if (ids.length > 0) {
1039
+ const before = layoutUndo.capture()
1040
1040
  model.nudge(ids, d[0]!, d[1]!)
1041
+ layoutUndo.commit(before)
1041
1042
  render()
1042
1043
  markDirty()
1043
1044
  }
@@ -1093,12 +1094,13 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1093
1094
  root.addEventListener('pointerdown', (ev) => pointerDown(ev as unknown as SeamPointer), signal)
1094
1095
  root.addEventListener('pointermove', (ev) => pointerMove(ev as unknown as SeamPointer), signal)
1095
1096
  root.addEventListener('pointerup', (ev) => pointerUp(ev as unknown as SeamPointer), signal)
1097
+ root.addEventListener('pointercancel', (ev) => pointerCancel(ev as unknown as SeamPointer), signal)
1096
1098
  root.addEventListener('wheel', (ev) => wheel(ev as unknown as SeamPointer), { ...signal, passive: false })
1097
1099
  root.addEventListener('keydown', (ev) => key(ev as unknown as SeamKey), signal)
1098
1100
  root.addEventListener('keyup', (ev) => keyUp(ev as unknown as SeamKey), signal)
1099
1101
  root.addEventListener('contextmenu', (ev) => {
1100
1102
  const e = ev as unknown as SeamPointer
1101
- e.preventDefault()
1103
+ if ((e.target as Element | null)?.closest?.('input, textarea, .p-search') != null) return // text fields keep the native menu (paste)
1102
1104
  // Right-drag pans (suppressNextMenu); a clean right-click opens the
1103
1105
  // menu programmatically at pointerup. Either way the native menu dies.
1104
1106
  e.preventDefault()
@@ -1174,7 +1176,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1174
1176
  if (model.getNode(s.id) !== undefined) {
1175
1177
  model.upsertNode({
1176
1178
  id: s.id, x: model.getNode(s.id)!.x, y: model.getNode(s.id)!.y,
1177
- label: s.label, team: s.team, name: s.name, live: s.live !== false,
1179
+ label: s.label, team: s.team, name: s.name, description: s.description, live: s.live !== false,
1178
1180
  memberships: effective,
1179
1181
  ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
1180
1182
  })
@@ -1188,7 +1190,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1188
1190
  y = seat.y
1189
1191
  }
1190
1192
  model.upsertNode({
1191
- id: s.id, x, y, label: s.label, team: s.team, name: s.name, live: s.live !== false,
1193
+ id: s.id, x, y, label: s.label, team: s.team, name: s.name, description: s.description, live: s.live !== false,
1192
1194
  memberships: effective,
1193
1195
  ...(s.teams !== undefined && s.teams.length > 0 ? { teams: s.teams } : {}),
1194
1196
  })
@@ -1389,21 +1391,17 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1389
1391
  // opened empty. Fit once, the first time there is something to frame.
1390
1392
  // Not marked dirty — nothing the user arranged changed; a saved layout
1391
1393
  // (adopted at any point) keeps its own viewport and cancels this.
1394
+ // Once per load, a board that arrives overlapped is shown settled (not
1395
+ // saved: no write without a user action; the next edit persists it).
1396
+ if (!settledOnce && gesture.kind === 'none' && model.nodeIds().length > 0) { settledOnce = true; const b = layoutUndo.capture(); settleBoard(model, new Set(remoteFramesRef.keys())); layoutUndo.commit(b) }
1392
1397
  if (firstFitPending && gesture.kind === 'none' && model.nodeIds().length > 0) {
1393
1398
  syncViewSize()
1394
1399
  if (viewW > 0 && viewH > 0) {
1395
1400
  firstFitPending = false
1396
1401
  // Peer cards hang outside the local content bounds — frame them too,
1397
- // or the federation column opens clipped at the board's edge.
1398
- let bounds = model.contentBounds()
1399
- for (const n of model.allNodes()) {
1400
- if (n.remote !== true) continue
1401
- const r = nodeRect(n)
1402
- bounds = bounds === null
1403
- ? { minX: r.x, minY: r.y, maxX: r.x + r.w, maxY: r.y + r.h }
1404
- : { minX: Math.min(bounds.minX, r.x), minY: Math.min(bounds.minY, r.y), maxX: Math.max(bounds.maxX, r.x + r.w), maxY: Math.max(bounds.maxY, r.y + r.h) }
1405
- }
1406
- vp = fitView(bounds, viewW, viewH, 1) // frame, never magnify
1402
+ // or the federation column opens clipped at the board's edge. Same
1403
+ // framing as 适配 (planning-fit): ≤100%, below the HUD.
1404
+ vp = fitBelowHud(boardBounds(model.contentBounds(), model.allNodes()), viewW, viewH, hudBottomOf(toolbar, status))
1407
1405
  }
1408
1406
  }
1409
1407
  render()
@@ -1491,6 +1489,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
1491
1489
  pointerDown,
1492
1490
  pointerMove,
1493
1491
  pointerUp,
1492
+ pointerCancel,
1494
1493
  wheel,
1495
1494
  key,
1496
1495
  keyUp,
@@ -82,6 +82,8 @@
82
82
  #dsh-plan .p-frame-head:hover{border-color:var(--card-line-hover)}
83
83
  #dsh-plan .p-frame-head .cnt{color:var(--ink-muted); font-weight:400}
84
84
  #dsh-plan .p-frame-head .route{font-size:10.5px; color:var(--ink-muted)}
85
+ #dsh-plan .p-frame.is-mirror{border-style:dashed}
86
+ #dsh-plan .p-frame.is-syncing .p-frame-head .route{color:var(--accent)}
85
87
 
86
88
  /* ── node card (fixed 56px border-box height = world.NODE_H) ── */
87
89
  #dsh-plan .p-node{position:absolute; width:172px; height:56px; padding:8px 26px 8px 14px;
@@ -97,12 +99,31 @@
97
99
  #dsh-plan .p-node .nm{font-weight:600; display:flex; align-items:center; gap:7px;
98
100
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
99
101
  /* The name span clips with an ellipsis (min-width:0 lets it shrink inside
100
- the flex row) — without it a long peer address was hard-cut mid-digit. */
101
- #dsh-plan .p-node .nm-text{min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
102
+ the flex row) — without it a long peer address was hard-cut mid-digit.
103
+ 属主令 W2: TWO-LINE clamp for the name (long Chinese team titles read
104
+ fully); the card's 172x56 geometry is untouched — the clamp absorbs the
105
+ overflow. The handle line (.sub) stays single-line ellipsis below. */
106
+ #dsh-plan .p-node .nm-text{min-width:0; overflow:hidden; white-space:normal;
107
+ display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; line-height:1.25}
108
+ /* A peer address has no break point, so the two-line clamp clipped it to one
109
+ line under the badge; let it wrap anywhere onto the second line. */
110
+ #dsh-plan .p-node.remote .nm-text{overflow-wrap:anywhere}
102
111
  #dsh-plan .p-node .dot{width:8px; height:8px; border-radius:50%; background:var(--edge-live); flex:none}
103
112
  #dsh-plan .p-node.cold .dot{background:transparent; border:1.5px solid var(--edge-cold)}
104
113
  #dsh-plan .p-node .sub{font-size:11px; color:var(--ink-muted); margin-top:2px;
105
114
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
115
+ /* W1: a picked search hit pulses its card for 1.2s (class removed on timer). */
116
+ #dsh-plan .p-node.pulse{box-shadow:0 0 0 2px var(--accent), 0 0 14px 4px var(--uupm-wash-15); border-color:var(--accent)}
117
+ /* W1: toolbar search box + hit list. */
118
+ #dsh-plan .p-search{position:relative}
119
+ #dsh-plan .p-search-input{width:170px; min-height:28px; padding:2px 8px; font-size:12px;
120
+ background:transparent; color:var(--ink); border:1px solid var(--card-line); border-radius:6px}
121
+ #dsh-plan .p-search-input:focus-visible{outline:2px solid var(--accent); outline-offset:1px}
122
+ #dsh-plan .p-search-list{position:absolute; top:100%; left:0; margin-top:4px; display:none;
123
+ min-width:240px; max-height:220px; overflow:auto; z-index:9;
124
+ background:var(--toolbar-bg); border:1px solid var(--card-line); border-radius:6px}
125
+ #dsh-plan .p-search-hit{padding:5px 10px; font-size:12px; color:var(--ink); cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
126
+ #dsh-plan .p-search-hit:hover, #dsh-plan .p-search-hit.active{background:var(--uupm-wash-15)}
106
127
  #dsh-plan .p-node .prio{position:absolute; top:6px; right:8px; font-size:10px; color:var(--ink-muted);
107
128
  border:1px solid var(--card-line); border-radius:4px; padding:0 5px}
108
129
 
@@ -126,6 +147,8 @@
126
147
  #dsh-plan .p-toolbar button:active{background:var(--uupm-wash-15)}
127
148
  #dsh-plan .p-toolbar button:focus-visible{outline:2px solid var(--accent); outline-offset:1px}
128
149
  #dsh-plan .p-zoom{min-width:44px; text-align:center; font-size:11px; color:var(--ink-muted)}
150
+ /* Toolbar groups (view | arrange | network): a hairline between them. */
151
+ #dsh-plan .p-sep{align-self:stretch; width:1px; margin:2px 4px; background:var(--card-line)}
129
152
  #dsh-plan .p-lamp{display:flex; align-items:center; gap:6px; font-size:11px; color:var(--ink-muted); padding:0 6px}
130
153
  #dsh-plan .p-lamp i{width:7px; height:7px; border-radius:50%; background:var(--ink-muted); transition:background 140ms ease}
131
154
  #dsh-plan .p-lamp.saved i{background:var(--edge-live)}
@@ -140,8 +163,11 @@
140
163
  #dsh-plan .ok{color:var(--edge-live)} #dsh-plan .warn{color:var(--uupm-warn)} #dsh-plan .mut{color:var(--ink-muted)}
141
164
 
142
165
  #dsh-plan .p-legend{position:absolute; bottom:16px; left:16px; z-index:3; font-size:11px; color:var(--ink-muted);
143
- background:var(--toolbar-bg); border:1px solid var(--card-line); border-radius:8px; padding:8px 12px;
144
- display:grid; gap:4px}
166
+ background:var(--toolbar-bg); border:1px solid var(--card-line); border-radius:8px; padding:4px 10px}
167
+ #dsh-plan .p-legend summary{cursor:pointer; padding:2px 0; user-select:none}
168
+ #dsh-plan .p-legend summary:focus-visible{outline:2px solid var(--accent); outline-offset:2px}
169
+ #dsh-plan .p-legend[open]{padding-bottom:8px}
170
+ #dsh-plan .p-legend-rows{display:grid; gap:4px; margin-top:4px}
145
171
  #dsh-plan .p-legend i{display:inline-block; width:18px; border-top:2px solid var(--uupm-edge-member);
146
172
  vertical-align:middle; margin-right:6px}
147
173
  #dsh-plan .p-legend i.dashed{border-top-style:dashed}
@@ -149,12 +175,19 @@
149
175
  /* ── narrow board (<680px): the HUD stacks instead of colliding ──
150
176
  The toolbar, status chip and legend are absolutely placed for a wide
151
177
  board; on a phone they overlapped each other and the toolbar's CJK
152
- labels wrapped one glyph per line. Toolbar scrolls sideways on one row,
153
- the status chip drops under it, the legend (reference, not state) hides. */
178
+ labels wrapped one glyph per line. The one-row sideways scroll that
179
+ followed hid the search box and half the actions off-screen (review
180
+ 2026-10-27), so the toolbar and status chip now flow at the top of the
181
+ board instead: the toolbar wraps, search first on its own row, and the
182
+ status chip sits under whatever height that takes. The legend (reference,
183
+ not state) hides. */
154
184
  @media (max-width:680px){
155
- #dsh-plan .p-toolbar{left:8px; right:8px; top:8px; overflow-x:auto; scrollbar-width:none}
185
+ #dsh-plan .p-toolbar{position:relative; top:auto; left:auto; margin:8px 8px 0; flex-wrap:wrap; row-gap:6px}
156
186
  #dsh-plan .p-toolbar > *{flex:none}
157
- #dsh-plan .p-status{left:8px; right:auto; top:58px; padding:6px 10px; gap:10px; flex-wrap:wrap}
187
+ #dsh-plan .p-toolbar .p-search{order:-1; flex:1 1 100%}
188
+ #dsh-plan .p-toolbar .p-sep{display:none}
189
+ #dsh-plan .p-toolbar .p-search-input{width:100%; box-sizing:border-box}
190
+ #dsh-plan .p-status{position:relative; top:auto; right:auto; left:auto; width:fit-content; margin:6px 8px 0; padding:6px 10px; gap:10px; flex-wrap:wrap}
158
191
  #dsh-plan .p-legend{display:none}
159
192
  }
160
193
  @media (pointer:coarse){