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.
@@ -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.clients.size > 0 || this.ended) return;
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 (/\x1b\]10;\?(?:\x07|\x1b\\)/.test(data)) reply += osc(10, this.clientFg);
1242
- if (/\x1b\]11;\?(?:\x07|\x1b\\)/.test(data)) reply += osc(11, this.clientBg);
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 color/DA/truecolor probes only while no client is attached", () => {
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
- // Attached: the real terminal answers; the room stays silent.
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
  });
@@ -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 { describeState, describeTrigger, orderForSession, type Checkback } from "../utils/checkbackText";
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 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.
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("/api/checkbacks", { cache: "no-store" })
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) => { setItems(Array.isArray(d?.items) ? d.items : []); setFailed(false); })
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, 8);
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={`Check-backs for ${sessionName}`}>
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">⏰ Check-backs</span>
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 check-backs.</p>
109
+ <p className="checkbacks-empty">Could not load what is scheduled.</p>
66
110
  ) : (
67
111
  <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>
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
- <div className="checkbacks-past-head">Recent</div>
201
+ <details className="sched-fold">
202
+ <summary>Recent ({past.length})</summary>
82
203
  {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>
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
- <p className="checkbacks-hint">Schedule one with <code>hop checkback</code> inside the session, or the agent does it itself over MCP.</p>
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 ? <span className="digest-reco">→ {item.recommendation}</span> : null}
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
- <span className="digest-replies">{item.replies.map((r) => <span key={r} className="digest-reply">{r}</span>)}</span>
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 ? <span className="digest-reco">→ {item.recommendation}</span> : null}
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
- <button
3086
- type="button"
3087
- className="switcher-chip checkback"
3088
- title={`${s.checkbacks} scheduled check-back${s.checkbacks === 1 ? "" : "s"} will be typed into this session — click to see what is waiting`}
3089
- aria-label={`${s.checkbacks} check-back${s.checkbacks === 1 ? "" : "s"} waiting on ${s.displayName}`}
3090
- onClick={(e) => { e.stopPropagation(); onOpenCheckbacks?.(key); }}
3091
- onPointerDown={(e) => e.stopPropagation()}
3092
- >
3093
- ⏰ {s.checkbacks}
3094
- </button>
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
- kind?: "file" | "server";
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 url = location.origin + hrefFor(item);
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 !== "server" && isImage(preview.name) ? (
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>