hop2 0.11.64 → 0.11.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/hay/apps/web/src/App.tsx +100 -21
- package/hay/apps/web/src/components/DigestCard.tsx +90 -17
- package/hay/apps/web/src/components/SecondaryPane.tsx +13 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +31 -4
- package/hay/apps/web/src/components/ViewsPanel.tsx +6 -2
- package/hay/apps/web/src/styles.css +118 -11
- package/hay/apps/web/src/utils/dragSelect.ts +100 -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/dragSelect.test.ts +87 -0
- package/hay/apps/web/test/readWitness.test.ts +75 -0
- package/hay/apps/web/test/viewBlocks.test.ts +84 -0
- package/hop +440 -13
- package/hopa +10 -1
- package/lib/agent-phase.js +12 -2
- package/lib/checkbacks.js +193 -0
- package/lib/codex-identity.js +48 -0
- package/lib/digest-reads.js +95 -0
- package/mcp/hop-mcp.js +54 -0
- package/package.json +1 -1
- package/scripts/codex-notify-hook.js +34 -9
- package/skills/hop-session/SKILL.md +21 -0
- package/tests/agent-phase.unit.test.cjs +18 -1
- package/tests/checkbacks.unit.test.cjs +87 -0
- package/tests/codex-identity.unit.test.cjs +32 -0
- package/tests/codex-notify-hook.unit.test.cjs +47 -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
|
@@ -22,6 +22,8 @@ import { getMathTip } from "./utils/mathTooltip";
|
|
|
22
22
|
import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
|
|
23
23
|
import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
|
|
24
24
|
import { claimOnAttach, claimOnClick, isPlainClick } from "./utils/sizeClaim";
|
|
25
|
+
import { installDragSelect } from "./utils/dragSelect";
|
|
26
|
+
import { enableViewBlocks } from "./utils/viewBlocks";
|
|
25
27
|
import { MobileKeyboard } from "./components/MobileKeyboard";
|
|
26
28
|
import { SessionSwitcher } from "./components/SessionSwitcher";
|
|
27
29
|
import { SecondaryPane } from "./components/SecondaryPane";
|
|
@@ -265,6 +267,8 @@ type SessionInfo = {
|
|
|
265
267
|
// a human has opened the session since it finished (see lib/agent-phase.js).
|
|
266
268
|
agentPhase?: "working" | "done" | null;
|
|
267
269
|
turnSeen?: boolean;
|
|
270
|
+
// Scheduled check-backs waiting on this session (hop checkback).
|
|
271
|
+
checkbacks?: number;
|
|
268
272
|
attentionReason?: string;
|
|
269
273
|
attentionNote?: string;
|
|
270
274
|
cols?: number;
|
|
@@ -476,8 +480,14 @@ const App = () => {
|
|
|
476
480
|
// while you cycle (⌘J/⌘L held / two-finger swipe) and commits on release — like
|
|
477
481
|
// ⌘Tab. Frozen ring so neighbours don't reshuffle mid-cycle (same rule as
|
|
478
482
|
// the iOS swipe order). Ref mirror for the keyup/wheel listeners.
|
|
479
|
-
|
|
480
|
-
|
|
483
|
+
// via: how the HUD was opened. "keys" (⌘J/⌘L) lands on the modifier's
|
|
484
|
+
// release — the hold IS the deciding time. "wheel" (a two-finger swipe)
|
|
485
|
+
// has no release, so it waits: ⏎ or a click on a card switches, esc
|
|
486
|
+
// cancels, and a quiet spell closes it without switching. It used to
|
|
487
|
+
// commit 320ms after the swipe settled, i.e. while you were still looking.
|
|
488
|
+
const [switchHud, setSwitchHud] = useState<{ ring: SessionInfo[]; index: number; via: "keys" | "wheel" } | null>(null);
|
|
489
|
+
const switchHudRef = useRef<{ ring: SessionInfo[]; index: number; via: "keys" | "wheel" } | null>(null);
|
|
490
|
+
const hudPickRef = useRef<((index: number) => void) | null>(null);
|
|
481
491
|
switchHudRef.current = switchHud;
|
|
482
492
|
// Server-owned folders (Manual mode). Kept in App because the sessions
|
|
483
493
|
// fetch already carries them.
|
|
@@ -699,7 +709,7 @@ const App = () => {
|
|
|
699
709
|
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
|
|
700
710
|
// Published views (`hop view`). `session` scopes the panel to one session;
|
|
701
711
|
// an object with none open it fleet-wide. Null = closed.
|
|
702
|
-
const [viewsOpen, setViewsOpen] = useState<{ session?: string; dock?: boolean } | null>(null);
|
|
712
|
+
const [viewsOpen, setViewsOpen] = useState<{ session?: string; dock?: boolean; item?: string } | null>(null);
|
|
703
713
|
const sessionsRef = useRef(sessions);
|
|
704
714
|
sessionsRef.current = sessions;
|
|
705
715
|
// Claude Code titles its process with a bare version number; hop's session
|
|
@@ -743,6 +753,14 @@ const App = () => {
|
|
|
743
753
|
return screenLooksLikeClaude();
|
|
744
754
|
};
|
|
745
755
|
foregroundIsClaudeRef.current = foregroundIsClaude;
|
|
756
|
+
// Which agent the current session runs, by the daemon's hook records.
|
|
757
|
+
const currentSessionAgentRef = useRef<(() => "claude" | "codex" | null) | null>(null);
|
|
758
|
+
currentSessionAgentRef.current = () => {
|
|
759
|
+
const room = activeSessionRoomRef.current;
|
|
760
|
+
if (!room) return null;
|
|
761
|
+
const s = sessionsRef.current.find((x) => (x.internalName || x.name) === room);
|
|
762
|
+
return s?.agent || null;
|
|
763
|
+
};
|
|
746
764
|
const drawerOpenRef = useRef(drawerOpen);
|
|
747
765
|
drawerOpenRef.current = drawerOpen;
|
|
748
766
|
const shortcutHelpRef = useRef(shortcutHelpOpen);
|
|
@@ -1496,12 +1514,17 @@ const App = () => {
|
|
|
1496
1514
|
lastSentSizeRef.current = { cols, rows };
|
|
1497
1515
|
const deliberate = claim === "attach" && deliberateAttachRef.current;
|
|
1498
1516
|
if (deliberate) deliberateAttachRef.current = false;
|
|
1517
|
+
// An explicit Fit says "use MY size" even over someone typing now;
|
|
1518
|
+
// opening/clicking only takes the size from an idle or absent owner.
|
|
1519
|
+
const take = takeSizeRef.current;
|
|
1520
|
+
takeSizeRef.current = false;
|
|
1499
1521
|
sendMessage({
|
|
1500
1522
|
type: "resize",
|
|
1501
1523
|
cols,
|
|
1502
1524
|
rows,
|
|
1503
1525
|
...(claim ? { claim } : {}),
|
|
1504
|
-
...(deliberate ? { user: true } : {})
|
|
1526
|
+
...(deliberate ? { user: true } : {}),
|
|
1527
|
+
...(take ? { take: true } : {})
|
|
1505
1528
|
});
|
|
1506
1529
|
};
|
|
1507
1530
|
|
|
@@ -1510,6 +1533,8 @@ const App = () => {
|
|
|
1510
1533
|
* here. Quiet when the session is already at our fit, so an ordinary click
|
|
1511
1534
|
* (or a selection drag) never pushes a resize.
|
|
1512
1535
|
*/
|
|
1536
|
+
// Set by an explicit Fit: the next resize carries take:true.
|
|
1537
|
+
const takeSizeRef = useRef(false);
|
|
1513
1538
|
const claimSizeHere = (force = false) => {
|
|
1514
1539
|
if (!force && !claimOnClick({
|
|
1515
1540
|
viewMode: viewModeRef.current,
|
|
@@ -1519,6 +1544,7 @@ const App = () => {
|
|
|
1519
1544
|
})) return;
|
|
1520
1545
|
attachClaimPendingRef.current = true;
|
|
1521
1546
|
deliberateAttachRef.current = true;
|
|
1547
|
+
if (force) takeSizeRef.current = true;
|
|
1522
1548
|
fitToViewport();
|
|
1523
1549
|
handleResize();
|
|
1524
1550
|
};
|
|
@@ -2098,6 +2124,12 @@ const App = () => {
|
|
|
2098
2124
|
fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--font-terminal").trim()
|
|
2099
2125
|
|| "Menlo, Monaco, 'Courier New', monospace",
|
|
2100
2126
|
fontSize,
|
|
2127
|
+
// ⌥-drag always selects, iTerm's escape hatch for apps that track the
|
|
2128
|
+
// mouse. (Plain drag selects too in agent sessions — utils/dragSelect.)
|
|
2129
|
+
macOptionClickForcesSelection: true,
|
|
2130
|
+
// Decorations (inline hop view cards, utils/viewBlocks.ts) are a
|
|
2131
|
+
// proposed xterm API; this is the switch that lets them register.
|
|
2132
|
+
allowProposedApi: true,
|
|
2101
2133
|
lineHeight: 1.3,
|
|
2102
2134
|
cursorBlink: true,
|
|
2103
2135
|
scrollback: 50000,
|
|
@@ -2135,7 +2167,18 @@ const App = () => {
|
|
|
2135
2167
|
// KaTeX, a click pins it. What `hop math` could only approximate in
|
|
2136
2168
|
// Unicode, the browser around the terminal draws properly.
|
|
2137
2169
|
enableMathHover(terminal, getMathTip());
|
|
2170
|
+
// `hop view` deliveries become small inline cards where they printed;
|
|
2171
|
+
// Open lands in the Views reader on that item.
|
|
2172
|
+
(terminal as any).__viewBlocksCleanup = enableViewBlocks(terminal as never, (b) => setViewsOpen({ session: b.session, item: b.name }));
|
|
2138
2173
|
terminal.open(containerRef.current);
|
|
2174
|
+
// Plain drag selects in Claude/Codex sessions even though they track the
|
|
2175
|
+
// mouse; the wheel still reaches them. See utils/dragSelect.ts.
|
|
2176
|
+
// Hooked on the SCROLL wrapper, not xterm's host: a press in the gutter
|
|
2177
|
+
// lands on the wrapper (which still starts a selection when tracking is
|
|
2178
|
+
// off), and a capture listener only sees presses on its own subtree.
|
|
2179
|
+
(terminal as any).__dragSelectCleanup = installDragSelect(terminal as never,
|
|
2180
|
+
(containerRef.current.closest(".terminal-scroll") as HTMLElement | null) || containerRef.current, () =>
|
|
2181
|
+
foregroundIsClaudeRef.current?.() === true || currentSessionAgentRef.current?.() === "codex");
|
|
2139
2182
|
|
|
2140
2183
|
// GPU-accelerated rendering (same renderer VS Code uses). Must load after
|
|
2141
2184
|
// open(). If WebGL isn't available (old GPU, blocklisted driver) the
|
|
@@ -3032,6 +3075,14 @@ const App = () => {
|
|
|
3032
3075
|
(terminal as any).__overlayScrollbarCleanup();
|
|
3033
3076
|
(terminal as any).__overlayScrollbarCleanup = null;
|
|
3034
3077
|
}
|
|
3078
|
+
if ((terminal as any).__viewBlocksCleanup) {
|
|
3079
|
+
(terminal as any).__viewBlocksCleanup();
|
|
3080
|
+
(terminal as any).__viewBlocksCleanup = null;
|
|
3081
|
+
}
|
|
3082
|
+
if ((terminal as any).__dragSelectCleanup) {
|
|
3083
|
+
(terminal as any).__dragSelectCleanup();
|
|
3084
|
+
(terminal as any).__dragSelectCleanup = null;
|
|
3085
|
+
}
|
|
3035
3086
|
terminal.dispose();
|
|
3036
3087
|
termRef.current = null;
|
|
3037
3088
|
fitRef.current = null;
|
|
@@ -3211,6 +3262,7 @@ const App = () => {
|
|
|
3211
3262
|
agent: s.agent === "claude" || s.agent === "codex" ? s.agent : null,
|
|
3212
3263
|
agentPhase: s.agentPhase === "working" || s.agentPhase === "done" ? s.agentPhase : null,
|
|
3213
3264
|
turnSeen: s.turnSeen !== false,
|
|
3265
|
+
checkbacks: Number(s.checkbacks) > 0 ? Number(s.checkbacks) : undefined,
|
|
3214
3266
|
cols: Number.isInteger(s.cols) ? s.cols : undefined,
|
|
3215
3267
|
rows: Number.isInteger(s.rows) ? s.rows : undefined,
|
|
3216
3268
|
// These three were silently dropped here, so the switcher never saw
|
|
@@ -3885,10 +3937,10 @@ const App = () => {
|
|
|
3885
3937
|
[...sessionsRef.current]
|
|
3886
3938
|
.filter((s) => s.type !== "port")
|
|
3887
3939
|
.sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
|
|
3888
|
-
const hudStep = (dir: 1 | -1) => {
|
|
3940
|
+
const hudStep = (dir: 1 | -1, via: "keys" | "wheel" = "keys") => {
|
|
3889
3941
|
const open = switchHudRef.current;
|
|
3890
3942
|
if (open) {
|
|
3891
|
-
setSwitchHud({
|
|
3943
|
+
setSwitchHud({ ...open, index: Math.min(open.ring.length - 1, Math.max(0, open.index + dir)) });
|
|
3892
3944
|
return;
|
|
3893
3945
|
}
|
|
3894
3946
|
const ring = hudRing();
|
|
@@ -3896,7 +3948,7 @@ const App = () => {
|
|
|
3896
3948
|
const curKey = activeSessionRoomRef.current;
|
|
3897
3949
|
const cur = ring.findIndex((s) => s.name === curKey || s.internalName === curKey);
|
|
3898
3950
|
const start = cur < 0 ? 0 : cur;
|
|
3899
|
-
setSwitchHud({ ring, index: Math.min(ring.length - 1, Math.max(0, start + dir)) });
|
|
3951
|
+
setSwitchHud({ ring, index: Math.min(ring.length - 1, Math.max(0, start + dir)), via });
|
|
3900
3952
|
};
|
|
3901
3953
|
const hudCommit = () => {
|
|
3902
3954
|
const open = switchHudRef.current;
|
|
@@ -3949,12 +4001,21 @@ const App = () => {
|
|
|
3949
4001
|
const mod = isMacPlatform
|
|
3950
4002
|
? event.metaKey && !event.ctrlKey && !event.altKey
|
|
3951
4003
|
: event.ctrlKey && event.shiftKey && !event.metaKey && !event.altKey;
|
|
4004
|
+
// esc cancels the HUD whether or not the modifier is still held — the
|
|
4005
|
+
// hint promises it, and with ⌘ down it used to fall through.
|
|
4006
|
+
if (event.key === "Escape" && switchHudRef.current) {
|
|
4007
|
+
event.preventDefault();
|
|
4008
|
+
event.stopPropagation();
|
|
4009
|
+
hudCancel();
|
|
4010
|
+
return;
|
|
4011
|
+
}
|
|
3952
4012
|
if (!mod) {
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
event.stopPropagation();
|
|
3956
|
-
|
|
3957
|
-
|
|
4013
|
+
// A swipe-opened HUD waits for a decision: ⏎ switches, ← → step.
|
|
4014
|
+
if (switchHudRef.current?.via === "wheel") {
|
|
4015
|
+
if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); hudCommit(); return; }
|
|
4016
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
4017
|
+
event.preventDefault(); event.stopPropagation(); hudStep(event.key === "ArrowRight" ? 1 : -1, "wheel"); return;
|
|
4018
|
+
}
|
|
3958
4019
|
}
|
|
3959
4020
|
if (event.key === "Escape" && (drawerOpenRef.current || shortcutHelpRef.current)) {
|
|
3960
4021
|
event.preventDefault();
|
|
@@ -4037,7 +4098,8 @@ const App = () => {
|
|
|
4037
4098
|
// target, ⌘Tab-style. Any keyup counts: the modifier's own release AND
|
|
4038
4099
|
// releasing it while another key is up both leave the flag false.
|
|
4039
4100
|
const onKeyUp = (event: KeyboardEvent) => {
|
|
4040
|
-
|
|
4101
|
+
const open = switchHudRef.current;
|
|
4102
|
+
if (!open || open.via !== "keys") return; // a swipe-opened HUD lands on ⏎ or a click, not on a key release
|
|
4041
4103
|
if (!(isMacPlatform ? event.metaKey : event.ctrlKey)) hudCommit();
|
|
4042
4104
|
};
|
|
4043
4105
|
window.addEventListener("keyup", onKeyUp, true);
|
|
@@ -4047,20 +4109,34 @@ const App = () => {
|
|
|
4047
4109
|
// swipe never fires. Steps per 60pt of travel, settles into a commit.
|
|
4048
4110
|
let wheelAccum = 0;
|
|
4049
4111
|
let wheelTimer: number | null = null;
|
|
4112
|
+
const HUD_IDLE_CLOSE_MS = 8000;
|
|
4050
4113
|
const onWheel = (event: WheelEvent) => {
|
|
4051
4114
|
if (!session) return;
|
|
4052
4115
|
const el = event.target as Element | null;
|
|
4053
4116
|
if (el?.closest(".switcher-scroll, .views-panel, .drawer, input, textarea, [contenteditable]")) return;
|
|
4054
4117
|
if (Math.abs(event.deltaX) <= Math.abs(event.deltaY) * 1.3) return;
|
|
4118
|
+
// A swipe never opens the HUD over a keyboard-held one.
|
|
4119
|
+
if (switchHudRef.current && switchHudRef.current.via !== "wheel") return;
|
|
4055
4120
|
event.preventDefault();
|
|
4056
4121
|
wheelAccum += event.deltaX;
|
|
4057
4122
|
while (Math.abs(wheelAccum) >= 60) {
|
|
4058
4123
|
const dir: 1 | -1 = wheelAccum > 0 ? 1 : -1;
|
|
4059
4124
|
wheelAccum -= dir * 60;
|
|
4060
|
-
hudStep(dir);
|
|
4125
|
+
hudStep(dir, "wheel");
|
|
4061
4126
|
}
|
|
4127
|
+
// The gesture settling is NOT a decision. The HUD stays until ⏎ or a
|
|
4128
|
+
// click (switch) or esc; left alone, it closes without switching.
|
|
4062
4129
|
if (wheelTimer) window.clearTimeout(wheelTimer);
|
|
4063
|
-
wheelTimer = window.setTimeout(() => {
|
|
4130
|
+
wheelTimer = window.setTimeout(() => {
|
|
4131
|
+
wheelAccum = 0;
|
|
4132
|
+
if (switchHudRef.current?.via === "wheel") hudCancel();
|
|
4133
|
+
}, HUD_IDLE_CLOSE_MS);
|
|
4134
|
+
};
|
|
4135
|
+
hudPickRef.current = (index: number) => {
|
|
4136
|
+
const open = switchHudRef.current;
|
|
4137
|
+
if (!open) return;
|
|
4138
|
+
switchHudRef.current = { ...open, index };
|
|
4139
|
+
hudCommit();
|
|
4064
4140
|
};
|
|
4065
4141
|
window.addEventListener("wheel", onWheel, { passive: false, capture: true });
|
|
4066
4142
|
return () => {
|
|
@@ -4091,7 +4167,7 @@ const App = () => {
|
|
|
4091
4167
|
const curKey = session?.room ?? sessionLabel;
|
|
4092
4168
|
const SLOT = 152; // card 140 + gap 12; must match .switch-hud CSS
|
|
4093
4169
|
return (
|
|
4094
|
-
<div className="switch-hud" aria-hidden="true">
|
|
4170
|
+
<div className={"switch-hud" + (switchHud.via === "wheel" ? " waits" : "")} aria-hidden="true">
|
|
4095
4171
|
<div className="switch-hud-window">
|
|
4096
4172
|
<div className="switch-hud-strip"
|
|
4097
4173
|
style={{ transform: `translateX(-${switchHud.index * SLOT + 70}px)` }}>
|
|
@@ -4103,7 +4179,8 @@ const App = () => {
|
|
|
4103
4179
|
<div key={key}
|
|
4104
4180
|
className={"switch-hud-card"
|
|
4105
4181
|
+ (isTarget ? " is-target" : "")
|
|
4106
|
-
+ (isCurrent ? " is-current" : "")}
|
|
4182
|
+
+ (isCurrent ? " is-current" : "")}
|
|
4183
|
+
onClick={() => hudPickRef.current?.(i)}>
|
|
4107
4184
|
<span className="switch-hud-name">{s.displayName || s.name}</span>
|
|
4108
4185
|
{isCurrent && <span className="switch-hud-now">now</span>}
|
|
4109
4186
|
</div>
|
|
@@ -4112,7 +4189,9 @@ const App = () => {
|
|
|
4112
4189
|
</div>
|
|
4113
4190
|
</div>
|
|
4114
4191
|
<div className="switch-hud-hint">
|
|
4115
|
-
{
|
|
4192
|
+
{switchHud.via === "wheel"
|
|
4193
|
+
? "swipe or ← → to step · ⏎ or click to switch · esc cancels"
|
|
4194
|
+
: `${isMacPlatform ? "⌘J / ⌘L step · release ⌘ to switch" : "Ctrl+Shift+J / +L step · release to switch"} · esc cancels`}
|
|
4116
4195
|
</div>
|
|
4117
4196
|
</div>
|
|
4118
4197
|
);
|
|
@@ -4220,7 +4299,7 @@ const App = () => {
|
|
|
4220
4299
|
onRefresh={() => fetchSessions({ showLoading: false })}
|
|
4221
4300
|
onNotice={showToast}
|
|
4222
4301
|
tileWsBase={resolveWsUrl()}
|
|
4223
|
-
onOpenViews={(scope) => setViewsOpen({ session: scope })}
|
|
4302
|
+
onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
|
|
4224
4303
|
userName={name}
|
|
4225
4304
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
4226
4305
|
/>
|
|
@@ -4884,7 +4963,7 @@ const App = () => {
|
|
|
4884
4963
|
tileWsBase={resolveWsUrl()}
|
|
4885
4964
|
onFocusSession={focusSessionInPlace}
|
|
4886
4965
|
folders={folders}
|
|
4887
|
-
onOpenViews={(scope) => setViewsOpen({ session: scope })}
|
|
4966
|
+
onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
|
|
4888
4967
|
userName={name}
|
|
4889
4968
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
4890
4969
|
onOpenSettings={() => {
|
|
@@ -5028,7 +5107,7 @@ const App = () => {
|
|
|
5028
5107
|
{/* Outside the mode ternary on purpose: Views opens from the hub, from a
|
|
5029
5108
|
session, and from over the switcher, and it must outlive a switch. */}
|
|
5030
5109
|
{viewsOpen && (
|
|
5031
|
-
<ViewsPanel session={viewsOpen.session} sessions={sessions} dock={viewsOpen.dock}
|
|
5110
|
+
<ViewsPanel session={viewsOpen.session} item={viewsOpen.item} sessions={sessions} dock={viewsOpen.dock}
|
|
5032
5111
|
onClose={() => setViewsOpen(null)} />
|
|
5033
5112
|
)}
|
|
5034
5113
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useEffect, useMemo, useState } from "react";
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { createReadWitness, wordCount } from "../utils/readWitness";
|
|
2
3
|
import type { SwitcherSession } from "../utils/switcherModel";
|
|
3
4
|
|
|
4
5
|
// The briefing, on the desktop wall — the same editions the iOS app renders,
|
|
@@ -16,7 +17,14 @@ type DigestItem = {
|
|
|
16
17
|
headline: string;
|
|
17
18
|
why?: string;
|
|
18
19
|
urgency?: string;
|
|
20
|
+
// The chief-of-staff page: decide / watch / done, and an "also" roll-up
|
|
21
|
+
// of headline-only items. Older editions have no slot and render flat.
|
|
22
|
+
slot?: "decide" | "watch" | "done" | "also";
|
|
23
|
+
recommendation?: string;
|
|
24
|
+
replies?: string[];
|
|
19
25
|
};
|
|
26
|
+
const SLOT_LABEL: Record<string, string> = { decide: "Decide", watch: "Watch", done: "Done" };
|
|
27
|
+
const SLOT_ORDER = ["decide", "watch", "done"];
|
|
20
28
|
|
|
21
29
|
type Edition = {
|
|
22
30
|
generated_at?: string;
|
|
@@ -63,6 +71,18 @@ export const DigestCard = ({ sessions, onOpen }: {
|
|
|
63
71
|
onOpen: (session: SwitcherSession) => void;
|
|
64
72
|
}) => {
|
|
65
73
|
const [editions, setEditions] = useState<Edition[]>([]);
|
|
74
|
+
// The read witness: dwell on a visible story, with the human present,
|
|
75
|
+
// reports glimpsed/read to the daemon; opening a session from a story
|
|
76
|
+
// reports acted. The daemon's union across devices feeds the "new"
|
|
77
|
+
// badge below and tells the generator what may be told as a delta.
|
|
78
|
+
const witnessRef = useRef<ReturnType<typeof createReadWitness> | null>(null);
|
|
79
|
+
if (!witnessRef.current) {
|
|
80
|
+
witnessRef.current = createReadWitness((reads) => {
|
|
81
|
+
fetch("/api/digest/reads", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reads }), keepalive: true })
|
|
82
|
+
.catch(() => { /* offline: the next batch carries it */ });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
useEffect(() => () => { witnessRef.current?.dispose(); witnessRef.current = null; }, []);
|
|
66
86
|
const [dismissed, setDismissed] = useState(() => {
|
|
67
87
|
try { return localStorage.getItem(DISMISS_KEY) || ""; } catch { return ""; }
|
|
68
88
|
});
|
|
@@ -83,9 +103,13 @@ export const DigestCard = ({ sessions, onOpen }: {
|
|
|
83
103
|
// the very first briefing, before an archive exists.
|
|
84
104
|
Promise.all([
|
|
85
105
|
fetch("/assets/digest-archive.json", { cache: "no-store" }).then((r) => (r.ok ? r.json() : null)).catch(() => null),
|
|
86
|
-
fetch("/assets/digest.json", { cache: "no-store" }).then((r) => (r.ok ? r.json() : null)).catch(() => null)
|
|
87
|
-
|
|
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>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
|
2
|
+
import { installDragSelect } from "../utils/dragSelect";
|
|
3
|
+
import { bufferLooksLikeClaude } from "../utils/voiceHold";
|
|
2
4
|
import { Terminal } from "@xterm/xterm";
|
|
3
5
|
import { FitAddon } from "@xterm/addon-fit";
|
|
4
6
|
import { safeParseServerMessage } from "hay-shared";
|
|
@@ -58,6 +60,8 @@ export const SecondaryPane = ({
|
|
|
58
60
|
onSplit,
|
|
59
61
|
onZoom
|
|
60
62
|
}: Props) => {
|
|
63
|
+
const procLabelRef = useRef(procLabel);
|
|
64
|
+
procLabelRef.current = procLabel;
|
|
61
65
|
const hostRef = useRef<HTMLDivElement>(null);
|
|
62
66
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
63
67
|
const termRef = useRef<Terminal | null>(null);
|
|
@@ -77,6 +81,10 @@ export const SecondaryPane = ({
|
|
|
77
81
|
cols: sizeRef.current.cols,
|
|
78
82
|
rows: sizeRef.current.rows,
|
|
79
83
|
fontSize,
|
|
84
|
+
macOptionClickForcesSelection: true,
|
|
85
|
+
// Decorations (inline hop view cards, utils/viewBlocks.ts) are a
|
|
86
|
+
// proposed xterm API; this is the switch that lets them register.
|
|
87
|
+
allowProposedApi: true,
|
|
80
88
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
81
89
|
theme: theme as never,
|
|
82
90
|
scrollback: 2000,
|
|
@@ -86,6 +94,10 @@ export const SecondaryPane = ({
|
|
|
86
94
|
term.loadAddon(fitAddon);
|
|
87
95
|
enableMathHover(term, getMathTip());
|
|
88
96
|
term.open(hostRef.current);
|
|
97
|
+
// Plain drag selects in Claude/Codex panes even though they track the
|
|
98
|
+
// mouse; the wheel still reaches them. See utils/dragSelect.ts.
|
|
99
|
+
const uninstallDragSelect = installDragSelect(term as never, hostRef.current, () =>
|
|
100
|
+
/^(claude|codex)$/i.test(procLabelRef.current || "") || bufferLooksLikeClaude(term as never));
|
|
89
101
|
termRef.current = term;
|
|
90
102
|
|
|
91
103
|
let shouldReconnect = true;
|
|
@@ -222,6 +234,7 @@ export const SecondaryPane = ({
|
|
|
222
234
|
ro.disconnect();
|
|
223
235
|
dataSub.dispose();
|
|
224
236
|
try { wsRef.current?.close(); } catch { /* ignore */ }
|
|
237
|
+
uninstallDragSelect();
|
|
225
238
|
term.dispose();
|
|
226
239
|
termRef.current = null;
|
|
227
240
|
};
|