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.
- package/hay/CHANGELOG.md +2 -1
- package/hay/apps/server/src/index.ts +2 -0
- package/hay/apps/server/src/lib.ts +2 -0
- package/hay/apps/server/src/rooms.ts +127 -76
- package/hay/apps/server/src/screenGrid.ts +30 -2
- package/hay/apps/server/test/rooms.test.ts +183 -79
- package/hay/apps/web/scripts/run-e2e.mjs +40 -0
- package/hay/apps/web/src/App.tsx +231 -97
- package/hay/apps/web/src/components/SessionSwitcher.tsx +158 -17
- package/hay/apps/web/src/styles.css +32 -0
- package/hay/apps/web/src/utils/switcherModel.ts +52 -13
- package/hay/apps/web/test/SessionSwitcher.test.tsx +12 -0
- package/hay/apps/web/test/switcherModel.test.ts +61 -0
- package/hay-web/assets/digest-archive.json +604 -0
- package/hay-web/assets/index--8sYRyoP.js +133 -0
- package/hay-web/assets/index--8sYRyoP.js.map +1 -0
- package/hay-web/assets/index-DHotLq-D.css +1 -0
- package/hay-web/assets/index-DnSA-VR6.js +133 -0
- package/hay-web/assets/{index-DQ3VWtnf.js.map → index-DnSA-VR6.js.map} +1 -1
- package/hay-web/assets/{index-DQ3VWtnf.js → index-DzlPn2In.js} +16 -16
- package/hay-web/assets/index-DzlPn2In.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +607 -109
- package/package.json +1 -1
- package/scripts/hay-host.js +25 -0
- package/skills/hop-session/SKILL.md +29 -2
- package/tests/terminal-api.integration.test.cjs +52 -2
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
968
|
-
|
|
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
|
|
975
|
-
|
|
976
|
-
if (
|
|
977
|
-
|
|
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
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
// the
|
|
1082
|
-
|
|
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
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
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 (
|
|
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
|
-
//
|
|
1296
|
-
|
|
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
|
-
|
|
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
|
|
1574
|
-
//
|
|
1575
|
-
//
|
|
1576
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
1593
|
-
//
|
|
1594
|
-
//
|
|
1595
|
-
//
|
|
1596
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1892
|
-
if (
|
|
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
|
|
1965
|
-
//
|
|
1966
|
-
//
|
|
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
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
const
|
|
1974
|
-
|
|
1975
|
-
|
|
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
|
-
|
|
1981
|
-
followAfterResizeRef.current = false;
|
|
2100
|
+
terminal.scrollToBottom();
|
|
1982
2101
|
}
|
|
1983
|
-
|
|
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={() => {
|
|
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 */}
|