hop2 0.8.0

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.
Files changed (50) hide show
  1. package/.hop-tmux.conf +18 -0
  2. package/INTEGRATION.md +92 -0
  3. package/README.md +260 -0
  4. package/hay/CHANGELOG.md +46 -0
  5. package/hay/DEV_NOTES.md +39 -0
  6. package/hay/README.md +290 -0
  7. package/hay/apps/cli/package.json +25 -0
  8. package/hay/apps/cli/src/index.ts +1250 -0
  9. package/hay/apps/cli/tsconfig.json +8 -0
  10. package/hay/apps/server/package.json +37 -0
  11. package/hay/apps/server/src/index.ts +103 -0
  12. package/hay/apps/server/src/lib.ts +161 -0
  13. package/hay/apps/server/src/pty.ts +68 -0
  14. package/hay/apps/server/src/rooms.ts +421 -0
  15. package/hay/apps/server/src/utils.ts +20 -0
  16. package/hay/apps/server/test/rooms.test.ts +146 -0
  17. package/hay/apps/server/tsconfig.json +9 -0
  18. package/hay/apps/web/index.html +12 -0
  19. package/hay/apps/web/package.json +32 -0
  20. package/hay/apps/web/scripts/run-e2e.mjs +130 -0
  21. package/hay/apps/web/src/App.tsx +1302 -0
  22. package/hay/apps/web/src/components/MobileKeyboard.tsx +701 -0
  23. package/hay/apps/web/src/main.tsx +13 -0
  24. package/hay/apps/web/src/setupTests.ts +1 -0
  25. package/hay/apps/web/src/styles.css +1198 -0
  26. package/hay/apps/web/src/utils/optimisticEcho.ts +115 -0
  27. package/hay/apps/web/src/utils/presence.ts +24 -0
  28. package/hay/apps/web/src/vite-env.d.ts +10 -0
  29. package/hay/apps/web/test/optimisticEcho.test.ts +39 -0
  30. package/hay/apps/web/test/presence.test.ts +18 -0
  31. package/hay/apps/web/tsconfig.json +8 -0
  32. package/hay/apps/web/vite.config.ts +13 -0
  33. package/hay/apps/web/vitest.config.ts +12 -0
  34. package/hay/package-lock.json +5988 -0
  35. package/hay/package.json +18 -0
  36. package/hay/packages/shared/package.json +27 -0
  37. package/hay/packages/shared/src/colors.ts +16 -0
  38. package/hay/packages/shared/src/index.ts +2 -0
  39. package/hay/packages/shared/src/protocol.ts +90 -0
  40. package/hay/packages/shared/test/protocol.test.ts +33 -0
  41. package/hay/packages/shared/tsconfig.json +7 -0
  42. package/hay/tsconfig.base.json +15 -0
  43. package/hay-web/assets/index-CTTzApRp.css +32 -0
  44. package/hay-web/assets/index-FDfdolCs.js +60 -0
  45. package/hay-web/assets/index-FDfdolCs.js.map +1 -0
  46. package/hay-web/index.html +13 -0
  47. package/hop +3717 -0
  48. package/package.json +52 -0
  49. package/sessions.html +243 -0
  50. package/tunnel-shell.js +254 -0
@@ -0,0 +1,1302 @@
1
+ import { useEffect, useMemo, useRef, useState, useCallback, type FormEvent } from "react";
2
+ import { FitAddon } from "@xterm/addon-fit";
3
+ import { Terminal } from "xterm";
4
+ import "xterm/css/xterm.css";
5
+ import {
6
+ safeParseServerMessage,
7
+ type PresenceClient,
8
+ type ClientMessage
9
+ } from "hay-shared";
10
+ import { activityLabel, sortPresence } from "./utils/presence";
11
+ import { createOptimisticEcho } from "./utils/optimisticEcho";
12
+ import { MobileKeyboard } from "./components/MobileKeyboard";
13
+
14
+ const createRoomId = () => `room-${Math.random().toString(36).slice(2, 7)}`;
15
+
16
+ const resolveWsUrl = () => {
17
+ // Check for hop session config (when embedded in hop)
18
+ const hopSession = (window as unknown as { __HOP_SESSION__?: { wsUrl?: string } }).__HOP_SESSION__;
19
+ if (hopSession?.wsUrl) {
20
+ const { protocol, host } = window.location;
21
+ const wsProtocol = protocol === "https:" ? "wss" : "ws";
22
+ return `${wsProtocol}://${host}${hopSession.wsUrl}`;
23
+ }
24
+ if (import.meta.env.VITE_WS_URL) {
25
+ return import.meta.env.VITE_WS_URL as string;
26
+ }
27
+ const { protocol, hostname, host } = window.location;
28
+ const wsProtocol = protocol === "https:" ? "wss" : "ws";
29
+ if (import.meta.env.DEV) {
30
+ return `${wsProtocol}://${hostname}:4001/ws`;
31
+ }
32
+ return `${wsProtocol}://${host}/ws`;
33
+ };
34
+
35
+ const formatStatus = (client: PresenceClient) => {
36
+ const state = activityLabel(client);
37
+ if (state === "typing") {
38
+ return "typing";
39
+ }
40
+ if (state === "active") {
41
+ return "active";
42
+ }
43
+ return "idle";
44
+ };
45
+
46
+ const createShareLink = (room: string) => {
47
+ const url = new URL(window.location.href);
48
+ url.searchParams.set("room", room);
49
+ url.searchParams.delete("name");
50
+ return url.toString();
51
+ };
52
+
53
+ const terminalTheme = {
54
+ background: "#0b1117",
55
+ foreground: "#e6edf3",
56
+ cursor: "#f97316",
57
+ selection: "#1f2937",
58
+ black: "#0b1117",
59
+ red: "#ef4444",
60
+ green: "#22c55e",
61
+ yellow: "#f59e0b",
62
+ blue: "#3b82f6",
63
+ magenta: "#d946ef",
64
+ cyan: "#14b8a6",
65
+ white: "#e5e7eb"
66
+ };
67
+
68
+ const isMobileDevice = () => {
69
+ if (typeof window === "undefined") return false;
70
+ const ua = navigator.userAgent || "";
71
+ return /iPhone|iPad|iPod|Android/i.test(ua) || window.innerWidth < 768;
72
+ };
73
+
74
+ type ConnectionStatus = "idle" | "connecting" | "connected" | "disconnected";
75
+
76
+ type SessionInfo = {
77
+ name: string;
78
+ displayName: string;
79
+ active: boolean;
80
+ starting: boolean;
81
+ type?: "terminal" | "port";
82
+ port?: number;
83
+ };
84
+
85
+ // Check if embedded in Hop
86
+ const getHopSession = () => (window as unknown as { __HOP_SESSION__?: { room?: string; wsUrl?: string } }).__HOP_SESSION__;
87
+ const isEmbeddedInHop = () => !!getHopSession()?.room;
88
+
89
+ const App = () => {
90
+ const params = new URLSearchParams(window.location.search);
91
+ const hopSession = getHopSession();
92
+
93
+ const [name, setName] = useState(() => {
94
+ return params.get("name") ?? localStorage.getItem("hay_name") ?? "User";
95
+ });
96
+ const [room, setRoom] = useState(() => {
97
+ if (hopSession?.room) return hopSession.room;
98
+ return params.get("room") ?? createRoomId();
99
+ });
100
+ // Auto-start session when embedded in Hop (skip join page)
101
+ const [session, setSession] = useState<{ name: string; room: string } | null>(() => {
102
+ if (hopSession?.room) {
103
+ const userName = params.get("name") ?? localStorage.getItem("hay_name") ?? "User";
104
+ return { name: userName, room: hopSession.room };
105
+ }
106
+ return null;
107
+ });
108
+ const [status, setStatus] = useState<ConnectionStatus>("idle");
109
+ const [presence, setPresence] = useState<PresenceClient[]>([]);
110
+ const [collabMode, setCollabMode] = useState(true);
111
+ const [controllerId, setControllerId] = useState<string | null>(null);
112
+ const [clientId, setClientId] = useState<string | null>(null);
113
+ const [notice, setNotice] = useState<string | null>(null);
114
+ const [latencyComp, setLatencyComp] = useState(true);
115
+ const [autoFitOnType, setAutoFitOnType] = useState(true);
116
+ const [terminalReady, setTerminalReady] = useState(false);
117
+ const [reconnectToken, setReconnectToken] = useState(0);
118
+ const [drawerOpen, setDrawerOpen] = useState(false);
119
+ const [fabPosition, setFabPosition] = useState({ x: 20, y: window.innerHeight - 80 });
120
+ const fabDragRef = useRef<{ dragging: boolean; startX: number; startY: number; startPosX: number; startPosY: number } | null>(null);
121
+ const [isMobile] = useState(() => isMobileDevice());
122
+ const [keyboardVisible, setKeyboardVisible] = useState(() => isMobileDevice());
123
+ const [selectionMode, setSelectionMode] = useState(() => {
124
+ const saved = localStorage.getItem("hay_selection_mode");
125
+ return saved === "true";
126
+ });
127
+ const [hapticsEnabled, setHapticsEnabled] = useState(() => {
128
+ const saved = localStorage.getItem("hay_haptics_enabled");
129
+ return saved !== "false";
130
+ });
131
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
132
+ const [loadingSessions, setLoadingSessions] = useState(false);
133
+ const [fontSize, setFontSize] = useState(() => {
134
+ const saved = localStorage.getItem("hay_font_size");
135
+ return saved ? parseInt(saved, 10) : 14;
136
+ });
137
+ const [viewMode, setViewMode] = useState<"fit" | "full">(() => {
138
+ const saved = localStorage.getItem("hay_view_mode");
139
+ return saved === "fit" ? "fit" : "full"; // Default to full
140
+ });
141
+
142
+ const wsRef = useRef<WebSocket | null>(null);
143
+ const termRef = useRef<Terminal | null>(null);
144
+ const fitRef = useRef<FitAddon | null>(null);
145
+ const containerRef = useRef<HTMLDivElement | null>(null);
146
+ const optimisticEchoRef = useRef(createOptimisticEcho());
147
+ const optimisticPrevRef = useRef(false);
148
+ const typingTimeout = useRef<number | null>(null);
149
+ const viewportTouchRef = useRef<{
150
+ start?: (ev: TouchEvent) => void;
151
+ move?: (ev: TouchEvent) => boolean;
152
+ } | null>(null);
153
+ useEffect(() => {
154
+ localStorage.setItem("hay_selection_mode", selectionMode ? "true" : "false");
155
+ }, [selectionMode]);
156
+ useEffect(() => {
157
+ localStorage.setItem("hay_haptics_enabled", hapticsEnabled ? "true" : "false");
158
+ }, [hapticsEnabled]);
159
+
160
+ const typingActive = useRef(false);
161
+ const noticeTimeout = useRef<number | null>(null);
162
+ const autoFitOnTypeRef = useRef(autoFitOnType);
163
+ const viewModeRef = useRef(viewMode);
164
+ const reconnectAttemptRef = useRef(0);
165
+ const reconnectTimerRef = useRef<number | null>(null);
166
+ const shouldReconnectRef = useRef(true);
167
+
168
+ const pushNotice = (message: string) => {
169
+ setNotice(message);
170
+ if (noticeTimeout.current) {
171
+ window.clearTimeout(noticeTimeout.current);
172
+ }
173
+ noticeTimeout.current = window.setTimeout(() => {
174
+ setNotice(null);
175
+ }, 3000);
176
+ };
177
+
178
+ const shareUrl = useMemo(() => {
179
+ return session ? createShareLink(session.room) : createShareLink(room);
180
+ }, [session, room]);
181
+
182
+ const sortedPresence = useMemo(() => sortPresence(presence, clientId), [presence, clientId]);
183
+
184
+ const controllerName = useMemo(() => {
185
+ if (!controllerId) {
186
+ return "";
187
+ }
188
+ return presence.find((client) => client.id === controllerId)?.name ?? "Someone";
189
+ }, [presence, controllerId]);
190
+
191
+ const optimisticActive =
192
+ latencyComp && status === "connected" && !collabMode && controllerId === clientId;
193
+
194
+ useEffect(() => {
195
+ if (optimisticPrevRef.current && !optimisticActive) {
196
+ optimisticEchoRef.current.reset();
197
+ }
198
+ optimisticPrevRef.current = optimisticActive;
199
+ }, [optimisticActive]);
200
+
201
+ useEffect(() => {
202
+ autoFitOnTypeRef.current = autoFitOnType;
203
+ }, [autoFitOnType]);
204
+
205
+ useEffect(() => {
206
+ viewModeRef.current = viewMode;
207
+ localStorage.setItem("hay_view_mode", viewMode);
208
+ // When switching to fit mode, refit terminal
209
+ if (viewMode === "fit" && termRef.current) {
210
+ setTimeout(() => {
211
+ fitToViewport();
212
+ handleResize();
213
+ }, 0);
214
+ }
215
+ }, [viewMode]);
216
+
217
+ const sendMessage = (message: ClientMessage) => {
218
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
219
+ wsRef.current.send(JSON.stringify(message));
220
+ }
221
+ };
222
+
223
+ const sendTyping = (active: boolean) => {
224
+ sendMessage({ type: "typing", active });
225
+ };
226
+
227
+ const handleUserInput = (data: string) => {
228
+ // Strip focus reporting sequences that can be echoed back as visible text
229
+ const sanitized = data.replace(/\x1b\[I/g, '').replace(/\x1b\[O/g, '');
230
+ if (!sanitized) {
231
+ return;
232
+ }
233
+ const echoed = optimisticEchoRef.current.onInput(sanitized, optimisticActive);
234
+ if (echoed) {
235
+ writeToTerminal(echoed);
236
+ }
237
+ sendMessage({ type: "input", data: sanitized });
238
+ if (autoFitOnTypeRef.current && viewModeRef.current === "fit") {
239
+ fitToViewport();
240
+ handleResize();
241
+ }
242
+ if (!typingActive.current) {
243
+ typingActive.current = true;
244
+ sendTyping(true);
245
+ }
246
+ if (typingTimeout.current) {
247
+ window.clearTimeout(typingTimeout.current);
248
+ }
249
+ typingTimeout.current = window.setTimeout(() => {
250
+ typingActive.current = false;
251
+ sendTyping(false);
252
+ }, 1200);
253
+ };
254
+
255
+ const handleResize = () => {
256
+ if (!termRef.current) {
257
+ return;
258
+ }
259
+ sendMessage({ type: "resize", cols: termRef.current.cols, rows: termRef.current.rows });
260
+ };
261
+
262
+ // Fit based on the scroll container viewport rather than the terminal element itself.
263
+ // This ensures correct sizing across desktop padding and mobile full-bleed layouts.
264
+ const fitToViewport = () => {
265
+ if (!termRef.current || !containerRef.current) return;
266
+
267
+ const terminal = termRef.current;
268
+ const core = (terminal as any)._core;
269
+ if (!core?._renderService?.dimensions?.css?.cell) return;
270
+
271
+ const cellWidth = core._renderService.dimensions.css.cell.width;
272
+ const cellHeight = core._renderService.dimensions.css.cell.height;
273
+ if (!cellWidth || !cellHeight) return;
274
+
275
+ const scrollContainer = containerRef.current.closest(".terminal-scroll");
276
+ if (!scrollContainer) return;
277
+
278
+ const rect = scrollContainer.getBoundingClientRect();
279
+ const styles = window.getComputedStyle(scrollContainer);
280
+ const paddingLeft = parseFloat(styles.paddingLeft) || 0;
281
+ const paddingRight = parseFloat(styles.paddingRight) || 0;
282
+ const paddingTop = parseFloat(styles.paddingTop) || 0;
283
+ const paddingBottom = parseFloat(styles.paddingBottom) || 0;
284
+
285
+ const availableWidth = rect.width - paddingLeft - paddingRight;
286
+ const availableHeight = rect.height - paddingTop - paddingBottom;
287
+
288
+ const cols = Math.max(2, Math.floor(availableWidth / cellWidth));
289
+ const rows = Math.max(1, Math.floor(availableHeight / cellHeight));
290
+
291
+ if (terminal.cols !== cols || terminal.rows !== rows) {
292
+ terminal.resize(cols, rows);
293
+ }
294
+ };
295
+
296
+ const writeToTerminal = (data: string) => {
297
+ if (!termRef.current) {
298
+ return;
299
+ }
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
+
306
+ // Filter focus reporting sequences that can leak as visible text
307
+ const filtered = data.replace(/\x1b\[I/g, '').replace(/\x1b\[O/g, '');
308
+ termRef.current.write(filtered, () => {
309
+ // Only scroll to bottom if we were already at bottom (follow output behavior)
310
+ if (wasAtBottom) {
311
+ termRef.current?.scrollToBottom();
312
+ }
313
+ });
314
+ };
315
+
316
+ const scheduleReconnect = (nextSession: { name: string; room: string }) => {
317
+ if (!shouldReconnectRef.current) return;
318
+
319
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
320
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttemptRef.current), 30000);
321
+ reconnectAttemptRef.current += 1;
322
+
323
+ pushNotice(`Reconnecting in ${Math.round(delay / 1000)}s...`);
324
+
325
+ reconnectTimerRef.current = window.setTimeout(() => {
326
+ reconnectTimerRef.current = null;
327
+ if (shouldReconnectRef.current) {
328
+ connect(nextSession);
329
+ }
330
+ }, delay);
331
+ };
332
+
333
+ const connect = (nextSession: { name: string; room: string }) => {
334
+ // Clear any pending reconnect
335
+ if (reconnectTimerRef.current) {
336
+ window.clearTimeout(reconnectTimerRef.current);
337
+ reconnectTimerRef.current = null;
338
+ }
339
+
340
+ const wsUrl = resolveWsUrl();
341
+ const cols = termRef.current?.cols ?? 80;
342
+ const rows = termRef.current?.rows ?? 24;
343
+ const url = `${wsUrl}?room=${encodeURIComponent(nextSession.room)}&name=${encodeURIComponent(
344
+ nextSession.name
345
+ )}&cols=${cols}&rows=${rows}`;
346
+
347
+ wsRef.current?.close();
348
+ setStatus("connecting");
349
+ shouldReconnectRef.current = true;
350
+
351
+ const ws = new WebSocket(url);
352
+ wsRef.current = ws;
353
+
354
+ ws.addEventListener("open", () => {
355
+ setStatus("connected");
356
+ reconnectAttemptRef.current = 0; // Reset backoff on successful connection
357
+ handleResize();
358
+ });
359
+
360
+ ws.addEventListener("message", (event) => {
361
+ const message = safeParseServerMessage(event.data);
362
+ if (!message) {
363
+ return;
364
+ }
365
+ switch (message.type) {
366
+ case "hello":
367
+ setClientId(message.clientId);
368
+ setCollabMode(message.collabMode);
369
+ setControllerId(message.controllerId);
370
+ break;
371
+ case "presence":
372
+ setPresence(message.clients);
373
+ break;
374
+ case "output":
375
+ {
376
+ const reconciled = optimisticEchoRef.current.reconcileOutput(message.data);
377
+ if (reconciled) {
378
+ writeToTerminal(reconciled);
379
+ }
380
+ }
381
+ break;
382
+ case "snapshot":
383
+ optimisticEchoRef.current.reset();
384
+ if (termRef.current) {
385
+ termRef.current.clear();
386
+ }
387
+ writeToTerminal(message.data);
388
+ if (termRef.current) {
389
+ // Respect cursor visibility state after snapshot restore.
390
+ if (typeof message.cursorHidden === "boolean") {
391
+ termRef.current.write(message.cursorHidden ? '\x1b[?25l' : '\x1b[?25h');
392
+ } else if (message.alternateScreen) {
393
+ // Fallback for older servers: alternate screen apps generally hide the cursor.
394
+ termRef.current.write('\x1b[?25l');
395
+ }
396
+ }
397
+ break;
398
+ case "collab":
399
+ setCollabMode(message.enabled);
400
+ setControllerId(message.controllerId);
401
+ pushNotice(message.enabled ? "Collaborative typing enabled" : "Control locked to a single editor");
402
+ break;
403
+ case "input_rejected":
404
+ pushNotice(message.reason);
405
+ break;
406
+ case "active_size":
407
+ // Resize terminal to match active user's size.
408
+ // This allows overflow/panning when the active user is larger than the viewport.
409
+ if (termRef.current) {
410
+ const currentCols = termRef.current.cols;
411
+ const currentRows = termRef.current.rows;
412
+ if (message.cols !== currentCols || message.rows !== currentRows) {
413
+ termRef.current.resize(message.cols, message.rows);
414
+ }
415
+ }
416
+ break;
417
+ case "session_ended":
418
+ shouldReconnectRef.current = false;
419
+ pushNotice(message.message);
420
+ ws.close();
421
+ setStatus("disconnected");
422
+ break;
423
+ default:
424
+ break;
425
+ }
426
+ });
427
+
428
+ ws.addEventListener("close", () => {
429
+ optimisticEchoRef.current.reset();
430
+ setStatus("disconnected");
431
+ scheduleReconnect(nextSession);
432
+ });
433
+
434
+ ws.addEventListener("error", () => {
435
+ optimisticEchoRef.current.reset();
436
+ setStatus("disconnected");
437
+ // Error is usually followed by close, so don't double-schedule
438
+ });
439
+ };
440
+
441
+ useEffect(() => {
442
+ if (!session || !terminalReady) {
443
+ return;
444
+ }
445
+ connect(session);
446
+ // eslint-disable-next-line react-hooks/exhaustive-deps
447
+ }, [session, terminalReady, reconnectToken]);
448
+
449
+ useEffect(() => {
450
+ if (!session || !containerRef.current || termRef.current) {
451
+ return;
452
+ }
453
+
454
+ const terminal = new Terminal({
455
+ fontFamily: "Menlo, Monaco, 'Courier New', monospace",
456
+ fontSize,
457
+ lineHeight: 1.3,
458
+ cursorBlink: true,
459
+ theme: terminalTheme
460
+ });
461
+ const fitAddon = new FitAddon();
462
+ terminal.loadAddon(fitAddon);
463
+ terminal.open(containerRef.current);
464
+ fitAddon.fit();
465
+
466
+ termRef.current = terminal;
467
+ fitRef.current = fitAddon;
468
+ if (viewModeRef.current === "fit") {
469
+ setTimeout(() => {
470
+ fitToViewport();
471
+ handleResize();
472
+ }, 0);
473
+ }
474
+
475
+ // Register OSC handlers for sequences xterm.js doesn't fully support
476
+ // These swallow the sequences so they don't appear as visible text
477
+ const oscIds = [
478
+ 4, // Color palette query/set
479
+ 10, // Foreground color query/set
480
+ 11, // Background color query/set
481
+ 12, // Cursor color query/set
482
+ 52, // Clipboard operations
483
+ 104, // Reset color palette
484
+ 110, // Reset foreground color
485
+ 111, // Reset background color
486
+ 112 // Reset cursor color
487
+ ];
488
+ for (const id of oscIds) {
489
+ terminal.parser.registerOscHandler(id, () => true);
490
+ }
491
+
492
+ // Register CSI handlers for focus reporting sequences
493
+ // ESC[I = Focus In, ESC[O = Focus Out
494
+ terminal.parser.registerCsiHandler({ final: 'I' }, () => true);
495
+ terminal.parser.registerCsiHandler({ final: 'O' }, () => true);
496
+
497
+ setTerminalReady(true);
498
+
499
+ // On mobile, disable the terminal's internal textarea to prevent:
500
+ // 1. Native keyboard from appearing when touching terminal
501
+ // 2. Double input from xterm's internal event handling
502
+ if (isMobile) {
503
+ const textarea = containerRef.current.querySelector('textarea');
504
+ if (textarea) {
505
+ textarea.setAttribute('readonly', 'true');
506
+ textarea.setAttribute('tabindex', '-1');
507
+ textarea.style.opacity = '0';
508
+ textarea.style.pointerEvents = 'none';
509
+ }
510
+ } else {
511
+ // Desktop: register onData for keyboard input
512
+ terminal.onData(handleUserInput);
513
+ }
514
+
515
+ // Prevent browser from intercepting common terminal shortcuts
516
+ terminal.attachCustomKeyEventHandler((event) => {
517
+ // Allow ctrl+shift+c/v for copy/paste
518
+ if (event.ctrlKey && event.shiftKey && (event.key === 'c' || event.key === 'v')) {
519
+ return false;
520
+ }
521
+ // Capture all other ctrl key combinations for the terminal
522
+ if (event.ctrlKey && !event.altKey && !event.metaKey) {
523
+ return true;
524
+ }
525
+ return true;
526
+ });
527
+
528
+ const resizeObserver = new ResizeObserver(() => {
529
+ // Only auto-fit in fit mode
530
+ if (viewModeRef.current === "fit") {
531
+ fitToViewport();
532
+ // Force a full refresh to clear any stale canvas content after resize
533
+ terminal.refresh(0, terminal.rows - 1);
534
+ handleResize();
535
+ }
536
+ });
537
+
538
+ const scrollContainer = containerRef.current.closest(".terminal-scroll");
539
+ if (scrollContainer) {
540
+ resizeObserver.observe(scrollContainer);
541
+ }
542
+
543
+ if (import.meta.env.VITE_E2E === "true") {
544
+ (window as any).__hay = {
545
+ getBufferText: () => {
546
+ const buffer = terminal.buffer.active;
547
+ let text = "";
548
+ for (let i = 0; i < buffer.length; i += 1) {
549
+ text += `${buffer.getLine(i)?.translateToString(true) ?? ""}\n`;
550
+ }
551
+ return text;
552
+ }
553
+ };
554
+ }
555
+
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
+ // eslint-disable-next-line react-hooks/exhaustive-deps
568
+ }, [session]);
569
+
570
+ useEffect(() => {
571
+ if (!isMobile || !terminalReady) {
572
+ return;
573
+ }
574
+
575
+ const terminal = termRef.current;
576
+ const container = containerRef.current?.closest(".terminal-scroll");
577
+ if (!terminal || !container) {
578
+ return;
579
+ }
580
+
581
+ if ((terminal as any).__touchCleanup) {
582
+ (terminal as any).__touchCleanup();
583
+ (terminal as any).__touchCleanup = null;
584
+ }
585
+
586
+ // Selection mode uses native text selection; CSS re-enables pointer events
587
+ // on xterm rows so iOS handles selection, and we skip custom scrolling.
588
+ if (selectionMode) {
589
+ return;
590
+ }
591
+
592
+ // Mobile touch scrolling with momentum (native-feeling)
593
+ // xterm.js has the viewport underneath the rows, so native scroll doesn't work
594
+ // We implement our own with inertia/momentum for iOS-like feel
595
+ let lastY = 0;
596
+ let scrollDebt = 0; // Accumulated sub-line scroll distance
597
+ let velocitySamples: number[] = []; // Recent velocities for smoothing
598
+ let lastMoveTime = 0;
599
+ let momentumVelocity = 0;
600
+ let momentumId: number | null = null;
601
+ let isScrolling = false;
602
+ let isPanning = false;
603
+ let panStartScrollLeft = 0;
604
+ let panStartScrollTop = 0;
605
+ let panStartX = 0;
606
+ let panStartY = 0;
607
+
608
+ const getCellHeight = () => {
609
+ const core = (terminal as any)._core;
610
+ const cellHeight = core?._renderService?.dimensions?.css?.cell?.height;
611
+ return cellHeight && cellHeight > 0 ? cellHeight : 18;
612
+ };
613
+
614
+ const getTwoFingerCenter = (touches: TouchList) => {
615
+ const x = (touches[0].clientX + touches[1].clientX) / 2;
616
+ const y = (touches[0].clientY + touches[1].clientY) / 2;
617
+ return { x, y };
618
+ };
619
+
620
+ const friction = 0.94; // Momentum decay (higher = longer coast)
621
+ const minMomentumVelocity = 0.25; // Minimum px/frame to continue momentum
622
+
623
+ const stopMomentum = () => {
624
+ if (momentumId !== null) {
625
+ cancelAnimationFrame(momentumId);
626
+ momentumId = null;
627
+ }
628
+ momentumVelocity = 0;
629
+ };
630
+
631
+ const applyMomentum = () => {
632
+ if (Math.abs(momentumVelocity) < minMomentumVelocity) {
633
+ momentumId = null;
634
+ return;
635
+ }
636
+
637
+ // Apply momentum as fractional scroll
638
+ scrollDebt += momentumVelocity;
639
+ const lines = Math.trunc(scrollDebt / getCellHeight());
640
+ if (lines !== 0) {
641
+ terminal.scrollLines(lines);
642
+ scrollDebt -= lines * getCellHeight();
643
+ }
644
+
645
+ momentumVelocity *= friction;
646
+ momentumId = requestAnimationFrame(applyMomentum);
647
+ };
648
+
649
+ const handleTouchStart = (e: TouchEvent) => {
650
+ stopMomentum();
651
+ if (e.cancelable) {
652
+ e.preventDefault();
653
+ }
654
+
655
+ if (e.touches.length === 2) {
656
+ isPanning = true;
657
+ const center = getTwoFingerCenter(e.touches);
658
+ panStartX = center.x;
659
+ panStartY = center.y;
660
+ const panTarget = containerRef.current?.closest(".terminal-scroll");
661
+ if (panTarget) {
662
+ panStartScrollLeft = panTarget.scrollLeft;
663
+ panStartScrollTop = panTarget.scrollTop;
664
+ }
665
+ return;
666
+ }
667
+
668
+ if (e.touches.length !== 1) {
669
+ isPanning = false;
670
+ isScrolling = false;
671
+ return;
672
+ }
673
+
674
+ isPanning = false;
675
+ lastY = e.touches[0].clientY;
676
+ lastMoveTime = Date.now();
677
+ scrollDebt = 0;
678
+ velocitySamples = [];
679
+ isScrolling = true;
680
+ };
681
+
682
+ const handleTouchMove = (e: TouchEvent) => {
683
+ if (e.touches.length === 2) {
684
+ if (!isPanning) {
685
+ isPanning = true;
686
+ const center = getTwoFingerCenter(e.touches);
687
+ panStartX = center.x;
688
+ panStartY = center.y;
689
+ const panTarget = containerRef.current?.closest(".terminal-scroll");
690
+ if (panTarget) {
691
+ panStartScrollLeft = panTarget.scrollLeft;
692
+ panStartScrollTop = panTarget.scrollTop;
693
+ }
694
+ }
695
+
696
+ const center = getTwoFingerCenter(e.touches);
697
+ const deltaX = panStartX - center.x;
698
+ const deltaY = panStartY - center.y;
699
+ const panTarget = containerRef.current?.closest(".terminal-scroll");
700
+ if (panTarget) {
701
+ panTarget.scrollLeft = panStartScrollLeft + deltaX;
702
+ panTarget.scrollTop = panStartScrollTop + deltaY;
703
+ }
704
+ e.preventDefault();
705
+ return;
706
+ }
707
+
708
+ if (e.touches.length !== 1) return;
709
+
710
+ if (isPanning) {
711
+ isPanning = false;
712
+ isScrolling = true;
713
+ }
714
+
715
+ const touchY = e.touches[0].clientY;
716
+ const now = Date.now();
717
+ const deltaY = lastY - touchY; // positive = scroll down (finger up)
718
+ const deltaTime = Math.max(1, now - lastMoveTime);
719
+
720
+ // ALWAYS track velocity (even before direction is locked)
721
+ // This ensures quick flicks have velocity data
722
+ if (deltaTime > 0 && deltaTime < 100) { // Ignore stale samples
723
+ velocitySamples.push(deltaY / deltaTime);
724
+ if (velocitySamples.length > 5) velocitySamples.shift();
725
+ }
726
+
727
+ e.preventDefault();
728
+ // Accumulate scroll and apply whole lines
729
+ scrollDebt += deltaY;
730
+ const lines = Math.trunc(scrollDebt / getCellHeight());
731
+ if (lines !== 0) {
732
+ terminal.scrollLines(lines);
733
+ scrollDebt -= lines * getCellHeight();
734
+ }
735
+
736
+ lastY = touchY;
737
+ lastMoveTime = now;
738
+ };
739
+
740
+ const handleTouchEnd = () => {
741
+ if (isPanning) {
742
+ isPanning = false;
743
+ return;
744
+ }
745
+
746
+ // Only apply momentum if we were scrolling vertically
747
+ if (isScrolling && velocitySamples.length > 0) {
748
+ // Use peak velocity (max absolute value) - users often slow down at end of flick
749
+ // but we want to capture their flick intent, not their stopping motion
750
+ let peakVelocity = 0;
751
+ for (const v of velocitySamples) {
752
+ if (Math.abs(v) > Math.abs(peakVelocity)) {
753
+ peakVelocity = v;
754
+ }
755
+ }
756
+ // Convert to pixels per frame (~16ms)
757
+ momentumVelocity = peakVelocity * 16;
758
+
759
+ // Start momentum if significant (lowered threshold for responsiveness)
760
+ if (Math.abs(momentumVelocity) >= minMomentumVelocity) {
761
+ momentumId = requestAnimationFrame(applyMomentum);
762
+ }
763
+ }
764
+ isScrolling = false;
765
+ };
766
+
767
+ // Use capture: true to intercept events before xterm's internal handlers
768
+ container.addEventListener('touchstart', handleTouchStart, { passive: false, capture: true });
769
+ container.addEventListener('touchmove', handleTouchMove, { passive: false, capture: true });
770
+ container.addEventListener('touchend', handleTouchEnd, { passive: true, capture: true });
771
+ container.addEventListener('touchcancel', handleTouchEnd, { passive: true, capture: true });
772
+
773
+ // Store cleanup handlers
774
+ (terminal as any).__touchCleanup = () => {
775
+ stopMomentum();
776
+ container.removeEventListener('touchstart', handleTouchStart, { capture: true });
777
+ container.removeEventListener('touchmove', handleTouchMove, { capture: true });
778
+ container.removeEventListener('touchend', handleTouchEnd, { capture: true });
779
+ container.removeEventListener('touchcancel', handleTouchEnd, { capture: true });
780
+ };
781
+
782
+ return () => {
783
+ if ((terminal as any).__touchCleanup) {
784
+ (terminal as any).__touchCleanup();
785
+ (terminal as any).__touchCleanup = null;
786
+ }
787
+ };
788
+ }, [isMobile, selectionMode, terminalReady]);
789
+
790
+ useEffect(() => {
791
+ if (!terminalReady || !isMobile) {
792
+ return;
793
+ }
794
+ const terminal = termRef.current;
795
+ if (!terminal) {
796
+ return;
797
+ }
798
+ const viewport = (terminal as any)._core?.viewport;
799
+ if (!viewport) {
800
+ return;
801
+ }
802
+
803
+ if (!viewportTouchRef.current) {
804
+ viewportTouchRef.current = {
805
+ start: typeof viewport.handleTouchStart === "function" ? viewport.handleTouchStart.bind(viewport) : undefined,
806
+ move: typeof viewport.handleTouchMove === "function" ? viewport.handleTouchMove.bind(viewport) : undefined
807
+ };
808
+ }
809
+
810
+ // Selection mode: disable xterm's internal touch handlers so Safari's
811
+ // selection handles can take priority (CSS toggles pointer-events).
812
+ if (selectionMode) {
813
+ viewport.handleTouchStart = () => {};
814
+ viewport.handleTouchMove = () => true;
815
+ } else if (viewportTouchRef.current) {
816
+ if (viewportTouchRef.current.start) {
817
+ viewport.handleTouchStart = viewportTouchRef.current.start;
818
+ }
819
+ if (viewportTouchRef.current.move) {
820
+ viewport.handleTouchMove = viewportTouchRef.current.move;
821
+ }
822
+ }
823
+
824
+ return () => {
825
+ if (viewportTouchRef.current) {
826
+ if (viewportTouchRef.current.start) {
827
+ viewport.handleTouchStart = viewportTouchRef.current.start;
828
+ }
829
+ if (viewportTouchRef.current.move) {
830
+ viewport.handleTouchMove = viewportTouchRef.current.move;
831
+ }
832
+ }
833
+ };
834
+ }, [terminalReady, isMobile, selectionMode]);
835
+
836
+ useEffect(() => {
837
+ if (!isMobile || !selectionMode) {
838
+ return;
839
+ }
840
+ const container = containerRef.current?.closest(".terminal-scroll");
841
+ if (!container) {
842
+ return;
843
+ }
844
+
845
+ const resetUserSelect = () => {
846
+ const xterm = containerRef.current?.querySelector(".xterm") as HTMLElement | null;
847
+ if (!xterm) {
848
+ return;
849
+ }
850
+ xterm.style.webkitUserSelect = "none";
851
+ xterm.style.userSelect = "none";
852
+ requestAnimationFrame(() => {
853
+ xterm.style.webkitUserSelect = "";
854
+ xterm.style.userSelect = "";
855
+ });
856
+ };
857
+
858
+ const handleClick = () => {
859
+ const selection = window.getSelection();
860
+ if (!selection || !selection.isCollapsed) {
861
+ return;
862
+ }
863
+ const textarea = containerRef.current?.querySelector("textarea") as HTMLTextAreaElement | null;
864
+ const active = document.activeElement as HTMLElement | null;
865
+ selection.removeAllRanges();
866
+ textarea?.blur();
867
+ if (active && active !== document.body) {
868
+ active.blur();
869
+ }
870
+ termRef.current?.clearSelection?.();
871
+ termRef.current?.blur?.();
872
+ resetUserSelect();
873
+ };
874
+
875
+ container.addEventListener("click", handleClick, true);
876
+ return () => {
877
+ container.removeEventListener("click", handleClick, true);
878
+ };
879
+ }, [isMobile, selectionMode]);
880
+
881
+ useEffect(() => {
882
+ if (!session) {
883
+ // Stop auto-reconnect when leaving session
884
+ shouldReconnectRef.current = false;
885
+ if (reconnectTimerRef.current) {
886
+ window.clearTimeout(reconnectTimerRef.current);
887
+ reconnectTimerRef.current = null;
888
+ }
889
+ wsRef.current?.close();
890
+ wsRef.current = null;
891
+ optimisticEchoRef.current.reset();
892
+ reconnectAttemptRef.current = 0;
893
+ }
894
+ return () => {
895
+ shouldReconnectRef.current = false;
896
+ if (reconnectTimerRef.current) {
897
+ window.clearTimeout(reconnectTimerRef.current);
898
+ }
899
+ wsRef.current?.close();
900
+ };
901
+ }, [session]);
902
+
903
+ const fetchSessions = useCallback(async () => {
904
+ setLoadingSessions(true);
905
+ try {
906
+ const res = await fetch("/api/sessions");
907
+ const data = await res.json();
908
+ const sessionMap = new Map<string, SessionInfo>();
909
+
910
+ // Process sessions from the API
911
+ for (const s of data.sessions || []) {
912
+ sessionMap.set(s.name, {
913
+ name: s.name,
914
+ displayName: s.displayName || s.name,
915
+ active: (data.active || []).includes(s.name),
916
+ starting: (data.starting || []).includes(s.name),
917
+ type: s.type,
918
+ port: s.port
919
+ });
920
+ }
921
+
922
+ // Add active sessions that might not be in sessions list
923
+ for (const name of data.active || []) {
924
+ if (!sessionMap.has(name)) {
925
+ sessionMap.set(name, {
926
+ name,
927
+ displayName: name,
928
+ active: true,
929
+ starting: false
930
+ });
931
+ }
932
+ }
933
+
934
+ setSessions(Array.from(sessionMap.values()));
935
+ } catch (err) {
936
+ console.error("Failed to fetch sessions:", err);
937
+ } finally {
938
+ setLoadingSessions(false);
939
+ }
940
+ }, []);
941
+
942
+ // Fetch sessions when drawer opens
943
+ useEffect(() => {
944
+ if (drawerOpen) {
945
+ fetchSessions();
946
+ }
947
+ }, [drawerOpen, fetchSessions]);
948
+
949
+ // Fetch sessions on desktop (no drawer)
950
+ useEffect(() => {
951
+ if (isMobile) return;
952
+ fetchSessions();
953
+ }, [fetchSessions, isMobile]);
954
+
955
+ // Update terminal font size when it changes
956
+ useEffect(() => {
957
+ if (termRef.current) {
958
+ termRef.current.options.fontSize = fontSize;
959
+ if (viewModeRef.current === "fit") {
960
+ fitToViewport();
961
+ handleResize();
962
+ } else {
963
+ termRef.current.refresh(0, termRef.current.rows - 1);
964
+ }
965
+ }
966
+ localStorage.setItem("hay_font_size", String(fontSize));
967
+ }, [fontSize]);
968
+
969
+ const switchSession = (sessionName: string) => {
970
+ // Navigate to the new session
971
+ window.location.href = `/s/${encodeURIComponent(sessionName)}/`;
972
+ };
973
+
974
+ const handleJoin = (event: FormEvent) => {
975
+ event.preventDefault();
976
+ if (!name.trim() || !room.trim()) {
977
+ pushNotice("Enter a name and room to start.");
978
+ return;
979
+ }
980
+ localStorage.setItem("hay_name", name.trim());
981
+ setSession({ name: name.trim(), room: room.trim() });
982
+ };
983
+
984
+ const handleCopyLink = async () => {
985
+ try {
986
+ await navigator.clipboard.writeText(shareUrl);
987
+ pushNotice("Share link copied.");
988
+ } catch {
989
+ pushNotice("Copy failed. Select the link manually.");
990
+ }
991
+ };
992
+
993
+ const handleToggleCollab = () => {
994
+ sendMessage({ type: "toggle_collab", enabled: !collabMode });
995
+ };
996
+
997
+ const handleTakeControl = () => {
998
+ sendMessage({ type: "take_control" });
999
+ };
1000
+
1001
+ const handleReleaseControl = () => {
1002
+ sendMessage({ type: "release_control" });
1003
+ };
1004
+
1005
+ const handleFabDragStart = (clientX: number, clientY: number) => {
1006
+ fabDragRef.current = {
1007
+ dragging: false,
1008
+ startX: clientX,
1009
+ startY: clientY,
1010
+ startPosX: fabPosition.x,
1011
+ startPosY: fabPosition.y
1012
+ };
1013
+ };
1014
+
1015
+ const handleFabDragMove = (clientX: number, clientY: number) => {
1016
+ if (!fabDragRef.current) return;
1017
+ const dx = clientX - fabDragRef.current.startX;
1018
+ const dy = clientY - fabDragRef.current.startY;
1019
+ if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
1020
+ fabDragRef.current.dragging = true;
1021
+ }
1022
+ if (fabDragRef.current.dragging) {
1023
+ setFabPosition({
1024
+ x: Math.max(0, Math.min(window.innerWidth - 56, fabDragRef.current.startPosX + dx)),
1025
+ y: Math.max(0, Math.min(window.innerHeight - 56, fabDragRef.current.startPosY + dy))
1026
+ });
1027
+ }
1028
+ };
1029
+
1030
+ const handleFabDragEnd = () => {
1031
+ if (fabDragRef.current && !fabDragRef.current.dragging) {
1032
+ setDrawerOpen(true);
1033
+ }
1034
+ fabDragRef.current = null;
1035
+ };
1036
+
1037
+ const handleKeyboardToggle = useCallback(() => {
1038
+ setKeyboardVisible((prev) => !prev);
1039
+ // Refit terminal after keyboard toggle
1040
+ setTimeout(() => {
1041
+ if (viewModeRef.current === "fit") {
1042
+ fitToViewport();
1043
+ handleResize();
1044
+ }
1045
+ }, 100);
1046
+ }, []);
1047
+
1048
+ const handleKeyboardInput = useCallback(
1049
+ (data: string) => {
1050
+ handleUserInput(data);
1051
+ },
1052
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1053
+ []
1054
+ );
1055
+
1056
+ return (
1057
+ <div className="app">
1058
+ <header className="topbar">
1059
+ <div className="brand">
1060
+ <span className="brand-mark">◎</span>
1061
+ <div>
1062
+ <p className="brand-title">Hay</p>
1063
+ <p className="brand-subtitle">Collaborative terminal sharing for Hop.</p>
1064
+ </div>
1065
+ </div>
1066
+ <div className="presence-strip">
1067
+ {sortedPresence.map((client) => (
1068
+ <div
1069
+ key={client.id}
1070
+ className={`presence-chip ${client.id === clientId ? "self" : ""}`}
1071
+ title={`${client.name} · ${formatStatus(client)}`}
1072
+ >
1073
+ <span className="presence-dot" style={{ backgroundColor: client.color }} />
1074
+ <span className="presence-name">{client.name}</span>
1075
+ <span className={`presence-status ${formatStatus(client)}`}>{formatStatus(client)}</span>
1076
+ </div>
1077
+ ))}
1078
+ {sortedPresence.length === 0 && <span className="presence-empty">No viewers yet</span>}
1079
+ </div>
1080
+ </header>
1081
+
1082
+ {!session ? (
1083
+ <main className="join">
1084
+ <div className="join-card">
1085
+ <h1>Terminal session</h1>
1086
+ <p>Connect to an existing session or start a new one.</p>
1087
+ <form onSubmit={handleJoin} className="join-form">
1088
+ <label>
1089
+ Display name
1090
+ <input
1091
+ value={name}
1092
+ onChange={(event) => setName(event.target.value)}
1093
+ placeholder="Casey"
1094
+ />
1095
+ </label>
1096
+ <label>
1097
+ Session
1098
+ <input
1099
+ value={room}
1100
+ onChange={(event) => setRoom(event.target.value)}
1101
+ placeholder="my-session"
1102
+ />
1103
+ </label>
1104
+ <button type="submit" className="primary">
1105
+ Connect
1106
+ </button>
1107
+ </form>
1108
+ </div>
1109
+ </main>
1110
+ ) : (
1111
+ <main
1112
+ className={`session${isMobile && keyboardVisible ? " has-keyboard" : ""}${selectionMode ? " selection-mode" : ""}`}
1113
+ >
1114
+ <button
1115
+ type="button"
1116
+ className="drawer-toggle"
1117
+ style={{ left: fabPosition.x, top: fabPosition.y, bottom: 'auto' }}
1118
+ onMouseDown={(e) => handleFabDragStart(e.clientX, e.clientY)}
1119
+ onMouseMove={(e) => handleFabDragMove(e.clientX, e.clientY)}
1120
+ onMouseUp={handleFabDragEnd}
1121
+ onMouseLeave={handleFabDragEnd}
1122
+ onTouchStart={(e) => handleFabDragStart(e.touches[0].clientX, e.touches[0].clientY)}
1123
+ onTouchMove={(e) => handleFabDragMove(e.touches[0].clientX, e.touches[0].clientY)}
1124
+ onTouchEnd={handleFabDragEnd}
1125
+ >
1126
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
1127
+ <circle cx="12" cy="12" r="3"/>
1128
+ <path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
1129
+ </svg>
1130
+ </button>
1131
+ {drawerOpen && (
1132
+ <div className="drawer-overlay" onClick={() => setDrawerOpen(false)} />
1133
+ )}
1134
+ <section className={`controls ${drawerOpen ? "open" : ""}`}>
1135
+ <button
1136
+ type="button"
1137
+ className="drawer-close"
1138
+ onClick={() => setDrawerOpen(false)}
1139
+ >
1140
+ ✕
1141
+ </button>
1142
+ <div className="room-info">
1143
+ <p className="room-label">Session</p>
1144
+ <h2>{session.room}</h2>
1145
+ <p className="room-meta">
1146
+ {status === "connected" ? "Live" : status === "connecting" ? "Connecting" : "Offline"}
1147
+ </p>
1148
+ </div>
1149
+ <div className="control-actions">
1150
+ {isMobile && (
1151
+ <button type="button" onClick={() => { handleKeyboardToggle(); setDrawerOpen(false); }}>
1152
+ {keyboardVisible ? "Hide keyboard" : "Show keyboard"}
1153
+ </button>
1154
+ )}
1155
+ <button type="button" onClick={handleCopyLink}>
1156
+ Copy link
1157
+ </button>
1158
+ <button type="button" onClick={() => { fitToViewport(); handleResize(); }}>
1159
+ Fit to screen
1160
+ </button>
1161
+ {status === "disconnected" && (
1162
+ <button type="button" onClick={() => setReconnectToken((value) => value + 1)}>
1163
+ Reconnect
1164
+ </button>
1165
+ )}
1166
+ </div>
1167
+ <div className="font-size-control">
1168
+ <label>Font size</label>
1169
+ <div className="font-size-buttons">
1170
+ <button type="button" onClick={() => setFontSize((s) => Math.max(8, s - 1))}>−</button>
1171
+ <span>{fontSize}px</span>
1172
+ <button type="button" onClick={() => setFontSize((s) => Math.min(24, s + 1))}>+</button>
1173
+ </div>
1174
+ </div>
1175
+ <div className="view-mode-control">
1176
+ <label>View mode</label>
1177
+ <div className="view-mode-buttons">
1178
+ <button
1179
+ type="button"
1180
+ className={viewMode === "fit" ? "active" : ""}
1181
+ onClick={() => setViewMode("fit")}
1182
+ >
1183
+ Auto-fit
1184
+ </button>
1185
+ <button
1186
+ type="button"
1187
+ className={viewMode === "full" ? "active" : ""}
1188
+ onClick={() => setViewMode("full")}
1189
+ >
1190
+ Manual
1191
+ </button>
1192
+ </div>
1193
+ </div>
1194
+ {isMobile && (
1195
+ <div className="selection-mode-control">
1196
+ <label>Selection mode</label>
1197
+ <div className="view-mode-buttons">
1198
+ <button
1199
+ type="button"
1200
+ className={!selectionMode ? "active" : ""}
1201
+ onClick={() => setSelectionMode(false)}
1202
+ >
1203
+ Scroll
1204
+ </button>
1205
+ <button
1206
+ type="button"
1207
+ className={selectionMode ? "active" : ""}
1208
+ onClick={() => setSelectionMode(true)}
1209
+ >
1210
+ Select
1211
+ </button>
1212
+ </div>
1213
+ </div>
1214
+ )}
1215
+ {isMobile && (
1216
+ <div className="haptics-control">
1217
+ <label>Haptics</label>
1218
+ <div className="view-mode-buttons">
1219
+ <button
1220
+ type="button"
1221
+ className={hapticsEnabled ? "active" : ""}
1222
+ onClick={() => setHapticsEnabled(true)}
1223
+ >
1224
+ On
1225
+ </button>
1226
+ <button
1227
+ type="button"
1228
+ className={!hapticsEnabled ? "active" : ""}
1229
+ onClick={() => setHapticsEnabled(false)}
1230
+ >
1231
+ Off
1232
+ </button>
1233
+ </div>
1234
+ </div>
1235
+ )}
1236
+ {notice && <p className="notice">{notice}</p>}
1237
+ {(() => {
1238
+ const otherSessions = sessions.filter((s) => s.name !== session?.room);
1239
+ return (
1240
+ <div className="session-switcher">
1241
+ <p className="session-switcher-label">Switch session</p>
1242
+ {loadingSessions ? (
1243
+ <div className="session-list-loading">Loading...</div>
1244
+ ) : otherSessions.length === 0 ? (
1245
+ <div className="session-list-empty">No other sessions</div>
1246
+ ) : (
1247
+ <div className="session-list">
1248
+ {otherSessions.map((s) => (
1249
+ <button
1250
+ key={s.name}
1251
+ type="button"
1252
+ className={`session-list-item${s.active ? " active" : ""}${s.starting ? " starting" : ""}`}
1253
+ onClick={() => switchSession(s.name)}
1254
+ >
1255
+ <span className="session-list-name">{s.displayName}</span>
1256
+ {s.active && <span className="session-badge live">LIVE</span>}
1257
+ {s.starting && !s.active && <span className="session-badge starting">STARTING</span>}
1258
+ {s.type === "port" && <span className="session-badge port">PORT {s.port}</span>}
1259
+ </button>
1260
+ ))}
1261
+ </div>
1262
+ )}
1263
+ </div>
1264
+ );
1265
+ })()}
1266
+ </section>
1267
+ <section className="terminal">
1268
+ <div
1269
+ className="terminal-frame"
1270
+ onClick={() => {
1271
+ // On mobile, don't focus terminal to prevent system keyboard
1272
+ if (!isMobile) {
1273
+ termRef.current?.focus();
1274
+ }
1275
+ }}
1276
+ >
1277
+ <div className="terminal-scroll">
1278
+ <div className="terminal-inner" ref={containerRef} />
1279
+ </div>
1280
+ </div>
1281
+ <div className="terminal-footer">
1282
+ <span>{status === "connected" ? "Live" : "Awaiting connection"}</span>
1283
+ <span>
1284
+ {sortedPresence.length} viewer{sortedPresence.length === 1 ? "" : "s"}
1285
+ </span>
1286
+ </div>
1287
+ </section>
1288
+ {isMobile && (
1289
+ <MobileKeyboard
1290
+ onInput={handleKeyboardInput}
1291
+ visible={keyboardVisible}
1292
+ onToggle={handleKeyboardToggle}
1293
+ hapticsEnabled={hapticsEnabled}
1294
+ />
1295
+ )}
1296
+ </main>
1297
+ )}
1298
+ </div>
1299
+ );
1300
+ };
1301
+
1302
+ export default App;