@codemem/server 0.22.0-alpha.3 → 0.22.0-alpha.5

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.3",
3
+ "version": "0.22.0-alpha.5",
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.3"
21
+ "@codemem/core": "^0.22.0-alpha.5"
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,228 @@
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 deriveVisiblePeopleActors(input) {
1875
+ const actors = Array.isArray(input.actors) ? input.actors : [];
1876
+ const peers = Array.isArray(input.peers) ? input.peers : [];
1877
+ const duplicatePeople = Array.isArray(input.duplicatePeople) ? input.duplicatePeople : [];
1878
+ const assignedCounts = /* @__PURE__ */ new Map();
1879
+ peers.forEach((peer) => {
1880
+ const actorId = cleanText(peer?.actor_id);
1881
+ if (!actorId) return;
1882
+ assignedCounts.set(actorId, (assignedCounts.get(actorId) ?? 0) + 1);
1883
+ });
1884
+ const hiddenIds = /* @__PURE__ */ new Set();
1885
+ duplicatePeople.forEach((candidate) => {
1886
+ if (!candidate.includesLocal) return;
1887
+ candidate.actorIds.forEach((actorId) => {
1888
+ const actor = actors.find((item) => cleanText(item?.actor_id) === actorId);
1889
+ if (!actor || actor.is_local) return;
1890
+ if ((assignedCounts.get(actorId) ?? 0) > 0) return;
1891
+ hiddenIds.add(actorId);
1892
+ });
1893
+ });
1894
+ return {
1895
+ visibleActors: actors.filter((actor) => !hiddenIds.has(cleanText(actor?.actor_id))),
1896
+ hiddenLocalDuplicateCount: hiddenIds.size
1897
+ };
1898
+ }
1899
+ function createRepairItem(device) {
1900
+ return {
1901
+ id: `repair:${device.id}`,
1902
+ kind: "device-needs-repair",
1903
+ priority: 10,
1904
+ title: `${device.name} needs repair`,
1905
+ summary: device.summary,
1906
+ actionLabel: "Review device",
1907
+ deviceId: device.id
1908
+ };
1909
+ }
1910
+ function createReviewItem(device) {
1911
+ return {
1912
+ id: `review:${device.id}`,
1913
+ kind: "review-team-device",
1914
+ priority: 20,
1915
+ title: `${device.name} is available to review`,
1916
+ summary: device.summary,
1917
+ actionLabel: "Review device",
1918
+ deviceId: device.id
1919
+ };
1920
+ }
1921
+ function createNamingItem(device) {
1922
+ return {
1923
+ id: `name:${device.id}`,
1924
+ kind: "name-device",
1925
+ priority: 30,
1926
+ title: `Name ${device.name}`,
1927
+ summary: device.summary,
1928
+ actionLabel: "Name device",
1929
+ deviceId: device.id
1930
+ };
1931
+ }
1932
+ function mergeDevices(peers, discoveredDevices) {
1933
+ const devices = /* @__PURE__ */ new Map();
1934
+ const getOrCreate = (deviceId) => {
1935
+ const current = devices.get(deviceId) ?? {
1936
+ deviceId,
1937
+ localName: "",
1938
+ coordinatorName: "",
1939
+ peer: null,
1940
+ discovered: null
1941
+ };
1942
+ devices.set(deviceId, current);
1943
+ return current;
1944
+ };
1945
+ peers.forEach((peer) => {
1946
+ const deviceId = cleanText(peer?.peer_device_id);
1947
+ if (!deviceId) return;
1948
+ const current = getOrCreate(deviceId);
1949
+ current.peer = peer;
1950
+ current.localName = cleanText(peer?.name);
1951
+ });
1952
+ discoveredDevices.forEach((device) => {
1953
+ const deviceId = cleanText(device?.device_id);
1954
+ if (!deviceId) return;
1955
+ const current = getOrCreate(deviceId);
1956
+ current.discovered = device;
1957
+ current.coordinatorName = cleanText(device?.display_name);
1958
+ });
1959
+ return [...devices.values()];
1960
+ }
1961
+ function deriveSyncViewModel(input) {
1962
+ const actors = Array.isArray(input.actors) ? input.actors : [];
1963
+ const peers = Array.isArray(input.peers) ? input.peers : [];
1964
+ const discoveredDevices = Array.isArray(input.coordinator?.discovered_devices) ? input.coordinator.discovered_devices : [];
1965
+ const mergedDevices = mergeDevices(peers, discoveredDevices);
1966
+ const duplicateDecisions = input.duplicatePersonDecisions ?? {};
1967
+ const duplicatePeople = deriveDuplicatePeople(actors).filter((candidate) => !duplicateDecisions[[...candidate.actorIds].sort().join("::")]);
1968
+ const attentionItems = [];
1969
+ duplicatePeople.forEach((candidate) => {
1970
+ attentionItems.push({
1971
+ id: `duplicate:${candidate.actorIds.join(":")}`,
1972
+ kind: "possible-duplicate-person",
1973
+ priority: candidate.includesLocal ? 5 : 15,
1974
+ title: `Possible duplicate person: ${candidate.displayName}`,
1975
+ 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.",
1976
+ actionLabel: "Review people",
1977
+ actorIds: candidate.actorIds
1978
+ });
1979
+ });
1980
+ mergedDevices.forEach((device) => {
1981
+ const name = resolveFriendlyDeviceName({
1982
+ localName: device.localName,
1983
+ coordinatorName: device.coordinatorName,
1984
+ deviceId: device.deviceId
1985
+ });
1986
+ const peerStatus = device.peer ? derivePeerUiStatus(device.peer) : "waiting";
1987
+ const discoveredFingerprint = cleanText(device.discovered?.fingerprint);
1988
+ const peerFingerprint = cleanText(device.peer?.fingerprint);
1989
+ if (Boolean(device.peer) && Boolean(discoveredFingerprint) && Boolean(peerFingerprint) && discoveredFingerprint !== peerFingerprint) {
1990
+ attentionItems.push(createRepairItem({
1991
+ id: device.deviceId,
1992
+ name,
1993
+ summary: "This device identity changed. Repair or remove the older local record before reconnecting it."
1994
+ }));
1995
+ return;
1996
+ }
1997
+ if (device.peer && peerStatus === "needs-repair") {
1998
+ const detail = cleanText(device.peer?.last_error) || "Sync health is degraded or broken.";
1999
+ attentionItems.push(createRepairItem({
2000
+ id: device.deviceId,
2001
+ name,
2002
+ summary: detail
2003
+ }));
2004
+ } else if (device.peer && peerStatus === "offline") attentionItems.push(createRepairItem({
2005
+ id: device.deviceId,
2006
+ name,
2007
+ summary: "This device is offline or stale. Review it before re-pairing or retrying sync."
2008
+ }));
2009
+ else if (!device.peer && device.discovered && !device.discovered?.stale) attentionItems.push(createReviewItem({
2010
+ id: device.deviceId,
2011
+ name,
2012
+ summary: "This device is seen on the team and is ready for review or connection on this machine."
2013
+ }));
2014
+ if (device.peer && deviceNeedsFriendlyName({
2015
+ localName: device.localName,
2016
+ coordinatorName: device.coordinatorName,
2017
+ deviceId: device.deviceId
2018
+ })) attentionItems.push(createNamingItem({
2019
+ id: device.deviceId,
2020
+ name,
2021
+ summary: "Give this device a friendly name so it is easier to recognize later."
2022
+ }));
2023
+ });
2024
+ return {
2025
+ summary: {
2026
+ connectedDeviceCount: peers.filter((peer) => derivePeerUiStatus(peer) === "connected").length,
2027
+ seenOnTeamCount: discoveredDevices.length,
2028
+ offlineTeamDeviceCount: discoveredDevices.filter((device) => Boolean(device?.stale)).length
2029
+ },
2030
+ duplicatePeople,
2031
+ attentionItems: attentionItems.sort((a, b) => a.priority - b.priority || a.title.localeCompare(b.title))
2032
+ };
2033
+ }
2034
+ //#endregion
1797
2035
  //#region src/tabs/sync/helpers.ts
1798
2036
  function hideSkeleton(id) {
1799
2037
  const skeleton = document.getElementById(id);
@@ -1810,6 +2048,7 @@
1810
2048
  var openPeerScopeEditors = /* @__PURE__ */ new Set();
1811
2049
  var pendingPeerScopeReviewIds = /* @__PURE__ */ new Set();
1812
2050
  var freshPeerScopeReviewIds = /* @__PURE__ */ new Set();
2051
+ var DUPLICATE_PERSON_DECISIONS_KEY = "codemem-sync-duplicate-person-decisions";
1813
2052
  function requestPeerScopeReview(peerDeviceId) {
1814
2053
  const value = String(peerDeviceId || "").trim();
1815
2054
  if (!value) return;
@@ -1832,6 +2071,41 @@
1832
2071
  freshPeerScopeReviewIds.delete(value);
1833
2072
  return true;
1834
2073
  }
2074
+ function readDuplicatePersonDecisionStore() {
2075
+ try {
2076
+ const raw = localStorage.getItem(DUPLICATE_PERSON_DECISIONS_KEY);
2077
+ if (!raw) return {};
2078
+ const parsed = JSON.parse(raw);
2079
+ return parsed && typeof parsed === "object" ? parsed : {};
2080
+ } catch {
2081
+ return {};
2082
+ }
2083
+ }
2084
+ function writeDuplicatePersonDecisionStore(value) {
2085
+ try {
2086
+ localStorage.setItem(DUPLICATE_PERSON_DECISIONS_KEY, JSON.stringify(value));
2087
+ } catch {}
2088
+ }
2089
+ function duplicatePersonDecisionKey(actorIds) {
2090
+ return [...actorIds].map((value) => String(value || "").trim()).filter(Boolean).sort().join("::");
2091
+ }
2092
+ function readDuplicatePersonDecisions() {
2093
+ return readDuplicatePersonDecisionStore();
2094
+ }
2095
+ function saveDuplicatePersonDecision(actorIds, decision) {
2096
+ const key = duplicatePersonDecisionKey(actorIds);
2097
+ if (!key) return;
2098
+ const next = readDuplicatePersonDecisionStore();
2099
+ next[key] = decision;
2100
+ writeDuplicatePersonDecisionStore(next);
2101
+ }
2102
+ function clearDuplicatePersonDecision(actorIds) {
2103
+ const key = duplicatePersonDecisionKey(actorIds);
2104
+ if (!key) return;
2105
+ const next = readDuplicatePersonDecisionStore();
2106
+ delete next[key];
2107
+ writeDuplicatePersonDecisionStore(next);
2108
+ }
1835
2109
  /** Redact the last two octets of IPv4 addresses. */
1836
2110
  function redactIpOctets(text) {
1837
2111
  return text.replace(/\b(\d{1,3}\.\d{1,3})\.\d{1,3}\.\d{1,3}\b/g, "$1.#.#");
@@ -1850,24 +2124,24 @@
1850
2124
  return value.split(",").map((item) => item.trim()).filter(Boolean);
1851
2125
  }
1852
2126
  function actorLabel(actor) {
1853
- if (!actor || typeof actor !== "object") return "Unknown actor";
2127
+ if (!actor || typeof actor !== "object") return "Unknown person";
1854
2128
  const displayName = String(actor.display_name || "").trim();
1855
- if (!displayName) return String(actor.actor_id || "Unknown actor");
2129
+ if (!displayName) return String(actor.actor_id || "Unknown person");
1856
2130
  return displayName;
1857
2131
  }
1858
2132
  function assignedActorCount(actorId) {
1859
2133
  return (Array.isArray(state.lastSyncPeers) ? state.lastSyncPeers : []).filter((peer) => String(peer?.actor_id || "") === actorId).length;
1860
2134
  }
1861
2135
  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.";
2136
+ if (!actorId) return "Unassigned devices keep legacy fallback attribution until you choose a person.";
2137
+ 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.";
2138
+ return "This person receives memories from allowed projects by default. Use Only me on a memory when it should stay local.";
1865
2139
  }
1866
2140
  function buildActorOptions(selectedActorId) {
1867
2141
  const options = [];
1868
2142
  const unassigned = document.createElement("option");
1869
2143
  unassigned.value = "";
1870
- unassigned.textContent = "No actor assigned";
2144
+ unassigned.textContent = "No person assigned";
1871
2145
  options.push(unassigned);
1872
2146
  (Array.isArray(state.lastSyncActors) ? state.lastSyncActors : []).forEach((actor) => {
1873
2147
  const option = document.createElement("option");
@@ -1884,7 +2158,7 @@
1884
2158
  }
1885
2159
  function actorMergeNote(targetActorId, secondaryActorId) {
1886
2160
  const target = mergeTargetActors(secondaryActorId).find((actor) => String(actor?.actor_id || "") === targetActorId);
1887
- if (!targetActorId || !target) return "Choose where this duplicate actor should collapse.";
2161
+ if (!targetActorId || !target) return "Choose which person should keep these devices.";
1888
2162
  return `Merge into ${actorLabel(target)}. Assigned devices move now; existing memories keep their current provenance.`;
1889
2163
  }
1890
2164
  function createChipEditor(initialValues, placeholder, emptyLabel) {
@@ -2288,7 +2562,7 @@
2288
2562
  const row = el("div", "actor-row");
2289
2563
  const details = el("div", "actor-details");
2290
2564
  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")}`));
2565
+ 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
2566
  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
2567
  details.append(title, note);
2294
2568
  const actions = el("div", "actor-actions");
@@ -2325,8 +2599,95 @@
2325
2599
  if (discoveredList) discoveredList.textContent = "";
2326
2600
  setInviteOutputVisibility();
2327
2601
  const coordinator = state.lastSyncCoordinator;
2602
+ const syncView = state.lastSyncViewModel || {
2603
+ summary: {},
2604
+ attentionItems: []
2605
+ };
2606
+ const focusAttentionTarget = (item) => {
2607
+ if (item.kind === "possible-duplicate-person") {
2608
+ const actorList = document.getElementById("syncActorsList");
2609
+ if (actorList instanceof HTMLElement) actorList.scrollIntoView({
2610
+ block: "center",
2611
+ behavior: "smooth"
2612
+ });
2613
+ return;
2614
+ }
2615
+ const deviceId = String(item.deviceId || "").trim();
2616
+ if (!deviceId) return;
2617
+ if (item.kind === "name-device") {
2618
+ const renameInput = document.querySelector(`[data-device-name-input="${CSS.escape(deviceId)}"]`);
2619
+ if (renameInput instanceof HTMLInputElement) {
2620
+ renameInput.scrollIntoView({
2621
+ block: "center",
2622
+ behavior: "smooth"
2623
+ });
2624
+ renameInput.focus();
2625
+ renameInput.select();
2626
+ return;
2627
+ }
2628
+ }
2629
+ const peerCard = document.querySelector(`[data-peer-device-id="${CSS.escape(deviceId)}"]`);
2630
+ if (peerCard instanceof HTMLElement) {
2631
+ peerCard.scrollIntoView({
2632
+ block: "center",
2633
+ behavior: "smooth"
2634
+ });
2635
+ return;
2636
+ }
2637
+ const discoveredRow = document.querySelector(`[data-discovered-device-id="${CSS.escape(deviceId)}"]`);
2638
+ if (discoveredRow instanceof HTMLElement) discoveredRow.scrollIntoView({
2639
+ block: "center",
2640
+ behavior: "smooth"
2641
+ });
2642
+ };
2643
+ const reviewDuplicatePeople = async (item) => {
2644
+ const actorIds = Array.isArray(item.actorIds) ? item.actorIds.map((value) => String(value || "").trim()).filter(Boolean) : [];
2645
+ const actors = (Array.isArray(state.lastSyncActors) ? state.lastSyncActors : []).filter((actor) => actorIds.includes(String(actor?.actor_id || "").trim()));
2646
+ if (actors.length < 2) {
2647
+ showGlobalNotice("This possible duplicate no longer has enough people to review.", "warning");
2648
+ return;
2649
+ }
2650
+ 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");
2651
+ const choice = String(firstAnswer || "").trim();
2652
+ if (choice === "2") {
2653
+ saveDuplicatePersonDecision(actorIds, "different-people");
2654
+ showGlobalNotice("Okay. I will keep these people separate on this device.");
2655
+ await _loadSyncData$1();
2656
+ return;
2657
+ }
2658
+ if (choice !== "1") return;
2659
+ clearDuplicatePersonDecision(actorIds);
2660
+ const localIndex = actors.findIndex((actor) => Boolean(actor?.is_local));
2661
+ const defaultChoice = localIndex >= 0 ? String(localIndex + 1) : "1";
2662
+ const options = actors.map((actor, index) => `${index + 1} = ${String(actor?.display_name || actor?.actor_id || `Person ${index + 1}`)}${actor?.is_local ? " (You)" : ""}`).join("\n");
2663
+ const keepAnswer = window.prompt(`Which person should remain after combining them?\n\n${options}`, defaultChoice);
2664
+ const primary = actors[Number.parseInt(String(keepAnswer || defaultChoice), 10) - 1] ?? actors[0];
2665
+ const secondary = actors.find((actor) => actor !== primary);
2666
+ if (!primary?.actor_id || !secondary?.actor_id) {
2667
+ showGlobalNotice("Could not determine which people to combine.", "warning");
2668
+ return;
2669
+ }
2670
+ try {
2671
+ await mergeActor(String(primary.actor_id), String(secondary.actor_id));
2672
+ showGlobalNotice(`Combined duplicate people into ${String(primary.display_name || primary.actor_id)}.`);
2673
+ await _loadSyncData$1();
2674
+ } catch (error) {
2675
+ showGlobalNotice(friendlyError(error, "Failed to combine these people."), "warning");
2676
+ }
2677
+ };
2678
+ const promptForDeviceName = async (deviceId, suggestedName) => {
2679
+ const nextName = window.prompt("What should this device be called? You can change it later in People & devices.", suggestedName);
2680
+ const value = String(nextName || "").trim();
2681
+ if (!value) {
2682
+ showGlobalNotice("You can name this device later from People & devices.", "warning");
2683
+ return false;
2684
+ }
2685
+ await renamePeer(deviceId, value);
2686
+ showGlobalNotice(`Saved device name: ${value}.`);
2687
+ return true;
2688
+ };
2328
2689
  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.";
2690
+ 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
2691
  if (!configured) {
2331
2692
  setupPanel.hidden = false;
2332
2693
  list.hidden = true;
@@ -2347,38 +2708,59 @@
2347
2708
  const statusLine = el("div", "sync-team-status-row");
2348
2709
  const statusLabel = el("span", "sync-team-status-label", "Status");
2349
2710
  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)}`);
2711
+ const metricParts = [`Connected devices: ${Number(syncView.summary?.connectedDeviceCount || 0)}`, `Seen on team: ${Number(syncView.summary?.seenOnTeamCount || 0)}`];
2712
+ if (Number(syncView.summary?.offlineTeamDeviceCount || 0) > 0) metricParts.push(`Offline on team: ${Number(syncView.summary?.offlineTeamDeviceCount || 0)}`);
2352
2713
  statusLine.append(statusLabel, statusBadge);
2353
2714
  statusRow.append(statusLine, el("div", "sync-team-metrics", metricParts.join(" · ")));
2354
2715
  list.appendChild(statusRow);
2355
2716
  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) {
2717
+ const attentionItems = Array.isArray(syncView.attentionItems) ? syncView.attentionItems : [];
2718
+ if (attentionItems.length) {
2719
+ const heading = el("div", "sync-action-text");
2720
+ heading.textContent = "Needs attention";
2721
+ actions.appendChild(heading);
2722
+ attentionItems.slice(0, 4).forEach((item) => {
2723
+ const row = el("div", "sync-action");
2724
+ const textWrap = el("div", "sync-action-text");
2725
+ textWrap.textContent = item.title;
2726
+ textWrap.appendChild(el("span", "sync-action-command", item.summary));
2727
+ const actionButton = el("button", "settings-button", item.actionLabel || "Review");
2728
+ actionButton.addEventListener("click", async () => {
2729
+ if (item.kind === "possible-duplicate-person") {
2730
+ await reviewDuplicatePeople(item);
2731
+ return;
2732
+ }
2733
+ focusAttentionTarget(item);
2734
+ });
2735
+ row.append(textWrap, actionButton);
2736
+ actions.appendChild(row);
2737
+ });
2738
+ } else if (coordinator.presence_status === "posted") {
2739
+ const row = el("div", "sync-action");
2740
+ const textWrap = el("div", "sync-action-text");
2741
+ textWrap.textContent = "No immediate issues";
2742
+ textWrap.appendChild(el("span", "sync-action-command", "Your devices and team records do not currently need review."));
2743
+ row.appendChild(textWrap);
2744
+ actions.appendChild(row);
2745
+ } else if (coordinator.presence_status === "not_enrolled") {
2365
2746
  const row = el("div", "sync-action");
2366
2747
  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."));
2748
+ textWrap.textContent = "This device still needs team enrollment";
2749
+ textWrap.appendChild(el("span", "sync-action-command", "Import an invite or ask your admin to enroll this device before expecting sync activity here."));
2369
2750
  row.appendChild(textWrap);
2370
2751
  actions.appendChild(row);
2371
2752
  }
2372
2753
  const discoveredDevices = Array.isArray(coordinator.discovered_devices) ? coordinator.discovered_devices : [];
2373
2754
  if (discoveredPanel && discoveredMeta && discoveredList && discoveredDevices.length) {
2374
2755
  discoveredPanel.hidden = false;
2375
- discoveredMeta.textContent = "These devices are visible through coordinator discovery only. They are not active sync peers unless you pair them.";
2756
+ discoveredMeta.textContent = "These devices are visible through team discovery. Review them before connecting them on this machine.";
2376
2757
  discoveredDevices.forEach((device) => {
2377
2758
  const row = el("div", "actor-row");
2378
2759
  const details = el("div", "actor-details");
2379
2760
  const title = el("div", "actor-title");
2380
2761
  const rowActions = el("div", "actor-actions");
2381
2762
  const deviceId = String(device.device_id || "").trim();
2763
+ if (deviceId) row.dataset.discoveredDeviceId = deviceId;
2382
2764
  const displayName = String(device.display_name || "").trim() || deviceId || "Discovered device";
2383
2765
  const fingerprint = String(device.fingerprint || "").trim();
2384
2766
  const pairedPeer = localPeers.find((peer) => String(peer?.peer_device_id || "") === deviceId);
@@ -2386,8 +2768,8 @@
2386
2768
  const hasConflict = Boolean(pairedPeer) && Boolean(fingerprint) && Boolean(pairedFingerprint) && pairedFingerprint !== fingerprint;
2387
2769
  const canAccept = Boolean(deviceId) && Boolean(fingerprint) && !pairedPeer && !device.stale;
2388
2770
  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"));
2771
+ title.appendChild(el("span", `badge actor-badge${device.stale ? "" : " local"}`, device.stale ? "Offline" : "Available"));
2772
+ title.appendChild(el("span", "badge actor-badge", hasConflict ? SYNC_TERMINOLOGY.conflicts : pairedPeer ? SYNC_TERMINOLOGY.pairedLocally : "Not connected on this device"));
2391
2773
  const addresses = Array.isArray(device.addresses) ? device.addresses : [];
2392
2774
  const noteParts = [deviceId, addresses.length ? addresses.map((address) => isSyncRedactionEnabled() ? redactAddress(String(address || "")) : String(address || "")).filter(Boolean).join(" · ") : "No fresh addresses"];
2393
2775
  if (hasConflict) noteParts.push("repair the local peer before accepting this discovered device");
@@ -2396,27 +2778,36 @@
2396
2778
  else if (pairedPeer?.status?.peer_state) noteParts.push(`paired status: ${String(pairedPeer.status.peer_state)}`);
2397
2779
  details.append(title, el("div", "peer-meta", noteParts.join(" · ")));
2398
2780
  if (canAccept) {
2399
- const acceptBtn = el("button", "settings-button", "Accept peer");
2781
+ const acceptBtn = el("button", "settings-button", "Review device");
2400
2782
  acceptBtn.addEventListener("click", async () => {
2401
2783
  acceptBtn.disabled = true;
2402
- acceptBtn.textContent = "Accepting…";
2784
+ acceptBtn.textContent = "Opening…";
2403
2785
  try {
2404
- await acceptDiscoveredPeer(deviceId, fingerprint);
2786
+ const result = await acceptDiscoveredPeer(deviceId, fingerprint);
2405
2787
  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();
2788
+ showGlobalNotice(`Connected ${displayName}. Its device settings are now open in People & devices so you can review sync scope next.`);
2789
+ try {
2790
+ await promptForDeviceName(deviceId, String(result?.name || displayName || "").trim() || displayName);
2791
+ } catch (error) {
2792
+ showGlobalNotice(friendlyError(error, "Device connected, but naming did not finish."), "warning");
2793
+ }
2794
+ try {
2795
+ await _loadSyncData$1();
2796
+ } catch (error) {
2797
+ showGlobalNotice(friendlyError(error, "Device connected, but the screen did not refresh yet."), "warning");
2798
+ }
2408
2799
  } catch (error) {
2409
- showGlobalNotice(friendlyError(error, "Failed to accept discovered peer."), "warning");
2410
- acceptBtn.textContent = "Retry accept";
2800
+ showGlobalNotice(friendlyError(error, "Failed to review this device."), "warning");
2801
+ acceptBtn.textContent = "Retry review";
2411
2802
  } finally {
2412
2803
  acceptBtn.disabled = false;
2413
- if (acceptBtn.textContent === "Accepting…") acceptBtn.textContent = "Accept peer";
2804
+ if (acceptBtn.textContent === "Opening…") acceptBtn.textContent = "Review device";
2414
2805
  }
2415
2806
  });
2416
2807
  rowActions.appendChild(acceptBtn);
2417
2808
  } else if (!pairedPeer && device.stale) rowActions.appendChild(el("div", "peer-meta", "Wait for a fresh coordinator presence update."));
2418
2809
  else if (hasConflict) {
2419
- const inspectBtn = el("button", "settings-button", "Jump to local peer");
2810
+ const inspectBtn = el("button", "settings-button", "Open device details");
2420
2811
  inspectBtn.addEventListener("click", () => {
2421
2812
  const peerCard = document.querySelector(`[data-peer-device-id="${CSS.escape(deviceId)}"]`);
2422
2813
  if (peerCard instanceof HTMLElement) {
@@ -2424,34 +2815,34 @@
2424
2815
  block: "center",
2425
2816
  behavior: "smooth"
2426
2817
  });
2427
- showGlobalNotice(`Jumped to the conflicting local peer for ${displayName}.`, "warning");
2818
+ showGlobalNotice(`Opened the conflicting local device record for ${displayName}.`, "warning");
2428
2819
  return;
2429
2820
  }
2430
- showGlobalNotice("Conflicting local peer is not visible yet. Scroll to the People area and try again.", "warning");
2821
+ showGlobalNotice("The conflicting local device record is not visible yet. Scroll to People & devices and try again.", "warning");
2431
2822
  });
2432
- const removeConflictBtn = el("button", "settings-button", "Remove conflicting peer");
2823
+ const removeConflictBtn = el("button", "settings-button", "Remove broken device record");
2433
2824
  removeConflictBtn.addEventListener("click", async () => {
2434
2825
  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;
2826
+ if (!window.confirm(`Remove the broken local device record for ${displayName}? You can review this device again after the screen refreshes.`)) return;
2436
2827
  removeConflictBtn.disabled = true;
2437
2828
  inspectBtn.disabled = true;
2438
2829
  removeConflictBtn.textContent = "Removing…";
2439
2830
  try {
2440
2831
  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.`);
2832
+ showGlobalNotice(`Removed the broken local device record for ${displayName}. If it is still available, you can review it again from Needs attention.`);
2442
2833
  await _loadSyncData$1();
2443
2834
  } catch (error) {
2444
- showGlobalNotice(friendlyError(error, "Failed to remove the conflicting local peer."), "warning");
2835
+ showGlobalNotice(friendlyError(error, "Failed to remove the broken local device record."), "warning");
2445
2836
  removeConflictBtn.textContent = "Retry remove";
2446
2837
  } finally {
2447
2838
  removeConflictBtn.disabled = false;
2448
2839
  inspectBtn.disabled = false;
2449
- if (removeConflictBtn.textContent === "Removing…") removeConflictBtn.textContent = "Remove conflicting peer";
2840
+ if (removeConflictBtn.textContent === "Removing…") removeConflictBtn.textContent = "Remove broken device record";
2450
2841
  }
2451
2842
  });
2452
2843
  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."));
2844
+ } else if (pairedPeer && isPeerScopeReviewPending(deviceId)) rowActions.appendChild(el("div", "peer-meta", "Review this device's scope in People & devices next."));
2845
+ else if (pairedPeer) rowActions.appendChild(el("div", "peer-meta", "Manage this device in People & devices."));
2455
2846
  row.append(details, rowActions);
2456
2847
  discoveredList.appendChild(row);
2457
2848
  });
@@ -2647,10 +3038,18 @@
2647
3038
  if (!actorList) return;
2648
3039
  hideSkeleton("syncActorsSkeleton");
2649
3040
  actorList.textContent = "";
2650
- 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.";
3041
+ const actorVisibility = deriveVisiblePeopleActors({
3042
+ actors: state.lastSyncActors,
3043
+ peers: state.lastSyncPeers,
3044
+ duplicatePeople: state.lastSyncViewModel?.duplicatePeople
3045
+ });
3046
+ const actors = actorVisibility.visibleActors;
3047
+ if (actorMeta) {
3048
+ 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.";
3049
+ if (actorVisibility.hiddenLocalDuplicateCount > 0) actorMeta.textContent += ` ${actorVisibility.hiddenLocalDuplicateCount} unresolved duplicate ${actorVisibility.hiddenLocalDuplicateCount === 1 ? "entry is" : "entries are"} hidden here until reviewed in Needs attention.`;
3050
+ }
2652
3051
  if (!actors.length) {
2653
- actorList.appendChild(el("div", "sync-empty-state", "No actors yet. Create one to represent yourself or a teammate."));
3052
+ actorList.appendChild(el("div", "sync-empty-state", "No people yet. Create one to represent yourself or a teammate."));
2654
3053
  return;
2655
3054
  }
2656
3055
  actors.forEach((actor) => {
@@ -2661,7 +3060,7 @@
2661
3060
  const count = assignedActorCount(String(actor.actor_id || ""));
2662
3061
  const badge = el("span", `badge actor-badge${actor.is_local ? " local" : ""}`, actor.is_local ? "Local" : `${count} device${count === 1 ? "" : "s"}`);
2663
3062
  title.append(name, badge);
2664
- const note = el("div", "peer-meta", actor.is_local ? "Used for this device and same-person devices." : `${count} assigned device${count === 1 ? "" : "s"}`);
3063
+ const note = el("div", "peer-meta", actor.is_local ? `Used for this device and same-person devices.${actorVisibility.hiddenLocalDuplicateCount > 0 ? ` ${actorVisibility.hiddenLocalDuplicateCount} unresolved duplicate ${actorVisibility.hiddenLocalDuplicateCount === 1 ? "entry is" : "entries are"} hidden until reviewed in Needs attention.` : ""}` : `${count} assigned device${count === 1 ? "" : "s"}`);
2665
3064
  details.append(title, note);
2666
3065
  const actions = el("div", "actor-actions");
2667
3066
  if (actor.is_local) actions.appendChild(el("div", "peer-meta", "Rename in config"));
@@ -2693,10 +3092,10 @@
2693
3092
  const mergeControls = el("div", "actor-merge-controls");
2694
3093
  const mergeSelect = document.createElement("select");
2695
3094
  mergeSelect.className = "sync-actor-select actor-merge-select";
2696
- mergeSelect.setAttribute("aria-label", `Merge ${actorLabel(actor)} into another actor`);
3095
+ mergeSelect.setAttribute("aria-label", `Combine ${actorLabel(actor)} into another person`);
2697
3096
  const placeholder = document.createElement("option");
2698
3097
  placeholder.value = "";
2699
- placeholder.textContent = "Merge into actor";
3098
+ placeholder.textContent = "Combine into person";
2700
3099
  placeholder.selected = true;
2701
3100
  mergeSelect.appendChild(placeholder);
2702
3101
  mergeTargets.forEach((target) => {
@@ -2705,16 +3104,16 @@
2705
3104
  option.textContent = target.is_local ? `${actorLabel(target)} (local)` : actorLabel(target);
2706
3105
  mergeSelect.appendChild(option);
2707
3106
  });
2708
- const mergeBtn = el("button", "settings-button", "Merge into selected actor");
3107
+ const mergeBtn = el("button", "settings-button", "Combine into selected person");
2709
3108
  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.");
3109
+ 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
3110
  mergeSelect.addEventListener("change", () => {
2712
3111
  mergeNote.textContent = actorMergeNote(mergeSelect.value, actorId);
2713
3112
  });
2714
3113
  mergeBtn.addEventListener("click", async () => {
2715
3114
  if (!mergeSelect.value) return;
2716
3115
  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;
3116
+ 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
3117
  mergeBtn.disabled = true;
2719
3118
  mergeSelect.disabled = true;
2720
3119
  input.disabled = true;
@@ -2722,17 +3121,17 @@
2722
3121
  mergeBtn.textContent = "Merging…";
2723
3122
  try {
2724
3123
  await mergeActor(mergeSelect.value, actorId);
2725
- showGlobalNotice("Actor merged. Assigned devices moved to the selected actor.");
3124
+ showGlobalNotice("People combined. Assigned devices moved to the selected person.");
2726
3125
  await _loadSyncData();
2727
3126
  } catch (error) {
2728
- showGlobalNotice(friendlyError(error, "Failed to merge actor."), "warning");
3127
+ showGlobalNotice(friendlyError(error, "Failed to combine people."), "warning");
2729
3128
  mergeBtn.textContent = "Retry merge";
2730
3129
  } finally {
2731
3130
  mergeBtn.disabled = mergeTargets.length === 0;
2732
3131
  mergeSelect.disabled = false;
2733
3132
  input.disabled = false;
2734
3133
  renameBtn.disabled = false;
2735
- if (mergeBtn.textContent === "Merging…") mergeBtn.textContent = "Merge into selected actor";
3134
+ if (mergeBtn.textContent === "Merging…") mergeBtn.textContent = "Combine into selected person";
2736
3135
  }
2737
3136
  });
2738
3137
  mergeControls.append(mergeSelect, mergeBtn);
@@ -2749,7 +3148,7 @@
2749
3148
  syncPeers.textContent = "";
2750
3149
  const peers = state.lastSyncPeers;
2751
3150
  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."));
3151
+ 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
3152
  return;
2754
3153
  }
2755
3154
  peers.forEach((peer) => {
@@ -2768,6 +3167,38 @@
2768
3167
  name.append(" ", badge);
2769
3168
  if (pendingScopeReview) name.append(" ", el("span", "badge actor-badge", "Needs scope review"));
2770
3169
  const actions = el("div", "peer-actions");
3170
+ const renameInput = document.createElement("input");
3171
+ renameInput.className = "peer-scope-input";
3172
+ renameInput.value = displayName;
3173
+ if (peerId) renameInput.dataset.deviceNameInput = peerId;
3174
+ renameInput.setAttribute("aria-label", `Friendly name for ${displayName}`);
3175
+ renameInput.placeholder = "Friendly device name";
3176
+ const renameBtn = el("button", null, "Save name");
3177
+ renameBtn.addEventListener("click", async () => {
3178
+ if (!peerId) return;
3179
+ const nextName = String(renameInput.value || "").trim();
3180
+ if (!nextName) {
3181
+ showGlobalNotice("Enter a friendly name for this device.", "warning");
3182
+ renameInput.focus();
3183
+ return;
3184
+ }
3185
+ renameBtn.disabled = true;
3186
+ renameInput.disabled = true;
3187
+ renameBtn.textContent = "Saving…";
3188
+ try {
3189
+ await renamePeer(peerId, nextName);
3190
+ showGlobalNotice("Device name saved.");
3191
+ await _loadSyncData();
3192
+ } catch (error) {
3193
+ showGlobalNotice(friendlyError(error, "Failed to save device name."), "warning");
3194
+ renameBtn.textContent = "Retry save";
3195
+ } finally {
3196
+ renameBtn.disabled = false;
3197
+ renameInput.disabled = false;
3198
+ if (renameBtn.textContent === "Saving…") renameBtn.textContent = "Save name";
3199
+ }
3200
+ });
3201
+ actions.append(renameInput, renameBtn);
2771
3202
  const primaryAddress = pickPrimaryAddress(peer.addresses);
2772
3203
  const syncBtn = el("button", null, "Sync now");
2773
3204
  syncBtn.disabled = !primaryAddress;
@@ -2823,7 +3254,7 @@
2823
3254
  const lastSyncAt = peerStatus.last_sync_at || peerStatus.last_sync_at_utc || "";
2824
3255
  const lastPingAt = peerStatus.last_ping_at || peerStatus.last_ping_at_utc || "";
2825
3256
  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");
3257
+ 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
3258
  const scope = peer.project_scope || {};
2828
3259
  const includeList = Array.isArray(scope.include) ? scope.include : [];
2829
3260
  const excludeList = Array.isArray(scope.exclude) ? scope.exclude : [];
@@ -2832,13 +3263,13 @@
2832
3263
  const inheritsGlobal = Boolean(scope.inherits_global);
2833
3264
  const scopePanel = el("div", "peer-scope");
2834
3265
  const identityRow = el("div", "peer-scope-summary");
2835
- identityRow.textContent = "Assigned actor";
3266
+ identityRow.textContent = "Assigned person";
2836
3267
  const actorRow = el("div", "peer-actor-row");
2837
3268
  const actorSelect = document.createElement("select");
2838
3269
  actorSelect.className = "sync-actor-select";
2839
- actorSelect.setAttribute("aria-label", `Assigned actor for ${displayName}`);
3270
+ actorSelect.setAttribute("aria-label", `Assigned person for ${displayName}`);
2840
3271
  buildActorOptions(String(peer.actor_id || "")).forEach((option) => actorSelect.appendChild(option));
2841
- const applyActorBtn = el("button", "settings-button", "Save actor");
3272
+ const applyActorBtn = el("button", "settings-button", "Save person");
2842
3273
  const actorHint = el("div", "peer-scope-effective", assignmentNote(String(peer.actor_id || "")));
2843
3274
  actorSelect.addEventListener("change", () => {
2844
3275
  actorHint.textContent = assignmentNote(actorSelect.value);
@@ -2849,15 +3280,15 @@
2849
3280
  applyActorBtn.textContent = "Applying…";
2850
3281
  try {
2851
3282
  await assignPeerActor(peerId, actorSelect.value || null);
2852
- showGlobalNotice(actorSelect.value ? "Device actor updated." : "Device actor cleared.");
3283
+ showGlobalNotice(actorSelect.value ? "Device person updated." : "Device person cleared.");
2853
3284
  await _loadSyncData();
2854
3285
  } catch (error) {
2855
- showGlobalNotice(friendlyError(error, "Failed to update device actor."), "warning");
3286
+ showGlobalNotice(friendlyError(error, "Failed to update device person."), "warning");
2856
3287
  applyActorBtn.textContent = "Retry";
2857
3288
  } finally {
2858
3289
  actorSelect.disabled = false;
2859
3290
  applyActorBtn.disabled = false;
2860
- if (applyActorBtn.textContent === "Applying…") applyActorBtn.textContent = "Save actor";
3291
+ if (applyActorBtn.textContent === "Applying…") applyActorBtn.textContent = "Save person";
2861
3292
  }
2862
3293
  });
2863
3294
  actorRow.append(actorSelect, applyActorBtn);
@@ -2969,7 +3400,7 @@
2969
3400
  if (!syncActorCreateButton || !syncActorCreateInput) return;
2970
3401
  const displayName = String(syncActorCreateInput.value || "").trim();
2971
3402
  if (!displayName) {
2972
- markFieldError(syncActorCreateInput, "Enter a name for the actor.");
3403
+ markFieldError(syncActorCreateInput, "Enter a name for the person.");
2973
3404
  return;
2974
3405
  }
2975
3406
  clearFieldError(syncActorCreateInput);
@@ -2978,30 +3409,30 @@
2978
3409
  syncActorCreateButton.textContent = "Creating…";
2979
3410
  try {
2980
3411
  await createActor(displayName);
2981
- showGlobalNotice("Actor created.");
3412
+ showGlobalNotice("Person created.");
2982
3413
  syncActorCreateInput.value = "";
2983
3414
  await loadSyncData();
2984
3415
  } catch (error) {
2985
- showGlobalNotice(friendlyError(error, "Failed to create actor."), "warning");
3416
+ showGlobalNotice(friendlyError(error, "Failed to create person."), "warning");
2986
3417
  syncActorCreateButton.textContent = "Retry";
2987
3418
  syncActorCreateButton.disabled = false;
2988
3419
  syncActorCreateInput.disabled = false;
2989
3420
  return;
2990
3421
  }
2991
- syncActorCreateButton.textContent = "Create actor";
3422
+ syncActorCreateButton.textContent = "Create person";
2992
3423
  syncActorCreateButton.disabled = false;
2993
3424
  syncActorCreateInput.disabled = false;
2994
3425
  });
2995
3426
  syncLegacyClaimButton?.addEventListener("click", async () => {
2996
3427
  const originDeviceId = String(syncLegacyDeviceSelect?.value || "").trim();
2997
3428
  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;
3429
+ if (!window.confirm(`Attach old device history from ${originDeviceId} to you? This updates legacy provenance for that device.`)) return;
2999
3430
  syncLegacyClaimButton.disabled = true;
3000
3431
  const originalText = syncLegacyClaimButton.textContent || "Attach device history";
3001
3432
  syncLegacyClaimButton.textContent = "Attaching…";
3002
3433
  try {
3003
3434
  await claimLegacyDeviceIdentity(originDeviceId);
3004
- showGlobalNotice("Old device history attached to your local actor.");
3435
+ showGlobalNotice("Old device history attached to you.");
3005
3436
  await loadSyncData();
3006
3437
  } catch (error) {
3007
3438
  showGlobalNotice(friendlyError(error, "Failed to attach old device history."), "warning");
@@ -3060,12 +3491,17 @@
3060
3491
  }
3061
3492
  let actorsPayload = null;
3062
3493
  let actorLoadError = false;
3494
+ const duplicatePersonDecisions = readDuplicatePersonDecisions();
3063
3495
  try {
3064
3496
  actorsPayload = await loadSyncActors();
3065
3497
  } catch {
3066
3498
  actorLoadError = true;
3067
3499
  }
3068
- const hash = JSON.stringify([payload, actorsPayload]);
3500
+ const hash = JSON.stringify([
3501
+ payload,
3502
+ actorsPayload,
3503
+ duplicatePersonDecisions
3504
+ ]);
3069
3505
  if (hash === lastSyncHash) return;
3070
3506
  lastSyncHash = hash;
3071
3507
  const statusPayload = payload.status && typeof payload.status === "object" ? payload.status : null;
@@ -3077,6 +3513,13 @@
3077
3513
  if (Array.isArray(payload.join_requests)) state.lastSyncJoinRequests = payload.join_requests;
3078
3514
  state.lastSyncAttempts = payload.attempts || [];
3079
3515
  state.lastSyncLegacyDevices = payload.legacy_devices || [];
3516
+ state.lastSyncDuplicatePersonDecisions = duplicatePersonDecisions;
3517
+ state.lastSyncViewModel = deriveSyncViewModel({
3518
+ actors: state.lastSyncActors,
3519
+ peers: state.lastSyncPeers,
3520
+ coordinator: state.lastSyncCoordinator,
3521
+ duplicatePersonDecisions: state.lastSyncDuplicatePersonDecisions
3522
+ });
3080
3523
  renderSyncStatus();
3081
3524
  renderTeamSync();
3082
3525
  renderSyncActors();
@@ -3087,7 +3530,7 @@
3087
3530
  renderHealthOverview();
3088
3531
  if (actorLoadError) {
3089
3532
  const actorMeta = document.getElementById("syncActorsMeta");
3090
- if (actorMeta) actorMeta.textContent = "Actor controls are temporarily unavailable. Peer status and sync health still loaded.";
3533
+ if (actorMeta) actorMeta.textContent = "People controls are temporarily unavailable. Device status and sync health still loaded.";
3091
3534
  }
3092
3535
  } catch {
3093
3536
  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>