hop2 0.11.64 → 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) |
@@ -22,6 +22,8 @@ import { getMathTip } from "./utils/mathTooltip";
22
22
  import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
23
23
  import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
24
24
  import { claimOnAttach, claimOnClick, isPlainClick } from "./utils/sizeClaim";
25
+ import { installDragSelect } from "./utils/dragSelect";
26
+ import { enableViewBlocks } from "./utils/viewBlocks";
25
27
  import { MobileKeyboard } from "./components/MobileKeyboard";
26
28
  import { SessionSwitcher } from "./components/SessionSwitcher";
27
29
  import { SecondaryPane } from "./components/SecondaryPane";
@@ -265,6 +267,8 @@ type SessionInfo = {
265
267
  // a human has opened the session since it finished (see lib/agent-phase.js).
266
268
  agentPhase?: "working" | "done" | null;
267
269
  turnSeen?: boolean;
270
+ // Scheduled check-backs waiting on this session (hop checkback).
271
+ checkbacks?: number;
268
272
  attentionReason?: string;
269
273
  attentionNote?: string;
270
274
  cols?: number;
@@ -476,8 +480,14 @@ const App = () => {
476
480
  // while you cycle (⌘J/⌘L held / two-finger swipe) and commits on release — like
477
481
  // ⌘Tab. Frozen ring so neighbours don't reshuffle mid-cycle (same rule as
478
482
  // the iOS swipe order). Ref mirror for the keyup/wheel listeners.
479
- const [switchHud, setSwitchHud] = useState<{ ring: SessionInfo[]; index: number } | null>(null);
480
- 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);
481
491
  switchHudRef.current = switchHud;
482
492
  // Server-owned folders (Manual mode). Kept in App because the sessions
483
493
  // fetch already carries them.
@@ -699,7 +709,7 @@ const App = () => {
699
709
  const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
700
710
  // Published views (`hop view`). `session` scopes the panel to one session;
701
711
  // an object with none open it fleet-wide. Null = closed.
702
- const [viewsOpen, setViewsOpen] = useState<{ session?: string; dock?: boolean } | null>(null);
712
+ const [viewsOpen, setViewsOpen] = useState<{ session?: string; dock?: boolean; item?: string } | null>(null);
703
713
  const sessionsRef = useRef(sessions);
704
714
  sessionsRef.current = sessions;
705
715
  // Claude Code titles its process with a bare version number; hop's session
@@ -743,6 +753,14 @@ const App = () => {
743
753
  return screenLooksLikeClaude();
744
754
  };
745
755
  foregroundIsClaudeRef.current = foregroundIsClaude;
756
+ // Which agent the current session runs, by the daemon's hook records.
757
+ const currentSessionAgentRef = useRef<(() => "claude" | "codex" | null) | null>(null);
758
+ currentSessionAgentRef.current = () => {
759
+ const room = activeSessionRoomRef.current;
760
+ if (!room) return null;
761
+ const s = sessionsRef.current.find((x) => (x.internalName || x.name) === room);
762
+ return s?.agent || null;
763
+ };
746
764
  const drawerOpenRef = useRef(drawerOpen);
747
765
  drawerOpenRef.current = drawerOpen;
748
766
  const shortcutHelpRef = useRef(shortcutHelpOpen);
@@ -1496,12 +1514,17 @@ const App = () => {
1496
1514
  lastSentSizeRef.current = { cols, rows };
1497
1515
  const deliberate = claim === "attach" && deliberateAttachRef.current;
1498
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;
1499
1521
  sendMessage({
1500
1522
  type: "resize",
1501
1523
  cols,
1502
1524
  rows,
1503
1525
  ...(claim ? { claim } : {}),
1504
- ...(deliberate ? { user: true } : {})
1526
+ ...(deliberate ? { user: true } : {}),
1527
+ ...(take ? { take: true } : {})
1505
1528
  });
1506
1529
  };
1507
1530
 
@@ -1510,6 +1533,8 @@ const App = () => {
1510
1533
  * here. Quiet when the session is already at our fit, so an ordinary click
1511
1534
  * (or a selection drag) never pushes a resize.
1512
1535
  */
1536
+ // Set by an explicit Fit: the next resize carries take:true.
1537
+ const takeSizeRef = useRef(false);
1513
1538
  const claimSizeHere = (force = false) => {
1514
1539
  if (!force && !claimOnClick({
1515
1540
  viewMode: viewModeRef.current,
@@ -1519,6 +1544,7 @@ const App = () => {
1519
1544
  })) return;
1520
1545
  attachClaimPendingRef.current = true;
1521
1546
  deliberateAttachRef.current = true;
1547
+ if (force) takeSizeRef.current = true;
1522
1548
  fitToViewport();
1523
1549
  handleResize();
1524
1550
  };
@@ -2098,6 +2124,12 @@ const App = () => {
2098
2124
  fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--font-terminal").trim()
2099
2125
  || "Menlo, Monaco, 'Courier New', monospace",
2100
2126
  fontSize,
2127
+ // ⌥-drag always selects, iTerm's escape hatch for apps that track the
2128
+ // mouse. (Plain drag selects too in agent sessions — utils/dragSelect.)
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,
2101
2133
  lineHeight: 1.3,
2102
2134
  cursorBlink: true,
2103
2135
  scrollback: 50000,
@@ -2135,7 +2167,18 @@ const App = () => {
2135
2167
  // KaTeX, a click pins it. What `hop math` could only approximate in
2136
2168
  // Unicode, the browser around the terminal draws properly.
2137
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 }));
2138
2173
  terminal.open(containerRef.current);
2174
+ // Plain drag selects in Claude/Codex sessions even though they track the
2175
+ // mouse; the wheel still reaches them. See utils/dragSelect.ts.
2176
+ // Hooked on the SCROLL wrapper, not xterm's host: a press in the gutter
2177
+ // lands on the wrapper (which still starts a selection when tracking is
2178
+ // off), and a capture listener only sees presses on its own subtree.
2179
+ (terminal as any).__dragSelectCleanup = installDragSelect(terminal as never,
2180
+ (containerRef.current.closest(".terminal-scroll") as HTMLElement | null) || containerRef.current, () =>
2181
+ foregroundIsClaudeRef.current?.() === true || currentSessionAgentRef.current?.() === "codex");
2139
2182
 
2140
2183
  // GPU-accelerated rendering (same renderer VS Code uses). Must load after
2141
2184
  // open(). If WebGL isn't available (old GPU, blocklisted driver) the
@@ -3032,6 +3075,14 @@ const App = () => {
3032
3075
  (terminal as any).__overlayScrollbarCleanup();
3033
3076
  (terminal as any).__overlayScrollbarCleanup = null;
3034
3077
  }
3078
+ if ((terminal as any).__viewBlocksCleanup) {
3079
+ (terminal as any).__viewBlocksCleanup();
3080
+ (terminal as any).__viewBlocksCleanup = null;
3081
+ }
3082
+ if ((terminal as any).__dragSelectCleanup) {
3083
+ (terminal as any).__dragSelectCleanup();
3084
+ (terminal as any).__dragSelectCleanup = null;
3085
+ }
3035
3086
  terminal.dispose();
3036
3087
  termRef.current = null;
3037
3088
  fitRef.current = null;
@@ -3211,6 +3262,7 @@ const App = () => {
3211
3262
  agent: s.agent === "claude" || s.agent === "codex" ? s.agent : null,
3212
3263
  agentPhase: s.agentPhase === "working" || s.agentPhase === "done" ? s.agentPhase : null,
3213
3264
  turnSeen: s.turnSeen !== false,
3265
+ checkbacks: Number(s.checkbacks) > 0 ? Number(s.checkbacks) : undefined,
3214
3266
  cols: Number.isInteger(s.cols) ? s.cols : undefined,
3215
3267
  rows: Number.isInteger(s.rows) ? s.rows : undefined,
3216
3268
  // These three were silently dropped here, so the switcher never saw
@@ -3885,10 +3937,10 @@ const App = () => {
3885
3937
  [...sessionsRef.current]
3886
3938
  .filter((s) => s.type !== "port")
3887
3939
  .sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
3888
- const hudStep = (dir: 1 | -1) => {
3940
+ const hudStep = (dir: 1 | -1, via: "keys" | "wheel" = "keys") => {
3889
3941
  const open = switchHudRef.current;
3890
3942
  if (open) {
3891
- 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)) });
3892
3944
  return;
3893
3945
  }
3894
3946
  const ring = hudRing();
@@ -3896,7 +3948,7 @@ const App = () => {
3896
3948
  const curKey = activeSessionRoomRef.current;
3897
3949
  const cur = ring.findIndex((s) => s.name === curKey || s.internalName === curKey);
3898
3950
  const start = cur < 0 ? 0 : cur;
3899
- 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 });
3900
3952
  };
3901
3953
  const hudCommit = () => {
3902
3954
  const open = switchHudRef.current;
@@ -3949,12 +4001,21 @@ const App = () => {
3949
4001
  const mod = isMacPlatform
3950
4002
  ? event.metaKey && !event.ctrlKey && !event.altKey
3951
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
+ }
3952
4012
  if (!mod) {
3953
- if (event.key === "Escape" && switchHudRef.current) {
3954
- event.preventDefault();
3955
- event.stopPropagation();
3956
- hudCancel();
3957
- 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
+ }
3958
4019
  }
3959
4020
  if (event.key === "Escape" && (drawerOpenRef.current || shortcutHelpRef.current)) {
3960
4021
  event.preventDefault();
@@ -4037,7 +4098,8 @@ const App = () => {
4037
4098
  // target, ⌘Tab-style. Any keyup counts: the modifier's own release AND
4038
4099
  // releasing it while another key is up both leave the flag false.
4039
4100
  const onKeyUp = (event: KeyboardEvent) => {
4040
- 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
4041
4103
  if (!(isMacPlatform ? event.metaKey : event.ctrlKey)) hudCommit();
4042
4104
  };
4043
4105
  window.addEventListener("keyup", onKeyUp, true);
@@ -4047,20 +4109,34 @@ const App = () => {
4047
4109
  // swipe never fires. Steps per 60pt of travel, settles into a commit.
4048
4110
  let wheelAccum = 0;
4049
4111
  let wheelTimer: number | null = null;
4112
+ const HUD_IDLE_CLOSE_MS = 8000;
4050
4113
  const onWheel = (event: WheelEvent) => {
4051
4114
  if (!session) return;
4052
4115
  const el = event.target as Element | null;
4053
4116
  if (el?.closest(".switcher-scroll, .views-panel, .drawer, input, textarea, [contenteditable]")) return;
4054
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;
4055
4120
  event.preventDefault();
4056
4121
  wheelAccum += event.deltaX;
4057
4122
  while (Math.abs(wheelAccum) >= 60) {
4058
4123
  const dir: 1 | -1 = wheelAccum > 0 ? 1 : -1;
4059
4124
  wheelAccum -= dir * 60;
4060
- hudStep(dir);
4125
+ hudStep(dir, "wheel");
4061
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.
4062
4129
  if (wheelTimer) window.clearTimeout(wheelTimer);
4063
- 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();
4064
4140
  };
4065
4141
  window.addEventListener("wheel", onWheel, { passive: false, capture: true });
4066
4142
  return () => {
@@ -4091,7 +4167,7 @@ const App = () => {
4091
4167
  const curKey = session?.room ?? sessionLabel;
4092
4168
  const SLOT = 152; // card 140 + gap 12; must match .switch-hud CSS
4093
4169
  return (
4094
- <div className="switch-hud" aria-hidden="true">
4170
+ <div className={"switch-hud" + (switchHud.via === "wheel" ? " waits" : "")} aria-hidden="true">
4095
4171
  <div className="switch-hud-window">
4096
4172
  <div className="switch-hud-strip"
4097
4173
  style={{ transform: `translateX(-${switchHud.index * SLOT + 70}px)` }}>
@@ -4103,7 +4179,8 @@ const App = () => {
4103
4179
  <div key={key}
4104
4180
  className={"switch-hud-card"
4105
4181
  + (isTarget ? " is-target" : "")
4106
- + (isCurrent ? " is-current" : "")}>
4182
+ + (isCurrent ? " is-current" : "")}
4183
+ onClick={() => hudPickRef.current?.(i)}>
4107
4184
  <span className="switch-hud-name">{s.displayName || s.name}</span>
4108
4185
  {isCurrent && <span className="switch-hud-now">now</span>}
4109
4186
  </div>
@@ -4112,7 +4189,9 @@ const App = () => {
4112
4189
  </div>
4113
4190
  </div>
4114
4191
  <div className="switch-hud-hint">
4115
- {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`}
4116
4195
  </div>
4117
4196
  </div>
4118
4197
  );
@@ -4220,7 +4299,7 @@ const App = () => {
4220
4299
  onRefresh={() => fetchSessions({ showLoading: false })}
4221
4300
  onNotice={showToast}
4222
4301
  tileWsBase={resolveWsUrl()}
4223
- onOpenViews={(scope) => setViewsOpen({ session: scope })}
4302
+ onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
4224
4303
  userName={name}
4225
4304
  terminalTheme={resolveTerminalTheme(themeMode)}
4226
4305
  />
@@ -4884,7 +4963,7 @@ const App = () => {
4884
4963
  tileWsBase={resolveWsUrl()}
4885
4964
  onFocusSession={focusSessionInPlace}
4886
4965
  folders={folders}
4887
- onOpenViews={(scope) => setViewsOpen({ session: scope })}
4966
+ onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
4888
4967
  userName={name}
4889
4968
  terminalTheme={resolveTerminalTheme(themeMode)}
4890
4969
  onOpenSettings={() => {
@@ -5028,7 +5107,7 @@ const App = () => {
5028
5107
  {/* Outside the mode ternary on purpose: Views opens from the hub, from a
5029
5108
  session, and from over the switcher, and it must outlive a switch. */}
5030
5109
  {viewsOpen && (
5031
- <ViewsPanel session={viewsOpen.session} sessions={sessions} dock={viewsOpen.dock}
5110
+ <ViewsPanel session={viewsOpen.session} item={viewsOpen.item} sessions={sessions} dock={viewsOpen.dock}
5032
5111
  onClose={() => setViewsOpen(null)} />
5033
5112
  )}
5034
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>
@@ -1,4 +1,6 @@
1
1
  import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
2
+ import { installDragSelect } from "../utils/dragSelect";
3
+ import { bufferLooksLikeClaude } from "../utils/voiceHold";
2
4
  import { Terminal } from "@xterm/xterm";
3
5
  import { FitAddon } from "@xterm/addon-fit";
4
6
  import { safeParseServerMessage } from "hay-shared";
@@ -58,6 +60,8 @@ export const SecondaryPane = ({
58
60
  onSplit,
59
61
  onZoom
60
62
  }: Props) => {
63
+ const procLabelRef = useRef(procLabel);
64
+ procLabelRef.current = procLabel;
61
65
  const hostRef = useRef<HTMLDivElement>(null);
62
66
  const boxRef = useRef<HTMLDivElement>(null);
63
67
  const termRef = useRef<Terminal | null>(null);
@@ -77,6 +81,10 @@ export const SecondaryPane = ({
77
81
  cols: sizeRef.current.cols,
78
82
  rows: sizeRef.current.rows,
79
83
  fontSize,
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,
80
88
  fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
81
89
  theme: theme as never,
82
90
  scrollback: 2000,
@@ -86,6 +94,10 @@ export const SecondaryPane = ({
86
94
  term.loadAddon(fitAddon);
87
95
  enableMathHover(term, getMathTip());
88
96
  term.open(hostRef.current);
97
+ // Plain drag selects in Claude/Codex panes even though they track the
98
+ // mouse; the wheel still reaches them. See utils/dragSelect.ts.
99
+ const uninstallDragSelect = installDragSelect(term as never, hostRef.current, () =>
100
+ /^(claude|codex)$/i.test(procLabelRef.current || "") || bufferLooksLikeClaude(term as never));
89
101
  termRef.current = term;
90
102
 
91
103
  let shouldReconnect = true;
@@ -222,6 +234,7 @@ export const SecondaryPane = ({
222
234
  ro.disconnect();
223
235
  dataSub.dispose();
224
236
  try { wsRef.current?.close(); } catch { /* ignore */ }
237
+ uninstallDragSelect();
225
238
  term.dispose();
226
239
  termRef.current = null;
227
240
  };