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 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
+ });
@@ -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, so a file drop can't
997
- // paste one. Catch the drop and teach the working gesture instead: the
998
- // OS-specific copy-path hotkey (⌘⌥C in Finder / Ctrl+Shift+C in Explorer).
999
- // 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.
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
- if ((e.dataTransfer?.files?.length || 0) > 0) {
4088
- pushNotice(originalPathHint(platformString));
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>{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>
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
- {extraContentMatches.length > 0 && (
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
- <div className="switcher-empty">{filter.trim() ? "No matches" : "No sessions"}</div>
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
  <>