hop2 0.11.54 → 0.11.56
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/apps/web/src/App.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
//
|
|
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
|
-
|
|
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-
|
|
870
|
-
//
|
|
871
|
-
//
|
|
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)
|
|
883
|
+
if (!cancelled) paint();
|
|
878
884
|
}, 350);
|
|
879
885
|
})
|
|
880
886
|
: null;
|
|
881
887
|
if (boxRef.current) ro?.observe(boxRef.current);
|
|
882
|
-
return () => {
|
|
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