hop2 0.11.79 → 0.11.81
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/server/src/rooms.ts +20 -3
- package/hay/apps/server/test/rooms.test.ts +12 -2
- package/hay/apps/web/src/App.tsx +8 -0
- package/hay/apps/web/src/components/CheckbacksPanel.tsx +156 -28
- package/hay/apps/web/src/components/DigestCard.tsx +13 -4
- package/hay/apps/web/src/components/SessionSwitcher.tsx +42 -12
- package/hay/apps/web/src/components/ViewsPanel.tsx +34 -9
- package/hay/apps/web/src/styles.css +106 -0
- package/hay/apps/web/src/utils/checkbackText.ts +101 -0
- package/hay/apps/web/src/utils/switcherModel.ts +4 -0
- package/hay/apps/web/test/scheduleText.test.ts +37 -0
- package/hop +251 -14
- package/package.json +1 -1
- package/skills/hop-session/SKILL.md +38 -0
- package/tests/terminal-api.integration.test.cjs +96 -0
|
@@ -1226,7 +1226,7 @@ export class Room extends EventEmitter {
|
|
|
1226
1226
|
* With a client attached, the real terminal answers and we stay silent.
|
|
1227
1227
|
*/
|
|
1228
1228
|
private answerTerminalQueriesIfHeadless(data: string) {
|
|
1229
|
-
if (this.
|
|
1229
|
+
if (this.ended) return;
|
|
1230
1230
|
if (data.indexOf("\x1b") === -1) return;
|
|
1231
1231
|
let reply = "";
|
|
1232
1232
|
// OSC 10/11: foreground / background color queries — answered with the
|
|
@@ -1234,12 +1234,29 @@ export class Room extends EventEmitter {
|
|
|
1234
1234
|
// background (Claude Code picks light vs dark this way) matches what the
|
|
1235
1235
|
// user is really looking at. Falls back to the dark default before any
|
|
1236
1236
|
// client has ever attached.
|
|
1237
|
+
//
|
|
1238
|
+
// Answered even while browsers are attached: the web terminal swallows
|
|
1239
|
+
// OSC 10/11 without replying, so "a client will answer" was never true
|
|
1240
|
+
// for them, and a Codex started with a tile or a viewer open got no
|
|
1241
|
+
// reply — it fell back to its dark palette and an unshaded input box
|
|
1242
|
+
// (alphagenome, 2026-10-15). Only a local CLI attach sits in a real
|
|
1243
|
+
// terminal that answers for itself.
|
|
1244
|
+
const answersColors = [...this.clients.values()].some((client) => client.source === "local-cli");
|
|
1237
1245
|
const osc = (n: number, hex: string) => {
|
|
1238
1246
|
const p = (i: number) => hex.slice(i, i + 2).repeat(2);
|
|
1239
1247
|
return `\x1b]${n};rgb:${p(0)}/${p(2)}/${p(4)}\x1b\\`;
|
|
1240
1248
|
};
|
|
1241
|
-
if (
|
|
1242
|
-
|
|
1249
|
+
if (!answersColors) {
|
|
1250
|
+
if (/\x1b\]10;\?(?:\x07|\x1b\\)/.test(data)) reply += osc(10, this.clientFg);
|
|
1251
|
+
if (/\x1b\]11;\?(?:\x07|\x1b\\)/.test(data)) reply += osc(11, this.clientBg);
|
|
1252
|
+
}
|
|
1253
|
+
// The rest xterm.js answers itself, so only a room nobody watches does.
|
|
1254
|
+
if (this.clients.size > 0) {
|
|
1255
|
+
if (reply) {
|
|
1256
|
+
try { this.pty.write(reply); } catch { /* pty gone */ }
|
|
1257
|
+
}
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1243
1260
|
// DA1: identify as a VT220-class terminal (matches xterm.js's own reply).
|
|
1244
1261
|
if (/\x1b\[0?c/.test(data)) reply += "\x1b[?62;22c";
|
|
1245
1262
|
// DSR 5 (status): "OK".
|
|
@@ -647,7 +647,7 @@ describe("Room", () => {
|
|
|
647
647
|
});
|
|
648
648
|
|
|
649
649
|
describe("headless terminal-identity answers", () => {
|
|
650
|
-
it("answers
|
|
650
|
+
it("answers DA/truecolor probes only while no client is attached; colors whenever no real terminal is", () => {
|
|
651
651
|
let ptyInstance: FakePty | null = null;
|
|
652
652
|
const factory: PtyFactory = () => {
|
|
653
653
|
ptyInstance = new FakePty() as unknown as FakePty;
|
|
@@ -663,9 +663,19 @@ describe("Room", () => {
|
|
|
663
663
|
expect(answered).toContain("[?62;22c");
|
|
664
664
|
expect(answered).toContain("P1+r524742");
|
|
665
665
|
|
|
666
|
-
//
|
|
666
|
+
// A browser attached: xterm.js answers DA itself, but swallows OSC
|
|
667
|
+
// 10/11 without replying — the room still answers the color, or a
|
|
668
|
+
// Codex started under a viewer gets no reply and goes dark.
|
|
667
669
|
room.attachClient({ id: "a", name: "Alex", colorIndex: 0, cols: 80, rows: 24 }, new FakeSocket());
|
|
668
670
|
ptyInstance!.writes.length = 0;
|
|
671
|
+
ptyInstance!.emit("\x1b]11;?\x07 \x1b[0c");
|
|
672
|
+
const withBrowser = ptyInstance!.writes.join("");
|
|
673
|
+
expect(withBrowser).toContain("]11;rgb:0d0d/1111/1717");
|
|
674
|
+
expect(withBrowser).not.toContain("[?62;22c");
|
|
675
|
+
|
|
676
|
+
// A local CLI attach sits in a real terminal that answers for itself.
|
|
677
|
+
room.attachClient({ id: "c", name: "cli", colorIndex: 1, cols: 80, rows: 24, source: "local-cli" }, new FakeSocket());
|
|
678
|
+
ptyInstance!.writes.length = 0;
|
|
669
679
|
ptyInstance!.emit("\x1b]11;?\x07");
|
|
670
680
|
expect(ptyInstance!.writes.join("")).toBe("");
|
|
671
681
|
});
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -276,6 +276,8 @@ type SessionInfo = {
|
|
|
276
276
|
turnSeen?: boolean;
|
|
277
277
|
// Scheduled check-backs waiting on this session (hop checkback).
|
|
278
278
|
checkbacks?: number;
|
|
279
|
+
checkbackNext?: number;
|
|
280
|
+
codexAutomations?: number;
|
|
279
281
|
attentionReason?: string;
|
|
280
282
|
attentionNote?: string;
|
|
281
283
|
cols?: number;
|
|
@@ -3301,6 +3303,8 @@ const App = () => {
|
|
|
3301
3303
|
agentPhase: s.agentPhase === "working" || s.agentPhase === "done" ? s.agentPhase : null,
|
|
3302
3304
|
turnSeen: s.turnSeen !== false,
|
|
3303
3305
|
checkbacks: Number(s.checkbacks) > 0 ? Number(s.checkbacks) : undefined,
|
|
3306
|
+
checkbackNext: Number(s.checkbackNext) > 0 ? Number(s.checkbackNext) : undefined,
|
|
3307
|
+
codexAutomations: Number(s.codexAutomations) > 0 ? Number(s.codexAutomations) : undefined,
|
|
3304
3308
|
cols: Number.isInteger(s.cols) ? s.cols : undefined,
|
|
3305
3309
|
rows: Number.isInteger(s.rows) ? s.rows : undefined,
|
|
3306
3310
|
// These three were silently dropped here, so the switcher never saw
|
|
@@ -4351,6 +4355,8 @@ const App = () => {
|
|
|
4351
4355
|
onOpenCheckbacks={(scope) => setCheckbacksOpen(scope)}
|
|
4352
4356
|
userName={name}
|
|
4353
4357
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
4358
|
+
themeMode={themeMode}
|
|
4359
|
+
onThemeChange={setThemeMode}
|
|
4354
4360
|
/>
|
|
4355
4361
|
{toast && <div className="terminal-toast" role="status" aria-live="polite">{toast}</div>}
|
|
4356
4362
|
</main>
|
|
@@ -5016,6 +5022,8 @@ const App = () => {
|
|
|
5016
5022
|
onOpenCheckbacks={(scope) => setCheckbacksOpen(scope)}
|
|
5017
5023
|
userName={name}
|
|
5018
5024
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
5025
|
+
themeMode={themeMode}
|
|
5026
|
+
onThemeChange={setThemeMode}
|
|
5019
5027
|
onOpenSettings={() => {
|
|
5020
5028
|
setSwitcherOpen(false);
|
|
5021
5029
|
setDrawerOpen(true);
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
chipWhen, conditionLabel, dayHeading, describeRrule, describeState, describeTrigger, isTimed,
|
|
4
|
+
nextOfTrigger, orderForSession, relative, timeOfDay, type Checkback, type CodexAutomation
|
|
5
|
+
} from "../utils/checkbackText";
|
|
3
6
|
|
|
4
|
-
// What is
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// and the
|
|
7
|
+
// What is scheduled on a session, read like a reminders list: the timed ones
|
|
8
|
+
// under their day, the ones waiting on something (a job, a file, idle) in a
|
|
9
|
+
// list of their own, the Codex automations aimed at the same conversation
|
|
10
|
+
// beside hop's, and the recent past folded away. Opened from a card's chip.
|
|
11
|
+
// hop's own can be cancelled here; scheduling stays with `hop checkback` and
|
|
12
|
+
// the agent's MCP tool, and Codex's automations with Codex.
|
|
8
13
|
|
|
9
14
|
type Props = {
|
|
10
15
|
session: string;
|
|
@@ -15,16 +20,26 @@ type Props = {
|
|
|
15
20
|
onChanged?: () => void;
|
|
16
21
|
};
|
|
17
22
|
|
|
23
|
+
type Timed =
|
|
24
|
+
| { source: "hop"; at: number; cb: Checkback }
|
|
25
|
+
| { source: "codex"; at: number; auto: CodexAutomation; rule: string };
|
|
26
|
+
|
|
18
27
|
export const CheckbacksPanel = ({ session, sessionName, onClose, onChanged }: Props) => {
|
|
19
28
|
const [items, setItems] = useState<Checkback[] | null>(null);
|
|
29
|
+
const [automations, setAutomations] = useState<CodexAutomation[]>([]);
|
|
20
30
|
const [failed, setFailed] = useState(false);
|
|
21
31
|
const [busy, setBusy] = useState<string | null>(null);
|
|
32
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
22
33
|
const now = Date.now();
|
|
23
34
|
|
|
24
35
|
const load = () => {
|
|
25
|
-
fetch(
|
|
36
|
+
fetch(`/api/checkbacks?session=${encodeURIComponent(session)}`, { cache: "no-store" })
|
|
26
37
|
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
|
27
|
-
.then((d) => {
|
|
38
|
+
.then((d) => {
|
|
39
|
+
setItems(Array.isArray(d?.items) ? d.items : []);
|
|
40
|
+
setAutomations(Array.isArray(d?.automations) ? d.automations : []);
|
|
41
|
+
setFailed(false);
|
|
42
|
+
})
|
|
28
43
|
.catch(() => { setFailed(true); setItems([]); });
|
|
29
44
|
};
|
|
30
45
|
useEffect(() => { load(); }, [session]);
|
|
@@ -47,14 +62,43 @@ export const CheckbacksPanel = ({ session, sessionName, onClose, onChanged }: Pr
|
|
|
47
62
|
|
|
48
63
|
const rows = items ? orderForSession(items, session) : [];
|
|
49
64
|
const pending = rows.filter((c) => c.status === "pending");
|
|
50
|
-
const past = rows.filter((c) => c.status !== "pending").slice(0,
|
|
65
|
+
const past = rows.filter((c) => c.status !== "pending").slice(0, 12);
|
|
66
|
+
// A one-shot Codex automation whose time has passed is not upcoming:
|
|
67
|
+
// Codex keeps it ACTIVE whether or not it ran (some never arrive), so it
|
|
68
|
+
// folds away under "Past due" instead of heading the list as "Overdue".
|
|
69
|
+
const autoTimes = automations.map((auto) => ({ auto, r: describeRrule(auto.rrule, now) }));
|
|
70
|
+
const isPastDue = (r: { next: number | null }, rule: string) => /COUNT=1/.test(rule) && r.next !== null && r.next < now;
|
|
71
|
+
const activeAutos = autoTimes.filter(({ auto, r }) => auto.status === "ACTIVE" && !isPastDue(r, auto.rrule)).map(({ auto }) => auto);
|
|
72
|
+
const pastDueAutos = autoTimes.filter(({ auto, r }) => auto.status === "ACTIVE" && isPastDue(r, auto.rrule));
|
|
73
|
+
const pausedAutos = automations.filter((a) => a.status !== "ACTIVE");
|
|
74
|
+
|
|
75
|
+
// Everything with a clock time, hop's and Codex's together, soonest first.
|
|
76
|
+
const timed: Timed[] = [
|
|
77
|
+
...pending.filter((c) => isTimed(c.trigger)).map((cb) => ({ source: "hop" as const, at: nextOfTrigger(cb.trigger) ?? now, cb })),
|
|
78
|
+
...activeAutos.map((auto) => { const r = describeRrule(auto.rrule, now); return { source: "codex" as const, at: r.next ?? Number.MAX_SAFE_INTEGER, auto, rule: r.label }; })
|
|
79
|
+
].sort((a, b) => a.at - b.at);
|
|
80
|
+
const days: Array<[string, Timed[]]> = [];
|
|
81
|
+
for (const t of timed) {
|
|
82
|
+
const head = t.at === Number.MAX_SAFE_INTEGER ? "Repeating" : dayHeading(t.at, now);
|
|
83
|
+
const last = days[days.length - 1];
|
|
84
|
+
if (last && last[0] === head) last[1].push(t); else days.push([head, [t]]);
|
|
85
|
+
}
|
|
86
|
+
const waiting = pending.filter((c) => !isTimed(c.trigger));
|
|
87
|
+
const nothing = timed.length === 0 && waiting.length === 0;
|
|
88
|
+
|
|
89
|
+
const message = (key: string, text: string) => (
|
|
90
|
+
<div className={"sched-message" + (open === key ? " open" : "")}
|
|
91
|
+
onClick={() => setOpen((o) => (o === key ? null : key))}
|
|
92
|
+
title={open === key ? "" : "Click to read it all"}>{text}</div>
|
|
93
|
+
);
|
|
94
|
+
const who = (c: Checkback) => (c.createdBy === "agent" ? "the agent" : "you");
|
|
51
95
|
|
|
52
96
|
return (
|
|
53
97
|
<>
|
|
54
98
|
<div className="views-backdrop" onClick={onClose} />
|
|
55
|
-
<div className="views-panel checkbacks-panel" role="dialog" aria-label={`
|
|
99
|
+
<div className="views-panel checkbacks-panel" role="dialog" aria-label={`Scheduled for ${sessionName}`}>
|
|
56
100
|
<div className="views-head">
|
|
57
|
-
<span className="views-badge"
|
|
101
|
+
<span className="views-badge">Scheduled</span>
|
|
58
102
|
<span className="checkbacks-session">{sessionName}</span>
|
|
59
103
|
<span className="views-head-spacer" />
|
|
60
104
|
<button type="button" className="views-close" aria-label="Close" onClick={onClose}>×</button>
|
|
@@ -62,32 +106,116 @@ export const CheckbacksPanel = ({ session, sessionName, onClose, onChanged }: Pr
|
|
|
62
106
|
{items === null ? (
|
|
63
107
|
<p className="checkbacks-empty">Loading…</p>
|
|
64
108
|
) : failed ? (
|
|
65
|
-
<p className="checkbacks-empty">Could not load
|
|
109
|
+
<p className="checkbacks-empty">Could not load what is scheduled.</p>
|
|
66
110
|
) : (
|
|
67
111
|
<div className="checkbacks-list">
|
|
68
|
-
{
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<div className="
|
|
73
|
-
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
112
|
+
{nothing && <p className="checkbacks-empty">Nothing scheduled on this session.</p>}
|
|
113
|
+
|
|
114
|
+
{days.map(([head, list]) => (
|
|
115
|
+
<section key={head} className="sched-day">
|
|
116
|
+
<div className="sched-day-head">{head}</div>
|
|
117
|
+
{list.map((t) => t.source === "hop" ? (
|
|
118
|
+
<div key={t.cb.id} className={"sched-row" + (t.cb.firedAt ? " fired" : "")}>
|
|
119
|
+
<div className="sched-time">{t.at === Number.MAX_SAFE_INTEGER ? "—" : timeOfDay(t.at)}</div>
|
|
120
|
+
<div className="sched-body">
|
|
121
|
+
{message(t.cb.id, t.cb.message)}
|
|
122
|
+
<div className="sched-meta">
|
|
123
|
+
{t.cb.trigger.kind === "every" ? describeTrigger(t.cb.trigger, now) : chipWhen(t.at, now)}
|
|
124
|
+
{t.cb.firedAt ? " · due, waiting for the agent to be idle" : ""}
|
|
125
|
+
{t.cb.force ? " · typed even mid-turn" : ""} · by {who(t.cb)}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
<button type="button" className="checkback-cancel" disabled={busy === t.cb.id} onClick={() => cancel(t.cb.id)}>Cancel</button>
|
|
129
|
+
</div>
|
|
130
|
+
) : (
|
|
131
|
+
<div key={"codex-" + t.auto.id} className="sched-row codex">
|
|
132
|
+
<div className="sched-time">{t.at === Number.MAX_SAFE_INTEGER ? "—" : timeOfDay(t.at)}</div>
|
|
133
|
+
<div className="sched-body">
|
|
134
|
+
<div className="sched-title">{t.auto.name}</div>
|
|
135
|
+
{message("codex-" + t.auto.id, t.auto.prompt)}
|
|
136
|
+
<div className="sched-meta">Codex automation · {t.rule}{t.at !== Number.MAX_SAFE_INTEGER && t.at < now ? " · past due" : ""}</div>
|
|
137
|
+
</div>
|
|
138
|
+
<span className="sched-owner" title="Codex runs this one; manage it in Codex">Codex</span>
|
|
139
|
+
</div>
|
|
140
|
+
))}
|
|
141
|
+
</section>
|
|
78
142
|
))}
|
|
143
|
+
|
|
144
|
+
{waiting.length > 0 && (
|
|
145
|
+
<section className="sched-day">
|
|
146
|
+
<div className="sched-day-head">When something happens</div>
|
|
147
|
+
{waiting.map((c) => {
|
|
148
|
+
const cond = conditionLabel(c.trigger);
|
|
149
|
+
return (
|
|
150
|
+
<div key={c.id} className={"sched-row" + (c.firedAt ? " fired" : "")}>
|
|
151
|
+
<div className="sched-time tag">{cond.tag}</div>
|
|
152
|
+
<div className="sched-body">
|
|
153
|
+
{message(c.id, c.message)}
|
|
154
|
+
<div className="sched-meta">
|
|
155
|
+
<code className="sched-cond" title={cond.detail}>{cond.detail}</code>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="sched-meta">
|
|
158
|
+
{c.firedAt ? "happened · waiting for the agent to be idle" : c.trigger.kind === "cmd" ? describeTrigger(c.trigger, now).replace(/^when `.*` succeeds · /, "") : "watching"}
|
|
159
|
+
{c.lastError && !c.firedAt ? ` · ${c.lastError}` : ""} · by {who(c)}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
<button type="button" className="checkback-cancel" disabled={busy === c.id} onClick={() => cancel(c.id)}>Cancel</button>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
</section>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{pastDueAutos.length > 0 && (
|
|
170
|
+
<details className="sched-fold">
|
|
171
|
+
<summary>Past due Codex automations ({pastDueAutos.length})</summary>
|
|
172
|
+
<p className="checkbacks-hint">Codex still lists these as active, but a one-shot follow-up is not always delivered — check the session before counting on it.</p>
|
|
173
|
+
{pastDueAutos.map(({ auto, r }) => (
|
|
174
|
+
<div key={auto.id} className="sched-row codex past">
|
|
175
|
+
<div className="sched-time tag">was due</div>
|
|
176
|
+
<div className="sched-body">
|
|
177
|
+
<div className="sched-title">{auto.name}</div>
|
|
178
|
+
<div className="sched-meta">{r.next ? `${dayHeading(r.next, now) === "Due now" ? new Date(r.next).toLocaleString([], { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : timeOfDay(r.next)} · ${relative(r.next, now)}` : r.label}</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
))}
|
|
182
|
+
</details>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{pausedAutos.length > 0 && (
|
|
186
|
+
<details className="sched-fold">
|
|
187
|
+
<summary>Paused Codex automations ({pausedAutos.length})</summary>
|
|
188
|
+
{pausedAutos.map((a) => (
|
|
189
|
+
<div key={a.id} className="sched-row codex past">
|
|
190
|
+
<div className="sched-time tag">paused</div>
|
|
191
|
+
<div className="sched-body">
|
|
192
|
+
<div className="sched-title">{a.name}</div>
|
|
193
|
+
<div className="sched-meta">{describeRrule(a.rrule, now).label}</div>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
))}
|
|
197
|
+
</details>
|
|
198
|
+
)}
|
|
199
|
+
|
|
79
200
|
{past.length > 0 && (
|
|
80
|
-
|
|
81
|
-
<
|
|
201
|
+
<details className="sched-fold">
|
|
202
|
+
<summary>Recent ({past.length})</summary>
|
|
82
203
|
{past.map((c) => (
|
|
83
|
-
<div key={c.id} className={"
|
|
84
|
-
<div className="
|
|
85
|
-
<div className="
|
|
204
|
+
<div key={c.id} className={"sched-row past " + c.status}>
|
|
205
|
+
<div className="sched-time tag">{c.status === "delivered" ? "sent" : "cancelled"}</div>
|
|
206
|
+
<div className="sched-body">
|
|
207
|
+
{message(c.id, c.message)}
|
|
208
|
+
<div className="sched-meta">{describeState(c, now)}</div>
|
|
209
|
+
</div>
|
|
86
210
|
</div>
|
|
87
211
|
))}
|
|
88
|
-
|
|
212
|
+
</details>
|
|
89
213
|
)}
|
|
90
|
-
|
|
214
|
+
|
|
215
|
+
<p className="checkbacks-hint">
|
|
216
|
+
hop types these into the session when they come due and the agent is idle. Schedule with <code>hop checkback</code> or
|
|
217
|
+
the agent's <code>hop_checkback</code> tool; Codex automations are run by Codex.
|
|
218
|
+
</p>
|
|
91
219
|
</div>
|
|
92
220
|
)}
|
|
93
221
|
</div>
|
|
@@ -220,9 +220,16 @@ export const DigestCard = ({ sessions, onOpen }: {
|
|
|
220
220
|
<span className="digest-dateline">{sessionLabel(item.session)}</span>
|
|
221
221
|
<span className="digest-headline">{item.headline}</span>
|
|
222
222
|
{item.why ? <span className="digest-why">{item.why}</span> : null}
|
|
223
|
-
{item.recommendation ?
|
|
223
|
+
{item.recommendation ? (
|
|
224
|
+
<span className="digest-reco"><span className="digest-reco-label">Suggested</span>{item.recommendation}</span>
|
|
225
|
+
) : null}
|
|
224
226
|
{item.replies?.length ? (
|
|
225
|
-
|
|
227
|
+
// The ways the agent offered to go — words, not buttons: the card
|
|
228
|
+
// opens the session, where the answer is given.
|
|
229
|
+
<span className="digest-replies">
|
|
230
|
+
<span className="digest-replies-label">Options</span>
|
|
231
|
+
{item.replies.map((r, i) => <span key={r} className="digest-reply">{i > 0 ? " · " : ""}{r}</span>)}
|
|
232
|
+
</span>
|
|
226
233
|
) : null}
|
|
227
234
|
</button>
|
|
228
235
|
);
|
|
@@ -237,7 +244,7 @@ export const DigestCard = ({ sessions, onOpen }: {
|
|
|
237
244
|
if (!rows.length) return null;
|
|
238
245
|
return (
|
|
239
246
|
<div key={slot} className={"digest-slot slot-" + slot}>
|
|
240
|
-
<div className="digest-slot-head">{SLOT_LABEL[slot]}</div>
|
|
247
|
+
<div className="digest-slot-head">{SLOT_LABEL[slot]}<span className="digest-slot-count">{rows.length}</span></div>
|
|
241
248
|
{rows.map((it) => renderItem(it, s))}
|
|
242
249
|
</div>
|
|
243
250
|
);
|
|
@@ -351,7 +358,9 @@ export const DigestCard = ({ sessions, onOpen }: {
|
|
|
351
358
|
<span className="digest-when">{ago(item.at)}</span>
|
|
352
359
|
<span className="digest-headline">{item.headline}</span>
|
|
353
360
|
{item.why ? <span className="digest-why">{item.why}</span> : null}
|
|
354
|
-
{item.recommendation ?
|
|
361
|
+
{item.recommendation ? (
|
|
362
|
+
<span className="digest-reco"><span className="digest-reco-label">Suggested</span>{item.recommendation}</span>
|
|
363
|
+
) : null}
|
|
355
364
|
</div>
|
|
356
365
|
))}
|
|
357
366
|
</div>
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
insertAfter, describeAccount, type AgentHome, type AgentHomes } from "../utils/switcherModel";
|
|
39
39
|
import { scanKeyboardProtocol } from "../utils/keyboardProtocol";
|
|
40
40
|
import { isAppCtrlEnter, isFullscreenChord } from "../utils/fullscreenChord";
|
|
41
|
+
import { chipWhen } from "../utils/checkbackText";
|
|
41
42
|
import { DigestCard } from "./DigestCard";
|
|
42
43
|
import { hasUnseenViews, loadViewsSeen } from "./ViewsPanel";
|
|
43
44
|
|
|
@@ -113,6 +114,10 @@ type Props = {
|
|
|
113
114
|
userName?: string;
|
|
114
115
|
terminalTheme?: object;
|
|
115
116
|
onOpenSettings?: () => void;
|
|
117
|
+
// The page theme, switchable straight from the wall: the settings drawer
|
|
118
|
+
// lives in the full-screen view, and the hub (no session open) has none.
|
|
119
|
+
themeMode?: "system" | "light" | "dark";
|
|
120
|
+
onThemeChange?: (mode: "system" | "light" | "dark") => void;
|
|
116
121
|
onToggleKeyboard?: () => void;
|
|
117
122
|
onFind?: () => void;
|
|
118
123
|
// Open the Views panel. No argument = the whole fleet (header button); an
|
|
@@ -1299,6 +1304,8 @@ export const SessionSwitcher = ({
|
|
|
1299
1304
|
userName,
|
|
1300
1305
|
terminalTheme,
|
|
1301
1306
|
onOpenSettings,
|
|
1307
|
+
themeMode,
|
|
1308
|
+
onThemeChange,
|
|
1302
1309
|
onToggleKeyboard,
|
|
1303
1310
|
onFind,
|
|
1304
1311
|
onOpenViews,
|
|
@@ -3081,18 +3088,29 @@ export const SessionSwitcher = ({
|
|
|
3081
3088
|
</span>
|
|
3082
3089
|
)}
|
|
3083
3090
|
{!current && s.starting && !s.active && <span className="switcher-chip starting">STARTING</span>}
|
|
3084
|
-
{!!s.checkbacks && (
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3091
|
+
{(!!s.checkbacks || !!s.codexAutomations) && (() => {
|
|
3092
|
+
// What is scheduled here, hop's and Codex's own: the chip says WHEN
|
|
3093
|
+
// the next timed one is due ("in 12m", "4:30 PM"), or that it is
|
|
3094
|
+
// waiting on something (a job, a file) when nothing has a time.
|
|
3095
|
+
const total = (s.checkbacks || 0) + (s.codexAutomations || 0);
|
|
3096
|
+
const when = s.checkbackNext ? chipWhen(s.checkbackNext, Date.now()) : "waiting";
|
|
3097
|
+
return (
|
|
3098
|
+
<button
|
|
3099
|
+
type="button"
|
|
3100
|
+
className="switcher-chip checkback"
|
|
3101
|
+
title={`${total} scheduled${s.codexAutomations ? ` (${s.codexAutomations} Codex automation${s.codexAutomations === 1 ? "" : "s"})` : ""}${s.checkbackNext ? ` — next ${when}` : " — waiting on a condition"}. Click to see the list.`}
|
|
3102
|
+
aria-label={`${total} scheduled on ${s.displayName}`}
|
|
3103
|
+
onClick={(e) => { e.stopPropagation(); onOpenCheckbacks?.(key); }}
|
|
3104
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
3105
|
+
>
|
|
3106
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
3107
|
+
<rect x="3" y="5" width="18" height="16" rx="3" /><path d="M3 10h18M8 3v4M16 3v4" />
|
|
3108
|
+
</svg>
|
|
3109
|
+
<span>{when}</span>
|
|
3110
|
+
{total > 1 && <span className="checkback-count">{total}</span>}
|
|
3111
|
+
</button>
|
|
3112
|
+
);
|
|
3113
|
+
})()}
|
|
3096
3114
|
{s.agentHome && homesFor(s.agent).length > 1 && (() => {
|
|
3097
3115
|
const known = homesFor(s.agent).find((h) => h.dir === s.agentHome?.dir);
|
|
3098
3116
|
const out = known?.login && !known.login.loggedIn;
|
|
@@ -3344,6 +3362,18 @@ export const SessionSwitcher = ({
|
|
|
3344
3362
|
: (() => { const n = visibleSessions.reduce((k, s) => k + (s.views?.count || 0), 0); return n > 0 ? <span className="views-entry-count">{n}</span> : null; })()}
|
|
3345
3363
|
</button>
|
|
3346
3364
|
)}
|
|
3365
|
+
{onThemeChange && themeMode && (() => {
|
|
3366
|
+
const next = themeMode === "system" ? "light" : themeMode === "light" ? "dark" : "system";
|
|
3367
|
+
const label = (m: string) => (m === "system" ? "Auto" : m === "light" ? "Light" : "Dark");
|
|
3368
|
+
return (
|
|
3369
|
+
<button type="button" className="switcher-action theme-entry" onClick={() => onThemeChange(next)}
|
|
3370
|
+
aria-label={`Theme: ${label(themeMode)} — switch to ${label(next)}`}
|
|
3371
|
+
title={`Theme: ${label(themeMode)} — click for ${label(next)}`}>
|
|
3372
|
+
<span aria-hidden="true">{themeMode === "light" ? "☀" : themeMode === "dark" ? "☾" : "◐"}</span>
|
|
3373
|
+
{label(themeMode)}
|
|
3374
|
+
</button>
|
|
3375
|
+
);
|
|
3376
|
+
})()}
|
|
3347
3377
|
{onOpenSettings && (
|
|
3348
3378
|
<button type="button" className="switcher-action" aria-label="Settings" title="Settings" onClick={onOpenSettings}>
|
|
3349
3379
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
@@ -46,7 +46,10 @@ type ViewItem = {
|
|
|
46
46
|
mtime?: number;
|
|
47
47
|
// "server" is a LIVE proxied localhost server (`hop port`), not a stored
|
|
48
48
|
// file: it stops working when the server does, which a file never does.
|
|
49
|
-
|
|
49
|
+
// "link" points at another app entirely — a Nebula notebook on its own
|
|
50
|
+
// server — opened at `target`; `app` names what opens it.
|
|
51
|
+
kind?: "file" | "server" | "link";
|
|
52
|
+
app?: string;
|
|
50
53
|
// For a server, the proxy itself. The manifest scan can only see the
|
|
51
54
|
// redirect page `hop port` leaves behind; going straight to the proxy skips
|
|
52
55
|
// a visible flash and a round trip.
|
|
@@ -104,6 +107,7 @@ export const hasUnseenViews = (
|
|
|
104
107
|
// that the column stops lining up. The tag also survives a monospace column.
|
|
105
108
|
const kindOf = (name: string, kind?: string) => {
|
|
106
109
|
if (kind === "server") return "LIVE";
|
|
110
|
+
if (kind === "link") return "NB";
|
|
107
111
|
const ext = (name.split(".").pop() || "").toLowerCase();
|
|
108
112
|
if (["csv", "tsv"].includes(ext)) return "TBL";
|
|
109
113
|
if (["mp3", "m4a", "wav", "flac", "aac", "ogg", "opus"].includes(ext)) return "AUD";
|
|
@@ -128,13 +132,13 @@ const formatBytes = (n?: number) => {
|
|
|
128
132
|
};
|
|
129
133
|
|
|
130
134
|
const hrefFor = (item: ViewItem) =>
|
|
131
|
-
item.kind === "server" && item.target ? item.target : item.path;
|
|
135
|
+
(item.kind === "server" || item.kind === "link") && item.target ? item.target : item.path;
|
|
132
136
|
|
|
133
137
|
// The stored file itself, as an attachment: the daemon's /download mode
|
|
134
138
|
// serves the markdown an agent wrote rather than the page it renders into.
|
|
135
139
|
// Only files — a server is live, there is nothing to save.
|
|
136
140
|
const downloadHrefFor = (item: ViewItem) =>
|
|
137
|
-
item.kind === "server" ? null : item.path.replace(/\/inline$/, "") + "/download";
|
|
141
|
+
item.kind === "server" || item.kind === "link" ? null : item.path.replace(/\/inline$/, "") + "/download";
|
|
138
142
|
|
|
139
143
|
// A hidden same-origin anchor with `download`: the row is already an <a>
|
|
140
144
|
// (anchors cannot nest), and window.open would show the file, not save it.
|
|
@@ -371,7 +375,8 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
|
|
|
371
375
|
};
|
|
372
376
|
|
|
373
377
|
const copyLink = (item: ViewItem) => {
|
|
374
|
-
const
|
|
378
|
+
const href = hrefFor(item);
|
|
379
|
+
const url = /^https?:\/\//.test(href) ? href : location.origin + href;
|
|
375
380
|
navigator.clipboard?.writeText(url).then(() => {
|
|
376
381
|
setCopied(item.path);
|
|
377
382
|
window.setTimeout(() => setCopied((c) => (c === item.path ? null : c)), 1400);
|
|
@@ -447,12 +452,16 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
|
|
|
447
452
|
const rowsFor = (key: string, rows: ViewItem[]) => rows.map((item) => {
|
|
448
453
|
const fresh = (item.mtime || 0) > (seenAtOpen[key] || 0);
|
|
449
454
|
const isServer = item.kind === "server";
|
|
455
|
+
const isLink = item.kind === "link";
|
|
450
456
|
const selected = preview?.path === item.path;
|
|
451
457
|
// A server has no meaningful size and its filename is an implementation
|
|
452
458
|
// detail (the redirect page) — it is a door, not a document, so say what
|
|
453
459
|
// it is instead.
|
|
460
|
+
const linkHost = isLink ? (() => { try { return new URL(item.target || "").host; } catch { return ""; } })() : "";
|
|
454
461
|
const meta = isServer
|
|
455
462
|
? "live server"
|
|
463
|
+
: isLink
|
|
464
|
+
? [item.app === "nebula" ? "Nebula notebook" : "link", linkHost, relativeTime((item.mtime || 0) * 1000, now)].filter(Boolean).join(" · ")
|
|
456
465
|
: [
|
|
457
466
|
// The filename is only worth a line of its own when the title isn't
|
|
458
467
|
// already showing it.
|
|
@@ -464,7 +473,7 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
|
|
|
464
473
|
<a
|
|
465
474
|
key={item.path}
|
|
466
475
|
className={"views-row" + (fresh ? " fresh" : "")
|
|
467
|
-
+ (isServer ? " server" : "") + (selected ? " selected" : "")}
|
|
476
|
+
+ (isServer || isLink ? " server" : "") + (selected ? " selected" : "")}
|
|
468
477
|
href={hrefFor(item)}
|
|
469
478
|
target="_blank"
|
|
470
479
|
rel="noopener"
|
|
@@ -487,7 +496,7 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
|
|
|
487
496
|
<button type="button" className="views-act"
|
|
488
497
|
title="Open in a new tab" aria-label="Open in a new tab"
|
|
489
498
|
onClick={() => window.open(hrefFor(item), "_blank", "noopener")}>↗</button>
|
|
490
|
-
{!isServer && (
|
|
499
|
+
{!isServer && !isLink && (
|
|
491
500
|
<button type="button" className="views-act"
|
|
492
501
|
title="Download" aria-label={`Download ${item.name}`}
|
|
493
502
|
onClick={() => triggerDownload(item)}>⤓</button>
|
|
@@ -496,7 +505,7 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
|
|
|
496
505
|
title={copied === item.path ? "Copied" : "Copy link"}
|
|
497
506
|
aria-label="Copy link"
|
|
498
507
|
onClick={() => copyLink(item)}>{copied === item.path ? "✓" : "⧉"}</button>
|
|
499
|
-
{!isServer && (
|
|
508
|
+
{!isServer && !isLink && (
|
|
500
509
|
/* The FILE, not the page: ?download=1 serves the published bytes as
|
|
501
510
|
an attachment under their real name (md/csv/json raw, not the
|
|
502
511
|
rendered view). A window.open, not an anchor: anchors can't nest
|
|
@@ -556,8 +565,24 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
|
|
|
556
565
|
// stay iframes — sandboxed only for LIVE servers (an arbitrary dev app
|
|
557
566
|
// must not frame-bust the wall); our own files skip the sandbox, because a
|
|
558
567
|
// sandboxed iframe disables the browser's PDF viewer.
|
|
568
|
+
// A notebook lives in its own app: the pane says where it opens rather
|
|
569
|
+
// than framing Nebula (another origin, often reached over a forwarded port).
|
|
570
|
+
const linkFile = (() => {
|
|
571
|
+
if (!preview || preview.kind !== "link") return "";
|
|
572
|
+
try { return new URL(preview.target || "").searchParams.get("file") || ""; } catch { return ""; }
|
|
573
|
+
})();
|
|
559
574
|
const page = showPreview && (
|
|
560
|
-
preview.kind
|
|
575
|
+
preview.kind === "link" ? (
|
|
576
|
+
<div className="views-link-card">
|
|
577
|
+
<div className="views-link-app">{preview.app === "nebula" ? "Nebula notebook" : "Link"}</div>
|
|
578
|
+
<div className="views-link-title">{preview.title || preview.name}</div>
|
|
579
|
+
{linkFile && <code className="views-link-path">{linkFile}</code>}
|
|
580
|
+
<a className="views-link-open" href={preview.target} target="_blank" rel="noopener">
|
|
581
|
+
Open in {preview.app === "nebula" ? "Nebula" : "a new tab"} ↗
|
|
582
|
+
</a>
|
|
583
|
+
<div className="views-link-host">{(() => { try { return new URL(preview.target || "").host; } catch { return ""; } })()}</div>
|
|
584
|
+
</div>
|
|
585
|
+
) : preview.kind !== "server" && isImage(preview.name) ? (
|
|
561
586
|
<div className="views-media"><img src={preview.path} alt={preview.title || preview.name} /></div>
|
|
562
587
|
) : preview.kind !== "server" && isVideo(preview.name) ? (
|
|
563
588
|
<div className="views-media"><video src={preview.path} controls autoPlay={false} /></div>
|
|
@@ -600,7 +625,7 @@ export const ViewsPanel = ({ session, item, sessions = [], dock = false, onClose
|
|
|
600
625
|
<span className="views-head-spacer" />
|
|
601
626
|
{preview && (
|
|
602
627
|
<span className="views-nav-acts">
|
|
603
|
-
{preview.kind !== "server" && (
|
|
628
|
+
{preview.kind !== "server" && preview.kind !== "link" && (
|
|
604
629
|
<button type="button" className="views-act"
|
|
605
630
|
title="Download" aria-label={`Download ${preview.name}`}
|
|
606
631
|
onClick={() => triggerDownload(preview)}>⤓</button>
|