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.
- package/hay/apps/web/src/App.tsx +37 -9
- package/hay/apps/web/src/components/SessionSwitcher.tsx +31 -13
- package/hay/apps/web/src/utils/sizeClaim.ts +41 -0
- package/hay/apps/web/src/utils/switcherModel.ts +13 -0
- package/hay/apps/web/test/sizeClaim.test.ts +31 -0
- package/hay/apps/web/test/switcherModel.test.ts +14 -1
- package/hop +7 -1
- package/package.json +1 -1
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")) {
|
|
@@ -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
|
-
//
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
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
|
-
|
|
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-
|
|
869
|
-
//
|
|
870
|
-
//
|
|
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)
|
|
883
|
+
if (!cancelled) paint();
|
|
877
884
|
}, 350);
|
|
878
885
|
})
|
|
879
886
|
: null;
|
|
880
887
|
if (boxRef.current) ro?.observe(boxRef.current);
|
|
881
|
-
return () => {
|
|
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:
|
|
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