hop2 0.11.66 → 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/hay/apps/web/src/App.tsx +23 -0
- package/hay/apps/web/src/components/CheckbacksPanel.tsx +96 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +13 -3
- package/hay/apps/web/src/styles.css +21 -0
- package/hay/apps/web/src/utils/checkbackText.ts +73 -0
- package/hay/apps/web/test/checkbackText.test.ts +40 -0
- package/package.json +1 -1
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -24,6 +24,7 @@ import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
|
|
|
24
24
|
import { claimOnAttach, claimOnClick, isPlainClick } from "./utils/sizeClaim";
|
|
25
25
|
import { installDragSelect } from "./utils/dragSelect";
|
|
26
26
|
import { enableViewBlocks } from "./utils/viewBlocks";
|
|
27
|
+
import { CheckbacksPanel } from "./components/CheckbacksPanel";
|
|
27
28
|
import { MobileKeyboard } from "./components/MobileKeyboard";
|
|
28
29
|
import { SessionSwitcher } from "./components/SessionSwitcher";
|
|
29
30
|
import { SecondaryPane } from "./components/SecondaryPane";
|
|
@@ -710,6 +711,8 @@ const App = () => {
|
|
|
710
711
|
// Published views (`hop view`). `session` scopes the panel to one session;
|
|
711
712
|
// an object with none open it fleet-wide. Null = closed.
|
|
712
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);
|
|
713
716
|
const sessionsRef = useRef(sessions);
|
|
714
717
|
sessionsRef.current = sessions;
|
|
715
718
|
// Claude Code titles its process with a bare version number; hop's session
|
|
@@ -3497,9 +3500,18 @@ const App = () => {
|
|
|
3497
3500
|
// lands full-screen in the terminal you were just typing in. State-only:
|
|
3498
3501
|
// while the wall is open the full-screen socket stays down (one client per
|
|
3499
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
|
+
};
|
|
3500
3511
|
const focusSessionInPlace = (nextSession: SessionInfo) => {
|
|
3501
3512
|
const targetRoom = nextSession.internalName || nextSession.name;
|
|
3502
3513
|
if ((session?.room ?? null) === targetRoom) return;
|
|
3514
|
+
markTurnSeenLocally(targetRoom);
|
|
3503
3515
|
const markers = loadSeenMarkers();
|
|
3504
3516
|
markers[targetRoom] = { out: nextSession.lastActivityAt || 0, bell: nextSession.bellSeq || 0 };
|
|
3505
3517
|
saveSeenMarkers(markers);
|
|
@@ -3519,6 +3531,7 @@ const App = () => {
|
|
|
3519
3531
|
};
|
|
3520
3532
|
|
|
3521
3533
|
const switchSession = (nextSession: SessionInfo) => {
|
|
3534
|
+
markTurnSeenLocally(nextSession.internalName || nextSession.name);
|
|
3522
3535
|
const targetRoom = nextSession.internalName || nextSession.name;
|
|
3523
3536
|
const nextPath = buildSessionPath(targetRoom);
|
|
3524
3537
|
const currentRoom = session?.room ?? sessionLabel;
|
|
@@ -4300,6 +4313,7 @@ const App = () => {
|
|
|
4300
4313
|
onNotice={showToast}
|
|
4301
4314
|
tileWsBase={resolveWsUrl()}
|
|
4302
4315
|
onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
|
|
4316
|
+
onOpenCheckbacks={(scope) => setCheckbacksOpen(scope)}
|
|
4303
4317
|
userName={name}
|
|
4304
4318
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
4305
4319
|
/>
|
|
@@ -4964,6 +4978,7 @@ const App = () => {
|
|
|
4964
4978
|
onFocusSession={focusSessionInPlace}
|
|
4965
4979
|
folders={folders}
|
|
4966
4980
|
onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
|
|
4981
|
+
onOpenCheckbacks={(scope) => setCheckbacksOpen(scope)}
|
|
4967
4982
|
userName={name}
|
|
4968
4983
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
4969
4984
|
onOpenSettings={() => {
|
|
@@ -5106,6 +5121,14 @@ const App = () => {
|
|
|
5106
5121
|
)}
|
|
5107
5122
|
{/* Outside the mode ternary on purpose: Views opens from the hub, from a
|
|
5108
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
|
+
)}
|
|
5109
5132
|
{viewsOpen && (
|
|
5110
5133
|
<ViewsPanel session={viewsOpen.session} item={viewsOpen.item} sessions={sessions} dock={viewsOpen.dock}
|
|
5111
5134
|
onClose={() => setViewsOpen(null)} />
|
|
@@ -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
|
+
};
|
|
@@ -118,6 +118,8 @@ type Props = {
|
|
|
118
118
|
// Open the Views panel. No argument = the whole fleet (header button); an
|
|
119
119
|
// internalName = just that session (a card's chip).
|
|
120
120
|
onOpenViews?: (session?: string, item?: string) => void;
|
|
121
|
+
/** The ⏰ chip: what hop will type into this session, and when. */
|
|
122
|
+
onOpenCheckbacks?: (session: string) => void;
|
|
121
123
|
};
|
|
122
124
|
|
|
123
125
|
type Sheet = {
|
|
@@ -1282,7 +1284,8 @@ export const SessionSwitcher = ({
|
|
|
1282
1284
|
onOpenSettings,
|
|
1283
1285
|
onToggleKeyboard,
|
|
1284
1286
|
onFind,
|
|
1285
|
-
onOpenViews
|
|
1287
|
+
onOpenViews,
|
|
1288
|
+
onOpenCheckbacks
|
|
1286
1289
|
}: Props) => {
|
|
1287
1290
|
// View state survives a REFRESH but not a new tab: sessionStorage, not
|
|
1288
1291
|
// localStorage. Reloading the page (or hop reconnecting) used to dump the
|
|
@@ -3007,9 +3010,16 @@ export const SessionSwitcher = ({
|
|
|
3007
3010
|
)}
|
|
3008
3011
|
{!current && s.starting && !s.active && <span className="switcher-chip starting">STARTING</span>}
|
|
3009
3012
|
{!!s.checkbacks && (
|
|
3010
|
-
<
|
|
3013
|
+
<button
|
|
3014
|
+
type="button"
|
|
3015
|
+
className="switcher-chip checkback"
|
|
3016
|
+
title={`${s.checkbacks} scheduled check-back${s.checkbacks === 1 ? "" : "s"} will be typed into this session — click to see what is waiting`}
|
|
3017
|
+
aria-label={`${s.checkbacks} check-back${s.checkbacks === 1 ? "" : "s"} waiting on ${s.displayName}`}
|
|
3018
|
+
onClick={(e) => { e.stopPropagation(); onOpenCheckbacks?.(key); }}
|
|
3019
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
3020
|
+
>
|
|
3011
3021
|
⏰ {s.checkbacks}
|
|
3012
|
-
</
|
|
3022
|
+
</button>
|
|
3013
3023
|
)}
|
|
3014
3024
|
{phase && (
|
|
3015
3025
|
<span className={`switcher-chip phase ${phase}`} title={phaseTitle(phase)}>
|
|
@@ -3878,7 +3878,28 @@ body.pane-dragging { user-select: none; }
|
|
|
3878
3878
|
color: var(--ink);
|
|
3879
3879
|
border: 1px solid var(--line);
|
|
3880
3880
|
font-weight: 600;
|
|
3881
|
+
font-family: inherit;
|
|
3882
|
+
cursor: pointer;
|
|
3881
3883
|
}
|
|
3884
|
+
.switcher-chip.checkback:hover { border-color: var(--accent); }
|
|
3885
|
+
/* The check-backs window (components/CheckbacksPanel.tsx). */
|
|
3886
|
+
.checkbacks-panel { width: min(560px, 92vw); max-height: min(78vh, 680px); }
|
|
3887
|
+
.checkbacks-session { font-family: var(--font-mono); font-size: 12px; font-weight: 700; color: var(--ink); }
|
|
3888
|
+
.checkbacks-list { overflow: auto; display: flex; flex-direction: column; gap: 8px; }
|
|
3889
|
+
.checkback-row { border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; background: var(--panel); }
|
|
3890
|
+
.checkback-row.pending { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); background: color-mix(in srgb, var(--accent) 6%, var(--panel)); }
|
|
3891
|
+
.checkback-row.pending.fired { border-color: color-mix(in srgb, var(--warn) 60%, var(--line)); }
|
|
3892
|
+
.checkback-row.delivered, .checkback-row.cancelled { opacity: 0.72; }
|
|
3893
|
+
.checkback-when { font-family: var(--font-mono); font-size: 11px; color: var(--ink-muted); }
|
|
3894
|
+
.checkback-message { margin-top: 3px; font-size: 13px; color: var(--ink); white-space: pre-wrap; word-break: break-word; }
|
|
3895
|
+
.checkback-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; }
|
|
3896
|
+
.checkback-state { font-size: 11px; color: var(--ink-muted); }
|
|
3897
|
+
.checkback-cancel { font: inherit; font-size: 11.5px; padding: 3px 9px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg-alt); color: var(--ink); cursor: pointer; }
|
|
3898
|
+
.checkback-cancel:hover { border-color: var(--err); color: var(--err); }
|
|
3899
|
+
.checkbacks-past-head { font-family: var(--font-mono); font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-muted); margin-top: 4px; }
|
|
3900
|
+
.checkbacks-empty { color: var(--ink-muted); font-size: 13px; margin: 6px 2px; }
|
|
3901
|
+
.checkbacks-hint { color: var(--ink-muted); font-size: 11.5px; margin: 6px 2px 0; }
|
|
3902
|
+
.checkbacks-hint code { font-family: var(--font-mono); }
|
|
3882
3903
|
.switcher-chip.phase.done-read {
|
|
3883
3904
|
background: var(--bg-alt);
|
|
3884
3905
|
color: var(--ink-muted);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Words for a check-back's trigger and state, as the daemon stores them
|
|
2
|
+
// (lib/checkbacks.js). Shared by the wall's check-back window.
|
|
3
|
+
|
|
4
|
+
export type CheckbackTrigger =
|
|
5
|
+
| { kind: "at"; at: number }
|
|
6
|
+
| { kind: "every"; everyMs: number; next: number; until?: number | null }
|
|
7
|
+
| { kind: "idle"; afterMs?: number }
|
|
8
|
+
| { kind: "file"; path: string; baselineMtime?: number | null }
|
|
9
|
+
| { kind: "cmd"; command: string; everyMs?: number };
|
|
10
|
+
|
|
11
|
+
export type Checkback = {
|
|
12
|
+
id: string;
|
|
13
|
+
session: string;
|
|
14
|
+
sessionName?: string;
|
|
15
|
+
message: string;
|
|
16
|
+
trigger: CheckbackTrigger;
|
|
17
|
+
force?: boolean;
|
|
18
|
+
createdBy?: string;
|
|
19
|
+
createdAt?: number;
|
|
20
|
+
status: "pending" | "delivered" | "cancelled";
|
|
21
|
+
firedAt?: number | null;
|
|
22
|
+
deliveredAt?: number | null;
|
|
23
|
+
cancelledAt?: number | null;
|
|
24
|
+
deliveries?: number;
|
|
25
|
+
lastError?: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const MIN = 60_000, HOUR = 3_600_000, DAY = 86_400_000;
|
|
29
|
+
|
|
30
|
+
/** "in 12m", "in 2.5h", "3m ago" — the distance from now, in the coarsest unit that reads. */
|
|
31
|
+
export const relative = (ms: number, now: number): string => {
|
|
32
|
+
const d = ms - now;
|
|
33
|
+
const a = Math.abs(d);
|
|
34
|
+
const s = a < MIN ? `${Math.max(1, Math.round(a / 1000))}s` : a < HOUR ? `${Math.round(a / MIN)}m` : a < DAY ? `${(a / HOUR).toFixed(a < 10 * HOUR ? 1 : 0)}h` : `${(a / DAY).toFixed(1)}d`;
|
|
35
|
+
return d >= 0 ? `in ${s}` : `${s} ago`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const clock = (ms: number): string => {
|
|
39
|
+
const d = new Date(ms);
|
|
40
|
+
const today = new Date().toDateString() === d.toDateString();
|
|
41
|
+
const time = d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
42
|
+
return today ? time : `${d.toLocaleDateString([], { weekday: "short" })} ${time}`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const period = (ms: number): string => (ms >= HOUR && ms % HOUR === 0 ? `${ms / HOUR}h` : ms >= HOUR ? `${(ms / HOUR).toFixed(1)}h` : `${Math.round(ms / MIN)}m`);
|
|
46
|
+
|
|
47
|
+
/** One line saying WHEN it fires, in the reader's words. */
|
|
48
|
+
export const describeTrigger = (t: CheckbackTrigger, now: number): string => {
|
|
49
|
+
switch (t.kind) {
|
|
50
|
+
case "at": return `at ${clock(t.at)} (${relative(t.at, now)})`;
|
|
51
|
+
case "every": return `every ${period(t.everyMs)} · next ${relative(t.next, now)}${t.until ? ` · until ${clock(t.until)}` : ""}`;
|
|
52
|
+
case "idle": return t.afterMs ? `when idle for ${period(t.afterMs)}` : "when the agent is next idle";
|
|
53
|
+
case "file": return `when ${t.path} ${t.baselineMtime ? "changes" : "exists"}`;
|
|
54
|
+
case "cmd": return `when \`${t.command}\` succeeds · checked every ${period(t.everyMs || MIN)}`;
|
|
55
|
+
default: return "";
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The state line: waiting, fired and waiting for idle, delivered, cancelled. */
|
|
60
|
+
export const describeState = (cb: Checkback, now: number): string => {
|
|
61
|
+
if (cb.status === "pending") return cb.firedAt ? "fired · waiting for the agent to be idle" : "waiting";
|
|
62
|
+
if (cb.status === "delivered") return `typed ${cb.deliveredAt ? relative(cb.deliveredAt, now) : ""}`.trim();
|
|
63
|
+
return `cancelled${cb.cancelledAt ? ` ${relative(cb.cancelledAt, now)}` : ""}`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Pending first (soonest first), then the recent rest, newest first. */
|
|
67
|
+
export const orderForSession = (items: Checkback[], session: string): Checkback[] => {
|
|
68
|
+
const mine = items.filter((c) => c.session === session);
|
|
69
|
+
const nextOf = (c: Checkback) => (c.trigger.kind === "at" ? c.trigger.at : c.trigger.kind === "every" ? c.trigger.next : Number.MAX_SAFE_INTEGER);
|
|
70
|
+
const pending = mine.filter((c) => c.status === "pending").sort((a, b) => nextOf(a) - nextOf(b));
|
|
71
|
+
const done = mine.filter((c) => c.status !== "pending").sort((a, b) => ((b.deliveredAt || b.cancelledAt || b.createdAt || 0) - (a.deliveredAt || a.cancelledAt || a.createdAt || 0)));
|
|
72
|
+
return [...pending, ...done];
|
|
73
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { describeState, describeTrigger, orderForSession, relative, type Checkback } from "../src/utils/checkbackText";
|
|
3
|
+
|
|
4
|
+
const NOW = Date.parse("2026-10-02T12:00:00Z");
|
|
5
|
+
const M = 60_000, H = 3_600_000;
|
|
6
|
+
|
|
7
|
+
describe("words for a check-back", () => {
|
|
8
|
+
it("relative distances read in the coarsest unit", () => {
|
|
9
|
+
expect(relative(NOW + 20_000, NOW)).toBe("in 20s");
|
|
10
|
+
expect(relative(NOW + 12 * M, NOW)).toBe("in 12m");
|
|
11
|
+
expect(relative(NOW + 2.5 * H, NOW)).toBe("in 2.5h");
|
|
12
|
+
expect(relative(NOW - 3 * M, NOW)).toBe("3m ago");
|
|
13
|
+
});
|
|
14
|
+
it("each trigger kind has a line", () => {
|
|
15
|
+
expect(describeTrigger({ kind: "every", everyMs: 2 * H, next: NOW + 30 * M }, NOW)).toBe("every 2h · next in 30m");
|
|
16
|
+
expect(describeTrigger({ kind: "idle" }, NOW)).toBe("when the agent is next idle");
|
|
17
|
+
expect(describeTrigger({ kind: "idle", afterMs: 15 * M }, NOW)).toBe("when idle for 15m");
|
|
18
|
+
expect(describeTrigger({ kind: "file", path: "/x/out.csv" }, NOW)).toBe("when /x/out.csv exists");
|
|
19
|
+
expect(describeTrigger({ kind: "file", path: "/x/out.csv", baselineMtime: 1 }, NOW)).toBe("when /x/out.csv changes");
|
|
20
|
+
expect(describeTrigger({ kind: "cmd", command: "test -f a", everyMs: 5 * M }, NOW)).toBe("when `test -f a` succeeds · checked every 5m");
|
|
21
|
+
expect(describeTrigger({ kind: "at", at: NOW + 10 * M }, NOW)).toMatch(/^at .* \(in 10m\)$/);
|
|
22
|
+
});
|
|
23
|
+
it("state lines", () => {
|
|
24
|
+
const base: Checkback = { id: "c", session: "s", message: "m", trigger: { kind: "idle" }, status: "pending" };
|
|
25
|
+
expect(describeState(base, NOW)).toBe("waiting");
|
|
26
|
+
expect(describeState({ ...base, firedAt: NOW }, NOW)).toBe("fired · waiting for the agent to be idle");
|
|
27
|
+
expect(describeState({ ...base, status: "delivered", deliveredAt: NOW - 5 * M }, NOW)).toBe("typed 5m ago");
|
|
28
|
+
expect(describeState({ ...base, status: "cancelled" }, NOW)).toBe("cancelled");
|
|
29
|
+
});
|
|
30
|
+
it("orders a session's check-backs: pending soonest first, then the recent rest newest first", () => {
|
|
31
|
+
const items: Checkback[] = [
|
|
32
|
+
{ id: "1", session: "s", message: "a", trigger: { kind: "at", at: NOW + 2 * H }, status: "pending" },
|
|
33
|
+
{ id: "2", session: "other", message: "x", trigger: { kind: "idle" }, status: "pending" },
|
|
34
|
+
{ id: "3", session: "s", message: "b", trigger: { kind: "at", at: NOW + 10 * M }, status: "pending" },
|
|
35
|
+
{ id: "4", session: "s", message: "c", trigger: { kind: "idle" }, status: "delivered", deliveredAt: NOW - H },
|
|
36
|
+
{ id: "5", session: "s", message: "d", trigger: { kind: "idle" }, status: "cancelled", cancelledAt: NOW - 10 * M }
|
|
37
|
+
];
|
|
38
|
+
expect(orderForSession(items, "s").map((c) => c.id)).toEqual(["3", "1", "5", "4"]);
|
|
39
|
+
});
|
|
40
|
+
});
|
package/package.json
CHANGED