hop2 0.11.38 → 0.11.40

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.
@@ -5,7 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <!-- Terminal prompt chevron on a dark tile — reads "terminal app" at 16px. -->
7
7
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2317181d'/%3E%3Cpath d='M11 9.5 L20.5 16 L11 22.5' fill='none' stroke='%23a78bfa' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
8
- <title>Hay</title>
8
+ <title>hop</title>
9
+ <meta name="apple-mobile-web-app-title" content="hop" />
9
10
  </head>
10
11
  <body>
11
12
  <div id="root"></div>
@@ -14,6 +14,7 @@ import { createOptimisticEcho, hasActiveOtherTypist } from "./utils/optimisticEc
14
14
  import { attachScrollFlywheel } from "./utils/scrollFlywheel";
15
15
  import { collectTerminalMatches, selectTerminalMatch } from "./utils/terminalSearch";
16
16
  import { createVoiceHold, speechRecognitionCtor } from "./utils/voiceHold";
17
+ import { tabTitle } from "./utils/tabTitle";
17
18
  import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
18
19
  import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
19
20
  import { MobileKeyboard } from "./components/MobileKeyboard";
@@ -897,7 +898,14 @@ const App = () => {
897
898
  return;
898
899
  }
899
900
  try {
900
- const notification = new Notification(title, { body: "Terminal bell" });
901
+ // silent: the attention is the notification itself — the dot in the
902
+ // tab title, the card's badge, the banner. macOS played the system
903
+ // alert sound for every bell on top of that, and a fleet of agents
904
+ // ringing for attention turned the laptop into a pinball machine.
905
+ // tag: a session that rings twice replaces its banner instead of
906
+ // stacking a second one (Safari ignores both options; its sound
907
+ // follows System Settings › Notifications).
908
+ const notification = new Notification(title, { body: "Terminal bell", silent: true, tag: `hop-bell-${title}` });
901
909
  notification.onclick = () => {
902
910
  window.focus();
903
911
  onClick?.();
@@ -3287,13 +3295,15 @@ const App = () => {
3287
3295
  return { bell, output };
3288
3296
  }, [sessions, session?.room]);
3289
3297
 
3290
- // Tab title: session name, with a dot when the attached session rang a bell
3291
- // while hidden or another session has an unseen bell.
3298
+ // Tab title: "<session> · hop", or "hop" on the hub, with a dot when the
3299
+ // attached session rang a bell while hidden or another session has an
3300
+ // unseen bell. Keyed on `session`, not the label alone: with no session
3301
+ // open the label still holds the placeholder id minted for a would-be new
3302
+ // room, which is not a name.
3292
3303
  useEffect(() => {
3293
- const base = sessionLabel || session?.room || "hop";
3294
3304
  const alert = titleAlert || otherAttention.bell;
3295
- document.title = `${alert ? "● " : ""}${base}`;
3296
- }, [titleAlert, otherAttention.bell, sessionLabel, session?.room]);
3305
+ document.title = tabTitle(session ? (sessionLabel || session.room) : null, alert);
3306
+ }, [titleAlert, otherAttention.bell, sessionLabel, session]);
3297
3307
 
3298
3308
  // Notify for bells in other sessions: the 5s poll flips bellUnseen false→true
3299
3309
  // when a session rings while unwatched. The seq map dedupes so a session that
@@ -4530,7 +4540,15 @@ const App = () => {
4530
4540
  e.preventDefault();
4531
4541
  dragDepthRef.current = 0;
4532
4542
  setDropActive(false);
4543
+ // iOS Safari delivers a Files-app drop through items,
4544
+ // with `files` empty; desktop browsers fill both.
4533
4545
  const files = Array.from(e.dataTransfer?.files || []);
4546
+ if (files.length === 0) {
4547
+ for (const item of Array.from(e.dataTransfer?.items || [])) {
4548
+ const f = item.kind === "file" ? item.getAsFile() : null;
4549
+ if (f) files.push(f);
4550
+ }
4551
+ }
4534
4552
  if (files.length > 0) {
4535
4553
  void uploadDroppedFiles(files);
4536
4554
  return;
@@ -0,0 +1,128 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { KeyboardEvent } from "react";
3
+
4
+ // Working-directory input for the create form, with directory completion.
5
+ //
6
+ // Completion comes from the daemon (/api/fs/complete): the browser cannot
7
+ // see the filesystem, and the session is going to start wherever the DAEMON
8
+ // resolves the path, so the daemon is the only honest source of what exists.
9
+ // The field keeps the text as typed — ~ included — and the create API expands
10
+ // it; showing absolute paths here would make every suggestion a screenful.
11
+ //
12
+ // With nothing typed, the directories of recent sessions are offered first:
13
+ // a new session usually joins a project that already has one.
14
+
15
+ type Entry = { name: string; path: string };
16
+
17
+ const shorten = (p: string, home: string) =>
18
+ home && (p === home || p.startsWith(home + "/")) ? "~" + p.slice(home.length) : p;
19
+
20
+ export const CwdField = ({ value, onChange, recent, id }: {
21
+ value: string;
22
+ onChange: (next: string) => void;
23
+ recent: string[];
24
+ id?: string;
25
+ }) => {
26
+ const [open, setOpen] = useState(false);
27
+ const [items, setItems] = useState<Array<{ label: string; fill: string }>>([]);
28
+ const [active, setActive] = useState(-1);
29
+ const [source, setSource] = useState<"recent" | "fs">("fs");
30
+ const seq = useRef(0);
31
+ const timer = useRef<number | null>(null);
32
+
33
+ const showRecent = () => {
34
+ setSource("recent");
35
+ setItems(recent.map((p) => ({ label: p, fill: p })));
36
+ setActive(-1);
37
+ setOpen(recent.length > 0);
38
+ };
39
+
40
+ const complete = (q: string) => {
41
+ const mine = ++seq.current;
42
+ fetch(`/api/fs/complete?q=${encodeURIComponent(q)}`)
43
+ .then((r) => (r.ok ? r.json() : null))
44
+ .then((data: { home?: string; entries?: Entry[] } | null) => {
45
+ if (mine !== seq.current) return; // a newer keystroke superseded this
46
+ const home = data?.home || "";
47
+ const list = (data?.entries || []).map((e) => {
48
+ const short = shorten(e.path, home);
49
+ return { label: short, fill: short + "/" };
50
+ });
51
+ setSource("fs");
52
+ setItems(list);
53
+ setActive(list.length ? 0 : -1);
54
+ setOpen(list.length > 0);
55
+ })
56
+ .catch(() => { /* completion is a convenience; the field still works */ });
57
+ };
58
+
59
+ useEffect(() => () => { if (timer.current) window.clearTimeout(timer.current); }, []);
60
+
61
+ const onInput = (next: string) => {
62
+ onChange(next);
63
+ if (timer.current) window.clearTimeout(timer.current);
64
+ if (!next.trim()) { showRecent(); return; }
65
+ timer.current = window.setTimeout(() => complete(next), 120);
66
+ };
67
+
68
+ const accept = (i: number) => {
69
+ const it = items[i];
70
+ if (!it) return;
71
+ onChange(it.fill);
72
+ if (source === "recent") { setOpen(false); return; }
73
+ // Descend: completing a directory immediately lists what is inside it,
74
+ // so a path is built one segment per Tab, like a shell.
75
+ complete(it.fill);
76
+ };
77
+
78
+ const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
79
+ if (!open) return;
80
+ if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(items.length - 1, a + 1)); return; }
81
+ if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(0, a - 1)); return; }
82
+ if (e.key === "Tab" || e.key === "ArrowRight") {
83
+ if (active >= 0) { e.preventDefault(); accept(active); }
84
+ return;
85
+ }
86
+ if (e.key === "Enter" && active >= 0) { e.preventDefault(); accept(active); return; }
87
+ if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setOpen(false); }
88
+ };
89
+
90
+ return (
91
+ <div className="switcher-create-cwd">
92
+ <input
93
+ id={id}
94
+ value={value}
95
+ placeholder="~ (working directory)"
96
+ aria-label="Working directory"
97
+ aria-autocomplete="list"
98
+ aria-expanded={open}
99
+ autoComplete="off"
100
+ spellCheck={false}
101
+ onChange={(e) => onInput(e.target.value)}
102
+ onFocus={() => { if (!value.trim()) showRecent(); else if (items.length) setOpen(true); }}
103
+ onBlur={() => setOpen(false)}
104
+ onKeyDown={onKey}
105
+ />
106
+ {open && items.length > 0 && (
107
+ <div className="cwd-suggest" role="listbox" aria-label={source === "recent" ? "Recent directories" : "Directories"}>
108
+ {source === "recent" && <div className="cwd-suggest-label">Recent</div>}
109
+ {items.map((it, i) => (
110
+ <button
111
+ key={it.fill}
112
+ type="button"
113
+ role="option"
114
+ aria-selected={i === active}
115
+ className={i === active ? "on" : ""}
116
+ // mousedown, not click: the input's blur (which closes the
117
+ // list) fires between the two, and the click would land on nothing.
118
+ onMouseDown={(e) => { e.preventDefault(); accept(i); }}
119
+ onMouseEnter={() => setActive(i)}
120
+ >
121
+ {it.label}
122
+ </button>
123
+ ))}
124
+ </div>
125
+ )}
126
+ </div>
127
+ );
128
+ };
@@ -14,6 +14,8 @@ import { Terminal } from "@xterm/xterm";
14
14
  import { FitAddon } from "@xterm/addon-fit";
15
15
  import { attachScrollFlywheel } from "../utils/scrollFlywheel";
16
16
  import { ContextMenu, type MenuRequest } from "./ContextMenu";
17
+ import { CwdField } from "./CwdField";
18
+ import { pasteableUploadPaths } from "../utils/fileDrop";
17
19
  import { collectTerminalMatches, selectTerminalMatch } from "../utils/terminalSearch";
18
20
  import { createVoiceHold, isClaudeSurface } from "../utils/voiceHold";
19
21
  import {
@@ -111,7 +113,7 @@ type Props = {
111
113
 
112
114
  type Sheet = {
113
115
  session: SwitcherSession;
114
- mode: "menu" | "rename";
116
+ mode: "menu" | "rename" | "folder";
115
117
  // Viewport point the menu anchors to — the ... button or the long-press
116
118
  // finger position. A bottom sheet made the thumb travel the whole screen
117
119
  // for actions about the element it was already touching.
@@ -347,7 +349,9 @@ const runningApp = (s: SwitcherSession) => {
347
349
  // responsiveness and whether keystrokes are accepted. The old design — an
348
350
  // HTML preview swapped for a fresh xterm + socket behind a veil — is gone.
349
351
  const LIVETILE_POLL_MS = 5000;
350
- const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice }: {
352
+ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice, onSender }: {
353
+ /** The wall keeps a registry of live tiles' input senders (file drops). */
354
+ onSender?: (send: ((data: string) => void) | null) => void;
351
355
  wsBase: string; room: string; userName: string; theme: object | undefined;
352
356
  live: boolean; claudeApp: boolean; claimSize: boolean; activeCols?: number; activeRows?: number;
353
357
  onFullscreen: () => void; onUnfocus: () => void; onNotice: (m: string) => void;
@@ -961,6 +965,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
961
965
  };
962
966
  };
963
967
  connect();
968
+ // Registered below, once the sender exists.
964
969
  sendInputRef.current = (data: string) => {
965
970
  // A keystroke INTO this tile is a genuine user action on it — per the
966
971
  // size-ownership model, it should take the size and fit the tile it
@@ -998,6 +1003,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
998
1003
  }
999
1004
  ws.send(JSON.stringify({ type: "input", data }));
1000
1005
  };
1006
+ onSender?.(sendInputRef.current);
1001
1007
  const hiddenReconnect = { hiddenAt: 0 };
1002
1008
  const onVisibility = () => {
1003
1009
  if (document.hidden) { hiddenReconnect.hiddenAt = Date.now(); return; }
@@ -1031,6 +1037,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
1031
1037
  window.removeEventListener("focus", onWindowFocus);
1032
1038
  try { ws?.close(); } catch { /* closing */ }
1033
1039
  sendInputRef.current = null;
1040
+ onSender?.(null);
1034
1041
  term.options.disableStdin = true;
1035
1042
  term.options.cursorBlink = false;
1036
1043
  window.clearTimeout(downChromeTimer);
@@ -1298,6 +1305,55 @@ export const SessionSwitcher = ({
1298
1305
  };
1299
1306
  const [dragKey, setDragKey] = useState<string | null>(null);
1300
1307
  const [dropFolder, setDropFolder] = useState<string | null>(null);
1308
+ // A file dragged over a card: that card is the drop target (upload).
1309
+ const [fileDropKey, setFileDropKey] = useState<string | null>(null);
1310
+ const tileSendersRef = useRef(new Map<string, (data: string) => void>());
1311
+ const isFileDrag = (dt: DataTransfer | null) =>
1312
+ !!dt && (Array.from(dt.types || []).includes("Files") || Array.from(dt.items || []).some((i) => i.kind === "file"));
1313
+ // iOS Safari delivers a Files-app drop through items, with `files` empty;
1314
+ // desktop browsers fill both.
1315
+ const filesFrom = (dt: DataTransfer | null): File[] => {
1316
+ const files = Array.from(dt?.files || []);
1317
+ if (files.length === 0) {
1318
+ for (const item of Array.from(dt?.items || [])) {
1319
+ const f = item.kind === "file" ? item.getAsFile() : null;
1320
+ if (f) files.push(f);
1321
+ }
1322
+ }
1323
+ return files;
1324
+ };
1325
+ // Drop on a card = upload to THAT session, same endpoint the full-screen
1326
+ // terminal uses. The card's reorder handlers used to preventDefault a file
1327
+ // drop and do nothing with it — on a phone, where the wall is the whole
1328
+ // UI, that was "drag and drop does nothing". A live tile gets the paths
1329
+ // typed into it; anything else gets told where the file landed.
1330
+ const uploadFilesTo = async (s: SwitcherSession, files: File[]) => {
1331
+ if (files.length === 0) return;
1332
+ const key = sessionKey(s);
1333
+ onNotice(files.length === 1 ? `Uploading ${files[0].name} to ${s.displayName}…` : `Uploading ${files.length} files to ${s.displayName}…`);
1334
+ const landed: string[] = [];
1335
+ for (const file of files) {
1336
+ try {
1337
+ const res = await fetch(`/api/sessions/upload?name=${encodeURIComponent(key)}&filename=${encodeURIComponent(file.name)}`, { method: "POST", body: file });
1338
+ const data = await res.json().catch(() => null) as { path?: string; error?: string } | null;
1339
+ if (!res.ok) { onNotice(data?.error || `Upload failed (${res.status})`); return; }
1340
+ if (data?.path) landed.push(String(data.path));
1341
+ } catch {
1342
+ onNotice("Upload failed");
1343
+ return;
1344
+ }
1345
+ }
1346
+ if (landed.length === 0) return;
1347
+ const paste = pasteableUploadPaths(landed);
1348
+ const send = tileSendersRef.current.get(key);
1349
+ if (send) {
1350
+ send(paste);
1351
+ onNotice(landed.length === 1 ? `Uploaded to ${landed[0]}` : `Uploaded ${landed.length} files — paths pasted`);
1352
+ } else {
1353
+ try { await navigator.clipboard?.writeText(paste); } catch { /* no clipboard: the notice still names the path */ }
1354
+ onNotice(`Uploaded to ${landed.join(", ")} — path copied; open ${s.displayName} to paste it`);
1355
+ }
1356
+ };
1301
1357
  const [menu, setMenu] = useState<MenuRequest>(null);
1302
1358
 
1303
1359
  // The wall's own background: the actions that create things, plus the view
@@ -1361,10 +1417,31 @@ export const SessionSwitcher = ({
1361
1417
  return false;
1362
1418
  }
1363
1419
  };
1420
+ // Returns the created folder (the daemon mints the id) so "New folder…"
1421
+ // from a session's menu can file the session into it in the same gesture.
1422
+ const createFolderNamed = async (name: string): Promise<SwitcherFolder | null> => {
1423
+ try {
1424
+ const res = await fetch("/api/folders", {
1425
+ method: "POST",
1426
+ headers: { "Content-Type": "application/json" },
1427
+ body: JSON.stringify({ name })
1428
+ });
1429
+ const data = await res.json().catch(() => ({} as { error?: string; folder?: SwitcherFolder }));
1430
+ if (!res.ok || !data.folder?.id) {
1431
+ onNotice(data.error || "Could not create folder");
1432
+ return null;
1433
+ }
1434
+ onRefresh();
1435
+ return { id: data.folder.id, name: data.folder.name || name };
1436
+ } catch {
1437
+ onNotice("Could not create folder");
1438
+ return null;
1439
+ }
1440
+ };
1364
1441
  const createFolder = async () => {
1365
1442
  const name = window.prompt("Folder name")?.trim();
1366
1443
  if (!name) return;
1367
- if (await folderApi("/api/folders", { name }, "Could not create folder")) onNotice(`Created ${name}`);
1444
+ if (await createFolderNamed(name)) onNotice(`Created ${name}`);
1368
1445
  };
1369
1446
  const renameFolder = async (folder: SwitcherFolder) => {
1370
1447
  const name = window.prompt("Rename folder", folder.name)?.trim();
@@ -1467,6 +1544,19 @@ export const SessionSwitcher = ({
1467
1544
  const [renameDraft, setRenameDraft] = useState("");
1468
1545
  const [creating, setCreating] = useState(false);
1469
1546
  const [createDraft, setCreateDraft] = useState("");
1547
+ // Where the new session starts. Remembered across creates: several
1548
+ // sessions in a row usually belong to the same project.
1549
+ const [createCwd, setCreateCwd] = useState(() => {
1550
+ try { return localStorage.getItem("hay_create_cwd") || ""; } catch { return ""; }
1551
+ });
1552
+ const recentCwds = useMemo(() => {
1553
+ const seen = new Set<string>();
1554
+ return [...sessions]
1555
+ .sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0))
1556
+ .map((s) => (s.cwd || "").replace(/^\/Users\/[^/]+(?=\/|$)/, "~"))
1557
+ .filter((c) => c && !seen.has(c) && seen.add(c))
1558
+ .slice(0, 8);
1559
+ }, [sessions]);
1470
1560
  // What a new session boots into: a plain shell, or straight into an agent.
1471
1561
  // Maps to the create API's `startup` command.
1472
1562
  const [createType, setCreateType] = useState<"terminal" | "claude" | "codex">("terminal");
@@ -2209,6 +2299,38 @@ export const SessionSwitcher = ({
2209
2299
  });
2210
2300
  };
2211
2301
 
2302
+ // Fork (same tool) or hand off (target names the other tool). One call
2303
+ // for both so the two can never disagree about the endpoint.
2304
+ const forkSession = async (s: SwitcherSession, target?: "claude" | "codex") => {
2305
+ setSheet(null);
2306
+ onNotice(target ? `Handing off to ${target === "codex" ? "Codex" : "Claude"}…` : "Forking…");
2307
+ try {
2308
+ const res = await fetch("/api/sessions/fork", {
2309
+ method: "POST",
2310
+ headers: { "Content-Type": "application/json" },
2311
+ body: JSON.stringify({ internalName: sessionKey(s), ...(target ? { target } : {}) })
2312
+ });
2313
+ const data = await res.json().catch(() => ({} as { error?: string; name?: string; kind?: string; extract?: { turns?: number; bytes?: number } }));
2314
+ if (!res.ok) {
2315
+ onNotice(data.error || (target ? "Handoff failed" : "Fork failed"));
2316
+ return;
2317
+ }
2318
+ if (data.kind === "handoff") {
2319
+ const kb = Math.max(1, Math.round((data.extract?.bytes || 0) / 1024));
2320
+ onNotice(`${data.name} — ${data.extract?.turns ?? "?"} turns handed off (${kb} KB); it reads them first`);
2321
+ } else {
2322
+ onNotice(
2323
+ data.kind === "claude" ? `Forked conversation → ${data.name}`
2324
+ : data.kind === "codex" ? `${data.name} opened — resuming the conversation`
2325
+ : `Forked → ${data.name} (new shell, same directory)`
2326
+ );
2327
+ }
2328
+ onRefresh();
2329
+ } catch {
2330
+ onNotice(target ? "Handoff failed" : "Fork failed");
2331
+ }
2332
+ };
2333
+
2212
2334
  const submitRename = async (event: FormEvent) => {
2213
2335
  event.preventDefault();
2214
2336
  if (!sheet) return;
@@ -2383,7 +2505,7 @@ export const SessionSwitcher = ({
2383
2505
  const res = await fetch("/api/sessions", {
2384
2506
  method: "POST",
2385
2507
  headers: { "Content-Type": "application/json" },
2386
- body: JSON.stringify({ name: next, type: "terminal", port: null, startup: startupFor(createType) })
2508
+ body: JSON.stringify({ name: next, type: "terminal", port: null, startup: startupFor(createType), cwd: createCwd.trim() || undefined })
2387
2509
  });
2388
2510
  const data = await res.json().catch(() => ({} as { name?: string; displayName?: string; internalName?: string; error?: string }));
2389
2511
  if (!res.ok || !data.name) {
@@ -2443,6 +2565,7 @@ export const SessionSwitcher = ({
2443
2565
  }
2444
2566
  setCreating(false);
2445
2567
  setCreateDraft("");
2568
+ try { localStorage.setItem("hay_create_cwd", createCwd.trim()); } catch { /* per-device convenience only */ }
2446
2569
  if (createInFolder) {
2447
2570
  // File it first: the move endpoint is the one place membership is
2448
2571
  // persisted durably.
@@ -2552,16 +2675,37 @@ export const SessionSwitcher = ({
2552
2675
  tabIndex={0}
2553
2676
  data-nav-index={navIndexByKey.get(key)}
2554
2677
  data-session-key={key}
2555
- className={`switcher-card${current ? " current" : ""}${activeNow ? " active-now" : ""}${kbdSelected ? " kbd-selected" : ""}${focusedKey === key ? " focused" : ""}${dragKey === key ? " dragging" : ""}${draggable ? " draggable" : ""}`}
2678
+ className={`switcher-card${current ? " current" : ""}${activeNow ? " active-now" : ""}${kbdSelected ? " kbd-selected" : ""}${focusedKey === key ? " focused" : ""}${dragKey === key ? " dragging" : ""}${draggable ? " draggable" : ""}${fileDropKey === key ? " file-drop" : ""}`}
2556
2679
  draggable={draggable}
2557
2680
  onDragStart={draggable ? (e) => { setDragKey(key); e.dataTransfer.effectAllowed = "move"; } : undefined}
2558
2681
  onDragEnter={draggable ? () => { if (dragKey && dragKey !== key) moveManual(dragKey, key); } : undefined}
2559
- onDragOver={draggable ? (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; } : undefined}
2682
+ onDragOver={(e) => {
2683
+ if (isFileDrag(e.dataTransfer)) {
2684
+ e.preventDefault();
2685
+ e.dataTransfer.dropEffect = "copy";
2686
+ if (fileDropKey !== key) setFileDropKey(key);
2687
+ return;
2688
+ }
2689
+ if (draggable) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }
2690
+ }}
2691
+ onDragLeave={(e) => {
2692
+ const to = e.relatedTarget;
2693
+ if (fileDropKey === key && !(to instanceof Node && e.currentTarget.contains(to))) setFileDropKey(null);
2694
+ }}
2560
2695
  // Order was already applied live during the drag — drop/end just clean up.
2561
2696
  // The release click after a drag must NEVER act — reordering ending
2562
2697
  // with "you are now inside a terminal" is a drag that went off a
2563
2698
  // cliff. Same suppression the long-press sheet uses.
2564
- onDrop={draggable ? (e) => { e.preventDefault(); suppressTapRef.current = true; setDragKey(null); } : undefined}
2699
+ onDrop={(e) => {
2700
+ if (isFileDrag(e.dataTransfer)) {
2701
+ e.preventDefault();
2702
+ setFileDropKey(null);
2703
+ suppressTapRef.current = true;
2704
+ void uploadFilesTo(s, filesFrom(e.dataTransfer));
2705
+ return;
2706
+ }
2707
+ if (draggable) { e.preventDefault(); suppressTapRef.current = true; setDragKey(null); }
2708
+ }}
2565
2709
  onDragEnd={draggable ? () => { suppressTapRef.current = true; setDragKey(null); window.setTimeout(() => { suppressTapRef.current = false; }, 250); } : undefined}
2566
2710
  onClick={(e) => {
2567
2711
  // Post-drag / post-long-press release: not a click.
@@ -2662,6 +2806,7 @@ export const SessionSwitcher = ({
2662
2806
  onFullscreen={() => onSwitch(s)}
2663
2807
  onUnfocus={() => setFocusedKey(null)}
2664
2808
  onNotice={onNotice}
2809
+ onSender={(fn) => { if (fn) tileSendersRef.current.set(key, fn); else tileSendersRef.current.delete(key); }}
2665
2810
  />
2666
2811
  </div>
2667
2812
  ) : (
@@ -2948,6 +3093,7 @@ export const SessionSwitcher = ({
2948
3093
  {createTypePicker()}
2949
3094
  <button type="submit">Create</button>
2950
3095
  <button type="button" onClick={() => { setCreating(false); setCreateDraft(""); setCreateInFolder(null); }}>✕</button>
3096
+ <CwdField value={createCwd} onChange={setCreateCwd} recent={recentCwds} />
2951
3097
  </form>
2952
3098
  )}
2953
3099
  </div>
@@ -3050,6 +3196,7 @@ export const SessionSwitcher = ({
3050
3196
  {createTypePicker()}
3051
3197
  <button type="submit">Create</button>
3052
3198
  <button type="button" onClick={() => { setCreating(false); setCreateDraft(""); setCreateInFolder(null); }}>✕</button>
3199
+ <CwdField value={createCwd} onChange={setCreateCwd} recent={recentCwds} />
3053
3200
  </form>
3054
3201
  )}
3055
3202
  {rows.length === 0 ? (
@@ -3149,7 +3296,9 @@ export const SessionSwitcher = ({
3149
3296
  aria-label={`Actions for ${sheetSession.displayName}`}
3150
3297
  style={(() => {
3151
3298
  const width = 264;
3152
- const estH = sheet.mode === "rename" ? 150 : 330;
3299
+ const estH = sheet.mode === "rename" ? 150
3300
+ : sheet.mode === "folder" ? 100 + 34 * (folders.length + 3)
3301
+ : 330;
3153
3302
  const left = Math.min(Math.max(8, sheet.anchor.x - width), window.innerWidth - width - 8);
3154
3303
  let top = sheet.anchor.y + 8;
3155
3304
  if (top + estH > window.innerHeight - 8) top = Math.max(8, sheet.anchor.y - estH - 8);
@@ -3176,40 +3325,77 @@ export const SessionSwitcher = ({
3176
3325
  <button type="submit">Save</button>
3177
3326
  <button type="button" onClick={() => setSheet({ session: sheetSession, mode: "menu", anchor: sheet.anchor })}>✕</button>
3178
3327
  </form>
3328
+ ) : sheet.mode === "folder" ? (
3329
+ // Filing from the menu: drag-and-drop needs a pointer, a free
3330
+ // hand, and the folder on screen at the same time as the card —
3331
+ // none of which a phone or a long wall reliably has. Same
3332
+ // /api/sessions/move the drop uses, so the two never disagree.
3333
+ <>
3334
+ {(() => {
3335
+ const key = sessionKey(sheetSession);
3336
+ const current = sheetSession.folderId || null;
3337
+ const file = async (folderId: string | null, label: string) => {
3338
+ setSheet(null);
3339
+ if (folderId === current) return;
3340
+ if (await folderApi("/api/sessions/move", { internalName: key, folderId }, "Could not move session")) {
3341
+ // Folders only render in the Manual layout; filing from
3342
+ // Recent or Project would otherwise look like a no-op.
3343
+ const where = sortMode === "manual" ? "" : " — folders show in the Manual layout";
3344
+ onNotice(folderId ? `Filed under ${label}${where}` : "Moved out of its folder");
3345
+ }
3346
+ };
3347
+ return (
3348
+ <>
3349
+ <button type="button" className={current === null ? "on" : ""} onClick={() => { void file(null, ""); }}>
3350
+ No folder
3351
+ </button>
3352
+ {folders.map((f) => (
3353
+ <button key={f.id} type="button" className={current === f.id ? "on" : ""} onClick={() => { void file(f.id, f.name); }}>
3354
+ {f.name}
3355
+ </button>
3356
+ ))}
3357
+ <div className="switcher-sheet-sep" aria-hidden="true" />
3358
+ <button
3359
+ type="button"
3360
+ onClick={async () => {
3361
+ const name = window.prompt("Folder name")?.trim();
3362
+ if (!name) return;
3363
+ const created = await createFolderNamed(name);
3364
+ if (created) await file(created.id, created.name);
3365
+ }}
3366
+ >
3367
+ New folder…
3368
+ </button>
3369
+ <button type="button" className="back" onClick={() => setSheet({ session: sheetSession, mode: "menu", anchor: sheet.anchor })}>
3370
+ ← Back
3371
+ </button>
3372
+ </>
3373
+ );
3374
+ })()}
3375
+ </>
3179
3376
  ) : (
3180
3377
  <>
3181
3378
  {!isCurrentSession(sheetSession) && (
3182
3379
  <button type="button" onClick={() => { setSheet(null); onSwitch(sheetSession); }}>Open full screen</button>
3183
3380
  )}
3184
- <button
3185
- type="button"
3186
- onClick={async () => {
3187
- setSheet(null);
3188
- onNotice("Forking…");
3189
- try {
3190
- const res = await fetch("/api/sessions/fork", {
3191
- method: "POST",
3192
- headers: { "Content-Type": "application/json" },
3193
- body: JSON.stringify({ internalName: sessionKey(sheetSession) })
3194
- });
3195
- const data = await res.json().catch(() => ({} as { error?: string; name?: string; kind?: string }));
3196
- if (!res.ok) {
3197
- onNotice(data.error || "Fork failed");
3198
- return;
3199
- }
3200
- onNotice(
3201
- data.kind === "claude" ? `Forked conversation → ${data.name}`
3202
- : data.kind === "codex" ? `${data.name} opened — pick the conversation to resume`
3203
- : `Forked → ${data.name} (new shell, same directory)`
3204
- );
3205
- onRefresh();
3206
- } catch {
3207
- onNotice("Fork failed");
3208
- }
3209
- }}
3210
- >
3381
+ <button type="button" onClick={() => { void forkSession(sheetSession); }}>
3211
3382
  Fork session
3212
3383
  </button>
3384
+ {/* Crossing tools: the conversation is extracted to a document
3385
+ the new agent reads first (context handoff, not a resume).
3386
+ Offered for every terminal — the daemon knows what a
3387
+ restored session runs better than the process name here —
3388
+ minus the tool this one is visibly running already. */}
3389
+ {sheetSession.type !== "port" && appLabel(sheetSession) !== "codex" && (
3390
+ <button type="button" onClick={() => { void forkSession(sheetSession, "codex"); }}>
3391
+ Continue in Codex…
3392
+ </button>
3393
+ )}
3394
+ {sheetSession.type !== "port" && appLabel(sheetSession) !== "claude" && (
3395
+ <button type="button" onClick={() => { void forkSession(sheetSession, "claude"); }}>
3396
+ Continue in Claude…
3397
+ </button>
3398
+ )}
3213
3399
  <button
3214
3400
  type="button"
3215
3401
  onClick={() => {
@@ -3219,6 +3405,12 @@ export const SessionSwitcher = ({
3219
3405
  >
3220
3406
  Rename
3221
3407
  </button>
3408
+ <button
3409
+ type="button"
3410
+ onClick={() => setSheet({ session: sheetSession, mode: "folder", anchor: sheet.anchor })}
3411
+ >
3412
+ Move to folder…
3413
+ </button>
3222
3414
  {sheetSession.type !== "port" && <div className="switcher-sheet-sep" aria-hidden="true" />}
3223
3415
  {sheetSession.type !== "port" && (
3224
3416
  <button type="button" onClick={toggleAgentAccess}>