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.
@@ -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; the runner's bundled npm is older.
68
- - run: npm install -g npm@latest
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
- Hop’s mobile UI includes:
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 = () => {
@@ -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 (!room) return false;
565
- const s = sessionsRef.current.find((x) => (x.internalName || x.name) === room);
566
- const proc = (s?.foregroundProcess || "").trim();
567
- return proc === "claude" || /^\d+\.\d+\.\d+$/.test(proc);
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
- // Browser SpeechRecognition (Chrome + Safari; where absent, space behaves
954
- // normally). The first press types its space instantly — no latency on the
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 speechRecognitionCtor = () =>
966
- (window as unknown as { SpeechRecognition?: unknown; webkitSpeechRecognition?: unknown }).SpeechRecognition
967
- || (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition;
968
- const finishVoiceInput = (send: boolean) => {
969
- if (!voiceActiveRef.current) return;
970
- voiceActiveRef.current = false;
971
- voiceRecRef.current = null;
972
- setVoiceOverlay(null);
973
- // Engines usually finalize pending speech on stop(); if only an interim
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, () => termRef.current?.refresh(0, (termRef.current?.rows ?? 1) - 1));
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 = true;
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
- // Long-press SPACE = voice input (Claude sessions, when the browser has
1739
- // SpeechRecognition). First press types its space with zero latency;
1740
- // holding past the threshold erases it and dictates until release.
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
- if (!event.repeat) {
1745
- window.clearTimeout(voiceTimerRef.current);
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
- // Any other keydown cancels a pending (not yet active) voice hold —
1761
- // normal typing must never trip the mic.
1762
- if (event.type === 'keydown' && event.code !== 'Space') {
1763
- window.clearTimeout(voiceTimerRef.current);
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. Load it AUTOMATICALLY — a "load full history"
1872
- // button was chrome for something the scroll gesture already said.
1873
- if (y === 0 && snapshotWasCappedRef.current) {
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 className="link-prompt" role="dialog" aria-label="Open link">
4149
- <span className="link-prompt-url">{linkPrompt}</span>
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
- className="link-prompt-open"
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
+ };