hop2 0.11.53 → 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")) {
@@ -28,7 +28,8 @@ import {
28
28
  type SessionOriginScope,
29
29
  type SwitcherFolder,
30
30
  type SwitcherSession,
31
- type SwitcherSortMode
31
+ type SwitcherSortMode,
32
+ insertAfter
32
33
  } from "../utils/switcherModel";
33
34
  import { scanKeyboardProtocol } from "../utils/keyboardProtocol";
34
35
  import { DigestCard } from "./DigestCard";
@@ -815,6 +816,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
815
816
  };
816
817
  const claimRef = useRef(claimTileSize);
817
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);
818
821
 
819
822
  // Watch mode: full-screen repaints from the daemon grid. One request, one
820
823
  // parse pass (RIS + serialized screen), no flicker — the same bytes the
@@ -849,10 +852,11 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
849
852
  });
850
853
  } catch { /* keep the last frame */ }
851
854
  };
852
- // Claim first, then CHASE it: poll the grid on a fast schedule until it
853
- // reports the claimed dims (the PTY resize → app redraw → grid ingest
854
- // pipeline takes a few hundred ms). One straggling 5s poll slot per tile
855
- // 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.
856
860
  const settle = (attempt: number) => {
857
861
  if (cancelled) return;
858
862
  const claimed = lastClaimRef.current;
@@ -862,23 +866,32 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
862
866
  paint();
863
867
  if (attempt < 5) window.setTimeout(() => settle(attempt + 1), 200 + attempt * 250);
864
868
  };
865
- claimRef.current(() => { settle(0); });
869
+ settleRef.current = settle;
866
870
  paint();
867
871
  const id = window.setInterval(paint, LIVETILE_POLL_MS);
868
- // Tile geometry changes (zoom step, window resize) re-claim, debounced —
869
- // "all session tiles autofit when the session view comes up and when
870
- // 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.
871
878
  let resizeDebounce = 0;
872
879
  const ro = typeof ResizeObserver !== "undefined"
873
880
  ? new ResizeObserver(() => {
874
881
  window.clearTimeout(resizeDebounce);
875
882
  resizeDebounce = window.setTimeout(() => {
876
- if (!cancelled) claimRef.current(() => { settle(0); });
883
+ if (!cancelled) paint();
877
884
  }, 350);
878
885
  })
879
886
  : null;
880
887
  if (boxRef.current) ro?.observe(boxRef.current);
881
- 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
+ };
882
895
  }, [live, room]);
883
896
 
884
897
  // Live mode: the same terminal, now fed by the room's websocket. Size is
@@ -1166,7 +1179,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
1166
1179
  onClick={(e) => {
1167
1180
  e.stopPropagation();
1168
1181
  panRef.current = null; // back to the automatic anchor
1169
- claimRef.current();
1182
+ claimRef.current(() => settleRef.current?.(0));
1170
1183
  }}
1171
1184
  >
1172
1185
  <span className="offgrid-glyph" aria-hidden="true">⤢</span>
@@ -2452,11 +2465,16 @@ export const SessionSwitcher = ({
2452
2465
  headers: { "Content-Type": "application/json" },
2453
2466
  body: JSON.stringify({ internalName: sessionKey(s), ...(target ? { target } : {}) })
2454
2467
  });
2455
- const data = await res.json().catch(() => ({} as { error?: string; name?: string; kind?: string; extract?: { turns?: number; bytes?: number } }));
2468
+ const data = await res.json().catch(() => ({} as { error?: string; name?: string; internalName?: string; kind?: string; extract?: { turns?: number; bytes?: number } }));
2456
2469
  if (!res.ok) {
2457
2470
  onNotice(data.error || (target ? "Handoff failed" : "Fork failed"));
2458
2471
  return;
2459
2472
  }
2473
+ // The new session belongs beside its source: the daemon files it in
2474
+ // the same folder; on a hand-arranged wall it takes the next slot.
2475
+ if (typeof data.internalName === "string" && data.internalName) {
2476
+ persistManualOrder(insertAfter(manualOrder, sessionKey(s), data.internalName));
2477
+ }
2460
2478
  if (data.kind === "handoff") {
2461
2479
  const kb = Math.max(1, Math.round((data.extract?.bytes || 0) / 1024));
2462
2480
  onNotice(`${data.name} — ${data.extract?.turns ?? "?"} turns handed off (${kb} KB); it reads them first`);
@@ -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
+ };
@@ -266,6 +266,19 @@ const orderManually = (sessions: SwitcherSession[], manualOrder: string[]): Swit
266
266
  });
267
267
  };
268
268
 
269
+ /**
270
+ * Place `key` right after `afterKey` in a manual order. A source the user
271
+ * never placed leaves the order alone: both then sort by name at the tail,
272
+ * where a fork's name already follows its source's.
273
+ */
274
+ export const insertAfter = (order: string[], afterKey: string, key: string): string[] => {
275
+ const at = order.indexOf(afterKey);
276
+ if (at < 0 || afterKey === key) return order;
277
+ const without = order.filter((k) => k !== key);
278
+ const i = without.indexOf(afterKey);
279
+ return [...without.slice(0, i + 1), key, ...without.slice(i + 1)];
280
+ };
281
+
269
282
  /**
270
283
  * Split sessions into the user's folders plus everything still loose.
271
284
  * Folders belong to MANUAL mode only: Recent is ordered by the machine and
@@ -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
+ });
@@ -5,7 +5,8 @@ import {
5
5
  filterSessionsByOrigin,
6
6
  projectKey,
7
7
  relativeTime,
8
- type SwitcherSession
8
+ type SwitcherSession,
9
+ insertAfter
9
10
  } from "../src/utils/switcherModel";
10
11
 
11
12
  const mk = (over: Partial<SwitcherSession> & { name: string }): SwitcherSession => ({
@@ -523,3 +524,15 @@ describe("origin scope vs foldered sessions", () => {
523
524
  expect(filterSessionsByOrigin(sessions, "agent").map((s) => s.name)).toContain("mybot");
524
525
  });
525
526
  });
527
+
528
+ describe("insertAfter — a fork takes the slot after its source", () => {
529
+ it("inserts right after a placed source, moving an existing entry", () => {
530
+ expect(insertAfter(["a", "b", "c"], "a", "x")).toEqual(["a", "x", "b", "c"]);
531
+ expect(insertAfter(["a", "b", "x"], "a", "x")).toEqual(["a", "x", "b"]);
532
+ });
533
+ it("leaves the order alone when the source was never placed, or is itself", () => {
534
+ const order = ["a", "b"];
535
+ expect(insertAfter(order, "zzz", "x")).toBe(order);
536
+ expect(insertAfter(order, "a", "a")).toBe(order);
537
+ });
538
+ });
package/hop CHANGED
@@ -11769,18 +11769,24 @@ happens until someone clicks it.
11769
11769
  saveDefinition: false,
11770
11770
  command
11771
11771
  });
11772
+ // Filed where its source is. The effective config is the
11773
+ // wall's own truth for the folder (saved, runtime and
11774
+ // durable layers merged); the durable meta alone missed
11775
+ // sessions filed before folders were written there.
11776
+ const folderId = config.folderId || meta?.folderId || null;
11772
11777
  rememberRuntimeSessionMetadata(forkInternal, {
11773
11778
  type: 'terminal',
11774
11779
  displayName: forkName,
11775
11780
  cwd,
11776
11781
  createdBy,
11777
- folderId: meta?.folderId || undefined
11782
+ folderId: folderId || undefined
11778
11783
  });
11779
11784
  writeClaudeSessionMeta(forkInternal, {
11780
11785
  internalName: forkInternal,
11781
11786
  displayName: forkName,
11782
11787
  cwd,
11783
11788
  createdBy,
11789
+ folderId: folderId || undefined,
11784
11790
  forkedFrom: source,
11785
11791
  ...(handoff ? { handoffFrom: sourceKind, handoffTo: wantTarget } : {})
11786
11792
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.53",
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": {