hop2 0.11.54 → 0.11.55

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.
@@ -19,6 +19,7 @@ import { remoteAppOwnsScreen } from "./utils/echoGuard";
19
19
  import { urlAtCell, cellAtPoint } from "./utils/urlAtCell";
20
20
  import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
21
21
  import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
22
+ import { claimOnAttach, claimOnClick } from "./utils/sizeClaim";
22
23
  import { MobileKeyboard } from "./components/MobileKeyboard";
23
24
  import { SessionSwitcher } from "./components/SessionSwitcher";
24
25
  import { SecondaryPane } from "./components/SecondaryPane";
@@ -1348,7 +1349,11 @@ const App = () => {
1348
1349
  const killArmedAtRef = useRef(0);
1349
1350
  // Set by an explicit session switch; the next attach claim carries
1350
1351
  // user:true (deliberate — wins the size election outright on new hosts).
1351
- const deliberateAttachRef = useRef(false);
1352
+ // A page load is the human opening this session, so the first attach may
1353
+ // claim the size. Every later attach must be re-armed by an actual act (a
1354
+ // switch, closing the wall into the session, a click, the Fit button) —
1355
+ // a reconnect is the socket healing itself, not a person.
1356
+ const deliberateAttachRef = useRef(true);
1352
1357
  // A canvas that was HIDDEN can come back showing a stale frame: the wall
1353
1358
  // covers the terminal with visibility:hidden, browsers throttle or drop
1354
1359
  // WebGL contexts for hidden canvases, and xterm only repaints rows it
@@ -1491,6 +1496,24 @@ const App = () => {
1491
1496
  });
1492
1497
  };
1493
1498
 
1499
+ /**
1500
+ * Take the session's size for THIS viewport, because the user just acted
1501
+ * here. Quiet when the session is already at our fit, so an ordinary click
1502
+ * (or a selection drag) never pushes a resize.
1503
+ */
1504
+ const claimSizeHere = (force = false) => {
1505
+ if (!force && !claimOnClick({
1506
+ viewMode: viewModeRef.current,
1507
+ natural: measureNaturalFit(),
1508
+ active: activeSizeRef.current,
1509
+ owned: activeOwnerRef.current === clientIdRef.current
1510
+ })) return;
1511
+ attachClaimPendingRef.current = true;
1512
+ deliberateAttachRef.current = true;
1513
+ fitToViewport();
1514
+ handleResize();
1515
+ };
1516
+
1494
1517
  // Scale the owner's grid to this viewport (letterboxed, centered
1495
1518
  // horizontally) — a follower renders the PTY's true grid, never rewraps
1496
1519
  // it. Transform only: bounds and cell metrics stay honest, so natural-fit
@@ -1904,10 +1927,14 @@ const App = () => {
1904
1927
  // ownership of the shared size outright — but only when someone is
1905
1928
  // actually LOOKING. A hidden tab's auto-reconnect is not an opening;
1906
1929
  // claiming from it would yank the size from whoever is present.
1930
+ if (claimOnAttach({
1931
+ viewMode: viewModeRef.current,
1932
+ visible: document.visibilityState === "visible",
1933
+ deliberate: deliberateAttachRef.current
1934
+ })) {
1935
+ attachClaimPendingRef.current = true;
1936
+ }
1907
1937
  if (viewModeRef.current === "fit") {
1908
- if (document.visibilityState === "visible") {
1909
- attachClaimPendingRef.current = true;
1910
- }
1911
1938
  fitWhenReady(12, () => termRef.current?.scrollToBottom());
1912
1939
  }
1913
1940
  break;
@@ -4331,11 +4358,8 @@ const App = () => {
4331
4358
  <div className="quick-group">
4332
4359
  <button type="button" className="quick-btn" onClick={() => {
4333
4360
  // An explicit Fit is a deliberate act: claim the size, even
4334
- // from a peer that owns it.
4335
- attachClaimPendingRef.current = true;
4336
- deliberateAttachRef.current = true;
4337
- fitToViewport();
4338
- handleResize();
4361
+ // from a peer that owns it, and even if we think we fit.
4362
+ claimSizeHere(true);
4339
4363
  }}>
4340
4364
  Fit
4341
4365
  </button>
@@ -4564,6 +4588,10 @@ const App = () => {
4564
4588
  if (!isMobile) {
4565
4589
  termRef.current?.focus();
4566
4590
  }
4591
+ // A click here is a human act on this surface, so it may
4592
+ // take a size some other window left behind. No-op when
4593
+ // the session already fits this viewport.
4594
+ claimSizeHere();
4567
4595
  }}
4568
4596
  onDragEnter={(e) => {
4569
4597
  if (Array.from(e.dataTransfer?.types || []).includes("Files")) {
@@ -816,6 +816,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
816
816
  };
817
817
  const claimRef = useRef(claimTileSize);
818
818
  claimRef.current = claimTileSize;
819
+ // Set by the watch effect so an explicit claim can chase the new size.
820
+ const settleRef = useRef<((attempt: number) => void) | null>(null);
819
821
 
820
822
  // Watch mode: full-screen repaints from the daemon grid. One request, one
821
823
  // parse pass (RIS + serialized screen), no flicker — the same bytes the
@@ -850,10 +852,11 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
850
852
  });
851
853
  } catch { /* keep the last frame */ }
852
854
  };
853
- // Claim first, then CHASE it: poll the grid on a fast schedule until it
854
- // reports the claimed dims (the PTY resize → app redraw → grid ingest
855
- // pipeline takes a few hundred ms). One straggling 5s poll slot per tile
856
- // is what made refits look one-by-one.
855
+ // CHASE a claim: poll the grid on a fast schedule until it reports the
856
+ // claimed dims (the PTY resize → app redraw → grid ingest pipeline takes
857
+ // a few hundred ms). One straggling 5s poll slot per tile is what made
858
+ // refits look one-by-one. Only the ⤢ button claims from here now — a
859
+ // tile the user is merely looking at must not resize the session.
857
860
  const settle = (attempt: number) => {
858
861
  if (cancelled) return;
859
862
  const claimed = lastClaimRef.current;
@@ -863,23 +866,32 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
863
866
  paint();
864
867
  if (attempt < 5) window.setTimeout(() => settle(attempt + 1), 200 + attempt * 250);
865
868
  };
866
- claimRef.current(() => { settle(0); });
869
+ settleRef.current = settle;
867
870
  paint();
868
871
  const id = window.setInterval(paint, LIVETILE_POLL_MS);
869
- // Tile geometry changes (zoom step, window resize) re-claim, debounced —
870
- // "all session tiles autofit when the session view comes up and when
871
- // resize happens".
872
+ // Tile geometry changes (zoom step, window resize) re-render this tile at
873
+ // the session's true grid, scaled. They used to re-CLAIM, which meant
874
+ // opening or resizing the wall silently resized every session on it —
875
+ // sessions ended up wearing the tile grid (121x32, 132x34) that nobody
876
+ // had ever typed in. The size follows what you act in, not what you look
877
+ // at; the ⤢ badge is how you hand a tile's shape to the session.
872
878
  let resizeDebounce = 0;
873
879
  const ro = typeof ResizeObserver !== "undefined"
874
880
  ? new ResizeObserver(() => {
875
881
  window.clearTimeout(resizeDebounce);
876
882
  resizeDebounce = window.setTimeout(() => {
877
- if (!cancelled) claimRef.current(() => { settle(0); });
883
+ if (!cancelled) paint();
878
884
  }, 350);
879
885
  })
880
886
  : null;
881
887
  if (boxRef.current) ro?.observe(boxRef.current);
882
- return () => { cancelled = true; window.clearInterval(id); window.clearTimeout(resizeDebounce); ro?.disconnect(); };
888
+ return () => {
889
+ cancelled = true;
890
+ settleRef.current = null;
891
+ window.clearInterval(id);
892
+ window.clearTimeout(resizeDebounce);
893
+ ro?.disconnect();
894
+ };
883
895
  }, [live, room]);
884
896
 
885
897
  // Live mode: the same terminal, now fed by the room's websocket. Size is
@@ -1167,7 +1179,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
1167
1179
  onClick={(e) => {
1168
1180
  e.stopPropagation();
1169
1181
  panRef.current = null; // back to the automatic anchor
1170
- claimRef.current();
1182
+ claimRef.current(() => settleRef.current?.(0));
1171
1183
  }}
1172
1184
  >
1173
1185
  <span className="offgrid-glyph" aria-hidden="true">⤢</span>
@@ -0,0 +1,41 @@
1
+ // Who may take a session's terminal size.
2
+ //
3
+ // The PTY has one size, shared by everyone looking at it, and it is sticky:
4
+ // whatever was claimed last stays until someone claims again. So the rule is
5
+ // deliberately narrow — only a surface the human just ACTED IN may take it.
6
+ // Merely having a session on screen is not an act: a wall of tiles, a
7
+ // background tab, a reconnect after a tunnel blip and a pane you are not
8
+ // typing in all leave the size alone and render what they are given, scaled.
9
+
10
+ export type ViewMode = "fit" | "full";
11
+ export type Size = { cols: number; rows: number };
12
+
13
+ /**
14
+ * An attach claims the size only when the human just opened THIS session
15
+ * here: a fresh page load, a switch, or closing the wall into it. A
16
+ * reconnect is the same socket healing itself, and a hidden tab is nobody
17
+ * looking, so neither claims.
18
+ *
19
+ * Manual view mode never auto-claims at all: its whole point is that the
20
+ * user sets the size themselves.
21
+ */
22
+ export const claimOnAttach = (o: { viewMode: ViewMode; visible: boolean; deliberate: boolean }): boolean =>
23
+ o.viewMode === "fit" && o.visible && o.deliberate;
24
+
25
+ /**
26
+ * A click inside the terminal is an act on this surface, so it may take the
27
+ * size — but only when that would actually change something. Already owning
28
+ * the session at this viewport's fit means a click is just a click, and must
29
+ * not push a resize (it would land on every selection drag).
30
+ */
31
+ export const claimOnClick = (o: {
32
+ viewMode: ViewMode;
33
+ natural: Size | null;
34
+ active: Size | null;
35
+ owned: boolean;
36
+ }): boolean => {
37
+ if (o.viewMode !== "fit" || !o.natural) return false;
38
+ if (!o.owned) return true;
39
+ if (!o.active) return true;
40
+ return o.active.cols !== o.natural.cols || o.active.rows !== o.natural.rows;
41
+ };
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { claimOnAttach, claimOnClick } from "../src/utils/sizeClaim";
3
+
4
+ describe("claimOnAttach — only a deliberate open takes the size", () => {
5
+ const base = { viewMode: "fit" as const, visible: true, deliberate: true };
6
+ it("claims when the human opened this session here", () => {
7
+ expect(claimOnAttach(base)).toBe(true);
8
+ });
9
+ it("does not claim on a reconnect, in a hidden tab, or in Manual mode", () => {
10
+ expect(claimOnAttach({ ...base, deliberate: false })).toBe(false);
11
+ expect(claimOnAttach({ ...base, visible: false })).toBe(false);
12
+ expect(claimOnAttach({ ...base, viewMode: "full" })).toBe(false);
13
+ });
14
+ });
15
+
16
+ describe("claimOnClick — a click takes the size only when it changes something", () => {
17
+ const natural = { cols: 171, rows: 40 };
18
+ it("claims when someone else holds the size", () => {
19
+ expect(claimOnClick({ viewMode: "fit", natural, active: { cols: 56, rows: 32 }, owned: false })).toBe(true);
20
+ });
21
+ it("claims when we own a size that is no longer our fit", () => {
22
+ expect(claimOnClick({ viewMode: "fit", natural, active: { cols: 132, rows: 34 }, owned: true })).toBe(true);
23
+ });
24
+ it("stays quiet when the session is already at this viewport's fit", () => {
25
+ expect(claimOnClick({ viewMode: "fit", natural, active: { ...natural }, owned: true })).toBe(false);
26
+ });
27
+ it("never claims in Manual mode or before the terminal can be measured", () => {
28
+ expect(claimOnClick({ viewMode: "full", natural, active: null, owned: false })).toBe(false);
29
+ expect(claimOnClick({ viewMode: "fit", natural: null, active: null, owned: false })).toBe(false);
30
+ });
31
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.54",
3
+ "version": "0.11.55",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {