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.
@@ -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: opening a session (the wall closes)
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) t.refresh(0, t.rows - 1);
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
- try {
592
- webgl = new WebglAddon();
593
- webgl.onContextLoss(() => { webgl?.dispose(); webgl = null; });
594
- term.loadAddon(webgl);
595
- } catch {
596
- webgl?.dispose();
597
- webgl = null;
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
- term.reset();
639
- term.write(m.data, () => rescaleRef.current());
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="Open full screen" aria-label="Open session full screen" onClick={onFullscreen}>⛶</button>
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
- if (model.mode !== "filter") return [];
1126
- const seen = new Set(model.rows.map(sessionKey));
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
- if (model.mode === "manual") return model.rows;
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" ? full.rows.map(sessionKey) : [];
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⏎): focus the selected tile for in-place interaction.
1368
- // Plain Enter stays "switch to session" — the muscle-memory action.
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 && interactiveTiles && target.active && target.type !== "port") {
1523
+ if (target) {
1372
1524
  event.preventDefault();
1373
1525
  filterInputRef.current?.blur();
1374
- setFocusedKey(sessionKey(target));
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
- handleTap(target);
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
- const onTerminalArea = !!el?.closest?.(".switcher-preview, .switcher-live-tile");
1795
- if (interactiveTiles && s.active && s.type !== "port" && onTerminalArea) {
1796
- setFocusedKey(key);
1797
- onFocusSession?.(s);
1798
- // The keyboard selection (accent outline + tinted background)
1799
- // follows explicit interaction — otherwise it lingers on the
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
- // ⌘⏎ focuses the tile; plain Enter switches (mirrors onNavKey).
1808
- if ((e.metaKey || e.ctrlKey) && interactiveTiles && s.active && s.type !== "port") {
1809
- setFocusedKey(key);
1810
- onFocusSession?.(s);
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
- + New
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 ⌥↑/⌥↓ to move the selected one"}
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={() => handleTap(sheetSession)}>Switch to</button>
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
  };