hop2 0.11.1 → 0.11.2

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.
@@ -64,8 +64,12 @@ jobs:
64
64
  node-version: 22
65
65
  registry-url: https://registry.npmjs.org
66
66
 
67
- # Trusted publishing needs npm >= 11.5.1; the runner's bundled npm is older.
68
- - run: npm install -g npm@latest
67
+ # Trusted publishing needs npm >= 11.5.1 and the runner ships older.
68
+ # Pinned to the 11.x line rather than @latest: npm 12 requires Node
69
+ # >= 22.22, so `npm@latest` breaks the moment npm raises its engine
70
+ # floor above whatever Node this workflow pins — which is exactly how
71
+ # the first nebula-notebook publish failed.
72
+ - run: npm install -g npm@^11
69
73
 
70
74
  # Nothing new since the last nightly? Then there is nothing to publish —
71
75
  # quiet days should not mint versions. npm records the commit each
@@ -28,6 +28,12 @@ export type ClientInfo = {
28
28
  // Optional per-connection cap for the join snapshot (clamped to the
29
29
  // room-level bound). Monitors request ~64KB.
30
30
  replayBytes?: number;
31
+ // False = skip the equal-size attach "wiggle" (the −1/+1 column repaint
32
+ // nudge). Wall tiles decline it: they arrive already showing the current
33
+ // grid (the daemon's headless screen, complete — not a raw byte tail), so
34
+ // the nudge's two SIGWINCHes only produce a visible reflow of the running
35
+ // app at the exact moment the tile goes live — the "twitch".
36
+ nudge?: boolean;
31
37
  };
32
38
 
33
39
  export type RoomCreateOptions = {
@@ -371,6 +377,7 @@ export class Room extends EventEmitter {
371
377
  // anyway; when sizes are EQUAL nothing would repaint, so nudge: a one-
372
378
  // column wiggle (tmux's redraw trick) makes full-screen apps repaint.
373
379
  if (
380
+ info.nudge !== false &&
374
381
  client.cols === this.activeCols &&
375
382
  client.rows === this.activeRows &&
376
383
  this.activeCols > 4 &&
@@ -100,6 +100,27 @@ describe("Room", () => {
100
100
  expect(capturedOptions?.shell).toBe("/bin/zsh");
101
101
  });
102
102
 
103
+ it("an equal-size attach nudges a repaint unless the client declines", () => {
104
+ let ptyInstance: FakePty | null = null;
105
+ const factory: PtyFactory = () => {
106
+ ptyInstance = new FakePty() as unknown as FakePty;
107
+ return ptyInstance as any;
108
+ };
109
+ const manager = new RoomManager(factory);
110
+ const room = manager.getRoom("wiggle", { cols: 80, rows: 24 }, "/tmp");
111
+
112
+ // Default attach at the PTY's exact size: the −1 wiggle fires (the
113
+ // bounce-back half is on a timer; the first resize is the evidence).
114
+ room.attachClient({ id: "a", name: "Alex", colorIndex: 0, cols: 80, rows: 24 }, new FakeSocket());
115
+ expect(ptyInstance!.resizes.some((r: { cols: number }) => r.cols === 79)).toBe(true);
116
+
117
+ // A wall tile declines (nudge: false): it already shows the current
118
+ // grid, and the wiggle's SIGWINCH reflow is the click-twitch. No resize.
119
+ const before = ptyInstance!.resizes.length;
120
+ room.attachClient({ id: "b", name: "Tile", colorIndex: 1, cols: 80, rows: 24, nudge: false }, new FakeSocket());
121
+ expect(ptyInstance!.resizes.length).toBe(before);
122
+ });
123
+
103
124
  it("broadcasts pty output to all clients", () => {
104
125
  let ptyInstance: FakePty | null = null;
105
126
  const factory: PtyFactory = () => {
@@ -375,6 +375,9 @@ const App = () => {
375
375
  const [sessions, setSessions] = useState<SessionInfo[]>([]);
376
376
  const [loadingSessions, setLoadingSessions] = useState(false);
377
377
  const [sessionsError, setSessionsError] = useState(false);
378
+ // Server-owned folders (Manual mode). Kept in App because the sessions
379
+ // fetch already carries them.
380
+ const [folders, setFolders] = useState<Array<{ id: string; name: string }>>([]);
378
381
  // Attention plumbing: tab-title alert for a bell in the attached session
379
382
  // while the tab is hidden; inline editors for display name / rename / create.
380
383
  const [titleAlert, setTitleAlert] = useState(false);
@@ -1132,6 +1135,16 @@ const App = () => {
1132
1135
  // Rooms the user asked full history for: their next connect replays the
1133
1136
  // server maximum instead of the fast bounded snapshot.
1134
1137
  const deepReplayRoomsRef = useRef(new Set<string>());
1138
+ // Guards for the automatic deep-history load. The buffer sits at viewport
1139
+ // 0 during every attach write (the terminal is reset before the snapshot
1140
+ // lands), so "y === 0" alone misfired the reload DURING attach on any
1141
+ // session busy enough to have a capped snapshot — which then restored the
1142
+ // viewport to old-top with follow-mode off: terminal parked above the
1143
+ // bottom, new output invisible until a manual scroll. The trigger now
1144
+ // requires a real gesture: a transition from scrolled-up to top, after the
1145
+ // snapshot has settled.
1146
+ const lastViewportYRef = useRef(0);
1147
+ const snapshotSettledAtRef = useRef(0);
1135
1148
  const snapshotWasCappedRef = useRef(false);
1136
1149
  // Set while an automatic deep-history reload is in flight: carries the old
1137
1150
  // buffer length so the post-reload snapshot can restore the reading
@@ -1309,7 +1322,14 @@ const App = () => {
1309
1322
  && (termRef.current.cols !== cols || termRef.current.rows !== rows)) {
1310
1323
  try { termRef.current.resize(cols, rows); } catch { /* keep local size */ }
1311
1324
  }
1312
- termRef.current.write(screen.data, () => termRef.current?.refresh(0, (termRef.current?.rows ?? 1) - 1));
1325
+ termRef.current.write(screen.data, () => {
1326
+ const t = termRef.current;
1327
+ if (!t) return;
1328
+ // Pin to the newest line — the fast paint is a stand-in for the
1329
+ // snapshot and must land where the snapshot would.
1330
+ t.scrollToBottom();
1331
+ t.refresh(0, t.rows - 1);
1332
+ });
1313
1333
  })
1314
1334
  .catch(() => { /* fast paint is best-effort */ });
1315
1335
 
@@ -1454,13 +1474,21 @@ const App = () => {
1454
1474
  if (pendingRestore) {
1455
1475
  // The user was reading at the top of the OLD buffer; that
1456
1476
  // content now sits newLen - oldLen lines down. Land there,
1457
- // not at the bottom — the reload should be invisible.
1477
+ // not at the bottom — the reload should be invisible. (This
1478
+ // used to fall through to scrollToBottom anyway, yanking the
1479
+ // view away from the spot it had just restored.)
1458
1480
  const line = Math.max(0, t.buffer.active.length - pendingRestore.oldLen);
1459
1481
  t.scrollToLine(line);
1460
- userScrolledUpRef.current = true;
1482
+ userScrolledUpRef.current = line < Math.max(0, t.buffer.active.length - t.rows);
1483
+ } else {
1484
+ t.scrollToBottom();
1485
+ userScrolledUpRef.current = false;
1461
1486
  }
1462
- t.scrollToBottom();
1463
1487
  t.refresh(0, t.rows - 1);
1488
+ // The snapshot has fully landed: only from here on can a
1489
+ // viewport-top event mean a user gesture.
1490
+ lastViewportYRef.current = t.buffer.active.viewportY;
1491
+ snapshotSettledAtRef.current = performance.now();
1464
1492
  });
1465
1493
  } else {
1466
1494
  writeToTerminal(message.data);
@@ -1735,6 +1763,15 @@ const App = () => {
1735
1763
 
1736
1764
  // Prevent browser from intercepting common terminal shortcuts
1737
1765
  terminal.attachCustomKeyEventHandler((event) => {
1766
+ // ⌘⏎ leaves full screen for the wall — the mirror of ⌘⏎ on a wall
1767
+ // tile entering it. One chord toggles the mode from either side.
1768
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey) {
1769
+ if (event.type === 'keydown') {
1770
+ event.preventDefault();
1771
+ setSwitcherOpen(true);
1772
+ }
1773
+ return false;
1774
+ }
1738
1775
  // Long-press SPACE = voice input (Claude sessions, when the browser has
1739
1776
  // SpeechRecognition). First press types its space with zero latency;
1740
1777
  // holding past the threshold erases it and dictates until release.
@@ -1865,12 +1902,14 @@ const App = () => {
1865
1902
  const onBufferScroll = () => {
1866
1903
  const { y, base } = scrollState();
1867
1904
  userScrolledUpRef.current = y < base;
1868
- // Hitting the very TOP of a bounded-replay buffer means the user wants
1869
- // more history than the fast snapshot carried — offer the full reload.
1870
1905
  // Top of a capped snapshot: the user wants more history than the fast
1871
- // snapshot carried. Load it AUTOMATICALLY — a "load full history"
1872
- // button was chrome for something the scroll gesture already said.
1873
- if (y === 0 && snapshotWasCappedRef.current) {
1906
+ // snapshot carried — load it automatically. "The user wants" must mean
1907
+ // a real gesture: arriving at 0 FROM above, on a settled snapshot.
1908
+ const cameFromAbove = lastViewportYRef.current > 0;
1909
+ const settled = snapshotSettledAtRef.current > 0
1910
+ && performance.now() - snapshotSettledAtRef.current > 600;
1911
+ lastViewportYRef.current = y;
1912
+ if (y === 0 && cameFromAbove && settled && snapshotWasCappedRef.current) {
1874
1913
  const room = activeSessionRoomRef.current;
1875
1914
  if (room && !deepReplayRoomsRef.current.has(room)) {
1876
1915
  deepReplayRoomsRef.current.add(room);
@@ -2337,7 +2376,7 @@ const App = () => {
2337
2376
  const row = Math.floor(((tapStartY - rect.top) / rect.height) * term.rows);
2338
2377
  if (col >= 0 && col < term.cols && row >= 0 && row < term.rows) {
2339
2378
  const url = extractUrlAtCell(term.buffer.active.viewportY + row, col);
2340
- if (url) setLinkPrompt(url);
2379
+ if (url) setLinkPrompt({ url, x: tapStartX, y: tapStartY });
2341
2380
  }
2342
2381
  }
2343
2382
  }
@@ -2512,6 +2551,32 @@ const App = () => {
2512
2551
  // eslint-disable-next-line react-hooks/exhaustive-deps
2513
2552
  }, [switcherOpen]);
2514
2553
 
2554
+ // THE RENDER INVARIANT, enforced continuously instead of per-transition.
2555
+ // Three rounds of patching individual transitions (wall close, tab
2556
+ // visibility, context loss, attach paths) each fixed the reproduced case
2557
+ // and missed a sibling — the class kept coming back as "renders only
2558
+ // after I scroll". Scrolling "fixed" it because a viewport move repaints
2559
+ // everything; so enforce what scrolling incidentally provides:
2560
+ // while following, the newest line is on screen — and the screen is
2561
+ // repainted from the buffer every tick, no matter which code path
2562
+ // forgot to.
2563
+ // A full refresh of ~50 rows at 0.7Hz is microseconds on the GPU renderer
2564
+ // and cheap on the DOM one; correctness beats elegance here.
2565
+ useEffect(() => {
2566
+ if (!session) return;
2567
+ const id = window.setInterval(() => {
2568
+ if (document.hidden || switcherOpenRef.current) return;
2569
+ const t = termRef.current;
2570
+ if (!t) return;
2571
+ try {
2572
+ const buf = t.buffer.active;
2573
+ if (!userScrolledUpRef.current && buf.viewportY < buf.baseY) t.scrollToBottom();
2574
+ t.refresh(0, t.rows - 1);
2575
+ } catch { /* disposed mid-tick */ }
2576
+ }, 1400);
2577
+ return () => window.clearInterval(id);
2578
+ }, [session]);
2579
+
2515
2580
  // Tab or window coming back to the foreground: same stale-frame risk as
2516
2581
  // the wall closing, from browser throttling rather than our own hiding.
2517
2582
  useEffect(() => {
@@ -2629,7 +2694,8 @@ const App = () => {
2629
2694
  tagline: typeof s.tagline === "string" ? s.tagline : undefined,
2630
2695
  parked: s.parked === true,
2631
2696
  archived: s.archived === true,
2632
- hasLocalCli: s.hasLocalCli === true
2697
+ hasLocalCli: s.hasLocalCli === true,
2698
+ folderId: typeof s.folderId === "string" ? s.folderId : null
2633
2699
  });
2634
2700
  }
2635
2701
 
@@ -2701,6 +2767,11 @@ const App = () => {
2701
2767
  // Drop a response superseded by a newer fetch started while this was
2702
2768
  // in flight (see the sequence-guard note above).
2703
2769
  if (seq !== fetchSeqRef.current) return;
2770
+ setFolders(Array.isArray(data.folders)
2771
+ ? data.folders
2772
+ .filter((f: { id?: string; name?: string }) => f && f.id && f.name)
2773
+ .map((f: { id: string; name: string }) => ({ id: String(f.id), name: String(f.name) }))
2774
+ : []);
2704
2775
  setSessions(list);
2705
2776
  setSessionsError(false);
2706
2777
  sessionListLoadedRef.current = true;
@@ -3151,7 +3222,7 @@ const App = () => {
3151
3222
  // layer disables native pointer events on terminal text, and remote apps
3152
3223
  // can't open the phone's browser — claude login prints a URL that was
3153
3224
  // otherwise un-tappable.)
3154
- const [linkPrompt, setLinkPrompt] = useState<string | null>(null);
3225
+ const [linkPrompt, setLinkPrompt] = useState<{ url: string; x: number; y: number } | null>(null);
3155
3226
  // Passkey nudge: biometric sign-in works but is invisible — the login page
3156
3227
  // only offers Touch ID / Face ID once a credential exists for this
3157
3228
  // hostname, so a user who never opened the settings drawer never learns it
@@ -3421,6 +3492,7 @@ const App = () => {
3421
3492
  sessions={sessions}
3422
3493
  currentRoom={null}
3423
3494
  dismissable={false}
3495
+ folders={folders}
3424
3496
  onClose={() => {}}
3425
3497
  onSwitch={(next) => {
3426
3498
  window.location.href = buildSessionPath(next.displayName || next.name);
@@ -4075,6 +4147,7 @@ const App = () => {
4075
4147
  onNotice={showToast}
4076
4148
  tileWsBase={resolveWsUrl()}
4077
4149
  onFocusSession={focusSessionInPlace}
4150
+ folders={folders}
4078
4151
  userName={name}
4079
4152
  terminalTheme={resolveTerminalTheme(themeMode)}
4080
4153
  onOpenSettings={() => {
@@ -4145,23 +4218,34 @@ const App = () => {
4145
4218
  </div>
4146
4219
  )}
4147
4220
  {linkPrompt && (
4148
- <div className="link-prompt" role="dialog" aria-label="Open link">
4149
- <span className="link-prompt-url">{linkPrompt}</span>
4221
+ <div
4222
+ className="context-menu link-menu"
4223
+ role="menu"
4224
+ aria-label="Open link"
4225
+ style={{
4226
+ left: Math.max(8, Math.min(linkPrompt.x, window.innerWidth - 268)),
4227
+ top: Math.max(8, Math.min(linkPrompt.y + 10, window.innerHeight - 150))
4228
+ }}
4229
+ >
4230
+ <span className="context-menu-title">{linkPrompt.url}</span>
4150
4231
  <button
4151
4232
  type="button"
4152
- className="link-prompt-open"
4233
+ role="menuitem"
4234
+ className="context-menu-item"
4153
4235
  onClick={() => {
4154
- window.open(linkPrompt, "_blank", "noopener");
4236
+ window.open(linkPrompt.url, "_blank", "noopener");
4155
4237
  setLinkPrompt(null);
4156
4238
  }}
4157
4239
  >
4158
- Open
4240
+ <span>Open link</span>
4159
4241
  </button>
4160
4242
  <button
4161
4243
  type="button"
4244
+ role="menuitem"
4245
+ className="context-menu-item"
4162
4246
  onClick={async () => {
4163
4247
  try {
4164
- await navigator.clipboard.writeText(linkPrompt);
4248
+ await navigator.clipboard.writeText(linkPrompt.url);
4165
4249
  showToast("Link copied");
4166
4250
  } catch {
4167
4251
  showToast("Clipboard denied — long-press the URL text instead");
@@ -4169,9 +4253,18 @@ const App = () => {
4169
4253
  setLinkPrompt(null);
4170
4254
  }}
4171
4255
  >
4172
- Copy
4256
+ <span>Copy link</span>
4257
+ </button>
4258
+ <div className="context-menu-separator" role="separator" />
4259
+ <button
4260
+ type="button"
4261
+ role="menuitem"
4262
+ className="context-menu-item"
4263
+ onClick={() => setLinkPrompt(null)}
4264
+ >
4265
+ <span>Cancel</span>
4266
+ <kbd>esc</kbd>
4173
4267
  </button>
4174
- <button type="button" aria-label="Dismiss" onClick={() => setLinkPrompt(null)}>✕</button>
4175
4268
  </div>
4176
4269
  )}
4177
4270
  {isMobile && (
@@ -0,0 +1,109 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+
3
+ // One context menu for the whole app.
4
+ //
5
+ // Right-click is a shortcut to the actions a surface already has — it must
6
+ // never be the ONLY way to reach something, and it must never invent a
7
+ // different vocabulary from the buttons beside it. So this renders the same
8
+ // items a surface's visible affordances expose, in the same order, with the
9
+ // same words.
10
+ //
11
+ // Deliberately NOT applied to the terminal: there, right-click already means
12
+ // something (paste on Linux/Windows, the native copy/paste menu everywhere),
13
+ // and mouse-reporting apps — Claude, vim, less — receive button events the
14
+ // moment tracking is on. Overriding it would either steal a click the app
15
+ // asked for or replace a menu people rely on with a worse one.
16
+
17
+ export type MenuItem =
18
+ | { kind?: "item"; label: string; onSelect: () => void; danger?: boolean; hint?: string; disabled?: boolean }
19
+ | { kind: "separator" };
20
+
21
+ export type MenuRequest = { x: number; y: number; items: MenuItem[] } | null;
22
+
23
+ const isItem = (i: MenuItem): i is Extract<MenuItem, { kind?: "item" }> => i.kind !== "separator";
24
+
25
+ export const ContextMenu = ({ request, onClose }: { request: MenuRequest; onClose: () => void }) => {
26
+ const ref = useRef<HTMLDivElement | null>(null);
27
+ const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
28
+ const [active, setActive] = useState(0);
29
+
30
+ // Clamp into the viewport BEFORE paint: a menu opened near an edge would
31
+ // otherwise flash off-screen and jump.
32
+ useLayoutEffect(() => {
33
+ if (!request) { setPos(null); return; }
34
+ const el = ref.current;
35
+ const w = el?.offsetWidth || 200;
36
+ const h = el?.offsetHeight || 200;
37
+ const pad = 8;
38
+ setPos({
39
+ left: Math.max(pad, Math.min(request.x, window.innerWidth - w - pad)),
40
+ top: Math.max(pad, Math.min(request.y, window.innerHeight - h - pad))
41
+ });
42
+ setActive(request.items.findIndex(isItem));
43
+ }, [request]);
44
+
45
+ useEffect(() => {
46
+ if (!request) return;
47
+ const onKey = (e: KeyboardEvent) => {
48
+ if (e.key === "Escape") { e.preventDefault(); onClose(); return; }
49
+ const idxs = request.items.map((it, i) => (isItem(it) && !it.disabled ? i : -1)).filter((i) => i >= 0);
50
+ if (idxs.length === 0) return;
51
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
52
+ e.preventDefault();
53
+ const at = idxs.indexOf(active);
54
+ const next = e.key === "ArrowDown"
55
+ ? idxs[(at + 1 + idxs.length) % idxs.length]
56
+ : idxs[(at - 1 + idxs.length) % idxs.length];
57
+ setActive(next);
58
+ } else if (e.key === "Enter") {
59
+ e.preventDefault();
60
+ const item = request.items[active];
61
+ if (item && isItem(item) && !item.disabled) { onClose(); item.onSelect(); }
62
+ }
63
+ };
64
+ // Capture so a menu click never also reaches the surface underneath.
65
+ const onDown = (e: MouseEvent) => {
66
+ if (!ref.current?.contains(e.target as Node)) onClose();
67
+ };
68
+ window.addEventListener("keydown", onKey, true);
69
+ window.addEventListener("mousedown", onDown, true);
70
+ window.addEventListener("resize", onClose);
71
+ window.addEventListener("blur", onClose);
72
+ return () => {
73
+ window.removeEventListener("keydown", onKey, true);
74
+ window.removeEventListener("mousedown", onDown, true);
75
+ window.removeEventListener("resize", onClose);
76
+ window.removeEventListener("blur", onClose);
77
+ };
78
+ }, [request, active, onClose]);
79
+
80
+ if (!request) return null;
81
+ return (
82
+ <div
83
+ ref={ref}
84
+ className="context-menu"
85
+ role="menu"
86
+ style={pos ? { left: pos.left, top: pos.top } : { left: -9999, top: -9999 }}
87
+ onContextMenu={(e) => e.preventDefault()}
88
+ >
89
+ {request.items.map((item, i) =>
90
+ isItem(item) ? (
91
+ <button
92
+ key={item.label + i}
93
+ type="button"
94
+ role="menuitem"
95
+ className={"context-menu-item" + (item.danger ? " danger" : "") + (i === active ? " active" : "")}
96
+ disabled={item.disabled}
97
+ onMouseEnter={() => setActive(i)}
98
+ onClick={() => { onClose(); item.onSelect(); }}
99
+ >
100
+ <span>{item.label}</span>
101
+ {item.hint && <kbd>{item.hint}</kbd>}
102
+ </button>
103
+ ) : (
104
+ <div key={"sep" + i} className="context-menu-separator" role="separator" />
105
+ )
106
+ )}
107
+ </div>
108
+ );
109
+ };