@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,73 @@
1
+ /**
2
+ * Board card text (review 2026-10-27): what a card's title, second line and
3
+ * tooltip say. Extracted from planning-view so the card rules read in one
4
+ * place (and the view stays under its M1 module-size cap).
5
+ *
6
+ * - Session cards used to read `dsh/11111111 · scout-01` on the second line:
7
+ * the title repeated, the handle truncated on every card. The line now
8
+ * carries what the session is FOR (its description) and falls back to the
9
+ * handle; the handle always rides the tooltip.
10
+ * - Peer cards titled `192.168.3.157:13080`, clipped by the peer badge with no
11
+ * break point. When the host publishes exactly one named team (its host
12
+ * row), that name is the title and the address moves to the second line.
13
+ * @module nexus-stage/planning-card-text
14
+ */
15
+ import { displayName } from './stage-name'
16
+ import type { RemoteTeamRow } from './federation'
17
+ import type { WorldNode } from './world'
18
+
19
+ export interface CardText { readonly title: string; readonly sub: string; readonly tip: string }
20
+
21
+ /**
22
+ * A description as one plain line. /state `description` is the session's
23
+ * recent-activity excerpt — the head of its last reply, newlines folded,
24
+ * capped at 160 chars — so on a real host it reads `# ✅ 已纠正——…`,
25
+ * `**全绿**`, `` `direct-…` `` and folded tables (`| 环节 | 结果 | |---|`).
26
+ * The card keeps the prose: it stops at the first table or code fence and
27
+ * drops heading, emphasis, code and link markup.
28
+ */
29
+ export function plainLine(text: string): string {
30
+ let s = text
31
+ const stop = s.search(/\|| ```|^```/)
32
+ if (stop >= 0) s = s.slice(0, stop)
33
+ return s
34
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // [text](url) → text
35
+ .replace(/(^|\s)#{1,6}\s+/g, '$1') // headings
36
+ .replace(/\*\*|__|`/g, '') // bold, code
37
+ .replace(/\s+/g, ' ')
38
+ .trim()
39
+ }
40
+
41
+ /** The remote-team summary a peer card carries: score + count + first names. */
42
+ function remoteSummary(rows: ReadonlyArray<RemoteTeamRow>, score: number | undefined): string {
43
+ const names = rows.map(r => r.name !== undefined && r.name !== '' ? r.name : r.team)
44
+ const head = names.slice(0, 2).join('、')
45
+ const more = names.length > 2 ? ` +${names.length - 2}` : ''
46
+ return (score !== undefined ? `score ${score} · ` : '') + `${rows.length} 远端团队` + (head !== '' ? `:${head}${more}` : '')
47
+ }
48
+
49
+ export function cardText(n: WorldNode, remoteRows: ReadonlyArray<RemoteTeamRow>, score: number | undefined): CardText {
50
+ // gh#38: member cards show their display NAME as the title (the whole
51
+ // point is picking a person).
52
+ if (n.id.startsWith('member-')) {
53
+ const title = n.name !== undefined && n.name !== '' ? n.name : n.team
54
+ return { title, sub: n.team, tip: n.team }
55
+ }
56
+ if (n.remote === true) {
57
+ const host = n.id.slice('peer-'.length)
58
+ const named = remoteRows.length === 1 && remoteRows[0]!.name !== undefined && remoteRows[0]!.name !== '' ? remoteRows[0]!.name : undefined
59
+ const summary = remoteSummary(remoteRows, score)
60
+ return named !== undefined
61
+ ? { title: named, sub: `${host} · ${summary}`, tip: `${named}\n${host}\n${summary}` }
62
+ : { title: host, sub: summary, tip: `${host}\n${summary}` }
63
+ }
64
+ const title = displayName(n)
65
+ const handle = n.team + (n.name !== undefined && n.name !== '' ? ' · ' + n.name : '')
66
+ const desc = plainLine(n.description ?? '')
67
+ // When the title fell back to the team label the handle would repeat it
68
+ // verbatim; then only the suffixes remain.
69
+ const base = desc !== '' ? desc : handle === title ? '' : n.team
70
+ const suffix = (n.memberships.length > 1 ? ` · 跨队×${n.memberships.length}` : '')
71
+ + (n.teams !== undefined && n.teams.length > 0 ? ` · 网络队×${n.teams.length}` : '')
72
+ return { title, sub: base + suffix, tip: desc !== '' ? `${handle}\n${desc}` : handle }
73
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The membership spokes' SVG layer (edges.ts stays pure geometry). Moved
3
+ * out of planning-view (module-size cap) when pointercancel landed.
4
+ * @module nexus-stage/planning-edges
5
+ */
6
+ import { starEdges } from './edges'
7
+ import type { WorldModel } from './world'
8
+
9
+ /**
10
+ * Stateless, rebuilt wholesale. Derived from the MODEL (not the host's
11
+ * teams): optimistic membership changes must repaint their edges
12
+ * immediately, before the host confirms.
13
+ */
14
+ export function drawMemberEdges(svg: SVGSVGElement, model: WorldModel): void {
15
+ while (svg.firstChild !== null) svg.firstChild.remove()
16
+ const teams = model.allFrames().map(([name]) => ({ name, members: model.teamMemberIds(name).map(id => ({ id })) }))
17
+ for (const e of starEdges(teams, new Map(model.allFrames()), model.positions())) {
18
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
19
+ line.setAttribute('class', 'e-member' + (e.dashed ? ' dashed' : ''))
20
+ line.setAttribute('x1', String(e.x1))
21
+ line.setAttribute('y1', String(e.y1))
22
+ line.setAttribute('x2', String(e.x2))
23
+ line.setAttribute('y2', String(e.y2))
24
+ svg.appendChild(line)
25
+ }
26
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The board's 适配 (fit) framing (review 2026-10-27).
3
+ *
4
+ * 适配 used to fit the local content into the whole board at any scale: a
5
+ * small board zoomed to 167%, the top frame's title slid under the toolbar,
6
+ * and the peer cards on the right were cut off. 适配 now frames what the
7
+ * first-run fit frames:
8
+ * - local content plus the peer cards;
9
+ * - the frame titles that sit above their frames;
10
+ * - never magnified past 100%;
11
+ * - the area below the HUD (toolbar / status chip), not under it.
12
+ * @module nexus-stage/planning-fit
13
+ */
14
+ import { FRAME_HEAD_TOP } from './edges'
15
+ import { fitView, type LayoutViewport, type WorldBounds } from './viewport'
16
+ import { nodeRect, type WorldNode } from './world'
17
+
18
+ /** Local content bounds ∪ peer cards, lifted to include the frame titles
19
+ * (a title straddles its frame's top border, FRAME_HEAD_TOP above it). */
20
+ export function boardBounds(content: WorldBounds | null, nodes: Iterable<WorldNode>): WorldBounds | null {
21
+ let b = content === null ? null : { ...content, minY: content.minY + FRAME_HEAD_TOP }
22
+ for (const n of nodes) {
23
+ if (n.remote !== true) continue
24
+ const r = nodeRect(n)
25
+ b = b === null
26
+ ? { minX: r.x, minY: r.y, maxX: r.x + r.w, maxY: r.y + r.h }
27
+ : { minX: Math.min(b.minX, r.x), minY: Math.min(b.minY, r.y), maxX: Math.max(b.maxX, r.x + r.w), maxY: Math.max(b.maxY, r.y + r.h) }
28
+ }
29
+ return b
30
+ }
31
+
32
+ /** Fit `bounds` at ≤100% into the board area below the HUD's bottom edge
33
+ * (`hudBottom` px from the board's top; 0 when the HUD is not laid out). */
34
+ export function fitBelowHud(bounds: WorldBounds | null, viewW: number, viewH: number, hudBottom: number): LayoutViewport {
35
+ const inset = hudBottom > 0 && hudBottom < viewH / 2 ? hudBottom : 0
36
+ const vp = fitView(bounds, viewW, viewH - inset, 1)
37
+ return inset === 0 || bounds === null ? vp : { ...vp, y: vp.y - inset / vp.scale }
38
+ }
39
+
40
+ /** The HUD's bottom edge inside the board root: the lowest of the toolbar
41
+ * and status chip, plus a small gap; 0 before layout (jsdom, hidden). */
42
+ export function hudBottomOf(...els: ReadonlyArray<HTMLElement>): number {
43
+ const bottom = Math.max(0, ...els.map(e => e.offsetHeight > 0 ? e.offsetTop + e.offsetHeight : 0))
44
+ return bottom > 0 ? bottom + 8 : 0
45
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The board legend (review 2026-10-27, real-host check): a fixed panel at
3
+ * the bottom-left sat over the lowest frames of a real 66-card board. It is
4
+ * now a native <details> fold — closed by default, a small 图例 chip that
5
+ * covers nothing; open, it shows the edge key. The open state is a
6
+ * per-viewer convenience in browser storage (absent or throwing → closed).
7
+ * @module nexus-stage/planning-legend
8
+ */
9
+ const KEY = 'dsh-a2a.board.legend'
10
+
11
+ /** Build the legend fold. */
12
+ export function createLegend(): HTMLDetailsElement {
13
+ const legend = document.createElement('details')
14
+ legend.className = 'p-legend'
15
+ const summary = document.createElement('summary')
16
+ summary.textContent = '图例'
17
+ summary.title = '边的含义(点击展开 / 收起)'
18
+ const rows = document.createElement('div')
19
+ rows.className = 'p-legend-rows'
20
+ const row = (dashed: boolean, text: string, color?: string): void => {
21
+ const line = document.createElement('span')
22
+ const bar = document.createElement('i')
23
+ if (dashed) bar.className = 'dashed'
24
+ if (color !== undefined) bar.style.borderTopColor = color
25
+ line.append(bar, document.createTextNode(text))
26
+ rows.appendChild(line)
27
+ }
28
+ row(false, '成员边(星形,框→成员)')
29
+ row(true, '跨队多属(虚线)')
30
+ row(false, '活动边(inFlight 路由,瞬时)', 'var(--accent)')
31
+ row(true, '联邦线(→peer)', 'var(--federal)')
32
+ legend.append(summary, rows)
33
+ try { legend.open = window.localStorage.getItem(KEY) === 'open' } catch { /* closed */ }
34
+ legend.addEventListener('toggle', () => {
35
+ try { window.localStorage.setItem(KEY, legend.open ? 'open' : 'closed') } catch { /* convenience only */ }
36
+ })
37
+ return legend
38
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Owner vs mirror on the board (owner ruling 2026-10-29, PR#96: canvas sync
3
+ * is single owner + mirror). A team belongs to the host that made it; every
4
+ * other host renders it as a MIRROR of that host's published card and edits
5
+ * it through the fleet relay. The frame head says so: 「镜像 · <owner host>」,
6
+ * and 「同步中…」 from the moment a relayed edit is sent until the owner
7
+ * answers (the relay refreshes the owner's card before replying, so the next
8
+ * poll already shows the result). Local frames carry no marker — this host
9
+ * is their owner.
10
+ * @module nexus-stage/planning-mirror
11
+ */
12
+ import { peerHostOf } from './federation'
13
+
14
+ export interface MirrorRoute {
15
+ readonly text: string
16
+ readonly title: string
17
+ readonly syncing: boolean
18
+ }
19
+
20
+ /** The route line of a remote (mirrored) frame; undefined for a local frame. */
21
+ export function mirrorRoute(ownerUrl: string | undefined, syncing: boolean): MirrorRoute | undefined {
22
+ if (ownerUrl === undefined) return undefined
23
+ const host = peerHostOf(ownerUrl)
24
+ return {
25
+ text: syncing ? `同步中… · ${host}` : `镜像 · ${host}`,
26
+ title: syncing
27
+ ? `已把改动交给属主 ${host},等待它确认`
28
+ : `这个团队属于 ${host},这里显示的是它的镜像;拖入或移出成员会经中继交给 ${host} 执行`,
29
+ syncing,
30
+ }
31
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Board overlap rules (owner, 2026-10-27: 团队方框需要避免遮盖和覆盖 — frames
3
+ * must not overlap each other, and a card must not sit on a frame it is not
4
+ * a member of).
5
+ *
6
+ * settleBoard() resolves both, deterministically:
7
+ * - Frame vs frame: one frame of an overlapping pair steps aside along the
8
+ * shortest of the four directions, carrying its members like a drag. The
9
+ * frame the user just touched stays where it was put; a remote org frame
10
+ * never moves on its own (gh#44: its rect is the user's), so a local frame
11
+ * yields to it; two remote frames are left alone.
12
+ * - Card vs frame: a card lying on a frame it does not belong to goes home
13
+ * first — the first free grid seat inside its own frame (memberships in
14
+ * state order) — and only a card with no frame (or no free seat) goes
15
+ * outside: right of that frame, else below it, else down a column right of
16
+ * every frame. No landing spot covers another card or a foreign frame, and
17
+ * frames are never moved or resized by this step. Members are never moved
18
+ * off their own frame; a member merely outside its frame (PR#85: a frame
19
+ * moves only itself) is left where it is.
20
+ * Callers run it after a frame drag or resize, after 整理成员 / 一键重排,
21
+ * and once when the board first has content — never on every poll (a poll
22
+ * does not reposition what the user arranged).
23
+ *
24
+ * tidyMembers() is the gh#48 整理成员 grid, moved here from planning-view.
25
+ * @module nexus-stage/planning-overlap
26
+ */
27
+ import { NODE_W, NODE_H, nodeRect, type FrameGeom, type WorldModel } from './world'
28
+
29
+ /** Clearance kept between a moved frame or card and what it stepped off. */
30
+ export const OVERLAP_GAP = 24
31
+ /** Bound on resolution passes (a dense board settles in a few). */
32
+ const MAX_PASSES = 200
33
+
34
+ type Rect = { readonly x: number; readonly y: number; readonly w: number; readonly h: number }
35
+ const hit = (a: Rect, b: Rect): boolean => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
36
+
37
+ /** The shortest translation that takes `m` clear of `f` (plus the gap). */
38
+ function stepAside(m: Rect, f: Rect): { dx: number; dy: number } {
39
+ const right = f.x + f.w + OVERLAP_GAP - m.x
40
+ const left = f.x - OVERLAP_GAP - (m.x + m.w)
41
+ const down = f.y + f.h + OVERLAP_GAP - m.y
42
+ const up = f.y - OVERLAP_GAP - (m.y + m.h)
43
+ const dx = Math.abs(right) <= Math.abs(left) ? right : left
44
+ const dy = Math.abs(down) <= Math.abs(up) ? down : up
45
+ return Math.abs(dx) <= Math.abs(dy) ? { dx, dy: 0 } : { dx: 0, dy }
46
+ }
47
+
48
+ function moveFrame(model: WorldModel, name: string, dx: number, dy: number): void {
49
+ const snap = model.beginFrameDrag(name)
50
+ if (snap !== undefined) model.applyFrameDrag(snap, dx, dy)
51
+ }
52
+
53
+ /** Separate overlapping frames; true when anything moved. */
54
+ export function separateFrames(model: WorldModel, remote: ReadonlySet<string>, touched?: string): boolean {
55
+ let changed = false
56
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
57
+ const frames = model.allFrames()
58
+ let pair: [string, FrameGeom, string, FrameGeom] | undefined
59
+ for (let i = 0; i < frames.length && pair === undefined; i++) {
60
+ for (let j = i + 1; j < frames.length; j++) {
61
+ const [a, ra] = frames[i]!
62
+ const [b, rb] = frames[j]!
63
+ if (remote.has(a) && remote.has(b)) continue
64
+ if (hit(ra, rb)) { pair = [a, ra, b, rb]; break }
65
+ }
66
+ }
67
+ if (pair === undefined) return changed
68
+ const [a, ra, b, rb] = pair
69
+ // Who yields: the lower stay score moves — remote 2 (never moves on its
70
+ // own), the frame just placed 1, any other 0; a tie moves the later one
71
+ // in board order. (A plain "a stays" check moved a remote b whenever a
72
+ // was the frame just placed — order-dependent: PR #83 review.)
73
+ const stay = (n: string): number => remote.has(n) ? 2 : n === touched ? 1 : 0
74
+ const [mover, mr, fixed] = stay(a) < stay(b) ? [a, ra, rb] : [b, rb, ra]
75
+ const { dx, dy } = stepAside(mr, fixed)
76
+ moveFrame(model, mover, dx, dy)
77
+ changed = true
78
+ }
79
+ return changed
80
+ }
81
+
82
+ /** Grid of the 整理成员 layout, shared by eviction seats. */
83
+ const PAD = 28
84
+ const GAP_X = 16
85
+ const GAP_Y = 14
86
+
87
+ /** Card-centred seats inside a frame, row by row (the 整理成员 grid). */
88
+ function seatsIn(f: Rect): Array<{ x: number; y: number }> {
89
+ const out: Array<{ x: number; y: number }> = []
90
+ for (let y = f.y + PAD; y + NODE_H <= f.y + f.h - PAD; y += NODE_H + GAP_Y) {
91
+ for (let x = f.x + PAD; x + NODE_W <= f.x + f.w - PAD; x += NODE_W + GAP_X) out.push({ x: x + NODE_W / 2, y: y + NODE_H / 2 })
92
+ }
93
+ return out
94
+ }
95
+
96
+ /** Move cards off frames they do not belong to; true when anything moved. */
97
+ export function evictStrangers(model: WorldModel): boolean {
98
+ let changed = false
99
+ const frames = model.allFrames()
100
+ const rectOf = new Map(frames)
101
+ for (const n of model.allNodes()) {
102
+ const own = new Set(n.memberships.map(m => m.team))
103
+ const host = frames.find(([name, f]) => !own.has(name) && hit(nodeRect(n), f))
104
+ if (host === undefined) continue
105
+ const f = host[1]
106
+ // Free = no foreign frame and no other card under it (cards read live: earlier evictions count).
107
+ const free = (p: { x: number; y: number }): boolean => {
108
+ const r = nodeRect(p)
109
+ return frames.every(([name, g]) => own.has(name) || !hit(r, g)) &&
110
+ model.allNodes().every(o => o.id === n.id || !hit(r, nodeRect(o)))
111
+ }
112
+ const home = n.memberships.map(m => rectOf.get(m.team)).filter((g): g is FrameGeom => g !== undefined)
113
+ const seat = home.flatMap(seatsIn).find(free)
114
+ const right = { x: f.x + f.w + OVERLAP_GAP + NODE_W / 2, y: n.y }
115
+ const below = { x: n.x, y: f.y + f.h + OVERLAP_GAP + NODE_H / 2 }
116
+ // Last resort: a column right of every frame, top-down from this frame. A card blocks
117
+ // at most two seats of this pitch, so 2·cards+1 seats always hold a free one.
118
+ const farX = Math.max(...frames.map(([, g]) => g.x + g.w)) + OVERLAP_GAP + NODE_W / 2
119
+ const column = Array.from({ length: 2 * model.allNodes().length + 1 }, (_, i) => ({ x: farX, y: f.y + NODE_H / 2 + i * (NODE_H + GAP_Y) }))
120
+ const to = seat ?? [right, below, ...column].find(free) ?? column[column.length - 1]!
121
+ model.dragNodes([n.id], to.x - n.x, to.y - n.y)
122
+ changed = true
123
+ }
124
+ return changed
125
+ }
126
+
127
+ /** Both rules, frames first (moving a frame can uncover or cover cards). */
128
+ export function settleBoard(model: WorldModel, remote: ReadonlySet<string>, touched?: string): boolean {
129
+ const framesMoved = separateFrames(model, remote, touched)
130
+ const cardsMoved = evictStrangers(model)
131
+ return framesMoved || cardsMoved
132
+ }
133
+
134
+ /**
135
+ * gh#48 整理成员: inside every frame, the members that belong to exactly that
136
+ * one team re-seat into a padded grid; the frame grows downward (bounded) to
137
+ * fit them. Members of 2+ teams keep their positions.
138
+ */
139
+ export function tidyMembers(model: WorldModel): void {
140
+ for (const [name, rect] of model.allFrames()) {
141
+ const sole = model.teamMemberIds(name)
142
+ .map(id => model.getNode(id))
143
+ .filter((n): n is NonNullable<typeof n> => n !== undefined)
144
+ .filter(n => n.memberships.length === 1)
145
+ if (sole.length === 0) continue // empty frame / only shared members: untouched
146
+ const perRow = Math.max(1, Math.floor((rect.w - 2 * PAD + GAP_X) / (NODE_W + GAP_X)))
147
+ const rows = Math.ceil(sole.length / perRow)
148
+ const needed = PAD * 2 + rows * NODE_H + (rows - 1) * GAP_Y
149
+ if (needed > rect.h) model.setFrame(name, { ...rect, h: Math.min(needed, rect.h + 400) })
150
+ const startX = rect.x + PAD + NODE_W / 2
151
+ let y = rect.y + PAD + NODE_H / 2
152
+ sole.forEach((n, i) => {
153
+ const col = i % perRow
154
+ if (i > 0 && col === 0) y += NODE_H + GAP_Y
155
+ const x = startX + col * (NODE_W + GAP_X)
156
+ model.dragNodes([n.id], x - n.x, y - n.y)
157
+ })
158
+ }
159
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * 「一键重排」board relayout (supervisor order v2, upgrade of gh#48): a
3
+ * deterministic whole-board re-seat — every LOCAL canvas team frame is
4
+ * resized from its content (sole-member count drives cols/rows; width AND
5
+ * height both derive from the grid), then the frames tile in a row flow
6
+ * (constructively non-overlapping, no collision pass). Multi-team members
7
+ * never enter the size derivation and never move (their dashed edges
8
+ * express membership); remote org frames and frameless nodes are out of
9
+ * scope. Pure geometry: same input, same output — pressing twice is
10
+ * stable by construction.
11
+ * @module nexus-stage/planning-relayout
12
+ */
13
+ import { NODE_W, NODE_H, type FrameGeom, type WorldModel } from './world'
14
+
15
+ /** Inner padding between frame edge and the card grid (gh#48 family). */
16
+ export const RELAYOUT_PAD = 28
17
+ /** Card gaps inside a frame (same family as the tidy grid). */
18
+ export const RELAYOUT_GAP_X = 16
19
+ export const RELAYOUT_GAP_Y = 14
20
+ /** Gap between neighbouring frames in the row-flow tiling. */
21
+ export const FRAME_GAP = 48
22
+ /** Column cap: perRow = min(4, ceil(sqrt(n))). */
23
+ export const PER_ROW_CAP = 4
24
+ /** Floor widths/heights: keeps the floating title band legible. */
25
+ export const FRAME_MIN_W = 180
26
+ export const FRAME_MIN_H = 90
27
+ /** Default world-space wrap width of one tiling row. */
28
+ export const RELAYOUT_WRAP = 1600
29
+
30
+ /** One local frame to re-seat: its current rect and its SOLE member ids. */
31
+ export interface RelayoutFrameInput {
32
+ readonly name: string
33
+ readonly rect: FrameGeom
34
+ readonly soleIds: readonly string[]
35
+ }
36
+
37
+ /** The deterministic outcome: new frame rects and absolute card centers. */
38
+ export interface RelayoutOutput {
39
+ readonly frames: ReadonlyArray<{ name: string; rect: FrameGeom }>
40
+ readonly seats: ReadonlyArray<{ id: string; x: number; y: number }>
41
+ }
42
+
43
+ /**
44
+ * Derive the whole-board layout. Frame order in = tiling order out (the
45
+ * caller passes a stable order, e.g. the model's insertion order, which
46
+ * keeps the result deterministic across presses).
47
+ */
48
+ export function planBoardRelayout(input: {
49
+ frames: ReadonlyArray<RelayoutFrameInput>
50
+ wrapWidth?: number
51
+ }): RelayoutOutput {
52
+ const wrap = input.wrapWidth ?? RELAYOUT_WRAP
53
+ // Pass 1 — content-derived size per frame (multi-team members excluded
54
+ // upstream: soleIds carries exactly the re-seatable cards).
55
+ const sized = input.frames.map(f => {
56
+ const n = Math.max(f.soleIds.length, 1)
57
+ const perRow = Math.min(PER_ROW_CAP, Math.max(1, Math.ceil(Math.sqrt(n))))
58
+ const rows = Math.ceil(n / perRow)
59
+ const w = Math.max(FRAME_MIN_W, RELAYOUT_PAD * 2 + perRow * NODE_W + (perRow - 1) * RELAYOUT_GAP_X)
60
+ const h = Math.max(FRAME_MIN_H, RELAYOUT_PAD * 2 + rows * NODE_H + (rows - 1) * RELAYOUT_GAP_Y)
61
+ return { name: f.name, w, h, rows, perRow, soleIds: f.soleIds }
62
+ })
63
+ // Pass 2 — row-flow tiling: left→right until the wrap width is full,
64
+ // then advance one row by the tallest frame of the band. Constructive
65
+ // disjointness: boxes on one band share the y band and never overlap in
66
+ // x; bands stack with FRAME_GAP.
67
+ const frames: Array<{ name: string; rect: FrameGeom }> = []
68
+ const seats: Array<{ id: string; x: number; y: number }> = []
69
+ let x = 0
70
+ let y = 0
71
+ let bandH = 0
72
+ for (const f of sized) {
73
+ if (x > 0 && x + f.w > wrap) {
74
+ x = 0
75
+ y += bandH + FRAME_GAP
76
+ bandH = 0
77
+ }
78
+ const rect: FrameGeom = { x, y, w: f.w, h: f.h }
79
+ frames.push({ name: f.name, rect })
80
+ // Pass 3 — seat the sole cards on the new rect's grid (the gh#48 rule,
81
+ // driven by the NEW frame geometry).
82
+ const startX = rect.x + RELAYOUT_PAD + NODE_W / 2
83
+ let cy = rect.y + RELAYOUT_PAD + NODE_H / 2
84
+ f.soleIds.forEach((id, i) => {
85
+ const col = i % f.perRow
86
+ if (i > 0 && col === 0) cy += NODE_H + RELAYOUT_GAP_Y
87
+ seats.push({ id, x: startX + col * (NODE_W + RELAYOUT_GAP_X), y: cy })
88
+ })
89
+ x += f.w + FRAME_GAP
90
+ bandH = Math.max(bandH, f.h)
91
+ }
92
+ return { frames, seats }
93
+ }
94
+
95
+ /**
96
+ * The model adapter (supervisor order v2): collect the LOCAL frames and
97
+ * their sole members (remote org frames and multi-team members are out of
98
+ * scope by spec), run the pure planner, apply the rects and seat moves
99
+ * through the model's own mutation seam. Returns false when nothing is in
100
+ * scope (the caller decides whether to dirty the layout).
101
+ */
102
+ export function applyBoardRelayout(model: WorldModel, remoteNames: ReadonlySet<string>): boolean {
103
+ const frames: Array<{ name: string; rect: FrameGeom; soleIds: string[] }> = []
104
+ for (const [name, rect] of model.allFrames()) {
105
+ if (remoteNames.has(name)) continue // remote org frames: untouched
106
+ const soleIds = model.teamMemberIds(name)
107
+ .map(id => model.getNode(id))
108
+ .filter((n): n is NonNullable<typeof n> => n !== undefined)
109
+ .filter(n => n.memberships.length === 1)
110
+ .map(n => n.id)
111
+ frames.push({ name, rect, soleIds })
112
+ }
113
+ if (frames.length === 0) return false
114
+ const out = planBoardRelayout({ frames })
115
+ for (const f of out.frames) model.setFrame(f.name, f.rect)
116
+ for (const seat of out.seats) {
117
+ const n = model.getNode(seat.id)
118
+ if (n !== undefined) model.dragNodes([seat.id], seat.x - n.x, seat.y - n.y)
119
+ }
120
+ return true
121
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * 画板全局搜索(属主令 W1): a toolbar search box (Ctrl+K focuses) over the
3
+ * board's node FACTS — display name, zone/id8 handle, and host spelling, in
4
+ * one substring match (case-insensitive). Picking a hit rides the existing
5
+ * fitView flight chain (the caller frames ~100% and pulses the card);
6
+ * Escape clears. Pure front end: no backend, no routing, no persistence.
7
+ * @module nexus-stage/planning-search
8
+ */
9
+ import { fitView, type LayoutViewport } from './viewport'
10
+ import { NODE_W, NODE_H } from './world'
11
+ /** One searchable node fact row (already display-resolved by the caller). */
12
+ export interface SearchHit {
13
+ readonly id: string
14
+ /** Display name (member cards' person name, peer hosts' spelling). */
15
+ readonly name: string
16
+ /** The zone/id8 handle (n.team) and any extra host tokens. */
17
+ readonly team: string
18
+ /** Optional host/owner token (peer cards carry the host spelling). */
19
+ readonly host?: string
20
+ }
21
+
22
+ /** Case-insensitive substring match across name / team / host. */
23
+ export function matchHits(rows: ReadonlyArray<SearchHit>, query: string): SearchHit[] {
24
+ const q = query.trim().toLowerCase()
25
+ if (q === '') return []
26
+ return rows.filter(r =>
27
+ r.name.toLowerCase().includes(q) || r.team.toLowerCase().includes(q) || (r.host ?? '').toLowerCase().includes(q))
28
+ .slice(0, 8)
29
+ }
30
+
31
+ /**
32
+ * Build the searchable rows from the board model — the SAME display facts
33
+ * the cards render (member display name / peer host spelling / its handle),
34
+ * so every hit is a card the user can see.
35
+ */
36
+ export function hitsFromModel(model: { allNodes(): ReadonlyArray<{ id: string; name?: string; team: string; remote?: boolean }> }, displayName: (n: never) => string): SearchHit[] {
37
+ return model.allNodes().map(n => {
38
+ const host = n.id.slice('peer-'.length)
39
+ const name = n.id.startsWith('member-')
40
+ ? (n.name !== undefined && n.name !== '' ? n.name : n.team)
41
+ : n.remote === true ? host : displayName(n as never)
42
+ return { id: n.id, name, team: n.team, ...(n.remote === true ? { host } : {}) }
43
+ })
44
+ }
45
+
46
+ /** W1 pulse: briefly ring the picked card (class removed on the timer). */
47
+ export function pulseCard(el: HTMLElement | undefined, ms = 1200): void {
48
+ if (el === undefined) return
49
+ el.classList.add('pulse')
50
+ setTimeout(() => el.classList.remove('pulse'), ms)
51
+ }
52
+
53
+ /** The view seams the node flyer needs (the view keeps owning its model). */
54
+ export interface NodeFlyerSpec {
55
+ readonly model: { getNode(id: string): { x: number; y: number } | undefined }
56
+ readonly viewSize: () => { w: number; h: number }
57
+ readonly setVp: (vp: LayoutViewport) => void
58
+ readonly applyViewport: () => void
59
+ readonly markDirty: () => void
60
+ readonly cardEl: (id: string) => HTMLElement | undefined
61
+ }
62
+
63
+ /**
64
+ * W1 pick flight: frame ONE node's rect at ~100% (the same fitView chain the
65
+ * statusbar fly rides) and pulse its card for 1.2s.
66
+ */
67
+ export function createNodeFlyer(spec: NodeFlyerSpec): (id: string) => void {
68
+ return id => {
69
+ const n = spec.model.getNode(id)
70
+ if (n === undefined) return
71
+ const size = spec.viewSize()
72
+ // fitView takes WorldBounds ({minX,minY,maxX,maxY}), not the card rect —
73
+ // passing {x,y,w,h} silently yielded NaN → IDENTITY (flight never landed).
74
+ spec.setVp(fitView({ minX: n.x, minY: n.y, maxX: n.x + NODE_W, maxY: n.y + NODE_H }, size.w, size.h, 1))
75
+ spec.applyViewport()
76
+ spec.markDirty()
77
+ pulseCard(spec.cardEl(id))
78
+ }
79
+ }
80
+
81
+ export interface SearchMount extends PlanningSearch {
82
+ /** Rebuild the index from the model (call from the node reconcile). */
83
+ refresh(): void
84
+ }
85
+
86
+ /**
87
+ * Mount the whole search surface into the toolbar — the wiring lines the
88
+ * view used to carry live here (create + append + index builder), keeping
89
+ * the view under the M1 ratchet; the view keeps ONE call.
90
+ */
91
+ export function mountBoardSearch(
92
+ toolbar: HTMLElement,
93
+ model: Parameters<typeof hitsFromModel>[0],
94
+ displayName: Parameters<typeof hitsFromModel>[1],
95
+ onPick: (id: string) => void,
96
+ ): SearchMount {
97
+ const search = createPlanningSearch({ onPick })
98
+ toolbar.appendChild(search.root)
99
+ return { ...search, refresh: () => search.setIndex(hitsFromModel(model, displayName)) }
100
+ }
101
+
102
+ export interface PlanningSearch {
103
+ readonly root: HTMLElement
104
+ /** Refresh the searchable index (called from the node reconcile). */
105
+ setIndex(rows: ReadonlyArray<SearchHit>): void
106
+ /** Focus the box (Ctrl+K) and select the current text. */
107
+ focus(): void
108
+ /** Clear the query and the dropdown (Escape elsewhere). */
109
+ clear(): void
110
+ }
111
+
112
+ /** Build the search box widget (input + hit list + keyboard handling). */
113
+ export function createPlanningSearch(opts: { onPick: (id: string) => void }): PlanningSearch {
114
+ const root = document.createElement('div')
115
+ root.className = 'p-search'
116
+ const input = document.createElement('input')
117
+ input.type = 'search'
118
+ input.className = 'p-search-input'
119
+ // Short enough to read whole in the 170px box (review 2026-10-27); the
120
+ // full scope rides the tooltip and the accessible name.
121
+ input.placeholder = '🔍 搜索(Ctrl+K)'
122
+ input.title = '搜索名字 / 句柄 / 宿主(Ctrl+K 聚焦,Esc 清空)'
123
+ input.setAttribute('aria-label', '搜索画板节点:名字、句柄或宿主')
124
+ const list = document.createElement('div')
125
+ list.className = 'p-search-list'
126
+ let rows: ReadonlyArray<SearchHit> = []
127
+ let hits: SearchHit[] = []
128
+ let active = 0
129
+ const close = (): void => { list.style.display = 'none'; list.textContent = '' }
130
+ const paint = (): void => {
131
+ list.textContent = ''
132
+ if (hits.length === 0) { close(); return }
133
+ hits.forEach((h, i) => {
134
+ const row = document.createElement('div')
135
+ row.className = 'p-search-hit' + (i === active ? ' active' : '')
136
+ row.dataset.id = h.id
137
+ row.textContent = h.name === h.team ? h.name : `${h.name} · ${h.team}`
138
+ row.addEventListener('mousedown', ev => { ev.preventDefault(); opts.onPick(h.id); clearBox() })
139
+ list.appendChild(row)
140
+ })
141
+ list.style.display = 'block'
142
+ }
143
+ const clearBox = (): void => { input.value = ''; hits = []; close() }
144
+ const refresh = (): void => { hits = matchHits(rows, input.value); active = 0; paint() }
145
+ input.addEventListener('input', refresh)
146
+ input.addEventListener('keydown', ev => {
147
+ if (ev.key === 'Escape') { clearBox(); input.blur(); ev.stopPropagation(); return }
148
+ if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
149
+ if (hits.length === 0) return
150
+ ev.preventDefault()
151
+ active = (active + (ev.key === 'ArrowDown' ? 1 : hits.length - 1)) % hits.length
152
+ paint()
153
+ return
154
+ }
155
+ if (ev.key === 'Enter' && hits.length > 0) {
156
+ ev.preventDefault()
157
+ opts.onPick(hits[active]!.id)
158
+ clearBox()
159
+ }
160
+ })
161
+ root.append(input, list)
162
+ return {
163
+ root,
164
+ setIndex(next) { rows = next; if (input.value !== '') refresh() },
165
+ focus() { input.focus(); input.select() },
166
+ clear() { clearBox() },
167
+ }
168
+ }
@@ -105,6 +105,7 @@ export interface PlanningView {
105
105
  pointerDown(ev: SeamPointer): void
106
106
  pointerMove(ev: SeamPointer): void
107
107
  pointerUp(ev: SeamPointer): void
108
+ pointerCancel(ev: SeamPointer): void
108
109
  wheel(ev: SeamPointer): void
109
110
  key(ev: SeamKey): void
110
111
  keyUp(ev: SeamKey): void