@tryinget/pi-activity-strip 0.2.0 → 0.3.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.
@@ -1,8 +1,36 @@
1
- export function createStripHtml({ interactive = false } = {}) {
1
+ // ---
2
+ // summary: "generates the calm, interactive activity strip with accessible cards and rich detail reveal"
3
+ // read_when:
4
+ // - "changing strip markup, styling, state rendering, ordering, or interactions"
5
+ // ---
6
+
7
+ import {
8
+ isActiveSession,
9
+ isMonitoringSession,
10
+ moveOrderItem,
11
+ reconcileActivityOrder,
12
+ } from "../common/activity-order.mjs";
13
+ import { ACTIVITY_STRIP_ORDER_REFRESH_MS } from "../common/constants.mjs";
14
+
15
+ const ORDER_RUNTIME = [
16
+ 'const ACTIVE_STATES = new Set(["thinking", "tool", "waiting"]);',
17
+ isActiveSession.toString(),
18
+ isMonitoringSession.toString(),
19
+ reconcileActivityOrder.toString(),
20
+ moveOrderItem.toString(),
21
+ ].join("\n");
22
+
23
+ export function shouldRetainExpandedCard({ hovered, activeElement, documentFocused }) {
24
+ return Boolean(hovered || (documentFocused && activeElement));
25
+ }
26
+
27
+ const INTERACTION_RUNTIME = shouldRetainExpandedCard.toString();
28
+
29
+ export function createStripHtml({ interactive = true, initiallyVisible = true } = {}) {
2
30
  const pointerEvents = interactive ? "auto" : "none";
3
31
 
4
32
  return `<!doctype html>
5
- <html>
33
+ <html data-strip-visible="${initiallyVisible ? "true" : "false"}">
6
34
  <head>
7
35
  <meta charset="utf-8" />
8
36
  <meta name="viewport" content="width=device-width,initial-scale=1" />
@@ -10,27 +38,25 @@ export function createStripHtml({ interactive = false } = {}) {
10
38
  :root {
11
39
  color-scheme: dark;
12
40
  --panel: rgba(9, 14, 24, 0.82);
13
- --panel-strong: rgba(14, 21, 35, 0.94);
41
+ --panel-strong: rgba(14, 21, 35, 0.96);
14
42
  --line: rgba(255, 255, 255, 0.08);
15
43
  --line-strong: rgba(255, 255, 255, 0.14);
16
44
  --text: rgba(247, 250, 255, 0.96);
17
45
  --muted: rgba(194, 205, 224, 0.74);
18
46
  --dim: rgba(167, 180, 203, 0.48);
19
47
  --accent: #79b8ff;
48
+ --current-line: color-mix(in srgb, var(--accent) 78%, white 12%);
20
49
  --thinking: #8ab4ff;
21
50
  --tool: #ffd166;
22
51
  --waiting: #ff9f7a;
23
52
  --success: #57d9a3;
24
53
  --error: #ff7d7d;
25
- --shadow: 0 16px 42px rgba(0, 0, 0, 0.42);
54
+ --shadow: none;
26
55
  }
27
56
 
28
- * {
29
- box-sizing: border-box;
30
- }
31
-
32
- html,
33
- body {
57
+ * { box-sizing: border-box; }
58
+ html[data-strip-visible="false"] { visibility: hidden; }
59
+ html, body {
34
60
  margin: 0;
35
61
  width: 100%;
36
62
  height: 100%;
@@ -39,10 +65,8 @@ export function createStripHtml({ interactive = false } = {}) {
39
65
  font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
40
66
  pointer-events: ${pointerEvents};
41
67
  }
42
-
43
- body {
44
- padding: 4px 10px 6px;
45
- }
68
+ body { padding: 4px 10px 6px; }
69
+ button { font: inherit; }
46
70
 
47
71
  .strip {
48
72
  display: grid;
@@ -51,15 +75,12 @@ export function createStripHtml({ interactive = false } = {}) {
51
75
  width: 100%;
52
76
  height: 100%;
53
77
  }
54
-
55
- .brand,
56
- .cards {
78
+ .brand, .cards {
57
79
  backdrop-filter: blur(18px) saturate(1.25);
58
80
  -webkit-backdrop-filter: blur(18px) saturate(1.25);
59
81
  border: 1px solid var(--line);
60
82
  box-shadow: var(--shadow);
61
83
  }
62
-
63
84
  .brand {
64
85
  display: flex;
65
86
  flex-direction: column;
@@ -68,76 +89,49 @@ export function createStripHtml({ interactive = false } = {}) {
68
89
  border-radius: 20px;
69
90
  background: linear-gradient(180deg, rgba(12, 19, 33, 0.92), rgba(8, 13, 22, 0.82));
70
91
  }
71
-
72
- .brand__eyebrow,
73
- .meta,
74
- .tool,
75
- .card__state,
76
- .placeholder__eyebrow {
92
+ .brand__eyebrow, .meta, .tool, .card__state, .inspector__key, .placeholder__eyebrow {
77
93
  font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
78
- letter-spacing: 0.14em;
94
+ letter-spacing: 0.12em;
79
95
  text-transform: uppercase;
80
96
  }
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
- }
97
+ .brand__eyebrow, .placeholder__eyebrow { color: var(--accent); font-size: 9px; }
98
+ .brand__title { margin-top: 4px; color: var(--text); font-size: 18px; font-weight: 650; line-height: 1; }
99
+ .brand__subtitle { margin-top: 4px; color: var(--muted); font-size: 10px; line-height: 1.25; }
100
+ .meta { color: var(--dim); font-size: 9px; line-height: 1.3; }
107
101
 
108
102
  .cards {
109
103
  display: flex;
110
104
  gap: 8px;
111
- align-items: stretch;
105
+ align-items: flex-start;
112
106
  min-width: 0;
113
107
  padding: 6px 8px 8px;
114
108
  border-radius: 20px;
109
+ overflow-x: auto;
110
+ overflow-y: hidden;
111
+ scrollbar-width: none;
115
112
  background: linear-gradient(180deg, rgba(10, 16, 28, 0.9), rgba(6, 10, 18, 0.74));
116
113
  }
114
+ .cards::-webkit-scrollbar { display: none; }
115
+ .cards--empty { align-items: center; justify-content: center; }
117
116
 
118
- .cards--empty {
119
- align-items: center;
120
- justify-content: center;
121
- }
122
-
123
- .card,
124
- .placeholder {
117
+ .card, .placeholder {
125
118
  position: relative;
126
119
  overflow: hidden;
127
120
  border-radius: 16px;
128
121
  border: 1px solid var(--line-strong);
129
- background: linear-gradient(180deg, rgba(13, 20, 35, 0.96), rgba(9, 14, 24, 0.84));
122
+ background: linear-gradient(180deg, rgba(13, 20, 35, 0.96), rgba(9, 14, 24, 0.9));
130
123
  }
131
-
132
124
  .card {
125
+ appearance: none;
133
126
  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;
127
+ flex: 0 0 224px;
128
+ height: 60px;
129
+ padding: 8px 10px 8px 12px;
130
+ color: inherit;
131
+ text-align: left;
132
+ cursor: pointer;
133
+ transition: border-color 120ms ease, background 120ms ease, height 120ms ease;
139
134
  }
140
-
141
135
  .card::before {
142
136
  content: "";
143
137
  position: absolute;
@@ -145,229 +139,344 @@ export function createStripHtml({ interactive = false } = {}) {
145
139
  width: 3px;
146
140
  background: var(--state-color, var(--accent));
147
141
  }
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;
142
+ .card:hover, .card:focus-visible, .card[data-open="true"] {
143
+ border-color: color-mix(in srgb, var(--state-color, var(--accent)) 58%, white 4%);
144
+ background: linear-gradient(180deg, rgba(18, 28, 47, 0.99), rgba(10, 16, 28, 0.96));
145
+ outline: none;
146
+ }
147
+ .card[data-current="true"] {
148
+ border-color: var(--current-line);
149
+ outline: 1px solid color-mix(in srgb, var(--accent) 34%, transparent);
150
+ outline-offset: -3px;
151
+ }
152
+ .card[data-current="true"]::before { width: 5px; }
153
+ .card[data-current="true"]:focus-visible {
154
+ outline: 2px solid var(--current-line);
155
+ outline-offset: -4px;
156
+ }
157
+ body[data-expanded="true"] .card[data-open="true"] { height: 228px; }
158
+ .card__header, .card__footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; }
159
+ .card__label { min-width: 0; }
160
+ .card__repo { color: var(--text); font-size: 12px; font-weight: 650; line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
161
+ .card__phase { margin-top: 4px; color: var(--muted); font-size: 10px; line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
162
+ .card__state { flex: 0 0 auto; padding: 4px 6px; border-radius: 999px; background: color-mix(in srgb, var(--state-color, var(--accent)) 14%, transparent); border: 1px solid color-mix(in srgb, var(--state-color, var(--accent)) 34%, transparent); color: var(--state-color, var(--accent)); font-size: 8px; line-height: 1; }
163
+ .card__footer { margin-top: 5px; }
164
+ .tool, .elapsed { color: var(--dim); font-size: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
165
+ .tool { max-width: 120px; }
166
+
167
+ .inspector {
168
+ display: none;
169
+ grid-template-columns: 58px minmax(0, 1fr);
170
+ gap: 6px 8px;
171
+ margin-top: 10px;
172
+ padding-top: 9px;
173
+ border-top: 1px solid var(--line);
174
+ }
175
+ body[data-expanded="true"] .card[data-open="true"] .inspector { display: grid; }
176
+ .inspector__key { color: var(--dim); font-size: 8px; }
177
+ .inspector__value { color: var(--muted); font-size: 10px; line-height: 1.25; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
178
+ .inspector__value--path { direction: rtl; text-align: left; white-space: nowrap; text-overflow: ellipsis; display: block; }
179
+ .activation { grid-column: 1 / -1; min-height: 14px; color: var(--dim); font-size: 9px; }
180
+ .activation[data-kind="error"] { color: var(--error); }
181
+ .activation[data-kind="success"] { color: var(--success); }
182
+
183
+ .placeholder { display: flex; flex-direction: column; justify-content: center; gap: 6px; width: 100%; max-width: 420px; padding: 12px 18px; }
184
+ .placeholder__title { color: var(--text); font-size: 14px; font-weight: 650; }
185
+ .placeholder__copy { color: var(--muted); font-size: 11px; line-height: 1.35; }
186
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
187
+
188
+ @media (prefers-reduced-motion: reduce) {
189
+ .card { transition: none; }
239
190
  }
240
191
  </style>
241
192
  </head>
242
- <body>
193
+ <body data-expanded="false">
243
194
  <div class="strip">
244
- <section class="brand">
195
+ <section class="brand" aria-label="Pi activity summary">
245
196
  <div>
246
197
  <div class="brand__eyebrow">π telemetry ribbon</div>
247
198
  <div class="brand__title">Activity</div>
248
- <div class="brand__subtitle">Live Pi session detail across your active tabs.</div>
199
+ <div class="brand__subtitle">Calm order · live detail</div>
249
200
  </div>
250
201
  <div class="meta" id="meta">Waiting for sessions…</div>
251
202
  </section>
252
- <section class="cards cards--empty" id="cards"></section>
203
+ <section class="cards cards--empty" id="cards" aria-label="Live Pi sessions"></section>
204
+ <div class="sr-only" id="announcer" aria-live="polite"></div>
253
205
  </div>
254
206
 
255
207
  <script>
208
+ ${ORDER_RUNTIME}
209
+ ${INTERACTION_RUNTIME}
210
+ const ORDER_REFRESH_MS = ${ACTIVITY_STRIP_ORDER_REFRESH_MS};
256
211
  const meta = document.getElementById("meta");
257
212
  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)",
213
+ const announcer = document.getElementById("announcer");
214
+ const api = window.activityStrip || {
215
+ activate: async () => ({ ok: false, error: "Activity bridge unavailable" }),
216
+ setExpanded: async () => {},
217
+ onCollapse() { return () => {}; },
218
+ onVisibility() { return () => {}; },
219
+ subscribe() { return () => {}; },
277
220
  };
221
+ let snapshot = { generatedAt: Date.now(), focusedSessionId: null, sessions: [] };
222
+ let orderedIds = [];
223
+ let nextOrderRefreshAt = 0;
224
+ let collapseTimer = null;
278
225
 
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
- }
226
+ const STATE_LABELS = { idle: "idle", thinking: "thinking", tool: "tool", waiting: "waiting", success: "done", error: "error" };
227
+ const STATE_COLORS = { idle: "var(--accent)", thinking: "var(--thinking)", tool: "var(--tool)", waiting: "var(--waiting)", success: "var(--success)", error: "var(--error)" };
291
228
 
292
229
  function formatDuration(totalSeconds) {
293
230
  const minutes = Math.floor(totalSeconds / 60);
294
231
  const seconds = totalSeconds % 60;
295
232
  return String(minutes) + ":" + String(seconds).padStart(2, "0");
296
233
  }
297
-
298
234
  function formatElapsed(session) {
299
235
  const anchor = Number(session.agentStartedAt || session.startedAt || snapshot.generatedAt || Date.now());
300
236
  return formatDuration(Math.max(0, Math.floor((Date.now() - anchor) / 1000)));
301
237
  }
302
-
303
238
  function formatLastSeen(session) {
304
239
  const anchor = Number(session.updatedAt || snapshot.generatedAt || Date.now());
305
240
  return formatDuration(Math.max(0, Math.floor((Date.now() - anchor) / 1000)));
306
241
  }
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>',
242
+ function sessionById(sessionId) {
243
+ return (Array.isArray(snapshot.sessions) ? snapshot.sessions : []).find((session) => session.sessionId === sessionId);
244
+ }
245
+ function safeDomId(sessionId) {
246
+ return "session-detail-" + String(sessionId).replace(/[^a-zA-Z0-9_-]/g, "-");
247
+ }
248
+ function setText(card, selector, value) {
249
+ const target = card.querySelector(selector);
250
+ if (target) target.textContent = String(value ?? "");
251
+ }
252
+ function cardTemplate(sessionId) {
253
+ const card = document.createElement("button");
254
+ card.type = "button";
255
+ card.className = "card";
256
+ card.dataset.sessionId = sessionId;
257
+ card.dataset.current = "false";
258
+ card.dataset.open = "false";
259
+ card.setAttribute("aria-expanded", "false");
260
+ card.setAttribute("aria-describedby", safeDomId(sessionId));
261
+ card.innerHTML = [
262
+ '<div class="card__header"><div class="card__label"><div class="card__repo"></div><div class="card__phase"></div></div><div class="card__state"></div></div>',
263
+ '<div class="card__footer"><div class="tool"></div><div class="elapsed"></div></div>',
264
+ '<div class="inspector" id="' + safeDomId(sessionId) + '" role="tooltip">',
265
+ '<div class="inspector__key">detail</div><div class="inspector__value detail"></div>',
266
+ '<div class="inspector__key">prompt</div><div class="inspector__value prompt"></div>',
267
+ '<div class="inspector__key">reply</div><div class="inspector__value reply"></div>',
268
+ '<div class="inspector__key">path</div><div class="inspector__value inspector__value--path path"></div>',
269
+ '<div class="activation" aria-live="polite"></div>',
314
270
  '</div>',
315
271
  ].join("");
272
+ return card;
316
273
  }
317
-
318
- function sessionCardHtml(session) {
274
+ function updateCard(card, session) {
319
275
  const stateColor = STATE_COLORS[session.state] || "var(--accent)";
320
276
  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("");
277
+ const isCurrent = session.sessionId === snapshot.focusedSessionId;
278
+ card.style.setProperty("--state-color", stateColor);
279
+ card.dataset.group = isMonitoringSession(session)
280
+ ? "monitoring"
281
+ : isActiveSession(session)
282
+ ? "active"
283
+ : "settled";
284
+ card.dataset.current = isCurrent ? "true" : "false";
285
+ if (isCurrent) card.setAttribute("aria-current", "true");
286
+ else card.removeAttribute("aria-current");
287
+ card.setAttribute("aria-label", (isCurrent ? "Current terminal, " : "") + (session.repoLabel || "Pi session") + ", " + (session.phase || "Idle") + ". Press Enter to focus its Ghostty window.");
288
+ card.title = (isCurrent ? "Current terminal · " : "Focus ") + (session.repoLabel || "Pi session");
289
+ setText(card, ".card__repo", session.repoLabel || "pi session");
290
+ setText(card, ".card__phase", session.phase || "Idle");
291
+ setText(card, ".card__state", stateLabel);
292
+ setText(card, ".tool", session.toolName || session.toolTarget || "monitoring");
293
+ setText(card, ".elapsed", formatElapsed(session) + " · " + formatLastSeen(session));
294
+ setText(card, ".detail", session.detail || "Ready");
295
+ setText(card, ".prompt", session.lastPromptPreview || "—");
296
+ setText(card, ".reply", session.assistantPreview || "—");
297
+ setText(card, ".path", session.cwd || "—");
298
+ }
299
+ function syncOrder(regroup) {
300
+ const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
301
+ orderedIds = reconcileActivityOrder(sessions, orderedIds, { regroup });
302
+ if (regroup) nextOrderRefreshAt = Date.now() + ORDER_REFRESH_MS;
348
303
  }
349
-
350
304
  function render() {
351
305
  const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
306
+ const byId = new Map(sessions.map((session) => [session.sessionId, session]));
307
+ const activeCount = sessions.filter(isActiveSession).length;
352
308
  meta.textContent = sessions.length
353
- ? String(sessions.length) + ' live ' + (sessions.length === 1 ? 'session' : 'sessions')
354
- : 'Waiting for sessions…';
355
-
309
+ ? String(activeCount) + " active · " + String(sessions.length - activeCount) + " settled · order " + String(Math.max(0, Math.ceil((nextOrderRefreshAt - Date.now()) / 1000))) + "s"
310
+ : "Waiting for sessions…";
356
311
  if (sessions.length === 0) {
357
- cards.classList.add('cards--empty');
358
- cards.innerHTML = placeholderHtml();
312
+ if (document.body.dataset.expanded === "true") setExpanded(null, false).catch(() => {});
313
+ cards.classList.add("cards--empty");
314
+ cards.innerHTML = '<div class="placeholder"><div class="placeholder__eyebrow">ready</div><div class="placeholder__title">No active Pi sessions yet</div><div class="placeholder__copy">Open Pi in Ghostty and the ribbon will populate automatically.</div></div>';
359
315
  return;
360
316
  }
361
-
362
- cards.classList.remove('cards--empty');
363
- cards.innerHTML = sessions.map((session) => sessionCardHtml(session)).join('');
317
+ cards.classList.remove("cards--empty");
318
+ cards.querySelector(".placeholder")?.remove();
319
+ const cardById = new Map(
320
+ [...cards.querySelectorAll(".card")].map((card) => [card.dataset.sessionId, card]),
321
+ );
322
+ for (const [sessionId, orphan] of cardById) {
323
+ if (!byId.has(sessionId)) {
324
+ orphan.remove();
325
+ cardById.delete(sessionId);
326
+ }
327
+ }
328
+ if (
329
+ document.body.dataset.expanded === "true" &&
330
+ !cards.querySelector('.card[data-open="true"]')
331
+ ) {
332
+ setExpanded(null, false).catch(() => {});
333
+ }
334
+ let targetIndex = 0;
335
+ for (const sessionId of orderedIds) {
336
+ const session = byId.get(sessionId);
337
+ if (!session) continue;
338
+ let card = cardById.get(sessionId);
339
+ if (!card) {
340
+ card = cardTemplate(sessionId);
341
+ cardById.set(sessionId, card);
342
+ }
343
+ updateCard(card, session);
344
+ const nodeAtTarget = cards.children[targetIndex] ?? null;
345
+ if (nodeAtTarget !== card) cards.insertBefore(card, nodeAtTarget);
346
+ targetIndex += 1;
347
+ }
348
+ }
349
+ async function setExpanded(card, expanded) {
350
+ if (collapseTimer) { clearTimeout(collapseTimer); collapseTimer = null; }
351
+ for (const candidate of cards.querySelectorAll(".card")) {
352
+ const isOpen = candidate === card && expanded;
353
+ candidate.dataset.open = isOpen ? "true" : "false";
354
+ candidate.setAttribute("aria-expanded", isOpen ? "true" : "false");
355
+ }
356
+ document.body.dataset.expanded = expanded ? "true" : "false";
357
+ await api.setExpanded(Boolean(expanded));
358
+ }
359
+ function scheduleCollapse() {
360
+ if (collapseTimer) clearTimeout(collapseTimer);
361
+ collapseTimer = setTimeout(() => {
362
+ const engagedCard = [...cards.querySelectorAll(".card")].find((candidate) =>
363
+ shouldRetainExpandedCard({
364
+ hovered: candidate.matches(":hover"),
365
+ activeElement: candidate === document.activeElement,
366
+ documentFocused: document.hasFocus(),
367
+ }),
368
+ );
369
+ if (engagedCard) {
370
+ setExpanded(engagedCard, true).catch(() => {});
371
+ return;
372
+ }
373
+ setExpanded(null, false).catch(() => {});
374
+ }, 120);
375
+ }
376
+ async function activate(card) {
377
+ const session = sessionById(card.dataset.sessionId);
378
+ if (!session) return;
379
+ const activation = card.querySelector(".activation");
380
+ if (activation) { activation.dataset.kind = ""; activation.textContent = "Locating exact Ghostty session…"; }
381
+ let result;
382
+ try {
383
+ result = await api.activate(session.sessionId);
384
+ } catch {
385
+ result = { ok: false, error: "Focus bridge failed; nothing focused." };
386
+ }
387
+ if (activation) {
388
+ activation.dataset.kind = result?.ok ? "success" : "error";
389
+ activation.textContent = result?.ok ? "Focused Ghostty window." : (result?.error || "No exact window match; nothing focused.");
390
+ }
391
+ announcer.textContent = activation?.textContent || "";
364
392
  }
365
393
 
366
- setInterval(() => render(), 1000);
394
+ cards.addEventListener("pointerover", (event) => {
395
+ const card = event.target.closest?.(".card");
396
+ if (card && !card.contains(event.relatedTarget)) setExpanded(card, true).catch(() => {});
397
+ });
398
+ cards.addEventListener("pointerout", (event) => {
399
+ const card = event.target.closest?.(".card");
400
+ if (card && !card.contains(event.relatedTarget)) scheduleCollapse();
401
+ });
402
+ cards.addEventListener("focusin", (event) => {
403
+ const card = event.target.closest?.(".card");
404
+ if (card) setExpanded(card, true).catch(() => {});
405
+ });
406
+ cards.addEventListener("focusout", (event) => {
407
+ const card = event.target.closest?.(".card");
408
+ if (card && !card.contains(event.relatedTarget)) scheduleCollapse();
409
+ });
410
+ window.addEventListener("blur", () => setExpanded(null, false).catch(() => {}));
411
+ document.addEventListener("pointerleave", () =>
412
+ setExpanded(null, false).catch(() => {}),
413
+ );
414
+ document.addEventListener("visibilitychange", () => {
415
+ if (document.hidden) setExpanded(null, false).catch(() => {});
416
+ });
417
+ cards.addEventListener("click", (event) => {
418
+ const card = event.target.closest?.(".card");
419
+ if (card) activate(card).catch(() => {});
420
+ });
421
+ cards.addEventListener("keydown", (event) => {
422
+ const card = event.target.closest?.(".card");
423
+ if (!card || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) return;
424
+ event.preventDefault();
425
+ const direction = event.key === "ArrowLeft" ? -1 : 1;
426
+ if (event.shiftKey) {
427
+ orderedIds = moveOrderItem(orderedIds, card.dataset.sessionId, direction);
428
+ nextOrderRefreshAt = Date.now() + ORDER_REFRESH_MS;
429
+ render();
430
+ const moved = [...cards.querySelectorAll(".card")].find((candidate) => candidate.dataset.sessionId === card.dataset.sessionId);
431
+ moved?.focus();
432
+ moved?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
433
+ announcer.textContent = "Session card moved " + (direction < 0 ? "left" : "right") + ".";
434
+ return;
435
+ }
436
+ const index = orderedIds.indexOf(card.dataset.sessionId);
437
+ const targetId = orderedIds[Math.max(0, Math.min(orderedIds.length - 1, index + direction))];
438
+ const target = [...cards.querySelectorAll(".card")].find((candidate) => candidate.dataset.sessionId === targetId);
439
+ target?.focus();
440
+ target?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
441
+ });
442
+
443
+ setInterval(() => {
444
+ if (Date.now() >= nextOrderRefreshAt) syncOrder(true);
445
+ render();
446
+ }, 1000);
447
+ api.onCollapse?.(() => setExpanded(null, false).catch(() => {}));
448
+ api.onVisibility?.(async (visible, isCurrent = () => true) => {
449
+ if (!visible) {
450
+ document.documentElement.dataset.stripVisible = "false";
451
+ if (document.hidden) return isCurrent();
452
+ await new Promise((resolve) =>
453
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
454
+ );
455
+ return isCurrent();
456
+ }
457
+ if (document.hidden) {
458
+ if (!isCurrent()) return false;
459
+ document.documentElement.dataset.stripVisible = "true";
460
+ return true;
461
+ }
462
+ await new Promise((resolve) =>
463
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
464
+ );
465
+ if (!isCurrent()) return false;
466
+ document.documentElement.dataset.stripVisible = "true";
467
+ await new Promise((resolve) => requestAnimationFrame(resolve));
468
+ if (!isCurrent()) {
469
+ document.documentElement.dataset.stripVisible = "false";
470
+ return false;
471
+ }
472
+ return true;
473
+ });
367
474
  api.subscribe((nextSnapshot) => {
368
- snapshot = nextSnapshot || { generatedAt: Date.now(), sessions: [] };
475
+ snapshot = nextSnapshot || { generatedAt: Date.now(), focusedSessionId: null, sessions: [] };
476
+ syncOrder(orderedIds.length === 0);
369
477
  render();
370
478
  });
479
+ syncOrder(true);
371
480
  render();
372
481
  </script>
373
482
  </body>