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.
- package/hay/apps/server/src/rooms.ts +20 -0
- package/hay/apps/server/test/rooms.test.ts +54 -0
- package/hay/apps/web/src/App.tsx +25 -1
- package/hay/apps/web/src/components/SessionSwitcher.tsx +55 -38
- package/hay/apps/web/test/SessionSwitcher.test.tsx +12 -0
- package/hay-web/assets/index--8sYRyoP.js +133 -0
- package/hay-web/assets/index--8sYRyoP.js.map +1 -0
- package/hay-web/assets/index-DnSA-VR6.js +133 -0
- package/hay-web/assets/index-DnSA-VR6.js.map +1 -0
- package/hay-web/index.html +1 -1
- package/hop +222 -63
- package/package.json +1 -1
- package/scripts/hay-host.js +25 -0
- package/skills/hop-session/SKILL.md +1 -1
- package/tests/terminal-api.integration.test.cjs +46 -0
|
@@ -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
|
+
});
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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
|
});
|