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 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
@@ -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, so a file drop can't
1021
- // paste one. Catch the drop and teach the working gesture instead: the
1022
- // OS-specific copy-path hotkey (⌘⌥C in Finder / Ctrl+Shift+C in Explorer).
1023
- // Dragged TEXT (a path string from anywhere) pastes normally.
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
- if ((e.dataTransfer?.files?.length || 0) > 0) {
4112
- pushNotice(originalPathHint(platformString));
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>{originalPathHint(platformString)}</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 &lt;file&gt;</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
+ };