hop2 0.11.1 → 0.11.2
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/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 +113 -20
- package/hay/apps/web/src/components/ContextMenu.tsx +109 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +289 -52
- package/hay/apps/web/src/styles.css +218 -41
- package/hay/apps/web/src/utils/switcherModel.ts +41 -4
- 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-BohAWpcL.css} +1 -1
- package/hay-web/assets/index-DfGT3DuO.js +130 -0
- package/hay-web/assets/index-DfGT3DuO.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +149 -12
- package/package.json +1 -1
- package/scripts/hay-host.js +3 -1
- package/hay-web/assets/index-_wMnETjm.js +0 -130
- package/hay-web/assets/index-_wMnETjm.js.map +0 -1
|
@@ -13,6 +13,7 @@ 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";
|
|
17
18
|
import {
|
|
18
19
|
buildSwitcherModel,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
projectKey,
|
|
21
22
|
relativeTime,
|
|
22
23
|
type SessionOriginScope,
|
|
24
|
+
type SwitcherFolder,
|
|
23
25
|
type SwitcherSession,
|
|
24
26
|
type SwitcherSortMode
|
|
25
27
|
} from "../utils/switcherModel";
|
|
@@ -79,6 +81,9 @@ type Props = {
|
|
|
79
81
|
// Interacting with a tile makes THAT session current: the chip moves, and
|
|
80
82
|
// closing the wall lands full-screen in the terminal you were typing in.
|
|
81
83
|
onFocusSession?: (session: SwitcherSession) => void;
|
|
84
|
+
// User-authored folders (Manual mode only). Server-owned, so they follow
|
|
85
|
+
// you across devices — unlike the drag ORDER, which is per-client taste.
|
|
86
|
+
folders?: SwitcherFolder[];
|
|
82
87
|
tileWsBase?: string;
|
|
83
88
|
userName?: string;
|
|
84
89
|
terminalTheme?: object;
|
|
@@ -103,7 +108,9 @@ type Sheet = {
|
|
|
103
108
|
// → the filter box: clicking it, ⌘K (open), ⌘F (unless a live tile
|
|
104
109
|
// owns the keyboard — then ⌘F is find-in-that-tile,
|
|
105
110
|
// scope follows focus), or ↑ from the top row
|
|
106
|
-
// → the full screen:
|
|
111
|
+
// → the full screen: ⌘⏎, the tile's ⛶, or the sheet's "Open full
|
|
112
|
+
// screen" — DELIBERATE only. Bare clicks and Enter
|
|
113
|
+
// engage the tile in place; full screen is a mode.
|
|
107
114
|
// Enforcement, not convention: a terminal REFUSES to take focus while any
|
|
108
115
|
// text input holds it (stealOk below), and editing the filter drops any live
|
|
109
116
|
// tile back to watch — so re-renders, remounts, reconnects, and polls can
|
|
@@ -534,7 +541,14 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
534
541
|
}
|
|
535
542
|
term.write("\x1bc" + data.data, () => {
|
|
536
543
|
const t = termRef.current;
|
|
537
|
-
if (t)
|
|
544
|
+
if (!t) return;
|
|
545
|
+
// Pin to the newest line. A repaint can leave the viewport parked
|
|
546
|
+
// where it was, so the tile showed older rows and the live part of
|
|
547
|
+
// the session — a Claude composer, a running command — sat below
|
|
548
|
+
// the fold with nothing indicating it. A preview is a window on
|
|
549
|
+
// NOW; scrollback belongs to the focused terminal.
|
|
550
|
+
t.scrollToBottom();
|
|
551
|
+
t.refresh(0, t.rows - 1);
|
|
538
552
|
rescaleRef.current();
|
|
539
553
|
});
|
|
540
554
|
} catch { /* keep the last frame */ }
|
|
@@ -588,19 +602,28 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
588
602
|
// less responsive than full screen. Only the live tile: browsers cap
|
|
589
603
|
// concurrent WebGL contexts (~16) and a wall can hold more tiles.
|
|
590
604
|
let webgl: WebglAddon | null = null;
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
webgl
|
|
597
|
-
|
|
598
|
-
|
|
605
|
+
let webglWanted = true;
|
|
606
|
+
const loadGpu = () => {
|
|
607
|
+
// Deferred until the first live snapshot has painted: the renderer
|
|
608
|
+
// swap is itself a visible flash, so it happens once the tile already
|
|
609
|
+
// shows the same pixels it will re-render — invisible in practice.
|
|
610
|
+
if (!webglWanted || webgl) return;
|
|
611
|
+
try {
|
|
612
|
+
webgl = new WebglAddon();
|
|
613
|
+
webgl.onContextLoss(() => { webgl?.dispose(); webgl = null; });
|
|
614
|
+
term.loadAddon(webgl);
|
|
615
|
+
term.refresh(0, term.rows - 1);
|
|
616
|
+
} catch {
|
|
617
|
+
try { webgl?.dispose(); } catch { /* half-constructed */ }
|
|
618
|
+
webgl = null;
|
|
619
|
+
webglWanted = false;
|
|
620
|
+
}
|
|
621
|
+
};
|
|
599
622
|
setConn("live");
|
|
600
623
|
const sep = wsBase.includes("?") ? "&" : "?";
|
|
601
624
|
const wsUrl = () =>
|
|
602
625
|
wsBase + sep + "room=" + encodeURIComponent(room) + "&name=" + encodeURIComponent(userName || "user")
|
|
603
|
-
+ "&replay=65536&cols=" + term.cols + "&rows=" + term.rows;
|
|
626
|
+
+ "&replay=65536&nudge=0&cols=" + term.cols + "&rows=" + term.rows;
|
|
604
627
|
let ws: WebSocket | null = null;
|
|
605
628
|
let disposed = false;
|
|
606
629
|
let reconnectTimer = 0;
|
|
@@ -635,8 +658,15 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
635
658
|
try {
|
|
636
659
|
const m = JSON.parse(String(ev.data));
|
|
637
660
|
if (m.type === "snapshot") {
|
|
638
|
-
|
|
639
|
-
|
|
661
|
+
// In-band RIS: clear and repaint in one parse pass. A separate
|
|
662
|
+
// reset() painted an empty frame before the snapshot landed —
|
|
663
|
+
// the blink that made preview → terminal feel like a switch.
|
|
664
|
+
term.write("\x1bc" + m.data, () => {
|
|
665
|
+
term.scrollToBottom();
|
|
666
|
+
term.refresh(0, term.rows - 1);
|
|
667
|
+
rescaleRef.current();
|
|
668
|
+
requestAnimationFrame(loadGpu);
|
|
669
|
+
});
|
|
640
670
|
kbdEnhancedRef.current = typeof m.keyboardEnhanced === "boolean"
|
|
641
671
|
? m.keyboardEnhanced
|
|
642
672
|
: scanKeyboardProtocol(String(m.data || ""), false);
|
|
@@ -778,7 +808,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
778
808
|
<button type="button" aria-label="Close find" onClick={closeTileFind}>✕</button>
|
|
779
809
|
</span>
|
|
780
810
|
)}
|
|
781
|
-
<button type="button" title="
|
|
811
|
+
<button type="button" title="Full screen (⌘⏎)" aria-label="Open session full screen" onClick={onFullscreen}>⛶</button>
|
|
782
812
|
<button type="button" title="Unfocus" aria-label="Unfocus tile" onClick={onUnfocus}>✕</button>
|
|
783
813
|
</div>
|
|
784
814
|
)}
|
|
@@ -798,6 +828,7 @@ export const SessionSwitcher = ({
|
|
|
798
828
|
onNotice,
|
|
799
829
|
tileWsBase,
|
|
800
830
|
onFocusSession,
|
|
831
|
+
folders = [],
|
|
801
832
|
userName,
|
|
802
833
|
terminalTheme,
|
|
803
834
|
onOpenSettings,
|
|
@@ -905,6 +936,89 @@ export const SessionSwitcher = ({
|
|
|
905
936
|
}
|
|
906
937
|
};
|
|
907
938
|
const [dragKey, setDragKey] = useState<string | null>(null);
|
|
939
|
+
const [dropFolder, setDropFolder] = useState<string | null>(null);
|
|
940
|
+
const [menu, setMenu] = useState<MenuRequest>(null);
|
|
941
|
+
|
|
942
|
+
// The wall's own background: the actions that create things, plus the view
|
|
943
|
+
// controls that otherwise live only in the header. Same words as the
|
|
944
|
+
// buttons — right-click is a shortcut, never a second vocabulary.
|
|
945
|
+
const openWallMenu = (e: ReactMouseEvent) => {
|
|
946
|
+
// Only the empty wall — a right-click on a card belongs to that card.
|
|
947
|
+
if ((e.target as HTMLElement)?.closest?.(".switcher-card, .switcher-group-label, .switcher-top")) return;
|
|
948
|
+
e.preventDefault();
|
|
949
|
+
setMenu({
|
|
950
|
+
x: e.clientX,
|
|
951
|
+
y: e.clientY,
|
|
952
|
+
items: [
|
|
953
|
+
{ label: "New session", onSelect: () => setCreating(true) },
|
|
954
|
+
...(sortMode === "manual" ? [{ label: "New folder", onSelect: () => { void createFolder(); } }] : []),
|
|
955
|
+
{ kind: "separator" as const },
|
|
956
|
+
{ label: "Sort by recent", onSelect: () => changeSortMode("recent"), disabled: sortMode === "recent" },
|
|
957
|
+
{ label: "Group by project", onSelect: () => changeSortMode("project"), disabled: sortMode === "project" },
|
|
958
|
+
{ label: "Manual order", onSelect: () => changeSortMode("manual"), disabled: sortMode === "manual" },
|
|
959
|
+
{ kind: "separator" as const },
|
|
960
|
+
{ label: "Zoom in", onSelect: () => changeZoom(effectiveZoom + 1), hint: "+", disabled: effectiveZoom >= maxZoom },
|
|
961
|
+
{ label: "Zoom out", onSelect: () => changeZoom(effectiveZoom - 1), hint: "−", disabled: effectiveZoom <= 0 }
|
|
962
|
+
]
|
|
963
|
+
});
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
const openFolderMenu = (e: ReactMouseEvent, folder: SwitcherFolder) => {
|
|
967
|
+
e.preventDefault();
|
|
968
|
+
e.stopPropagation();
|
|
969
|
+
setMenu({
|
|
970
|
+
x: e.clientX,
|
|
971
|
+
y: e.clientY,
|
|
972
|
+
items: [
|
|
973
|
+
{ label: "Rename folder…", onSelect: () => { void renameFolder(folder); } },
|
|
974
|
+
{ label: "Delete folder", danger: true, onSelect: () => { void deleteFolder(folder); } },
|
|
975
|
+
{ kind: "separator" as const },
|
|
976
|
+
{ label: "New folder", onSelect: () => { void createFolder(); } }
|
|
977
|
+
]
|
|
978
|
+
});
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
// Folder mutations go straight to the daemon: folders are shared structure,
|
|
982
|
+
// not client preference, so they must not live in localStorage the way the
|
|
983
|
+
// manual ORDER does.
|
|
984
|
+
const folderApi = async (path: string, body: unknown, failure: string) => {
|
|
985
|
+
try {
|
|
986
|
+
const res = await fetch(path, {
|
|
987
|
+
method: "POST",
|
|
988
|
+
headers: { "Content-Type": "application/json" },
|
|
989
|
+
body: JSON.stringify(body)
|
|
990
|
+
});
|
|
991
|
+
if (!res.ok) {
|
|
992
|
+
const data = await res.json().catch(() => ({} as { error?: string }));
|
|
993
|
+
onNotice(data.error || failure);
|
|
994
|
+
return false;
|
|
995
|
+
}
|
|
996
|
+
onRefresh();
|
|
997
|
+
return true;
|
|
998
|
+
} catch {
|
|
999
|
+
onNotice(failure);
|
|
1000
|
+
return false;
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
const createFolder = async () => {
|
|
1004
|
+
const name = window.prompt("Folder name")?.trim();
|
|
1005
|
+
if (!name) return;
|
|
1006
|
+
if (await folderApi("/api/folders", { name }, "Could not create folder")) onNotice(`Created ${name}`);
|
|
1007
|
+
};
|
|
1008
|
+
const renameFolder = async (folder: SwitcherFolder) => {
|
|
1009
|
+
const name = window.prompt("Rename folder", folder.name)?.trim();
|
|
1010
|
+
if (!name || name === folder.name) return;
|
|
1011
|
+
await folderApi("/api/folders/rename", { id: folder.id, name }, "Could not rename folder");
|
|
1012
|
+
};
|
|
1013
|
+
const deleteFolder = async (folder: SwitcherFolder) => {
|
|
1014
|
+
// Deleting a folder releases its sessions; it never kills them, so this
|
|
1015
|
+
// needs no scarier confirmation than the sentence itself.
|
|
1016
|
+
if (!window.confirm(`Delete folder "${folder.name}"? Its sessions move back to unfiled.`)) return;
|
|
1017
|
+
await folderApi("/api/folders/delete", { id: folder.id }, "Could not delete folder");
|
|
1018
|
+
};
|
|
1019
|
+
const fileSession = async (key: string, folderId: string | null) => {
|
|
1020
|
+
await folderApi("/api/sessions/move", { internalName: key, folderId }, "Could not move session");
|
|
1021
|
+
};
|
|
908
1022
|
|
|
909
1023
|
// Drag auto-scroll: a manual reorder often has to travel past a screenful
|
|
910
1024
|
// of tiles, and HTML5 drag blocks normal scrolling — so while a drag is
|
|
@@ -984,6 +1098,8 @@ export const SessionSwitcher = ({
|
|
|
984
1098
|
const [renameDraft, setRenameDraft] = useState("");
|
|
985
1099
|
const [creating, setCreating] = useState(false);
|
|
986
1100
|
const [createDraft, setCreateDraft] = useState("");
|
|
1101
|
+
// When set, the next created session is filed here (the folder header's +).
|
|
1102
|
+
const [createInFolder, setCreateInFolder] = useState<SwitcherFolder | null>(null);
|
|
987
1103
|
const [, setTick] = useState(0);
|
|
988
1104
|
// Preview cache survives page reloads via sessionStorage: a freshly loaded
|
|
989
1105
|
// page paints last-known screens instantly instead of a wall of blanks.
|
|
@@ -1035,7 +1151,7 @@ export const SessionSwitcher = ({
|
|
|
1035
1151
|
// A filter query searches EVERYTHING (parked included); the browsing
|
|
1036
1152
|
// wall shows only unparked sessions.
|
|
1037
1153
|
const pool = filter.trim() ? visibleSessions : wallSessions;
|
|
1038
|
-
const live = buildSwitcherModel(pool, currentRoom, filter, sortMode, manualOrder);
|
|
1154
|
+
const live = buildSwitcherModel(pool, currentRoom, filter, sortMode, manualOrder, folders);
|
|
1039
1155
|
if (!open || (live.mode !== "tiers" && live.mode !== "project")) return live;
|
|
1040
1156
|
let frozen = frozenOrderRef.current;
|
|
1041
1157
|
if (!frozen || frozen.mode !== live.mode) {
|
|
@@ -1122,17 +1238,33 @@ export const SessionSwitcher = ({
|
|
|
1122
1238
|
|
|
1123
1239
|
// Content matches the name filter didn't already surface.
|
|
1124
1240
|
const extraContentMatches = useMemo(() => {
|
|
1125
|
-
|
|
1126
|
-
|
|
1241
|
+
// Content hits surface in EVERY mode under a query. They were rendered
|
|
1242
|
+
// only by the score-ranked filter branch — which Manual mode never uses
|
|
1243
|
+
// (its filter narrows in place) — so anyone living in Manual simply
|
|
1244
|
+
// never saw the terminal-output search they were promised.
|
|
1245
|
+
if (!filter.trim()) return [];
|
|
1246
|
+
const seen = new Set<string>();
|
|
1247
|
+
if (model.mode === "filter") model.rows.forEach((r) => seen.add(sessionKey(r)));
|
|
1248
|
+
else if (model.mode === "manual") {
|
|
1249
|
+
model.rows.forEach((r) => seen.add(sessionKey(r)));
|
|
1250
|
+
model.folders.forEach((f) => f.rows.forEach((r) => seen.add(sessionKey(r))));
|
|
1251
|
+
} else if (model.mode === "project") {
|
|
1252
|
+
model.groups.forEach((g) => g.rows.forEach((r) => seen.add(sessionKey(r))));
|
|
1253
|
+
} else {
|
|
1254
|
+
model.hero.forEach((r) => seen.add(sessionKey(r)));
|
|
1255
|
+
model.groups.forEach((g) => g.rows.forEach((r) => seen.add(sessionKey(r))));
|
|
1256
|
+
}
|
|
1127
1257
|
return contentMatches.filter((m) => !seen.has(sessionKey(m.session)));
|
|
1128
|
-
}, [model, contentMatches]);
|
|
1258
|
+
}, [model, contentMatches, filter]);
|
|
1129
1259
|
|
|
1130
1260
|
// Flat navigation order = exactly the visual order: heroes, then group rows
|
|
1131
1261
|
// (and under a filter: name matches, then on-screen content matches).
|
|
1132
1262
|
const flatNav = useMemo<SwitcherSession[]>(
|
|
1133
1263
|
() => {
|
|
1134
1264
|
if (model.mode === "filter") return [...model.rows, ...extraContentMatches.map((m) => m.session)];
|
|
1135
|
-
|
|
1265
|
+
// Folder rows come first because that is their visual order; keyboard
|
|
1266
|
+
// ↑/↓ and Enter must traverse what the eye sees, foldered included.
|
|
1267
|
+
if (model.mode === "manual") return [...model.folders.flatMap((f) => f.rows), ...model.rows, ...extraContentMatches.map((m) => m.session)];
|
|
1136
1268
|
if (model.mode === "project") return model.groups.flatMap((g) => g.rows);
|
|
1137
1269
|
return [...model.hero, ...model.groups.flatMap((g) => g.rows)];
|
|
1138
1270
|
},
|
|
@@ -1148,8 +1280,10 @@ export const SessionSwitcher = ({
|
|
|
1148
1280
|
// reordering works against, regardless of what the filter is showing.
|
|
1149
1281
|
const orderedAllKeysRef = useRef<string[]>([]);
|
|
1150
1282
|
{
|
|
1151
|
-
const full = buildSwitcherModel(wallSessions, currentRoom, "", "manual", manualOrder);
|
|
1152
|
-
orderedAllKeysRef.current = full.mode === "manual"
|
|
1283
|
+
const full = buildSwitcherModel(wallSessions, currentRoom, "", "manual", manualOrder, folders);
|
|
1284
|
+
orderedAllKeysRef.current = full.mode === "manual"
|
|
1285
|
+
? [...full.folders.flatMap((f) => f.rows), ...full.rows].map(sessionKey)
|
|
1286
|
+
: [];
|
|
1153
1287
|
}
|
|
1154
1288
|
const flatNavRef = useRef(flatNav);
|
|
1155
1289
|
flatNavRef.current = flatNav;
|
|
@@ -1325,7 +1459,24 @@ export const SessionSwitcher = ({
|
|
|
1325
1459
|
}
|
|
1326
1460
|
};
|
|
1327
1461
|
|
|
1462
|
+
// Engage a session IN PLACE — the wall's default action. Full screen is a
|
|
1463
|
+
// mode you enter on purpose (⌘⏎, the ⛶ button, or the sheet), never a side
|
|
1464
|
+
// effect of clicking around or pressing Enter.
|
|
1465
|
+
const focusInPlace = (session: SwitcherSession) => {
|
|
1466
|
+
const key = sessionKey(session);
|
|
1467
|
+
filterInputRef.current?.blur();
|
|
1468
|
+
setFocusedKey(key);
|
|
1469
|
+
onFocusSession?.(session);
|
|
1470
|
+
const idx = flatNav.findIndex((x) => sessionKey(x) === key);
|
|
1471
|
+
if (idx >= 0) setKbdIndex(idx);
|
|
1472
|
+
};
|
|
1473
|
+
const canFocusInPlace = (session: SwitcherSession) =>
|
|
1474
|
+
interactiveTiles && session.active && session.type !== "port";
|
|
1475
|
+
|
|
1328
1476
|
const handleTap = (session: SwitcherSession) => {
|
|
1477
|
+
// Same immediacy when the tap switches sessions: focus leaves the filter
|
|
1478
|
+
// at the moment of intent, not when the terminal finishes connecting.
|
|
1479
|
+
filterInputRef.current?.blur();
|
|
1329
1480
|
cancelLongPress();
|
|
1330
1481
|
if (suppressTapRef.current) {
|
|
1331
1482
|
suppressTapRef.current = false;
|
|
@@ -1364,15 +1515,15 @@ export const SessionSwitcher = ({
|
|
|
1364
1515
|
return;
|
|
1365
1516
|
}
|
|
1366
1517
|
if (document.activeElement?.closest?.(".switcher-live-tile, .switcher-focus-tile")) return;
|
|
1367
|
-
// ⌘⏎ (Ctrl⏎):
|
|
1368
|
-
//
|
|
1518
|
+
// ⌘⏎ (Ctrl⏎): enter FULL SCREEN on the selected session — the one
|
|
1519
|
+
// deliberate keyboard route into the mode. Plain Enter engages the
|
|
1520
|
+
// tile in place; full screen never happens by accident.
|
|
1369
1521
|
if (event.key === "Enter" && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey) {
|
|
1370
1522
|
const target = flatNav[kbdIndex];
|
|
1371
|
-
if (target
|
|
1523
|
+
if (target) {
|
|
1372
1524
|
event.preventDefault();
|
|
1373
1525
|
filterInputRef.current?.blur();
|
|
1374
|
-
|
|
1375
|
-
onFocusSession?.(target);
|
|
1526
|
+
onSwitch(target);
|
|
1376
1527
|
}
|
|
1377
1528
|
return;
|
|
1378
1529
|
}
|
|
@@ -1476,7 +1627,8 @@ export const SessionSwitcher = ({
|
|
|
1476
1627
|
const target = flatNav[kbdIndex];
|
|
1477
1628
|
if (target) {
|
|
1478
1629
|
event.preventDefault();
|
|
1479
|
-
|
|
1630
|
+
if (canFocusInPlace(target)) focusInPlace(target);
|
|
1631
|
+
else handleTap(target); // ports, dead sessions, non-interactive zoom
|
|
1480
1632
|
}
|
|
1481
1633
|
return;
|
|
1482
1634
|
}
|
|
@@ -1687,6 +1839,16 @@ export const SessionSwitcher = ({
|
|
|
1687
1839
|
}
|
|
1688
1840
|
setCreating(false);
|
|
1689
1841
|
setCreateDraft("");
|
|
1842
|
+
if (createInFolder) {
|
|
1843
|
+
// File it before switching: reuses the move endpoint, which is the
|
|
1844
|
+
// one place membership is persisted durably.
|
|
1845
|
+
await fetch("/api/sessions/move", {
|
|
1846
|
+
method: "POST",
|
|
1847
|
+
headers: { "Content-Type": "application/json" },
|
|
1848
|
+
body: JSON.stringify({ internalName: data.name, folderId: createInFolder.id })
|
|
1849
|
+
}).catch(() => { /* filing is best-effort; the session exists */ });
|
|
1850
|
+
setCreateInFolder(null);
|
|
1851
|
+
}
|
|
1690
1852
|
onSwitch({ name: data.name, displayName: data.name, active: false, starting: true, internalName: data.name });
|
|
1691
1853
|
} catch {
|
|
1692
1854
|
onNotice("Failed to create session");
|
|
@@ -1791,26 +1953,19 @@ export const SessionSwitcher = ({
|
|
|
1791
1953
|
// any stray click at interactive zoom silently rerouted the
|
|
1792
1954
|
// keyboard into that session ("typing goes to a terminal").
|
|
1793
1955
|
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);
|
|
1956
|
+
if (el?.closest?.("button, a, input")) return; // controls own their clicks
|
|
1957
|
+
// The WHOLE card engages in place — clicking the header used to
|
|
1958
|
+
// switch to full screen, which made the mode trivially easy to
|
|
1959
|
+
// enter by accident. Full screen is ⌘⏎ / ⛶ / the sheet, only.
|
|
1960
|
+
if (canFocusInPlace(s)) focusInPlace(s);
|
|
1961
|
+
else handleTap(s);
|
|
1804
1962
|
}}
|
|
1805
1963
|
onKeyDown={(e) => {
|
|
1806
1964
|
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);
|
|
1965
|
+
// ⌘⏎ = full screen (deliberate); plain Enter engages in place.
|
|
1966
|
+
if (e.metaKey || e.ctrlKey) { onSwitch(s); return; }
|
|
1967
|
+
if (canFocusInPlace(s)) focusInPlace(s);
|
|
1968
|
+
else handleTap(s);
|
|
1814
1969
|
}
|
|
1815
1970
|
}}
|
|
1816
1971
|
{...pressHandlers(s)}
|
|
@@ -1944,7 +2099,7 @@ export const SessionSwitcher = ({
|
|
|
1944
2099
|
aria-label="New session"
|
|
1945
2100
|
onClick={() => setCreating(true)}
|
|
1946
2101
|
>
|
|
1947
|
-
|
|
2102
|
+
+<span className="switcher-new-label"> New</span>
|
|
1948
2103
|
</button>
|
|
1949
2104
|
)}
|
|
1950
2105
|
<div className="switcher-tilesize" role="group" aria-label="Tile zoom">
|
|
@@ -2083,7 +2238,7 @@ export const SessionSwitcher = ({
|
|
|
2083
2238
|
{creating && (
|
|
2084
2239
|
<form className="switcher-create-row inline-edit" onSubmit={submitCreate}>
|
|
2085
2240
|
<input
|
|
2086
|
-
placeholder="session-name"
|
|
2241
|
+
placeholder={createInFolder ? "new session in " + createInFolder.name : "session-name"}
|
|
2087
2242
|
value={createDraft}
|
|
2088
2243
|
onChange={(e) => setCreateDraft(e.target.value)}
|
|
2089
2244
|
maxLength={64}
|
|
@@ -2091,11 +2246,11 @@ export const SessionSwitcher = ({
|
|
|
2091
2246
|
aria-label="New session name"
|
|
2092
2247
|
/>
|
|
2093
2248
|
<button type="submit">Create</button>
|
|
2094
|
-
<button type="button" onClick={() => { setCreating(false); setCreateDraft(""); }}>✕</button>
|
|
2249
|
+
<button type="button" onClick={() => { setCreating(false); setCreateDraft(""); setCreateInFolder(null); }}>✕</button>
|
|
2095
2250
|
</form>
|
|
2096
2251
|
)}
|
|
2097
2252
|
</div>
|
|
2098
|
-
<div className="switcher-scroll" ref={scrollRef}>
|
|
2253
|
+
<div className="switcher-scroll" ref={scrollRef} onContextMenu={openWallMenu}>
|
|
2099
2254
|
{model.mode === "filter" ? (
|
|
2100
2255
|
// Matches keep their terminal previews: search results are preview
|
|
2101
2256
|
// cards at the current zoom, same as the wall — the screen content
|
|
@@ -2138,15 +2293,67 @@ export const SessionSwitcher = ({
|
|
|
2138
2293
|
</>
|
|
2139
2294
|
) : model.mode === "manual" ? (
|
|
2140
2295
|
<>
|
|
2141
|
-
{model.rows.length === 0 ? (
|
|
2142
|
-
<div className="switcher-empty">No sessions</div>
|
|
2296
|
+
{model.rows.length === 0 && model.folders.every((f) => f.rows.length === 0) ? (
|
|
2297
|
+
<div className="switcher-empty">{filter.trim() ? "No matches" : "No sessions"}</div>
|
|
2143
2298
|
) : (
|
|
2144
2299
|
<>
|
|
2145
2300
|
<p className="switcher-hint">
|
|
2146
2301
|
{filter.trim()
|
|
2147
2302
|
? "Showing matches in your order — drag one next to another (or ⌥↑/⌥↓); the move applies to the full list"
|
|
2148
|
-
: "Drag tiles to reorder, or
|
|
2303
|
+
: "Drag tiles to reorder, or onto a folder to file them"}
|
|
2304
|
+
<button type="button" className="switcher-folder-new" onClick={createFolder}>+ New folder</button>
|
|
2149
2305
|
</p>
|
|
2306
|
+
{model.folders.map(({ folder, rows }) => (
|
|
2307
|
+
<section
|
|
2308
|
+
key={folder.id}
|
|
2309
|
+
className={"switcher-group switcher-folder" + (dropFolder === folder.id ? " drop-target" : "")}
|
|
2310
|
+
onDragOver={(e) => { e.preventDefault(); setDropFolder(folder.id); }}
|
|
2311
|
+
onDragLeave={() => setDropFolder((cur) => (cur === folder.id ? null : cur))}
|
|
2312
|
+
onDrop={(e) => {
|
|
2313
|
+
e.preventDefault();
|
|
2314
|
+
setDropFolder(null);
|
|
2315
|
+
suppressTapRef.current = true;
|
|
2316
|
+
if (dragKey) fileSession(dragKey, folder.id);
|
|
2317
|
+
setDragKey(null);
|
|
2318
|
+
}}
|
|
2319
|
+
>
|
|
2320
|
+
<h3 className="switcher-group-label" onContextMenu={(e) => openFolderMenu(e, folder)}>
|
|
2321
|
+
{folder.name}
|
|
2322
|
+
<span className="switcher-folder-count">{rows.length}</span>
|
|
2323
|
+
<button
|
|
2324
|
+
type="button"
|
|
2325
|
+
className="switcher-folder-add"
|
|
2326
|
+
title={"New session in " + folder.name}
|
|
2327
|
+
onClick={() => { setCreateInFolder(folder); setCreating(true); }}
|
|
2328
|
+
>
|
|
2329
|
+
+
|
|
2330
|
+
</button>
|
|
2331
|
+
<button type="button" onClick={() => renameFolder(folder)} title="Rename folder">✎</button>
|
|
2332
|
+
<button type="button" onClick={() => deleteFolder(folder)} title="Delete folder">🗑</button>
|
|
2333
|
+
</h3>
|
|
2334
|
+
{rows.length === 0 ? (
|
|
2335
|
+
<p className="switcher-folder-empty">Drag a session here</p>
|
|
2336
|
+
) : (
|
|
2337
|
+
<div className="switcher-grid">{rows.map((s) => renderCard(s))}</div>
|
|
2338
|
+
)}
|
|
2339
|
+
</section>
|
|
2340
|
+
))}
|
|
2341
|
+
{model.folders.length > 0 && model.rows.length > 0 && (
|
|
2342
|
+
<h3
|
|
2343
|
+
className={"switcher-group-label switcher-unfiled" + (dropFolder === "" ? " drop-target" : "")}
|
|
2344
|
+
onDragOver={(e) => { e.preventDefault(); setDropFolder(""); }}
|
|
2345
|
+
onDragLeave={() => setDropFolder((cur) => (cur === "" ? null : cur))}
|
|
2346
|
+
onDrop={(e) => {
|
|
2347
|
+
e.preventDefault();
|
|
2348
|
+
setDropFolder(null);
|
|
2349
|
+
suppressTapRef.current = true;
|
|
2350
|
+
if (dragKey) fileSession(dragKey, null); // drop here to unfile
|
|
2351
|
+
setDragKey(null);
|
|
2352
|
+
}}
|
|
2353
|
+
>
|
|
2354
|
+
Unfiled
|
|
2355
|
+
</h3>
|
|
2356
|
+
)}
|
|
2150
2357
|
<div className="switcher-grid">{model.rows.map((s) => renderCard(s))}</div>
|
|
2151
2358
|
</>
|
|
2152
2359
|
)}
|
|
@@ -2243,8 +2450,37 @@ export const SessionSwitcher = ({
|
|
|
2243
2450
|
) : (
|
|
2244
2451
|
<>
|
|
2245
2452
|
{!isCurrentSession(sheetSession) && (
|
|
2246
|
-
<button type="button" onClick={() =>
|
|
2453
|
+
<button type="button" onClick={() => { setSheet(null); onSwitch(sheetSession); }}>Open full screen</button>
|
|
2247
2454
|
)}
|
|
2455
|
+
<button
|
|
2456
|
+
type="button"
|
|
2457
|
+
onClick={async () => {
|
|
2458
|
+
setSheet(null);
|
|
2459
|
+
onNotice("Forking…");
|
|
2460
|
+
try {
|
|
2461
|
+
const res = await fetch("/api/sessions/fork", {
|
|
2462
|
+
method: "POST",
|
|
2463
|
+
headers: { "Content-Type": "application/json" },
|
|
2464
|
+
body: JSON.stringify({ internalName: sessionKey(sheetSession) })
|
|
2465
|
+
});
|
|
2466
|
+
const data = await res.json().catch(() => ({} as { error?: string; name?: string; kind?: string }));
|
|
2467
|
+
if (!res.ok) {
|
|
2468
|
+
onNotice(data.error || "Fork failed");
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
onNotice(
|
|
2472
|
+
data.kind === "claude" ? `Forked conversation → ${data.name}`
|
|
2473
|
+
: data.kind === "codex" ? `${data.name} opened — pick the conversation to resume`
|
|
2474
|
+
: `Forked → ${data.name} (new shell, same directory)`
|
|
2475
|
+
);
|
|
2476
|
+
onRefresh();
|
|
2477
|
+
} catch {
|
|
2478
|
+
onNotice("Fork failed");
|
|
2479
|
+
}
|
|
2480
|
+
}}
|
|
2481
|
+
>
|
|
2482
|
+
Fork session
|
|
2483
|
+
</button>
|
|
2248
2484
|
<button
|
|
2249
2485
|
type="button"
|
|
2250
2486
|
onClick={() => {
|
|
@@ -2283,6 +2519,7 @@ export const SessionSwitcher = ({
|
|
|
2283
2519
|
</div>
|
|
2284
2520
|
</>
|
|
2285
2521
|
)}
|
|
2522
|
+
<ContextMenu request={menu} onClose={() => setMenu(null)} />
|
|
2286
2523
|
</div>
|
|
2287
2524
|
);
|
|
2288
2525
|
};
|