hop2 0.11.11 → 0.11.13

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.
@@ -279,6 +279,30 @@ const resolveInitialName = () => {
279
279
  return fromParams ?? stored ?? (injected && injected !== "Guest" ? injected : null) ?? "User";
280
280
  };
281
281
 
282
+ // Stable connection identity: device half persists per browser, tab half per
283
+ // tab. The server evicts a previous connection bearing the same key on
284
+ // attach — a reconnect replaces its own ghost in presence instead of sitting
285
+ // next to it — while two real tabs (different tab halves) coexist.
286
+ const randomKeyPart = () =>
287
+ (window.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)).replace(/-/g, "").slice(0, 10);
288
+ const resolveDeviceKey = () => {
289
+ try {
290
+ let device = localStorage.getItem("hop_device_id");
291
+ if (!device) {
292
+ device = randomKeyPart();
293
+ localStorage.setItem("hop_device_id", device);
294
+ }
295
+ let tab = sessionStorage.getItem("hop_tab_id");
296
+ if (!tab) {
297
+ tab = randomKeyPart();
298
+ sessionStorage.setItem("hop_tab_id", tab);
299
+ }
300
+ return `${device}.${tab}`;
301
+ } catch {
302
+ return ""; // storage unavailable (private mode quota) — no eviction key
303
+ }
304
+ };
305
+
282
306
  // Per-session "seen" markers, shared with the session manager page via
283
307
  // localStorage (same origin). Keyed by internal session name; values are the
284
308
  // server's own lastActivityAt/bellSeq so no cross-device clock comparison
@@ -519,10 +543,6 @@ const App = () => {
519
543
  const shouldReconnectRef = useRef(true);
520
544
  const connectNonceRef = useRef(0);
521
545
  const userScrolledUpRef = useRef(false);
522
- // A resize can collapse a previously scrolled viewport to y=base=0 without
523
- // clearing xterm's internal user-scroll state. The next output must repair
524
- // follow mode after xterm has actually grown the buffer again.
525
- const followAfterResizeRef = useRef(false);
526
546
  // Set by every write into the terminal; the render watchdog only spends a
527
547
  // refresh when something actually changed since its last tick.
528
548
  const outputSinceTickRef = useRef(true);
@@ -961,20 +981,31 @@ const App = () => {
961
981
  useEffect(() => {
962
982
  viewModeRef.current = viewMode;
963
983
  localStorage.setItem("hay_view_mode", viewMode);
964
- // When switching to fit mode, refit terminal
984
+ // Switching to fit: if a peer owns the size, follow their grid scaled to
985
+ // this viewport; otherwise local fit rules.
965
986
  if (viewMode === "fit" && termRef.current) {
966
987
  setTimeout(() => {
967
- fitToViewport();
968
- handleResize();
988
+ const owner = activeOwnerRef.current;
989
+ const active = activeSizeRef.current;
990
+ if (owner && owner !== clientIdRef.current && active) {
991
+ enterFollow(active.cols, active.rows);
992
+ handleResize(); // declare the natural fit under the follow
993
+ } else {
994
+ fitToViewport();
995
+ handleResize();
996
+ }
969
997
  }, 0);
970
998
  }
971
999
  // Switching to Manual restores the shared (active) size: fit mode may have
972
1000
  // shrunk the local terminal to this viewport, wrapping the buffer at a
973
1001
  // width the PTY isn't. Back at the true size, overflow + panning work.
974
- if (viewMode === "full" && termRef.current && activeSizeRef.current) {
975
- const { cols, rows } = activeSizeRef.current;
976
- if (termRef.current.cols !== cols || termRef.current.rows !== rows) {
977
- termRef.current.resize(cols, rows);
1002
+ if (viewMode === "full" && termRef.current) {
1003
+ exitFollow();
1004
+ if (activeSizeRef.current) {
1005
+ const { cols, rows } = activeSizeRef.current;
1006
+ if (termRef.current.cols !== cols || termRef.current.rows !== rows) {
1007
+ termRef.current.resize(cols, rows);
1008
+ }
978
1009
  }
979
1010
  }
980
1011
  }, [viewMode]);
@@ -1075,13 +1106,11 @@ const App = () => {
1075
1106
  if (nowFit - lastTypeFitAtRef.current > 500) {
1076
1107
  lastTypeFitAtRef.current = nowFit;
1077
1108
  fitToViewport();
1078
- // If another client holds the session at a different size, our fit can
1079
- // equal the last size we sent (a rejected claim) — the dedupe guard
1080
- // would swallow the reclaim. Typing is the election winner, so force
1081
- // the resize through whenever the active size isn't ours.
1082
- const active = activeSizeRef.current;
1083
- const t = termRef.current;
1084
- if (active && t && (active.cols !== t.cols || active.rows !== t.rows)) {
1109
+ // Typing transfers ownership server-side (the keystroke above lands
1110
+ // first) and the server snaps the PTY to our declared fit — this
1111
+ // refresh just makes sure that declaration is current. Force it
1112
+ // through the dedupe whenever the size isn't ours yet.
1113
+ if (activeOwnerRef.current && activeOwnerRef.current !== clientIdRef.current) {
1085
1114
  lastSentSizeRef.current = null;
1086
1115
  }
1087
1116
  handleResize();
@@ -1186,6 +1215,45 @@ const App = () => {
1186
1215
  // Last size actually sent, to skip redundant resize messages. Reset when a
1187
1216
  // new connection opens so the server always learns the size once.
1188
1217
  const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
1218
+ // Our own id per the server (from hello) — active_size carries the OWNER's
1219
+ // clientId, so "is this size ours" is an identity check, never a size
1220
+ // comparison (comparing sizes is how a follower gets fooled into thinking
1221
+ // an adopted grid is its own).
1222
+ const clientIdRef = useRef<string | null>(null);
1223
+ // Who owns the shared size right now, per the latest active_size.
1224
+ const activeOwnerRef = useRef<string | null>(null);
1225
+ // Non-null while a PEER owns the size in fit mode: the local terminal is
1226
+ // resized to the owner's grid and scaled to this viewport. Our own natural
1227
+ // fit keeps flowing to the server as a declaration — the moment we type,
1228
+ // the server transfers ownership and applies it.
1229
+ const followSizeRef = useRef<{ cols: number; rows: number } | null>(null);
1230
+
1231
+ // Measure the cols/rows this viewport would naturally hold at the current
1232
+ // font — WITHOUT touching the terminal. Null when not measurable yet
1233
+ // (render service not ready, or the container has no layout).
1234
+ const measureNaturalFit = (): { cols: number; rows: number } | null => {
1235
+ if (!termRef.current || !containerRef.current) return null;
1236
+ const core = (termRef.current as any)._core;
1237
+ if (!core?._renderService?.dimensions?.css?.cell) return null;
1238
+ const cellWidth = core._renderService.dimensions.css.cell.width;
1239
+ const cellHeight = core._renderService.dimensions.css.cell.height;
1240
+ if (!cellWidth || !cellHeight) return null;
1241
+ const scrollContainer = containerRef.current.closest(".terminal-scroll");
1242
+ if (!scrollContainer) return null;
1243
+ const rect = scrollContainer.getBoundingClientRect();
1244
+ if (rect.width < 1 || rect.height < 1) return null;
1245
+ const styles = window.getComputedStyle(scrollContainer);
1246
+ const paddingLeft = parseFloat(styles.paddingLeft) || 0;
1247
+ const paddingRight = parseFloat(styles.paddingRight) || 0;
1248
+ const paddingTop = parseFloat(styles.paddingTop) || 0;
1249
+ const paddingBottom = parseFloat(styles.paddingBottom) || 0;
1250
+ const availableWidth = rect.width - paddingLeft - paddingRight;
1251
+ const availableHeight = rect.height - paddingTop - paddingBottom;
1252
+ return {
1253
+ cols: Math.max(2, Math.floor(availableWidth / cellWidth)),
1254
+ rows: Math.max(1, Math.floor(availableHeight / cellHeight))
1255
+ };
1256
+ };
1189
1257
 
1190
1258
  const handleResize = () => {
1191
1259
  if (!termRef.current) {
@@ -1193,8 +1261,13 @@ const App = () => {
1193
1261
  }
1194
1262
  const claim = attachClaimPendingRef.current ? ("attach" as const) : undefined;
1195
1263
  attachClaimPendingRef.current = false;
1196
- const cols = termRef.current.cols;
1197
- const rows = termRef.current.rows;
1264
+ // While following a peer's grid the terminal is THEIR size — declare
1265
+ // this viewport's natural fit instead. Sending the terminal's cols would
1266
+ // register the peer's own grid as our declared fit, and the next
1267
+ // keystroke's ownership transfer would "apply" it as a no-op.
1268
+ const natural = followSizeRef.current ? measureNaturalFit() : null;
1269
+ const cols = natural?.cols ?? termRef.current.cols;
1270
+ const rows = natural?.rows ?? termRef.current.rows;
1198
1271
  if (!claim && lastSentSizeRef.current && lastSentSizeRef.current.cols === cols && lastSentSizeRef.current.rows === rows) {
1199
1272
  return;
1200
1273
  }
@@ -1210,42 +1283,63 @@ const App = () => {
1210
1283
  });
1211
1284
  };
1212
1285
 
1286
+ // Scale the owner's grid to this viewport (letterboxed, centered
1287
+ // horizontally) — a follower renders the PTY's true grid, never rewraps
1288
+ // it. Transform only: bounds and cell metrics stay honest, so natural-fit
1289
+ // measurement keeps working underneath.
1290
+ const applyFollowScale = () => {
1291
+ const follow = followSizeRef.current;
1292
+ const inner = containerRef.current;
1293
+ if (!follow || !inner || !termRef.current) return;
1294
+ const core = (termRef.current as any)._core;
1295
+ const cell = core?._renderService?.dimensions?.css?.cell;
1296
+ if (!cell?.width || !cell?.height) return;
1297
+ const scrollContainer = inner.closest(".terminal-scroll");
1298
+ if (!scrollContainer) return;
1299
+ const rect = scrollContainer.getBoundingClientRect();
1300
+ if (rect.width < 1 || rect.height < 1) return;
1301
+ const gridW = follow.cols * cell.width;
1302
+ const gridH = follow.rows * cell.height;
1303
+ const scale = Math.min(rect.width / gridW, rect.height / gridH, 1.4);
1304
+ const offsetX = Math.max(0, (rect.width - gridW * scale) / 2);
1305
+ inner.style.transformOrigin = "top left";
1306
+ inner.style.transform = `translate(${offsetX}px, 0) scale(${scale})`;
1307
+ };
1308
+
1309
+ const enterFollow = (cols: number, rows: number) => {
1310
+ followSizeRef.current = { cols, rows };
1311
+ const t = termRef.current;
1312
+ if (t && (t.cols !== cols || t.rows !== rows)) {
1313
+ t.resize(cols, rows);
1314
+ }
1315
+ applyFollowScale();
1316
+ };
1317
+
1318
+ const exitFollow = () => {
1319
+ followSizeRef.current = null;
1320
+ const inner = containerRef.current;
1321
+ if (inner) {
1322
+ inner.style.transform = "";
1323
+ inner.style.transformOrigin = "";
1324
+ }
1325
+ };
1326
+
1213
1327
  // Fit based on the scroll container viewport rather than the terminal element itself.
1214
1328
  // This ensures correct sizing across desktop padding and mobile full-bleed layouts.
1215
1329
  // Returns false when it couldn't measure yet (render service not ready, or the
1216
1330
  // container has no layout) so callers can retry on a later frame.
1217
1331
  const fitToViewport = (): boolean => {
1218
- if (!termRef.current || !containerRef.current) return false;
1219
-
1220
- const terminal = termRef.current;
1221
- const core = (terminal as any)._core;
1222
- if (!core?._renderService?.dimensions?.css?.cell) return false;
1223
-
1224
- const cellWidth = core._renderService.dimensions.css.cell.width;
1225
- const cellHeight = core._renderService.dimensions.css.cell.height;
1226
- if (!cellWidth || !cellHeight) return false;
1227
-
1228
- const scrollContainer = containerRef.current.closest(".terminal-scroll");
1229
- if (!scrollContainer) return false;
1230
-
1231
- const rect = scrollContainer.getBoundingClientRect();
1232
- // No layout yet (e.g. the terminal mounted while the switcher covered it) —
1233
- // report not-ready so the caller retries once it has real dimensions.
1234
- if (rect.width < 1 || rect.height < 1) return false;
1235
- const styles = window.getComputedStyle(scrollContainer);
1236
- const paddingLeft = parseFloat(styles.paddingLeft) || 0;
1237
- const paddingRight = parseFloat(styles.paddingRight) || 0;
1238
- const paddingTop = parseFloat(styles.paddingTop) || 0;
1239
- const paddingBottom = parseFloat(styles.paddingBottom) || 0;
1240
-
1241
- const availableWidth = rect.width - paddingLeft - paddingRight;
1242
- const availableHeight = rect.height - paddingTop - paddingBottom;
1243
-
1244
- const cols = Math.max(2, Math.floor(availableWidth / cellWidth));
1245
- const rows = Math.max(1, Math.floor(availableHeight / cellHeight));
1246
-
1247
- if (terminal.cols !== cols || terminal.rows !== rows) {
1248
- terminal.resize(cols, rows);
1332
+ // Following a peer's grid: the terminal must stay at THEIR size; the
1333
+ // viewport is handled by the follow scale, not by resizing.
1334
+ if (followSizeRef.current) {
1335
+ applyFollowScale();
1336
+ return true;
1337
+ }
1338
+ const fitted = measureNaturalFit();
1339
+ if (!fitted) return false;
1340
+ const terminal = termRef.current!;
1341
+ if (terminal.cols !== fitted.cols || terminal.rows !== fitted.rows) {
1342
+ terminal.resize(fitted.cols, fitted.rows);
1249
1343
  }
1250
1344
  return true;
1251
1345
  };
@@ -1276,14 +1370,9 @@ const App = () => {
1276
1370
 
1277
1371
  // Filter focus reporting sequences that can leak as visible text
1278
1372
  const filtered = data.replace(/\x1b\[I/g, '').replace(/\x1b\[O/g, '');
1279
- const repairFollowAfterResize = followAfterResizeRef.current;
1280
1373
  termRef.current.write(filtered, () => {
1281
- if (repairFollowAfterResize) {
1282
- followAfterResizeRef.current = false;
1283
- userScrolledUpRef.current = false;
1284
- }
1285
1374
  // Auto-scroll to bottom unless the user has explicitly scrolled up
1286
- if (repairFollowAfterResize || !userScrolledUpRef.current) {
1375
+ if (!userScrolledUpRef.current) {
1287
1376
  termRef.current?.scrollToBottom();
1288
1377
  }
1289
1378
  });
@@ -1292,11 +1381,15 @@ const App = () => {
1292
1381
  const scheduleReconnect = (nextSession: { name: string; room: string }) => {
1293
1382
  if (!shouldReconnectRef.current) return;
1294
1383
 
1295
- // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
1296
- const delay = Math.min(1000 * Math.pow(2, reconnectAttemptRef.current), 30000);
1384
+ // Immediately the first time — most disconnects (a tunnel blip, a laptop
1385
+ // waking up) are already gone by the time a socket would even finish
1386
+ // waiting to check. Backoff (1s, 2s, 4s, 8s, 16s, max 30s) only starts
1387
+ // once that immediate retry has already failed.
1388
+ const attempt = reconnectAttemptRef.current;
1389
+ const delay = attempt === 0 ? 0 : Math.min(1000 * Math.pow(2, attempt - 1), 30000);
1297
1390
  reconnectAttemptRef.current += 1;
1298
1391
 
1299
- pushNotice(`Reconnecting in ${Math.round(delay / 1000)}s...`);
1392
+ pushNotice(delay === 0 ? "Reconnecting..." : `Reconnecting in ${Math.round(delay / 1000)}s...`);
1300
1393
 
1301
1394
  reconnectTimerRef.current = window.setTimeout(() => {
1302
1395
  reconnectTimerRef.current = null;
@@ -1328,7 +1421,6 @@ const App = () => {
1328
1421
  activeSizeRef.current = null;
1329
1422
  optimisticEchoRef.current.reset();
1330
1423
  userScrolledUpRef.current = false;
1331
- followAfterResizeRef.current = false;
1332
1424
  // reset() can emit onScroll(0). Clear the previous room's history guards
1333
1425
  // first so that event cannot masquerade as a real scroll-to-top gesture.
1334
1426
  lastViewportYRef.current = 0;
@@ -1396,7 +1488,7 @@ const App = () => {
1396
1488
  const hex = (c?: string) => (c || "").replace("#", "").slice(0, 6);
1397
1489
  const url = `${wsUrl}?room=${encodeURIComponent(nextSession.room)}&name=${encodeURIComponent(
1398
1490
  nextSession.name
1399
- )}&cols=${cols}&rows=${rows}&replay=${replay}&bg=${hex(surface.background)}&fg=${hex(surface.foreground)}`;
1491
+ )}&cols=${cols}&rows=${rows}&replay=${replay}&bg=${hex(surface.background)}&fg=${hex(surface.foreground)}&device=${encodeURIComponent(resolveDeviceKey())}`;
1400
1492
 
1401
1493
  wsRef.current?.close();
1402
1494
  setStatus("connecting");
@@ -1413,7 +1505,10 @@ const App = () => {
1413
1505
  setStatus("connected");
1414
1506
  reconnectAttemptRef.current = 0; // Reset backoff on successful connection
1415
1507
  lastSentSizeRef.current = null; // fresh connection must learn the size once
1416
- handleResize();
1508
+ // No resize here: the connect URL already declared our size, and the
1509
+ // real fit (with the attach claim) goes out after the snapshot. The
1510
+ // old immediate send carried the PREVIOUS session's grid and could
1511
+ // reshape the PTY twice per switch.
1417
1512
  // Replay keystrokes buffered during the outage: this room only, in
1418
1513
  // order, and only within the age window — stale input is discarded.
1419
1514
  const queued = pendingInputRef.current;
@@ -1444,6 +1539,7 @@ const App = () => {
1444
1539
  }
1445
1540
  switch (message.type) {
1446
1541
  case "hello":
1542
+ clientIdRef.current = message.clientId;
1447
1543
  setClientId(message.clientId);
1448
1544
  setCollabMode(message.collabMode);
1449
1545
  setControllerId(message.controllerId);
@@ -1570,13 +1666,14 @@ const App = () => {
1570
1666
  // Auto-fit and scroll to end once after snapshot load. Retry across
1571
1667
  // frames so a not-yet-measured render service doesn't leave the fresh
1572
1668
  // session at a stale (often too-wide) size. The fit's resize goes out
1573
- // as claim:"attach": opening a session is intent, so it takes the
1574
- // shared size immediately unless a peer typed in the last few seconds
1575
- // — without the claim it lost the 60s idle election to any recently
1576
- // active client and the page sat mis-wrapped until the first
1577
- // keystroke ("one autofit away").
1669
+ // as claim:"attach": opening a session is a user action, so it takes
1670
+ // ownership of the shared size outright — but only when someone is
1671
+ // actually LOOKING. A hidden tab's auto-reconnect is not an opening;
1672
+ // claiming from it would yank the size from whoever is present.
1578
1673
  if (viewModeRef.current === "fit") {
1579
- attachClaimPendingRef.current = true;
1674
+ if (document.visibilityState === "visible") {
1675
+ attachClaimPendingRef.current = true;
1676
+ }
1580
1677
  fitWhenReady(12, () => termRef.current?.scrollToBottom());
1581
1678
  }
1582
1679
  break;
@@ -1589,13 +1686,30 @@ const App = () => {
1589
1686
  pushNotice(message.reason);
1590
1687
  break;
1591
1688
  case "active_size":
1592
- // Remember the active user's size for Manual mode (overflow/panning),
1593
- // but in Auto-fit mode DON'T resize to it — the local terminal is sized
1594
- // to this viewport, and applying the active size here is exactly what
1595
- // clobbered the post-load autofit (mobile snapping to a desktop/PTY
1596
- // 80×24). Manual mode still matches the active size for correct render.
1689
+ // Identity, not size comparison: the message carries the OWNER's
1690
+ // clientId. Ours → local fit rules (and adopt the confirmed size,
1691
+ // since a keystroke's ownership transfer can apply our declared
1692
+ // fit before any resize of ours goes out). A peer's → follow
1693
+ // their grid, scaled to this viewport; our natural fit keeps
1694
+ // flowing as a declaration and our next keystroke takes the size
1695
+ // back server-side. Manual mode matches the shared size 1:1 for
1696
+ // overflow + panning, as before.
1597
1697
  activeSizeRef.current = { cols: message.cols, rows: message.rows };
1598
- if (termRef.current && viewModeRef.current !== "fit") {
1698
+ activeOwnerRef.current = message.clientId;
1699
+ if (viewModeRef.current === "fit") {
1700
+ if (message.clientId === clientIdRef.current) {
1701
+ exitFollow();
1702
+ if (termRef.current && (termRef.current.cols !== message.cols || termRef.current.rows !== message.rows)) {
1703
+ termRef.current.resize(message.cols, message.rows);
1704
+ // Our declared fit may have gone stale (e.g. rotated while
1705
+ // following) — true it up; the resulting resize applies
1706
+ // because the size is ours now.
1707
+ fitWhenReady(4);
1708
+ }
1709
+ } else {
1710
+ enterFollow(message.cols, message.rows);
1711
+ }
1712
+ } else if (termRef.current) {
1599
1713
  const currentCols = termRef.current.cols;
1600
1714
  const currentRows = termRef.current.rows;
1601
1715
  if (message.cols !== currentCols || message.rows !== currentRows) {
@@ -1887,14 +2001,22 @@ const App = () => {
1887
2001
  return true;
1888
2002
  });
1889
2003
 
2004
+ // Debounced: a window drag / keyboard animation / orientation change
2005
+ // emits a burst of observer callbacks, and every winning resize used to
2006
+ // be a real SIGWINCH + grid reflow. One trailing fit per 150ms burst.
2007
+ let fitDebounce: number | null = null;
1890
2008
  const resizeObserver = new ResizeObserver(() => {
1891
- // Only auto-fit in fit mode
1892
- if (viewModeRef.current === "fit") {
2009
+ if (viewModeRef.current !== "fit") return;
2010
+ if (fitDebounce !== null) window.clearTimeout(fitDebounce);
2011
+ fitDebounce = window.setTimeout(() => {
2012
+ fitDebounce = null;
1893
2013
  fitToViewport();
1894
2014
  // Force a full refresh to clear any stale canvas content after resize
1895
2015
  terminal.refresh(0, terminal.rows - 1);
2016
+ // Owner: the refit flows straight to the PTY. Follower: this is a
2017
+ // declaration of the new natural fit (fitToViewport only re-scaled).
1896
2018
  handleResize();
1897
- }
2019
+ }, 150);
1898
2020
  });
1899
2021
  (terminal as any).__resizeObserver = resizeObserver;
1900
2022
 
@@ -1961,27 +2083,24 @@ const App = () => {
1961
2083
  terminal.onScroll(onBufferScroll);
1962
2084
  // xterm does not always emit onScroll when a resize collapses scrollback.
1963
2085
  // Its public y/base can then say "bottom" while both our follow flag and
1964
- // xterm's private user-scroll latch remain stuck above it. Reconcile every
1965
- // resize from buffer truth and remember to repair follow mode after the
1966
- // next write has regrown the buffer (scrollToBottom is a no-op at 0/0).
2086
+ // xterm's internal user-scroll latch still describe the old viewport.
2087
+ // The browser's public scrollToBottom() is a DOM no-op at y=base, so clear
2088
+ // the buffer-service latch directly; this code already relies on _core for
2089
+ // renderer dimensions and touch viewport integration elsewhere.
1967
2090
  terminal.onResize(() => {
1968
- // onResize fires inside xterm's resize transaction, before its private
1969
- // user-scroll latch has fully settled. Reconcile just after resize()
1970
- // returns, still ahead of terminal writes queued later in the turn.
1971
- queueMicrotask(() => {
1972
- if (termRef.current !== terminal) return;
1973
- const { y, base } = scrollState();
1974
- lastViewportYRef.current = y;
1975
- if (y >= base) {
1976
- userScrolledUpRef.current = false;
1977
- followAfterResizeRef.current = true;
1978
- terminal.scrollToBottom();
2091
+ if (termRef.current !== terminal) return;
2092
+ const { y, base } = scrollState();
2093
+ lastViewportYRef.current = y;
2094
+ userScrolledUpRef.current = y < base;
2095
+ if (!userScrolledUpRef.current) {
2096
+ const bufferService = (terminal as any)._core?._bufferService;
2097
+ if (typeof bufferService?.scrollLines === "function") {
2098
+ bufferService.scrollLines(0);
1979
2099
  } else {
1980
- userScrolledUpRef.current = true;
1981
- followAfterResizeRef.current = false;
2100
+ terminal.scrollToBottom();
1982
2101
  }
1983
- syncOverlayScrollbar();
1984
- });
2102
+ }
2103
+ syncOverlayScrollbar();
1985
2104
  });
1986
2105
  // Buffer growth while following doesn't always fire onScroll — a light
1987
2106
  // interval keeps the thumb honest.
@@ -2947,6 +3066,8 @@ const App = () => {
2947
3066
  if (termRef.current) {
2948
3067
  termRef.current.options.fontSize = fontSize;
2949
3068
  if (viewModeRef.current === "fit") {
3069
+ // Cell metrics changed: re-fit (owner) or re-scale the followed
3070
+ // grid and re-declare the natural fit (follower).
2950
3071
  fitToViewport();
2951
3072
  handleResize();
2952
3073
  } else {
@@ -2988,6 +3109,9 @@ const App = () => {
2988
3109
  setPresence([]);
2989
3110
  setControllerId(null);
2990
3111
  setClientId(null);
3112
+ clientIdRef.current = null;
3113
+ activeOwnerRef.current = null;
3114
+ exitFollow();
2991
3115
  setLiveCwd(null);
2992
3116
  setStatus("connecting");
2993
3117
  setRoom(targetRoom);
@@ -3028,6 +3152,9 @@ const App = () => {
3028
3152
  setPresence([]);
3029
3153
  setControllerId(null);
3030
3154
  setClientId(null);
3155
+ clientIdRef.current = null;
3156
+ activeOwnerRef.current = null;
3157
+ exitFollow();
3031
3158
  setLiveCwd(null);
3032
3159
  setStatus("connecting");
3033
3160
  setRoom(targetRoom);
@@ -3735,7 +3862,14 @@ const App = () => {
3735
3862
  )}
3736
3863
  </div>
3737
3864
  <div className="quick-group">
3738
- <button type="button" className="quick-btn" onClick={() => { fitToViewport(); handleResize(); }}>
3865
+ <button type="button" className="quick-btn" onClick={() => {
3866
+ // An explicit Fit is a deliberate act: claim the size, even
3867
+ // from a peer that owns it.
3868
+ attachClaimPendingRef.current = true;
3869
+ deliberateAttachRef.current = true;
3870
+ fitToViewport();
3871
+ handleResize();
3872
+ }}>
3739
3873
  Fit
3740
3874
  </button>
3741
3875
  {/* Mobile gets the in-app switcher; desktop keeps the manager page */}