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 +32 -0
- package/hay/apps/web/src/App.tsx +99 -21
- package/hay/apps/web/src/components/CheckbacksPanel.tsx +96 -0
- package/hay/apps/web/src/components/DigestCard.tsx +90 -17
- package/hay/apps/web/src/components/SecondaryPane.tsx +3 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +29 -3
- package/hay/apps/web/src/components/ViewsPanel.tsx +6 -2
- package/hay/apps/web/src/styles.css +112 -0
- package/hay/apps/web/src/utils/checkbackText.ts +73 -0
- package/hay/apps/web/src/utils/readWitness.ts +131 -0
- package/hay/apps/web/src/utils/switcherModel.ts +2 -0
- package/hay/apps/web/src/utils/viewBlocks.ts +192 -0
- package/hay/apps/web/test/checkbackText.test.ts +40 -0
- package/hay/apps/web/test/readWitness.test.ts +75 -0
- package/hay/apps/web/test/viewBlocks.test.ts +84 -0
- package/hop +384 -3
- package/hopa +10 -1
- package/lib/checkbacks.js +193 -0
- package/lib/digest-reads.js +95 -0
- package/mcp/hop-mcp.js +54 -0
- package/package.json +1 -1
- package/skills/hop-session/SKILL.md +21 -0
- package/tests/checkbacks.unit.test.cjs +87 -0
- package/tests/digest-reads.unit.test.cjs +45 -0
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) |
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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
|
-
|
|
481
|
-
|
|
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({
|
|
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
|
-
|
|
3978
|
-
|
|
3979
|
-
event.stopPropagation();
|
|
3980
|
-
|
|
3981
|
-
|
|
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
|
-
|
|
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(() => {
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
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,
|