hop2 0.11.13 → 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/web/src/App.tsx +96 -11
- package/hay/apps/web/src/components/SessionSwitcher.tsx +47 -2
- 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 +19 -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 +629 -63
- package/package.json +1 -1
- package/skills/hop-session/SKILL.md +32 -11
- package/tests/terminal-api.integration.test.cjs +84 -0
- package/hay-web/assets/index--8sYRyoP.js +0 -133
- package/hay-web/assets/index--8sYRyoP.js.map +0 -1
- package/hay-web/assets/index-D4EXRUpb.css +0 -1
- package/hay-web/assets/index-DnSA-VR6.js +0 -133
- package/hay-web/assets/index-DnSA-VR6.js.map +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
|
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
|
|
@@ -592,6 +595,9 @@ const App = () => {
|
|
|
592
595
|
// layer calls through this ref to avoid declaration-order coupling.
|
|
593
596
|
const switchSessionRef = useRef<((s: SessionInfo) => void) | null>(null);
|
|
594
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);
|
|
595
601
|
const sessionsRef = useRef(sessions);
|
|
596
602
|
sessionsRef.current = sessions;
|
|
597
603
|
// Claude Code titles its process with a bare version number; hop's session
|
|
@@ -1017,13 +1023,51 @@ const App = () => {
|
|
|
1017
1023
|
};
|
|
1018
1024
|
|
|
1019
1025
|
// ── Drag-and-drop onto the terminal ──
|
|
1020
|
-
// A browser never reveals a dropped file's real path
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1023
|
-
//
|
|
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.
|
|
1024
1032
|
const [dropActive, setDropActive] = useState(false);
|
|
1033
|
+
const [uploading, setUploading] = useState<{ done: number; total: number } | null>(null);
|
|
1025
1034
|
const dragDepthRef = useRef(0);
|
|
1026
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
|
+
};
|
|
1027
1071
|
// Without these guards a drop that misses the target navigates the whole
|
|
1028
1072
|
// page to the file, killing the session view.
|
|
1029
1073
|
useEffect(() => {
|
|
@@ -2882,7 +2926,12 @@ const App = () => {
|
|
|
2882
2926
|
parked: s.parked === true,
|
|
2883
2927
|
archived: s.archived === true,
|
|
2884
2928
|
hasLocalCli: s.hasLocalCli === true,
|
|
2885
|
-
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
|
|
2886
2935
|
});
|
|
2887
2936
|
}
|
|
2888
2937
|
|
|
@@ -3612,6 +3661,15 @@ const App = () => {
|
|
|
3612
3661
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3613
3662
|
}, [session]);
|
|
3614
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
|
+
|
|
3615
3673
|
const sessionStyle = isMobile
|
|
3616
3674
|
? ({ "--mobile-keyboard-height": `${keyboardVisible ? keyboardHeight : 0}px` } as CSSProperties)
|
|
3617
3675
|
: undefined;
|
|
@@ -3660,6 +3718,15 @@ const App = () => {
|
|
|
3660
3718
|
Sessions
|
|
3661
3719
|
<kbd>{isMacPlatform ? "⌘K" : "⌃⇧K"}</kbd>
|
|
3662
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>
|
|
3663
3730
|
<button
|
|
3664
3731
|
type="button"
|
|
3665
3732
|
className="topbar-sessions-btn"
|
|
@@ -3695,7 +3762,7 @@ const App = () => {
|
|
|
3695
3762
|
onRefresh={() => fetchSessions({ showLoading: false })}
|
|
3696
3763
|
onNotice={showToast}
|
|
3697
3764
|
tileWsBase={resolveWsUrl()}
|
|
3698
|
-
|
|
3765
|
+
onOpenViews={(scope) => setViewsOpen({ session: scope })}
|
|
3699
3766
|
userName={name}
|
|
3700
3767
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
3701
3768
|
/>
|
|
@@ -3872,6 +3939,13 @@ const App = () => {
|
|
|
3872
3939
|
}}>
|
|
3873
3940
|
Fit
|
|
3874
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>
|
|
3875
3949
|
{/* Mobile gets the in-app switcher; desktop keeps the manager page */}
|
|
3876
3950
|
{isMobile && isEmbeddedInHop() ? (
|
|
3877
3951
|
<button type="button" className="quick-btn" onClick={() => { setDrawerOpen(false); setSwitcherOpen(true); }}>
|
|
@@ -4108,8 +4182,9 @@ const App = () => {
|
|
|
4108
4182
|
e.preventDefault();
|
|
4109
4183
|
dragDepthRef.current = 0;
|
|
4110
4184
|
setDropActive(false);
|
|
4111
|
-
|
|
4112
|
-
|
|
4185
|
+
const files = Array.from(e.dataTransfer?.files || []);
|
|
4186
|
+
if (files.length > 0) {
|
|
4187
|
+
void uploadDroppedFiles(files);
|
|
4113
4188
|
return;
|
|
4114
4189
|
}
|
|
4115
4190
|
// A dragged text snippet (e.g. a path string) pastes like
|
|
@@ -4134,9 +4209,13 @@ const App = () => {
|
|
|
4134
4209
|
</span>
|
|
4135
4210
|
</div>
|
|
4136
4211
|
)}
|
|
4137
|
-
{dropActive && (
|
|
4212
|
+
{(dropActive || uploading) && (
|
|
4138
4213
|
<div className="terminal-drop-overlay" aria-hidden="true">
|
|
4139
|
-
<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>
|
|
4140
4219
|
</div>
|
|
4141
4220
|
)}
|
|
4142
4221
|
{isMobile && status === "connecting" && (
|
|
@@ -4370,6 +4449,7 @@ const App = () => {
|
|
|
4370
4449
|
tileWsBase={resolveWsUrl()}
|
|
4371
4450
|
onFocusSession={focusSessionInPlace}
|
|
4372
4451
|
folders={folders}
|
|
4452
|
+
onOpenViews={(scope) => setViewsOpen({ session: scope })}
|
|
4373
4453
|
userName={name}
|
|
4374
4454
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
4375
4455
|
onOpenSettings={() => {
|
|
@@ -4504,6 +4584,11 @@ const App = () => {
|
|
|
4504
4584
|
{toast && <div className="terminal-toast" role="status" aria-live="polite">{toast}</div>}
|
|
4505
4585
|
</main>
|
|
4506
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
|
+
)}
|
|
4507
4592
|
</div>
|
|
4508
4593
|
);
|
|
4509
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.
|
|
@@ -1793,15 +1807,20 @@ export const SessionSwitcher = ({
|
|
|
1793
1807
|
if (document.activeElement?.closest?.(".switcher-live-tile, .switcher-focus-tile")) return;
|
|
1794
1808
|
if (event.key === "Escape") {
|
|
1795
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.
|
|
1796
1814
|
if (sheet) setSheet(null);
|
|
1797
1815
|
else if (focusedKey) setFocusedKey(null);
|
|
1816
|
+
else if (filter) setFilter("");
|
|
1798
1817
|
else if (dismissable) onClose();
|
|
1799
1818
|
}
|
|
1800
1819
|
};
|
|
1801
1820
|
window.addEventListener("keydown", onKey);
|
|
1802
1821
|
return () => window.removeEventListener("keydown", onKey);
|
|
1803
1822
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1804
|
-
}, [open, sheet, focusedKey]);
|
|
1823
|
+
}, [open, sheet, focusedKey, filter]);
|
|
1805
1824
|
|
|
1806
1825
|
// Tile previews: EVERY session gets a preview tile. Hot tiles (the
|
|
1807
1826
|
// attention/recency heroes) refresh each tick; the long tail refreshes at a
|
|
@@ -2498,6 +2517,22 @@ export const SessionSwitcher = ({
|
|
|
2498
2517
|
</span>
|
|
2499
2518
|
)}
|
|
2500
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
|
+
)}
|
|
2501
2536
|
{waitingOnUser(preview) && (
|
|
2502
2537
|
<span className="switcher-chip waiting" title="Claude is asking how to resume this conversation — open it and choose">
|
|
2503
2538
|
NEEDS YOU
|
|
@@ -2700,6 +2735,16 @@ export const SessionSwitcher = ({
|
|
|
2700
2735
|
</svg>
|
|
2701
2736
|
</button>
|
|
2702
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
|
+
)}
|
|
2703
2748
|
{onOpenSettings && (
|
|
2704
2749
|
<button type="button" className="switcher-action" aria-label="Settings" title="Settings" onClick={onOpenSettings}>
|
|
2705
2750
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { relativeTime } from "../utils/switcherModel";
|
|
3
|
+
|
|
4
|
+
// Views: the results an agent hands a human that a terminal cannot draw — a
|
|
5
|
+
// PDF, a rendered write-up, a screenshot, a proxied dev server. The daemon has
|
|
6
|
+
// published them since `hop view` shipped, but the only way to reach one from
|
|
7
|
+
// this client was to catch the printed URL before it scrolled out of the
|
|
8
|
+
// viewport. This panel is the index that was missing: one fetch of the
|
|
9
|
+
// manifest the daemon already maintains, grouped by session, newest first.
|
|
10
|
+
|
|
11
|
+
type ViewItem = {
|
|
12
|
+
// The session's internalName — the same key /api/sessions reports views
|
|
13
|
+
// under, so seen-markers written here line up with the switcher's counts.
|
|
14
|
+
session: string;
|
|
15
|
+
name: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
path: string;
|
|
18
|
+
bytes?: number;
|
|
19
|
+
// Epoch SECONDS (the manifest stores unix time, not ms).
|
|
20
|
+
mtime?: number;
|
|
21
|
+
// "server" is a LIVE proxied localhost server (`hop port`), not a stored
|
|
22
|
+
// file: it stops working when the server does, which a file never does.
|
|
23
|
+
kind?: "file" | "server";
|
|
24
|
+
// For a server, the proxy itself. The manifest scan can only see the
|
|
25
|
+
// redirect page `hop port` leaves behind; going straight to the proxy skips
|
|
26
|
+
// a visible flash and a round trip on a phone link.
|
|
27
|
+
target?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type Props = {
|
|
31
|
+
// Scope to one session — set when opened from that session's own affordance.
|
|
32
|
+
// Absent means the whole fleet, which is the default reading.
|
|
33
|
+
session?: string;
|
|
34
|
+
// Only used to translate internalName into the name the user renamed it to;
|
|
35
|
+
// the manifest has no idea a session was ever renamed.
|
|
36
|
+
sessions?: { name: string; displayName?: string; internalName?: string }[];
|
|
37
|
+
onClose: () => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Per-session high-water mark: the newest mtime this browser has looked at.
|
|
41
|
+
const SEEN_KEY = "hop_views_seen";
|
|
42
|
+
|
|
43
|
+
export const loadViewsSeen = (): Record<string, number> => {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(localStorage.getItem(SEEN_KEY) || "{}");
|
|
46
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
47
|
+
return parsed as Record<string, number>;
|
|
48
|
+
} catch { return {}; } // private mode, or a half-written value
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const saveViewsSeen = (seen: Record<string, number>) => {
|
|
52
|
+
try { localStorage.setItem(SEEN_KEY, JSON.stringify(seen)); } catch { /* private mode */ }
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether a session has published something since this browser last looked.
|
|
57
|
+
* A session with no marker at all counts as unseen — a first-time reader HAS
|
|
58
|
+
* results waiting, and silently baselining them would hide the whole feature
|
|
59
|
+
* behind an affordance that never lights up.
|
|
60
|
+
*/
|
|
61
|
+
export const hasUnseenViews = (
|
|
62
|
+
key: string,
|
|
63
|
+
latestAt: number | undefined,
|
|
64
|
+
seen: Record<string, number>
|
|
65
|
+
) => (latestAt || 0) > (seen[key] || 0);
|
|
66
|
+
|
|
67
|
+
// A short type tag instead of an emoji: this sits at ~9px on a phone, and
|
|
68
|
+
// emoji coverage for document glyphs differs enough between iOS and Android
|
|
69
|
+
// that the column stops lining up. The tag also survives a monospace column.
|
|
70
|
+
const kindOf = (name: string, kind?: string) => {
|
|
71
|
+
if (kind === "server") return "LIVE";
|
|
72
|
+
const ext = (name.split(".").pop() || "").toLowerCase();
|
|
73
|
+
if (["csv", "tsv"].includes(ext)) return "TBL";
|
|
74
|
+
if (["mp3", "m4a", "wav", "flac", "aac", "ogg", "opus"].includes(ext)) return "AUD";
|
|
75
|
+
if (ext === "pdf") return "PDF";
|
|
76
|
+
if (["png", "jpg", "jpeg", "gif", "webp", "svg", "ico"].includes(ext)) return "IMG";
|
|
77
|
+
if (["mp4", "mov", "webm"].includes(ext)) return "VID";
|
|
78
|
+
if (["md", "markdown"].includes(ext)) return "MD";
|
|
79
|
+
if (["html", "htm"].includes(ext)) return "WEB";
|
|
80
|
+
if (["txt", "log", "json", "csv"].includes(ext)) return "TXT";
|
|
81
|
+
return "FILE";
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const formatBytes = (n?: number) => {
|
|
85
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return "";
|
|
86
|
+
if (n < 1024) return `${n} B`;
|
|
87
|
+
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
|
88
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const ViewsPanel = ({ session, sessions = [], onClose }: Props) => {
|
|
92
|
+
const [items, setItems] = useState<ViewItem[] | null>(null);
|
|
93
|
+
const [failed, setFailed] = useState(false);
|
|
94
|
+
// Frozen at open. The mark-seen effect below fires the moment the list
|
|
95
|
+
// lands, so reading live storage during render would clear every "new" dot
|
|
96
|
+
// in the same frame that exists to show them.
|
|
97
|
+
const [seenAtOpen] = useState(loadViewsSeen);
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
let cancelled = false;
|
|
102
|
+
fetch("/api/views", { cache: "no-store", credentials: "same-origin" })
|
|
103
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
|
104
|
+
.then((d: { items?: ViewItem[] }) => {
|
|
105
|
+
if (!cancelled) setItems(Array.isArray(d?.items) ? d.items : []);
|
|
106
|
+
})
|
|
107
|
+
.catch(() => { if (!cancelled) setFailed(true); });
|
|
108
|
+
return () => { cancelled = true; };
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
// Capture phase + stopPropagation: the switcher listens for Escape on window
|
|
112
|
+
// too, so without this one press closed the panel AND dismissed the wall
|
|
113
|
+
// behind it, dumping the reader into a terminal they never asked for.
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const onKey = (event: KeyboardEvent) => {
|
|
116
|
+
if (event.key !== "Escape") return;
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
event.stopPropagation();
|
|
119
|
+
onClose();
|
|
120
|
+
};
|
|
121
|
+
window.addEventListener("keydown", onKey, true);
|
|
122
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
123
|
+
}, [onClose]);
|
|
124
|
+
|
|
125
|
+
const groups = useMemo(() => {
|
|
126
|
+
// The daemon folds case when a name addresses a session; match it here so
|
|
127
|
+
// a scoped open from a differently-cased key still finds its rows.
|
|
128
|
+
const scope = session?.toLowerCase();
|
|
129
|
+
const by = new Map<string, ViewItem[]>();
|
|
130
|
+
// /api/views is already newest-first, so plain insertion order gives both
|
|
131
|
+
// newest-first rows inside a session and newest-first sessions.
|
|
132
|
+
for (const item of items || []) {
|
|
133
|
+
if (!item?.path || !item?.name) continue;
|
|
134
|
+
const key = String(item.session || "");
|
|
135
|
+
if (scope && key.toLowerCase() !== scope) continue;
|
|
136
|
+
const rows = by.get(key);
|
|
137
|
+
if (rows) rows.push(item);
|
|
138
|
+
else by.set(key, [item]);
|
|
139
|
+
}
|
|
140
|
+
return [...by.entries()];
|
|
141
|
+
}, [items, session]);
|
|
142
|
+
|
|
143
|
+
// Opening the panel IS the act of seeing what it shows.
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!items) return;
|
|
146
|
+
const seen = loadViewsSeen();
|
|
147
|
+
let dirty = false;
|
|
148
|
+
for (const [key, rows] of groups) {
|
|
149
|
+
const newest = rows[0]?.mtime || 0;
|
|
150
|
+
if (newest > (seen[key] || 0)) { seen[key] = newest; dirty = true; }
|
|
151
|
+
}
|
|
152
|
+
// Only a fleet-wide open knows the full population. Pruning from a scoped
|
|
153
|
+
// open would delete every other session's marker, and their long-read
|
|
154
|
+
// views would announce themselves as new all over again.
|
|
155
|
+
if (!session) {
|
|
156
|
+
const live = new Set(groups.map(([key]) => key));
|
|
157
|
+
for (const key of Object.keys(seen)) {
|
|
158
|
+
if (!live.has(key)) { delete seen[key]; dirty = true; }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (dirty) saveViewsSeen(seen);
|
|
162
|
+
}, [items, groups, session]);
|
|
163
|
+
|
|
164
|
+
const label = (internalName: string) => {
|
|
165
|
+
const hit = sessions.find((s) => (s.internalName || s.name) === internalName)
|
|
166
|
+
|| sessions.find((s) => (s.internalName || s.name).toLowerCase() === internalName.toLowerCase());
|
|
167
|
+
return hit ? (hit.displayName || hit.name) : internalName;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<>
|
|
172
|
+
<div className="views-backdrop" onClick={onClose} />
|
|
173
|
+
<div className="views-panel" role="dialog" aria-label="Published views">
|
|
174
|
+
<div className="views-head">
|
|
175
|
+
<span className="views-badge">◧ Views</span>
|
|
176
|
+
{session && <span className="views-scope">{label(session)}</span>}
|
|
177
|
+
<span className="views-head-spacer" />
|
|
178
|
+
<button type="button" className="views-close" aria-label="Close views" onClick={onClose}>×</button>
|
|
179
|
+
</div>
|
|
180
|
+
{failed ? (
|
|
181
|
+
<p className="views-empty">Couldn't reach the view index.</p>
|
|
182
|
+
) : !items ? (
|
|
183
|
+
<p className="views-empty">Loading…</p>
|
|
184
|
+
) : groups.length === 0 ? (
|
|
185
|
+
<p className="views-empty">
|
|
186
|
+
Nothing published yet — agents publish results with <code>hop view <file></code>.
|
|
187
|
+
PDFs, images, HTML and markdown write-ups land here instead of scrolling
|
|
188
|
+
past as text.
|
|
189
|
+
</p>
|
|
190
|
+
) : (
|
|
191
|
+
<div className="views-list">
|
|
192
|
+
{groups.map(([key, rows]) => (
|
|
193
|
+
<div className="views-group" key={key}>
|
|
194
|
+
<div className="views-dateline">{label(key)}</div>
|
|
195
|
+
{rows.map((item) => {
|
|
196
|
+
const fresh = (item.mtime || 0) > (seenAtOpen[key] || 0);
|
|
197
|
+
const isServer = item.kind === "server";
|
|
198
|
+
// A server has no meaningful size and its filename is an
|
|
199
|
+
// implementation detail (the redirect page) — it is a door,
|
|
200
|
+
// not a document, so say what it is instead.
|
|
201
|
+
const meta = isServer
|
|
202
|
+
? "live server · opens the running app"
|
|
203
|
+
: [
|
|
204
|
+
// The filename is only worth a line of its own when
|
|
205
|
+
// the title isn't already showing it.
|
|
206
|
+
item.title ? item.name : "",
|
|
207
|
+
relativeTime((item.mtime || 0) * 1000, now),
|
|
208
|
+
formatBytes(item.bytes)
|
|
209
|
+
].filter(Boolean).join(" · ");
|
|
210
|
+
return (
|
|
211
|
+
// A real anchor to a new tab, not an in-app overlay or a
|
|
212
|
+
// button: these are PDFs, videos and whole HTML pages, and
|
|
213
|
+
// the browser's own viewers already do zoom/scrub/print far
|
|
214
|
+
// better than an iframe would. Decisively, navigating in
|
|
215
|
+
// place would tear down the WebSocket attached to the live
|
|
216
|
+
// terminal — reading a result must not disconnect the
|
|
217
|
+
// session that produced it. The tunnel_session cookie is
|
|
218
|
+
// same-origin, so the new tab authenticates itself, and
|
|
219
|
+
// being an anchor keeps ⌘-click and "copy link" working.
|
|
220
|
+
<a
|
|
221
|
+
key={item.path}
|
|
222
|
+
className={"views-row" + (fresh ? " fresh" : "") + (isServer ? " server" : "")}
|
|
223
|
+
href={isServer && item.target ? item.target : item.path}
|
|
224
|
+
target="_blank"
|
|
225
|
+
rel="noopener"
|
|
226
|
+
>
|
|
227
|
+
<span className="views-kind" aria-hidden="true">{kindOf(item.name, item.kind)}</span>
|
|
228
|
+
<span className="views-row-text">
|
|
229
|
+
<span className="views-title">{item.title || item.name}</span>
|
|
230
|
+
{meta && <span className="views-meta">{meta}</span>}
|
|
231
|
+
</span>
|
|
232
|
+
{fresh && <span className="views-dot" aria-label="New" />}
|
|
233
|
+
</a>
|
|
234
|
+
);
|
|
235
|
+
})}
|
|
236
|
+
</div>
|
|
237
|
+
))}
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
</>
|
|
242
|
+
);
|
|
243
|
+
};
|