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.
@@ -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
- // Transform the SCREEN, not `.xterm`. `.xterm` sets overflow:hidden on
501
- // itself, so translating it slides an already-clipped window: the rows
502
- // outside never appear no matter how far you pan (and a rect-based
503
- // measurement is fooled, because getBoundingClientRect reports clipped
504
- // geometry as if visible — which is how the first version of this
505
- // "fixed" the bottom crop on paper while the pixels stayed hidden).
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
- const boxRect = inner.getBoundingClientRect(); // .xterm IS the clip
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) screen.style.transform = `translate(${px}px, ${py}px)`;
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
- const clip = box.querySelector(".xterm") as HTMLElement | null;
593
- if (!screen || !clip) return null;
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
- const [filter, setFilter] = useState("");
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>(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
- setOriginScope("user");
1605
- setFilter("");
1606
- setSheet(null);
1607
- setCreating(false);
1608
- setCreateDraft("");
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,