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.
- package/hay/apps/web/src/App.tsx +139 -30
- package/hay/apps/web/src/components/SecondaryPane.tsx +24 -4
- package/hay/apps/web/src/components/SessionSwitcher.tsx +49 -7
- package/hay/apps/web/src/components/ViewsPanel.tsx +407 -87
- package/hay/apps/web/src/styles.css +184 -18
- package/hay/apps/web/src/utils/voiceHold.ts +94 -14
- package/hay/apps/web/test/ViewsPanel.test.tsx +101 -1
- package/hay/apps/web/test/switcherModel.test.ts +37 -0
- package/hay/apps/web/test/voiceHold.test.ts +285 -0
- package/hay-web/assets/digest-archive.json +808 -670
- package/hay-web/assets/index-AVfUyFVP.js +134 -0
- package/hay-web/assets/index-AVfUyFVP.js.map +1 -0
- package/hay-web/assets/index-B8TPcotl.css +1 -0
- package/hay-web/assets/{index-B_Y3BI1G.js → index-BJCXjaYS.js} +8 -7
- package/hay-web/assets/{index-B_Y3BI1G.js.map → index-BJCXjaYS.js.map} +1 -1
- package/hay-web/assets/index-BbSHpMlM.js +134 -0
- package/hay-web/assets/index-BbSHpMlM.js.map +1 -0
- package/hay-web/assets/index-C3jI1fbf.js +134 -0
- package/hay-web/assets/index-C3jI1fbf.js.map +1 -0
- package/hay-web/assets/index-CAEFMBxO.js +134 -0
- package/hay-web/assets/index-CAEFMBxO.js.map +1 -0
- package/hay-web/assets/index-CWWk5klN.css +1 -0
- package/hay-web/assets/{index-DKWcSY-E.css → index-CqORfwjm.css} +1 -1
- package/hay-web/assets/index-DB5OXQnX.js +134 -0
- package/hay-web/assets/index-DB5OXQnX.js.map +1 -0
- package/hay-web/assets/index-DE0k6ab3.css +1 -0
- package/hay-web/index.html +2 -2
- package/hop +469 -43
- package/package.json +1 -1
- package/scripts/claude-session-hook.js +20 -1
- package/tests/claude-session-hook.unit.test.cjs +121 -0
- package/tests/registration.integration.test.cjs +111 -0
- package/tests/terminal-api.integration.test.cjs +101 -0
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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
|
|
287
|
-
//
|
|
288
|
-
//
|
|
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
|
-
|
|
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
|
|
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 (
|
|
3718
|
+
if (key === "e") {
|
|
3625
3719
|
const leaves = paneLeafIds(paneTreeRef.current).filter((id) => id !== "primary");
|
|
3626
|
-
const
|
|
3720
|
+
const swapTarget = focusedPaneIdRef.current !== "primary"
|
|
3627
3721
|
? focusedPaneIdRef.current
|
|
3628
3722
|
: (leaves.length === 1 ? leaves[0] : null);
|
|
3629
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
3912
|
+
{visibleStatus !== "connected" && visibleStatus !== "idle" && (
|
|
3807
3913
|
<div
|
|
3808
|
-
className={`connection-banner${
|
|
3914
|
+
className={`connection-banner${visibleStatus === "ended" ? " ended" : ""}`}
|
|
3809
3915
|
role="status"
|
|
3810
3916
|
aria-live="polite"
|
|
3811
3917
|
>
|
|
3812
3918
|
<span>
|
|
3813
|
-
{
|
|
3919
|
+
{visibleStatus === "connecting"
|
|
3814
3920
|
? "Connecting…"
|
|
3815
|
-
:
|
|
3921
|
+
: visibleStatus === "ended"
|
|
3816
3922
|
? "Session ended"
|
|
3817
3923
|
: "Disconnected — reconnecting…"}
|
|
3818
3924
|
</span>
|
|
3819
|
-
{(
|
|
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
|
-
{
|
|
4004
|
+
{visibleStatus === "connected"
|
|
3899
4005
|
? "Live"
|
|
3900
|
-
:
|
|
4006
|
+
: visibleStatus === "connecting"
|
|
3901
4007
|
? "Connecting"
|
|
3902
|
-
:
|
|
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
|
-
{(
|
|
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${
|
|
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 &&
|
|
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
|
-
{
|
|
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
|
-
) :
|
|
4398
|
+
) : visibleStatus === "idle" ? (
|
|
4293
4399
|
<span>awaiting connection</span>
|
|
4294
|
-
) :
|
|
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
|
-
⟳ {
|
|
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 ? "⌘
|
|
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}
|
|
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
|
-
|
|
73
|
-
ws.onopen = () =>
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" ?
|
|
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
|
-
<
|
|
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 & 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>
|