@nelsonlongxiang/dsh-open-a2a-net 0.6.25 → 0.6.27
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.
- package/README.md +15 -11
- package/README_zh.md +3 -2
- package/assets/nexusDist/assets/index-DszoLz8i.js +5 -0
- package/assets/nexusDist/assets/index-uQf7NwjF.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +612 -0
- package/lib/card.js +17 -0
- package/lib/cli-deposit.js +141 -0
- package/lib/index.js +636 -25
- package/lib/owner-settings.js +100 -0
- package/lib/peer-store.js +65 -9
- package/lib/task-ledger.js +44 -3
- package/lib/types/cli-deposit.d.ts +53 -0
- package/lib/types/owner-settings.d.ts +19 -0
- package/lib/types/peer-store.d.ts +22 -3
- package/lib/types/task-ledger.d.ts +18 -0
- package/lib/types/types.d.ts +10 -0
- package/nexus-stage/src/canvas-ops.ts +2 -2
- package/nexus-stage/src/federation.ts +6 -0
- package/nexus-stage/src/main.ts +97 -4
- package/nexus-stage/src/planning-menus.ts +16 -4
- package/nexus-stage/src/planning-types.ts +3 -0
- package/nexus-stage/src/planning-view.ts +208 -18
- package/nexus-stage/src/planning.css +4 -0
- package/nexus-stage/src/wb-chat.ts +670 -0
- package/nexus-stage/src/wb-directory.ts +222 -0
- package/nexus-stage/src/wb-ledger.ts +266 -0
- package/nexus-stage/src/wb-rail.ts +147 -0
- package/nexus-stage/src/workbench.css +190 -84
- package/nexus-stage/src/workbench.ts +385 -721
- package/nexus-stage/src/world.ts +5 -1
- package/package.json +214 -218
- package/assets/nexusDist/assets/index-034h1cNc.js +0 -3
- package/assets/nexusDist/assets/index-PG9EieGX.css +0 -1
|
@@ -57,8 +57,15 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
57
57
|
|
|
58
58
|
function startCreateFromSelection(): void {
|
|
59
59
|
if (dialog !== null) return
|
|
60
|
-
//
|
|
61
|
-
|
|
60
|
+
// gh#20 W-B: a mixed local+remote selection composes a cross-host team —
|
|
61
|
+
// peers ride their zone/id8 handle at the server boundary. A peer
|
|
62
|
+
// without a handle yet (no remote team rows) cannot join; drop it and
|
|
63
|
+
// let the ≥2 floor judge the rest.
|
|
64
|
+
const ids = selectedByY().filter(id => {
|
|
65
|
+
const node = model.getNode(id)
|
|
66
|
+
if (node === undefined) return false
|
|
67
|
+
return node.remote !== true || (node.remoteHandle !== undefined && node.remoteHandle !== '')
|
|
68
|
+
})
|
|
62
69
|
if (ids.length < 2) { d.notice('info', '框选至少 2 个会话节点'); return }
|
|
63
70
|
openNameDialog(ids)
|
|
64
71
|
}
|
|
@@ -313,9 +320,14 @@ export function createPlanningMenus(d: MenusDeps): PlanningMenus {
|
|
|
313
320
|
/** Context menu from a real contextmenu event or the keyboard equivalent. */
|
|
314
321
|
function contextMenuAt(x: number, y: number, target: Element | null): void {
|
|
315
322
|
if (dialog !== null) return
|
|
316
|
-
//
|
|
323
|
+
// gh#20 W-B: a peer WITH a remote handle gets the roster menu (加入团队
|
|
324
|
+
// composes a cross-host team through the handle boundary); a peer
|
|
325
|
+
// WITHOUT one stays a read-only entry (nothing the server would accept).
|
|
317
326
|
const probeNode = target?.closest<HTMLElement>('.p-node') ?? null
|
|
318
|
-
if (probeNode !== null
|
|
327
|
+
if (probeNode !== null) {
|
|
328
|
+
const probe = model.getNode(probeNode.dataset.id ?? '')
|
|
329
|
+
if (probe?.remote === true && (probe.remoteHandle === undefined || probe.remoteHandle === '')) return
|
|
330
|
+
}
|
|
319
331
|
const nodeEl = probeNode
|
|
320
332
|
const headEl = target?.closest<HTMLElement>('.p-frame-head') ?? null
|
|
321
333
|
if (nodeEl !== null) {
|
|
@@ -24,6 +24,9 @@ export interface PlanningInput {
|
|
|
24
24
|
inFlight?: ReadonlyArray<InFlightRow>
|
|
25
25
|
/** Remote team directory rows (PR D v1 ruling): grouped under peer cards. */
|
|
26
26
|
remoteTeams?: ReadonlyArray<RemoteTeamRow>
|
|
27
|
+
/** gh#41 fleet planning: each tracked peer's published canvas org —
|
|
28
|
+
* rendered as REMOTE team frames (read + cross-host regulation). */
|
|
29
|
+
peerOrg?: ReadonlyArray<{ url: string; org: ReadonlyArray<{ name: string; team: string; members: readonly string[] }> }>
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/** Screen-width class a viewport is remembered under (window width, not board width — collapsing the rail must not switch buckets). */
|
|
@@ -383,22 +383,41 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
383
383
|
el.classList.toggle('selected', model.isSelected(n.id))
|
|
384
384
|
el.setAttribute('aria-selected', String(model.isSelected(n.id)))
|
|
385
385
|
const host = n.id.slice('peer-'.length)
|
|
386
|
-
|
|
386
|
+
// gh#38: member cards show their display NAME as the title (the whole
|
|
387
|
+
// point is picking a person); peer HOST cards keep the host spelling.
|
|
388
|
+
const nmText = n.id.startsWith('member-')
|
|
389
|
+
? (n.name !== undefined && n.name !== '' ? n.name : n.team)
|
|
390
|
+
: n.remote === true ? host : displayName(n)
|
|
387
391
|
const nmEl = el.querySelector<HTMLElement>('.nm-text')!
|
|
388
392
|
if (nmEl.textContent !== nmText) nmEl.textContent = nmText
|
|
389
393
|
const subEl = el.querySelector<HTMLElement>('.sub')!
|
|
390
394
|
const subBase = n.team + (n.name !== undefined && n.name !== '' ? ' · ' + n.name : '')
|
|
391
395
|
// When the title fell back to the team label the sub line would repeat
|
|
392
396
|
// it verbatim; collapse to the cross-team suffix alone in that case.
|
|
393
|
-
const subText = n.
|
|
394
|
-
?
|
|
395
|
-
:
|
|
396
|
-
|
|
397
|
-
|
|
397
|
+
const subText = n.id.startsWith('member-')
|
|
398
|
+
? n.team
|
|
399
|
+
: n.remote === true
|
|
400
|
+
? remoteSubText(host)
|
|
401
|
+
: (subBase === nmText ? '' : subBase)
|
|
402
|
+
+ (n.memberships.length > 1 ? ` · 跨队×${n.memberships.length}` : '')
|
|
403
|
+
+ (n.teams !== undefined && n.teams.length > 0 ? ` · 网络队×${n.teams.length}` : '')
|
|
398
404
|
if (subEl.textContent !== subText) subEl.textContent = subText
|
|
399
405
|
const prioEl = el.querySelector<HTMLElement>('.prio')!
|
|
400
|
-
|
|
401
|
-
|
|
406
|
+
if (n.remote === true) {
|
|
407
|
+
// gh#20 W-A: the peer badge — link class colors the chip (linked
|
|
408
|
+
// green / stranger gray), the verified tick/cross rides after it.
|
|
409
|
+
// An absent link field (older host) renders the neutral 'peer'.
|
|
410
|
+
const info = peerInfoByHost.get(host)
|
|
411
|
+
const cls = info?.link === 'linked' ? 'lnk-linked' : info?.link === 'stranger' ? 'lnk-stranger' : ''
|
|
412
|
+
if (prioEl.className !== `prio mono ${cls}`.trim()) prioEl.className = `prio mono ${cls}`.trim()
|
|
413
|
+
const mark = info === undefined ? '' : info.verified === true ? '✓' : '✗'
|
|
414
|
+
const badge = (info?.link ?? 'peer') + mark
|
|
415
|
+
if (prioEl.textContent !== badge) prioEl.textContent = badge
|
|
416
|
+
} else {
|
|
417
|
+
if (prioEl.className !== 'prio mono') prioEl.className = 'prio mono'
|
|
418
|
+
const prioText = n.memberships[0] !== undefined ? `P${n.memberships[0]!.index}` : ''
|
|
419
|
+
if (prioEl.textContent !== prioText) prioEl.textContent = prioText
|
|
420
|
+
}
|
|
402
421
|
}
|
|
403
422
|
for (const [id, el] of [...nodeEls]) {
|
|
404
423
|
if (!seen.has(id)) { el.remove(); nodeEls.delete(id) }
|
|
@@ -433,6 +452,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
433
452
|
let lastPeers: ReadonlyArray<PeerRow> = []
|
|
434
453
|
let lastRemoteTeams: ReadonlyMap<string, RemoteTeamRow[]> = new Map()
|
|
435
454
|
const peerScoreByText = new Map<string, number | undefined>()
|
|
455
|
+
/** gh#20 W-A: per-host link/verified info for the peer badges. */
|
|
456
|
+
const peerInfoByHost = new Map<string, { link?: 'linked' | 'stranger'; verified?: boolean }>()
|
|
436
457
|
let lastInFlight: ReadonlyArray<InFlightRow> = []
|
|
437
458
|
|
|
438
459
|
function renderFederation(): void {
|
|
@@ -524,8 +545,41 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
524
545
|
if (n <= 0) pendingTeams.delete(team)
|
|
525
546
|
else pendingTeams.set(team, n)
|
|
526
547
|
}
|
|
548
|
+
// gh#41: remote-frame registry (frame name → peer URL), refreshed by
|
|
549
|
+
// reconcile and read by emitAction's serverAction to fork remote drops.
|
|
550
|
+
let remoteFramesRef = new Map<string, string>()
|
|
527
551
|
|
|
528
552
|
function emitAction(a: CanvasAction): void {
|
|
553
|
+
// gh#20 W-B: the SERVER sees zone/id8 remote handles where the model
|
|
554
|
+
// keeps its peer-<host> node id — the canvas member contract is
|
|
555
|
+
// session-id-or-remote-ref. Translation happens here, one boundary;
|
|
556
|
+
// the model action keeps board-local ids so rendering and undo stay
|
|
557
|
+
// node-keyed. A peer without a known handle (no remote team rows yet)
|
|
558
|
+
// is filtered out — the server would refuse the raw peer- id.
|
|
559
|
+
function serverAction(act: CanvasAction): CanvasAction {
|
|
560
|
+
const mapIds = (ids: readonly string[]): string[] => {
|
|
561
|
+
const out: string[] = []
|
|
562
|
+
for (const id of ids) {
|
|
563
|
+
// Both remote card families translate to their zone/id8 handle
|
|
564
|
+
// (gh#38: member- cards join teams like peer- cards do).
|
|
565
|
+
if (!id.startsWith('peer-') && !id.startsWith('member-')) { out.push(id); continue }
|
|
566
|
+
const handle = model.getNode(id)?.remoteHandle
|
|
567
|
+
if (handle !== undefined && handle !== '') out.push(handle)
|
|
568
|
+
}
|
|
569
|
+
return out
|
|
570
|
+
}
|
|
571
|
+
if (act.type === 'add-member' || act.type === 'remove-member' || act.type === 'create-team') {
|
|
572
|
+
const ids = mapIds(act.ids)
|
|
573
|
+
// gh#41: a drop into a REMOTE frame carries the peer URL — main.ts
|
|
574
|
+
// forks it to the fleet relay instead of the local canvas control.
|
|
575
|
+
const fleetPeer = act.type !== 'create-team' ? remoteFramesRef.get(act.team) : undefined
|
|
576
|
+
return ids.length > 0 || fleetPeer !== undefined
|
|
577
|
+
? { ...act, ids, ...(fleetPeer !== undefined ? { fleetPeer } : {}) }
|
|
578
|
+
: act
|
|
579
|
+
}
|
|
580
|
+
return act
|
|
581
|
+
}
|
|
582
|
+
|
|
529
583
|
// Optimistic seats: a write may reference a session the teamed gate
|
|
530
584
|
// keeps OFF the model (no canvas card yet). The write actions read and
|
|
531
585
|
// write memberships through the node table, so materialize those ids
|
|
@@ -534,7 +588,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
534
588
|
if (a.type === 'add-member' || a.type === 'remove-member' || a.type === 'reorder' || a.type === 'create-team') {
|
|
535
589
|
const ids = a.type === 'reorder' ? a.ops.map(op => op.id) : [...a.ids]
|
|
536
590
|
for (const id of ids) {
|
|
537
|
-
if (id === '' || id.startsWith('peer-')) continue
|
|
591
|
+
if (id === '' || id.startsWith('peer-') || id.startsWith('member-')) continue
|
|
538
592
|
if (model.getNode(id) !== undefined) continue
|
|
539
593
|
const s = lastSessions.find(row => row.id === id)
|
|
540
594
|
if (s === undefined) continue
|
|
@@ -551,7 +605,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
551
605
|
const team = actionTeam(a)
|
|
552
606
|
pendingAdd(team)
|
|
553
607
|
render()
|
|
554
|
-
void deps.onCanvasAction(a).then(
|
|
608
|
+
void deps.onCanvasAction(serverAction(a)).then(
|
|
555
609
|
ok => {
|
|
556
610
|
pendingRelease(team)
|
|
557
611
|
if (!ok) undo() // host refused: roll this action's optimistic delta back
|
|
@@ -627,10 +681,18 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
627
681
|
const p = localXY(ev)
|
|
628
682
|
const w = screenToWorld(vp, p.x, p.y)
|
|
629
683
|
const f = innermostFrameAt(new Map(model.allFrames()), w)
|
|
630
|
-
//
|
|
631
|
-
|
|
684
|
+
// gh#20 W-B: peer cards join canvas teams too — a mixed local+remote
|
|
685
|
+
// drop composes a cross-host team; the server boundary translates
|
|
686
|
+
// peer ids to zone/id8 handles (emitAction's serverAction). Reorder
|
|
687
|
+
// stays local-only (roster priority is a local-session fact).
|
|
688
|
+
const memberIds = gesture.ids.filter(id => {
|
|
689
|
+
const node = model.getNode(id)
|
|
690
|
+
if (node === undefined) return false
|
|
691
|
+
if (node.remote !== true) return true
|
|
692
|
+
return node.remoteHandle !== undefined && node.remoteHandle !== ''
|
|
693
|
+
})
|
|
632
694
|
if (f !== undefined) {
|
|
633
|
-
const fresh =
|
|
695
|
+
const fresh = memberIds.filter(id => !(model.getNode(id)?.memberships.some(m => m.team === f)))
|
|
634
696
|
if (fresh.length > 0) {
|
|
635
697
|
emitAction({ type: 'add-member', team: f, ids: fresh }) // 入队:不自动离原队(多对多)
|
|
636
698
|
return
|
|
@@ -772,7 +834,12 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
772
834
|
// Drop-target highlight: the innermost frame under the pointer, but
|
|
773
835
|
// only when the drop would change something (some id not yet member).
|
|
774
836
|
const f = innermostFrameAt(new Map(model.allFrames()), w)
|
|
775
|
-
|
|
837
|
+
// W-B: peers with a remote handle are droppable members too.
|
|
838
|
+
const joinables = gesture.ids.filter(id => {
|
|
839
|
+
const node = model.getNode(id)
|
|
840
|
+
if (node === undefined) return false
|
|
841
|
+
return node.remote !== true || (node.remoteHandle !== undefined && node.remoteHandle !== '')
|
|
842
|
+
})
|
|
776
843
|
const relevant = f !== undefined && joinables.length > 0
|
|
777
844
|
for (const [name, el] of frameEls) el.classList.toggle('drop-target', relevant && name === f)
|
|
778
845
|
render()
|
|
@@ -1043,6 +1110,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1043
1110
|
let lastTeams: ReadonlyArray<PlanningTeam> = []
|
|
1044
1111
|
|
|
1045
1112
|
function reconcile(input: PlanningInput): void {
|
|
1113
|
+
// gh#41: remote-frame registry shared with serverAction (drop fork).
|
|
1046
1114
|
lastTeams = input.teams
|
|
1047
1115
|
lastPeers = input.peers ?? []
|
|
1048
1116
|
lastInFlight = input.inFlight ?? []
|
|
@@ -1127,7 +1195,8 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1127
1195
|
}
|
|
1128
1196
|
}
|
|
1129
1197
|
for (const id of model.nodeIds()) {
|
|
1130
|
-
|
|
1198
|
+
// peer- and member- cards are reconciled by their own blocks below.
|
|
1199
|
+
if (!seen.has(id) && !id.startsWith('peer-') && !id.startsWith('member-')) model.removeNode(id)
|
|
1131
1200
|
}
|
|
1132
1201
|
|
|
1133
1202
|
// Peer nodes (v1 ruling): same model pipeline as sessions, `remote` flag
|
|
@@ -1139,6 +1208,7 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1139
1208
|
peerScoreByText.clear()
|
|
1140
1209
|
for (const p of input.peers ?? []) {
|
|
1141
1210
|
peerScoreByText.set(peerHostOf(p.url), p.score)
|
|
1211
|
+
peerInfoByHost.set(peerHostOf(p.url), { link: p.link, verified: p.verified })
|
|
1142
1212
|
}
|
|
1143
1213
|
if (input.peers !== undefined) {
|
|
1144
1214
|
const localBounds = model.contentBounds()
|
|
@@ -1153,9 +1223,15 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1153
1223
|
: saved !== undefined
|
|
1154
1224
|
? { x: saved.x, y: saved.y }
|
|
1155
1225
|
: { x: placements[i]?.x ?? 200, y: placements[i]?.y ?? 0 }
|
|
1226
|
+
// gh#20 W-B: the peer's server-side member form — its first remote
|
|
1227
|
+
// team row's zone/id8 handle (canvas remote refs are exact handles
|
|
1228
|
+
// per the D1 ruling). Absent until the first remote team row lands.
|
|
1229
|
+
const host = peerHostOf(p.url)
|
|
1230
|
+
const handle = lastRemoteTeams.get(host)?.[0]?.team
|
|
1156
1231
|
model.upsertNode({
|
|
1157
1232
|
id, x: base.x, y: base.y,
|
|
1158
1233
|
label: p.url, team: '', name: p.url, peerUrl: p.url,
|
|
1234
|
+
remoteHandle: handle,
|
|
1159
1235
|
live: true, remote: true, memberships: [],
|
|
1160
1236
|
})
|
|
1161
1237
|
model.upsertScore(id, p.score)
|
|
@@ -1164,6 +1240,118 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1164
1240
|
for (const id of model.nodeIds()) {
|
|
1165
1241
|
if (id.startsWith('peer-') && !peerSeen.has(id)) model.removeNode(id)
|
|
1166
1242
|
}
|
|
1243
|
+
// gh#38 (owner ruling 「统一到画布里面拖拽」): every remote session-team
|
|
1244
|
+
// HANDLE renders as its own draggable card, stacked beside its host's
|
|
1245
|
+
// peer card — pulling a specific remote member into a local team is a
|
|
1246
|
+
// plain drag (no picker, no hand-typed handle). Host cards stay for
|
|
1247
|
+
// federation context; the member cards carry the actual join gesture.
|
|
1248
|
+
const memberSeen = new Set<string>()
|
|
1249
|
+
for (const [host, rows] of lastRemoteTeams) {
|
|
1250
|
+
const hostNode = model.allNodes().find(n => n.remote === true && peerHostOf(n.peerUrl ?? '') === host && !n.id.startsWith('member-'))
|
|
1251
|
+
const baseX = (hostNode?.x ?? 400) + 190
|
|
1252
|
+
let y = hostNode?.y ?? 0
|
|
1253
|
+
for (const row of rows) {
|
|
1254
|
+
if (!row.team.includes('/')) continue
|
|
1255
|
+
const id = `member-${row.team.replace(/[^a-z0-9-]/gi, '-')}`
|
|
1256
|
+
if (memberSeen.has(id)) continue
|
|
1257
|
+
memberSeen.add(id)
|
|
1258
|
+
const saved = layoutDoc?.nodes[id]
|
|
1259
|
+
const existing = model.getNode(id)
|
|
1260
|
+
const pos = existing !== undefined ? { x: existing.x, y: existing.y } : saved !== undefined ? { x: saved.x, y: saved.y } : { x: baseX, y }
|
|
1261
|
+
y += 92
|
|
1262
|
+
model.upsertNode({
|
|
1263
|
+
id, x: pos.x, y: pos.y,
|
|
1264
|
+
label: row.team, team: row.team, name: row.name !== undefined && row.name !== '' ? row.name : row.team,
|
|
1265
|
+
remoteHandle: row.team,
|
|
1266
|
+
live: true, remote: true, memberships: [],
|
|
1267
|
+
})
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
for (const id of model.nodeIds()) {
|
|
1271
|
+
if (id.startsWith('member-') && !memberSeen.has(id)) model.removeNode(id)
|
|
1272
|
+
}
|
|
1273
|
+
// gh#42 (owner report: dragged a remote peer into base, no edge): team
|
|
1274
|
+
// edges derive from NODE memberships, but remote cards carried hardcoded
|
|
1275
|
+
// empty memberships — the server truth (base contains dsh/d659a8f9)
|
|
1276
|
+
// never re-attached after a poll or reload. Re-attach here: every local
|
|
1277
|
+
// team's remote member handles resolve onto their cards (member- card
|
|
1278
|
+
// preferred, the peer HOST card as fallback), in-flight teams excluded.
|
|
1279
|
+
const handleToNode = new Map<string, string>()
|
|
1280
|
+
for (const n of model.allNodes()) {
|
|
1281
|
+
if (n.remote !== true || n.remoteHandle === undefined || n.remoteHandle === '') continue
|
|
1282
|
+
if (!handleToNode.has(n.remoteHandle) || n.id.startsWith('member-')) handleToNode.set(n.remoteHandle, n.id)
|
|
1283
|
+
}
|
|
1284
|
+
for (const team of input.teams) {
|
|
1285
|
+
if (pendingTeams.has(team.name)) continue
|
|
1286
|
+
const resolved: string[] = []
|
|
1287
|
+
for (const m of team.members) {
|
|
1288
|
+
const id = handleToNode.get(m.id) ?? m.id
|
|
1289
|
+
if (model.getNode(id) !== undefined) resolved.push(id)
|
|
1290
|
+
}
|
|
1291
|
+
const current = [...model.teamMemberIds(team.name)].sort().join('|')
|
|
1292
|
+
const next = [...resolved].sort().join('|')
|
|
1293
|
+
if (current !== next) model.setTeamMembers(team.name, resolved)
|
|
1294
|
+
}
|
|
1295
|
+
// gh#41 fleet planning: the peers' published canvas org renders as
|
|
1296
|
+
// REMOTE team frames — the owner reads the other side's grouping from
|
|
1297
|
+
// ANY node and regulates it in place. Members attach as board
|
|
1298
|
+
// memberships; drops into a remote frame fork to the host relay (see
|
|
1299
|
+
// serverAction: the action carries fleetPeer, and main.ts routes it to
|
|
1300
|
+
// /__dsh_a2a/fleet-canvas-op instead of the local canvas control).
|
|
1301
|
+
const remoteFrames = new Map<string, string>()
|
|
1302
|
+
const prevRemoteFrames = remoteFramesRef
|
|
1303
|
+
remoteFramesRef = remoteFrames
|
|
1304
|
+
if (input.peerOrg !== undefined) {
|
|
1305
|
+
let slot = 0
|
|
1306
|
+
const localNames = new Set(input.teams.map(t => t.name))
|
|
1307
|
+
for (const row of input.peerOrg) {
|
|
1308
|
+
// The peer's zone comes from its directory rows ('dsh/xxxx' →
|
|
1309
|
+
// 'dsh'); the org's members are stored verbatim — LOCAL members as
|
|
1310
|
+
// session ids, remote members as zone/id8 handles. Both must
|
|
1311
|
+
// resolve onto that host's session cards: id-form members become
|
|
1312
|
+
// `member-<zone>-<id8>` (id8 = strip 'session-' prefix, first 8
|
|
1313
|
+
// chars — the repo's id8 derivation), handle members sanitize
|
|
1314
|
+
// directly. The old sanitize-only pass misresolved every local
|
|
1315
|
+
// member (owner: 数据对不上).
|
|
1316
|
+
const peerZone = input.remoteTeams?.find(r => r.via === row.url)?.team.split('/')[0] ?? 'dsh'
|
|
1317
|
+
const id8of = (id: string): string => id.replace(/^session-/, '').slice(0, 8)
|
|
1318
|
+
// The server now canonicalizes org members to handles (gh#41 wire
|
|
1319
|
+
// form); the id-form branch stays as the defensive path for peers
|
|
1320
|
+
// on an older build that still publish raw session ids.
|
|
1321
|
+
const memberBoardId = (m: string): string =>
|
|
1322
|
+
m.includes('/') ? `member-${m.replace(/[^a-z0-9-]/gi, '-')}` : `member-${peerZone}-${id8of(m)}`
|
|
1323
|
+
for (const team of row.org) {
|
|
1324
|
+
if (team.name === '' || localNames.has(team.name)) continue
|
|
1325
|
+
const memberIds = team.members.map(memberBoardId).filter(id => model.getNode(id) !== undefined)
|
|
1326
|
+
const saved = layoutDoc?.frames?.[team.name]
|
|
1327
|
+
// gh#44 (owner: frame position/size never sticks): the LOCAL-team
|
|
1328
|
+
// path keeps an existing frame's live rect ("live rect wins" — a
|
|
1329
|
+
// poll must not snap a frame the user just dragged, the save loop
|
|
1330
|
+
// persists it a beat later). Remote frames re-set unconditionally
|
|
1331
|
+
// every poll, so every drag snapped back before the save landed.
|
|
1332
|
+
// Same contract here: only place the frame when it is absent.
|
|
1333
|
+
if (model.getFrame(team.name) === undefined) {
|
|
1334
|
+
model.setFrame(team.name, saved !== undefined ? { x: saved.x, y: saved.y, w: saved.w, h: saved.h } : { x: 200 + (slot % 4) * 280, y: 940 + Math.floor(slot / 4) * 160, w: 250, h: 130 })
|
|
1335
|
+
}
|
|
1336
|
+
model.setTeamMembers(team.name, memberIds)
|
|
1337
|
+
remoteFrames.set(team.name, row.url)
|
|
1338
|
+
slot += 1
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
for (const [name, url] of prevRemoteFrames) {
|
|
1343
|
+
if (!remoteFrames.has(name)) {
|
|
1344
|
+
model.removeFrame(name)
|
|
1345
|
+
void url
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
// gh#20 W-B: the server echoes cross-host members as zone/id8 handles —
|
|
1349
|
+
// map each handle back onto the peer node that carries it, so the
|
|
1350
|
+
// reconcile keeps remote memberships instead of dropping them.
|
|
1351
|
+
const handleToPeer = new Map<string, string>()
|
|
1352
|
+
for (const n of model.allNodes()) {
|
|
1353
|
+
if (n.remote === true && n.remoteHandle !== undefined && n.remoteHandle !== '') handleToPeer.set(n.remoteHandle, n.id)
|
|
1354
|
+
}
|
|
1167
1355
|
const homes = homeTeams(input.teams)
|
|
1168
1356
|
for (const team of input.teams) {
|
|
1169
1357
|
if (model.getFrame(team.name) !== undefined) continue // live rect wins
|
|
@@ -1177,17 +1365,19 @@ export function createPlanningView(deps: PlanningDeps): PlanningView {
|
|
|
1177
1365
|
const rects: LayoutRect[] = []
|
|
1178
1366
|
const all: LayoutRect[] = []
|
|
1179
1367
|
for (const m of team.members) {
|
|
1180
|
-
const n = model.getNode(m.id)
|
|
1368
|
+
const n = model.getNode(handleToPeer.get(m.id) ?? m.id)
|
|
1181
1369
|
if (n === undefined) continue
|
|
1182
1370
|
all.push(nodeRect(n))
|
|
1183
|
-
if (homes.get(
|
|
1371
|
+
if (homes.get(n.id) === team.name) rects.push(nodeRect(n))
|
|
1184
1372
|
}
|
|
1185
1373
|
const wrap = rects.length > 0 ? rects : all
|
|
1186
1374
|
if (wrap.length > 0) model.setFrame(team.name, deriveInitialFrame(wrap))
|
|
1187
1375
|
}
|
|
1188
1376
|
for (const [name] of model.allFrames()) {
|
|
1189
1377
|
if (pendingTeams.has(name)) continue // an in-flight create/remove owns this name
|
|
1190
|
-
|
|
1378
|
+
// gh#41: remote org frames are reconciled by the peerOrg block above —
|
|
1379
|
+
// the local-team cleanup must not remove them.
|
|
1380
|
+
if (!input.teams.some(t => t.name === name) && !remoteFrames.has(name)) model.removeFrame(name)
|
|
1191
1381
|
}
|
|
1192
1382
|
// An open node panel follows the poll: facts (joins, leaves, team
|
|
1193
1383
|
// declarations, peer arrivals) land within one cadence.
|
|
@@ -176,6 +176,10 @@
|
|
|
176
176
|
#dsh-plan .p-node.remote{border-left-color:var(--federal); padding-right:46px}
|
|
177
177
|
#dsh-plan .p-node.remote .dot{background:var(--federal)}
|
|
178
178
|
#dsh-plan .p-node.remote .prio{color:var(--ink)}
|
|
179
|
+
/* gh#20 W-A link badge: linked green / stranger gray (fleet reserved);
|
|
180
|
+
the ✓/✗ verified mark rides inside the same chip. */
|
|
181
|
+
#dsh-plan .p-node .prio.lnk-linked{color:var(--live, #2e9e5b); border-color:var(--live, #2e9e5b)}
|
|
182
|
+
#dsh-plan .p-node .prio.lnk-stranger{color:var(--ink-muted); border-color:var(--card-line)}
|
|
179
183
|
#dsh-plan .e-activity{stroke:var(--accent); stroke-width:2; stroke-dasharray:8 6; stroke-opacity:.9}
|
|
180
184
|
#dsh-plan .e-federal{stroke:var(--federal); stroke-width:1.5; stroke-dasharray:6 5; stroke-opacity:.5}
|
|
181
185
|
#dsh-plan .edge-label{font-size:10.5px; fill:var(--ink-muted)}
|