hop2 0.11.34 → 0.11.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hay/apps/server/src/rooms.ts +47 -11
- package/hay/apps/web/src/App.tsx +207 -18
- package/hay/apps/web/src/components/DigestCard.tsx +233 -59
- package/hay/apps/web/src/components/SessionSwitcher.tsx +33 -1
- package/hay/apps/web/src/styles.css +235 -0
- package/hay/apps/web/src/utils/optimisticEcho.ts +29 -0
- package/hay/apps/web/src/utils/switcherModel.ts +26 -8
- package/hay/apps/web/test/optimisticEcho.test.ts +48 -1
- package/hay/apps/web/test/switcherModel.test.ts +30 -5
- package/hop +1257 -94
- package/lib/cf-access.js +145 -0
- package/lib/registration.js +40 -8
- package/mcp/hop-mcp.js +8 -5
- package/package.json +1 -1
- package/scripts/claude-session-hook.js +50 -7
- package/scripts/hay-host.js +26 -10
- package/tests/cf-access.unit.test.cjs +116 -0
- package/tests/claude-session-hook.unit.test.cjs +73 -0
- package/tests/registration.integration.test.cjs +50 -2
- package/tests/registration.unit.test.cjs +34 -0
- package/tests/terminal-api.integration.test.cjs +215 -0
|
@@ -8,6 +8,40 @@ import type { PtyFactory } from "./pty";
|
|
|
8
8
|
import { createScreenGrid, loadScreenGridDeps, screenGridsAvailable } from "./screenGrid";
|
|
9
9
|
import type { ScreenGrid } from "./screenGrid";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Darwin `lsof` probes are rate-limited process-wide, not per room.
|
|
13
|
+
*
|
|
14
|
+
* Each room polls its own cwd on its own timer, so the cost of the fallback
|
|
15
|
+
* scales with the number of live rooms — and `lsof` on macOS is not a cheap
|
|
16
|
+
* syscall but a process that stats every mount, blocking uninterruptibly when
|
|
17
|
+
* any of them is slow. At ~150 rooms the 15s timers align into bursts of ~150
|
|
18
|
+
* concurrent `lsof`s, each holding a 5s timeout; every one of them counts
|
|
19
|
+
* toward the load average. That is how this host drove the machine to a
|
|
20
|
+
* 1-minute load of 367 across 18 cores on 2026-09-01, starving its own event
|
|
21
|
+
* loop until the watchdog restarted it and killed every session inside.
|
|
22
|
+
*
|
|
23
|
+
* A small global ceiling keeps the fallback a fallback. Probes that arrive
|
|
24
|
+
* while the gate is full are DROPPED, not queued: this is a best-effort poll
|
|
25
|
+
* that repeats every 15s anyway, and a queue would just rebuild the burst.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_CONCURRENT_CWD_PROBES = 4;
|
|
28
|
+
let activeCwdProbes = 0;
|
|
29
|
+
|
|
30
|
+
function runGatedCwdProbe(pid: number, onCwd: (cwdPath: string) => void) {
|
|
31
|
+
if (activeCwdProbes >= MAX_CONCURRENT_CWD_PROBES) return;
|
|
32
|
+
activeCwdProbes++;
|
|
33
|
+
execFile("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { timeout: 5000 }, (err, stdout) => {
|
|
34
|
+
activeCwdProbes--;
|
|
35
|
+
if (err || !stdout) return;
|
|
36
|
+
for (const line of stdout.split("\n")) {
|
|
37
|
+
if (line.startsWith("n/")) {
|
|
38
|
+
onCwd(line.slice(1));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
11
45
|
export type SocketAdapter = {
|
|
12
46
|
send: (data: string) => void;
|
|
13
47
|
onMessage: (handler: (data: string) => void) => void;
|
|
@@ -787,6 +821,12 @@ export class Room extends EventEmitter {
|
|
|
787
821
|
return;
|
|
788
822
|
}
|
|
789
823
|
|
|
824
|
+
// The shell reports its own cwd, so the lsof fallback has nothing left to
|
|
825
|
+
// add for this room — retire its timer. Most rooms run a shell that emits
|
|
826
|
+
// OSC 7, so this is what keeps the global probe budget for the few that
|
|
827
|
+
// genuinely need it.
|
|
828
|
+
this.stopCwdPolling();
|
|
829
|
+
|
|
790
830
|
if (cwdPath && cwdPath !== this.liveCwd) {
|
|
791
831
|
if (DEBUG_STATE) {
|
|
792
832
|
console.log(`[hay] room=${this.id} cwd=${cwdPath}`);
|
|
@@ -830,15 +870,7 @@ export class Room extends EventEmitter {
|
|
|
830
870
|
this.foregroundProcess = fg.replace(/^-/, ""); // strip login-shell "-zsh" dash
|
|
831
871
|
}
|
|
832
872
|
if (process.platform === "darwin") {
|
|
833
|
-
|
|
834
|
-
if (err || !stdout) return;
|
|
835
|
-
for (const line of stdout.split("\n")) {
|
|
836
|
-
if (line.startsWith("n/")) {
|
|
837
|
-
updateCwd(line.slice(1));
|
|
838
|
-
break;
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
});
|
|
873
|
+
runGatedCwdProbe(pid, updateCwd);
|
|
842
874
|
} else if (process.platform === "linux") {
|
|
843
875
|
readlink(`/proc/${pid}/cwd`, (err, target) => {
|
|
844
876
|
if (err || !target) return;
|
|
@@ -847,9 +879,13 @@ export class Room extends EventEmitter {
|
|
|
847
879
|
}
|
|
848
880
|
};
|
|
849
881
|
|
|
850
|
-
|
|
882
|
+
// Jitter both the phase and the period: identical timers started together
|
|
883
|
+
// (a `hop restore` brings up dozens of rooms at once) stay in lockstep for
|
|
884
|
+
// the life of the host, turning a per-room poll into a synchronized burst.
|
|
885
|
+
const jitteredInterval = intervalMs + Math.floor(Math.random() * intervalMs);
|
|
886
|
+
this.cwdPollTimer = setInterval(poll, jitteredInterval);
|
|
851
887
|
// Initial poll with a generous delay to not interfere with startup
|
|
852
|
-
setTimeout(poll, 3000);
|
|
888
|
+
setTimeout(poll, 3000 + Math.floor(Math.random() * intervalMs));
|
|
853
889
|
}
|
|
854
890
|
|
|
855
891
|
stopCwdPolling() {
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type ClientMessage
|
|
11
11
|
} from "hay-shared";
|
|
12
12
|
import { activityLabel, sortPresence } from "./utils/presence";
|
|
13
|
-
import { createOptimisticEcho } from "./utils/optimisticEcho";
|
|
13
|
+
import { createOptimisticEcho, hasActiveOtherTypist } from "./utils/optimisticEcho";
|
|
14
14
|
import { attachScrollFlywheel } from "./utils/scrollFlywheel";
|
|
15
15
|
import { collectTerminalMatches, selectTerminalMatch } from "./utils/terminalSearch";
|
|
16
16
|
import { createVoiceHold, speechRecognitionCtor } from "./utils/voiceHold";
|
|
@@ -231,6 +231,10 @@ const paneTreeHasPrimary = (n: PaneNode): boolean =>
|
|
|
231
231
|
n.kind === "leaf" ? n.session === null : paneTreeHasPrimary(n.a) || paneTreeHasPrimary(n.b);
|
|
232
232
|
|
|
233
233
|
const LATENCY_COMP = true;
|
|
234
|
+
// A WebSocket handshake normally completes in well under a second. One still
|
|
235
|
+
// CONNECTING after this long has hung (a stalled tunnel upgrade) and must be
|
|
236
|
+
// aborted and retried — it will never resolve itself.
|
|
237
|
+
const STUCK_CONNECTING_MS = 8000;
|
|
234
238
|
const AUTO_FIT_ON_TYPE = true;
|
|
235
239
|
|
|
236
240
|
type SessionSwitchMode = "page" | "instant";
|
|
@@ -454,6 +458,14 @@ const App = () => {
|
|
|
454
458
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
455
459
|
const [loadingSessions, setLoadingSessions] = useState(false);
|
|
456
460
|
const [sessionsError, setSessionsError] = useState(false);
|
|
461
|
+
// The desktop twin of the iOS swipe filmstrip: an on-demand HUD showing the
|
|
462
|
+
// recency ring centred on the current session, target lit, that appears
|
|
463
|
+
// while you cycle (⌘J/⌘L held / two-finger swipe) and commits on release — like
|
|
464
|
+
// ⌘Tab. Frozen ring so neighbours don't reshuffle mid-cycle (same rule as
|
|
465
|
+
// the iOS swipe order). Ref mirror for the keyup/wheel listeners.
|
|
466
|
+
const [switchHud, setSwitchHud] = useState<{ ring: SessionInfo[]; index: number } | null>(null);
|
|
467
|
+
const switchHudRef = useRef<{ ring: SessionInfo[]; index: number } | null>(null);
|
|
468
|
+
switchHudRef.current = switchHud;
|
|
457
469
|
// Server-owned folders (Manual mode). Kept in App because the sessions
|
|
458
470
|
// fetch already carries them.
|
|
459
471
|
const [folders, setFolders] = useState<Array<{ id: string; name: string }>>([]);
|
|
@@ -602,6 +614,7 @@ const App = () => {
|
|
|
602
614
|
const viewModeRef = useRef(viewMode);
|
|
603
615
|
const reconnectAttemptRef = useRef(0);
|
|
604
616
|
const reconnectTimerRef = useRef<number | null>(null);
|
|
617
|
+
const connectStartedAtRef = useRef<number>(0);
|
|
605
618
|
const shouldReconnectRef = useRef(true);
|
|
606
619
|
const connectNonceRef = useRef(0);
|
|
607
620
|
const userScrolledUpRef = useRef(false);
|
|
@@ -648,6 +661,18 @@ const App = () => {
|
|
|
648
661
|
const pendingInputRef = useRef<Array<{ room: string; data: string; at: number }>>([]);
|
|
649
662
|
// Presence re-render throttle (see the "presence" message handler).
|
|
650
663
|
const presenceThrottleRef = useRef<number | null>(null);
|
|
664
|
+
// Per-peer "last observed typing" — refreshed on every presence frame that
|
|
665
|
+
// shows a peer typing. A live typist keeps advancing it (presence fires per
|
|
666
|
+
// keystroke); a ghost that dropped mid-type stops, so its flag ages out
|
|
667
|
+
// instead of pinning local echo off forever. See hasActiveOtherTypist.
|
|
668
|
+
const typingLastSeenRef = useRef<Map<string, number>>(new Map());
|
|
669
|
+
// Fresh mirrors of the echo-gating state, read at KEYSTROKE time (not from a
|
|
670
|
+
// possibly-stale render), so a silent ghost never keeps echo disabled once
|
|
671
|
+
// its typing flag has gone stale — no re-render is needed to recover.
|
|
672
|
+
const presenceRef = useRef<PresenceClient[]>([]);
|
|
673
|
+
const collabModeRef = useRef(true);
|
|
674
|
+
const controllerIdRef = useRef<string | null>(null);
|
|
675
|
+
const clientIdRef = useRef<string | null>(null);
|
|
651
676
|
const presencePendingRef = useRef<PresenceClient[] | null>(null);
|
|
652
677
|
const activeSessionRoomRef = useRef<string | null>(null);
|
|
653
678
|
// Set each render once switchSession exists (defined later); the keyboard
|
|
@@ -1081,10 +1106,27 @@ const App = () => {
|
|
|
1081
1106
|
// now (presence carries the flag), and drops the instant one starts. A
|
|
1082
1107
|
// sub-second overlap before presence propagates is absorbed by the
|
|
1083
1108
|
// reconciler's guards (complex-chunk passthrough, two-strike clear).
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1109
|
+
// Keep the keystroke-time mirrors current every render.
|
|
1110
|
+
presenceRef.current = presence;
|
|
1111
|
+
collabModeRef.current = collabMode;
|
|
1112
|
+
controllerIdRef.current = controllerId;
|
|
1113
|
+
// The single source of truth for "should local echo be on", evaluated from
|
|
1114
|
+
// refs so it can be recomputed at keystroke time. A peer only suppresses
|
|
1115
|
+
// echo if we've seen them typing RECENTLY (ghosts age out).
|
|
1116
|
+
const echoEnabledNow = () =>
|
|
1117
|
+
LATENCY_COMP && statusForChromeRef.current === "connected"
|
|
1118
|
+
// NOT on the alternate screen. A full-screen TUI (Claude Code's composer,
|
|
1119
|
+
// vim, less) repaints on every keystroke, and that redraw IS the echo —
|
|
1120
|
+
// an optimistic char painted first then overwritten by the redraw is a
|
|
1121
|
+
// visible flicker ("rendered wrong, then changes"), for no latency win the
|
|
1122
|
+
// app's own fast repaint doesn't already give. Optimistic echo belongs to
|
|
1123
|
+
// the SHELL, where the terminal echoes char-by-char with no redraw to
|
|
1124
|
+
// fight. (Reconcile's TUI guard still protects any in-flight echo when the
|
|
1125
|
+
// app flips to alt-screen mid-keystroke.)
|
|
1126
|
+
&& !remoteAltScreenRef.current
|
|
1127
|
+
&& !hasActiveOtherTypist(presenceRef.current, clientIdRef.current, typingLastSeenRef.current, Date.now())
|
|
1128
|
+
&& (collabModeRef.current ? true : controllerIdRef.current === clientIdRef.current);
|
|
1129
|
+
const optimisticActive = echoEnabledNow();
|
|
1088
1130
|
|
|
1089
1131
|
useEffect(() => {
|
|
1090
1132
|
if (optimisticPrevRef.current && !optimisticActive) {
|
|
@@ -1241,7 +1283,7 @@ const App = () => {
|
|
|
1241
1283
|
}
|
|
1242
1284
|
return;
|
|
1243
1285
|
}
|
|
1244
|
-
const echoed = optimisticEchoRef.current.onInput(sanitized,
|
|
1286
|
+
const echoed = optimisticEchoRef.current.onInput(sanitized, echoEnabledNow());
|
|
1245
1287
|
if (echoed) {
|
|
1246
1288
|
writeToTerminal(echoed);
|
|
1247
1289
|
}
|
|
@@ -1372,7 +1414,6 @@ const App = () => {
|
|
|
1372
1414
|
// clientId, so "is this size ours" is an identity check, never a size
|
|
1373
1415
|
// comparison (comparing sizes is how a follower gets fooled into thinking
|
|
1374
1416
|
// an adopted grid is its own).
|
|
1375
|
-
const clientIdRef = useRef<string | null>(null);
|
|
1376
1417
|
// Who owns the shared size right now, per the latest active_size.
|
|
1377
1418
|
const activeOwnerRef = useRef<string | null>(null);
|
|
1378
1419
|
// Non-null while a PEER owns the size in fit mode: the local terminal is
|
|
@@ -1653,6 +1694,10 @@ const App = () => {
|
|
|
1653
1694
|
|
|
1654
1695
|
const ws = new WebSocket(url);
|
|
1655
1696
|
wsRef.current = ws;
|
|
1697
|
+
// When this socket began CONNECTING — the watchdog uses it to catch a
|
|
1698
|
+
// handshake that hangs (accepted TCP, WS upgrade never completes through
|
|
1699
|
+
// the tunnel): it fires no open/close/error, so nothing else can notice.
|
|
1700
|
+
connectStartedAtRef.current = Date.now();
|
|
1656
1701
|
|
|
1657
1702
|
ws.addEventListener("open", () => {
|
|
1658
1703
|
if (connectionNonce !== connectNonceRef.current || activeSessionRoomRef.current !== targetRoom) {
|
|
@@ -1711,6 +1756,19 @@ const App = () => {
|
|
|
1711
1756
|
// indicator) — re-rendering the whole app tree per remote key is
|
|
1712
1757
|
// wasted main-thread time while output streams. Leading update for
|
|
1713
1758
|
// immediacy, then at most one trailing update per 300ms window.
|
|
1759
|
+
// Refresh each typing peer's "last seen" from the RAW frame (before
|
|
1760
|
+
// the render throttle) so a live typist never ages out; a ghost that
|
|
1761
|
+
// stopped sending does. Prune peers no longer present.
|
|
1762
|
+
{
|
|
1763
|
+
const now = Date.now();
|
|
1764
|
+
const seen = typingLastSeenRef.current;
|
|
1765
|
+
const ids = new Set<string>();
|
|
1766
|
+
for (const c of message.clients) {
|
|
1767
|
+
ids.add(c.id);
|
|
1768
|
+
if (c.typing) seen.set(c.id, now);
|
|
1769
|
+
}
|
|
1770
|
+
for (const id of [...seen.keys()]) if (!ids.has(id)) seen.delete(id);
|
|
1771
|
+
}
|
|
1714
1772
|
if (presenceThrottleRef.current === null) {
|
|
1715
1773
|
setPresence(message.clients);
|
|
1716
1774
|
presenceThrottleRef.current = window.setTimeout(() => {
|
|
@@ -1945,6 +2003,39 @@ const App = () => {
|
|
|
1945
2003
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1946
2004
|
}, [session, terminalReady, reconnectToken]);
|
|
1947
2005
|
|
|
2006
|
+
// Reconnect watchdog — the guarantee that a live session never stays
|
|
2007
|
+
// stranded in a non-connected state. scheduleReconnect's loop bails
|
|
2008
|
+
// (correctly) in a few edge cases: a room-ref mismatch when a switch was
|
|
2009
|
+
// gated behind the open wall, or a retry timer cleared by a superseding
|
|
2010
|
+
// connect that then no-op'd. Individually right, together they can leave
|
|
2011
|
+
// the socket dead with nothing driving a retry — the "stuck reconnecting
|
|
2012
|
+
// until you refresh" symptom. This notices a session that SHOULD be up but
|
|
2013
|
+
// has no live/connecting socket and no pending retry, and kicks a fresh
|
|
2014
|
+
// connect through the normal effect (reconnectToken) — a refresh's recovery
|
|
2015
|
+
// without the refresh. It NEVER fires while a socket is open/connecting or a
|
|
2016
|
+
// retry is already scheduled, so it cannot cause a connect storm.
|
|
2017
|
+
useEffect(() => {
|
|
2018
|
+
if (!session || !terminalReady) return;
|
|
2019
|
+
const id = window.setInterval(() => {
|
|
2020
|
+
if (switcherOpenRef.current) return; // the wall owns the socket while it's up
|
|
2021
|
+
if (!shouldReconnectRef.current) return; // session ended / leaving on purpose
|
|
2022
|
+
const st = statusForChromeRef.current;
|
|
2023
|
+
if (st === "connected" || st === "ended") return;
|
|
2024
|
+
const ws = wsRef.current;
|
|
2025
|
+
if (ws && ws.readyState === WebSocket.OPEN) return; // healthy, leave it
|
|
2026
|
+
// A socket wedged in CONNECTING past the deadline is a hung upgrade —
|
|
2027
|
+
// it will never fire open/close/error on its own. Abort it so a fresh
|
|
2028
|
+
// connect can run; a handshake that hasn't hung yet is still given time.
|
|
2029
|
+
if (ws && ws.readyState === WebSocket.CONNECTING) {
|
|
2030
|
+
if (Date.now() - connectStartedAtRef.current < STUCK_CONNECTING_MS) return;
|
|
2031
|
+
try { ws.close(); } catch { /* aborting the hung upgrade */ }
|
|
2032
|
+
}
|
|
2033
|
+
if (reconnectTimerRef.current !== null) return; // a retry is already pending
|
|
2034
|
+
setReconnectToken((value) => value + 1); // stranded → drive a fresh connect
|
|
2035
|
+
}, 2500);
|
|
2036
|
+
return () => window.clearInterval(id);
|
|
2037
|
+
}, [session, terminalReady]);
|
|
2038
|
+
|
|
1948
2039
|
useEffect(() => {
|
|
1949
2040
|
if (!session || !containerRef.current || termRef.current) {
|
|
1950
2041
|
return;
|
|
@@ -3706,19 +3797,43 @@ const App = () => {
|
|
|
3706
3797
|
// Desktop keyboard layer — capture phase so shortcuts win over the focused
|
|
3707
3798
|
// terminal. ⌘-based (Ctrl+Shift elsewhere) to stay clear of readline/TUI
|
|
3708
3799
|
// keys, which the terminal must keep receiving untouched.
|
|
3709
|
-
// ⌘K session palette · ⌘E last session (toggles) · ⌘J
|
|
3800
|
+
// ⌘K session palette · ⌘E last session (toggles) · ⌘J/⌘L cycle left/right ·
|
|
3710
3801
|
// ⌘, settings · ⌘+/−/0 font size · ⌘/ shortcut help · Esc closes drawer/help
|
|
3711
3802
|
useEffect(() => {
|
|
3712
3803
|
if (!session || !isEmbeddedInHop()) return;
|
|
3713
|
-
|
|
3714
|
-
|
|
3804
|
+
// ── Desktop switch HUD (the swipe filmstrip's desktop twin) ────────────
|
|
3805
|
+
// Recency ring, FROZEN when the HUD opens so neighbours don't reshuffle
|
|
3806
|
+
// mid-cycle. Stepping only moves the lit target; the switch is DEFERRED to
|
|
3807
|
+
// commit (modifier release / wheel settle) — one navigation, ⌘Tab-style,
|
|
3808
|
+
// instead of the old switch-per-press.
|
|
3809
|
+
const hudRing = (): SessionInfo[] =>
|
|
3810
|
+
[...sessionsRef.current]
|
|
3715
3811
|
.filter((s) => s.type !== "port")
|
|
3716
|
-
.sort((a, b) => (
|
|
3717
|
-
|
|
3718
|
-
const
|
|
3719
|
-
|
|
3720
|
-
|
|
3812
|
+
.sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
|
|
3813
|
+
const hudStep = (dir: 1 | -1) => {
|
|
3814
|
+
const open = switchHudRef.current;
|
|
3815
|
+
if (open) {
|
|
3816
|
+
setSwitchHud({ ring: open.ring, index: Math.min(open.ring.length - 1, Math.max(0, open.index + dir)) });
|
|
3817
|
+
return;
|
|
3818
|
+
}
|
|
3819
|
+
const ring = hudRing();
|
|
3820
|
+
if (ring.length < 2) return;
|
|
3821
|
+
const curKey = activeSessionRoomRef.current;
|
|
3822
|
+
const cur = ring.findIndex((s) => s.name === curKey || s.internalName === curKey);
|
|
3823
|
+
const start = cur < 0 ? 0 : cur;
|
|
3824
|
+
setSwitchHud({ ring, index: Math.min(ring.length - 1, Math.max(0, start + dir)) });
|
|
3721
3825
|
};
|
|
3826
|
+
const hudCommit = () => {
|
|
3827
|
+
const open = switchHudRef.current;
|
|
3828
|
+
setSwitchHud(null);
|
|
3829
|
+
if (!open) return;
|
|
3830
|
+
const target = open.ring[open.index];
|
|
3831
|
+
const curKey = activeSessionRoomRef.current;
|
|
3832
|
+
if (target && target.name !== curKey && target.internalName !== curKey) {
|
|
3833
|
+
switchSessionRef.current?.(target);
|
|
3834
|
+
}
|
|
3835
|
+
};
|
|
3836
|
+
const hudCancel = () => setSwitchHud(null);
|
|
3722
3837
|
// Back and forth between the two sessions you are actually working in,
|
|
3723
3838
|
// without a trip through the palette. MRU, so pressing it twice returns
|
|
3724
3839
|
// you — the switch itself pushes the room you left to the top of the
|
|
@@ -3745,6 +3860,12 @@ const App = () => {
|
|
|
3745
3860
|
? event.metaKey && !event.ctrlKey && !event.altKey
|
|
3746
3861
|
: event.ctrlKey && event.shiftKey && !event.metaKey && !event.altKey;
|
|
3747
3862
|
if (!mod) {
|
|
3863
|
+
if (event.key === "Escape" && switchHudRef.current) {
|
|
3864
|
+
event.preventDefault();
|
|
3865
|
+
event.stopPropagation();
|
|
3866
|
+
hudCancel();
|
|
3867
|
+
return;
|
|
3868
|
+
}
|
|
3748
3869
|
if (event.key === "Escape" && (drawerOpenRef.current || shortcutHelpRef.current)) {
|
|
3749
3870
|
event.preventDefault();
|
|
3750
3871
|
event.stopPropagation();
|
|
@@ -3765,7 +3886,10 @@ const App = () => {
|
|
|
3765
3886
|
: {}));
|
|
3766
3887
|
return;
|
|
3767
3888
|
}
|
|
3768
|
-
|
|
3889
|
+
// ⌘J / ⌘L step the switch HUD left / right (J is left of L on the
|
|
3890
|
+
// keyboard), committing on ⌘ release — no Shift needed for the reverse.
|
|
3891
|
+
if (key === "j") { grab(); hudStep(-1); return; }
|
|
3892
|
+
if (key === "l") { grab(); hudStep(1); return; }
|
|
3769
3893
|
if (key === ",") { grab(); setDrawerOpen((v) => !v); return; }
|
|
3770
3894
|
if (key === "/" || (shifted && key === "?")) { grab(); setShortcutHelpOpen((v) => !v); return; }
|
|
3771
3895
|
if (key === "e") {
|
|
@@ -3813,7 +3937,42 @@ const App = () => {
|
|
|
3813
3937
|
}
|
|
3814
3938
|
};
|
|
3815
3939
|
window.addEventListener("keydown", onKey, true);
|
|
3816
|
-
|
|
3940
|
+
// Release the hold modifier (⌘ on mac, Ctrl elsewhere) → land on the lit
|
|
3941
|
+
// target, ⌘Tab-style. Any keyup counts: the modifier's own release AND
|
|
3942
|
+
// releasing it while another key is up both leave the flag false.
|
|
3943
|
+
const onKeyUp = (event: KeyboardEvent) => {
|
|
3944
|
+
if (!switchHudRef.current) return;
|
|
3945
|
+
if (!(isMacPlatform ? event.metaKey : event.ctrlKey)) hudCommit();
|
|
3946
|
+
};
|
|
3947
|
+
window.addEventListener("keyup", onKeyUp, true);
|
|
3948
|
+
// Two-finger horizontal swipe = the literal parity with the phone swipe.
|
|
3949
|
+
// Horizontal-dominant wheel only; ignore vertical scroll, pinch, and any
|
|
3950
|
+
// scrollable panel/field; preventDefault so the browser's back/forward
|
|
3951
|
+
// swipe never fires. Steps per 60pt of travel, settles into a commit.
|
|
3952
|
+
let wheelAccum = 0;
|
|
3953
|
+
let wheelTimer: number | null = null;
|
|
3954
|
+
const onWheel = (event: WheelEvent) => {
|
|
3955
|
+
if (!session) return;
|
|
3956
|
+
const el = event.target as Element | null;
|
|
3957
|
+
if (el?.closest(".switcher-scroll, .views-panel, .drawer, input, textarea, [contenteditable]")) return;
|
|
3958
|
+
if (Math.abs(event.deltaX) <= Math.abs(event.deltaY) * 1.3) return;
|
|
3959
|
+
event.preventDefault();
|
|
3960
|
+
wheelAccum += event.deltaX;
|
|
3961
|
+
while (Math.abs(wheelAccum) >= 60) {
|
|
3962
|
+
const dir: 1 | -1 = wheelAccum > 0 ? 1 : -1;
|
|
3963
|
+
wheelAccum -= dir * 60;
|
|
3964
|
+
hudStep(dir);
|
|
3965
|
+
}
|
|
3966
|
+
if (wheelTimer) window.clearTimeout(wheelTimer);
|
|
3967
|
+
wheelTimer = window.setTimeout(() => { wheelAccum = 0; hudCommit(); }, 320);
|
|
3968
|
+
};
|
|
3969
|
+
window.addEventListener("wheel", onWheel, { passive: false, capture: true });
|
|
3970
|
+
return () => {
|
|
3971
|
+
window.removeEventListener("keydown", onKey, true);
|
|
3972
|
+
window.removeEventListener("keyup", onKeyUp, true);
|
|
3973
|
+
window.removeEventListener("wheel", onWheel, true);
|
|
3974
|
+
if (wheelTimer) window.clearTimeout(wheelTimer);
|
|
3975
|
+
};
|
|
3817
3976
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3818
3977
|
}, [session]);
|
|
3819
3978
|
|
|
@@ -3832,6 +3991,36 @@ const App = () => {
|
|
|
3832
3991
|
|
|
3833
3992
|
return (
|
|
3834
3993
|
<div className="app">
|
|
3994
|
+
{switchHud && (() => {
|
|
3995
|
+
const curKey = session?.room ?? sessionLabel;
|
|
3996
|
+
const SLOT = 152; // card 140 + gap 12; must match .switch-hud CSS
|
|
3997
|
+
return (
|
|
3998
|
+
<div className="switch-hud" aria-hidden="true">
|
|
3999
|
+
<div className="switch-hud-window">
|
|
4000
|
+
<div className="switch-hud-strip"
|
|
4001
|
+
style={{ transform: `translateX(-${switchHud.index * SLOT + 70}px)` }}>
|
|
4002
|
+
{switchHud.ring.map((s, i) => {
|
|
4003
|
+
const key = s.internalName || s.name;
|
|
4004
|
+
const isTarget = i === switchHud.index;
|
|
4005
|
+
const isCurrent = key === curKey;
|
|
4006
|
+
return (
|
|
4007
|
+
<div key={key}
|
|
4008
|
+
className={"switch-hud-card"
|
|
4009
|
+
+ (isTarget ? " is-target" : "")
|
|
4010
|
+
+ (isCurrent ? " is-current" : "")}>
|
|
4011
|
+
<span className="switch-hud-name">{s.displayName || s.name}</span>
|
|
4012
|
+
{isCurrent && <span className="switch-hud-now">now</span>}
|
|
4013
|
+
</div>
|
|
4014
|
+
);
|
|
4015
|
+
})}
|
|
4016
|
+
</div>
|
|
4017
|
+
</div>
|
|
4018
|
+
<div className="switch-hud-hint">
|
|
4019
|
+
{isMacPlatform ? "⌘J / ⌘L step · release ⌘ to switch" : "Ctrl+Shift+J / +L step · release to switch"} · esc cancels
|
|
4020
|
+
</div>
|
|
4021
|
+
</div>
|
|
4022
|
+
);
|
|
4023
|
+
})()}
|
|
3835
4024
|
<header className="topbar">
|
|
3836
4025
|
<div className="brand">
|
|
3837
4026
|
<div>
|
|
@@ -4640,7 +4829,7 @@ const App = () => {
|
|
|
4640
4829
|
{[
|
|
4641
4830
|
[isMacPlatform ? "⌘K" : "Ctrl+Shift+K", "session palette (type to filter, ↑↓, ⏎)"],
|
|
4642
4831
|
[isMacPlatform ? "⌘E" : "Ctrl+Shift+E", "back to the last session (press again to return)"],
|
|
4643
|
-
[isMacPlatform ? "⌘J /
|
|
4832
|
+
[isMacPlatform ? "⌘J / ⌘L" : "Ctrl+Shift+J / +L", "previous / next session (left / right)"],
|
|
4644
4833
|
[isMacPlatform ? "⌘," : "Ctrl+Shift+,", "settings drawer"],
|
|
4645
4834
|
[isMacPlatform ? "⌘F" : "Ctrl+Shift+F", "find in terminal"],
|
|
4646
4835
|
...(isMacPlatform ? [["⌘⇧V", "views — results agents published"]] : []),
|