hop2 0.11.39 → 0.11.41
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/hay/apps/web/index.html +2 -1
- package/hay/apps/web/src/App.tsx +24 -6
- package/hay/apps/web/src/components/CwdField.tsx +128 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +227 -35
- package/hay/apps/web/src/styles.css +78 -1
- package/hay/apps/web/src/utils/tabTitle.ts +15 -0
- package/hay/apps/web/src/utils/voiceHold.ts +53 -12
- package/hay/apps/web/test/SessionSwitcher.test.tsx +138 -0
- package/hay/apps/web/test/tabTitle.test.ts +16 -0
- package/hay/apps/web/test/voiceHold.test.ts +46 -0
- package/hop +182 -16
- package/package.json +1 -1
- package/scripts/trajectory-extract.js +234 -0
- package/tests/trajectory-extract.unit.test.cjs +72 -0
package/hay/apps/web/index.html
CHANGED
|
@@ -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>
|
|
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>
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
3291
|
-
// while hidden or another session has an
|
|
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 =
|
|
3296
|
-
}, [titleAlert, otherAttention.bell, sessionLabel, session
|
|
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
|
|
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={
|
|
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={
|
|
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
|
|
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}>
|