hop2 0.11.5 → 0.11.7
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/components/SessionSwitcher.tsx +112 -21
- package/hay/apps/web/test/SessionSwitcher.test.tsx +4 -0
- package/hay-web/assets/index-bmNjFOSa.js +133 -0
- package/hay-web/assets/{index-CJHvzXqn.js.map → index-bmNjFOSa.js.map} +1 -1
- package/hay-web/index.html +1 -1
- package/package.json +1 -1
- package/hay-web/assets/index-CJHvzXqn.js +0 -133
|
@@ -497,17 +497,16 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
497
497
|
// tolerance, so no resize is ever asked for and the crop is permanent.
|
|
498
498
|
// Translate so the LAST row is flush with the box bottom; overflow now
|
|
499
499
|
// crops the oldest rows off the top, which is what scrollback is for.
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
// Moving the screen inside that clip is what actually reveals rows.
|
|
500
|
+
// WHICH ELEMENT CLIPS, measured rather than assumed (both earlier
|
|
501
|
+
// attempts got this wrong). `.switcher-focus-term` is the clip: 450px
|
|
502
|
+
// tall with overflow:hidden. `.xterm` is NOT — it sizes to its content
|
|
503
|
+
// (475px for a 25-row grid), so its own overflow:hidden clips nothing,
|
|
504
|
+
// and measuring against it made the overflow compute as ZERO and the
|
|
505
|
+
// anchor a no-op. Measure the CLIP, move the terminal inside it.
|
|
507
506
|
inner.style.transform = "";
|
|
508
|
-
screen.style.transformOrigin = "bottom left";
|
|
509
507
|
screen.style.transform = "";
|
|
510
|
-
|
|
508
|
+
inner.style.transformOrigin = "bottom left";
|
|
509
|
+
const boxRect = box.getBoundingClientRect(); // the clip
|
|
511
510
|
const screenRect = screen.getBoundingClientRect(); // reads after the reset above
|
|
512
511
|
const overflowX = Math.max(0, Math.round(screenRect.width - boxRect.width));
|
|
513
512
|
const overflowY = Math.max(0, Math.round(screenRect.height - boxRect.height));
|
|
@@ -519,7 +518,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
519
518
|
const px = pan ? Math.max(-overflowX, Math.min(0, pan.x)) : 0;
|
|
520
519
|
const py = pan ? Math.max(autoY, Math.min(0, pan.y)) : autoY;
|
|
521
520
|
if (pan) panRef.current = { x: px, y: py };
|
|
522
|
-
if (px !== 0 || py !== 0)
|
|
521
|
+
if (px !== 0 || py !== 0) inner.style.transform = `translate(${px}px, ${py}px)`;
|
|
523
522
|
// Only publish on CHANGE: rescale runs from a ResizeObserver, and
|
|
524
523
|
// setting a fresh object every pass would re-render (and re-observe)
|
|
525
524
|
// in a loop.
|
|
@@ -589,9 +588,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
589
588
|
let panning: { sx: number; sy: number; ox: number; oy: number } | null = null;
|
|
590
589
|
const panBounds = () => {
|
|
591
590
|
const screen = box.querySelector(".xterm-screen") as HTMLElement | null;
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const boxRect = clip.getBoundingClientRect(); // same clip rescale uses
|
|
591
|
+
if (!screen) return null;
|
|
592
|
+
const boxRect = box.getBoundingClientRect(); // same clip rescale uses
|
|
595
593
|
const scrRect = screen.getBoundingClientRect();
|
|
596
594
|
return {
|
|
597
595
|
overflowX: Math.max(0, scrRect.width - boxRect.width),
|
|
@@ -1094,7 +1092,20 @@ export const SessionSwitcher = ({
|
|
|
1094
1092
|
onToggleKeyboard,
|
|
1095
1093
|
onFind
|
|
1096
1094
|
}: Props) => {
|
|
1097
|
-
|
|
1095
|
+
// View state survives a REFRESH but not a new tab: sessionStorage, not
|
|
1096
|
+
// localStorage. Reloading the page (or hop reconnecting) used to dump the
|
|
1097
|
+
// filter you had typed, the tile you had focused, and where you were
|
|
1098
|
+
// scrolled — none of which is shareable state that belongs in the URL,
|
|
1099
|
+
// and all of which you expect to still be there afterwards.
|
|
1100
|
+
const [filter, setFilter] = useState(() => {
|
|
1101
|
+
try { return sessionStorage.getItem("hay_switcher_filter") || ""; } catch { return ""; }
|
|
1102
|
+
});
|
|
1103
|
+
useEffect(() => {
|
|
1104
|
+
try {
|
|
1105
|
+
if (filter) sessionStorage.setItem("hay_switcher_filter", filter);
|
|
1106
|
+
else sessionStorage.removeItem("hay_switcher_filter");
|
|
1107
|
+
} catch { /* private mode */ }
|
|
1108
|
+
}, [filter]);
|
|
1098
1109
|
const [originScope, setOriginScope] = useState<SessionOriginScope>("user");
|
|
1099
1110
|
// Tile zoom: bigger tiles show more of each terminal preview.
|
|
1100
1111
|
const [zoom, setZoom] = useState(() => {
|
|
@@ -1109,6 +1120,37 @@ export const SessionSwitcher = ({
|
|
|
1109
1120
|
// Grid content width, kept fresh across viewport/panel resizes — it decides
|
|
1110
1121
|
// how far the zoom ladder is climbable on THIS screen.
|
|
1111
1122
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
1123
|
+
// Where you were scrolled on the wall, restored once the cards exist.
|
|
1124
|
+
const scrollRestoredRef = useRef(false);
|
|
1125
|
+
useEffect(() => {
|
|
1126
|
+
const el = scrollRef.current;
|
|
1127
|
+
if (!el) return;
|
|
1128
|
+
const onScroll = () => {
|
|
1129
|
+
try { sessionStorage.setItem("hay_switcher_scroll", String(el.scrollTop)); } catch { /* private mode */ }
|
|
1130
|
+
};
|
|
1131
|
+
el.addEventListener("scroll", onScroll, { passive: true });
|
|
1132
|
+
return () => el.removeEventListener("scroll", onScroll);
|
|
1133
|
+
}, [open]);
|
|
1134
|
+
useEffect(() => {
|
|
1135
|
+
if (!open || scrollRestoredRef.current) return;
|
|
1136
|
+
let saved = 0;
|
|
1137
|
+
try { saved = Number(sessionStorage.getItem("hay_switcher_scroll") || 0); } catch { /* private mode */ }
|
|
1138
|
+
if (!saved) { scrollRestoredRef.current = true; return; }
|
|
1139
|
+
// Cards mount over a couple of frames; retry briefly until the wall is
|
|
1140
|
+
// tall enough for the offset to mean anything.
|
|
1141
|
+
let tries = 0;
|
|
1142
|
+
const tick = () => {
|
|
1143
|
+
const el = scrollRef.current;
|
|
1144
|
+
if (!el) return;
|
|
1145
|
+
if (el.scrollHeight > el.clientHeight + saved || tries++ > 20) {
|
|
1146
|
+
el.scrollTop = saved;
|
|
1147
|
+
scrollRestoredRef.current = true;
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
requestAnimationFrame(tick);
|
|
1151
|
+
};
|
|
1152
|
+
requestAnimationFrame(tick);
|
|
1153
|
+
}, [open]);
|
|
1112
1154
|
const [gridWidth, setGridWidth] = useState(0);
|
|
1113
1155
|
useEffect(() => {
|
|
1114
1156
|
if (!open) return;
|
|
@@ -1351,7 +1393,15 @@ export const SessionSwitcher = ({
|
|
|
1351
1393
|
const [sheet, setSheet] = useState<Sheet | null>(null);
|
|
1352
1394
|
// The wall's single interactive tile. Cleared on close / zooming below the
|
|
1353
1395
|
// interactive threshold.
|
|
1354
|
-
const [focusedKey, setFocusedKey] = useState<string | null>(
|
|
1396
|
+
const [focusedKey, setFocusedKey] = useState<string | null>(() => {
|
|
1397
|
+
try { return sessionStorage.getItem("hay_switcher_focus"); } catch { return null; }
|
|
1398
|
+
});
|
|
1399
|
+
useEffect(() => {
|
|
1400
|
+
try {
|
|
1401
|
+
if (focusedKey) sessionStorage.setItem("hay_switcher_focus", focusedKey);
|
|
1402
|
+
else sessionStorage.removeItem("hay_switcher_focus");
|
|
1403
|
+
} catch { /* private mode */ }
|
|
1404
|
+
}, [focusedKey]);
|
|
1355
1405
|
useEffect(() => { if (!open) setFocusedKey(null); }, [open]);
|
|
1356
1406
|
useEffect(() => { if (!interactiveTiles) setFocusedKey(null); }, [interactiveTiles]);
|
|
1357
1407
|
const [renameDraft, setRenameDraft] = useState("");
|
|
@@ -1599,14 +1649,30 @@ export const SessionSwitcher = ({
|
|
|
1599
1649
|
|
|
1600
1650
|
// Every visit starts on user-owned sessions. Agent sessions stay one toggle
|
|
1601
1651
|
// away without competing for the default view.
|
|
1652
|
+
const restoredFilterRef = useRef<string>((() => {
|
|
1653
|
+
try { return sessionStorage.getItem("hay_switcher_filter") || ""; } catch { return ""; }
|
|
1654
|
+
})());
|
|
1655
|
+
const openedOnceRef = useRef(false);
|
|
1602
1656
|
useLayoutEffect(() => {
|
|
1603
|
-
if (open)
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1657
|
+
if (!open) return;
|
|
1658
|
+
// OPENING the wall resets it — but a page that LOADS with the wall
|
|
1659
|
+
// already up is not an open, it is a refresh, and it must keep the
|
|
1660
|
+
// filter/focus/scroll it restored. Without this the reset ran on mount
|
|
1661
|
+
// and wiped the restore a frame after it happened.
|
|
1662
|
+
if (!openedOnceRef.current) {
|
|
1663
|
+
openedOnceRef.current = true;
|
|
1664
|
+
if (restoredFilterRef.current) {
|
|
1665
|
+
setSheet(null);
|
|
1666
|
+
setCreating(false);
|
|
1667
|
+
setCreateDraft("");
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1609
1670
|
}
|
|
1671
|
+
setOriginScope("user");
|
|
1672
|
+
setFilter("");
|
|
1673
|
+
setSheet(null);
|
|
1674
|
+
setCreating(false);
|
|
1675
|
+
setCreateDraft("");
|
|
1610
1676
|
}, [open]);
|
|
1611
1677
|
|
|
1612
1678
|
|
|
@@ -2142,6 +2208,31 @@ export const SessionSwitcher = ({
|
|
|
2142
2208
|
});
|
|
2143
2209
|
const data = await res.json().catch(() => ({} as { name?: string; error?: string }));
|
|
2144
2210
|
if (!res.ok || !data.name) {
|
|
2211
|
+
// A name collision is the common failure, and "already in use" alone
|
|
2212
|
+
// leaves the user hunting: the holder may be filed in another folder,
|
|
2213
|
+
// or be an agent-created session they never opened. Name it, say
|
|
2214
|
+
// where it lives, and put it on screen — the answer to "it doesn't
|
|
2215
|
+
// work" should be the session that was in the way.
|
|
2216
|
+
const taken = res.status === 409
|
|
2217
|
+
? wallSessions.find((s) => (s.displayName || s.name).toLowerCase() === next.toLowerCase())
|
|
2218
|
+
: null;
|
|
2219
|
+
if (taken) {
|
|
2220
|
+
const home = folders.find((f) => f.id === taken.folderId);
|
|
2221
|
+
onNotice(`"${taken.displayName || taken.name}" already exists${home ? ` in ${home.name}` : ""} — showing it`);
|
|
2222
|
+
setCreating(false);
|
|
2223
|
+
setCreateDraft("");
|
|
2224
|
+
setCreateInFolder(null);
|
|
2225
|
+
const key = sessionKey(taken);
|
|
2226
|
+
setFocusedKey(key);
|
|
2227
|
+
const idx = navIndexByKey.get(key);
|
|
2228
|
+
if (typeof idx === "number") {
|
|
2229
|
+
setKbdIndex(idx);
|
|
2230
|
+
requestAnimationFrame(() => {
|
|
2231
|
+
document.querySelector(`[data-nav-index="${idx}"]`)?.scrollIntoView({ block: "center" });
|
|
2232
|
+
});
|
|
2233
|
+
}
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2145
2236
|
onNotice(data.error || "Failed to create session");
|
|
2146
2237
|
return;
|
|
2147
2238
|
}
|
|
@@ -32,6 +32,10 @@ const props = {
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
beforeEach(() => {
|
|
35
|
+
// The wall persists filter/focus/scroll in sessionStorage so a REFRESH
|
|
36
|
+
// keeps your view. Tests share one jsdom, so a filter typed in one case
|
|
37
|
+
// would restore into the next and silently empty the wall.
|
|
38
|
+
sessionStorage.clear();
|
|
35
39
|
const values = new Map<string, string>();
|
|
36
40
|
vi.stubGlobal("localStorage", {
|
|
37
41
|
getItem: (key: string) => values.get(key) ?? null,
|