multi-agent-collaboration-mcp 0.13.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/index.html CHANGED
@@ -1205,13 +1205,14 @@
1205
1205
  id="join-name"
1206
1206
  placeholder="Your name"
1207
1207
  aria-label="Your name"
1208
- maxlength="200"
1208
+ maxlength="177"
1209
1209
  autocomplete="off"
1210
1210
  spellcheck="false"
1211
1211
  />
1212
1212
  <button id="join-btn" class="btn-accent">Join room</button>
1213
+ <button id="join-change-btn" class="linkish" hidden>Change name</button>
1213
1214
  <span class="compose-hint"
1214
- >no password: identity is self-asserted, like the agents</span
1215
+ >no password: human identity is self-asserted</span
1215
1216
  >
1216
1217
  <span id="join-error" class="compose-hint" style="color: var(--danger)"></span>
1217
1218
  </div>
@@ -1242,8 +1243,13 @@
1242
1243
  <script>
1243
1244
  "use strict";
1244
1245
 
1245
- const ROOM_KEY = "agent-chat.room";
1246
- const SEEN_KEY = "agent-chat.seen";
1246
+ const ROOM_KEY = "agent-chat.v2.room";
1247
+ const SEEN_KEY = "agent-chat.v2.seen";
1248
+ const SEEN_LOCK = "agent-chat.seen";
1249
+ const IDENT_KEY = "agent-chat.v2.identity";
1250
+ const JOINED_KEY = "agent-chat.v2.joined";
1251
+ const GHOST_KEY = "agent-chat.v2.ghosts";
1252
+ const IDENTITY_VERSION = 2;
1247
1253
  const POLL_MS = 1500;
1248
1254
  const ROOMS_MS = 30000;
1249
1255
  const PAGE = 400;
@@ -1258,7 +1264,7 @@
1258
1264
  "room-head", "room-name", "room-desc", "room-stats",
1259
1265
  "pinned", "pinned-preview", "pinned-body",
1260
1266
  "banner", "stream-wrap", "stream", "older", "markall", "msgs", "jump", "placeholder",
1261
- "composer", "compose-join", "join-name", "join-btn", "join-error",
1267
+ "composer", "compose-join", "join-name", "join-btn", "join-change-btn", "join-error",
1262
1268
  "compose-box", "reply-target", "compose-text", "send", "compose-ident",
1263
1269
  "ident-btn", "leave-btn", "compose-error",
1264
1270
  "search", "search-bar", "search-info", "search-back", "results",
@@ -1276,7 +1282,8 @@
1276
1282
  // polling cannot walk and rebuild every intervening 400-row page.
1277
1283
  historicalWindow: false,
1278
1284
  failures: 0,
1279
- loadingMsgs: false,
1285
+ // Truthy for the full lifetime of a message load and awaitable by jumps.
1286
+ msgsLoad: null,
1280
1287
  loadingRooms: false,
1281
1288
  seen: loadSeen(),
1282
1289
  replyTo: null, // { seq, label } the composer is replying to
@@ -1316,16 +1323,14 @@
1316
1323
  roomEpoch: 0,
1317
1324
  searchSeq: 0, // orders concurrent search requests
1318
1325
  searchController: null, // aborts the superseded/abandoned search request
1326
+ changingIdentity: false,
1327
+ changingFrom: null,
1319
1328
  // A rename that lands WHILE search mode hides the stream cannot rebuild
1320
1329
  // the (hidden) rows' identity-derived decoration then; this defers it
1321
1330
  // to exitSearch.
1322
1331
  needsRebuild: false,
1323
1332
  };
1324
1333
 
1325
- const IDENT_KEY = "agent-chat.identity";
1326
- const JOINED_KEY = "agent-chat.joined";
1327
- const GHOST_KEY = "agent-chat.ghosts";
1328
-
1329
1334
  // Cross-tab serialization for EVERY membership mutation (identity,
1330
1335
  // joined map, ghost ledger, and the server calls they mirror). These
1331
1336
  // are read-modify-write sequences over shared localStorage: two tabs
@@ -1342,20 +1347,101 @@
1342
1347
  return navigator.locks.request("agent-chat.membership", fn);
1343
1348
  }
1344
1349
 
1350
+ function emptyIdentityState() {
1351
+ return {
1352
+ version: IDENTITY_VERSION,
1353
+ current: null,
1354
+ pending_allocation: null,
1355
+ };
1356
+ }
1357
+
1358
+ function readIdentityState() {
1359
+ try {
1360
+ const v = JSON.parse(localStorage.getItem(IDENT_KEY));
1361
+ const currentOk =
1362
+ v?.current === null ||
1363
+ (v?.current &&
1364
+ typeof v.current.base_name === "string" &&
1365
+ typeof v.current.agent_id === "string");
1366
+ const p = v?.pending_allocation;
1367
+ const pendingOk =
1368
+ p === null ||
1369
+ (p &&
1370
+ typeof p.operation_id === "string" &&
1371
+ Number.isSafeInteger(p.room) &&
1372
+ p.room > 0 &&
1373
+ typeof p.base_name === "string" &&
1374
+ (p.previous_agent_id === null ||
1375
+ typeof p.previous_agent_id === "string") &&
1376
+ Array.isArray(p.previous_memberships) &&
1377
+ p.previous_memberships.every(
1378
+ (m) =>
1379
+ m &&
1380
+ Number.isSafeInteger(m.room) &&
1381
+ m.room > 0 &&
1382
+ typeof m.agent_id === "string",
1383
+ ));
1384
+ if (
1385
+ v &&
1386
+ v.version === IDENTITY_VERSION &&
1387
+ currentOk &&
1388
+ pendingOk
1389
+ ) {
1390
+ return v;
1391
+ }
1392
+ } catch {}
1393
+ return emptyIdentityState();
1394
+ }
1395
+
1396
+ let browserIdentity = readIdentityState();
1397
+
1398
+ function reloadIdentityState() {
1399
+ browserIdentity = readIdentityState();
1400
+ return browserIdentity;
1401
+ }
1402
+
1403
+ // The current identity and its pending replacement share one atomic
1404
+ // localStorage value. Update the in-memory view only after persistence
1405
+ // succeeds, so a thrown write cannot make this tab outrun reload.
1406
+ function saveIdentityState(next) {
1407
+ try {
1408
+ localStorage.setItem(IDENT_KEY, JSON.stringify(next));
1409
+ browserIdentity = next;
1410
+ return true;
1411
+ } catch {
1412
+ return false;
1413
+ }
1414
+ }
1415
+
1416
+ function currentIdentity() {
1417
+ return browserIdentity.current;
1418
+ }
1419
+
1420
+ function identity() {
1421
+ return currentIdentity()?.agent_id || "";
1422
+ }
1423
+
1345
1424
  // Memberships whose soft-leave failed (e.g. server briefly down during
1346
1425
  // a rename): tracked so later joins can retry the leave instead of the
1347
1426
  // ghost becoming permanently unaddressable.
1348
1427
  function loadGhosts() {
1349
- // Type-check, not just parse-check: valid-but-wrong-typed JSON (a
1350
- // bare number, an object) written by devtools or version skew would
1351
- // otherwise throw deep inside consumers as an unhandled rejection.
1428
+ // Reject valid JSON with the wrong shape before consumers assume an array.
1352
1429
  try {
1353
1430
  const v = JSON.parse(localStorage.getItem(GHOST_KEY));
1354
1431
  return Array.isArray(v)
1355
- ? v.filter(
1356
- (g) =>
1357
- g && typeof g.room === "number" && typeof g.name === "string",
1358
- )
1432
+ ? v
1433
+ .filter(
1434
+ (g) =>
1435
+ g &&
1436
+ Number.isSafeInteger(g.room) &&
1437
+ g.room > 0 &&
1438
+ typeof g.agent_id === "string",
1439
+ )
1440
+ .map((g) => ({
1441
+ room: g.room,
1442
+ agent_id: g.agent_id,
1443
+ explicit_leave: g.explicit_leave === true,
1444
+ }))
1359
1445
  : [];
1360
1446
  } catch {
1361
1447
  return [];
@@ -1364,53 +1450,101 @@
1364
1450
  function saveGhosts(g) {
1365
1451
  try {
1366
1452
  localStorage.setItem(GHOST_KEY, JSON.stringify(g));
1367
- } catch {}
1453
+ return true;
1454
+ } catch {
1455
+ return false;
1456
+ }
1457
+ }
1458
+ // Always filter the current ledger: callers may have awaited network
1459
+ // work since they last loaded it, while another tab added an entry.
1460
+ function removeGhost(room, agentId) {
1461
+ return saveGhosts(
1462
+ loadGhosts().filter(
1463
+ (g) => !(g.room === room && g.agent_id === agentId),
1464
+ ),
1465
+ );
1368
1466
  }
1369
1467
  let ghostRetryActive = false;
1370
1468
  async function retryGhosts() {
1371
1469
  if (ghostRetryActive) return; // overlapping sweeps would race the ledger
1372
1470
  ghostRetryActive = true;
1471
+ let currentMembershipChanged = false;
1373
1472
  try {
1374
1473
  // The membership lock is held ACROSS each leave's round trip: the
1375
1474
  // stale-check below is only race-free if no other tab can re-adopt
1376
- // the (room, name) while the leave is in flight.
1475
+ // the (room, agent id) while the leave is in flight.
1377
1476
  await withMembershipLock(async () => {
1477
+ reloadIdentityState();
1478
+ // Pending allocation recovery must establish the new current
1479
+ // identity and its cleanup ledger before any old leave can run.
1480
+ if (browserIdentity.pending_allocation) return;
1378
1481
  for (const g of loadGhosts()) {
1379
- // A ghost whose (room, name) is the CURRENT mapping again is not
1380
- // stale: the user re-adopted the name and rejoined, and leaving
1381
- // now would evict that live membership seconds after it was
1382
- // created. Purge the entry instead of retrying it.
1383
- if (loadJoined()[String(g.room)] === g.name) {
1384
- saveGhosts(
1385
- loadGhosts().filter(
1386
- (x) => !(x.room === g.room && x.name === g.name),
1387
- ),
1388
- );
1482
+ // A cleanup ghost whose (room, agent id) is the CURRENT mapping
1483
+ // again is not stale: the user re-adopted the identity and
1484
+ // rejoined. An explicit-leave ghost is different: the matching
1485
+ // map can be the crash point between recording the leave intent
1486
+ // and deleting that map entry, so it must still be processed.
1487
+ if (
1488
+ !g.explicit_leave &&
1489
+ loadJoined()[String(g.room)] === g.agent_id
1490
+ ) {
1491
+ if (!removeGhost(g.room, g.agent_id)) return;
1389
1492
  continue;
1390
1493
  }
1391
1494
  try {
1392
- await postJson("/api/leave", { room: g.room, name: g.name });
1393
- } catch {
1394
- continue; // still unreachable; keep the entry for the next sweep
1495
+ await postJson("/api/leave", {
1496
+ room: g.room,
1497
+ agent_id: g.agent_id,
1498
+ });
1499
+ } catch (error) {
1500
+ // Generated fields cannot become valid through retry. A 400
1501
+ // means the room or human no longer exists, or stored state is
1502
+ // malformed, so finish local cleanup instead of keeping an
1503
+ // immortal request. Transport and server failures remain
1504
+ // retryable.
1505
+ if (error?.httpStatus !== 400) continue;
1395
1506
  }
1396
- // Remove exactly this entry from the CURRENT ledger: a wholesale
1397
- // save of a pre-loop snapshot would erase entries recorded while
1398
- // these retries were in flight.
1399
- saveGhosts(
1400
- loadGhosts().filter(
1401
- (x) => !(x.room === g.room && x.name === g.name),
1402
- ),
1403
- );
1507
+ if (g.explicit_leave) {
1508
+ const joined = loadJoined();
1509
+ const key = String(g.room);
1510
+ if (joined[key] === g.agent_id) {
1511
+ delete joined[key];
1512
+ if (!saveJoined(joined)) {
1513
+ return;
1514
+ }
1515
+ if (
1516
+ state.current?.id === g.room &&
1517
+ identity() === g.agent_id
1518
+ ) {
1519
+ currentMembershipChanged = true;
1520
+ }
1521
+ }
1522
+ }
1523
+ if (!removeGhost(g.room, g.agent_id)) return;
1404
1524
  }
1405
1525
  });
1526
+ // localStorage notifies only OTHER tabs. When this tab recovered the
1527
+ // leave, refresh its own UI and invalidate work captured while the
1528
+ // membership still appeared present.
1529
+ if (
1530
+ currentMembershipChanged &&
1531
+ state.current &&
1532
+ !joinedHere()
1533
+ ) {
1534
+ state.roomEpoch += 1;
1535
+ state.lastReadSent = 0;
1536
+ state.marker = -1;
1537
+ state.markerIdent = null;
1538
+ state.gap = false;
1539
+ clearReply();
1540
+ renderComposer();
1541
+ computeGap();
1542
+ }
1406
1543
  } finally {
1407
1544
  ghostRetryActive = false;
1408
1545
  }
1409
1546
  }
1410
1547
 
1411
- function identity() {
1412
- return localStorage.getItem(IDENT_KEY) || "";
1413
- }
1414
1548
  function loadJoined() {
1415
1549
  try {
1416
1550
  const v = JSON.parse(localStorage.getItem(JOINED_KEY));
@@ -1457,7 +1591,11 @@
1457
1591
  try {
1458
1592
  const r = await fetch(url, { ...options, signal: controller.signal });
1459
1593
  const data = await r.json().catch(() => ({}));
1460
- if (!r.ok) throw new Error(data.error || "HTTP " + r.status);
1594
+ if (!r.ok) {
1595
+ const error = new Error(data.error || "HTTP " + r.status);
1596
+ error.httpStatus = r.status;
1597
+ throw error;
1598
+ }
1461
1599
  return data;
1462
1600
  } finally {
1463
1601
  clearTimeout(timeout);
@@ -1474,18 +1612,68 @@
1474
1612
 
1475
1613
  // ---- utilities ------------------------------------------------------
1476
1614
 
1615
+ function parseSeen(raw) {
1616
+ try {
1617
+ const value = JSON.parse(raw);
1618
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1619
+ return {};
1620
+ }
1621
+ const out = {};
1622
+ for (const [room, seq] of Object.entries(value)) {
1623
+ const roomId = Number(room);
1624
+ if (
1625
+ Number.isSafeInteger(roomId) &&
1626
+ roomId > 0 &&
1627
+ String(roomId) === room &&
1628
+ Number.isSafeInteger(seq) &&
1629
+ seq > 0
1630
+ ) {
1631
+ out[roomId] = seq;
1632
+ }
1633
+ }
1634
+ return out;
1635
+ } catch {
1636
+ return {};
1637
+ }
1638
+ }
1639
+
1477
1640
  function loadSeen() {
1478
1641
  try {
1479
- const v = JSON.parse(localStorage.getItem(SEEN_KEY));
1480
- return v && typeof v === "object" && !Array.isArray(v) ? v : {};
1642
+ return parseSeen(localStorage.getItem(SEEN_KEY));
1481
1643
  } catch {
1482
1644
  return {};
1483
1645
  }
1484
1646
  }
1485
- function saveSeen() {
1647
+
1648
+ function mergeSeen(target, roomId, seq) {
1649
+ if (seq <= (target[roomId] ?? 0)) return false;
1650
+ target[roomId] = seq;
1651
+ return true;
1652
+ }
1653
+
1654
+ function persistSeen(seen) {
1486
1655
  try {
1487
- localStorage.setItem(SEEN_KEY, JSON.stringify(state.seen));
1488
- } catch {}
1656
+ localStorage.setItem(SEEN_KEY, JSON.stringify(seen));
1657
+ } catch {
1658
+ // This tab stays optimistic; another tab or reload may show the dot.
1659
+ }
1660
+ }
1661
+
1662
+ async function saveSeen(roomId, seq) {
1663
+ await navigator.locks.request(SEEN_LOCK, () => {
1664
+ const seen = loadSeen();
1665
+ if (!mergeSeen(seen, roomId, seq)) return;
1666
+ persistSeen(seen);
1667
+ });
1668
+ }
1669
+
1670
+ async function forgetSeen(roomId) {
1671
+ delete state.seen[roomId];
1672
+ await navigator.locks.request(SEEN_LOCK, () => {
1673
+ const seen = loadSeen();
1674
+ delete seen[roomId];
1675
+ persistSeen(seen);
1676
+ });
1489
1677
  }
1490
1678
 
1491
1679
  // Stable per-agent identity in GRAYSCALE: the FNV-1a hash picks a
@@ -1514,10 +1702,21 @@
1514
1702
  return new Date(at.replace(" ", "T") + "Z");
1515
1703
  }
1516
1704
 
1705
+ const TIME_FORMATTER = new Intl.DateTimeFormat([], {
1706
+ hour: "2-digit",
1707
+ minute: "2-digit",
1708
+ second: "2-digit",
1709
+ hour12: false,
1710
+ });
1711
+ const DAY_FORMATTER = new Intl.DateTimeFormat([], {
1712
+ weekday: "short",
1713
+ year: "numeric",
1714
+ month: "short",
1715
+ day: "numeric",
1716
+ });
1717
+
1517
1718
  function fmtTime(d) {
1518
- return d.toLocaleTimeString([], {
1519
- hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
1520
- });
1719
+ return TIME_FORMATTER.format(d);
1521
1720
  }
1522
1721
 
1523
1722
  function fmtDay(d) {
@@ -1527,9 +1726,7 @@
1527
1726
  const days = Math.round((today - that) / 86400000);
1528
1727
  if (days === 0) return "Today";
1529
1728
  if (days === 1) return "Yesterday";
1530
- return d.toLocaleDateString([], {
1531
- weekday: "short", year: "numeric", month: "short", day: "numeric",
1532
- });
1729
+ return DAY_FORMATTER.format(d);
1533
1730
  }
1534
1731
 
1535
1732
  function fmtRel(at) {
@@ -1678,12 +1875,11 @@
1678
1875
  card.append(desc);
1679
1876
  }
1680
1877
 
1878
+ // Use seq because timestamps collide within one second. Compare by
1879
+ // ordering because the rendered tail can be ahead of the cached list.
1681
1880
  const isOpen = state.current && state.current.id === room.id;
1682
- if (
1683
- !isOpen &&
1684
- room.last_activity &&
1685
- state.seen[room.id] !== room.last_activity
1686
- ) {
1881
+ const seenSeq = state.seen[room.id];
1882
+ if (!isOpen && room.last_seq && !(seenSeq >= room.last_seq)) {
1687
1883
  const dot = document.createElement("span");
1688
1884
  dot.className = "activity";
1689
1885
  dot.title = "new activity since you last viewed this room";
@@ -1709,7 +1905,9 @@
1709
1905
  } catch {
1710
1906
  return; // server unreachable: keep the room, retry next refresh
1711
1907
  }
1712
- if (!gone || !sameView(room, epoch)) return;
1908
+ if (!gone) return;
1909
+ await forgetDeletedRoom(room.id);
1910
+ if (!sameView(room, epoch)) return;
1713
1911
  state.current = null;
1714
1912
  state.searchMode = false;
1715
1913
  el.streamWrap.hidden = true;
@@ -1748,8 +1946,7 @@
1748
1946
  pinned,
1749
1947
  description: fullDesc ?? cur.description,
1750
1948
  };
1751
- state.seen[cur.id] = cur.last_activity;
1752
- saveSeen();
1949
+ // Refreshing metadata does not prove the stream was rendered.
1753
1950
  renderRoomHead();
1754
1951
  } else {
1755
1952
  // The open room is ABSENT from the (capped, most-recently-active)
@@ -1766,7 +1963,7 @@
1766
1963
  // Recovered from "Loading..."/error with no room selected.
1767
1964
  showPlaceholder(
1768
1965
  "Pick a room.",
1769
- "Rooms are listed on the left; the amber dot marks activity you have not seen.",
1966
+ "Rooms are listed on the left; the activity dot marks messages you have not seen.",
1770
1967
  );
1771
1968
  }
1772
1969
  renderRooms();
@@ -1903,27 +2100,29 @@
1903
2100
  container.append(wrap);
1904
2101
  }
1905
2102
 
1906
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1907
-
1908
- // Scroll a message into view. If it is outside the bounded DOM window,
1909
- // fetch a page starting at that seq directly; never walk/rebuild
1910
- // thousands of intermediate pages.
2103
+ // Fetch one anchored page for an off-window target, never intermediate
2104
+ // pages. Leave the current view alone when the target was pruned.
1911
2105
  async function jumpToSeq(seq) {
1912
2106
  const room = state.current;
1913
2107
  const epoch = state.roomEpoch;
1914
2108
  if (!room || !Number.isSafeInteger(seq) || seq < 1) return;
1915
2109
  let target = el.msgs.querySelector(`[data-seq="${seq}"]`);
1916
2110
  if (!target) {
1917
- // A search exit may have one ordinary tail refresh in flight. Wait a
1918
- // short bounded period for its lock, then give up rather than overlap
1919
- // mutations of the same DOM/state window.
1920
- for (let i = 0; i < 20 && state.loadingMsgs; i++) await sleep(100);
1921
- if (!sameView(room, epoch) || state.loadingMsgs) return;
1922
- state.loadingMsgs = true;
2111
+ // Wait for the real load lifetime, not an unrelated fixed delay.
2112
+ while (state.msgsLoad) await state.msgsLoad;
2113
+ if (!sameView(room, epoch)) return;
2114
+ target = el.msgs.querySelector(`[data-seq="${seq}"]`);
2115
+ }
2116
+ if (!target) {
2117
+ let releaseLoad;
2118
+ state.msgsLoad = new Promise((r) => (releaseLoad = r));
1923
2119
  try {
1924
2120
  const anchor = seq === 1 ? "before=2&limit=1" : `after=${seq - 1}&limit=${PAGE}`;
1925
2121
  const data = await fetchJson(`/api/messages?room=${room.id}&${anchor}`);
1926
2122
  if (!sameView(room, epoch) || data.error || !data.messages) return;
2123
+ // Both anchors put an existing target at row zero. Check before
2124
+ // freezing or replacing the live stream because targets can prune.
2125
+ if (data.messages[0]?.seq !== seq) return;
1927
2126
  state.msgs = data.messages;
1928
2127
  state.historicalWindow = true;
1929
2128
  state.follow = false;
@@ -1938,12 +2137,12 @@
1938
2137
  noteFail();
1939
2138
  return;
1940
2139
  } finally {
1941
- state.loadingMsgs = false;
2140
+ state.msgsLoad = null;
2141
+ releaseLoad();
1942
2142
  }
1943
2143
  target = el.msgs.querySelector(`[data-seq="${seq}"]`);
1944
2144
  }
1945
- if (!sameView(room, epoch)) return;
1946
- if (!target) return;
2145
+ if (!sameView(room, epoch) || !target) return;
1947
2146
  target.scrollIntoView({ block: "center" });
1948
2147
  target.classList.remove("flash");
1949
2148
  void target.offsetWidth; // restart the animation
@@ -1986,11 +2185,13 @@
1986
2185
  const who = document.createElement("span");
1987
2186
  who.className = "who";
1988
2187
  who.textContent = label(m);
2188
+ who.title = m.from;
1989
2189
  head.append(who);
1990
2190
  if (label(m) !== m.from) {
1991
2191
  const id = document.createElement("span");
1992
2192
  id.className = "who-id";
1993
2193
  id.textContent = m.from;
2194
+ id.title = m.from;
1994
2195
  head.append(id);
1995
2196
  }
1996
2197
  if (identity() && m.from === identity()) {
@@ -2166,7 +2367,7 @@
2166
2367
  const epoch = state.roomEpoch;
2167
2368
  if (!room || state.searchMode) return;
2168
2369
  if (state.historicalWindow && !initial) return;
2169
- if (state.loadingMsgs) {
2370
+ if (state.msgsLoad) {
2170
2371
  if (initial) state.pendingInitial = true;
2171
2372
  return;
2172
2373
  }
@@ -2174,7 +2375,8 @@
2174
2375
  initial = true;
2175
2376
  state.pendingInitial = false;
2176
2377
  }
2177
- state.loadingMsgs = true;
2378
+ let releaseLoad;
2379
+ state.msgsLoad = new Promise((r) => (releaseLoad = r));
2178
2380
  try {
2179
2381
  const last = state.msgs.length
2180
2382
  ? state.msgs[state.msgs.length - 1].seq
@@ -2220,33 +2422,40 @@
2220
2422
  } else {
2221
2423
  hidePlaceholder();
2222
2424
  }
2223
- requestAnimationFrame(scrollBottom);
2425
+ requestAnimationFrame(() => {
2426
+ if (!sameView(room, epoch)) return;
2427
+ scrollBottom();
2428
+ noteSeen();
2429
+ maybeMarkRead();
2430
+ });
2224
2431
  } else if (rows.length) {
2225
2432
  hidePlaceholder();
2226
2433
  const stick = state.follow || nearBottom();
2227
2434
  appendMsgs(rows, true);
2228
2435
  if (stick) {
2229
- requestAnimationFrame(scrollBottom);
2436
+ requestAnimationFrame(() => {
2437
+ if (!sameView(room, epoch)) return;
2438
+ scrollBottom();
2439
+ noteSeen();
2440
+ maybeMarkRead();
2441
+ });
2230
2442
  } else {
2231
2443
  state.newCount += rows.length;
2232
2444
  updateJump();
2233
2445
  }
2234
2446
  }
2235
- // A transient /api/me failure must not freeze this visit's read
2236
- // receipt forever. Retry only while the baseline is unknown;
2237
- // fetchMarker supplies an in-flight guard and five-second backoff.
2447
+ // Retry marker bootstrap only while it is unknown for this identity;
2448
+ // fetchMarker owns the in-flight guard and five-second backoff.
2238
2449
  if (state.marker < 0 || state.markerIdent !== identity()) fetchMarker();
2239
- maybeMarkRead();
2240
2450
  } catch {
2241
2451
  if (initial) state.pendingInitial = true; // retry with initial semantics
2242
2452
  noteFail();
2243
2453
  } finally {
2244
- state.loadingMsgs = false;
2454
+ state.msgsLoad = null;
2455
+ releaseLoad();
2245
2456
  }
2246
2457
  }
2247
2458
 
2248
- // Returns true when it prepended messages (used by jumpToSeq to detect
2249
- // progress); false on busy, error, or exhausted history.
2250
2459
  async function loadOlder() {
2251
2460
  const room = state.current;
2252
2461
  const epoch = state.roomEpoch;
@@ -2254,12 +2463,12 @@
2254
2463
  if (
2255
2464
  !room ||
2256
2465
  !first ||
2257
- state.loadingMsgs ||
2466
+ state.msgsLoad ||
2258
2467
  state.msgs.length >= MAX_RENDERED_MESSAGES
2259
- ) return false;
2260
- state.loadingMsgs = true;
2468
+ ) return;
2469
+ let releaseLoad;
2470
+ state.msgsLoad = new Promise((r) => (releaseLoad = r));
2261
2471
  el.older.disabled = true;
2262
- let changed = false;
2263
2472
  try {
2264
2473
  const pageLimit = Math.min(
2265
2474
  PAGE,
@@ -2268,7 +2477,7 @@
2268
2477
  const data = await fetchJson(
2269
2478
  `/api/messages?room=${room.id}&before=${first.seq}&limit=${pageLimit}`,
2270
2479
  );
2271
- if (!sameView(room, epoch) || data.error || !data.messages) return false;
2480
+ if (!sameView(room, epoch) || data.error || !data.messages) return;
2272
2481
  if (data.messages.length) {
2273
2482
  const prevHeight = el.stream.scrollHeight;
2274
2483
  const prevTop = el.stream.scrollTop;
@@ -2276,13 +2485,13 @@
2276
2485
  rebuildStream();
2277
2486
  el.stream.scrollTop =
2278
2487
  el.stream.scrollHeight - prevHeight + prevTop;
2279
- changed = true;
2280
2488
  }
2281
- updateOlderButton();
2282
- if (!data.messages.length) {
2283
- el.older.hidden = true;
2284
- state.noOlderHistory = true; // floor reached: the rest was pruned
2489
+ // A short, untrimmed page proves the history floor immediately;
2490
+ // avoid one more request that must return empty.
2491
+ if (data.messages.length < pageLimit && !data.trimmed) {
2492
+ state.noOlderHistory = true;
2285
2493
  }
2494
+ updateOlderButton();
2286
2495
  const hadGap = state.gap;
2287
2496
  computeGap();
2288
2497
  if (hadGap && !state.gap) maybeMarkRead(); // gap closed by paging
@@ -2290,9 +2499,9 @@
2290
2499
  noteFail();
2291
2500
  } finally {
2292
2501
  el.older.disabled = false;
2293
- state.loadingMsgs = false;
2502
+ state.msgsLoad = null;
2503
+ releaseLoad();
2294
2504
  }
2295
- return changed;
2296
2505
  }
2297
2506
 
2298
2507
  // ---- search mode --------------------------------------------------------
@@ -2307,11 +2516,13 @@
2307
2516
  const who = document.createElement("span");
2308
2517
  who.className = "who";
2309
2518
  who.textContent = label(m);
2519
+ who.title = m.from;
2310
2520
  head.append(who);
2311
2521
  if (label(m) !== m.from) {
2312
2522
  const id = document.createElement("span");
2313
2523
  id.className = "who-id";
2314
2524
  id.textContent = m.from;
2525
+ id.title = m.from;
2315
2526
  head.append(id);
2316
2527
  }
2317
2528
  const meta = document.createElement("span");
@@ -2430,19 +2641,159 @@
2430
2641
 
2431
2642
  // ---- participation ------------------------------------------------------
2432
2643
 
2644
+ function recordedMemberships(agentId) {
2645
+ if (!agentId) return [];
2646
+ return Object.entries(loadJoined())
2647
+ .filter(([, storedAgentId]) => storedAgentId === agentId)
2648
+ .map(([room]) => ({ room: Number(room), agent_id: agentId }))
2649
+ .filter((m) => Number.isSafeInteger(m.room) && m.room > 0);
2650
+ }
2651
+
2652
+ async function completePendingAllocationLocked(pending) {
2653
+ let j;
2654
+ try {
2655
+ j = await postJson("/api/join", {
2656
+ room: pending.room,
2657
+ base_name: pending.base_name,
2658
+ operation_id: pending.operation_id,
2659
+ });
2660
+ } catch (error) {
2661
+ // A delivered 4xx is a final rejection, not an uncertain response:
2662
+ // the server's allocation paths commit nothing on these statuses.
2663
+ // Drop only that request so the user can correct it. Transport
2664
+ // failures and 5xx responses keep the UUID for safe replay.
2665
+ if (
2666
+ error?.httpStatus >= 400 &&
2667
+ error.httpStatus < 500
2668
+ ) {
2669
+ saveIdentityState({
2670
+ version: IDENTITY_VERSION,
2671
+ current: browserIdentity.current,
2672
+ pending_allocation: null,
2673
+ });
2674
+ }
2675
+ throw error;
2676
+ }
2677
+ if (
2678
+ typeof j.agent_id !== "string" ||
2679
+ j.base_name !== pending.base_name ||
2680
+ j.room_id !== pending.room ||
2681
+ typeof j.joined !== "boolean"
2682
+ ) {
2683
+ throw new Error("allocation response did not match the pending request");
2684
+ }
2685
+
2686
+ const oldMemberships = pending.previous_memberships.filter(
2687
+ (m) => m.agent_id !== j.agent_id,
2688
+ );
2689
+ if (oldMemberships.length) {
2690
+ const ghosts = loadGhosts();
2691
+ for (const old of oldMemberships) {
2692
+ if (
2693
+ !ghosts.some(
2694
+ (g) =>
2695
+ g.room === old.room && g.agent_id === old.agent_id,
2696
+ )
2697
+ ) {
2698
+ ghosts.push(old);
2699
+ }
2700
+ }
2701
+ if (!saveGhosts(ghosts)) {
2702
+ throw new Error(
2703
+ "the new identity was allocated, but cleanup state could not be saved; free browser storage and retry",
2704
+ );
2705
+ }
2706
+ }
2707
+
2708
+ // Save the new membership map before switching the atomic identity
2709
+ // object. If this write fails, pending_allocation remains durable and
2710
+ // reload can replay the same server allocation.
2711
+ const joined = loadJoined();
2712
+ for (const old of oldMemberships) {
2713
+ const key = String(old.room);
2714
+ if (joined[key] === old.agent_id) delete joined[key];
2715
+ }
2716
+ if (j.joined) joined[String(pending.room)] = j.agent_id;
2717
+ if (!saveJoined(joined)) {
2718
+ throw new Error(
2719
+ "the new identity was allocated, but room state could not be saved; free browser storage and retry",
2720
+ );
2721
+ }
2722
+
2723
+ const nextIdentity = {
2724
+ version: IDENTITY_VERSION,
2725
+ current: {
2726
+ base_name: j.base_name,
2727
+ agent_id: j.agent_id,
2728
+ },
2729
+ pending_allocation: null,
2730
+ };
2731
+ if (!saveIdentityState(nextIdentity)) {
2732
+ throw new Error(
2733
+ "the new identity was allocated, but identity state could not be saved; free browser storage and retry",
2734
+ );
2735
+ }
2736
+
2737
+ // Only the durable ghost ledger and switched identity make it safe to
2738
+ // leave old memberships. A failed leave stays in the ledger.
2739
+ for (const old of oldMemberships) {
2740
+ try {
2741
+ await postJson("/api/leave", {
2742
+ room: old.room,
2743
+ agent_id: old.agent_id,
2744
+ });
2745
+ } catch {
2746
+ continue;
2747
+ }
2748
+ removeGhost(old.room, old.agent_id);
2749
+ }
2750
+ return j;
2751
+ }
2752
+
2753
+ async function recoverPendingAllocationLocked() {
2754
+ const pending = browserIdentity.pending_allocation;
2755
+ return pending
2756
+ ? completePendingAllocationLocked(pending)
2757
+ : null;
2758
+ }
2759
+
2760
+ async function recoverPendingAllocation() {
2761
+ try {
2762
+ await withMembershipLock(async () => {
2763
+ reloadIdentityState();
2764
+ await recoverPendingAllocationLocked();
2765
+ });
2766
+ return true;
2767
+ } catch {
2768
+ reloadIdentityState();
2769
+ return browserIdentity.pending_allocation === null;
2770
+ }
2771
+ }
2772
+
2433
2773
  function renderComposer() {
2434
2774
  el.composer.hidden = !state.current;
2435
2775
  if (!state.current) return;
2436
2776
  const joined = joinedHere();
2437
- el.composeJoin.hidden = joined;
2438
- el.composeBox.hidden = !joined;
2777
+ const current = currentIdentity();
2778
+ const allocationMode = !current || state.changingIdentity;
2779
+ el.composeJoin.hidden = joined && !state.changingIdentity;
2780
+ el.composeBox.hidden = !joined || state.changingIdentity;
2439
2781
  el.joinError.textContent = "";
2440
2782
  el.composeError.textContent = "";
2441
- if (joined) {
2783
+ if (joined && !state.changingIdentity) {
2442
2784
  el.composeIdent.textContent = identity();
2785
+ el.composeIdent.title = identity();
2443
2786
  } else {
2444
- if (!el.joinName.value) el.joinName.value = identity();
2445
- el.joinBtn.textContent = "Join " + state.current.name;
2787
+ el.joinName.hidden = !allocationMode;
2788
+ el.joinChangeBtn.hidden = allocationMode || !current;
2789
+ if (allocationMode) {
2790
+ if (!el.joinName.value && current) {
2791
+ el.joinName.value = current.base_name;
2792
+ }
2793
+ el.joinBtn.textContent = current ? "Change name" : "Join room";
2794
+ } else {
2795
+ el.joinBtn.textContent = "Join as " + current.agent_id;
2796
+ }
2446
2797
  }
2447
2798
  renderReplyTarget();
2448
2799
  }
@@ -2476,109 +2827,140 @@
2476
2827
  }
2477
2828
 
2478
2829
  async function joinCurrent() {
2479
- // The Enter key in the name field bypasses the button's disabled
2480
- // state; gate on it here so two rename loops can never interleave
2481
- // over the shared localStorage maps.
2482
2830
  if (el.joinBtn.disabled) return;
2483
2831
  const room = state.current;
2484
- const name = el.joinName.value.trim().replace(/^@/, "");
2485
- if (!room || !name) {
2486
- el.joinError.textContent = "pick a name first";
2487
- el.joinName.focus();
2488
- return;
2489
- }
2832
+ if (!room) return;
2833
+ const requestedBase = el.joinName.value;
2834
+ const explicitChange = state.changingIdentity;
2835
+ const changingFrom = state.changingFrom;
2836
+ const previousUiIdentity = identity();
2490
2837
  el.joinBtn.disabled = true;
2491
2838
  const epoch = state.roomEpoch;
2492
2839
  try {
2493
- // The whole join/rename sequence (server calls INCLUDED) runs under
2494
- // the cross-tab membership lock: its localStorage read-modify-write
2495
- // steps are only merge-safe against writes that happen strictly
2496
- // before or after, not interleaved between read and save.
2497
- const { j, prevIdentity, saved } = await withMembershipLock(async () => {
2498
- const prevIdentity = identity();
2499
- // Join FIRST: the server validates the new name, and an invalid
2500
- // rename must not have already torn down the old memberships.
2501
- const j = await postJson("/api/join", { room: room.id, name });
2502
- try {
2503
- localStorage.setItem(IDENT_KEY, name);
2504
- } catch {}
2505
- // Renaming: soft-leave EVERY membership recorded under another name
2506
- // (else the abandoned identity lingers as a present ghost member,
2507
- // blocking prune's unread-refusal). Failed leaves go to the ghost
2508
- // ledger for retry on later joins, INCLUDING the current room's
2509
- // old name, which the map overwrite below would otherwise orphan.
2510
- const joinedMap = loadJoined();
2511
- const newGhosts = [];
2512
- const removed = [];
2513
- for (const [rid, storedName] of Object.entries(joinedMap)) {
2514
- if (!storedName || storedName === name) continue;
2515
- try {
2516
- await postJson("/api/leave", { room: Number(rid), name: storedName });
2517
- } catch {
2518
- newGhosts.push({ room: Number(rid), name: storedName });
2840
+ const { j, saved, newIdentity } = await withMembershipLock(async () => {
2841
+ // Web Locks serialize same-origin tabs, but the decision must use
2842
+ // the state read INSIDE the lock. Two initially empty tabs should
2843
+ // share the first allocation, not mint one identity each.
2844
+ reloadIdentityState();
2845
+ const recovered = await recoverPendingAllocationLocked();
2846
+ // A recovery satisfies this click only if it joined the visible
2847
+ // room; a foreign-room or joined:false replay falls through below.
2848
+ if (
2849
+ recovered &&
2850
+ recovered.room_id === room.id &&
2851
+ recovered.joined === true
2852
+ ) {
2853
+ return {
2854
+ j: recovered,
2855
+ saved: true,
2856
+ newIdentity: identity(),
2857
+ };
2519
2858
  }
2520
- removed.push([rid, storedName]);
2521
- }
2522
- // Merge the new failures into the CURRENT ledger at save time: a
2523
- // snapshot-wholesale save would erase entries another tab (or a
2524
- // concurrent retry sweep) touched since this function started.
2525
- if (newGhosts.length) {
2526
- const ledger = loadGhosts();
2527
- for (const g of newGhosts) {
2528
- if (!ledger.some((x) => x.room === g.room && x.name === g.name)) {
2529
- ledger.push(g);
2859
+ const current = currentIdentity();
2860
+
2861
+ // An explicit rename was chosen for one visible identity. If a
2862
+ // different tab replaced it while this form was open, do not turn
2863
+ // the stale click into an unintended second rename.
2864
+ if (
2865
+ explicitChange &&
2866
+ changingFrom &&
2867
+ current?.agent_id !== changingFrom
2868
+ ) {
2869
+ throw new Error(
2870
+ "the identity changed in another tab; review the current name and try again",
2871
+ );
2872
+ }
2873
+
2874
+ let j;
2875
+ let saved = true;
2876
+ if (
2877
+ current &&
2878
+ (!explicitChange || requestedBase === current.base_name)
2879
+ ) {
2880
+ // Journal an uncertain rejoin before the server write. Until the
2881
+ // joined map is durable, recovery may safely undo it with leave.
2882
+ // After the map is saved, this non-explicit ghost is discarded
2883
+ // without touching the live membership.
2884
+ const ghosts = loadGhosts();
2885
+ const existing = ghosts.find(
2886
+ (g) =>
2887
+ g.room === room.id &&
2888
+ g.agent_id === current.agent_id,
2889
+ );
2890
+ if (existing) {
2891
+ existing.explicit_leave = false;
2892
+ } else {
2893
+ ghosts.push({
2894
+ room: room.id,
2895
+ agent_id: current.agent_id,
2896
+ explicit_leave: false,
2897
+ });
2530
2898
  }
2899
+ if (!saveGhosts(ghosts)) {
2900
+ throw new Error(
2901
+ "browser storage is unavailable; the room was not joined",
2902
+ );
2903
+ }
2904
+
2905
+ j = await postJson("/api/join", {
2906
+ room: room.id,
2907
+ agent_id: current.agent_id,
2908
+ });
2909
+ if (j.agent_id !== current.agent_id || j.room_id !== room.id) {
2910
+ throw new Error(
2911
+ "canonical join response did not match the requested identity",
2912
+ );
2913
+ }
2914
+ const joined = loadJoined();
2915
+ joined[String(room.id)] = current.agent_id;
2916
+ saved = saveJoined(joined);
2917
+ if (saved) {
2918
+ // Safe if this write fails: retryGhosts sees the matching map
2919
+ // and removes the non-explicit record without leaving.
2920
+ removeGhost(room.id, current.agent_id);
2921
+ }
2922
+ } else {
2923
+ if (!requestedBase) {
2924
+ throw new Error("enter a base name first");
2925
+ }
2926
+ const previousAgentId = current?.agent_id || null;
2927
+ const pending = {
2928
+ operation_id: crypto.randomUUID(),
2929
+ room: room.id,
2930
+ base_name: requestedBase,
2931
+ previous_agent_id: previousAgentId,
2932
+ previous_memberships:
2933
+ recordedMemberships(previousAgentId),
2934
+ };
2935
+ if (
2936
+ !saveIdentityState({
2937
+ version: IDENTITY_VERSION,
2938
+ current,
2939
+ pending_allocation: pending,
2940
+ })
2941
+ ) {
2942
+ throw new Error(
2943
+ "browser storage is unavailable; no identity was allocated",
2944
+ );
2945
+ }
2946
+ j = await completePendingAllocationLocked(pending);
2531
2947
  }
2532
- saveGhosts(ledger);
2533
- }
2534
- // Same merge discipline for the joined map: re-read it and delete
2535
- // only the entries we actually left AND that still hold the value
2536
- // we left under (defense in depth under the lock).
2537
- const freshMap = loadJoined();
2538
- for (const [rid, storedName] of removed) {
2539
- if (freshMap[rid] === storedName) delete freshMap[rid];
2540
- }
2541
- freshMap[String(room.id)] = name;
2542
- const saved = saveJoined(freshMap);
2543
- // Re-adopting a previously-ghosted (room, name): the mapping above
2544
- // makes that pair live again, so drop any stale ledger entry for it
2545
- // before the next sweep can leave the room we just joined.
2546
- const ledgerNow = loadGhosts();
2547
- const keptGhosts = ledgerNow.filter(
2548
- (x) => !(x.room === room.id && x.name === name),
2549
- );
2550
- if (keptGhosts.length !== ledgerNow.length) saveGhosts(keptGhosts);
2551
- return { j, prevIdentity, saved };
2948
+ return { j, saved, newIdentity: identity() };
2552
2949
  });
2553
- // Fire-and-forget AFTER the lock releases: retryGhosts takes the
2554
- // same (non-reentrant) lock, so awaiting it here would deadlock.
2950
+ state.changingIdentity = false;
2951
+ state.changingFrom = null;
2555
2952
  retryGhosts();
2556
- // Local storage write failed (e.g. quota): the server join committed
2557
- // but the client cannot record it, so joinedHere() will read false
2558
- // and the composer would silently show "not joined". Surface it
2559
- // instead of leaving the user confused.
2953
+ renderComposer();
2560
2954
  if (!saved) {
2561
2955
  el.joinError.textContent =
2562
- "joined on the server, but this browser could not save it locally (storage full?); free space and rejoin";
2956
+ "joined on the server, but this browser could not save the room membership; free browser storage and retry";
2563
2957
  }
2564
- // The composer reflects the GLOBAL identity for whatever room is
2565
- // shown now; re-render it even after a room switch so a stale
2566
- // successful rename cannot leave it showing the old identity.
2567
- renderComposer();
2568
- // A rename invalidates identity-derived DECORATION on already-
2569
- // rendered rows ("you" badges, directed-at-me styling); rebuild
2570
- // whatever stream is currently shown from its cached rows, or defer
2571
- // to exitSearch when the stream is hidden behind search results.
2572
- if (prevIdentity !== name) {
2958
+ if (previousUiIdentity !== newIdentity) {
2573
2959
  if (state.searchMode) state.needsRebuild = true;
2574
2960
  else rebuildStream();
2575
2961
  }
2576
- // A delayed response must not mutate another visit's marker/UI --
2577
- // but a RENAME was global, so the room shown now still carries the
2578
- // old identity's marker state: reset it and re-baseline (fetchMarker
2579
- // no-ops unless the new identity is joined there).
2580
2962
  if (!sameView(room, epoch)) {
2581
- if (prevIdentity !== name) {
2963
+ if (previousUiIdentity !== newIdentity) {
2582
2964
  state.lastReadSent = 0;
2583
2965
  state.marker = -1;
2584
2966
  state.markerIdent = null;
@@ -2587,18 +2969,32 @@
2587
2969
  }
2588
2970
  return;
2589
2971
  }
2590
- // An identity change invalidates every in-flight operation that
2591
- // was issued under the OLD identity in this same room (their
2592
- // markers belong to the old name); a first join must not, or it
2593
- // would drop its own visit's in-flight initial load.
2594
- if (prevIdentity && prevIdentity !== name) state.roomEpoch += 1;
2972
+ if (
2973
+ previousUiIdentity &&
2974
+ previousUiIdentity !== newIdentity
2975
+ ) {
2976
+ state.roomEpoch += 1;
2977
+ }
2595
2978
  state.lastReadSent = 0;
2596
- state.marker = j.last_read_seq ?? 0;
2597
- state.markerIdent = name;
2979
+ state.marker = saved && j.joined ? (j.last_read_seq ?? 0) : -1;
2980
+ state.markerIdent = saved && j.joined ? newIdentity : null;
2598
2981
  computeGap();
2599
2982
  maybeMarkRead();
2600
- el.composeText.focus();
2983
+ if (joinedHere()) el.composeText.focus();
2601
2984
  } catch (e) {
2985
+ if (previousUiIdentity !== identity()) {
2986
+ state.changingIdentity = false;
2987
+ state.changingFrom = null;
2988
+ state.lastReadSent = 0;
2989
+ state.marker = -1;
2990
+ state.markerIdent = null;
2991
+ state.gap = false;
2992
+ renderComposer();
2993
+ if (state.searchMode) state.needsRebuild = true;
2994
+ else rebuildStream();
2995
+ computeGap();
2996
+ fetchMarker();
2997
+ }
2602
2998
  if (sameView(room, epoch)) el.joinError.textContent = e.message;
2603
2999
  } finally {
2604
3000
  el.joinBtn.disabled = false;
@@ -2608,23 +3004,61 @@
2608
3004
  async function leaveCurrent() {
2609
3005
  const room = state.current;
2610
3006
  const epoch = state.roomEpoch;
2611
- const name = identity();
2612
- if (!room || !name) return;
3007
+ const agentId = identity();
3008
+ if (!room || !agentId) return;
2613
3009
  try {
2614
3010
  // Same lock as join/rename: the leave plus its map update must not
2615
3011
  // interleave with another tab's read-modify-write.
2616
3012
  await withMembershipLock(async () => {
2617
- await postJson("/api/leave", { room: room.id, name });
2618
- // Drop the mapping only while it still records THIS leave's
2619
- // identity: a rename in another tab must not lose its mapping.
3013
+ const ghosts = loadGhosts();
3014
+ const existing = ghosts.find(
3015
+ (g) => g.room === room.id && g.agent_id === agentId,
3016
+ );
3017
+ if (existing) {
3018
+ existing.explicit_leave = true;
3019
+ } else {
3020
+ ghosts.push({
3021
+ room: room.id,
3022
+ agent_id: agentId,
3023
+ explicit_leave: true,
3024
+ });
3025
+ }
3026
+ if (!saveGhosts(ghosts)) {
3027
+ throw new Error(
3028
+ "browser storage is unavailable; the room was not left",
3029
+ );
3030
+ }
3031
+
3032
+ // Persist the local leave before the request. If the request or
3033
+ // page dies afterward, the ghost entry retries the server leave.
2620
3034
  const joined = loadJoined();
2621
- if (joined[String(room.id)] === name) {
3035
+ if (joined[String(room.id)] === agentId) {
2622
3036
  delete joined[String(room.id)];
2623
- saveJoined(joined);
3037
+ if (!saveJoined(joined)) {
3038
+ throw new Error(
3039
+ "leave is recorded, but browser room state could not be updated; it will retry",
3040
+ );
3041
+ }
3042
+ }
3043
+
3044
+ await postJson("/api/leave", {
3045
+ room: room.id,
3046
+ agent_id: agentId,
3047
+ });
3048
+
3049
+ if (!removeGhost(room.id, agentId)) {
3050
+ throw new Error(
3051
+ "the room was left, but cleanup state could not be saved; free browser storage and retry",
3052
+ );
2624
3053
  }
2625
3054
  });
2626
3055
  } catch (e) {
2627
- if (sameView(room, epoch)) el.composeError.textContent = e.message;
3056
+ if (sameView(room, epoch)) {
3057
+ if (!joinedHere()) clearReply();
3058
+ renderComposer();
3059
+ const target = joinedHere() ? el.composeError : el.joinError;
3060
+ target.textContent = e.message;
3061
+ }
2628
3062
  return;
2629
3063
  }
2630
3064
  // Composer/reply belong to whatever room is shown NOW.
@@ -2645,7 +3079,7 @@
2645
3079
  try {
2646
3080
  await postJson("/api/post", {
2647
3081
  room: room.id,
2648
- name: identity(),
3082
+ agent_id: identity(),
2649
3083
  body,
2650
3084
  reply_to_seq: state.replyTo ? state.replyTo.seq : null,
2651
3085
  });
@@ -2708,9 +3142,9 @@
2708
3142
  async function fetchMarker() {
2709
3143
  const room = state.current;
2710
3144
  const epoch = state.roomEpoch;
2711
- const name = identity();
2712
- if (!room || !name || !joinedHere()) return;
2713
- const retryKey = `${room.id}:${epoch}:${name}`;
3145
+ const agentId = identity();
3146
+ if (!room || !agentId || !joinedHere()) return;
3147
+ const retryKey = `${room.id}:${epoch}:${agentId}`;
2714
3148
  if (state.markerLoading) return;
2715
3149
  if (
2716
3150
  state.markerRetryKey === retryKey &&
@@ -2721,7 +3155,7 @@
2721
3155
  state.markerLoading = true;
2722
3156
  try {
2723
3157
  const me = await fetchJson(
2724
- `/api/me?room=${room.id}&name=${encodeURIComponent(name)}`,
3158
+ `/api/me?room=${room.id}&agent_id=${encodeURIComponent(agentId)}`,
2725
3159
  );
2726
3160
  if (me.error) throw new Error(me.error);
2727
3161
  if (!sameView(room, epoch)) return;
@@ -2729,23 +3163,23 @@
2729
3163
  // request and this completion; the epoch bump from the storage
2730
3164
  // listener usually catches that, but its event delivery is async,
2731
3165
  // so re-check before baselining the wrong identity's marker.
2732
- if (identity() !== name) return;
3166
+ if (identity() !== agentId) return;
2733
3167
  // Never LOWER a marker a same-epoch same-identity writer (join,
2734
3168
  // markall) advanced while this response was in flight: the server
2735
3169
  // marker is monotonic, so the max is the truth; a stale value
2736
3170
  // would resurrect a cleared gap and suspend auto-marking.
2737
3171
  state.marker = me.joined
2738
- ? state.markerIdent === name && state.marker >= 0
3172
+ ? state.markerIdent === agentId && state.marker >= 0
2739
3173
  ? Math.max(state.marker, me.last_read_seq)
2740
3174
  : me.last_read_seq
2741
3175
  : -1;
2742
- state.markerIdent = name;
3176
+ state.markerIdent = agentId;
2743
3177
  state.markerRetryKey = "";
2744
3178
  state.markerRetryAt = 0;
2745
3179
  computeGap();
2746
3180
  maybeMarkRead();
2747
3181
  } catch {
2748
- if (sameView(room, epoch) && identity() === name) {
3182
+ if (sameView(room, epoch) && identity() === agentId) {
2749
3183
  state.markerRetryKey = retryKey;
2750
3184
  state.markerRetryAt = Date.now() + 5_000;
2751
3185
  }
@@ -2757,8 +3191,8 @@
2757
3191
  function maybeMarkRead() {
2758
3192
  const room = state.current;
2759
3193
  const epoch = state.roomEpoch;
2760
- const name = identity();
2761
- if (!room || !name || !joinedHere()) return;
3194
+ const agentId = identity();
3195
+ if (!room || !agentId || !joinedHere()) return;
2762
3196
  // Search mode hides the live stream: follow/scroll state describes
2763
3197
  // the RESULTS layout, and the stream tail was never on screen, so
2764
3198
  // marking it read here would claim unseen messages (monotonically,
@@ -2768,12 +3202,10 @@
2768
3202
  // tab renames, marker/gap state still describes the old name until
2769
3203
  // fetchMarker re-baselines; posting meanwhile would graft the old
2770
3204
  // identity's read position onto the new name.
2771
- if (state.markerIdent !== name) return;
3205
+ if (state.markerIdent !== agentId) return;
2772
3206
  if (document.visibilityState !== "visible") return;
2773
- // Only claim messages as read while pinned to the bottom: arrivals
2774
- // above the jump pill have not been seen, and agents treat this
2775
- // marker as a read receipt.
2776
- if (!state.follow) return;
3207
+ // follow can be stale after an append; the durable marker needs both.
3208
+ if (!state.follow || !nearBottom()) return;
2777
3209
  // Re-derive the gap from current state rather than trusting a stale
2778
3210
  // flag: non-initial appends (including the degraded recovery path
2779
3211
  // after a dropped initial load) never ran computeGap.
@@ -2787,7 +3219,11 @@
2787
3219
  : 0;
2788
3220
  if (last <= state.lastReadSent) return;
2789
3221
  state.lastReadSent = last;
2790
- postJson("/api/read", { room: room.id, name, seq: last })
3222
+ postJson("/api/read", {
3223
+ room: room.id,
3224
+ agent_id: agentId,
3225
+ seq: last,
3226
+ })
2791
3227
  .then((r) => {
2792
3228
  // A delayed response must not mutate another visit's state.
2793
3229
  if (sameView(room, epoch)) state.marker = r.last_read_seq;
@@ -2797,8 +3233,44 @@
2797
3233
  });
2798
3234
  }
2799
3235
 
3236
+ // Local activity state is independent of membership and server read
3237
+ // gaps. Read-only viewers can clear dots, and only the rendered tail is
3238
+ // recorded. Scroll frames are separately gated to the visit that queued
3239
+ // them.
3240
+ function noteSeen() {
3241
+ const room = state.current;
3242
+ if (!room) return;
3243
+ // A hidden live stream or historical slice is not the current tail.
3244
+ if (state.searchMode || state.historicalWindow) return;
3245
+ if (document.visibilityState !== "visible") return;
3246
+ // follow can be stale until an appended tail is actually scrolled.
3247
+ if (!state.follow || !nearBottom()) return;
3248
+ const last = state.msgs.length
3249
+ ? state.msgs[state.msgs.length - 1].seq
3250
+ : 0;
3251
+ // Never let a shorter rendered window resurrect a cleared dot.
3252
+ if (!last || !mergeSeen(state.seen, room.id, last)) return;
3253
+ // Update before awaiting the lock so scroll events do not queue repeats.
3254
+ void saveSeen(room.id, last);
3255
+ }
3256
+
2800
3257
  // ---- room deletion ------------------------------------------------------
2801
3258
 
3259
+ async function forgetDeletedRoom(roomId) {
3260
+ await withMembershipLock(() => {
3261
+ const joined = loadJoined();
3262
+ delete joined[String(roomId)];
3263
+ saveJoined(joined);
3264
+ saveGhosts(loadGhosts().filter((g) => g.room !== roomId));
3265
+ });
3266
+ await forgetSeen(roomId);
3267
+ delete state.drafts[roomId];
3268
+ try {
3269
+ const saved = JSON.parse(localStorage.getItem(ROOM_KEY));
3270
+ if (saved?.id === roomId) localStorage.removeItem(ROOM_KEY);
3271
+ } catch {}
3272
+ }
3273
+
2802
3274
  function offerDelete() {
2803
3275
  const room = state.current;
2804
3276
  if (!room) return;
@@ -2822,20 +3294,9 @@
2822
3294
  return;
2823
3295
  }
2824
3296
  el.deleteYes.disabled = false;
2825
- // Forget every local trace of the deleted room regardless of where
2826
- // the user is now (map edit under the cross-tab lock).
2827
- await withMembershipLock(() => {
2828
- const joined = loadJoined();
2829
- delete joined[String(room.id)];
2830
- saveJoined(joined);
2831
- });
2832
- delete state.seen[room.id];
2833
- saveSeen();
2834
- delete state.drafts[room.id];
2835
- try {
2836
- const saved = JSON.parse(localStorage.getItem(ROOM_KEY));
2837
- if (saved && saved.id === room.id) localStorage.removeItem(ROOM_KEY);
2838
- } catch {}
3297
+ // Forget every local trace even if the user switched rooms while the
3298
+ // delete request was in flight.
3299
+ await forgetDeletedRoom(room.id);
2839
3300
  // Tear down the UI only if the deleted room is still the one shown;
2840
3301
  // after a switch, the current room's view (and draft) must survive.
2841
3302
  if (sameView(room, epoch)) {
@@ -2875,12 +3336,13 @@
2875
3336
  state.gap = false;
2876
3337
  state.noOlderHistory = false;
2877
3338
  state.needsRebuild = false; // a deferred rebuild belongs to the old room
3339
+ state.changingIdentity = false;
3340
+ state.changingFrom = null;
2878
3341
  state.roomEpoch += 1;
2879
3342
  try {
2880
3343
  localStorage.setItem(ROOM_KEY, JSON.stringify({ id: room.id }));
2881
3344
  } catch {}
2882
- state.seen[room.id] = room.last_activity;
2883
- saveSeen();
3345
+ // Selecting a room is not proof that its messages rendered.
2884
3346
  el.msgs.textContent = "";
2885
3347
  el.older.hidden = true;
2886
3348
  el.streamWrap.hidden = false;
@@ -2933,6 +3395,8 @@
2933
3395
  updateJump();
2934
3396
  }
2935
3397
  if (!was && state.follow) maybeMarkRead(); // caught up by scrolling
3398
+ // The scroll position is already current, so no frame deferral is needed.
3399
+ noteSeen();
2936
3400
  });
2937
3401
 
2938
3402
  el.jump.addEventListener("click", () => {
@@ -2953,6 +3417,8 @@
2953
3417
  updateJump();
2954
3418
  scrollBottom();
2955
3419
  maybeMarkRead();
3420
+ // Explicit tail navigation has already realized the scroll position.
3421
+ noteSeen();
2956
3422
  });
2957
3423
 
2958
3424
  el.older.addEventListener("click", loadOlder);
@@ -2961,18 +3427,18 @@
2961
3427
  // whole room read (same semantics as an agent's bare mark_read).
2962
3428
  const room = state.current;
2963
3429
  const epoch = state.roomEpoch;
2964
- const name = identity();
3430
+ const agentId = identity();
2965
3431
  const last = state.msgs.length
2966
3432
  ? state.msgs[state.msgs.length - 1].seq
2967
3433
  : 0;
2968
- if (!room || !name || !joinedHere() || !last) return;
3434
+ if (!room || !agentId || !joinedHere() || !last) return;
2969
3435
  // Same identity guard as maybeMarkRead: never advance a marker whose
2970
3436
  // baseline was fetched for a different (pre-rename) identity.
2971
- if (state.markerIdent !== name) return;
3437
+ if (state.markerIdent !== agentId) return;
2972
3438
  try {
2973
3439
  const r = await postJson("/api/read", {
2974
3440
  room: room.id,
2975
- name,
3441
+ agent_id: agentId,
2976
3442
  seq: last,
2977
3443
  });
2978
3444
  // A delayed response must not mutate another visit's state.
@@ -2995,13 +3461,18 @@
2995
3461
  }
2996
3462
  });
2997
3463
  el.leaveBtn.addEventListener("click", leaveCurrent);
2998
- el.identBtn.addEventListener("click", () => {
2999
- el.composeBox.hidden = true;
3000
- el.composeJoin.hidden = false;
3001
- el.joinName.value = identity();
3464
+ function startChangingIdentity() {
3465
+ const current = currentIdentity();
3466
+ if (!current) return;
3467
+ state.changingIdentity = true;
3468
+ state.changingFrom = current.agent_id;
3469
+ el.joinName.value = current.base_name;
3470
+ renderComposer();
3002
3471
  el.joinName.focus();
3003
3472
  el.joinName.select();
3004
- });
3473
+ }
3474
+ el.identBtn.addEventListener("click", startChangingIdentity);
3475
+ el.joinChangeBtn.addEventListener("click", startChangingIdentity);
3005
3476
  el.replyTarget.addEventListener("click", clearReply);
3006
3477
  el.send.addEventListener("click", sendMessage);
3007
3478
  el.composeText.addEventListener("input", autosize);
@@ -3013,8 +3484,11 @@
3013
3484
  if (e.key === "Escape") clearReply();
3014
3485
  });
3015
3486
  el.joinName.addEventListener("keydown", (e) => {
3016
- // Escape backs out of "Change name" when already joined.
3017
- if (e.key === "Escape" && joinedHere()) renderComposer();
3487
+ if (e.key === "Escape" && currentIdentity()) {
3488
+ state.changingIdentity = false;
3489
+ state.changingFrom = null;
3490
+ renderComposer();
3491
+ }
3018
3492
  });
3019
3493
  el.search.addEventListener("keydown", (e) => {
3020
3494
  if (e.key === "Enter") {
@@ -3036,7 +3510,11 @@
3036
3510
  if (document.hidden) return;
3037
3511
  loadNew(false);
3038
3512
  loadRooms();
3039
- maybeMarkRead();
3513
+ // Record hidden arrivals only after the visible frame can paint them.
3514
+ requestAnimationFrame(() => {
3515
+ noteSeen();
3516
+ maybeMarkRead();
3517
+ });
3040
3518
  });
3041
3519
 
3042
3520
  // Another tab changing the identity or the membership map invalidates
@@ -3046,7 +3524,28 @@
3046
3524
  // only for OTHER documents, so same-tab writes never loop through here;
3047
3525
  // a discarded in-flight initial load re-arms via pendingInitial.)
3048
3526
  window.addEventListener("storage", (e) => {
3527
+ if (e.key === SEEN_KEY) {
3528
+ const before = parseSeen(e.oldValue);
3529
+ const after = parseSeen(e.newValue);
3530
+ for (const room of Object.keys(before)) {
3531
+ if (!(room in after)) delete state.seen[room];
3532
+ }
3533
+ for (const [room, seq] of Object.entries(after)) {
3534
+ mergeSeen(state.seen, room, seq);
3535
+ }
3536
+ renderRooms();
3537
+ return;
3538
+ }
3539
+ if (e.key === GHOST_KEY) {
3540
+ retryGhosts();
3541
+ return;
3542
+ }
3049
3543
  if (e.key !== IDENT_KEY && e.key !== JOINED_KEY) return;
3544
+ if (e.key === IDENT_KEY) {
3545
+ reloadIdentityState();
3546
+ state.changingIdentity = false;
3547
+ state.changingFrom = null;
3548
+ }
3050
3549
  state.roomEpoch += 1;
3051
3550
  state.lastReadSent = 0;
3052
3551
  state.marker = -1;
@@ -3064,18 +3563,46 @@
3064
3563
  computeGap();
3065
3564
  fetchMarker();
3066
3565
  }
3566
+ if (e.key === IDENT_KEY) {
3567
+ if (browserIdentity.pending_allocation) {
3568
+ recoverPendingAllocation().then((recovered) => {
3569
+ if (!recovered) return;
3570
+ state.roomEpoch += 1;
3571
+ state.lastReadSent = 0;
3572
+ state.marker = -1;
3573
+ state.markerIdent = null;
3574
+ state.gap = false;
3575
+ renderComposer();
3576
+ if (state.current) {
3577
+ if (state.searchMode) state.needsRebuild = true;
3578
+ else rebuildStream();
3579
+ computeGap();
3580
+ fetchMarker();
3581
+ }
3582
+ retryGhosts();
3583
+ });
3584
+ } else {
3585
+ retryGhosts();
3586
+ }
3587
+ }
3067
3588
  });
3068
3589
 
3069
3590
  setInterval(() => {
3070
3591
  if (!document.hidden) loadNew(false);
3071
3592
  }, POLL_MS);
3072
3593
  setInterval(() => {
3073
- if (!document.hidden) loadRooms();
3594
+ if (!document.hidden) {
3595
+ loadRooms();
3596
+ // Failed leaves and uncertain rejoins must converge even when the
3597
+ // user keeps this page open and performs no later identity action.
3598
+ if (loadGhosts().length > 0) retryGhosts();
3599
+ }
3074
3600
  }, ROOMS_MS);
3075
3601
 
3076
3602
  (async function init() {
3077
3603
  showPlaceholder("Loading...", "");
3078
- retryGhosts(); // clean up any leaves that failed in a prior session
3604
+ const recovered = await recoverPendingAllocation();
3605
+ if (recovered) retryGhosts();
3079
3606
  await loadRooms();
3080
3607
  let savedId = null;
3081
3608
  try {
@@ -3088,7 +3615,7 @@
3088
3615
  } else if (state.rooms.length) {
3089
3616
  showPlaceholder(
3090
3617
  "Pick a room.",
3091
- "Rooms are listed on the left; the amber dot marks activity you have not seen.",
3618
+ "Rooms are listed on the left; the activity dot marks messages you have not seen.",
3092
3619
  );
3093
3620
  }
3094
3621
  })();