@codemem/server 0.22.0-alpha.3 → 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 +2 -2
- package/static/app.js +479 -69
- package/static/index.html +15 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codemem/server",
|
|
3
|
-
"version": "0.22.0-alpha.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
1863
|
-
if ((Array.isArray(state.lastSyncActors) ? state.lastSyncActors : []).find((item) => String(item?.actor_id || "") === actorId)?.is_local) return "
|
|
1864
|
-
return "This
|
|
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
|
|
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
|
|
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", `
|
|
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"}` : "
|
|
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 = [`
|
|
2351
|
-
if (Number(
|
|
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
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
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 =
|
|
2368
|
-
textWrap.appendChild(el("span", "sync-action-command", "
|
|
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
|
|
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 ? "
|
|
2390
|
-
title.appendChild(el("span", "badge actor-badge", hasConflict ?
|
|
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", "
|
|
2756
|
+
const acceptBtn = el("button", "settings-button", "Review device");
|
|
2400
2757
|
acceptBtn.addEventListener("click", async () => {
|
|
2401
2758
|
acceptBtn.disabled = true;
|
|
2402
|
-
acceptBtn.textContent = "
|
|
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(`
|
|
2407
|
-
|
|
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
|
|
2410
|
-
acceptBtn.textContent = "Retry
|
|
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 === "
|
|
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", "
|
|
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(`
|
|
2793
|
+
showGlobalNotice(`Opened the conflicting local device record for ${displayName}.`, "warning");
|
|
2428
2794
|
return;
|
|
2429
2795
|
}
|
|
2430
|
-
showGlobalNotice("
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2454
|
-
else if (pairedPeer) rowActions.appendChild(el("div", "peer-meta", "Manage this
|
|
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
|
|
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
|
|
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", `
|
|
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 = "
|
|
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", "
|
|
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
|
|
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(`
|
|
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("
|
|
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
|
|
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 = "
|
|
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
|
|
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 ? " ·
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
3379
|
+
showGlobalNotice("Person created.");
|
|
2982
3380
|
syncActorCreateInput.value = "";
|
|
2983
3381
|
await loadSyncData();
|
|
2984
3382
|
} catch (error) {
|
|
2985
|
-
showGlobalNotice(friendlyError(error, "Failed to create
|
|
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
|
|
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
|
|
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
|
|
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([
|
|
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 = "
|
|
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.
|
|
1726
|
+
<!-- 1. Needs attention + team setup/status -->
|
|
1727
1727
|
<div class="card" id="syncTeamCard">
|
|
1728
1728
|
<div class="section-header">
|
|
1729
|
-
<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"
|
|
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">
|
|
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
|
|
1786
|
+
<!-- 2. People & devices -->
|
|
1786
1787
|
<div class="card" style="animation-delay: 0.04s">
|
|
1787
|
-
<h2>People</h2>
|
|
1788
|
+
<h2>People & devices</h2>
|
|
1788
1789
|
|
|
1789
|
-
<h3 class="settings-group-title">
|
|
1790
|
-
<div class="section-meta" id="syncActorsMeta">Create and rename
|
|
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">
|
|
1793
|
-
<input class="peer-scope-input" id="syncActorCreateInput" placeholder="Create a named
|
|
1794
|
-
<button class="settings-button" id="syncActorCreateButton">Create
|
|
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.
|
|
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>
|
|
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">
|
|
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>
|