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.
@@ -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: opening a session (the wall closes)
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) t.refresh(0, t.rows - 1);
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
- 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
- }
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
- term.reset();
639
- term.write(m.data, () => rescaleRef.current());
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="Open full screen" aria-label="Open session full screen" onClick={onFullscreen}>⛶</button>
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
- if (model.mode !== "filter") return [];
1126
- const seen = new Set(model.rows.map(sessionKey));
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
- if (model.mode === "manual") return model.rows;
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" ? full.rows.map(sessionKey) : [];
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⏎): focus the selected tile for in-place interaction.
1368
- // Plain Enter stays "switch to session" — the muscle-memory action.
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 && interactiveTiles && target.active && target.type !== "port") {
1581
+ if (target) {
1372
1582
  event.preventDefault();
1373
1583
  filterInputRef.current?.blur();
1374
- setFocusedKey(sessionKey(target));
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
- handleTap(target);
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
- onSwitch({ name: data.name, displayName: data.name, active: false, starting: true, internalName: data.name });
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
- 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);
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
- // ⌘⏎ 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);
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
- + New
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 ⌥↑/⌥↓ to move the selected one"}
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={() => handleTap(sheetSession)}>Switch to</button>
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
  };