hop2 0.11.1 → 0.11.3
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/.github/workflows/publish.yml +6 -2
- package/README.md +7 -1
- package/hay/apps/server/src/rooms.ts +7 -0
- package/hay/apps/server/test/rooms.test.ts +21 -0
- package/hay/apps/web/src/App.tsx +167 -118
- package/hay/apps/web/src/components/ContextMenu.tsx +109 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +371 -55
- package/hay/apps/web/src/styles.css +231 -41
- package/hay/apps/web/src/utils/switcherModel.ts +41 -4
- package/hay/apps/web/src/utils/voiceHold.ts +170 -0
- package/hay/apps/web/test/SessionSwitcher.test.tsx +51 -0
- package/hay/apps/web/test/switcherModel.test.ts +65 -0
- package/hay-web/assets/{index--cf1ILsi.css → index-BRpBf8QE.css} +1 -1
- package/hay-web/assets/index-BrLbmVOB.js +132 -0
- package/hay-web/assets/index-BrLbmVOB.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +301 -20
- package/package.json +1 -1
- package/scripts/claude-session-hook.js +24 -3
- package/scripts/hay-host.js +14 -1
- package/tests/terminal-api.integration.test.cjs +26 -0
- package/hay-web/assets/index-_wMnETjm.js +0 -130
- package/hay-web/assets/index-_wMnETjm.js.map +0 -1
|
@@ -64,8 +64,12 @@ jobs:
|
|
|
64
64
|
node-version: 22
|
|
65
65
|
registry-url: https://registry.npmjs.org
|
|
66
66
|
|
|
67
|
-
# Trusted publishing needs npm >= 11.5.1
|
|
68
|
-
|
|
67
|
+
# Trusted publishing needs npm >= 11.5.1 and the runner ships older.
|
|
68
|
+
# Pinned to the 11.x line rather than @latest: npm 12 requires Node
|
|
69
|
+
# >= 22.22, so `npm@latest` breaks the moment npm raises its engine
|
|
70
|
+
# floor above whatever Node this workflow pins — which is exactly how
|
|
71
|
+
# the first nebula-notebook publish failed.
|
|
72
|
+
- run: npm install -g npm@^11
|
|
69
73
|
|
|
70
74
|
# Nothing new since the last nightly? Then there is nothing to publish —
|
|
71
75
|
# quiet days should not mint versions. npm records the commit each
|
package/README.md
CHANGED
|
@@ -199,7 +199,13 @@ The attached-terminal UI adapts light/dark themes to your terminal and puts the
|
|
|
199
199
|
|
|
200
200
|
### Use Hop on Mobile
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
**Native iOS app**: [hop-ios](https://github.com/jzthree/hop-ios) — a SwiftUI
|
|
203
|
+
client for iPhone with a real terminal (SwiftTerm), optimistic-echo typing,
|
|
204
|
+
bell notifications with lock-screen reply, Siri/Shortcuts/Spotlight/Handoff
|
|
205
|
+
integration, and a fixed-height terminal keyboard. It consumes hop's public
|
|
206
|
+
HTTP + WebSocket API and needs no changes to hop.
|
|
207
|
+
|
|
208
|
+
Hop’s mobile **web** UI includes:
|
|
203
209
|
|
|
204
210
|
- a terminal-oriented accessory row with `Esc`, `Tab`, `Ctrl`, `Alt`, and arrows
|
|
205
211
|
- a native keyboard button for dictation, spellcheck, and autocomplete
|
|
@@ -28,6 +28,12 @@ export type ClientInfo = {
|
|
|
28
28
|
// Optional per-connection cap for the join snapshot (clamped to the
|
|
29
29
|
// room-level bound). Monitors request ~64KB.
|
|
30
30
|
replayBytes?: number;
|
|
31
|
+
// False = skip the equal-size attach "wiggle" (the −1/+1 column repaint
|
|
32
|
+
// nudge). Wall tiles decline it: they arrive already showing the current
|
|
33
|
+
// grid (the daemon's headless screen, complete — not a raw byte tail), so
|
|
34
|
+
// the nudge's two SIGWINCHes only produce a visible reflow of the running
|
|
35
|
+
// app at the exact moment the tile goes live — the "twitch".
|
|
36
|
+
nudge?: boolean;
|
|
31
37
|
};
|
|
32
38
|
|
|
33
39
|
export type RoomCreateOptions = {
|
|
@@ -371,6 +377,7 @@ export class Room extends EventEmitter {
|
|
|
371
377
|
// anyway; when sizes are EQUAL nothing would repaint, so nudge: a one-
|
|
372
378
|
// column wiggle (tmux's redraw trick) makes full-screen apps repaint.
|
|
373
379
|
if (
|
|
380
|
+
info.nudge !== false &&
|
|
374
381
|
client.cols === this.activeCols &&
|
|
375
382
|
client.rows === this.activeRows &&
|
|
376
383
|
this.activeCols > 4 &&
|
|
@@ -100,6 +100,27 @@ describe("Room", () => {
|
|
|
100
100
|
expect(capturedOptions?.shell).toBe("/bin/zsh");
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
it("an equal-size attach nudges a repaint unless the client declines", () => {
|
|
104
|
+
let ptyInstance: FakePty | null = null;
|
|
105
|
+
const factory: PtyFactory = () => {
|
|
106
|
+
ptyInstance = new FakePty() as unknown as FakePty;
|
|
107
|
+
return ptyInstance as any;
|
|
108
|
+
};
|
|
109
|
+
const manager = new RoomManager(factory);
|
|
110
|
+
const room = manager.getRoom("wiggle", { cols: 80, rows: 24 }, "/tmp");
|
|
111
|
+
|
|
112
|
+
// Default attach at the PTY's exact size: the −1 wiggle fires (the
|
|
113
|
+
// bounce-back half is on a timer; the first resize is the evidence).
|
|
114
|
+
room.attachClient({ id: "a", name: "Alex", colorIndex: 0, cols: 80, rows: 24 }, new FakeSocket());
|
|
115
|
+
expect(ptyInstance!.resizes.some((r: { cols: number }) => r.cols === 79)).toBe(true);
|
|
116
|
+
|
|
117
|
+
// A wall tile declines (nudge: false): it already shows the current
|
|
118
|
+
// grid, and the wiggle's SIGWINCH reflow is the click-twitch. No resize.
|
|
119
|
+
const before = ptyInstance!.resizes.length;
|
|
120
|
+
room.attachClient({ id: "b", name: "Tile", colorIndex: 1, cols: 80, rows: 24, nudge: false }, new FakeSocket());
|
|
121
|
+
expect(ptyInstance!.resizes.length).toBe(before);
|
|
122
|
+
});
|
|
123
|
+
|
|
103
124
|
it("broadcasts pty output to all clients", () => {
|
|
104
125
|
let ptyInstance: FakePty | null = null;
|
|
105
126
|
const factory: PtyFactory = () => {
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { activityLabel, sortPresence } from "./utils/presence";
|
|
|
13
13
|
import { createOptimisticEcho } from "./utils/optimisticEcho";
|
|
14
14
|
import { attachScrollFlywheel } from "./utils/scrollFlywheel";
|
|
15
15
|
import { collectTerminalMatches, selectTerminalMatch } from "./utils/terminalSearch";
|
|
16
|
+
import { createVoiceHold, speechRecognitionCtor } from "./utils/voiceHold";
|
|
16
17
|
import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
|
|
17
18
|
import { originalPathHint } from "./utils/fileDrop";
|
|
18
19
|
import { MobileKeyboard } from "./components/MobileKeyboard";
|
|
@@ -375,6 +376,9 @@ const App = () => {
|
|
|
375
376
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
376
377
|
const [loadingSessions, setLoadingSessions] = useState(false);
|
|
377
378
|
const [sessionsError, setSessionsError] = useState(false);
|
|
379
|
+
// Server-owned folders (Manual mode). Kept in App because the sessions
|
|
380
|
+
// fetch already carries them.
|
|
381
|
+
const [folders, setFolders] = useState<Array<{ id: string; name: string }>>([]);
|
|
378
382
|
// Attention plumbing: tab-title alert for a bell in the attached session
|
|
379
383
|
// while the tab is hidden; inline editors for display name / rename / create.
|
|
380
384
|
const [titleAlert, setTitleAlert] = useState(false);
|
|
@@ -515,6 +519,10 @@ const App = () => {
|
|
|
515
519
|
const shouldReconnectRef = useRef(true);
|
|
516
520
|
const connectNonceRef = useRef(0);
|
|
517
521
|
const userScrolledUpRef = useRef(false);
|
|
522
|
+
// Late-bound hooks for the voice controller (created before these exist).
|
|
523
|
+
const handleUserInputRef = useRef<((data: string) => void) | null>(null);
|
|
524
|
+
const pushNoticeRef = useRef<((m: string) => void) | null>(null);
|
|
525
|
+
const foregroundIsClaudeRef = useRef<(() => boolean) | null>(null);
|
|
518
526
|
// Custom overlay scrollbar (native overlay bars can't be styled, and styled
|
|
519
527
|
// webkit bars reserve layout width): a floating semi-transparent thumb.
|
|
520
528
|
const termScrollbarRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -559,13 +567,44 @@ const App = () => {
|
|
|
559
567
|
// Claude Code titles its process with a bare version number; hop's session
|
|
560
568
|
// poll surfaces it as foregroundProcess. Used to unlock claude-specific key
|
|
561
569
|
// encodings (Shift+Enter) without protocol negotiation.
|
|
570
|
+
// Is Claude Code the thing on screen? The foreground PROCESS cannot answer
|
|
571
|
+
// alone: a session relaunched by `hop restore` runs
|
|
572
|
+
// `shell -lc "claude ...; exec shell -l"`, so the room reports the WRAPPER
|
|
573
|
+
// SHELL for claude's whole life. Everything gated on this — Shift+Enter as
|
|
574
|
+
// a newline, hold-space voice — silently stopped working in exactly the
|
|
575
|
+
// sessions a restore brought back, which is why voice "does not always
|
|
576
|
+
// work". The screen is the honest witness, so fall back to claude's own
|
|
577
|
+
// chrome in the buffer (cached ~1s, since key handlers ask per keystroke).
|
|
578
|
+
const claudeScreenRef = useRef<{ at: number; value: boolean }>({ at: 0, value: false });
|
|
579
|
+
const screenLooksLikeClaude = () => {
|
|
580
|
+
const now = performance.now();
|
|
581
|
+
if (now - claudeScreenRef.current.at < 1000) return claudeScreenRef.current.value;
|
|
582
|
+
let value = false;
|
|
583
|
+
const t = termRef.current;
|
|
584
|
+
if (t) {
|
|
585
|
+
try {
|
|
586
|
+
const buf = t.buffer.active;
|
|
587
|
+
const end = buf.baseY + t.rows;
|
|
588
|
+
let text = "";
|
|
589
|
+
for (let i = Math.max(0, end - 30); i < end; i++) {
|
|
590
|
+
text += (buf.getLine(i)?.translateToString(true) ?? "") + "\n";
|
|
591
|
+
}
|
|
592
|
+
value = /bypass permissions on|shift\+tab to cycle|esc to interrupt|\? for shortcuts/i.test(text);
|
|
593
|
+
} catch { /* disposed mid-read */ }
|
|
594
|
+
}
|
|
595
|
+
claudeScreenRef.current = { at: now, value };
|
|
596
|
+
return value;
|
|
597
|
+
};
|
|
562
598
|
const foregroundIsClaude = () => {
|
|
563
599
|
const room = activeSessionRoomRef.current;
|
|
564
|
-
if (
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
600
|
+
if (room) {
|
|
601
|
+
const s = sessionsRef.current.find((x) => (x.internalName || x.name) === room);
|
|
602
|
+
const proc = (s?.foregroundProcess || "").trim();
|
|
603
|
+
if (proc === "claude" || /^\d+\.\d+\.\d+$/.test(proc)) return true;
|
|
604
|
+
}
|
|
605
|
+
return screenLooksLikeClaude();
|
|
568
606
|
};
|
|
607
|
+
foregroundIsClaudeRef.current = foregroundIsClaude;
|
|
569
608
|
const drawerOpenRef = useRef(drawerOpen);
|
|
570
609
|
drawerOpenRef.current = drawerOpen;
|
|
571
610
|
const shortcutHelpRef = useRef(shortcutHelpOpen);
|
|
@@ -720,6 +759,7 @@ const App = () => {
|
|
|
720
759
|
}, 3000);
|
|
721
760
|
};
|
|
722
761
|
|
|
762
|
+
pushNoticeRef.current = pushNotice;
|
|
723
763
|
const showToast = (message: string, durationMs = 2000) => {
|
|
724
764
|
setToast(message);
|
|
725
765
|
if (toastTimeout.current) {
|
|
@@ -950,78 +990,18 @@ const App = () => {
|
|
|
950
990
|
};
|
|
951
991
|
|
|
952
992
|
// ── Voice input: hold SPACE in a Claude session to dictate. ──
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
// most common key; crossing the hold threshold erases that space and
|
|
956
|
-
// listens until release. The transcript is TYPED, not submitted: you can
|
|
957
|
-
// edit before Enter. Auto-repeat spaces are swallowed while held, which in
|
|
958
|
-
// a Claude composer is noise nobody wants anyway.
|
|
959
|
-
const voiceTimerRef = useRef(0);
|
|
960
|
-
const voiceActiveRef = useRef(false);
|
|
961
|
-
const voiceRecRef = useRef<{ stop: () => void } | null>(null);
|
|
962
|
-
const voiceFinalRef = useRef("");
|
|
963
|
-
const voiceInterimRef = useRef("");
|
|
993
|
+
// Shared controller (utils/voiceHold) — the live wall tiles run the same
|
|
994
|
+
// machine, so dictation exists on every surface a Claude composer does.
|
|
964
995
|
const [voiceOverlay, setVoiceOverlay] = useState<{ interim: string } | null>(null);
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
// result ever arrived, it is still what the user said — use it.
|
|
975
|
-
const text = (voiceFinalRef.current || voiceInterimRef.current).trim();
|
|
976
|
-
voiceFinalRef.current = "";
|
|
977
|
-
voiceInterimRef.current = "";
|
|
978
|
-
if (send && text) handleUserInput(text);
|
|
979
|
-
};
|
|
980
|
-
const startVoiceInput = () => {
|
|
981
|
-
if (voiceActiveRef.current) return;
|
|
982
|
-
const Ctor = speechRecognitionCtor() as (new () => never) | undefined;
|
|
983
|
-
if (!Ctor) return;
|
|
984
|
-
let rec: never;
|
|
985
|
-
try { rec = new Ctor(); } catch { return; }
|
|
986
|
-
const r = rec as {
|
|
987
|
-
lang: string; continuous: boolean; interimResults: boolean;
|
|
988
|
-
onresult: (ev: { resultIndex: number; results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }> }) => void;
|
|
989
|
-
onerror: (ev: { error?: string }) => void; onend: () => void;
|
|
990
|
-
start: () => void; stop: () => void;
|
|
991
|
-
};
|
|
992
|
-
voiceActiveRef.current = true;
|
|
993
|
-
voiceFinalRef.current = "";
|
|
994
|
-
voiceInterimRef.current = "";
|
|
995
|
-
handleUserInput(""); // erase the space the initial press typed
|
|
996
|
-
r.lang = navigator.language || "en-US";
|
|
997
|
-
r.continuous = true;
|
|
998
|
-
r.interimResults = true;
|
|
999
|
-
r.onresult = (ev) => {
|
|
1000
|
-
let interim = "";
|
|
1001
|
-
for (let i = ev.resultIndex; i < ev.results.length; i++) {
|
|
1002
|
-
const res = ev.results[i];
|
|
1003
|
-
if (res.isFinal) voiceFinalRef.current += res[0].transcript;
|
|
1004
|
-
else interim += res[0].transcript;
|
|
1005
|
-
}
|
|
1006
|
-
voiceInterimRef.current = interim;
|
|
1007
|
-
setVoiceOverlay({ interim: (voiceFinalRef.current + interim).trim() });
|
|
1008
|
-
};
|
|
1009
|
-
r.onerror = (ev) => {
|
|
1010
|
-
if (ev?.error === "not-allowed" || ev?.error === "service-not-allowed") {
|
|
1011
|
-
pushNotice("Microphone permission needed for voice input");
|
|
1012
|
-
}
|
|
1013
|
-
finishVoiceInput(false);
|
|
1014
|
-
};
|
|
1015
|
-
r.onend = () => finishVoiceInput(true);
|
|
1016
|
-
voiceRecRef.current = r;
|
|
1017
|
-
setVoiceOverlay({ interim: "" });
|
|
1018
|
-
try { r.start(); } catch { finishVoiceInput(false); }
|
|
1019
|
-
};
|
|
1020
|
-
const stopVoiceInput = () => {
|
|
1021
|
-
const rec = voiceRecRef.current;
|
|
1022
|
-
if (rec) { try { rec.stop(); } catch { finishVoiceInput(true); } }
|
|
1023
|
-
else finishVoiceInput(true);
|
|
1024
|
-
};
|
|
996
|
+
const voiceHoldRef = useRef<ReturnType<typeof createVoiceHold> | null>(null);
|
|
997
|
+
if (!voiceHoldRef.current) {
|
|
998
|
+
voiceHoldRef.current = createVoiceHold({
|
|
999
|
+
send: (data) => handleUserInputRef.current?.(data),
|
|
1000
|
+
notify: (m) => pushNoticeRef.current?.(m),
|
|
1001
|
+
setOverlay: (interim) => setVoiceOverlay(interim === null ? null : { interim }),
|
|
1002
|
+
eligible: () => foregroundIsClaudeRef.current?.() ?? false
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1025
1005
|
|
|
1026
1006
|
const handleUserInput = (data: string) => {
|
|
1027
1007
|
// Typing is "I am at the prompt": any earlier scroll-up turned
|
|
@@ -1102,6 +1082,7 @@ const App = () => {
|
|
|
1102
1082
|
|
|
1103
1083
|
// One-shot: armed when a snapshot loads (fresh session open/switch), consumed
|
|
1104
1084
|
// by the next resize so it goes out as an attach claim.
|
|
1085
|
+
handleUserInputRef.current = handleUserInput;
|
|
1105
1086
|
const attachClaimPendingRef = useRef(false);
|
|
1106
1087
|
// Throttle gate for fit-on-type (one fit per typing burst, not per key).
|
|
1107
1088
|
const lastTypeFitAtRef = useRef(0);
|
|
@@ -1132,6 +1113,16 @@ const App = () => {
|
|
|
1132
1113
|
// Rooms the user asked full history for: their next connect replays the
|
|
1133
1114
|
// server maximum instead of the fast bounded snapshot.
|
|
1134
1115
|
const deepReplayRoomsRef = useRef(new Set<string>());
|
|
1116
|
+
// Guards for the automatic deep-history load. The buffer sits at viewport
|
|
1117
|
+
// 0 during every attach write (the terminal is reset before the snapshot
|
|
1118
|
+
// lands), so "y === 0" alone misfired the reload DURING attach on any
|
|
1119
|
+
// session busy enough to have a capped snapshot — which then restored the
|
|
1120
|
+
// viewport to old-top with follow-mode off: terminal parked above the
|
|
1121
|
+
// bottom, new output invisible until a manual scroll. The trigger now
|
|
1122
|
+
// requires a real gesture: a transition from scrolled-up to top, after the
|
|
1123
|
+
// snapshot has settled.
|
|
1124
|
+
const lastViewportYRef = useRef(0);
|
|
1125
|
+
const snapshotSettledAtRef = useRef(0);
|
|
1135
1126
|
const snapshotWasCappedRef = useRef(false);
|
|
1136
1127
|
// Set while an automatic deep-history reload is in flight: carries the old
|
|
1137
1128
|
// buffer length so the post-reload snapshot can restore the reading
|
|
@@ -1309,7 +1300,14 @@ const App = () => {
|
|
|
1309
1300
|
&& (termRef.current.cols !== cols || termRef.current.rows !== rows)) {
|
|
1310
1301
|
try { termRef.current.resize(cols, rows); } catch { /* keep local size */ }
|
|
1311
1302
|
}
|
|
1312
|
-
termRef.current.write(screen.data, () =>
|
|
1303
|
+
termRef.current.write(screen.data, () => {
|
|
1304
|
+
const t = termRef.current;
|
|
1305
|
+
if (!t) return;
|
|
1306
|
+
// Pin to the newest line — the fast paint is a stand-in for the
|
|
1307
|
+
// snapshot and must land where the snapshot would.
|
|
1308
|
+
t.scrollToBottom();
|
|
1309
|
+
t.refresh(0, t.rows - 1);
|
|
1310
|
+
});
|
|
1313
1311
|
})
|
|
1314
1312
|
.catch(() => { /* fast paint is best-effort */ });
|
|
1315
1313
|
|
|
@@ -1454,13 +1452,21 @@ const App = () => {
|
|
|
1454
1452
|
if (pendingRestore) {
|
|
1455
1453
|
// The user was reading at the top of the OLD buffer; that
|
|
1456
1454
|
// content now sits newLen - oldLen lines down. Land there,
|
|
1457
|
-
// not at the bottom — the reload should be invisible.
|
|
1455
|
+
// not at the bottom — the reload should be invisible. (This
|
|
1456
|
+
// used to fall through to scrollToBottom anyway, yanking the
|
|
1457
|
+
// view away from the spot it had just restored.)
|
|
1458
1458
|
const line = Math.max(0, t.buffer.active.length - pendingRestore.oldLen);
|
|
1459
1459
|
t.scrollToLine(line);
|
|
1460
|
-
userScrolledUpRef.current =
|
|
1460
|
+
userScrolledUpRef.current = line < Math.max(0, t.buffer.active.length - t.rows);
|
|
1461
|
+
} else {
|
|
1462
|
+
t.scrollToBottom();
|
|
1463
|
+
userScrolledUpRef.current = false;
|
|
1461
1464
|
}
|
|
1462
|
-
t.scrollToBottom();
|
|
1463
1465
|
t.refresh(0, t.rows - 1);
|
|
1466
|
+
// The snapshot has fully landed: only from here on can a
|
|
1467
|
+
// viewport-top event mean a user gesture.
|
|
1468
|
+
lastViewportYRef.current = t.buffer.active.viewportY;
|
|
1469
|
+
snapshotSettledAtRef.current = performance.now();
|
|
1464
1470
|
});
|
|
1465
1471
|
} else {
|
|
1466
1472
|
writeToTerminal(message.data);
|
|
@@ -1735,32 +1741,19 @@ const App = () => {
|
|
|
1735
1741
|
|
|
1736
1742
|
// Prevent browser from intercepting common terminal shortcuts
|
|
1737
1743
|
terminal.attachCustomKeyEventHandler((event) => {
|
|
1738
|
-
//
|
|
1739
|
-
//
|
|
1740
|
-
|
|
1741
|
-
if (event.code === 'Space' && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey
|
|
1742
|
-
&& foregroundIsClaude() && !!speechRecognitionCtor()) {
|
|
1744
|
+
// ⌘⏎ leaves full screen for the wall — the mirror of ⌘⏎ on a wall
|
|
1745
|
+
// tile entering it. One chord toggles the mode from either side.
|
|
1746
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey) {
|
|
1743
1747
|
if (event.type === 'keydown') {
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
voiceTimerRef.current = window.setTimeout(startVoiceInput, 550);
|
|
1747
|
-
return true;
|
|
1748
|
-
}
|
|
1749
|
-
return false; // swallow auto-repeat while held
|
|
1750
|
-
}
|
|
1751
|
-
if (event.type === 'keyup') {
|
|
1752
|
-
window.clearTimeout(voiceTimerRef.current);
|
|
1753
|
-
if (voiceActiveRef.current) {
|
|
1754
|
-
stopVoiceInput();
|
|
1755
|
-
return false;
|
|
1756
|
-
}
|
|
1757
|
-
return true;
|
|
1748
|
+
event.preventDefault();
|
|
1749
|
+
setSwitcherOpen(true);
|
|
1758
1750
|
}
|
|
1751
|
+
return false;
|
|
1759
1752
|
}
|
|
1760
|
-
//
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1753
|
+
// Long-press SPACE = voice input — shared controller; see voiceHold.ts.
|
|
1754
|
+
{
|
|
1755
|
+
const verdict = voiceHoldRef.current?.handleKey(event);
|
|
1756
|
+
if (verdict !== undefined) return verdict;
|
|
1764
1757
|
}
|
|
1765
1758
|
// Shift+Enter: xterm.js would emit a plain \r (indistinguishable from
|
|
1766
1759
|
// Enter), which apps like Claude Code treat as "submit". Synthesize the
|
|
@@ -1865,12 +1858,14 @@ const App = () => {
|
|
|
1865
1858
|
const onBufferScroll = () => {
|
|
1866
1859
|
const { y, base } = scrollState();
|
|
1867
1860
|
userScrolledUpRef.current = y < base;
|
|
1868
|
-
// Hitting the very TOP of a bounded-replay buffer means the user wants
|
|
1869
|
-
// more history than the fast snapshot carried — offer the full reload.
|
|
1870
1861
|
// Top of a capped snapshot: the user wants more history than the fast
|
|
1871
|
-
// snapshot carried
|
|
1872
|
-
//
|
|
1873
|
-
|
|
1862
|
+
// snapshot carried — load it automatically. "The user wants" must mean
|
|
1863
|
+
// a real gesture: arriving at 0 FROM above, on a settled snapshot.
|
|
1864
|
+
const cameFromAbove = lastViewportYRef.current > 0;
|
|
1865
|
+
const settled = snapshotSettledAtRef.current > 0
|
|
1866
|
+
&& performance.now() - snapshotSettledAtRef.current > 600;
|
|
1867
|
+
lastViewportYRef.current = y;
|
|
1868
|
+
if (y === 0 && cameFromAbove && settled && snapshotWasCappedRef.current) {
|
|
1874
1869
|
const room = activeSessionRoomRef.current;
|
|
1875
1870
|
if (room && !deepReplayRoomsRef.current.has(room)) {
|
|
1876
1871
|
deepReplayRoomsRef.current.add(room);
|
|
@@ -2337,7 +2332,7 @@ const App = () => {
|
|
|
2337
2332
|
const row = Math.floor(((tapStartY - rect.top) / rect.height) * term.rows);
|
|
2338
2333
|
if (col >= 0 && col < term.cols && row >= 0 && row < term.rows) {
|
|
2339
2334
|
const url = extractUrlAtCell(term.buffer.active.viewportY + row, col);
|
|
2340
|
-
if (url) setLinkPrompt(url);
|
|
2335
|
+
if (url) setLinkPrompt({ url, x: tapStartX, y: tapStartY });
|
|
2341
2336
|
}
|
|
2342
2337
|
}
|
|
2343
2338
|
}
|
|
@@ -2512,6 +2507,32 @@ const App = () => {
|
|
|
2512
2507
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2513
2508
|
}, [switcherOpen]);
|
|
2514
2509
|
|
|
2510
|
+
// THE RENDER INVARIANT, enforced continuously instead of per-transition.
|
|
2511
|
+
// Three rounds of patching individual transitions (wall close, tab
|
|
2512
|
+
// visibility, context loss, attach paths) each fixed the reproduced case
|
|
2513
|
+
// and missed a sibling — the class kept coming back as "renders only
|
|
2514
|
+
// after I scroll". Scrolling "fixed" it because a viewport move repaints
|
|
2515
|
+
// everything; so enforce what scrolling incidentally provides:
|
|
2516
|
+
// while following, the newest line is on screen — and the screen is
|
|
2517
|
+
// repainted from the buffer every tick, no matter which code path
|
|
2518
|
+
// forgot to.
|
|
2519
|
+
// A full refresh of ~50 rows at 0.7Hz is microseconds on the GPU renderer
|
|
2520
|
+
// and cheap on the DOM one; correctness beats elegance here.
|
|
2521
|
+
useEffect(() => {
|
|
2522
|
+
if (!session) return;
|
|
2523
|
+
const id = window.setInterval(() => {
|
|
2524
|
+
if (document.hidden || switcherOpenRef.current) return;
|
|
2525
|
+
const t = termRef.current;
|
|
2526
|
+
if (!t) return;
|
|
2527
|
+
try {
|
|
2528
|
+
const buf = t.buffer.active;
|
|
2529
|
+
if (!userScrolledUpRef.current && buf.viewportY < buf.baseY) t.scrollToBottom();
|
|
2530
|
+
t.refresh(0, t.rows - 1);
|
|
2531
|
+
} catch { /* disposed mid-tick */ }
|
|
2532
|
+
}, 1400);
|
|
2533
|
+
return () => window.clearInterval(id);
|
|
2534
|
+
}, [session]);
|
|
2535
|
+
|
|
2515
2536
|
// Tab or window coming back to the foreground: same stale-frame risk as
|
|
2516
2537
|
// the wall closing, from browser throttling rather than our own hiding.
|
|
2517
2538
|
useEffect(() => {
|
|
@@ -2629,7 +2650,8 @@ const App = () => {
|
|
|
2629
2650
|
tagline: typeof s.tagline === "string" ? s.tagline : undefined,
|
|
2630
2651
|
parked: s.parked === true,
|
|
2631
2652
|
archived: s.archived === true,
|
|
2632
|
-
hasLocalCli: s.hasLocalCli === true
|
|
2653
|
+
hasLocalCli: s.hasLocalCli === true,
|
|
2654
|
+
folderId: typeof s.folderId === "string" ? s.folderId : null
|
|
2633
2655
|
});
|
|
2634
2656
|
}
|
|
2635
2657
|
|
|
@@ -2701,6 +2723,11 @@ const App = () => {
|
|
|
2701
2723
|
// Drop a response superseded by a newer fetch started while this was
|
|
2702
2724
|
// in flight (see the sequence-guard note above).
|
|
2703
2725
|
if (seq !== fetchSeqRef.current) return;
|
|
2726
|
+
setFolders(Array.isArray(data.folders)
|
|
2727
|
+
? data.folders
|
|
2728
|
+
.filter((f: { id?: string; name?: string }) => f && f.id && f.name)
|
|
2729
|
+
.map((f: { id: string; name: string }) => ({ id: String(f.id), name: String(f.name) }))
|
|
2730
|
+
: []);
|
|
2704
2731
|
setSessions(list);
|
|
2705
2732
|
setSessionsError(false);
|
|
2706
2733
|
sessionListLoadedRef.current = true;
|
|
@@ -3151,7 +3178,7 @@ const App = () => {
|
|
|
3151
3178
|
// layer disables native pointer events on terminal text, and remote apps
|
|
3152
3179
|
// can't open the phone's browser — claude login prints a URL that was
|
|
3153
3180
|
// otherwise un-tappable.)
|
|
3154
|
-
const [linkPrompt, setLinkPrompt] = useState<string | null>(null);
|
|
3181
|
+
const [linkPrompt, setLinkPrompt] = useState<{ url: string; x: number; y: number } | null>(null);
|
|
3155
3182
|
// Passkey nudge: biometric sign-in works but is invisible — the login page
|
|
3156
3183
|
// only offers Touch ID / Face ID once a credential exists for this
|
|
3157
3184
|
// hostname, so a user who never opened the settings drawer never learns it
|
|
@@ -3421,6 +3448,7 @@ const App = () => {
|
|
|
3421
3448
|
sessions={sessions}
|
|
3422
3449
|
currentRoom={null}
|
|
3423
3450
|
dismissable={false}
|
|
3451
|
+
folders={folders}
|
|
3424
3452
|
onClose={() => {}}
|
|
3425
3453
|
onSwitch={(next) => {
|
|
3426
3454
|
window.location.href = buildSessionPath(next.displayName || next.name);
|
|
@@ -4075,6 +4103,7 @@ const App = () => {
|
|
|
4075
4103
|
onNotice={showToast}
|
|
4076
4104
|
tileWsBase={resolveWsUrl()}
|
|
4077
4105
|
onFocusSession={focusSessionInPlace}
|
|
4106
|
+
folders={folders}
|
|
4078
4107
|
userName={name}
|
|
4079
4108
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
4080
4109
|
onOpenSettings={() => {
|
|
@@ -4145,23 +4174,34 @@ const App = () => {
|
|
|
4145
4174
|
</div>
|
|
4146
4175
|
)}
|
|
4147
4176
|
{linkPrompt && (
|
|
4148
|
-
<div
|
|
4149
|
-
|
|
4177
|
+
<div
|
|
4178
|
+
className="context-menu link-menu"
|
|
4179
|
+
role="menu"
|
|
4180
|
+
aria-label="Open link"
|
|
4181
|
+
style={{
|
|
4182
|
+
left: Math.max(8, Math.min(linkPrompt.x, window.innerWidth - 268)),
|
|
4183
|
+
top: Math.max(8, Math.min(linkPrompt.y + 10, window.innerHeight - 150))
|
|
4184
|
+
}}
|
|
4185
|
+
>
|
|
4186
|
+
<span className="context-menu-title">{linkPrompt.url}</span>
|
|
4150
4187
|
<button
|
|
4151
4188
|
type="button"
|
|
4152
|
-
|
|
4189
|
+
role="menuitem"
|
|
4190
|
+
className="context-menu-item"
|
|
4153
4191
|
onClick={() => {
|
|
4154
|
-
window.open(linkPrompt, "_blank", "noopener");
|
|
4192
|
+
window.open(linkPrompt.url, "_blank", "noopener");
|
|
4155
4193
|
setLinkPrompt(null);
|
|
4156
4194
|
}}
|
|
4157
4195
|
>
|
|
4158
|
-
Open
|
|
4196
|
+
<span>Open link</span>
|
|
4159
4197
|
</button>
|
|
4160
4198
|
<button
|
|
4161
4199
|
type="button"
|
|
4200
|
+
role="menuitem"
|
|
4201
|
+
className="context-menu-item"
|
|
4162
4202
|
onClick={async () => {
|
|
4163
4203
|
try {
|
|
4164
|
-
await navigator.clipboard.writeText(linkPrompt);
|
|
4204
|
+
await navigator.clipboard.writeText(linkPrompt.url);
|
|
4165
4205
|
showToast("Link copied");
|
|
4166
4206
|
} catch {
|
|
4167
4207
|
showToast("Clipboard denied — long-press the URL text instead");
|
|
@@ -4169,9 +4209,18 @@ const App = () => {
|
|
|
4169
4209
|
setLinkPrompt(null);
|
|
4170
4210
|
}}
|
|
4171
4211
|
>
|
|
4172
|
-
Copy
|
|
4212
|
+
<span>Copy link</span>
|
|
4213
|
+
</button>
|
|
4214
|
+
<div className="context-menu-separator" role="separator" />
|
|
4215
|
+
<button
|
|
4216
|
+
type="button"
|
|
4217
|
+
role="menuitem"
|
|
4218
|
+
className="context-menu-item"
|
|
4219
|
+
onClick={() => setLinkPrompt(null)}
|
|
4220
|
+
>
|
|
4221
|
+
<span>Cancel</span>
|
|
4222
|
+
<kbd>esc</kbd>
|
|
4173
4223
|
</button>
|
|
4174
|
-
<button type="button" aria-label="Dismiss" onClick={() => setLinkPrompt(null)}>✕</button>
|
|
4175
4224
|
</div>
|
|
4176
4225
|
)}
|
|
4177
4226
|
{isMobile && (
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
// One context menu for the whole app.
|
|
4
|
+
//
|
|
5
|
+
// Right-click is a shortcut to the actions a surface already has — it must
|
|
6
|
+
// never be the ONLY way to reach something, and it must never invent a
|
|
7
|
+
// different vocabulary from the buttons beside it. So this renders the same
|
|
8
|
+
// items a surface's visible affordances expose, in the same order, with the
|
|
9
|
+
// same words.
|
|
10
|
+
//
|
|
11
|
+
// Deliberately NOT applied to the terminal: there, right-click already means
|
|
12
|
+
// something (paste on Linux/Windows, the native copy/paste menu everywhere),
|
|
13
|
+
// and mouse-reporting apps — Claude, vim, less — receive button events the
|
|
14
|
+
// moment tracking is on. Overriding it would either steal a click the app
|
|
15
|
+
// asked for or replace a menu people rely on with a worse one.
|
|
16
|
+
|
|
17
|
+
export type MenuItem =
|
|
18
|
+
| { kind?: "item"; label: string; onSelect: () => void; danger?: boolean; hint?: string; disabled?: boolean }
|
|
19
|
+
| { kind: "separator" };
|
|
20
|
+
|
|
21
|
+
export type MenuRequest = { x: number; y: number; items: MenuItem[] } | null;
|
|
22
|
+
|
|
23
|
+
const isItem = (i: MenuItem): i is Extract<MenuItem, { kind?: "item" }> => i.kind !== "separator";
|
|
24
|
+
|
|
25
|
+
export const ContextMenu = ({ request, onClose }: { request: MenuRequest; onClose: () => void }) => {
|
|
26
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
27
|
+
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
|
|
28
|
+
const [active, setActive] = useState(0);
|
|
29
|
+
|
|
30
|
+
// Clamp into the viewport BEFORE paint: a menu opened near an edge would
|
|
31
|
+
// otherwise flash off-screen and jump.
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
if (!request) { setPos(null); return; }
|
|
34
|
+
const el = ref.current;
|
|
35
|
+
const w = el?.offsetWidth || 200;
|
|
36
|
+
const h = el?.offsetHeight || 200;
|
|
37
|
+
const pad = 8;
|
|
38
|
+
setPos({
|
|
39
|
+
left: Math.max(pad, Math.min(request.x, window.innerWidth - w - pad)),
|
|
40
|
+
top: Math.max(pad, Math.min(request.y, window.innerHeight - h - pad))
|
|
41
|
+
});
|
|
42
|
+
setActive(request.items.findIndex(isItem));
|
|
43
|
+
}, [request]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!request) return;
|
|
47
|
+
const onKey = (e: KeyboardEvent) => {
|
|
48
|
+
if (e.key === "Escape") { e.preventDefault(); onClose(); return; }
|
|
49
|
+
const idxs = request.items.map((it, i) => (isItem(it) && !it.disabled ? i : -1)).filter((i) => i >= 0);
|
|
50
|
+
if (idxs.length === 0) return;
|
|
51
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
const at = idxs.indexOf(active);
|
|
54
|
+
const next = e.key === "ArrowDown"
|
|
55
|
+
? idxs[(at + 1 + idxs.length) % idxs.length]
|
|
56
|
+
: idxs[(at - 1 + idxs.length) % idxs.length];
|
|
57
|
+
setActive(next);
|
|
58
|
+
} else if (e.key === "Enter") {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
const item = request.items[active];
|
|
61
|
+
if (item && isItem(item) && !item.disabled) { onClose(); item.onSelect(); }
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
// Capture so a menu click never also reaches the surface underneath.
|
|
65
|
+
const onDown = (e: MouseEvent) => {
|
|
66
|
+
if (!ref.current?.contains(e.target as Node)) onClose();
|
|
67
|
+
};
|
|
68
|
+
window.addEventListener("keydown", onKey, true);
|
|
69
|
+
window.addEventListener("mousedown", onDown, true);
|
|
70
|
+
window.addEventListener("resize", onClose);
|
|
71
|
+
window.addEventListener("blur", onClose);
|
|
72
|
+
return () => {
|
|
73
|
+
window.removeEventListener("keydown", onKey, true);
|
|
74
|
+
window.removeEventListener("mousedown", onDown, true);
|
|
75
|
+
window.removeEventListener("resize", onClose);
|
|
76
|
+
window.removeEventListener("blur", onClose);
|
|
77
|
+
};
|
|
78
|
+
}, [request, active, onClose]);
|
|
79
|
+
|
|
80
|
+
if (!request) return null;
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
ref={ref}
|
|
84
|
+
className="context-menu"
|
|
85
|
+
role="menu"
|
|
86
|
+
style={pos ? { left: pos.left, top: pos.top } : { left: -9999, top: -9999 }}
|
|
87
|
+
onContextMenu={(e) => e.preventDefault()}
|
|
88
|
+
>
|
|
89
|
+
{request.items.map((item, i) =>
|
|
90
|
+
isItem(item) ? (
|
|
91
|
+
<button
|
|
92
|
+
key={item.label + i}
|
|
93
|
+
type="button"
|
|
94
|
+
role="menuitem"
|
|
95
|
+
className={"context-menu-item" + (item.danger ? " danger" : "") + (i === active ? " active" : "")}
|
|
96
|
+
disabled={item.disabled}
|
|
97
|
+
onMouseEnter={() => setActive(i)}
|
|
98
|
+
onClick={() => { onClose(); item.onSelect(); }}
|
|
99
|
+
>
|
|
100
|
+
<span>{item.label}</span>
|
|
101
|
+
{item.hint && <kbd>{item.hint}</kbd>}
|
|
102
|
+
</button>
|
|
103
|
+
) : (
|
|
104
|
+
<div key={"sep" + i} className="context-menu-separator" role="separator" />
|
|
105
|
+
)
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
};
|