hop2 0.11.65 → 0.11.66

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
@@ -245,6 +245,32 @@ curl -X POST http://127.0.0.1:$PORT/api/sessions/fork \
245
245
  -d '{"name":"angler","target":"codex"}' # omit target to fork with the same tool
246
246
  ```
247
247
 
248
+ ### Check back later (`hop checkback`)
249
+
250
+ Agents promise to "check back in 30 minutes" and then don't — Codex
251
+ especially. A check-back makes hop do the remembering: a message that hop
252
+ types into a session (and submits) when a trigger fires, once the session
253
+ is idle, so it lands at the prompt like something you typed.
254
+
255
+ ```bash
256
+ hop checkback --in 30m "Check the training job and report the loss curve" # inside a session: this one
257
+ hop checkback --session surf --at 15:00 "Summarise today's AF results for me"
258
+ hop checkback --session surf --every 2h --until 18:00 "Status?"
259
+ hop checkback --when-idle --idle-for 15m "Idle 15 minutes: write the status note and stop"
260
+ hop checkback --when-file results/af.csv "The AF results are in: summarise them" # --changed: on change, not existence
261
+ hop checkback --when-cmd "squeue -j 123 | grep -q COMPLETED" --every 5m "Job 123 finished — collect its outputs"
262
+ hop checkback --list hop checkback --cancel <id> hop checkback --cancel-all --session surf
263
+ ```
264
+
265
+ Triggers: a moment (`--in 45m`, `--at 15:00` / `3pm` / ISO), a period
266
+ (`--every`, with `--until`), the session going idle (`--when-idle`, optionally
267
+ after a quiet spell), a file appearing or changing (`--when-file`), a shell
268
+ command succeeding (`--when-cmd`, polled). Delivery waits for the agent to
269
+ finish its turn unless `--force`. Check-backs are durable (`~/.hop2/checkbacks.json`)
270
+ and survive daemon restarts; the wall shows ⏰ on a session that has one
271
+ waiting. Agents get the same over MCP (`hop_checkback`, `hop_list_checkbacks`,
272
+ `hop_cancel_checkback`) and are told to use it on themselves.
273
+
248
274
  ### Render LaTeX math (`hop math`)
249
275
 
250
276
  Glance at a formula without leaving the terminal:
@@ -277,6 +303,11 @@ hop view --list # what this session published
277
303
  hop view --rm x.png # unpublish
278
304
  ```
279
305
 
306
+ On the web, every delivery also shows as a small inline card right where
307
+ `hop view` printed it — title, file, size, a thumbnail for an image, and
308
+ an Open button that lands the Views reader on that item. Plain terminals
309
+ and the phone see the same delivery as a text box.
310
+
280
311
  HTML, PDF, images, video and markdown all render (markdown is converted at
281
312
  serve time — headings, tables, fenced code). `--title` is what a human sees
282
313
  in the session's Views list, so it is worth writing properly. Published files
@@ -424,6 +455,7 @@ Hop uses the external Hay host runtime for PTY hosting and session recovery.
424
455
  | `hop` | Start hop daemon/tunnel if needed, then launch a local terminal. Unnamed launches do not reuse a session that already has a local CLI attached. |
425
456
  | `hop start` | Start the hop daemon and print QR codes |
426
457
  | `hop attach [session]` | Attach to an existing terminal session |
458
+ | `hop checkback …` | Type a message into a session later: `--in`, `--at`, `--every`, `--when-idle`, `--when-file`, `--when-cmd`; `--list`, `--cancel` |
427
459
  | `hop attach all` | Attach sequentially to all terminal sessions |
428
460
  | `hop local [session]` | Start a daemonless local terminal (`[session]` attaches if it exists) |
429
461
  | `hop stop` | Stop the hop daemon (terminal sessions keep running) |
@@ -23,6 +23,7 @@ import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
23
23
  import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
24
24
  import { claimOnAttach, claimOnClick, isPlainClick } from "./utils/sizeClaim";
25
25
  import { installDragSelect } from "./utils/dragSelect";
26
+ import { enableViewBlocks } from "./utils/viewBlocks";
26
27
  import { MobileKeyboard } from "./components/MobileKeyboard";
27
28
  import { SessionSwitcher } from "./components/SessionSwitcher";
28
29
  import { SecondaryPane } from "./components/SecondaryPane";
@@ -266,6 +267,8 @@ type SessionInfo = {
266
267
  // a human has opened the session since it finished (see lib/agent-phase.js).
267
268
  agentPhase?: "working" | "done" | null;
268
269
  turnSeen?: boolean;
270
+ // Scheduled check-backs waiting on this session (hop checkback).
271
+ checkbacks?: number;
269
272
  attentionReason?: string;
270
273
  attentionNote?: string;
271
274
  cols?: number;
@@ -477,8 +480,14 @@ const App = () => {
477
480
  // while you cycle (⌘J/⌘L held / two-finger swipe) and commits on release — like
478
481
  // ⌘Tab. Frozen ring so neighbours don't reshuffle mid-cycle (same rule as
479
482
  // the iOS swipe order). Ref mirror for the keyup/wheel listeners.
480
- const [switchHud, setSwitchHud] = useState<{ ring: SessionInfo[]; index: number } | null>(null);
481
- const switchHudRef = useRef<{ ring: SessionInfo[]; index: number } | null>(null);
483
+ // via: how the HUD was opened. "keys" (⌘J/⌘L) lands on the modifier's
484
+ // release — the hold IS the deciding time. "wheel" (a two-finger swipe)
485
+ // has no release, so it waits: ⏎ or a click on a card switches, esc
486
+ // cancels, and a quiet spell closes it without switching. It used to
487
+ // commit 320ms after the swipe settled, i.e. while you were still looking.
488
+ const [switchHud, setSwitchHud] = useState<{ ring: SessionInfo[]; index: number; via: "keys" | "wheel" } | null>(null);
489
+ const switchHudRef = useRef<{ ring: SessionInfo[]; index: number; via: "keys" | "wheel" } | null>(null);
490
+ const hudPickRef = useRef<((index: number) => void) | null>(null);
482
491
  switchHudRef.current = switchHud;
483
492
  // Server-owned folders (Manual mode). Kept in App because the sessions
484
493
  // fetch already carries them.
@@ -700,7 +709,7 @@ const App = () => {
700
709
  const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
701
710
  // Published views (`hop view`). `session` scopes the panel to one session;
702
711
  // an object with none open it fleet-wide. Null = closed.
703
- const [viewsOpen, setViewsOpen] = useState<{ session?: string; dock?: boolean } | null>(null);
712
+ const [viewsOpen, setViewsOpen] = useState<{ session?: string; dock?: boolean; item?: string } | null>(null);
704
713
  const sessionsRef = useRef(sessions);
705
714
  sessionsRef.current = sessions;
706
715
  // Claude Code titles its process with a bare version number; hop's session
@@ -1505,12 +1514,17 @@ const App = () => {
1505
1514
  lastSentSizeRef.current = { cols, rows };
1506
1515
  const deliberate = claim === "attach" && deliberateAttachRef.current;
1507
1516
  if (deliberate) deliberateAttachRef.current = false;
1517
+ // An explicit Fit says "use MY size" even over someone typing now;
1518
+ // opening/clicking only takes the size from an idle or absent owner.
1519
+ const take = takeSizeRef.current;
1520
+ takeSizeRef.current = false;
1508
1521
  sendMessage({
1509
1522
  type: "resize",
1510
1523
  cols,
1511
1524
  rows,
1512
1525
  ...(claim ? { claim } : {}),
1513
- ...(deliberate ? { user: true } : {})
1526
+ ...(deliberate ? { user: true } : {}),
1527
+ ...(take ? { take: true } : {})
1514
1528
  });
1515
1529
  };
1516
1530
 
@@ -1519,6 +1533,8 @@ const App = () => {
1519
1533
  * here. Quiet when the session is already at our fit, so an ordinary click
1520
1534
  * (or a selection drag) never pushes a resize.
1521
1535
  */
1536
+ // Set by an explicit Fit: the next resize carries take:true.
1537
+ const takeSizeRef = useRef(false);
1522
1538
  const claimSizeHere = (force = false) => {
1523
1539
  if (!force && !claimOnClick({
1524
1540
  viewMode: viewModeRef.current,
@@ -1528,6 +1544,7 @@ const App = () => {
1528
1544
  })) return;
1529
1545
  attachClaimPendingRef.current = true;
1530
1546
  deliberateAttachRef.current = true;
1547
+ if (force) takeSizeRef.current = true;
1531
1548
  fitToViewport();
1532
1549
  handleResize();
1533
1550
  };
@@ -2110,6 +2127,9 @@ const App = () => {
2110
2127
  // ⌥-drag always selects, iTerm's escape hatch for apps that track the
2111
2128
  // mouse. (Plain drag selects too in agent sessions — utils/dragSelect.)
2112
2129
  macOptionClickForcesSelection: true,
2130
+ // Decorations (inline hop view cards, utils/viewBlocks.ts) are a
2131
+ // proposed xterm API; this is the switch that lets them register.
2132
+ allowProposedApi: true,
2113
2133
  lineHeight: 1.3,
2114
2134
  cursorBlink: true,
2115
2135
  scrollback: 50000,
@@ -2147,6 +2167,9 @@ const App = () => {
2147
2167
  // KaTeX, a click pins it. What `hop math` could only approximate in
2148
2168
  // Unicode, the browser around the terminal draws properly.
2149
2169
  enableMathHover(terminal, getMathTip());
2170
+ // `hop view` deliveries become small inline cards where they printed;
2171
+ // Open lands in the Views reader on that item.
2172
+ (terminal as any).__viewBlocksCleanup = enableViewBlocks(terminal as never, (b) => setViewsOpen({ session: b.session, item: b.name }));
2150
2173
  terminal.open(containerRef.current);
2151
2174
  // Plain drag selects in Claude/Codex sessions even though they track the
2152
2175
  // mouse; the wheel still reaches them. See utils/dragSelect.ts.
@@ -3052,6 +3075,10 @@ const App = () => {
3052
3075
  (terminal as any).__overlayScrollbarCleanup();
3053
3076
  (terminal as any).__overlayScrollbarCleanup = null;
3054
3077
  }
3078
+ if ((terminal as any).__viewBlocksCleanup) {
3079
+ (terminal as any).__viewBlocksCleanup();
3080
+ (terminal as any).__viewBlocksCleanup = null;
3081
+ }
3055
3082
  if ((terminal as any).__dragSelectCleanup) {
3056
3083
  (terminal as any).__dragSelectCleanup();
3057
3084
  (terminal as any).__dragSelectCleanup = null;
@@ -3235,6 +3262,7 @@ const App = () => {
3235
3262
  agent: s.agent === "claude" || s.agent === "codex" ? s.agent : null,
3236
3263
  agentPhase: s.agentPhase === "working" || s.agentPhase === "done" ? s.agentPhase : null,
3237
3264
  turnSeen: s.turnSeen !== false,
3265
+ checkbacks: Number(s.checkbacks) > 0 ? Number(s.checkbacks) : undefined,
3238
3266
  cols: Number.isInteger(s.cols) ? s.cols : undefined,
3239
3267
  rows: Number.isInteger(s.rows) ? s.rows : undefined,
3240
3268
  // These three were silently dropped here, so the switcher never saw
@@ -3909,10 +3937,10 @@ const App = () => {
3909
3937
  [...sessionsRef.current]
3910
3938
  .filter((s) => s.type !== "port")
3911
3939
  .sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
3912
- const hudStep = (dir: 1 | -1) => {
3940
+ const hudStep = (dir: 1 | -1, via: "keys" | "wheel" = "keys") => {
3913
3941
  const open = switchHudRef.current;
3914
3942
  if (open) {
3915
- setSwitchHud({ ring: open.ring, index: Math.min(open.ring.length - 1, Math.max(0, open.index + dir)) });
3943
+ setSwitchHud({ ...open, index: Math.min(open.ring.length - 1, Math.max(0, open.index + dir)) });
3916
3944
  return;
3917
3945
  }
3918
3946
  const ring = hudRing();
@@ -3920,7 +3948,7 @@ const App = () => {
3920
3948
  const curKey = activeSessionRoomRef.current;
3921
3949
  const cur = ring.findIndex((s) => s.name === curKey || s.internalName === curKey);
3922
3950
  const start = cur < 0 ? 0 : cur;
3923
- setSwitchHud({ ring, index: Math.min(ring.length - 1, Math.max(0, start + dir)) });
3951
+ setSwitchHud({ ring, index: Math.min(ring.length - 1, Math.max(0, start + dir)), via });
3924
3952
  };
3925
3953
  const hudCommit = () => {
3926
3954
  const open = switchHudRef.current;
@@ -3973,12 +4001,21 @@ const App = () => {
3973
4001
  const mod = isMacPlatform
3974
4002
  ? event.metaKey && !event.ctrlKey && !event.altKey
3975
4003
  : event.ctrlKey && event.shiftKey && !event.metaKey && !event.altKey;
4004
+ // esc cancels the HUD whether or not the modifier is still held — the
4005
+ // hint promises it, and with ⌘ down it used to fall through.
4006
+ if (event.key === "Escape" && switchHudRef.current) {
4007
+ event.preventDefault();
4008
+ event.stopPropagation();
4009
+ hudCancel();
4010
+ return;
4011
+ }
3976
4012
  if (!mod) {
3977
- if (event.key === "Escape" && switchHudRef.current) {
3978
- event.preventDefault();
3979
- event.stopPropagation();
3980
- hudCancel();
3981
- return;
4013
+ // A swipe-opened HUD waits for a decision: ⏎ switches, ← → step.
4014
+ if (switchHudRef.current?.via === "wheel") {
4015
+ if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); hudCommit(); return; }
4016
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
4017
+ event.preventDefault(); event.stopPropagation(); hudStep(event.key === "ArrowRight" ? 1 : -1, "wheel"); return;
4018
+ }
3982
4019
  }
3983
4020
  if (event.key === "Escape" && (drawerOpenRef.current || shortcutHelpRef.current)) {
3984
4021
  event.preventDefault();
@@ -4061,7 +4098,8 @@ const App = () => {
4061
4098
  // target, ⌘Tab-style. Any keyup counts: the modifier's own release AND
4062
4099
  // releasing it while another key is up both leave the flag false.
4063
4100
  const onKeyUp = (event: KeyboardEvent) => {
4064
- if (!switchHudRef.current) return;
4101
+ const open = switchHudRef.current;
4102
+ if (!open || open.via !== "keys") return; // a swipe-opened HUD lands on ⏎ or a click, not on a key release
4065
4103
  if (!(isMacPlatform ? event.metaKey : event.ctrlKey)) hudCommit();
4066
4104
  };
4067
4105
  window.addEventListener("keyup", onKeyUp, true);
@@ -4071,20 +4109,34 @@ const App = () => {
4071
4109
  // swipe never fires. Steps per 60pt of travel, settles into a commit.
4072
4110
  let wheelAccum = 0;
4073
4111
  let wheelTimer: number | null = null;
4112
+ const HUD_IDLE_CLOSE_MS = 8000;
4074
4113
  const onWheel = (event: WheelEvent) => {
4075
4114
  if (!session) return;
4076
4115
  const el = event.target as Element | null;
4077
4116
  if (el?.closest(".switcher-scroll, .views-panel, .drawer, input, textarea, [contenteditable]")) return;
4078
4117
  if (Math.abs(event.deltaX) <= Math.abs(event.deltaY) * 1.3) return;
4118
+ // A swipe never opens the HUD over a keyboard-held one.
4119
+ if (switchHudRef.current && switchHudRef.current.via !== "wheel") return;
4079
4120
  event.preventDefault();
4080
4121
  wheelAccum += event.deltaX;
4081
4122
  while (Math.abs(wheelAccum) >= 60) {
4082
4123
  const dir: 1 | -1 = wheelAccum > 0 ? 1 : -1;
4083
4124
  wheelAccum -= dir * 60;
4084
- hudStep(dir);
4125
+ hudStep(dir, "wheel");
4085
4126
  }
4127
+ // The gesture settling is NOT a decision. The HUD stays until ⏎ or a
4128
+ // click (switch) or esc; left alone, it closes without switching.
4086
4129
  if (wheelTimer) window.clearTimeout(wheelTimer);
4087
- wheelTimer = window.setTimeout(() => { wheelAccum = 0; hudCommit(); }, 320);
4130
+ wheelTimer = window.setTimeout(() => {
4131
+ wheelAccum = 0;
4132
+ if (switchHudRef.current?.via === "wheel") hudCancel();
4133
+ }, HUD_IDLE_CLOSE_MS);
4134
+ };
4135
+ hudPickRef.current = (index: number) => {
4136
+ const open = switchHudRef.current;
4137
+ if (!open) return;
4138
+ switchHudRef.current = { ...open, index };
4139
+ hudCommit();
4088
4140
  };
4089
4141
  window.addEventListener("wheel", onWheel, { passive: false, capture: true });
4090
4142
  return () => {
@@ -4115,7 +4167,7 @@ const App = () => {
4115
4167
  const curKey = session?.room ?? sessionLabel;
4116
4168
  const SLOT = 152; // card 140 + gap 12; must match .switch-hud CSS
4117
4169
  return (
4118
- <div className="switch-hud" aria-hidden="true">
4170
+ <div className={"switch-hud" + (switchHud.via === "wheel" ? " waits" : "")} aria-hidden="true">
4119
4171
  <div className="switch-hud-window">
4120
4172
  <div className="switch-hud-strip"
4121
4173
  style={{ transform: `translateX(-${switchHud.index * SLOT + 70}px)` }}>
@@ -4127,7 +4179,8 @@ const App = () => {
4127
4179
  <div key={key}
4128
4180
  className={"switch-hud-card"
4129
4181
  + (isTarget ? " is-target" : "")
4130
- + (isCurrent ? " is-current" : "")}>
4182
+ + (isCurrent ? " is-current" : "")}
4183
+ onClick={() => hudPickRef.current?.(i)}>
4131
4184
  <span className="switch-hud-name">{s.displayName || s.name}</span>
4132
4185
  {isCurrent && <span className="switch-hud-now">now</span>}
4133
4186
  </div>
@@ -4136,7 +4189,9 @@ const App = () => {
4136
4189
  </div>
4137
4190
  </div>
4138
4191
  <div className="switch-hud-hint">
4139
- {isMacPlatform ? "⌘J / ⌘L step · release ⌘ to switch" : "Ctrl+Shift+J / +L step · release to switch"} · esc cancels
4192
+ {switchHud.via === "wheel"
4193
+ ? "swipe or ← → to step · ⏎ or click to switch · esc cancels"
4194
+ : `${isMacPlatform ? "⌘J / ⌘L step · release ⌘ to switch" : "Ctrl+Shift+J / +L step · release to switch"} · esc cancels`}
4140
4195
  </div>
4141
4196
  </div>
4142
4197
  );
@@ -4244,7 +4299,7 @@ const App = () => {
4244
4299
  onRefresh={() => fetchSessions({ showLoading: false })}
4245
4300
  onNotice={showToast}
4246
4301
  tileWsBase={resolveWsUrl()}
4247
- onOpenViews={(scope) => setViewsOpen({ session: scope })}
4302
+ onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
4248
4303
  userName={name}
4249
4304
  terminalTheme={resolveTerminalTheme(themeMode)}
4250
4305
  />
@@ -4908,7 +4963,7 @@ const App = () => {
4908
4963
  tileWsBase={resolveWsUrl()}
4909
4964
  onFocusSession={focusSessionInPlace}
4910
4965
  folders={folders}
4911
- onOpenViews={(scope) => setViewsOpen({ session: scope })}
4966
+ onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
4912
4967
  userName={name}
4913
4968
  terminalTheme={resolveTerminalTheme(themeMode)}
4914
4969
  onOpenSettings={() => {
@@ -5052,7 +5107,7 @@ const App = () => {
5052
5107
  {/* Outside the mode ternary on purpose: Views opens from the hub, from a
5053
5108
  session, and from over the switcher, and it must outlive a switch. */}
5054
5109
  {viewsOpen && (
5055
- <ViewsPanel session={viewsOpen.session} sessions={sessions} dock={viewsOpen.dock}
5110
+ <ViewsPanel session={viewsOpen.session} item={viewsOpen.item} sessions={sessions} dock={viewsOpen.dock}
5056
5111
  onClose={() => setViewsOpen(null)} />
5057
5112
  )}
5058
5113
  </div>
@@ -1,4 +1,5 @@
1
- import { useEffect, useMemo, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { createReadWitness, wordCount } from "../utils/readWitness";
2
3
  import type { SwitcherSession } from "../utils/switcherModel";
3
4
 
4
5
  // The briefing, on the desktop wall — the same editions the iOS app renders,
@@ -16,7 +17,14 @@ type DigestItem = {
16
17
  headline: string;
17
18
  why?: string;
18
19
  urgency?: string;
20
+ // The chief-of-staff page: decide / watch / done, and an "also" roll-up
21
+ // of headline-only items. Older editions have no slot and render flat.
22
+ slot?: "decide" | "watch" | "done" | "also";
23
+ recommendation?: string;
24
+ replies?: string[];
19
25
  };
26
+ const SLOT_LABEL: Record<string, string> = { decide: "Decide", watch: "Watch", done: "Done" };
27
+ const SLOT_ORDER = ["decide", "watch", "done"];
20
28
 
21
29
  type Edition = {
22
30
  generated_at?: string;
@@ -63,6 +71,18 @@ export const DigestCard = ({ sessions, onOpen }: {
63
71
  onOpen: (session: SwitcherSession) => void;
64
72
  }) => {
65
73
  const [editions, setEditions] = useState<Edition[]>([]);
74
+ // The read witness: dwell on a visible story, with the human present,
75
+ // reports glimpsed/read to the daemon; opening a session from a story
76
+ // reports acted. The daemon's union across devices feeds the "new"
77
+ // badge below and tells the generator what may be told as a delta.
78
+ const witnessRef = useRef<ReturnType<typeof createReadWitness> | null>(null);
79
+ if (!witnessRef.current) {
80
+ witnessRef.current = createReadWitness((reads) => {
81
+ fetch("/api/digest/reads", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reads }), keepalive: true })
82
+ .catch(() => { /* offline: the next batch carries it */ });
83
+ });
84
+ }
85
+ useEffect(() => () => { witnessRef.current?.dispose(); witnessRef.current = null; }, []);
66
86
  const [dismissed, setDismissed] = useState(() => {
67
87
  try { return localStorage.getItem(DISMISS_KEY) || ""; } catch { return ""; }
68
88
  });
@@ -83,9 +103,13 @@ export const DigestCard = ({ sessions, onOpen }: {
83
103
  // the very first briefing, before an archive exists.
84
104
  Promise.all([
85
105
  fetch("/assets/digest-archive.json", { cache: "no-store" }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
86
- fetch("/assets/digest.json", { cache: "no-store" }).then((r) => (r.ok ? r.json() : null)).catch(() => null)
87
- ]).then(([archive, latest]: [{ editions?: Edition[] } | null, Edition | null]) => {
106
+ fetch("/assets/digest.json", { cache: "no-store" }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
107
+ fetch("/api/digest/reads", { cache: "no-store" }).then((r) => (r.ok ? r.json() : null)).catch(() => null)
108
+ ]).then(([archive, latest, serverReads]: [{ editions?: Edition[] } | null, Edition | null, { editionsSeen?: string[] } | null]) => {
88
109
  if (cancelled) return;
110
+ // Editions any device has witnessed are not "new" here either.
111
+ const seenElsewhere = Array.isArray(serverReads?.editionsSeen) ? serverReads!.editionsSeen! : [];
112
+ if (seenElsewhere.length) setSeen((prev) => { const next = new Set(prev); for (const x of seenElsewhere) next.add(x); saveSeen(next); return next; });
89
113
  let list: Edition[] = Array.isArray(archive?.editions) ? archive!.editions! : [];
90
114
  if (latest?.summary && (!list.length || list[0]?.generated_at !== latest.generated_at)) {
91
115
  list = [latest, ...list];
@@ -171,7 +195,8 @@ export const DigestCard = ({ sessions, onOpen }: {
171
195
  );
172
196
  }
173
197
 
174
- const openItem = (session: string) => {
198
+ const openItem = (session: string, edition?: string) => {
199
+ if (edition) witnessRef.current?.acted(edition, session);
175
200
  const t = find(session);
176
201
  if (t) onOpen(t);
177
202
  };
@@ -181,6 +206,63 @@ export const DigestCard = ({ sessions, onOpen }: {
181
206
  try { localStorage.setItem(MODE_KEY, m); } catch { /* ok */ }
182
207
  };
183
208
 
209
+ // One edition's page. New editions carry slots: Decide, Watch, Done as
210
+ // sections, then "Also" as a row of session chips — the roll-up that keeps
211
+ // a tap into each session without a paragraph each. Older editions (no
212
+ // slot) render as the flat list they were written as.
213
+ const renderItem = (item: DigestItem, s?: string) => (
214
+ <button
215
+ key={item.session + item.headline}
216
+ ref={witnessRef.current!.ref({ edition: s || "", item: item.session, words: wordCount(item.headline + " " + (item.why || "") + " " + (item.recommendation || "")) })}
217
+ className={"digest-item " + urgencyClass(item.urgency)}
218
+ onClick={() => openItem(item.session, s)}
219
+ >
220
+ <span className="digest-dateline">{sessionLabel(item.session)}</span>
221
+ <span className="digest-headline">{item.headline}</span>
222
+ {item.why ? <span className="digest-why">{item.why}</span> : null}
223
+ {item.recommendation ? <span className="digest-reco">→ {item.recommendation}</span> : null}
224
+ {item.replies?.length ? (
225
+ <span className="digest-replies">{item.replies.map((r) => <span key={r} className="digest-reply">{r}</span>)}</span>
226
+ ) : null}
227
+ </button>
228
+ );
229
+ const renderEdition = (ed: Edition, s?: string) => {
230
+ const items = ed.items || [];
231
+ if (!items.some((it) => it.slot)) return items.map((it) => renderItem(it, s));
232
+ const also = items.filter((it) => it.slot === "also");
233
+ return (
234
+ <>
235
+ {SLOT_ORDER.map((slot) => {
236
+ const rows = items.filter((it) => it.slot === slot);
237
+ if (!rows.length) return null;
238
+ return (
239
+ <div key={slot} className={"digest-slot slot-" + slot}>
240
+ <div className="digest-slot-head">{SLOT_LABEL[slot]}</div>
241
+ {rows.map((it) => renderItem(it, s))}
242
+ </div>
243
+ );
244
+ })}
245
+ {also.length ? (
246
+ <div className="digest-also">
247
+ <span className="digest-slot-head">Also</span>
248
+ {also.map((it) => (
249
+ <button
250
+ key={it.session}
251
+ ref={witnessRef.current!.ref({ edition: s || "", item: it.session, words: wordCount(it.headline) })}
252
+ className="digest-also-chip"
253
+ title={it.headline}
254
+ onClick={() => openItem(it.session, s)}
255
+ >
256
+ <span className="digest-also-name">{sessionLabel(it.session)}</span>
257
+ <span className="digest-also-head">{it.headline}</span>
258
+ </button>
259
+ ))}
260
+ </div>
261
+ ) : null}
262
+ </>
263
+ );
264
+ };
265
+
184
266
  const toggleEdition = (s?: string) => {
185
267
  if (!s) return;
186
268
  setOpenEditions((prev) => {
@@ -234,17 +316,7 @@ export const DigestCard = ({ sessions, onOpen }: {
234
316
  <span className="digest-edition-summary">{ed.summary}</span>
235
317
  <span className="digest-caret">{open ? "▾" : "▸"}</span>
236
318
  </button>
237
- {open && (ed.items || []).map((item) => (
238
- <button
239
- key={item.session + item.headline}
240
- className={"digest-item " + urgencyClass(item.urgency)}
241
- onClick={() => openItem(item.session)}
242
- >
243
- <span className="digest-dateline">{sessionLabel(item.session)}</span>
244
- <span className="digest-headline">{item.headline}</span>
245
- {item.why ? <span className="digest-why">{item.why}</span> : null}
246
- </button>
247
- ))}
319
+ {open && renderEdition(ed, s)}
248
320
  </section>
249
321
  );
250
322
  })}
@@ -271,14 +343,15 @@ export const DigestCard = ({ sessions, onOpen }: {
271
343
  into the live session. */}
272
344
  {open && (
273
345
  <div className="digest-thread">
274
- <button className="digest-open-session" onClick={() => openItem(g.session)}>
346
+ <button className="digest-open-session" onClick={() => openItem(g.session, newest?.at)}>
275
347
  open {sessionLabel(g.session)} →
276
348
  </button>
277
349
  {g.items.map((item, i) => (
278
- <div key={i} className={"digest-thread-item " + urgencyClass(item.urgency)}>
350
+ <div key={i} ref={witnessRef.current!.ref({ edition: item.at || "", item: g.session, words: wordCount(item.headline + " " + (item.why || "")) })} className={"digest-thread-item " + urgencyClass(item.urgency)}>
279
351
  <span className="digest-when">{ago(item.at)}</span>
280
352
  <span className="digest-headline">{item.headline}</span>
281
353
  {item.why ? <span className="digest-why">{item.why}</span> : null}
354
+ {item.recommendation ? <span className="digest-reco">→ {item.recommendation}</span> : null}
282
355
  </div>
283
356
  ))}
284
357
  </div>
@@ -82,6 +82,9 @@ export const SecondaryPane = ({
82
82
  rows: sizeRef.current.rows,
83
83
  fontSize,
84
84
  macOptionClickForcesSelection: true,
85
+ // Decorations (inline hop view cards, utils/viewBlocks.ts) are a
86
+ // proposed xterm API; this is the switch that lets them register.
87
+ allowProposedApi: true,
85
88
  fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
86
89
  theme: theme as never,
87
90
  scrollback: 2000,
@@ -18,6 +18,7 @@ import { getMathTip } from "../utils/mathTooltip";
18
18
  import { claimOnTileFocus, sizeIsForeign } from "../utils/sizeClaim";
19
19
  import { cardPhase, phaseLabel, phaseTitle } from "../utils/agentPhase";
20
20
  import { installDragSelect } from "../utils/dragSelect";
21
+ import { enableViewBlocks } from "../utils/viewBlocks";
21
22
  import { urlAtCell, cellAtPoint } from "../utils/urlAtCell";
22
23
  import { attachScrollFlywheel } from "../utils/scrollFlywheel";
23
24
  import { ContextMenu, type MenuRequest } from "./ContextMenu";
@@ -116,7 +117,7 @@ type Props = {
116
117
  onFind?: () => void;
117
118
  // Open the Views panel. No argument = the whole fleet (header button); an
118
119
  // internalName = just that session (a card's chip).
119
- onOpenViews?: (session?: string) => void;
120
+ onOpenViews?: (session?: string, item?: string) => void;
120
121
  };
121
122
 
122
123
  type Sheet = {
@@ -357,9 +358,11 @@ const runningApp = (s: SwitcherSession) => {
357
358
  // responsiveness and whether keystrokes are accepted. The old design — an
358
359
  // HTML preview swapped for a fresh xterm + socket behind a veil — is gone.
359
360
  const LIVETILE_POLL_MS = 5000;
360
- const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp, codexApp = false, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice, onSender }: {
361
+ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp, codexApp = false, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice, onSender, onOpenView }: {
361
362
  /** The wall keeps a registry of live tiles' input senders (file drops). */
362
363
  onSender?: (send: ((data: string) => void) | null) => void;
364
+ /** An inline hop view card's Open: the reader for that session, on that item. */
365
+ onOpenView?: (session: string, item: string) => void;
363
366
  wsBase: string; room: string; userName: string; theme: object | undefined;
364
367
  /**
365
368
  * `live` says the tile is the focused one; `deliberate` says the human
@@ -430,6 +433,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
430
433
  const looksLikeClaude = () => isClaudeSurface(claudeAppRef.current, termRef.current as never);
431
434
  const codexAppRef = useRef(codexApp);
432
435
  codexAppRef.current = codexApp;
436
+ const onOpenViewRef = useRef(onOpenView);
437
+ onOpenViewRef.current = onOpenView;
433
438
  if (!voiceHoldRef.current) {
434
439
  voiceHoldRef.current = createVoiceHold({
435
440
  send: (data) => sendInputRef.current?.(data),
@@ -505,6 +510,9 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
505
510
  rows: activeRows && activeRows > 1 ? activeRows : 24,
506
511
  scrollback: 2000,
507
512
  macOptionClickForcesSelection: true,
513
+ // Decorations (inline hop view cards, utils/viewBlocks.ts) are a
514
+ // proposed xterm API; this is the switch that lets them register.
515
+ allowProposedApi: true,
508
516
  // Same scroll feel as the full-screen terminal: one wheel notch moves
509
517
  // 4 lines (xterm's default of 1 is the "tiles feel sluggish" report),
510
518
  // Shift+wheel blasts. Momentum is attached below.
@@ -535,6 +543,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
535
543
  window.open(target, "_blank", "noopener");
536
544
  }));
537
545
  enableMathHover(term, getMathTip()); // rendered math on hover, here too
546
+ const uninstallViewBlocks = enableViewBlocks(term as never, (b) => onOpenViewRef.current?.(b.session, b.name));
538
547
  term.open(box);
539
548
  // Plain drag selects in Claude/Codex tiles even though they track the
540
549
  // mouse; the wheel still reaches them. See utils/dragSelect.ts.
@@ -758,6 +767,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
758
767
  detachFlywheel();
759
768
  ro?.disconnect();
760
769
  sub.dispose();
770
+ uninstallViewBlocks();
761
771
  uninstallDragSelect();
762
772
  term.dispose();
763
773
  termRef.current = null;
@@ -2996,6 +3006,11 @@ export const SessionSwitcher = ({
2996
3006
  </span>
2997
3007
  )}
2998
3008
  {!current && s.starting && !s.active && <span className="switcher-chip starting">STARTING</span>}
3009
+ {!!s.checkbacks && (
3010
+ <span className="switcher-chip checkback" title={`${s.checkbacks} scheduled check-back${s.checkbacks === 1 ? "" : "s"} will be typed into this session (hop checkback --list)`}>
3011
+ ⏰ {s.checkbacks}
3012
+ </span>
3013
+ )}
2999
3014
  {phase && (
3000
3015
  <span className={`switcher-chip phase ${phase}`} title={phaseTitle(phase)}>
3001
3016
  <span className="phase-dot" aria-hidden="true" />{phaseLabel(phase)}
@@ -3046,6 +3061,7 @@ export const SessionSwitcher = ({
3046
3061
  onUnfocus={() => setFocusedKey(null)}
3047
3062
  onNotice={onNotice}
3048
3063
  onSender={(fn) => { if (fn) tileSendersRef.current.set(key, fn); else tileSendersRef.current.delete(key); }}
3064
+ onOpenView={(scope, item) => onOpenViews?.(scope, item)}
3049
3065
  />
3050
3066
  </div>
3051
3067
  ) : (
@@ -65,6 +65,9 @@ type Props = {
65
65
  // the screen's edge was farther from where the eye already is. The dock
66
66
  // stays available for a caller that wants it.
67
67
  dock?: boolean;
68
+ // Open straight on this item (its file name within `session`), e.g. from
69
+ // an inline hop view card in the terminal.
70
+ item?: string;
68
71
  onClose: () => void;
69
72
  };
70
73
 
@@ -176,7 +179,7 @@ const DEFAULT_DOCK_W = 760;
176
179
  const clampDockW = (w: number) =>
177
180
  Math.max(560, Math.min(Math.round(window.innerWidth * 0.75), w));
178
181
 
179
- export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Props) => {
182
+ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose }: Props) => {
180
183
  const [items, setItems] = useState<ViewItem[] | null>(null);
181
184
  const [failed, setFailed] = useState(false);
182
185
  const [preview, setPreview] = useState<ViewItem | null>(null);
@@ -276,7 +279,8 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
276
279
  useEffect(() => {
277
280
  if (!items || !canPreview()) return;
278
281
  if (preview && flatRows.some((r) => r.path === preview.path)) return;
279
- setPreview(flatRows[0] ?? null);
282
+ const asked = item ? flatRows.find((r) => r.name === item) : null;
283
+ setPreview(asked ?? flatRows[0] ?? null);
280
284
  // eslint-disable-next-line react-hooks/exhaustive-deps
281
285
  }, [items, flatRows]);
282
286