hop2 0.11.12 → 0.11.14
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/README.md +31 -0
- package/hay/apps/server/src/rooms.ts +20 -0
- package/hay/apps/server/test/rooms.test.ts +54 -0
- package/hay/apps/web/src/App.tsx +121 -12
- package/hay/apps/web/src/components/SessionSwitcher.tsx +102 -40
- package/hay/apps/web/src/components/ViewsPanel.tsx +243 -0
- package/hay/apps/web/src/styles.css +109 -0
- package/hay/apps/web/src/utils/fileDrop.ts +19 -3
- package/hay/apps/web/src/utils/switcherModel.ts +12 -0
- package/hay/apps/web/test/SessionSwitcher.test.tsx +31 -0
- package/hay/apps/web/test/ViewsPanel.test.tsx +118 -0
- package/hay/apps/web/test/fileDrop.test.ts +25 -1
- package/hay-web/assets/digest-archive.json +1024 -292
- package/hay-web/assets/index-Bcl-2CbE.js +133 -0
- package/hay-web/assets/index-Bcl-2CbE.js.map +1 -0
- package/hay-web/assets/{index-DHotLq-D.css → index-DKWcSY-E.css} +1 -1
- package/hay-web/index.html +2 -2
- package/hop +843 -118
- package/package.json +1 -1
- package/scripts/hay-host.js +25 -0
- package/skills/hop-session/SKILL.md +32 -11
- package/tests/terminal-api.integration.test.cjs +130 -0
- package/hay-web/assets/index-D4EXRUpb.css +0 -1
- package/hay-web/assets/index-DzlPn2In.js +0 -133
- package/hay-web/assets/index-DzlPn2In.js.map +0 -1
package/README.md
CHANGED
|
@@ -240,6 +240,37 @@ Agents working inside hop terminals can render formulas for the humans watching
|
|
|
240
240
|
|
|
241
241
|
`hop math` renders a **2D Unicode layout** — fractions, roots, sums/limits, sub/superscripts, Greek + operators, and accents — with no dependencies. Inline-image rendering (Kitty/iTerm) is deferred until graphics support lands in the web client (xterm.js): the real payoff is math that renders *inside* a shared hop session, which the re-rendering clients can't show yet, so until then it's Unicode everywhere.
|
|
242
242
|
|
|
243
|
+
### Show something a terminal cannot draw (`hop view`, `hop port`)
|
|
244
|
+
|
|
245
|
+
A terminal can only print text, which makes it the wrong surface for the
|
|
246
|
+
thing you most often want back from a long-running job: a plot, a report, a
|
|
247
|
+
rendered write-up. `hop view` publishes a file behind hop's auth and prints a
|
|
248
|
+
link — the web client and the phone app both open it in place, with the
|
|
249
|
+
session's cookie, so nothing has to be public.
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
hop view --title "ROC curve: new model vs baseline" results/roc_curve.png
|
|
253
|
+
hop view --title "Q3 analysis" report.html analysis.pdf
|
|
254
|
+
generate_summary | hop view --name findings.md --title "What the profiler found" -
|
|
255
|
+
hop view --list # what this session published
|
|
256
|
+
hop view --rm x.png # unpublish
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
HTML, PDF, images, video and markdown all render (markdown is converted at
|
|
260
|
+
serve time — headings, tables, fenced code). `--title` is what a human sees
|
|
261
|
+
in the session's Views list, so it is worth writing properly. Published files
|
|
262
|
+
are grouped **by session**: a result belongs to the conversation that
|
|
263
|
+
produced it.
|
|
264
|
+
|
|
265
|
+
For something live rather than a file, `hop port 5173` proxies a local web
|
|
266
|
+
server through the same auth (WebSocket-safe, so dev-server hot reload
|
|
267
|
+
works), attached to the current session — `--standalone` makes it a room of
|
|
268
|
+
its own instead.
|
|
269
|
+
|
|
270
|
+
Agents do this for you when the bundled skill is installed (`hop agent-skill
|
|
271
|
+
install`): the convention is to hand over a titled view rather than describe
|
|
272
|
+
a result in prose.
|
|
273
|
+
|
|
243
274
|
## Operations
|
|
244
275
|
|
|
245
276
|
### Logging and History
|
|
@@ -24,6 +24,11 @@ export type ClientInfo = {
|
|
|
24
24
|
// switcher wall): excluded from presence and client counts, and given a
|
|
25
25
|
// small snapshot so a wall of tiles doesn't pull megabytes per open.
|
|
26
26
|
source?: string;
|
|
27
|
+
// Stable per-browser-tab identity (device id + tab id). A tab holds one
|
|
28
|
+
// live connection per room, so a new attach bearing the same key means the
|
|
29
|
+
// previous connection is dead even if its socket never closed — it is
|
|
30
|
+
// evicted on the spot instead of haunting presence until TCP notices.
|
|
31
|
+
deviceKey?: string;
|
|
27
32
|
colorIndex: number;
|
|
28
33
|
cols: number;
|
|
29
34
|
rows: number;
|
|
@@ -182,6 +187,7 @@ class ClientState {
|
|
|
182
187
|
lastActive = now();
|
|
183
188
|
socket: SocketAdapter;
|
|
184
189
|
source: string;
|
|
190
|
+
deviceKey: string;
|
|
185
191
|
// Declared fit: the size this client's viewport wants, updated by every
|
|
186
192
|
// resize message whether or not it applied. Becomes the PTY size the
|
|
187
193
|
// moment a keystroke makes this client the size owner.
|
|
@@ -192,6 +198,7 @@ class ClientState {
|
|
|
192
198
|
this.id = info.id;
|
|
193
199
|
this.name = info.name;
|
|
194
200
|
this.source = info.source || "";
|
|
201
|
+
this.deviceKey = info.deviceKey || "";
|
|
195
202
|
this.color = pickPresenceColor(info.colorIndex);
|
|
196
203
|
this.socket = socket;
|
|
197
204
|
this.cols = info.cols;
|
|
@@ -361,6 +368,19 @@ export class Room extends EventEmitter {
|
|
|
361
368
|
this.cleanupTimer = null;
|
|
362
369
|
}
|
|
363
370
|
|
|
371
|
+
// Same tab, same room: the newcomer replaces its predecessor. A phone
|
|
372
|
+
// that dropped off the network never sends a close, so its old
|
|
373
|
+
// connection would otherwise sit in presence as a second "iPhone"
|
|
374
|
+
// until TCP gives up on the corpse.
|
|
375
|
+
if (info.deviceKey) {
|
|
376
|
+
for (const [id, peer] of [...this.clients]) {
|
|
377
|
+
if (peer.deviceKey === info.deviceKey && peer.source !== "monitor") {
|
|
378
|
+
try { peer.socket.close(); } catch { /* already dead */ }
|
|
379
|
+
this.removeClient(id);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
364
384
|
const client = new ClientState(info, socket);
|
|
365
385
|
this.clients.set(client.id, client);
|
|
366
386
|
const created = !this.hasHadClient;
|
|
@@ -1004,3 +1004,57 @@ describe("Room", () => {
|
|
|
1004
1004
|
});
|
|
1005
1005
|
});
|
|
1006
1006
|
});
|
|
1007
|
+
|
|
1008
|
+
describe("device-key eviction", () => {
|
|
1009
|
+
// The room actively closes the predecessor's socket; a plain FakeSocket
|
|
1010
|
+
// close is indistinguishable from a client-side close, so count them.
|
|
1011
|
+
class TrackingSocket extends FakeSocket {
|
|
1012
|
+
serverCloses = 0;
|
|
1013
|
+
close() {
|
|
1014
|
+
this.serverCloses += 1;
|
|
1015
|
+
super.close();
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const makeRoom = (name: string) => {
|
|
1020
|
+
const manager = new RoomManager(() => new FakePty() as any);
|
|
1021
|
+
return manager.getRoom(name, { cols: 80, rows: 24 }, "/tmp");
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
it("a same-key attach replaces the predecessor instead of joining it", () => {
|
|
1025
|
+
const room = makeRoom("evict");
|
|
1026
|
+
const ghost = new TrackingSocket();
|
|
1027
|
+
const fresh = new FakeSocket();
|
|
1028
|
+
room.attachClient({ id: "a", name: "iPhone", colorIndex: 0, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, ghost);
|
|
1029
|
+
room.attachClient({ id: "b", name: "iPhone", colorIndex: 1, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, fresh);
|
|
1030
|
+
|
|
1031
|
+
expect(ghost.serverCloses).toBe(1);
|
|
1032
|
+
const presence = findMessages(fresh, "presence").at(-1) as any;
|
|
1033
|
+
expect(presence.clients.map((c: { id: string }) => c.id)).toEqual(["b"]);
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it("different tabs from the same device coexist", () => {
|
|
1037
|
+
const room = makeRoom("tabs");
|
|
1038
|
+
const tabA = new TrackingSocket();
|
|
1039
|
+
const tabB = new FakeSocket();
|
|
1040
|
+
room.attachClient({ id: "a", name: "iPhone", colorIndex: 0, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, tabA);
|
|
1041
|
+
room.attachClient({ id: "b", name: "iPhone", colorIndex: 1, cols: 80, rows: 24, deviceKey: "dev1.tab2" }, tabB);
|
|
1042
|
+
|
|
1043
|
+
expect(tabA.serverCloses).toBe(0);
|
|
1044
|
+
const presence = findMessages(tabB, "presence").at(-1) as any;
|
|
1045
|
+
expect(presence.clients.map((c: { id: string }) => c.id).sort()).toEqual(["a", "b"]);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it("keyless clients (CLI, older bundles) are never evicted", () => {
|
|
1049
|
+
const room = makeRoom("legacy");
|
|
1050
|
+
const legacy = new TrackingSocket();
|
|
1051
|
+
const keyed = new FakeSocket();
|
|
1052
|
+
room.attachClient({ id: "a", name: "cli", colorIndex: 0, cols: 80, rows: 24 }, legacy);
|
|
1053
|
+
room.attachClient({ id: "b", name: "iPhone", colorIndex: 1, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, keyed);
|
|
1054
|
+
room.attachClient({ id: "c", name: "iPhone", colorIndex: 2, cols: 80, rows: 24, deviceKey: "dev1.tab1" }, new FakeSocket());
|
|
1055
|
+
|
|
1056
|
+
expect(legacy.serverCloses).toBe(0);
|
|
1057
|
+
const presence = findMessages(legacy, "presence").at(-1) as any;
|
|
1058
|
+
expect(presence.clients.map((c: { id: string }) => c.id).sort()).toEqual(["a", "c"]);
|
|
1059
|
+
});
|
|
1060
|
+
});
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -15,10 +15,12 @@ import { attachScrollFlywheel } from "./utils/scrollFlywheel";
|
|
|
15
15
|
import { collectTerminalMatches, selectTerminalMatch } from "./utils/terminalSearch";
|
|
16
16
|
import { createVoiceHold, speechRecognitionCtor } from "./utils/voiceHold";
|
|
17
17
|
import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
|
|
18
|
-
import { originalPathHint } from "./utils/fileDrop";
|
|
18
|
+
import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
|
|
19
19
|
import { MobileKeyboard } from "./components/MobileKeyboard";
|
|
20
20
|
import { SessionSwitcher } from "./components/SessionSwitcher";
|
|
21
21
|
import { SecondaryPane } from "./components/SecondaryPane";
|
|
22
|
+
import { ViewsPanel, hasUnseenViews, loadViewsSeen } from "./components/ViewsPanel";
|
|
23
|
+
import type { ViewsSummary } from "./utils/switcherModel";
|
|
22
24
|
|
|
23
25
|
const createRoomId = () => `room-${Math.random().toString(36).slice(2, 7)}`;
|
|
24
26
|
|
|
@@ -260,6 +262,7 @@ type SessionInfo = {
|
|
|
260
262
|
tagline?: string;
|
|
261
263
|
parked?: boolean;
|
|
262
264
|
archived?: boolean;
|
|
265
|
+
views?: ViewsSummary;
|
|
263
266
|
};
|
|
264
267
|
|
|
265
268
|
// Check if embedded in Hop
|
|
@@ -279,6 +282,30 @@ const resolveInitialName = () => {
|
|
|
279
282
|
return fromParams ?? stored ?? (injected && injected !== "Guest" ? injected : null) ?? "User";
|
|
280
283
|
};
|
|
281
284
|
|
|
285
|
+
// Stable connection identity: device half persists per browser, tab half per
|
|
286
|
+
// tab. The server evicts a previous connection bearing the same key on
|
|
287
|
+
// attach — a reconnect replaces its own ghost in presence instead of sitting
|
|
288
|
+
// next to it — while two real tabs (different tab halves) coexist.
|
|
289
|
+
const randomKeyPart = () =>
|
|
290
|
+
(window.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)).replace(/-/g, "").slice(0, 10);
|
|
291
|
+
const resolveDeviceKey = () => {
|
|
292
|
+
try {
|
|
293
|
+
let device = localStorage.getItem("hop_device_id");
|
|
294
|
+
if (!device) {
|
|
295
|
+
device = randomKeyPart();
|
|
296
|
+
localStorage.setItem("hop_device_id", device);
|
|
297
|
+
}
|
|
298
|
+
let tab = sessionStorage.getItem("hop_tab_id");
|
|
299
|
+
if (!tab) {
|
|
300
|
+
tab = randomKeyPart();
|
|
301
|
+
sessionStorage.setItem("hop_tab_id", tab);
|
|
302
|
+
}
|
|
303
|
+
return `${device}.${tab}`;
|
|
304
|
+
} catch {
|
|
305
|
+
return ""; // storage unavailable (private mode quota) — no eviction key
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
282
309
|
// Per-session "seen" markers, shared with the session manager page via
|
|
283
310
|
// localStorage (same origin). Keyed by internal session name; values are the
|
|
284
311
|
// server's own lastActivityAt/bellSeq so no cross-device clock comparison
|
|
@@ -568,6 +595,9 @@ const App = () => {
|
|
|
568
595
|
// layer calls through this ref to avoid declaration-order coupling.
|
|
569
596
|
const switchSessionRef = useRef<((s: SessionInfo) => void) | null>(null);
|
|
570
597
|
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
|
|
598
|
+
// Published views (`hop view`). `session` scopes the panel to one session;
|
|
599
|
+
// an object with none open it fleet-wide. Null = closed.
|
|
600
|
+
const [viewsOpen, setViewsOpen] = useState<{ session?: string } | null>(null);
|
|
571
601
|
const sessionsRef = useRef(sessions);
|
|
572
602
|
sessionsRef.current = sessions;
|
|
573
603
|
// Claude Code titles its process with a bare version number; hop's session
|
|
@@ -993,13 +1023,51 @@ const App = () => {
|
|
|
993
1023
|
};
|
|
994
1024
|
|
|
995
1025
|
// ── Drag-and-drop onto the terminal ──
|
|
996
|
-
// A browser never reveals a dropped file's real path
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1026
|
+
// A browser never reveals a dropped file's real path — but it does hand
|
|
1027
|
+
// over the BYTES, and hop's host is the machine the session runs on. So a
|
|
1028
|
+
// drop uploads: the file lands in a per-session staging dir on the host
|
|
1029
|
+
// and the terminal receives the path it can actually open. Dragged TEXT (a
|
|
1030
|
+
// path string from anywhere) still pastes as text. The old copy-path hint
|
|
1031
|
+
// remains the fallback for a host too old to have the upload endpoint.
|
|
1000
1032
|
const [dropActive, setDropActive] = useState(false);
|
|
1033
|
+
const [uploading, setUploading] = useState<{ done: number; total: number } | null>(null);
|
|
1001
1034
|
const dragDepthRef = useRef(0);
|
|
1002
1035
|
const platformString = `${navigator.platform || ""} ${navigator.userAgent || ""}`;
|
|
1036
|
+
|
|
1037
|
+
const uploadDroppedFiles = async (files: File[]) => {
|
|
1038
|
+
const room = activeSessionRoomRef.current;
|
|
1039
|
+
if (!room) return;
|
|
1040
|
+
setUploading({ done: 0, total: files.length });
|
|
1041
|
+
const landed: string[] = [];
|
|
1042
|
+
try {
|
|
1043
|
+
for (const file of files) {
|
|
1044
|
+
const res = await fetch(
|
|
1045
|
+
`/api/sessions/upload?name=${encodeURIComponent(room)}&filename=${encodeURIComponent(file.name)}`,
|
|
1046
|
+
{ method: "POST", body: file }
|
|
1047
|
+
);
|
|
1048
|
+
if (!res.ok) {
|
|
1049
|
+
// 404 means this host predates uploads: fall back to teaching the
|
|
1050
|
+
// OS copy-path gesture rather than failing silently.
|
|
1051
|
+
const detail = await res.json().catch(() => null);
|
|
1052
|
+
pushNotice(res.status === 404 ? originalPathHint(platformString) : (detail?.error || `Upload failed (${res.status})`));
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
const data = await res.json();
|
|
1056
|
+
if (data?.path) landed.push(String(data.path));
|
|
1057
|
+
setUploading((u) => (u ? { ...u, done: u.done + 1 } : u));
|
|
1058
|
+
}
|
|
1059
|
+
if (landed.length > 0) {
|
|
1060
|
+
termRef.current?.paste(pasteableUploadPaths(landed));
|
|
1061
|
+
pushNotice(landed.length === 1
|
|
1062
|
+
? `Uploaded to ${landed[0]}`
|
|
1063
|
+
: `Uploaded ${landed.length} files — paths pasted`);
|
|
1064
|
+
}
|
|
1065
|
+
} catch {
|
|
1066
|
+
pushNotice(originalPathHint(platformString));
|
|
1067
|
+
} finally {
|
|
1068
|
+
setUploading(null);
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1003
1071
|
// Without these guards a drop that misses the target navigates the whole
|
|
1004
1072
|
// page to the file, killing the session view.
|
|
1005
1073
|
useEffect(() => {
|
|
@@ -1464,7 +1532,7 @@ const App = () => {
|
|
|
1464
1532
|
const hex = (c?: string) => (c || "").replace("#", "").slice(0, 6);
|
|
1465
1533
|
const url = `${wsUrl}?room=${encodeURIComponent(nextSession.room)}&name=${encodeURIComponent(
|
|
1466
1534
|
nextSession.name
|
|
1467
|
-
)}&cols=${cols}&rows=${rows}&replay=${replay}&bg=${hex(surface.background)}&fg=${hex(surface.foreground)}`;
|
|
1535
|
+
)}&cols=${cols}&rows=${rows}&replay=${replay}&bg=${hex(surface.background)}&fg=${hex(surface.foreground)}&device=${encodeURIComponent(resolveDeviceKey())}`;
|
|
1468
1536
|
|
|
1469
1537
|
wsRef.current?.close();
|
|
1470
1538
|
setStatus("connecting");
|
|
@@ -2858,7 +2926,12 @@ const App = () => {
|
|
|
2858
2926
|
parked: s.parked === true,
|
|
2859
2927
|
archived: s.archived === true,
|
|
2860
2928
|
hasLocalCli: s.hasLocalCli === true,
|
|
2861
|
-
folderId: typeof s.folderId === "string" ? s.folderId : null
|
|
2929
|
+
folderId: typeof s.folderId === "string" ? s.folderId : null,
|
|
2930
|
+
// Only present when the session has published — the switcher chip
|
|
2931
|
+
// and the Views dot both key off its absence meaning "nothing here".
|
|
2932
|
+
views: s.views && Number(s.views.count) > 0
|
|
2933
|
+
? { ...s.views, count: Number(s.views.count), latestAt: Number(s.views.latestAt) || 0 }
|
|
2934
|
+
: undefined
|
|
2862
2935
|
});
|
|
2863
2936
|
}
|
|
2864
2937
|
|
|
@@ -3588,6 +3661,15 @@ const App = () => {
|
|
|
3588
3661
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3589
3662
|
}, [session]);
|
|
3590
3663
|
|
|
3664
|
+
// How many sessions have published something this browser hasn't opened.
|
|
3665
|
+
// Rides the 5s /api/sessions poll rather than fetching the manifest again;
|
|
3666
|
+
// `viewsOpen` is a dep because closing the panel is what rewrites the
|
|
3667
|
+
// seen-markers this reads — without it the dot stayed lit after a read.
|
|
3668
|
+
const unseenViewSessions = useMemo(() => {
|
|
3669
|
+
const seen = loadViewsSeen();
|
|
3670
|
+
return sessions.filter((s) => hasUnseenViews(s.internalName || s.name, s.views?.latestAt, seen)).length;
|
|
3671
|
+
}, [sessions, viewsOpen]);
|
|
3672
|
+
|
|
3591
3673
|
const sessionStyle = isMobile
|
|
3592
3674
|
? ({ "--mobile-keyboard-height": `${keyboardVisible ? keyboardHeight : 0}px` } as CSSProperties)
|
|
3593
3675
|
: undefined;
|
|
@@ -3636,6 +3718,15 @@ const App = () => {
|
|
|
3636
3718
|
Sessions
|
|
3637
3719
|
<kbd>{isMacPlatform ? "⌘K" : "⌃⇧K"}</kbd>
|
|
3638
3720
|
</button>
|
|
3721
|
+
<button
|
|
3722
|
+
type="button"
|
|
3723
|
+
className="topbar-sessions-btn"
|
|
3724
|
+
title="Files agents published with hop view"
|
|
3725
|
+
onClick={() => setViewsOpen({})}
|
|
3726
|
+
>
|
|
3727
|
+
Views
|
|
3728
|
+
{unseenViewSessions > 0 && <span className="views-chip-dot" aria-label="New views" />}
|
|
3729
|
+
</button>
|
|
3639
3730
|
<button
|
|
3640
3731
|
type="button"
|
|
3641
3732
|
className="topbar-sessions-btn"
|
|
@@ -3671,7 +3762,7 @@ const App = () => {
|
|
|
3671
3762
|
onRefresh={() => fetchSessions({ showLoading: false })}
|
|
3672
3763
|
onNotice={showToast}
|
|
3673
3764
|
tileWsBase={resolveWsUrl()}
|
|
3674
|
-
|
|
3765
|
+
onOpenViews={(scope) => setViewsOpen({ session: scope })}
|
|
3675
3766
|
userName={name}
|
|
3676
3767
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
3677
3768
|
/>
|
|
@@ -3848,6 +3939,13 @@ const App = () => {
|
|
|
3848
3939
|
}}>
|
|
3849
3940
|
Fit
|
|
3850
3941
|
</button>
|
|
3942
|
+
{/* The drawer is the only menu on mobile, where the topbar's
|
|
3943
|
+
Views button is hidden — without this entry the phone had
|
|
3944
|
+
to go through the switcher to reach a published result. */}
|
|
3945
|
+
<button type="button" className="quick-btn" onClick={() => { setDrawerOpen(false); setViewsOpen({}); }}>
|
|
3946
|
+
Views
|
|
3947
|
+
{unseenViewSessions > 0 && <span className="views-chip-dot" aria-label="New views" />}
|
|
3948
|
+
</button>
|
|
3851
3949
|
{/* Mobile gets the in-app switcher; desktop keeps the manager page */}
|
|
3852
3950
|
{isMobile && isEmbeddedInHop() ? (
|
|
3853
3951
|
<button type="button" className="quick-btn" onClick={() => { setDrawerOpen(false); setSwitcherOpen(true); }}>
|
|
@@ -4084,8 +4182,9 @@ const App = () => {
|
|
|
4084
4182
|
e.preventDefault();
|
|
4085
4183
|
dragDepthRef.current = 0;
|
|
4086
4184
|
setDropActive(false);
|
|
4087
|
-
|
|
4088
|
-
|
|
4185
|
+
const files = Array.from(e.dataTransfer?.files || []);
|
|
4186
|
+
if (files.length > 0) {
|
|
4187
|
+
void uploadDroppedFiles(files);
|
|
4089
4188
|
return;
|
|
4090
4189
|
}
|
|
4091
4190
|
// A dragged text snippet (e.g. a path string) pastes like
|
|
@@ -4110,9 +4209,13 @@ const App = () => {
|
|
|
4110
4209
|
</span>
|
|
4111
4210
|
</div>
|
|
4112
4211
|
)}
|
|
4113
|
-
{dropActive && (
|
|
4212
|
+
{(dropActive || uploading) && (
|
|
4114
4213
|
<div className="terminal-drop-overlay" aria-hidden="true">
|
|
4115
|
-
<span>
|
|
4214
|
+
<span>
|
|
4215
|
+
{uploading
|
|
4216
|
+
? `Uploading ${uploading.total > 1 ? `${uploading.done + 1} of ${uploading.total}` : "file"}…`
|
|
4217
|
+
: "Drop to upload — the path pastes into this session"}
|
|
4218
|
+
</span>
|
|
4116
4219
|
</div>
|
|
4117
4220
|
)}
|
|
4118
4221
|
{isMobile && status === "connecting" && (
|
|
@@ -4346,6 +4449,7 @@ const App = () => {
|
|
|
4346
4449
|
tileWsBase={resolveWsUrl()}
|
|
4347
4450
|
onFocusSession={focusSessionInPlace}
|
|
4348
4451
|
folders={folders}
|
|
4452
|
+
onOpenViews={(scope) => setViewsOpen({ session: scope })}
|
|
4349
4453
|
userName={name}
|
|
4350
4454
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
4351
4455
|
onOpenSettings={() => {
|
|
@@ -4480,6 +4584,11 @@ const App = () => {
|
|
|
4480
4584
|
{toast && <div className="terminal-toast" role="status" aria-live="polite">{toast}</div>}
|
|
4481
4585
|
</main>
|
|
4482
4586
|
)}
|
|
4587
|
+
{/* Outside the mode ternary on purpose: Views opens from the hub, from a
|
|
4588
|
+
session, and from over the switcher, and it must outlive a switch. */}
|
|
4589
|
+
{viewsOpen && (
|
|
4590
|
+
<ViewsPanel session={viewsOpen.session} sessions={sessions} onClose={() => setViewsOpen(null)} />
|
|
4591
|
+
)}
|
|
4483
4592
|
</div>
|
|
4484
4593
|
);
|
|
4485
4594
|
};
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "../utils/switcherModel";
|
|
29
29
|
import { scanKeyboardProtocol } from "../utils/keyboardProtocol";
|
|
30
30
|
import { DigestCard } from "./DigestCard";
|
|
31
|
+
import { hasUnseenViews, loadViewsSeen } from "./ViewsPanel";
|
|
31
32
|
|
|
32
33
|
// Full-screen, in-app session switcher (mobile-first). Hot sessions — current,
|
|
33
34
|
// attention, most recent — are preview cards; the tail is compact rows grouped
|
|
@@ -103,6 +104,9 @@ type Props = {
|
|
|
103
104
|
onOpenSettings?: () => void;
|
|
104
105
|
onToggleKeyboard?: () => void;
|
|
105
106
|
onFind?: () => void;
|
|
107
|
+
// Open the Views panel. No argument = the whole fleet (header button); an
|
|
108
|
+
// internalName = just that session (a card's chip).
|
|
109
|
+
onOpenViews?: (session?: string) => void;
|
|
106
110
|
};
|
|
107
111
|
|
|
108
112
|
type Sheet = {
|
|
@@ -1130,7 +1134,8 @@ export const SessionSwitcher = ({
|
|
|
1130
1134
|
terminalTheme,
|
|
1131
1135
|
onOpenSettings,
|
|
1132
1136
|
onToggleKeyboard,
|
|
1133
|
-
onFind
|
|
1137
|
+
onFind,
|
|
1138
|
+
onOpenViews
|
|
1134
1139
|
}: Props) => {
|
|
1135
1140
|
// View state survives a REFRESH but not a new tab: sessionStorage, not
|
|
1136
1141
|
// localStorage. Reloading the page (or hop reconnecting) used to dump the
|
|
@@ -1480,6 +1485,15 @@ export const SessionSwitcher = ({
|
|
|
1480
1485
|
() => filterSessionsByOrigin(sessions, originScope),
|
|
1481
1486
|
[sessions, originScope]
|
|
1482
1487
|
);
|
|
1488
|
+
// Read once per session-list refresh rather than per card: every tile on a
|
|
1489
|
+
// 30-session wall hitting localStorage on each of the wall's frequent
|
|
1490
|
+
// re-renders is a synchronous cost the tile previews cannot absorb. The
|
|
1491
|
+
// 5s poll's new array is also what re-reads markers the panel just wrote.
|
|
1492
|
+
const viewsSeen = useMemo(() => loadViewsSeen(), [sessions]);
|
|
1493
|
+
const unseenViewSessions = useMemo(
|
|
1494
|
+
() => visibleSessions.filter((s) => hasUnseenViews(sessionKey(s), s.views?.latestAt, viewsSeen)).length,
|
|
1495
|
+
[visibleSessions, viewsSeen]
|
|
1496
|
+
);
|
|
1483
1497
|
// Parked sessions leave the wall but stay one glance away in a collapsed
|
|
1484
1498
|
// section — and the FILTER still searches them, so a parked session is
|
|
1485
1499
|
// never more than ⌘K + a few letters from coming back.
|
|
@@ -1651,6 +1665,10 @@ export const SessionSwitcher = ({
|
|
|
1651
1665
|
if (!deepSearch || deepSearch.status !== "done" || deepSearch.q !== filter.trim()) return [];
|
|
1652
1666
|
const seen = new Set<string>();
|
|
1653
1667
|
if (model.mode === "filter") model.rows.forEach((r) => seen.add(sessionKey(r)));
|
|
1668
|
+
else if (model.mode === "manual") {
|
|
1669
|
+
model.rows.forEach((r) => seen.add(sessionKey(r)));
|
|
1670
|
+
model.folders.forEach((f) => f.rows.forEach((r) => seen.add(sessionKey(r))));
|
|
1671
|
+
}
|
|
1654
1672
|
extraContentMatches.forEach((m) => seen.add(sessionKey(m.session)));
|
|
1655
1673
|
const out: Array<{ session: SwitcherSession; snippet: string }> = [];
|
|
1656
1674
|
for (const m of deepSearch.rows) {
|
|
@@ -1669,7 +1687,7 @@ export const SessionSwitcher = ({
|
|
|
1669
1687
|
if (model.mode === "filter") return [...model.rows, ...extraContentMatches.map((m) => m.session), ...extraDeepMatches.map((m) => m.session)];
|
|
1670
1688
|
// Folder rows come first because that is their visual order; keyboard
|
|
1671
1689
|
// ↑/↓ and Enter must traverse what the eye sees, foldered included.
|
|
1672
|
-
if (model.mode === "manual") return [...model.folders.flatMap((f) => f.rows), ...model.rows, ...extraContentMatches.map((m) => m.session)];
|
|
1690
|
+
if (model.mode === "manual") return [...model.folders.flatMap((f) => f.rows), ...model.rows, ...extraContentMatches.map((m) => m.session), ...extraDeepMatches.map((m) => m.session)];
|
|
1673
1691
|
if (model.mode === "project") return model.groups.flatMap((g) => g.rows);
|
|
1674
1692
|
return [...model.hero, ...model.groups.flatMap((g) => g.rows)];
|
|
1675
1693
|
},
|
|
@@ -1789,15 +1807,20 @@ export const SessionSwitcher = ({
|
|
|
1789
1807
|
if (document.activeElement?.closest?.(".switcher-live-tile, .switcher-focus-tile")) return;
|
|
1790
1808
|
if (event.key === "Escape") {
|
|
1791
1809
|
event.preventDefault();
|
|
1810
|
+
// Escape unwinds one layer at a time, innermost first. A typed query
|
|
1811
|
+
// is a layer: Escape clears it and leaves you on the full wall.
|
|
1812
|
+
// Dismissing the whole switcher out from under a search meant losing
|
|
1813
|
+
// your place to undo one word.
|
|
1792
1814
|
if (sheet) setSheet(null);
|
|
1793
1815
|
else if (focusedKey) setFocusedKey(null);
|
|
1816
|
+
else if (filter) setFilter("");
|
|
1794
1817
|
else if (dismissable) onClose();
|
|
1795
1818
|
}
|
|
1796
1819
|
};
|
|
1797
1820
|
window.addEventListener("keydown", onKey);
|
|
1798
1821
|
return () => window.removeEventListener("keydown", onKey);
|
|
1799
1822
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1800
|
-
}, [open, sheet, focusedKey]);
|
|
1823
|
+
}, [open, sheet, focusedKey, filter]);
|
|
1801
1824
|
|
|
1802
1825
|
// Tile previews: EVERY session gets a preview tile. Hot tiles (the
|
|
1803
1826
|
// attention/recency heroes) refresh each tick; the long tail refreshes at a
|
|
@@ -2494,6 +2517,22 @@ export const SessionSwitcher = ({
|
|
|
2494
2517
|
</span>
|
|
2495
2518
|
)}
|
|
2496
2519
|
{!current && s.starting && !s.active && <span className="switcher-chip starting">STARTING</span>}
|
|
2520
|
+
{/* A result waiting on this session is worth as much as its status:
|
|
2521
|
+
a published PDF or write-up was otherwise invisible from the wall.
|
|
2522
|
+
stopPropagation on pointerdown keeps the long-press sheet out of
|
|
2523
|
+
it, the same way the inline action buttons do. */}
|
|
2524
|
+
{onOpenViews && s.views && s.views.count > 0 && (
|
|
2525
|
+
<button
|
|
2526
|
+
type="button"
|
|
2527
|
+
className={"switcher-chip views" + (hasUnseenViews(key, s.views.latestAt, viewsSeen) ? " fresh" : "")}
|
|
2528
|
+
title={s.views.latestTitle || s.views.latestName || "Published views"}
|
|
2529
|
+
aria-label={`${s.views.count} published view${s.views.count === 1 ? "" : "s"} in ${s.displayName}`}
|
|
2530
|
+
onClick={(e) => { e.stopPropagation(); onOpenViews(key); }}
|
|
2531
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
2532
|
+
>
|
|
2533
|
+
◧ {s.views.count}
|
|
2534
|
+
</button>
|
|
2535
|
+
)}
|
|
2497
2536
|
{waitingOnUser(preview) && (
|
|
2498
2537
|
<span className="switcher-chip waiting" title="Claude is asking how to resume this conversation — open it and choose">
|
|
2499
2538
|
NEEDS YOU
|
|
@@ -2579,6 +2618,50 @@ export const SessionSwitcher = ({
|
|
|
2579
2618
|
const sheetSession = sheet?.session;
|
|
2580
2619
|
const sheetAgentPermitted = sheetSession?.agentPermitted === true;
|
|
2581
2620
|
|
|
2621
|
+
// Content/history hits plus the full-history row, rendered below whatever
|
|
2622
|
+
// the mode showed. Manual mode keeps its own branch under a query (filtering
|
|
2623
|
+
// narrows the user's order in place), so this tail must ride along there
|
|
2624
|
+
// too — search reaches past the name tier in every mode, not just Recent.
|
|
2625
|
+
const searchTail = filter.trim() ? (
|
|
2626
|
+
<>
|
|
2627
|
+
{extraContentMatches.length > 0 && (
|
|
2628
|
+
<section className="switcher-group">
|
|
2629
|
+
<h3 className="switcher-group-label">found in terminal output{contentTruncated ? " · most recent sessions only" : ""}</h3>
|
|
2630
|
+
<div className="switcher-grid">
|
|
2631
|
+
{extraContentMatches.map(({ session: s, snippet }) => renderCard(s, snippet))}
|
|
2632
|
+
</div>
|
|
2633
|
+
</section>
|
|
2634
|
+
)}
|
|
2635
|
+
{extraDeepMatches.length > 0 && (
|
|
2636
|
+
<section className="switcher-group">
|
|
2637
|
+
<h3 className="switcher-group-label">found in full history</h3>
|
|
2638
|
+
<div className="switcher-grid">
|
|
2639
|
+
{extraDeepMatches.map(({ session: s, snippet }) => renderCard(s, snippet))}
|
|
2640
|
+
</div>
|
|
2641
|
+
</section>
|
|
2642
|
+
)}
|
|
2643
|
+
{filter.trim().length >= 3 && (
|
|
2644
|
+
<div className="switcher-deep-row">
|
|
2645
|
+
{!deepSearch ? (
|
|
2646
|
+
<button type="button" className="switcher-deep-btn" onClick={runDeepSearch}>
|
|
2647
|
+
Search full history for “{filter.trim()}”
|
|
2648
|
+
</button>
|
|
2649
|
+
) : deepSearch.status === "loading" ? (
|
|
2650
|
+
<span className="switcher-deep-note">searching full history…</span>
|
|
2651
|
+
) : deepSearch.status === "unavailable" ? (
|
|
2652
|
+
<span className="switcher-deep-note">full-history search unavailable on this host</span>
|
|
2653
|
+
) : (
|
|
2654
|
+
<span className="switcher-deep-note">
|
|
2655
|
+
{extraDeepMatches.length > 0
|
|
2656
|
+
? `${extraDeepMatches.length} more in full history`
|
|
2657
|
+
: "nothing further back matches"}
|
|
2658
|
+
</span>
|
|
2659
|
+
)}
|
|
2660
|
+
</div>
|
|
2661
|
+
)}
|
|
2662
|
+
</>
|
|
2663
|
+
) : null;
|
|
2664
|
+
|
|
2582
2665
|
return (
|
|
2583
2666
|
<div
|
|
2584
2667
|
// tile-dense: too narrow for inline actions. tile-micro: too short for
|
|
@@ -2652,6 +2735,16 @@ export const SessionSwitcher = ({
|
|
|
2652
2735
|
</svg>
|
|
2653
2736
|
</button>
|
|
2654
2737
|
)}
|
|
2738
|
+
{/* The hub renders this wall as the whole page, so this is the only
|
|
2739
|
+
fleet-wide way in to Views when no session is open. */}
|
|
2740
|
+
{onOpenViews && (
|
|
2741
|
+
<button type="button" className="switcher-action" aria-label="Published views" title="Files agents published with hop view" onClick={() => onOpenViews()}>
|
|
2742
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
2743
|
+
<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M10 4v16"/>
|
|
2744
|
+
</svg>
|
|
2745
|
+
{unseenViewSessions > 0 && <span className="views-chip-dot" aria-label="New views" />}
|
|
2746
|
+
</button>
|
|
2747
|
+
)}
|
|
2655
2748
|
{onOpenSettings && (
|
|
2656
2749
|
<button type="button" className="switcher-action" aria-label="Settings" title="Settings" onClick={onOpenSettings}>
|
|
2657
2750
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
@@ -2766,46 +2859,12 @@ export const SessionSwitcher = ({
|
|
|
2766
2859
|
// cards at the current zoom, same as the wall — the screen content
|
|
2767
2860
|
// is usually WHY you're looking for the session.
|
|
2768
2861
|
<>
|
|
2769
|
-
{model.rows.length === 0 && extraContentMatches.length === 0 ? (
|
|
2862
|
+
{model.rows.length === 0 && extraContentMatches.length === 0 && extraDeepMatches.length === 0 ? (
|
|
2770
2863
|
<div className="switcher-empty">No sessions match “{filter.trim()}”</div>
|
|
2771
2864
|
) : (
|
|
2772
2865
|
<div className="switcher-grid">{model.rows.map((s) => renderCard(s))}</div>
|
|
2773
2866
|
)}
|
|
2774
|
-
{
|
|
2775
|
-
<section className="switcher-group">
|
|
2776
|
-
<h3 className="switcher-group-label">found in terminal output{contentTruncated ? " · most recent sessions only" : ""}</h3>
|
|
2777
|
-
<div className="switcher-grid">
|
|
2778
|
-
{extraContentMatches.map(({ session: s, snippet }) => renderCard(s, snippet))}
|
|
2779
|
-
</div>
|
|
2780
|
-
</section>
|
|
2781
|
-
)}
|
|
2782
|
-
{extraDeepMatches.length > 0 && (
|
|
2783
|
-
<section className="switcher-group">
|
|
2784
|
-
<h3 className="switcher-group-label">found in full history</h3>
|
|
2785
|
-
<div className="switcher-grid">
|
|
2786
|
-
{extraDeepMatches.map(({ session: s, snippet }) => renderCard(s, snippet))}
|
|
2787
|
-
</div>
|
|
2788
|
-
</section>
|
|
2789
|
-
)}
|
|
2790
|
-
{filter.trim().length >= 3 && (
|
|
2791
|
-
<div className="switcher-deep-row">
|
|
2792
|
-
{!deepSearch ? (
|
|
2793
|
-
<button type="button" className="switcher-deep-btn" onClick={runDeepSearch}>
|
|
2794
|
-
Search full history for “{filter.trim()}”
|
|
2795
|
-
</button>
|
|
2796
|
-
) : deepSearch.status === "loading" ? (
|
|
2797
|
-
<span className="switcher-deep-note">searching full history…</span>
|
|
2798
|
-
) : deepSearch.status === "unavailable" ? (
|
|
2799
|
-
<span className="switcher-deep-note">full-history search unavailable on this host</span>
|
|
2800
|
-
) : (
|
|
2801
|
-
<span className="switcher-deep-note">
|
|
2802
|
-
{extraDeepMatches.length > 0
|
|
2803
|
-
? `${extraDeepMatches.length} more in full history`
|
|
2804
|
-
: "nothing further back matches"}
|
|
2805
|
-
</span>
|
|
2806
|
-
)}
|
|
2807
|
-
</div>
|
|
2808
|
-
)}
|
|
2867
|
+
{searchTail}
|
|
2809
2868
|
</>
|
|
2810
2869
|
) : model.mode === "project" ? (
|
|
2811
2870
|
<>
|
|
@@ -2831,7 +2890,9 @@ export const SessionSwitcher = ({
|
|
|
2831
2890
|
) : model.mode === "manual" ? (
|
|
2832
2891
|
<>
|
|
2833
2892
|
{model.rows.length === 0 && model.folders.every((f) => f.rows.length === 0) ? (
|
|
2834
|
-
|
|
2893
|
+
extraContentMatches.length === 0 && extraDeepMatches.length === 0 && (
|
|
2894
|
+
<div className="switcher-empty">{filter.trim() ? "No matches" : "No sessions"}</div>
|
|
2895
|
+
)
|
|
2835
2896
|
) : (
|
|
2836
2897
|
<>
|
|
2837
2898
|
<p className="switcher-hint">
|
|
@@ -2908,6 +2969,7 @@ export const SessionSwitcher = ({
|
|
|
2908
2969
|
<div className="switcher-grid">{model.rows.map((s) => renderCard(s))}</div>
|
|
2909
2970
|
</>
|
|
2910
2971
|
)}
|
|
2972
|
+
{searchTail}
|
|
2911
2973
|
</>
|
|
2912
2974
|
) : (
|
|
2913
2975
|
<>
|