hop2 0.11.65 → 0.11.67

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,8 @@ 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";
27
+ import { CheckbacksPanel } from "./components/CheckbacksPanel";
26
28
  import { MobileKeyboard } from "./components/MobileKeyboard";
27
29
  import { SessionSwitcher } from "./components/SessionSwitcher";
28
30
  import { SecondaryPane } from "./components/SecondaryPane";
@@ -266,6 +268,8 @@ type SessionInfo = {
266
268
  // a human has opened the session since it finished (see lib/agent-phase.js).
267
269
  agentPhase?: "working" | "done" | null;
268
270
  turnSeen?: boolean;
271
+ // Scheduled check-backs waiting on this session (hop checkback).
272
+ checkbacks?: number;
269
273
  attentionReason?: string;
270
274
  attentionNote?: string;
271
275
  cols?: number;
@@ -477,8 +481,14 @@ const App = () => {
477
481
  // while you cycle (⌘J/⌘L held / two-finger swipe) and commits on release — like
478
482
  // ⌘Tab. Frozen ring so neighbours don't reshuffle mid-cycle (same rule as
479
483
  // 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);
484
+ // via: how the HUD was opened. "keys" (⌘J/⌘L) lands on the modifier's
485
+ // release — the hold IS the deciding time. "wheel" (a two-finger swipe)
486
+ // has no release, so it waits: ⏎ or a click on a card switches, esc
487
+ // cancels, and a quiet spell closes it without switching. It used to
488
+ // commit 320ms after the swipe settled, i.e. while you were still looking.
489
+ const [switchHud, setSwitchHud] = useState<{ ring: SessionInfo[]; index: number; via: "keys" | "wheel" } | null>(null);
490
+ const switchHudRef = useRef<{ ring: SessionInfo[]; index: number; via: "keys" | "wheel" } | null>(null);
491
+ const hudPickRef = useRef<((index: number) => void) | null>(null);
482
492
  switchHudRef.current = switchHud;
483
493
  // Server-owned folders (Manual mode). Kept in App because the sessions
484
494
  // fetch already carries them.
@@ -700,7 +710,9 @@ const App = () => {
700
710
  const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
701
711
  // Published views (`hop view`). `session` scopes the panel to one session;
702
712
  // an object with none open it fleet-wide. Null = closed.
703
- const [viewsOpen, setViewsOpen] = useState<{ session?: string; dock?: boolean } | null>(null);
713
+ const [viewsOpen, setViewsOpen] = useState<{ session?: string; dock?: boolean; item?: string } | null>(null);
714
+ // The ⏰ chip's window: what is waiting on a session.
715
+ const [checkbacksOpen, setCheckbacksOpen] = useState<string | null>(null);
704
716
  const sessionsRef = useRef(sessions);
705
717
  sessionsRef.current = sessions;
706
718
  // Claude Code titles its process with a bare version number; hop's session
@@ -1505,12 +1517,17 @@ const App = () => {
1505
1517
  lastSentSizeRef.current = { cols, rows };
1506
1518
  const deliberate = claim === "attach" && deliberateAttachRef.current;
1507
1519
  if (deliberate) deliberateAttachRef.current = false;
1520
+ // An explicit Fit says "use MY size" even over someone typing now;
1521
+ // opening/clicking only takes the size from an idle or absent owner.
1522
+ const take = takeSizeRef.current;
1523
+ takeSizeRef.current = false;
1508
1524
  sendMessage({
1509
1525
  type: "resize",
1510
1526
  cols,
1511
1527
  rows,
1512
1528
  ...(claim ? { claim } : {}),
1513
- ...(deliberate ? { user: true } : {})
1529
+ ...(deliberate ? { user: true } : {}),
1530
+ ...(take ? { take: true } : {})
1514
1531
  });
1515
1532
  };
1516
1533
 
@@ -1519,6 +1536,8 @@ const App = () => {
1519
1536
  * here. Quiet when the session is already at our fit, so an ordinary click
1520
1537
  * (or a selection drag) never pushes a resize.
1521
1538
  */
1539
+ // Set by an explicit Fit: the next resize carries take:true.
1540
+ const takeSizeRef = useRef(false);
1522
1541
  const claimSizeHere = (force = false) => {
1523
1542
  if (!force && !claimOnClick({
1524
1543
  viewMode: viewModeRef.current,
@@ -1528,6 +1547,7 @@ const App = () => {
1528
1547
  })) return;
1529
1548
  attachClaimPendingRef.current = true;
1530
1549
  deliberateAttachRef.current = true;
1550
+ if (force) takeSizeRef.current = true;
1531
1551
  fitToViewport();
1532
1552
  handleResize();
1533
1553
  };
@@ -2110,6 +2130,9 @@ const App = () => {
2110
2130
  // ⌥-drag always selects, iTerm's escape hatch for apps that track the
2111
2131
  // mouse. (Plain drag selects too in agent sessions — utils/dragSelect.)
2112
2132
  macOptionClickForcesSelection: true,
2133
+ // Decorations (inline hop view cards, utils/viewBlocks.ts) are a
2134
+ // proposed xterm API; this is the switch that lets them register.
2135
+ allowProposedApi: true,
2113
2136
  lineHeight: 1.3,
2114
2137
  cursorBlink: true,
2115
2138
  scrollback: 50000,
@@ -2147,6 +2170,9 @@ const App = () => {
2147
2170
  // KaTeX, a click pins it. What `hop math` could only approximate in
2148
2171
  // Unicode, the browser around the terminal draws properly.
2149
2172
  enableMathHover(terminal, getMathTip());
2173
+ // `hop view` deliveries become small inline cards where they printed;
2174
+ // Open lands in the Views reader on that item.
2175
+ (terminal as any).__viewBlocksCleanup = enableViewBlocks(terminal as never, (b) => setViewsOpen({ session: b.session, item: b.name }));
2150
2176
  terminal.open(containerRef.current);
2151
2177
  // Plain drag selects in Claude/Codex sessions even though they track the
2152
2178
  // mouse; the wheel still reaches them. See utils/dragSelect.ts.
@@ -3052,6 +3078,10 @@ const App = () => {
3052
3078
  (terminal as any).__overlayScrollbarCleanup();
3053
3079
  (terminal as any).__overlayScrollbarCleanup = null;
3054
3080
  }
3081
+ if ((terminal as any).__viewBlocksCleanup) {
3082
+ (terminal as any).__viewBlocksCleanup();
3083
+ (terminal as any).__viewBlocksCleanup = null;
3084
+ }
3055
3085
  if ((terminal as any).__dragSelectCleanup) {
3056
3086
  (terminal as any).__dragSelectCleanup();
3057
3087
  (terminal as any).__dragSelectCleanup = null;
@@ -3235,6 +3265,7 @@ const App = () => {
3235
3265
  agent: s.agent === "claude" || s.agent === "codex" ? s.agent : null,
3236
3266
  agentPhase: s.agentPhase === "working" || s.agentPhase === "done" ? s.agentPhase : null,
3237
3267
  turnSeen: s.turnSeen !== false,
3268
+ checkbacks: Number(s.checkbacks) > 0 ? Number(s.checkbacks) : undefined,
3238
3269
  cols: Number.isInteger(s.cols) ? s.cols : undefined,
3239
3270
  rows: Number.isInteger(s.rows) ? s.rows : undefined,
3240
3271
  // These three were silently dropped here, so the switcher never saw
@@ -3469,9 +3500,18 @@ const App = () => {
3469
3500
  // lands full-screen in the terminal you were just typing in. State-only:
3470
3501
  // while the wall is open the full-screen socket stays down (one client per
3471
3502
  // tab); the wall-close deliberate attach performs the actual connect.
3503
+ // Opening a session IS reading it. The daemon's seen witness says so on
3504
+ // the next poll (≤5s); the card should not wait for it — mark the turn
3505
+ // read locally now, and poll soon so the daemon's own verdict replaces
3506
+ // the local one.
3507
+ const markTurnSeenLocally = (key: string) => {
3508
+ setSessions((prev) => prev.map((s) => ((s.internalName || s.name) === key && s.turnSeen === false ? { ...s, turnSeen: true } : s)));
3509
+ window.setTimeout(() => { void fetchSessions({ showLoading: false }); }, 900);
3510
+ };
3472
3511
  const focusSessionInPlace = (nextSession: SessionInfo) => {
3473
3512
  const targetRoom = nextSession.internalName || nextSession.name;
3474
3513
  if ((session?.room ?? null) === targetRoom) return;
3514
+ markTurnSeenLocally(targetRoom);
3475
3515
  const markers = loadSeenMarkers();
3476
3516
  markers[targetRoom] = { out: nextSession.lastActivityAt || 0, bell: nextSession.bellSeq || 0 };
3477
3517
  saveSeenMarkers(markers);
@@ -3491,6 +3531,7 @@ const App = () => {
3491
3531
  };
3492
3532
 
3493
3533
  const switchSession = (nextSession: SessionInfo) => {
3534
+ markTurnSeenLocally(nextSession.internalName || nextSession.name);
3494
3535
  const targetRoom = nextSession.internalName || nextSession.name;
3495
3536
  const nextPath = buildSessionPath(targetRoom);
3496
3537
  const currentRoom = session?.room ?? sessionLabel;
@@ -3909,10 +3950,10 @@ const App = () => {
3909
3950
  [...sessionsRef.current]
3910
3951
  .filter((s) => s.type !== "port")
3911
3952
  .sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
3912
- const hudStep = (dir: 1 | -1) => {
3953
+ const hudStep = (dir: 1 | -1, via: "keys" | "wheel" = "keys") => {
3913
3954
  const open = switchHudRef.current;
3914
3955
  if (open) {
3915
- setSwitchHud({ ring: open.ring, index: Math.min(open.ring.length - 1, Math.max(0, open.index + dir)) });
3956
+ setSwitchHud({ ...open, index: Math.min(open.ring.length - 1, Math.max(0, open.index + dir)) });
3916
3957
  return;
3917
3958
  }
3918
3959
  const ring = hudRing();
@@ -3920,7 +3961,7 @@ const App = () => {
3920
3961
  const curKey = activeSessionRoomRef.current;
3921
3962
  const cur = ring.findIndex((s) => s.name === curKey || s.internalName === curKey);
3922
3963
  const start = cur < 0 ? 0 : cur;
3923
- setSwitchHud({ ring, index: Math.min(ring.length - 1, Math.max(0, start + dir)) });
3964
+ setSwitchHud({ ring, index: Math.min(ring.length - 1, Math.max(0, start + dir)), via });
3924
3965
  };
3925
3966
  const hudCommit = () => {
3926
3967
  const open = switchHudRef.current;
@@ -3973,12 +4014,21 @@ const App = () => {
3973
4014
  const mod = isMacPlatform
3974
4015
  ? event.metaKey && !event.ctrlKey && !event.altKey
3975
4016
  : event.ctrlKey && event.shiftKey && !event.metaKey && !event.altKey;
4017
+ // esc cancels the HUD whether or not the modifier is still held — the
4018
+ // hint promises it, and with ⌘ down it used to fall through.
4019
+ if (event.key === "Escape" && switchHudRef.current) {
4020
+ event.preventDefault();
4021
+ event.stopPropagation();
4022
+ hudCancel();
4023
+ return;
4024
+ }
3976
4025
  if (!mod) {
3977
- if (event.key === "Escape" && switchHudRef.current) {
3978
- event.preventDefault();
3979
- event.stopPropagation();
3980
- hudCancel();
3981
- return;
4026
+ // A swipe-opened HUD waits for a decision: ⏎ switches, ← → step.
4027
+ if (switchHudRef.current?.via === "wheel") {
4028
+ if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); hudCommit(); return; }
4029
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
4030
+ event.preventDefault(); event.stopPropagation(); hudStep(event.key === "ArrowRight" ? 1 : -1, "wheel"); return;
4031
+ }
3982
4032
  }
3983
4033
  if (event.key === "Escape" && (drawerOpenRef.current || shortcutHelpRef.current)) {
3984
4034
  event.preventDefault();
@@ -4061,7 +4111,8 @@ const App = () => {
4061
4111
  // target, ⌘Tab-style. Any keyup counts: the modifier's own release AND
4062
4112
  // releasing it while another key is up both leave the flag false.
4063
4113
  const onKeyUp = (event: KeyboardEvent) => {
4064
- if (!switchHudRef.current) return;
4114
+ const open = switchHudRef.current;
4115
+ if (!open || open.via !== "keys") return; // a swipe-opened HUD lands on ⏎ or a click, not on a key release
4065
4116
  if (!(isMacPlatform ? event.metaKey : event.ctrlKey)) hudCommit();
4066
4117
  };
4067
4118
  window.addEventListener("keyup", onKeyUp, true);
@@ -4071,20 +4122,34 @@ const App = () => {
4071
4122
  // swipe never fires. Steps per 60pt of travel, settles into a commit.
4072
4123
  let wheelAccum = 0;
4073
4124
  let wheelTimer: number | null = null;
4125
+ const HUD_IDLE_CLOSE_MS = 8000;
4074
4126
  const onWheel = (event: WheelEvent) => {
4075
4127
  if (!session) return;
4076
4128
  const el = event.target as Element | null;
4077
4129
  if (el?.closest(".switcher-scroll, .views-panel, .drawer, input, textarea, [contenteditable]")) return;
4078
4130
  if (Math.abs(event.deltaX) <= Math.abs(event.deltaY) * 1.3) return;
4131
+ // A swipe never opens the HUD over a keyboard-held one.
4132
+ if (switchHudRef.current && switchHudRef.current.via !== "wheel") return;
4079
4133
  event.preventDefault();
4080
4134
  wheelAccum += event.deltaX;
4081
4135
  while (Math.abs(wheelAccum) >= 60) {
4082
4136
  const dir: 1 | -1 = wheelAccum > 0 ? 1 : -1;
4083
4137
  wheelAccum -= dir * 60;
4084
- hudStep(dir);
4138
+ hudStep(dir, "wheel");
4085
4139
  }
4140
+ // The gesture settling is NOT a decision. The HUD stays until ⏎ or a
4141
+ // click (switch) or esc; left alone, it closes without switching.
4086
4142
  if (wheelTimer) window.clearTimeout(wheelTimer);
4087
- wheelTimer = window.setTimeout(() => { wheelAccum = 0; hudCommit(); }, 320);
4143
+ wheelTimer = window.setTimeout(() => {
4144
+ wheelAccum = 0;
4145
+ if (switchHudRef.current?.via === "wheel") hudCancel();
4146
+ }, HUD_IDLE_CLOSE_MS);
4147
+ };
4148
+ hudPickRef.current = (index: number) => {
4149
+ const open = switchHudRef.current;
4150
+ if (!open) return;
4151
+ switchHudRef.current = { ...open, index };
4152
+ hudCommit();
4088
4153
  };
4089
4154
  window.addEventListener("wheel", onWheel, { passive: false, capture: true });
4090
4155
  return () => {
@@ -4115,7 +4180,7 @@ const App = () => {
4115
4180
  const curKey = session?.room ?? sessionLabel;
4116
4181
  const SLOT = 152; // card 140 + gap 12; must match .switch-hud CSS
4117
4182
  return (
4118
- <div className="switch-hud" aria-hidden="true">
4183
+ <div className={"switch-hud" + (switchHud.via === "wheel" ? " waits" : "")} aria-hidden="true">
4119
4184
  <div className="switch-hud-window">
4120
4185
  <div className="switch-hud-strip"
4121
4186
  style={{ transform: `translateX(-${switchHud.index * SLOT + 70}px)` }}>
@@ -4127,7 +4192,8 @@ const App = () => {
4127
4192
  <div key={key}
4128
4193
  className={"switch-hud-card"
4129
4194
  + (isTarget ? " is-target" : "")
4130
- + (isCurrent ? " is-current" : "")}>
4195
+ + (isCurrent ? " is-current" : "")}
4196
+ onClick={() => hudPickRef.current?.(i)}>
4131
4197
  <span className="switch-hud-name">{s.displayName || s.name}</span>
4132
4198
  {isCurrent && <span className="switch-hud-now">now</span>}
4133
4199
  </div>
@@ -4136,7 +4202,9 @@ const App = () => {
4136
4202
  </div>
4137
4203
  </div>
4138
4204
  <div className="switch-hud-hint">
4139
- {isMacPlatform ? "⌘J / ⌘L step · release ⌘ to switch" : "Ctrl+Shift+J / +L step · release to switch"} · esc cancels
4205
+ {switchHud.via === "wheel"
4206
+ ? "swipe or ← → to step · ⏎ or click to switch · esc cancels"
4207
+ : `${isMacPlatform ? "⌘J / ⌘L step · release ⌘ to switch" : "Ctrl+Shift+J / +L step · release to switch"} · esc cancels`}
4140
4208
  </div>
4141
4209
  </div>
4142
4210
  );
@@ -4244,7 +4312,8 @@ const App = () => {
4244
4312
  onRefresh={() => fetchSessions({ showLoading: false })}
4245
4313
  onNotice={showToast}
4246
4314
  tileWsBase={resolveWsUrl()}
4247
- onOpenViews={(scope) => setViewsOpen({ session: scope })}
4315
+ onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
4316
+ onOpenCheckbacks={(scope) => setCheckbacksOpen(scope)}
4248
4317
  userName={name}
4249
4318
  terminalTheme={resolveTerminalTheme(themeMode)}
4250
4319
  />
@@ -4908,7 +4977,8 @@ const App = () => {
4908
4977
  tileWsBase={resolveWsUrl()}
4909
4978
  onFocusSession={focusSessionInPlace}
4910
4979
  folders={folders}
4911
- onOpenViews={(scope) => setViewsOpen({ session: scope })}
4980
+ onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
4981
+ onOpenCheckbacks={(scope) => setCheckbacksOpen(scope)}
4912
4982
  userName={name}
4913
4983
  terminalTheme={resolveTerminalTheme(themeMode)}
4914
4984
  onOpenSettings={() => {
@@ -5051,8 +5121,16 @@ const App = () => {
5051
5121
  )}
5052
5122
  {/* Outside the mode ternary on purpose: Views opens from the hub, from a
5053
5123
  session, and from over the switcher, and it must outlive a switch. */}
5124
+ {checkbacksOpen && (
5125
+ <CheckbacksPanel
5126
+ session={checkbacksOpen}
5127
+ sessionName={sessions.find((s) => (s.internalName || s.name) === checkbacksOpen)?.displayName || checkbacksOpen}
5128
+ onClose={() => setCheckbacksOpen(null)}
5129
+ onChanged={() => { void fetchSessions({ showLoading: false }); }}
5130
+ />
5131
+ )}
5054
5132
  {viewsOpen && (
5055
- <ViewsPanel session={viewsOpen.session} sessions={sessions} dock={viewsOpen.dock}
5133
+ <ViewsPanel session={viewsOpen.session} item={viewsOpen.item} sessions={sessions} dock={viewsOpen.dock}
5056
5134
  onClose={() => setViewsOpen(null)} />
5057
5135
  )}
5058
5136
  </div>
@@ -0,0 +1,96 @@
1
+ import { useEffect, useState } from "react";
2
+ import { describeState, describeTrigger, orderForSession, type Checkback } from "../utils/checkbackText";
3
+
4
+ // What is waiting on a session: the check-backs hop will type into it, when,
5
+ // and the recent ones it already typed. Opened from the ⏰ chip on a card.
6
+ // Pending ones can be cancelled here; scheduling stays with `hop checkback`
7
+ // and the agent's own MCP tool.
8
+
9
+ type Props = {
10
+ session: string;
11
+ sessionName: string;
12
+ onClose: () => void;
13
+ // The wall's chip count comes from the session list; a cancel here should
14
+ // show there without waiting for the next poll.
15
+ onChanged?: () => void;
16
+ };
17
+
18
+ export const CheckbacksPanel = ({ session, sessionName, onClose, onChanged }: Props) => {
19
+ const [items, setItems] = useState<Checkback[] | null>(null);
20
+ const [failed, setFailed] = useState(false);
21
+ const [busy, setBusy] = useState<string | null>(null);
22
+ const now = Date.now();
23
+
24
+ const load = () => {
25
+ fetch("/api/checkbacks", { cache: "no-store" })
26
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
27
+ .then((d) => { setItems(Array.isArray(d?.items) ? d.items : []); setFailed(false); })
28
+ .catch(() => { setFailed(true); setItems([]); });
29
+ };
30
+ useEffect(() => { load(); }, [session]);
31
+ useEffect(() => {
32
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); onClose(); } };
33
+ window.addEventListener("keydown", onKey, true);
34
+ return () => window.removeEventListener("keydown", onKey, true);
35
+ }, [onClose]);
36
+
37
+ const cancel = async (id: string) => {
38
+ setBusy(id);
39
+ try {
40
+ await fetch("/api/checkbacks/cancel", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id }) });
41
+ } finally {
42
+ setBusy(null);
43
+ load();
44
+ onChanged?.();
45
+ }
46
+ };
47
+
48
+ const rows = items ? orderForSession(items, session) : [];
49
+ const pending = rows.filter((c) => c.status === "pending");
50
+ const past = rows.filter((c) => c.status !== "pending").slice(0, 8);
51
+
52
+ return (
53
+ <>
54
+ <div className="views-backdrop" onClick={onClose} />
55
+ <div className="views-panel checkbacks-panel" role="dialog" aria-label={`Check-backs for ${sessionName}`}>
56
+ <div className="views-head">
57
+ <span className="views-badge">⏰ Check-backs</span>
58
+ <span className="checkbacks-session">{sessionName}</span>
59
+ <span className="views-head-spacer" />
60
+ <button type="button" className="views-close" aria-label="Close" onClick={onClose}>×</button>
61
+ </div>
62
+ {items === null ? (
63
+ <p className="checkbacks-empty">Loading…</p>
64
+ ) : failed ? (
65
+ <p className="checkbacks-empty">Could not load check-backs.</p>
66
+ ) : (
67
+ <div className="checkbacks-list">
68
+ {pending.length === 0 && <p className="checkbacks-empty">Nothing waiting on this session.</p>}
69
+ {pending.map((c) => (
70
+ <div key={c.id} className={"checkback-row pending" + (c.firedAt ? " fired" : "")}>
71
+ <div className="checkback-when">{describeTrigger(c.trigger, now)}{c.force ? " · typed even mid-turn" : ""}</div>
72
+ <div className="checkback-message">{c.message}</div>
73
+ <div className="checkback-foot">
74
+ <span className="checkback-state">{describeState(c, now)} · by {c.createdBy === "agent" ? "the agent" : "you"}</span>
75
+ <button type="button" className="checkback-cancel" disabled={busy === c.id} onClick={() => cancel(c.id)}>Cancel</button>
76
+ </div>
77
+ </div>
78
+ ))}
79
+ {past.length > 0 && (
80
+ <>
81
+ <div className="checkbacks-past-head">Recent</div>
82
+ {past.map((c) => (
83
+ <div key={c.id} className={"checkback-row " + c.status}>
84
+ <div className="checkback-when">{describeState(c, now)} · {describeTrigger(c.trigger, now)}</div>
85
+ <div className="checkback-message">{c.message}</div>
86
+ </div>
87
+ ))}
88
+ </>
89
+ )}
90
+ <p className="checkbacks-hint">Schedule one with <code>hop checkback</code> inside the session, or the agent does it itself over MCP.</p>
91
+ </div>
92
+ )}
93
+ </div>
94
+ </>
95
+ );
96
+ };
@@ -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,