hop2 0.9.0 → 0.9.1
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/AGENTS.md +33 -0
- package/MULTI-AGENT-CHECKLIST.md +30 -0
- package/README-MCP.md +213 -0
- package/README.md +117 -17
- package/hay/CHANGELOG.md +6 -0
- package/hay/apps/cli/src/index.ts +285 -48
- package/hay/apps/server/src/index.ts +4 -3
- package/hay/apps/server/src/lib.ts +4 -3
- package/hay/apps/server/src/pty.ts +20 -7
- package/hay/apps/server/src/rooms.ts +93 -12
- package/hay/apps/server/test/rooms.test.ts +82 -3
- package/hay/apps/web/src/App.tsx +253 -40
- package/hay/apps/web/src/components/MobileKeyboard.tsx +43 -2
- package/hay/apps/web/src/styles.css +9 -2
- package/hay/package-lock.json +4 -4
- package/hay/packages/shared/src/protocol.ts +4 -0
- package/hay-web/assets/{index-CTTzApRp.css → index-B7rkCY1_.css} +1 -1
- package/hay-web/assets/index-kr4B-B06.js +60 -0
- package/hay-web/assets/{index-FDfdolCs.js.map → index-kr4B-B06.js.map} +1 -1
- package/hay-web/index.html +2 -2
- package/hop +4896 -585
- package/hop.bak2 +8071 -0
- package/lib/terminal-access.js +17 -0
- package/mcp/hop-mcp.js +4530 -0
- package/mcp/hop-mcp.js.bak +4492 -0
- package/mcp/hop-mcp.js.bak2 +4530 -0
- package/package.json +6 -3
- package/scripts/hay-host.js +189 -0
- package/sessions.html +314 -26
- package/setup-mcp.js +443 -0
- package/tests/hop-mcp.integration.test.cjs +3014 -0
- package/tests/terminal-access.unit.test.cjs +21 -0
- package/tests/terminal-api.integration.test.cjs +298 -0
- package/hay-web/assets/index-FDfdolCs.js +0 -60
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState, useCallback, type FormEvent } from "react";
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, useCallback, type CSSProperties, type FormEvent } from "react";
|
|
2
2
|
import { FitAddon } from "@xterm/addon-fit";
|
|
3
3
|
import { Terminal } from "xterm";
|
|
4
4
|
import "xterm/css/xterm.css";
|
|
@@ -13,6 +13,28 @@ import { MobileKeyboard } from "./components/MobileKeyboard";
|
|
|
13
13
|
|
|
14
14
|
const createRoomId = () => `room-${Math.random().toString(36).slice(2, 7)}`;
|
|
15
15
|
|
|
16
|
+
const parseSessionNameFromPath = (pathname: string) => {
|
|
17
|
+
const match = pathname.match(/^\/s\/([^/]+)\/?$/);
|
|
18
|
+
if (!match) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return decodeURIComponent(match[1]);
|
|
23
|
+
} catch {
|
|
24
|
+
return match[1];
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const getLocationRoom = () => {
|
|
29
|
+
const pathRoom = parseSessionNameFromPath(window.location.pathname);
|
|
30
|
+
if (pathRoom) {
|
|
31
|
+
return pathRoom;
|
|
32
|
+
}
|
|
33
|
+
return new URLSearchParams(window.location.search).get("room");
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const buildSessionPath = (sessionName: string) => `/s/${encodeURIComponent(sessionName)}/`;
|
|
37
|
+
|
|
16
38
|
const resolveWsUrl = () => {
|
|
17
39
|
// Check for hop session config (when embedded in hop)
|
|
18
40
|
const hopSession = (window as unknown as { __HOP_SESSION__?: { wsUrl?: string } }).__HOP_SESSION__;
|
|
@@ -72,6 +94,9 @@ const isMobileDevice = () => {
|
|
|
72
94
|
};
|
|
73
95
|
|
|
74
96
|
type ConnectionStatus = "idle" | "connecting" | "connected" | "disconnected";
|
|
97
|
+
type SessionSwitchMode = "page" | "instant";
|
|
98
|
+
const DEFAULT_SESSION_SWITCH_MODE: SessionSwitchMode = "instant";
|
|
99
|
+
const SESSION_LIST_STALE_MS = 5000;
|
|
75
100
|
|
|
76
101
|
type SessionInfo = {
|
|
77
102
|
name: string;
|
|
@@ -89,22 +114,21 @@ const isEmbeddedInHop = () => !!getHopSession()?.room;
|
|
|
89
114
|
const App = () => {
|
|
90
115
|
const params = new URLSearchParams(window.location.search);
|
|
91
116
|
const hopSession = getHopSession();
|
|
117
|
+
const initialRoom = hopSession?.room ?? getLocationRoom() ?? createRoomId();
|
|
92
118
|
|
|
93
119
|
const [name, setName] = useState(() => {
|
|
94
120
|
return params.get("name") ?? localStorage.getItem("hay_name") ?? "User";
|
|
95
121
|
});
|
|
96
|
-
const [room, setRoom] = useState(() =>
|
|
97
|
-
if (hopSession?.room) return hopSession.room;
|
|
98
|
-
return params.get("room") ?? createRoomId();
|
|
99
|
-
});
|
|
122
|
+
const [room, setRoom] = useState(() => initialRoom);
|
|
100
123
|
// Auto-start session when embedded in Hop (skip join page)
|
|
101
124
|
const [session, setSession] = useState<{ name: string; room: string } | null>(() => {
|
|
102
125
|
if (hopSession?.room) {
|
|
103
126
|
const userName = params.get("name") ?? localStorage.getItem("hay_name") ?? "User";
|
|
104
|
-
return { name: userName, room:
|
|
127
|
+
return { name: userName, room: initialRoom };
|
|
105
128
|
}
|
|
106
129
|
return null;
|
|
107
130
|
});
|
|
131
|
+
const [sessionLabel, setSessionLabel] = useState(() => initialRoom);
|
|
108
132
|
const [status, setStatus] = useState<ConnectionStatus>("idle");
|
|
109
133
|
const [presence, setPresence] = useState<PresenceClient[]>([]);
|
|
110
134
|
const [collabMode, setCollabMode] = useState(true);
|
|
@@ -120,6 +144,7 @@ const App = () => {
|
|
|
120
144
|
const fabDragRef = useRef<{ dragging: boolean; startX: number; startY: number; startPosX: number; startPosY: number } | null>(null);
|
|
121
145
|
const [isMobile] = useState(() => isMobileDevice());
|
|
122
146
|
const [keyboardVisible, setKeyboardVisible] = useState(() => isMobileDevice());
|
|
147
|
+
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
|
123
148
|
const [selectionMode, setSelectionMode] = useState(() => {
|
|
124
149
|
const saved = localStorage.getItem("hay_selection_mode");
|
|
125
150
|
return saved === "true";
|
|
@@ -130,6 +155,20 @@ const App = () => {
|
|
|
130
155
|
});
|
|
131
156
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
132
157
|
const [loadingSessions, setLoadingSessions] = useState(false);
|
|
158
|
+
const [sessionSwitchMode, setSessionSwitchMode] = useState<SessionSwitchMode>(() => {
|
|
159
|
+
const saved = localStorage.getItem("hay_session_switch_mode");
|
|
160
|
+
// Keep "page" as a legacy fallback while we validate instant mode end-to-end.
|
|
161
|
+
if (saved === "page") {
|
|
162
|
+
return "page";
|
|
163
|
+
}
|
|
164
|
+
return DEFAULT_SESSION_SWITCH_MODE;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (session?.room) {
|
|
169
|
+
setSessionLabel(session.room);
|
|
170
|
+
}
|
|
171
|
+
}, [session?.room]);
|
|
133
172
|
const [fontSize, setFontSize] = useState(() => {
|
|
134
173
|
const saved = localStorage.getItem("hay_font_size");
|
|
135
174
|
return saved ? parseInt(saved, 10) : 14;
|
|
@@ -156,6 +195,9 @@ const App = () => {
|
|
|
156
195
|
useEffect(() => {
|
|
157
196
|
localStorage.setItem("hay_haptics_enabled", hapticsEnabled ? "true" : "false");
|
|
158
197
|
}, [hapticsEnabled]);
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
localStorage.setItem("hay_session_switch_mode", sessionSwitchMode);
|
|
200
|
+
}, [sessionSwitchMode]);
|
|
159
201
|
|
|
160
202
|
const typingActive = useRef(false);
|
|
161
203
|
const noticeTimeout = useRef<number | null>(null);
|
|
@@ -164,6 +206,11 @@ const App = () => {
|
|
|
164
206
|
const reconnectAttemptRef = useRef(0);
|
|
165
207
|
const reconnectTimerRef = useRef<number | null>(null);
|
|
166
208
|
const shouldReconnectRef = useRef(true);
|
|
209
|
+
const connectNonceRef = useRef(0);
|
|
210
|
+
const userScrolledUpRef = useRef(false);
|
|
211
|
+
const activeSessionRoomRef = useRef<string | null>(null);
|
|
212
|
+
const sessionListLoadedRef = useRef(false);
|
|
213
|
+
const sessionListFetchedAtRef = useRef(0);
|
|
167
214
|
|
|
168
215
|
const pushNotice = (message: string) => {
|
|
169
216
|
setNotice(message);
|
|
@@ -175,9 +222,16 @@ const App = () => {
|
|
|
175
222
|
}, 3000);
|
|
176
223
|
};
|
|
177
224
|
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
activeSessionRoomRef.current = session?.room ?? null;
|
|
227
|
+
}, [session?.room]);
|
|
228
|
+
|
|
178
229
|
const shareUrl = useMemo(() => {
|
|
179
|
-
|
|
180
|
-
|
|
230
|
+
if (session) {
|
|
231
|
+
return createShareLink(sessionLabel || session.room);
|
|
232
|
+
}
|
|
233
|
+
return createShareLink(room);
|
|
234
|
+
}, [session, room, sessionLabel]);
|
|
181
235
|
|
|
182
236
|
const sortedPresence = useMemo(() => sortPresence(presence, clientId), [presence, clientId]);
|
|
183
237
|
|
|
@@ -297,17 +351,12 @@ const App = () => {
|
|
|
297
351
|
if (!termRef.current) {
|
|
298
352
|
return;
|
|
299
353
|
}
|
|
300
|
-
// Check if currently at bottom before writing (standard terminal follow behavior)
|
|
301
|
-
const viewport = containerRef.current?.querySelector('.xterm-viewport');
|
|
302
|
-
const wasAtBottom = viewport
|
|
303
|
-
? viewport.scrollTop >= viewport.scrollHeight - viewport.clientHeight - 5
|
|
304
|
-
: true;
|
|
305
354
|
|
|
306
355
|
// Filter focus reporting sequences that can leak as visible text
|
|
307
356
|
const filtered = data.replace(/\x1b\[I/g, '').replace(/\x1b\[O/g, '');
|
|
308
357
|
termRef.current.write(filtered, () => {
|
|
309
|
-
//
|
|
310
|
-
if (
|
|
358
|
+
// Auto-scroll to bottom unless the user has explicitly scrolled up
|
|
359
|
+
if (!userScrolledUpRef.current) {
|
|
311
360
|
termRef.current?.scrollToBottom();
|
|
312
361
|
}
|
|
313
362
|
});
|
|
@@ -331,12 +380,15 @@ const App = () => {
|
|
|
331
380
|
};
|
|
332
381
|
|
|
333
382
|
const connect = (nextSession: { name: string; room: string }) => {
|
|
383
|
+
const targetRoom = nextSession.room;
|
|
334
384
|
// Clear any pending reconnect
|
|
335
385
|
if (reconnectTimerRef.current) {
|
|
336
386
|
window.clearTimeout(reconnectTimerRef.current);
|
|
337
387
|
reconnectTimerRef.current = null;
|
|
338
388
|
}
|
|
339
389
|
|
|
390
|
+
const connectionNonce = ++connectNonceRef.current;
|
|
391
|
+
|
|
340
392
|
const wsUrl = resolveWsUrl();
|
|
341
393
|
const cols = termRef.current?.cols ?? 80;
|
|
342
394
|
const rows = termRef.current?.rows ?? 24;
|
|
@@ -352,6 +404,10 @@ const App = () => {
|
|
|
352
404
|
wsRef.current = ws;
|
|
353
405
|
|
|
354
406
|
ws.addEventListener("open", () => {
|
|
407
|
+
if (connectionNonce !== connectNonceRef.current || activeSessionRoomRef.current !== targetRoom) {
|
|
408
|
+
ws.close();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
355
411
|
setStatus("connected");
|
|
356
412
|
reconnectAttemptRef.current = 0; // Reset backoff on successful connection
|
|
357
413
|
handleResize();
|
|
@@ -381,6 +437,7 @@ const App = () => {
|
|
|
381
437
|
break;
|
|
382
438
|
case "snapshot":
|
|
383
439
|
optimisticEchoRef.current.reset();
|
|
440
|
+
userScrolledUpRef.current = false;
|
|
384
441
|
if (termRef.current) {
|
|
385
442
|
termRef.current.clear();
|
|
386
443
|
}
|
|
@@ -394,6 +451,14 @@ const App = () => {
|
|
|
394
451
|
termRef.current.write('\x1b[?25l');
|
|
395
452
|
}
|
|
396
453
|
}
|
|
454
|
+
// Auto-fit and scroll to end once after snapshot load
|
|
455
|
+
if (viewModeRef.current === "fit") {
|
|
456
|
+
setTimeout(() => {
|
|
457
|
+
fitToViewport();
|
|
458
|
+
handleResize();
|
|
459
|
+
termRef.current?.scrollToBottom();
|
|
460
|
+
}, 0);
|
|
461
|
+
}
|
|
397
462
|
break;
|
|
398
463
|
case "collab":
|
|
399
464
|
setCollabMode(message.enabled);
|
|
@@ -420,18 +485,37 @@ const App = () => {
|
|
|
420
485
|
ws.close();
|
|
421
486
|
setStatus("disconnected");
|
|
422
487
|
break;
|
|
488
|
+
case "session_renamed":
|
|
489
|
+
setSessionLabel(message.displayName);
|
|
490
|
+
pushNotice(`Session renamed to ${message.displayName}`);
|
|
491
|
+
break;
|
|
423
492
|
default:
|
|
424
493
|
break;
|
|
425
494
|
}
|
|
426
495
|
});
|
|
427
496
|
|
|
428
497
|
ws.addEventListener("close", () => {
|
|
498
|
+
if (connectionNonce !== connectNonceRef.current) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (activeSessionRoomRef.current !== targetRoom) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (!shouldReconnectRef.current) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
429
507
|
optimisticEchoRef.current.reset();
|
|
430
508
|
setStatus("disconnected");
|
|
431
509
|
scheduleReconnect(nextSession);
|
|
432
510
|
});
|
|
433
511
|
|
|
434
512
|
ws.addEventListener("error", () => {
|
|
513
|
+
if (connectionNonce !== connectNonceRef.current) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (activeSessionRoomRef.current !== targetRoom || !shouldReconnectRef.current) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
435
519
|
optimisticEchoRef.current.reset();
|
|
436
520
|
setStatus("disconnected");
|
|
437
521
|
// Error is usually followed by close, so don't double-schedule
|
|
@@ -534,12 +618,24 @@ const App = () => {
|
|
|
534
618
|
handleResize();
|
|
535
619
|
}
|
|
536
620
|
});
|
|
621
|
+
(terminal as any).__resizeObserver = resizeObserver;
|
|
537
622
|
|
|
538
623
|
const scrollContainer = containerRef.current.closest(".terminal-scroll");
|
|
539
624
|
if (scrollContainer) {
|
|
540
625
|
resizeObserver.observe(scrollContainer);
|
|
541
626
|
}
|
|
542
627
|
|
|
628
|
+
// Track user scroll to implement follow-mode:
|
|
629
|
+
// Auto-scroll stays on until the user scrolls up, and resumes when they scroll back to bottom.
|
|
630
|
+
const xtermViewport = containerRef.current.querySelector('.xterm-viewport');
|
|
631
|
+
if (xtermViewport) {
|
|
632
|
+
xtermViewport.addEventListener('scroll', () => {
|
|
633
|
+
const el = xtermViewport as HTMLElement;
|
|
634
|
+
const atBottom = el.scrollTop >= el.scrollHeight - el.clientHeight - 5;
|
|
635
|
+
userScrolledUpRef.current = !atBottom;
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
543
639
|
if (import.meta.env.VITE_E2E === "true") {
|
|
544
640
|
(window as any).__hay = {
|
|
545
641
|
getBufferText: () => {
|
|
@@ -553,17 +649,6 @@ const App = () => {
|
|
|
553
649
|
};
|
|
554
650
|
}
|
|
555
651
|
|
|
556
|
-
return () => {
|
|
557
|
-
resizeObserver.disconnect();
|
|
558
|
-
// Clean up touch handlers
|
|
559
|
-
if ((terminal as any).__touchCleanup) {
|
|
560
|
-
(terminal as any).__touchCleanup();
|
|
561
|
-
}
|
|
562
|
-
terminal.dispose();
|
|
563
|
-
termRef.current = null;
|
|
564
|
-
fitRef.current = null;
|
|
565
|
-
setTerminalReady(false);
|
|
566
|
-
};
|
|
567
652
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
568
653
|
}, [session]);
|
|
569
654
|
|
|
@@ -891,17 +976,68 @@ const App = () => {
|
|
|
891
976
|
optimisticEchoRef.current.reset();
|
|
892
977
|
reconnectAttemptRef.current = 0;
|
|
893
978
|
}
|
|
979
|
+
}, [session]);
|
|
980
|
+
|
|
981
|
+
useEffect(() => {
|
|
894
982
|
return () => {
|
|
983
|
+
const terminal = termRef.current;
|
|
984
|
+
const resizeObserver = (terminal as any)?.__resizeObserver as ResizeObserver | undefined;
|
|
985
|
+
if (resizeObserver) {
|
|
986
|
+
resizeObserver.disconnect();
|
|
987
|
+
}
|
|
988
|
+
if (terminal) {
|
|
989
|
+
// Clear any custom touch cleanup hooks from the momentum handler.
|
|
990
|
+
if ((terminal as any).__touchCleanup) {
|
|
991
|
+
(terminal as any).__touchCleanup();
|
|
992
|
+
(terminal as any).__touchCleanup = null;
|
|
993
|
+
}
|
|
994
|
+
terminal.dispose();
|
|
995
|
+
termRef.current = null;
|
|
996
|
+
fitRef.current = null;
|
|
997
|
+
}
|
|
998
|
+
|
|
895
999
|
shouldReconnectRef.current = false;
|
|
896
1000
|
if (reconnectTimerRef.current) {
|
|
897
1001
|
window.clearTimeout(reconnectTimerRef.current);
|
|
898
1002
|
}
|
|
899
1003
|
wsRef.current?.close();
|
|
900
1004
|
};
|
|
901
|
-
}, [
|
|
1005
|
+
}, []);
|
|
902
1006
|
|
|
903
|
-
|
|
904
|
-
|
|
1007
|
+
useEffect(() => {
|
|
1008
|
+
const handlePopState = () => {
|
|
1009
|
+
const nextRoom = getLocationRoom();
|
|
1010
|
+
const nextName =
|
|
1011
|
+
new URLSearchParams(window.location.search).get("name") ??
|
|
1012
|
+
localStorage.getItem("hay_name") ??
|
|
1013
|
+
name;
|
|
1014
|
+
if (!nextRoom) {
|
|
1015
|
+
if (!isEmbeddedInHop()) {
|
|
1016
|
+
setSession(null);
|
|
1017
|
+
}
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
setRoom(nextRoom);
|
|
1021
|
+
setSession((current) => {
|
|
1022
|
+
const resolvedName = current?.name ?? nextName ?? "User";
|
|
1023
|
+
if (current?.room === nextRoom && current.name === resolvedName) {
|
|
1024
|
+
return current;
|
|
1025
|
+
}
|
|
1026
|
+
return { name: resolvedName, room: nextRoom };
|
|
1027
|
+
});
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
window.addEventListener("popstate", handlePopState);
|
|
1031
|
+
return () => {
|
|
1032
|
+
window.removeEventListener("popstate", handlePopState);
|
|
1033
|
+
};
|
|
1034
|
+
}, [name]);
|
|
1035
|
+
|
|
1036
|
+
const fetchSessions = useCallback(async (options: { showLoading?: boolean } = {}) => {
|
|
1037
|
+
const { showLoading = true } = options;
|
|
1038
|
+
if (showLoading) {
|
|
1039
|
+
setLoadingSessions(true);
|
|
1040
|
+
}
|
|
905
1041
|
try {
|
|
906
1042
|
const res = await fetch("/api/sessions");
|
|
907
1043
|
const data = await res.json();
|
|
@@ -932,25 +1068,34 @@ const App = () => {
|
|
|
932
1068
|
}
|
|
933
1069
|
|
|
934
1070
|
setSessions(Array.from(sessionMap.values()));
|
|
1071
|
+
sessionListLoadedRef.current = true;
|
|
1072
|
+
sessionListFetchedAtRef.current = Date.now();
|
|
935
1073
|
} catch (err) {
|
|
936
1074
|
console.error("Failed to fetch sessions:", err);
|
|
937
1075
|
} finally {
|
|
938
|
-
|
|
1076
|
+
if (showLoading) {
|
|
1077
|
+
setLoadingSessions(false);
|
|
1078
|
+
}
|
|
939
1079
|
}
|
|
940
1080
|
}, []);
|
|
941
1081
|
|
|
942
1082
|
// Fetch sessions when drawer opens
|
|
943
1083
|
useEffect(() => {
|
|
944
1084
|
if (drawerOpen) {
|
|
945
|
-
|
|
1085
|
+
const isFreshEnough = Date.now() - sessionListFetchedAtRef.current <= SESSION_LIST_STALE_MS;
|
|
1086
|
+
const shouldRefresh = !sessionListLoadedRef.current || !isFreshEnough;
|
|
1087
|
+
if (shouldRefresh) {
|
|
1088
|
+
fetchSessions({
|
|
1089
|
+
showLoading: !sessionListLoadedRef.current
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
946
1092
|
}
|
|
947
1093
|
}, [drawerOpen, fetchSessions]);
|
|
948
1094
|
|
|
949
|
-
// Fetch sessions on
|
|
1095
|
+
// Fetch sessions once on mount; mobile keeps list warm for instant drawer open.
|
|
950
1096
|
useEffect(() => {
|
|
951
|
-
if (isMobile) return;
|
|
952
1097
|
fetchSessions();
|
|
953
|
-
}, [fetchSessions
|
|
1098
|
+
}, [fetchSessions]);
|
|
954
1099
|
|
|
955
1100
|
// Update terminal font size when it changes
|
|
956
1101
|
useEffect(() => {
|
|
@@ -966,9 +1111,51 @@ const App = () => {
|
|
|
966
1111
|
localStorage.setItem("hay_font_size", String(fontSize));
|
|
967
1112
|
}, [fontSize]);
|
|
968
1113
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1114
|
+
useEffect(() => {
|
|
1115
|
+
if (!isMobile || !terminalReady || !keyboardVisible || keyboardHeight <= 0) {
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const frame = window.requestAnimationFrame(() => {
|
|
1120
|
+
if (viewModeRef.current === "fit") {
|
|
1121
|
+
fitToViewport();
|
|
1122
|
+
handleResize();
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
return () => {
|
|
1127
|
+
window.cancelAnimationFrame(frame);
|
|
1128
|
+
};
|
|
1129
|
+
}, [isMobile, terminalReady, keyboardVisible, keyboardHeight]);
|
|
1130
|
+
|
|
1131
|
+
const switchSession = (nextSession: SessionInfo) => {
|
|
1132
|
+
const nextPath = buildSessionPath(nextSession.name);
|
|
1133
|
+
const currentRoom = session?.room ?? sessionLabel;
|
|
1134
|
+
const canSwitchInPlace = sessionSwitchMode === "instant" && nextSession.type !== "port";
|
|
1135
|
+
|
|
1136
|
+
if (!canSwitchInPlace) {
|
|
1137
|
+
window.location.href = nextPath;
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (currentRoom === nextSession.name) {
|
|
1142
|
+
setDrawerOpen(false);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
optimisticEchoRef.current.reset();
|
|
1147
|
+
setPresence([]);
|
|
1148
|
+
setControllerId(null);
|
|
1149
|
+
setClientId(null);
|
|
1150
|
+
setStatus("connecting");
|
|
1151
|
+
setRoom(nextSession.name);
|
|
1152
|
+
setSessionLabel(nextSession.displayName || nextSession.name);
|
|
1153
|
+
setSession((current) => ({
|
|
1154
|
+
name: current?.name ?? name.trim() ?? "User",
|
|
1155
|
+
room: nextSession.name
|
|
1156
|
+
}));
|
|
1157
|
+
window.history.pushState({}, "", nextPath);
|
|
1158
|
+
setDrawerOpen(false);
|
|
972
1159
|
};
|
|
973
1160
|
|
|
974
1161
|
const handleJoin = (event: FormEvent) => {
|
|
@@ -1053,6 +1240,10 @@ const App = () => {
|
|
|
1053
1240
|
[]
|
|
1054
1241
|
);
|
|
1055
1242
|
|
|
1243
|
+
const sessionStyle = isMobile
|
|
1244
|
+
? ({ "--mobile-keyboard-height": `${keyboardVisible ? keyboardHeight : 0}px` } as CSSProperties)
|
|
1245
|
+
: undefined;
|
|
1246
|
+
|
|
1056
1247
|
return (
|
|
1057
1248
|
<div className="app">
|
|
1058
1249
|
<header className="topbar">
|
|
@@ -1110,6 +1301,7 @@ const App = () => {
|
|
|
1110
1301
|
) : (
|
|
1111
1302
|
<main
|
|
1112
1303
|
className={`session${isMobile && keyboardVisible ? " has-keyboard" : ""}${selectionMode ? " selection-mode" : ""}`}
|
|
1304
|
+
style={sessionStyle}
|
|
1113
1305
|
>
|
|
1114
1306
|
<button
|
|
1115
1307
|
type="button"
|
|
@@ -1141,7 +1333,7 @@ const App = () => {
|
|
|
1141
1333
|
</button>
|
|
1142
1334
|
<div className="room-info">
|
|
1143
1335
|
<p className="room-label">Session</p>
|
|
1144
|
-
<h2>{session.room}</h2>
|
|
1336
|
+
<h2>{sessionLabel || session.room}</h2>
|
|
1145
1337
|
<p className="room-meta">
|
|
1146
1338
|
{status === "connected" ? "Live" : status === "connecting" ? "Connecting" : "Offline"}
|
|
1147
1339
|
</p>
|
|
@@ -1191,6 +1383,25 @@ const App = () => {
|
|
|
1191
1383
|
</button>
|
|
1192
1384
|
</div>
|
|
1193
1385
|
</div>
|
|
1386
|
+
<div className="view-mode-control">
|
|
1387
|
+
<label>Session switch</label>
|
|
1388
|
+
<div className="view-mode-buttons">
|
|
1389
|
+
<button
|
|
1390
|
+
type="button"
|
|
1391
|
+
className={sessionSwitchMode === "page" ? "active" : ""}
|
|
1392
|
+
onClick={() => setSessionSwitchMode("page")}
|
|
1393
|
+
>
|
|
1394
|
+
Page load
|
|
1395
|
+
</button>
|
|
1396
|
+
<button
|
|
1397
|
+
type="button"
|
|
1398
|
+
className={sessionSwitchMode === "instant" ? "active" : ""}
|
|
1399
|
+
onClick={() => setSessionSwitchMode("instant")}
|
|
1400
|
+
>
|
|
1401
|
+
Instant
|
|
1402
|
+
</button>
|
|
1403
|
+
</div>
|
|
1404
|
+
</div>
|
|
1194
1405
|
{isMobile && (
|
|
1195
1406
|
<div className="selection-mode-control">
|
|
1196
1407
|
<label>Selection mode</label>
|
|
@@ -1235,7 +1446,8 @@ const App = () => {
|
|
|
1235
1446
|
)}
|
|
1236
1447
|
{notice && <p className="notice">{notice}</p>}
|
|
1237
1448
|
{(() => {
|
|
1238
|
-
const
|
|
1449
|
+
const currentSessionName = sessionLabel || session?.room;
|
|
1450
|
+
const otherSessions = sessions.filter((s) => s.name !== currentSessionName);
|
|
1239
1451
|
return (
|
|
1240
1452
|
<div className="session-switcher">
|
|
1241
1453
|
<p className="session-switcher-label">Switch session</p>
|
|
@@ -1250,7 +1462,7 @@ const App = () => {
|
|
|
1250
1462
|
key={s.name}
|
|
1251
1463
|
type="button"
|
|
1252
1464
|
className={`session-list-item${s.active ? " active" : ""}${s.starting ? " starting" : ""}`}
|
|
1253
|
-
onClick={() => switchSession(s
|
|
1465
|
+
onClick={() => switchSession(s)}
|
|
1254
1466
|
>
|
|
1255
1467
|
<span className="session-list-name">{s.displayName}</span>
|
|
1256
1468
|
{s.active && <span className="session-badge live">LIVE</span>}
|
|
@@ -1290,6 +1502,7 @@ const App = () => {
|
|
|
1290
1502
|
onInput={handleKeyboardInput}
|
|
1291
1503
|
visible={keyboardVisible}
|
|
1292
1504
|
onToggle={handleKeyboardToggle}
|
|
1505
|
+
onHeightChange={setKeyboardHeight}
|
|
1293
1506
|
hapticsEnabled={hapticsEnabled}
|
|
1294
1507
|
/>
|
|
1295
1508
|
)}
|
|
@@ -12,6 +12,7 @@ interface MobileKeyboardProps {
|
|
|
12
12
|
onInput: (data: string) => void;
|
|
13
13
|
visible: boolean;
|
|
14
14
|
onToggle: () => void;
|
|
15
|
+
onHeightChange?: (height: number) => void;
|
|
15
16
|
hapticsEnabled?: boolean;
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -91,7 +92,13 @@ const PasteIcon = () => (
|
|
|
91
92
|
</svg>
|
|
92
93
|
);
|
|
93
94
|
|
|
94
|
-
export const MobileKeyboard = ({
|
|
95
|
+
export const MobileKeyboard = ({
|
|
96
|
+
onInput,
|
|
97
|
+
visible,
|
|
98
|
+
onToggle,
|
|
99
|
+
onHeightChange,
|
|
100
|
+
hapticsEnabled = true
|
|
101
|
+
}: MobileKeyboardProps) => {
|
|
95
102
|
const [view, setView] = useState<KeyboardView>("abc");
|
|
96
103
|
const [shift, setShift] = useState(false);
|
|
97
104
|
const [caps, setCaps] = useState(false);
|
|
@@ -107,6 +114,7 @@ export const MobileKeyboard = ({ onInput, visible, onToggle, hapticsEnabled = tr
|
|
|
107
114
|
const bkspInterval = useRef<number | null>(null);
|
|
108
115
|
const sysInputRef = useRef<HTMLTextAreaElement>(null);
|
|
109
116
|
const hapticLabelRef = useRef<HTMLLabelElement | null>(null);
|
|
117
|
+
const keyboardRef = useRef<HTMLDivElement>(null);
|
|
110
118
|
|
|
111
119
|
// Use refs to avoid stale closures in rapid key presses
|
|
112
120
|
const onInputRef = useRef(onInput);
|
|
@@ -296,6 +304,39 @@ export const MobileKeyboard = ({ onInput, visible, onToggle, hapticsEnabled = tr
|
|
|
296
304
|
resizeSysTextarea();
|
|
297
305
|
}, [sysKbDraft, sysKbOpen, resizeSysTextarea]);
|
|
298
306
|
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
if (!visible) {
|
|
309
|
+
onHeightChange?.(0);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const el = keyboardRef.current;
|
|
314
|
+
if (!el) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const reportHeight = () => {
|
|
319
|
+
onHeightChange?.(Math.ceil(el.getBoundingClientRect().height));
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
reportHeight();
|
|
323
|
+
window.addEventListener("resize", reportHeight);
|
|
324
|
+
|
|
325
|
+
if (typeof ResizeObserver === "undefined") {
|
|
326
|
+
return () => {
|
|
327
|
+
window.removeEventListener("resize", reportHeight);
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const observer = new ResizeObserver(reportHeight);
|
|
332
|
+
observer.observe(el);
|
|
333
|
+
|
|
334
|
+
return () => {
|
|
335
|
+
observer.disconnect();
|
|
336
|
+
window.removeEventListener("resize", reportHeight);
|
|
337
|
+
};
|
|
338
|
+
}, [visible, onHeightChange]);
|
|
339
|
+
|
|
299
340
|
const handleShift = useCallback(() => {
|
|
300
341
|
const now = Date.now();
|
|
301
342
|
const doubleTap = shiftTapAt.current && now - shiftTapAt.current < 300;
|
|
@@ -591,7 +632,7 @@ export const MobileKeyboard = ({ onInput, visible, onToggle, hapticsEnabled = tr
|
|
|
591
632
|
</div>
|
|
592
633
|
)}
|
|
593
634
|
|
|
594
|
-
<div className="mobile-keyboard">
|
|
635
|
+
<div ref={keyboardRef} className="mobile-keyboard">
|
|
595
636
|
{/* Accessory row - Esc, Tab, Ctrl, Alt, arrows, system keyboard */}
|
|
596
637
|
<div className="kb-acc-row">
|
|
597
638
|
{ACC_KEYS.map((k, i) => {
|
|
@@ -564,6 +564,7 @@ button.primary {
|
|
|
564
564
|
flex-direction: column;
|
|
565
565
|
overflow: hidden;
|
|
566
566
|
background: #0b1117;
|
|
567
|
+
--mobile-keyboard-height: 0px;
|
|
567
568
|
}
|
|
568
569
|
|
|
569
570
|
.session .terminal {
|
|
@@ -780,14 +781,20 @@ button.primary {
|
|
|
780
781
|
}
|
|
781
782
|
|
|
782
783
|
.mobile-keyboard {
|
|
784
|
+
position: fixed;
|
|
785
|
+
left: 0;
|
|
786
|
+
right: 0;
|
|
787
|
+
bottom: 0;
|
|
788
|
+
width: 100%;
|
|
783
789
|
background: #d1d3d9;
|
|
784
|
-
padding: 2px max(2px, env(safe-area-inset-right))
|
|
790
|
+
padding: 2px max(2px, env(safe-area-inset-right)) env(safe-area-inset-bottom, 0px) max(2px, env(safe-area-inset-left));
|
|
785
791
|
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif;
|
|
786
792
|
box-sizing: border-box;
|
|
787
793
|
overflow-x: hidden;
|
|
788
794
|
-webkit-user-select: none;
|
|
789
795
|
user-select: none;
|
|
790
796
|
flex-shrink: 0;
|
|
797
|
+
z-index: 150;
|
|
791
798
|
}
|
|
792
799
|
|
|
793
800
|
.kb-acc-row {
|
|
@@ -1065,7 +1072,7 @@ button.primary {
|
|
|
1065
1072
|
|
|
1066
1073
|
/* Keyboard visibility state - when keyboard is shown, maximize terminal space */
|
|
1067
1074
|
.session.has-keyboard {
|
|
1068
|
-
padding-bottom:
|
|
1075
|
+
padding-bottom: var(--mobile-keyboard-height, 0px);
|
|
1069
1076
|
padding-top: 8px;
|
|
1070
1077
|
}
|
|
1071
1078
|
|
package/hay/package-lock.json
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"apps/cli": {
|
|
17
17
|
"name": "hay-cli",
|
|
18
|
-
"version": "0.
|
|
18
|
+
"version": "0.8.0",
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"hay-shared": "*",
|
|
21
21
|
"ws": "^8.18.0",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"apps/server": {
|
|
36
36
|
"name": "hay-server",
|
|
37
|
-
"version": "0.
|
|
37
|
+
"version": "0.8.0",
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"express": "^4.21.2",
|
|
40
40
|
"hay-shared": "*",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"apps/web": {
|
|
55
55
|
"name": "hay-web",
|
|
56
|
-
"version": "0.
|
|
56
|
+
"version": "0.8.0",
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@xterm/addon-fit": "^0.11.0",
|
|
59
59
|
"hay-shared": "*",
|
|
@@ -5973,7 +5973,7 @@
|
|
|
5973
5973
|
},
|
|
5974
5974
|
"packages/shared": {
|
|
5975
5975
|
"name": "hay-shared",
|
|
5976
|
-
"version": "0.
|
|
5976
|
+
"version": "0.8.0",
|
|
5977
5977
|
"dependencies": {
|
|
5978
5978
|
"zod": "^3.24.1"
|
|
5979
5979
|
},
|
|
@@ -50,6 +50,10 @@ export const serverMessageSchema = z.discriminatedUnion("type", [
|
|
|
50
50
|
signal: z.string().nullable(),
|
|
51
51
|
message: z.string()
|
|
52
52
|
}),
|
|
53
|
+
z.object({
|
|
54
|
+
type: z.literal("session_renamed"),
|
|
55
|
+
displayName: z.string()
|
|
56
|
+
}),
|
|
53
57
|
z.object({ type: z.literal("pong"), t: z.number() }),
|
|
54
58
|
// Broadcast when active user's terminal size should be followed
|
|
55
59
|
z.object({ type: z.literal("active_size"), clientId: z.string(), cols: z.number().int(), rows: z.number().int() })
|