@tryinget/pi-activity-strip 0.2.0

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.
@@ -0,0 +1,319 @@
1
+ import { execFile } from "node:child_process";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { promisify } from "node:util";
5
+ import { app, BrowserWindow, screen } from "electron";
6
+ import { createActivityStripBroker } from "../broker/server.mjs";
7
+ import { detectDisplayServer, detectWindowManager } from "../common/compatibility.mjs";
8
+ import { ACTIVITY_STRIP_HEIGHT, ACTIVITY_STRIP_WIDTH_PADDING } from "../common/constants.mjs";
9
+ import { createStripHtml } from "../ui/strip-html.mjs";
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+ const preloadPath = path.join(__dirname, "preload.cjs");
14
+ const interactive = process.env.PI_ACTIVITY_STRIP_CLICK_THROUGH === "0";
15
+ const execFileAsync = promisify(execFile);
16
+
17
+ let browserWindow = null;
18
+ let broker = null;
19
+ let latestSnapshot = { generatedAt: Date.now(), sessions: [] };
20
+ const runtimeStatus = {
21
+ state: "starting",
22
+ startedAt: Date.now(),
23
+ readyAt: null,
24
+ windowVisible: false,
25
+ displayServer: detectDisplayServer(process.env),
26
+ windowManager: detectWindowManager(process.env),
27
+ displayCount: null,
28
+ alignmentMode: isNiriSession() ? "niri" : "generic",
29
+ warnings: [],
30
+ error: null,
31
+ };
32
+
33
+ function isNiriSession() {
34
+ return Boolean(process.env.NIRI_SOCKET);
35
+ }
36
+
37
+ function formatDelta(value) {
38
+ const rounded = Math.round(value);
39
+ return rounded >= 0 ? `+${rounded}` : String(rounded);
40
+ }
41
+
42
+ function readNiriPosition(window) {
43
+ const position = window?.layout?.tile_pos_in_workspace_view;
44
+ if (!Array.isArray(position) || position.length < 2) return null;
45
+ return {
46
+ x: Number(position[0] ?? 0),
47
+ y: Number(position[1] ?? 0),
48
+ };
49
+ }
50
+
51
+ async function getNiriWindowByPid(pid) {
52
+ if (!isNiriSession()) return null;
53
+
54
+ try {
55
+ const { stdout } = await execFileAsync("niri", ["msg", "-j", "windows"], {
56
+ env: process.env,
57
+ });
58
+ const windows = JSON.parse(stdout);
59
+ if (!Array.isArray(windows)) return null;
60
+
61
+ return (
62
+ windows.find((window) => window?.pid === pid && window?.is_floating) ??
63
+ windows.find((window) => window?.pid === pid) ??
64
+ null
65
+ );
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ async function moveWindowToTopViaNiri() {
72
+ if (!browserWindow || browserWindow.isDestroyed() || !isNiriSession()) return false;
73
+
74
+ const target = currentBounds();
75
+ const niriWindow = await getNiriWindowByPid(process.pid);
76
+ if (!niriWindow?.id) return false;
77
+
78
+ const currentPosition = readNiriPosition(niriWindow);
79
+ if (!currentPosition) return false;
80
+
81
+ let deltaX = target.x - currentPosition.x;
82
+ let deltaY = target.y - currentPosition.y;
83
+ if (Math.abs(deltaX) < 1 && Math.abs(deltaY) < 1) return true;
84
+
85
+ for (let attempt = 0; attempt < 5; attempt += 1) {
86
+ try {
87
+ await execFileAsync(
88
+ "niri",
89
+ [
90
+ "msg",
91
+ "action",
92
+ "move-floating-window",
93
+ "--id",
94
+ String(niriWindow.id),
95
+ "-x",
96
+ formatDelta(deltaX),
97
+ "-y",
98
+ formatDelta(deltaY),
99
+ ],
100
+ { env: process.env },
101
+ );
102
+ } catch {
103
+ return false;
104
+ }
105
+
106
+ await new Promise((resolve) => setTimeout(resolve, 80));
107
+ const refreshedWindow = await getNiriWindowByPid(process.pid);
108
+ const refreshedPosition = readNiriPosition(refreshedWindow);
109
+ if (!refreshedPosition) return false;
110
+
111
+ deltaX = target.x - refreshedPosition.x;
112
+ deltaY = target.y - refreshedPosition.y;
113
+ if (Math.abs(deltaX) < 1 && Math.abs(deltaY) < 1) return true;
114
+ }
115
+
116
+ return false;
117
+ }
118
+
119
+ async function alignWindowToTop() {
120
+ if (!browserWindow || browserWindow.isDestroyed()) return;
121
+
122
+ const target = currentBounds();
123
+ if (!isNiriSession()) {
124
+ browserWindow.setBounds(target, false);
125
+ return;
126
+ }
127
+
128
+ const niriWindow = await getNiriWindowByPid(process.pid);
129
+ const currentSize = niriWindow?.layout?.window_size;
130
+ const widthMatches = Array.isArray(currentSize) && Number(currentSize[0]) === target.width;
131
+ const heightMatches = Array.isArray(currentSize) && Number(currentSize[1]) === target.height;
132
+
133
+ if (!widthMatches || !heightMatches) {
134
+ browserWindow.setSize(target.width, target.height, false);
135
+ await new Promise((resolve) => setTimeout(resolve, 120));
136
+ }
137
+
138
+ for (let attempt = 0; attempt < 16; attempt += 1) {
139
+ if (await moveWindowToTopViaNiri()) return;
140
+ await new Promise((resolve) => setTimeout(resolve, 90));
141
+ }
142
+ }
143
+
144
+ function scheduleTopAlignment() {
145
+ alignWindowToTop().catch(() => {});
146
+
147
+ for (const delayMs of [150, 450, 900, 1500, 2400]) {
148
+ setTimeout(() => {
149
+ alignWindowToTop().catch(() => {});
150
+ }, delayMs);
151
+ }
152
+ }
153
+
154
+ function refreshRuntimeStatus() {
155
+ runtimeStatus.alignmentMode = isNiriSession() ? "niri" : "generic";
156
+ runtimeStatus.windowVisible = Boolean(
157
+ browserWindow && !browserWindow.isDestroyed() && browserWindow.isVisible(),
158
+ );
159
+
160
+ if (!app.isReady()) {
161
+ runtimeStatus.warnings = isNiriSession()
162
+ ? []
163
+ : [
164
+ "Top-edge repair is optimized for Niri. Other window managers fall back to generic Electron bounds and may need manual adjustment.",
165
+ ];
166
+ return;
167
+ }
168
+
169
+ const displays = screen.getAllDisplays();
170
+ runtimeStatus.displayCount = displays.length;
171
+ const warnings = [];
172
+ if (displays.length > 1) {
173
+ warnings.push(
174
+ `Detected ${displays.length} displays; the strip currently renders on the primary display only.`,
175
+ );
176
+ }
177
+ if (!isNiriSession()) {
178
+ warnings.push(
179
+ "Top-edge repair is optimized for Niri. Other window managers fall back to generic Electron bounds and may need manual adjustment.",
180
+ );
181
+ }
182
+ runtimeStatus.warnings = warnings;
183
+ }
184
+
185
+ function currentDisplay() {
186
+ return screen.getPrimaryDisplay();
187
+ }
188
+
189
+ function currentBounds() {
190
+ const display = currentDisplay();
191
+ const bounds = display.bounds;
192
+ return {
193
+ x: bounds.x + ACTIVITY_STRIP_WIDTH_PADDING / 2,
194
+ y: bounds.y,
195
+ width: Math.max(420, bounds.width - ACTIVITY_STRIP_WIDTH_PADDING),
196
+ height: ACTIVITY_STRIP_HEIGHT,
197
+ };
198
+ }
199
+
200
+ function updateWindowBounds() {
201
+ if (!browserWindow || browserWindow.isDestroyed()) return;
202
+ refreshRuntimeStatus();
203
+ scheduleTopAlignment();
204
+ }
205
+
206
+ async function createWindow() {
207
+ browserWindow = new BrowserWindow({
208
+ ...currentBounds(),
209
+ title: "Pi Activity Strip",
210
+ frame: false,
211
+ transparent: true,
212
+ resizable: false,
213
+ movable: false,
214
+ minimizable: false,
215
+ maximizable: false,
216
+ closable: true,
217
+ skipTaskbar: true,
218
+ show: false,
219
+ focusable: interactive,
220
+ alwaysOnTop: true,
221
+ webPreferences: {
222
+ preload: preloadPath,
223
+ contextIsolation: true,
224
+ sandbox: true,
225
+ nodeIntegration: false,
226
+ backgroundThrottling: false,
227
+ },
228
+ backgroundColor: "#00000000",
229
+ });
230
+
231
+ browserWindow.setAlwaysOnTop(true, "screen-saver");
232
+ browserWindow.setVisibleOnAllWorkspaces?.(true, { visibleOnFullScreen: true });
233
+ browserWindow.setIgnoreMouseEvents(!interactive, { forward: true });
234
+
235
+ browserWindow.once("ready-to-show", () => {
236
+ runtimeStatus.state = "ready";
237
+ runtimeStatus.readyAt = Date.now();
238
+ runtimeStatus.error = null;
239
+ browserWindow?.showInactive?.();
240
+ browserWindow?.webContents.send("pi-activity-strip:snapshot", latestSnapshot);
241
+ refreshRuntimeStatus();
242
+ scheduleTopAlignment();
243
+ });
244
+
245
+ const html = createStripHtml({ interactive });
246
+ await browserWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
247
+
248
+ if (!browserWindow.isVisible()) {
249
+ browserWindow.showInactive?.();
250
+ browserWindow.webContents.send("pi-activity-strip:snapshot", latestSnapshot);
251
+ refreshRuntimeStatus();
252
+ scheduleTopAlignment();
253
+ }
254
+
255
+ browserWindow.on("show", () => refreshRuntimeStatus());
256
+ browserWindow.on("hide", () => refreshRuntimeStatus());
257
+ browserWindow.on("closed", () => {
258
+ browserWindow = null;
259
+ refreshRuntimeStatus();
260
+ });
261
+ }
262
+
263
+ async function main() {
264
+ if (!app.requestSingleInstanceLock()) {
265
+ app.quit();
266
+ return;
267
+ }
268
+
269
+ broker = await createActivityStripBroker({
270
+ getRuntimeStatus: () => ({
271
+ ...runtimeStatus,
272
+ warnings: [...runtimeStatus.warnings],
273
+ }),
274
+ });
275
+ broker.on("snapshot", (snapshot) => {
276
+ latestSnapshot = snapshot;
277
+ browserWindow?.webContents.send("pi-activity-strip:snapshot", latestSnapshot);
278
+ });
279
+ broker.on("shutdown-requested", async () => {
280
+ await broker?.stop();
281
+ app.quit();
282
+ });
283
+
284
+ app.setName("pi-activity-strip");
285
+
286
+ await app.whenReady();
287
+ refreshRuntimeStatus();
288
+ await createWindow();
289
+
290
+ screen.on("display-metrics-changed", () => updateWindowBounds());
291
+ screen.on("display-added", () => updateWindowBounds());
292
+ screen.on("display-removed", () => updateWindowBounds());
293
+
294
+ app.on("second-instance", () => {
295
+ browserWindow?.webContents.send("pi-activity-strip:snapshot", latestSnapshot);
296
+ alignWindowToTop().catch(() => {});
297
+ });
298
+
299
+ app.on("before-quit", async () => {
300
+ await broker?.stop();
301
+ });
302
+
303
+ app.on("window-all-closed", () => {
304
+ // Keep the broker alive until an explicit stop or process exit.
305
+ });
306
+ }
307
+
308
+ main().catch(async (error) => {
309
+ runtimeStatus.state = "error";
310
+ runtimeStatus.error = error instanceof Error ? error.message : String(error);
311
+ refreshRuntimeStatus();
312
+ console.error(error?.stack ?? error?.message ?? String(error));
313
+ try {
314
+ await broker?.stop();
315
+ } catch {
316
+ // ignore cleanup errors on fatal exit
317
+ }
318
+ process.exit(1);
319
+ });
@@ -0,0 +1,10 @@
1
+ const { contextBridge, ipcRenderer } = require("electron");
2
+
3
+ contextBridge.exposeInMainWorld("activityStrip", {
4
+ subscribe(handler) {
5
+ if (typeof handler !== "function") return () => {};
6
+ const listener = (_event, snapshot) => handler(snapshot);
7
+ ipcRenderer.on("pi-activity-strip:snapshot", listener);
8
+ return () => ipcRenderer.removeListener("pi-activity-strip:snapshot", listener);
9
+ },
10
+ });
@@ -0,0 +1,375 @@
1
+ export function createStripHtml({ interactive = false } = {}) {
2
+ const pointerEvents = interactive ? "auto" : "none";
3
+
4
+ return `<!doctype html>
5
+ <html>
6
+ <head>
7
+ <meta charset="utf-8" />
8
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
9
+ <style>
10
+ :root {
11
+ color-scheme: dark;
12
+ --panel: rgba(9, 14, 24, 0.82);
13
+ --panel-strong: rgba(14, 21, 35, 0.94);
14
+ --line: rgba(255, 255, 255, 0.08);
15
+ --line-strong: rgba(255, 255, 255, 0.14);
16
+ --text: rgba(247, 250, 255, 0.96);
17
+ --muted: rgba(194, 205, 224, 0.74);
18
+ --dim: rgba(167, 180, 203, 0.48);
19
+ --accent: #79b8ff;
20
+ --thinking: #8ab4ff;
21
+ --tool: #ffd166;
22
+ --waiting: #ff9f7a;
23
+ --success: #57d9a3;
24
+ --error: #ff7d7d;
25
+ --shadow: 0 16px 42px rgba(0, 0, 0, 0.42);
26
+ }
27
+
28
+ * {
29
+ box-sizing: border-box;
30
+ }
31
+
32
+ html,
33
+ body {
34
+ margin: 0;
35
+ width: 100%;
36
+ height: 100%;
37
+ overflow: hidden;
38
+ background: transparent;
39
+ font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
40
+ pointer-events: ${pointerEvents};
41
+ }
42
+
43
+ body {
44
+ padding: 4px 10px 6px;
45
+ }
46
+
47
+ .strip {
48
+ display: grid;
49
+ grid-template-columns: 146px minmax(0, 1fr);
50
+ gap: 8px;
51
+ width: 100%;
52
+ height: 100%;
53
+ }
54
+
55
+ .brand,
56
+ .cards {
57
+ backdrop-filter: blur(18px) saturate(1.25);
58
+ -webkit-backdrop-filter: blur(18px) saturate(1.25);
59
+ border: 1px solid var(--line);
60
+ box-shadow: var(--shadow);
61
+ }
62
+
63
+ .brand {
64
+ display: flex;
65
+ flex-direction: column;
66
+ justify-content: space-between;
67
+ padding: 10px 12px;
68
+ border-radius: 20px;
69
+ background: linear-gradient(180deg, rgba(12, 19, 33, 0.92), rgba(8, 13, 22, 0.82));
70
+ }
71
+
72
+ .brand__eyebrow,
73
+ .meta,
74
+ .tool,
75
+ .card__state,
76
+ .placeholder__eyebrow {
77
+ font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
78
+ letter-spacing: 0.14em;
79
+ text-transform: uppercase;
80
+ }
81
+
82
+ .brand__eyebrow,
83
+ .placeholder__eyebrow {
84
+ color: var(--accent);
85
+ font-size: 10px;
86
+ }
87
+
88
+ .brand__title {
89
+ margin-top: 6px;
90
+ color: var(--text);
91
+ font-size: 20px;
92
+ font-weight: 650;
93
+ line-height: 1;
94
+ }
95
+
96
+ .brand__subtitle {
97
+ margin-top: 6px;
98
+ color: var(--muted);
99
+ font-size: 11px;
100
+ line-height: 1.35;
101
+ }
102
+
103
+ .meta {
104
+ color: var(--dim);
105
+ font-size: 11px;
106
+ }
107
+
108
+ .cards {
109
+ display: flex;
110
+ gap: 8px;
111
+ align-items: stretch;
112
+ min-width: 0;
113
+ padding: 6px 8px 8px;
114
+ border-radius: 20px;
115
+ background: linear-gradient(180deg, rgba(10, 16, 28, 0.9), rgba(6, 10, 18, 0.74));
116
+ }
117
+
118
+ .cards--empty {
119
+ align-items: center;
120
+ justify-content: center;
121
+ }
122
+
123
+ .card,
124
+ .placeholder {
125
+ position: relative;
126
+ overflow: hidden;
127
+ border-radius: 16px;
128
+ border: 1px solid var(--line-strong);
129
+ background: linear-gradient(180deg, rgba(13, 20, 35, 0.96), rgba(9, 14, 24, 0.84));
130
+ }
131
+
132
+ .card {
133
+ display: grid;
134
+ grid-template-rows: auto auto 1fr;
135
+ gap: 6px;
136
+ min-width: 0;
137
+ flex: 1 1 220px;
138
+ padding: 10px 12px 10px;
139
+ }
140
+
141
+ .card::before {
142
+ content: "";
143
+ position: absolute;
144
+ inset: 0 auto 0 0;
145
+ width: 3px;
146
+ background: var(--state-color, var(--accent));
147
+ }
148
+
149
+ .card__header,
150
+ .card__footer {
151
+ display: flex;
152
+ justify-content: space-between;
153
+ align-items: center;
154
+ gap: 10px;
155
+ min-width: 0;
156
+ }
157
+
158
+ .card__label {
159
+ min-width: 0;
160
+ }
161
+
162
+ .card__repo {
163
+ color: var(--text);
164
+ font-size: 13px;
165
+ font-weight: 650;
166
+ line-height: 1.15;
167
+ white-space: nowrap;
168
+ overflow: hidden;
169
+ text-overflow: ellipsis;
170
+ }
171
+
172
+ .card__cwd {
173
+ margin-top: 2px;
174
+ color: var(--dim);
175
+ font-size: 10px;
176
+ white-space: nowrap;
177
+ overflow: hidden;
178
+ text-overflow: ellipsis;
179
+ }
180
+
181
+ .card__state {
182
+ flex: 0 0 auto;
183
+ padding: 4px 7px;
184
+ border-radius: 999px;
185
+ background: color-mix(in srgb, var(--state-color, var(--accent)) 14%, transparent);
186
+ border: 1px solid color-mix(in srgb, var(--state-color, var(--accent)) 34%, transparent);
187
+ color: var(--state-color, var(--accent));
188
+ font-size: 9px;
189
+ line-height: 1;
190
+ }
191
+
192
+ .card__phase {
193
+ color: var(--text);
194
+ font-size: 12px;
195
+ font-weight: 600;
196
+ line-height: 1.2;
197
+ white-space: nowrap;
198
+ overflow: hidden;
199
+ text-overflow: ellipsis;
200
+ }
201
+
202
+ .card__detail {
203
+ color: var(--muted);
204
+ font-size: 11px;
205
+ line-height: 1.2;
206
+ display: -webkit-box;
207
+ -webkit-line-clamp: 3;
208
+ -webkit-box-orient: vertical;
209
+ overflow: hidden;
210
+ min-height: 3.6em;
211
+ }
212
+
213
+ .tool,
214
+ .elapsed {
215
+ color: var(--dim);
216
+ font-size: 9px;
217
+ }
218
+
219
+ .placeholder {
220
+ display: flex;
221
+ flex-direction: column;
222
+ justify-content: center;
223
+ gap: 8px;
224
+ width: 100%;
225
+ max-width: 420px;
226
+ padding: 18px 20px;
227
+ }
228
+
229
+ .placeholder__title {
230
+ color: var(--text);
231
+ font-size: 16px;
232
+ font-weight: 650;
233
+ }
234
+
235
+ .placeholder__copy {
236
+ color: var(--muted);
237
+ font-size: 13px;
238
+ line-height: 1.45;
239
+ }
240
+ </style>
241
+ </head>
242
+ <body>
243
+ <div class="strip">
244
+ <section class="brand">
245
+ <div>
246
+ <div class="brand__eyebrow">π telemetry ribbon</div>
247
+ <div class="brand__title">Activity</div>
248
+ <div class="brand__subtitle">Live Pi session detail across your active tabs.</div>
249
+ </div>
250
+ <div class="meta" id="meta">Waiting for sessions…</div>
251
+ </section>
252
+ <section class="cards cards--empty" id="cards"></section>
253
+ </div>
254
+
255
+ <script>
256
+ const meta = document.getElementById("meta");
257
+ const cards = document.getElementById("cards");
258
+ const api = window.activityStrip || { subscribe() { return () => {}; } };
259
+ let snapshot = { generatedAt: Date.now(), sessions: [] };
260
+
261
+ const STATE_LABELS = {
262
+ idle: "idle",
263
+ thinking: "thinking",
264
+ tool: "tool",
265
+ waiting: "waiting",
266
+ success: "done",
267
+ error: "error",
268
+ };
269
+
270
+ const STATE_COLORS = {
271
+ idle: "var(--accent)",
272
+ thinking: "var(--thinking)",
273
+ tool: "var(--tool)",
274
+ waiting: "var(--waiting)",
275
+ success: "var(--success)",
276
+ error: "var(--error)",
277
+ };
278
+
279
+ function escapeHtml(value) {
280
+ return String(value ?? "").replace(/[&<>"']/g, (character) => {
281
+ switch (character) {
282
+ case "&": return "&amp;";
283
+ case "<": return "&lt;";
284
+ case ">": return "&gt;";
285
+ case '"': return "&quot;";
286
+ case "'": return "&#39;";
287
+ default: return character;
288
+ }
289
+ });
290
+ }
291
+
292
+ function formatDuration(totalSeconds) {
293
+ const minutes = Math.floor(totalSeconds / 60);
294
+ const seconds = totalSeconds % 60;
295
+ return String(minutes) + ":" + String(seconds).padStart(2, "0");
296
+ }
297
+
298
+ function formatElapsed(session) {
299
+ const anchor = Number(session.agentStartedAt || session.startedAt || snapshot.generatedAt || Date.now());
300
+ return formatDuration(Math.max(0, Math.floor((Date.now() - anchor) / 1000)));
301
+ }
302
+
303
+ function formatLastSeen(session) {
304
+ const anchor = Number(session.updatedAt || snapshot.generatedAt || Date.now());
305
+ return formatDuration(Math.max(0, Math.floor((Date.now() - anchor) / 1000)));
306
+ }
307
+
308
+ function placeholderHtml() {
309
+ return [
310
+ '<div class="placeholder">',
311
+ '<div class="placeholder__eyebrow">ready</div>',
312
+ '<div class="placeholder__title">No active Pi sessions yet</div>',
313
+ '<div class="placeholder__copy">Open Pi in Ghostty, install this package, and the ribbon will populate automatically as sessions stream status into the broker.</div>',
314
+ '</div>',
315
+ ].join("");
316
+ }
317
+
318
+ function sessionCardHtml(session) {
319
+ const stateColor = STATE_COLORS[session.state] || "var(--accent)";
320
+ const stateLabel = STATE_LABELS[session.state] || session.state || "idle";
321
+ const repoLabel = escapeHtml(session.repoLabel || "pi session");
322
+ const cwd = escapeHtml(session.cwd || "");
323
+ const phase = escapeHtml(session.phase || "Idle");
324
+ const detail = escapeHtml(session.detail || session.assistantPreview || "Ready");
325
+ const tool = escapeHtml(session.toolName || session.toolTarget || "monitoring");
326
+ const elapsed = escapeHtml(formatElapsed(session));
327
+ const lastSeen = escapeHtml(formatLastSeen(session));
328
+
329
+ return [
330
+ '<article class="card" style="--state-color:' + stateColor + '">',
331
+ '<header class="card__header">',
332
+ '<div class="card__label">',
333
+ '<div class="card__repo">' + repoLabel + '</div>',
334
+ '<div class="card__cwd">' + cwd + '</div>',
335
+ '</div>',
336
+ '<div class="card__state">' + escapeHtml(stateLabel) + '</div>',
337
+ '</header>',
338
+ '<div>',
339
+ '<div class="card__phase">' + phase + '</div>',
340
+ '<div class="card__detail">' + detail + '</div>',
341
+ '</div>',
342
+ '<footer class="card__footer">',
343
+ '<div class="tool">' + tool + '</div>',
344
+ '<div class="elapsed">' + elapsed + ' · seen ' + lastSeen + '</div>',
345
+ '</footer>',
346
+ '</article>',
347
+ ].join("");
348
+ }
349
+
350
+ function render() {
351
+ const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
352
+ meta.textContent = sessions.length
353
+ ? String(sessions.length) + ' live ' + (sessions.length === 1 ? 'session' : 'sessions')
354
+ : 'Waiting for sessions…';
355
+
356
+ if (sessions.length === 0) {
357
+ cards.classList.add('cards--empty');
358
+ cards.innerHTML = placeholderHtml();
359
+ return;
360
+ }
361
+
362
+ cards.classList.remove('cards--empty');
363
+ cards.innerHTML = sessions.map((session) => sessionCardHtml(session)).join('');
364
+ }
365
+
366
+ setInterval(() => render(), 1000);
367
+ api.subscribe((nextSnapshot) => {
368
+ snapshot = nextSnapshot || { generatedAt: Date.now(), sessions: [] };
369
+ render();
370
+ });
371
+ render();
372
+ </script>
373
+ </body>
374
+ </html>`;
375
+ }