hop2 0.11.12 → 0.11.13

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.
@@ -24,6 +24,11 @@ export type ClientInfo = {
24
24
  // switcher wall): excluded from presence and client counts, and given a
25
25
  // small snapshot so a wall of tiles doesn't pull megabytes per open.
26
26
  source?: string;
27
+ // Stable per-browser-tab identity (device id + tab id). A tab holds one
28
+ // live connection per room, so a new attach bearing the same key means the
29
+ // previous connection is dead even if its socket never closed — it is
30
+ // evicted on the spot instead of haunting presence until TCP notices.
31
+ deviceKey?: string;
27
32
  colorIndex: number;
28
33
  cols: number;
29
34
  rows: number;
@@ -182,6 +187,7 @@ class ClientState {
182
187
  lastActive = now();
183
188
  socket: SocketAdapter;
184
189
  source: string;
190
+ deviceKey: string;
185
191
  // Declared fit: the size this client's viewport wants, updated by every
186
192
  // resize message whether or not it applied. Becomes the PTY size the
187
193
  // moment a keystroke makes this client the size owner.
@@ -192,6 +198,7 @@ class ClientState {
192
198
  this.id = info.id;
193
199
  this.name = info.name;
194
200
  this.source = info.source || "";
201
+ this.deviceKey = info.deviceKey || "";
195
202
  this.color = pickPresenceColor(info.colorIndex);
196
203
  this.socket = socket;
197
204
  this.cols = info.cols;
@@ -361,6 +368,19 @@ export class Room extends EventEmitter {
361
368
  this.cleanupTimer = null;
362
369
  }
363
370
 
371
+ // Same tab, same room: the newcomer replaces its predecessor. A phone
372
+ // that dropped off the network never sends a close, so its old
373
+ // connection would otherwise sit in presence as a second "iPhone"
374
+ // until TCP gives up on the corpse.
375
+ if (info.deviceKey) {
376
+ for (const [id, peer] of [...this.clients]) {
377
+ if (peer.deviceKey === info.deviceKey && peer.source !== "monitor") {
378
+ try { peer.socket.close(); } catch { /* already dead */ }
379
+ this.removeClient(id);
380
+ }
381
+ }
382
+ }
383
+
364
384
  const client = new ClientState(info, socket);
365
385
  this.clients.set(client.id, client);
366
386
  const created = !this.hasHadClient;
@@ -1004,3 +1004,57 @@ describe("Room", () => {
1004
1004
  });
1005
1005
  });
1006
1006
  });
1007
+
1008
+ describe("device-key eviction", () => {
1009
+ // The room actively closes the predecessor's socket; a plain FakeSocket
1010
+ // close is indistinguishable from a client-side close, so count them.
1011
+ class TrackingSocket extends FakeSocket {
1012
+ serverCloses = 0;
1013
+ close() {
1014
+ this.serverCloses += 1;
1015
+ super.close();
1016
+ }
1017
+ }
1018
+
1019
+ const makeRoom = (name: string) => {
1020
+ const manager = new RoomManager(() => new FakePty() as any);
1021
+ return manager.getRoom(name, { cols: 80, rows: 24 }, "/tmp");
1022
+ };
1023
+
1024
+ it("a same-key attach replaces the predecessor instead of joining it", () => {
1025
+ const room = makeRoom("evict");
1026
+ const ghost = new TrackingSocket();
1027
+ const fresh = new FakeSocket();
1028
+ room.attachClient({ id: "a", name: "iPhone", colorIndex: 0, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, ghost);
1029
+ room.attachClient({ id: "b", name: "iPhone", colorIndex: 1, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, fresh);
1030
+
1031
+ expect(ghost.serverCloses).toBe(1);
1032
+ const presence = findMessages(fresh, "presence").at(-1) as any;
1033
+ expect(presence.clients.map((c: { id: string }) => c.id)).toEqual(["b"]);
1034
+ });
1035
+
1036
+ it("different tabs from the same device coexist", () => {
1037
+ const room = makeRoom("tabs");
1038
+ const tabA = new TrackingSocket();
1039
+ const tabB = new FakeSocket();
1040
+ room.attachClient({ id: "a", name: "iPhone", colorIndex: 0, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, tabA);
1041
+ room.attachClient({ id: "b", name: "iPhone", colorIndex: 1, cols: 80, rows: 24, deviceKey: "dev1.tab2" }, tabB);
1042
+
1043
+ expect(tabA.serverCloses).toBe(0);
1044
+ const presence = findMessages(tabB, "presence").at(-1) as any;
1045
+ expect(presence.clients.map((c: { id: string }) => c.id).sort()).toEqual(["a", "b"]);
1046
+ });
1047
+
1048
+ it("keyless clients (CLI, older bundles) are never evicted", () => {
1049
+ const room = makeRoom("legacy");
1050
+ const legacy = new TrackingSocket();
1051
+ const keyed = new FakeSocket();
1052
+ room.attachClient({ id: "a", name: "cli", colorIndex: 0, cols: 80, rows: 24 }, legacy);
1053
+ room.attachClient({ id: "b", name: "iPhone", colorIndex: 1, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, keyed);
1054
+ room.attachClient({ id: "c", name: "iPhone", colorIndex: 2, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, new FakeSocket());
1055
+
1056
+ expect(legacy.serverCloses).toBe(0);
1057
+ const presence = findMessages(legacy, "presence").at(-1) as any;
1058
+ expect(presence.clients.map((c: { id: string }) => c.id).sort()).toEqual(["a", "c"]);
1059
+ });
1060
+ });
@@ -279,6 +279,30 @@ const resolveInitialName = () => {
279
279
  return fromParams ?? stored ?? (injected && injected !== "Guest" ? injected : null) ?? "User";
280
280
  };
281
281
 
282
+ // Stable connection identity: device half persists per browser, tab half per
283
+ // tab. The server evicts a previous connection bearing the same key on
284
+ // attach — a reconnect replaces its own ghost in presence instead of sitting
285
+ // next to it — while two real tabs (different tab halves) coexist.
286
+ const randomKeyPart = () =>
287
+ (window.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)).replace(/-/g, "").slice(0, 10);
288
+ const resolveDeviceKey = () => {
289
+ try {
290
+ let device = localStorage.getItem("hop_device_id");
291
+ if (!device) {
292
+ device = randomKeyPart();
293
+ localStorage.setItem("hop_device_id", device);
294
+ }
295
+ let tab = sessionStorage.getItem("hop_tab_id");
296
+ if (!tab) {
297
+ tab = randomKeyPart();
298
+ sessionStorage.setItem("hop_tab_id", tab);
299
+ }
300
+ return `${device}.${tab}`;
301
+ } catch {
302
+ return ""; // storage unavailable (private mode quota) — no eviction key
303
+ }
304
+ };
305
+
282
306
  // Per-session "seen" markers, shared with the session manager page via
283
307
  // localStorage (same origin). Keyed by internal session name; values are the
284
308
  // server's own lastActivityAt/bellSeq so no cross-device clock comparison
@@ -1464,7 +1488,7 @@ const App = () => {
1464
1488
  const hex = (c?: string) => (c || "").replace("#", "").slice(0, 6);
1465
1489
  const url = `${wsUrl}?room=${encodeURIComponent(nextSession.room)}&name=${encodeURIComponent(
1466
1490
  nextSession.name
1467
- )}&cols=${cols}&rows=${rows}&replay=${replay}&bg=${hex(surface.background)}&fg=${hex(surface.foreground)}`;
1491
+ )}&cols=${cols}&rows=${rows}&replay=${replay}&bg=${hex(surface.background)}&fg=${hex(surface.foreground)}&device=${encodeURIComponent(resolveDeviceKey())}`;
1468
1492
 
1469
1493
  wsRef.current?.close();
1470
1494
  setStatus("connecting");
@@ -1651,6 +1651,10 @@ export const SessionSwitcher = ({
1651
1651
  if (!deepSearch || deepSearch.status !== "done" || deepSearch.q !== filter.trim()) return [];
1652
1652
  const seen = new Set<string>();
1653
1653
  if (model.mode === "filter") model.rows.forEach((r) => seen.add(sessionKey(r)));
1654
+ else if (model.mode === "manual") {
1655
+ model.rows.forEach((r) => seen.add(sessionKey(r)));
1656
+ model.folders.forEach((f) => f.rows.forEach((r) => seen.add(sessionKey(r))));
1657
+ }
1654
1658
  extraContentMatches.forEach((m) => seen.add(sessionKey(m.session)));
1655
1659
  const out: Array<{ session: SwitcherSession; snippet: string }> = [];
1656
1660
  for (const m of deepSearch.rows) {
@@ -1669,7 +1673,7 @@ export const SessionSwitcher = ({
1669
1673
  if (model.mode === "filter") return [...model.rows, ...extraContentMatches.map((m) => m.session), ...extraDeepMatches.map((m) => m.session)];
1670
1674
  // Folder rows come first because that is their visual order; keyboard
1671
1675
  // ↑/↓ and Enter must traverse what the eye sees, foldered included.
1672
- if (model.mode === "manual") return [...model.folders.flatMap((f) => f.rows), ...model.rows, ...extraContentMatches.map((m) => m.session)];
1676
+ if (model.mode === "manual") return [...model.folders.flatMap((f) => f.rows), ...model.rows, ...extraContentMatches.map((m) => m.session), ...extraDeepMatches.map((m) => m.session)];
1673
1677
  if (model.mode === "project") return model.groups.flatMap((g) => g.rows);
1674
1678
  return [...model.hero, ...model.groups.flatMap((g) => g.rows)];
1675
1679
  },
@@ -2579,6 +2583,50 @@ export const SessionSwitcher = ({
2579
2583
  const sheetSession = sheet?.session;
2580
2584
  const sheetAgentPermitted = sheetSession?.agentPermitted === true;
2581
2585
 
2586
+ // Content/history hits plus the full-history row, rendered below whatever
2587
+ // the mode showed. Manual mode keeps its own branch under a query (filtering
2588
+ // narrows the user's order in place), so this tail must ride along there
2589
+ // too — search reaches past the name tier in every mode, not just Recent.
2590
+ const searchTail = filter.trim() ? (
2591
+ <>
2592
+ {extraContentMatches.length > 0 && (
2593
+ <section className="switcher-group">
2594
+ <h3 className="switcher-group-label">found in terminal output{contentTruncated ? " · most recent sessions only" : ""}</h3>
2595
+ <div className="switcher-grid">
2596
+ {extraContentMatches.map(({ session: s, snippet }) => renderCard(s, snippet))}
2597
+ </div>
2598
+ </section>
2599
+ )}
2600
+ {extraDeepMatches.length > 0 && (
2601
+ <section className="switcher-group">
2602
+ <h3 className="switcher-group-label">found in full history</h3>
2603
+ <div className="switcher-grid">
2604
+ {extraDeepMatches.map(({ session: s, snippet }) => renderCard(s, snippet))}
2605
+ </div>
2606
+ </section>
2607
+ )}
2608
+ {filter.trim().length >= 3 && (
2609
+ <div className="switcher-deep-row">
2610
+ {!deepSearch ? (
2611
+ <button type="button" className="switcher-deep-btn" onClick={runDeepSearch}>
2612
+ Search full history for “{filter.trim()}”
2613
+ </button>
2614
+ ) : deepSearch.status === "loading" ? (
2615
+ <span className="switcher-deep-note">searching full history…</span>
2616
+ ) : deepSearch.status === "unavailable" ? (
2617
+ <span className="switcher-deep-note">full-history search unavailable on this host</span>
2618
+ ) : (
2619
+ <span className="switcher-deep-note">
2620
+ {extraDeepMatches.length > 0
2621
+ ? `${extraDeepMatches.length} more in full history`
2622
+ : "nothing further back matches"}
2623
+ </span>
2624
+ )}
2625
+ </div>
2626
+ )}
2627
+ </>
2628
+ ) : null;
2629
+
2582
2630
  return (
2583
2631
  <div
2584
2632
  // tile-dense: too narrow for inline actions. tile-micro: too short for
@@ -2766,46 +2814,12 @@ export const SessionSwitcher = ({
2766
2814
  // cards at the current zoom, same as the wall — the screen content
2767
2815
  // is usually WHY you're looking for the session.
2768
2816
  <>
2769
- {model.rows.length === 0 && extraContentMatches.length === 0 ? (
2817
+ {model.rows.length === 0 && extraContentMatches.length === 0 && extraDeepMatches.length === 0 ? (
2770
2818
  <div className="switcher-empty">No sessions match “{filter.trim()}”</div>
2771
2819
  ) : (
2772
2820
  <div className="switcher-grid">{model.rows.map((s) => renderCard(s))}</div>
2773
2821
  )}
2774
- {extraContentMatches.length > 0 && (
2775
- <section className="switcher-group">
2776
- <h3 className="switcher-group-label">found in terminal output{contentTruncated ? " · most recent sessions only" : ""}</h3>
2777
- <div className="switcher-grid">
2778
- {extraContentMatches.map(({ session: s, snippet }) => renderCard(s, snippet))}
2779
- </div>
2780
- </section>
2781
- )}
2782
- {extraDeepMatches.length > 0 && (
2783
- <section className="switcher-group">
2784
- <h3 className="switcher-group-label">found in full history</h3>
2785
- <div className="switcher-grid">
2786
- {extraDeepMatches.map(({ session: s, snippet }) => renderCard(s, snippet))}
2787
- </div>
2788
- </section>
2789
- )}
2790
- {filter.trim().length >= 3 && (
2791
- <div className="switcher-deep-row">
2792
- {!deepSearch ? (
2793
- <button type="button" className="switcher-deep-btn" onClick={runDeepSearch}>
2794
- Search full history for “{filter.trim()}”
2795
- </button>
2796
- ) : deepSearch.status === "loading" ? (
2797
- <span className="switcher-deep-note">searching full history…</span>
2798
- ) : deepSearch.status === "unavailable" ? (
2799
- <span className="switcher-deep-note">full-history search unavailable on this host</span>
2800
- ) : (
2801
- <span className="switcher-deep-note">
2802
- {extraDeepMatches.length > 0
2803
- ? `${extraDeepMatches.length} more in full history`
2804
- : "nothing further back matches"}
2805
- </span>
2806
- )}
2807
- </div>
2808
- )}
2822
+ {searchTail}
2809
2823
  </>
2810
2824
  ) : model.mode === "project" ? (
2811
2825
  <>
@@ -2831,7 +2845,9 @@ export const SessionSwitcher = ({
2831
2845
  ) : model.mode === "manual" ? (
2832
2846
  <>
2833
2847
  {model.rows.length === 0 && model.folders.every((f) => f.rows.length === 0) ? (
2834
- <div className="switcher-empty">{filter.trim() ? "No matches" : "No sessions"}</div>
2848
+ extraContentMatches.length === 0 && extraDeepMatches.length === 0 && (
2849
+ <div className="switcher-empty">{filter.trim() ? "No matches" : "No sessions"}</div>
2850
+ )
2835
2851
  ) : (
2836
2852
  <>
2837
2853
  <p className="switcher-hint">
@@ -2908,6 +2924,7 @@ export const SessionSwitcher = ({
2908
2924
  <div className="switcher-grid">{model.rows.map((s) => renderCard(s))}</div>
2909
2925
  </>
2910
2926
  )}
2927
+ {searchTail}
2911
2928
  </>
2912
2929
  ) : (
2913
2930
  <>
@@ -325,4 +325,16 @@ describe("manual folders in the wall", () => {
325
325
  expect(screen.getByText("inside")).toBeTruthy();
326
326
  expect(screen.getByText("Filed")).toBeTruthy();
327
327
  });
328
+
329
+ it("a query in manual mode still offers the full-history search", () => {
330
+ // Manual keeps its own render branch under a query (narrow-in-place), so
331
+ // the search tail — content hits + the deep button — must ride along or
332
+ // manual-mode users never see anything past the name tier.
333
+ render(<SessionSwitcher {...props} sessions={withFolder} folders={folders} open />);
334
+ fireEvent.click(screen.getByRole("button", { name: "Manual" }));
335
+ const input = document.querySelector(".switcher-top input") as HTMLInputElement
336
+ || document.querySelector("input") as HTMLInputElement;
337
+ fireEvent.change(input, { target: { value: "outsid" } });
338
+ expect(screen.getByRole("button", { name: /Search full history for/ })).toBeTruthy();
339
+ });
328
340
  });