@codemem/server 0.21.2 → 0.22.0-alpha.0

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.
@@ -1 +1 @@
1
- {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/routes/sync.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,OAAO,KAAK,EAAE,WAAW,EAAiB,MAAM,eAAe,CAAC;AAuBhE,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAG5B,KAAK,YAAY,GAAG,MAAM,WAAW,CAAC;AACtC,KAAK,iBAAiB,GAAG;IACxB,KAAK,EACF,UAAU,GACV,SAAS,GACT,UAAU,GACV,OAAO,GACP,UAAU,GACV,iBAAiB,GACjB,iBAAiB,GACjB,IAAI,CAAC;IACR,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAmXF;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,YAAY,8EAsMxD;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CACzB,QAAQ,EAAE,YAAY,EACtB,oBAAoB,CAAC,EAAE,MAAM,iBAAiB,GAAG,IAAI,8EAqfrD"}
1
+ {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/routes/sync.ts"],"names":[],"mappings":"AAAA;;GAEG;AAKH,OAAO,KAAK,EAAE,WAAW,EAAiB,MAAM,eAAe,CAAC;AAyBhE,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAG5B,KAAK,YAAY,GAAG,MAAM,WAAW,CAAC;AACtC,KAAK,iBAAiB,GAAG;IACxB,KAAK,EACF,UAAU,GACV,SAAS,GACT,UAAU,GACV,OAAO,GACP,UAAU,GACV,iBAAiB,GACjB,iBAAiB,GACjB,IAAI,CAAC;IACR,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AA4fF;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,YAAY,8EAsMxD;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CACzB,QAAQ,EAAE,YAAY,EACtB,oBAAoB,CAAC,EAAE,MAAM,iBAAiB,GAAG,IAAI,8EAkiBrD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemem/server",
3
- "version": "0.21.2",
3
+ "version": "0.22.0-alpha.0",
4
4
  "type": "module",
5
5
  "types": "./dist/index.d.ts",
6
6
  "exports": {
@@ -18,7 +18,7 @@
18
18
  "@hono/node-server": "^1.14.3",
19
19
  "drizzle-orm": "^0.45.1",
20
20
  "hono": "^4.7.10",
21
- "@codemem/core": "^0.21.2"
21
+ "@codemem/core": "^0.22.0-alpha.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/better-sqlite3": "^7.6.13",
package/static/app.js CHANGED
@@ -366,6 +366,32 @@
366
366
  if (!resp.ok) throw new Error(payload?.error || text || "request failed");
367
367
  return payload;
368
368
  }
369
+ async function deletePeer(peerDeviceId) {
370
+ const resp = await fetch(`/api/sync/peers/${encodeURIComponent(peerDeviceId)}`, { method: "DELETE" });
371
+ const text = await resp.text();
372
+ const payload = text ? JSON.parse(text) : {};
373
+ if (!resp.ok) throw new Error(payload?.error || text || "request failed");
374
+ return payload;
375
+ }
376
+ async function acceptDiscoveredPeer(peerDeviceId, fingerprint) {
377
+ const resp = await fetch("/api/sync/peers/accept-discovered", {
378
+ method: "POST",
379
+ headers: { "Content-Type": "application/json" },
380
+ body: JSON.stringify({
381
+ peer_device_id: peerDeviceId,
382
+ fingerprint
383
+ })
384
+ });
385
+ const text = await resp.text();
386
+ let payload = {};
387
+ try {
388
+ payload = text ? JSON.parse(text) : {};
389
+ } catch {
390
+ payload = {};
391
+ }
392
+ if (!resp.ok) throw new Error(payload?.detail || payload?.error || text || "request failed");
393
+ return payload;
394
+ }
369
395
  async function createActor(displayName) {
370
396
  const resp = await fetch("/api/sync/actors", {
371
397
  method: "POST",
@@ -421,11 +447,19 @@
421
447
  }
422
448
  async function triggerSync(address) {
423
449
  const payload = address ? { address } : {};
424
- await fetch("/api/sync/run", {
450
+ const resp = await fetch("/api/sync/run", {
425
451
  method: "POST",
426
452
  headers: { "Content-Type": "application/json" },
427
453
  body: JSON.stringify(payload)
428
454
  });
455
+ const text = await resp.text();
456
+ let body = {};
457
+ try {
458
+ body = text ? JSON.parse(text) : {};
459
+ } catch {
460
+ body = {};
461
+ }
462
+ if (!resp.ok) throw new Error(body?.error || text || "request failed");
429
463
  }
430
464
  //#endregion
431
465
  //#region src/lib/format.ts
@@ -1774,6 +1808,30 @@
1774
1808
  teamInvitePanelOpen = v;
1775
1809
  }
1776
1810
  var openPeerScopeEditors = /* @__PURE__ */ new Set();
1811
+ var pendingPeerScopeReviewIds = /* @__PURE__ */ new Set();
1812
+ var freshPeerScopeReviewIds = /* @__PURE__ */ new Set();
1813
+ function requestPeerScopeReview(peerDeviceId) {
1814
+ const value = String(peerDeviceId || "").trim();
1815
+ if (!value) return;
1816
+ pendingPeerScopeReviewIds.add(value);
1817
+ freshPeerScopeReviewIds.add(value);
1818
+ openPeerScopeEditors.add(value);
1819
+ }
1820
+ function isPeerScopeReviewPending(peerDeviceId) {
1821
+ const value = String(peerDeviceId || "").trim();
1822
+ return Boolean(value) && pendingPeerScopeReviewIds.has(value);
1823
+ }
1824
+ function clearPeerScopeReview(peerDeviceId) {
1825
+ const value = String(peerDeviceId || "").trim();
1826
+ if (!value) return;
1827
+ pendingPeerScopeReviewIds.delete(value);
1828
+ }
1829
+ function consumePeerScopeReviewRequest(peerDeviceId) {
1830
+ const value = String(peerDeviceId || "").trim();
1831
+ if (!value || !freshPeerScopeReviewIds.has(value)) return false;
1832
+ freshPeerScopeReviewIds.delete(value);
1833
+ return true;
1834
+ }
1777
1835
  /** Redact the last two octets of IPv4 addresses. */
1778
1836
  function redactIpOctets(text) {
1779
1837
  return text.replace(/\b(\d{1,3}\.\d{1,3})\.\d{1,3}\.\d{1,3}\b/g, "$1.#.#");
@@ -2254,6 +2312,9 @@
2254
2312
  const toggleAdmin = document.getElementById("syncToggleAdmin");
2255
2313
  const joinPanel = document.getElementById("syncJoinPanel");
2256
2314
  const joinRequests = document.getElementById("syncJoinRequests");
2315
+ const discoveredPanel = document.getElementById("syncCoordinatorDiscovered");
2316
+ const discoveredMeta = document.getElementById("syncCoordinatorDiscoveredMeta");
2317
+ const discoveredList = document.getElementById("syncCoordinatorDiscoveredList");
2257
2318
  if (!meta || !setupPanel || !list || !actions) return;
2258
2319
  hideSkeleton("syncTeamSkeleton");
2259
2320
  ensureInvitePanelInAdminSection();
@@ -2261,6 +2322,7 @@
2261
2322
  list.textContent = "";
2262
2323
  actions.textContent = "";
2263
2324
  if (joinRequests) joinRequests.textContent = "";
2325
+ if (discoveredList) discoveredList.textContent = "";
2264
2326
  setInviteOutputVisibility();
2265
2327
  const coordinator = state.lastSyncCoordinator;
2266
2328
  const configured = Boolean(coordinator && coordinator.configured);
@@ -2270,6 +2332,7 @@
2270
2332
  list.hidden = true;
2271
2333
  actions.hidden = true;
2272
2334
  if (joinRequests) joinRequests.hidden = true;
2335
+ if (discoveredPanel) discoveredPanel.hidden = true;
2273
2336
  if (invitePanel) invitePanel.hidden = !adminSetupExpanded;
2274
2337
  if (toggleAdmin) toggleAdmin.textContent = adminSetupExpanded ? "Hide team setup" : "Set up a new team instead…";
2275
2338
  return;
@@ -2278,16 +2341,121 @@
2278
2341
  list.hidden = false;
2279
2342
  actions.hidden = false;
2280
2343
  if (joinRequests) joinRequests.hidden = false;
2344
+ if (discoveredPanel) discoveredPanel.hidden = true;
2281
2345
  const presenceLabel = coordinator.presence_status === "posted" ? "Connected" : coordinator.presence_status === "not_enrolled" ? "Not connected — import an invite or ask your admin to enroll this device" : "Connection error";
2282
2346
  const statusRow = el("div", "sync-team-summary");
2283
2347
  const statusLine = el("div", "sync-team-status-row");
2284
2348
  const statusLabel = el("span", "sync-team-status-label", "Status");
2285
2349
  const statusBadge = el("span", `pill ${coordinator.presence_status === "posted" ? "pill-success" : coordinator.presence_status === "not_enrolled" ? "pill-warning" : "pill-error"}`, presenceLabel);
2286
- const metricParts = [`Paired devices: ${Number(coordinator.paired_peer_count || 0)}`, `Discovered: ${Number(coordinator.fresh_peer_count || 0)}`];
2350
+ const metricParts = [`Paired devices: ${Number(coordinator.paired_peer_count || 0)}`, `Fresh discovered: ${Number(coordinator.fresh_peer_count || 0)}`];
2287
2351
  if (Number(coordinator.stale_peer_count || 0) > 0) metricParts.push(`Inactive: ${Number(coordinator.stale_peer_count || 0)}`);
2288
2352
  statusLine.append(statusLabel, statusBadge);
2289
2353
  statusRow.append(statusLine, el("div", "sync-team-metrics", metricParts.join(" · ")));
2290
2354
  list.appendChild(statusRow);
2355
+ const localPeers = Array.isArray(state.lastSyncPeers) ? state.lastSyncPeers : [];
2356
+ const unhealthyPeers = localPeers.filter((peer) => {
2357
+ const status = peer?.status || {};
2358
+ return Boolean(peer?.has_error) || [
2359
+ "offline",
2360
+ "stale",
2361
+ "degraded"
2362
+ ].includes(String(status.peer_state || ""));
2363
+ });
2364
+ if (unhealthyPeers.length) {
2365
+ const row = el("div", "sync-action");
2366
+ const textWrap = el("div", "sync-action-text");
2367
+ textWrap.textContent = `${unhealthyPeers.length} paired device${unhealthyPeers.length === 1 ? "" : "s"} look stale or unhealthy.`;
2368
+ textWrap.appendChild(el("span", "sync-action-command", "Use Remove peer in People or codemem sync peers remove <peer> before re-pairing."));
2369
+ row.appendChild(textWrap);
2370
+ actions.appendChild(row);
2371
+ }
2372
+ const discoveredDevices = Array.isArray(coordinator.discovered_devices) ? coordinator.discovered_devices : [];
2373
+ if (discoveredPanel && discoveredMeta && discoveredList && discoveredDevices.length) {
2374
+ discoveredPanel.hidden = false;
2375
+ discoveredMeta.textContent = "These devices are visible through coordinator discovery only. They are not active sync peers unless you pair them.";
2376
+ discoveredDevices.forEach((device) => {
2377
+ const row = el("div", "actor-row");
2378
+ const details = el("div", "actor-details");
2379
+ const title = el("div", "actor-title");
2380
+ const rowActions = el("div", "actor-actions");
2381
+ const deviceId = String(device.device_id || "").trim();
2382
+ const displayName = String(device.display_name || "").trim() || deviceId || "Discovered device";
2383
+ const fingerprint = String(device.fingerprint || "").trim();
2384
+ const pairedPeer = localPeers.find((peer) => String(peer?.peer_device_id || "") === deviceId);
2385
+ const pairedFingerprint = String(pairedPeer?.fingerprint || "").trim();
2386
+ const hasConflict = Boolean(pairedPeer) && Boolean(fingerprint) && Boolean(pairedFingerprint) && pairedFingerprint !== fingerprint;
2387
+ const canAccept = Boolean(deviceId) && Boolean(fingerprint) && !pairedPeer && !device.stale;
2388
+ title.append(el("strong", null, displayName));
2389
+ title.appendChild(el("span", `badge actor-badge${device.stale ? "" : " local"}`, device.stale ? "Inactive" : "Fresh"));
2390
+ title.appendChild(el("span", "badge actor-badge", hasConflict ? "Conflicts with local peer" : pairedPeer ? "Paired locally" : "Not paired locally"));
2391
+ const addresses = Array.isArray(device.addresses) ? device.addresses : [];
2392
+ const noteParts = [deviceId, addresses.length ? addresses.map((address) => isSyncRedactionEnabled() ? redactAddress(String(address || "")) : String(address || "")).filter(Boolean).join(" · ") : "No fresh addresses"];
2393
+ if (hasConflict) noteParts.push("repair the local peer before accepting this discovered device");
2394
+ else if (pairedPeer && isPeerScopeReviewPending(deviceId)) noteParts.push("scope review pending in People");
2395
+ else if (pairedPeer?.last_error) noteParts.push(`paired error: ${String(pairedPeer.last_error)}`);
2396
+ else if (pairedPeer?.status?.peer_state) noteParts.push(`paired status: ${String(pairedPeer.status.peer_state)}`);
2397
+ details.append(title, el("div", "peer-meta", noteParts.join(" · ")));
2398
+ if (canAccept) {
2399
+ const acceptBtn = el("button", "settings-button", "Accept peer");
2400
+ acceptBtn.addEventListener("click", async () => {
2401
+ acceptBtn.disabled = true;
2402
+ acceptBtn.textContent = "Accepting…";
2403
+ try {
2404
+ await acceptDiscoveredPeer(deviceId, fingerprint);
2405
+ requestPeerScopeReview(deviceId);
2406
+ showGlobalNotice(`Accepted ${displayName}. Its scope editor is now open in People so you can review sync scope next.`);
2407
+ await _loadSyncData$1();
2408
+ } catch (error) {
2409
+ showGlobalNotice(friendlyError(error, "Failed to accept discovered peer."), "warning");
2410
+ acceptBtn.textContent = "Retry accept";
2411
+ } finally {
2412
+ acceptBtn.disabled = false;
2413
+ if (acceptBtn.textContent === "Accepting…") acceptBtn.textContent = "Accept peer";
2414
+ }
2415
+ });
2416
+ rowActions.appendChild(acceptBtn);
2417
+ } else if (!pairedPeer && device.stale) rowActions.appendChild(el("div", "peer-meta", "Wait for a fresh coordinator presence update."));
2418
+ else if (hasConflict) {
2419
+ const inspectBtn = el("button", "settings-button", "Jump to local peer");
2420
+ inspectBtn.addEventListener("click", () => {
2421
+ const peerCard = document.querySelector(`[data-peer-device-id="${CSS.escape(deviceId)}"]`);
2422
+ if (peerCard instanceof HTMLElement) {
2423
+ peerCard.scrollIntoView({
2424
+ block: "center",
2425
+ behavior: "smooth"
2426
+ });
2427
+ showGlobalNotice(`Jumped to the conflicting local peer for ${displayName}.`, "warning");
2428
+ return;
2429
+ }
2430
+ showGlobalNotice("Conflicting local peer is not visible yet. Scroll to the People area and try again.", "warning");
2431
+ });
2432
+ const removeConflictBtn = el("button", "settings-button", "Remove conflicting peer");
2433
+ removeConflictBtn.addEventListener("click", async () => {
2434
+ if (!pairedPeer) return;
2435
+ if (!window.confirm(`Remove the conflicting local peer for ${displayName}? You can accept the discovered device again after this refreshes.`)) return;
2436
+ removeConflictBtn.disabled = true;
2437
+ inspectBtn.disabled = true;
2438
+ removeConflictBtn.textContent = "Removing…";
2439
+ try {
2440
+ await deletePeer(deviceId);
2441
+ showGlobalNotice(`Removed the conflicting local peer for ${displayName}. If it is still fresh, you can now accept it from Team sync.`);
2442
+ await _loadSyncData$1();
2443
+ } catch (error) {
2444
+ showGlobalNotice(friendlyError(error, "Failed to remove the conflicting local peer."), "warning");
2445
+ removeConflictBtn.textContent = "Retry remove";
2446
+ } finally {
2447
+ removeConflictBtn.disabled = false;
2448
+ inspectBtn.disabled = false;
2449
+ if (removeConflictBtn.textContent === "Removing…") removeConflictBtn.textContent = "Remove conflicting peer";
2450
+ }
2451
+ });
2452
+ rowActions.append(inspectBtn, removeConflictBtn);
2453
+ } else if (pairedPeer && isPeerScopeReviewPending(deviceId)) rowActions.appendChild(el("div", "peer-meta", "Review this peer's scope in People next."));
2454
+ else if (pairedPeer) rowActions.appendChild(el("div", "peer-meta", "Manage this peer in People."));
2455
+ row.append(details, rowActions);
2456
+ discoveredList.appendChild(row);
2457
+ });
2458
+ }
2291
2459
  const inviteToggleRow = el("div", "sync-action");
2292
2460
  const inviteToggleText = el("div", "sync-action-text");
2293
2461
  inviteToggleText.textContent = "Generate an invite to add another teammate to this team.";
@@ -2421,6 +2589,7 @@
2421
2589
  syncInviteOutput.focus();
2422
2590
  syncInviteOutput.select();
2423
2591
  showGlobalNotice("Invite created. Copy the text above and share it with your teammate.");
2592
+ (Array.isArray(result.warnings) ? result.warnings : []).forEach((warning) => showGlobalNotice(String(warning), "warning"));
2424
2593
  } catch (error) {
2425
2594
  showGlobalNotice(friendlyError(error, "Failed to create invite."), "warning");
2426
2595
  } finally {
@@ -2587,27 +2756,66 @@
2587
2756
  const card = el("div", "peer-card");
2588
2757
  const titleRow = el("div", "peer-title");
2589
2758
  const peerId = peer.peer_device_id ? String(peer.peer_device_id) : "";
2759
+ if (peerId) card.dataset.peerDeviceId = peerId;
2590
2760
  const displayName = peer.name || (peerId ? peerId.slice(0, 8) : "unknown");
2761
+ const destructiveLabel = peer.name || peerId || displayName;
2762
+ const pendingScopeReview = isPeerScopeReviewPending(peerId);
2591
2763
  const name = el("strong", null, displayName);
2592
2764
  if (peerId) name.title = peerId;
2593
2765
  const peerStatus = peer.status || {};
2594
2766
  const online = peerStatus.sync_status === "ok" || peerStatus.ping_status === "ok";
2595
2767
  const badge = el("span", `badge ${online ? "badge-online" : "badge-offline"}`, online ? "Online" : "Offline");
2596
2768
  name.append(" ", badge);
2769
+ if (pendingScopeReview) name.append(" ", el("span", "badge actor-badge", "Needs scope review"));
2597
2770
  const actions = el("div", "peer-actions");
2598
2771
  const primaryAddress = pickPrimaryAddress(peer.addresses);
2599
2772
  const syncBtn = el("button", null, "Sync now");
2600
2773
  syncBtn.disabled = !primaryAddress;
2601
2774
  syncBtn.addEventListener("click", async () => {
2775
+ if (pendingScopeReview) {
2776
+ if (!window.confirm(`Sync scope review is still pending for ${displayName}. Continue with a manual sync anyway?`)) return;
2777
+ }
2602
2778
  syncBtn.disabled = true;
2603
2779
  syncBtn.textContent = "Syncing…";
2604
2780
  try {
2605
2781
  await triggerSync(primaryAddress);
2606
- } catch {}
2607
- syncBtn.disabled = false;
2608
- syncBtn.textContent = "Sync now";
2782
+ if (pendingScopeReview) showGlobalNotice(`Triggered sync for ${displayName} before scope review was finished. Review scope in this card if you want tighter sharing rules.`, "warning");
2783
+ else showGlobalNotice(`Started sync with ${displayName}.`);
2784
+ } catch (error) {
2785
+ showGlobalNotice(friendlyError(error, "Failed to trigger sync."), "warning");
2786
+ syncBtn.disabled = false;
2787
+ syncBtn.textContent = "Sync now";
2788
+ return;
2789
+ }
2790
+ try {
2791
+ await _loadSyncData();
2792
+ } catch {
2793
+ showGlobalNotice("Sync started, but the local status view did not refresh yet.", "warning");
2794
+ } finally {
2795
+ syncBtn.disabled = false;
2796
+ syncBtn.textContent = "Sync now";
2797
+ }
2609
2798
  });
2610
2799
  actions.appendChild(syncBtn);
2800
+ const removeBtn = el("button", null, "Remove peer");
2801
+ removeBtn.addEventListener("click", async () => {
2802
+ if (!peerId) return;
2803
+ if (!window.confirm(`Remove peer ${destructiveLabel}? This deletes the local sync peer entry.`)) return;
2804
+ removeBtn.disabled = true;
2805
+ removeBtn.textContent = "Removing…";
2806
+ try {
2807
+ await deletePeer(peerId);
2808
+ showGlobalNotice(`Removed peer ${destructiveLabel}.`);
2809
+ await _loadSyncData();
2810
+ } catch (error) {
2811
+ showGlobalNotice(friendlyError(error, "Failed to remove peer."), "warning");
2812
+ removeBtn.textContent = "Retry remove";
2813
+ } finally {
2814
+ removeBtn.disabled = false;
2815
+ if (removeBtn.textContent === "Removing…") removeBtn.textContent = "Remove peer";
2816
+ }
2817
+ });
2818
+ actions.appendChild(removeBtn);
2611
2819
  const toggleScopeBtn = el("button", null, "Edit scope");
2612
2820
  actions.appendChild(toggleScopeBtn);
2613
2821
  const peerAddresses = Array.isArray(peer.addresses) ? Array.from(new Set(peer.addresses.filter(Boolean))) : [];
@@ -2658,6 +2866,7 @@
2658
2866
  const includeEditor = createChipEditor(includeList, "Add included project", "All projects");
2659
2867
  const excludeEditor = createChipEditor(excludeList, "Add excluded project", "No exclusions");
2660
2868
  const scopeEditorOpen = openPeerScopeEditors.has(peerId);
2869
+ const scopeReviewRequested = consumePeerScopeReviewRequest(peerId);
2661
2870
  const editorWrap = el("div", `peer-scope-editor-wrap${scopeEditorOpen ? "" : " collapsed"}`);
2662
2871
  if (!scopeEditorOpen) editorWrap.inert = true;
2663
2872
  const inputRow = el("div", "peer-scope-row");
@@ -2670,6 +2879,7 @@
2670
2879
  saveScopeBtn.textContent = "Saving…";
2671
2880
  try {
2672
2881
  await updatePeerScope(peerId, includeEditor.values(), excludeEditor.values());
2882
+ clearPeerScopeReview(peerId);
2673
2883
  showGlobalNotice("Device sync scope saved.");
2674
2884
  await _loadSyncData();
2675
2885
  } catch (error) {
@@ -2685,6 +2895,7 @@
2685
2895
  inheritBtn.textContent = "Resetting…";
2686
2896
  try {
2687
2897
  await updatePeerScope(peerId, null, null, true);
2898
+ clearPeerScopeReview(peerId);
2688
2899
  showGlobalNotice("Device sync scope reset to global defaults.");
2689
2900
  await _loadSyncData();
2690
2901
  } catch (error) {
@@ -2708,6 +2919,13 @@
2708
2919
  toggleScopeBtn.setAttribute("aria-expanded", String(isCollapsed));
2709
2920
  toggleScopeBtn.textContent = isCollapsed ? "Hide scope editor" : "Edit scope";
2710
2921
  });
2922
+ if (scopeReviewRequested) {
2923
+ scopePanel.prepend(el("div", "peer-meta", "Review this device's sync scope now. Global defaults apply until you save an override here."));
2924
+ queueMicrotask(() => card.scrollIntoView({
2925
+ block: "center",
2926
+ behavior: "smooth"
2927
+ }));
2928
+ } else if (pendingScopeReview) scopePanel.prepend(el("div", "peer-meta", "Scope review still pending. Save an override here or reset to global scope when you are done reviewing. Manual syncs can proceed, but they will use the current effective scope until you change it."));
2711
2929
  scopePanel.append(identityRow, identityMeta, actorRow, actorHint, scopeSummary, effectiveSummary, editorWrap);
2712
2930
  titleRow.append(name, actions);
2713
2931
  card.append(titleRow, addressLabel, meta, scopePanel);
package/static/index.html CHANGED
@@ -1774,6 +1774,11 @@
1774
1774
  </div>
1775
1775
  <div class="actor-list" id="syncTeamStatus" aria-live="polite"></div>
1776
1776
  <div class="actor-list" id="syncJoinRequests"></div>
1777
+ <div id="syncCoordinatorDiscovered" hidden>
1778
+ <h3 class="settings-group-title" style="margin-top: 20px">Coordinator-discovered devices</h3>
1779
+ <div class="section-meta" id="syncCoordinatorDiscoveredMeta"></div>
1780
+ <div class="actor-list" id="syncCoordinatorDiscoveredList"></div>
1781
+ </div>
1777
1782
  <div class="sync-actions" id="syncTeamActions" hidden></div>
1778
1783
  </div>
1779
1784