@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 +2 -2
- package/static/app.js +514 -71
- 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.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.
|
|
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
|
|
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
|
|
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
|
|
1863
|
-
if ((Array.isArray(state.lastSyncActors) ? state.lastSyncActors : []).find((item) => String(item?.actor_id || "") === actorId)?.is_local) return "
|
|
1864
|
-
return "This
|
|
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
|
|
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
|
|
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", `
|
|
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"}` : "
|
|
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 = [`
|
|
2351
|
-
if (Number(
|
|
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
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
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 =
|
|
2368
|
-
textWrap.appendChild(el("span", "sync-action-command", "
|
|
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
|
|
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 ? "
|
|
2390
|
-
title.appendChild(el("span", "badge actor-badge", hasConflict ?
|
|
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", "
|
|
2781
|
+
const acceptBtn = el("button", "settings-button", "Review device");
|
|
2400
2782
|
acceptBtn.addEventListener("click", async () => {
|
|
2401
2783
|
acceptBtn.disabled = true;
|
|
2402
|
-
acceptBtn.textContent = "
|
|
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(`
|
|
2407
|
-
|
|
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
|
|
2410
|
-
acceptBtn.textContent = "Retry
|
|
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 === "
|
|
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", "
|
|
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(`
|
|
2818
|
+
showGlobalNotice(`Opened the conflicting local device record for ${displayName}.`, "warning");
|
|
2428
2819
|
return;
|
|
2429
2820
|
}
|
|
2430
|
-
showGlobalNotice("
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2454
|
-
else if (pairedPeer) rowActions.appendChild(el("div", "peer-meta", "Manage this
|
|
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
|
|
2651
|
-
|
|
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
|
|
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 ?
|
|
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", `
|
|
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 = "
|
|
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", "
|
|
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
|
|
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(`
|
|
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("
|
|
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
|
|
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 = "
|
|
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
|
|
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 ? " ·
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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("
|
|
3412
|
+
showGlobalNotice("Person created.");
|
|
2982
3413
|
syncActorCreateInput.value = "";
|
|
2983
3414
|
await loadSyncData();
|
|
2984
3415
|
} catch (error) {
|
|
2985
|
-
showGlobalNotice(friendlyError(error, "Failed to create
|
|
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
|
|
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
|
|
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
|
|
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([
|
|
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 = "
|
|
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.
|
|
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>
|