hop2 0.11.16 → 0.11.18

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 (33) hide show
  1. package/hay/apps/web/src/App.tsx +139 -30
  2. package/hay/apps/web/src/components/SecondaryPane.tsx +24 -4
  3. package/hay/apps/web/src/components/SessionSwitcher.tsx +49 -7
  4. package/hay/apps/web/src/components/ViewsPanel.tsx +407 -87
  5. package/hay/apps/web/src/styles.css +184 -18
  6. package/hay/apps/web/src/utils/voiceHold.ts +94 -14
  7. package/hay/apps/web/test/ViewsPanel.test.tsx +101 -1
  8. package/hay/apps/web/test/switcherModel.test.ts +37 -0
  9. package/hay/apps/web/test/voiceHold.test.ts +285 -0
  10. package/hay-web/assets/digest-archive.json +808 -670
  11. package/hay-web/assets/index-AVfUyFVP.js +134 -0
  12. package/hay-web/assets/index-AVfUyFVP.js.map +1 -0
  13. package/hay-web/assets/index-B8TPcotl.css +1 -0
  14. package/hay-web/assets/{index-B_Y3BI1G.js → index-BJCXjaYS.js} +8 -7
  15. package/hay-web/assets/{index-B_Y3BI1G.js.map → index-BJCXjaYS.js.map} +1 -1
  16. package/hay-web/assets/index-BbSHpMlM.js +134 -0
  17. package/hay-web/assets/index-BbSHpMlM.js.map +1 -0
  18. package/hay-web/assets/index-C3jI1fbf.js +134 -0
  19. package/hay-web/assets/index-C3jI1fbf.js.map +1 -0
  20. package/hay-web/assets/index-CAEFMBxO.js +134 -0
  21. package/hay-web/assets/index-CAEFMBxO.js.map +1 -0
  22. package/hay-web/assets/index-CWWk5klN.css +1 -0
  23. package/hay-web/assets/{index-DKWcSY-E.css → index-CqORfwjm.css} +1 -1
  24. package/hay-web/assets/index-DB5OXQnX.js +134 -0
  25. package/hay-web/assets/index-DB5OXQnX.js.map +1 -0
  26. package/hay-web/assets/index-DE0k6ab3.css +1 -0
  27. package/hay-web/index.html +2 -2
  28. package/hop +469 -43
  29. package/package.json +1 -1
  30. package/scripts/claude-session-hook.js +20 -1
  31. package/tests/claude-session-hook.unit.test.cjs +121 -0
  32. package/tests/registration.integration.test.cjs +111 -0
  33. package/tests/terminal-api.integration.test.cjs +101 -0
@@ -283,11 +283,19 @@ const resolveInitialName = () => {
283
283
  };
284
284
 
285
285
  // Stable connection identity: device half persists per browser, tab half per
286
- // tab. The server evicts a previous connection bearing the same key on
287
- // attach — a reconnect replaces its own ghost in presence instead of sitting
288
- // next to it — while two real tabs (different tab halves) coexist.
286
+ // tab — and a PAGE-LOAD nonce on the end, which is the part that actually
287
+ // scopes eviction. The server evicts a previous connection bearing the same
288
+ // key on attach, so a reconnect replaces its own ghost in presence. The
289
+ // ghost is by definition a connection this same page instance made (a
290
+ // network drop sends no close frame; a reload does), so an in-memory nonce
291
+ // matches every ghost that can exist — while "Duplicate Tab" and
292
+ // window.open, which CLONE sessionStorage, get a fresh nonce and coexist.
293
+ // Keying on device.tab alone made two such tabs on one session evict each
294
+ // other on every attach: with immediate reconnect on both sides, that was a
295
+ // full-speed connect-disconnect loop (measured: 557 reconnects in 20s).
289
296
  const randomKeyPart = () =>
290
297
  (window.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)).replace(/-/g, "").slice(0, 10);
298
+ const surfaceNonce = randomKeyPart();
291
299
  const resolveDeviceKey = () => {
292
300
  try {
293
301
  let device = localStorage.getItem("hop_device_id");
@@ -300,7 +308,7 @@ const resolveDeviceKey = () => {
300
308
  tab = randomKeyPart();
301
309
  sessionStorage.setItem("hop_tab_id", tab);
302
310
  }
303
- return `${device}.${tab}`;
311
+ return `${device}.${tab}.${surfaceNonce}`;
304
312
  } catch {
305
313
  return ""; // storage unavailable (private mode quota) — no eviction key
306
314
  }
@@ -360,6 +368,46 @@ const App = () => {
360
368
  const [sessionLabel, setSessionLabel] = useState(() => initialRoom);
361
369
  const [liveCwd, setLiveCwd] = useState<string | null>(null);
362
370
  const [status, setStatus] = useState<ConnectionStatus>("idle");
371
+ // What the CHROME shows. Most drops (a tunnel restart, a laptop waking, an
372
+ // edge blip) are healed by the immediate reconnect before a human could
373
+ // read a banner — surfacing them bought nothing but flicker. Once a session
374
+ // has connected, an outage must OUTLIVE the grace window to be seen;
375
+ // "ended" and the first connect are real events and show at once. All
376
+ // reconnect logic stays on the real `status`.
377
+ const OUTAGE_GRACE_MS = 1200;
378
+ const [visibleStatus, setVisibleStatus] = useState<ConnectionStatus>("idle");
379
+ const visibleStatusRef = useRef<ConnectionStatus>("idle");
380
+ visibleStatusRef.current = visibleStatus;
381
+ const statusForChromeRef = useRef<ConnectionStatus>("idle");
382
+ statusForChromeRef.current = status;
383
+ const hadConnectedRef = useRef(false);
384
+ const outageTimerRef = useRef<number | null>(null);
385
+ useEffect(() => {
386
+ const clearOutageTimer = () => {
387
+ if (outageTimerRef.current !== null) {
388
+ window.clearTimeout(outageTimerRef.current);
389
+ outageTimerRef.current = null;
390
+ }
391
+ };
392
+ if (status === "connected" || status === "ended" || status === "idle") {
393
+ hadConnectedRef.current = status === "connected";
394
+ clearOutageTimer();
395
+ setVisibleStatus(status);
396
+ return;
397
+ }
398
+ // connecting / disconnected: the first connect shows honestly; after
399
+ // that, hold the good face while the immediate retry gets its chance.
400
+ if (!hadConnectedRef.current) {
401
+ clearOutageTimer();
402
+ setVisibleStatus(status);
403
+ return;
404
+ }
405
+ if (outageTimerRef.current !== null) return; // grace runs from OUTAGE start
406
+ outageTimerRef.current = window.setTimeout(() => {
407
+ outageTimerRef.current = null;
408
+ setVisibleStatus(statusForChromeRef.current);
409
+ }, OUTAGE_GRACE_MS);
410
+ }, [status]);
363
411
  const [presence, setPresence] = useState<PresenceClient[]>([]);
364
412
  const [collabMode, setCollabMode] = useState(true);
365
413
  const [controllerId, setControllerId] = useState<string | null>(null);
@@ -597,7 +645,7 @@ const App = () => {
597
645
  const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
598
646
  // Published views (`hop view`). `session` scopes the panel to one session;
599
647
  // an object with none open it fleet-wide. Null = closed.
600
- const [viewsOpen, setViewsOpen] = useState<{ session?: string } | null>(null);
648
+ const [viewsOpen, setViewsOpen] = useState<{ session?: string; dock?: boolean } | null>(null);
601
649
  const sessionsRef = useRef(sessions);
602
650
  sessionsRef.current = sessions;
603
651
  // Claude Code titles its process with a bare version number; hop's session
@@ -944,8 +992,23 @@ const App = () => {
944
992
  }
945
993
  };
946
994
 
995
+ // Visit order, most recent first — the alt-tab stack behind ⌘E. Recency of
996
+ // OUTPUT is the wrong signal here: a session you have never opened can be
997
+ // the busiest one on the machine, and "go back" must mean where YOU were.
998
+ // Kept per tab (each tab has its own back-and-forth) and across reloads.
999
+ const recentRoomsRef = useRef<string[]>((() => {
1000
+ try { return JSON.parse(sessionStorage.getItem("hop_recent_rooms_v1") || "[]"); }
1001
+ catch { return []; }
1002
+ })());
1003
+
947
1004
  useEffect(() => {
948
1005
  activeSessionRoomRef.current = session?.room ?? null;
1006
+ const room = session?.room;
1007
+ if (!room) return;
1008
+ const next = [room, ...recentRoomsRef.current.filter((r) => r !== room)].slice(0, 10);
1009
+ recentRoomsRef.current = next;
1010
+ try { sessionStorage.setItem("hop_recent_rooms_v1", JSON.stringify(next)); }
1011
+ catch { /* private mode — the stack just won't survive a reload */ }
949
1012
  }, [session?.room]);
950
1013
 
951
1014
  const shareUrl = useMemo(() => {
@@ -1128,7 +1191,7 @@ const App = () => {
1128
1191
  const now = Date.now();
1129
1192
  if (now - lastDropToastRef.current > 2000) {
1130
1193
  lastDropToastRef.current = now;
1131
- showToast("Reconnecting — input buffered");
1194
+ if (visibleStatusRef.current !== "connected") showToast("Reconnecting — input buffered");
1132
1195
  }
1133
1196
  return;
1134
1197
  }
@@ -1433,7 +1496,9 @@ const App = () => {
1433
1496
  const delay = attempt === 0 ? 0 : Math.min(1000 * Math.pow(2, attempt - 1), 30000);
1434
1497
  reconnectAttemptRef.current += 1;
1435
1498
 
1436
- pushNotice(delay === 0 ? "Reconnecting..." : `Reconnecting in ${Math.round(delay / 1000)}s...`);
1499
+ // The immediate retry stays silent — it usually wins before anyone could
1500
+ // read a notice. Only an outage that earned a backoff narrates itself.
1501
+ if (delay > 0) pushNotice(`Reconnecting in ${Math.round(delay / 1000)}s...`);
1437
1502
 
1438
1503
  reconnectTimerRef.current = window.setTimeout(() => {
1439
1504
  reconnectTimerRef.current = null;
@@ -3588,8 +3653,8 @@ const App = () => {
3588
3653
  // Desktop keyboard layer — capture phase so shortcuts win over the focused
3589
3654
  // terminal. ⌘-based (Ctrl+Shift elsewhere) to stay clear of readline/TUI
3590
3655
  // keys, which the terminal must keep receiving untouched.
3591
- // ⌘K session palette · ⌘J/⌘⇧J cycle sessions · ⌘, settings ·
3592
- // ⌘+/−/0 font size · ⌘/ shortcut help · Esc closes drawer/help
3656
+ // ⌘K session palette · ⌘E last session (toggles) · ⌘J/⌘⇧J cycle by name ·
3657
+ // ⌘, settings · ⌘+/−/0 font size · ⌘/ shortcut help · Esc closes drawer/help
3593
3658
  useEffect(() => {
3594
3659
  if (!session || !isEmbeddedInHop()) return;
3595
3660
  const cycleSession = (dir: 1 | -1) => {
@@ -3601,6 +3666,27 @@ const App = () => {
3601
3666
  const next = list[(cur + dir + list.length) % list.length];
3602
3667
  if (next) switchSessionRef.current?.(next);
3603
3668
  };
3669
+ // Back and forth between the two sessions you are actually working in,
3670
+ // without a trip through the palette. MRU, so pressing it twice returns
3671
+ // you — the switch itself pushes the room you left to the top of the
3672
+ // stack, which is what makes it a toggle rather than a walk.
3673
+ const jumpToLastSession = () => {
3674
+ const list = sessionsRef.current.filter((s) => s.type !== "port");
3675
+ const cur = activeSessionRoomRef.current;
3676
+ const keyOf = (s: { internalName?: string; name: string }) => s.internalName || s.name;
3677
+ const visited = recentRoomsRef.current
3678
+ .filter((r) => r !== cur)
3679
+ .map((r) => list.find((s) => keyOf(s) === r))
3680
+ .find((s): s is NonNullable<typeof s> => !!s);
3681
+ // Nothing visited yet this tab (a fresh load lands straight in a
3682
+ // session): fall back to the liveliest other session, so the shortcut
3683
+ // does something sensible the very first time it is pressed.
3684
+ const target = visited
3685
+ || [...list]
3686
+ .filter((s) => keyOf(s) !== cur)
3687
+ .sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0))[0];
3688
+ if (target) switchSessionRef.current?.(target);
3689
+ };
3604
3690
  const onKey = (event: KeyboardEvent) => {
3605
3691
  const mod = isMacPlatform
3606
3692
  ? event.metaKey && !event.ctrlKey && !event.altKey
@@ -3618,15 +3704,32 @@ const App = () => {
3618
3704
  const shifted = event.shiftKey;
3619
3705
  const grab = () => { event.preventDefault(); event.stopPropagation(); };
3620
3706
  if (key === "k" && (isMacPlatform ? !shifted : true)) { grab(); setSwitcherOpen((v) => !v); return; }
3707
+ if (isMacPlatform && shifted && key === "v") {
3708
+ grab();
3709
+ setViewsOpen((v) => v ? null
3710
+ : (activeSessionRoomRef.current
3711
+ ? { session: activeSessionRoomRef.current, dock: true }
3712
+ : {}));
3713
+ return;
3714
+ }
3621
3715
  if (key === "j") { grab(); cycleSession(shifted ? -1 : 1); return; }
3622
3716
  if (key === ",") { grab(); setDrawerOpen((v) => !v); return; }
3623
3717
  if (key === "/" || (shifted && key === "?")) { grab(); setShortcutHelpOpen((v) => !v); return; }
3624
- if (shifted && key === "e") {
3718
+ if (key === "e") {
3625
3719
  const leaves = paneLeafIds(paneTreeRef.current).filter((id) => id !== "primary");
3626
- const target = focusedPaneIdRef.current !== "primary"
3720
+ const swapTarget = focusedPaneIdRef.current !== "primary"
3627
3721
  ? focusedPaneIdRef.current
3628
3722
  : (leaves.length === 1 ? leaves[0] : null);
3629
- if (target) { grab(); paneOpsRef.current.swapPaneWithPrimary(target); }
3723
+ // On a Mac the two are distinct chords (⌘E / ⌘⇧E). Elsewhere the
3724
+ // modifier already includes Shift, so there is only one chord to
3725
+ // give out: panes take it when a pane could actually be swapped.
3726
+ const wantsSwap = isMacPlatform ? shifted : !!swapTarget;
3727
+ if (wantsSwap) {
3728
+ if (swapTarget) { grab(); paneOpsRef.current.swapPaneWithPrimary(swapTarget); }
3729
+ return;
3730
+ }
3731
+ grab();
3732
+ jumpToLastSession();
3630
3733
  return;
3631
3734
  }
3632
3735
  if (key === "]" || key === "[") {
@@ -3722,7 +3825,10 @@ const App = () => {
3722
3825
  type="button"
3723
3826
  className="topbar-sessions-btn"
3724
3827
  title="Files agents published with hop view"
3725
- onClick={() => setViewsOpen({})}
3828
+ // DOCKED and scoped: in full-screen mode you almost always want
3829
+ // THIS session's results, read beside the live terminal rather
3830
+ // than over it. The panel's own toggle widens to the fleet.
3831
+ onClick={() => setViewsOpen({ session: session.room, dock: true })}
3726
3832
  >
3727
3833
  Views
3728
3834
  {unseenViewSessions > 0 && <span className="views-chip-dot" aria-label="New views" />}
@@ -3803,20 +3909,20 @@ const App = () => {
3803
3909
  >
3804
3910
  {/* Connection state banner — on mobile the footer is hidden, and on
3805
3911
  desktop the footer dot alone is too easy to miss during an outage. */}
3806
- {status !== "connected" && status !== "idle" && (
3912
+ {visibleStatus !== "connected" && visibleStatus !== "idle" && (
3807
3913
  <div
3808
- className={`connection-banner${status === "ended" ? " ended" : ""}`}
3914
+ className={`connection-banner${visibleStatus === "ended" ? " ended" : ""}`}
3809
3915
  role="status"
3810
3916
  aria-live="polite"
3811
3917
  >
3812
3918
  <span>
3813
- {status === "connecting"
3919
+ {visibleStatus === "connecting"
3814
3920
  ? "Connecting…"
3815
- : status === "ended"
3921
+ : visibleStatus === "ended"
3816
3922
  ? "Session ended"
3817
3923
  : "Disconnected — reconnecting…"}
3818
3924
  </span>
3819
- {(status === "disconnected" || status === "ended") && (
3925
+ {(visibleStatus === "disconnected" || visibleStatus === "ended") && (
3820
3926
  <button type="button" onClick={() => setReconnectToken((value) => value + 1)}>
3821
3927
  Reconnect now
3822
3928
  </button>
@@ -3895,11 +4001,11 @@ const App = () => {
3895
4001
  <p className="room-cwd" title={liveCwd}>{shortenPath(liveCwd)}</p>
3896
4002
  )}
3897
4003
  <p className="room-meta">
3898
- {status === "connected"
4004
+ {visibleStatus === "connected"
3899
4005
  ? "Live"
3900
- : status === "connecting"
4006
+ : visibleStatus === "connecting"
3901
4007
  ? "Connecting"
3902
- : status === "ended"
4008
+ : visibleStatus === "ended"
3903
4009
  ? "Ended"
3904
4010
  : "Offline"}
3905
4011
  </p>
@@ -3959,7 +4065,7 @@ const App = () => {
3959
4065
  <button type="button" className="quick-btn danger" onClick={handleKillSession}>
3960
4066
  Kill
3961
4067
  </button>
3962
- {(status === "disconnected" || status === "ended") && (
4068
+ {(visibleStatus === "disconnected" || visibleStatus === "ended") && (
3963
4069
  <button type="button" className="quick-btn primary" onClick={() => setReconnectToken((value) => value + 1)}>
3964
4070
  Reconnect
3965
4071
  </button>
@@ -4156,7 +4262,7 @@ const App = () => {
4156
4262
  // evaluated on the hub page (session can be null there), and the
4157
4263
  // pane renderer places it wherever the tree's primary leaf sits.
4158
4264
  const renderPrimarySection = (): ReactElement => (
4159
- <section inert={switcherOpen} style={switcherOpen ? { visibility: "hidden" } : undefined} className={`terminal${status === "disconnected" || status === "ended" ? " degraded" : ""}`}>
4265
+ <section inert={switcherOpen} style={switcherOpen ? { visibility: "hidden" } : undefined} className={`terminal${visibleStatus === "disconnected" || visibleStatus === "ended" ? " degraded" : ""}`}>
4160
4266
  <div
4161
4267
  className="terminal-frame"
4162
4268
  onClick={() => {
@@ -4218,7 +4324,7 @@ const App = () => {
4218
4324
  </span>
4219
4325
  </div>
4220
4326
  )}
4221
- {isMobile && status === "connecting" && (
4327
+ {isMobile && visibleStatus === "connecting" && (
4222
4328
  <div className="terminal-loading" role="status" aria-live="polite">
4223
4329
  <IosSpinner />
4224
4330
  <span className="terminal-loading-label">Loading session…</span>
@@ -4259,7 +4365,7 @@ const App = () => {
4259
4365
  <span className="footer-chip">{sessionLabel || session.room}</span>
4260
4366
  {liveCwd ? <span className="footer-cwd">{liveCwd}</span> : null}
4261
4367
  <span className="footer-spacer" />
4262
- {status === "connected" ? (
4368
+ {visibleStatus === "connected" ? (
4263
4369
  <>
4264
4370
  <button type="button" className="footer-find-toggle" aria-label="Find in terminal" onClick={openSearch}>
4265
4371
  {isMacPlatform ? "⌘F" : "Ctrl+F"} find
@@ -4289,9 +4395,9 @@ const App = () => {
4289
4395
  })()}
4290
4396
  </span>
4291
4397
  </>
4292
- ) : status === "idle" ? (
4398
+ ) : visibleStatus === "idle" ? (
4293
4399
  <span>awaiting connection</span>
4294
- ) : status === "ended" ? (
4400
+ ) : visibleStatus === "ended" ? (
4295
4401
  <>
4296
4402
  <span className="ended-label">session ended</span>
4297
4403
  <button
@@ -4305,7 +4411,7 @@ const App = () => {
4305
4411
  ) : (
4306
4412
  <>
4307
4413
  <span className="reconnecting">
4308
- ⟳ {status === "connecting" ? "Connecting…" : "Reconnecting…"}
4414
+ ⟳ {visibleStatus === "connecting" ? "Connecting…" : "Reconnecting…"}
4309
4415
  </span>
4310
4416
  <button
4311
4417
  type="button"
@@ -4480,9 +4586,11 @@ const App = () => {
4480
4586
  <h3>Keyboard shortcuts</h3>
4481
4587
  {[
4482
4588
  [isMacPlatform ? "⌘K" : "Ctrl+Shift+K", "session palette (type to filter, ↑↓, ⏎)"],
4483
- [isMacPlatform ? "⌘J / ⌘⇧J" : "Ctrl+Shift+J / +⇧", "next / previous session"],
4589
+ [isMacPlatform ? "⌘E" : "Ctrl+Shift+E", "back to the last session (press again to return)"],
4590
+ [isMacPlatform ? "⌘J / ⌘⇧J" : "Ctrl+Shift+J / +⇧", "next / previous session (by name)"],
4484
4591
  [isMacPlatform ? "⌘," : "Ctrl+Shift+,", "settings drawer"],
4485
4592
  [isMacPlatform ? "⌘F" : "Ctrl+Shift+F", "find in terminal"],
4593
+ ...(isMacPlatform ? [["⌘⇧V", "views — results agents published"]] : []),
4486
4594
  ...(isMacPlatform ? [["⌘+ / ⌘− / ⌘0", "terminal font size"]] : []),
4487
4595
  ["Ctrl+Shift+C / V", "copy / paste"],
4488
4596
  ["Shift+PgUp / PgDn", "scrollback"],
@@ -4587,7 +4695,8 @@ const App = () => {
4587
4695
  {/* Outside the mode ternary on purpose: Views opens from the hub, from a
4588
4696
  session, and from over the switcher, and it must outlive a switch. */}
4589
4697
  {viewsOpen && (
4590
- <ViewsPanel session={viewsOpen.session} sessions={sessions} onClose={() => setViewsOpen(null)} />
4698
+ <ViewsPanel session={viewsOpen.session} sessions={sessions} dock={viewsOpen.dock}
4699
+ onClose={() => setViewsOpen(null)} />
4591
4700
  )}
4592
4701
  </div>
4593
4702
  );
@@ -62,6 +62,16 @@ export const SecondaryPane = ({ sessionName, procLabel, wsUrl, userName, cols, r
62
62
  let reconnectTimer: number | null = null;
63
63
  let attempt = 0;
64
64
 
65
+ // The "connecting" label waits out a grace window on RECONNECTS, so a
66
+ // sub-second drop repaints nothing. First connect shows honestly.
67
+ let chromeTimer = 0;
68
+ let everConnected = false;
69
+ const showConnecting = () => {
70
+ if (!everConnected) { setStatus("connecting"); return; }
71
+ window.clearTimeout(chromeTimer);
72
+ chromeTimer = window.setTimeout(() => setStatus("connecting"), 1200);
73
+ };
74
+
65
75
  const connect = () => {
66
76
  const sep = wsUrl.includes("?") ? "&" : "?";
67
77
  const ws = new WebSocket(
@@ -69,8 +79,13 @@ export const SecondaryPane = ({ sessionName, procLabel, wsUrl, userName, cols, r
69
79
  `&cols=${sizeRef.current.cols}&rows=${sizeRef.current.rows}`
70
80
  );
71
81
  wsRef.current = ws;
72
- setStatus("connecting");
73
- ws.onopen = () => setStatus("connected");
82
+ showConnecting();
83
+ ws.onopen = () => {
84
+ everConnected = true;
85
+ attempt = 0; // a healed link earns a fresh immediate retry next time
86
+ window.clearTimeout(chromeTimer);
87
+ setStatus("connected");
88
+ };
74
89
  ws.onmessage = (event) => {
75
90
  const message = safeParseServerMessage(String(event.data));
76
91
  if (!message) return;
@@ -95,6 +110,7 @@ export const SecondaryPane = ({ sessionName, procLabel, wsUrl, userName, cols, r
95
110
  term.write(prelude + message.data + coda, () => term.scrollToBottom());
96
111
  } else if (message.type === "session_ended") {
97
112
  shouldReconnect = false;
113
+ window.clearTimeout(chromeTimer);
98
114
  setStatus("ended");
99
115
  } else if (message.type === "active_size") {
100
116
  // Someone else became active — follow the room's elected size. A
@@ -110,9 +126,12 @@ export const SecondaryPane = ({ sessionName, procLabel, wsUrl, userName, cols, r
110
126
  };
111
127
  ws.onclose = () => {
112
128
  if (!shouldReconnect) return;
113
- setStatus("connecting");
129
+ showConnecting();
130
+ // Immediate first retry: most drops are a tunnel blip that has
131
+ // already healed; backoff starts once that retry has failed.
132
+ const delay = attempt === 0 ? 0 : Math.min(1000 * 2 ** (attempt - 1), 15000);
114
133
  attempt += 1;
115
- reconnectTimer = window.setTimeout(connect, Math.min(1000 * 2 ** attempt, 15000));
134
+ reconnectTimer = window.setTimeout(connect, delay);
116
135
  };
117
136
  };
118
137
  connect();
@@ -168,6 +187,7 @@ export const SecondaryPane = ({ sessionName, procLabel, wsUrl, userName, cols, r
168
187
  return () => {
169
188
  shouldReconnect = false;
170
189
  if (reconnectTimer) window.clearTimeout(reconnectTimer);
190
+ window.clearTimeout(chromeTimer);
171
191
  window.clearTimeout(fitTimer);
172
192
  ro.disconnect();
173
193
  dataSub.dispose();
@@ -867,11 +867,25 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
867
867
  let reconnectTimer = 0;
868
868
  let reconnectAttempt = 0;
869
869
  const pendingInput: Array<{ data: string; at: number }> = [];
870
+ // Sub-second drops stay invisible: the "reconnecting…" chrome waits out
871
+ // a grace window, and the first retry goes immediately — a tunnel
872
+ // restart or a waking laptop usually reconnects before either fires.
873
+ let downChromeTimer = 0;
874
+ const markDown = () => {
875
+ if (disposed) return;
876
+ window.clearTimeout(downChromeTimer);
877
+ downChromeTimer = window.setTimeout(() => { if (!disposed) setConn("down"); }, 1200);
878
+ };
879
+ const markLive = () => {
880
+ window.clearTimeout(downChromeTimer);
881
+ downChromeTimer = 0;
882
+ setConn("live");
883
+ };
870
884
  const scheduleReconnect = () => {
871
885
  if (disposed) return;
872
- setConn("down");
886
+ markDown();
873
887
  window.clearTimeout(reconnectTimer);
874
- const delay = Math.min(1000 * 2 ** reconnectAttempt, 15000);
888
+ const delay = reconnectAttempt === 0 ? 0 : Math.min(1000 * 2 ** (reconnectAttempt - 1), 15000);
875
889
  reconnectAttempt += 1;
876
890
  reconnectTimer = window.setTimeout(() => connect(), delay);
877
891
  };
@@ -884,7 +898,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
884
898
  sock.onopen = () => {
885
899
  if (disposed || ws !== sock) return;
886
900
  reconnectAttempt = 0;
887
- setConn("live");
901
+ markLive();
888
902
  window.setTimeout(() => rescaleRef.current(), 30);
889
903
  const cutoff = Date.now() - 15000;
890
904
  for (const pend of pendingInput) {
@@ -975,7 +989,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
975
989
  if (!ws || ws.readyState !== WebSocket.OPEN) {
976
990
  pendingInput.push({ data, at: Date.now() });
977
991
  if (pendingInput.length > 200) pendingInput.shift();
978
- setConn("down");
992
+ markDown();
979
993
  if (!ws || ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) {
980
994
  reconnectAttempt = 0;
981
995
  connect();
@@ -1019,6 +1033,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
1019
1033
  sendInputRef.current = null;
1020
1034
  term.options.disableStdin = true;
1021
1035
  term.options.cursorBlink = false;
1036
+ window.clearTimeout(downChromeTimer);
1022
1037
  setConn(null);
1023
1038
  setViewers([]);
1024
1039
  setDriver(null);
@@ -1735,6 +1750,25 @@ export const SessionSwitcher = ({
1735
1750
  ? [...full.folders.flatMap((f) => f.rows), ...full.rows].map(sessionKey)
1736
1751
  : [];
1737
1752
  }
1753
+ // Manual means manual — including for sessions nobody has dragged yet.
1754
+ // Placed cards were always immune to activity, but UNPLACED ones sorted by
1755
+ // display name at the tail, so the wall still rearranged itself with no
1756
+ // user action: creating a session inserted it alphabetically between
1757
+ // existing cards, and renaming one (an agent may) slid it somewhere else.
1758
+ // Give every newcomer a permanent index the first time the manual wall
1759
+ // sees it; after that only a drag can move anything.
1760
+ useEffect(() => {
1761
+ if (!open || sortMode !== "manual") return;
1762
+ const visual = orderedAllKeysRef.current; // full wall, filter-independent
1763
+ if (visual.length === 0) return;
1764
+ const placed = new Set(manualOrder);
1765
+ if (visual.every((k) => placed.has(k))) return; // nothing new to pin
1766
+ // Keep the order the user is looking at right now, then freeze it.
1767
+ const next = [...manualOrder];
1768
+ for (const key of visual) if (!placed.has(key)) next.push(key);
1769
+ persistManualOrder(next);
1770
+ }, [open, sortMode, manualOrder, wallSessions, folders]);
1771
+
1738
1772
  const flatNavRef = useRef(flatNav);
1739
1773
  flatNavRef.current = flatNav;
1740
1774
 
@@ -3040,14 +3074,21 @@ export const SessionSwitcher = ({
3040
3074
  aria-label={`Actions for ${sheetSession.displayName}`}
3041
3075
  style={(() => {
3042
3076
  const width = 264;
3043
- const estH = sheet.mode === "rename" ? 140 : 340;
3077
+ const estH = sheet.mode === "rename" ? 150 : 330;
3044
3078
  const left = Math.min(Math.max(8, sheet.anchor.x - width), window.innerWidth - width - 8);
3045
3079
  let top = sheet.anchor.y + 8;
3046
3080
  if (top + estH > window.innerHeight - 8) top = Math.max(8, sheet.anchor.y - estH - 8);
3047
3081
  return { left, top };
3048
3082
  })()}
3049
3083
  >
3050
- <p className="switcher-sheet-title">{sheetSession.displayName}</p>
3084
+ <header className="switcher-sheet-head">
3085
+ <p className="switcher-sheet-title">{sheetSession.displayName}</p>
3086
+ {sheetSession.cwd && (
3087
+ <p className="switcher-sheet-sub" title={sheetSession.cwd}>
3088
+ {sheetSession.cwd.replace(/^\/Users\/[^/]+/, "~")}
3089
+ </p>
3090
+ )}
3091
+ </header>
3051
3092
  {sheet.mode === "rename" ? (
3052
3093
  <form className="inline-edit" onSubmit={submitRename}>
3053
3094
  <input
@@ -3103,6 +3144,7 @@ export const SessionSwitcher = ({
3103
3144
  >
3104
3145
  Rename
3105
3146
  </button>
3147
+ {sheetSession.type !== "port" && <div className="switcher-sheet-sep" aria-hidden="true" />}
3106
3148
  {sheetSession.type !== "port" && (
3107
3149
  <button type="button" onClick={toggleAgentAccess}>
3108
3150
  {sheetAgentPermitted ? "Disable agent access" : "Enable agent access"}
@@ -3123,10 +3165,10 @@ export const SessionSwitcher = ({
3123
3165
  Stop &amp; park (resumable)
3124
3166
  </button>
3125
3167
  )}
3168
+ <div className="switcher-sheet-sep" aria-hidden="true" />
3126
3169
  <button type="button" className="danger" onClick={killSession}>
3127
3170
  Kill session
3128
3171
  </button>
3129
- <button type="button" onClick={() => setSheet(null)}>Cancel</button>
3130
3172
  </>
3131
3173
  )}
3132
3174
  </div>