hop2 0.8.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.
@@ -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: hopSession.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
- return session ? createShareLink(session.room) : createShareLink(room);
180
- }, [session, room]);
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
- // Only scroll to bottom if we were already at bottom (follow output behavior)
310
- if (wasAtBottom) {
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
- }, [session]);
1005
+ }, []);
902
1006
 
903
- const fetchSessions = useCallback(async () => {
904
- setLoadingSessions(true);
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
- setLoadingSessions(false);
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
- fetchSessions();
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 desktop (no drawer)
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, isMobile]);
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
- const switchSession = (sessionName: string) => {
970
- // Navigate to the new session
971
- window.location.href = `/s/${encodeURIComponent(sessionName)}/`;
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 otherSessions = sessions.filter((s) => s.name !== session?.room);
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.name)}
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 = ({ onInput, visible, onToggle, hapticsEnabled = true }: MobileKeyboardProps) => {
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)) calc(6px + env(safe-area-inset-bottom, 0px)) max(2px, env(safe-area-inset-left));
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: 0;
1075
+ padding-bottom: var(--mobile-keyboard-height, 0px);
1069
1076
  padding-top: 8px;
1070
1077
  }
1071
1078
 
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "apps/cli": {
17
17
  "name": "hay-cli",
18
- "version": "0.1.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.1.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.1.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.1.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() })