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
|
@@ -13,13 +13,16 @@ import { Terminal } from "@xterm/xterm";
|
|
|
13
13
|
import { FitAddon } from "@xterm/addon-fit";
|
|
14
14
|
import { WebglAddon } from "@xterm/addon-webgl";
|
|
15
15
|
import { attachScrollFlywheel } from "../utils/scrollFlywheel";
|
|
16
|
+
import { ContextMenu, type MenuRequest } from "./ContextMenu";
|
|
16
17
|
import { collectTerminalMatches, selectTerminalMatch } from "../utils/terminalSearch";
|
|
18
|
+
import { bufferLooksLikeClaude, createVoiceHold } from "../utils/voiceHold";
|
|
17
19
|
import {
|
|
18
20
|
buildSwitcherModel,
|
|
19
21
|
filterSessionsByOrigin,
|
|
20
22
|
projectKey,
|
|
21
23
|
relativeTime,
|
|
22
24
|
type SessionOriginScope,
|
|
25
|
+
type SwitcherFolder,
|
|
23
26
|
type SwitcherSession,
|
|
24
27
|
type SwitcherSortMode
|
|
25
28
|
} from "../utils/switcherModel";
|
|
@@ -79,6 +82,9 @@ type Props = {
|
|
|
79
82
|
// Interacting with a tile makes THAT session current: the chip moves, and
|
|
80
83
|
// closing the wall lands full-screen in the terminal you were typing in.
|
|
81
84
|
onFocusSession?: (session: SwitcherSession) => void;
|
|
85
|
+
// User-authored folders (Manual mode only). Server-owned, so they follow
|
|
86
|
+
// you across devices — unlike the drag ORDER, which is per-client taste.
|
|
87
|
+
folders?: SwitcherFolder[];
|
|
82
88
|
tileWsBase?: string;
|
|
83
89
|
userName?: string;
|
|
84
90
|
terminalTheme?: object;
|
|
@@ -103,7 +109,9 @@ type Sheet = {
|
|
|
103
109
|
// → the filter box: clicking it, ⌘K (open), ⌘F (unless a live tile
|
|
104
110
|
// owns the keyboard — then ⌘F is find-in-that-tile,
|
|
105
111
|
// scope follows focus), or ↑ from the top row
|
|
106
|
-
// → the full screen:
|
|
112
|
+
// → the full screen: ⌘⏎, the tile's ⛶, or the sheet's "Open full
|
|
113
|
+
// screen" — DELIBERATE only. Bare clicks and Enter
|
|
114
|
+
// engage the tile in place; full screen is a mode.
|
|
107
115
|
// Enforcement, not convention: a terminal REFUSES to take focus while any
|
|
108
116
|
// text input holds it (stealOk below), and editing the filter drops any live
|
|
109
117
|
// tile back to watch — so re-renders, remounts, reconnects, and polls can
|
|
@@ -263,10 +271,10 @@ const runningApp = (s: SwitcherSession) => {
|
|
|
263
271
|
// responsiveness and whether keystrokes are accepted. The old design — an
|
|
264
272
|
// HTML preview swapped for a fresh xterm + socket behind a veil — is gone.
|
|
265
273
|
const LIVETILE_POLL_MS = 5000;
|
|
266
|
-
const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, activeCols, activeRows, onFullscreen, onUnfocus }: {
|
|
274
|
+
const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice }: {
|
|
267
275
|
wsBase: string; room: string; userName: string; theme: object | undefined;
|
|
268
276
|
live: boolean; claudeApp: boolean; claimSize: boolean; activeCols?: number; activeRows?: number;
|
|
269
|
-
onFullscreen: () => void; onUnfocus: () => void;
|
|
277
|
+
onFullscreen: () => void; onUnfocus: () => void; onNotice: (m: string) => void;
|
|
270
278
|
}) => {
|
|
271
279
|
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
272
280
|
const termRef = useRef<Terminal | null>(null);
|
|
@@ -277,6 +285,24 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
277
285
|
const kbdEnhancedRef = useRef(false);
|
|
278
286
|
// null = watching; "live"/"down" = connected state for the chrome.
|
|
279
287
|
const [conn, setConn] = useState<"live" | "down" | null>(null);
|
|
288
|
+
// Hold-space voice, same controller as the full screen — the tile is where
|
|
289
|
+
// typing mostly happens now, and dictation not existing here was most of
|
|
290
|
+
// "voice doesn't always work".
|
|
291
|
+
const [voiceInterim, setVoiceInterim] = useState<string | null>(null);
|
|
292
|
+
const voiceHoldRef = useRef<ReturnType<typeof createVoiceHold> | null>(null);
|
|
293
|
+
if (!voiceHoldRef.current) {
|
|
294
|
+
voiceHoldRef.current = createVoiceHold({
|
|
295
|
+
send: (data) => sendInputRef.current?.(data),
|
|
296
|
+
notify: (m) => onNoticeRef.current?.(m),
|
|
297
|
+
setOverlay: setVoiceInterim,
|
|
298
|
+
eligible: () => {
|
|
299
|
+
if (claudeAppRef.current) return true;
|
|
300
|
+
const t = termRef.current;
|
|
301
|
+
return !!t && bufferLooksLikeClaude(t as never);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
280
306
|
// In-tile find (⌘F while the tile is live): same engine as the full-screen
|
|
281
307
|
// find bar. Scope follows focus — ⌘F in a live tile searches THIS session;
|
|
282
308
|
// ⌘F anywhere else is the wall filter, which finds sessions.
|
|
@@ -312,6 +338,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
312
338
|
const myIdRef = useRef<string | null>(null);
|
|
313
339
|
const [viewers, setViewers] = useState<Array<{ name: string }>>([]);
|
|
314
340
|
const [driver, setDriver] = useState<{ name: string; color: string } | null>(null);
|
|
341
|
+
const onNoticeRef = useRef(onNotice);
|
|
342
|
+
onNoticeRef.current = onNotice;
|
|
315
343
|
const claudeAppRef = useRef(claudeApp);
|
|
316
344
|
claudeAppRef.current = claudeApp;
|
|
317
345
|
const liveRef = useRef(live);
|
|
@@ -400,6 +428,10 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
400
428
|
// Keys route through refs so the handler survives mode flips untouched.
|
|
401
429
|
term.attachCustomKeyEventHandler((ev) => {
|
|
402
430
|
if (!liveRef.current) return false; // watching: the terminal takes no keys
|
|
431
|
+
{
|
|
432
|
+
const verdict = voiceHoldRef.current?.handleKey(ev);
|
|
433
|
+
if (verdict !== undefined) return verdict;
|
|
434
|
+
}
|
|
403
435
|
if ((ev.metaKey || ev.ctrlKey) && !ev.altKey && !ev.shiftKey && ev.key.toLowerCase() === "f") {
|
|
404
436
|
// Scope follows focus: find in THIS terminal. (The wall filter stays
|
|
405
437
|
// one ⌘K away; ⌘. still unfocuses the tile.)
|
|
@@ -534,7 +566,14 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
534
566
|
}
|
|
535
567
|
term.write("\x1bc" + data.data, () => {
|
|
536
568
|
const t = termRef.current;
|
|
537
|
-
if (t)
|
|
569
|
+
if (!t) return;
|
|
570
|
+
// Pin to the newest line. A repaint can leave the viewport parked
|
|
571
|
+
// where it was, so the tile showed older rows and the live part of
|
|
572
|
+
// the session — a Claude composer, a running command — sat below
|
|
573
|
+
// the fold with nothing indicating it. A preview is a window on
|
|
574
|
+
// NOW; scrollback belongs to the focused terminal.
|
|
575
|
+
t.scrollToBottom();
|
|
576
|
+
t.refresh(0, t.rows - 1);
|
|
538
577
|
rescaleRef.current();
|
|
539
578
|
});
|
|
540
579
|
} catch { /* keep the last frame */ }
|
|
@@ -588,19 +627,28 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
588
627
|
// less responsive than full screen. Only the live tile: browsers cap
|
|
589
628
|
// concurrent WebGL contexts (~16) and a wall can hold more tiles.
|
|
590
629
|
let webgl: WebglAddon | null = null;
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
webgl
|
|
597
|
-
|
|
598
|
-
|
|
630
|
+
let webglWanted = true;
|
|
631
|
+
const loadGpu = () => {
|
|
632
|
+
// Deferred until the first live snapshot has painted: the renderer
|
|
633
|
+
// swap is itself a visible flash, so it happens once the tile already
|
|
634
|
+
// shows the same pixels it will re-render — invisible in practice.
|
|
635
|
+
if (!webglWanted || webgl) return;
|
|
636
|
+
try {
|
|
637
|
+
webgl = new WebglAddon();
|
|
638
|
+
webgl.onContextLoss(() => { webgl?.dispose(); webgl = null; });
|
|
639
|
+
term.loadAddon(webgl);
|
|
640
|
+
term.refresh(0, term.rows - 1);
|
|
641
|
+
} catch {
|
|
642
|
+
try { webgl?.dispose(); } catch { /* half-constructed */ }
|
|
643
|
+
webgl = null;
|
|
644
|
+
webglWanted = false;
|
|
645
|
+
}
|
|
646
|
+
};
|
|
599
647
|
setConn("live");
|
|
600
648
|
const sep = wsBase.includes("?") ? "&" : "?";
|
|
601
649
|
const wsUrl = () =>
|
|
602
650
|
wsBase + sep + "room=" + encodeURIComponent(room) + "&name=" + encodeURIComponent(userName || "user")
|
|
603
|
-
+ "&replay=65536&cols=" + term.cols + "&rows=" + term.rows;
|
|
651
|
+
+ "&replay=65536&nudge=0&cols=" + term.cols + "&rows=" + term.rows;
|
|
604
652
|
let ws: WebSocket | null = null;
|
|
605
653
|
let disposed = false;
|
|
606
654
|
let reconnectTimer = 0;
|
|
@@ -635,8 +683,15 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
635
683
|
try {
|
|
636
684
|
const m = JSON.parse(String(ev.data));
|
|
637
685
|
if (m.type === "snapshot") {
|
|
638
|
-
|
|
639
|
-
|
|
686
|
+
// In-band RIS: clear and repaint in one parse pass. A separate
|
|
687
|
+
// reset() painted an empty frame before the snapshot landed —
|
|
688
|
+
// the blink that made preview → terminal feel like a switch.
|
|
689
|
+
term.write("\x1bc" + m.data, () => {
|
|
690
|
+
term.scrollToBottom();
|
|
691
|
+
term.refresh(0, term.rows - 1);
|
|
692
|
+
rescaleRef.current();
|
|
693
|
+
requestAnimationFrame(loadGpu);
|
|
694
|
+
});
|
|
640
695
|
kbdEnhancedRef.current = typeof m.keyboardEnhanced === "boolean"
|
|
641
696
|
? m.keyboardEnhanced
|
|
642
697
|
: scanKeyboardProtocol(String(m.data || ""), false);
|
|
@@ -722,6 +777,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
722
777
|
setConn(null);
|
|
723
778
|
setViewers([]);
|
|
724
779
|
setDriver(null);
|
|
780
|
+
voiceHoldRef.current?.dispose();
|
|
725
781
|
setFind(null);
|
|
726
782
|
findIndexRef.current = -1;
|
|
727
783
|
term.blur();
|
|
@@ -740,6 +796,14 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
740
796
|
onPointerDown={live ? (e) => e.stopPropagation() : undefined}
|
|
741
797
|
>
|
|
742
798
|
<div className="switcher-focus-term" ref={boxRef} />
|
|
799
|
+
{voiceInterim !== null && (
|
|
800
|
+
<div className="voice-overlay" aria-hidden="true">
|
|
801
|
+
<span className="voice-dot" />
|
|
802
|
+
<span className={voiceInterim ? "voice-text" : "voice-text muted"}>
|
|
803
|
+
{voiceInterim || "Listening… release Space to insert"}
|
|
804
|
+
</span>
|
|
805
|
+
</div>
|
|
806
|
+
)}
|
|
743
807
|
{live && (
|
|
744
808
|
<div className="switcher-live-chrome" onClick={(e) => e.stopPropagation()}>
|
|
745
809
|
<span className={"switcher-focus-label" + (conn === "down" ? " down" : "")}>
|
|
@@ -778,7 +842,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
778
842
|
<button type="button" aria-label="Close find" onClick={closeTileFind}>✕</button>
|
|
779
843
|
</span>
|
|
780
844
|
)}
|
|
781
|
-
<button type="button" title="
|
|
845
|
+
<button type="button" title="Full screen (⌘⏎)" aria-label="Open session full screen" onClick={onFullscreen}>⛶</button>
|
|
782
846
|
<button type="button" title="Unfocus" aria-label="Unfocus tile" onClick={onUnfocus}>✕</button>
|
|
783
847
|
</div>
|
|
784
848
|
)}
|
|
@@ -798,6 +862,7 @@ export const SessionSwitcher = ({
|
|
|
798
862
|
onNotice,
|
|
799
863
|
tileWsBase,
|
|
800
864
|
onFocusSession,
|
|
865
|
+
folders = [],
|
|
801
866
|
userName,
|
|
802
867
|
terminalTheme,
|
|
803
868
|
onOpenSettings,
|
|
@@ -905,6 +970,89 @@ export const SessionSwitcher = ({
|
|
|
905
970
|
}
|
|
906
971
|
};
|
|
907
972
|
const [dragKey, setDragKey] = useState<string | null>(null);
|
|
973
|
+
const [dropFolder, setDropFolder] = useState<string | null>(null);
|
|
974
|
+
const [menu, setMenu] = useState<MenuRequest>(null);
|
|
975
|
+
|
|
976
|
+
// The wall's own background: the actions that create things, plus the view
|
|
977
|
+
// controls that otherwise live only in the header. Same words as the
|
|
978
|
+
// buttons — right-click is a shortcut, never a second vocabulary.
|
|
979
|
+
const openWallMenu = (e: ReactMouseEvent) => {
|
|
980
|
+
// Only the empty wall — a right-click on a card belongs to that card.
|
|
981
|
+
if ((e.target as HTMLElement)?.closest?.(".switcher-card, .switcher-group-label, .switcher-top")) return;
|
|
982
|
+
e.preventDefault();
|
|
983
|
+
setMenu({
|
|
984
|
+
x: e.clientX,
|
|
985
|
+
y: e.clientY,
|
|
986
|
+
items: [
|
|
987
|
+
{ label: "New session", onSelect: () => setCreating(true) },
|
|
988
|
+
...(sortMode === "manual" ? [{ label: "New folder", onSelect: () => { void createFolder(); } }] : []),
|
|
989
|
+
{ kind: "separator" as const },
|
|
990
|
+
{ label: "Sort by recent", onSelect: () => changeSortMode("recent"), disabled: sortMode === "recent" },
|
|
991
|
+
{ label: "Group by project", onSelect: () => changeSortMode("project"), disabled: sortMode === "project" },
|
|
992
|
+
{ label: "Manual order", onSelect: () => changeSortMode("manual"), disabled: sortMode === "manual" },
|
|
993
|
+
{ kind: "separator" as const },
|
|
994
|
+
{ label: "Zoom in", onSelect: () => changeZoom(effectiveZoom + 1), hint: "+", disabled: effectiveZoom >= maxZoom },
|
|
995
|
+
{ label: "Zoom out", onSelect: () => changeZoom(effectiveZoom - 1), hint: "−", disabled: effectiveZoom <= 0 }
|
|
996
|
+
]
|
|
997
|
+
});
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
const openFolderMenu = (e: ReactMouseEvent, folder: SwitcherFolder) => {
|
|
1001
|
+
e.preventDefault();
|
|
1002
|
+
e.stopPropagation();
|
|
1003
|
+
setMenu({
|
|
1004
|
+
x: e.clientX,
|
|
1005
|
+
y: e.clientY,
|
|
1006
|
+
items: [
|
|
1007
|
+
{ label: "Rename folder…", onSelect: () => { void renameFolder(folder); } },
|
|
1008
|
+
{ label: "Delete folder", danger: true, onSelect: () => { void deleteFolder(folder); } },
|
|
1009
|
+
{ kind: "separator" as const },
|
|
1010
|
+
{ label: "New folder", onSelect: () => { void createFolder(); } }
|
|
1011
|
+
]
|
|
1012
|
+
});
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
// Folder mutations go straight to the daemon: folders are shared structure,
|
|
1016
|
+
// not client preference, so they must not live in localStorage the way the
|
|
1017
|
+
// manual ORDER does.
|
|
1018
|
+
const folderApi = async (path: string, body: unknown, failure: string) => {
|
|
1019
|
+
try {
|
|
1020
|
+
const res = await fetch(path, {
|
|
1021
|
+
method: "POST",
|
|
1022
|
+
headers: { "Content-Type": "application/json" },
|
|
1023
|
+
body: JSON.stringify(body)
|
|
1024
|
+
});
|
|
1025
|
+
if (!res.ok) {
|
|
1026
|
+
const data = await res.json().catch(() => ({} as { error?: string }));
|
|
1027
|
+
onNotice(data.error || failure);
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
1030
|
+
onRefresh();
|
|
1031
|
+
return true;
|
|
1032
|
+
} catch {
|
|
1033
|
+
onNotice(failure);
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
const createFolder = async () => {
|
|
1038
|
+
const name = window.prompt("Folder name")?.trim();
|
|
1039
|
+
if (!name) return;
|
|
1040
|
+
if (await folderApi("/api/folders", { name }, "Could not create folder")) onNotice(`Created ${name}`);
|
|
1041
|
+
};
|
|
1042
|
+
const renameFolder = async (folder: SwitcherFolder) => {
|
|
1043
|
+
const name = window.prompt("Rename folder", folder.name)?.trim();
|
|
1044
|
+
if (!name || name === folder.name) return;
|
|
1045
|
+
await folderApi("/api/folders/rename", { id: folder.id, name }, "Could not rename folder");
|
|
1046
|
+
};
|
|
1047
|
+
const deleteFolder = async (folder: SwitcherFolder) => {
|
|
1048
|
+
// Deleting a folder releases its sessions; it never kills them, so this
|
|
1049
|
+
// needs no scarier confirmation than the sentence itself.
|
|
1050
|
+
if (!window.confirm(`Delete folder "${folder.name}"? Its sessions move back to unfiled.`)) return;
|
|
1051
|
+
await folderApi("/api/folders/delete", { id: folder.id }, "Could not delete folder");
|
|
1052
|
+
};
|
|
1053
|
+
const fileSession = async (key: string, folderId: string | null) => {
|
|
1054
|
+
await folderApi("/api/sessions/move", { internalName: key, folderId }, "Could not move session");
|
|
1055
|
+
};
|
|
908
1056
|
|
|
909
1057
|
// Drag auto-scroll: a manual reorder often has to travel past a screenful
|
|
910
1058
|
// of tiles, and HTML5 drag blocks normal scrolling — so while a drag is
|
|
@@ -984,6 +1132,8 @@ export const SessionSwitcher = ({
|
|
|
984
1132
|
const [renameDraft, setRenameDraft] = useState("");
|
|
985
1133
|
const [creating, setCreating] = useState(false);
|
|
986
1134
|
const [createDraft, setCreateDraft] = useState("");
|
|
1135
|
+
// When set, the next created session is filed here (the folder header's +).
|
|
1136
|
+
const [createInFolder, setCreateInFolder] = useState<SwitcherFolder | null>(null);
|
|
987
1137
|
const [, setTick] = useState(0);
|
|
988
1138
|
// Preview cache survives page reloads via sessionStorage: a freshly loaded
|
|
989
1139
|
// page paints last-known screens instantly instead of a wall of blanks.
|
|
@@ -1035,7 +1185,7 @@ export const SessionSwitcher = ({
|
|
|
1035
1185
|
// A filter query searches EVERYTHING (parked included); the browsing
|
|
1036
1186
|
// wall shows only unparked sessions.
|
|
1037
1187
|
const pool = filter.trim() ? visibleSessions : wallSessions;
|
|
1038
|
-
const live = buildSwitcherModel(pool, currentRoom, filter, sortMode, manualOrder);
|
|
1188
|
+
const live = buildSwitcherModel(pool, currentRoom, filter, sortMode, manualOrder, folders);
|
|
1039
1189
|
if (!open || (live.mode !== "tiers" && live.mode !== "project")) return live;
|
|
1040
1190
|
let frozen = frozenOrderRef.current;
|
|
1041
1191
|
if (!frozen || frozen.mode !== live.mode) {
|
|
@@ -1122,17 +1272,33 @@ export const SessionSwitcher = ({
|
|
|
1122
1272
|
|
|
1123
1273
|
// Content matches the name filter didn't already surface.
|
|
1124
1274
|
const extraContentMatches = useMemo(() => {
|
|
1125
|
-
|
|
1126
|
-
|
|
1275
|
+
// Content hits surface in EVERY mode under a query. They were rendered
|
|
1276
|
+
// only by the score-ranked filter branch — which Manual mode never uses
|
|
1277
|
+
// (its filter narrows in place) — so anyone living in Manual simply
|
|
1278
|
+
// never saw the terminal-output search they were promised.
|
|
1279
|
+
if (!filter.trim()) return [];
|
|
1280
|
+
const seen = new Set<string>();
|
|
1281
|
+
if (model.mode === "filter") model.rows.forEach((r) => seen.add(sessionKey(r)));
|
|
1282
|
+
else if (model.mode === "manual") {
|
|
1283
|
+
model.rows.forEach((r) => seen.add(sessionKey(r)));
|
|
1284
|
+
model.folders.forEach((f) => f.rows.forEach((r) => seen.add(sessionKey(r))));
|
|
1285
|
+
} else if (model.mode === "project") {
|
|
1286
|
+
model.groups.forEach((g) => g.rows.forEach((r) => seen.add(sessionKey(r))));
|
|
1287
|
+
} else {
|
|
1288
|
+
model.hero.forEach((r) => seen.add(sessionKey(r)));
|
|
1289
|
+
model.groups.forEach((g) => g.rows.forEach((r) => seen.add(sessionKey(r))));
|
|
1290
|
+
}
|
|
1127
1291
|
return contentMatches.filter((m) => !seen.has(sessionKey(m.session)));
|
|
1128
|
-
}, [model, contentMatches]);
|
|
1292
|
+
}, [model, contentMatches, filter]);
|
|
1129
1293
|
|
|
1130
1294
|
// Flat navigation order = exactly the visual order: heroes, then group rows
|
|
1131
1295
|
// (and under a filter: name matches, then on-screen content matches).
|
|
1132
1296
|
const flatNav = useMemo<SwitcherSession[]>(
|
|
1133
1297
|
() => {
|
|
1134
1298
|
if (model.mode === "filter") return [...model.rows, ...extraContentMatches.map((m) => m.session)];
|
|
1135
|
-
|
|
1299
|
+
// Folder rows come first because that is their visual order; keyboard
|
|
1300
|
+
// ↑/↓ and Enter must traverse what the eye sees, foldered included.
|
|
1301
|
+
if (model.mode === "manual") return [...model.folders.flatMap((f) => f.rows), ...model.rows, ...extraContentMatches.map((m) => m.session)];
|
|
1136
1302
|
if (model.mode === "project") return model.groups.flatMap((g) => g.rows);
|
|
1137
1303
|
return [...model.hero, ...model.groups.flatMap((g) => g.rows)];
|
|
1138
1304
|
},
|
|
@@ -1148,8 +1314,10 @@ export const SessionSwitcher = ({
|
|
|
1148
1314
|
// reordering works against, regardless of what the filter is showing.
|
|
1149
1315
|
const orderedAllKeysRef = useRef<string[]>([]);
|
|
1150
1316
|
{
|
|
1151
|
-
const full = buildSwitcherModel(wallSessions, currentRoom, "", "manual", manualOrder);
|
|
1152
|
-
orderedAllKeysRef.current = full.mode === "manual"
|
|
1317
|
+
const full = buildSwitcherModel(wallSessions, currentRoom, "", "manual", manualOrder, folders);
|
|
1318
|
+
orderedAllKeysRef.current = full.mode === "manual"
|
|
1319
|
+
? [...full.folders.flatMap((f) => f.rows), ...full.rows].map(sessionKey)
|
|
1320
|
+
: [];
|
|
1153
1321
|
}
|
|
1154
1322
|
const flatNavRef = useRef(flatNav);
|
|
1155
1323
|
flatNavRef.current = flatNav;
|
|
@@ -1325,7 +1493,24 @@ export const SessionSwitcher = ({
|
|
|
1325
1493
|
}
|
|
1326
1494
|
};
|
|
1327
1495
|
|
|
1496
|
+
// Engage a session IN PLACE — the wall's default action. Full screen is a
|
|
1497
|
+
// mode you enter on purpose (⌘⏎, the ⛶ button, or the sheet), never a side
|
|
1498
|
+
// effect of clicking around or pressing Enter.
|
|
1499
|
+
const focusInPlace = (session: SwitcherSession) => {
|
|
1500
|
+
const key = sessionKey(session);
|
|
1501
|
+
filterInputRef.current?.blur();
|
|
1502
|
+
setFocusedKey(key);
|
|
1503
|
+
onFocusSession?.(session);
|
|
1504
|
+
const idx = flatNav.findIndex((x) => sessionKey(x) === key);
|
|
1505
|
+
if (idx >= 0) setKbdIndex(idx);
|
|
1506
|
+
};
|
|
1507
|
+
const canFocusInPlace = (session: SwitcherSession) =>
|
|
1508
|
+
interactiveTiles && session.active && session.type !== "port";
|
|
1509
|
+
|
|
1328
1510
|
const handleTap = (session: SwitcherSession) => {
|
|
1511
|
+
// Same immediacy when the tap switches sessions: focus leaves the filter
|
|
1512
|
+
// at the moment of intent, not when the terminal finishes connecting.
|
|
1513
|
+
filterInputRef.current?.blur();
|
|
1329
1514
|
cancelLongPress();
|
|
1330
1515
|
if (suppressTapRef.current) {
|
|
1331
1516
|
suppressTapRef.current = false;
|
|
@@ -1344,6 +1529,30 @@ export const SessionSwitcher = ({
|
|
|
1344
1529
|
body: JSON.stringify({ internalName: sessionKey(session), parked: false })
|
|
1345
1530
|
}).catch(() => { /* daemon will still show it parked; harmless */ });
|
|
1346
1531
|
}
|
|
1532
|
+
// A DEAD terminal session starts IN PLACE instead of falling through to
|
|
1533
|
+
// full screen — the last accidental door into the mode. Attaching is what
|
|
1534
|
+
// starts a known session, so a claim-free throwaway socket kicks it
|
|
1535
|
+
// alive; the tile is pre-focused and goes live the moment the session
|
|
1536
|
+
// reports active. Ports still open full screen (they are web pages), and
|
|
1537
|
+
// without live tiles (no wsBase / small zoom) the old switch stands.
|
|
1538
|
+
if (session.type !== "port" && !session.active && interactiveTiles && tileWsBase) {
|
|
1539
|
+
const key = sessionKey(session);
|
|
1540
|
+
try {
|
|
1541
|
+
const sep = tileWsBase.includes("?") ? "&" : "?";
|
|
1542
|
+
const kick = new WebSocket(
|
|
1543
|
+
tileWsBase + sep + "room=" + encodeURIComponent(key)
|
|
1544
|
+
+ "&name=" + encodeURIComponent((userName || "user") + " (start)")
|
|
1545
|
+
+ "&replay=0&nudge=0&cols=80&rows=24"
|
|
1546
|
+
);
|
|
1547
|
+
kick.onopen = () => { window.setTimeout(() => { try { kick.close(); } catch { /* done */ } }, 400); };
|
|
1548
|
+
kick.onerror = () => { try { kick.close(); } catch { /* refused */ } };
|
|
1549
|
+
} catch { /* daemon will surface it as starting on the next poll */ }
|
|
1550
|
+
onNotice("Starting " + (session.displayName || session.name) + "…");
|
|
1551
|
+
setFocusedKey(key);
|
|
1552
|
+
onFocusSession?.(session);
|
|
1553
|
+
onRefresh();
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1347
1556
|
onSwitch(session);
|
|
1348
1557
|
};
|
|
1349
1558
|
|
|
@@ -1364,15 +1573,15 @@ export const SessionSwitcher = ({
|
|
|
1364
1573
|
return;
|
|
1365
1574
|
}
|
|
1366
1575
|
if (document.activeElement?.closest?.(".switcher-live-tile, .switcher-focus-tile")) return;
|
|
1367
|
-
// ⌘⏎ (Ctrl⏎):
|
|
1368
|
-
//
|
|
1576
|
+
// ⌘⏎ (Ctrl⏎): enter FULL SCREEN on the selected session — the one
|
|
1577
|
+
// deliberate keyboard route into the mode. Plain Enter engages the
|
|
1578
|
+
// tile in place; full screen never happens by accident.
|
|
1369
1579
|
if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey) {
|
|
1370
1580
|
const target = flatNav[kbdIndex];
|
|
1371
|
-
if (target
|
|
1581
|
+
if (target) {
|
|
1372
1582
|
event.preventDefault();
|
|
1373
1583
|
filterInputRef.current?.blur();
|
|
1374
|
-
|
|
1375
|
-
onFocusSession?.(target);
|
|
1584
|
+
onSwitch(target);
|
|
1376
1585
|
}
|
|
1377
1586
|
return;
|
|
1378
1587
|
}
|
|
@@ -1476,7 +1685,8 @@ export const SessionSwitcher = ({
|
|
|
1476
1685
|
const target = flatNav[kbdIndex];
|
|
1477
1686
|
if (target) {
|
|
1478
1687
|
event.preventDefault();
|
|
1479
|
-
|
|
1688
|
+
if (canFocusInPlace(target)) focusInPlace(target);
|
|
1689
|
+
else handleTap(target); // ports, dead sessions, non-interactive zoom
|
|
1480
1690
|
}
|
|
1481
1691
|
return;
|
|
1482
1692
|
}
|
|
@@ -1687,7 +1897,23 @@ export const SessionSwitcher = ({
|
|
|
1687
1897
|
}
|
|
1688
1898
|
setCreating(false);
|
|
1689
1899
|
setCreateDraft("");
|
|
1690
|
-
|
|
1900
|
+
if (createInFolder) {
|
|
1901
|
+
// File it first: the move endpoint is the one place membership is
|
|
1902
|
+
// persisted durably.
|
|
1903
|
+
await fetch("/api/sessions/move", {
|
|
1904
|
+
method: "POST",
|
|
1905
|
+
headers: { "Content-Type": "application/json" },
|
|
1906
|
+
body: JSON.stringify({ internalName: data.name, folderId: createInFolder.id })
|
|
1907
|
+
}).catch(() => { /* filing is best-effort; the session exists */ });
|
|
1908
|
+
setCreateInFolder(null);
|
|
1909
|
+
}
|
|
1910
|
+
// Stay on the wall. Creating used to jump straight to full screen —
|
|
1911
|
+
// the exact accidental mode-entry the hotkey inversion removed from
|
|
1912
|
+
// clicks. The new tile becomes current and goes live in place as soon
|
|
1913
|
+
// as the session reports active.
|
|
1914
|
+
onFocusSession?.({ name: data.name, displayName: data.name, active: false, starting: true, internalName: data.name });
|
|
1915
|
+
setFocusedKey(data.name);
|
|
1916
|
+
onRefresh();
|
|
1691
1917
|
} catch {
|
|
1692
1918
|
onNotice("Failed to create session");
|
|
1693
1919
|
}
|
|
@@ -1791,26 +2017,19 @@ export const SessionSwitcher = ({
|
|
|
1791
2017
|
// any stray click at interactive zoom silently rerouted the
|
|
1792
2018
|
// keyboard into that session ("typing goes to a terminal").
|
|
1793
2019
|
const el = e.target as HTMLElement | null;
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
// previous current card, reading as a stale current-marker.
|
|
1801
|
-
const idx = flatNav.findIndex((x) => sessionKey(x) === key);
|
|
1802
|
-
if (idx >= 0) setKbdIndex(idx);
|
|
1803
|
-
} else handleTap(s);
|
|
2020
|
+
if (el?.closest?.("button, a, input")) return; // controls own their clicks
|
|
2021
|
+
// The WHOLE card engages in place — clicking the header used to
|
|
2022
|
+
// switch to full screen, which made the mode trivially easy to
|
|
2023
|
+
// enter by accident. Full screen is ⌘⏎ / ⛶ / the sheet, only.
|
|
2024
|
+
if (canFocusInPlace(s)) focusInPlace(s);
|
|
2025
|
+
else handleTap(s);
|
|
1804
2026
|
}}
|
|
1805
2027
|
onKeyDown={(e) => {
|
|
1806
2028
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
1807
|
-
// ⌘⏎
|
|
1808
|
-
if (
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
const idx = flatNav.findIndex((x) => sessionKey(x) === key);
|
|
1812
|
-
if (idx >= 0) setKbdIndex(idx);
|
|
1813
|
-
} else handleTap(s);
|
|
2029
|
+
// ⌘⏎ = full screen (deliberate); plain Enter engages in place.
|
|
2030
|
+
if (e.metaKey || e.ctrlKey) { onSwitch(s); return; }
|
|
2031
|
+
if (canFocusInPlace(s)) focusInPlace(s);
|
|
2032
|
+
else handleTap(s);
|
|
1814
2033
|
}
|
|
1815
2034
|
}}
|
|
1816
2035
|
{...pressHandlers(s)}
|
|
@@ -1861,6 +2080,7 @@ export const SessionSwitcher = ({
|
|
|
1861
2080
|
activeRows={preview?.rows}
|
|
1862
2081
|
onFullscreen={() => onSwitch(s)}
|
|
1863
2082
|
onUnfocus={() => setFocusedKey(null)}
|
|
2083
|
+
onNotice={onNotice}
|
|
1864
2084
|
/>
|
|
1865
2085
|
</div>
|
|
1866
2086
|
) : (
|
|
@@ -1944,7 +2164,7 @@ export const SessionSwitcher = ({
|
|
|
1944
2164
|
aria-label="New session"
|
|
1945
2165
|
onClick={() => setCreating(true)}
|
|
1946
2166
|
>
|
|
1947
|
-
|
|
2167
|
+
+<span className="switcher-new-label"> New</span>
|
|
1948
2168
|
</button>
|
|
1949
2169
|
)}
|
|
1950
2170
|
<div className="switcher-tilesize" role="group" aria-label="Tile zoom">
|
|
@@ -2080,7 +2300,7 @@ export const SessionSwitcher = ({
|
|
|
2080
2300
|
</div>
|
|
2081
2301
|
{/* Create form lives in the top bar so the header + works from EVERY
|
|
2082
2302
|
mode — the dashed grid card only exists in recent mode. */}
|
|
2083
|
-
{creating && (
|
|
2303
|
+
{creating && !createInFolder && (
|
|
2084
2304
|
<form className="switcher-create-row inline-edit" onSubmit={submitCreate}>
|
|
2085
2305
|
<input
|
|
2086
2306
|
placeholder="session-name"
|
|
@@ -2091,11 +2311,11 @@ export const SessionSwitcher = ({
|
|
|
2091
2311
|
aria-label="New session name"
|
|
2092
2312
|
/>
|
|
2093
2313
|
<button type="submit">Create</button>
|
|
2094
|
-
<button type="button" onClick={() => { setCreating(false); setCreateDraft(""); }}>✕</button>
|
|
2314
|
+
<button type="button" onClick={() => { setCreating(false); setCreateDraft(""); setCreateInFolder(null); }}>✕</button>
|
|
2095
2315
|
</form>
|
|
2096
2316
|
)}
|
|
2097
2317
|
</div>
|
|
2098
|
-
<div className="switcher-scroll" ref={scrollRef}>
|
|
2318
|
+
<div className="switcher-scroll" ref={scrollRef} onContextMenu={openWallMenu}>
|
|
2099
2319
|
{model.mode === "filter" ? (
|
|
2100
2320
|
// Matches keep their terminal previews: search results are preview
|
|
2101
2321
|
// cards at the current zoom, same as the wall — the screen content
|
|
@@ -2138,15 +2358,81 @@ export const SessionSwitcher = ({
|
|
|
2138
2358
|
</>
|
|
2139
2359
|
) : model.mode === "manual" ? (
|
|
2140
2360
|
<>
|
|
2141
|
-
{model.rows.length === 0 ? (
|
|
2142
|
-
<div className="switcher-empty">No sessions</div>
|
|
2361
|
+
{model.rows.length === 0 && model.folders.every((f) => f.rows.length === 0) ? (
|
|
2362
|
+
<div className="switcher-empty">{filter.trim() ? "No matches" : "No sessions"}</div>
|
|
2143
2363
|
) : (
|
|
2144
2364
|
<>
|
|
2145
2365
|
<p className="switcher-hint">
|
|
2146
2366
|
{filter.trim()
|
|
2147
2367
|
? "Showing matches in your order — drag one next to another (or ⌥↑/⌥↓); the move applies to the full list"
|
|
2148
|
-
: "Drag tiles to reorder, or
|
|
2368
|
+
: "Drag tiles to reorder, or onto a folder to file them"}
|
|
2369
|
+
<button type="button" className="switcher-folder-new" onClick={createFolder}>+ New folder</button>
|
|
2149
2370
|
</p>
|
|
2371
|
+
{model.folders.map(({ folder, rows }) => (
|
|
2372
|
+
<section
|
|
2373
|
+
key={folder.id}
|
|
2374
|
+
className={"switcher-group switcher-folder" + (dropFolder === folder.id ? " drop-target" : "")}
|
|
2375
|
+
onDragOver={(e) => { e.preventDefault(); setDropFolder(folder.id); }}
|
|
2376
|
+
onDragLeave={() => setDropFolder((cur) => (cur === folder.id ? null : cur))}
|
|
2377
|
+
onDrop={(e) => {
|
|
2378
|
+
e.preventDefault();
|
|
2379
|
+
setDropFolder(null);
|
|
2380
|
+
suppressTapRef.current = true;
|
|
2381
|
+
if (dragKey) fileSession(dragKey, folder.id);
|
|
2382
|
+
setDragKey(null);
|
|
2383
|
+
}}
|
|
2384
|
+
>
|
|
2385
|
+
<h3 className="switcher-group-label" onContextMenu={(e) => openFolderMenu(e, folder)}>
|
|
2386
|
+
{folder.name}
|
|
2387
|
+
<span className="switcher-folder-count">{rows.length}</span>
|
|
2388
|
+
<button
|
|
2389
|
+
type="button"
|
|
2390
|
+
className="switcher-folder-add"
|
|
2391
|
+
title={"New session in " + folder.name}
|
|
2392
|
+
onClick={() => { setCreateInFolder(folder); setCreating(true); }}
|
|
2393
|
+
>
|
|
2394
|
+
+
|
|
2395
|
+
</button>
|
|
2396
|
+
<button type="button" onClick={() => renameFolder(folder)} title="Rename folder">✎</button>
|
|
2397
|
+
<button type="button" onClick={() => deleteFolder(folder)} title="Delete folder">🗑</button>
|
|
2398
|
+
</h3>
|
|
2399
|
+
{creating && createInFolder?.id === folder.id && (
|
|
2400
|
+
<form className="switcher-create-row inline-edit switcher-create-infolder" onSubmit={submitCreate}>
|
|
2401
|
+
<input
|
|
2402
|
+
placeholder={"new session in " + folder.name}
|
|
2403
|
+
value={createDraft}
|
|
2404
|
+
onChange={(e) => setCreateDraft(e.target.value)}
|
|
2405
|
+
maxLength={64}
|
|
2406
|
+
autoFocus
|
|
2407
|
+
aria-label={"New session name in " + folder.name}
|
|
2408
|
+
/>
|
|
2409
|
+
<button type="submit">Create</button>
|
|
2410
|
+
<button type="button" onClick={() => { setCreating(false); setCreateDraft(""); setCreateInFolder(null); }}>✕</button>
|
|
2411
|
+
</form>
|
|
2412
|
+
)}
|
|
2413
|
+
{rows.length === 0 ? (
|
|
2414
|
+
creating && createInFolder?.id === folder.id ? null : <p className="switcher-folder-empty">Drag a session here, or + to create one</p>
|
|
2415
|
+
) : (
|
|
2416
|
+
<div className="switcher-grid">{rows.map((s) => renderCard(s))}</div>
|
|
2417
|
+
)}
|
|
2418
|
+
</section>
|
|
2419
|
+
))}
|
|
2420
|
+
{model.folders.length > 0 && model.rows.length > 0 && (
|
|
2421
|
+
<h3
|
|
2422
|
+
className={"switcher-group-label switcher-unfiled" + (dropFolder === "" ? " drop-target" : "")}
|
|
2423
|
+
onDragOver={(e) => { e.preventDefault(); setDropFolder(""); }}
|
|
2424
|
+
onDragLeave={() => setDropFolder((cur) => (cur === "" ? null : cur))}
|
|
2425
|
+
onDrop={(e) => {
|
|
2426
|
+
e.preventDefault();
|
|
2427
|
+
setDropFolder(null);
|
|
2428
|
+
suppressTapRef.current = true;
|
|
2429
|
+
if (dragKey) fileSession(dragKey, null); // drop here to unfile
|
|
2430
|
+
setDragKey(null);
|
|
2431
|
+
}}
|
|
2432
|
+
>
|
|
2433
|
+
Unfiled
|
|
2434
|
+
</h3>
|
|
2435
|
+
)}
|
|
2150
2436
|
<div className="switcher-grid">{model.rows.map((s) => renderCard(s))}</div>
|
|
2151
2437
|
</>
|
|
2152
2438
|
)}
|
|
@@ -2243,8 +2529,37 @@ export const SessionSwitcher = ({
|
|
|
2243
2529
|
) : (
|
|
2244
2530
|
<>
|
|
2245
2531
|
{!isCurrentSession(sheetSession) && (
|
|
2246
|
-
<button type="button" onClick={() =>
|
|
2532
|
+
<button type="button" onClick={() => { setSheet(null); onSwitch(sheetSession); }}>Open full screen</button>
|
|
2247
2533
|
)}
|
|
2534
|
+
<button
|
|
2535
|
+
type="button"
|
|
2536
|
+
onClick={async () => {
|
|
2537
|
+
setSheet(null);
|
|
2538
|
+
onNotice("Forking…");
|
|
2539
|
+
try {
|
|
2540
|
+
const res = await fetch("/api/sessions/fork", {
|
|
2541
|
+
method: "POST",
|
|
2542
|
+
headers: { "Content-Type": "application/json" },
|
|
2543
|
+
body: JSON.stringify({ internalName: sessionKey(sheetSession) })
|
|
2544
|
+
});
|
|
2545
|
+
const data = await res.json().catch(() => ({} as { error?: string; name?: string; kind?: string }));
|
|
2546
|
+
if (!res.ok) {
|
|
2547
|
+
onNotice(data.error || "Fork failed");
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
onNotice(
|
|
2551
|
+
data.kind === "claude" ? `Forked conversation → ${data.name}`
|
|
2552
|
+
: data.kind === "codex" ? `${data.name} opened — pick the conversation to resume`
|
|
2553
|
+
: `Forked → ${data.name} (new shell, same directory)`
|
|
2554
|
+
);
|
|
2555
|
+
onRefresh();
|
|
2556
|
+
} catch {
|
|
2557
|
+
onNotice("Fork failed");
|
|
2558
|
+
}
|
|
2559
|
+
}}
|
|
2560
|
+
>
|
|
2561
|
+
Fork session
|
|
2562
|
+
</button>
|
|
2248
2563
|
<button
|
|
2249
2564
|
type="button"
|
|
2250
2565
|
onClick={() => {
|
|
@@ -2283,6 +2598,7 @@ export const SessionSwitcher = ({
|
|
|
2283
2598
|
</div>
|
|
2284
2599
|
</>
|
|
2285
2600
|
)}
|
|
2601
|
+
<ContextMenu request={menu} onClose={() => setMenu(null)} />
|
|
2286
2602
|
</div>
|
|
2287
2603
|
);
|
|
2288
2604
|
};
|