@codemem/server 0.22.0-alpha.2 → 0.22.0-alpha.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemem/server",
3
- "version": "0.22.0-alpha.2",
3
+ "version": "0.22.0-alpha.4",
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.22.0-alpha.2"
21
+ "@codemem/core": "^0.22.0-alpha.4"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/better-sqlite3": "^7.6.13",
package/static/app.js CHANGED
@@ -146,6 +146,8 @@
146
146
  lastTeamJoin: null,
147
147
  lastSyncAttempts: [],
148
148
  lastSyncLegacyDevices: [],
149
+ lastSyncViewModel: null,
150
+ lastSyncDuplicatePersonDecisions: {},
149
151
  pairingPayloadRaw: null,
150
152
  pairingCommandRaw: "",
151
153
  configDefaults: {},
@@ -373,6 +375,20 @@
373
375
  if (!resp.ok) throw new Error(payload?.error || text || "request failed");
374
376
  return payload;
375
377
  }
378
+ async function renamePeer(peerDeviceId, name) {
379
+ const resp = await fetch("/api/sync/peers/rename", {
380
+ method: "POST",
381
+ headers: { "Content-Type": "application/json" },
382
+ body: JSON.stringify({
383
+ peer_device_id: peerDeviceId,
384
+ name
385
+ })
386
+ });
387
+ const text = await resp.text();
388
+ const payload = text ? JSON.parse(text) : {};
389
+ if (!resp.ok) throw new Error(payload?.error || text || "request failed");
390
+ return payload;
391
+ }
376
392
  async function acceptDiscoveredPeer(peerDeviceId, fingerprint) {
377
393
  const resp = await fetch("/api/sync/peers/accept-discovered", {
378
394
  method: "POST",
@@ -1794,6 +1810,203 @@
1794
1810
  if (state.activeTab === "feed" && previousActorId !== nextActorId) updateFeedView(true);
1795
1811
  }
1796
1812
  //#endregion
1813
+ //#region src/tabs/sync/view-model.ts
1814
+ var SYNC_TERMINOLOGY = {
1815
+ actor: "person",
1816
+ actors: "people",
1817
+ actorAssignment: "person assignment",
1818
+ localActor: "you",
1819
+ peer: "device",
1820
+ peers: "devices",
1821
+ pairedLocally: "Connected on this device",
1822
+ discovered: "Seen on team",
1823
+ conflicts: "Needs repair"
1824
+ };
1825
+ function cleanText(value) {
1826
+ return String(value ?? "").trim();
1827
+ }
1828
+ function normalizeDisplayName(value) {
1829
+ return cleanText(value).replace(/\s+/g, " ").toLowerCase();
1830
+ }
1831
+ function friendlyDeviceFallback(deviceId) {
1832
+ const cleanId = cleanText(deviceId);
1833
+ return cleanId ? cleanId.slice(0, 8) : "Unnamed device";
1834
+ }
1835
+ function resolveFriendlyDeviceName(input) {
1836
+ const localName = cleanText(input.localName);
1837
+ if (localName) return localName;
1838
+ const coordinatorName = cleanText(input.coordinatorName);
1839
+ if (coordinatorName) return coordinatorName;
1840
+ return friendlyDeviceFallback(cleanText(input.deviceId));
1841
+ }
1842
+ function deviceNeedsFriendlyName(input) {
1843
+ const localName = cleanText(input.localName);
1844
+ const coordinatorName = cleanText(input.coordinatorName);
1845
+ if (localName || coordinatorName) return false;
1846
+ return Boolean(cleanText(input.deviceId));
1847
+ }
1848
+ function derivePeerUiStatus(peer) {
1849
+ const peerState = cleanText(peer?.status?.peer_state);
1850
+ if (peer?.has_error || peerState === "degraded") return "needs-repair";
1851
+ if (peerState === "online") return "connected";
1852
+ if (peerState === "offline" || peerState === "stale") return "offline";
1853
+ if (peer?.status?.fresh) return "connected";
1854
+ return "waiting";
1855
+ }
1856
+ function deriveDuplicatePeople(actors) {
1857
+ const groups = /* @__PURE__ */ new Map();
1858
+ (Array.isArray(actors) ? actors : []).forEach((actor) => {
1859
+ const displayName = cleanText(actor?.display_name);
1860
+ const actorId = cleanText(actor?.actor_id);
1861
+ const normalized = normalizeDisplayName(displayName);
1862
+ if (!displayName || !actorId || !normalized) return;
1863
+ const current = groups.get(normalized) ?? {
1864
+ displayName,
1865
+ actorIds: [],
1866
+ includesLocal: false
1867
+ };
1868
+ current.actorIds = [...current.actorIds, actorId];
1869
+ current.includesLocal = current.includesLocal || Boolean(actor?.is_local);
1870
+ groups.set(normalized, current);
1871
+ });
1872
+ return [...groups.values()].filter((item) => item.actorIds.length > 1).sort((a, b) => a.displayName.localeCompare(b.displayName));
1873
+ }
1874
+ function createRepairItem(device) {
1875
+ return {
1876
+ id: `repair:${device.id}`,
1877
+ kind: "device-needs-repair",
1878
+ priority: 10,
1879
+ title: `${device.name} needs repair`,
1880
+ summary: device.summary,
1881
+ actionLabel: "Review device",
1882
+ deviceId: device.id
1883
+ };
1884
+ }
1885
+ function createReviewItem(device) {
1886
+ return {
1887
+ id: `review:${device.id}`,
1888
+ kind: "review-team-device",
1889
+ priority: 20,
1890
+ title: `${device.name} is available to review`,
1891
+ summary: device.summary,
1892
+ actionLabel: "Review device",
1893
+ deviceId: device.id
1894
+ };
1895
+ }
1896
+ function createNamingItem(device) {
1897
+ return {
1898
+ id: `name:${device.id}`,
1899
+ kind: "name-device",
1900
+ priority: 30,
1901
+ title: `Name ${device.name}`,
1902
+ summary: device.summary,
1903
+ actionLabel: "Name device",
1904
+ deviceId: device.id
1905
+ };
1906
+ }
1907
+ function mergeDevices(peers, discoveredDevices) {
1908
+ const devices = /* @__PURE__ */ new Map();
1909
+ const getOrCreate = (deviceId) => {
1910
+ const current = devices.get(deviceId) ?? {
1911
+ deviceId,
1912
+ localName: "",
1913
+ coordinatorName: "",
1914
+ peer: null,
1915
+ discovered: null
1916
+ };
1917
+ devices.set(deviceId, current);
1918
+ return current;
1919
+ };
1920
+ peers.forEach((peer) => {
1921
+ const deviceId = cleanText(peer?.peer_device_id);
1922
+ if (!deviceId) return;
1923
+ const current = getOrCreate(deviceId);
1924
+ current.peer = peer;
1925
+ current.localName = cleanText(peer?.name);
1926
+ });
1927
+ discoveredDevices.forEach((device) => {
1928
+ const deviceId = cleanText(device?.device_id);
1929
+ if (!deviceId) return;
1930
+ const current = getOrCreate(deviceId);
1931
+ current.discovered = device;
1932
+ current.coordinatorName = cleanText(device?.display_name);
1933
+ });
1934
+ return [...devices.values()];
1935
+ }
1936
+ function deriveSyncViewModel(input) {
1937
+ const actors = Array.isArray(input.actors) ? input.actors : [];
1938
+ const peers = Array.isArray(input.peers) ? input.peers : [];
1939
+ const discoveredDevices = Array.isArray(input.coordinator?.discovered_devices) ? input.coordinator.discovered_devices : [];
1940
+ const mergedDevices = mergeDevices(peers, discoveredDevices);
1941
+ const duplicateDecisions = input.duplicatePersonDecisions ?? {};
1942
+ const duplicatePeople = deriveDuplicatePeople(actors).filter((candidate) => !duplicateDecisions[[...candidate.actorIds].sort().join("::")]);
1943
+ const attentionItems = [];
1944
+ duplicatePeople.forEach((candidate) => {
1945
+ attentionItems.push({
1946
+ id: `duplicate:${candidate.actorIds.join(":")}`,
1947
+ kind: "possible-duplicate-person",
1948
+ priority: candidate.includesLocal ? 5 : 15,
1949
+ title: `Possible duplicate person: ${candidate.displayName}`,
1950
+ summary: candidate.includesLocal ? "At least one entry is marked as you. Confirm whether these records represent the same person." : "Multiple people share this name. Confirm whether they should stay separate or be combined.",
1951
+ actionLabel: "Review people",
1952
+ actorIds: candidate.actorIds
1953
+ });
1954
+ });
1955
+ mergedDevices.forEach((device) => {
1956
+ const name = resolveFriendlyDeviceName({
1957
+ localName: device.localName,
1958
+ coordinatorName: device.coordinatorName,
1959
+ deviceId: device.deviceId
1960
+ });
1961
+ const peerStatus = device.peer ? derivePeerUiStatus(device.peer) : "waiting";
1962
+ const discoveredFingerprint = cleanText(device.discovered?.fingerprint);
1963
+ const peerFingerprint = cleanText(device.peer?.fingerprint);
1964
+ if (Boolean(device.peer) && Boolean(discoveredFingerprint) && Boolean(peerFingerprint) && discoveredFingerprint !== peerFingerprint) {
1965
+ attentionItems.push(createRepairItem({
1966
+ id: device.deviceId,
1967
+ name,
1968
+ summary: "This device identity changed. Repair or remove the older local record before reconnecting it."
1969
+ }));
1970
+ return;
1971
+ }
1972
+ if (device.peer && peerStatus === "needs-repair") {
1973
+ const detail = cleanText(device.peer?.last_error) || "Sync health is degraded or broken.";
1974
+ attentionItems.push(createRepairItem({
1975
+ id: device.deviceId,
1976
+ name,
1977
+ summary: detail
1978
+ }));
1979
+ } else if (device.peer && peerStatus === "offline") attentionItems.push(createRepairItem({
1980
+ id: device.deviceId,
1981
+ name,
1982
+ summary: "This device is offline or stale. Review it before re-pairing or retrying sync."
1983
+ }));
1984
+ else if (!device.peer && device.discovered && !device.discovered?.stale) attentionItems.push(createReviewItem({
1985
+ id: device.deviceId,
1986
+ name,
1987
+ summary: "This device is seen on the team and is ready for review or connection on this machine."
1988
+ }));
1989
+ if (device.peer && deviceNeedsFriendlyName({
1990
+ localName: device.localName,
1991
+ coordinatorName: device.coordinatorName,
1992
+ deviceId: device.deviceId
1993
+ })) attentionItems.push(createNamingItem({
1994
+ id: device.deviceId,
1995
+ name,
1996
+ summary: "Give this device a friendly name so it is easier to recognize later."
1997
+ }));
1998
+ });
1999
+ return {
2000
+ summary: {
2001
+ connectedDeviceCount: peers.filter((peer) => derivePeerUiStatus(peer) === "connected").length,
2002
+ seenOnTeamCount: discoveredDevices.length,
2003
+ offlineTeamDeviceCount: discoveredDevices.filter((device) => Boolean(device?.stale)).length
2004
+ },
2005
+ duplicatePeople,
2006
+ attentionItems: attentionItems.sort((a, b) => a.priority - b.priority || a.title.localeCompare(b.title))
2007
+ };
2008
+ }
2009
+ //#endregion
1797
2010
  //#region src/tabs/sync/helpers.ts
1798
2011
  function hideSkeleton(id) {
1799
2012
  const skeleton = document.getElementById(id);
@@ -1810,6 +2023,7 @@
1810
2023
  var openPeerScopeEditors = /* @__PURE__ */ new Set();
1811
2024
  var pendingPeerScopeReviewIds = /* @__PURE__ */ new Set();
1812
2025
  var freshPeerScopeReviewIds = /* @__PURE__ */ new Set();
2026
+ var DUPLICATE_PERSON_DECISIONS_KEY = "codemem-sync-duplicate-person-decisions";
1813
2027
  function requestPeerScopeReview(peerDeviceId) {
1814
2028
  const value = String(peerDeviceId || "").trim();
1815
2029
  if (!value) return;
@@ -1832,6 +2046,41 @@
1832
2046
  freshPeerScopeReviewIds.delete(value);
1833
2047
  return true;
1834
2048
  }
2049
+ function readDuplicatePersonDecisionStore() {
2050
+ try {
2051
+ const raw = localStorage.getItem(DUPLICATE_PERSON_DECISIONS_KEY);
2052
+ if (!raw) return {};
2053
+ const parsed = JSON.parse(raw);
2054
+ return parsed && typeof parsed === "object" ? parsed : {};
2055
+ } catch {
2056
+ return {};
2057
+ }
2058
+ }
2059
+ function writeDuplicatePersonDecisionStore(value) {
2060
+ try {
2061
+ localStorage.setItem(DUPLICATE_PERSON_DECISIONS_KEY, JSON.stringify(value));
2062
+ } catch {}
2063
+ }
2064
+ function duplicatePersonDecisionKey(actorIds) {
2065
+ return [...actorIds].map((value) => String(value || "").trim()).filter(Boolean).sort().join("::");
2066
+ }
2067
+ function readDuplicatePersonDecisions() {
2068
+ return readDuplicatePersonDecisionStore();
2069
+ }
2070
+ function saveDuplicatePersonDecision(actorIds, decision) {
2071
+ const key = duplicatePersonDecisionKey(actorIds);
2072
+ if (!key) return;
2073
+ const next = readDuplicatePersonDecisionStore();
2074
+ next[key] = decision;
2075
+ writeDuplicatePersonDecisionStore(next);
2076
+ }
2077
+ function clearDuplicatePersonDecision(actorIds) {
2078
+ const key = duplicatePersonDecisionKey(actorIds);
2079
+ if (!key) return;
2080
+ const next = readDuplicatePersonDecisionStore();
2081
+ delete next[key];
2082
+ writeDuplicatePersonDecisionStore(next);
2083
+ }
1835
2084
  /** Redact the last two octets of IPv4 addresses. */
1836
2085
  function redactIpOctets(text) {
1837
2086
  return text.replace(/\b(\d{1,3}\.\d{1,3})\.\d{1,3}\.\d{1,3}\b/g, "$1.#.#");
@@ -1850,24 +2099,24 @@
1850
2099
  return value.split(",").map((item) => item.trim()).filter(Boolean);
1851
2100
  }
1852
2101
  function actorLabel(actor) {
1853
- if (!actor || typeof actor !== "object") return "Unknown actor";
2102
+ if (!actor || typeof actor !== "object") return "Unknown person";
1854
2103
  const displayName = String(actor.display_name || "").trim();
1855
- if (!displayName) return String(actor.actor_id || "Unknown actor");
2104
+ if (!displayName) return String(actor.actor_id || "Unknown person");
1856
2105
  return displayName;
1857
2106
  }
1858
2107
  function assignedActorCount(actorId) {
1859
2108
  return (Array.isArray(state.lastSyncPeers) ? state.lastSyncPeers : []).filter((peer) => String(peer?.actor_id || "") === actorId).length;
1860
2109
  }
1861
2110
  function assignmentNote(actorId) {
1862
- if (!actorId) return "Unassigned devices keep legacy fallback attribution until you choose an actor.";
1863
- if ((Array.isArray(state.lastSyncActors) ? state.lastSyncActors : []).find((item) => String(item?.actor_id || "") === actorId)?.is_local) return "Local actor assignment keeps this device in your same-person continuity path, including private sync.";
1864
- return "This actor receives memories from allowed projects by default. Use Only me on a memory when it should stay local.";
2111
+ if (!actorId) return "Unassigned devices keep legacy fallback attribution until you choose a person.";
2112
+ if ((Array.isArray(state.lastSyncActors) ? state.lastSyncActors : []).find((item) => String(item?.actor_id || "") === actorId)?.is_local) return "Assigning this device to you keeps it in your same-person continuity path, including private sync.";
2113
+ return "This person receives memories from allowed projects by default. Use Only me on a memory when it should stay local.";
1865
2114
  }
1866
2115
  function buildActorOptions(selectedActorId) {
1867
2116
  const options = [];
1868
2117
  const unassigned = document.createElement("option");
1869
2118
  unassigned.value = "";
1870
- unassigned.textContent = "No actor assigned";
2119
+ unassigned.textContent = "No person assigned";
1871
2120
  options.push(unassigned);
1872
2121
  (Array.isArray(state.lastSyncActors) ? state.lastSyncActors : []).forEach((actor) => {
1873
2122
  const option = document.createElement("option");
@@ -1884,7 +2133,7 @@
1884
2133
  }
1885
2134
  function actorMergeNote(targetActorId, secondaryActorId) {
1886
2135
  const target = mergeTargetActors(secondaryActorId).find((actor) => String(actor?.actor_id || "") === targetActorId);
1887
- if (!targetActorId || !target) return "Choose where this duplicate actor should collapse.";
2136
+ if (!targetActorId || !target) return "Choose which person should keep these devices.";
1888
2137
  return `Merge into ${actorLabel(target)}. Assigned devices move now; existing memories keep their current provenance.`;
1889
2138
  }
1890
2139
  function createChipEditor(initialValues, placeholder, emptyLabel) {
@@ -2288,7 +2537,7 @@
2288
2537
  const row = el("div", "actor-row");
2289
2538
  const details = el("div", "actor-details");
2290
2539
  const title = el("div", "actor-title");
2291
- title.append(el("strong", null, String(item.peer_name || item.peer_device_id || "Device")), el("span", "badge actor-badge", `actor: ${String(item.actor_display_name || item.actor_id || "unknown")}`));
2540
+ title.append(el("strong", null, String(item.peer_name || item.peer_device_id || "Device")), el("span", "badge actor-badge", `person: ${String(item.actor_display_name || item.actor_id || "unknown")}`));
2292
2541
  const note = el("div", "peer-meta", `${Number(item.shareable_count || 0)} share by default \u00b7 ${Number(item.private_count || 0)} marked Only me \u00b7 ${String(item.scope_label || "All allowed projects")}`);
2293
2542
  details.append(title, note);
2294
2543
  const actions = el("div", "actor-actions");
@@ -2325,8 +2574,95 @@
2325
2574
  if (discoveredList) discoveredList.textContent = "";
2326
2575
  setInviteOutputVisibility();
2327
2576
  const coordinator = state.lastSyncCoordinator;
2577
+ const syncView = state.lastSyncViewModel || {
2578
+ summary: {},
2579
+ attentionItems: []
2580
+ };
2581
+ const focusAttentionTarget = (item) => {
2582
+ if (item.kind === "possible-duplicate-person") {
2583
+ const actorList = document.getElementById("syncActorsList");
2584
+ if (actorList instanceof HTMLElement) actorList.scrollIntoView({
2585
+ block: "center",
2586
+ behavior: "smooth"
2587
+ });
2588
+ return;
2589
+ }
2590
+ const deviceId = String(item.deviceId || "").trim();
2591
+ if (!deviceId) return;
2592
+ if (item.kind === "name-device") {
2593
+ const renameInput = document.querySelector(`[data-device-name-input="${CSS.escape(deviceId)}"]`);
2594
+ if (renameInput instanceof HTMLInputElement) {
2595
+ renameInput.scrollIntoView({
2596
+ block: "center",
2597
+ behavior: "smooth"
2598
+ });
2599
+ renameInput.focus();
2600
+ renameInput.select();
2601
+ return;
2602
+ }
2603
+ }
2604
+ const peerCard = document.querySelector(`[data-peer-device-id="${CSS.escape(deviceId)}"]`);
2605
+ if (peerCard instanceof HTMLElement) {
2606
+ peerCard.scrollIntoView({
2607
+ block: "center",
2608
+ behavior: "smooth"
2609
+ });
2610
+ return;
2611
+ }
2612
+ const discoveredRow = document.querySelector(`[data-discovered-device-id="${CSS.escape(deviceId)}"]`);
2613
+ if (discoveredRow instanceof HTMLElement) discoveredRow.scrollIntoView({
2614
+ block: "center",
2615
+ behavior: "smooth"
2616
+ });
2617
+ };
2618
+ const reviewDuplicatePeople = async (item) => {
2619
+ const actorIds = Array.isArray(item.actorIds) ? item.actorIds.map((value) => String(value || "").trim()).filter(Boolean) : [];
2620
+ const actors = (Array.isArray(state.lastSyncActors) ? state.lastSyncActors : []).filter((actor) => actorIds.includes(String(actor?.actor_id || "").trim()));
2621
+ if (actors.length < 2) {
2622
+ showGlobalNotice("This possible duplicate no longer has enough people to review.", "warning");
2623
+ return;
2624
+ }
2625
+ const firstAnswer = window.prompt(`Possible duplicate person: ${item.title.replace(/^Possible duplicate person:\s*/, "")}\n\nChoose an option:\n1 = These are both me\n2 = These are different people\n3 = Decide later`, "1");
2626
+ const choice = String(firstAnswer || "").trim();
2627
+ if (choice === "2") {
2628
+ saveDuplicatePersonDecision(actorIds, "different-people");
2629
+ showGlobalNotice("Okay. I will keep these people separate on this device.");
2630
+ await _loadSyncData$1();
2631
+ return;
2632
+ }
2633
+ if (choice !== "1") return;
2634
+ clearDuplicatePersonDecision(actorIds);
2635
+ const localIndex = actors.findIndex((actor) => Boolean(actor?.is_local));
2636
+ const defaultChoice = localIndex >= 0 ? String(localIndex + 1) : "1";
2637
+ const options = actors.map((actor, index) => `${index + 1} = ${String(actor?.display_name || actor?.actor_id || `Person ${index + 1}`)}${actor?.is_local ? " (You)" : ""}`).join("\n");
2638
+ const keepAnswer = window.prompt(`Which person should remain after combining them?\n\n${options}`, defaultChoice);
2639
+ const primary = actors[Number.parseInt(String(keepAnswer || defaultChoice), 10) - 1] ?? actors[0];
2640
+ const secondary = actors.find((actor) => actor !== primary);
2641
+ if (!primary?.actor_id || !secondary?.actor_id) {
2642
+ showGlobalNotice("Could not determine which people to combine.", "warning");
2643
+ return;
2644
+ }
2645
+ try {
2646
+ await mergeActor(String(primary.actor_id), String(secondary.actor_id));
2647
+ showGlobalNotice(`Combined duplicate people into ${String(primary.display_name || primary.actor_id)}.`);
2648
+ await _loadSyncData$1();
2649
+ } catch (error) {
2650
+ showGlobalNotice(friendlyError(error, "Failed to combine these people."), "warning");
2651
+ }
2652
+ };
2653
+ const promptForDeviceName = async (deviceId, suggestedName) => {
2654
+ const nextName = window.prompt("What should this device be called? You can change it later in People & devices.", suggestedName);
2655
+ const value = String(nextName || "").trim();
2656
+ if (!value) {
2657
+ showGlobalNotice("You can name this device later from People & devices.", "warning");
2658
+ return false;
2659
+ }
2660
+ await renamePeer(deviceId, value);
2661
+ showGlobalNotice(`Saved device name: ${value}.`);
2662
+ return true;
2663
+ };
2328
2664
  const configured = Boolean(coordinator && coordinator.configured);
2329
- meta.textContent = configured ? `Connected to ${String(coordinator.coordinator_url || "")} \u00b7 group: ${(coordinator.groups || []).join(", ") || "none"}` : "Create a team invite or join an existing team to start syncing memories with teammates.";
2665
+ meta.textContent = configured ? `Connected to ${String(coordinator.coordinator_url || "")} \u00b7 group: ${(coordinator.groups || []).join(", ") || "none"}` : "Start here: join an existing team or create a new one before connecting devices and people.";
2330
2666
  if (!configured) {
2331
2667
  setupPanel.hidden = false;
2332
2668
  list.hidden = true;
@@ -2347,38 +2683,59 @@
2347
2683
  const statusLine = el("div", "sync-team-status-row");
2348
2684
  const statusLabel = el("span", "sync-team-status-label", "Status");
2349
2685
  const statusBadge = el("span", `pill ${coordinator.presence_status === "posted" ? "pill-success" : coordinator.presence_status === "not_enrolled" ? "pill-warning" : "pill-error"}`, presenceLabel);
2350
- const metricParts = [`Paired devices: ${Number(coordinator.paired_peer_count || 0)}`, `Fresh discovered: ${Number(coordinator.fresh_peer_count || 0)}`];
2351
- if (Number(coordinator.stale_peer_count || 0) > 0) metricParts.push(`Inactive: ${Number(coordinator.stale_peer_count || 0)}`);
2686
+ const metricParts = [`Connected devices: ${Number(syncView.summary?.connectedDeviceCount || 0)}`, `Seen on team: ${Number(syncView.summary?.seenOnTeamCount || 0)}`];
2687
+ if (Number(syncView.summary?.offlineTeamDeviceCount || 0) > 0) metricParts.push(`Offline on team: ${Number(syncView.summary?.offlineTeamDeviceCount || 0)}`);
2352
2688
  statusLine.append(statusLabel, statusBadge);
2353
2689
  statusRow.append(statusLine, el("div", "sync-team-metrics", metricParts.join(" · ")));
2354
2690
  list.appendChild(statusRow);
2355
2691
  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) {
2692
+ const attentionItems = Array.isArray(syncView.attentionItems) ? syncView.attentionItems : [];
2693
+ if (attentionItems.length) {
2694
+ const heading = el("div", "sync-action-text");
2695
+ heading.textContent = "Needs attention";
2696
+ actions.appendChild(heading);
2697
+ attentionItems.slice(0, 4).forEach((item) => {
2698
+ const row = el("div", "sync-action");
2699
+ const textWrap = el("div", "sync-action-text");
2700
+ textWrap.textContent = item.title;
2701
+ textWrap.appendChild(el("span", "sync-action-command", item.summary));
2702
+ const actionButton = el("button", "settings-button", item.actionLabel || "Review");
2703
+ actionButton.addEventListener("click", async () => {
2704
+ if (item.kind === "possible-duplicate-person") {
2705
+ await reviewDuplicatePeople(item);
2706
+ return;
2707
+ }
2708
+ focusAttentionTarget(item);
2709
+ });
2710
+ row.append(textWrap, actionButton);
2711
+ actions.appendChild(row);
2712
+ });
2713
+ } else if (coordinator.presence_status === "posted") {
2714
+ const row = el("div", "sync-action");
2715
+ const textWrap = el("div", "sync-action-text");
2716
+ textWrap.textContent = "No immediate issues";
2717
+ textWrap.appendChild(el("span", "sync-action-command", "Your devices and team records do not currently need review."));
2718
+ row.appendChild(textWrap);
2719
+ actions.appendChild(row);
2720
+ } else if (coordinator.presence_status === "not_enrolled") {
2365
2721
  const row = el("div", "sync-action");
2366
2722
  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."));
2723
+ textWrap.textContent = "This device still needs team enrollment";
2724
+ textWrap.appendChild(el("span", "sync-action-command", "Import an invite or ask your admin to enroll this device before expecting sync activity here."));
2369
2725
  row.appendChild(textWrap);
2370
2726
  actions.appendChild(row);
2371
2727
  }
2372
2728
  const discoveredDevices = Array.isArray(coordinator.discovered_devices) ? coordinator.discovered_devices : [];
2373
2729
  if (discoveredPanel && discoveredMeta && discoveredList && discoveredDevices.length) {
2374
2730
  discoveredPanel.hidden = false;
2375
- discoveredMeta.textContent = "These devices are visible through coordinator discovery only. They are not active sync peers unless you pair them.";
2731
+ discoveredMeta.textContent = "These devices are visible through team discovery. Review them before connecting them on this machine.";
2376
2732
  discoveredDevices.forEach((device) => {
2377
2733
  const row = el("div", "actor-row");
2378
2734
  const details = el("div", "actor-details");
2379
2735
  const title = el("div", "actor-title");
2380
2736
  const rowActions = el("div", "actor-actions");
2381
2737
  const deviceId = String(device.device_id || "").trim();
2738
+ if (deviceId) row.dataset.discoveredDeviceId = deviceId;
2382
2739
  const displayName = String(device.display_name || "").trim() || deviceId || "Discovered device";
2383
2740
  const fingerprint = String(device.fingerprint || "").trim();
2384
2741
  const pairedPeer = localPeers.find((peer) => String(peer?.peer_device_id || "") === deviceId);
@@ -2386,8 +2743,8 @@
2386
2743
  const hasConflict = Boolean(pairedPeer) && Boolean(fingerprint) && Boolean(pairedFingerprint) && pairedFingerprint !== fingerprint;
2387
2744
  const canAccept = Boolean(deviceId) && Boolean(fingerprint) && !pairedPeer && !device.stale;
2388
2745
  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"));
2746
+ title.appendChild(el("span", `badge actor-badge${device.stale ? "" : " local"}`, device.stale ? "Offline" : "Available"));
2747
+ title.appendChild(el("span", "badge actor-badge", hasConflict ? SYNC_TERMINOLOGY.conflicts : pairedPeer ? SYNC_TERMINOLOGY.pairedLocally : "Not connected on this device"));
2391
2748
  const addresses = Array.isArray(device.addresses) ? device.addresses : [];
2392
2749
  const noteParts = [deviceId, addresses.length ? addresses.map((address) => isSyncRedactionEnabled() ? redactAddress(String(address || "")) : String(address || "")).filter(Boolean).join(" · ") : "No fresh addresses"];
2393
2750
  if (hasConflict) noteParts.push("repair the local peer before accepting this discovered device");
@@ -2396,27 +2753,36 @@
2396
2753
  else if (pairedPeer?.status?.peer_state) noteParts.push(`paired status: ${String(pairedPeer.status.peer_state)}`);
2397
2754
  details.append(title, el("div", "peer-meta", noteParts.join(" · ")));
2398
2755
  if (canAccept) {
2399
- const acceptBtn = el("button", "settings-button", "Accept peer");
2756
+ const acceptBtn = el("button", "settings-button", "Review device");
2400
2757
  acceptBtn.addEventListener("click", async () => {
2401
2758
  acceptBtn.disabled = true;
2402
- acceptBtn.textContent = "Accepting…";
2759
+ acceptBtn.textContent = "Opening…";
2403
2760
  try {
2404
- await acceptDiscoveredPeer(deviceId, fingerprint);
2761
+ const result = await acceptDiscoveredPeer(deviceId, fingerprint);
2405
2762
  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();
2763
+ showGlobalNotice(`Connected ${displayName}. Its device settings are now open in People & devices so you can review sync scope next.`);
2764
+ try {
2765
+ await promptForDeviceName(deviceId, String(result?.name || displayName || "").trim() || displayName);
2766
+ } catch (error) {
2767
+ showGlobalNotice(friendlyError(error, "Device connected, but naming did not finish."), "warning");
2768
+ }
2769
+ try {
2770
+ await _loadSyncData$1();
2771
+ } catch (error) {
2772
+ showGlobalNotice(friendlyError(error, "Device connected, but the screen did not refresh yet."), "warning");
2773
+ }
2408
2774
  } catch (error) {
2409
- showGlobalNotice(friendlyError(error, "Failed to accept discovered peer."), "warning");
2410
- acceptBtn.textContent = "Retry accept";
2775
+ showGlobalNotice(friendlyError(error, "Failed to review this device."), "warning");
2776
+ acceptBtn.textContent = "Retry review";
2411
2777
  } finally {
2412
2778
  acceptBtn.disabled = false;
2413
- if (acceptBtn.textContent === "Accepting…") acceptBtn.textContent = "Accept peer";
2779
+ if (acceptBtn.textContent === "Opening…") acceptBtn.textContent = "Review device";
2414
2780
  }
2415
2781
  });
2416
2782
  rowActions.appendChild(acceptBtn);
2417
2783
  } else if (!pairedPeer && device.stale) rowActions.appendChild(el("div", "peer-meta", "Wait for a fresh coordinator presence update."));
2418
2784
  else if (hasConflict) {
2419
- const inspectBtn = el("button", "settings-button", "Jump to local peer");
2785
+ const inspectBtn = el("button", "settings-button", "Open device details");
2420
2786
  inspectBtn.addEventListener("click", () => {
2421
2787
  const peerCard = document.querySelector(`[data-peer-device-id="${CSS.escape(deviceId)}"]`);
2422
2788
  if (peerCard instanceof HTMLElement) {
@@ -2424,34 +2790,34 @@
2424
2790
  block: "center",
2425
2791
  behavior: "smooth"
2426
2792
  });
2427
- showGlobalNotice(`Jumped to the conflicting local peer for ${displayName}.`, "warning");
2793
+ showGlobalNotice(`Opened the conflicting local device record for ${displayName}.`, "warning");
2428
2794
  return;
2429
2795
  }
2430
- showGlobalNotice("Conflicting local peer is not visible yet. Scroll to the People area and try again.", "warning");
2796
+ showGlobalNotice("The conflicting local device record is not visible yet. Scroll to People & devices and try again.", "warning");
2431
2797
  });
2432
- const removeConflictBtn = el("button", "settings-button", "Remove conflicting peer");
2798
+ const removeConflictBtn = el("button", "settings-button", "Remove broken device record");
2433
2799
  removeConflictBtn.addEventListener("click", async () => {
2434
2800
  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;
2801
+ if (!window.confirm(`Remove the broken local device record for ${displayName}? You can review this device again after the screen refreshes.`)) return;
2436
2802
  removeConflictBtn.disabled = true;
2437
2803
  inspectBtn.disabled = true;
2438
2804
  removeConflictBtn.textContent = "Removing…";
2439
2805
  try {
2440
2806
  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.`);
2807
+ showGlobalNotice(`Removed the broken local device record for ${displayName}. If it is still available, you can review it again from Needs attention.`);
2442
2808
  await _loadSyncData$1();
2443
2809
  } catch (error) {
2444
- showGlobalNotice(friendlyError(error, "Failed to remove the conflicting local peer."), "warning");
2810
+ showGlobalNotice(friendlyError(error, "Failed to remove the broken local device record."), "warning");
2445
2811
  removeConflictBtn.textContent = "Retry remove";
2446
2812
  } finally {
2447
2813
  removeConflictBtn.disabled = false;
2448
2814
  inspectBtn.disabled = false;
2449
- if (removeConflictBtn.textContent === "Removing…") removeConflictBtn.textContent = "Remove conflicting peer";
2815
+ if (removeConflictBtn.textContent === "Removing…") removeConflictBtn.textContent = "Remove broken device record";
2450
2816
  }
2451
2817
  });
2452
2818
  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."));
2819
+ } else if (pairedPeer && isPeerScopeReviewPending(deviceId)) rowActions.appendChild(el("div", "peer-meta", "Review this device's scope in People & devices next."));
2820
+ else if (pairedPeer) rowActions.appendChild(el("div", "peer-meta", "Manage this device in People & devices."));
2455
2821
  row.append(details, rowActions);
2456
2822
  discoveredList.appendChild(row);
2457
2823
  });
@@ -2648,9 +3014,9 @@
2648
3014
  hideSkeleton("syncActorsSkeleton");
2649
3015
  actorList.textContent = "";
2650
3016
  const actors = Array.isArray(state.lastSyncActors) ? state.lastSyncActors : [];
2651
- if (actorMeta) actorMeta.textContent = actors.length ? "Create, rename, and merge actors here. Assign each device below. Non-local actors receive memories from allowed projects unless you mark them Only me." : "No named actors yet. Create one here, then assign devices below.";
3017
+ if (actorMeta) actorMeta.textContent = actors.length ? "Create, rename, and combine people here. Assign each device below. Non-local people receive memories from allowed projects unless you mark them Only me." : "No named people yet. Create one here, then assign devices below.";
2652
3018
  if (!actors.length) {
2653
- actorList.appendChild(el("div", "sync-empty-state", "No actors yet. Create one to represent yourself or a teammate."));
3019
+ actorList.appendChild(el("div", "sync-empty-state", "No people yet. Create one to represent yourself or a teammate."));
2654
3020
  return;
2655
3021
  }
2656
3022
  actors.forEach((actor) => {
@@ -2693,10 +3059,10 @@
2693
3059
  const mergeControls = el("div", "actor-merge-controls");
2694
3060
  const mergeSelect = document.createElement("select");
2695
3061
  mergeSelect.className = "sync-actor-select actor-merge-select";
2696
- mergeSelect.setAttribute("aria-label", `Merge ${actorLabel(actor)} into another actor`);
3062
+ mergeSelect.setAttribute("aria-label", `Combine ${actorLabel(actor)} into another person`);
2697
3063
  const placeholder = document.createElement("option");
2698
3064
  placeholder.value = "";
2699
- placeholder.textContent = "Merge into actor";
3065
+ placeholder.textContent = "Combine into person";
2700
3066
  placeholder.selected = true;
2701
3067
  mergeSelect.appendChild(placeholder);
2702
3068
  mergeTargets.forEach((target) => {
@@ -2705,16 +3071,16 @@
2705
3071
  option.textContent = target.is_local ? `${actorLabel(target)} (local)` : actorLabel(target);
2706
3072
  mergeSelect.appendChild(option);
2707
3073
  });
2708
- const mergeBtn = el("button", "settings-button", "Merge into selected actor");
3074
+ const mergeBtn = el("button", "settings-button", "Combine into selected person");
2709
3075
  mergeBtn.disabled = mergeTargets.length === 0;
2710
- const mergeNote = el("div", "peer-meta actor-merge-note", mergeTargets.length ? actorMergeNote("", actorId) : "No merge targets yet. Create another actor or use the local actor.");
3076
+ const mergeNote = el("div", "peer-meta actor-merge-note", mergeTargets.length ? actorMergeNote("", actorId) : "No people available to combine yet. Create another person or use You.");
2711
3077
  mergeSelect.addEventListener("change", () => {
2712
3078
  mergeNote.textContent = actorMergeNote(mergeSelect.value, actorId);
2713
3079
  });
2714
3080
  mergeBtn.addEventListener("click", async () => {
2715
3081
  if (!mergeSelect.value) return;
2716
3082
  const target = mergeTargets.find((candidate) => String(candidate.actor_id || "") === mergeSelect.value);
2717
- if (!window.confirm(`Merge ${actorLabel(actor)} into ${actorLabel(target)}? Assigned devices move now, but older memories keep their current stamped provenance for now.`)) return;
3083
+ if (!window.confirm(`Combine ${actorLabel(actor)} into ${actorLabel(target)}? Assigned devices move now, but older memories keep their current stamped provenance for now.`)) return;
2718
3084
  mergeBtn.disabled = true;
2719
3085
  mergeSelect.disabled = true;
2720
3086
  input.disabled = true;
@@ -2722,17 +3088,17 @@
2722
3088
  mergeBtn.textContent = "Merging…";
2723
3089
  try {
2724
3090
  await mergeActor(mergeSelect.value, actorId);
2725
- showGlobalNotice("Actor merged. Assigned devices moved to the selected actor.");
3091
+ showGlobalNotice("People combined. Assigned devices moved to the selected person.");
2726
3092
  await _loadSyncData();
2727
3093
  } catch (error) {
2728
- showGlobalNotice(friendlyError(error, "Failed to merge actor."), "warning");
3094
+ showGlobalNotice(friendlyError(error, "Failed to combine people."), "warning");
2729
3095
  mergeBtn.textContent = "Retry merge";
2730
3096
  } finally {
2731
3097
  mergeBtn.disabled = mergeTargets.length === 0;
2732
3098
  mergeSelect.disabled = false;
2733
3099
  input.disabled = false;
2734
3100
  renameBtn.disabled = false;
2735
- if (mergeBtn.textContent === "Merging…") mergeBtn.textContent = "Merge into selected actor";
3101
+ if (mergeBtn.textContent === "Merging…") mergeBtn.textContent = "Combine into selected person";
2736
3102
  }
2737
3103
  });
2738
3104
  mergeControls.append(mergeSelect, mergeBtn);
@@ -2749,7 +3115,7 @@
2749
3115
  syncPeers.textContent = "";
2750
3116
  const peers = state.lastSyncPeers;
2751
3117
  if (!Array.isArray(peers) || !peers.length) {
2752
- syncPeers.appendChild(el("div", "sync-empty-state", "No devices paired yet. Use the pairing command in Diagnostics to connect another device."));
3118
+ syncPeers.appendChild(el("div", "sync-empty-state", "No devices connected on this machine yet. Use the pairing command in Diagnostics to connect another device."));
2753
3119
  return;
2754
3120
  }
2755
3121
  peers.forEach((peer) => {
@@ -2768,6 +3134,38 @@
2768
3134
  name.append(" ", badge);
2769
3135
  if (pendingScopeReview) name.append(" ", el("span", "badge actor-badge", "Needs scope review"));
2770
3136
  const actions = el("div", "peer-actions");
3137
+ const renameInput = document.createElement("input");
3138
+ renameInput.className = "peer-scope-input";
3139
+ renameInput.value = displayName;
3140
+ if (peerId) renameInput.dataset.deviceNameInput = peerId;
3141
+ renameInput.setAttribute("aria-label", `Friendly name for ${displayName}`);
3142
+ renameInput.placeholder = "Friendly device name";
3143
+ const renameBtn = el("button", null, "Save name");
3144
+ renameBtn.addEventListener("click", async () => {
3145
+ if (!peerId) return;
3146
+ const nextName = String(renameInput.value || "").trim();
3147
+ if (!nextName) {
3148
+ showGlobalNotice("Enter a friendly name for this device.", "warning");
3149
+ renameInput.focus();
3150
+ return;
3151
+ }
3152
+ renameBtn.disabled = true;
3153
+ renameInput.disabled = true;
3154
+ renameBtn.textContent = "Saving…";
3155
+ try {
3156
+ await renamePeer(peerId, nextName);
3157
+ showGlobalNotice("Device name saved.");
3158
+ await _loadSyncData();
3159
+ } catch (error) {
3160
+ showGlobalNotice(friendlyError(error, "Failed to save device name."), "warning");
3161
+ renameBtn.textContent = "Retry save";
3162
+ } finally {
3163
+ renameBtn.disabled = false;
3164
+ renameInput.disabled = false;
3165
+ if (renameBtn.textContent === "Saving…") renameBtn.textContent = "Save name";
3166
+ }
3167
+ });
3168
+ actions.append(renameInput, renameBtn);
2771
3169
  const primaryAddress = pickPrimaryAddress(peer.addresses);
2772
3170
  const syncBtn = el("button", null, "Sync now");
2773
3171
  syncBtn.disabled = !primaryAddress;
@@ -2823,7 +3221,7 @@
2823
3221
  const lastSyncAt = peerStatus.last_sync_at || peerStatus.last_sync_at_utc || "";
2824
3222
  const lastPingAt = peerStatus.last_ping_at || peerStatus.last_ping_at_utc || "";
2825
3223
  const meta = el("div", "peer-meta", [lastSyncAt ? `Sync: ${formatTimestamp(lastSyncAt)}` : "Sync: never", lastPingAt ? `Ping: ${formatTimestamp(lastPingAt)}` : "Ping: never"].join(" · "));
2826
- const identityMeta = el("div", "peer-meta", peer.actor_display_name ? `Assigned to ${String(peer.actor_display_name)}${peer.claimed_local_actor ? " · local actor" : ""}` : "Unassigned actor");
3224
+ const identityMeta = el("div", "peer-meta", peer.actor_display_name ? `Assigned to ${String(peer.actor_display_name)}${peer.claimed_local_actor ? " · you" : ""}` : "Unassigned person");
2827
3225
  const scope = peer.project_scope || {};
2828
3226
  const includeList = Array.isArray(scope.include) ? scope.include : [];
2829
3227
  const excludeList = Array.isArray(scope.exclude) ? scope.exclude : [];
@@ -2832,13 +3230,13 @@
2832
3230
  const inheritsGlobal = Boolean(scope.inherits_global);
2833
3231
  const scopePanel = el("div", "peer-scope");
2834
3232
  const identityRow = el("div", "peer-scope-summary");
2835
- identityRow.textContent = "Assigned actor";
3233
+ identityRow.textContent = "Assigned person";
2836
3234
  const actorRow = el("div", "peer-actor-row");
2837
3235
  const actorSelect = document.createElement("select");
2838
3236
  actorSelect.className = "sync-actor-select";
2839
- actorSelect.setAttribute("aria-label", `Assigned actor for ${displayName}`);
3237
+ actorSelect.setAttribute("aria-label", `Assigned person for ${displayName}`);
2840
3238
  buildActorOptions(String(peer.actor_id || "")).forEach((option) => actorSelect.appendChild(option));
2841
- const applyActorBtn = el("button", "settings-button", "Save actor");
3239
+ const applyActorBtn = el("button", "settings-button", "Save person");
2842
3240
  const actorHint = el("div", "peer-scope-effective", assignmentNote(String(peer.actor_id || "")));
2843
3241
  actorSelect.addEventListener("change", () => {
2844
3242
  actorHint.textContent = assignmentNote(actorSelect.value);
@@ -2849,15 +3247,15 @@
2849
3247
  applyActorBtn.textContent = "Applying…";
2850
3248
  try {
2851
3249
  await assignPeerActor(peerId, actorSelect.value || null);
2852
- showGlobalNotice(actorSelect.value ? "Device actor updated." : "Device actor cleared.");
3250
+ showGlobalNotice(actorSelect.value ? "Device person updated." : "Device person cleared.");
2853
3251
  await _loadSyncData();
2854
3252
  } catch (error) {
2855
- showGlobalNotice(friendlyError(error, "Failed to update device actor."), "warning");
3253
+ showGlobalNotice(friendlyError(error, "Failed to update device person."), "warning");
2856
3254
  applyActorBtn.textContent = "Retry";
2857
3255
  } finally {
2858
3256
  actorSelect.disabled = false;
2859
3257
  applyActorBtn.disabled = false;
2860
- if (applyActorBtn.textContent === "Applying…") applyActorBtn.textContent = "Save actor";
3258
+ if (applyActorBtn.textContent === "Applying…") applyActorBtn.textContent = "Save person";
2861
3259
  }
2862
3260
  });
2863
3261
  actorRow.append(actorSelect, applyActorBtn);
@@ -2969,7 +3367,7 @@
2969
3367
  if (!syncActorCreateButton || !syncActorCreateInput) return;
2970
3368
  const displayName = String(syncActorCreateInput.value || "").trim();
2971
3369
  if (!displayName) {
2972
- markFieldError(syncActorCreateInput, "Enter a name for the actor.");
3370
+ markFieldError(syncActorCreateInput, "Enter a name for the person.");
2973
3371
  return;
2974
3372
  }
2975
3373
  clearFieldError(syncActorCreateInput);
@@ -2978,30 +3376,30 @@
2978
3376
  syncActorCreateButton.textContent = "Creating…";
2979
3377
  try {
2980
3378
  await createActor(displayName);
2981
- showGlobalNotice("Actor created.");
3379
+ showGlobalNotice("Person created.");
2982
3380
  syncActorCreateInput.value = "";
2983
3381
  await loadSyncData();
2984
3382
  } catch (error) {
2985
- showGlobalNotice(friendlyError(error, "Failed to create actor."), "warning");
3383
+ showGlobalNotice(friendlyError(error, "Failed to create person."), "warning");
2986
3384
  syncActorCreateButton.textContent = "Retry";
2987
3385
  syncActorCreateButton.disabled = false;
2988
3386
  syncActorCreateInput.disabled = false;
2989
3387
  return;
2990
3388
  }
2991
- syncActorCreateButton.textContent = "Create actor";
3389
+ syncActorCreateButton.textContent = "Create person";
2992
3390
  syncActorCreateButton.disabled = false;
2993
3391
  syncActorCreateInput.disabled = false;
2994
3392
  });
2995
3393
  syncLegacyClaimButton?.addEventListener("click", async () => {
2996
3394
  const originDeviceId = String(syncLegacyDeviceSelect?.value || "").trim();
2997
3395
  if (!originDeviceId || !syncLegacyClaimButton) return;
2998
- if (!window.confirm(`Attach old device history from ${originDeviceId} to your local actor? This updates legacy provenance for that device.`)) return;
3396
+ if (!window.confirm(`Attach old device history from ${originDeviceId} to you? This updates legacy provenance for that device.`)) return;
2999
3397
  syncLegacyClaimButton.disabled = true;
3000
3398
  const originalText = syncLegacyClaimButton.textContent || "Attach device history";
3001
3399
  syncLegacyClaimButton.textContent = "Attaching…";
3002
3400
  try {
3003
3401
  await claimLegacyDeviceIdentity(originDeviceId);
3004
- showGlobalNotice("Old device history attached to your local actor.");
3402
+ showGlobalNotice("Old device history attached to you.");
3005
3403
  await loadSyncData();
3006
3404
  } catch (error) {
3007
3405
  showGlobalNotice(friendlyError(error, "Failed to attach old device history."), "warning");
@@ -3060,12 +3458,17 @@
3060
3458
  }
3061
3459
  let actorsPayload = null;
3062
3460
  let actorLoadError = false;
3461
+ const duplicatePersonDecisions = readDuplicatePersonDecisions();
3063
3462
  try {
3064
3463
  actorsPayload = await loadSyncActors();
3065
3464
  } catch {
3066
3465
  actorLoadError = true;
3067
3466
  }
3068
- const hash = JSON.stringify([payload, actorsPayload]);
3467
+ const hash = JSON.stringify([
3468
+ payload,
3469
+ actorsPayload,
3470
+ duplicatePersonDecisions
3471
+ ]);
3069
3472
  if (hash === lastSyncHash) return;
3070
3473
  lastSyncHash = hash;
3071
3474
  const statusPayload = payload.status && typeof payload.status === "object" ? payload.status : null;
@@ -3077,6 +3480,13 @@
3077
3480
  if (Array.isArray(payload.join_requests)) state.lastSyncJoinRequests = payload.join_requests;
3078
3481
  state.lastSyncAttempts = payload.attempts || [];
3079
3482
  state.lastSyncLegacyDevices = payload.legacy_devices || [];
3483
+ state.lastSyncDuplicatePersonDecisions = duplicatePersonDecisions;
3484
+ state.lastSyncViewModel = deriveSyncViewModel({
3485
+ actors: state.lastSyncActors,
3486
+ peers: state.lastSyncPeers,
3487
+ coordinator: state.lastSyncCoordinator,
3488
+ duplicatePersonDecisions: state.lastSyncDuplicatePersonDecisions
3489
+ });
3080
3490
  renderSyncStatus();
3081
3491
  renderTeamSync();
3082
3492
  renderSyncActors();
@@ -3087,7 +3497,7 @@
3087
3497
  renderHealthOverview();
3088
3498
  if (actorLoadError) {
3089
3499
  const actorMeta = document.getElementById("syncActorsMeta");
3090
- if (actorMeta) actorMeta.textContent = "Actor controls are temporarily unavailable. Peer status and sync health still loaded.";
3500
+ if (actorMeta) actorMeta.textContent = "People controls are temporarily unavailable. Device status and sync health still loaded.";
3091
3501
  }
3092
3502
  } catch {
3093
3503
  hideSkeleton("syncTeamSkeleton");
package/static/index.html CHANGED
@@ -1723,15 +1723,15 @@
1723
1723
  <!-- ── Tab: Sync ───────────────────────────────────────── -->
1724
1724
  <div class="tab-panel" id="tab-sync" hidden>
1725
1725
 
1726
- <!-- 1. Team sync — primary, always visible -->
1726
+ <!-- 1. Needs attention + team setup/status -->
1727
1727
  <div class="card" id="syncTeamCard">
1728
1728
  <div class="section-header">
1729
- <h2>Team sync</h2>
1729
+ <h2>Needs attention</h2>
1730
1730
  <div class="section-actions">
1731
1731
  <button class="settings-button" id="syncNowButton">Sync now</button>
1732
1732
  </div>
1733
1733
  </div>
1734
- <div class="section-meta" id="syncTeamMeta"></div>
1734
+ <div class="section-meta" id="syncTeamMeta">See what needs action first, then review the current team status below.</div>
1735
1735
  <div class="sync-skeleton" id="syncTeamSkeleton" role="status" aria-label="Loading team sync">
1736
1736
  <div class="sync-skeleton-line medium"></div>
1737
1737
  <div class="sync-skeleton-block"></div>
@@ -1772,26 +1772,27 @@
1772
1772
  </div>
1773
1773
  </div>
1774
1774
  </div>
1775
+ <h3 class="settings-group-title" style="margin-top: 20px">Team status</h3>
1775
1776
  <div class="actor-list" id="syncTeamStatus" aria-live="polite"></div>
1776
1777
  <div class="actor-list" id="syncJoinRequests"></div>
1777
1778
  <div id="syncCoordinatorDiscovered" hidden>
1778
- <h3 class="settings-group-title" style="margin-top: 20px">Coordinator-discovered devices</h3>
1779
+ <h3 class="settings-group-title" style="margin-top: 20px">Devices seen on team</h3>
1779
1780
  <div class="section-meta" id="syncCoordinatorDiscoveredMeta"></div>
1780
1781
  <div class="actor-list" id="syncCoordinatorDiscoveredList"></div>
1781
1782
  </div>
1782
1783
  <div class="sync-actions" id="syncTeamActions" hidden></div>
1783
1784
  </div>
1784
1785
 
1785
- <!-- 2. People — actors, devices, sharing, legacy claims -->
1786
+ <!-- 2. People & devices -->
1786
1787
  <div class="card" style="animation-delay: 0.04s">
1787
- <h2>People</h2>
1788
+ <h2>People &amp; devices</h2>
1788
1789
 
1789
- <h3 class="settings-group-title">Actors</h3>
1790
- <div class="section-meta" id="syncActorsMeta">Create and rename actors here. Assign each device below.</div>
1790
+ <h3 class="settings-group-title">People</h3>
1791
+ <div class="section-meta" id="syncActorsMeta">Create and rename people here. Assign each device below.</div>
1791
1792
  <div class="actor-create-row">
1792
- <label for="syncActorCreateInput" class="sr-only">Actor name</label>
1793
- <input class="peer-scope-input" id="syncActorCreateInput" placeholder="Create a named actor" />
1794
- <button class="settings-button" id="syncActorCreateButton">Create actor</button>
1793
+ <label for="syncActorCreateInput" class="sr-only">Person name</label>
1794
+ <input class="peer-scope-input" id="syncActorCreateInput" placeholder="Create a named person" />
1795
+ <button class="settings-button" id="syncActorCreateButton">Create person</button>
1795
1796
  </div>
1796
1797
  <div class="actor-list" id="syncActorsList"></div>
1797
1798
  <div class="sync-skeleton" id="syncActorsSkeleton" role="status" aria-label="Loading actors">
@@ -1823,10 +1824,10 @@
1823
1824
  </div>
1824
1825
  </div>
1825
1826
 
1826
- <!-- 3. Diagnostics — status, pairing, attempts -->
1827
+ <!-- 3. Advanced diagnostics — status, pairing, attempts -->
1827
1828
  <div class="card" style="animation-delay: 0.06s">
1828
1829
  <div class="section-header">
1829
- <h2>Diagnostics</h2>
1830
+ <h2>Advanced diagnostics</h2>
1830
1831
  <div class="section-actions">
1831
1832
  <button class="settings-button" id="syncPairingToggle">Show pairing</button>
1832
1833
  <label class="small" style="display:inline-flex;align-items:center;gap:6px;">
@@ -1835,7 +1836,7 @@
1835
1836
  </label>
1836
1837
  </div>
1837
1838
  </div>
1838
- <div class="section-meta" id="syncMeta">Loading sync status…</div>
1839
+ <div class="section-meta" id="syncMeta">Debug details for sync status, pairing, and recent attempts.</div>
1839
1840
  <div class="sync-skeleton" id="syncDiagSkeleton" role="status" aria-label="Loading diagnostics">
1840
1841
  <div class="sync-skeleton-line long"></div>
1841
1842
  <div class="sync-skeleton-line medium"></div>