hop2 0.11.65 → 0.11.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/hay/apps/web/src/App.tsx +76 -21
- 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 +18 -2
- package/hay/apps/web/src/components/ViewsPanel.tsx +6 -2
- package/hay/apps/web/src/styles.css +91 -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/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,7 @@ import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
|
|
|
23
23
|
import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
|
|
24
24
|
import { claimOnAttach, claimOnClick, isPlainClick } from "./utils/sizeClaim";
|
|
25
25
|
import { installDragSelect } from "./utils/dragSelect";
|
|
26
|
+
import { enableViewBlocks } from "./utils/viewBlocks";
|
|
26
27
|
import { MobileKeyboard } from "./components/MobileKeyboard";
|
|
27
28
|
import { SessionSwitcher } from "./components/SessionSwitcher";
|
|
28
29
|
import { SecondaryPane } from "./components/SecondaryPane";
|
|
@@ -266,6 +267,8 @@ type SessionInfo = {
|
|
|
266
267
|
// a human has opened the session since it finished (see lib/agent-phase.js).
|
|
267
268
|
agentPhase?: "working" | "done" | null;
|
|
268
269
|
turnSeen?: boolean;
|
|
270
|
+
// Scheduled check-backs waiting on this session (hop checkback).
|
|
271
|
+
checkbacks?: number;
|
|
269
272
|
attentionReason?: string;
|
|
270
273
|
attentionNote?: string;
|
|
271
274
|
cols?: number;
|
|
@@ -477,8 +480,14 @@ const App = () => {
|
|
|
477
480
|
// while you cycle (⌘J/⌘L held / two-finger swipe) and commits on release — like
|
|
478
481
|
// ⌘Tab. Frozen ring so neighbours don't reshuffle mid-cycle (same rule as
|
|
479
482
|
// the iOS swipe order). Ref mirror for the keyup/wheel listeners.
|
|
480
|
-
|
|
481
|
-
|
|
483
|
+
// via: how the HUD was opened. "keys" (⌘J/⌘L) lands on the modifier's
|
|
484
|
+
// release — the hold IS the deciding time. "wheel" (a two-finger swipe)
|
|
485
|
+
// has no release, so it waits: ⏎ or a click on a card switches, esc
|
|
486
|
+
// cancels, and a quiet spell closes it without switching. It used to
|
|
487
|
+
// commit 320ms after the swipe settled, i.e. while you were still looking.
|
|
488
|
+
const [switchHud, setSwitchHud] = useState<{ ring: SessionInfo[]; index: number; via: "keys" | "wheel" } | null>(null);
|
|
489
|
+
const switchHudRef = useRef<{ ring: SessionInfo[]; index: number; via: "keys" | "wheel" } | null>(null);
|
|
490
|
+
const hudPickRef = useRef<((index: number) => void) | null>(null);
|
|
482
491
|
switchHudRef.current = switchHud;
|
|
483
492
|
// Server-owned folders (Manual mode). Kept in App because the sessions
|
|
484
493
|
// fetch already carries them.
|
|
@@ -700,7 +709,7 @@ const App = () => {
|
|
|
700
709
|
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
|
|
701
710
|
// Published views (`hop view`). `session` scopes the panel to one session;
|
|
702
711
|
// an object with none open it fleet-wide. Null = closed.
|
|
703
|
-
const [viewsOpen, setViewsOpen] = useState<{ session?: string; dock?: boolean } | null>(null);
|
|
712
|
+
const [viewsOpen, setViewsOpen] = useState<{ session?: string; dock?: boolean; item?: string } | null>(null);
|
|
704
713
|
const sessionsRef = useRef(sessions);
|
|
705
714
|
sessionsRef.current = sessions;
|
|
706
715
|
// Claude Code titles its process with a bare version number; hop's session
|
|
@@ -1505,12 +1514,17 @@ const App = () => {
|
|
|
1505
1514
|
lastSentSizeRef.current = { cols, rows };
|
|
1506
1515
|
const deliberate = claim === "attach" && deliberateAttachRef.current;
|
|
1507
1516
|
if (deliberate) deliberateAttachRef.current = false;
|
|
1517
|
+
// An explicit Fit says "use MY size" even over someone typing now;
|
|
1518
|
+
// opening/clicking only takes the size from an idle or absent owner.
|
|
1519
|
+
const take = takeSizeRef.current;
|
|
1520
|
+
takeSizeRef.current = false;
|
|
1508
1521
|
sendMessage({
|
|
1509
1522
|
type: "resize",
|
|
1510
1523
|
cols,
|
|
1511
1524
|
rows,
|
|
1512
1525
|
...(claim ? { claim } : {}),
|
|
1513
|
-
...(deliberate ? { user: true } : {})
|
|
1526
|
+
...(deliberate ? { user: true } : {}),
|
|
1527
|
+
...(take ? { take: true } : {})
|
|
1514
1528
|
});
|
|
1515
1529
|
};
|
|
1516
1530
|
|
|
@@ -1519,6 +1533,8 @@ const App = () => {
|
|
|
1519
1533
|
* here. Quiet when the session is already at our fit, so an ordinary click
|
|
1520
1534
|
* (or a selection drag) never pushes a resize.
|
|
1521
1535
|
*/
|
|
1536
|
+
// Set by an explicit Fit: the next resize carries take:true.
|
|
1537
|
+
const takeSizeRef = useRef(false);
|
|
1522
1538
|
const claimSizeHere = (force = false) => {
|
|
1523
1539
|
if (!force && !claimOnClick({
|
|
1524
1540
|
viewMode: viewModeRef.current,
|
|
@@ -1528,6 +1544,7 @@ const App = () => {
|
|
|
1528
1544
|
})) return;
|
|
1529
1545
|
attachClaimPendingRef.current = true;
|
|
1530
1546
|
deliberateAttachRef.current = true;
|
|
1547
|
+
if (force) takeSizeRef.current = true;
|
|
1531
1548
|
fitToViewport();
|
|
1532
1549
|
handleResize();
|
|
1533
1550
|
};
|
|
@@ -2110,6 +2127,9 @@ const App = () => {
|
|
|
2110
2127
|
// ⌥-drag always selects, iTerm's escape hatch for apps that track the
|
|
2111
2128
|
// mouse. (Plain drag selects too in agent sessions — utils/dragSelect.)
|
|
2112
2129
|
macOptionClickForcesSelection: true,
|
|
2130
|
+
// Decorations (inline hop view cards, utils/viewBlocks.ts) are a
|
|
2131
|
+
// proposed xterm API; this is the switch that lets them register.
|
|
2132
|
+
allowProposedApi: true,
|
|
2113
2133
|
lineHeight: 1.3,
|
|
2114
2134
|
cursorBlink: true,
|
|
2115
2135
|
scrollback: 50000,
|
|
@@ -2147,6 +2167,9 @@ const App = () => {
|
|
|
2147
2167
|
// KaTeX, a click pins it. What `hop math` could only approximate in
|
|
2148
2168
|
// Unicode, the browser around the terminal draws properly.
|
|
2149
2169
|
enableMathHover(terminal, getMathTip());
|
|
2170
|
+
// `hop view` deliveries become small inline cards where they printed;
|
|
2171
|
+
// Open lands in the Views reader on that item.
|
|
2172
|
+
(terminal as any).__viewBlocksCleanup = enableViewBlocks(terminal as never, (b) => setViewsOpen({ session: b.session, item: b.name }));
|
|
2150
2173
|
terminal.open(containerRef.current);
|
|
2151
2174
|
// Plain drag selects in Claude/Codex sessions even though they track the
|
|
2152
2175
|
// mouse; the wheel still reaches them. See utils/dragSelect.ts.
|
|
@@ -3052,6 +3075,10 @@ const App = () => {
|
|
|
3052
3075
|
(terminal as any).__overlayScrollbarCleanup();
|
|
3053
3076
|
(terminal as any).__overlayScrollbarCleanup = null;
|
|
3054
3077
|
}
|
|
3078
|
+
if ((terminal as any).__viewBlocksCleanup) {
|
|
3079
|
+
(terminal as any).__viewBlocksCleanup();
|
|
3080
|
+
(terminal as any).__viewBlocksCleanup = null;
|
|
3081
|
+
}
|
|
3055
3082
|
if ((terminal as any).__dragSelectCleanup) {
|
|
3056
3083
|
(terminal as any).__dragSelectCleanup();
|
|
3057
3084
|
(terminal as any).__dragSelectCleanup = null;
|
|
@@ -3235,6 +3262,7 @@ const App = () => {
|
|
|
3235
3262
|
agent: s.agent === "claude" || s.agent === "codex" ? s.agent : null,
|
|
3236
3263
|
agentPhase: s.agentPhase === "working" || s.agentPhase === "done" ? s.agentPhase : null,
|
|
3237
3264
|
turnSeen: s.turnSeen !== false,
|
|
3265
|
+
checkbacks: Number(s.checkbacks) > 0 ? Number(s.checkbacks) : undefined,
|
|
3238
3266
|
cols: Number.isInteger(s.cols) ? s.cols : undefined,
|
|
3239
3267
|
rows: Number.isInteger(s.rows) ? s.rows : undefined,
|
|
3240
3268
|
// These three were silently dropped here, so the switcher never saw
|
|
@@ -3909,10 +3937,10 @@ const App = () => {
|
|
|
3909
3937
|
[...sessionsRef.current]
|
|
3910
3938
|
.filter((s) => s.type !== "port")
|
|
3911
3939
|
.sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
|
|
3912
|
-
const hudStep = (dir: 1 | -1) => {
|
|
3940
|
+
const hudStep = (dir: 1 | -1, via: "keys" | "wheel" = "keys") => {
|
|
3913
3941
|
const open = switchHudRef.current;
|
|
3914
3942
|
if (open) {
|
|
3915
|
-
setSwitchHud({
|
|
3943
|
+
setSwitchHud({ ...open, index: Math.min(open.ring.length - 1, Math.max(0, open.index + dir)) });
|
|
3916
3944
|
return;
|
|
3917
3945
|
}
|
|
3918
3946
|
const ring = hudRing();
|
|
@@ -3920,7 +3948,7 @@ const App = () => {
|
|
|
3920
3948
|
const curKey = activeSessionRoomRef.current;
|
|
3921
3949
|
const cur = ring.findIndex((s) => s.name === curKey || s.internalName === curKey);
|
|
3922
3950
|
const start = cur < 0 ? 0 : cur;
|
|
3923
|
-
setSwitchHud({ ring, index: Math.min(ring.length - 1, Math.max(0, start + dir)) });
|
|
3951
|
+
setSwitchHud({ ring, index: Math.min(ring.length - 1, Math.max(0, start + dir)), via });
|
|
3924
3952
|
};
|
|
3925
3953
|
const hudCommit = () => {
|
|
3926
3954
|
const open = switchHudRef.current;
|
|
@@ -3973,12 +4001,21 @@ const App = () => {
|
|
|
3973
4001
|
const mod = isMacPlatform
|
|
3974
4002
|
? event.metaKey && !event.ctrlKey && !event.altKey
|
|
3975
4003
|
: event.ctrlKey && event.shiftKey && !event.metaKey && !event.altKey;
|
|
4004
|
+
// esc cancels the HUD whether or not the modifier is still held — the
|
|
4005
|
+
// hint promises it, and with ⌘ down it used to fall through.
|
|
4006
|
+
if (event.key === "Escape" && switchHudRef.current) {
|
|
4007
|
+
event.preventDefault();
|
|
4008
|
+
event.stopPropagation();
|
|
4009
|
+
hudCancel();
|
|
4010
|
+
return;
|
|
4011
|
+
}
|
|
3976
4012
|
if (!mod) {
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
event.stopPropagation();
|
|
3980
|
-
|
|
3981
|
-
|
|
4013
|
+
// A swipe-opened HUD waits for a decision: ⏎ switches, ← → step.
|
|
4014
|
+
if (switchHudRef.current?.via === "wheel") {
|
|
4015
|
+
if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); hudCommit(); return; }
|
|
4016
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
4017
|
+
event.preventDefault(); event.stopPropagation(); hudStep(event.key === "ArrowRight" ? 1 : -1, "wheel"); return;
|
|
4018
|
+
}
|
|
3982
4019
|
}
|
|
3983
4020
|
if (event.key === "Escape" && (drawerOpenRef.current || shortcutHelpRef.current)) {
|
|
3984
4021
|
event.preventDefault();
|
|
@@ -4061,7 +4098,8 @@ const App = () => {
|
|
|
4061
4098
|
// target, ⌘Tab-style. Any keyup counts: the modifier's own release AND
|
|
4062
4099
|
// releasing it while another key is up both leave the flag false.
|
|
4063
4100
|
const onKeyUp = (event: KeyboardEvent) => {
|
|
4064
|
-
|
|
4101
|
+
const open = switchHudRef.current;
|
|
4102
|
+
if (!open || open.via !== "keys") return; // a swipe-opened HUD lands on ⏎ or a click, not on a key release
|
|
4065
4103
|
if (!(isMacPlatform ? event.metaKey : event.ctrlKey)) hudCommit();
|
|
4066
4104
|
};
|
|
4067
4105
|
window.addEventListener("keyup", onKeyUp, true);
|
|
@@ -4071,20 +4109,34 @@ const App = () => {
|
|
|
4071
4109
|
// swipe never fires. Steps per 60pt of travel, settles into a commit.
|
|
4072
4110
|
let wheelAccum = 0;
|
|
4073
4111
|
let wheelTimer: number | null = null;
|
|
4112
|
+
const HUD_IDLE_CLOSE_MS = 8000;
|
|
4074
4113
|
const onWheel = (event: WheelEvent) => {
|
|
4075
4114
|
if (!session) return;
|
|
4076
4115
|
const el = event.target as Element | null;
|
|
4077
4116
|
if (el?.closest(".switcher-scroll, .views-panel, .drawer, input, textarea, [contenteditable]")) return;
|
|
4078
4117
|
if (Math.abs(event.deltaX) <= Math.abs(event.deltaY) * 1.3) return;
|
|
4118
|
+
// A swipe never opens the HUD over a keyboard-held one.
|
|
4119
|
+
if (switchHudRef.current && switchHudRef.current.via !== "wheel") return;
|
|
4079
4120
|
event.preventDefault();
|
|
4080
4121
|
wheelAccum += event.deltaX;
|
|
4081
4122
|
while (Math.abs(wheelAccum) >= 60) {
|
|
4082
4123
|
const dir: 1 | -1 = wheelAccum > 0 ? 1 : -1;
|
|
4083
4124
|
wheelAccum -= dir * 60;
|
|
4084
|
-
hudStep(dir);
|
|
4125
|
+
hudStep(dir, "wheel");
|
|
4085
4126
|
}
|
|
4127
|
+
// The gesture settling is NOT a decision. The HUD stays until ⏎ or a
|
|
4128
|
+
// click (switch) or esc; left alone, it closes without switching.
|
|
4086
4129
|
if (wheelTimer) window.clearTimeout(wheelTimer);
|
|
4087
|
-
wheelTimer = window.setTimeout(() => {
|
|
4130
|
+
wheelTimer = window.setTimeout(() => {
|
|
4131
|
+
wheelAccum = 0;
|
|
4132
|
+
if (switchHudRef.current?.via === "wheel") hudCancel();
|
|
4133
|
+
}, HUD_IDLE_CLOSE_MS);
|
|
4134
|
+
};
|
|
4135
|
+
hudPickRef.current = (index: number) => {
|
|
4136
|
+
const open = switchHudRef.current;
|
|
4137
|
+
if (!open) return;
|
|
4138
|
+
switchHudRef.current = { ...open, index };
|
|
4139
|
+
hudCommit();
|
|
4088
4140
|
};
|
|
4089
4141
|
window.addEventListener("wheel", onWheel, { passive: false, capture: true });
|
|
4090
4142
|
return () => {
|
|
@@ -4115,7 +4167,7 @@ const App = () => {
|
|
|
4115
4167
|
const curKey = session?.room ?? sessionLabel;
|
|
4116
4168
|
const SLOT = 152; // card 140 + gap 12; must match .switch-hud CSS
|
|
4117
4169
|
return (
|
|
4118
|
-
<div className="switch-hud" aria-hidden="true">
|
|
4170
|
+
<div className={"switch-hud" + (switchHud.via === "wheel" ? " waits" : "")} aria-hidden="true">
|
|
4119
4171
|
<div className="switch-hud-window">
|
|
4120
4172
|
<div className="switch-hud-strip"
|
|
4121
4173
|
style={{ transform: `translateX(-${switchHud.index * SLOT + 70}px)` }}>
|
|
@@ -4127,7 +4179,8 @@ const App = () => {
|
|
|
4127
4179
|
<div key={key}
|
|
4128
4180
|
className={"switch-hud-card"
|
|
4129
4181
|
+ (isTarget ? " is-target" : "")
|
|
4130
|
-
+ (isCurrent ? " is-current" : "")}
|
|
4182
|
+
+ (isCurrent ? " is-current" : "")}
|
|
4183
|
+
onClick={() => hudPickRef.current?.(i)}>
|
|
4131
4184
|
<span className="switch-hud-name">{s.displayName || s.name}</span>
|
|
4132
4185
|
{isCurrent && <span className="switch-hud-now">now</span>}
|
|
4133
4186
|
</div>
|
|
@@ -4136,7 +4189,9 @@ const App = () => {
|
|
|
4136
4189
|
</div>
|
|
4137
4190
|
</div>
|
|
4138
4191
|
<div className="switch-hud-hint">
|
|
4139
|
-
{
|
|
4192
|
+
{switchHud.via === "wheel"
|
|
4193
|
+
? "swipe or ← → to step · ⏎ or click to switch · esc cancels"
|
|
4194
|
+
: `${isMacPlatform ? "⌘J / ⌘L step · release ⌘ to switch" : "Ctrl+Shift+J / +L step · release to switch"} · esc cancels`}
|
|
4140
4195
|
</div>
|
|
4141
4196
|
</div>
|
|
4142
4197
|
);
|
|
@@ -4244,7 +4299,7 @@ const App = () => {
|
|
|
4244
4299
|
onRefresh={() => fetchSessions({ showLoading: false })}
|
|
4245
4300
|
onNotice={showToast}
|
|
4246
4301
|
tileWsBase={resolveWsUrl()}
|
|
4247
|
-
onOpenViews={(scope) => setViewsOpen({ session: scope })}
|
|
4302
|
+
onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
|
|
4248
4303
|
userName={name}
|
|
4249
4304
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
4250
4305
|
/>
|
|
@@ -4908,7 +4963,7 @@ const App = () => {
|
|
|
4908
4963
|
tileWsBase={resolveWsUrl()}
|
|
4909
4964
|
onFocusSession={focusSessionInPlace}
|
|
4910
4965
|
folders={folders}
|
|
4911
|
-
onOpenViews={(scope) => setViewsOpen({ session: scope })}
|
|
4966
|
+
onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
|
|
4912
4967
|
userName={name}
|
|
4913
4968
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
4914
4969
|
onOpenSettings={() => {
|
|
@@ -5052,7 +5107,7 @@ const App = () => {
|
|
|
5052
5107
|
{/* Outside the mode ternary on purpose: Views opens from the hub, from a
|
|
5053
5108
|
session, and from over the switcher, and it must outlive a switch. */}
|
|
5054
5109
|
{viewsOpen && (
|
|
5055
|
-
<ViewsPanel session={viewsOpen.session} sessions={sessions} dock={viewsOpen.dock}
|
|
5110
|
+
<ViewsPanel session={viewsOpen.session} item={viewsOpen.item} sessions={sessions} dock={viewsOpen.dock}
|
|
5056
5111
|
onClose={() => setViewsOpen(null)} />
|
|
5057
5112
|
)}
|
|
5058
5113
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useEffect, useMemo, useState } from "react";
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { createReadWitness, wordCount } from "../utils/readWitness";
|
|
2
3
|
import type { SwitcherSession } from "../utils/switcherModel";
|
|
3
4
|
|
|
4
5
|
// The briefing, on the desktop wall — the same editions the iOS app renders,
|
|
@@ -16,7 +17,14 @@ type DigestItem = {
|
|
|
16
17
|
headline: string;
|
|
17
18
|
why?: string;
|
|
18
19
|
urgency?: string;
|
|
20
|
+
// The chief-of-staff page: decide / watch / done, and an "also" roll-up
|
|
21
|
+
// of headline-only items. Older editions have no slot and render flat.
|
|
22
|
+
slot?: "decide" | "watch" | "done" | "also";
|
|
23
|
+
recommendation?: string;
|
|
24
|
+
replies?: string[];
|
|
19
25
|
};
|
|
26
|
+
const SLOT_LABEL: Record<string, string> = { decide: "Decide", watch: "Watch", done: "Done" };
|
|
27
|
+
const SLOT_ORDER = ["decide", "watch", "done"];
|
|
20
28
|
|
|
21
29
|
type Edition = {
|
|
22
30
|
generated_at?: string;
|
|
@@ -63,6 +71,18 @@ export const DigestCard = ({ sessions, onOpen }: {
|
|
|
63
71
|
onOpen: (session: SwitcherSession) => void;
|
|
64
72
|
}) => {
|
|
65
73
|
const [editions, setEditions] = useState<Edition[]>([]);
|
|
74
|
+
// The read witness: dwell on a visible story, with the human present,
|
|
75
|
+
// reports glimpsed/read to the daemon; opening a session from a story
|
|
76
|
+
// reports acted. The daemon's union across devices feeds the "new"
|
|
77
|
+
// badge below and tells the generator what may be told as a delta.
|
|
78
|
+
const witnessRef = useRef<ReturnType<typeof createReadWitness> | null>(null);
|
|
79
|
+
if (!witnessRef.current) {
|
|
80
|
+
witnessRef.current = createReadWitness((reads) => {
|
|
81
|
+
fetch("/api/digest/reads", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reads }), keepalive: true })
|
|
82
|
+
.catch(() => { /* offline: the next batch carries it */ });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
useEffect(() => () => { witnessRef.current?.dispose(); witnessRef.current = null; }, []);
|
|
66
86
|
const [dismissed, setDismissed] = useState(() => {
|
|
67
87
|
try { return localStorage.getItem(DISMISS_KEY) || ""; } catch { return ""; }
|
|
68
88
|
});
|
|
@@ -83,9 +103,13 @@ export const DigestCard = ({ sessions, onOpen }: {
|
|
|
83
103
|
// the very first briefing, before an archive exists.
|
|
84
104
|
Promise.all([
|
|
85
105
|
fetch("/assets/digest-archive.json", { cache: "no-store" }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
|
|
86
|
-
fetch("/assets/digest.json", { cache: "no-store" }).then((r) => (r.ok ? r.json() : null)).catch(() => null)
|
|
87
|
-
|
|
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,
|
|
@@ -18,6 +18,7 @@ import { getMathTip } from "../utils/mathTooltip";
|
|
|
18
18
|
import { claimOnTileFocus, sizeIsForeign } from "../utils/sizeClaim";
|
|
19
19
|
import { cardPhase, phaseLabel, phaseTitle } from "../utils/agentPhase";
|
|
20
20
|
import { installDragSelect } from "../utils/dragSelect";
|
|
21
|
+
import { enableViewBlocks } from "../utils/viewBlocks";
|
|
21
22
|
import { urlAtCell, cellAtPoint } from "../utils/urlAtCell";
|
|
22
23
|
import { attachScrollFlywheel } from "../utils/scrollFlywheel";
|
|
23
24
|
import { ContextMenu, type MenuRequest } from "./ContextMenu";
|
|
@@ -116,7 +117,7 @@ type Props = {
|
|
|
116
117
|
onFind?: () => void;
|
|
117
118
|
// Open the Views panel. No argument = the whole fleet (header button); an
|
|
118
119
|
// internalName = just that session (a card's chip).
|
|
119
|
-
onOpenViews?: (session?: string) => void;
|
|
120
|
+
onOpenViews?: (session?: string, item?: string) => void;
|
|
120
121
|
};
|
|
121
122
|
|
|
122
123
|
type Sheet = {
|
|
@@ -357,9 +358,11 @@ const runningApp = (s: SwitcherSession) => {
|
|
|
357
358
|
// responsiveness and whether keystrokes are accepted. The old design — an
|
|
358
359
|
// HTML preview swapped for a fresh xterm + socket behind a veil — is gone.
|
|
359
360
|
const LIVETILE_POLL_MS = 5000;
|
|
360
|
-
const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp, codexApp = false, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice, onSender }: {
|
|
361
|
+
const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp, codexApp = false, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice, onSender, onOpenView }: {
|
|
361
362
|
/** The wall keeps a registry of live tiles' input senders (file drops). */
|
|
362
363
|
onSender?: (send: ((data: string) => void) | null) => void;
|
|
364
|
+
/** An inline hop view card's Open: the reader for that session, on that item. */
|
|
365
|
+
onOpenView?: (session: string, item: string) => void;
|
|
363
366
|
wsBase: string; room: string; userName: string; theme: object | undefined;
|
|
364
367
|
/**
|
|
365
368
|
* `live` says the tile is the focused one; `deliberate` says the human
|
|
@@ -430,6 +433,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
|
|
|
430
433
|
const looksLikeClaude = () => isClaudeSurface(claudeAppRef.current, termRef.current as never);
|
|
431
434
|
const codexAppRef = useRef(codexApp);
|
|
432
435
|
codexAppRef.current = codexApp;
|
|
436
|
+
const onOpenViewRef = useRef(onOpenView);
|
|
437
|
+
onOpenViewRef.current = onOpenView;
|
|
433
438
|
if (!voiceHoldRef.current) {
|
|
434
439
|
voiceHoldRef.current = createVoiceHold({
|
|
435
440
|
send: (data) => sendInputRef.current?.(data),
|
|
@@ -505,6 +510,9 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
|
|
|
505
510
|
rows: activeRows && activeRows > 1 ? activeRows : 24,
|
|
506
511
|
scrollback: 2000,
|
|
507
512
|
macOptionClickForcesSelection: true,
|
|
513
|
+
// Decorations (inline hop view cards, utils/viewBlocks.ts) are a
|
|
514
|
+
// proposed xterm API; this is the switch that lets them register.
|
|
515
|
+
allowProposedApi: true,
|
|
508
516
|
// Same scroll feel as the full-screen terminal: one wheel notch moves
|
|
509
517
|
// 4 lines (xterm's default of 1 is the "tiles feel sluggish" report),
|
|
510
518
|
// Shift+wheel blasts. Momentum is attached below.
|
|
@@ -535,6 +543,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
|
|
|
535
543
|
window.open(target, "_blank", "noopener");
|
|
536
544
|
}));
|
|
537
545
|
enableMathHover(term, getMathTip()); // rendered math on hover, here too
|
|
546
|
+
const uninstallViewBlocks = enableViewBlocks(term as never, (b) => onOpenViewRef.current?.(b.session, b.name));
|
|
538
547
|
term.open(box);
|
|
539
548
|
// Plain drag selects in Claude/Codex tiles even though they track the
|
|
540
549
|
// mouse; the wheel still reaches them. See utils/dragSelect.ts.
|
|
@@ -758,6 +767,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
|
|
|
758
767
|
detachFlywheel();
|
|
759
768
|
ro?.disconnect();
|
|
760
769
|
sub.dispose();
|
|
770
|
+
uninstallViewBlocks();
|
|
761
771
|
uninstallDragSelect();
|
|
762
772
|
term.dispose();
|
|
763
773
|
termRef.current = null;
|
|
@@ -2996,6 +3006,11 @@ export const SessionSwitcher = ({
|
|
|
2996
3006
|
</span>
|
|
2997
3007
|
)}
|
|
2998
3008
|
{!current && s.starting && !s.active && <span className="switcher-chip starting">STARTING</span>}
|
|
3009
|
+
{!!s.checkbacks && (
|
|
3010
|
+
<span className="switcher-chip checkback" title={`${s.checkbacks} scheduled check-back${s.checkbacks === 1 ? "" : "s"} will be typed into this session (hop checkback --list)`}>
|
|
3011
|
+
⏰ {s.checkbacks}
|
|
3012
|
+
</span>
|
|
3013
|
+
)}
|
|
2999
3014
|
{phase && (
|
|
3000
3015
|
<span className={`switcher-chip phase ${phase}`} title={phaseTitle(phase)}>
|
|
3001
3016
|
<span className="phase-dot" aria-hidden="true" />{phaseLabel(phase)}
|
|
@@ -3046,6 +3061,7 @@ export const SessionSwitcher = ({
|
|
|
3046
3061
|
onUnfocus={() => setFocusedKey(null)}
|
|
3047
3062
|
onNotice={onNotice}
|
|
3048
3063
|
onSender={(fn) => { if (fn) tileSendersRef.current.set(key, fn); else tileSendersRef.current.delete(key); }}
|
|
3064
|
+
onOpenView={(scope, item) => onOpenViews?.(scope, item)}
|
|
3049
3065
|
/>
|
|
3050
3066
|
</div>
|
|
3051
3067
|
) : (
|
|
@@ -65,6 +65,9 @@ type Props = {
|
|
|
65
65
|
// the screen's edge was farther from where the eye already is. The dock
|
|
66
66
|
// stays available for a caller that wants it.
|
|
67
67
|
dock?: boolean;
|
|
68
|
+
// Open straight on this item (its file name within `session`), e.g. from
|
|
69
|
+
// an inline hop view card in the terminal.
|
|
70
|
+
item?: string;
|
|
68
71
|
onClose: () => void;
|
|
69
72
|
};
|
|
70
73
|
|
|
@@ -176,7 +179,7 @@ const DEFAULT_DOCK_W = 760;
|
|
|
176
179
|
const clampDockW = (w: number) =>
|
|
177
180
|
Math.max(560, Math.min(Math.round(window.innerWidth * 0.75), w));
|
|
178
181
|
|
|
179
|
-
export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Props) => {
|
|
182
|
+
export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose }: Props) => {
|
|
180
183
|
const [items, setItems] = useState<ViewItem[] | null>(null);
|
|
181
184
|
const [failed, setFailed] = useState(false);
|
|
182
185
|
const [preview, setPreview] = useState<ViewItem | null>(null);
|
|
@@ -276,7 +279,8 @@ export const ViewsPanel = ({ session, sessions = [], dock = false, onClose }: Pr
|
|
|
276
279
|
useEffect(() => {
|
|
277
280
|
if (!items || !canPreview()) return;
|
|
278
281
|
if (preview && flatRows.some((r) => r.path === preview.path)) return;
|
|
279
|
-
|
|
282
|
+
const asked = item ? flatRows.find((r) => r.name === item) : null;
|
|
283
|
+
setPreview(asked ?? flatRows[0] ?? null);
|
|
280
284
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
281
285
|
}, [items, flatRows]);
|
|
282
286
|
|